From a48eb0b8d961cff155e993b3393807333d4e2ed4 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sat, 24 Oct 2026 12:54:45 +0200
Subject: [PATCH 001/146] Refactor: IP Scanner / Port Scanner options with init
properties (R1)
Replace the positional constructors (14 / 6 parameters) of IPScannerOptions
and PortScannerOptions with init-only properties set via object initializer.
No behaviour change.
Co-Authored-By: Claude Opus 5.5
---
.../Network/IPScannerOptions.cs | 69 +++++--------------
.../Network/PortScannerOptions.cs | 38 +++-------
.../ViewModels/IPScannerViewModel.cs | 33 ++++-----
.../ViewModels/PortScannerViewModel.cs | 17 ++---
4 files changed, 51 insertions(+), 106 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs
index b7c4fdcb3a..7a94646369 100644
--- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs
+++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs
@@ -1,114 +1,77 @@
namespace NETworkManager.Models.Network;
///
-/// Class contains the options for the traceroute.
+/// Class contains the options for the IP scanner.
///
public class IPScannerOptions
{
- ///
- /// Create an instance of with parameters.
- ///
- /// Max concurrent threads for the host scan.
- /// Max concurrent threads for the port scan of each host.
- /// Number of attempts to ping a host.
- /// Timeout in milliseconds after which a ping is considered lost.
- /// Size of the buffer used in the ping in bytes.
- /// Scan the ports of the host.
- /// List of ports to scan.
- /// Timeout in milliseconds after which a port is considered closed.
- /// Number of attempts to connect to a port if there was no answer.
- /// Resolve the hostname for an IP address.
- /// Resolve the NetBIOS name for an IP address.
- /// Timeout in milliseconds after which a NetBIOS request is considered lost.
- /// Resolve the MAC address for the host from ARP.
- /// Include unreachable IP addresses in the result.
- public IPScannerOptions(int maxHostThreads, int maxPortThreads, int icmpAttempts, int icmpTimeout,
- byte[] icmpBuffer, bool resolveHostname, bool portScanEnabled, int[] portScanPorts, int portScanTimeout,
- int portScanAttempts, bool netBIOSEnabled, int netBIOSTimeout, bool resolveMACAddress, bool showAllResults)
- {
- MaxHostThreads = maxHostThreads;
- MaxPortThreads = maxPortThreads;
- ICMPAttempts = icmpAttempts;
- ICMPTimeout = icmpTimeout;
- ICMPBuffer = icmpBuffer;
- ResolveHostname = resolveHostname;
- PortScanEnabled = portScanEnabled;
- PortScanPorts = portScanPorts;
- PortScanTimeout = portScanTimeout;
- PortScanAttempts = portScanAttempts;
- NetBIOSEnabled = netBIOSEnabled;
- NetBIOSTimeout = netBIOSTimeout;
- ResolveMACAddress = resolveMACAddress;
- ShowAllResults = showAllResults;
- }
-
///
/// Max concurrent threads for the host scan.
///
- public int MaxHostThreads { get; }
+ public int MaxHostThreads { get; init; }
///
/// Max concurrent threads for the port scan of each host.
///
- public int MaxPortThreads { get; }
+ public int MaxPortThreads { get; init; }
///
/// Number of attempts to ping a host.
///
- public int ICMPAttempts { get; }
+ public int ICMPAttempts { get; init; }
///
/// Timeout in milliseconds after which a ping is considered lost.
///
- public int ICMPTimeout { get; }
+ public int ICMPTimeout { get; init; }
///
/// Size of the buffer used in the ping in bytes.
///
- public byte[] ICMPBuffer { get; }
+ public byte[] ICMPBuffer { get; init; } = [];
///
/// Resolve the hostname for an IP address.
///
- public bool ResolveHostname { get; }
+ public bool ResolveHostname { get; init; }
///
/// Scan the ports of the host.
///
- public bool PortScanEnabled { get; }
+ public bool PortScanEnabled { get; init; }
///
/// Ports to scan.
///
- public int[] PortScanPorts { get; }
+ public int[] PortScanPorts { get; init; } = [];
///
/// Timeout in milliseconds after which a port is considered closed.
///
- public int PortScanTimeout { get; }
+ public int PortScanTimeout { get; init; }
///
/// Number of attempts to connect to a port if there was no answer (timed out).
///
- public int PortScanAttempts { get; }
+ public int PortScanAttempts { get; init; }
///
/// Resolve the NetBIOS name for an IP address.
///
- public bool NetBIOSEnabled { get; }
+ public bool NetBIOSEnabled { get; init; }
///
/// Timeout in milliseconds after which a NetBIOS request is considered lost.
///
- public int NetBIOSTimeout { get; }
+ public int NetBIOSTimeout { get; init; }
///
/// Resolve the MAC address for the host from ARP.
///
- public bool ResolveMACAddress { get; }
+ public bool ResolveMACAddress { get; init; }
///
/// Include unreachable IP addresses in the result.
///
- public bool ShowAllResults { get; }
-}
\ No newline at end of file
+ public bool ShowAllResults { get; init; }
+}
diff --git a/Source/NETworkManager.Models/Network/PortScannerOptions.cs b/Source/NETworkManager.Models/Network/PortScannerOptions.cs
index ef32159425..a022546f0f 100644
--- a/Source/NETworkManager.Models/Network/PortScannerOptions.cs
+++ b/Source/NETworkManager.Models/Network/PortScannerOptions.cs
@@ -6,52 +6,32 @@
public class PortScannerOptions
{
///
- /// Create an instance of with parameters.
+ /// Max concurrent threads for the host scan.
///
- /// Max cuncurrent threads for the host scan.
- /// Max cuncurrent threads for the port scan of each host.
- /// Timeout in ms after which the port is considered closed.
- /// Number of attempts to connect to a port if there was no answer.
- /// Resolve the hostname for an IP address.
- /// Include closed ports in the result.
- public PortScannerOptions(int maxHostThreads, int maxPortThreads, int timeout, int attempts,
- bool resolveHostname, bool showAllResults)
- {
- MaxHostThreads = maxHostThreads;
- MaxPortThreads = maxPortThreads;
- Timeout = timeout;
- Attempts = attempts;
- ResolveHostname = resolveHostname;
- ShowAllResults = showAllResults;
- }
+ public int MaxHostThreads { get; init; }
///
- /// Max cuncurrent threads for the host scan.
+ /// Max concurrent threads for the port scan of each host.
///
- public int MaxHostThreads { get; set; }
-
- ///
- /// Max cuncurrent threads for the port scan of each host.
- ///
- public int MaxPortThreads { get; set; }
+ public int MaxPortThreads { get; init; }
///
/// Timeout in ms after which the port is considered closed.
///
- public int Timeout { get; set; }
+ public int Timeout { get; init; }
///
/// Number of attempts to connect to a port if there was no answer (timed out).
///
- public int Attempts { get; set; }
+ public int Attempts { get; init; }
///
/// Resolve the hostname for an IP address.
///
- public bool ResolveHostname { get; set; }
+ public bool ResolveHostname { get; init; }
///
/// Include closed ports in the result.
///
- public bool ShowAllResults { get; set; }
-}
\ No newline at end of file
+ public bool ShowAllResults { get; init; }
+}
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index c36ae8b305..3e8a7e585a 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -610,22 +610,23 @@ private async Task Start()
ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, Host,
SettingsManager.Current.General_HistoryListEntries);
- var ipScanner = new IPScanner(new IPScannerOptions(
- SettingsManager.Current.IPScanner_MaxHostThreads,
- SettingsManager.Current.IPScanner_MaxPortThreads,
- SettingsManager.Current.IPScanner_ICMPAttempts,
- SettingsManager.Current.IPScanner_ICMPTimeout,
- new byte[SettingsManager.Current.IPScanner_ICMPBuffer],
- SettingsManager.Current.IPScanner_ResolveHostname,
- SettingsManager.Current.IPScanner_PortScanEnabled,
- ports,
- SettingsManager.Current.IPScanner_PortScanTimeout,
- SettingsManager.Current.IPScanner_PortScanAttempts,
- SettingsManager.Current.IPScanner_NetBIOSEnabled,
- SettingsManager.Current.IPScanner_NetBIOSTimeout,
- SettingsManager.Current.IPScanner_ResolveMACAddress,
- SettingsManager.Current.IPScanner_ShowAllResults
- ));
+ var ipScanner = new IPScanner(new IPScannerOptions
+ {
+ MaxHostThreads = SettingsManager.Current.IPScanner_MaxHostThreads,
+ MaxPortThreads = SettingsManager.Current.IPScanner_MaxPortThreads,
+ ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts,
+ ICMPTimeout = SettingsManager.Current.IPScanner_ICMPTimeout,
+ ICMPBuffer = new byte[SettingsManager.Current.IPScanner_ICMPBuffer],
+ ResolveHostname = SettingsManager.Current.IPScanner_ResolveHostname,
+ PortScanEnabled = SettingsManager.Current.IPScanner_PortScanEnabled,
+ PortScanPorts = ports,
+ PortScanTimeout = SettingsManager.Current.IPScanner_PortScanTimeout,
+ PortScanAttempts = SettingsManager.Current.IPScanner_PortScanAttempts,
+ NetBIOSEnabled = SettingsManager.Current.IPScanner_NetBIOSEnabled,
+ NetBIOSTimeout = SettingsManager.Current.IPScanner_NetBIOSTimeout,
+ ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress,
+ ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults
+ });
ipScanner.HostScanned += HostScanned;
ipScanner.ScanComplete += ScanComplete;
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index f618cc7747..28f89296c7 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -557,14 +557,15 @@ private async Task Start()
ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports,
SettingsManager.Current.General_HistoryListEntries);
- var portScanner = new PortScanner(new PortScannerOptions(
- SettingsManager.Current.PortScanner_MaxHostThreads,
- SettingsManager.Current.PortScanner_MaxPortThreads,
- SettingsManager.Current.PortScanner_Timeout,
- SettingsManager.Current.PortScanner_Attempts,
- SettingsManager.Current.PortScanner_ResolveHostname,
- SettingsManager.Current.PortScanner_ShowAllResults
- ));
+ var portScanner = new PortScanner(new PortScannerOptions
+ {
+ MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads,
+ MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads,
+ Timeout = SettingsManager.Current.PortScanner_Timeout,
+ Attempts = SettingsManager.Current.PortScanner_Attempts,
+ ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname,
+ ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults
+ });
portScanner.PortScanned += PortScanned;
portScanner.ScanComplete += ScanComplete;
From a1fa49f7d152f9eeb49db7f4d07df2aca242d359 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sat, 24 Oct 2026 12:56:17 +0200
Subject: [PATCH 002/146] Refactor: Split the IP Scanner host scan into
discover / enrich (R2)
The per-host lambda in ScanAsync is split into DiscoverAsync (ping, port scan
and NetBIOS concurrently -> reachable) and EnrichAsync (DNS PTR, MAC address
and vendor -> result), with ResolveHostnameAsync / ResolveMACAddressAsync as
own methods. Prepares progressive results (enrich as soon as a host answers).
No behaviour change. Port Scanner: nothing to split (per port probe -> result,
PTR already runs concurrently).
Co-Authored-By: Claude Opus 5.5
---
.../Network/IPScanner.cs | 268 +++++++++++-------
1 file changed, 162 insertions(+), 106 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs
index 7e1456f437..b971698f6a 100644
--- a/Source/NETworkManager.Models/Network/IPScanner.cs
+++ b/Source/NETworkManager.Models/Network/IPScanner.cs
@@ -145,123 +145,23 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) =>
// Spread the start of the hosts over time instead of starting all at once
await hostStartScheduler.WaitAsync(ct).ConfigureAwait(false);
- // Start ping, port scan and netbios lookup concurrently - none of these block a thread anymore
- var pingTask = PingAsync(host.ipAddress, ct);
-
- var portScanTask = options.PortScanEnabled
- ? PortScanAsync(host.ipAddress, portScanParallelOptions, ct)
- : Task.FromResult(new List());
-
- var netbiosTask = options.NetBIOSEnabled
- ? NetBIOSResolver.ResolveAsync(host.ipAddress, options.NetBIOSTimeout, ct)
- : Task.FromResult(new NetBIOSInfo(host.ipAddress));
-
- await Task.WhenAll(pingTask, portScanTask, netbiosTask).ConfigureAwait(false);
-
- // A reachable host is in the neighbor table (ARP/NDP) from now on (before it can answer, the
- // local system has to resolve its MAC address)
- var discoveredAt = DateTime.UtcNow;
-
- var pingInfo = pingTask.Result;
- var portScanResults = portScanTask.Result;
- var netBIOSInfo = netbiosTask.Result;
+ var discovery = await DiscoverAsync(host.ipAddress, portScanParallelOptions, ct)
+ .ConfigureAwait(false);
// Cancel if the user has canceled
ct.ThrowIfCancellationRequested();
- // Check if host is up
- var isAnyPortOpen = portScanResults.Any(x => x.State == PortState.Open);
- var isReachable = pingInfo.Status == IPStatus.Success || // ICMP response
- isAnyPortOpen || // Any port is open
- portScanResults.Any(x => x.State == PortState.Closed) || // Connection refused (TCP reset)
- netBIOSInfo.IsReachable; // NetBIOS response
-
// Count reachable/unreachable hosts unconditionally, since ShowAllResults
// (below) may prevent unreachable hosts from ever reaching HostScanned
- if (isReachable)
+ if (discovery.IsReachable)
_hostsUp.Increment();
else
_hostsDown.Increment();
- // DNS & ARP
- if (isReachable || options.ShowAllResults)
- {
- // DNS
- var dnsHostname = string.Empty;
-
- if (options.ResolveHostname)
- {
- var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(host.ipAddress)
- .WaitAsync(ct).ConfigureAwait(false);
-
- if (!dnsResult.HasError)
- dnsHostname = dnsResult.Value;
- }
-
- // ARP
- var arpMACAddress = string.Empty;
- var arpVendor = string.Empty;
-
- if (options.ResolveMACAddress)
- {
- // Get info from neighbor table. Only hosts in a local subnet are in it (for any other
- // host it contains the router), an unreachable host never needs a new snapshot.
- if (IsInLocalSubnet(host.ipAddress, networkInterfaces))
- arpMACAddress = await GetMACAddressFromNeighborTableAsync(host.ipAddress,
- isReachable ? discoveredAt : DateTime.MinValue, ct).ConfigureAwait(false);
-
- // Check if it is the local mac
- if (string.IsNullOrEmpty(arpMACAddress))
- {
- var ipAddress = IPAddressHelper.WithoutScopeId(host.ipAddress);
-
- var networkInterfaceInfo = networkInterfaces.FirstOrDefault(p =>
- p.IPv4Address.Any(x => x.Item1.Equals(ipAddress)) ||
- p.IPv6Address.Concat(p.IPv6AddressLinkLocal)
- .Any(x => IPAddressHelper.WithoutScopeId(x).Equals(ipAddress)));
-
- if (networkInterfaceInfo != null)
- arpMACAddress = networkInterfaceInfo.PhysicalAddress.ToString();
- }
-
- // Vendor lookup & default format
- if (!string.IsNullOrEmpty(arpMACAddress))
- {
- var info = OUILookup.LookupByMacAddress(arpMACAddress).FirstOrDefault();
-
- if (info != null)
- arpVendor = info.Vendor;
-
- // Apply default format
- arpMACAddress = MACAddressHelper.GetDefaultFormat(arpMACAddress);
- }
- }
-
+ if (discovery.IsReachable || options.ShowAllResults)
OnHostScanned(new IPScannerHostScannedArgs(
- new IPScannerHostInfo(
- isReachable,
- pingInfo,
- // DNS is default, fallback to netbios
- !string.IsNullOrEmpty(dnsHostname)
- ? dnsHostname
- : netBIOSInfo?.ComputerName ?? string.Empty,
- dnsHostname,
- isAnyPortOpen,
- portScanResults
- .Where(x => x.State == PortState.Open || options.ShowAllResults)
- .OrderBy(x => x.Port).ToList(),
- netBIOSInfo,
- // ARP/NDP is preferred, fallback to NetBIOS
- !string.IsNullOrEmpty(arpMACAddress)
- ? arpMACAddress
- : netBIOSInfo?.MACAddress ?? string.Empty,
- !string.IsNullOrEmpty(arpMACAddress)
- ? arpVendor
- : netBIOSInfo?.Vendor ?? string.Empty
- )
- )
- );
- }
+ await EnrichAsync(host.ipAddress, discovery, networkInterfaces, ct)
+ .ConfigureAwait(false)));
IncreaseProgress();
}).ConfigureAwait(false);
@@ -281,6 +181,162 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) =>
});
}
+ ///
+ /// Result of : the probes that decide if a host is reachable.
+ ///
+ /// Result of the ping (last attempt).
+ /// All probed ports (also closed / timed out ones).
+ /// Result of the NetBIOS request.
+ /// Time all probes were finished (UTC).
+ private sealed record HostDiscovery(
+ PingInfo PingInfo,
+ List Ports,
+ NetBIOSInfo NetBIOSInfo,
+ DateTime DiscoveredAt)
+ {
+ ///
+ /// Indicates whether any port is open.
+ ///
+ public bool IsAnyPortOpen { get; } = Ports.Any(x => x.State == PortState.Open);
+
+ ///
+ /// Indicates whether the host answered any probe.
+ ///
+ public bool IsReachable => PingInfo.Status == IPStatus.Success || // ICMP response
+ IsAnyPortOpen || // Any port is open
+ Ports.Any(x => x.State == PortState.Closed) || // Connection refused (TCP reset)
+ NetBIOSInfo.IsReachable; // NetBIOS response
+ }
+
+ ///
+ /// Discovers a host: ping, port scan and NetBIOS request run concurrently (none of these block a thread).
+ ///
+ /// IP address of the host.
+ /// Options for the concurrent port probes of the host.
+ /// Token to monitor for cancellation requests.
+ /// Results of all probes.
+ private async Task DiscoverAsync(IPAddress ipAddress, ParallelOptions portScanParallelOptions,
+ CancellationToken cancellationToken)
+ {
+ var pingTask = PingAsync(ipAddress, cancellationToken);
+
+ var portScanTask = options.PortScanEnabled
+ ? PortScanAsync(ipAddress, portScanParallelOptions, cancellationToken)
+ : Task.FromResult(new List());
+
+ var netbiosTask = options.NetBIOSEnabled
+ ? NetBIOSResolver.ResolveAsync(ipAddress, options.NetBIOSTimeout, cancellationToken)
+ : Task.FromResult(new NetBIOSInfo(ipAddress));
+
+ await Task.WhenAll(pingTask, portScanTask, netbiosTask).ConfigureAwait(false);
+
+ // A reachable host is in the neighbor table (ARP/NDP) from now on (before it can answer, the
+ // local system has to resolve its MAC address)
+ return new HostDiscovery(pingTask.Result, portScanTask.Result, netbiosTask.Result, DateTime.UtcNow);
+ }
+
+ ///
+ /// Enriches a discovered host with the DNS hostname (PTR), MAC address and vendor and creates the result.
+ ///
+ /// IP address of the host.
+ /// Result of .
+ /// Local network interfaces (for the MAC address lookup).
+ /// Token to monitor for cancellation requests.
+ /// Result of the host.
+ private async Task EnrichAsync(IPAddress ipAddress, HostDiscovery discovery,
+ List networkInterfaces, CancellationToken cancellationToken)
+ {
+ var dnsHostname = options.ResolveHostname
+ ? await ResolveHostnameAsync(ipAddress, cancellationToken).ConfigureAwait(false)
+ : string.Empty;
+
+ var (arpMACAddress, arpVendor) = options.ResolveMACAddress
+ ? await ResolveMACAddressAsync(ipAddress, discovery.IsReachable ? discovery.DiscoveredAt : DateTime.MinValue,
+ networkInterfaces, cancellationToken).ConfigureAwait(false)
+ : (string.Empty, string.Empty);
+
+ var netBIOSInfo = discovery.NetBIOSInfo;
+
+ return new IPScannerHostInfo(
+ discovery.IsReachable,
+ discovery.PingInfo,
+ // DNS is default, fallback to netbios
+ !string.IsNullOrEmpty(dnsHostname)
+ ? dnsHostname
+ : netBIOSInfo?.ComputerName ?? string.Empty,
+ dnsHostname,
+ discovery.IsAnyPortOpen,
+ discovery.Ports
+ .Where(x => x.State == PortState.Open || options.ShowAllResults)
+ .OrderBy(x => x.Port).ToList(),
+ netBIOSInfo,
+ // ARP/NDP is preferred, fallback to NetBIOS
+ !string.IsNullOrEmpty(arpMACAddress)
+ ? arpMACAddress
+ : netBIOSInfo?.MACAddress ?? string.Empty,
+ !string.IsNullOrEmpty(arpMACAddress)
+ ? arpVendor
+ : netBIOSInfo?.Vendor ?? string.Empty
+ );
+ }
+
+ ///
+ /// Resolves the DNS hostname (PTR record) of a host.
+ ///
+ /// IP address of the host.
+ /// Token to monitor for cancellation requests.
+ /// Hostname or an empty string.
+ private static async Task ResolveHostnameAsync(IPAddress ipAddress, CancellationToken cancellationToken)
+ {
+ var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(ipAddress)
+ .WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ return dnsResult.HasError ? string.Empty : dnsResult.Value;
+ }
+
+ ///
+ /// Resolves the MAC address of a host from the neighbor table (ARP/NDP) or the local network interfaces,
+ /// and its vendor.
+ ///
+ /// IP address of the host.
+ /// Time the host answered, if it is unreachable.
+ /// Local network interfaces.
+ /// Token to monitor for cancellation requests.
+ /// MAC address (default format) and vendor, empty strings if not found.
+ private async Task<(string macAddress, string vendor)> ResolveMACAddressAsync(IPAddress ipAddress,
+ DateTime discoveredAt, List networkInterfaces, CancellationToken cancellationToken)
+ {
+ var macAddress = string.Empty;
+
+ // Get info from neighbor table. Only hosts in a local subnet are in it (for any other
+ // host it contains the router), an unreachable host never needs a new snapshot.
+ if (IsInLocalSubnet(ipAddress, networkInterfaces))
+ macAddress = await GetMACAddressFromNeighborTableAsync(ipAddress, discoveredAt, cancellationToken)
+ .ConfigureAwait(false);
+
+ // Check if it is the local mac
+ if (string.IsNullOrEmpty(macAddress))
+ {
+ var ipAddressWithoutScopeId = IPAddressHelper.WithoutScopeId(ipAddress);
+
+ var networkInterfaceInfo = networkInterfaces.FirstOrDefault(p =>
+ p.IPv4Address.Any(x => x.Item1.Equals(ipAddressWithoutScopeId)) ||
+ p.IPv6Address.Concat(p.IPv6AddressLinkLocal)
+ .Any(x => IPAddressHelper.WithoutScopeId(x).Equals(ipAddressWithoutScopeId)));
+
+ if (networkInterfaceInfo != null)
+ macAddress = networkInterfaceInfo.PhysicalAddress.ToString();
+ }
+
+ if (string.IsNullOrEmpty(macAddress))
+ return (string.Empty, string.Empty);
+
+ // Vendor lookup & default format
+ var vendor = OUILookup.LookupByMacAddress(macAddress).FirstOrDefault()?.Vendor ?? string.Empty;
+
+ return (MACAddressHelper.GetDefaultFormat(macAddress), vendor);
+ }
+
///
/// Gets the MAC address of from the neighbor table (ARP / NDP). The table is
/// read once and cached for the scan instead of reading it for every host. It is only read again on a miss
From 895ddcb6e670137cbd50a664ff5fd593f99c3c81 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sat, 24 Oct 2026 12:57:08 +0200
Subject: [PATCH 003/146] Fix: Trim the trailing dot of DNS hostnames once (C2)
The PTR record is fully qualified (e.g. "server.example.com."). The IP Scanner
only trimmed it for custom commands and "Add profile", so the grid, the export
and other features (DNS Lookup, Remote Desktop, ...) got the hostname with the
trailing dot. It is now trimmed once when the result is created, in the IP
Scanner and the Port Scanner.
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Models/Network/IPScanner.cs | 5 +++--
Source/NETworkManager.Models/Network/PortScanner.cs | 3 ++-
Source/NETworkManager/ViewModels/IPScannerViewModel.cs | 4 ++--
Website/docs/changelog/next-release.md | 1 +
4 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs
index b971698f6a..721c2b7b10 100644
--- a/Source/NETworkManager.Models/Network/IPScanner.cs
+++ b/Source/NETworkManager.Models/Network/IPScanner.cs
@@ -285,13 +285,14 @@ private async Task EnrichAsync(IPAddress ipAddress, HostDisco
///
/// IP address of the host.
/// Token to monitor for cancellation requests.
- /// Hostname or an empty string.
+ /// Hostname without the trailing dot or an empty string.
private static async Task ResolveHostnameAsync(IPAddress ipAddress, CancellationToken cancellationToken)
{
var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(ipAddress)
.WaitAsync(cancellationToken).ConfigureAwait(false);
- return dnsResult.HasError ? string.Empty : dnsResult.Value;
+ // The PTR record is fully qualified (trailing dot) - trim it once for the grid, export and other tools
+ return dnsResult.HasError ? string.Empty : dnsResult.Value.TrimEnd('.');
}
///
diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs
index 2716b0e342..c7d91b69e5 100644
--- a/Source/NETworkManager.Models/Network/PortScanner.cs
+++ b/Source/NETworkManager.Models/Network/PortScanner.cs
@@ -177,7 +177,8 @@ private static async Task ResolveHostnameAsync(IPAddress ipAddress, Canc
var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(ipAddress)
.WaitAsync(cancellationToken).ConfigureAwait(false);
- return dnsResult.HasError ? string.Empty : dnsResult.Value;
+ // The PTR record is fully qualified (trailing dot) - trim it once for the grid, export and other tools
+ return dnsResult.HasError ? string.Empty : dnsResult.Value.TrimEnd('.');
}
private void IncreaseProgress()
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index 3e8a7e585a..c0c668194b 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -451,7 +451,7 @@ private async void AddProfileSelectedHostAction()
{
Name = string.IsNullOrEmpty(SelectedResult.Hostname)
? SelectedResult.PingInfo.IPAddress.ToString()
- : SelectedResult.Hostname.TrimEnd('.'),
+ : SelectedResult.Hostname,
Host = SelectedResult.PingInfo.IPAddress.ToString(),
// Additional data
@@ -743,7 +743,7 @@ private async Task CustomCommand(object guid)
}
// Replace vars
- var hostname = !string.IsNullOrEmpty(SelectedResult.Hostname) ? SelectedResult.Hostname.TrimEnd('.') : "";
+ var hostname = SelectedResult.Hostname ?? "";
// The hostname comes from the network (PTR record or NetBIOS name) and could contain characters
// like "&" that would run additional commands (e.g. "cmd.exe /c ... {{Hostname}}") - only use valid ones
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 6964fe76b5..3986ee8f3b 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -205,6 +205,7 @@ Release date: **xx.xx.2026**
- **IP Scanner**: Fixed **Copy** > **Hostname** / **MAC address** / **Vendor** being hidden in the context menu while the column was shown (resolved via NetBIOS only), and **Copy** > **Status** copying `True` / `False` instead of `Up` / `Down`. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- **IP Scanner**: Fixed a hostname received from the network (DNS PTR record or NetBIOS name) with special characters like `&` being able to run additional commands through a custom command with `{{Hostname}}` (e.g. `cmd.exe /c ...`). Such a hostname is now replaced with an empty value. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- **IP Scanner**: Fixed **Edit** (`F2`) and **Delete** (`Del`) of a custom command in the settings being available without a selected command. The delete confirmation shows the name of the command. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
+- Fixed hostnames resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the export and when opened in another feature (e.g. **DNS Lookup**, **Remote Desktop**). [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR)
**IP Geolocation**
From 441cf296df6ad691d85ad3ec8a0cf2c385eb1a27 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sat, 24 Oct 2026 12:57:35 +0200
Subject: [PATCH 004/146] Improvement: IP Scanner export DNSHostname to CSV and
XML (C4)
The DNS hostname (without the NetBIOS fallback of Hostname) was only exported
to JSON. Add it to CSV and XML, right after Hostname (JSON moved to the same
position). Port Scanner: no separate DNS hostname, nothing to change.
Co-Authored-By: Claude Opus 5.5
---
.../Export/ExportManager.IPScannerHostInfo.cs | 5 ++++-
Website/docs/changelog/next-release.md | 1 +
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs
index 07ab949f82..7d253dd848 100644
--- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs
+++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs
@@ -51,6 +51,7 @@ private static void CreateCsv(IEnumerable collection, string
$"{nameof(IPScannerHostInfo.IsReachable)}," +
$"{nameof(PingInfo.IPAddress)}," +
$"{nameof(IPScannerHostInfo.Hostname)}," +
+ $"{nameof(IPScannerHostInfo.DNSHostname)}," +
$"PingStatus," +
$"{nameof(PingInfo.Timestamp)}," +
$"{nameof(PingInfo.Time)}," +
@@ -88,6 +89,7 @@ private static void CreateCsv(IEnumerable collection, string
$"{info.IsReachable}," +
$"{info.PingInfo.IPAddress}," +
$"{CsvHelper.QuoteString(info.Hostname)}," +
+ $"{CsvHelper.QuoteString(info.DNSHostname)}," +
$"{info.PingInfo.Status}," +
$"{DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp)}," +
$"{Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true)}," +
@@ -127,6 +129,7 @@ from info in collection
new XElement(nameof(IPScannerHostInfo.IsReachable), info.IsReachable),
new XElement(nameof(PingInfo.IPAddress), info.PingInfo.IPAddress),
new XElement(nameof(IPScannerHostInfo.Hostname), info.Hostname),
+ new XElement(nameof(IPScannerHostInfo.DNSHostname), info.DNSHostname),
new XElement("PingStatus", info.PingInfo.Status),
new XElement(nameof(PingInfo.Timestamp),
DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp)),
@@ -199,12 +202,12 @@ private static void CreateJson(IReadOnlyList collection, stri
info.IsReachable,
IPAddress = info.PingInfo.IPAddress.ToString(),
info.Hostname,
+ info.DNSHostname,
PingStatus = info.PingInfo.Status.ToString(),
Timestamp = DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp),
Time = Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true),
info.PingInfo.TTL,
info.PingInfo.Bytes,
- info.DNSHostname,
PortStatus = info.IsAnyPortOpen ? PortState.Open.ToString() : PortState.Closed.ToString(),
Ports = jsonDataPorts,
NetBIOSIsReachable = info.NetBIOSInfo?.IsReachable,
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 3986ee8f3b..e1684451a1 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -70,6 +70,7 @@ Release date: **xx.xx.2026**
- The neighbor table (ARP/NDP) is read once and cached during a scan instead of once per reachable host to resolve the MAC address. The MAC address of the own IPv6 addresses and of IPv6 link-local neighbors is resolved as well. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- **Resolve MAC address and vendor** is enabled by default for new installations (as documented). [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- The export runs in the background, so the window stays responsive while a large result is exported, and uses the order shown in the result (sorting, grouping) instead of the order in which the hosts were scanned (also in the **Port Scanner**). [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
+- The CSV and XML export contain the DNS hostname (`DNSHostname`) in addition to the hostname (DNS, or NetBIOS as fallback), like the JSON export. [#PR](https://github.com/BornToBeRoot/NETworkManager/pull/PR)
- Hosts are started one after another (every 10 ms, shorter with short timeouts and many threads) instead of all at the same moment, the **Port Scanner** does the same with ports (every 1 ms). This avoids bursts of more than a thousand connection requests within a few milliseconds and makes the progress smoother. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Added **RDAP...** to the context menu to query the registration data of the IP address or hostname of a host in the [**RDAP**](../application/rdap.md) tool. [#3642](https://github.com/BornToBeRoot/NETworkManager/pull/3642)
From 54c80d5d81c6ba9a45715dc793a39d41ca144fe7 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sat, 24 Oct 2026 12:59:22 +0200
Subject: [PATCH 005/146] Fix: IP Scanner / Port Scanner columns follow the
options of the shown scan (C5)
Columns, Copy menu entries and the row details (IP Scanner) were bound to the
current settings. After changing a setting the shown results got columns
without data (e.g. MAC address enabled after the scan) or lost columns with
data, the row details showed "Port scan is disabled" for scanned ports.
The view models keep the options of the shown scan (ScanOptions, until the
first scan the settings when the tab was opened) and the views bind to them
via the BindingProxy (DataGrid columns are not in the visual tree).
Port Scanner: Hostname column and Copy > Hostname.
Co-Authored-By: Claude Opus 5.5
---
.../ViewModels/IPScannerViewModel.cs | 67 ++++++++++++++-----
.../ViewModels/PortScannerViewModel.cs | 50 +++++++++++---
.../NETworkManager/Views/IPScannerView.xaml | 63 +++++++++--------
.../NETworkManager/Views/PortScannerView.xaml | 6 +-
Website/docs/application/ip-scanner.md | 2 +
Website/docs/application/port-scanner.md | 2 +-
Website/docs/changelog/next-release.md | 1 +
7 files changed, 129 insertions(+), 62 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index c0c668194b..d7d863fcc4 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -290,6 +290,24 @@ private set
}
}
+ ///
+ /// Gets the options of the scan whose results are shown. Columns, context menu and row details follow them
+ /// instead of the current settings, which may have been changed since. Before the first scan: the settings
+ /// when the tab was opened.
+ ///
+ public IPScannerOptions ScanOptions
+ {
+ get;
+ private set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets the available custom commands for the IP Scanner.
///
@@ -317,6 +335,9 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true)
// Host history
HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.IPScanner_HostHistory);
+ // Columns etc. until the first scan
+ ScanOptions = CreateScanOptions([]);
+
// Result view
ResultsView = CollectionViewSource.GetDefaultView(Results);
@@ -610,23 +631,9 @@ private async Task Start()
ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, Host,
SettingsManager.Current.General_HistoryListEntries);
- var ipScanner = new IPScanner(new IPScannerOptions
- {
- MaxHostThreads = SettingsManager.Current.IPScanner_MaxHostThreads,
- MaxPortThreads = SettingsManager.Current.IPScanner_MaxPortThreads,
- ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts,
- ICMPTimeout = SettingsManager.Current.IPScanner_ICMPTimeout,
- ICMPBuffer = new byte[SettingsManager.Current.IPScanner_ICMPBuffer],
- ResolveHostname = SettingsManager.Current.IPScanner_ResolveHostname,
- PortScanEnabled = SettingsManager.Current.IPScanner_PortScanEnabled,
- PortScanPorts = ports,
- PortScanTimeout = SettingsManager.Current.IPScanner_PortScanTimeout,
- PortScanAttempts = SettingsManager.Current.IPScanner_PortScanAttempts,
- NetBIOSEnabled = SettingsManager.Current.IPScanner_NetBIOSEnabled,
- NetBIOSTimeout = SettingsManager.Current.IPScanner_NetBIOSTimeout,
- ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress,
- ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults
- });
+ ScanOptions = CreateScanOptions(ports);
+
+ var ipScanner = new IPScanner(ScanOptions);
ipScanner.HostScanned += HostScanned;
ipScanner.ScanComplete += ScanComplete;
@@ -646,6 +653,32 @@ private async Task Start()
ipScanner.ScanAsync(hosts.hosts, _cancellationTokenSource.Token);
}
+ ///
+ /// Creates the scan options from the current settings.
+ ///
+ /// Ports to scan.
+ /// Options for the .
+ private static IPScannerOptions CreateScanOptions(int[] ports)
+ {
+ return new IPScannerOptions
+ {
+ MaxHostThreads = SettingsManager.Current.IPScanner_MaxHostThreads,
+ MaxPortThreads = SettingsManager.Current.IPScanner_MaxPortThreads,
+ ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts,
+ ICMPTimeout = SettingsManager.Current.IPScanner_ICMPTimeout,
+ ICMPBuffer = new byte[SettingsManager.Current.IPScanner_ICMPBuffer],
+ ResolveHostname = SettingsManager.Current.IPScanner_ResolveHostname,
+ PortScanEnabled = SettingsManager.Current.IPScanner_PortScanEnabled,
+ PortScanPorts = ports,
+ PortScanTimeout = SettingsManager.Current.IPScanner_PortScanTimeout,
+ PortScanAttempts = SettingsManager.Current.IPScanner_PortScanAttempts,
+ NetBIOSEnabled = SettingsManager.Current.IPScanner_NetBIOSEnabled,
+ NetBIOSTimeout = SettingsManager.Current.IPScanner_NetBIOSTimeout,
+ ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress,
+ ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults
+ };
+ }
+
///
/// Stops the IP scan.
///
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 28f89296c7..4c83dca008 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -291,6 +291,24 @@ private set
}
}
+ ///
+ /// Gets the options of the scan whose results are shown. Columns and context menu follow them instead of
+ /// the current settings, which may have been changed since. Before the first scan: the settings when the
+ /// tab was opened.
+ ///
+ public PortScannerOptions ScanOptions
+ {
+ get;
+ private set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
#endregion
#region Constructor, load settings, shutdown
@@ -316,6 +334,9 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t
HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_HostHistory);
PortsHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_PortHistory);
+ // Columns etc. until the first scan
+ ScanOptions = CreateScanOptions();
+
// Result view
ResultsView = CollectionViewSource.GetDefaultView(Results);
ResultsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(PortScannerPortInfo.HostAsString)));
@@ -557,15 +578,9 @@ private async Task Start()
ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports,
SettingsManager.Current.General_HistoryListEntries);
- var portScanner = new PortScanner(new PortScannerOptions
- {
- MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads,
- MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads,
- Timeout = SettingsManager.Current.PortScanner_Timeout,
- Attempts = SettingsManager.Current.PortScanner_Attempts,
- ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname,
- ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults
- });
+ ScanOptions = CreateScanOptions();
+
+ var portScanner = new PortScanner(ScanOptions);
portScanner.PortScanned += PortScanned;
portScanner.ScanComplete += ScanComplete;
@@ -585,6 +600,23 @@ private async Task Start()
portScanner.ScanAsync(hosts.hosts, ports, _cancellationTokenSource.Token);
}
+ ///
+ /// Creates the scan options from the current settings.
+ ///
+ /// Options for the .
+ private static PortScannerOptions CreateScanOptions()
+ {
+ return new PortScannerOptions
+ {
+ MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads,
+ MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads,
+ Timeout = SettingsManager.Current.PortScanner_Timeout,
+ Attempts = SettingsManager.Current.PortScanner_Attempts,
+ ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname,
+ ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults
+ };
+ }
+
private void Stop()
{
IsCanceling = true;
diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml
index d7eeaffe48..3427047e33 100644
--- a/Source/NETworkManager/Views/IPScannerView.xaml
+++ b/Source/NETworkManager/Views/IPScannerView.xaml
@@ -12,7 +12,6 @@
xmlns:viewModels="clr-namespace:NETworkManager.ViewModels"
xmlns:network="clr-namespace:NETworkManager.Models.Network;assembly=NETworkManager.Models"
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
- xmlns:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings"
xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls"
xmlns:models="clr-namespace:NETworkManager.Models;assembly=NETworkManager.Models"
Loaded="UserControl_Loaded"
@@ -459,17 +458,17 @@
Style="{StaticResource ResourceKey=CopyMenuItem}">
-
-
+
+
@@ -494,10 +493,10 @@
Style="{StaticResource ResourceKey=CopyMenuItem}">
-
-
+
+
@@ -607,10 +606,10 @@
MinWidth="150">
-
-
+
+
@@ -621,12 +620,12 @@
-
-
+
+
@@ -648,10 +647,10 @@
Width="*">
-
-
+
+
@@ -789,7 +788,7 @@
+ Visibility="{Binding Path=Data.(viewModels:IPScannerViewModel.ScanOptions).PortScanEnabled, Source={StaticResource BindingProxy}, Converter={StaticResource ResourceKey=BooleanReverseToVisibilityCollapsedConverter}}">
+ Visibility="{Binding Path=Data.(viewModels:IPScannerViewModel.ScanOptions).PortScanEnabled, Source={StaticResource BindingProxy}, Converter={StaticResource ResourceKey=BooleanToVisibilityCollapsedConverter}}">
+ Visibility="{Binding Path=Data.(viewModels:IPScannerViewModel.ScanOptions).NetBIOSEnabled, Source={StaticResource BindingProxy}, Converter={StaticResource ResourceKey=BooleanReverseToVisibilityCollapsedConverter}}">
+ Visibility="{Binding Path=Data.(viewModels:IPScannerViewModel.ScanOptions).NetBIOSEnabled, Source={StaticResource BindingProxy}, Converter={StaticResource ResourceKey=BooleanToVisibilityCollapsedConverter}}">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From e0ec581a8fd08a14c6a52be3be039050b92c74d1 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 14:48:02 +0200
Subject: [PATCH 059/146] Fix: IP Scanner Copy > Ports hidden with the port
scan disabled (review 5, #6)
Copy > Port status already follows the port scan option of the shown scan,
Copy > Ports was always shown and copied an empty text without a port scan.
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager/Views/IPScannerView.xaml | 1 +
Website/docs/changelog/next-release.md | 1 +
2 files changed, 2 insertions(+)
diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml
index a52bb39a09..10f89d3679 100644
--- a/Source/NETworkManager/Views/IPScannerView.xaml
+++ b/Source/NETworkManager/Views/IPScannerView.xaml
@@ -539,6 +539,7 @@
/// Inputs like "10.0.0.1", "example.com", "10.0.0.0/24" (see ).
/// Prefer the A record over the AAAA record for hostnames.
+ ///
+ /// Keep one entry per hostname for an IP address entered with different hostnames (e.g. the Ping Monitor shows
+ /// the hostname of the input). Otherwise every IP address is returned once, with the first hostname (e.g. the
+ /// IP Scanner and the Port Scanner resolve the hostname themselves, a second entry would be scanned again).
+ ///
/// Token to monitor for cancellation requests.
/// Sorted, distinct list of hosts and the hostnames that could not be resolved.
public static async Task<(List<(IPAddress ipAddress, string hostname)> hosts, List hostnamesNotResolved)>
- ResolveAsync(IEnumerable hosts, bool dnsResolveHostnamePreferIPv4, CancellationToken cancellationToken)
+ ResolveAsync(IEnumerable hosts, bool dnsResolveHostnamePreferIPv4, bool keepAllHostnames,
+ CancellationToken cancellationToken)
{
var inputs = hosts.ToList();
@@ -300,20 +306,20 @@ await Parallel.ForEachAsync(Enumerable.Range(0, inputs.Count),
}
}).ConfigureAwait(false);
- return (SortAndRemoveDuplicates(hostsPerInput),
+ return (SortAndRemoveDuplicates(hostsPerInput, keepAllHostnames),
[.. hostnameNotResolvedPerInput.Where(x => x != null)]);
}
///
/// Merges the hosts of all inputs into one list sorted by IP address, with every IP address only once
/// (e.g. overlapping ranges). If the same IP address was entered with different hostnames (e.g. two names of
- /// one server), every hostname is kept once; an entry without hostname is only kept if there is no entry
- /// with a hostname. Entries with the same IP address keep the input order (stable sort), so the result is
+ /// one server), every hostname is kept once with , otherwise only the
+ /// first one; an entry without hostname is only kept if there is no entry with a hostname. Entries with the same IP address keep the input order (stable sort), so the result is
/// the same on every run. IPv6 addresses with a different scope id (e.g. "fe80::1%3" and "fe80::1%7",
/// two devices on different interfaces) are different hosts.
///
private static List<(IPAddress ipAddress, string hostname)> SortAndRemoveDuplicates(
- List<(IPAddress ipAddress, string hostname)>[] hostsPerInput)
+ List<(IPAddress ipAddress, string hostname)>[] hostsPerInput, bool keepAllHostnames)
{
var hosts = hostsPerInput.Length == 1 ? hostsPerInput[0] : [.. hostsPerInput.SelectMany(x => x)];
@@ -353,7 +359,7 @@ await Parallel.ForEachAsync(Enumerable.Range(0, inputs.Count),
.ToList();
if (entriesWithHostname.Count > 0)
- distinctHosts.AddRange(entriesWithHostname);
+ distinctHosts.AddRange(keepAllHostnames ? entriesWithHostname : [entriesWithHostname[0]]);
else
distinctHosts.Add(sortedHosts[start]);
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index 0b66148f26..9066f615ea 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -761,12 +761,8 @@ private async Task Start()
try
{
hosts = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(Host),
- SettingsManager.Current.Network_ResolveHostnamePreferIPv4, _cancellationTokenSource.Token);
-
- // The scanner doesn't use the hostname of the input (it resolves the hostname via PTR) - an IP address
- // entered with different hostnames (e.g. two names of one server) would be scanned once per hostname,
- // with the same result
- hosts.hosts = [.. hosts.hosts.DistinctBy(x => x.ipAddress)];
+ SettingsManager.Current.Network_ResolveHostnamePreferIPv4, keepAllHostnames: false,
+ _cancellationTokenSource.Token);
ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync(
SettingsManager.Current.IPScanner_PortScanPorts);
diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
index 7e3b50c6d3..427f0ce087 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
@@ -324,7 +324,8 @@ public async Task Start()
try
{
hosts = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(Host),
- SettingsManager.Current.Network_ResolveHostnamePreferIPv4, _cancellationTokenSource.Token);
+ SettingsManager.Current.Network_ResolveHostnamePreferIPv4, keepAllHostnames: true,
+ _cancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 34cc5776a1..4b8b3b9e8d 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -681,12 +681,8 @@ private async Task Start()
try
{
hosts = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(Host),
- SettingsManager.Current.Network_ResolveHostnamePreferIPv4, _cancellationTokenSource.Token);
-
- // The scanner doesn't use the hostname of the input (it resolves the hostname via PTR) - an IP address
- // entered with different hostnames (e.g. two names of one server) would be scanned once per hostname,
- // with the same result
- hosts.hosts = [.. hosts.hosts.DistinctBy(x => x.ipAddress)];
+ SettingsManager.Current.Network_ResolveHostnamePreferIPv4, keepAllHostnames: false,
+ _cancellationTokenSource.Token);
ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync(Ports);
}
From dca395b455c36ac8965811e43c06f0e8f11d19ea Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:27:07 +0200
Subject: [PATCH 063/146] Fix: Port Scanner rescan keeps collapsed groups and
the scroll position (review 6, #4)
The single reset of review 5 #3 recreates every host group: their expanders
are expanded by default, so groups the user collapsed opened again and the
grid lost its scroll position. ReplaceResults removes / adds up to 1000 rows
one by one (other groups unchanged) and only uses one reset for more rows,
where single changes would freeze the window. Also used to restore the rows
of a canceled rescan.
Co-Authored-By: Claude Opus 5.5
---
.../ViewModels/PortScannerViewModel.cs | 39 ++++++++++++++++---
1 file changed, 34 insertions(+), 5 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 4b8b3b9e8d..87670e77c7 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -33,6 +33,12 @@ public class PortScannerViewModel : ViewModelBase
#region Variables
private static readonly ILog Log = LogManager.GetLogger(typeof(PortScannerViewModel));
+ ///
+ /// Maximum number of rows that are removed / added one by one when the results of hosts are replaced (rescan),
+ /// see .
+ ///
+ private const int MaxRowsChangedOneByOne = 1000;
+
private CancellationTokenSource _cancellationTokenSource;
private readonly Guid _tabId;
@@ -743,9 +749,7 @@ private void RescanSelectedHosts()
_rescanPortsOpen = PortsOpen;
_rescanPortsClosed = PortsClosed;
- // One reset instead of removing the rows one by one - every removal searches the collection and the sorted,
- // grouped view (e.g. a host with 1-65535 and closed ports shown would freeze the window for minutes)
- Results.ReplaceAll(Results.Where(x => !rescannedIPAddresses.Contains(x.IPAddress)));
+ ReplaceResults(rescannedIPAddresses, []);
// Every host was scanned with all ports: the other hosts keep their counters
var rescanPortCount = ipAddresses.Count * _lastPorts.Length;
@@ -778,6 +782,32 @@ private void RescanSelectedHosts()
StartScanner([.. ipAddresses.Select(x => (x, string.Empty))], _lastPorts);
}
+ ///
+ /// Replaces the rows of the given hosts. Few rows are removed / added one by one, so the groups of the other hosts
+ /// keep their state (collapsed) and the scroll position stays. Many rows with one reset instead: every single
+ /// change searches the collection and the sorted, grouped view (e.g. a host with 1-65535 and closed ports shown
+ /// would freeze the window for minutes).
+ ///
+ /// Hosts whose rows are replaced.
+ /// New rows of the hosts.
+ private void ReplaceResults(HashSet ipAddresses, IReadOnlyCollection rows)
+ {
+ var oldRows = Results.Where(x => ipAddresses.Contains(x.IPAddress)).ToList();
+
+ if (oldRows.Count + rows.Count > MaxRowsChangedOneByOne)
+ {
+ Results.ReplaceAll(Results.Where(x => !ipAddresses.Contains(x.IPAddress)).Concat(rows));
+
+ return;
+ }
+
+ foreach (var row in oldRows)
+ Results.Remove(row);
+
+ foreach (var row in rows)
+ Results.Add(row);
+ }
+
///
/// Creates the scanner (with ), subscribes to its events, starts the flush timer and
/// the scan.
@@ -1052,8 +1082,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e)
// rescan started, only some ports were scanned again)
if (e.IsCanceled || e.Exception != null)
{
- Results.ReplaceAll(Results.Where(x => !_rescanIPAddresses.Contains(x.IPAddress))
- .Concat(_rescanRemovedRows));
+ ReplaceResults(_rescanIPAddresses, _rescanRemovedRows);
PortsOpen = _rescanPortsOpen;
PortsClosed = _rescanPortsClosed;
From 18119d0ae3e2044b177cdb877ac61cec068803a3 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:27:58 +0200
Subject: [PATCH 064/146] Fix: IP Scanner MAC address of IPv6 hosts with a
scope id (review 6, #5)
The neighbor table map was keyed without the IPv6 scope id, while the input
keeps it: "fe80::1%3; fe80::1%7" (e.g. the routers of two interfaces) are two
hosts, but both got the MAC address of the last "fe80::1" entry - also used
for Wake on LAN and Add profile. The map now contains every IPv6 entry with
its scope id (exact lookup for an address entered with one) and without it
(an address entered without scope id, as before).
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Models/Network/IPScanner.cs | 8 ++++----
.../NETworkManager.Models/Network/NeighborTable.cs | 12 ++++++++----
2 files changed, 12 insertions(+), 8 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs
index 9655febdaf..4d11d86dad 100644
--- a/Source/NETworkManager.Models/Network/IPScanner.cs
+++ b/Source/NETworkManager.Models/Network/IPScanner.cs
@@ -498,14 +498,14 @@ await Task.WhenAll(
private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddress, DateTime discoveredAt,
CancellationToken cancellationToken)
{
- var key = IPAddressHelper.WithoutScopeId(ipAddress);
-
// Hosts wait without blocking a thread while another host reads the table
await _neighborTableSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- if (_neighborTable.TryGetValue(key, out var macAddress))
+ // With the scope id if it was entered (e.g. "fe80::1%3" and "fe80::1%7" are two hosts on different
+ // interfaces), see NeighborTable.GetMACAddressMap
+ if (_neighborTable.TryGetValue(ipAddress, out var macAddress))
return macAddress;
// Already read after the host answered -> no entry
@@ -516,7 +516,7 @@ private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddre
_neighborTableTimestamp = DateTime.UtcNow;
_neighborTable = NeighborTable.GetMACAddressMap();
- return _neighborTable.GetValueOrDefault(key, string.Empty);
+ return _neighborTable.GetValueOrDefault(ipAddress, string.Empty);
}
finally
{
diff --git a/Source/NETworkManager.Models/Network/NeighborTable.cs b/Source/NETworkManager.Models/Network/NeighborTable.cs
index 32ccd9d0e0..d1ad3ed551 100644
--- a/Source/NETworkManager.Models/Network/NeighborTable.cs
+++ b/Source/NETworkManager.Models/Network/NeighborTable.cs
@@ -313,8 +313,9 @@ private static Dictionary BuildInterfaceAliasMap()
///
/// Returns a snapshot of the neighbor cache (IPv4 and IPv6) as map from IP address to MAC address
- /// (e.g. "001122AABBCC"). IPv6 addresses are stored without scope id (see
- /// ), entries without a MAC address are skipped.
+ /// (e.g. "001122AABBCC"). IPv6 addresses are stored with their scope id (e.g. "fe80::1%3", for an address entered
+ /// with one) and without it (see , for an address entered without one -
+ /// if the address is on several interfaces, the last entry wins), entries without a MAC address are skipped.
/// Reading the table once and looking up many addresses is much cheaper than reading it per address.
///
public static Dictionary GetMACAddressMap()
@@ -325,8 +326,11 @@ public static Dictionary GetMACAddressMap()
{
var macAddress = entry.MACAddress.ToString();
- if (!string.IsNullOrEmpty(macAddress))
- map[IPAddressHelper.WithoutScopeId(entry.IPAddress)] = macAddress;
+ if (string.IsNullOrEmpty(macAddress))
+ continue;
+
+ map[entry.IPAddress] = macAddress;
+ map[IPAddressHelper.WithoutScopeId(entry.IPAddress)] = macAddress;
}
return map;
From f0760ab093502f14fbe61c0dbf8f23e6786f2419 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:28:19 +0200
Subject: [PATCH 065/146] Fix: IP Scanner sort by MAC address / Time in a fixed
order for equal values (review 6, #6)
Hosts with the same MAC address or ping time - especially all hosts without a
MAC address or without a reply, and the many 0 ms replies on a LAN - compared
as equal. The sort of the collection view is not stable, so these hosts were
shown in a random order that changed with every sort. They are sorted by IP
address now.
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager/Views/IPScannerView.xaml.cs | 8 +++++++-
Website/docs/application/ip-scanner.md | 2 +-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/Source/NETworkManager/Views/IPScannerView.xaml.cs b/Source/NETworkManager/Views/IPScannerView.xaml.cs
index 6741994b1f..50d423b484 100644
--- a/Source/NETworkManager/Views/IPScannerView.xaml.cs
+++ b/Source/NETworkManager/Views/IPScannerView.xaml.cs
@@ -162,7 +162,7 @@ public int Compare(object x, object y)
return 0;
// Compare the data
- return comparer switch
+ var result = comparer switch
{
// IP address
0 => direction == ListSortDirection.Ascending
@@ -176,6 +176,12 @@ public int Compare(object x, object y)
: CompareDescendingNoReplyLast(first, second),
_ => 0
};
+
+ // Same MAC address / time (e.g. all hosts without a MAC address or ping reply): by IP address - the sort is
+ // not stable, the order of these hosts would be random otherwise
+ return result != 0 || comparer == 0
+ ? result
+ : IPAddressHelper.CompareIPAddresses(first.PingInfo.IPAddress, second.PingInfo.IPAddress);
}
///
diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md
index 07220feb68..22d97ea451 100644
--- a/Website/docs/application/ip-scanner.md
+++ b/Website/docs/application/ip-scanner.md
@@ -93,7 +93,7 @@ The search box right of the **Result** header filters the results by IP address,
Expand a row with the button in front of it to see its details, or enable [Expand host view](#expand-host-view) to expand all hosts.
-The **Time** column shows the round trip time of the ping reply (`-/-` without a reply); sorted by time, hosts without a reply are always at the end. Sorted by **MAC address**, hosts without a MAC address are always at the end.
+The **Time** column shows the round trip time of the ping reply (`-/-` without a reply); sorted by time, hosts without a reply are always at the end. Sorted by **MAC address**, hosts without a MAC address are always at the end. Hosts with the same time or MAC address (or none) are sorted by IP address.
The columns (e.g. **Hostname**, **Port status**, **MAC address**) and the row details are shown according to the settings the scan was started with. Settings changed afterwards apply to the next scan.
From d0d52915ef50a3b9087099b42393e773851cc61e Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:28:44 +0200
Subject: [PATCH 066/146] Fix: IP Scanner requests NetBIOS from IPv4 addresses
only (review 6, #7)
NetBIOS (UDP 137) doesn't exist over IPv6, but the request was sent to IPv6
hosts as well: every IPv6 host kept "..." in NetBIOS reachable and wasn't
finished until the NetBIOS timeout (default 2 s), then showed "No". Skipped
for IPv6 now (result: not reachable, right away).
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Models/Network/IPScanner.cs | 3 ++-
Website/docs/application/ip-scanner.md | 2 +-
Website/docs/changelog/next-release.md | 1 +
3 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs
index 4d11d86dad..2ea0cc6691 100644
--- a/Source/NETworkManager.Models/Network/IPScanner.cs
+++ b/Source/NETworkManager.Models/Network/IPScanner.cs
@@ -215,7 +215,8 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa
? PortScanAsync(ipAddress, portScanParallelOptions, () => portAnswered.TrySetResult(), cancellationToken)
: Task.FromResult(new List());
- var netBIOSTask = options.NetBIOSEnabled
+ // NetBIOS only exists over IPv4 - an IPv6 host would never answer and wait for the timeout
+ var netBIOSTask = options.NetBIOSEnabled && ipAddress.AddressFamily == AddressFamily.InterNetwork
? NetBIOSResolver.ResolveAsync(ipAddress, options.NetBIOSTimeout, cancellationToken)
: Task.FromResult(new NetBIOSInfo(ipAddress));
diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md
index 22d97ea451..e6f3b00c21 100644
--- a/Website/docs/application/ip-scanner.md
+++ b/Website/docs/application/ip-scanner.md
@@ -242,7 +242,7 @@ In a local network (LAN), `1` is enough. On an unreliable network (e.g. Wi-Fi, V
### Scan for NetBIOS
-Scan each IP address for NetBIOS information.
+Scan each IP address for NetBIOS information. Only IPv4 addresses are scanned, NetBIOS doesn't exist over IPv6.
**Type:** `Boolean`
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 119ceb3eee..4027206e08 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -267,6 +267,7 @@ Release date: **xx.xx.2026**
- Fixed the application crashing when the results were sorted by **MAC address** and a host had no MAC address (e.g. a host behind a router, or while the MAC address was still resolved). Hosts without a MAC address are shown at the end. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed the sort arrow of the previously sorted column still being shown after sorting by **IP address**, **MAC address** or **Time**. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed **Copy** > **Ports** being shown in the context menu with the port scan disabled (it copied nothing). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Fixed IPv6 hosts waiting for the NetBIOS timeout (NetBIOS doesn't exist over IPv6). NetBIOS is only requested from IPv4 addresses. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed columns that couldn't be made wider (by dragging or double-clicking the gripper) if the window was not wide enough, e.g. to see a long hostname. The last column still fills the remaining width, other columns can now grow beyond the window with a horizontal scrollbar. This also applies to the custom commands in the settings. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557)
**Port Scanner**
From 0e422fa43436ee14b6d4a23f2ec94b471cbc8eed Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:29:42 +0200
Subject: [PATCH 067/146] Fix: IP Scanner export of running probes as empty
values (review 6, #8)
Export during a scan (or of a scan canceled before every probe finished)
wrote the placeholders of probes that were still running as results: a
running port scan as PortStatus "Closed", a running ping as PingStatus
"Unknown" with timestamp / TTL / bytes, a running NetBIOS request as not
reachable. These values are empty now (CSV empty, XML empty element, JSON
null); finished results are exported as before.
Co-Authored-By: Claude Opus 5.5
---
.../Export/ExportManager.IPScannerHostInfo.cs | 105 +++++++++++-------
Website/docs/application/ip-scanner.md | 2 +-
2 files changed, 67 insertions(+), 40 deletions(-)
diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs
index 5ebeeffbb4..509d6fdc08 100644
--- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs
+++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs
@@ -77,6 +77,11 @@ private static void CreateCsv(IEnumerable collection, string
if (info?.PingInfo == null)
continue;
+ // Values of a probe that is still running (export during a scan, or a scan canceled before the probe
+ // finished) are placeholders - exported as empty values instead of a result
+ var ping = info.IsPingPending ? null : info.PingInfo;
+ var netBIOSInfo = info.IsNetBIOSPending ? null : info.NetBIOSInfo;
+
var stringBuilderPorts = new StringBuilder();
if (info.Ports != null)
@@ -90,20 +95,20 @@ private static void CreateCsv(IEnumerable collection, string
$"{info.IsReachable}," +
$"{info.PingInfo.IPAddress}," +
$"{CsvHelper.QuoteString(info.Hostname)}," +
- $"{info.PingInfo.Status}," +
- $"{DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp)}," +
- $"{Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true)}," +
- $"{info.PingInfo.TTL}," +
- $"{info.PingInfo.Bytes}," +
- $"{(info.IsAnyPortOpen ? PortState.Open : PortState.Closed)}," +
+ $"{ping?.Status}," +
+ $"{(ping == null ? null : DateTimeHelper.DateTimeToFullDateTimeString(ping.Timestamp))}," +
+ $"{(ping == null ? null : Ping.TimeToString(ping.Status, ping.Time, true))}," +
+ $"{ping?.TTL}," +
+ $"{ping?.Bytes}," +
+ $"{GetPortStatus(info)}," +
$"{CsvHelper.QuoteString(stringBuilderPorts.ToString().TrimEnd(';'))}," +
- $"{info.NetBIOSInfo?.IsReachable}," +
- $"{info.NetBIOSInfo?.IPAddress}," +
- $"{CsvHelper.QuoteString(info.NetBIOSInfo?.ComputerName)}," +
- $"{CsvHelper.QuoteString(info.NetBIOSInfo?.UserName)}," +
- $"{CsvHelper.QuoteString(info.NetBIOSInfo?.GroupName)}," +
- $"{info.NetBIOSInfo?.MACAddress}," +
- $"{CsvHelper.QuoteString(info.NetBIOSInfo?.Vendor)}," +
+ $"{netBIOSInfo?.IsReachable}," +
+ $"{netBIOSInfo?.IPAddress}," +
+ $"{CsvHelper.QuoteString(netBIOSInfo?.ComputerName)}," +
+ $"{CsvHelper.QuoteString(netBIOSInfo?.UserName)}," +
+ $"{CsvHelper.QuoteString(netBIOSInfo?.GroupName)}," +
+ $"{netBIOSInfo?.MACAddress}," +
+ $"{CsvHelper.QuoteString(netBIOSInfo?.Vendor)}," +
$"{info.MACAddress}," +
$"{CsvHelper.QuoteString(info.Vendor)}," +
$"{CsvHelper.QuoteString(info.DNSHostname)}"
@@ -125,19 +130,22 @@ private static void CreateXml(IEnumerable collection, string
new XElement(nameof(IPScannerHostInfo) + "s",
from info in collection
where info?.PingInfo != null
+ // Placeholders of running probes are exported as empty values (see CreateCsv)
+ let ping = info.IsPingPending ? null : info.PingInfo
+ let netBIOSInfo = info.IsNetBIOSPending ? null : info.NetBIOSInfo
select
new XElement(nameof(IPScannerHostInfo),
new XElement(nameof(IPScannerHostInfo.IsReachable), info.IsReachable),
new XElement(nameof(PingInfo.IPAddress), info.PingInfo.IPAddress),
new XElement(nameof(IPScannerHostInfo.Hostname), info.Hostname),
- new XElement("PingStatus", info.PingInfo.Status),
+ new XElement("PingStatus", ping?.Status),
new XElement(nameof(PingInfo.Timestamp),
- DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp)),
+ ping == null ? null : DateTimeHelper.DateTimeToFullDateTimeString(ping.Timestamp)),
new XElement(nameof(PingInfo.Time),
- Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true)),
- new XElement(nameof(PingInfo.TTL), info.PingInfo.TTL),
- new XElement(nameof(PingInfo.Bytes), info.PingInfo.Bytes),
- new XElement("PortStatus", info.IsAnyPortOpen ? PortState.Open : PortState.Closed),
+ ping == null ? null : Ping.TimeToString(ping.Status, ping.Time, true)),
+ new XElement(nameof(PingInfo.TTL), ping?.TTL),
+ new XElement(nameof(PingInfo.Bytes), ping?.Bytes),
+ new XElement("PortStatus", GetPortStatus(info)),
info.Ports != null
? from port in info.Ports
select new XElement(nameof(PortInfo),
@@ -147,13 +155,13 @@ from info in collection
new XElement(nameof(PortInfo.LookupInfo.Description), port.LookupInfo.Description),
new XElement(nameof(PortInfo.State), port.State))
: Enumerable.Empty(),
- new XElement("NetBIOSIsReachable", info.NetBIOSInfo?.IsReachable),
- new XElement("NetBIOSIPAddress", info.NetBIOSInfo?.IPAddress),
- new XElement("NetBIOSComputerName", info.NetBIOSInfo?.ComputerName),
- new XElement("NetBIOSUserName", info.NetBIOSInfo?.UserName),
- new XElement("NetBIOSGroupName", info.NetBIOSInfo?.GroupName),
- new XElement("NetBIOSMACAddress", info.NetBIOSInfo?.MACAddress),
- new XElement("NetBIOSVendor", info.NetBIOSInfo?.Vendor),
+ new XElement("NetBIOSIsReachable", netBIOSInfo?.IsReachable),
+ new XElement("NetBIOSIPAddress", netBIOSInfo?.IPAddress),
+ new XElement("NetBIOSComputerName", netBIOSInfo?.ComputerName),
+ new XElement("NetBIOSUserName", netBIOSInfo?.UserName),
+ new XElement("NetBIOSGroupName", netBIOSInfo?.GroupName),
+ new XElement("NetBIOSMACAddress", netBIOSInfo?.MACAddress),
+ new XElement("NetBIOSVendor", netBIOSInfo?.Vendor),
new XElement(nameof(IPScannerHostInfo.MACAddress), info.MACAddress),
new XElement(nameof(IPScannerHostInfo.Vendor), info.Vendor),
// Added later - last element, so the position of the other elements doesn't change
@@ -179,6 +187,11 @@ private static void CreateJson(IReadOnlyList collection, stri
for (var i = 0; i < validCollection.Count; i++)
{
var info = validCollection[i];
+
+ // Placeholders of running probes are exported as empty values (see CreateCsv)
+ var ping = info.IsPingPending ? null : info.PingInfo;
+ var netBIOSInfo = info.IsNetBIOSPending ? null : info.NetBIOSInfo;
+
object[] jsonDataPorts;
if (info.Ports != null && info.Ports.Count > 0)
@@ -204,21 +217,21 @@ private static void CreateJson(IReadOnlyList collection, stri
info.IsReachable,
IPAddress = info.PingInfo.IPAddress.ToString(),
info.Hostname,
- PingStatus = info.PingInfo.Status.ToString(),
- Timestamp = DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp),
- Time = Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true),
- info.PingInfo.TTL,
- info.PingInfo.Bytes,
+ PingStatus = ping?.Status.ToString(),
+ Timestamp = ping == null ? null : DateTimeHelper.DateTimeToFullDateTimeString(ping.Timestamp),
+ Time = ping == null ? null : Ping.TimeToString(ping.Status, ping.Time, true),
+ TTL = ping?.TTL,
+ Bytes = ping?.Bytes,
info.DNSHostname,
- PortStatus = info.IsAnyPortOpen ? PortState.Open.ToString() : PortState.Closed.ToString(),
+ PortStatus = GetPortStatus(info)?.ToString(),
Ports = jsonDataPorts,
- NetBIOSIsReachable = info.NetBIOSInfo?.IsReachable,
- NetBIOSIPAddress = info.NetBIOSInfo?.IPAddress?.ToString(),
- NetBIOSComputerName = info.NetBIOSInfo?.ComputerName,
- NetBIOSUserName = info.NetBIOSInfo?.UserName,
- NetBIOSGroupName = info.NetBIOSInfo?.GroupName,
- NetBIOSMACAddress = info.NetBIOSInfo?.MACAddress,
- NetBIOSVendor = info.NetBIOSInfo?.Vendor,
+ NetBIOSIsReachable = netBIOSInfo?.IsReachable,
+ NetBIOSIPAddress = netBIOSInfo?.IPAddress?.ToString(),
+ NetBIOSComputerName = netBIOSInfo?.ComputerName,
+ NetBIOSUserName = netBIOSInfo?.UserName,
+ NetBIOSGroupName = netBIOSInfo?.GroupName,
+ NetBIOSMACAddress = netBIOSInfo?.MACAddress,
+ NetBIOSVendor = netBIOSInfo?.Vendor,
info.MACAddress,
info.Vendor
};
@@ -226,4 +239,18 @@ private static void CreateJson(IReadOnlyList collection, stri
File.WriteAllText(filePath, JsonConvert.SerializeObject(jsonData, Formatting.Indented));
}
+
+ ///
+ /// Gets the port status of a host: open if any port is open, otherwise closed - null while the port scan is
+ /// still running (no result yet).
+ ///
+ /// Host to get the port status for.
+ /// Port status or null.
+ private static PortState? GetPortStatus(IPScannerHostInfo info)
+ {
+ if (info.IsPortScanPending)
+ return null;
+
+ return info.IsAnyPortOpen ? PortState.Open : PortState.Closed;
+ }
}
\ No newline at end of file
diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md
index e6f3b00c21..eb647576b6 100644
--- a/Website/docs/application/ip-scanner.md
+++ b/Website/docs/application/ip-scanner.md
@@ -89,7 +89,7 @@ A host is shown (and counted as up) as soon as it answers the first probe: ping
Only reachable hosts are shown, unless [Show unreachable IP addresses and ports](#show-unreachable-ip-addresses-and-ports) is enabled. A message below the input is shown if no reachable host was found, the scan was canceled, a hostname could not be resolved or an error occurred.
-The search box right of the **Result** header filters the results by IP address, hostname, MAC address, vendor and open ports (number or service), also while the scan is running (hosts whose hostname or MAC address arrives later are filtered again). **Export...** > all exports the filtered results; the up / down counters always count all hosts.
+The search box right of the **Result** header filters the results by IP address, hostname, MAC address, vendor and open ports (number or service), also while the scan is running (hosts whose hostname or MAC address arrives later are filtered again). **Export...** > all exports the filtered results; the up / down counters always count all hosts. Results can also be exported while the scan is running; values of probes that are still running (shown as `...`) are exported as empty values.
Expand a row with the button in front of it to see its details, or enable [Expand host view](#expand-host-view) to expand all hosts.
From e1578e80cd3378e2e3ab155fe70ebdfc8e4db54a Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:31:28 +0200
Subject: [PATCH 068/146] Chore: IP Scanner help icon for NetBIOS IPv4 only
(review 6, #7)
Scan for NetBIOS has a help icon with the tooltip that only IPv4 addresses
are scanned for NetBIOS (NetBIOS doesn't exist over IPv6), like the other
settings with a help icon (e.g. Firewall > Show all rules). Docs as note.
Co-Authored-By: Claude Opus 5.5
---
.../Resources/Strings.Designer.cs | 10 ++++++++++
.../Resources/Strings.resx | 3 +++
.../NETworkManager/Views/IPScannerSettingsView.xaml | 12 ++++++++++--
Website/docs/application/ip-scanner.md | 8 +++++++-
4 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index dbc99ed066..0ddad9e3f2 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -1,6 +1,7 @@
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
@@ -5959,6 +5960,15 @@ public static string HelpMessage_SaveCredentials {
}
}
+ ///
+ /// Looks up a localized string similar to Only IPv4 addresses are scanned for NetBIOS, NetBIOS doesn't exist over IPv6..
+ ///
+ public static string HelpMessage_ScanForNetBIOS {
+ get {
+ return ResourceManager.GetString("HelpMessage_ScanForNetBIOS", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Displays the status bar in the bottom-left of the WebView when hovering over a link..
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index 7e05a80096..99f68c88eb 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -5089,4 +5089,7 @@ Do you want to start the scan?
{0} Up
+
+ Only IPv4 addresses are scanned for NetBIOS, NetBIOS doesn't exist over IPv6.
+
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml
index d491e51a34..6435fc8e01 100644
--- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml
+++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml
@@ -55,8 +55,16 @@
-
+
+
+
+
+
+
+
+
Date: Tue, 27 Oct 2026 15:42:18 +0200
Subject: [PATCH 069/146] Fix: IP Scanner scan aborted by a link-layer address
that is not a MAC address (final review, #2)
The neighbor table and the local network interfaces can have link-layer
addresses of another length than 6 bytes (e.g. 4 bytes = the IPv4 address of
an ISATAP tunnel, 8 bytes on Teredo). MACAddressHelper.GetDefaultFormat
always reads 6 hex pairs: a shorter address threw in the MAC lookup of a
host, which aborted the whole scan (exception as status message); a longer
one was truncated. Only 6 byte addresses are used as MAC address now.
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Models/Network/IPScanner.cs | 3 ++-
Source/NETworkManager.Models/Network/NeighborTable.cs | 9 +++++----
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs
index 2ea0cc6691..2d1e4ba719 100644
--- a/Source/NETworkManager.Models/Network/IPScanner.cs
+++ b/Source/NETworkManager.Models/Network/IPScanner.cs
@@ -472,7 +472,8 @@ await Task.WhenAll(
p.IPv6Address.Concat(p.IPv6AddressLinkLocal)
.Any(x => IPAddressHelper.WithoutScopeId(x).Equals(ipAddressWithoutScopeId)));
- if (networkInterfaceInfo != null)
+ // Only a MAC address (6 bytes) - e.g. a tunnel adapter has none or a link-layer address of another length
+ if (networkInterfaceInfo?.PhysicalAddress?.GetAddressBytes().Length == 6)
macAddress = networkInterfaceInfo.PhysicalAddress.ToString();
}
diff --git a/Source/NETworkManager.Models/Network/NeighborTable.cs b/Source/NETworkManager.Models/Network/NeighborTable.cs
index d1ad3ed551..2de34aa297 100644
--- a/Source/NETworkManager.Models/Network/NeighborTable.cs
+++ b/Source/NETworkManager.Models/Network/NeighborTable.cs
@@ -315,7 +315,8 @@ private static Dictionary BuildInterfaceAliasMap()
/// Returns a snapshot of the neighbor cache (IPv4 and IPv6) as map from IP address to MAC address
/// (e.g. "001122AABBCC"). IPv6 addresses are stored with their scope id (e.g. "fe80::1%3", for an address entered
/// with one) and without it (see , for an address entered without one -
- /// if the address is on several interfaces, the last entry wins), entries without a MAC address are skipped.
+ /// if the address is on several interfaces, the last entry wins). Entries without a MAC address, or with a link-layer
+ /// address that is not a 6 byte MAC address (e.g. the IPv4 address of a tunnel interface), are skipped.
/// Reading the table once and looking up many addresses is much cheaper than reading it per address.
///
public static Dictionary GetMACAddressMap()
@@ -324,11 +325,11 @@ public static Dictionary GetMACAddressMap()
foreach (var entry in GetTable())
{
- var macAddress = entry.MACAddress.ToString();
-
- if (string.IsNullOrEmpty(macAddress))
+ if (entry.MACAddress.GetAddressBytes().Length != 6)
continue;
+ var macAddress = entry.MACAddress.ToString();
+
map[entry.IPAddress] = macAddress;
map[IPAddressHelper.WithoutScopeId(entry.IPAddress)] = macAddress;
}
From 255044a84622a7db6867079888834b76ce61d8b7 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:43:24 +0200
Subject: [PATCH 070/146] Fix: Port Scanner counters after a rescan of a
canceled scan (final review, #1)
The counters of the other hosts were computed as if every rescanned host had
been scanned with all ports: closed = scanned - rescanned hosts x ports -
open. After a canceled scan (a host scanned only partly) closed was too low
during and after the rescan, and the scanned counter didn't match open +
closed. Closed ports are not kept as rows unless they are shown, so the
scanner counts them per host now (GetPortsClosedPerHost); the view model
subtracts the open and closed ports of the rescanned hosts, and after the
rescan adds its scanned ports. Same idea as review 6 #1 in the IP Scanner.
Co-Authored-By: Claude Opus 5.5
---
.../Network/PortScanner.cs | 19 ++++++++
.../ViewModels/PortScannerViewModel.cs | 47 ++++++++++++++++---
2 files changed, 59 insertions(+), 7 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs
index 3021976c79..5a91e4c807 100644
--- a/Source/NETworkManager.Models/Network/PortScanner.cs
+++ b/Source/NETworkManager.Models/Network/PortScanner.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net;
@@ -34,6 +35,7 @@ public PortScanner(PortScannerOptions options)
private int _progressValue;
private readonly InterlockedCounter _portsOpen = new();
private readonly InterlockedCounter _portsClosed = new();
+ private readonly ConcurrentDictionary _portsClosedPerHost = new();
private readonly PortScannerOptions _options;
@@ -55,6 +57,17 @@ public PortScanner(PortScannerOptions options)
///
public int PortsClosed => _portsClosed.Value;
+ ///
+ /// Gets the number of closed (or timed out) ports per host scanned so far, only hosts with a closed port. Closed
+ /// ports are only raised in if they are shown - e.g. a rescan of some hosts needs
+ /// them to keep the counters of the other hosts.
+ ///
+ /// Number of closed ports per IP address.
+ public Dictionary GetPortsClosedPerHost()
+ {
+ return _portsClosedPerHost.ToDictionary(x => x.Key, x => x.Value.Value);
+ }
+
#endregion
#region Events
@@ -150,9 +163,15 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
// Count open/closed ports unconditionally, since ShowAllResults (below)
// may prevent closed ports from ever reaching PortScanned
if (portState == PortState.Open)
+ {
_portsOpen.Increment();
+ }
else
+ {
_portsClosed.Increment();
+ _portsClosedPerHost.GetOrAdd(host.ipAddress, static _ => new InterlockedCounter())
+ .Increment();
+ }
if (_options.ShowAllResults || portState == PortState.Open)
OnPortScanned(new PortScannerPortScannedArgs(
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 87670e77c7..64906211e1 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -77,6 +77,14 @@ public class PortScannerViewModel : ViewModelBase
private int _rescanPortsOpen;
private int _rescanPortsClosed;
+ // Ports of the last scan without the rescanned hosts, the ports of the rescan are added when it is finished
+ private int _rescanBasePortsScanned;
+
+ // Closed (or timed out) ports per host of the shown result. Closed ports are not shown unless enabled - a rescan
+ // needs them to keep the counters of the other hosts (also if the last scan was canceled before all ports of a
+ // host were scanned)
+ private readonly Dictionary _portsClosedPerHost = [];
+
///
/// Gets or sets the host to scan.
///
@@ -659,6 +667,7 @@ private async Task Start()
}
Results.Clear();
+ _portsClosedPerHost.Clear();
// No results are shown until the scan's options are known - follow the current settings
ScanOptions = CreateScanOptions();
@@ -751,13 +760,17 @@ private void RescanSelectedHosts()
ReplaceResults(rescannedIPAddresses, []);
- // Every host was scanned with all ports: the other hosts keep their counters
var rescanPortCount = ipAddresses.Count * _lastPorts.Length;
+ // The other hosts keep their counters: without the open (all shown) and closed ports of the rescanned hosts
+ var removedPortsOpen = _rescanRemovedRows.Count(x => x.State == PortState.Open);
+ var removedPortsClosed = ipAddresses.Sum(x => _portsClosedPerHost.GetValueOrDefault(x));
+
_rescanPortsToScan = PortsToScan;
_rescanPortsScanned = PortsScanned;
- _rescanBasePortsOpen = Results.Count(x => x.State == PortState.Open);
- _rescanBasePortsClosed = Math.Max(0, PortsScanned - rescanPortCount - _rescanBasePortsOpen);
+ _rescanBasePortsOpen = Math.Max(0, PortsOpen - removedPortsOpen);
+ _rescanBasePortsClosed = Math.Max(0, PortsClosed - removedPortsClosed);
+ _rescanBasePortsScanned = Math.Max(0, PortsScanned - removedPortsOpen - removedPortsClosed);
_isRescan = true;
IsStatusMessageDisplayed = false;
@@ -1073,26 +1086,46 @@ private void ScanComplete(object sender, ScanCompleteArgs e)
FlushProgress();
+ // Canceled / failed rescan: keep the previous results of the hosts (their rows were removed when the
+ // rescan started, only some ports were scanned again)
+ var isRescanDiscarded = _isRescan && (e.IsCanceled || e.Exception != null);
+
+ // Closed ports per host of the shown result (of a rescan: replace the ones of the rescanned hosts)
+ if (sender is PortScanner closedPortsScanner && !isRescanDiscarded)
+ {
+ if (_isRescan)
+ {
+ foreach (var ipAddress in _rescanIPAddresses)
+ _portsClosedPerHost.Remove(ipAddress);
+ }
+
+ foreach (var (ipAddress, count) in closedPortsScanner.GetPortsClosedPerHost())
+ _portsClosedPerHost[ipAddress] = count;
+ }
+
// Rescan: back to the progress of the last scan (open / closed include the rescan)
if (_isRescan)
{
_isRescan = false;
- // Canceled / failed rescan: keep the previous results of the hosts (their rows were removed when the
- // rescan started, only some ports were scanned again)
- if (e.IsCanceled || e.Exception != null)
+ if (isRescanDiscarded)
{
ReplaceResults(_rescanIPAddresses, _rescanRemovedRows);
PortsOpen = _rescanPortsOpen;
PortsClosed = _rescanPortsClosed;
+ PortsScanned = _rescanPortsScanned;
+ }
+ else
+ {
+ // Ports of the other hosts + ports of the rescan
+ PortsScanned = _rescanBasePortsScanned + PortsScanned;
}
_rescanIPAddresses = null;
_rescanRemovedRows = null;
PortsToScan = _rescanPortsToScan;
- PortsScanned = _rescanPortsScanned;
}
if (e.IsCanceled)
From 2d6a975f7eadf8912b96faa97a376b9868f218f1 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:43:52 +0200
Subject: [PATCH 071/146] Fix: IP Scanner Copy of running probes disabled
(final review, #3)
The grid shows "..." for a probe that is still running and the export writes
an empty value (review 6 #8), but Copy > Ping status / Timestamp / Time / TTL
/ Bytes and Port status / Ports copied the placeholders as result (e.g. port
status "Closed" while the ports were still scanned, ping status "Unknown").
These items are disabled until the ping / port scan of the host is finished.
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager/Views/IPScannerView.xaml | 7 +++++++
Website/docs/application/ip-scanner.md | 2 +-
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml
index 10f89d3679..335aafd628 100644
--- a/Source/NETworkManager/Views/IPScannerView.xaml
+++ b/Source/NETworkManager/Views/IPScannerView.xaml
@@ -505,11 +505,13 @@
diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md
index bf4144ccf7..fcc7b04808 100644
--- a/Website/docs/application/ip-scanner.md
+++ b/Website/docs/application/ip-scanner.md
@@ -74,7 +74,7 @@ Right-clicking a result allows forwarding device information to another tool (e.
| **Wake on LAN** | Sends a magic packet to the MAC address of the host with [Wake on LAN](./wake-on-lan) (only with a MAC address) |
| **Custom commands** | Executes a [custom command](#custom-commands) for the host |
| **Add profile...** | Creates a new profile with the host (IP address), the hostname as name and the MAC address for Wake on LAN |
-| **Copy** | Copies the selected information to the clipboard |
+| **Copy** | Copies the selected information to the clipboard (ping and port values only when the probe is finished) |
| **Export...** | Exports the selected or all results to a file |
A NetBIOS name (shown as hostname if there is no DNS hostname) is not passed to other tools, because it may not be resolvable via DNS - the IP address is used instead. **DNS lookup...** / **RDAP...** > **Hostname** are only available with a DNS hostname. In [custom commands](#custom-commands), `{{Hostname}}` is the shown hostname (DNS or NetBIOS).
From 37d539184a2bdd0ff280bdf14dfa976f6465e174 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:44:21 +0200
Subject: [PATCH 072/146] Improvement: IP Scanner live filtering only while
searching (final review, #5)
Live filtering was always enabled: the view keeps a live shaping list with a
listener per row for Hostname, MAC address, Vendor and Ports and filters a
row again on every update - also without a search, when every row is shown
anyway (e.g. 65,536 rows with Show unreachable). It is enabled in
ApplySearch while the search box has text, and disabled again when it is
cleared.
Co-Authored-By: Claude Opus 5.5
---
.../NETworkManager/ViewModels/IPScannerViewModel.cs | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index 9066f615ea..4695e7c2b4 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -428,9 +428,9 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true)
};
// Hostname, MAC address, vendor and ports arrive after the host was added - filter it again when they
- // change (without live filtering a host found before its hostname arrived would stay hidden)
+ // change (without live filtering a host found before its hostname arrived would stay hidden). Only enabled
+ // while searching, see ApplySearch.
var liveShaping = (ICollectionViewLiveShaping)ResultsView;
- liveShaping.IsLiveFiltering = true;
liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Hostname));
liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.MACAddress));
liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Vendor));
@@ -1195,6 +1195,14 @@ private void ApplySearch()
{
_searchDispatcherTimer.Stop();
+ // Live filtering only while searching - without a search every host is shown anyway, but it would still
+ // listen to the filtered properties of every row and filter a row again on every update
+ var liveShaping = (ICollectionViewLiveShaping)ResultsView;
+ var isSearching = !string.IsNullOrEmpty(Search);
+
+ if (liveShaping.IsLiveFiltering != isSearching)
+ liveShaping.IsLiveFiltering = isSearching;
+
ResultsView.Refresh();
}
From c293185dd827965c1781ab2d23471d959a7591c5 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:44:40 +0200
Subject: [PATCH 073/146] Fix: Port Scanner rescan of a large result with one
reset (final review, #6)
ReplaceResults removed / added up to 1000 rows one by one regardless of the
size of the result, but every single change searches the whole collection
and the sorted, grouped view: 500 rows of a result with ~1 M rows (closed
ports shown) froze the window. The limit is the effort now: changed rows x
all rows (max. 10 M, e.g. 1,000 of 10,000 rows), above it one reset.
Co-Authored-By: Claude Opus 5.5
---
.../ViewModels/PortScannerViewModel.cs | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 64906211e1..8a18aaad4d 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -34,10 +34,10 @@ public class PortScannerViewModel : ViewModelBase
private static readonly ILog Log = LogManager.GetLogger(typeof(PortScannerViewModel));
///
- /// Maximum number of rows that are removed / added one by one when the results of hosts are replaced (rescan),
- /// see .
+ /// Maximum effort (changed rows x all rows) to remove / add the rows one by one when the results of hosts are
+ /// replaced (rescan), see . E.g. 1,000 rows of 10,000 or 10 rows of 1,000,000.
///
- private const int MaxRowsChangedOneByOne = 1000;
+ private const long MaxRowChangesOneByOne = 10_000_000;
private CancellationTokenSource _cancellationTokenSource;
@@ -797,9 +797,9 @@ private void RescanSelectedHosts()
///
/// Replaces the rows of the given hosts. Few rows are removed / added one by one, so the groups of the other hosts
- /// keep their state (collapsed) and the scroll position stays. Many rows with one reset instead: every single
- /// change searches the collection and the sorted, grouped view (e.g. a host with 1-65535 and closed ports shown
- /// would freeze the window for minutes).
+ /// keep their state (collapsed) and the scroll position stays. Many rows (or rows of a large result) with one reset
+ /// instead: every single change searches the whole collection and the sorted, grouped view (e.g. a host with
+ /// 1-65535 and closed ports shown would freeze the window for minutes).
///
/// Hosts whose rows are replaced.
/// New rows of the hosts.
@@ -807,7 +807,7 @@ private void ReplaceResults(HashSet ipAddresses, IReadOnlyCollection<
{
var oldRows = Results.Where(x => ipAddresses.Contains(x.IPAddress)).ToList();
- if (oldRows.Count + rows.Count > MaxRowsChangedOneByOne)
+ if ((long)(oldRows.Count + rows.Count) * Results.Count > MaxRowChangesOneByOne)
{
Results.ReplaceAll(Results.Where(x => !ipAddresses.Contains(x.IPAddress)).Concat(rows));
From cb2b48ee8913d68b3787e4468e6fe63dcebfaad1 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:45:18 +0200
Subject: [PATCH 074/146] Refactor: IP Scanner rescan with one row per IP
address (final review, #7)
Since 9f45080a3 every IP address is scanned once, so it has exactly one row.
The rescan maps IP address -> list of rows and result -> list of rows (and
the loops over them) supported a case that can't happen any more: one row
per IP address / result now.
Co-Authored-By: Claude Opus 5.5
---
.../ViewModels/IPScannerViewModel.cs | 42 +++++++++----------
1 file changed, 20 insertions(+), 22 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index 4695e7c2b4..28560efe1e 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -58,14 +58,15 @@ public class IPScannerViewModel : ViewModelBase, IProfileManagerMinimal
// Options of the last scan, to rescan hosts with the same options (the columns follow them, see ScanOptions)
private IPScannerOptions _lastScanOptions;
- // Rescan of shown hosts (see RescanSelectedHosts), null if no rescan is running: rows per rescanned IP address.
- // The scanner creates new results - their values are copied into the existing rows (selection and expanded
- // details stay), the updates of a new result are applied to the rows it was copied to (_rescanResults).
- private Dictionary> _rescanRows;
- private readonly Dictionary> _rescanResults = [];
+ // Rescan of shown hosts (see RescanSelectedHosts), null if no rescan is running: row per rescanned IP address
+ // (every IP address is scanned once, see HostRangeHelper.ResolveAsync). The scanner creates new results - their
+ // values are copied into the existing rows (selection and expanded details stay), the updates of a new result are
+ // applied to the row it was copied to (_rescanResults: result -> row).
+ private Dictionary _rescanRows;
+ private readonly Dictionary _rescanResults = [];
// Values of the rescanned rows before the rescan (row -> copy), restored if the rescan is canceled or fails: a
- // found host is copied into its rows with placeholders for the running probes, which would stay otherwise
+ // found host is copied into its row with placeholders for the running probes, which would stay otherwise
private Dictionary _rescanSnapshots;
// Counters of the last scan, restored after a rescan (the rescan shows its own progress)
@@ -804,8 +805,8 @@ private async Task Start()
}
///
- /// Rescans the selected hosts with the options of the last scan. The rows are updated in place (all rows with
- /// the IP address of a selected row); a host that is unreachable now is shown as down.
+ /// Rescans the selected hosts with the options of the last scan. The rows are updated in place; a host that is
+ /// unreachable now is shown as down.
///
private void RescanSelectedHosts()
{
@@ -817,13 +818,14 @@ private void RescanSelectedHosts()
if (selectedIPAddresses.Count == 0)
return;
+ // One row per IP address (DistinctBy only so a broken assumption can't throw)
_rescanRows = Results.Where(x => selectedIPAddresses.Contains(x.IPAddress))
- .GroupBy(x => x.IPAddress)
- .ToDictionary(x => x.Key, x => x.ToList());
+ .DistinctBy(x => x.IPAddress)
+ .ToDictionary(x => x.IPAddress);
_rescanResults.Clear();
- _rescanSnapshots = _rescanRows.Values.SelectMany(x => x).ToDictionary(x => x, x => x.Clone());
+ _rescanSnapshots = _rescanRows.Values.ToDictionary(x => x, x => x.Clone());
_rescanHostsToScan = HostsToScan;
_rescanHostsScanned = HostsScanned;
@@ -1308,8 +1310,8 @@ private void FlushResultsBuffer()
}
///
- /// Applies a buffered result of a rescan to the existing rows: a found host is copied into the rows of its IP
- /// address, its updates are applied to these rows.
+ /// Applies a buffered result of a rescan to the existing rows: a found host is copied into the row of its IP
+ /// address, its updates are applied to this row.
///
/// Result of the rescan.
/// Update of the result, null if the host was found.
@@ -1317,22 +1319,18 @@ private void ApplyRescanResult(IPScannerHostInfo host, IPScannerHostUpdatedArgs
{
if (update == null)
{
- if (!_rescanRows.TryGetValue(host.IPAddress, out var rows))
+ if (!_rescanRows.TryGetValue(host.IPAddress, out var row))
return;
- foreach (var row in rows)
- row.CopyFrom(host);
+ row.CopyFrom(host);
- _rescanResults[host] = rows;
+ _rescanResults[host] = row;
return;
}
- if (!_rescanResults.TryGetValue(update.Host, out var targets))
- return;
-
- foreach (var row in targets)
- update.ApplyTo(row);
+ if (_rescanResults.TryGetValue(update.Host, out var target))
+ update.ApplyTo(target);
}
///
From 95e9f57ca918eb39838fafb92108443302bd06c4 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 15:45:51 +0200
Subject: [PATCH 075/146] Refactor: IP Scanner rescan options with "with"
(final review, #8)
CreateRescanOptions copied all 15 options by hand to set ReportAllHosts -
every new option had to be added there too, and a wrong copy (e.g. a current
setting instead of the option of the last scan) would compile. IPScannerOptions
is a record now, the rescan options are "options with { ReportAllHosts = true }".
Co-Authored-By: Claude Opus 5.5
---
.../Network/IPScannerOptions.cs | 5 +++--
.../ViewModels/IPScannerViewModel.cs | 19 +------------------
2 files changed, 4 insertions(+), 20 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs
index cc63216e03..54e08dba1a 100644
--- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs
+++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs
@@ -1,9 +1,10 @@
namespace NETworkManager.Models.Network;
///
-/// Class contains the options for the IP scanner.
+/// Class contains the options for the IP scanner. A record, so a copy with other values can be created with
+/// with (e.g. for a rescan).
///
-public class IPScannerOptions
+public record IPScannerOptions
{
///
/// Max concurrent threads for the host scan.
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index 28560efe1e..52542a5e78 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -917,24 +917,7 @@ private static IPScannerOptions CreateScanOptions(int[] ports)
/// Options for the rescan.
private static IPScannerOptions CreateRescanOptions(IPScannerOptions options)
{
- return new IPScannerOptions
- {
- MaxHostThreads = options.MaxHostThreads,
- MaxPortThreads = options.MaxPortThreads,
- ICMPAttempts = options.ICMPAttempts,
- ICMPTimeout = options.ICMPTimeout,
- ICMPBuffer = options.ICMPBuffer,
- ResolveHostname = options.ResolveHostname,
- PortScanEnabled = options.PortScanEnabled,
- PortScanPorts = options.PortScanPorts,
- PortScanTimeout = options.PortScanTimeout,
- PortScanAttempts = options.PortScanAttempts,
- NetBIOSEnabled = options.NetBIOSEnabled,
- NetBIOSTimeout = options.NetBIOSTimeout,
- ResolveMACAddress = options.ResolveMACAddress,
- ShowAllResults = options.ShowAllResults,
- ReportAllHosts = true
- };
+ return options with { ReportAllHosts = true };
}
///
From 70d9a584a166b965164e8f47533322464a36d38d Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 16:02:46 +0200
Subject: [PATCH 076/146] Fix: Port Scanner groups the results per IP address
(PS-B1)
The results were grouped by the hostname (the IP address only without one):
different IP addresses with the same PTR name (e.g. a generic name of a
provider, or a server with two IP addresses) were shown in one group, and the
group header didn't show any of the IP addresses. The group header is
"hostname (IP address)" now (the IP address without hostname) - one group per
IP address, which is scanned only once.
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Models/Network/PortScannerPortInfo.cs | 6 ++++--
Website/docs/application/port-scanner.md | 2 +-
Website/docs/changelog/next-release.md | 1 +
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs b/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs
index 2a4add8cbd..c6eb3b7454 100644
--- a/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs
+++ b/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs
@@ -34,7 +34,9 @@ public PortScannerPortInfo(IPAddress ipAddress, string hostname, int port, PortL
public string Hostname { get; set; }
///
- /// Hostname (if available) or IP address of the host.
+ /// Hostname and IP address of the host (e.g. "server.example.com (10.0.0.1)"), only the IP address if there is
+ /// no hostname. The results are grouped by it - one group per IP address, also if several hosts have the same
+ /// hostname (e.g. a generic PTR record of a provider).
///
- public string HostAsString => string.IsNullOrEmpty(Hostname) ? IPAddress.ToString() : Hostname;
+ public string HostAsString => string.IsNullOrEmpty(Hostname) ? IPAddress.ToString() : $"{Hostname} ({IPAddress})";
}
\ No newline at end of file
diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md
index 218c6c3e3b..6806e5c129 100644
--- a/Website/docs/application/port-scanner.md
+++ b/Website/docs/application/port-scanner.md
@@ -75,7 +75,7 @@ If multiple results are selected, **Rescan**, **Copy** > **IP address** / **Host
### Result
-The results are grouped by host and sorted by IP address and port. The number of open and closed ports is shown next to the **Result** header. A port is **open** if the connection was established, **closed** if the host refused the connection and **timed out** if there was no answer within the [timeout](#timeout-ms) (e.g. blocked by a firewall). Only open ports are shown, unless [Show closed ports](#show-closed-ports) is enabled. The **Hostname** column is shown if [Resolve hostname](#resolve-hostname) was enabled when the scan was started.
+The results are grouped by host (one group per IP address, the header shows the hostname and the IP address) and sorted by IP address and port. The number of open and closed ports is shown next to the **Result** header. A port is **open** if the connection was established, **closed** if the host refused the connection and **timed out** if there was no answer within the [timeout](#timeout-ms) (e.g. blocked by a firewall). Only open ports are shown, unless [Show closed ports](#show-closed-ports) is enabled. The **Hostname** column is shown if [Resolve hostname](#resolve-hostname) was enabled when the scan was started.
The search box right of the **Result** header filters the results by IP address, hostname, port, protocol, service, description and status, also while the scan is running. **Export...** > all exports the filtered results; the open / closed counters always count all ports.
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 4027206e08..6cbb12f960 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -290,6 +290,7 @@ Release date: **xx.xx.2026**
- Fixed the progress and the open / closed counters possibly staying below the final values (e.g. 1023 of 1024 ports scanned) when the results of several ports arrived at the same time. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed `Enter` in the search box of the port profiles dialog closing the dialog with **OK**. It only filters the port profiles now. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed columns that couldn't be made wider than the window (by dragging or double-clicking the gripper) in the result and the port profiles (dialog, settings). The grid scrolls horizontally now, the last (or main) column still fills the remaining width. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557)
+- Fixed the ports of different hosts with the same hostname (DNS `PTR`, e.g. a generic name of a provider or a server with two IP addresses) being shown in one group. The results are grouped per IP address, the group header shows the hostname and the IP address. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
**IP Geolocation**
From 60001cb79cbb7e638f052ead3e9d1f25153bad49 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 16:03:29 +0200
Subject: [PATCH 077/146] Fix: Port Scanner port profile edit / delete only
with a selection (PS-B2)
Edit and Delete of a port profile in the settings had no CanExecute and the
selection started as an empty profile that is not in the list: with no
profile selected (e.g. all deleted), F2 / double-click opened Edit with an
empty profile and OK added a new one, Del asked to delete nothing. Both need
a selected profile now (like the custom commands of the IP Scanner, B15), the
delete confirmation names the profile.
Co-Authored-By: Claude Opus 5.5
---
.../PortScannerSettingsViewModel.cs | 19 +++++++++++++++----
Website/docs/application/port-scanner.md | 2 +-
Website/docs/changelog/next-release.md | 1 +
3 files changed, 17 insertions(+), 5 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
index a2c07cbfde..50ecd338a9 100644
--- a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
@@ -4,6 +4,7 @@
using NETworkManager.Settings;
using NETworkManager.Utilities;
using NETworkManager.Views;
+using System;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
@@ -41,7 +42,7 @@ public PortProfileInfo SelectedPortProfile
field = value;
OnPropertyChanged();
}
- } = new();
+ }
///
/// Gets or sets a value indicating whether to show all results (open and closed ports).
@@ -200,14 +201,23 @@ private void AddPortProfileAction()
_ = AddPortProfile();
}
- public ICommand EditPortProfileCommand => new RelayCommand(_ => EditPortProfileAction());
+ public ICommand EditPortProfileCommand => new RelayCommand(_ => EditPortProfileAction(), PortProfileSelected_CanExecute);
+
+ ///
+ /// Checks if a port profile is selected (edit and delete need one, e.g. F2 / Del without a selection).
+ ///
+ private bool PortProfileSelected_CanExecute(object parameter)
+ {
+ return SelectedPortProfile != null;
+ }
private void EditPortProfileAction()
{
_ = EditPortProfile();
}
- public ICommand DeletePortProfileCommand => new RelayCommand(_ => DeletePortProfileAction());
+ public ICommand DeletePortProfileCommand =>
+ new RelayCommand(_ => DeletePortProfileAction(), PortProfileSelected_CanExecute);
private void DeletePortProfileAction()
{
@@ -275,7 +285,8 @@ private async Task DeletePortProfile()
{
var result = await DialogHelper.ShowConfirmationMessageAsync(Application.Current.MainWindow,
Strings.DeletePortProfile,
- Strings.DeletePortProfileMessage,
+ Strings.DeletePortProfileMessage + Environment.NewLine + Environment.NewLine +
+ DialogHelper.FormatItemList([SelectedPortProfile.Name]),
ChildWindowIcon.Info,
Strings.Delete);
diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md
index 6806e5c129..93b30ca85e 100644
--- a/Website/docs/application/port-scanner.md
+++ b/Website/docs/application/port-scanner.md
@@ -171,7 +171,7 @@ List of common TCP ports to scan for.
Right-click on a selected port profile to `edit` or `delete` it.
-You can also use the Hotkeys `F2` (`edit`) or `Del` (`delete`) on a selected port profile.
+You can also use the Hotkeys `F2` (`edit`) or `Del` (`delete`) on a selected port profile, or double-click it to edit it.
:::
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 6cbb12f960..b7ee94c9eb 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -291,6 +291,7 @@ Release date: **xx.xx.2026**
- Fixed `Enter` in the search box of the port profiles dialog closing the dialog with **OK**. It only filters the port profiles now. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed columns that couldn't be made wider than the window (by dragging or double-clicking the gripper) in the result and the port profiles (dialog, settings). The grid scrolls horizontally now, the last (or main) column still fills the remaining width. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557)
- Fixed the ports of different hosts with the same hostname (DNS `PTR`, e.g. a generic name of a provider or a server with two IP addresses) being shown in one group. The results are grouped per IP address, the group header shows the hostname and the IP address. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Fixed **Edit** (`F2`, double-click) and **Delete** (`Del`) of a port profile in the settings being available without a selected profile (edit created a new profile). The delete confirmation shows the name of the profile. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
**IP Geolocation**
From 3ae408df977372d0d30f02ebc6f09334a556f850 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 16:04:12 +0200
Subject: [PATCH 078/146] Improvement: Port Scanner scans more hosts at once
with few ports (PS-P1)
The number of hosts scanned at the same time was fixed (host threads, default
4, max. 10), independent of the ports per host: with few ports the port
threads stayed unused - a /24 with one port (e.g. 3389) had 4 connections in
flight, 64 rounds x 2 s timeout = about 2 minutes for an empty range (the IP
Scanner needs about 4 s).
If the hosts have fewer ports than port threads, more hosts are scanned at the
same time within the same number of connections (host threads x port threads,
default 512), at most 128 hosts (concurrent PTR lookups, like the IP Scanner):
/24 x 1 port -> 128 hosts, about 2 timeouts; 1-1024 unchanged (4 hosts).
Co-Authored-By: Claude Opus 5.5
---
.../Network/PortScanner.cs | 36 +++++++++++++++----
Website/docs/application/port-scanner.md | 6 ++--
Website/docs/changelog/next-release.md | 1 +
3 files changed, 35 insertions(+), 8 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs
index 5a91e4c807..43f9afff64 100644
--- a/Source/NETworkManager.Models/Network/PortScanner.cs
+++ b/Source/NETworkManager.Models/Network/PortScanner.cs
@@ -32,6 +32,13 @@ public PortScanner(PortScannerOptions options)
///
private static readonly TimeSpan MaxPortStartInterval = TimeSpan.FromMilliseconds(1);
+ ///
+ /// Maximum number of hosts scanned at the same time if hosts have fewer ports than port threads (see
+ /// ) - also the number of concurrent DNS (PTR) lookups, like the default of the
+ /// IP Scanner.
+ ///
+ private const int MaxHostThreadsForFewPorts = 128;
+
private int _progressValue;
private readonly InterlockedCounter _portsOpen = new();
private readonly InterlockedCounter _portsClosed = new();
@@ -115,10 +122,13 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
try
{
+ var portCount = ports.Count();
+ var maxHostThreads = GetMaxHostThreads(portCount);
+
var hostParallelOptions = new ParallelOptions
{
CancellationToken = cancellationToken,
- MaxDegreeOfParallelism = _options.MaxHostThreads
+ MaxDegreeOfParallelism = maxHostThreads
};
var portParallelOptions = new ParallelOptions
@@ -129,12 +139,10 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
// Spread the start of the port probes over time instead of starting all at once
var hostConcurrency = hosts.TryGetNonEnumeratedCount(out var hostCount)
- ? Math.Min(hostCount, _options.MaxHostThreads)
- : _options.MaxHostThreads;
+ ? Math.Min(hostCount, maxHostThreads)
+ : maxHostThreads;
- var portConcurrency = hostConcurrency * (ports.TryGetNonEnumeratedCount(out var portCount)
- ? Math.Min(portCount, _options.MaxPortThreads)
- : _options.MaxPortThreads);
+ var portConcurrency = hostConcurrency * Math.Min(portCount, _options.MaxPortThreads);
var portStartScheduler = new StartScheduler(StartScheduler.GetInterval(MaxPortStartInterval,
TimeSpan.FromMilliseconds(_options.Timeout * _options.Attempts), portConcurrency), portConcurrency);
@@ -197,6 +205,22 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
});
}
+ ///
+ /// Gets the number of hosts scanned at the same time. A host with fewer ports than port threads leaves port
+ /// threads unused (e.g. one port on a /24: 4 hosts = 4 connections at a time) - then more hosts are scanned at the
+ /// same time, within the same number of connections (host threads x port threads), at most
+ /// (never less than the host threads).
+ ///
+ /// Number of ports per host.
+ /// Number of hosts scanned at the same time.
+ private int GetMaxHostThreads(int portCount)
+ {
+ var portsAtOnce = Math.Max(1, Math.Min(portCount, _options.MaxPortThreads));
+
+ return Math.Max(_options.MaxHostThreads,
+ Math.Min(MaxHostThreadsForFewPorts, _options.MaxHostThreads * _options.MaxPortThreads / portsAtOnce));
+ }
+
private void IncreaseProgress()
{
// Increase the progress
diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md
index 93b30ca85e..844e6e4238 100644
--- a/Website/docs/application/port-scanner.md
+++ b/Website/docs/application/port-scanner.md
@@ -221,7 +221,9 @@ Resolve the hostname for given IP addresses.
### Max. concurrent host threads
-Maximum number of hosts that are scanned at the same time. A thread is not an operating system thread, but one host whose ports are scanned concurrently.
+Number of hosts that are scanned at the same time. A thread is not an operating system thread, but one host whose ports are scanned concurrently.
+
+If the hosts have fewer ports than [Max. concurrent port threads](#max-concurrent-port-threads) (e.g. one port on a `/24`), more hosts are scanned at the same time (up to `128`), so that the same number of connections is used (host threads × port threads, with the default settings 4 × 128 = 512). Example: a `/24` with one port takes about as long as two timeouts instead of 64.
**Type:** `Integer` [Min `1`, Max `10`]
@@ -229,7 +231,7 @@ Maximum number of hosts that are scanned at the same time. A thread is not an op
:::warning
-Every host opens up to [Max. concurrent port threads](#max-concurrent-port-threads) TCP connections at the same time (with the default settings up to 4 × 128 = 512). Too many simultaneous requests may be detected as a port scan and blocked or rate-limited by a firewall (local, on the network or on the scanned host) and can overload small routers. If ports are missing in the result (shown as timed out) or the network becomes slow during the scan, reduce the number of threads. This will increase the scan time.
+The scan opens up to host threads × [Max. concurrent port threads](#max-concurrent-port-threads) TCP connections at the same time (with the default settings up to 4 × 128 = 512). Too many simultaneous requests may be detected as a port scan and blocked or rate-limited by a firewall (local, on the network or on the scanned host) and can overload small routers. If ports are missing in the result (shown as timed out) or the network becomes slow during the scan, reduce the number of threads. This will increase the scan time.
:::
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index b7ee94c9eb..b17eb9988d 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -99,6 +99,7 @@ Release date: **xx.xx.2026**
- Added **Web Console** to the context menu of an open web port (`80`, `443`, `8080`, `8443`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added a search box above the result to filter the ports by IP address, hostname, port, protocol, service, description and status, also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- **Copy** > **IP address** / **Hostname** / **Port** / **IP address:Port** is available if multiple results are selected (one value per line, in the order shown). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Hosts with fewer ports than **Max. concurrent port threads** are scanned faster: more hosts are scanned at the same time (up to `128`), with the same number of connections (host threads × port threads). For example, a `/24` with one port (e.g. `3389`) takes about 4 seconds instead of about 2 minutes. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added a new **Well-known ports** (`1-1024`) default port profile. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564)
- The export runs in the background, so the window stays responsive while a large result is exported, and uses the order shown in the result (sorting, grouping) instead of the order in which the ports were scanned. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Ports are started one after another (every 1 ms, shorter with short timeouts and many threads) instead of all at the same moment. This avoids bursts of hundreds of connection requests within a few milliseconds and makes the progress smoother. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
From b096521df128e5ffee020553751778c489d7cd56 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 16:05:49 +0200
Subject: [PATCH 079/146] Improvement: Port Scanner counts timed out ports
separately (PS-B3, PS-C2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Closed counter next to the Result header counted closed and timed out
ports, while the result and the docs distinguish closed (the host refused
the connection) and timed out (no answer): a host behind a firewall showed
"0 Open · 1024 Closed" with every row "Timed out". The counter is
"Open · Closed · Timed out" now (new string XTimedOut). The scanner counts
timed out ports separately, also per host for the rescan of some hosts.
Docs: Show closed ports shows closed and timed out ports.
Co-Authored-By: Claude Opus 5.5
---
.../Resources/Strings.Designer.cs | 9 +++
.../Resources/Strings.resx | 3 +
.../Network/PortScanner.cs | 40 +++++++++---
.../ViewModels/PortScannerViewModel.cs | 64 ++++++++++++++-----
.../NETworkManager/Views/PortScannerView.xaml | 10 +++
Website/docs/application/port-scanner.md | 6 +-
Website/docs/changelog/next-release.md | 1 +
7 files changed, 104 insertions(+), 29 deletions(-)
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index 0ddad9e3f2..288c4e09fa 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -14731,6 +14731,15 @@ public static string XPaused {
}
}
+ ///
+ /// Looks up a localized string similar to {0} Timed out.
+ ///
+ public static string XTimedOut {
+ get {
+ return ResourceManager.GetString("XTimedOut", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to {0} Up.
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index 99f68c88eb..144434ec19 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -5089,6 +5089,9 @@ Do you want to start the scan?
{0} Up
+
+ {0} Timed out
+
Only IPv4 addresses are scanned for NetBIOS, NetBIOS doesn't exist over IPv6.
diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs
index 43f9afff64..853b52e9b6 100644
--- a/Source/NETworkManager.Models/Network/PortScanner.cs
+++ b/Source/NETworkManager.Models/Network/PortScanner.cs
@@ -42,7 +42,11 @@ public PortScanner(PortScannerOptions options)
private int _progressValue;
private readonly InterlockedCounter _portsOpen = new();
private readonly InterlockedCounter _portsClosed = new();
- private readonly ConcurrentDictionary _portsClosedPerHost = new();
+ private readonly InterlockedCounter _portsTimedOut = new();
+
+ // Closed and timed out ports per host, see GetClosedAndTimedOutPortsPerHost
+ private readonly ConcurrentDictionary
+ _portsPerHost = new();
private readonly PortScannerOptions _options;
@@ -59,20 +63,26 @@ public PortScanner(PortScannerOptions options)
public int PortsOpen => _portsOpen.Value;
///
- /// Gets the number of ports found to be closed (or timed out) so far. Thread-safe; may be
+ /// Gets the number of ports found to be closed (the host refused the connection) so far. Thread-safe; may be
/// read from any thread while the scan is running.
///
public int PortsClosed => _portsClosed.Value;
///
- /// Gets the number of closed (or timed out) ports per host scanned so far, only hosts with a closed port. Closed
+ /// Gets the number of ports without an answer (timed out) so far. Thread-safe; may be read from any thread
+ /// while the scan is running.
+ ///
+ public int PortsTimedOut => _portsTimedOut.Value;
+
+ ///
+ /// Gets the number of closed and timed out ports per host scanned so far, only hosts with such a port. These
/// ports are only raised in if they are shown - e.g. a rescan of some hosts needs
/// them to keep the counters of the other hosts.
///
- /// Number of closed ports per IP address.
- public Dictionary GetPortsClosedPerHost()
+ /// Number of closed and timed out ports per IP address.
+ public Dictionary GetClosedAndTimedOutPortsPerHost()
{
- return _portsClosedPerHost.ToDictionary(x => x.Key, x => x.Value.Value);
+ return _portsPerHost.ToDictionary(x => x.Key, x => (x.Value.closed.Value, x.Value.timedOut.Value));
}
#endregion
@@ -168,7 +178,7 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
// long before the first port is finished
var hostname = await hostnameTask.ConfigureAwait(false);
- // Count open/closed ports unconditionally, since ShowAllResults (below)
+ // Count open/closed/timed out ports unconditionally, since ShowAllResults (below)
// may prevent closed ports from ever reaching PortScanned
if (portState == PortState.Open)
{
@@ -176,9 +186,19 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
}
else
{
- _portsClosed.Increment();
- _portsClosedPerHost.GetOrAdd(host.ipAddress, static _ => new InterlockedCounter())
- .Increment();
+ var portsOfHost = _portsPerHost.GetOrAdd(host.ipAddress,
+ static _ => (new InterlockedCounter(), new InterlockedCounter()));
+
+ if (portState == PortState.Closed)
+ {
+ _portsClosed.Increment();
+ portsOfHost.closed.Increment();
+ }
+ else
+ {
+ _portsTimedOut.Increment();
+ portsOfHost.timedOut.Increment();
+ }
}
if (_options.ShowAllResults || portState == PortState.Open)
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 8a18aaad4d..f072654c84 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -58,6 +58,7 @@ public class PortScannerViewModel : ViewModelBase
private int _latestPortsScanned;
private int _latestPortsOpen;
private int _latestPortsClosed;
+ private int _latestPortsTimedOut;
// Ports of the last scan, to rescan hosts with the same ports (and ScanOptions)
private int[] _lastPorts;
@@ -68,6 +69,7 @@ public class PortScannerViewModel : ViewModelBase
private bool _isRescan;
private int _rescanBasePortsOpen;
private int _rescanBasePortsClosed;
+ private int _rescanBasePortsTimedOut;
private int _rescanPortsToScan;
private int _rescanPortsScanned;
@@ -76,14 +78,15 @@ public class PortScannerViewModel : ViewModelBase
private List _rescanRemovedRows;
private int _rescanPortsOpen;
private int _rescanPortsClosed;
+ private int _rescanPortsTimedOut;
// Ports of the last scan without the rescanned hosts, the ports of the rescan are added when it is finished
private int _rescanBasePortsScanned;
- // Closed (or timed out) ports per host of the shown result. Closed ports are not shown unless enabled - a rescan
- // needs them to keep the counters of the other hosts (also if the last scan was canceled before all ports of a
- // host were scanned)
- private readonly Dictionary _portsClosedPerHost = [];
+ // Closed and timed out ports per host of the shown result. They are not shown unless enabled - a rescan needs them
+ // to keep the counters of the other hosts (also if the last scan was canceled before all ports of a host were
+ // scanned)
+ private readonly Dictionary _portsNotOpenPerHost = [];
///
/// Gets or sets the host to scan.
@@ -268,7 +271,7 @@ public int PortsOpen
}
///
- /// Gets or sets the number of ports found to be closed so far.
+ /// Gets or sets the number of ports found to be closed (the host refused the connection) so far.
///
public int PortsClosed
{
@@ -283,6 +286,22 @@ public int PortsClosed
}
}
+ ///
+ /// Gets or sets the number of ports without an answer (timed out) so far.
+ ///
+ public int PortsTimedOut
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets or sets a value indicating whether the scan is being prepared.
///
@@ -667,7 +686,7 @@ private async Task Start()
}
Results.Clear();
- _portsClosedPerHost.Clear();
+ _portsNotOpenPerHost.Clear();
// No results are shown until the scan's options are known - follow the current settings
ScanOptions = CreateScanOptions();
@@ -679,9 +698,11 @@ private async Task Start()
PortsScanned = 0;
PortsOpen = 0;
PortsClosed = 0;
+ PortsTimedOut = 0;
Volatile.Write(ref _latestPortsScanned, 0);
Volatile.Write(ref _latestPortsOpen, 0);
Volatile.Write(ref _latestPortsClosed, 0);
+ Volatile.Write(ref _latestPortsTimedOut, 0);
DragablzTabItem.SetTabHeader(_tabId, Host);
@@ -757,20 +778,25 @@ private void RescanSelectedHosts()
_rescanRemovedRows = [.. Results.Where(x => rescannedIPAddresses.Contains(x.IPAddress))];
_rescanPortsOpen = PortsOpen;
_rescanPortsClosed = PortsClosed;
+ _rescanPortsTimedOut = PortsTimedOut;
ReplaceResults(rescannedIPAddresses, []);
var rescanPortCount = ipAddresses.Count * _lastPorts.Length;
- // The other hosts keep their counters: without the open (all shown) and closed ports of the rescanned hosts
+ // The other hosts keep their counters: without the open (all shown), closed and timed out ports of the
+ // rescanned hosts
var removedPortsOpen = _rescanRemovedRows.Count(x => x.State == PortState.Open);
- var removedPortsClosed = ipAddresses.Sum(x => _portsClosedPerHost.GetValueOrDefault(x));
+ var removedPortsClosed = ipAddresses.Sum(x => _portsNotOpenPerHost.GetValueOrDefault(x).closed);
+ var removedPortsTimedOut = ipAddresses.Sum(x => _portsNotOpenPerHost.GetValueOrDefault(x).timedOut);
_rescanPortsToScan = PortsToScan;
_rescanPortsScanned = PortsScanned;
_rescanBasePortsOpen = Math.Max(0, PortsOpen - removedPortsOpen);
_rescanBasePortsClosed = Math.Max(0, PortsClosed - removedPortsClosed);
- _rescanBasePortsScanned = Math.Max(0, PortsScanned - removedPortsOpen - removedPortsClosed);
+ _rescanBasePortsTimedOut = Math.Max(0, PortsTimedOut - removedPortsTimedOut);
+ _rescanBasePortsScanned = Math.Max(0,
+ PortsScanned - removedPortsOpen - removedPortsClosed - removedPortsTimedOut);
_isRescan = true;
IsStatusMessageDisplayed = false;
@@ -788,6 +814,7 @@ private void RescanSelectedHosts()
Volatile.Write(ref _latestPortsScanned, 0);
Volatile.Write(ref _latestPortsOpen, _rescanBasePortsOpen);
Volatile.Write(ref _latestPortsClosed, _rescanBasePortsClosed);
+ Volatile.Write(ref _latestPortsTimedOut, _rescanBasePortsTimedOut);
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = new CancellationTokenSource();
@@ -1045,11 +1072,13 @@ private void StoreProgress(PortScanner portScanner)
Volatile.Write(ref _latestPortsOpen, (isRescan ? _rescanBasePortsOpen : 0) + portScanner.PortsOpen);
Volatile.Write(ref _latestPortsClosed, (isRescan ? _rescanBasePortsClosed : 0) + portScanner.PortsClosed);
+ Volatile.Write(ref _latestPortsTimedOut,
+ (isRescan ? _rescanBasePortsTimedOut : 0) + portScanner.PortsTimedOut);
}
///
/// Pushes the latest buffered progress value into ,
- /// and . Always called on the UI thread -
+ /// , and . Always called on the UI thread -
/// same calling contexts as .
///
private void FlushProgress()
@@ -1057,6 +1086,7 @@ private void FlushProgress()
PortsScanned = Volatile.Read(ref _latestPortsScanned);
PortsOpen = Volatile.Read(ref _latestPortsOpen);
PortsClosed = Volatile.Read(ref _latestPortsClosed);
+ PortsTimedOut = Volatile.Read(ref _latestPortsTimedOut);
}
///
@@ -1090,20 +1120,21 @@ private void ScanComplete(object sender, ScanCompleteArgs e)
// rescan started, only some ports were scanned again)
var isRescanDiscarded = _isRescan && (e.IsCanceled || e.Exception != null);
- // Closed ports per host of the shown result (of a rescan: replace the ones of the rescanned hosts)
- if (sender is PortScanner closedPortsScanner && !isRescanDiscarded)
+ // Closed and timed out ports per host of the shown result (of a rescan: replace the ones of the rescanned
+ // hosts)
+ if (sender is PortScanner finishedPortScanner && !isRescanDiscarded)
{
if (_isRescan)
{
foreach (var ipAddress in _rescanIPAddresses)
- _portsClosedPerHost.Remove(ipAddress);
+ _portsNotOpenPerHost.Remove(ipAddress);
}
- foreach (var (ipAddress, count) in closedPortsScanner.GetPortsClosedPerHost())
- _portsClosedPerHost[ipAddress] = count;
+ foreach (var (ipAddress, count) in finishedPortScanner.GetClosedAndTimedOutPortsPerHost())
+ _portsNotOpenPerHost[ipAddress] = count;
}
- // Rescan: back to the progress of the last scan (open / closed include the rescan)
+ // Rescan: back to the progress of the last scan (open / closed / timed out include the rescan)
if (_isRescan)
{
_isRescan = false;
@@ -1114,6 +1145,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e)
PortsOpen = _rescanPortsOpen;
PortsClosed = _rescanPortsClosed;
+ PortsTimedOut = _rescanPortsTimedOut;
PortsScanned = _rescanPortsScanned;
}
else
diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml
index 399c94cd10..65e401cf55 100644
--- a/Source/NETworkManager/Views/PortScannerView.xaml
+++ b/Source/NETworkManager/Views/PortScannerView.xaml
@@ -251,6 +251,16 @@
+
+
+
+
+
+ **IP address** / **Host
### Result
-The results are grouped by host (one group per IP address, the header shows the hostname and the IP address) and sorted by IP address and port. The number of open and closed ports is shown next to the **Result** header. A port is **open** if the connection was established, **closed** if the host refused the connection and **timed out** if there was no answer within the [timeout](#timeout-ms) (e.g. blocked by a firewall). Only open ports are shown, unless [Show closed ports](#show-closed-ports) is enabled. The **Hostname** column is shown if [Resolve hostname](#resolve-hostname) was enabled when the scan was started.
+The results are grouped by host (one group per IP address, the header shows the hostname and the IP address) and sorted by IP address and port. The number of open, closed and timed out ports is shown next to the **Result** header. A port is **open** if the connection was established, **closed** if the host refused the connection and **timed out** if there was no answer within the [timeout](#timeout-ms) (e.g. blocked by a firewall). Only open ports are shown, unless [Show closed ports](#show-closed-ports) is enabled (closed and timed out ports). The **Hostname** column is shown if [Resolve hostname](#resolve-hostname) was enabled when the scan was started.
-The search box right of the **Result** header filters the results by IP address, hostname, port, protocol, service, description and status, also while the scan is running. **Export...** > all exports the filtered results; the open / closed counters always count all ports.
+The search box right of the **Result** header filters the results by IP address, hostname, port, protocol, service, description and status, also while the scan is running. **Export...** > all exports the filtered results; the counters always count all ports.
### Keyboard shortcuts
@@ -177,7 +177,7 @@ You can also use the Hotkeys `F2` (`edit`) or `Del` (`delete`) on a selected por
### Show closed ports
-Show closed ports in the result list.
+Show closed and timed out ports in the result list.
**Type:** `Boolean`
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index b17eb9988d..2449aa01e4 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -94,6 +94,7 @@ Release date: **xx.xx.2026**
- New defaults: **Timeout** `4000` → `2000` ms (still allows one retransmission of a lost connection request), **Max. concurrent host threads** `5` → `4` and **Max. concurrent port threads** `256` → `128`, which puts less simultaneous load on the scanned hosts (up to 512 instead of 1280 connections at the same time). Settings that are still on the old default are updated. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Closed ports are reported immediately. Windows repeated the connection request after the host refused it and reported a closed port only after about 2 seconds, so a scan of a host that refuses most ports is much faster now (e.g. `1-1024`: about 1 second instead of about 16 seconds). The connection request is sent once. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- New **Attempts** setting (default `1`): another connection request is sent if there was no answer, e.g. on an unreliable network (Wi-Fi, VPN) or for hosts on the internet. Open and closed ports are not repeated. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
+- The counter next to the **Result** header shows timed out ports (no answer) separately: `Open · Closed · Timed out`. Before, they were counted as closed. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- The open / closed counter next to the **Result** header is translated as one text, so translations can put the number in a different position. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- **Rescan** in the context menu scans the selected host(s) again with the ports and settings of the shown scan and replaces their results. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Web Console** to the context menu of an open web port (`80`, `443`, `8080`, `8443`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
From abde6505995b40a71f624cde4edbad2ec1374393 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 16:06:39 +0200
Subject: [PATCH 080/146] Fix: Port Scanner crash in the port profiles dialog
without a profile (PS-B4)
Without a shown profile (all port profiles deleted, or the search has no
match) the selection was set to [null]. The OK button is disabled then (via
the grid's selection count), but Enter in the grid runs OKCommand directly:
the OK callback (async lambda) joined the ports of the selection -
NullReferenceException in an async void, the application crashed.
The selection is empty instead of [null], OKCommand needs a selected profile.
Co-Authored-By: Claude Opus 5.5
---
.../ViewModels/PortProfilesViewModel.cs | 26 ++++++++++++-------
Website/docs/changelog/next-release.md | 1 +
2 files changed, 18 insertions(+), 9 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs b/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs
index 06a21f8033..95ddf1161d 100644
--- a/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs
@@ -27,11 +27,12 @@ public class PortProfilesViewModel : ViewModelBase
public PortProfilesViewModel(Action okCommand, Action cancelHandler)
{
// Clear the filter on close so it doesn't leak into other views sharing this collection's default view.
+ // Only with a selected profile - also for Enter in the grid (the OK button is disabled without a selection)
OKCommand = new RelayCommand(_ =>
{
PortProfiles.Filter = null;
okCommand(this);
- });
+ }, _ => SelectedPortProfiles.Count > 0);
CancelCommand = new RelayCommand(_ =>
{
PortProfiles.Filter = null;
@@ -56,9 +57,7 @@ public PortProfilesViewModel(Action okCommand, Action -1;
};
- SelectedPortProfiles = new ArrayList {
- PortProfiles.Cast().FirstOrDefault()
- };
+ SelectedPortProfiles = CreateFirstProfileSelection();
}
#endregion
@@ -90,10 +89,7 @@ public string Search
PortProfiles.Refresh();
- SelectedPortProfiles = new ArrayList
- {
- PortProfiles.Cast().FirstOrDefault()
- };
+ SelectedPortProfiles = CreateFirstProfileSelection();
OnPropertyChanged();
}
@@ -129,7 +125,19 @@ public IList SelectedPortProfiles
/// A collection of selected .
public IEnumerable GetSelectedPortProfiles()
{
- return [.. SelectedPortProfiles.Cast()];
+ return [.. SelectedPortProfiles.OfType()];
+ }
+
+ ///
+ /// Creates the selection with the first shown profile - empty if no profile is shown (no profiles or no match for
+ /// the search), never a entry.
+ ///
+ /// Selection.
+ private ArrayList CreateFirstProfileSelection()
+ {
+ var firstProfile = PortProfiles.Cast().FirstOrDefault();
+
+ return firstProfile != null ? new ArrayList { firstProfile } : new ArrayList();
}
#endregion
}
\ No newline at end of file
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 2449aa01e4..d51e1eaed9 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -294,6 +294,7 @@ Release date: **xx.xx.2026**
- Fixed columns that couldn't be made wider than the window (by dragging or double-clicking the gripper) in the result and the port profiles (dialog, settings). The grid scrolls horizontally now, the last (or main) column still fills the remaining width. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557)
- Fixed the ports of different hosts with the same hostname (DNS `PTR`, e.g. a generic name of a provider or a server with two IP addresses) being shown in one group. The results are grouped per IP address, the group header shows the hostname and the IP address. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed **Edit** (`F2`, double-click) and **Delete** (`Del`) of a port profile in the settings being available without a selected profile (edit created a new profile). The delete confirmation shows the name of the profile. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Fixed the application crashing when `Enter` was pressed in the port profiles dialog without a shown profile (no port profiles, or the search has no match). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
**IP Geolocation**
From e9512e0cc9f81eb3bbda2bb03fb3cd79d0d73102 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 16:07:09 +0200
Subject: [PATCH 081/146] Fix: Port Scanner port profiles with own collection
views (PS-B5)
The settings and the port profiles dialog showed the port profiles through
the default view of the settings collection: every dialog / settings view
model added another sort by name to the shared view (the sort keys piled up)
and the dialog had to remove its search filter on close, so it didn't filter
the settings list. Both use an own view now (like the application list of
the main window).
Co-Authored-By: Claude Opus 5.5
---
.../ViewModels/PortProfilesViewModel.cs | 17 +++++------------
.../ViewModels/PortScannerSettingsViewModel.cs | 3 ++-
2 files changed, 7 insertions(+), 13 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs b/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs
index 95ddf1161d..bdb1b75407 100644
--- a/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs
@@ -26,20 +26,13 @@ public class PortProfilesViewModel : ViewModelBase
/// The action to execute when the Cancel command is invoked.
public PortProfilesViewModel(Action okCommand, Action cancelHandler)
{
- // Clear the filter on close so it doesn't leak into other views sharing this collection's default view.
// Only with a selected profile - also for Enter in the grid (the OK button is disabled without a selection)
- OKCommand = new RelayCommand(_ =>
- {
- PortProfiles.Filter = null;
- okCommand(this);
- }, _ => SelectedPortProfiles.Count > 0);
- CancelCommand = new RelayCommand(_ =>
- {
- PortProfiles.Filter = null;
- cancelHandler(this);
- });
+ OKCommand = new RelayCommand(_ => okCommand(this), _ => SelectedPortProfiles.Count > 0);
+ CancelCommand = new RelayCommand(_ => cancelHandler(this));
- PortProfiles = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_PortProfiles);
+ // Own view (not the default view of the collection, which the settings show too) - sort and filter only
+ // apply to this dialog
+ PortProfiles = new CollectionViewSource { Source = SettingsManager.Current.PortScanner_PortProfiles }.View;
PortProfiles.SortDescriptions.Add(
new SortDescription(nameof(PortProfileInfo.Name), ListSortDirection.Ascending));
PortProfiles.Filter = o =>
diff --git a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
index 50ecd338a9..fdd8344a6c 100644
--- a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
@@ -169,7 +169,8 @@ public PortScannerSettingsViewModel()
{
_isLoading = true;
- PortProfiles = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_PortProfiles);
+ // Own view (not the default view of the collection, which the port profiles dialog shows too)
+ PortProfiles = new CollectionViewSource { Source = SettingsManager.Current.PortScanner_PortProfiles }.View;
PortProfiles.SortDescriptions.Add(
new SortDescription(nameof(PortProfileInfo.Name), ListSortDirection.Ascending));
From ae57512915ab34e4d701ca76e3023a229f1e1fc5 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 16:22:34 +0200
Subject: [PATCH 082/146] Feature: Port Scanner setting to adjust the threads
automatically (PS-P1, D-PS1)
Replaces the scaling of 3ae408df9, which made "Max. concurrent host threads"
a minimum (up to 128 hosts also with 1 host thread set, a hidden limit).
New setting "Adjust threads automatically" (default on, help icon): up to
512 connections at the same time (the old defaults 4 x 128), at most 128
hosts and 128 ports per host - 4 hosts for 1-1024, 128 hosts for one port
(/24 x 3389: ~4 s instead of ~2 min). Disabled, Max. concurrent host /
port threads are used exactly (fields disabled while it is on); host threads
up to 256 (was 10), host threads x port threads at most 8,192 connections
(half of the local ports, like the IP Scanner maximum 256 x 32) - the maximum
of one field follows the other, help icon explains it.
Update: customized threads (not 4 / 128) disable the new setting, so they
still apply. The PTR lookup of a host starts with its first port probe, so
the lookups of many hosts follow the ramp-up instead of starting at once.
Co-Authored-By: Claude Opus 5.5
---
.../Resources/Strings.Designer.cs | 27 +++++++++
.../Resources/Strings.resx | 9 +++
.../Network/PortScanner.cs | 56 ++++++++++++-------
.../Network/PortScannerOptions.cs | 10 +++-
.../GlobalStaticConfiguration.cs | 5 ++
.../NETworkManager.Settings/SettingsInfo.cs | 13 +++++
.../SettingsManager.cs | 10 ++++
.../PortScannerSettingsViewModel.cs | 37 ++++++++++++
.../ViewModels/PortScannerViewModel.cs | 1 +
.../Views/PortScannerSettingsView.xaml | 43 +++++++++++---
Website/docs/application/port-scanner.md | 24 ++++++--
Website/docs/changelog/next-release.md | 2 +-
12 files changed, 201 insertions(+), 36 deletions(-)
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index 288c4e09fa..405a49fa26 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -780,6 +780,15 @@ public static string AdjustScreenAutomatically {
}
}
+ ///
+ /// Looks up a localized string similar to Adjust threads automatically.
+ ///
+ public static string AdjustThreadsAutomatically {
+ get {
+ return ResourceManager.GetString("AdjustThreadsAutomatically", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Admin (console) session.
///
@@ -5671,6 +5680,15 @@ public static string Help {
}
}
+ ///
+ /// Looks up a localized string similar to Adjusts the number of hosts and ports scanned at the same time to the scan: up to 512 connections at the same time, at most 128 hosts and 128 ports per host (e.g. 4 hosts with 1-1024, 128 hosts with one port). Disable it to set the threads yourself, e.g. to slow down a scan..
+ ///
+ public static string HelpMessage_AdjustThreadsAutomatically {
+ get {
+ return ResourceManager.GetString("HelpMessage_AdjustThreadsAutomatically", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to If you enable this option, the default PowerShell console settings in the registry under HKCU:\Console are overridden so that the PowerShell console window matches the application theme. This is a global system setting that may affect the appearance of the normal PowerShell window.
///
@@ -5867,6 +5885,15 @@ public static string HelpMessage_PasswordNotDisplayedCanBeOverwritten {
}
}
+ ///
+ /// Looks up a localized string similar to Host threads × port threads can be at most 8,192 connections at the same time (every connection uses a local port, this is half of them). Many connections at the same time may be blocked by a firewall or overload small routers..
+ ///
+ public static string HelpMessage_PortScannerMaxThreads {
+ get {
+ return ResourceManager.GetString("HelpMessage_PortScannerMaxThreads", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The following placeholder can be used:.
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index 144434ec19..bce09faa81 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -5095,4 +5095,13 @@ Do you want to start the scan?
Only IPv4 addresses are scanned for NetBIOS, NetBIOS doesn't exist over IPv6.
+
+ Adjust threads automatically
+
+
+ Adjusts the number of hosts and ports scanned at the same time to the scan: up to 512 connections at the same time, at most 128 hosts and 128 ports per host (e.g. 4 hosts with 1-1024, 128 hosts with one port). Disable it to set the threads yourself, e.g. to slow down a scan.
+
+
+ Host threads × port threads can be at most 8,192 connections at the same time (every connection uses a local port, this is half of them). Many connections at the same time may be blocked by a firewall or overload small routers.
+
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs
index 853b52e9b6..607a3aea33 100644
--- a/Source/NETworkManager.Models/Network/PortScanner.cs
+++ b/Source/NETworkManager.Models/Network/PortScanner.cs
@@ -33,11 +33,21 @@ public PortScanner(PortScannerOptions options)
private static readonly TimeSpan MaxPortStartInterval = TimeSpan.FromMilliseconds(1);
///
- /// Maximum number of hosts scanned at the same time if hosts have fewer ports than port threads (see
- /// ) - also the number of concurrent DNS (PTR) lookups, like the default of the
- /// IP Scanner.
+ /// Threads adjusted automatically (see ): connections at the same time (the old
+ /// defaults 4 hosts x 128 ports).
///
- private const int MaxHostThreadsForFewPorts = 128;
+ private const int AutoMaxConnections = 512;
+
+ ///
+ /// Threads adjusted automatically: hosts at the same time - also the number of concurrent DNS (PTR) lookups,
+ /// like the default of the IP Scanner.
+ ///
+ private const int AutoMaxHostThreads = 128;
+
+ ///
+ /// Threads adjusted automatically: ports of one host at the same time.
+ ///
+ private const int AutoMaxPortThreads = 128;
private int _progressValue;
private readonly InterlockedCounter _portsOpen = new();
@@ -133,7 +143,7 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
try
{
var portCount = ports.Count();
- var maxHostThreads = GetMaxHostThreads(portCount);
+ var (maxHostThreads, maxPortThreads) = GetThreads(portCount);
var hostParallelOptions = new ParallelOptions
{
@@ -144,7 +154,7 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
var portParallelOptions = new ParallelOptions
{
CancellationToken = cancellationToken,
- MaxDegreeOfParallelism = _options.MaxPortThreads
+ MaxDegreeOfParallelism = maxPortThreads
};
// Spread the start of the port probes over time instead of starting all at once
@@ -152,17 +162,18 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
? Math.Min(hostCount, maxHostThreads)
: maxHostThreads;
- var portConcurrency = hostConcurrency * Math.Min(portCount, _options.MaxPortThreads);
+ var portConcurrency = hostConcurrency * Math.Min(portCount, maxPortThreads);
var portStartScheduler = new StartScheduler(StartScheduler.GetInterval(MaxPortStartInterval,
TimeSpan.FromMilliseconds(_options.Timeout * _options.Attempts), portConcurrency), portConcurrency);
await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) =>
{
- // Resolve Hostname (PTR) while the ports are scanned
- var hostnameTask = _options.ResolveHostname
+ // Resolve Hostname (PTR) while the ports are scanned. Started with the first port probe of the
+ // host, so the lookups of many hosts are spread over time like the probes instead of all at once
+ var hostnameTask = new Lazy>(() => _options.ResolveHostname
? DNSClient.GetInstance().ResolvePtrOrEmptyAsync(host.ipAddress, hostCt)
- : Task.FromResult(string.Empty);
+ : Task.FromResult(string.Empty));
// Check each port
await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
@@ -170,13 +181,15 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
// Spread the start of the port probes over time instead of starting all at once
await portStartScheduler.WaitAsync(portCt).ConfigureAwait(false);
+ _ = hostnameTask.Value;
+
var portState = await PortProbe.ProbeAsync(host.ipAddress, port, _options.Timeout,
_options.Attempts, portCt)
.ConfigureAwait(false);
// Every result needs the hostname (results are grouped by it) - usually resolved
// long before the first port is finished
- var hostname = await hostnameTask.ConfigureAwait(false);
+ var hostname = await hostnameTask.Value.ConfigureAwait(false);
// Count open/closed/timed out ports unconditionally, since ShowAllResults (below)
// may prevent closed ports from ever reaching PortScanned
@@ -226,19 +239,22 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
}
///
- /// Gets the number of hosts scanned at the same time. A host with fewer ports than port threads leaves port
- /// threads unused (e.g. one port on a /24: 4 hosts = 4 connections at a time) - then more hosts are scanned at the
- /// same time, within the same number of connections (host threads x port threads), at most
- /// (never less than the host threads).
+ /// Gets the number of hosts and the number of ports per host scanned at the same time: the settings, or -
+ /// adjusted automatically - connections spread over as many hosts as needed
+ /// (at most ) with up to ports each. E.g.
+ /// 4 hosts x 128 ports for 1-1024, 128 hosts x 1 port for one port (with the fixed 4 host threads only 4
+ /// connections at a time).
///
/// Number of ports per host.
- /// Number of hosts scanned at the same time.
- private int GetMaxHostThreads(int portCount)
+ /// Hosts and ports per host at the same time.
+ private (int hostThreads, int portThreads) GetThreads(int portCount)
{
- var portsAtOnce = Math.Max(1, Math.Min(portCount, _options.MaxPortThreads));
+ if (!_options.AutoAdjustThreads)
+ return (_options.MaxHostThreads, _options.MaxPortThreads);
+
+ var portsAtOnce = Math.Max(1, Math.Min(portCount, AutoMaxPortThreads));
- return Math.Max(_options.MaxHostThreads,
- Math.Min(MaxHostThreadsForFewPorts, _options.MaxHostThreads * _options.MaxPortThreads / portsAtOnce));
+ return (Math.Clamp(AutoMaxConnections / portsAtOnce, 1, AutoMaxHostThreads), AutoMaxPortThreads);
}
private void IncreaseProgress()
diff --git a/Source/NETworkManager.Models/Network/PortScannerOptions.cs b/Source/NETworkManager.Models/Network/PortScannerOptions.cs
index 46c3035a3e..3c531daee8 100644
--- a/Source/NETworkManager.Models/Network/PortScannerOptions.cs
+++ b/Source/NETworkManager.Models/Network/PortScannerOptions.cs
@@ -6,12 +6,18 @@
public class PortScannerOptions
{
///
- /// Max concurrent threads for the host scan.
+ /// Adjust the number of hosts and ports scanned at the same time to the scan (see
+ /// ) instead of using and .
+ ///
+ public required bool AutoAdjustThreads { get; init; }
+
+ ///
+ /// Max concurrent threads for the host scan (if the threads are not adjusted automatically).
///
public required int MaxHostThreads { get; init; }
///
- /// Max concurrent threads for the port scan of each host.
+ /// Max concurrent threads for the port scan of each host (if the threads are not adjusted automatically).
///
public required int MaxPortThreads { get; init; }
diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs
index 8cbed972f3..cdc201aa27 100644
--- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs
+++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs
@@ -157,8 +157,13 @@ public static class GlobalStaticConfiguration
public static long IPScanner_MaxHostCount => 16777216;
// Application: Port Scanner
+ public static bool PortScanner_AutoAdjustThreads => true;
public static int PortScanner_MaxHostThreads => 4;
public static int PortScanner_MaxPortThreads => 128;
+
+ // Max. connections at the same time if the threads are set manually (host threads x port threads): half of the
+ // local (ephemeral) ports of Windows (16,384), like the maximum of the IP Scanner (256 x 32)
+ public static int PortScanner_MaxConnections => 8192;
public static int PortScanner_Timeout => 2000;
public static int PortScanner_Attempts => 1;
public static ExportFileType PortScanner_ExportFileType => ExportFileType.Csv;
diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs
index 677c64d9b4..52e88573b8 100644
--- a/Source/NETworkManager.Settings/SettingsInfo.cs
+++ b/Source/NETworkManager.Settings/SettingsInfo.cs
@@ -1541,6 +1541,19 @@ public int PortScanner_Attempts
}
} = GlobalStaticConfiguration.PortScanner_Attempts;
+ public bool PortScanner_AutoAdjustThreads
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = GlobalStaticConfiguration.PortScanner_AutoAdjustThreads;
+
public int PortScanner_MaxHostThreads
{
get;
diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs
index f7a5395208..5bc128b866 100644
--- a/Source/NETworkManager.Settings/SettingsManager.cs
+++ b/Source/NETworkManager.Settings/SettingsManager.cs
@@ -792,6 +792,16 @@ private static void UpgradeToLatest(Version version)
Current.PortScanner_MaxPortThreads = GlobalStaticConfiguration.PortScanner_MaxPortThreads;
}
+ // Port Scanner - new setting "Adjust threads automatically" (default on). Customized threads (not the
+ // defaults after the update above) are kept: automatic off, so they still apply.
+ if (Current.PortScanner_AutoAdjustThreads &&
+ (Current.PortScanner_MaxHostThreads != GlobalStaticConfiguration.PortScanner_MaxHostThreads ||
+ Current.PortScanner_MaxPortThreads != GlobalStaticConfiguration.PortScanner_MaxPortThreads))
+ {
+ Log.Info("Disable \"PortScanner_AutoAdjustThreads\", the threads were customized...");
+ Current.PortScanner_AutoAdjustThreads = false;
+ }
+
if (Current.IPScanner_ICMPTimeout == 4000)
{
Log.Info($"Update \"IPScanner_ICMPTimeout\" from 4000 to {GlobalStaticConfiguration.IPScanner_ICMPTimeout}...");
diff --git a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
index fdd8344a6c..c575c70f4d 100644
--- a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
@@ -120,6 +120,26 @@ public bool ResolveHostname
}
}
+ ///
+ /// Gets or sets whether the number of hosts and ports scanned at the same time is adjusted to the scan (instead of
+ /// and ).
+ ///
+ public bool AutoAdjustThreads
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.PortScanner_AutoAdjustThreads = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets or sets the maximum number of threads for scanning hosts.
///
@@ -136,9 +156,17 @@ public int MaxHostThreads
field = value;
OnPropertyChanged();
+ OnPropertyChanged(nameof(MaxPortThreadsMaximum));
}
}
+ ///
+ /// Gets the maximum of : 256, but host threads x port threads at most
+ /// (each connection uses a local port).
+ ///
+ public int MaxHostThreadsMaximum =>
+ Math.Min(256, GlobalStaticConfiguration.PortScanner_MaxConnections / Math.Max(1, MaxPortThreads));
+
///
/// Gets or sets the maximum number of threads for scanning ports.
///
@@ -155,9 +183,17 @@ public int MaxPortThreads
field = value;
OnPropertyChanged();
+ OnPropertyChanged(nameof(MaxHostThreadsMaximum));
}
}
+ ///
+ /// Gets the maximum of : 512, but host threads x port threads at most
+ /// (each connection uses a local port).
+ ///
+ public int MaxPortThreadsMaximum =>
+ Math.Min(512, GlobalStaticConfiguration.PortScanner_MaxConnections / Math.Max(1, MaxHostThreads));
+
#endregion
#region Constructor, load settings
@@ -187,6 +223,7 @@ private void LoadSettings()
Timeout = SettingsManager.Current.PortScanner_Timeout;
Attempts = SettingsManager.Current.PortScanner_Attempts;
ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname;
+ AutoAdjustThreads = SettingsManager.Current.PortScanner_AutoAdjustThreads;
MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads;
MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads;
}
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index f072654c84..31bffb751a 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -884,6 +884,7 @@ private static PortScannerOptions CreateScanOptions()
{
return new PortScannerOptions
{
+ AutoAdjustThreads = SettingsManager.Current.PortScanner_AutoAdjustThreads,
MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads,
MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads,
Timeout = SettingsManager.Current.PortScanner_Timeout,
diff --git a/Source/NETworkManager/Views/PortScannerSettingsView.xaml b/Source/NETworkManager/Views/PortScannerSettingsView.xaml
index 279ebbce02..9393368833 100644
--- a/Source/NETworkManager/Views/PortScannerSettingsView.xaml
+++ b/Source/NETworkManager/Views/PortScannerSettingsView.xaml
@@ -8,8 +8,12 @@
xmlns:viewModels="clr-namespace:NETworkManager.ViewModels"
xmlns:network="clr-namespace:NETworkManager.Models.Network;assembly=NETworkManager.Models"
xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
- xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
+ xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
+ xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters"
mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:PortScannerSettingsViewModel}">
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md
index 4f760a1838..b0e12e8e07 100644
--- a/Website/docs/application/port-scanner.md
+++ b/Website/docs/application/port-scanner.md
@@ -219,25 +219,37 @@ Resolve the hostname for given IP addresses.
**Default:** `Enabled`
-### Max. concurrent host threads
+### Adjust threads automatically
-Number of hosts that are scanned at the same time. A thread is not an operating system thread, but one host whose ports are scanned concurrently.
+Adjusts the number of hosts and ports that are scanned at the same time to the scan: up to `512` connections at the same time, at most `128` hosts and `128` ports per host. For example, `1-1024` scans 4 hosts with 128 ports at the same time, a single port (e.g. `3389` on a `/24`) 128 hosts. [Max. concurrent host threads](#max-concurrent-host-threads) and [Max. concurrent port threads](#max-concurrent-port-threads) are only used if this option is disabled, e.g. to slow down a scan.
-If the hosts have fewer ports than [Max. concurrent port threads](#max-concurrent-port-threads) (e.g. one port on a `/24`), more hosts are scanned at the same time (up to `128`), so that the same number of connections is used (host threads × port threads, with the default settings 4 × 128 = 512). Example: a `/24` with one port takes about as long as two timeouts instead of 64.
+**Type:** `Boolean`
-**Type:** `Integer` [Min `1`, Max `10`]
+**Default:** `Enabled`
+
+:::note
+
+When updating from an older version, this option is disabled if the threads were changed, so the changed values still apply.
+
+:::
+
+### Max. concurrent host threads
+
+Maximum number of hosts that are scanned at the same time, if [Adjust threads automatically](#adjust-threads-automatically) is disabled. A thread is not an operating system thread, but one host whose ports are scanned concurrently.
+
+**Type:** `Integer` [Min `1`, Max `256`]
**Default:** `4`
:::warning
-The scan opens up to host threads × [Max. concurrent port threads](#max-concurrent-port-threads) TCP connections at the same time (with the default settings up to 4 × 128 = 512). Too many simultaneous requests may be detected as a port scan and blocked or rate-limited by a firewall (local, on the network or on the scanned host) and can overload small routers. If ports are missing in the result (shown as timed out) or the network becomes slow during the scan, reduce the number of threads. This will increase the scan time.
+The scan opens up to host threads × [Max. concurrent port threads](#max-concurrent-port-threads) TCP connections at the same time (with the default settings up to 4 × 128 = 512), at most `8,192` (every connection uses a local port, this is half of them) - the maximum of one setting depends on the other (e.g. 256 host threads allow 32 port threads). Too many simultaneous requests may be detected as a port scan and blocked or rate-limited by a firewall (local, on the network or on the scanned host) and can overload small routers. If ports are missing in the result (shown as timed out) or the network becomes slow during the scan, reduce the number of threads. This will increase the scan time.
:::
### Max. concurrent port threads
-Maximum number of ports that are scanned at the same time for each host.
+Maximum number of ports that are scanned at the same time for each host, if [Adjust threads automatically](#adjust-threads-automatically) is disabled.
**Type:** `Integer` [Min `1`, Max `512`]
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index d51e1eaed9..3706943a69 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -100,7 +100,7 @@ Release date: **xx.xx.2026**
- Added **Web Console** to the context menu of an open web port (`80`, `443`, `8080`, `8443`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added a search box above the result to filter the ports by IP address, hostname, port, protocol, service, description and status, also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- **Copy** > **IP address** / **Hostname** / **Port** / **IP address:Port** is available if multiple results are selected (one value per line, in the order shown). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
-- Hosts with fewer ports than **Max. concurrent port threads** are scanned faster: more hosts are scanned at the same time (up to `128`), with the same number of connections (host threads × port threads). For example, a `/24` with one port (e.g. `3389`) takes about 4 seconds instead of about 2 minutes. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- New setting **Adjust threads automatically** (default on): the number of hosts and ports scanned at the same time is adjusted to the scan (up to `512` connections, at most `128` hosts and `128` ports per host). A scan with few ports is much faster, e.g. a `/24` with one port (e.g. `3389`) takes about 4 seconds instead of about 2 minutes; `1-1024` is unchanged. **Max. concurrent host threads** (now up to `256`) and **Max. concurrent port threads** are used if it is disabled, with at most `8,192` connections at the same time. It is disabled when updating if the threads were changed. The hostname (DNS `PTR`) of a host is resolved when its first port is scanned, so the lookups are spread over time like the ports. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added a new **Well-known ports** (`1-1024`) default port profile. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564)
- The export runs in the background, so the window stays responsive while a large result is exported, and uses the order shown in the result (sorting, grouping) instead of the order in which the ports were scanned. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Ports are started one after another (every 1 ms, shorter with short timeouts and many threads) instead of all at the same moment. This avoids bursts of hundreds of connection requests within a few milliseconds and makes the progress smoother. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
From b344ced7c520e431cc747f2d60f932c283a0b1a0 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 16:27:14 +0200
Subject: [PATCH 083/146] Feature: Port Scanner context menu opens the host in
other tools (PS-C1, PS-M1)
The context menu of a result only had Rescan, Web Console, Copy and Export.
Added the host tools of the IP Scanner (same items, icons and order): Ping
Monitor, Traceroute, DNS lookup... (IP address / hostname), RDAP... (IP
address / registered domain of the hostname), Remote Desktop, PowerShell,
PuTTY, TigerVNC and SNMP. Target: the hostname (always a DNS name from the
PTR record in the Port Scanner), otherwise the IP address; Web Console uses
the same.
Co-Authored-By: Claude Opus 5.5
---
.../ViewModels/PortScannerViewModel.cs | 73 ++++++++-
.../NETworkManager/Views/PortScannerView.xaml | 141 ++++++++++++++++++
Website/docs/application/port-scanner.md | 5 +
Website/docs/changelog/next-release.md | 1 +
4 files changed, 214 insertions(+), 6 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 31bffb751a..d78635ff95 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -538,6 +538,72 @@ private void ScanAction()
///
public ICommand RescanSelectedHostsCommand => new RelayCommand(_ => RescanSelectedHosts());
+ ///
+ /// Gets the command to open the host of the selected result in another application.
+ ///
+ public ICommand RedirectDataToApplicationCommand => new RelayCommand(RedirectDataToApplicationAction);
+
+ private void RedirectDataToApplicationAction(object name)
+ {
+ if (name is not ApplicationName applicationName)
+ return;
+
+ NavigationManager.Open(new ApplicationRequest(applicationName) { Target = GetSelectedHost() });
+ }
+
+ ///
+ /// Gets the host of the selected result for other tools: the hostname (always a DNS hostname from the PTR
+ /// record), otherwise the IP address.
+ ///
+ /// Hostname or IP address.
+ private string GetSelectedHost()
+ {
+ return !string.IsNullOrEmpty(SelectedResult.Hostname)
+ ? SelectedResult.Hostname
+ : SelectedResult.IPAddress.ToString();
+ }
+
+ ///
+ /// Gets the command to perform a DNS lookup for the IP address of the selected result.
+ ///
+ public ICommand PerformDNSLookupIPAddressCommand => new RelayCommand(_ => PerformDNSLookupIPAddressAction());
+
+ private void PerformDNSLookupIPAddressAction()
+ {
+ NavigationManager.Open(new DNSLookupRequest { Target = SelectedResult.IPAddress.ToString() });
+ }
+
+ ///
+ /// Gets the command to perform a DNS lookup for the hostname of the selected result.
+ ///
+ public ICommand PerformDNSLookupHostnameCommand => new RelayCommand(_ => PerformDNSLookupHostnameAction());
+
+ private void PerformDNSLookupHostnameAction()
+ {
+ NavigationManager.Open(new DNSLookupRequest { Target = SelectedResult.Hostname });
+ }
+
+ ///
+ /// Gets the command to query the registration data of the IP address of the selected result via RDAP.
+ ///
+ public ICommand PerformRDAPQueryIPAddressCommand => new RelayCommand(_ => PerformRDAPQueryIPAddressAction());
+
+ private void PerformRDAPQueryIPAddressAction()
+ {
+ NavigationManager.Open(new RDAPRequest { Target = SelectedResult.IPAddress.ToString() });
+ }
+
+ ///
+ /// Gets the command to query the registration data of the hostname (its registered domain) of the selected
+ /// result via RDAP.
+ ///
+ public ICommand PerformRDAPQueryHostnameCommand => new RelayCommand(_ => PerformRDAPQueryHostnameAction());
+
+ private void PerformRDAPQueryHostnameAction()
+ {
+ NavigationManager.Open(new RDAPRequest { Target = SelectedResult.Hostname });
+ }
+
///
/// Gets the command to open the selected open web port in the Web Console.
///
@@ -548,14 +614,9 @@ private void OpenWebConsoleAction()
if (!IsWebConsoleAvailable)
return;
- // The hostname of the Port Scanner is always a DNS hostname (PTR)
- var host = !string.IsNullOrEmpty(SelectedResult.Hostname)
- ? SelectedResult.Hostname
- : SelectedResult.IPAddress.ToString();
-
NavigationManager.Open(new ApplicationRequest(ApplicationName.WebConsole)
{
- Target = WebConsoleUrlHelper.CreateUrl(host, SelectedResult.Port)
+ Target = WebConsoleUrlHelper.CreateUrl(GetSelectedHost(), SelectedResult.Port)
});
}
diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml
index 65e401cf55..6308ff9888 100644
--- a/Source/NETworkManager/Views/PortScannerView.xaml
+++ b/Source/NETworkManager/Views/PortScannerView.xaml
@@ -13,10 +13,13 @@
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls"
xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
+ xmlns:models="clr-namespace:NETworkManager.Models;assembly=NETworkManager.Models"
Loaded="UserControl_Loaded"
mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:PortScannerViewModel}">
+
+
@@ -295,6 +298,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -306,6 +436,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md
index b0e12e8e07..cb84217a85 100644
--- a/Website/docs/application/port-scanner.md
+++ b/Website/docs/application/port-scanner.md
@@ -67,7 +67,12 @@ A scan of more than `65,536` ports (hosts × ports, e.g. 64 hosts with `1-1024`)
| Action | Description |
| --------------- | -------------------------------------------------------------------------------------------------------------- |
| **Rescan** | Scans the selected host(s) again with the ports and settings of the shown scan and replaces their results. A canceled rescan keeps the previous results. Not available while a scan is running |
+| **Ping Monitor** / **Traceroute** | Opens the host (hostname if resolved, otherwise the IP address) in the tool |
+| **DNS lookup...** > **IP address** / **Hostname** | Looks up the IP address (`PTR`) or the hostname in [DNS Lookup](./dns-lookup) |
+| **RDAP...** > **IP address** / **Hostname** | Queries the registration data of the IP address or the registered domain of the hostname in [RDAP](./rdap) |
+| **Remote Desktop** / **PowerShell** / **PuTTY** / **TigerVNC** | Opens the host (hostname if resolved, otherwise the IP address) in the tool (with its default port) |
| **Web Console** | Opens the open web port (`80`, `443`, `8080`, `8443`) in the [Web Console](./web-console) (https for 443 / 8443) |
+| **SNMP** | Opens the host (hostname if resolved, otherwise the IP address) in [SNMP](./snmp) |
| **Copy** | Copies the selected information to the clipboard |
| **Export...** | Exports the selected or all results to a file |
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 3706943a69..00921cb1db 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -98,6 +98,7 @@ Release date: **xx.xx.2026**
- The open / closed counter next to the **Result** header is translated as one text, so translations can put the number in a different position. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- **Rescan** in the context menu scans the selected host(s) again with the ports and settings of the shown scan and replaces their results. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Web Console** to the context menu of an open web port (`80`, `443`, `8080`, `8443`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Added **Ping Monitor**, **Traceroute**, **DNS lookup...**, **RDAP...**, **Remote Desktop**, **PowerShell**, **PuTTY**, **TigerVNC** and **SNMP** to the context menu to open the host (hostname, otherwise the IP address) in the tool, like in the **IP Scanner**. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added a search box above the result to filter the ports by IP address, hostname, port, protocol, service, description and status, also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- **Copy** > **IP address** / **Hostname** / **Port** / **IP address:Port** is available if multiple results are selected (one value per line, in the order shown). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- New setting **Adjust threads automatically** (default on): the number of hosts and ports scanned at the same time is adjusted to the scan (up to `512` connections, at most `128` hosts and `128` ports per host). A scan with few ports is much faster, e.g. a `/24` with one port (e.g. `3389`) takes about 4 seconds instead of about 2 minutes; `1-1024` is unchanged. **Max. concurrent host threads** (now up to `256`) and **Max. concurrent port threads** are used if it is disabled, with at most `8,192` connections at the same time. It is disabled when updating if the threads were changed. The hostname (DNS `PTR`) of a host is resolved when its first port is scanned, so the lookups are spread over time like the ports. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
From 1e407f2fa518255e836cd7ce73c9143fdb71f012 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 16:27:50 +0200
Subject: [PATCH 084/146] Feature: Port Scanner Add profile from a result
(PS-M2)
Context menu "Add profile..." like the IP Scanner: the profile dialog opens
with the host (IP address), the hostname as name (single-label names
without the trailing dot) and the Port Scanner enabled with the ports of
the shown scan as entered (e.g. "22; 80; 443").
Co-Authored-By: Claude Opus 5.5
---
.../ViewModels/PortScannerViewModel.cs | 32 ++++++++++++++++++-
.../NETworkManager/Views/PortScannerView.xaml | 11 +++++++
Website/docs/application/port-scanner.md | 1 +
Website/docs/changelog/next-release.md | 1 +
4 files changed, 44 insertions(+), 1 deletion(-)
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index d78635ff95..5ad1262e6d 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -7,6 +7,7 @@
using NETworkManager.Models;
using NETworkManager.Models.Export;
using NETworkManager.Models.Network;
+using NETworkManager.Profiles;
using NETworkManager.Settings;
using NETworkManager.Utilities;
using NETworkManager.Views;
@@ -28,7 +29,7 @@ namespace NETworkManager.ViewModels;
///
/// ViewModel for the Port Scanner feature.
///
-public class PortScannerViewModel : ViewModelBase
+public class PortScannerViewModel : ViewModelBase, IProfileManagerMinimal
{
#region Variables
private static readonly ILog Log = LogManager.GetLogger(typeof(PortScannerViewModel));
@@ -63,6 +64,9 @@ public class PortScannerViewModel : ViewModelBase
// Ports of the last scan, to rescan hosts with the same ports (and ScanOptions)
private int[] _lastPorts;
+ // Ports of the last scan as entered (e.g. "22; 80; 443"), for a profile created from a result
+ private string _lastPortsInput;
+
// Rescan of shown hosts (see RescanSelectedHosts): their rows are removed and added again by the rescan. The
// counters are the ones of the other hosts (base) plus the ones of the rescan; the progress of the last scan is
// restored afterwards.
@@ -604,6 +608,31 @@ private void PerformRDAPQueryHostnameAction()
NavigationManager.Open(new RDAPRequest { Target = SelectedResult.Hostname });
}
+ ///
+ /// Gets the command to add the host of the selected result as a profile (with the ports of the shown scan).
+ ///
+ public ICommand AddProfileSelectedHostCommand => new RelayCommand(_ => AddProfileSelectedHostAction());
+
+ private async void AddProfileSelectedHostAction()
+ {
+ ProfileInfo profileInfo = new()
+ {
+ // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.")
+ Name = string.IsNullOrEmpty(SelectedResult.Hostname)
+ ? SelectedResult.IPAddress.ToString()
+ : SelectedResult.Hostname.TrimEnd('.'),
+ Host = SelectedResult.IPAddress.ToString(),
+
+ // Additional data
+ PortScanner_Ports = _lastPortsInput
+ };
+
+ var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive);
+
+ await ProfileDialogManager.ShowAddProfileDialog(window, this, profileInfo, null,
+ ApplicationName.PortScanner);
+ }
+
///
/// Gets the command to open the selected open web port in the Web Console.
///
@@ -815,6 +844,7 @@ private async Task Start()
ScanOptions = CreateScanOptions();
_lastPorts = ports;
+ _lastPortsInput = Ports;
StartScanner(hosts.hosts, ports);
}
diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml
index 6308ff9888..4db56b182c 100644
--- a/Source/NETworkManager/Views/PortScannerView.xaml
+++ b/Source/NETworkManager/Views/PortScannerView.xaml
@@ -448,6 +448,17 @@
+
+
+
+
+
+
+
+
+
+ **IP address** / **Hostname** / **Port** / **IP address:Port** is available if multiple results are selected (one value per line, in the order shown). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- New setting **Adjust threads automatically** (default on): the number of hosts and ports scanned at the same time is adjusted to the scan (up to `512` connections, at most `128` hosts and `128` ports per host). A scan with few ports is much faster, e.g. a `/24` with one port (e.g. `3389`) takes about 4 seconds instead of about 2 minutes; `1-1024` is unchanged. **Max. concurrent host threads** (now up to `256`) and **Max. concurrent port threads** are used if it is disabled, with at most `8,192` connections at the same time. It is disabled when updating if the threads were changed. The hostname (DNS `PTR`) of a host is resolved when its first port is scanned, so the lookups are spread over time like the ports. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
From 3ca4af094b6808a1f84def7d002edd11218eff34 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 16:29:32 +0200
Subject: [PATCH 085/146] Feature: Port Scanner group header with the number of
open ports (PS-M3)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Each host group shows its open ports next to the name ("server (10.0.0.1) ·
3 Open", translated as one text like the counters), also while the scan runs
- useful with many hosts collapsed.
The view model counts the open ports per IP address when a row is added
(OpenPortsPerHost, also for the rows replaced by a rescan), the converter
only looks the host up: WPF raises ItemCount for every added row, counting
the rows of the group there would take rows x rows (1-65535 with closed
ports shown).
Co-Authored-By: Claude Opus 5.5
---
.../PortScannerGroupOpenPortsConverter.cs | 44 +++++++++++++++++++
.../ViewModels/PortScannerViewModel.cs | 40 ++++++++++++++++-
.../NETworkManager/Views/PortScannerView.xaml | 19 ++++++++
Website/docs/application/port-scanner.md | 2 +-
Website/docs/changelog/next-release.md | 1 +
5 files changed, 103 insertions(+), 3 deletions(-)
create mode 100644 Source/NETworkManager.Converters/PortScannerGroupOpenPortsConverter.cs
diff --git a/Source/NETworkManager.Converters/PortScannerGroupOpenPortsConverter.cs b/Source/NETworkManager.Converters/PortScannerGroupOpenPortsConverter.cs
new file mode 100644
index 0000000000..fb8b02401c
--- /dev/null
+++ b/Source/NETworkManager.Converters/PortScannerGroupOpenPortsConverter.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Windows.Data;
+using NETworkManager.Models.Network;
+
+namespace NETworkManager.Converters;
+
+///
+/// Formats the number of open ports of a Port Scanner group (one host) with the format string bound as the fourth
+/// value (e.g. Strings.XOpen = "{0} Open", so the word order can be translated).
+///
+///
+/// Bound as a with the as the first value, its
+/// as the second and the open ports per IP address (counted by the view
+/// model when a row is added) as the third. The second value isn't used directly, it only forces re-evaluation when a
+/// port is added to (or removed from) the group while the scan is running. The rows of the group are not counted
+/// here: that would take rows x rows for a large group (e.g. 1-65535 with closed ports shown).
+///
+public sealed class PortScannerGroupOpenPortsConverter : IMultiValueConverter
+{
+ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (values.Length < 3 || values[0] is not CollectionViewGroup group ||
+ values[2] is not IReadOnlyDictionary openPortsPerHost)
+ return string.Empty;
+
+ // One host per group
+ var open = group.Items.FirstOrDefault() is PortScannerPortInfo info
+ ? openPortsPerHost.GetValueOrDefault(info.IPAddress)
+ : 0;
+
+ var format = values.Length > 3 ? values[3] as string ?? "{0}" : "{0}";
+
+ return string.Format(culture, format, open);
+ }
+
+ public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 5ad1262e6d..2aa32ad1fe 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -182,6 +182,13 @@ public ObservableRangeCollection Results
}
} = [];
+ ///
+ /// Gets the number of open ports per host (IP address) of the shown result, for the group headers. Counted when a
+ /// result is added (see ) - counting the rows of a group on every added row would take
+ /// rows x rows with closed ports shown. Changed on the UI thread only, before the row is added.
+ ///
+ public Dictionary OpenPortsPerHost { get; } = [];
+
///
/// Gets the collection view for the scan results.
///
@@ -775,6 +782,7 @@ private async Task Start()
_resultsBuffer.Clear();
}
+ OpenPortsPerHost.Clear();
Results.Clear();
_portsNotOpenPerHost.Clear();
@@ -925,8 +933,14 @@ private void ReplaceResults(HashSet ipAddresses, IReadOnlyCollection<
{
var oldRows = Results.Where(x => ipAddresses.Contains(x.IPAddress)).ToList();
+ foreach (var ipAddress in ipAddresses)
+ OpenPortsPerHost.Remove(ipAddress);
+
if ((long)(oldRows.Count + rows.Count) * Results.Count > MaxRowChangesOneByOne)
{
+ foreach (var row in rows)
+ CountOpenPort(row);
+
Results.ReplaceAll(Results.Where(x => !ipAddresses.Contains(x.IPAddress)).Concat(rows));
return;
@@ -936,7 +950,29 @@ private void ReplaceResults(HashSet ipAddresses, IReadOnlyCollection<
Results.Remove(row);
foreach (var row in rows)
- Results.Add(row);
+ AddResult(row);
+ }
+
+ ///
+ /// Adds a result, counted in before it is added (the group header is updated when
+ /// the row is added).
+ ///
+ /// Result to add.
+ private void AddResult(PortScannerPortInfo result)
+ {
+ CountOpenPort(result);
+
+ Results.Add(result);
+ }
+
+ ///
+ /// Counts the result in if the port is open.
+ ///
+ /// Result to count.
+ private void CountOpenPort(PortScannerPortInfo result)
+ {
+ if (result.State == PortState.Open)
+ OpenPortsPerHost[result.IPAddress] = OpenPortsPerHost.GetValueOrDefault(result.IPAddress) + 1;
}
///
@@ -1140,7 +1176,7 @@ private void FlushResultsBuffer()
}
foreach (var item in itemsToAdd)
- Results.Add(item);
+ AddResult(item);
}
private void ProgressChanged(object sender, ProgressChangedArgs e)
diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml
index 4db56b182c..d12f483d05 100644
--- a/Source/NETworkManager/Views/PortScannerView.xaml
+++ b/Source/NETworkManager/Views/PortScannerView.xaml
@@ -18,6 +18,7 @@
mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:PortScannerViewModel}">
+
@@ -587,6 +588,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md
index beae0f9334..2cc65e9900 100644
--- a/Website/docs/application/port-scanner.md
+++ b/Website/docs/application/port-scanner.md
@@ -81,7 +81,7 @@ If multiple results are selected, **Rescan**, **Copy** > **IP address** / **Host
### Result
-The results are grouped by host (one group per IP address, the header shows the hostname and the IP address) and sorted by IP address and port. The number of open, closed and timed out ports is shown next to the **Result** header. A port is **open** if the connection was established, **closed** if the host refused the connection and **timed out** if there was no answer within the [timeout](#timeout-ms) (e.g. blocked by a firewall). Only open ports are shown, unless [Show closed ports](#show-closed-ports) is enabled (closed and timed out ports). The **Hostname** column is shown if [Resolve hostname](#resolve-hostname) was enabled when the scan was started.
+The results are grouped by host (one group per IP address, the header shows the hostname, the IP address and the number of open ports) and sorted by IP address and port. The number of open, closed and timed out ports is shown next to the **Result** header. A port is **open** if the connection was established, **closed** if the host refused the connection and **timed out** if there was no answer within the [timeout](#timeout-ms) (e.g. blocked by a firewall). Only open ports are shown, unless [Show closed ports](#show-closed-ports) is enabled (closed and timed out ports). The **Hostname** column is shown if [Resolve hostname](#resolve-hostname) was enabled when the scan was started.
The search box right of the **Result** header filters the results by IP address, hostname, port, protocol, service, description and status, also while the scan is running. **Export...** > all exports the filtered results; the counters always count all ports.
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 0358b8d14e..1a015ed5c9 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -99,6 +99,7 @@ Release date: **xx.xx.2026**
- **Rescan** in the context menu scans the selected host(s) again with the ports and settings of the shown scan and replaces their results. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Web Console** to the context menu of an open web port (`80`, `443`, `8080`, `8443`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Ping Monitor**, **Traceroute**, **DNS lookup...**, **RDAP...**, **Remote Desktop**, **PowerShell**, **PuTTY**, **TigerVNC** and **SNMP** to the context menu to open the host (hostname, otherwise the IP address) in the tool, like in the **IP Scanner**. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- The header of each host shows the number of open ports (e.g. `server (10.0.0.1) · 3 Open`), also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Add profile...** to the context menu: creates a profile with the host (IP address), the hostname as name and the ports of the shown scan. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added a search box above the result to filter the ports by IP address, hostname, port, protocol, service, description and status, also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- **Copy** > **IP address** / **Hostname** / **Port** / **IP address:Port** is available if multiple results are selected (one value per line, in the order shown). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
From 26e336b27f48b6178905b7da9fd013404ea3435e Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 16:58:06 +0200
Subject: [PATCH 086/146] Feature: Port Scanner port profiles by name in the
ports input (D1)
The ports input accepts names of port profiles, also several and together
with ports (e.g. "Webserver; Database; 8080", case-insensitive): every ";"
entry is a port, a port range or a port profile, resolved when the scan
starts (PortProfile.TryResolvePorts, unit tests). Validated by
PortsOrPortProfilesValidator in the tool and the run command, which passes
the input as entered ("portscan host webserver;database;22") - its port
profile suggestions also complete the entry after the last ";".
Port profile names must be unique, must not contain ";" and must not be a
port or port range (PortProfileNameValidator, IsNameUnique); the update
replaces ";" in existing names with ",".
PortRangeHelper.IsValidPortOrPortRange: one port or range, "1-2-3" is
invalid (was accepted and scanned 1-2, PS-B6 for this input).
Co-Authored-By: Claude Opus 5.5
---
.../Resources/Strings.Designer.cs | 18 +++++
.../Resources/Strings.resx | 6 ++
.../Network/PortProfile.cs | 77 ++++++++++++++++++-
.../SettingsManager.cs | 8 ++
.../Network/PortProfileTests.cs | 54 +++++++++++++
.../Utilities/PortRangeHelperTests.cs | 15 ++++
.../PortRangeHelper.cs | 18 +++++
.../PortProfileNameValidator.cs | 20 +++++
.../PortsOrPortProfilesValidator.cs | 21 +++++
Source/NETworkManager/RunCommandManager.cs | 16 +---
.../ViewModels/PortProfileViewModel.cs | 12 +++
.../ViewModels/PortScannerViewModel.cs | 15 +++-
.../ViewModels/RunCommandViewModel.cs | 25 ++++--
.../Views/PortProfileChildWindow.xaml | 47 +++++------
.../NETworkManager/Views/PortScannerView.xaml | 2 +-
Website/docs/application/port-scanner.md | 3 +-
Website/docs/changelog/next-release.md | 3 +-
Website/docs/run-command.md | 4 +-
18 files changed, 308 insertions(+), 56 deletions(-)
create mode 100644 Source/NETworkManager.Tests/Network/PortProfileTests.cs
create mode 100644 Source/NETworkManager.Validators/PortProfileNameValidator.cs
create mode 100644 Source/NETworkManager.Validators/PortsOrPortProfilesValidator.cs
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index 405a49fa26..2b9deb6eee 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -4499,6 +4499,15 @@ public static string EnterValidPortOrPortRange {
}
}
+ ///
+ /// Looks up a localized string similar to Enter valid ports, port ranges (1 - 65535) and/or port profiles!.
+ ///
+ public static string EnterValidPortsOrPortProfiles {
+ get {
+ return ResourceManager.GetString("EnterValidPortsOrPortProfiles", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Enter a valid domain, IP address, AS number or entity handle!.
///
@@ -4616,6 +4625,15 @@ public static string ErrorMessage_NameIsAlreadyUsed {
}
}
+ ///
+ /// Looks up a localized string similar to The name must not contain ";" and must not be a port or port range..
+ ///
+ public static string ErrorMessage_PortProfileNameInvalid {
+ get {
+ return ResourceManager.GetString("ErrorMessage_PortProfileNameInvalid", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The settings on this page contain errors. Correct them to be able to save..
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index bce09faa81..bc6728e74b 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -5104,4 +5104,10 @@ Do you want to start the scan?
Host threads × port threads can be at most 8,192 connections at the same time (every connection uses a local port, this is half of them). Many connections at the same time may be blocked by a firewall or overload small routers.
+
+ Enter valid ports, port ranges (1 - 65535) and/or port profiles!
+
+
+ The name must not contain ";" and must not be a port or port range.
+
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Network/PortProfile.cs b/Source/NETworkManager.Models/Network/PortProfile.cs
index f124078cef..074e5b0e88 100644
--- a/Source/NETworkManager.Models/Network/PortProfile.cs
+++ b/Source/NETworkManager.Models/Network/PortProfile.cs
@@ -1,9 +1,84 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NETworkManager.Utilities;
namespace NETworkManager.Models.Network;
public static class PortProfile
{
+ ///
+ /// Separator of the entries of a ports input (e.g. "Webserver; 8080").
+ ///
+ private const char EntrySeparator = ';';
+
+ ///
+ /// Finds a port profile by its name (case-insensitive, leading / trailing spaces are ignored).
+ ///
+ /// Port profiles.
+ /// Name of the port profile.
+ /// Port profile, or null if there is none with the name.
+ public static PortProfileInfo FindByName(IEnumerable portProfiles, string name)
+ {
+ var trimmedName = name?.Trim();
+
+ return string.IsNullOrEmpty(trimmedName)
+ ? null
+ : portProfiles.FirstOrDefault(x =>
+ string.Equals(x.Name?.Trim(), trimmedName, StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Checks if a name can be used for a port profile: the name can be entered in a ports input, so it must not
+ /// contain the entry separator ";" and must not be a port or port range (e.g. "80" would be read as port).
+ ///
+ /// Name to check.
+ /// True if the name can be used.
+ public static bool IsValidName(string name)
+ {
+ return !string.IsNullOrWhiteSpace(name) && !name.Contains(EntrySeparator) &&
+ !PortRangeHelper.IsValidPortOrPortRange(name);
+ }
+
+ ///
+ /// Resolves a ports input into ports. Every entry (separated by ";") is a port (e.g. "80"), a port range (e.g.
+ /// "1-1024") or the name of a port profile (e.g. "Webserver"), which is replaced by its ports - e.g.
+ /// "Webserver; 8080" -> "80; 443; 8080".
+ ///
+ /// Ports input.
+ /// Port profiles that can be used by their name.
+ /// Ports (ports and port ranges separated by ";"), null if the input is not valid.
+ /// True if every entry is a port, port range or port profile.
+ public static bool TryResolvePorts(string input, IEnumerable portProfiles, out string ports)
+ {
+ ports = null;
+
+ if (string.IsNullOrWhiteSpace(input))
+ return false;
+
+ List resolvedEntries = [];
+
+ foreach (var entry in input.Split(EntrySeparator))
+ {
+ if (PortRangeHelper.IsValidPortOrPortRange(entry))
+ {
+ resolvedEntries.Add(entry.Replace(" ", ""));
+ continue;
+ }
+
+ var portProfile = FindByName(portProfiles, entry);
+
+ if (portProfile == null)
+ return false;
+
+ resolvedEntries.Add(portProfile.Ports);
+ }
+
+ ports = string.Join("; ", resolvedEntries);
+
+ return true;
+ }
+
public static List GetDefaultList()
{
return new List
diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs
index 5bc128b866..341e654b26 100644
--- a/Source/NETworkManager.Settings/SettingsManager.cs
+++ b/Source/NETworkManager.Settings/SettingsManager.cs
@@ -792,6 +792,14 @@ private static void UpgradeToLatest(Version version)
Current.PortScanner_MaxPortThreads = GlobalStaticConfiguration.PortScanner_MaxPortThreads;
}
+ // Port Scanner - port profiles can be entered by name in the ports input (e.g. "Webserver; 8080"), where ";"
+ // separates the entries: replace it in the names
+ foreach (var portProfile in Current.PortScanner_PortProfiles.Where(x => x.Name?.Contains(';') == true))
+ {
+ Log.Info($"Replace \";\" in the name of the port profile \"{portProfile.Name}\"...");
+ portProfile.Name = portProfile.Name.Replace(';', ',');
+ }
+
// Port Scanner - new setting "Adjust threads automatically" (default on). Customized threads (not the
// defaults after the update above) are kept: automatic off, so they still apply.
if (Current.PortScanner_AutoAdjustThreads &&
diff --git a/Source/NETworkManager.Tests/Network/PortProfileTests.cs b/Source/NETworkManager.Tests/Network/PortProfileTests.cs
new file mode 100644
index 0000000000..3769325c8f
--- /dev/null
+++ b/Source/NETworkManager.Tests/Network/PortProfileTests.cs
@@ -0,0 +1,54 @@
+using NETworkManager.Models.Network;
+using Xunit;
+
+namespace NETworkManager.Tests.Network;
+
+public class PortProfileTests
+{
+ private static readonly PortProfileInfo[] PortProfiles =
+ [
+ new("Webserver", "80; 443"),
+ new("Well-known ports", "1-1024"),
+ new("Database", "1433-1434; 3306")
+ ];
+
+ [Theory]
+ [InlineData("80", "80")]
+ [InlineData("80; 443; 1-1024", "80; 443; 1-1024")]
+ [InlineData("Webserver", "80; 443")]
+ [InlineData("webserver", "80; 443")]
+ [InlineData(" Webserver ;8080", "80; 443; 8080")]
+ [InlineData("Webserver;Database;22", "80; 443; 1433-1434; 3306; 22")]
+ [InlineData("Well-known ports; 8080", "1-1024; 8080")]
+ public void TryResolvePorts_Valid(string input, string expectedPorts)
+ {
+ Assert.True(PortProfile.TryResolvePorts(input, PortProfiles, out var ports));
+ Assert.Equal(expectedPorts, ports);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("Unknown")]
+ [InlineData("Webserver; Unknown")]
+ [InlineData("80;")]
+ [InlineData("70000")]
+ public void TryResolvePorts_Invalid(string input)
+ {
+ Assert.False(PortProfile.TryResolvePorts(input, PortProfiles, out var ports));
+ Assert.Null(ports);
+ }
+
+ [Theory]
+ [InlineData("Webserver", true)]
+ [InlineData("Well-known ports", true)]
+ [InlineData("Web; Mail", false)]
+ [InlineData("80", false)]
+ [InlineData("1-1024", false)]
+ [InlineData(" ", false)]
+ [InlineData(null, false)]
+ public void IsValidName(string name, bool isValid)
+ {
+ Assert.Equal(isValid, PortProfile.IsValidName(name));
+ }
+}
diff --git a/Source/NETworkManager.Tests/Utilities/PortRangeHelperTests.cs b/Source/NETworkManager.Tests/Utilities/PortRangeHelperTests.cs
index 5d841aaeec..99a62b34c7 100644
--- a/Source/NETworkManager.Tests/Utilities/PortRangeHelperTests.cs
+++ b/Source/NETworkManager.Tests/Utilities/PortRangeHelperTests.cs
@@ -28,4 +28,19 @@ public void IsValidPortRange(int startPort, int endPort, bool isValid)
{
Assert.Equal(isValid, PortRangeHelper.IsValidPortRange(startPort, endPort));
}
+
+ [Theory]
+ [InlineData("80", true)]
+ [InlineData(" 80 ", true)]
+ [InlineData("1-1024", true)]
+ [InlineData("1 - 1024", true)]
+ [InlineData("0", false)]
+ [InlineData("1024-1", false)]
+ [InlineData("1-2-3", false)]
+ [InlineData("", false)]
+ [InlineData("Webserver", false)]
+ public void IsValidPortOrPortRange(string value, bool isValid)
+ {
+ Assert.Equal(isValid, PortRangeHelper.IsValidPortOrPortRange(value));
+ }
}
diff --git a/Source/NETworkManager.Utilities/PortRangeHelper.cs b/Source/NETworkManager.Utilities/PortRangeHelper.cs
index f53bf0de43..42c6666ec7 100644
--- a/Source/NETworkManager.Utilities/PortRangeHelper.cs
+++ b/Source/NETworkManager.Utilities/PortRangeHelper.cs
@@ -27,6 +27,24 @@ public static bool IsValidPortRange(int startPort, int endPort)
return IsValidPort(startPort) && IsValidPort(endPort) && startPort < endPort;
}
+ ///
+ /// Checks if the value is a port (e.g. "80") or a port range (e.g. "1-1024"), spaces are ignored.
+ ///
+ /// Value to check.
+ /// True if it is a valid port or port range.
+ public static bool IsValidPortOrPortRange(string value)
+ {
+ var parts = value.Replace(" ", "").Split('-');
+
+ return parts.Length switch
+ {
+ 1 => int.TryParse(parts[0], out var port) && IsValidPort(port),
+ 2 => int.TryParse(parts[0], out var startPort) && int.TryParse(parts[1], out var endPort) &&
+ IsValidPortRange(startPort, endPort),
+ _ => false
+ };
+ }
+
public static Task ConvertPortRangeToIntArrayAsync(string portRange)
{
return Task.Run(() => ConvertPortRangeToIntArray(portRange));
diff --git a/Source/NETworkManager.Validators/PortProfileNameValidator.cs b/Source/NETworkManager.Validators/PortProfileNameValidator.cs
new file mode 100644
index 0000000000..6831001206
--- /dev/null
+++ b/Source/NETworkManager.Validators/PortProfileNameValidator.cs
@@ -0,0 +1,20 @@
+using System.Globalization;
+using System.Windows.Controls;
+using NETworkManager.Localization.Resources;
+using NETworkManager.Models.Network;
+
+namespace NETworkManager.Validators;
+
+///
+/// Validates the name of a port profile, see (the name can be entered in the
+/// ports input of the Port Scanner).
+///
+public class PortProfileNameValidator : ValidationRule
+{
+ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
+ {
+ return PortProfile.IsValidName(value as string)
+ ? ValidationResult.ValidResult
+ : new ValidationResult(false, Strings.ErrorMessage_PortProfileNameInvalid);
+ }
+}
diff --git a/Source/NETworkManager.Validators/PortsOrPortProfilesValidator.cs b/Source/NETworkManager.Validators/PortsOrPortProfilesValidator.cs
new file mode 100644
index 0000000000..9b026b2c23
--- /dev/null
+++ b/Source/NETworkManager.Validators/PortsOrPortProfilesValidator.cs
@@ -0,0 +1,21 @@
+using System.Globalization;
+using System.Windows.Controls;
+using NETworkManager.Localization.Resources;
+using NETworkManager.Models.Network;
+using NETworkManager.Settings;
+
+namespace NETworkManager.Validators;
+
+///
+/// Validates a ports input of the Port Scanner: ports, port ranges and names of port profiles separated by ";"
+/// (e.g. "Webserver; 8080"), see .
+///
+public class PortsOrPortProfilesValidator : ValidationRule
+{
+ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
+ {
+ return PortProfile.TryResolvePorts(value as string, SettingsManager.Current.PortScanner_PortProfiles, out _)
+ ? ValidationResult.ValidResult
+ : new ValidationResult(false, Strings.EnterValidPortsOrPortProfiles);
+ }
+}
diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs
index f591928c21..1c80e0a49a 100644
--- a/Source/NETworkManager/RunCommandManager.cs
+++ b/Source/NETworkManager/RunCommandManager.cs
@@ -120,14 +120,6 @@ public static IEnumerable GetPortProfiles()
return SettingsManager.Current.PortScanner_PortProfiles;
}
- ///
- /// Gets the port profile with the name (case-insensitive), or null if there is none.
- ///
- private static PortProfileInfo GetPortProfile(string name)
- {
- return GetPortProfiles().FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase));
- }
-
///
/// Gets the translated name of the application (or settings) of a command.
///
@@ -337,7 +329,7 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT
{
ValidationRule argumentValidator = result.Command.Argument switch
{
- RunCommandArgument.Ports => new PortRangeValidator(),
+ RunCommandArgument.Ports => new PortsOrPortProfilesValidator(),
RunCommandArgument.Broadcast => new IPv4AddressValidator(),
_ => null
};
@@ -345,8 +337,6 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT
error = result.Command.Argument switch
{
RunCommandArgument.DNSServer => ValidateDNSServer(result.Argument),
- // A port profile (e.g. "Webserver") or ports
- RunCommandArgument.Ports when GetPortProfile(result.Argument) != null => null,
RunCommandArgument.Subnet => ValidateSubnetArgument(result.Target, result.Argument),
_ => Validate(argumentValidator, result.Argument)
};
@@ -437,11 +427,11 @@ private static ApplicationRequest CreateRequest(RunCommandParseResult result)
// Without a type, the DNS lookup uses PTR for IP addresses and its selected type for hosts.
QueryType = result.Type != null ? Enum.Parse(result.Type, true) : null
},
- // A port profile is used with its ports.
+ // Ports and / or port profiles (e.g. "webserver;8080"), resolved by the Port Scanner (shown as entered).
ApplicationName.PortScanner => new PortScannerRequest
{
Target = result.Target,
- Ports = GetPortProfile(result.Argument)?.Ports ?? result.Argument
+ Ports = result.Argument
},
ApplicationName.WakeOnLAN => new WakeOnLANRequest { Target = result.Target, Broadcast = result.Argument },
ApplicationName.RDAP => new RDAPRequest { Target = result.Target, QueryType = GetRDAPQueryType(result.Type) },
diff --git a/Source/NETworkManager/ViewModels/PortProfileViewModel.cs b/Source/NETworkManager/ViewModels/PortProfileViewModel.cs
index 996da98ec8..86e386fb72 100644
--- a/Source/NETworkManager/ViewModels/PortProfileViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortProfileViewModel.cs
@@ -1,6 +1,9 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Windows.Input;
using NETworkManager.Models.Network;
+using NETworkManager.Settings;
using NETworkManager.Utilities;
namespace NETworkManager.ViewModels;
@@ -34,6 +37,10 @@ public PortProfileViewModel(Action saveCommand, Action !ReferenceEquals(x, info))
+ .Select(x => x.Name)];
+
Name = _info.Name;
// List to string
@@ -45,6 +52,11 @@ public PortProfileViewModel(Action saveCommand, Action
+ /// Gets the names of the other port profiles (the name must be unique).
+ ///
+ public List UsedNames { get; }
+
///
/// Gets the command to save the profile.
///
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 2aa32ad1fe..465f0f6e6d 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -721,6 +721,17 @@ private async Task OpenPortProfileSelection()
private async Task Start()
{
+ // Port profiles in the input (e.g. "Webserver; 8080") are replaced by their ports. The input is validated, but
+ // a port profile may have been renamed or deleted since.
+ if (!PortProfile.TryResolvePorts(Ports, SettingsManager.Current.PortScanner_PortProfiles,
+ out var resolvedPorts))
+ {
+ StatusMessage = Strings.EnterValidPortsOrPortProfiles;
+ IsStatusMessageDisplayed = true;
+
+ return;
+ }
+
// Every port is kept in memory - ask before a large scan and refuse a too large one
// (e.g. 10.0.0.0/8 with 1-65535) instead of running out of memory
long hostCount;
@@ -729,7 +740,7 @@ private async Task Start()
try
{
hostCount = HostRangeHelper.GetHostCount(HostRangeHelper.CreateListFromInput(Host));
- portsPerHost = PortRangeHelper.ConvertPortRangeToIntArray(Ports).Length;
+ portsPerHost = PortRangeHelper.ConvertPortRangeToIntArray(resolvedPorts).Length;
}
catch (Exception ex)
{
@@ -818,7 +829,7 @@ private async Task Start()
SettingsManager.Current.Network_ResolveHostnamePreferIPv4, keepAllHostnames: false,
_cancellationTokenSource.Token);
- ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync(Ports);
+ ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync(resolvedPorts);
}
catch (OperationCanceledException)
{
diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
index 101ddec981..b97c55d58c 100644
--- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
+++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
@@ -1131,13 +1131,14 @@ private IEnumerable GetTypeSuggestions()
///
/// Port profiles of the Port Scanner (e.g. "Webserver") that contain the typed word, if no ports were entered
- /// yet.
+ /// yet. The ports can be a list of ports and port profiles (e.g. "webserver;database;22"), the last entry is
+ /// completed.
///
private IEnumerable GetPortProfileSuggestions()
{
return GetArgumentValueSuggestions(RunCommandArgument.Ports,
RunCommandManager.GetPortProfiles().Select(x => (x.Name, x.Ports)), RunCommandSuggestionKind.PortProfile,
- Strings.PortProfiles);
+ Strings.PortProfiles, isList: true);
}
///
@@ -1159,24 +1160,34 @@ private IEnumerable GetDNSServerSuggestions()
/// Names and descriptions of the values.
/// Kind of the suggestions.
/// Group of the suggestions.
+ ///
+ /// The argument can be a list separated by ";" (e.g. "webserver;database;22"): the entry after the last ";" is
+ /// completed. Names with spaces only as the first entry (a value in quotes can't be part of a list).
+ ///
private IEnumerable GetArgumentValueSuggestions(RunCommandArgument argument,
- IEnumerable<(string Name, string Description)> values, RunCommandSuggestionKind kind, string group)
+ IEnumerable<(string Name, string Description)> values, RunCommandSuggestionKind kind, string group,
+ bool isList = false)
{
if (_result.Command.Argument != argument ||
_result.Argument != null && !string.Equals(_result.Argument, _result.CurrentWord))
return [];
+ // Entries before the one that is being typed (e.g. "webserver;" of "webserver;dat")
+ var previousEntries = isList ? _result.CurrentWord[..(_result.CurrentWord.LastIndexOf(';') + 1)] : string.Empty;
+ var entry = _result.CurrentWord[previousEntries.Length..];
+
return values
- .Where(x => x.Name.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase))
+ .Where(x => x.Name.Contains(entry, StringComparison.OrdinalIgnoreCase) &&
+ (previousEntries.Length == 0 || !x.Name.Contains(' ')))
.Select(x => new RunCommandSuggestion
{
Kind = kind,
Application = _result.Command.Application,
Title = x.Name,
Description = x.Description,
- CompletionText =
- _result.InputBeforeCurrentWord + (x.Name.Contains(' ') ? $"\"{x.Name}\"" : x.Name) + " ",
- Highlight = _result.CurrentWord,
+ CompletionText = _result.InputBeforeCurrentWord + previousEntries +
+ (x.Name.Contains(' ') ? $"\"{x.Name}\"" : x.Name) + " ",
+ Highlight = entry,
Group = group
});
}
diff --git a/Source/NETworkManager/Views/PortProfileChildWindow.xaml b/Source/NETworkManager/Views/PortProfileChildWindow.xaml
index 2d17d6c0ec..3f7e1375e5 100644
--- a/Source/NETworkManager/Views/PortProfileChildWindow.xaml
+++ b/Source/NETworkManager/Views/PortProfileChildWindow.xaml
@@ -9,6 +9,7 @@
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
xmlns:simpleChildWindow="clr-namespace:MahApps.Metro.SimpleChildWindow;assembly=MahApps.Metro.SimpleChildWindow"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
+ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
CloseButtonCommand="{Binding Path=CancelCommand}"
Style="{StaticResource DefaultChildWindow}"
Loaded="ChildWindow_OnLoaded"
@@ -37,34 +38,24 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md
index 2cc65e9900..5dbb5bd78d 100644
--- a/Website/docs/application/port-scanner.md
+++ b/Website/docs/application/port-scanner.md
@@ -43,6 +43,7 @@ TCP (Transmission Control Protocol) is a connection-oriented transport-layer pro
| --------------------- | -------------------------------------------------- |
| `1-1024` | All ports in a given range (`1`, `2`, ..., `1024`) |
| `80; 443; 8080; 8443` | Multiple ports like (`80`, `443`, `8080`, `8443`) |
+| `Webserver; 8080` | Ports of a [port profile](#port-profiles) (`80`, `443`) and other ports (`8080`) |
:::note
@@ -152,7 +153,7 @@ See also the [Port Scanner](./port-scanner) example inputs for more information
### Port profiles
-List of common TCP ports to scan for.
+List of common TCP ports to scan for. A port profile can be entered by its name in the ports input (case-insensitive, also several and together with ports, e.g. `Webserver; Database; 8080`), so the name must be unique, must not contain `;` and must not be a port or port range (e.g. `80`).
**Type:** `List`
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 1a015ed5c9..1e6bfe8d78 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -30,7 +30,7 @@ Release date: **xx.xx.2026**
- The [**Run command**](../run-command.md) (`Ctrl+Shift+P`) now runs a feature directly, e.g. `ping 10.0.0.1`, `dns borntoberoot.net 1.1.1.1 aaaa`, `rdap 1.10 asn` or `rdp @server-01` (profile). It opens in a dialog instead of a flyout, with short aliases (e.g. `ping`, `dns`, `rdp`, `ssh`, `geo`, `wol`), suggestions for recent commands, profiles of the feature and types (`Up`/`Down` to select, `Tab` to complete, `Enter` to run, `Esc` to close), and validates the input like the feature does. Only visible features are offered. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644)
- New run command targets for the **Subnet Calculator** (`subnet 10.0.0.0/24`, `subnet 10.0.0.0/16 /24` for subnetting, `subnet 10.0.0.0/24 10.0.1.0/24` for the wide subnet) and the **Lookup** (`lookup oui 00:11:22`, `lookup port 22; ssh`). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
- New global hotkey to bring the window to the foreground and open the run command (default `Ctrl+Alt+P`, disabled by default), see [Settings > HotKeys](../settings/hotkeys.md). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
-- Run command: the **Port Scanner** needs ports, which can also be the name of a port profile (e.g. `portscan 10.0.0.1 webserver`, suggested after the host). Own history size for the run command (default 20, [Settings > General](../settings/general.md#number-of-stored-entries-run-command)). `Tab` also completes profiles and port profiles, `PageUp`/`PageDown` move the selection by 5 suggestions. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+- Run command: the **Port Scanner** needs ports, which can also be names of port profiles, also several and together with ports (e.g. `portscan 10.0.0.1 webserver` or `portscan 10.0.0.1 webserver;database;22`, suggested after the host and after a `;`). Own history size for the run command (default 20, [Settings > General](../settings/general.md#number-of-stored-entries-run-command)). `Tab` also completes profiles and port profiles, `PageUp`/`PageDown` move the selection by 5 suggestions. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
- Run command dialog redesigned: the icon of the recognized feature, the syntax with the part being typed highlighted, the values as they were understood (e.g. `Hosts: borntoberoot.net`, `Type: AAAA`), validation while typing, grouped suggestions with the typed text highlighted, examples and key hints (can be hidden in the settings). The dialog uses more space in a large window. The title bar shows a **Run command...** box in the center (like the command center of VS Code) instead of the icon. Type only a target (e.g. `10.0.0.1`, `server-01`, `00:11:22:33:44:55`, `10.0.0.0/24`) to get the commands that accept it, recent commands are also suggested while typing (e.g. a host); optionally, a target in the clipboard is suggested ([Settings > General](../settings/general.md#suggest-the-clipboard-content-as-target)). `Ctrl+Enter` runs and keeps the dialog open, `Shift+Enter` fills in the input without starting, `Shift+Del` or the remove button removes a recent command. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
**RDAP**
@@ -100,6 +100,7 @@ Release date: **xx.xx.2026**
- Added **Web Console** to the context menu of an open web port (`80`, `443`, `8080`, `8443`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Ping Monitor**, **Traceroute**, **DNS lookup...**, **RDAP...**, **Remote Desktop**, **PowerShell**, **PuTTY**, **TigerVNC** and **SNMP** to the context menu to open the host (hostname, otherwise the IP address) in the tool, like in the **IP Scanner**. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- The header of each host shows the number of open ports (e.g. `server (10.0.0.1) · 3 Open`), also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Port profiles can be entered by name in the **Ports** input, also several and together with ports (e.g. `Webserver; Database; 8080`, case-insensitive). The name of a port profile must be unique, must not contain `;` (replaced by `,` when updating) and must not be a port or port range. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Add profile...** to the context menu: creates a profile with the host (IP address), the hostname as name and the ports of the shown scan. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added a search box above the result to filter the ports by IP address, hostname, port, protocol, service, description and status, also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- **Copy** > **IP address** / **Hostname** / **Port** / **IP address:Port** is available if multiple results are selected (one value per line, in the order shown). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md
index 81f9fcfc4e..8d2ae3f006 100644
--- a/Website/docs/run-command.md
+++ b/Website/docs/run-command.md
@@ -29,7 +29,7 @@ The input is validated like the input field of the feature (e.g. a valid host or
| Command | Aliases | Arguments | Example |
| ------------------ | ------------------ | ----------------------------------- | ---------------------------------------- |
| `ipscanner` | `ipscan` | `` | `ipscan 10.0.0.0/24` |
-| `portscanner` | `portscan` | `` | `portscan 10.0.0.1 22; 80; 443` |
+| `portscanner` | `portscan` | `` | `portscan 10.0.0.1 webserver;22` |
| `pingmonitor` | `ping` | `` | `ping 10.0.0.1; 10.0.0.2` |
| `traceroute` | `trace`, `tracert` | `` | `trace borntoberoot.net` |
| `dnslookup` | `dns`, `nslookup` | ` [server] [type]` | `dns borntoberoot.net 1.1.1.1 aaaa` |
@@ -56,7 +56,7 @@ All features that accept a target also accept a profile (`@profile`), except the
- **RDAP:** The type is `domain`, `nameserver`, `tld`, `ip`, `asn` or `entity`. Without a type, it is detected from the query (see [RDAP](./application/rdap.md#example-inputs)).
- **Subnet Calculator:** A subnet (e.g. `subnet 10.0.0.0/24`) is calculated in **Calculator**. With a new subnet mask or CIDR (e.g. `/26`, `26` or `255.255.255.192`), it is split in **Subnetting**; the new mask must be between the CIDR of the subnet and `/32` (IPv4) or `/128` (IPv6). With a second subnet (e.g. `subnet 10.0.0.0/24 10.0.1.0/24`), the **Wide Subnet** is calculated (IPv4 only).
- **Lookup:** The first word after the command is the lookup: `oui` (MAC addresses or vendors, e.g. `lookup oui 00:11:22`) or `port` (ports, port ranges or services, e.g. `lookup port 22; 80-90/tcp; ssh`). The rest of the input is the query, so vendors can contain spaces without quotes (e.g. `lookup oui Cisco Systems`). Without a query (e.g. `lookup port`), the lookup is only shown.
-- **Port Scanner:** The ports are required (a new tab has none): ports and port ranges (e.g. `22; 80; 443`, `1-1024`) or the name of a port profile of the Port Scanner (case-insensitive, e.g. `portscan 10.0.0.1 webserver`; a name with spaces in quotes). The port profiles are suggested after the host.
+- **Port Scanner:** The ports are required (a new tab has none): ports and port ranges (e.g. `22; 80; 443`, `1-1024`) and / or names of port profiles of the Port Scanner (case-insensitive, e.g. `portscan 10.0.0.1 webserver` or `portscan 10.0.0.1 webserver;database;22`; a single name with spaces in quotes). The port profiles are suggested after the host, also after a `;`.
- **SNMP:** Opens a tab with the host; the query is started in the feature (as before), also with `Enter`.
- **Wake on LAN:** The magic packet is sent to the broadcast address (or the one last used in the feature). If there is no valid broadcast address yet, the MAC address is only filled in. A profile sends the magic packet with its settings.
From d403a65fcec995b6487934136ef9973a9af5a714 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:00:00 +0200
Subject: [PATCH 087/146] Feature: Port Scanner ports input with port profiles
and history (D2)
The Ports input is an editable ComboBox like the DNS server of DNS Lookup:
the list shows the port profiles (name and ports, sorted) and the history,
grouped (PortScannerPortsSuggestions, updated when the port profiles or the
history change - weak events, no leak of closed tabs / dialogs). Selecting
a port profile inserts its name, resolved when the scan starts (D1); several
port profiles can be typed.
Removed the "Select port profile" button and its dialog
(PortProfilesChildWindow, PortProfilesViewModel) - also the changelog
entries of fixes in that dialog (it doesn't exist in the release).
Co-Authored-By: Claude Opus 5.5
---
.../PortScannerPortsSuggestions.cs | 71 +++++++++
.../ViewModels/PortProfilesViewModel.cs | 136 ------------------
.../ViewModels/PortScannerViewModel.cs | 47 +-----
.../Views/PortProfilesChildWindow.xaml | 85 -----------
.../Views/PortProfilesChildWindow.xaml.cs | 43 ------
.../NETworkManager/Views/PortScannerView.xaml | 49 ++++---
Website/docs/application/port-scanner.md | 6 +-
Website/docs/changelog/next-release.md | 5 +-
8 files changed, 105 insertions(+), 337 deletions(-)
create mode 100644 Source/NETworkManager/PortScannerPortsSuggestions.cs
delete mode 100644 Source/NETworkManager/ViewModels/PortProfilesViewModel.cs
delete mode 100644 Source/NETworkManager/Views/PortProfilesChildWindow.xaml
delete mode 100644 Source/NETworkManager/Views/PortProfilesChildWindow.xaml.cs
diff --git a/Source/NETworkManager/PortScannerPortsSuggestions.cs b/Source/NETworkManager/PortScannerPortsSuggestions.cs
new file mode 100644
index 0000000000..ec70ff2c1f
--- /dev/null
+++ b/Source/NETworkManager/PortScannerPortsSuggestions.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Linq;
+using System.Windows.Data;
+using NETworkManager.Controls;
+using NETworkManager.Localization.Resources;
+using NETworkManager.Settings;
+
+namespace NETworkManager;
+
+///
+/// Entry of the ports input of the Port Scanner: a port profile (with its ports) or an entry of the history.
+///
+/// Text inserted into the input: name of the port profile or the history entry.
+/// Ports of the port profile, null for a history entry.
+/// Group in the list (port profiles, history).
+public sealed record PortScannerPortsSuggestion(string Text, string Ports, string Group)
+{
+ ///
+ /// The text, e.g. kept in an editable ComboBox when the entry is removed from the list (see
+ /// ).
+ ///
+ public override string ToString()
+ {
+ return Text;
+ }
+}
+
+///
+/// Port profiles and history of the Port Scanner for the ports input (an editable ComboBox like the DNS server of
+/// DNS Lookup): the port profiles (sorted by name) and the history, grouped. Updated when the port profiles or the
+/// history change (weak events, the list doesn't keep a closed tab or dialog alive).
+///
+public sealed class PortScannerPortsSuggestions
+{
+ private readonly ObservableRangeCollection _suggestions = [];
+
+ ///
+ /// Creates the list with the current port profiles and history.
+ ///
+ public PortScannerPortsSuggestions()
+ {
+ View = new CollectionViewSource { Source = _suggestions }.View;
+ View.GroupDescriptions.Add(new PropertyGroupDescription(nameof(PortScannerPortsSuggestion.Group)));
+
+ CollectionChangedEventManager.AddHandler(SettingsManager.Current.PortScanner_PortProfiles, OnSourceChanged);
+ CollectionChangedEventManager.AddHandler(SettingsManager.Current.PortScanner_PortHistory, OnSourceChanged);
+
+ Refresh();
+ }
+
+ ///
+ /// Gets the view of the entries, grouped (port profiles, history).
+ ///
+ public ICollectionView View { get; }
+
+ private void OnSourceChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ Refresh();
+ }
+
+ private void Refresh()
+ {
+ _suggestions.ReplaceAll(SettingsManager.Current.PortScanner_PortProfiles
+ .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(x => new PortScannerPortsSuggestion(x.Name, x.Ports, Strings.PortProfiles))
+ .Concat(SettingsManager.Current.PortScanner_PortHistory
+ .Select(x => new PortScannerPortsSuggestion(x, null, Strings.History))));
+ }
+}
diff --git a/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs b/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs
deleted file mode 100644
index bdb1b75407..0000000000
--- a/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs
+++ /dev/null
@@ -1,136 +0,0 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.ComponentModel;
-using System.Linq;
-using System.Windows.Data;
-using System.Windows.Input;
-using NETworkManager.Models.Network;
-using NETworkManager.Settings;
-using NETworkManager.Utilities;
-
-namespace NETworkManager.ViewModels;
-
-///
-/// Represents the view model for managing port profiles.
-///
-public class PortProfilesViewModel : ViewModelBase
-{
- #region Constructor
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The action to execute when the OK command is invoked.
- /// The action to execute when the Cancel command is invoked.
- public PortProfilesViewModel(Action okCommand, Action cancelHandler)
- {
- // Only with a selected profile - also for Enter in the grid (the OK button is disabled without a selection)
- OKCommand = new RelayCommand(_ => okCommand(this), _ => SelectedPortProfiles.Count > 0);
- CancelCommand = new RelayCommand(_ => cancelHandler(this));
-
- // Own view (not the default view of the collection, which the settings show too) - sort and filter only
- // apply to this dialog
- PortProfiles = new CollectionViewSource { Source = SettingsManager.Current.PortScanner_PortProfiles }.View;
- PortProfiles.SortDescriptions.Add(
- new SortDescription(nameof(PortProfileInfo.Name), ListSortDirection.Ascending));
- PortProfiles.Filter = o =>
- {
- if (string.IsNullOrEmpty(Search))
- return true;
-
- if (o is not PortProfileInfo info)
- return false;
-
- var search = Search.Trim();
-
- // Search: Name, Ports
- return info.Name.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1 ||
- info.Ports.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1;
- };
-
- SelectedPortProfiles = CreateFirstProfileSelection();
- }
-
- #endregion
-
- #region Variables
-
- ///
- /// Gets the command to confirm the selection.
- ///
- public ICommand OKCommand { get; }
-
- ///
- /// Gets the command to cancel the operation.
- ///
- public ICommand CancelCommand { get; }
-
- ///
- /// Gets or sets the search text to filter the port profiles.
- ///
- public string Search
- {
- get;
- set
- {
- if (value == field)
- return;
-
- field = value;
-
- PortProfiles.Refresh();
-
- SelectedPortProfiles = CreateFirstProfileSelection();
-
- OnPropertyChanged();
- }
- }
-
- ///
- /// Gets the collection of port profiles.
- ///
- public ICollectionView PortProfiles { get; }
-
- ///
- /// Gets or sets the list of selected port profiles.
- ///
- public IList SelectedPortProfiles
- {
- get;
- set
- {
- if (Equals(value, field))
- return;
-
- field = value;
- OnPropertyChanged();
- }
- } = new ArrayList();
-
- #endregion
-
- #region Methods
- ///
- /// Gets the selected port profiles as a typed collection.
- ///
- /// A collection of selected .
- public IEnumerable GetSelectedPortProfiles()
- {
- return [.. SelectedPortProfiles.OfType()];
- }
-
- ///
- /// Creates the selection with the first shown profile - empty if no profile is shown (no profiles or no match for
- /// the search), never a entry.
- ///
- /// Selection.
- private ArrayList CreateFirstProfileSelection()
- {
- var firstProfile = PortProfiles.Cast().FirstOrDefault();
-
- return firstProfile != null ? new ArrayList { firstProfile } : new ArrayList();
- }
- #endregion
-}
\ No newline at end of file
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 465f0f6e6d..c690e65d07 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -130,9 +130,9 @@ public string Ports
}
///
- /// Gets the collection view for the ports history.
+ /// Gets the port profiles and the ports history for the ports input (grouped).
///
- public ICollectionView PortsHistoryView { get; }
+ public ICollectionView PortsSuggestions { get; } = new PortScannerPortsSuggestions().View;
///
/// Gets or sets a value indicating whether the scan is currently running.
@@ -429,7 +429,6 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t
// Set collection view
HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_HostHistory);
- PortsHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_PortHistory);
// Columns etc. until the first scan
ScanOptions = CreateScanOptions();
@@ -512,21 +511,6 @@ public void OnClose()
#region ICommands & Actions
- public ICommand OpenPortProfileSelectionCommand =>
- new RelayCommand(_ => OpenPortProfileSelectionAction(), OpenPortProfileSelection_CanExecute);
-
- private bool OpenPortProfileSelection_CanExecute(object parameter)
- {
- return Application.Current.MainWindow != null &&
- !((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen &&
- !ConfigurationManager.Current.IsChildWindowOpen;
- }
-
- private void OpenPortProfileSelectionAction()
- {
- _ = OpenPortProfileSelection();
- }
-
public ICommand ScanCommand => new RelayCommand(_ => ScanAction(), Scan_CanExecute);
private bool Scan_CanExecute(object parameter)
@@ -692,33 +676,6 @@ private void ExportAction()
#region Methods
- private async Task OpenPortProfileSelection()
- {
- var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive);
-
- var childWindow = new PortProfilesChildWindow(window);
-
- var childWindowViewModel = new PortProfilesViewModel(async instance =>
- {
- childWindow.IsOpen = false;
- ConfigurationManager.Current.IsChildWindowOpen = false;
-
- Ports = string.Join("; ", instance.GetSelectedPortProfiles().Select(x => x.Ports));
- }, async _ =>
- {
- childWindow.IsOpen = false;
- ConfigurationManager.Current.IsChildWindowOpen = false;
- });
-
- childWindow.Title = Strings.SelectPortProfile;
-
- childWindow.DataContext = childWindowViewModel;
-
- ConfigurationManager.Current.IsChildWindowOpen = true;
-
- await window.ShowChildWindowAsync(childWindow);
- }
-
private async Task Start()
{
// Port profiles in the input (e.g. "Webserver; 8080") are replaced by their ports. The input is validated, but
diff --git a/Source/NETworkManager/Views/PortProfilesChildWindow.xaml b/Source/NETworkManager/Views/PortProfilesChildWindow.xaml
deleted file mode 100644
index 0a89488e89..0000000000
--- a/Source/NETworkManager/Views/PortProfilesChildWindow.xaml
+++ /dev/null
@@ -1,85 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/PortProfilesChildWindow.xaml.cs b/Source/NETworkManager/Views/PortProfilesChildWindow.xaml.cs
deleted file mode 100644
index 8f38f244c3..0000000000
--- a/Source/NETworkManager/Views/PortProfilesChildWindow.xaml.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using NETworkManager.ViewModels;
-using System;
-using System.Windows;
-using System.Windows.Threading;
-
-namespace NETworkManager.Views;
-
-public partial class PortProfilesChildWindow
-{
- public PortProfilesChildWindow(Window parentWindow)
- {
- InitializeComponent();
-
- // Set the width and height of the child window based on the parent window size
- ChildWindowMaxWidth = 850;
- ChildWindowMaxHeight = 650;
- ChildWindowWidth = parentWindow.ActualWidth * 0.85;
- //ChildWindowHeight = parentWindow.ActualHeight * 0.85;
-
- // Update the size of the child window when the parent window is resized
- parentWindow.SizeChanged += (_, _) =>
- {
- ChildWindowWidth = parentWindow.ActualWidth * 0.85;
- //ChildWindowHeight = parentWindow.ActualHeight * 0.85;
- };
- }
-
- private void ChildWindow_OnLoaded(object sender, RoutedEventArgs e)
- {
- Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(delegate
- {
- TextBoxSearch.Focus();
- }));
- }
-
- private void DataGridRow_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
- {
- var x = (PortProfilesViewModel)DataContext;
-
- if (x.OKCommand.CanExecute(null))
- x.OKCommand.Execute(null);
- }
-}
diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml
index b74c047027..e15fb130b0 100644
--- a/Source/NETworkManager/Views/PortScannerView.xaml
+++ b/Source/NETworkManager/Views/PortScannerView.xaml
@@ -24,6 +24,7 @@
+
@@ -53,8 +54,6 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -96,24 +120,7 @@
-
-
public class PortsOrPortProfilesValidator : ValidationRule
{
+ ///
+ /// An empty input is valid (e.g. the ports of a profile).
+ ///
+ public bool AllowEmpty { get; set; }
+
public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
+ if (AllowEmpty && string.IsNullOrWhiteSpace(value as string))
+ return ValidationResult.ValidResult;
+
return PortProfile.TryResolvePorts(value as string, SettingsManager.Current.PortScanner_PortProfiles, out _)
? ValidationResult.ValidResult
: new ValidationResult(false, Strings.EnterValidPortsOrPortProfiles);
diff --git a/Source/NETworkManager/ProfileDialogManager.cs b/Source/NETworkManager/ProfileDialogManager.cs
index 9107f937eb..772dacd442 100644
--- a/Source/NETworkManager/ProfileDialogManager.cs
+++ b/Source/NETworkManager/ProfileDialogManager.cs
@@ -6,6 +6,7 @@
using NETworkManager.Models.PuTTY;
using NETworkManager.Models.RemoteDesktop;
using NETworkManager.Profiles;
+using NETworkManager.Settings;
using NETworkManager.Utilities;
using NETworkManager.ViewModels;
using NETworkManager.Views;
@@ -54,7 +55,14 @@ private static ProfileInfo ParseProfileInfo(ProfileViewModel instance)
PortScanner_Host = instance.PortScanner_InheritHost
? instance.Host?.Trim()
: instance.PortScanner_Host?.Trim(),
- PortScanner_Ports = instance.PortScanner_Ports?.Trim(),
+ // Port profiles are referenced by their ID (found after a rename), their ports are saved as well (used if
+ // a port profile can't be found, e.g. a profile file from another computer)
+ PortScanner_Ports = PortProfile.TryResolvePorts(instance.PortScanner_Ports,
+ SettingsManager.Current.PortScanner_PortProfiles, out var portScannerPorts)
+ ? portScannerPorts
+ : instance.PortScanner_Ports?.Trim(),
+ PortScanner_PortsInput = PortProfile.EncodeReferences(instance.PortScanner_Ports,
+ SettingsManager.Current.PortScanner_PortProfiles),
// Ping Monitor
PingMonitor_Enabled = instance.PingMonitor_Enabled,
diff --git a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
index a248a87516..a5b9abe829 100644
--- a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
+++ b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
@@ -1,7 +1,8 @@
+ xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls"
+ xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters">
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs
index 7e86dae89d..e095857c2c 100644
--- a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs
@@ -2,7 +2,9 @@
using NETworkManager.Controls;
using NETworkManager.Localization.Resources;
using NETworkManager.Models;
+using NETworkManager.Models.Network;
using NETworkManager.Profiles;
+using NETworkManager.Settings;
using NETworkManager.Utilities;
using NETworkManager.Views;
using System;
@@ -137,19 +139,36 @@ private static void CloseItemAction(ItemActionCallbackArgs args)
/// The host to scan.
/// The ports to scan.
/// Starts it on the first load. If false, the input is only filled in.
- public void AddTab(string host = null, string ports = null, bool start = true)
+ /// Message shown below the input (e.g. a port profile of the profile was not found).
+ public void AddTab(string host = null, string ports = null, bool start = true, string message = null)
{
var tabId = Guid.NewGuid();
TabItems.Add(new DragablzTabItem(string.IsNullOrEmpty(host) ? Strings.NewTab : host,
- new PortScannerView(tabId, host, ports, start), tabId));
+ new PortScannerView(tabId, host, ports, start, message), tabId));
SelectedTabIndex = TabItems.Count - 1;
}
public void AddTab(ProfileInfo profile, bool start = true)
{
- AddTab(profile.PortScanner_Host, profile.PortScanner_Ports, start: start);
+ // Port profiles with their current name and ports. A port profile that can't be found (deleted, or a profile
+ // file shared from another computer - port profiles are saved in the settings): the ports saved with the
+ // profile, with a note.
+ if (string.IsNullOrEmpty(profile.PortScanner_PortsInput))
+ {
+ AddTab(profile.PortScanner_Host, profile.PortScanner_Ports, start);
+ }
+ else if (PortProfile.TryDecodeReferences(profile.PortScanner_PortsInput,
+ SettingsManager.Current.PortScanner_PortProfiles, out var ports))
+ {
+ AddTab(profile.PortScanner_Host, ports, start);
+ }
+ else
+ {
+ AddTab(profile.PortScanner_Host, profile.PortScanner_Ports, start,
+ Strings.PortProfileOfProfileNotFoundMessage);
+ }
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index c690e65d07..72cf503be6 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -67,6 +67,9 @@ public class PortScannerViewModel : ViewModelBase, IProfileManagerMinimal
// Ports of the last scan as entered (e.g. "22; 80; 443"), for a profile created from a result
private string _lastPortsInput;
+ // Message of the tab, shown until the first scan has started (see constructor)
+ private string _message;
+
// Rescan of shown hosts (see RescanSelectedHosts): their rows are removed and added again by the rescan. The
// counters are the ones of the other hosts (base) plus the ones of the rescan; the progress of the last scan is
// restored afterwards.
@@ -417,10 +420,21 @@ private set
/// The initial host to scan.
/// The initial ports to scan.
/// Starts it on the first load. If false, the input is only filled in.
- public PortScannerViewModel(Guid tabId, string host, string port, bool start = true)
+ ///
+ /// Message shown below the input, also with the first scan (e.g. a port profile of the profile was not found).
+ ///
+ public PortScannerViewModel(Guid tabId, string host, string port, bool start = true, string message = null)
{
_firstLoad = start;
+ if (!string.IsNullOrEmpty(message))
+ {
+ _message = message;
+
+ StatusMessage = message;
+ IsStatusMessageDisplayed = true;
+ }
+
ConfigurationManager.Current.PortScannerTabCount++;
_tabId = tabId;
@@ -740,6 +754,15 @@ private async Task Start()
IsStatusMessageDisplayed = false;
StatusMessage = string.Empty;
+ // Message of the tab (see constructor): still shown with the first scan
+ if (_message != null)
+ {
+ StatusMessage = _message;
+ IsStatusMessageDisplayed = true;
+
+ _message = null;
+ }
+
IsRunning = true;
PreparingScan = true;
diff --git a/Source/NETworkManager/ViewModels/ProfileViewModel.cs b/Source/NETworkManager/ViewModels/ProfileViewModel.cs
index b67356d8ee..b53858a0fe 100644
--- a/Source/NETworkManager/ViewModels/ProfileViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ProfileViewModel.cs
@@ -89,7 +89,12 @@ public ProfileViewModel(Action saveCommand, Action
+ /// Gets the port profiles and the ports history of the Port Scanner for the ports input (grouped).
+ ///
+ public ICollectionView PortScanner_PortsSuggestions { get; } = new PortScannerPortsSuggestions().View;
+
public string PortScanner_Ports
{
get;
diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml
index e15fb130b0..63d6dbc72b 100644
--- a/Source/NETworkManager/Views/PortScannerView.xaml
+++ b/Source/NETworkManager/Views/PortScannerView.xaml
@@ -24,7 +24,6 @@
-
@@ -83,34 +82,15 @@
x:Name="ComboBoxPorts"
ItemsSource="{Binding PortsSuggestions}"
TextSearch.TextPath="Text"
+ ItemTemplate="{StaticResource PortScannerPortsSuggestionTemplate}"
mah:TextBoxHelper.Watermark="{x:Static localization:StaticStrings.ExamplePortScanRange}"
IsReadOnly="{Binding Path=IsRunning}"
controls:ComboBoxPasteBehavior.ConvertMultilineToSemicolon="True"
Style="{StaticResource HistoryComboBox}">
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
diff --git a/Source/NETworkManager/Views/PortScannerView.xaml.cs b/Source/NETworkManager/Views/PortScannerView.xaml.cs
index cfe8d174ae..52e5ba14af 100644
--- a/Source/NETworkManager/Views/PortScannerView.xaml.cs
+++ b/Source/NETworkManager/Views/PortScannerView.xaml.cs
@@ -15,11 +15,12 @@ public partial class PortScannerView : IDragablzTabItem
{
private readonly PortScannerViewModel _viewModel;
- public PortScannerView(Guid tabId, string host = null, string ports = null, bool start = true)
+ public PortScannerView(Guid tabId, string host = null, string ports = null, bool start = true,
+ string message = null)
{
InitializeComponent();
- _viewModel = new PortScannerViewModel(tabId, host, ports, start);
+ _viewModel = new PortScannerViewModel(tabId, host, ports, start, message);
DataContext = _viewModel;
diff --git a/Source/NETworkManager/Views/ProfileChildWindow.xaml b/Source/NETworkManager/Views/ProfileChildWindow.xaml
index 58db166951..570961f19b 100644
--- a/Source/NETworkManager/Views/ProfileChildWindow.xaml
+++ b/Source/NETworkManager/Views/ProfileChildWindow.xaml
@@ -320,7 +320,7 @@
Binding="{Binding Data.PortScanner_Enabled, Source={StaticResource BindingProxy}}"
Value="True" />
@@ -1718,17 +1718,26 @@
-
-
+
+
+
+
+
+
-
+
-
-
+
+
@@ -4321,7 +4330,7 @@
diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md
index 9f70434c14..28b3822d89 100644
--- a/Website/docs/application/port-scanner.md
+++ b/Website/docs/application/port-scanner.md
@@ -130,7 +130,7 @@ See also the [Port Scanner](./port-scanner) example inputs for more information
### Ports
-TCP ports to scan each host for.
+TCP ports to scan each host for: ports, port ranges and names of [port profiles](#port-profiles) (selectable in the list).
**Type:** `String`
@@ -140,6 +140,13 @@ TCP ports to scan each host for.
- `1-1024`
- `80; 443; 8080; 8443`
+- `Webserver; Database; 8080`
+
+:::note
+
+A port profile is referenced by the profile, so changes to the port profile apply to the profile, also if it is renamed. Its ports are saved with the profile as well: if the port profile can't be found (it was deleted, or the profile file is shared from another computer - port profiles are saved in the settings of each computer), the saved ports are used and a note is shown.
+
+:::
:::note
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index fe88249e16..ceb630363a 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -101,6 +101,7 @@ Release date: **xx.xx.2026**
- Added **Ping Monitor**, **Traceroute**, **DNS lookup...**, **RDAP...**, **Remote Desktop**, **PowerShell**, **PuTTY**, **TigerVNC** and **SNMP** to the context menu to open the host (hostname, otherwise the IP address) in the tool, like in the **IP Scanner**. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- The header of each host shows the number of open ports (e.g. `server (10.0.0.1) · 3 Open`), also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- The **Ports** input lists the port profiles (with their ports) and the history, like the DNS servers in **DNS Lookup**: select a port profile, or type ports and port profile names. Replaces the **Select port profile** button and its dialog. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Profiles: the **Ports** of the Port Scanner can contain port profiles (selectable in the list like in the tool), which are referenced by the profile - changes to a port profile apply, also after a rename. The ports are saved with the profile as well and used, with a note, if the port profile can't be found (e.g. a profile file shared from another computer, port profiles are saved in the settings). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Port profiles can be entered by name in the **Ports** input, also several and together with ports (e.g. `Webserver; Database; 8080`, case-insensitive). The name of a port profile must be unique, must not contain `;` (replaced by `,` when updating) and must not be a port or port range. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Add profile...** to the context menu: creates a profile with the host (IP address), the hostname as name and the ports of the shown scan. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added a search box above the result to filter the ports by IP address, hostname, port, protocol, service, description and status, also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
From 864a3c743e846120872494ca1d2c8fec95d760a3 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:07:10 +0200
Subject: [PATCH 091/146] Chore: Remove the strings of the removed port
profiles dialog (D2)
OpenPortProfilesDots (button tooltip) and SelectPortProfile (dialog title)
are no longer used since the "Select port profile" button and its dialog
were replaced by the ports input (d403a65fc). English only, the
translations follow via Transifex.
Co-Authored-By: Claude Opus 5.5
---
.../Resources/Strings.Designer.cs | 18 ------------------
.../Resources/Strings.resx | 6 ------
2 files changed, 24 deletions(-)
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index 212ef57fdb..b4c23ff3e0 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -8707,15 +8707,6 @@ public static string OpenOIDProfilesDots {
}
}
- ///
- /// Looks up a localized string similar to Open port profiles....
- ///
- public static string OpenPortProfilesDots {
- get {
- return ResourceManager.GetString("OpenPortProfilesDots", resourceCulture);
- }
- }
-
///
/// Looks up a localized string similar to Open project.
///
@@ -12281,15 +12272,6 @@ public static string SelectOIDProfile {
}
}
- ///
- /// Looks up a localized string similar to Select port profile.
- ///
- public static string SelectPortProfile {
- get {
- return ResourceManager.GetString("SelectPortProfile", resourceCulture);
- }
- }
-
///
/// Looks up a localized string similar to Send.
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index 8a6d7365fe..e4119c82b7 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -2871,12 +2871,6 @@ is disabled!
The selected port profile will be deleted permanently.
-
- Select port profile
-
-
- Open port profiles...
-
Show SplashScreen on start
From c8ec0fc525e75d8fc93b7399ecc282ec070c0ed2 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:08:07 +0200
Subject: [PATCH 092/146] Refactor: Shared ping probe for the IP Scanner and
Port Scanner (C2)
The ping of the IP Scanner (attempts, retry only on a timeout) is moved
unchanged into PingProbe (like PortProbe), so the Port Scanner can ping the
hosts before the scan with the same logic.
Co-Authored-By: Claude Opus 5.5
---
.../Network/IPScanner.cs | 63 +-------------
.../Network/PingProbe.cs | 85 +++++++++++++++++++
2 files changed, 87 insertions(+), 61 deletions(-)
create mode 100644 Source/NETworkManager.Models/Network/PingProbe.cs
diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs
index 2d1e4ba719..7272b85387 100644
--- a/Source/NETworkManager.Models/Network/IPScanner.cs
+++ b/Source/NETworkManager.Models/Network/IPScanner.cs
@@ -209,7 +209,8 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa
// all ports are finished
var portAnswered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var pingTask = PingAsync(ipAddress, cancellationToken);
+ var pingTask = PingProbe.PingAsync(ipAddress, options.ICMPTimeout, options.ICMPAttempts,
+ options.ICMPBuffer, cancellationToken);
var portScanTask = options.PortScanEnabled
? PortScanAsync(ipAddress, portScanParallelOptions, () => portAnswered.TrySetResult(), cancellationToken)
@@ -562,66 +563,6 @@ private static bool IsInLocalSubnet(IPAddress ipAddress, IEnumerable PingAsync(IPAddress ipAddress, CancellationToken cancellationToken)
- {
- using var ping = new System.Net.NetworkInformation.Ping();
-
- PingInfo pingInfo = null;
-
- for (var i = 0; i < options.ICMPAttempts; i++)
- {
- // Get timestamp
- var timestamp = DateTime.Now;
-
- try
- {
- // Note: the CancellationToken-accepting overload requires a TimeSpan timeout,
- // unlike the legacy int-based overloads used elsewhere in .NET's Ping API.
- var pingReply = await ping.SendPingAsync(ipAddress, TimeSpan.FromMilliseconds(options.ICMPTimeout),
- options.ICMPBuffer, cancellationToken: cancellationToken).ConfigureAwait(false);
-
- // Success
- if (pingReply is { Status: IPStatus.Success })
- {
- switch (ipAddress.AddressFamily)
- {
- case AddressFamily.InterNetwork:
- return new PingInfo(
- timestamp,
- pingReply.Address,
- pingReply.Buffer.Length,
- pingReply.RoundtripTime,
- pingReply.Options!.Ttl,
- pingReply.Status);
- case AddressFamily.InterNetworkV6:
- return new PingInfo(
- timestamp,
- pingReply.Address,
- pingReply.Buffer.Length,
- pingReply.RoundtripTime,
- pingReply.Status);
- }
- }
-
- // Failed
- pingInfo = new PingInfo(timestamp, ipAddress, pingReply.Status);
-
- // Only try again if the request timed out (e.g. a lost packet). Other states like
- // "destination host unreachable" are a definitive answer.
- if (pingReply.Status != IPStatus.TimedOut)
- break;
- }
- catch (PingException)
- {
- // Ping failed with unknown status
- return new PingInfo(timestamp, ipAddress, IPStatus.Unknown);
- }
- }
-
- // Result of the last attempt (falls back to unknown status if there was no attempt)
- return pingInfo ?? new PingInfo(DateTime.Now, ipAddress, IPStatus.Unknown);
- }
-
///
/// Probes all configured ports of a host. Returns every port (also closed / timed out ones), because
/// a closed port is used to detect a reachable host as well - the caller filters what is shown.
diff --git a/Source/NETworkManager.Models/Network/PingProbe.cs b/Source/NETworkManager.Models/Network/PingProbe.cs
new file mode 100644
index 0000000000..b5dff7a4cc
--- /dev/null
+++ b/Source/NETworkManager.Models/Network/PingProbe.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace NETworkManager.Models.Network;
+
+///
+/// Provides a shared ICMP echo (ping) probe used by and .
+///
+internal static class PingProbe
+{
+ ///
+ /// Pings the host. Another echo request is only sent if the previous one timed out (e.g. a lost packet),
+ /// other states like "destination host unreachable" are a definitive answer.
+ ///
+ /// IP address of the host.
+ /// Timeout in milliseconds of one echo request.
+ /// Number of attempts.
+ /// Data sent with the echo request.
+ /// Token to monitor for cancellation requests.
+ /// Result of the successful attempt, otherwise of the last attempt.
+ public static async Task PingAsync(IPAddress ipAddress, int timeoutMs, int attempts, byte[] buffer,
+ CancellationToken cancellationToken)
+ {
+ using var ping = new System.Net.NetworkInformation.Ping();
+
+ PingInfo pingInfo = null;
+
+ for (var i = 0; i < attempts; i++)
+ {
+ // Get timestamp
+ var timestamp = DateTime.Now;
+
+ try
+ {
+ // Note: the CancellationToken-accepting overload requires a TimeSpan timeout,
+ // unlike the legacy int-based overloads used elsewhere in .NET's Ping API.
+ var pingReply = await ping.SendPingAsync(ipAddress, TimeSpan.FromMilliseconds(timeoutMs),
+ buffer, cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ // Success
+ if (pingReply is { Status: IPStatus.Success })
+ {
+ switch (ipAddress.AddressFamily)
+ {
+ case AddressFamily.InterNetwork:
+ return new PingInfo(
+ timestamp,
+ pingReply.Address,
+ pingReply.Buffer.Length,
+ pingReply.RoundtripTime,
+ pingReply.Options!.Ttl,
+ pingReply.Status);
+ case AddressFamily.InterNetworkV6:
+ return new PingInfo(
+ timestamp,
+ pingReply.Address,
+ pingReply.Buffer.Length,
+ pingReply.RoundtripTime,
+ pingReply.Status);
+ }
+ }
+
+ // Failed
+ pingInfo = new PingInfo(timestamp, ipAddress, pingReply.Status);
+
+ // Only try again if the request timed out (e.g. a lost packet). Other states like
+ // "destination host unreachable" are a definitive answer.
+ if (pingReply.Status != IPStatus.TimedOut)
+ break;
+ }
+ catch (PingException)
+ {
+ // Ping failed with unknown status
+ return new PingInfo(timestamp, ipAddress, IPStatus.Unknown);
+ }
+ }
+
+ // Result of the last attempt (falls back to unknown status if there was no attempt)
+ return pingInfo ?? new PingInfo(DateTime.Now, ipAddress, IPStatus.Unknown);
+ }
+}
From 07c196cb3bad851d9b07f28c74a38f20f25c38b1 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:09:07 +0200
Subject: [PATCH 093/146] Feature: Port Scanner option to ping hosts before the
scan (C2, PS-M5)
New setting "Ping hosts before the scan" (default off, help icon): every
host is pinged first (PingProbe, with the timeout and attempts of the port
scan, retry only on a timeout); a host that doesn't answer is not scanned,
its ports are counted as scanned for the progress. After the scan the
number of skipped hosts is shown below the input. Sparse ranges with many
ports no longer wait for the timeout of every port of an unused address;
hosts that block ICMP are skipped (warning in the docs and the tooltip).
Co-Authored-By: Claude Opus 5.5
---
.../Resources/Strings.Designer.cs | 27 ++++++++++++++
.../Resources/Strings.resx | 9 +++++
.../Network/PortScanner.cs | 37 +++++++++++++++++--
.../Network/PortScannerOptions.cs | 6 +++
.../GlobalStaticConfiguration.cs | 1 +
.../NETworkManager.Settings/SettingsInfo.cs | 13 +++++++
.../PortScannerSettingsViewModel.cs | 20 ++++++++++
.../ViewModels/PortScannerViewModel.cs | 11 ++++++
.../Views/PortScannerSettingsView.xaml | 13 ++++++-
Website/docs/application/port-scanner.md | 14 +++++++
Website/docs/changelog/next-release.md | 1 +
11 files changed, 148 insertions(+), 4 deletions(-)
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index b4c23ff3e0..12b38682f9 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -5903,6 +5903,15 @@ public static string HelpMessage_PasswordNotDisplayedCanBeOverwritten {
}
}
+ ///
+ /// Looks up a localized string similar to Only hosts that answer a ping (ICMP echo, with the timeout and attempts above) are scanned. Faster for ranges with many unused IP addresses. Hosts that block ICMP (e.g. Windows with the firewall enabled) are not scanned..
+ ///
+ public static string HelpMessage_PingHostsBeforeScan {
+ get {
+ return ResourceManager.GetString("HelpMessage_PingHostsBeforeScan", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Host threads × port threads can be at most 8,192 connections at the same time (every connection uses a local port, this is half of them). Many connections at the same time may be blocked by a firewall or overload small routers..
///
@@ -9031,6 +9040,15 @@ public static string PingDetails {
}
}
+ ///
+ /// Looks up a localized string similar to Ping hosts before the scan.
+ ///
+ public static string PingHostsBeforeScan {
+ get {
+ return ResourceManager.GetString("PingHostsBeforeScan", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Ping Monitor.
///
@@ -14749,6 +14767,15 @@ public static string XDown {
}
}
+ ///
+ /// Looks up a localized string similar to {0} host(s) did not answer the ping and were not scanned..
+ ///
+ public static string XHostsDidNotAnswerThePingAndWereNotScanned {
+ get {
+ return ResourceManager.GetString("XHostsDidNotAnswerThePingAndWereNotScanned", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to {0} Open.
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index e4119c82b7..d79e6862ca 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -5107,4 +5107,13 @@ Do you want to start the scan?
A port profile of this profile was not found, the ports saved with the profile are used. Port profiles are saved in the settings - if the profile file was shared from another computer, create the port profile here or edit the profile.
+
+ Ping hosts before the scan
+
+
+ Only hosts that answer a ping (ICMP echo, with the timeout and attempts above) are scanned. Faster for ranges with many unused IP addresses. Hosts that block ICMP (e.g. Windows with the firewall enabled) are not scanned.
+
+
+ {0} host(s) did not answer the ping and were not scanned.
+
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs
index 607a3aea33..590a68af84 100644
--- a/Source/NETworkManager.Models/Network/PortScanner.cs
+++ b/Source/NETworkManager.Models/Network/PortScanner.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
+using System.Net.NetworkInformation;
using System.Threading;
using System.Threading.Tasks;
using NETworkManager.Models.Lookup;
@@ -53,6 +54,13 @@ public PortScanner(PortScannerOptions options)
private readonly InterlockedCounter _portsOpen = new();
private readonly InterlockedCounter _portsClosed = new();
private readonly InterlockedCounter _portsTimedOut = new();
+ private readonly InterlockedCounter _hostsSkipped = new();
+
+ ///
+ /// Data of the ping before the scan (see ), like the
+ /// default buffer of the IP Scanner.
+ ///
+ private static readonly byte[] PingBuffer = new byte[32];
// Closed and timed out ports per host, see GetClosedAndTimedOutPortsPerHost
private readonly ConcurrentDictionary
@@ -84,6 +92,13 @@ public PortScanner(PortScannerOptions options)
///
public int PortsTimedOut => _portsTimedOut.Value;
+ ///
+ /// Gets the number of hosts that were not scanned because they didn't answer the ping before the scan (see
+ /// ). Thread-safe; may be read from any thread while the
+ /// scan is running.
+ ///
+ public int HostsSkipped => _hostsSkipped.Value;
+
///
/// Gets the number of closed and timed out ports per host scanned so far, only hosts with such a port. These
/// ports are only raised in if they are shown - e.g. a rescan of some hosts needs
@@ -169,6 +184,22 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) =>
{
+ // Only hosts that answer a ping are scanned (option) - the ports of the other hosts are counted
+ // as scanned, so the progress is complete
+ if (_options.PingHostsBeforeScan)
+ {
+ var pingInfo = await PingProbe.PingAsync(host.ipAddress, _options.Timeout, _options.Attempts,
+ PingBuffer, hostCt).ConfigureAwait(false);
+
+ if (pingInfo.Status != IPStatus.Success)
+ {
+ _hostsSkipped.Increment();
+ IncreaseProgress(portCount);
+
+ return;
+ }
+ }
+
// Resolve Hostname (PTR) while the ports are scanned. Started with the first port probe of the
// host, so the lookups of many hosts are spread over time like the probes instead of all at once
var hostnameTask = new Lazy>(() => _options.ResolveHostname
@@ -257,10 +288,10 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
return (Math.Clamp(AutoMaxConnections / portsAtOnce, 1, AutoMaxHostThreads), AutoMaxPortThreads);
}
- private void IncreaseProgress()
+ private void IncreaseProgress(int count = 1)
{
- // Increase the progress
- Interlocked.Increment(ref _progressValue);
+ // Increase the progress
+ Interlocked.Add(ref _progressValue, count);
OnProgressChanged();
}
diff --git a/Source/NETworkManager.Models/Network/PortScannerOptions.cs b/Source/NETworkManager.Models/Network/PortScannerOptions.cs
index 3c531daee8..94b6c12d84 100644
--- a/Source/NETworkManager.Models/Network/PortScannerOptions.cs
+++ b/Source/NETworkManager.Models/Network/PortScannerOptions.cs
@@ -31,6 +31,12 @@ public class PortScannerOptions
///
public required int Attempts { get; init; }
+ ///
+ /// Ping every host before its ports are scanned (with and ), a host
+ /// that doesn't answer is not scanned.
+ ///
+ public required bool PingHostsBeforeScan { get; init; }
+
///
/// Resolve the hostname for an IP address.
///
diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs
index cdc201aa27..9ddfd821b1 100644
--- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs
+++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs
@@ -166,6 +166,7 @@ public static class GlobalStaticConfiguration
public static int PortScanner_MaxConnections => 8192;
public static int PortScanner_Timeout => 2000;
public static int PortScanner_Attempts => 1;
+ public static bool PortScanner_PingHostsBeforeScan => false;
public static ExportFileType PortScanner_ExportFileType => ExportFileType.Csv;
// Larger scans (hosts x ports) need a confirmation, even larger ones are refused
public static long PortScanner_ConfirmPortCount => 65536;
diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs
index 52e88573b8..db3738bdcf 100644
--- a/Source/NETworkManager.Settings/SettingsInfo.cs
+++ b/Source/NETworkManager.Settings/SettingsInfo.cs
@@ -1541,6 +1541,19 @@ public int PortScanner_Attempts
}
} = GlobalStaticConfiguration.PortScanner_Attempts;
+ public bool PortScanner_PingHostsBeforeScan
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = GlobalStaticConfiguration.PortScanner_PingHostsBeforeScan;
+
public bool PortScanner_AutoAdjustThreads
{
get;
diff --git a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
index 5ea9f5af9e..2d991dc575 100644
--- a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
@@ -120,6 +120,25 @@ public bool ResolveHostname
}
}
+ ///
+ /// Gets or sets whether every host is pinged before its ports are scanned (hosts without answer are skipped).
+ ///
+ public bool PingHostsBeforeScan
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.PortScanner_PingHostsBeforeScan = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets or sets whether the number of hosts and ports scanned at the same time is adjusted to the scan (instead of
/// and ).
@@ -222,6 +241,7 @@ private void LoadSettings()
ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults;
Timeout = SettingsManager.Current.PortScanner_Timeout;
Attempts = SettingsManager.Current.PortScanner_Attempts;
+ PingHostsBeforeScan = SettingsManager.Current.PortScanner_PingHostsBeforeScan;
ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname;
AutoAdjustThreads = SettingsManager.Current.PortScanner_AutoAdjustThreads;
MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads;
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 72cf503be6..30abb3c5be 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -1007,6 +1007,7 @@ private static PortScannerOptions CreateScanOptions()
MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads,
Timeout = SettingsManager.Current.PortScanner_Timeout,
Attempts = SettingsManager.Current.PortScanner_Attempts,
+ PingHostsBeforeScan = SettingsManager.Current.PortScanner_PingHostsBeforeScan,
ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname,
ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults
};
@@ -1300,6 +1301,16 @@ private void ScanComplete(object sender, ScanCompleteArgs e)
IsStatusMessageDisplayed = true;
}
+ // Hosts that were not scanned because they didn't answer the ping before the scan (option)
+ if (!e.IsCanceled && e.Exception == null && sender is PortScanner { HostsSkipped: > 0 } pingScanner)
+ {
+ var message = string.Format(Strings.XHostsDidNotAnswerThePingAndWereNotScanned,
+ pingScanner.HostsSkipped);
+
+ StatusMessage = IsStatusMessageDisplayed ? StatusMessage + Environment.NewLine + message : message;
+ IsStatusMessageDisplayed = true;
+ }
+
PreparingScan = false;
IsCanceling = false;
IsRunning = false;
diff --git a/Source/NETworkManager/Views/PortScannerSettingsView.xaml b/Source/NETworkManager/Views/PortScannerSettingsView.xaml
index 9393368833..e44742383d 100644
--- a/Source/NETworkManager/Views/PortScannerSettingsView.xaml
+++ b/Source/NETworkManager/Views/PortScannerSettingsView.xaml
@@ -100,7 +100,18 @@
-
+
+
+
+
+
+
+
+
+
diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md
index 28b3822d89..6fae740099 100644
--- a/Website/docs/application/port-scanner.md
+++ b/Website/docs/application/port-scanner.md
@@ -223,6 +223,20 @@ In a local network (LAN), `1` is enough. On an unreliable network (e.g. Wi-Fi, V
:::
+### Ping hosts before the scan
+
+Pings every host before its ports are scanned (ICMP echo, with the [Timeout](#timeout-ms) and [Attempts](#attempts) of the port scan). Only hosts that answer are scanned, the number of hosts that were not scanned is shown below the input after the scan. Faster for ranges with many unused IP addresses (e.g. a `/24` with `1-1024`), because the ports of unused addresses are not scanned until their timeout.
+
+**Type:** `Boolean`
+
+**Default:** `Disabled`
+
+:::warning
+
+Hosts that block ICMP (e.g. Windows with the firewall enabled) are not scanned, even if they have open ports.
+
+:::
+
### Resolve hostname
Resolve the hostname for given IP addresses.
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index ceb630363a..e4a3174e7f 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -106,6 +106,7 @@ Release date: **xx.xx.2026**
- Added **Add profile...** to the context menu: creates a profile with the host (IP address), the hostname as name and the ports of the shown scan. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added a search box above the result to filter the ports by IP address, hostname, port, protocol, service, description and status, also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- **Copy** > **IP address** / **Hostname** / **Port** / **IP address:Port** is available if multiple results are selected (one value per line, in the order shown). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- New setting **Ping hosts before the scan** (default off): only hosts that answer a ping are scanned, the number of skipped hosts is shown after the scan. Faster for ranges with many unused IP addresses; hosts that block ICMP are skipped. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- New setting **Adjust threads automatically** (default on): the number of hosts and ports scanned at the same time is adjusted to the scan (up to `512` connections, at most `128` hosts and `128` ports per host). A scan with few ports is much faster, e.g. a `/24` with one port (e.g. `3389`) takes about 4 seconds instead of about 2 minutes; `1-1024` is unchanged. **Max. concurrent host threads** (now up to `256`) and **Max. concurrent port threads** are used if it is disabled, with at most `8,192` connections at the same time. It is disabled when updating if the threads were changed. The hostname (DNS `PTR`) of a host is resolved when its first port is scanned, so the lookups are spread over time like the ports. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added a new **Well-known ports** (`1-1024`) default port profile. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564)
- The export runs in the background, so the window stays responsive while a large result is exported, and uses the order shown in the result (sorting, grouping) instead of the order in which the ports were scanned. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
From 4a85e28917d6099685b46bd7bf488da98846674b Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:11:47 +0200
Subject: [PATCH 094/146] Fix: Run command list with a quoted entry, e.g. "DNS
(via TCP)";Webserver (D1)
A value in quotes ended the word: `portscan host "DNS (via TCP)";Webs`
(after completing a port profile with spaces and typing "; Webs") was the
argument "DNS (via TCP)" plus an unknown argument ";Webs" - no port profile
suggestions. In an open quote ("DNS (via TCP); Webs) the suggestions were
shown, but the completion used the value without quotes, so the quotes of
the previous entries were lost.
Parser: a word consists of parts in quotes and parts without spaces
("DNS (via TCP)";Webs is one word, value DNS (via TCP);Webs); the word as
typed is CurrentToken. Completion: the previous entries are kept as typed
(an open quote is closed before the ";"), names with spaces are quoted in
any entry. Parser unit tests.
Co-Authored-By: Claude Opus 5.5
---
.../RunCommand/RunCommandParseResult.cs | 6 ++++
.../RunCommand/RunCommandParser.cs | 16 +++++----
.../RunCommand/RunCommandParserTests.cs | 33 +++++++++++++++++++
.../ViewModels/RunCommandViewModel.cs | 19 +++++++----
Website/docs/changelog/next-release.md | 2 +-
Website/docs/run-command.md | 2 +-
6 files changed, 64 insertions(+), 14 deletions(-)
diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs
index 32230999e3..d071d92cf6 100644
--- a/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs
+++ b/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs
@@ -47,6 +47,12 @@ public sealed class RunCommandParseResult
///
public string CurrentWord { get; init; } = string.Empty;
+ ///
+ /// Word that is being typed as entered, with its quotes (e.g. "DNS (via TCP)";Webs for the
+ /// DNS (via TCP);Webs), used to complete an entry of a list.
+ ///
+ public string CurrentToken { get; init; } = string.Empty;
+
///
/// Input without the word that is being typed (e.g. "dns borntoberoot.net " for "dns borntoberoot.net aa"),
/// used to complete a suggestion.
diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs
index f0dad503bd..9c0d9aa834 100644
--- a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs
+++ b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs
@@ -17,10 +17,10 @@ public static partial class RunCommandParser
private static partial Regex ListSeparatorOrQuoteRegex();
///
- /// A word: a value in quotes (can contain spaces, the closing quote can be missing while typing) or a value
- /// without spaces.
+ /// A word: parts in quotes (can contain spaces, the closing quote can be missing while typing) and parts without
+ /// spaces, e.g. a list with a value in quotes "DNS (via TCP)";Webserver.
///
- [GeneratedRegex("\"[^\"]*(?:\"|$)|[^\\s\"]+")]
+ [GeneratedRegex("(?:\"[^\"]*(?:\"|$)|[^\\s\"])+")]
private static partial Regex WordRegex();
///
@@ -207,6 +207,7 @@ public static RunCommandParseResult Parse(string input, IEnumerable
- /// Removes the quotes of a value (e.g. "Google Public DNS").
+ /// Removes the quotes of a value (e.g. "Google Public DNS", or "DNS (via TCP)";Webserver in a list).
///
private static string Unquote(string word)
{
- return word.StartsWith('"') ? word.Trim('"') : word;
+ return word.Replace("\"", string.Empty);
}
+ ///
+ /// Checks if the word ends in a quote that is not closed (while typing, e.g. "Google ).
+ ///
private static bool IsOpenQuote(string token)
{
- return token.StartsWith('"') && (token.Length == 1 || !token.EndsWith('"'));
+ return token.Count(x => x == '"') % 2 == 1;
}
}
diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs
index 401d52c4b7..a7edbd7f90 100644
--- a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs
+++ b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs
@@ -243,6 +243,39 @@ public void PortsWithWhitespace()
Assert.Equal(RunCommandParseError.None, result.Error);
}
+ [Theory]
+ [InlineData("portscan 10.8.0.1 \"DNS (via TCP)\";Webs")]
+ [InlineData("portscan 10.8.0.1 \"DNS (via TCP)\" ; Webs")]
+ public void ListWithAQuotedEntry(string input)
+ {
+ // A value in quotes is an entry of the list, not a word on its own
+ var result = Parse(input);
+
+ Assert.Equal("DNS (via TCP);Webs", result.Argument);
+ Assert.Equal("DNS (via TCP);Webs", result.CurrentWord);
+ Assert.Equal("\"DNS (via TCP)\";Webs", result.CurrentToken);
+ Assert.Equal("portscan 10.8.0.1 ", result.InputBeforeCurrentWord);
+ Assert.Equal(RunCommandParseError.None, result.Error);
+ }
+
+ [Fact]
+ public void ListWithAQuotedEntryAndType()
+ {
+ var result = Parse("dns borntoberoot.net \"Google Public DNS\";1.1.1.1 aaaa");
+
+ Assert.Equal("Google Public DNS;1.1.1.1", result.Argument);
+ Assert.Equal("aaaa", result.Type);
+ }
+
+ [Fact]
+ public void ListInAnOpenQuote()
+ {
+ var result = Parse("portscan 10.8.0.1 \"DNS (via TCP); Webs");
+
+ Assert.Equal("DNS (via TCP); Webs", result.CurrentWord);
+ Assert.Equal("\"DNS (via TCP); Webs", result.CurrentToken);
+ }
+
[Fact]
public void TargetThatLooksLikeAType()
{
diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
index b97c55d58c..94eb4388b3 100644
--- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
+++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
@@ -1162,7 +1162,7 @@ private IEnumerable GetDNSServerSuggestions()
/// Group of the suggestions.
///
/// The argument can be a list separated by ";" (e.g. "webserver;database;22"): the entry after the last ";" is
- /// completed. Names with spaces only as the first entry (a value in quotes can't be part of a list).
+ /// completed, the entries before are kept as entered (with their quotes, e.g. "DNS (via TCP)";web).
///
private IEnumerable GetArgumentValueSuggestions(RunCommandArgument argument,
IEnumerable<(string Name, string Description)> values, RunCommandSuggestionKind kind, string group,
@@ -1172,13 +1172,20 @@ private IEnumerable GetArgumentValueSuggestions(RunCommand
_result.Argument != null && !string.Equals(_result.Argument, _result.CurrentWord))
return [];
- // Entries before the one that is being typed (e.g. "webserver;" of "webserver;dat")
- var previousEntries = isList ? _result.CurrentWord[..(_result.CurrentWord.LastIndexOf(';') + 1)] : string.Empty;
- var entry = _result.CurrentWord[previousEntries.Length..];
+ // Entries before the one that is being typed, as entered (e.g. "webserver;" of "webserver;dat")
+ var separator = isList ? _result.CurrentToken.LastIndexOf(';') : -1;
+ var previousEntries = _result.CurrentToken[..(separator + 1)];
+
+ // Typed in the open quote of the previous entry (e.g. "DNS (via TCP); web) - close it before the ";"
+ if (previousEntries.Count(x => x == '"') % 2 == 1)
+ previousEntries = previousEntries[..^1].TrimEnd() + "\";";
+
+ var entry = separator < 0
+ ? _result.CurrentWord
+ : _result.CurrentToken[(separator + 1)..].Replace("\"", string.Empty).Trim();
return values
- .Where(x => x.Name.Contains(entry, StringComparison.OrdinalIgnoreCase) &&
- (previousEntries.Length == 0 || !x.Name.Contains(' ')))
+ .Where(x => x.Name.Contains(entry, StringComparison.OrdinalIgnoreCase))
.Select(x => new RunCommandSuggestion
{
Kind = kind,
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index e4a3174e7f..31b91a8f13 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -30,7 +30,7 @@ Release date: **xx.xx.2026**
- The [**Run command**](../run-command.md) (`Ctrl+Shift+P`) now runs a feature directly, e.g. `ping 10.0.0.1`, `dns borntoberoot.net 1.1.1.1 aaaa`, `rdap 1.10 asn` or `rdp @server-01` (profile). It opens in a dialog instead of a flyout, with short aliases (e.g. `ping`, `dns`, `rdp`, `ssh`, `geo`, `wol`), suggestions for recent commands, profiles of the feature and types (`Up`/`Down` to select, `Tab` to complete, `Enter` to run, `Esc` to close), and validates the input like the feature does. Only visible features are offered. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644)
- New run command targets for the **Subnet Calculator** (`subnet 10.0.0.0/24`, `subnet 10.0.0.0/16 /24` for subnetting, `subnet 10.0.0.0/24 10.0.1.0/24` for the wide subnet) and the **Lookup** (`lookup oui 00:11:22`, `lookup port 22; ssh`). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
- New global hotkey to bring the window to the foreground and open the run command (default `Ctrl+Alt+P`, disabled by default), see [Settings > HotKeys](../settings/hotkeys.md). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
-- Run command: the **Port Scanner** needs ports, which can also be names of port profiles, also several and together with ports (e.g. `portscan 10.0.0.1 webserver` or `portscan 10.0.0.1 webserver;database;22`, suggested after the host and after a `;`). Own history size for the run command (default 20, [Settings > General](../settings/general.md#number-of-stored-entries-run-command)). `Tab` also completes profiles and port profiles, `PageUp`/`PageDown` move the selection by 5 suggestions. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+- Run command: the **Port Scanner** needs ports, which can also be names of port profiles, also several and together with ports (e.g. `portscan 10.0.0.1 webserver` or `portscan 10.0.0.1 "DNS (via TCP)";webserver;22`, suggested after the host and after a `;`). Own history size for the run command (default 20, [Settings > General](../settings/general.md#number-of-stored-entries-run-command)). `Tab` also completes profiles and port profiles, `PageUp`/`PageDown` move the selection by 5 suggestions. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
- Run command dialog redesigned: the icon of the recognized feature, the syntax with the part being typed highlighted, the values as they were understood (e.g. `Hosts: borntoberoot.net`, `Type: AAAA`), validation while typing, grouped suggestions with the typed text highlighted, examples and key hints (can be hidden in the settings). The dialog uses more space in a large window. The title bar shows a **Run command...** box in the center (like the command center of VS Code) instead of the icon. Type only a target (e.g. `10.0.0.1`, `server-01`, `00:11:22:33:44:55`, `10.0.0.0/24`) to get the commands that accept it, recent commands are also suggested while typing (e.g. a host); optionally, a target in the clipboard is suggested ([Settings > General](../settings/general.md#suggest-the-clipboard-content-as-target)). `Ctrl+Enter` runs and keeps the dialog open, `Shift+Enter` fills in the input without starting, `Shift+Del` or the remove button removes a recent command. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
**RDAP**
diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md
index 8d2ae3f006..9af8ef4a32 100644
--- a/Website/docs/run-command.md
+++ b/Website/docs/run-command.md
@@ -56,7 +56,7 @@ All features that accept a target also accept a profile (`@profile`), except the
- **RDAP:** The type is `domain`, `nameserver`, `tld`, `ip`, `asn` or `entity`. Without a type, it is detected from the query (see [RDAP](./application/rdap.md#example-inputs)).
- **Subnet Calculator:** A subnet (e.g. `subnet 10.0.0.0/24`) is calculated in **Calculator**. With a new subnet mask or CIDR (e.g. `/26`, `26` or `255.255.255.192`), it is split in **Subnetting**; the new mask must be between the CIDR of the subnet and `/32` (IPv4) or `/128` (IPv6). With a second subnet (e.g. `subnet 10.0.0.0/24 10.0.1.0/24`), the **Wide Subnet** is calculated (IPv4 only).
- **Lookup:** The first word after the command is the lookup: `oui` (MAC addresses or vendors, e.g. `lookup oui 00:11:22`) or `port` (ports, port ranges or services, e.g. `lookup port 22; 80-90/tcp; ssh`). The rest of the input is the query, so vendors can contain spaces without quotes (e.g. `lookup oui Cisco Systems`). Without a query (e.g. `lookup port`), the lookup is only shown.
-- **Port Scanner:** The ports are required (a new tab has none): ports and port ranges (e.g. `22; 80; 443`, `1-1024`) and / or names of port profiles of the Port Scanner (case-insensitive, e.g. `portscan 10.0.0.1 webserver` or `portscan 10.0.0.1 webserver;database;22`; a single name with spaces in quotes). The port profiles are suggested after the host, also after a `;`.
+- **Port Scanner:** The ports are required (a new tab has none): ports and port ranges (e.g. `22; 80; 443`, `1-1024`) and / or names of port profiles of the Port Scanner (case-insensitive, e.g. `portscan 10.0.0.1 webserver` or `portscan 10.0.0.1 webserver;database;22`; a name with spaces in quotes, also in a list, e.g. `"DNS (via TCP)";webserver`). The port profiles are suggested after the host, also after a `;`.
- **SNMP:** Opens a tab with the host; the query is started in the feature (as before), also with `Enter`.
- **Wake on LAN:** The magic packet is sent to the broadcast address (or the one last used in the feature). If there is no valid broadcast address yet, the MAC address is only filled in. A profile sends the magic packet with its settings.
From bfc7ecf0cb26788b4cea94855561fb22af440a2f Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:17:22 +0200
Subject: [PATCH 095/146] Feature: Port Scanner Tab completion of port profiles
in the ports input (D1)
Like the run command: Tab completes the entry that is being typed with the
name of a port profile, the entries before are kept as entered
("webserver; dat" -> "webserver; Database"). Names that start with the
entry first, then names that contain it (case-insensitive); Tab again shows
the next match, after the last one Tab moves the focus. Without anything to
complete (empty entry, port, no match, already the name) Tab moves the
focus as usual. A rest the ComboBox autocompleted and selected is ignored.
ComboBoxListCompletionBehavior (Controls, now references Utilities) on the
ports input of the tool and the profile dialog; the matching is in
ListEntryCompletionHelper (unit tests).
Co-Authored-By: Claude Opus 5.5
---
.../ComboBoxListCompletionBehavior.cs | 116 ++++++++++++++++++
.../NETworkManager.Controls.csproj | 3 +
.../ListEntryCompletionHelperTests.cs | 54 ++++++++
.../ListEntryCompletionHelper.cs | 57 +++++++++
.../PortScannerPortsSuggestions.cs | 8 ++
.../ViewModels/PortScannerViewModel.cs | 9 +-
.../ViewModels/ProfileViewModel.cs | 9 +-
.../NETworkManager/Views/PortScannerView.xaml | 1 +
.../Views/ProfileChildWindow.xaml | 2 +
Website/docs/application/port-scanner.md | 3 +-
Website/docs/changelog/next-release.md | 2 +-
11 files changed, 260 insertions(+), 4 deletions(-)
create mode 100644 Source/NETworkManager.Controls/ComboBoxListCompletionBehavior.cs
create mode 100644 Source/NETworkManager.Tests/Utilities/ListEntryCompletionHelperTests.cs
create mode 100644 Source/NETworkManager.Utilities/ListEntryCompletionHelper.cs
diff --git a/Source/NETworkManager.Controls/ComboBoxListCompletionBehavior.cs b/Source/NETworkManager.Controls/ComboBoxListCompletionBehavior.cs
new file mode 100644
index 0000000000..b10fcb7298
--- /dev/null
+++ b/Source/NETworkManager.Controls/ComboBoxListCompletionBehavior.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Threading;
+using NETworkManager.Utilities;
+
+namespace NETworkManager.Controls;
+
+///
+/// Tab completion for an editable with a list separated by ";" (e.g. the ports of the Port
+/// Scanner with port profile names): Tab completes the last entry with a name of
+/// (e.g. "webserver; dat" -> "webserver; Database"), the entries before are kept. Tab again shows the next
+/// name that matches the typed entry, after the last one it moves the focus. If there is nothing to complete (empty
+/// entry, no match, already the name), Tab moves the focus as usual. See
+/// .
+///
+public static class ComboBoxListCompletionBehavior
+{
+ public static readonly DependencyProperty ItemsProperty =
+ DependencyProperty.RegisterAttached(
+ "Items",
+ typeof(IEnumerable),
+ typeof(ComboBoxListCompletionBehavior),
+ new PropertyMetadata(null, OnItemsChanged));
+
+ public static void SetItems(UIElement element, IEnumerable value) =>
+ element.SetValue(ItemsProperty, value);
+
+ public static IEnumerable GetItems(UIElement element) =>
+ (IEnumerable)element.GetValue(ItemsProperty);
+
+ // Completion that is shown, to show the next name with the next Tab
+ private static readonly DependencyProperty StateProperty =
+ DependencyProperty.RegisterAttached(
+ "State",
+ typeof(CompletionState),
+ typeof(ComboBoxListCompletionBehavior),
+ new PropertyMetadata(null));
+
+ private sealed record CompletionState(string Prefix, List Candidates, int Index, string Text);
+
+ private static void OnItemsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not ComboBox comboBox)
+ return;
+
+ comboBox.PreviewKeyDown -= ComboBox_PreviewKeyDown;
+
+ if (e.NewValue != null)
+ comboBox.PreviewKeyDown += ComboBox_PreviewKeyDown;
+ }
+
+ private static void ComboBox_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key != Key.Tab || Keyboard.Modifiers != ModifierKeys.None ||
+ sender is not ComboBox { IsEditable: true, IsReadOnly: false } comboBox)
+ return;
+
+ var textBox = comboBox.Template?.FindName("PART_EditableTextBox", comboBox) as TextBox;
+ var text = comboBox.Text ?? string.Empty;
+
+ // Tab again: the next name for the same entry, after the last one Tab moves the focus
+ if (comboBox.GetValue(StateProperty) is CompletionState state && state.Text == text)
+ {
+ if (state.Index + 1 >= state.Candidates.Count)
+ {
+ comboBox.ClearValue(StateProperty);
+ return;
+ }
+
+ Complete(comboBox, textBox, state with { Index = state.Index + 1 }, e);
+ return;
+ }
+
+ // The text typed by the user, without a rest that the ComboBox completed and selected (e.g. "Webs[erver]")
+ if (textBox is { SelectionLength: > 0 } &&
+ textBox.SelectionStart + textBox.SelectionLength == text.Length)
+ text = text[..textBox.SelectionStart];
+
+ var (prefix, entry) = ListEntryCompletionHelper.SplitLastEntry(text);
+ var candidates = ListEntryCompletionHelper.GetCandidates(entry, GetItems(comboBox) ?? []);
+
+ // Nothing to complete: Tab moves the focus
+ if (candidates.Count == 0)
+ return;
+
+ // The entry is already the first name: start with the next one (if there is none, Tab moves the focus)
+ var index = string.Equals(candidates[0], entry.Trim(), StringComparison.OrdinalIgnoreCase) ? 1 : 0;
+
+ if (index >= candidates.Count)
+ return;
+
+ Complete(comboBox, textBox, new CompletionState(prefix, candidates, index, null), e);
+ }
+
+ private static void Complete(ComboBox comboBox, TextBox textBox, CompletionState state, KeyEventArgs e)
+ {
+ var text = state.Prefix + state.Candidates[state.Index];
+
+ comboBox.Text = text;
+ comboBox.SetValue(StateProperty, state with { Text = text });
+
+ // Caret at the end, nothing selected - also after the ComboBox updated its text box
+ if (textBox != null)
+ {
+ textBox.Select(text.Length, 0);
+ textBox.Dispatcher.BeginInvoke(DispatcherPriority.Input,
+ new Action(() => textBox.Select(textBox.Text.Length, 0)));
+ }
+
+ e.Handled = true;
+ }
+}
diff --git a/Source/NETworkManager.Controls/NETworkManager.Controls.csproj b/Source/NETworkManager.Controls/NETworkManager.Controls.csproj
index 4de1ff2b59..9203863a62 100644
--- a/Source/NETworkManager.Controls/NETworkManager.Controls.csproj
+++ b/Source/NETworkManager.Controls/NETworkManager.Controls.csproj
@@ -13,4 +13,7 @@
+
+
+
diff --git a/Source/NETworkManager.Tests/Utilities/ListEntryCompletionHelperTests.cs b/Source/NETworkManager.Tests/Utilities/ListEntryCompletionHelperTests.cs
new file mode 100644
index 0000000000..943639d56e
--- /dev/null
+++ b/Source/NETworkManager.Tests/Utilities/ListEntryCompletionHelperTests.cs
@@ -0,0 +1,54 @@
+using NETworkManager.Utilities;
+using Xunit;
+
+namespace NETworkManager.Tests.Utilities;
+
+public class ListEntryCompletionHelperTests
+{
+ private static readonly string[] Names = ["Webserver", "Webserver (Other)", "Database", "DNS (via TCP)", "Mailserver"];
+
+ [Theory]
+ [InlineData("", "", "")]
+ [InlineData("dat", "", "dat")]
+ [InlineData("webserver; dat", "webserver; ", "dat")]
+ [InlineData("webserver;dat", "webserver;", "dat")]
+ [InlineData("webserver; ", "webserver; ", "")]
+ [InlineData("22; 80; Mail", "22; 80; ", "Mail")]
+ public void SplitLastEntry(string text, string expectedPrefix, string expectedEntry)
+ {
+ var (prefix, entry) = ListEntryCompletionHelper.SplitLastEntry(text);
+
+ Assert.Equal(expectedPrefix, prefix);
+ Assert.Equal(expectedEntry, entry);
+ }
+
+ [Fact]
+ public void GetCandidates_StartsWithBeforeContains()
+ {
+ // No name starts with "server", all three contain it
+ Assert.Equal(["Mailserver", "Webserver", "Webserver (Other)"],
+ ListEntryCompletionHelper.GetCandidates("server", Names));
+
+ Assert.Equal(["Webserver", "Webserver (Other)"], ListEntryCompletionHelper.GetCandidates("web", Names));
+ Assert.Equal(["Database", "DNS (via TCP)"], ListEntryCompletionHelper.GetCandidates("d", Names));
+
+ // Case-insensitive, also in the middle of a name
+ Assert.Equal(["DNS (via TCP)"], ListEntryCompletionHelper.GetCandidates("tcp", Names));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ [InlineData("xyz")]
+ public void GetCandidates_None(string entry)
+ {
+ Assert.Empty(ListEntryCompletionHelper.GetCandidates(entry, Names));
+ }
+
+ [Fact]
+ public void GetCandidates_DuplicateNamesOnce()
+ {
+ Assert.Equal(["Webserver"], ListEntryCompletionHelper.GetCandidates("web", ["Webserver", "webserver"]));
+ }
+}
diff --git a/Source/NETworkManager.Utilities/ListEntryCompletionHelper.cs b/Source/NETworkManager.Utilities/ListEntryCompletionHelper.cs
new file mode 100644
index 0000000000..9362636f69
--- /dev/null
+++ b/Source/NETworkManager.Utilities/ListEntryCompletionHelper.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace NETworkManager.Utilities;
+
+///
+/// Completes the last entry of a list separated by ";" with a name (e.g. "webserver; dat" -> "webserver; Database"),
+/// used by the Tab completion of an input (e.g. the ports of the Port Scanner with port profile names).
+///
+public static class ListEntryCompletionHelper
+{
+ ///
+ /// Splits a list into the text before the last entry (as entered, with the separator and the whitespace after
+ /// it) and the last entry (e.g. "webserver; dat" -> "webserver; " and "dat").
+ ///
+ /// List separated by ";".
+ /// Text before the last entry and the last entry.
+ public static (string prefix, string entry) SplitLastEntry(string text)
+ {
+ text ??= string.Empty;
+
+ var start = text.LastIndexOf(';') + 1;
+
+ while (start < text.Length && char.IsWhiteSpace(text[start]))
+ start++;
+
+ return (text[..start], text[start..]);
+ }
+
+ ///
+ /// Gets the names an entry can be completed with: names that start with the entry first, then names that
+ /// contain it (case-insensitive, each sorted by name, every name once).
+ ///
+ /// Entry that is being typed.
+ /// Available names.
+ /// Names, empty if the entry is empty or nothing matches.
+ public static List GetCandidates(string entry, IEnumerable names)
+ {
+ var typed = entry?.Trim();
+
+ if (string.IsNullOrEmpty(typed))
+ return [];
+
+ var distinctNames = names.Where(x => !string.IsNullOrEmpty(x))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ return
+ [
+ .. distinctNames.Where(x => x.StartsWith(typed, StringComparison.OrdinalIgnoreCase)),
+ .. distinctNames.Where(x => !x.StartsWith(typed, StringComparison.OrdinalIgnoreCase) &&
+ x.Contains(typed, StringComparison.OrdinalIgnoreCase))
+ ];
+ }
+}
diff --git a/Source/NETworkManager/PortScannerPortsSuggestions.cs b/Source/NETworkManager/PortScannerPortsSuggestions.cs
index ec70ff2c1f..07c8bcbeba 100644
--- a/Source/NETworkManager/PortScannerPortsSuggestions.cs
+++ b/Source/NETworkManager/PortScannerPortsSuggestions.cs
@@ -55,6 +55,12 @@ public PortScannerPortsSuggestions()
///
public ICollectionView View { get; }
+ ///
+ /// Gets the names of the port profiles, for the Tab completion of an entry (see
+ /// ).
+ ///
+ public ObservableRangeCollection PortProfileNames { get; } = [];
+
private void OnSourceChanged(object sender, NotifyCollectionChangedEventArgs e)
{
Refresh();
@@ -62,6 +68,8 @@ private void OnSourceChanged(object sender, NotifyCollectionChangedEventArgs e)
private void Refresh()
{
+ PortProfileNames.ReplaceAll(SettingsManager.Current.PortScanner_PortProfiles.Select(x => x.Name));
+
_suggestions.ReplaceAll(SettingsManager.Current.PortScanner_PortProfiles
.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.Select(x => new PortScannerPortsSuggestion(x.Name, x.Ports, Strings.PortProfiles))
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 30abb3c5be..e1300adf41 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -132,10 +132,17 @@ public string Ports
}
}
+ private readonly PortScannerPortsSuggestions _portsSuggestions = new();
+
///
/// Gets the port profiles and the ports history for the ports input (grouped).
///
- public ICollectionView PortsSuggestions { get; } = new PortScannerPortsSuggestions().View;
+ public ICollectionView PortsSuggestions => _portsSuggestions.View;
+
+ ///
+ /// Gets the names of the port profiles for the Tab completion of the ports input.
+ ///
+ public IEnumerable PortProfileNames => _portsSuggestions.PortProfileNames;
///
/// Gets or sets a value indicating whether the scan is currently running.
diff --git a/Source/NETworkManager/ViewModels/ProfileViewModel.cs b/Source/NETworkManager/ViewModels/ProfileViewModel.cs
index b53858a0fe..5739768b3b 100644
--- a/Source/NETworkManager/ViewModels/ProfileViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ProfileViewModel.cs
@@ -739,10 +739,17 @@ public string PortScanner_Host
}
}
+ private readonly PortScannerPortsSuggestions _portScannerPortsSuggestions = new();
+
///
/// Gets the port profiles and the ports history of the Port Scanner for the ports input (grouped).
///
- public ICollectionView PortScanner_PortsSuggestions { get; } = new PortScannerPortsSuggestions().View;
+ public ICollectionView PortScanner_PortsSuggestions => _portScannerPortsSuggestions.View;
+
+ ///
+ /// Gets the names of the port profiles for the Tab completion of the ports input of the Port Scanner.
+ ///
+ public IEnumerable PortScanner_PortProfileNames => _portScannerPortsSuggestions.PortProfileNames;
public string PortScanner_Ports
{
diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml
index 63d6dbc72b..09468b7c10 100644
--- a/Source/NETworkManager/Views/PortScannerView.xaml
+++ b/Source/NETworkManager/Views/PortScannerView.xaml
@@ -83,6 +83,7 @@
ItemsSource="{Binding PortsSuggestions}"
TextSearch.TextPath="Text"
ItemTemplate="{StaticResource PortScannerPortsSuggestionTemplate}"
+ controls:ComboBoxListCompletionBehavior.Items="{Binding PortProfileNames}"
mah:TextBoxHelper.Watermark="{x:Static localization:StaticStrings.ExamplePortScanRange}"
IsReadOnly="{Binding Path=IsRunning}"
controls:ComboBoxPasteBehavior.ConvertMultilineToSemicolon="True"
diff --git a/Source/NETworkManager/Views/ProfileChildWindow.xaml b/Source/NETworkManager/Views/ProfileChildWindow.xaml
index 570961f19b..f528eaed75 100644
--- a/Source/NETworkManager/Views/ProfileChildWindow.xaml
+++ b/Source/NETworkManager/Views/ProfileChildWindow.xaml
@@ -11,6 +11,7 @@
xmlns:network="clr-namespace:NETworkManager.Models.Network;assembly=NETworkManager.Models"
xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters"
xmlns:validators="clr-namespace:NETworkManager.Validators;assembly=NETworkManager.Validators"
+ xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls"
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:viewModels="clr-namespace:NETworkManager.ViewModels"
xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
@@ -1723,6 +1724,7 @@
ItemsSource="{Binding PortScanner_PortsSuggestions}"
TextSearch.TextPath="Text"
ItemTemplate="{StaticResource PortScannerPortsSuggestionTemplate}"
+ controls:ComboBoxListCompletionBehavior.Items="{Binding PortScanner_PortProfileNames}"
mah:TextBoxHelper.Watermark="{x:Static localization:StaticStrings.ExamplePortScanRange}"
Style="{StaticResource HistoryComboBox}">
diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md
index 6fae740099..51cf13b848 100644
--- a/Website/docs/application/port-scanner.md
+++ b/Website/docs/application/port-scanner.md
@@ -59,7 +59,7 @@ A scan of more than `65,536` ports (hosts × ports, e.g. 64 hosts with `1-1024`)
### Ports
-The **Ports** input lists the [port profiles](#port-profiles) (with their ports) and the recently scanned ports (history). Select an entry, or type ports, port ranges and names of port profiles separated by `;` (e.g. `Webserver; Database; 8080`). Port profiles are replaced by their ports when the scan starts.
+The **Ports** input lists the [port profiles](#port-profiles) (with their ports) and the recently scanned ports (history). Select an entry, or type ports, port ranges and names of port profiles separated by `;` (e.g. `Webserver; Database; 8080`). Port profiles are replaced by their ports when the scan starts. `Tab` completes the name of a port profile that is being typed (e.g. `webserver; dat` → `webserver; Database`), `Tab` again shows the next matching port profile.
### Context menu
@@ -89,6 +89,7 @@ The search box right of the **Result** header filters the results by IP address,
| Key | Action |
| -------- | ------------------------------------------------------------- |
| `Enter` | Start the scan (in the search box: filter the results) |
+| `Tab` | In the ports input: complete the name of a port profile |
| `Esc` | Cancel the running scan |
| `Ctrl+T` | Open a new tab |
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 31b91a8f13..e0a7946680 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -102,7 +102,7 @@ Release date: **xx.xx.2026**
- The header of each host shows the number of open ports (e.g. `server (10.0.0.1) · 3 Open`), also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- The **Ports** input lists the port profiles (with their ports) and the history, like the DNS servers in **DNS Lookup**: select a port profile, or type ports and port profile names. Replaces the **Select port profile** button and its dialog. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Profiles: the **Ports** of the Port Scanner can contain port profiles (selectable in the list like in the tool), which are referenced by the profile - changes to a port profile apply, also after a rename. The ports are saved with the profile as well and used, with a note, if the port profile can't be found (e.g. a profile file shared from another computer, port profiles are saved in the settings). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
-- Port profiles can be entered by name in the **Ports** input, also several and together with ports (e.g. `Webserver; Database; 8080`, case-insensitive). The name of a port profile must be unique, must not contain `;` (replaced by `,` when updating) and must not be a port or port range. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Port profiles can be entered by name in the **Ports** input, also several and together with ports (e.g. `Webserver; Database; 8080`, case-insensitive). `Tab` completes the name that is being typed (e.g. `webserver; dat` → `webserver; Database`), also in the profile dialog. The name of a port profile must be unique, must not contain `;` (replaced by `,` when updating) and must not be a port or port range. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Add profile...** to the context menu: creates a profile with the host (IP address), the hostname as name and the ports of the shown scan. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added a search box above the result to filter the ports by IP address, hostname, port, protocol, service, description and status, also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- **Copy** > **IP address** / **Hostname** / **Port** / **IP address:Port** is available if multiple results are selected (one value per line, in the order shown). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
From 08d2232d8e0a810c4c7ba1d7b582726d7242e3f0 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:22:10 +0200
Subject: [PATCH 096/146] Refactor: Generic input suggestions with profiles and
history (E0)
PortScannerPortsSuggestions -> InputSuggestions (profiles with a
description + history, grouped, profile names for the Tab completion),
created per input with a factory (ForPortScannerPorts), so the DNS server
input of DNS Lookup can use the same list. Template
PortScannerPortsSuggestionTemplate -> InputSuggestionTemplate (Description
instead of Ports). No behaviour change.
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager/InputSuggestions.cs | 109 ++++++++++++++++++
.../PortScannerPortsSuggestions.cs | 79 -------------
.../Resources/Styles/ComboBoxStyles.xaml | 8 +-
.../ViewModels/PortScannerViewModel.cs | 4 +-
.../ViewModels/ProfileViewModel.cs | 4 +-
.../NETworkManager/Views/PortScannerView.xaml | 2 +-
.../Views/ProfileChildWindow.xaml | 2 +-
7 files changed, 119 insertions(+), 89 deletions(-)
create mode 100644 Source/NETworkManager/InputSuggestions.cs
delete mode 100644 Source/NETworkManager/PortScannerPortsSuggestions.cs
diff --git a/Source/NETworkManager/InputSuggestions.cs b/Source/NETworkManager/InputSuggestions.cs
new file mode 100644
index 0000000000..2fd1d0aa87
--- /dev/null
+++ b/Source/NETworkManager/InputSuggestions.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Linq;
+using System.Windows.Data;
+using NETworkManager.Controls;
+using NETworkManager.Localization.Resources;
+using NETworkManager.Settings;
+
+namespace NETworkManager;
+
+///
+/// Entry of an input with profiles and history (e.g. the ports of the Port Scanner): a profile (with a description,
+/// e.g. its ports) or an entry of the history.
+///
+/// Text inserted into the input: name of the profile or the history entry.
+/// Description of the profile (e.g. its ports), null for a history entry.
+/// Group in the list (profiles, history).
+public sealed record InputSuggestion(string Text, string Description, string Group)
+{
+ ///
+ /// The text, e.g. kept in an editable ComboBox when the entry is removed from the list (see
+ /// ).
+ ///
+ public override string ToString()
+ {
+ return Text;
+ }
+}
+
+///
+/// Profiles and history for an input (an editable ComboBox like the ports of the Port Scanner or the DNS server of
+/// DNS Lookup): the profiles and the history, grouped, and the names of the profiles for the Tab completion.
+/// Updated when the profiles or the history change (weak events, the list doesn't keep a closed tab or dialog
+/// alive).
+///
+public sealed class InputSuggestions
+{
+ private readonly ObservableRangeCollection _suggestions = [];
+ private readonly Func> _getProfiles;
+ private readonly string _profilesGroup;
+ private readonly ObservableCollection _history;
+
+ ///
+ /// Creates the list with the current profiles and history.
+ ///
+ /// Collection of the profiles (to update the list when it changes).
+ /// Gets the names and descriptions of the profiles, in the order shown.
+ /// Name of the group of the profiles.
+ /// History of the input.
+ private InputSuggestions(INotifyCollectionChanged profiles,
+ Func> getProfiles, string profilesGroup,
+ ObservableCollection history)
+ {
+ _getProfiles = getProfiles;
+ _profilesGroup = profilesGroup;
+ _history = history;
+
+ View = new CollectionViewSource { Source = _suggestions }.View;
+ View.GroupDescriptions.Add(new PropertyGroupDescription(nameof(InputSuggestion.Group)));
+
+ CollectionChangedEventManager.AddHandler(profiles, OnSourceChanged);
+ CollectionChangedEventManager.AddHandler(history, OnSourceChanged);
+
+ Refresh();
+ }
+
+ ///
+ /// Creates the list for the ports of the Port Scanner: the port profiles (sorted by name, with their ports) and
+ /// the ports history.
+ ///
+ public static InputSuggestions ForPortScannerPorts()
+ {
+ return new InputSuggestions(SettingsManager.Current.PortScanner_PortProfiles,
+ () => SettingsManager.Current.PortScanner_PortProfiles
+ .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(x => (x.Name, x.Ports)),
+ Strings.PortProfiles, SettingsManager.Current.PortScanner_PortHistory);
+ }
+
+ ///
+ /// Gets the view of the entries, grouped (profiles, history).
+ ///
+ public ICollectionView View { get; }
+
+ ///
+ /// Gets the names of the profiles, for the Tab completion of an entry (see
+ /// ).
+ ///
+ public ObservableRangeCollection ProfileNames { get; } = [];
+
+ private void OnSourceChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ Refresh();
+ }
+
+ private void Refresh()
+ {
+ var profiles = _getProfiles().ToList();
+
+ ProfileNames.ReplaceAll(profiles.Select(x => x.Name));
+
+ _suggestions.ReplaceAll(profiles
+ .Select(x => new InputSuggestion(x.Name, x.Description, _profilesGroup))
+ .Concat(_history.Select(x => new InputSuggestion(x, null, Strings.History))));
+ }
+}
diff --git a/Source/NETworkManager/PortScannerPortsSuggestions.cs b/Source/NETworkManager/PortScannerPortsSuggestions.cs
deleted file mode 100644
index 07c8bcbeba..0000000000
--- a/Source/NETworkManager/PortScannerPortsSuggestions.cs
+++ /dev/null
@@ -1,79 +0,0 @@
-using System;
-using System.Collections.Specialized;
-using System.ComponentModel;
-using System.Linq;
-using System.Windows.Data;
-using NETworkManager.Controls;
-using NETworkManager.Localization.Resources;
-using NETworkManager.Settings;
-
-namespace NETworkManager;
-
-///
-/// Entry of the ports input of the Port Scanner: a port profile (with its ports) or an entry of the history.
-///
-/// Text inserted into the input: name of the port profile or the history entry.
-/// Ports of the port profile, null for a history entry.
-/// Group in the list (port profiles, history).
-public sealed record PortScannerPortsSuggestion(string Text, string Ports, string Group)
-{
- ///
- /// The text, e.g. kept in an editable ComboBox when the entry is removed from the list (see
- /// ).
- ///
- public override string ToString()
- {
- return Text;
- }
-}
-
-///
-/// Port profiles and history of the Port Scanner for the ports input (an editable ComboBox like the DNS server of
-/// DNS Lookup): the port profiles (sorted by name) and the history, grouped. Updated when the port profiles or the
-/// history change (weak events, the list doesn't keep a closed tab or dialog alive).
-///
-public sealed class PortScannerPortsSuggestions
-{
- private readonly ObservableRangeCollection _suggestions = [];
-
- ///
- /// Creates the list with the current port profiles and history.
- ///
- public PortScannerPortsSuggestions()
- {
- View = new CollectionViewSource { Source = _suggestions }.View;
- View.GroupDescriptions.Add(new PropertyGroupDescription(nameof(PortScannerPortsSuggestion.Group)));
-
- CollectionChangedEventManager.AddHandler(SettingsManager.Current.PortScanner_PortProfiles, OnSourceChanged);
- CollectionChangedEventManager.AddHandler(SettingsManager.Current.PortScanner_PortHistory, OnSourceChanged);
-
- Refresh();
- }
-
- ///
- /// Gets the view of the entries, grouped (port profiles, history).
- ///
- public ICollectionView View { get; }
-
- ///
- /// Gets the names of the port profiles, for the Tab completion of an entry (see
- /// ).
- ///
- public ObservableRangeCollection PortProfileNames { get; } = [];
-
- private void OnSourceChanged(object sender, NotifyCollectionChangedEventArgs e)
- {
- Refresh();
- }
-
- private void Refresh()
- {
- PortProfileNames.ReplaceAll(SettingsManager.Current.PortScanner_PortProfiles.Select(x => x.Name));
-
- _suggestions.ReplaceAll(SettingsManager.Current.PortScanner_PortProfiles
- .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
- .Select(x => new PortScannerPortsSuggestion(x.Name, x.Ports, Strings.PortProfiles))
- .Concat(SettingsManager.Current.PortScanner_PortHistory
- .Select(x => new PortScannerPortsSuggestion(x, null, Strings.History))));
- }
-}
diff --git a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
index a5b9abe829..c5b2e6ff03 100644
--- a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
+++ b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
@@ -24,7 +24,7 @@
-
+
@@ -33,13 +33,13 @@
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Margin="5,5,5,2" />
-
+
-
+ Visibility="{Binding Path=Description, Converter={StaticResource ComboBoxStringIsNotNullOrEmptyToVisibilityCollapsedConverter}}" />
\ No newline at end of file
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index e1300adf41..db323f1894 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -132,7 +132,7 @@ public string Ports
}
}
- private readonly PortScannerPortsSuggestions _portsSuggestions = new();
+ private readonly InputSuggestions _portsSuggestions = InputSuggestions.ForPortScannerPorts();
///
/// Gets the port profiles and the ports history for the ports input (grouped).
@@ -142,7 +142,7 @@ public string Ports
///
/// Gets the names of the port profiles for the Tab completion of the ports input.
///
- public IEnumerable PortProfileNames => _portsSuggestions.PortProfileNames;
+ public IEnumerable PortProfileNames => _portsSuggestions.ProfileNames;
///
/// Gets or sets a value indicating whether the scan is currently running.
diff --git a/Source/NETworkManager/ViewModels/ProfileViewModel.cs b/Source/NETworkManager/ViewModels/ProfileViewModel.cs
index 5739768b3b..7c36f91f88 100644
--- a/Source/NETworkManager/ViewModels/ProfileViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ProfileViewModel.cs
@@ -739,7 +739,7 @@ public string PortScanner_Host
}
}
- private readonly PortScannerPortsSuggestions _portScannerPortsSuggestions = new();
+ private readonly InputSuggestions _portScannerPortsSuggestions = InputSuggestions.ForPortScannerPorts();
///
/// Gets the port profiles and the ports history of the Port Scanner for the ports input (grouped).
@@ -749,7 +749,7 @@ public string PortScanner_Host
///
/// Gets the names of the port profiles for the Tab completion of the ports input of the Port Scanner.
///
- public IEnumerable PortScanner_PortProfileNames => _portScannerPortsSuggestions.PortProfileNames;
+ public IEnumerable PortScanner_PortProfileNames => _portScannerPortsSuggestions.ProfileNames;
public string PortScanner_Ports
{
diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml
index 09468b7c10..02e56d2ee0 100644
--- a/Source/NETworkManager/Views/PortScannerView.xaml
+++ b/Source/NETworkManager/Views/PortScannerView.xaml
@@ -82,7 +82,7 @@
x:Name="ComboBoxPorts"
ItemsSource="{Binding PortsSuggestions}"
TextSearch.TextPath="Text"
- ItemTemplate="{StaticResource PortScannerPortsSuggestionTemplate}"
+ ItemTemplate="{StaticResource InputSuggestionTemplate}"
controls:ComboBoxListCompletionBehavior.Items="{Binding PortProfileNames}"
mah:TextBoxHelper.Watermark="{x:Static localization:StaticStrings.ExamplePortScanRange}"
IsReadOnly="{Binding Path=IsRunning}"
diff --git a/Source/NETworkManager/Views/ProfileChildWindow.xaml b/Source/NETworkManager/Views/ProfileChildWindow.xaml
index f528eaed75..c7080e8b96 100644
--- a/Source/NETworkManager/Views/ProfileChildWindow.xaml
+++ b/Source/NETworkManager/Views/ProfileChildWindow.xaml
@@ -1723,7 +1723,7 @@
From ce5b4a82c44cd6259c282f59558dbba84c28e46b Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:25:15 +0200
Subject: [PATCH 097/146] Feature: DNS Lookup several DNS servers and DNS
server names in a list (E1)
The DNS server input accepts several entries separated by ";": names of
DNS server profiles (case-insensitive) and DNS servers (IP address or
hostname, optional port), e.g. "Google Public DNS; Cloudflare; 9.9.9.9".
Shared resolver DNSServer.ResolveServers (every server once, [Windows DNS]
alone uses the Windows resolver as before, in a list it adds the DNS
servers of Windows) and GetInvalidEntries for the validators; unit tests.
Fixes: the tool matched the name case-sensitively while the run command
validated it case-insensitively - "dns host cloudflare" was resolved as
hostname "cloudflare".
Run command: every entry is validated (custom servers still need an IP or a
domain), suggestions also after a ";". DNS server names must not contain
";" (validator in the server profile dialog, shared with SNTP; the update
replaces ";" with ","), the name of [Windows DNS] counts as used.
Co-Authored-By: Claude Opus 5.5
---
.../Resources/Strings.Designer.cs | 18 +++
.../Resources/Strings.resx | 6 +
.../Network/DNSServer.cs | 131 +++++++++++++++++-
.../SettingsManager.cs | 8 ++
.../Network/DNSServerTests.cs | 57 ++++++++
.../DNSServersValidator.cs | 29 ++++
.../NoSemicolonValidator.cs | 19 +++
Source/NETworkManager/RunCommandManager.cs | 21 +--
.../ViewModels/DNSLookupSettingsViewModel.cs | 4 +-
.../ViewModels/DNSLookupViewModel.cs | 31 +----
.../ViewModels/RunCommandViewModel.cs | 5 +-
...erverConnectionInfoProfileChildWindow.xaml | 2 +
Website/docs/application/dns-lookup.md | 3 +-
Website/docs/changelog/next-release.md | 2 +
Website/docs/run-command.md | 2 +-
15 files changed, 293 insertions(+), 45 deletions(-)
create mode 100644 Source/NETworkManager.Tests/Network/DNSServerTests.cs
create mode 100644 Source/NETworkManager.Validators/DNSServersValidator.cs
create mode 100644 Source/NETworkManager.Validators/NoSemicolonValidator.cs
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index 12b38682f9..1ee1dcf93d 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -4292,6 +4292,15 @@ public static string EnterValidBaud {
}
}
+ ///
+ /// Looks up a localized string similar to Enter valid DNS servers (IP address or hostname, optional port) and/or names of DNS servers!.
+ ///
+ public static string EnterValidDNSServersOrDNSServerProfiles {
+ get {
+ return ResourceManager.GetString("EnterValidDNSServersOrDNSServerProfiles", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Enter a valid domain (e.g., "example.com")!.
///
@@ -4625,6 +4634,15 @@ public static string ErrorMessage_NameIsAlreadyUsed {
}
}
+ ///
+ /// Looks up a localized string similar to The name must not contain ";"..
+ ///
+ public static string ErrorMessage_NameMustNotContainSemicolon {
+ get {
+ return ResourceManager.GetString("ErrorMessage_NameMustNotContainSemicolon", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The name must not contain ";" and must not be a port or port range..
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index d79e6862ca..ec25663be6 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -5116,4 +5116,10 @@ Do you want to start the scan?
{0} host(s) did not answer the ping and were not scanned.
+
+ Enter valid DNS servers (IP address or hostname, optional port) and/or names of DNS servers!
+
+
+ The name must not contain ";".
+
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Network/DNSServer.cs b/Source/NETworkManager.Models/Network/DNSServer.cs
index acbd0270ee..d55a2c564f 100644
--- a/Source/NETworkManager.Models/Network/DNSServer.cs
+++ b/Source/NETworkManager.Models/Network/DNSServer.cs
@@ -1,4 +1,8 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using DnsClient;
namespace NETworkManager.Models.Network;
@@ -7,6 +11,131 @@ namespace NETworkManager.Models.Network;
///
public static class DNSServer
{
+ ///
+ /// Separator of the entries of a DNS server input (e.g. "Google Public DNS; 9.9.9.9").
+ ///
+ private const char EntrySeparator = ';';
+
+ ///
+ /// Default port of a DNS server entered without a port.
+ ///
+ private const int DefaultPort = 53;
+
+ ///
+ /// Finds a DNS server profile by its name (case-insensitive, leading / trailing spaces are ignored).
+ ///
+ /// DNS server profiles.
+ /// Name of the DNS server profile.
+ /// DNS server profile, or null if there is none with the name.
+ public static DNSServerConnectionInfoProfile FindByName(IEnumerable profiles,
+ string name)
+ {
+ var trimmedName = name?.Trim();
+
+ return string.IsNullOrEmpty(trimmedName)
+ ? null
+ : profiles.FirstOrDefault(x =>
+ string.Equals(x.Name?.Trim(), trimmedName, StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Gets the entries of a DNS server input that are neither the name of a DNS server profile nor a DNS server
+ /// (see ), without resolving anything.
+ ///
+ /// DNS server input.
+ /// DNS server profiles that can be used by their name.
+ ///
+ /// A hostname must contain a dot (e.g. the run command, where a single word can also be a mistyped type).
+ ///
+ /// Invalid entries, the input itself if it is empty.
+ public static List GetInvalidEntries(string input, IEnumerable profiles,
+ bool hostnameNeedsDomain = false)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return [input ?? string.Empty];
+
+ var profileList = profiles.ToList();
+
+ return
+ [
+ .. input.Split(EntrySeparator)
+ .Where(entry => FindByName(profileList, entry) == null &&
+ !TryParseServer(entry, hostnameNeedsDomain, out _))
+ .Select(entry => entry.Trim())
+ ];
+ }
+
+ ///
+ /// Resolves a DNS server input into DNS servers. Every entry (separated by ";") is the name of a DNS server
+ /// profile (e.g. "Google Public DNS", case-insensitive), which is replaced by its servers, or a DNS server (IP
+ /// address or hostname, optional port, e.g. "9.9.9.9" or "dns.example.com:5353") - e.g. "Google Public DNS;
+ /// 9.9.9.9". The Windows DNS profile in a list is replaced by the DNS servers of Windows. Every server only once.
+ ///
+ /// DNS server input.
+ /// DNS server profiles that can be used by their name.
+ ///
+ /// True if the input is only the Windows DNS profile: the DNS servers of Windows are used (no servers are
+ /// returned).
+ ///
+ /// Entries that are neither a DNS server profile nor a DNS server (skipped).
+ /// DNS servers (hostnames are not resolved).
+ public static List ResolveServers(string input,
+ IEnumerable profiles, out bool useWindowsDNSServer,
+ out List invalidEntries)
+ {
+ useWindowsDNSServer = false;
+ invalidEntries = [];
+
+ List servers = [];
+
+ if (string.IsNullOrWhiteSpace(input))
+ return servers;
+
+ var profileList = profiles.ToList();
+ var entries = input.Split(EntrySeparator);
+
+ foreach (var entry in entries)
+ {
+ var profile = FindByName(profileList, entry);
+
+ if (profile is { UseWindowsDNSServer: true })
+ {
+ // Only the Windows DNS profile: DNS lookup uses the DNS servers of Windows itself
+ if (entries.Length == 1)
+ {
+ useWindowsDNSServer = true;
+ return servers;
+ }
+
+ servers.AddRange(NameServer.ResolveNameServers(true, false).Select(x =>
+ new ServerConnectionInfo(x.Address, x.Port, TransportProtocol.Udp)));
+ }
+ else if (profile != null)
+ {
+ servers.AddRange(profile.Servers);
+ }
+ else if (TryParseServer(entry, false, out var server))
+ {
+ servers.Add(server);
+ }
+ else
+ {
+ invalidEntries.Add(entry.Trim());
+ }
+ }
+
+ return [.. servers.DistinctBy(x => (x.Server.ToLowerInvariant(), x.Port, x.TransportProtocol))];
+ }
+
+ ///
+ /// Parses a DNS server entry (IP address or hostname, optional port).
+ ///
+ private static bool TryParseServer(string entry, bool hostnameNeedsDomain, out ServerConnectionInfo server)
+ {
+ return ServerConnectionInfo.TryParse(entry, out server, DefaultPort, TransportProtocol.Udp) &&
+ (!hostnameNeedsDomain || IPAddress.TryParse(server.Server, out _) || server.Server.Contains('.'));
+ }
+
///
/// Gets the default list of common dns servers.
///
diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs
index f07013f902..db2ace57b7 100644
--- a/Source/NETworkManager.Settings/SettingsManager.cs
+++ b/Source/NETworkManager.Settings/SettingsManager.cs
@@ -792,6 +792,14 @@ private static void UpgradeToLatest(Version version)
Current.PortScanner_MaxPortThreads = GlobalStaticConfiguration.PortScanner_MaxPortThreads;
}
+ // DNS Lookup - DNS servers can be entered by name in a list (e.g. "Google Public DNS; Cloudflare"), where ";"
+ // separates the entries: replace it in the names
+ foreach (var dnsServer in Current.DNSLookup_DNSServers.Where(x => x.Name?.Contains(';') == true))
+ {
+ Log.Info($"Replace \";\" in the name of the DNS server \"{dnsServer.Name}\"...");
+ dnsServer.Name = dnsServer.Name.Replace(';', ',');
+ }
+
// Port Scanner - port profiles have an internal ID (profiles reference them by it)
foreach (var portProfile in Current.PortScanner_PortProfiles.Where(x => x.Id == Guid.Empty))
{
diff --git a/Source/NETworkManager.Tests/Network/DNSServerTests.cs b/Source/NETworkManager.Tests/Network/DNSServerTests.cs
new file mode 100644
index 0000000000..0093301b21
--- /dev/null
+++ b/Source/NETworkManager.Tests/Network/DNSServerTests.cs
@@ -0,0 +1,57 @@
+using System.Collections.Generic;
+using System.Linq;
+using NETworkManager.Models.Network;
+using Xunit;
+
+namespace NETworkManager.Tests.Network;
+
+public class DNSServerTests
+{
+ private static readonly List Profiles = DNSServer.GetDefaultList();
+
+ [Theory]
+ [InlineData("Cloudflare", "1.1.1.1:53; 1.0.0.1:53")]
+ [InlineData("cloudflare", "1.1.1.1:53; 1.0.0.1:53")]
+ [InlineData("Google Public DNS;Cloudflare", "8.8.8.8:53; 8.8.4.4:53; 1.1.1.1:53; 1.0.0.1:53")]
+ [InlineData(" Quad9 ; 1.1.1.1", "9.9.9.9:53; 149.112.112.112:53; 1.1.1.1:53")]
+ [InlineData("1.1.1.1:5353; dns.example.com", "1.1.1.1:5353; dns.example.com:53")]
+ [InlineData("Cloudflare; 1.1.1.1", "1.1.1.1:53; 1.0.0.1:53")]
+ public void ResolveServers(string input, string expected)
+ {
+ var servers = DNSServer.ResolveServers(input, Profiles, out var useWindowsDNSServer, out var invalidEntries);
+
+ Assert.False(useWindowsDNSServer);
+ Assert.Empty(invalidEntries);
+ Assert.Equal(expected, string.Join("; ", servers.Select(x => $"{x.Server}:{x.Port}")));
+ }
+
+ [Fact]
+ public void ResolveServers_WindowsDNSServerAlone()
+ {
+ var servers = DNSServer.ResolveServers("[windows dns]", Profiles, out var useWindowsDNSServer, out _);
+
+ Assert.True(useWindowsDNSServer);
+ Assert.Empty(servers);
+ }
+
+ [Fact]
+ public void ResolveServers_InvalidEntriesAreSkipped()
+ {
+ var servers = DNSServer.ResolveServers("Cloudflare; not valid!", Profiles, out _, out var invalidEntries);
+
+ Assert.Equal(2, servers.Count);
+ Assert.Equal(["not valid!"], invalidEntries);
+ }
+
+ [Theory]
+ [InlineData("Cloudflare", false, 0)]
+ [InlineData("google public dns;9.9.9.9", true, 0)]
+ [InlineData("dns01", false, 0)]
+ [InlineData("dns01", true, 1)]
+ [InlineData("Cloudflare;aaa", true, 1)]
+ [InlineData("", false, 1)]
+ public void GetInvalidEntries(string input, bool hostnameNeedsDomain, int expectedCount)
+ {
+ Assert.Equal(expectedCount, DNSServer.GetInvalidEntries(input, Profiles, hostnameNeedsDomain).Count);
+ }
+}
diff --git a/Source/NETworkManager.Validators/DNSServersValidator.cs b/Source/NETworkManager.Validators/DNSServersValidator.cs
new file mode 100644
index 0000000000..4976a50776
--- /dev/null
+++ b/Source/NETworkManager.Validators/DNSServersValidator.cs
@@ -0,0 +1,29 @@
+using System.Globalization;
+using System.Windows.Controls;
+using NETworkManager.Localization.Resources;
+using NETworkManager.Models.Network;
+using NETworkManager.Settings;
+
+namespace NETworkManager.Validators;
+
+///
+/// Validates a DNS server input of DNS Lookup: names of DNS servers and DNS servers (IP address or hostname,
+/// optional port) separated by ";" (e.g. "Google Public DNS; 9.9.9.9"), see .
+///
+public class DNSServersValidator : ValidationRule
+{
+ ///
+ /// An empty input is valid (e.g. the DNS server of a profile).
+ ///
+ public bool AllowEmpty { get; set; }
+
+ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
+ {
+ if (AllowEmpty && string.IsNullOrWhiteSpace(value as string))
+ return ValidationResult.ValidResult;
+
+ return DNSServer.GetInvalidEntries(value as string, SettingsManager.Current.DNSLookup_DNSServers).Count == 0
+ ? ValidationResult.ValidResult
+ : new ValidationResult(false, Strings.EnterValidDNSServersOrDNSServerProfiles);
+ }
+}
diff --git a/Source/NETworkManager.Validators/NoSemicolonValidator.cs b/Source/NETworkManager.Validators/NoSemicolonValidator.cs
new file mode 100644
index 0000000000..f5e21b25c3
--- /dev/null
+++ b/Source/NETworkManager.Validators/NoSemicolonValidator.cs
@@ -0,0 +1,19 @@
+using System.Globalization;
+using System.Windows.Controls;
+using NETworkManager.Localization.Resources;
+
+namespace NETworkManager.Validators;
+
+///
+/// Validates that a name doesn't contain ";" - e.g. the name of a DNS server, which can be entered in a list
+/// separated by ";".
+///
+public class NoSemicolonValidator : ValidationRule
+{
+ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
+ {
+ return (value as string)?.Contains(';') == true
+ ? new ValidationResult(false, Strings.ErrorMessage_NameMustNotContainSemicolon)
+ : ValidationResult.ValidResult;
+ }
+}
diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs
index 1c80e0a49a..65da272890 100644
--- a/Source/NETworkManager/RunCommandManager.cs
+++ b/Source/NETworkManager/RunCommandManager.cs
@@ -358,24 +358,17 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT
}
///
- /// Validates the DNS server argument: the name of a configured DNS server, or custom DNS server(s) in the format
- /// of the DNS lookup (IP address or host with a domain, optional port). A single word that is neither (e.g. a
- /// mistyped type like "aaa") is an unknown argument instead of a DNS server.
+ /// Validates the DNS server argument: names of configured DNS servers (case-insensitive) and / or custom DNS
+ /// servers in the format of the DNS lookup (IP address or host with a domain, optional port), separated by ";"
+ /// (e.g. "google;cloudflare;9.9.9.9"). A single word that is neither (e.g. a mistyped type like "aaa") is an
+ /// unknown argument instead of a DNS server.
///
private static string ValidateDNSServer(string dnsServer)
{
- if (GetDNSServers().Any(x => string.Equals(x, dnsServer, StringComparison.OrdinalIgnoreCase)))
- return null;
+ var invalidEntry = DNSServer.GetInvalidEntries(dnsServer, SettingsManager.Current.DNSLookup_DNSServers,
+ hostnameNeedsDomain: true).FirstOrDefault();
- foreach (var server in dnsServer.Split(';',
- StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
- {
- if (!ServerConnectionInfo.TryParse(server, out var serverInfo, 53, TransportProtocol.Udp) ||
- !IPAddress.TryParse(serverInfo.Server, out _) && !serverInfo.Server.Contains('.'))
- return string.Format(Strings.UnknownArgumentX, server);
- }
-
- return null;
+ return invalidEntry == null ? null : string.Format(Strings.UnknownArgumentX, invalidEntry);
}
///
diff --git a/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs
index d9dcee5604..61a356dd20 100644
--- a/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs
@@ -59,8 +59,8 @@ public DNSServerConnectionInfoProfile SelectedDNSServer
///
private List ServerInfoProfileNames =>
[
- .. SettingsManager.Current.DNSLookup_DNSServers
- .Where(x => !x.UseWindowsDNSServer).Select(x => x.Name)
+ // Also the Windows DNS server - names are used in the DNS server input, they must be unique
+ .. SettingsManager.Current.DNSLookup_DNSServers.Select(x => x.Name)
];
///
diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
index 5233973d29..aab3413878 100644
--- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
+++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
@@ -410,32 +410,15 @@ private async Task QueryAsync()
dnsSettings.CustomDNSSuffix = SettingsManager.Current.DNSLookup_CustomDNSSuffix?.TrimStart('.');
}
- // Try to find DNS server profile
- var dnsServerProfile = SettingsManager.Current.DNSLookup_DNSServers
- .FirstOrDefault(x => x.Name == DNSServer);
+ // DNS server profiles (by name, case-insensitive) and DNS servers, also several (e.g. "Google Public DNS;
+ // 9.9.9.9")
+ var dnsServersToResolve = Models.Network.DNSServer.ResolveServers(DNSServer,
+ SettingsManager.Current.DNSLookup_DNSServers, out var useWindowsDNSServer, out var invalidEntries);
- DNSLookup dnsLookup = null;
- List dnsServersToResolve = [];
+ foreach (var invalidEntry in invalidEntries)
+ AppendStatusMessage(Strings.DNSServer + ": " + string.Format(Strings.CouldNotParseX, invalidEntry));
- // Use DNS server profile if found
- if (dnsServerProfile != null)
- {
- if (dnsServerProfile.UseWindowsDNSServer)
- dnsLookup = new DNSLookup(dnsSettings);
- else
- dnsServersToResolve = dnsServerProfile.Servers;
- }
- // Parse DNS server input
- else
- {
- foreach (var dnsServerInput in DNSServer.Split(';'))
- {
- if (ServerConnectionInfo.TryParse(dnsServerInput, out var serverInfo, 53, TransportProtocol.Udp))
- dnsServersToResolve.Add(serverInfo);
- else
- AppendStatusMessage(Strings.DNSServer + ": " + string.Format(Strings.CouldNotParseX, dnsServerInput));
- }
- }
+ var dnsLookup = useWindowsDNSServer ? new DNSLookup(dnsSettings) : null;
// Resolve DNS server hostnames (if any) - not required when using Windows DNS servers
if (dnsLookup == null)
diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
index 94eb4388b3..07a34dd5df 100644
--- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
+++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
@@ -1143,13 +1143,14 @@ private IEnumerable GetPortProfileSuggestions()
///
/// DNS servers configured in the DNS lookup (e.g. "Cloudflare") that contain the typed word, if no DNS server was
- /// entered yet.
+ /// entered yet. Several DNS servers can be entered (e.g. "\"Google Public DNS\";cloudflare"), the entry after
+ /// the last ";" is completed.
///
private IEnumerable GetDNSServerSuggestions()
{
return GetArgumentValueSuggestions(RunCommandArgument.DNSServer,
RunCommandManager.GetDNSServers().Select(x => (x, Strings.DNSServer)), RunCommandSuggestionKind.DNSServer,
- Strings.DNSServers);
+ Strings.DNSServers, isList: true);
}
///
diff --git a/Source/NETworkManager/Views/ServerConnectionInfoProfileChildWindow.xaml b/Source/NETworkManager/Views/ServerConnectionInfoProfileChildWindow.xaml
index 3a3ccb48f6..51e25d336a 100644
--- a/Source/NETworkManager/Views/ServerConnectionInfoProfileChildWindow.xaml
+++ b/Source/NETworkManager/Views/ServerConnectionInfoProfileChildWindow.xaml
@@ -52,6 +52,8 @@
+
+ |:` (`` is optional, to use a custom port with IPv6 enclose the address in square brackets: `[]:53`).
+The DNS server can be selected from a list of configured servers or you can enter a custom DNS server in the format `|:` (`` is optional, to use a custom port with IPv6 enclose the address in square brackets: `[]:53`). Names of configured DNS servers (case-insensitive) and custom DNS servers can be combined with a semicolon (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once. `[Windows DNS]` together with other DNS servers adds the DNS servers configured in Windows.
### Context menu
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index e0a7946680..bddd837f12 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -127,6 +127,7 @@ Release date: **xx.xx.2026**
**DNS Lookup**
+- Several DNS servers can be entered, also names of configured DNS servers together with custom DNS servers (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once; `[Windows DNS]` in a list adds the DNS servers of Windows. The name of a DNS server must not contain `;` (replaced by `,` when updating). Also with the run command (`dns borntoberoot.net "Google Public DNS";cloudflare`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- IP addresses opened from another feature (e.g. **IP Scanner** > **DNS lookup** > **IP address**), from a profile or with the run command are looked up with `PTR` instead of the selected record type. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644)
**Hosts File Editor**
@@ -246,6 +247,7 @@ Release date: **xx.xx.2026**
**DNS Lookup**
+- Fixed a DNS server opened with the run command in another case than configured (e.g. `cloudflare` for `Cloudflare`) being looked up as hostname instead of using the configured DNS server. Names of DNS servers are case-insensitive now. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed **Add DNS suffix (primary) to hostname** silently appending no suffix at all on machines without a Windows primary DNS suffix configured (e.g. not domain-joined), even though a usable suffix was available via the active network adapter's connection-specific DNS suffix (shown under **DNS Suffix Search List** in `ipconfig /all`). It now falls back to that adapter's suffix in this case. [#3591](https://github.com/BornToBeRoot/NETworkManager/pull/3591)
- Fixed columns that couldn't be made wider than the window (by dragging or double-clicking the gripper) in the result and the DNS server profiles in the settings. The grid scrolls horizontally now, the last (or main) column still fills the remaining width. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557)
diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md
index 9af8ef4a32..bc2348226e 100644
--- a/Website/docs/run-command.md
+++ b/Website/docs/run-command.md
@@ -52,7 +52,7 @@ All features that accept a target also accept a profile (`@profile`), except the
:::note
- **Remote Desktop, PowerShell, PuTTY, TigerVNC and Web Console** open the connect dialog with the host, so the connection settings can be adjusted before connecting. A profile connects directly with its settings.
-- **DNS lookup:** The type is the DNS record type (`a`, `aaaa`, `any`, `caa`, `cname`, `dnskey`, `mx`, `ns`, `ptr`, `soa`, `srv`, `txt`). Without a type, IP addresses are looked up with `ptr` and other hosts with the type selected in the feature. The DNS server is the name of a configured DNS server (case-insensitive, e.g. `cloudflare`) or custom DNS server(s): an IP address or a host with a domain, with an optional port, separated by `;` (e.g. `1.1.1.1`, `dns.google:53`).
+- **DNS lookup:** The type is the DNS record type (`a`, `aaaa`, `any`, `caa`, `cname`, `dnskey`, `mx`, `ns`, `ptr`, `soa`, `srv`, `txt`). Without a type, IP addresses are looked up with `ptr` and other hosts with the type selected in the feature. The DNS server is the name of a configured DNS server (case-insensitive, e.g. `cloudflare`) or a custom DNS server (an IP address or a host with a domain, with an optional port, e.g. `1.1.1.1`, `dns.google:53`), also several separated by `;` (e.g. `"Google Public DNS";cloudflare;9.9.9.9`, the configured DNS servers are suggested after a `;` as well).
- **RDAP:** The type is `domain`, `nameserver`, `tld`, `ip`, `asn` or `entity`. Without a type, it is detected from the query (see [RDAP](./application/rdap.md#example-inputs)).
- **Subnet Calculator:** A subnet (e.g. `subnet 10.0.0.0/24`) is calculated in **Calculator**. With a new subnet mask or CIDR (e.g. `/26`, `26` or `255.255.255.192`), it is split in **Subnetting**; the new mask must be between the CIDR of the subnet and `/32` (IPv4) or `/128` (IPv6). With a second subnet (e.g. `subnet 10.0.0.0/24 10.0.1.0/24`), the **Wide Subnet** is calculated (IPv4 only).
- **Lookup:** The first word after the command is the lookup: `oui` (MAC addresses or vendors, e.g. `lookup oui 00:11:22`) or `port` (ports, port ranges or services, e.g. `lookup port 22; 80-90/tcp; ssh`). The rest of the input is the query, so vendors can contain spaces without quotes (e.g. `lookup oui Cisco Systems`). Without a query (e.g. `lookup port`), the lookup is only shown.
From 60459b6d615740c5bd4b29b7af63757532fbc5fb Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:26:27 +0200
Subject: [PATCH 098/146] Feature: DNS Lookup DNS server input with history and
Tab completion (E2)
The DNS server ComboBox shows the DNS servers (Windows DNS first, then by
name, with their servers) and a new history (DNSLookup_DNSServerHistory),
grouped like the ports of the Port Scanner (InputSuggestions); Tab completes
the name of a DNS server that is being typed (ComboBoxListCompletionBehavior),
the input is validated (DNSServersValidator). The SelectedItem binding to a
profile is gone - the input is resolved by name (E1).
A single profile name is not added to the history, it is already in the list
(DNS Lookup and Port Scanner).
Co-Authored-By: Claude Opus 5.5
---
.../NETworkManager.Settings/SettingsInfo.cs | 15 +++++++
Source/NETworkManager/InputSuggestions.cs | 16 +++++++
.../ViewModels/DNSLookupViewModel.cs | 43 +++++++------------
.../ViewModels/PortScannerViewModel.cs | 6 ++-
.../NETworkManager/Views/DNSLookupView.xaml | 18 +++++---
Website/docs/application/dns-lookup.md | 2 +-
Website/docs/changelog/next-release.md | 4 +-
7 files changed, 67 insertions(+), 37 deletions(-)
diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs
index db3738bdcf..a460ab433b 100644
--- a/Source/NETworkManager.Settings/SettingsInfo.cs
+++ b/Source/NETworkManager.Settings/SettingsInfo.cs
@@ -1985,6 +1985,20 @@ public ObservableCollection DNSLookup_HostHistory
}
} = [];
+ public ObservableCollection DNSLookup_DNSServerHistory
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ UpdateCollectionChanged(field, value);
+ field = value;
+ OnPropertyChanged();
+ }
+ } = [];
+
public ObservableCollection DNSLookup_DNSServers
{
get;
@@ -4239,6 +4253,7 @@ public SettingsInfo()
// DNS Lookup
DNSLookup_HostHistory.CollectionChanged += CollectionChanged;
DNSLookup_DNSServers.CollectionChanged += CollectionChanged;
+ DNSLookup_DNSServerHistory.CollectionChanged += CollectionChanged;
// Remote Desktop
RemoteDesktop_HostHistory.CollectionChanged += CollectionChanged;
diff --git a/Source/NETworkManager/InputSuggestions.cs b/Source/NETworkManager/InputSuggestions.cs
index 2fd1d0aa87..c3d3057723 100644
--- a/Source/NETworkManager/InputSuggestions.cs
+++ b/Source/NETworkManager/InputSuggestions.cs
@@ -80,6 +80,22 @@ public static InputSuggestions ForPortScannerPorts()
Strings.PortProfiles, SettingsManager.Current.PortScanner_PortHistory);
}
+ ///
+ /// Creates the list for the DNS server of DNS Lookup: the DNS servers (Windows DNS first, then sorted by name,
+ /// with their servers) and the DNS server history.
+ ///
+ public static InputSuggestions ForDNSLookupDNSServers()
+ {
+ return new InputSuggestions(SettingsManager.Current.DNSLookup_DNSServers,
+ () => SettingsManager.Current.DNSLookup_DNSServers
+ .OrderByDescending(x => x.UseWindowsDNSServer)
+ .ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(x => (x.Name, x.UseWindowsDNSServer
+ ? null
+ : string.Join("; ", x.Servers.Select(server => server.Port == 53 ? server.Server : server.ToString())))),
+ Strings.DNSServers, SettingsManager.Current.DNSLookup_DNSServerHistory);
+ }
+
///
/// Gets the view of the entries, grouped (profiles, history).
///
diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
index aab3413878..c094b46a80 100644
--- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
+++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
@@ -62,14 +62,21 @@ public string Host
///
public ICollectionView HostHistoryView { get; }
+ private readonly InputSuggestions _dnsServerSuggestions = InputSuggestions.ForDNSLookupDNSServers();
+
///
- /// Gets the collection view of DNS servers.
+ /// Gets the DNS servers and the DNS server history for the DNS server input (grouped).
///
- public ICollectionView DNSServers { get; }
+ public ICollectionView DNSServers => _dnsServerSuggestions.View;
///
- /// Gets or sets the selected DNS server.
- /// This can either be an ip/host:port or a profile name.
+ /// Gets the names of the DNS servers for the Tab completion of the DNS server input.
+ ///
+ public IEnumerable DNSServerNames => _dnsServerSuggestions.ProfileNames;
+
+ ///
+ /// Gets or sets the DNS server input: names of DNS servers (profiles) and / or DNS servers (ip/host:port),
+ /// separated by ";" (see ).
///
public string DNSServer
{
@@ -79,10 +86,6 @@ public string DNSServer
if (field == value)
return;
- // Try finding matching dns server profile by name, otherwise set to null (de-select)
- SelectedDNSServer = SettingsManager.Current.DNSLookup_DNSServers
- .FirstOrDefault(x => x.Name == value);
-
if (!_isLoading)
SettingsManager.Current.DNSLookup_SelectedDNSServer_v2 = value;
@@ -91,19 +94,6 @@ public string DNSServer
}
}
- public DNSServerConnectionInfoProfile SelectedDNSServer
- {
- get;
- set
- {
- if (field == value)
- return;
-
- field = value;
- OnPropertyChanged();
- }
- }
-
///
/// Gets the list of available query types.
///
@@ -265,12 +255,6 @@ public DNSLookupViewModel(Guid tabId, string host, string dnsServer = null, Quer
HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.DNSLookup_HostHistory);
- DNSServers = new CollectionViewSource { Source = SettingsManager.Current.DNSLookup_DNSServers }.View;
- DNSServers.SortDescriptions.Add(new SortDescription(nameof(DNSServerConnectionInfoProfile.UseWindowsDNSServer),
- ListSortDirection.Descending));
- DNSServers.SortDescriptions.Add(new SortDescription(nameof(DNSServerConnectionInfoProfile.Name),
- ListSortDirection.Ascending));
-
DNSServer = string.IsNullOrEmpty(SettingsManager.Current.DNSLookup_SelectedDNSServer_v2)
? SettingsManager.Current.DNSLookup_DNSServers.FirstOrDefault()?.Name
: SettingsManager.Current.DNSLookup_SelectedDNSServer_v2;
@@ -392,6 +376,11 @@ private async Task QueryAsync()
ListHelper.Modify(SettingsManager.Current.DNSLookup_HostHistory, Host,
SettingsManager.Current.General_HistoryListEntries);
+ // A single DNS server name is already in the list, only other inputs are added to the history
+ if (Models.Network.DNSServer.FindByName(SettingsManager.Current.DNSLookup_DNSServers, DNSServer) == null)
+ ListHelper.Modify(SettingsManager.Current.DNSLookup_DNSServerHistory, DNSServer.Trim(),
+ SettingsManager.Current.General_HistoryListEntries);
+
DNSLookupSettings dnsSettings = new()
{
AddDNSSuffix = SettingsManager.Current.DNSLookup_AddDNSSuffix,
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index db323f1894..623c43ef9f 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -844,8 +844,10 @@ private async Task Start()
// Add host(s) to the history
ListHelper.Modify(SettingsManager.Current.PortScanner_HostHistory, Host,
SettingsManager.Current.General_HistoryListEntries);
- ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports,
- SettingsManager.Current.General_HistoryListEntries);
+ // A single port profile name is already in the list, only other inputs are added to the history
+ if (PortProfile.FindByName(SettingsManager.Current.PortScanner_PortProfiles, Ports) == null)
+ ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports,
+ SettingsManager.Current.General_HistoryListEntries);
ScanOptions = CreateScanOptions();
diff --git a/Source/NETworkManager/Views/DNSLookupView.xaml b/Source/NETworkManager/Views/DNSLookupView.xaml
index c445837c87..f6bee381af 100644
--- a/Source/NETworkManager/Views/DNSLookupView.xaml
+++ b/Source/NETworkManager/Views/DNSLookupView.xaml
@@ -69,15 +69,23 @@
-
+
+
+
+
+
+
diff --git a/Website/docs/application/dns-lookup.md b/Website/docs/application/dns-lookup.md
index 8f19932340..96df2cec23 100644
--- a/Website/docs/application/dns-lookup.md
+++ b/Website/docs/application/dns-lookup.md
@@ -53,7 +53,7 @@ Example: `server-01.borntoberoot.net; 10.0.0.1`
:::
-The DNS server can be selected from a list of configured servers or you can enter a custom DNS server in the format `|:` (`` is optional, to use a custom port with IPv6 enclose the address in square brackets: `[]:53`). Names of configured DNS servers (case-insensitive) and custom DNS servers can be combined with a semicolon (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once. `[Windows DNS]` together with other DNS servers adds the DNS servers configured in Windows.
+The DNS server can be selected from a list of configured servers or you can enter a custom DNS server in the format `|:` (`` is optional, to use a custom port with IPv6 enclose the address in square brackets: `[]:53`). Names of configured DNS servers (case-insensitive) and custom DNS servers can be combined with a semicolon (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once. `[Windows DNS]` together with other DNS servers adds the DNS servers configured in Windows. The list shows the configured DNS servers (with their IP addresses) and the recently used DNS servers (history, a single configured DNS server is not added). `Tab` completes the name of a configured DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`), `Tab` again shows the next match.
### Context menu
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index bddd837f12..3daaf39e79 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -100,7 +100,7 @@ Release date: **xx.xx.2026**
- Added **Web Console** to the context menu of an open web port (`80`, `443`, `8080`, `8443`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Ping Monitor**, **Traceroute**, **DNS lookup...**, **RDAP...**, **Remote Desktop**, **PowerShell**, **PuTTY**, **TigerVNC** and **SNMP** to the context menu to open the host (hostname, otherwise the IP address) in the tool, like in the **IP Scanner**. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- The header of each host shows the number of open ports (e.g. `server (10.0.0.1) · 3 Open`), also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
-- The **Ports** input lists the port profiles (with their ports) and the history, like the DNS servers in **DNS Lookup**: select a port profile, or type ports and port profile names. Replaces the **Select port profile** button and its dialog. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- The **Ports** input lists the port profiles (with their ports) and the history (a single port profile is not added to the history), like the DNS servers in **DNS Lookup**: select a port profile, or type ports and port profile names. Replaces the **Select port profile** button and its dialog. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Profiles: the **Ports** of the Port Scanner can contain port profiles (selectable in the list like in the tool), which are referenced by the profile - changes to a port profile apply, also after a rename. The ports are saved with the profile as well and used, with a note, if the port profile can't be found (e.g. a profile file shared from another computer, port profiles are saved in the settings). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Port profiles can be entered by name in the **Ports** input, also several and together with ports (e.g. `Webserver; Database; 8080`, case-insensitive). `Tab` completes the name that is being typed (e.g. `webserver; dat` → `webserver; Database`), also in the profile dialog. The name of a port profile must be unique, must not contain `;` (replaced by `,` when updating) and must not be a port or port range. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Add profile...** to the context menu: creates a profile with the host (IP address), the hostname as name and the ports of the shown scan. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
@@ -127,7 +127,7 @@ Release date: **xx.xx.2026**
**DNS Lookup**
-- Several DNS servers can be entered, also names of configured DNS servers together with custom DNS servers (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once; `[Windows DNS]` in a list adds the DNS servers of Windows. The name of a DNS server must not contain `;` (replaced by `,` when updating). Also with the run command (`dns borntoberoot.net "Google Public DNS";cloudflare`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Several DNS servers can be entered, also names of configured DNS servers together with custom DNS servers (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once; `[Windows DNS]` in a list adds the DNS servers of Windows. The list of the **DNS server** input shows the configured DNS servers (with their IP addresses) and the history, `Tab` completes the name of a DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`). The name of a DNS server must not contain `;` (replaced by `,` when updating). Also with the run command (`dns borntoberoot.net "Google Public DNS";cloudflare`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- IP addresses opened from another feature (e.g. **IP Scanner** > **DNS lookup** > **IP address**), from a profile or with the run command are looked up with `PTR` instead of the selected record type. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644)
**Hosts File Editor**
From b8b8a2f959b923a530fb49d6afc2ad8a5586c159 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:27:09 +0200
Subject: [PATCH 099/146] Feature: DNS Lookup DNS servers with an internal ID
(E3)
Profiles will reference DNS servers by an ID instead of the name, so a
renamed DNS server is still found (E4). New DNS servers (and the default
Windows DNS server) get a new Guid, editing keeps it, the update adds an ID
to existing DNS servers. The Windows DNS server will be referenced by name
(it exists on every computer, with another ID).
GlobalAssemblyInfo 2026.10.27.0 -> 2026.10.27.1, so the update runs again on
a development machine.
Co-Authored-By: Claude Opus 5.5
---
Source/GlobalAssemblyInfo.cs | 4 ++--
Source/NETworkManager.Models/Network/DNSServer.cs | 2 +-
.../Network/DNSServerConnectionInfoProfile.cs | 12 ++++++++++--
Source/NETworkManager.Settings/SettingsManager.cs | 7 +++++++
.../ViewModels/DNSLookupSettingsViewModel.cs | 15 +++++++++++----
5 files changed, 31 insertions(+), 9 deletions(-)
diff --git a/Source/GlobalAssemblyInfo.cs b/Source/GlobalAssemblyInfo.cs
index 7a6801ffcc..2d5beb97d3 100644
--- a/Source/GlobalAssemblyInfo.cs
+++ b/Source/GlobalAssemblyInfo.cs
@@ -6,5 +6,5 @@
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
-[assembly: AssemblyVersion("2026.10.27.0")]
-[assembly: AssemblyFileVersion("2026.10.27.0")]
+[assembly: AssemblyVersion("2026.10.27.1")]
+[assembly: AssemblyFileVersion("2026.10.27.1")]
diff --git a/Source/NETworkManager.Models/Network/DNSServer.cs b/Source/NETworkManager.Models/Network/DNSServer.cs
index d55a2c564f..e7f6dc3a9f 100644
--- a/Source/NETworkManager.Models/Network/DNSServer.cs
+++ b/Source/NETworkManager.Models/Network/DNSServer.cs
@@ -144,7 +144,7 @@ public static List GetDefaultList()
{
return
[
- new(), // Windows DNS server
+ new() { Id = Guid.NewGuid() }, // Windows DNS server
new("Cloudflare",
[
new("1.1.1.1", 53, TransportProtocol.Udp),
diff --git a/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs b/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs
index ccc9209aae..961a9fce2a 100644
--- a/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs
+++ b/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs
@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
namespace NETworkManager.Models.Network;
@@ -24,9 +25,16 @@ public DNSServerConnectionInfoProfile()
/// List of servers as .
public DNSServerConnectionInfoProfile(string name, List servers) : base(name, servers)
{
-
+ Id = Guid.NewGuid();
}
+ ///
+ /// Internal ID of the DNS server profile - profiles reference a DNS server profile by it, so a renamed DNS
+ /// server profile is still found. Kept when the DNS server profile is edited. The Windows DNS profile is
+ /// referenced by its name (it exists on every computer, with another ID).
+ ///
+ public Guid Id { get; set; }
+
///
/// Use the DNS server from Windows.
///
diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs
index db2ace57b7..2dc5a326ff 100644
--- a/Source/NETworkManager.Settings/SettingsManager.cs
+++ b/Source/NETworkManager.Settings/SettingsManager.cs
@@ -800,6 +800,13 @@ private static void UpgradeToLatest(Version version)
dnsServer.Name = dnsServer.Name.Replace(';', ',');
}
+ // DNS Lookup - DNS servers have an internal ID (profiles reference them by it)
+ foreach (var dnsServer in Current.DNSLookup_DNSServers.Where(x => x.Id == Guid.Empty))
+ {
+ Log.Info($"Add an ID to the DNS server \"{dnsServer.Name}\"...");
+ dnsServer.Id = Guid.NewGuid();
+ }
+
// Port Scanner - port profiles have an internal ID (profiles reference them by it)
foreach (var portProfile in Current.PortScanner_PortProfiles.Where(x => x.Id == Guid.Empty))
{
diff --git a/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs
index 61a356dd20..383dc3736b 100644
--- a/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs
@@ -370,21 +370,28 @@ public async Task EditDNSServer()
{
var childWindow = new ServerConnectionInfoProfileChildWindow();
+ var dnsServer = SelectedDNSServer;
+
var childWindowViewModel = new ServerConnectionInfoProfileViewModel(instance =>
{
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
- SettingsManager.Current.DNSLookup_DNSServers.Remove(SelectedDNSServer);
- SettingsManager.Current.DNSLookup_DNSServers.Add(
- new DNSServerConnectionInfoProfile(instance.Name, [.. instance.Servers]));
+ // Same ID, profiles that reference the DNS server still find it (also with a new name)
+ var editedDNSServer = new DNSServerConnectionInfoProfile(instance.Name, [.. instance.Servers]);
+
+ if (dnsServer.Id != Guid.Empty)
+ editedDNSServer.Id = dnsServer.Id;
+
+ SettingsManager.Current.DNSLookup_DNSServers.Remove(dnsServer);
+ SettingsManager.Current.DNSLookup_DNSServers.Add(editedDNSServer);
}, _ =>
{
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
},
(ServerInfoProfileNames, true, false),
- _profileDialogDefaultValues, SelectedDNSServer);
+ _profileDialogDefaultValues, dnsServer);
childWindow.Title = Strings.EditDNSServer;
From aecaa3f371c06a9353b026b17ca216b8025d724c Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:28:33 +0200
Subject: [PATCH 100/146] Fix: Profile DNS Lookup "Inherit host" saved with the
Traceroute value
ParseProfileInfo set DNSLookup_InheritHost from Traceroute_InheritHost
(copy & paste). A profile with an own DNS Lookup host and Traceroute
inheriting the host was saved with DNS Lookup inheriting too - the next save
replaced the DNS Lookup host with the host of the profile.
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager/ProfileDialogManager.cs | 2 +-
Website/docs/changelog/next-release.md | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/Source/NETworkManager/ProfileDialogManager.cs b/Source/NETworkManager/ProfileDialogManager.cs
index 772dacd442..e464007194 100644
--- a/Source/NETworkManager/ProfileDialogManager.cs
+++ b/Source/NETworkManager/ProfileDialogManager.cs
@@ -79,7 +79,7 @@ private static ProfileInfo ParseProfileInfo(ProfileViewModel instance)
// DNS Lookup
DNSLookup_Enabled = instance.DNSLookup_Enabled,
- DNSLookup_InheritHost = instance.Traceroute_InheritHost,
+ DNSLookup_InheritHost = instance.DNSLookup_InheritHost,
DNSLookup_Host = instance.DNSLookup_InheritHost ? instance.Host?.Trim() : instance.DNSLookup_Host?.Trim(),
// Remote Desktop
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 3daaf39e79..45768f0654 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -247,6 +247,7 @@ Release date: **xx.xx.2026**
**DNS Lookup**
+- Fixed **Inherit host from general** of a profile being saved with the value of the **Traceroute**, so the host of the DNS lookup could be replaced with the host of the profile. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed a DNS server opened with the run command in another case than configured (e.g. `cloudflare` for `Cloudflare`) being looked up as hostname instead of using the configured DNS server. Names of DNS servers are case-insensitive now. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed **Add DNS suffix (primary) to hostname** silently appending no suffix at all on machines without a Windows primary DNS suffix configured (e.g. not domain-joined), even though a usable suffix was available via the active network adapter's connection-specific DNS suffix (shown under **DNS Suffix Search List** in `ipconfig /all`). It now falls back to that adapter's suffix in this case. [#3591](https://github.com/BornToBeRoot/NETworkManager/pull/3591)
- Fixed columns that couldn't be made wider than the window (by dragging or double-clicking the gripper) in the result and the DNS server profiles in the settings. The grid scrolls horizontally now, the last (or main) column still fills the remaining width. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557)
From 4ff123ecb67919e59fcf91b5ff39f4619be0607e Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:30:35 +0200
Subject: [PATCH 101/146] Feature: Profiles can override the DNS server of DNS
Lookup (E4)
New profile option "DNS server" (DNS Lookup tab, checkbox + the same
ComboBox as the tool: DNS servers + history, Tab completion, validation;
tab error indicator / OK disabled on an invalid input).
Saved like the ports of the Port Scanner (D4): DNSLookup_DNSServerInput =
the input with DNS servers by name as references to their ID ("{guid};
9.9.9.9", DNSServer.EncodeReferences; [Windows DNS] by name, its ID differs
per computer), null without a DNS server by name; DNSLookup_DNSServer = the
input with the servers of the DNS servers (DNSServer.ReplaceProfilesByServers).
Opened / looked up: the references with the current names; if a DNS server
can't be found (deleted, or a profile file shared from another computer) the
saved servers, and the tab shows a note (also with the first lookup).
Unit tests for encode / decode / replace.
Co-Authored-By: Claude Opus 5.5
---
.../Resources/Strings.Designer.cs | 9 ++
.../Resources/Strings.resx | 3 +
.../Network/DNSServer.cs | 99 +++++++++++++++++++
Source/NETworkManager.Profiles/ProfileInfo.cs | 23 +++++
.../Network/DNSServerTests.cs | 34 +++++++
Source/NETworkManager/ProfileDialogManager.cs | 7 ++
.../ViewModels/DNSLookupHostViewModel.cs | 30 +++++-
.../ViewModels/DNSLookupViewModel.cs | 23 ++++-
.../ViewModels/ProfileViewModel.cs | 46 +++++++++
.../Views/DNSLookupView.xaml.cs | 4 +-
.../Views/ProfileChildWindow.xaml | 59 +++++++++++
Website/docs/application/dns-lookup.md | 19 ++++
Website/docs/changelog/next-release.md | 1 +
13 files changed, 351 insertions(+), 6 deletions(-)
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index 1ee1dcf93d..b2d9f945a6 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -3649,6 +3649,15 @@ public static string DNSServer {
}
}
+ ///
+ /// Looks up a localized string similar to A DNS server of this profile was not found, the DNS servers saved with the profile are used. DNS servers are saved in the settings - if the profile file was shared from another computer, create the DNS server here or edit the profile..
+ ///
+ public static string DNSServerOfProfileNotFoundMessage {
+ get {
+ return ResourceManager.GetString("DNSServerOfProfileNotFoundMessage", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to DNS server(s).
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index ec25663be6..b4b95f2682 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -5122,4 +5122,7 @@ Do you want to start the scan?
The name must not contain ";".
+
+ A DNS server of this profile was not found, the DNS servers saved with the profile are used. DNS servers are saved in the settings - if the profile file was shared from another computer, create the DNS server here or edit the profile.
+
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Network/DNSServer.cs b/Source/NETworkManager.Models/Network/DNSServer.cs
index e7f6dc3a9f..495a85bcf9 100644
--- a/Source/NETworkManager.Models/Network/DNSServer.cs
+++ b/Source/NETworkManager.Models/Network/DNSServer.cs
@@ -127,6 +127,105 @@ public static List ResolveServers(string input,
return [.. servers.DistinctBy(x => (x.Server.ToLowerInvariant(), x.Port, x.TransportProtocol))];
}
+ ///
+ /// Replaces the names of DNS server profiles in a DNS server input by references to their ID (e.g. "Cloudflare;
+ /// 9.9.9.9" -> "{8f1c...}; 9.9.9.9"), so a profile still finds the DNS server profile after it was renamed. The
+ /// Windows DNS profile is kept by name (it exists on every computer, with another ID), other entries are kept.
+ ///
+ /// DNS server input (see ).
+ /// DNS server profiles.
+ /// Input with references, null if it contains no DNS server profile with an ID.
+ public static string EncodeReferences(string input, IEnumerable profiles)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return null;
+
+ var profileList = profiles.ToList();
+ var hasReference = false;
+ List entries = [];
+
+ foreach (var entry in input.Split(EntrySeparator))
+ {
+ var profile = FindByName(profileList, entry);
+
+ if (profile == null || profile.UseWindowsDNSServer || profile.Id == Guid.Empty)
+ {
+ entries.Add(entry.Trim());
+ continue;
+ }
+
+ entries.Add(profile.Id.ToString("B"));
+ hasReference = true;
+ }
+
+ return hasReference ? string.Join("; ", entries) : null;
+ }
+
+ ///
+ /// Replaces the references in a DNS server input (see ) by the current names of
+ /// the DNS server profiles (e.g. "{8f1c...}; 9.9.9.9" -> "Cloudflare; 9.9.9.9").
+ ///
+ /// DNS server input with references.
+ /// DNS server profiles.
+ /// DNS server input with the names, null if a DNS server profile was not found.
+ /// True if every referenced DNS server profile was found.
+ public static bool TryDecodeReferences(string inputWithReferences,
+ IEnumerable profiles, out string input)
+ {
+ input = null;
+
+ if (string.IsNullOrWhiteSpace(inputWithReferences))
+ return false;
+
+ var profileList = profiles.ToList();
+ List entries = [];
+
+ foreach (var entry in inputWithReferences.Split(EntrySeparator))
+ {
+ if (!Guid.TryParseExact(entry.Trim(), "B", out var id))
+ {
+ entries.Add(entry.Trim());
+ continue;
+ }
+
+ var profile = profileList.FirstOrDefault(x => x.Id == id);
+
+ if (profile == null)
+ return false;
+
+ entries.Add(profile.Name);
+ }
+
+ input = string.Join("; ", entries);
+
+ return true;
+ }
+
+ ///
+ /// Replaces the names of DNS server profiles in a DNS server input by their servers (e.g. "Cloudflare; 9.9.9.9"
+ /// -> "1.1.1.1:53; 1.0.0.1:53; 9.9.9.9"), so the input still works without the DNS server profiles (e.g. on another
+ /// computer). The Windows DNS profile and other entries are kept.
+ ///
+ /// DNS server input.
+ /// DNS server profiles.
+ /// Input with servers instead of DNS server profiles.
+ public static string ReplaceProfilesByServers(string input, IEnumerable profiles)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return input;
+
+ var profileList = profiles.ToList();
+
+ return string.Join("; ", input.Split(EntrySeparator).Select(entry =>
+ {
+ var profile = FindByName(profileList, entry);
+
+ return profile == null || profile.UseWindowsDNSServer
+ ? entry.Trim()
+ : string.Join("; ", profile.Servers.Select(x => x.ToString()));
+ }));
+ }
+
///
/// Parses a DNS server entry (IP address or hostname, optional port).
///
diff --git a/Source/NETworkManager.Profiles/ProfileInfo.cs b/Source/NETworkManager.Profiles/ProfileInfo.cs
index babd4bc8d0..ade5fa6376 100644
--- a/Source/NETworkManager.Profiles/ProfileInfo.cs
+++ b/Source/NETworkManager.Profiles/ProfileInfo.cs
@@ -79,6 +79,9 @@ public ProfileInfo(ProfileInfo profile)
DNSLookup_Enabled = profile.DNSLookup_Enabled;
DNSLookup_InheritHost = profile.DNSLookup_InheritHost;
DNSLookup_Host = profile.DNSLookup_Host;
+ DNSLookup_OverrideDNSServer = profile.DNSLookup_OverrideDNSServer;
+ DNSLookup_DNSServer = profile.DNSLookup_DNSServer;
+ DNSLookup_DNSServerInput = profile.DNSLookup_DNSServerInput;
// Remote Desktop
RemoteDesktop_Enabled = profile.RemoteDesktop_Enabled;
@@ -338,6 +341,26 @@ public ProfileInfo(ProfileInfo profile)
public bool DNSLookup_InheritHost { get; set; } = true;
public string DNSLookup_Host { get; set; }
+ ///
+ /// Use the DNS server of the profile instead of the DNS server selected in DNS Lookup.
+ ///
+ public bool DNSLookup_OverrideDNSServer { get; set; }
+
+ ///
+ /// DNS server of the profile (names of DNS servers and / or DNS servers). If DNS servers were entered by name,
+ /// their servers at the time the profile was saved - used if a DNS server of
+ /// can't be found (e.g. a profile file from another computer, DNS servers
+ /// are saved in the settings).
+ ///
+ public string DNSLookup_DNSServer { get; set; }
+
+ ///
+ /// DNS server as entered, if it contains DNS servers by name: they are referenced by their ID (e.g. "{8f1c...};
+ /// 9.9.9.9", see DNSServer.EncodeReferences), so they are also found after a rename. Null if no DNS
+ /// server was entered by name.
+ ///
+ public string DNSLookup_DNSServerInput { get; set; }
+
public bool RemoteDesktop_Enabled { get; set; }
public bool RemoteDesktop_InheritHost { get; set; } = true;
public string RemoteDesktop_Host { get; set; }
diff --git a/Source/NETworkManager.Tests/Network/DNSServerTests.cs b/Source/NETworkManager.Tests/Network/DNSServerTests.cs
index 0093301b21..054722ce6b 100644
--- a/Source/NETworkManager.Tests/Network/DNSServerTests.cs
+++ b/Source/NETworkManager.Tests/Network/DNSServerTests.cs
@@ -43,6 +43,40 @@ public void ResolveServers_InvalidEntriesAreSkipped()
Assert.Equal(["not valid!"], invalidEntries);
}
+ [Fact]
+ public void EncodeAndDecodeReferences()
+ {
+ var cloudflare = Profiles.First(x => x.Name == "Cloudflare");
+
+ var input = DNSServer.EncodeReferences("[Windows DNS]; cloudflare; 9.9.9.9", Profiles);
+
+ Assert.Equal($"[Windows DNS]; {cloudflare.Id:B}; 9.9.9.9", input);
+ Assert.True(DNSServer.TryDecodeReferences(input, Profiles, out var decoded));
+ Assert.Equal("[Windows DNS]; Cloudflare; 9.9.9.9", decoded);
+ }
+
+ [Theory]
+ [InlineData("9.9.9.9")]
+ [InlineData("[Windows DNS]")]
+ public void EncodeReferences_NullWithoutProfileReference(string input)
+ {
+ Assert.Null(DNSServer.EncodeReferences(input, Profiles));
+ }
+
+ [Fact]
+ public void DecodeReferences_FalseIfProfileIsMissing()
+ {
+ Assert.False(DNSServer.TryDecodeReferences($"{System.Guid.NewGuid():B}", Profiles, out var decoded));
+ Assert.Null(decoded);
+ }
+
+ [Fact]
+ public void ReplaceProfilesByServers()
+ {
+ Assert.Equal("[Windows DNS]; 1.1.1.1:53; 1.0.0.1:53; 9.9.9.9",
+ DNSServer.ReplaceProfilesByServers("[Windows DNS]; Cloudflare; 9.9.9.9", Profiles));
+ }
+
[Theory]
[InlineData("Cloudflare", false, 0)]
[InlineData("google public dns;9.9.9.9", true, 0)]
diff --git a/Source/NETworkManager/ProfileDialogManager.cs b/Source/NETworkManager/ProfileDialogManager.cs
index e464007194..2c62478873 100644
--- a/Source/NETworkManager/ProfileDialogManager.cs
+++ b/Source/NETworkManager/ProfileDialogManager.cs
@@ -81,6 +81,13 @@ private static ProfileInfo ParseProfileInfo(ProfileViewModel instance)
DNSLookup_Enabled = instance.DNSLookup_Enabled,
DNSLookup_InheritHost = instance.DNSLookup_InheritHost,
DNSLookup_Host = instance.DNSLookup_InheritHost ? instance.Host?.Trim() : instance.DNSLookup_Host?.Trim(),
+ DNSLookup_OverrideDNSServer = instance.DNSLookup_OverrideDNSServer,
+ // DNS servers by name are referenced by their ID (found after a rename), their servers are saved as well
+ // (used if a DNS server can't be found, e.g. a profile file from another computer)
+ DNSLookup_DNSServer = DNSServer.ReplaceProfilesByServers(instance.DNSLookup_DNSServer?.Trim(),
+ SettingsManager.Current.DNSLookup_DNSServers),
+ DNSLookup_DNSServerInput = DNSServer.EncodeReferences(instance.DNSLookup_DNSServer,
+ SettingsManager.Current.DNSLookup_DNSServers),
// Remote Desktop
RemoteDesktop_Enabled = instance.RemoteDesktop_Enabled,
diff --git a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs
index 1d95cb4978..8ed0d47689 100644
--- a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs
@@ -3,7 +3,9 @@
using NETworkManager.Controls;
using NETworkManager.Localization.Resources;
using NETworkManager.Models;
+using NETworkManager.Models.Network;
using NETworkManager.Profiles;
+using NETworkManager.Settings;
using NETworkManager.Utilities;
using NETworkManager.Views;
using System;
@@ -158,7 +160,8 @@ private static void CloseItemAction(ItemActionCallbackArgs args)
/// hosts.
///
/// Starts it on the first load. If false, the input is only filled in.
- public void AddTab(string host = null, string dnsServer = null, QueryType? queryType = null, bool start = true)
+ public void AddTab(string host = null, string dnsServer = null, QueryType? queryType = null, bool start = true,
+ string message = null)
{
if (queryType == null && IsIPAddresses(host))
queryType = QueryType.PTR;
@@ -166,7 +169,7 @@ public void AddTab(string host = null, string dnsServer = null, QueryType? query
var tabId = Guid.NewGuid();
TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab,
- new DNSLookupView(tabId, host, dnsServer, queryType, start), tabId));
+ new DNSLookupView(tabId, host, dnsServer, queryType, start, message), tabId));
SelectedTabIndex = TabItems.Count - 1;
}
@@ -189,7 +192,28 @@ private static bool IsIPAddresses(string hosts)
/// Starts it on the first load. If false, the input is only filled in.
public void AddTab(ProfileInfo profile, bool start = true)
{
- AddTab(profile.DNSLookup_Host, start: start);
+ // DNS server of DNS Lookup (last selected)
+ if (!profile.DNSLookup_OverrideDNSServer || string.IsNullOrWhiteSpace(profile.DNSLookup_DNSServer))
+ {
+ AddTab(profile.DNSLookup_Host, start: start);
+ return;
+ }
+
+ // DNS servers by name with their current name (also after a rename)
+ if (!string.IsNullOrEmpty(profile.DNSLookup_DNSServerInput) &&
+ DNSServer.TryDecodeReferences(profile.DNSLookup_DNSServerInput,
+ SettingsManager.Current.DNSLookup_DNSServers, out var dnsServer))
+ {
+ AddTab(profile.DNSLookup_Host, dnsServer, start: start);
+ return;
+ }
+
+ // No DNS server by name, or one can't be found (deleted, or a profile file shared from another computer - DNS
+ // servers are saved in the settings): the servers saved with the profile, with a note if one wasn't found
+ AddTab(profile.DNSLookup_Host, profile.DNSLookup_DNSServer, start: start,
+ message: string.IsNullOrEmpty(profile.DNSLookup_DNSServerInput)
+ ? null
+ : Strings.DNSServerOfProfileNotFoundMessage);
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
index c094b46a80..26499769c3 100644
--- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
+++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
@@ -41,6 +41,9 @@ public class DNSLookupViewModel : ViewModelBase
///
private readonly bool _isLoading;
+ // Message of the tab, shown until the first lookup has started (see constructor)
+ private string _message;
+
///
/// Gets or sets the host to lookup.
///
@@ -241,11 +244,21 @@ private set
/// DNS server(s) to use (not saved as default). If null, the last selected one is used.
/// Query type (not saved as default). If null, the last selected one is used.
/// Starts it on the first load. If false, the input is only filled in.
+ ///
+ /// Message shown below the input, also with the first lookup (e.g. a DNS server of the profile was not found).
+ ///
public DNSLookupViewModel(Guid tabId, string host, string dnsServer = null, QueryType? queryType = null,
- bool start = true)
+ bool start = true, string message = null)
{
_firstLoad = start;
+ if (!string.IsNullOrEmpty(message))
+ {
+ _message = message;
+
+ AppendStatusMessage(message);
+ }
+
_isLoading = true;
ConfigurationManager.Current.DNSLookupTabCount++;
@@ -367,6 +380,14 @@ private async Task QueryAsync()
IsStatusMessageDisplayed = false;
StatusMessage = string.Empty;
+ // Message of the tab (see constructor): still shown with the first lookup
+ if (_message != null)
+ {
+ AppendStatusMessage(_message);
+
+ _message = null;
+ }
+
IsRunning = true;
Results.Clear();
diff --git a/Source/NETworkManager/ViewModels/ProfileViewModel.cs b/Source/NETworkManager/ViewModels/ProfileViewModel.cs
index 7c36f91f88..ca4c44aced 100644
--- a/Source/NETworkManager/ViewModels/ProfileViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ProfileViewModel.cs
@@ -116,6 +116,14 @@ public ProfileViewModel(Action saveCommand, Action
+ /// Gets the DNS servers and the DNS server history of DNS Lookup for the DNS server input (grouped).
+ ///
+ public ICollectionView DNSLookup_DNSServerSuggestions => _dnsLookupDNSServerSuggestions.View;
+
+ ///
+ /// Gets the names of the DNS servers for the Tab completion of the DNS server input of DNS Lookup.
+ ///
+ public IEnumerable DNSLookup_DNSServerNames => _dnsLookupDNSServerSuggestions.ProfileNames;
+
#endregion
#region Remote Desktop
diff --git a/Source/NETworkManager/Views/DNSLookupView.xaml.cs b/Source/NETworkManager/Views/DNSLookupView.xaml.cs
index 335981648f..4f31313c6e 100644
--- a/Source/NETworkManager/Views/DNSLookupView.xaml.cs
+++ b/Source/NETworkManager/Views/DNSLookupView.xaml.cs
@@ -12,11 +12,11 @@ public partial class DNSLookupView : IDragablzTabItem
private readonly DNSLookupViewModel _viewModel;
public DNSLookupView(Guid tabId, string host = null, string dnsServer = null, QueryType? queryType = null,
- bool start = true)
+ bool start = true, string message = null)
{
InitializeComponent();
- _viewModel = new DNSLookupViewModel(tabId, host, dnsServer, queryType, start);
+ _viewModel = new DNSLookupViewModel(tabId, host, dnsServer, queryType, start, message);
DataContext = _viewModel;
diff --git a/Source/NETworkManager/Views/ProfileChildWindow.xaml b/Source/NETworkManager/Views/ProfileChildWindow.xaml
index c7080e8b96..23259d05e7 100644
--- a/Source/NETworkManager/Views/ProfileChildWindow.xaml
+++ b/Source/NETworkManager/Views/ProfileChildWindow.xaml
@@ -388,6 +388,25 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -1878,6 +1897,8 @@
+
+
@@ -1921,6 +1942,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -4378,6 +4425,18 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Website/docs/application/dns-lookup.md b/Website/docs/application/dns-lookup.md
index 96df2cec23..fe44b20c7d 100644
--- a/Website/docs/application/dns-lookup.md
+++ b/Website/docs/application/dns-lookup.md
@@ -91,6 +91,25 @@ Hostname or IP address (or any other resource record) to query.
- `server-01.borntoberoot.net` (`A` record)
- `1.1.1.1` (`PTR` record)
+### DNS server
+
+Use this DNS server instead of the DNS server selected in the DNS lookup: names of [DNS servers](#dns-server-1) and / or custom DNS servers, separated by `;` (selectable in the list, `Tab` completes a name).
+
+**Type:** `String`
+
+**Default:** `Disabled` / `Empty`
+
+**Example:**
+
+- `Cloudflare`
+- `Google Public DNS; 9.9.9.9`
+
+:::note
+
+A configured DNS server is referenced by the profile, so changes to it apply to the profile, also if it is renamed. Its IP addresses are saved with the profile as well: if the DNS server can't be found (it was deleted, or the profile file is shared from another computer - DNS servers are saved in the settings of each computer), the saved IP addresses are used and a note is shown. `[Windows DNS]` always uses the DNS servers of the computer.
+
+:::
+
## Settings
### DNS server
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 45768f0654..234520daf8 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -127,6 +127,7 @@ Release date: **xx.xx.2026**
**DNS Lookup**
+- Profiles: new option **DNS server** to use a DNS server of the profile instead of the one selected in the DNS lookup, with the same list and `Tab` completion. Configured DNS servers are referenced by the profile (changes apply, also after a rename), their IP addresses are saved as well and used, with a note, if the DNS server can't be found (e.g. a profile file shared from another computer). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Several DNS servers can be entered, also names of configured DNS servers together with custom DNS servers (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once; `[Windows DNS]` in a list adds the DNS servers of Windows. The list of the **DNS server** input shows the configured DNS servers (with their IP addresses) and the history, `Tab` completes the name of a DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`). The name of a DNS server must not contain `;` (replaced by `,` when updating). Also with the run command (`dns borntoberoot.net "Google Public DNS";cloudflare`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- IP addresses opened from another feature (e.g. **IP Scanner** > **DNS lookup** > **IP address**), from a profile or with the run command are looked up with `PTR` instead of the selected record type. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644)
From 8f26cb538254b304fd4f0fb07de1934c276d2dc6 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:31:22 +0200
Subject: [PATCH 102/146] Test: Run command with several DNS servers by name
(E1)
Parser tests for "dns host "Google Public DNS";cloudflare;9.9.9.9 aaaa" (with
/ without spaces around ";", type before or after) and for completing the
entry after the last ";" of a DNS server list.
Co-Authored-By: Claude Opus 5.5
---
.../RunCommand/RunCommandParserTests.cs | 27 +++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs
index a7edbd7f90..a636e37b29 100644
--- a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs
+++ b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs
@@ -267,6 +267,33 @@ public void ListWithAQuotedEntryAndType()
Assert.Equal("aaaa", result.Type);
}
+ [Theory]
+ [InlineData("dns borntoberoot.net \"Google Public DNS\";cloudflare;9.9.9.9 aaaa")]
+ [InlineData("dns borntoberoot.net \"Google Public DNS\"; cloudflare; 9.9.9.9 aaaa")]
+ [InlineData("dns borntoberoot.net aaaa \"Google Public DNS\" ; cloudflare;9.9.9.9")]
+ public void DNSServerListWithNamesAndType(string input)
+ {
+ // Several DNS servers: names (one in quotes) and an IP address, the type before or after them
+ var result = Parse(input);
+
+ Assert.Equal("borntoberoot.net", result.Target);
+ Assert.Equal("Google Public DNS;cloudflare;9.9.9.9", result.Argument);
+ Assert.Equal("aaaa", result.Type);
+ Assert.Equal(RunCommandParseError.None, result.Error);
+ }
+
+ [Fact]
+ public void DNSServerListThatIsBeingTyped()
+ {
+ // Completion of the entry after the last ";", the entries before as typed
+ var result = Parse("dns borntoberoot.net \"Google Public DNS\";clo");
+
+ Assert.Equal(RunCommandPosition.Argument, result.Position);
+ Assert.Equal("Google Public DNS;clo", result.CurrentWord);
+ Assert.Equal("\"Google Public DNS\";clo", result.CurrentToken);
+ Assert.Equal("dns borntoberoot.net ", result.InputBeforeCurrentWord);
+ }
+
[Fact]
public void ListInAnOpenQuote()
{
From 16939237d48b34101069d364fb687c9ffb75ce9f Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:38:58 +0200
Subject: [PATCH 103/146] Improvement: History first and profiles in two lines
in the ports / DNS server input
The drop down of the ports (Port Scanner, profile) and DNS server (DNS
Lookup, profile) input looks like the suggestions of the run command: group
headers in Gray3 with more space, a profile in two lines (name, then its
ports / IP addresses in gray, trimmed with a tooltip), the items as wide as
the ComboBox (the drop down grew with a long description).
The history comes first: few entries (at most the history size, custom
inputs only - a single profile name isn't added), visible without scrolling
below 12+ profiles, and the ComboBox's own autocomplete prefers what was
used last.
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager/InputSuggestions.cs | 15 +++++++++------
.../Resources/Styles/ComboBoxStyles.xaml | 18 ++++++++++++------
Website/docs/application/dns-lookup.md | 2 +-
Website/docs/application/port-scanner.md | 2 +-
Website/docs/changelog/next-release.md | 4 ++--
5 files changed, 25 insertions(+), 16 deletions(-)
diff --git a/Source/NETworkManager/InputSuggestions.cs b/Source/NETworkManager/InputSuggestions.cs
index c3d3057723..630ec51630 100644
--- a/Source/NETworkManager/InputSuggestions.cs
+++ b/Source/NETworkManager/InputSuggestions.cs
@@ -31,8 +31,9 @@ public override string ToString()
}
///
-/// Profiles and history for an input (an editable ComboBox like the ports of the Port Scanner or the DNS server of
-/// DNS Lookup): the profiles and the history, grouped, and the names of the profiles for the Tab completion.
+/// History and profiles for an input (an editable ComboBox like the ports of the Port Scanner or the DNS server of
+/// DNS Lookup): the history first (recently used, at most the number of history entries, a single profile name is
+/// not added), then the profiles, grouped, and the names of the profiles for the Tab completion.
/// Updated when the profiles or the history change (weak events, the list doesn't keep a closed tab or dialog
/// alive).
///
@@ -97,7 +98,7 @@ public static InputSuggestions ForDNSLookupDNSServers()
}
///
- /// Gets the view of the entries, grouped (profiles, history).
+ /// Gets the view of the entries, grouped (history, profiles).
///
public ICollectionView View { get; }
@@ -118,8 +119,10 @@ private void Refresh()
ProfileNames.ReplaceAll(profiles.Select(x => x.Name));
- _suggestions.ReplaceAll(profiles
- .Select(x => new InputSuggestion(x.Name, x.Description, _profilesGroup))
- .Concat(_history.Select(x => new InputSuggestion(x, null, Strings.History))));
+ // History first: the recently used inputs (few, usually custom ones) are visible without scrolling below the
+ // profiles; the groups are shown in the order of their first entry
+ _suggestions.ReplaceAll(_history
+ .Select(x => new InputSuggestion(x, null, Strings.History))
+ .Concat(profiles.Select(x => new InputSuggestion(x.Name, x.Description, _profilesGroup))));
}
}
diff --git a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
index c5b2e6ff03..568f649f88 100644
--- a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
+++ b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
@@ -24,21 +24,27 @@
-
+
+ Foreground="{DynamicResource MahApps.Brushes.Gray3}"
+ Margin="5,10,0,5" />
-
-
+
+
+
+
diff --git a/Website/docs/application/dns-lookup.md b/Website/docs/application/dns-lookup.md
index fe44b20c7d..34513cb955 100644
--- a/Website/docs/application/dns-lookup.md
+++ b/Website/docs/application/dns-lookup.md
@@ -53,7 +53,7 @@ Example: `server-01.borntoberoot.net; 10.0.0.1`
:::
-The DNS server can be selected from a list of configured servers or you can enter a custom DNS server in the format `|:` (`` is optional, to use a custom port with IPv6 enclose the address in square brackets: `[]:53`). Names of configured DNS servers (case-insensitive) and custom DNS servers can be combined with a semicolon (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once. `[Windows DNS]` together with other DNS servers adds the DNS servers configured in Windows. The list shows the configured DNS servers (with their IP addresses) and the recently used DNS servers (history, a single configured DNS server is not added). `Tab` completes the name of a configured DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`), `Tab` again shows the next match.
+The DNS server can be selected from a list of configured servers or you can enter a custom DNS server in the format `|:` (`` is optional, to use a custom port with IPv6 enclose the address in square brackets: `[]:53`). Names of configured DNS servers (case-insensitive) and custom DNS servers can be combined with a semicolon (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once. `[Windows DNS]` together with other DNS servers adds the DNS servers configured in Windows. The list shows the recently used DNS servers (history, a single configured DNS server is not added) and the configured DNS servers (name, with the IP addresses below). `Tab` completes the name of a configured DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`), `Tab` again shows the next match.
### Context menu
diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md
index 51cf13b848..3cc6929e23 100644
--- a/Website/docs/application/port-scanner.md
+++ b/Website/docs/application/port-scanner.md
@@ -59,7 +59,7 @@ A scan of more than `65,536` ports (hosts × ports, e.g. 64 hosts with `1-1024`)
### Ports
-The **Ports** input lists the [port profiles](#port-profiles) (with their ports) and the recently scanned ports (history). Select an entry, or type ports, port ranges and names of port profiles separated by `;` (e.g. `Webserver; Database; 8080`). Port profiles are replaced by their ports when the scan starts. `Tab` completes the name of a port profile that is being typed (e.g. `webserver; dat` → `webserver; Database`), `Tab` again shows the next matching port profile.
+The **Ports** input lists the recently scanned ports (history) and the [port profiles](#port-profiles) (name, with the ports below). Select an entry, or type ports, port ranges and names of port profiles separated by `;` (e.g. `Webserver; Database; 8080`). Port profiles are replaced by their ports when the scan starts. `Tab` completes the name of a port profile that is being typed (e.g. `webserver; dat` → `webserver; Database`), `Tab` again shows the next matching port profile.
### Context menu
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 234520daf8..1b4aad273b 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -100,7 +100,7 @@ Release date: **xx.xx.2026**
- Added **Web Console** to the context menu of an open web port (`80`, `443`, `8080`, `8443`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Ping Monitor**, **Traceroute**, **DNS lookup...**, **RDAP...**, **Remote Desktop**, **PowerShell**, **PuTTY**, **TigerVNC** and **SNMP** to the context menu to open the host (hostname, otherwise the IP address) in the tool, like in the **IP Scanner**. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- The header of each host shows the number of open ports (e.g. `server (10.0.0.1) · 3 Open`), also while the scan is running. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
-- The **Ports** input lists the port profiles (with their ports) and the history (a single port profile is not added to the history), like the DNS servers in **DNS Lookup**: select a port profile, or type ports and port profile names. Replaces the **Select port profile** button and its dialog. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- The **Ports** input lists the history (a single port profile is not added to it) and the port profiles (name, with the ports below), like the DNS servers in **DNS Lookup**: select a port profile, or type ports and port profile names. Replaces the **Select port profile** button and its dialog. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Profiles: the **Ports** of the Port Scanner can contain port profiles (selectable in the list like in the tool), which are referenced by the profile - changes to a port profile apply, also after a rename. The ports are saved with the profile as well and used, with a note, if the port profile can't be found (e.g. a profile file shared from another computer, port profiles are saved in the settings). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Port profiles can be entered by name in the **Ports** input, also several and together with ports (e.g. `Webserver; Database; 8080`, case-insensitive). `Tab` completes the name that is being typed (e.g. `webserver; dat` → `webserver; Database`), also in the profile dialog. The name of a port profile must be unique, must not contain `;` (replaced by `,` when updating) and must not be a port or port range. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Added **Add profile...** to the context menu: creates a profile with the host (IP address), the hostname as name and the ports of the shown scan. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
@@ -128,7 +128,7 @@ Release date: **xx.xx.2026**
**DNS Lookup**
- Profiles: new option **DNS server** to use a DNS server of the profile instead of the one selected in the DNS lookup, with the same list and `Tab` completion. Configured DNS servers are referenced by the profile (changes apply, also after a rename), their IP addresses are saved as well and used, with a note, if the DNS server can't be found (e.g. a profile file shared from another computer). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
-- Several DNS servers can be entered, also names of configured DNS servers together with custom DNS servers (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once; `[Windows DNS]` in a list adds the DNS servers of Windows. The list of the **DNS server** input shows the configured DNS servers (with their IP addresses) and the history, `Tab` completes the name of a DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`). The name of a DNS server must not contain `;` (replaced by `,` when updating). Also with the run command (`dns borntoberoot.net "Google Public DNS";cloudflare`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Several DNS servers can be entered, also names of configured DNS servers together with custom DNS servers (e.g. `Google Public DNS; Cloudflare; 9.9.9.9`), every DNS server is queried once; `[Windows DNS]` in a list adds the DNS servers of Windows. The list of the **DNS server** input shows the history and the configured DNS servers (name, with the IP addresses below), `Tab` completes the name of a DNS server that is being typed (e.g. `google; cl` → `google; Cloudflare`). The name of a DNS server must not contain `;` (replaced by `,` when updating). Also with the run command (`dns borntoberoot.net "Google Public DNS";cloudflare`). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- IP addresses opened from another feature (e.g. **IP Scanner** > **DNS lookup** > **IP address**), from a profile or with the run command are looked up with `PTR` instead of the selected record type. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644)
**Hosts File Editor**
From 0adf83b6c0fe47f09e2f2c0619d903c5af6fb16d Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:48:35 +0200
Subject: [PATCH 104/146] Chore: GitHub issue forms and simplified pull request
template
Bug report, feature request and question are issue forms now (affected
feature, version, installation, architecture, Windows version, log) with
contact links to the docs, discussions, Transifex and security advisories.
Blank issues are disabled. The pull request template drops the Copilot
summary and gets a type of change, a test section and the repo checklist.
Co-Authored-By: Claude Opus 5.5
---
.claude/skills/feature-review/SKILL.md | 4 +-
.github/ISSUE_TEMPLATE/Bug_report.md | 28 ----
.github/ISSUE_TEMPLATE/Feature_request.md | 7 -
.github/ISSUE_TEMPLATE/Question.md | 5 -
.github/ISSUE_TEMPLATE/bug_report.yml | 149 +++++++++++++++++++++
.github/ISSUE_TEMPLATE/config.yml | 14 ++
.github/ISSUE_TEMPLATE/feature_request.yml | 90 +++++++++++++
.github/ISSUE_TEMPLATE/question.yml | 29 ++++
.github/pull_request_template.md | 55 +++++---
README.md | 4 +-
Website/docs/changelog/next-release.md | 1 +
11 files changed, 324 insertions(+), 62 deletions(-)
delete mode 100644 .github/ISSUE_TEMPLATE/Bug_report.md
delete mode 100644 .github/ISSUE_TEMPLATE/Feature_request.md
delete mode 100644 .github/ISSUE_TEMPLATE/Question.md
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml
create mode 100644 .github/ISSUE_TEMPLATE/config.yml
create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml
create mode 100644 .github/ISSUE_TEMPLATE/question.yml
diff --git a/.claude/skills/feature-review/SKILL.md b/.claude/skills/feature-review/SKILL.md
index ba73fa8aca..78d9d3a8bd 100644
--- a/.claude/skills/feature-review/SKILL.md
+++ b/.claude/skills/feature-review/SKILL.md
@@ -147,8 +147,8 @@ Reference code as `path:line`. Rate each finding (High / Medium / Low) in the fi
prioritize. Proposed fixes describe the smallest change that follows the existing patterns.
For larger missing features, add an issue draft at the end that follows the repository's feature request
-template (`.github/ISSUE_TEMPLATE/Feature_request.md`): a short title, "Describe your feature request",
-the motivation, a proposed behaviour (UI + logic), and which existing feature it could be modelled on.
+form (`.github/ISSUE_TEMPLATE/feature_request.yml`): a short title, the affected feature, "Problem / motivation",
+"Proposed solution" (UI + logic), and "Alternatives / workarounds" incl. which existing feature it could be modelled on.
### 7. After the report
diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md
deleted file mode 100644
index 37f5895da5..0000000000
--- a/.github/ISSUE_TEMPLATE/Bug_report.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-name: Bug report
-about: Report an issue
-labels: ":mag: needs triage"
----
-
-**Describe your issue**:
-
-**Steps to reproduce the issue**:
-
-1.
-2.
-3.
-
-**NETworkManager version**:
-
-- [ ] Portable
-
-**Windows version** (like 10.0.x.x):
-
-**.NET version** (like 6.0.x):
-
-**Screenshots**
-
-
-Eventlog (if application crashes)
-
-
diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md
deleted file mode 100644
index 377f9a53f5..0000000000
--- a/.github/ISSUE_TEMPLATE/Feature_request.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: Feature request
-about: Create a feature request
-labels: ":rocket: feature-request"
----
-
-**Describe your feature request**:
diff --git a/.github/ISSUE_TEMPLATE/Question.md b/.github/ISSUE_TEMPLATE/Question.md
deleted file mode 100644
index f941c3be7f..0000000000
--- a/.github/ISSUE_TEMPLATE/Question.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-name: Question
-about: Ask a question
-labels: ":question: question"
----
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000000..f25368f9f2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,149 @@
+name: 🐛 Bug report
+description: Something doesn't work as expected, or the application crashes.
+labels: [":mag: needs triage"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to report a bug! 🙏
+
+ Please check the [existing issues](https://github.com/BornToBeRoot/NETworkManager/issues?q=is%3Aissue) first and whether the bug still happens with the [latest version](https://github.com/BornToBeRoot/NETworkManager/releases) (or pre-release).
+
+ > [!IMPORTANT]
+ > **Security vulnerabilities** must not be reported here - please use a [private security advisory](https://github.com/BornToBeRoot/NETworkManager/security/advisories/new).
+
+ - type: dropdown
+ id: area
+ attributes:
+ label: Affected feature
+ description: Where does the bug happen?
+ options:
+ - General (start, settings, profiles, update, ...)
+ - Run command
+ - Dashboard
+ - Network Interface
+ - WiFi
+ - IP Scanner
+ - Port Scanner
+ - Ping Monitor
+ - Traceroute
+ - DNS Lookup
+ - Remote Desktop
+ - PowerShell
+ - PuTTY
+ - TigerVNC
+ - Web Console
+ - SNMP
+ - SNTP Lookup
+ - Hosts File Editor
+ - Firewall
+ - Discovery Protocol
+ - Wake on LAN
+ - RDAP
+ - Whois
+ - IP Geolocation
+ - Subnet Calculator
+ - Bit Calculator
+ - Lookup
+ - Connections
+ - Listeners
+ - Neighbor Table
+ - Documentation / website
+ - Other
+ validations:
+ required: true
+
+ - type: textarea
+ id: description
+ attributes:
+ label: What happened?
+ description: Describe the bug and what you expected to happen instead.
+ placeholder: When I scan a range, hosts are shown as down, but I expected them to be up because ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to reproduce
+ description: How can the bug be reproduced? Include the input and settings you used (without sensitive data).
+ placeholder: |
+ 1. Open the IP Scanner
+ 2. Enter `10.0.0.0/24` and click **Scan**
+ 3. ...
+ validations:
+ required: true
+
+ - type: input
+ id: version
+ attributes:
+ label: NETworkManager version
+ description: Shown in **Settings > About**.
+ placeholder: e.g. 2026.7.7.0
+ validations:
+ required: true
+
+ - type: dropdown
+ id: installation
+ attributes:
+ label: Installation
+ options:
+ - Setup (MSI)
+ - Portable
+ - Archive
+ - WinGet
+ - Chocolatey
+ - Built from source
+ validations:
+ required: true
+
+ - type: dropdown
+ id: architecture
+ attributes:
+ label: Architecture
+ options:
+ - x64
+ - ARM64
+ validations:
+ required: true
+
+ - type: input
+ id: windows
+ attributes:
+ label: Windows version
+ description: Run `winver` to see the version and build.
+ placeholder: e.g. Windows 11 24H2 (Build 26100.4061)
+ validations:
+ required: true
+
+ - type: input
+ id: dotnet
+ attributes:
+ label: .NET Desktop Runtime version
+ description: Only needed if the application doesn't start - run `dotnet --list-runtimes`.
+ placeholder: e.g. Microsoft.WindowsDesktop.App 10.0.0
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Error message / log
+ description: |
+ The error message shown by NETworkManager, or the relevant part of the log file `%LocalAppData%\NETworkManager\NETworkManager.log`.
+ If the application crashes, also the error in the Event Viewer (**Windows Logs > Application**).
+ render: text
+
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Screenshots / additional information
+ description: Drag & drop screenshots or files here, or add anything else that helps to understand the bug.
+
+ - type: checkboxes
+ id: checklist
+ attributes:
+ label: Checklist
+ options:
+ - label: I have searched the existing issues and this bug hasn't been reported yet.
+ required: true
+ - label: I have removed sensitive data (e.g. passwords, IP addresses, hostnames) from logs and screenshots.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000000..87c23fc802
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,14 @@
+blank_issues_enabled: false
+contact_links:
+ - name: 📖 Documentation
+ url: https://borntoberoot.net/NETworkManager/docs/introduction
+ about: Features, settings, FAQ and how to use NETworkManager.
+ - name: 💬 Discussions
+ url: https://github.com/BornToBeRoot/NETworkManager/discussions
+ about: Share ideas, show what you built or talk with the community.
+ - name: 🌍 Translations
+ url: https://app.transifex.com/BornToBeRoot/NETworkManager/dashboard/
+ about: Translations are managed on Transifex - help translate NETworkManager there.
+ - name: 🔒 Security vulnerability
+ url: https://github.com/BornToBeRoot/NETworkManager/security/advisories/new
+ about: Please report security vulnerabilities privately, not as a public issue.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000000..9047944f3c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,90 @@
+name: 💡 Feature request
+description: Suggest a new feature or an improvement of an existing one.
+labels: [":rocket: feature-request"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for helping to make NETworkManager better! 🚀
+
+ Please check the [existing issues](https://github.com/BornToBeRoot/NETworkManager/issues?q=is%3Aissue) first - maybe your idea was already suggested. If so, give it a 👍 instead of creating a new issue.
+
+ - type: dropdown
+ id: area
+ attributes:
+ label: Feature
+ description: Which feature is it about?
+ options:
+ - New feature / tool
+ - General (start, settings, profiles, update, ...)
+ - Run command
+ - Dashboard
+ - Network Interface
+ - WiFi
+ - IP Scanner
+ - Port Scanner
+ - Ping Monitor
+ - Traceroute
+ - DNS Lookup
+ - Remote Desktop
+ - PowerShell
+ - PuTTY
+ - TigerVNC
+ - Web Console
+ - SNMP
+ - SNTP Lookup
+ - Hosts File Editor
+ - Firewall
+ - Discovery Protocol
+ - Wake on LAN
+ - RDAP
+ - Whois
+ - IP Geolocation
+ - Subnet Calculator
+ - Bit Calculator
+ - Lookup
+ - Connections
+ - Listeners
+ - Neighbor Table
+ - Documentation / website
+ - Other
+ validations:
+ required: true
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem / motivation
+ description: What do you want to achieve, or what is missing or annoying today?
+ placeholder: I often need to ... but currently I have to ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: solution
+ attributes:
+ label: Proposed solution
+ description: How should it work? Describe the behavior and the UI (e.g. button, setting, context menu entry).
+ placeholder: Add a ... to the ... that ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives / workarounds
+ description: Other solutions you have considered, or how other tools solve it.
+
+ - type: textarea
+ id: context
+ attributes:
+ label: Additional information
+ description: Screenshots, mockups, links or anything else that helps.
+
+ - type: checkboxes
+ id: checklist
+ attributes:
+ label: Checklist
+ options:
+ - label: I have searched the existing issues and this feature hasn't been requested yet.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
new file mode 100644
index 0000000000..a2c5c7746f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/question.yml
@@ -0,0 +1,29 @@
+name: ❓ Question
+description: Ask a question about NETworkManager or how to use a feature.
+labels: [":question: question"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Have a question? Maybe the [documentation](https://borntoberoot.net/NETworkManager/docs/introduction) or the [FAQ](https://borntoberoot.net/NETworkManager/docs/category/faq) already has the answer. For ideas and general discussions, you can also use the [discussions](https://github.com/BornToBeRoot/NETworkManager/discussions).
+
+ - type: textarea
+ id: question
+ attributes:
+ label: Question
+ description: What would you like to know?
+ validations:
+ required: true
+
+ - type: textarea
+ id: context
+ attributes:
+ label: Context
+ description: What are you trying to achieve, and what have you tried? Screenshots are welcome.
+
+ - type: input
+ id: version
+ attributes:
+ label: NETworkManager version
+ description: Shown in **Settings > About** (if the question is about the application).
+ placeholder: e.g. 2026.7.7.0
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index c0570eda3a..48a570cee5 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,32 +1,51 @@
-## Changes proposed in this pull request
+
--
--
+## Description
-## Related issue(s)
+
-- Fixes #{issue number}
-## Copilot generated summary
-Provide a Copilot generated summary of the changes in this pull request.
+## Related issue
-
-Copilot summary
+
-{generated summary}
+Closes #
-
+## Type of change
-## To-Do
+- [ ] 🐛 Bug fix
+- [ ] ✨ New feature / improvement
+- [ ] 📖 Documentation / website
+- [ ] 🧹 Refactoring / maintenance (no functional change)
-- [ ] Update [documentation](https://github.com/BornToBeRoot/NETworkManager/tree/main/Website/docs) to reflect this changes
-- [ ] Update [changelog](https://github.com/BornToBeRoot/NETworkManager/tree/main/Website/docs/changelog) to reflect this changes
+## How has this been tested?
+
+
+
+
+
+## Screenshots
+
+
+
+
+
+## Checklist
+
+- [ ] The solution builds and was tested on Windows (`dotnet build`, `dotnet test`).
+- [ ] The [documentation](https://github.com/BornToBeRoot/NETworkManager/tree/main/Website/docs) is updated (if needed).
+- [ ] An entry is added to the [changelog](https://github.com/BornToBeRoot/NETworkManager/blob/main/Website/docs/changelog/next-release.md).
+- [ ] New texts are only added to `Strings.resx` (translations are done on [Transifex](https://app.transifex.com/BornToBeRoot/NETworkManager/dashboard/)).
+- [ ] No files in `Source/3rdparty/` are changed.
## Contributing
-**By submitting this pull request, I confirm the following:**
+**By submitting this pull request, I confirm that:**
-- [ ] I have read and understood the [contributing guidelines](https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTING.md) and the [code of conduct](https://github.com/BornToBeRoot/NETworkManager/blob/main/CODE_OF_CONDUCT.md).
-- [ ] I have have added my name, username or email to the [contributors](https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTORS.md) list or don't want to.
-- [ ] The code or resource is compatible with the [GNU General Public License v3.0](https://github.com/BornToBeRoot/NETworkManager/blob/main/LICENSE).
+- [ ] I have read the [contributing guidelines](https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTING.md) and the [code of conduct](https://github.com/BornToBeRoot/NETworkManager/blob/main/CODE_OF_CONDUCT.md).
+- [ ] My contribution is compatible with the [GNU General Public License v3.0](https://github.com/BornToBeRoot/NETworkManager/blob/main/LICENSE).
+- [ ] I have added myself to the [contributors](https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTORS.md) list, or I don't want to be listed.
diff --git a/README.md b/README.md
index 062e8c98ab..7d07bd4d26 100644
--- a/README.md
+++ b/README.md
@@ -46,10 +46,10 @@
-
+
-
+
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 1b4aad273b..eee279f9ae 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -365,5 +365,6 @@ Release date: **xx.xx.2026**
- Replaced the internal event system for opening a feature with data (e.g. **Open in RDAP** from the **IP Scanner**) with typed requests, which can also carry options (e.g. DNS server and record type) and profiles. Features no longer fail to open from another feature while the application list is filtered by a search. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644)
- Input histories (e.g. hosts, domains, ports) are updated in place instead of being cleared and refilled, which avoids clearing the text of the input field and unnecessary updates. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644)
- Added a unit test project (xUnit) with tests for the run command parser and the history lists. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644)
+- Reworked the GitHub issue templates as forms (bug report, feature request, question) that ask for the relevant information (e.g. affected feature, version, installation, Windows version, log), with links to the documentation, discussions, translations and security advisories. Simplified the pull request template and removed the Copilot summary section. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Language files updated via [#transifex](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Ftransifex-integration)
- Dependencies updated via [#dependabot](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Fdependabot)
From 9ed96a63fcc8ad5efe68dd0003efecd6335e3f95 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:14:07 +0200
Subject: [PATCH 105/146] Fix: IP Scanner host duration of the start scheduler
overflows int (review #4)
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Models/Network/IPScanner.cs | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs
index 7272b85387..19fe7bf6f0 100644
--- a/Source/NETworkManager.Models/Network/IPScanner.cs
+++ b/Source/NETworkManager.Models/Network/IPScanner.cs
@@ -142,12 +142,13 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
};
// Spread the start of the hosts over time instead of starting all at once - based on the time
- // an unreachable host takes (the slowest of ping attempts, port scan and NetBIOS)
- var hostDuration = Math.Max(options.ICMPAttempts * options.ICMPTimeout,
+ // an unreachable host takes (the slowest of ping attempts, port scan and NetBIOS). In long, the port
+ // scan can exceed int (e.g. 15000 ms x 10 attempts x 65535 ports with one port thread)
+ var hostDuration = Math.Max((long)options.ICMPAttempts * options.ICMPTimeout,
Math.Max(
options.PortScanEnabled
- ? options.PortScanTimeout * options.PortScanAttempts *
- (int)Math.Ceiling(options.PortScanPorts.Length / (double)options.MaxPortThreads)
+ ? (long)options.PortScanTimeout * options.PortScanAttempts *
+ (long)Math.Ceiling(options.PortScanPorts.Length / (double)options.MaxPortThreads)
: 0,
options.NetBIOSEnabled ? options.NetBIOSTimeout : 0));
From e255a039e897a9b96a6ba7e9ab6aededb627072d Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:15:23 +0200
Subject: [PATCH 106/146] Fix: Web Console URL with the trailing dot of a
single-label hostname (review #5)
Co-Authored-By: Claude Opus 5.5
---
.../Network/WebConsoleUrlHelper.cs | 15 ++++++++++++---
.../Network/WebConsoleUrlHelperTests.cs | 2 ++
2 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
index 3f0b0376c7..c522430a83 100644
--- a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
+++ b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
@@ -43,7 +43,7 @@ public static string CreateUrl(string host, IEnumerable openPorts)
///
/// Creates the URL for a host and a web port: https for 443 / 8443, otherwise http; default ports (80 / 443)
- /// are left out, IPv6 addresses in brackets.
+ /// are left out, IPv6 addresses in brackets, the trailing dot of a hostname is removed.
///
/// Hostname or IP address.
/// Port number.
@@ -53,8 +53,17 @@ public static string CreateUrl(string host, int port)
var scheme = port is 443 or 8443 ? "https" : "http";
// The zone id of an IPv6 address has to be encoded ("%" -> "%25", RFC 6874)
- if (IPAddress.TryParse(host, out var ipAddress) && ipAddress.AddressFamily == AddressFamily.InterNetworkV6)
- host = $"[{ipAddress.ToString().Replace("%", "%25")}]";
+ if (IPAddress.TryParse(host, out var ipAddress))
+ {
+ if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6)
+ host = $"[{ipAddress.ToString().Replace("%", "%25")}]";
+ }
+ else
+ {
+ // Trim the dot a single-label name still has (e.g. "gateway."), so Windows resolves it like a name typed
+ // by the user (DNS suffix search, LLMNR / NetBIOS) and the name matches the certificate (https)
+ host = host.TrimEnd('.');
+ }
return port is 80 or 443 ? $"{scheme}://{host}/" : $"{scheme}://{host}:{port}/";
}
diff --git a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
index 1676eb98ad..e31eb70866 100644
--- a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
+++ b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
@@ -12,6 +12,8 @@ public class WebConsoleUrlHelperTests
[InlineData("10.0.0.1", 8443, "https://10.0.0.1:8443/")]
[InlineData("2001:db8::1", 443, "https://[2001:db8::1]/")]
[InlineData("fe80::1%12", 8080, "http://[fe80::1%2512]:8080/")]
+ [InlineData("gateway.", 80, "http://gateway/")]
+ [InlineData("gateway.", 8443, "https://gateway:8443/")]
public void CreatesUrlForPort(string host, int port, string expected)
{
Assert.Equal(expected, WebConsoleUrlHelper.CreateUrl(host, port));
From f5964ce1172688de5228409edc2ea339ba892ef6 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:15:33 +0200
Subject: [PATCH 107/146] Fix: IP Scanner reads the neighbor table for an
unreachable host if it was not read yet (review #6)
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Models/Network/IPScanner.cs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs
index 19fe7bf6f0..bd22e0e480 100644
--- a/Source/NETworkManager.Models/Network/IPScanner.cs
+++ b/Source/NETworkManager.Models/Network/IPScanner.cs
@@ -512,8 +512,9 @@ private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddre
if (_neighborTable.TryGetValue(ipAddress, out var macAddress))
return macAddress;
- // Already read after the host answered -> no entry
- if (_neighborTableTimestamp > discoveredAt)
+ // Already read after the host answered -> no entry. An unreachable host (MinValue) uses the cache only,
+ // also if the table was not read yet (MinValue > MinValue is false)
+ if (discoveredAt == DateTime.MinValue || _neighborTableTimestamp > discoveredAt)
return string.Empty;
// Timestamp before reading the table, so entries created while reading are covered by the next read
From 9cf6f06440344aecb594a3aaabbd2db38ecd32e4 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:15:57 +0200
Subject: [PATCH 108/146] Fix: Port Scanner message of the tab still shown
after the ports were changed (review #7)
Co-Authored-By: Claude Opus 5.5
---
.../ViewModels/PortScannerViewModel.cs | 25 +++++++++++++------
1 file changed, 18 insertions(+), 7 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 623c43ef9f..0c2055d72c 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -67,7 +67,7 @@ public class PortScannerViewModel : ViewModelBase, IProfileManagerMinimal
// Ports of the last scan as entered (e.g. "22; 80; 443"), for a profile created from a result
private string _lastPortsInput;
- // Message of the tab, shown until the first scan has started (see constructor)
+ // Message of the tab, shown until the first scan has started or the ports are changed (see constructor)
private string _message;
// Rescan of shown hosts (see RescanSelectedHosts): their rows are removed and added again by the rescan. The
@@ -129,6 +129,16 @@ public string Ports
field = value;
OnPropertyChanged();
+
+ // The message of the tab is about the ports passed in (e.g. a port profile of the profile was not
+ // found), it no longer applies when they are changed
+ if (_message != null)
+ {
+ _message = null;
+
+ IsStatusMessageDisplayed = false;
+ StatusMessage = string.Empty;
+ }
}
}
@@ -434,6 +444,13 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t
{
_firstLoad = start;
+ ConfigurationManager.Current.PortScannerTabCount++;
+
+ _tabId = tabId;
+ Host = host;
+ Ports = port;
+
+ // After the ports, changing them removes the message (see Ports)
if (!string.IsNullOrEmpty(message))
{
_message = message;
@@ -442,12 +459,6 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t
IsStatusMessageDisplayed = true;
}
- ConfigurationManager.Current.PortScannerTabCount++;
-
- _tabId = tabId;
- Host = host;
- Ports = port;
-
// Set collection view
HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_HostHistory);
From 6456eed7b134d505a7d51c1e3f92ba729638d4b9 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:17:14 +0200
Subject: [PATCH 109/146] Improvement: Port Scanner pings hosts before the scan
with their own concurrency if threads are adjusted automatically (review #2)
Co-Authored-By: Claude Opus 5.5
---
.../Network/PortScanner.cs | 130 +++++++++++++++---
Website/docs/application/port-scanner.md | 2 +-
2 files changed, 114 insertions(+), 18 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs
index 590a68af84..b332b55f8f 100644
--- a/Source/NETworkManager.Models/Network/PortScanner.cs
+++ b/Source/NETworkManager.Models/Network/PortScanner.cs
@@ -5,6 +5,7 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Threading;
+using System.Threading.Channels;
using System.Threading.Tasks;
using NETworkManager.Models.Lookup;
using NETworkManager.Utilities;
@@ -50,6 +51,12 @@ public PortScanner(PortScannerOptions options)
///
private const int AutoMaxPortThreads = 128;
+ ///
+ /// Threads adjusted automatically: pings before the scan at the same time (see
+ /// ), like the default host threads of the IP Scanner.
+ ///
+ private const int AutoMaxPingThreads = 128;
+
private int _progressValue;
private readonly InterlockedCounter _portsOpen = new();
private readonly InterlockedCounter _portsClosed = new();
@@ -182,23 +189,22 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
var portStartScheduler = new StartScheduler(StartScheduler.GetInterval(MaxPortStartInterval,
TimeSpan.FromMilliseconds(_options.Timeout * _options.Attempts), portConcurrency), portConcurrency);
- await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) =>
+ // Only hosts that answer a ping are scanned (option). Adjusted automatically, the pings run ahead of
+ // the port scan with their own concurrency - otherwise an unused address would take one of the few
+ // hosts of the port scan (e.g. 4 for 1-1024) until its ping timed out. With the threads of the
+ // settings, every host is pinged in its slot of the port scan, so Max. concurrent host threads also
+ // limits the pings (e.g. to slow down a scan).
+ if (_options.PingHostsBeforeScan && _options.AutoAdjustThreads)
+ await ScanReachableHostsAsync(hosts, portCount, hostParallelOptions, ScanHostAsync,
+ cancellationToken).ConfigureAwait(false);
+ else
+ await Parallel.ForEachAsync(hosts, hostParallelOptions, ScanHostAsync).ConfigureAwait(false);
+
+ async ValueTask ScanHostAsync((IPAddress ipAddress, string hostname) host, CancellationToken hostCt)
{
- // Only hosts that answer a ping are scanned (option) - the ports of the other hosts are counted
- // as scanned, so the progress is complete
- if (_options.PingHostsBeforeScan)
- {
- var pingInfo = await PingProbe.PingAsync(host.ipAddress, _options.Timeout, _options.Attempts,
- PingBuffer, hostCt).ConfigureAwait(false);
-
- if (pingInfo.Status != IPStatus.Success)
- {
- _hostsSkipped.Increment();
- IncreaseProgress(portCount);
-
- return;
- }
- }
+ if (_options.PingHostsBeforeScan && !_options.AutoAdjustThreads &&
+ !await IsReachableAsync(host.ipAddress, portCount, hostCt).ConfigureAwait(false))
+ return;
// Resolve Hostname (PTR) while the ports are scanned. Started with the first port probe of the
// host, so the lookups of many hosts are spread over time like the probes instead of all at once
@@ -252,7 +258,7 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
IncreaseProgress();
}).ConfigureAwait(false);
- }).ConfigureAwait(false);
+ }
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -269,6 +275,96 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
});
}
+ ///
+ /// Pings the hosts with at the same time and scans the hosts that answer (as
+ /// soon as they answer) with . Returns when all pings and scans are finished, also on
+ /// cancellation or an error.
+ ///
+ /// Hosts to ping and scan.
+ /// Number of ports per host (counted as scanned for a host that doesn't answer).
+ /// Options for the hosts that are scanned at the same time.
+ /// Scans the ports of a host.
+ /// Token to monitor for cancellation requests.
+ private async Task ScanReachableHostsAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
+ int portCount, ParallelOptions hostParallelOptions,
+ Func<(IPAddress ipAddress, string hostname), CancellationToken, ValueTask> scanHost,
+ CancellationToken cancellationToken)
+ {
+ var reachableHosts = Channel.CreateUnbounded<(IPAddress ipAddress, string hostname)>();
+
+ // Also stops the pings if the port scan fails
+ using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+
+ var pingTask = Task.Run(async () =>
+ {
+ try
+ {
+ var pingParallelOptions = new ParallelOptions
+ {
+ CancellationToken = pingCts.Token,
+ MaxDegreeOfParallelism = AutoMaxPingThreads
+ };
+
+ await Parallel.ForEachAsync(hosts, pingParallelOptions, async (host, ct) =>
+ {
+ if (await IsReachableAsync(host.ipAddress, portCount, ct).ConfigureAwait(false))
+ reachableHosts.Writer.TryWrite(host); // Unbounded, always written
+ }).ConfigureAwait(false);
+ }
+ finally
+ {
+ // Also on cancellation or an error (raised below), the port scan ends with the hosts written so far
+ reachableHosts.Writer.TryComplete();
+ }
+ }, CancellationToken.None);
+
+ try
+ {
+ await Parallel.ForEachAsync(reachableHosts.Reader.ReadAllAsync(cancellationToken), hostParallelOptions,
+ scanHost).ConfigureAwait(false);
+ }
+ finally
+ {
+ // Never return while pings are still running (e.g. the port scan failed or was canceled), they would
+ // change the progress after ScanComplete
+ await pingCts.CancelAsync().ConfigureAwait(false);
+
+ try
+ {
+ await pingTask.ConfigureAwait(false);
+ }
+ catch
+ {
+ // Raised below if the port scan was finished
+ }
+ }
+
+ // Error of the pings
+ await pingTask.ConfigureAwait(false);
+ }
+
+ ///
+ /// Pings a host before the scan (see ). If it doesn't
+ /// answer, it is counted as skipped and its ports as scanned, so the progress is complete.
+ ///
+ /// IP address of the host.
+ /// Number of ports per host.
+ /// Token to monitor for cancellation requests.
+ /// True if the host answered the ping.
+ private async Task IsReachableAsync(IPAddress ipAddress, int portCount, CancellationToken cancellationToken)
+ {
+ var pingInfo = await PingProbe.PingAsync(ipAddress, _options.Timeout, _options.Attempts, PingBuffer,
+ cancellationToken).ConfigureAwait(false);
+
+ if (pingInfo.Status == IPStatus.Success)
+ return true;
+
+ _hostsSkipped.Increment();
+ IncreaseProgress(portCount);
+
+ return false;
+ }
+
///
/// Gets the number of hosts and the number of ports per host scanned at the same time: the settings, or -
/// adjusted automatically - connections spread over as many hosts as needed
diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md
index 3cc6929e23..f53fb79395 100644
--- a/Website/docs/application/port-scanner.md
+++ b/Website/docs/application/port-scanner.md
@@ -226,7 +226,7 @@ In a local network (LAN), `1` is enough. On an unreliable network (e.g. Wi-Fi, V
### Ping hosts before the scan
-Pings every host before its ports are scanned (ICMP echo, with the [Timeout](#timeout-ms) and [Attempts](#attempts) of the port scan). Only hosts that answer are scanned, the number of hosts that were not scanned is shown below the input after the scan. Faster for ranges with many unused IP addresses (e.g. a `/24` with `1-1024`), because the ports of unused addresses are not scanned until their timeout.
+Pings every host before its ports are scanned (ICMP echo, with the [Timeout](#timeout-ms) and [Attempts](#attempts) of the port scan). Only hosts that answer are scanned, the number of hosts that were not scanned is shown below the input after the scan. Faster for ranges with many unused IP addresses (e.g. a `/24` with `1-1024`), because the ports of unused addresses are not scanned until their timeout. With [Adjust threads automatically](#adjust-threads-automatically), up to `128` hosts are pinged at the same time and a host is scanned as soon as it answers; otherwise each host is pinged when it is scanned, so [Max. concurrent host threads](#max-concurrent-host-threads) also limits the pings.
**Type:** `Boolean`
From 0cc0435c002974637c172c270cef3dacc97e9261 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:19:01 +0200
Subject: [PATCH 110/146] Refactor: Shared name / ID reference input for port
profiles and DNS server profiles (review #8)
Co-Authored-By: Claude Opus 5.5
---
.../Network/DNSServer.cs | 60 +--------
.../Network/DNSServerConnectionInfoProfile.cs | 2 +-
.../Network/NamedReferenceInput.cs | 122 ++++++++++++++++++
.../Network/PortProfile.cs | 63 ++-------
.../Network/PortProfileInfo.cs | 2 +-
.../Network/NamedReferenceInputTests.cs | 95 ++++++++++++++
6 files changed, 233 insertions(+), 111 deletions(-)
create mode 100644 Source/NETworkManager.Models/Network/NamedReferenceInput.cs
create mode 100644 Source/NETworkManager.Tests/Network/NamedReferenceInputTests.cs
diff --git a/Source/NETworkManager.Models/Network/DNSServer.cs b/Source/NETworkManager.Models/Network/DNSServer.cs
index 495a85bcf9..034ca1ea08 100644
--- a/Source/NETworkManager.Models/Network/DNSServer.cs
+++ b/Source/NETworkManager.Models/Network/DNSServer.cs
@@ -14,7 +14,7 @@ public static class DNSServer
///
/// Separator of the entries of a DNS server input (e.g. "Google Public DNS; 9.9.9.9").
///
- private const char EntrySeparator = ';';
+ private const char EntrySeparator = NamedReferenceInput.EntrySeparator;
///
/// Default port of a DNS server entered without a port.
@@ -30,12 +30,7 @@ public static class DNSServer
public static DNSServerConnectionInfoProfile FindByName(IEnumerable profiles,
string name)
{
- var trimmedName = name?.Trim();
-
- return string.IsNullOrEmpty(trimmedName)
- ? null
- : profiles.FirstOrDefault(x =>
- string.Equals(x.Name?.Trim(), trimmedName, StringComparison.OrdinalIgnoreCase));
+ return NamedReferenceInput.FindByName(profiles, name);
}
///
@@ -137,28 +132,11 @@ public static List ResolveServers(string input,
/// Input with references, null if it contains no DNS server profile with an ID.
public static string EncodeReferences(string input, IEnumerable profiles)
{
- if (string.IsNullOrWhiteSpace(input))
- return null;
-
var profileList = profiles.ToList();
- var hasReference = false;
- List entries = [];
-
- foreach (var entry in input.Split(EntrySeparator))
- {
- var profile = FindByName(profileList, entry);
- if (profile == null || profile.UseWindowsDNSServer || profile.Id == Guid.Empty)
- {
- entries.Add(entry.Trim());
- continue;
- }
-
- entries.Add(profile.Id.ToString("B"));
- hasReference = true;
- }
-
- return hasReference ? string.Join("; ", entries) : null;
+ // The Windows DNS profile is kept by name
+ return NamedReferenceInput.EncodeReferences(input,
+ entry => FindByName(profileList, entry) is { UseWindowsDNSServer: false } profile ? profile : null);
}
///
@@ -172,33 +150,7 @@ public static string EncodeReferences(string input, IEnumerable profiles, out string input)
{
- input = null;
-
- if (string.IsNullOrWhiteSpace(inputWithReferences))
- return false;
-
- var profileList = profiles.ToList();
- List entries = [];
-
- foreach (var entry in inputWithReferences.Split(EntrySeparator))
- {
- if (!Guid.TryParseExact(entry.Trim(), "B", out var id))
- {
- entries.Add(entry.Trim());
- continue;
- }
-
- var profile = profileList.FirstOrDefault(x => x.Id == id);
-
- if (profile == null)
- return false;
-
- entries.Add(profile.Name);
- }
-
- input = string.Join("; ", entries);
-
- return true;
+ return NamedReferenceInput.TryDecodeReferences(inputWithReferences, profiles, out input);
}
///
diff --git a/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs b/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs
index 961a9fce2a..956f1db248 100644
--- a/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs
+++ b/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs
@@ -6,7 +6,7 @@ namespace NETworkManager.Models.Network;
///
/// Class is used to store informations about a DNS server profile.
///
-public class DNSServerConnectionInfoProfile : ServerConnectionInfoProfile
+public class DNSServerConnectionInfoProfile : ServerConnectionInfoProfile, INamedReference
{
///
/// Create an instance of . This will use the
diff --git a/Source/NETworkManager.Models/Network/NamedReferenceInput.cs b/Source/NETworkManager.Models/Network/NamedReferenceInput.cs
new file mode 100644
index 0000000000..ff3c614ddb
--- /dev/null
+++ b/Source/NETworkManager.Models/Network/NamedReferenceInput.cs
@@ -0,0 +1,122 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace NETworkManager.Models.Network;
+
+///
+/// Item that can be entered by its name in an input with several entries (e.g. a port profile in a ports input)
+/// and is saved as a reference to its ID (see ).
+///
+public interface INamedReference
+{
+ ///
+ /// Internal ID, kept when the item is renamed.
+ ///
+ Guid Id { get; }
+
+ ///
+ /// Name that is entered in the input.
+ ///
+ string Name { get; }
+}
+
+///
+/// Input with entries separated by ";" (e.g. "Webserver; 8080"), where an entry can be the name of an item (e.g. a
+/// port profile or DNS server profile). The names can be replaced by references to the ID of the item (e.g.
+/// "{8f1c...}; 8080"), so a profile still finds the item after it was renamed.
+///
+public static class NamedReferenceInput
+{
+ ///
+ /// Separator of the entries of an input.
+ ///
+ public const char EntrySeparator = ';';
+
+ ///
+ /// Finds an item by its name (case-insensitive, leading / trailing spaces are ignored).
+ ///
+ /// Items.
+ /// Name of the item.
+ /// Item, or null if there is none with the name.
+ public static T FindByName(IEnumerable items, string name) where T : class, INamedReference
+ {
+ var trimmedName = name?.Trim();
+
+ return string.IsNullOrEmpty(trimmedName)
+ ? null
+ : items.FirstOrDefault(x => string.Equals(x.Name?.Trim(), trimmedName, StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Replaces the names of items in an input by references to their ID (e.g. "Webserver; 8080" ->
+ /// "{8f1c...}; 8080"). Entries without an item (or an item without an ID) are kept, trimmed.
+ ///
+ /// Input.
+ /// Gets the item an entry is replaced by, null to keep the entry.
+ /// Input with references, null if it contains no item.
+ public static string EncodeReferences(string input, Func findItem) where T : class, INamedReference
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return null;
+
+ var hasReference = false;
+ List entries = [];
+
+ foreach (var entry in input.Split(EntrySeparator))
+ {
+ var item = findItem(entry);
+
+ if (item == null || item.Id == Guid.Empty)
+ {
+ entries.Add(entry.Trim());
+ continue;
+ }
+
+ entries.Add(item.Id.ToString("B"));
+ hasReference = true;
+ }
+
+ return hasReference ? string.Join("; ", entries) : null;
+ }
+
+ ///
+ /// Replaces the references in an input (see ) by the current names of the
+ /// items (e.g. "{8f1c...}; 8080" -> "Webserver; 8080").
+ ///
+ /// Input with references.
+ /// Items.
+ /// Input with the names of the items, null if an item was not found.
+ /// True if every referenced item was found.
+ public static bool TryDecodeReferences(string inputWithReferences, IEnumerable items, out string input)
+ where T : class, INamedReference
+ {
+ input = null;
+
+ if (string.IsNullOrWhiteSpace(inputWithReferences))
+ return false;
+
+ var itemList = items as IList ?? [.. items];
+ List entries = [];
+
+ foreach (var entry in inputWithReferences.Split(EntrySeparator))
+ {
+ if (!Guid.TryParseExact(entry.Trim(), "B", out var id))
+ {
+ entries.Add(entry.Trim());
+ continue;
+ }
+
+ var item = itemList.FirstOrDefault(x => x.Id == id);
+
+ if (item == null)
+ return false;
+
+ entries.Add(item.Name);
+ }
+
+ input = string.Join("; ", entries);
+
+ return true;
+ }
+}
diff --git a/Source/NETworkManager.Models/Network/PortProfile.cs b/Source/NETworkManager.Models/Network/PortProfile.cs
index 323113336c..bbc0297057 100644
--- a/Source/NETworkManager.Models/Network/PortProfile.cs
+++ b/Source/NETworkManager.Models/Network/PortProfile.cs
@@ -1,5 +1,4 @@
-using System;
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using NETworkManager.Utilities;
@@ -10,7 +9,7 @@ public static class PortProfile
///
/// Separator of the entries of a ports input (e.g. "Webserver; 8080").
///
- private const char EntrySeparator = ';';
+ private const char EntrySeparator = NamedReferenceInput.EntrySeparator;
///
/// Finds a port profile by its name (case-insensitive, leading / trailing spaces are ignored).
@@ -20,12 +19,7 @@ public static class PortProfile
/// Port profile, or null if there is none with the name.
public static PortProfileInfo FindByName(IEnumerable portProfiles, string name)
{
- var trimmedName = name?.Trim();
-
- return string.IsNullOrEmpty(trimmedName)
- ? null
- : portProfiles.FirstOrDefault(x =>
- string.Equals(x.Name?.Trim(), trimmedName, StringComparison.OrdinalIgnoreCase));
+ return NamedReferenceInput.FindByName(portProfiles, name);
}
///
@@ -89,27 +83,11 @@ public static bool TryResolvePorts(string input, IEnumerable po
/// Input with references, null if it contains no port profile.
public static string EncodeReferences(string input, IEnumerable portProfiles)
{
- if (string.IsNullOrWhiteSpace(input))
- return null;
-
- var hasReference = false;
- List entries = [];
-
- foreach (var entry in input.Split(EntrySeparator))
- {
- var portProfile = PortRangeHelper.IsValidPortOrPortRange(entry) ? null : FindByName(portProfiles, entry);
+ var portProfileList = portProfiles.ToList();
- if (portProfile == null || portProfile.Id == Guid.Empty)
- {
- entries.Add(entry.Trim());
- continue;
- }
-
- entries.Add(portProfile.Id.ToString("B"));
- hasReference = true;
- }
-
- return hasReference ? string.Join("; ", entries) : null;
+ // A port or port range is never a port profile (see TryResolvePorts)
+ return NamedReferenceInput.EncodeReferences(input,
+ entry => PortRangeHelper.IsValidPortOrPortRange(entry) ? null : FindByName(portProfileList, entry));
}
///
@@ -123,32 +101,7 @@ public static string EncodeReferences(string input, IEnumerable
public static bool TryDecodeReferences(string inputWithReferences, IEnumerable portProfiles,
out string input)
{
- input = null;
-
- if (string.IsNullOrWhiteSpace(inputWithReferences))
- return false;
-
- List entries = [];
-
- foreach (var entry in inputWithReferences.Split(EntrySeparator))
- {
- if (!Guid.TryParseExact(entry.Trim(), "B", out var id))
- {
- entries.Add(entry.Trim());
- continue;
- }
-
- var portProfile = portProfiles.FirstOrDefault(x => x.Id == id);
-
- if (portProfile == null)
- return false;
-
- entries.Add(portProfile.Name);
- }
-
- input = string.Join("; ", entries);
-
- return true;
+ return NamedReferenceInput.TryDecodeReferences(inputWithReferences, portProfiles, out input);
}
public static List GetDefaultList()
diff --git a/Source/NETworkManager.Models/Network/PortProfileInfo.cs b/Source/NETworkManager.Models/Network/PortProfileInfo.cs
index 4bae65a68d..8d25cd7bd4 100644
--- a/Source/NETworkManager.Models/Network/PortProfileInfo.cs
+++ b/Source/NETworkManager.Models/Network/PortProfileInfo.cs
@@ -2,7 +2,7 @@
namespace NETworkManager.Models.Network;
-public class PortProfileInfo
+public class PortProfileInfo : INamedReference
{
public PortProfileInfo()
{
diff --git a/Source/NETworkManager.Tests/Network/NamedReferenceInputTests.cs b/Source/NETworkManager.Tests/Network/NamedReferenceInputTests.cs
new file mode 100644
index 0000000000..edf2fd06cc
--- /dev/null
+++ b/Source/NETworkManager.Tests/Network/NamedReferenceInputTests.cs
@@ -0,0 +1,95 @@
+using System;
+using NETworkManager.Models.Network;
+using Xunit;
+
+namespace NETworkManager.Tests.Network;
+
+public class NamedReferenceInputTests
+{
+ private sealed class Item(Guid id, string name) : INamedReference
+ {
+ public Guid Id { get; } = id;
+ public string Name { get; } = name;
+ }
+
+ private static readonly Item Alpha = new(Guid.Parse("11111111-1111-1111-1111-111111111111"), "Alpha");
+ private static readonly Item Beta = new(Guid.Parse("22222222-2222-2222-2222-222222222222"), " Beta ");
+ private static readonly Item WithoutId = new(Guid.Empty, "Legacy");
+
+ private static readonly Item[] Items = [Alpha, Beta, WithoutId];
+
+ private static Item Find(string entry) => NamedReferenceInput.FindByName(Items, entry);
+
+ [Theory]
+ [InlineData("Alpha", "Alpha")]
+ [InlineData("alpha", "Alpha")]
+ [InlineData(" ALPHA ", "Alpha")]
+ [InlineData("Beta", " Beta ")]
+ public void FindByName_IgnoresCaseAndSpaces(string name, string expected)
+ {
+ Assert.Equal(expected, NamedReferenceInput.FindByName(Items, name)?.Name);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("Gamma")]
+ public void FindByName_NullIfNotFound(string name)
+ {
+ Assert.Null(NamedReferenceInput.FindByName(Items, name));
+ }
+
+ [Fact]
+ public void EncodeReferences_ReplacesNamesAndTrimsOtherEntries()
+ {
+ Assert.Equal("{11111111-1111-1111-1111-111111111111}; 8080; {22222222-2222-2222-2222-222222222222}",
+ NamedReferenceInput.EncodeReferences(" alpha ;8080 ; Beta", Find));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(" ")]
+ [InlineData("8080; Gamma")]
+ [InlineData("Legacy")]
+ public void EncodeReferences_NullWithoutReference(string input)
+ {
+ Assert.Null(NamedReferenceInput.EncodeReferences(input, Find));
+ }
+
+ [Fact]
+ public void EncodeReferences_KeepsEntriesSkippedByFindItem()
+ {
+ Assert.Equal("Alpha; {22222222-2222-2222-2222-222222222222}",
+ NamedReferenceInput.EncodeReferences("Alpha; Beta", entry => entry.Trim() == "Alpha" ? null : Find(entry)));
+ }
+
+ [Fact]
+ public void DecodeReferences_RoundTrip()
+ {
+ var encoded = NamedReferenceInput.EncodeReferences("Alpha; 8080", Find);
+
+ Assert.True(NamedReferenceInput.TryDecodeReferences(encoded, Items, out var input));
+ Assert.Equal("Alpha; 8080", input);
+ }
+
+ [Fact]
+ public void DecodeReferences_CurrentName()
+ {
+ var renamed = new Item(Alpha.Id, "Alpha (renamed)");
+
+ Assert.True(NamedReferenceInput.TryDecodeReferences("{11111111-1111-1111-1111-111111111111}; 8080 ",
+ [renamed], out var input));
+ Assert.Equal("Alpha (renamed); 8080", input);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("{33333333-3333-3333-3333-333333333333}; 8080")]
+ public void DecodeReferences_FalseIfEmptyOrItemIsMissing(string inputWithReferences)
+ {
+ Assert.False(NamedReferenceInput.TryDecodeReferences(inputWithReferences, Items, out var input));
+ Assert.Null(input);
+ }
+}
From 758568870f10af5fc239dba352bd2e8753aa3b0b Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:21:20 +0200
Subject: [PATCH 111/146] Refactor: Shared search delay instead of a search
timer in every view model (review #9)
Co-Authored-By: Claude Opus 5.5
---
.../SearchDelay.cs | 47 +++++++++++++++++++
.../ViewModels/ConnectionsViewModel.cs | 23 +++------
.../ViewModels/FirewallViewModel.cs | 23 +++------
.../ViewModels/HostsFileEditorViewModel.cs | 23 +++------
.../ViewModels/IPScannerViewModel.cs | 23 +++------
.../ImportProfilesResultViewModel.cs | 18 ++-----
.../ViewModels/ListenersViewModel.cs | 23 +++------
.../ViewModels/NeighborTableViewModel.cs | 23 +++------
.../ViewModels/PortScannerViewModel.cs | 23 +++------
.../ViewModels/ProfileHostViewModelBase.cs | 26 ++++++----
.../ViewModels/ProfilesViewModel.cs | 19 ++------
.../ViewModels/WiFiViewModel.cs | 23 +++------
12 files changed, 121 insertions(+), 173 deletions(-)
create mode 100644 Source/NETworkManager.Utilities.WPF/SearchDelay.cs
diff --git a/Source/NETworkManager.Utilities.WPF/SearchDelay.cs b/Source/NETworkManager.Utilities.WPF/SearchDelay.cs
new file mode 100644
index 0000000000..b2cc01af35
--- /dev/null
+++ b/Source/NETworkManager.Utilities.WPF/SearchDelay.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Windows.Threading;
+
+namespace NETworkManager.Utilities.WPF;
+
+///
+/// Delays a search while the user is typing, so a large list is not filtered on every key press:
+/// on every change of the search text, the search is applied once the user stopped typing
+/// for the interval - or right away with (e.g. Enter in the search box, see
+/// ).
+///
+public sealed class SearchDelay
+{
+ private readonly DispatcherTimer _timer;
+ private readonly Action _applySearch;
+
+ ///
+ /// Creates a new search delay. Must be created on the UI thread.
+ ///
+ /// Time without a change after which the search is applied.
+ /// Applies the search (e.g. refreshes the filtered view).
+ public SearchDelay(TimeSpan interval, Action applySearch)
+ {
+ _applySearch = applySearch;
+
+ _timer = new DispatcherTimer { Interval = interval };
+ _timer.Tick += (_, _) => Apply();
+ }
+
+ ///
+ /// Starts the delay again (the search text was changed).
+ ///
+ public void Restart()
+ {
+ _timer.Stop();
+ _timer.Start();
+ }
+
+ ///
+ /// Applies the search right away, a pending delay is stopped.
+ ///
+ public void Apply()
+ {
+ _timer.Stop();
+ _applySearch();
+ }
+}
diff --git a/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs b/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs
index 64362628a7..af0325273b 100644
--- a/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs
@@ -7,6 +7,7 @@
using NETworkManager.Models.Network;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -34,8 +35,7 @@ public class ConnectionsViewModel : ViewModelBase
///
public ConnectionsViewModel()
{
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
_isLoading = true;
@@ -123,7 +123,7 @@ private async void AutoRefreshTimer_Tick(object sender, EventArgs e)
/// Delays filtering while the user is typing (see ), so a large list is not filtered on
/// every key press.
///
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
///
/// Gets or sets the search text.
@@ -138,9 +138,8 @@ public string Search
field = value;
- // Filter after a short delay while typing (see _searchDispatcherTimer)
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ // Filter after a short delay while typing (see _searchDelay)
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -449,27 +448,17 @@ public void OnViewHide()
_autoRefreshTimer.Stop();
}
- ///
- /// Filters once the user stopped typing (see ).
- ///
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
- {
- ApplySearch();
- }
-
///
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch());
+ public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
///
/// Filters with the current right away.
///
private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
-
ResultsView.Refresh();
}
diff --git a/Source/NETworkManager/ViewModels/FirewallViewModel.cs b/Source/NETworkManager/ViewModels/FirewallViewModel.cs
index bfe5213efc..bf61b204af 100644
--- a/Source/NETworkManager/ViewModels/FirewallViewModel.cs
+++ b/Source/NETworkManager/ViewModels/FirewallViewModel.cs
@@ -10,6 +10,7 @@
using NETworkManager.Models.Firewall;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -108,11 +109,11 @@ public IList SelectedResults
/// Delays filtering the rules while the user is typing (see ),
/// so the view (can be 1000+ rules if all rules are shown) is not filtered on every key press.
///
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
///
/// Gets or sets the search text for filtering rules.
- /// The rules are filtered after a short delay (see ).
+ /// The rules are filtered after a short delay (see ).
///
public string RulesSearch
{
@@ -124,8 +125,7 @@ public string RulesSearch
field = value;
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -272,8 +272,7 @@ public FirewallViewModel()
Matches(rule.Description);
};
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
// Load firewall rules
_ = Refresh(true);
@@ -912,27 +911,17 @@ private static List ParseAddressesString(string value)
return [.. value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
}
- ///
- /// Filters the rules once the user stopped typing (see ).
- ///
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
- {
- ApplySearch();
- }
-
///
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch());
+ public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
///
/// Filters with the current right away.
///
private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
-
ResultsView.Refresh();
}
diff --git a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
index 04dc7a026d..0973cdb821 100644
--- a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
+++ b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
@@ -7,6 +7,7 @@
using NETworkManager.Models.HostsFileEditor;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -40,7 +41,7 @@ public class HostsFileEditorViewModel : ViewModelBase
/// Delays filtering while the user is typing (see ), so a large list is not filtered on
/// every key press.
///
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
///
/// Gets or sets the search text.
@@ -55,9 +56,8 @@ public string Search
field = value;
- // Filter after a short delay while typing (see _searchDispatcherTimer)
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ // Filter after a short delay while typing (see _searchDelay)
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -199,8 +199,7 @@ private set
///
public HostsFileEditorViewModel()
{
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
_isLoading = true;
@@ -769,27 +768,17 @@ public void OnViewHide()
{
}
- ///
- /// Filters once the user stopped typing (see ).
- ///
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
- {
- ApplySearch();
- }
-
///
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch());
+ public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
///
/// Filters with the current right away.
///
private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
-
ResultsView.Refresh();
}
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index 52542a5e78..5454deca1a 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -10,6 +10,7 @@
using NETworkManager.Profiles;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -173,11 +174,11 @@ public ObservableCollection Results
/// Delays filtering the results while the user is typing (see ), so a large result is not
/// filtered on every key press.
///
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
///
/// Gets or sets the search text for filtering the results (IP address, hostname, MAC address, vendor, ports).
- /// The results are filtered after a short delay (see ).
+ /// The results are filtered after a short delay (see ).
///
public string Search
{
@@ -189,8 +190,7 @@ public string Search
field = value;
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -437,8 +437,7 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true)
liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Vendor));
liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Ports));
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
}
///
@@ -1159,27 +1158,17 @@ public void OnClose()
#region Events
- ///
- /// Filters the results once the user stopped typing (see ).
- ///
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
- {
- ApplySearch();
- }
-
///
/// Gets the command to filter the results right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch());
+ public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
///
/// Filters the results with the current right away (e.g. Enter in the search box).
///
private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
-
// Live filtering only while searching - without a search every host is shown anyway, but it would still
// listen to the filtered properties of every row and filter a row again on every update
var liveShaping = (ICollectionViewLiveShaping)ResultsView;
diff --git a/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs b/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs
index d5d220b440..1c022e123c 100644
--- a/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs
@@ -5,13 +5,13 @@
using System.Linq;
using System.Windows.Data;
using System.Windows.Input;
-using System.Windows.Threading;
using log4net;
using NETworkManager.Localization;
using NETworkManager.Models.RDAP;
using NETworkManager.Profiles;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
namespace NETworkManager.ViewModels;
@@ -20,7 +20,7 @@ public sealed class ImportProfilesResultViewModel : ViewModelBase
private static readonly ILog Log = LogManager.GetLogger(typeof(ImportProfilesResultViewModel));
private readonly Action _completedCallback;
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
public ImportProfilesResultViewModel(IReadOnlyList candidates, ProfileImportSource importSource, string targetGroup, Action backCallback, Action cancelCallback, Action completedCallback)
{
@@ -50,8 +50,7 @@ public ImportProfilesResultViewModel(IReadOnlyList candi
CandidatesView = CollectionViewSource.GetDefaultView(Candidates);
CandidatesView.Filter = FilterCandidate;
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
ApplicationOptions = BuildApplicationOptions();
foreach (var tool in ApplicationOptions)
@@ -78,8 +77,7 @@ public string Search
field = value;
IsSearching = true;
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -308,23 +306,17 @@ private void OnApplicationOptionChanged(object sender, PropertyChangedEventArgs
HasAnyEnabledApplication = ApplicationOptions.Any(t => t.IsEnabled);
}
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
- {
- ApplySearch();
- }
-
///
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch());
+ public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
///
/// Filters with the current right away.
///
private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
CandidatesView.Refresh();
IsSearching = false;
}
diff --git a/Source/NETworkManager/ViewModels/ListenersViewModel.cs b/Source/NETworkManager/ViewModels/ListenersViewModel.cs
index 8d20adc07f..3494da8d3f 100644
--- a/Source/NETworkManager/ViewModels/ListenersViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ListenersViewModel.cs
@@ -6,6 +6,7 @@
using NETworkManager.Models.Network;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -33,8 +34,7 @@ public class ListenersViewModel : ViewModelBase
///
public ListenersViewModel()
{
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
_isLoading = true;
@@ -114,7 +114,7 @@ private async void AutoRefreshTimer_Tick(object sender, EventArgs e)
/// Delays filtering while the user is typing (see ), so a large list is not filtered on
/// every key press.
///
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
///
/// Gets or sets the search text.
@@ -129,9 +129,8 @@ public string Search
field = value;
- // Filter after a short delay while typing (see _searchDispatcherTimer)
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ // Filter after a short delay while typing (see _searchDelay)
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -437,27 +436,17 @@ public void OnViewHide()
_autoRefreshTimer.Stop();
}
- ///
- /// Filters once the user stopped typing (see ).
- ///
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
- {
- ApplySearch();
- }
-
///
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch());
+ public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
///
/// Filters with the current right away.
///
private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
-
ResultsView.Refresh();
}
diff --git a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs
index 7fd8efe303..9e5ac0359c 100644
--- a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs
+++ b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs
@@ -6,6 +6,7 @@
using NETworkManager.Models.Network;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -32,15 +33,14 @@ public class NeighborTableViewModel : ViewModelBase
/// Delays filtering while the user is typing (see ), so a large list is not filtered on
/// every key press.
///
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
///
/// Initializes a new instance of the class.
///
public NeighborTableViewModel()
{
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
_isLoading = true;
@@ -110,9 +110,8 @@ public string Search
field = value;
- // Filter after a short delay while typing (see _searchDispatcherTimer)
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ // Filter after a short delay while typing (see _searchDelay)
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -515,27 +514,17 @@ private async void AutoRefreshTimer_Tick(object sender, EventArgs e)
_autoRefreshTimer.Start();
}
- ///
- /// Filters once the user stopped typing (see ).
- ///
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
- {
- ApplySearch();
- }
-
///
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch());
+ public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
///
/// Filters with the current right away.
///
private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
-
ResultsView.Refresh();
}
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 0c2055d72c..8062b9145c 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -10,6 +10,7 @@
using NETworkManager.Profiles;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -369,11 +370,11 @@ public bool IsStatusMessageDisplayed
/// Delays filtering the results while the user is typing (see ), so a large result is not
/// filtered on every key press.
///
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
///
/// Gets or sets the search text for filtering the results (IP address, hostname, port, protocol, service, status).
- /// The results are filtered after a short delay (see ).
+ /// The results are filtered after a short delay (see ).
///
public string Search
{
@@ -385,8 +386,7 @@ public string Search
field = value;
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -501,8 +501,7 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t
Matches(ResourceTranslator.Translate(ResourceIdentifier.PortState, info.State));
};
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
LoadSettings();
}
@@ -1116,27 +1115,17 @@ await DialogHelper.ShowMessageAsync(window, Strings.Error,
#region Events
- ///
- /// Filters the results once the user stopped typing (see ).
- ///
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
- {
- ApplySearch();
- }
-
///
/// Gets the command to filter the results right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch());
+ public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
///
/// Filters the results with the current right away (e.g. Enter in the search box).
///
private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
-
ResultsView.Refresh();
}
diff --git a/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs b/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs
index 1cf7e58328..ad719ff64e 100644
--- a/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs
+++ b/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs
@@ -3,6 +3,7 @@
using NETworkManager.Profiles;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
@@ -11,7 +12,6 @@
using System.Windows;
using System.Windows.Data;
using System.Windows.Input;
-using System.Windows.Threading;
namespace NETworkManager.ViewModels;
@@ -24,7 +24,7 @@ public abstract class ProfileHostViewModelBase : ViewModelBase, IProfileHostView
{
#region Variables
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
private bool _searchDisabled;
private bool _isViewActive = true;
@@ -95,8 +95,7 @@ public string Search
if (!_searchDisabled)
{
IsSearching = true;
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ _searchDelay.Restart();
}
OnPropertyChanged();
@@ -199,6 +198,15 @@ public bool IsProfileFilterSet
#region Constructor
+ ///
+ /// Initializes the search delay, before the constructor of the derived class (which may already set
+ /// ).
+ ///
+ protected ProfileHostViewModelBase()
+ {
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
+ }
+
///
/// Initializes the shared profile-panel state. Must be called by derived constructors after their own
/// tool-specific initialization (and while _isLoading-equivalent guards, if any, are still active).
@@ -217,9 +225,6 @@ protected void InitializeProfileHost()
ProfileManager.OnProfilesUpdated += ProfileManager_OnProfilesUpdated;
GroupExpanderStateStore.GroupExpandedChanged += GroupExpanderStateStore_GroupExpandedChanged;
-
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
}
#endregion
@@ -505,10 +510,11 @@ private void GroupExpanderStateStore_GroupExpandedChanged(object sender, GroupEx
SettingsManager.Current.SettingsChanged = true;
}
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
+ ///
+ /// Filters the profiles with the current .
+ ///
+ private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
-
RefreshProfiles();
IsSearching = false;
diff --git a/Source/NETworkManager/ViewModels/ProfilesViewModel.cs b/Source/NETworkManager/ViewModels/ProfilesViewModel.cs
index 69399a1f4c..ca71abe814 100644
--- a/Source/NETworkManager/ViewModels/ProfilesViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ProfilesViewModel.cs
@@ -1,6 +1,7 @@
using NETworkManager.Profiles;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using System;
using System.Collections;
using System.Collections.Generic;
@@ -10,7 +11,6 @@
using System.Windows;
using System.Windows.Data;
using System.Windows.Input;
-using System.Windows.Threading;
namespace NETworkManager.ViewModels;
@@ -28,15 +28,14 @@ public ProfilesViewModel()
ProfileManager.OnProfilesUpdated += ProfileManager_OnProfilesUpdated;
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
}
#endregion
#region Variables
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
private bool _searchDisabled;
private bool _isViewActive = true;
@@ -150,8 +149,7 @@ public string Search
if (!_searchDisabled)
{
IsSearching = true;
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ _searchDelay.Restart();
}
OnPropertyChanged();
@@ -442,24 +440,17 @@ private void ProfileManager_OnProfilesUpdated(object sender, EventArgs e)
RefreshProfiles();
}
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
- {
- ApplySearch();
- }
-
///
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch());
+ public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
///
/// Filters with the current right away.
///
private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
-
RefreshProfiles();
IsSearching = false;
diff --git a/Source/NETworkManager/ViewModels/WiFiViewModel.cs b/Source/NETworkManager/ViewModels/WiFiViewModel.cs
index 55579ef76f..842f712b1f 100644
--- a/Source/NETworkManager/ViewModels/WiFiViewModel.cs
+++ b/Source/NETworkManager/ViewModels/WiFiViewModel.cs
@@ -13,6 +13,7 @@
using NETworkManager.Models.Network;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using SkiaSharp;
using System;
@@ -187,7 +188,7 @@ public AutoRefreshTimeInfo SelectedAutoRefreshTime
/// Delays filtering while the user is typing (see ), so a large list is not filtered on
/// every key press.
///
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
public string Search
{
@@ -199,9 +200,8 @@ public string Search
field = value;
- // Filter after a short delay while typing (see _searchDispatcherTimer)
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ // Filter after a short delay while typing (see _searchDelay)
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -486,8 +486,7 @@ private set
public WiFiViewModel()
{
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
_isLoading = true;
@@ -1356,27 +1355,17 @@ private void HideConnectionStatusMessageTimer_Tick(object sender, EventArgs e)
IsConnectionStatusMessageDisplayed = false;
}
- ///
- /// Filters once the user stopped typing (see ).
- ///
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
- {
- ApplySearch();
- }
-
///
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => ApplySearch());
+ public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
///
/// Filters with the current right away.
///
private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
-
NetworksView.Refresh();
}
From 56c1399f362d0ede488b1dd5bc704f949713895b Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:36:38 +0200
Subject: [PATCH 112/146] Revert: IP Scanner reads the neighbor table for an
unreachable host if it was not read yet (final review #1)
An unreachable host that blocks ICMP still has an ARP entry from the ping, the
first read of the table finds its MAC address. Comments describe this now.
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Models/Network/IPScanner.cs | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs
index bd22e0e480..f62f11742b 100644
--- a/Source/NETworkManager.Models/Network/IPScanner.cs
+++ b/Source/NETworkManager.Models/Network/IPScanner.cs
@@ -331,7 +331,8 @@ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanPa
OnHostFound(new IPScannerHostFoundArgs(hostInfo));
- // An unreachable host never needs a new snapshot of the neighbor table
+ // An unreachable host doesn't need a new snapshot of the neighbor table - the table is only read
+ // if it was not read yet (e.g. a host that blocks ICMP still has an ARP entry from the ping)
enrichTask = EnrichAsync(ipAddress, hostInfo, DateTime.MinValue, networkInterfaces,
cancellationToken);
}
@@ -459,7 +460,7 @@ await Task.WhenAll(
var macAddress = string.Empty;
// Get info from neighbor table. Only hosts in a local subnet are in it (for any other
- // host it contains the router), an unreachable host never needs a new snapshot.
+ // host it contains the router), an unreachable host only reads it if it was not read yet.
if (IsInLocalSubnet(ipAddress, networkInterfaces))
macAddress = await GetMACAddressFromNeighborTableAsync(ipAddress, discoveredAt, cancellationToken)
.ConfigureAwait(false);
@@ -496,7 +497,10 @@ await Task.WhenAll(
/// that finish at the same time share one read.
///
/// IP address of the host (in a local subnet, see ).
- /// Time the host answered, to use the cache only.
+ ///
+ /// Time the host answered, to use the cache - or to read the table if it was not
+ /// read yet.
+ ///
/// Token to monitor for cancellation requests.
/// MAC address or an empty string.
private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddress, DateTime discoveredAt,
@@ -512,9 +516,8 @@ private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddre
if (_neighborTable.TryGetValue(ipAddress, out var macAddress))
return macAddress;
- // Already read after the host answered -> no entry. An unreachable host (MinValue) uses the cache only,
- // also if the table was not read yet (MinValue > MinValue is false)
- if (discoveredAt == DateTime.MinValue || _neighborTableTimestamp > discoveredAt)
+ // Already read after the host answered -> no entry
+ if (_neighborTableTimestamp > discoveredAt)
return string.Empty;
// Timestamp before reading the table, so entries created while reading are covered by the next read
From f083c47d867653444e1d9430739a5c7539cede32 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:36:47 +0200
Subject: [PATCH 113/146] Fix: Port Scanner hid an error of a scan that didn't
start when the ports were changed (final review #2)
Co-Authored-By: Claude Opus 5.5
---
.../ViewModels/PortScannerViewModel.cs | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 8062b9145c..2ef5997c8f 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -132,13 +132,17 @@ public string Ports
OnPropertyChanged();
// The message of the tab is about the ports passed in (e.g. a port profile of the profile was not
- // found), it no longer applies when they are changed
+ // found), it no longer applies when they are changed. Hidden only if it is still shown - not an error of
+ // a scan that didn't start (e.g. invalid hosts)
if (_message != null)
{
- _message = null;
+ if (StatusMessage == _message)
+ {
+ IsStatusMessageDisplayed = false;
+ StatusMessage = string.Empty;
+ }
- IsStatusMessageDisplayed = false;
- StatusMessage = string.Empty;
+ _message = null;
}
}
}
From 34853469d9ea63296db5ea2fa7ac7ead470817d7 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:38:02 +0200
Subject: [PATCH 114/146] Refactor: Port Scanner ping mode computed once, ping
pipeline without Task.Run and a second token (final review #5, #6)
Co-Authored-By: Claude Opus 5.5
---
.../Network/PortScanner.cs | 73 ++++++++++---------
1 file changed, 39 insertions(+), 34 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs
index b332b55f8f..bdc436e87f 100644
--- a/Source/NETworkManager.Models/Network/PortScanner.cs
+++ b/Source/NETworkManager.Models/Network/PortScanner.cs
@@ -194,16 +194,18 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
// hosts of the port scan (e.g. 4 for 1-1024) until its ping timed out. With the threads of the
// settings, every host is pinged in its slot of the port scan, so Max. concurrent host threads also
// limits the pings (e.g. to slow down a scan).
- if (_options.PingHostsBeforeScan && _options.AutoAdjustThreads)
- await ScanReachableHostsAsync(hosts, portCount, hostParallelOptions, ScanHostAsync,
- cancellationToken).ConfigureAwait(false);
+ var pingAhead = _options.PingHostsBeforeScan && _options.AutoAdjustThreads;
+ var pingInSlot = _options.PingHostsBeforeScan && !pingAhead;
+
+ if (pingAhead)
+ await ScanReachableHostsAsync(hosts, portCount, hostParallelOptions, ScanHostAsync)
+ .ConfigureAwait(false);
else
await Parallel.ForEachAsync(hosts, hostParallelOptions, ScanHostAsync).ConfigureAwait(false);
async ValueTask ScanHostAsync((IPAddress ipAddress, string hostname) host, CancellationToken hostCt)
{
- if (_options.PingHostsBeforeScan && !_options.AutoAdjustThreads &&
- !await IsReachableAsync(host.ipAddress, portCount, hostCt).ConfigureAwait(false))
+ if (pingInSlot && !await IsReachableAsync(host.ipAddress, portCount, hostCt).ConfigureAwait(false))
return;
// Resolve Hostname (PTR) while the ports are scanned. Started with the first port probe of the
@@ -282,46 +284,25 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
///
/// Hosts to ping and scan.
/// Number of ports per host (counted as scanned for a host that doesn't answer).
- /// Options for the hosts that are scanned at the same time.
+ ///
+ /// Options for the hosts that are scanned at the same time, with the token to monitor for cancellation requests.
+ ///
/// Scans the ports of a host.
- /// Token to monitor for cancellation requests.
private async Task ScanReachableHostsAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
int portCount, ParallelOptions hostParallelOptions,
- Func<(IPAddress ipAddress, string hostname), CancellationToken, ValueTask> scanHost,
- CancellationToken cancellationToken)
+ Func<(IPAddress ipAddress, string hostname), CancellationToken, ValueTask> scanHost)
{
var reachableHosts = Channel.CreateUnbounded<(IPAddress ipAddress, string hostname)>();
// Also stops the pings if the port scan fails
- using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
-
- var pingTask = Task.Run(async () =>
- {
- try
- {
- var pingParallelOptions = new ParallelOptions
- {
- CancellationToken = pingCts.Token,
- MaxDegreeOfParallelism = AutoMaxPingThreads
- };
+ using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(hostParallelOptions.CancellationToken);
- await Parallel.ForEachAsync(hosts, pingParallelOptions, async (host, ct) =>
- {
- if (await IsReachableAsync(host.ipAddress, portCount, ct).ConfigureAwait(false))
- reachableHosts.Writer.TryWrite(host); // Unbounded, always written
- }).ConfigureAwait(false);
- }
- finally
- {
- // Also on cancellation or an error (raised below), the port scan ends with the hosts written so far
- reachableHosts.Writer.TryComplete();
- }
- }, CancellationToken.None);
+ var pingTask = PingHostsAsync();
try
{
- await Parallel.ForEachAsync(reachableHosts.Reader.ReadAllAsync(cancellationToken), hostParallelOptions,
- scanHost).ConfigureAwait(false);
+ await Parallel.ForEachAsync(reachableHosts.Reader.ReadAllAsync(hostParallelOptions.CancellationToken),
+ hostParallelOptions, scanHost).ConfigureAwait(false);
}
finally
{
@@ -341,6 +322,30 @@ await Parallel.ForEachAsync(reachableHosts.Reader.ReadAllAsync(cancellationToken
// Error of the pings
await pingTask.ConfigureAwait(false);
+
+ // Writes the hosts that answer to the channel, the port scan reads them while the other hosts are pinged
+ async Task PingHostsAsync()
+ {
+ try
+ {
+ var pingParallelOptions = new ParallelOptions
+ {
+ CancellationToken = pingCts.Token,
+ MaxDegreeOfParallelism = AutoMaxPingThreads
+ };
+
+ await Parallel.ForEachAsync(hosts, pingParallelOptions, async (host, ct) =>
+ {
+ if (await IsReachableAsync(host.ipAddress, portCount, ct).ConfigureAwait(false))
+ reachableHosts.Writer.TryWrite(host); // Unbounded, always written
+ }).ConfigureAwait(false);
+ }
+ finally
+ {
+ // Also on cancellation or an error (raised above), the port scan ends with the hosts written so far
+ reachableHosts.Writer.TryComplete();
+ }
+ }
}
///
From 211c099bff886243e2a30bbcd707b88d6ce6aa00 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:38:25 +0200
Subject: [PATCH 115/146] Refactor: Search delay provides the command to apply
the search right away (final review #7)
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Utilities.WPF/DataGridHelper.cs | 4 ++--
.../NETworkManager.Utilities.WPF.csproj | 3 +++
Source/NETworkManager.Utilities.WPF/SearchDelay.cs | 9 +++++++++
Source/NETworkManager/ViewModels/ConnectionsViewModel.cs | 2 +-
Source/NETworkManager/ViewModels/FirewallViewModel.cs | 2 +-
.../ViewModels/HostsFileEditorViewModel.cs | 2 +-
Source/NETworkManager/ViewModels/IPScannerViewModel.cs | 2 +-
.../ViewModels/ImportProfilesResultViewModel.cs | 2 +-
Source/NETworkManager/ViewModels/ListenersViewModel.cs | 2 +-
.../NETworkManager/ViewModels/NeighborTableViewModel.cs | 2 +-
Source/NETworkManager/ViewModels/PortScannerViewModel.cs | 2 +-
Source/NETworkManager/ViewModels/ProfilesViewModel.cs | 2 +-
Source/NETworkManager/ViewModels/WiFiViewModel.cs | 2 +-
13 files changed, 24 insertions(+), 12 deletions(-)
diff --git a/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs b/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs
index a18add1162..4d8c51df23 100644
--- a/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs
+++ b/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs
@@ -283,9 +283,9 @@ private double GetAvailableWidth()
private static ScrollViewer FindScrollViewer(DependencyObject parent)
{
- for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
+ for (var i = 0; i < System.Windows.Media.VisualTreeHelper.GetChildrenCount(parent); i++)
{
- var child = VisualTreeHelper.GetChild(parent, i);
+ var child = System.Windows.Media.VisualTreeHelper.GetChild(parent, i);
if (child is ScrollViewer scrollViewer)
return scrollViewer;
diff --git a/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj b/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj
index 34ffbec737..59c1349ae6 100644
--- a/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj
+++ b/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj
@@ -16,4 +16,7 @@
+
+
+
diff --git a/Source/NETworkManager.Utilities.WPF/SearchDelay.cs b/Source/NETworkManager.Utilities.WPF/SearchDelay.cs
index b2cc01af35..d4b02977c1 100644
--- a/Source/NETworkManager.Utilities.WPF/SearchDelay.cs
+++ b/Source/NETworkManager.Utilities.WPF/SearchDelay.cs
@@ -1,4 +1,5 @@
using System;
+using System.Windows.Input;
using System.Windows.Threading;
namespace NETworkManager.Utilities.WPF;
@@ -25,8 +26,16 @@ public SearchDelay(TimeSpan interval, Action applySearch)
_timer = new DispatcherTimer { Interval = interval };
_timer.Tick += (_, _) => Apply();
+
+ ApplyCommand = new RelayCommand(_ => Apply());
}
+ ///
+ /// Gets the command to apply the search right away (see ), e.g. bound to Enter in the search
+ /// box.
+ ///
+ public ICommand ApplyCommand { get; }
+
///
/// Starts the delay again (the search text was changed).
///
diff --git a/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs b/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs
index af0325273b..8f94813720 100644
--- a/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs
@@ -452,7 +452,7 @@ public void OnViewHide()
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
///
/// Filters with the current right away.
diff --git a/Source/NETworkManager/ViewModels/FirewallViewModel.cs b/Source/NETworkManager/ViewModels/FirewallViewModel.cs
index bf61b204af..1a55443650 100644
--- a/Source/NETworkManager/ViewModels/FirewallViewModel.cs
+++ b/Source/NETworkManager/ViewModels/FirewallViewModel.cs
@@ -915,7 +915,7 @@ private static List ParseAddressesString(string value)
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
///
/// Filters with the current right away.
diff --git a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
index 0973cdb821..ab7bcc42ee 100644
--- a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
+++ b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
@@ -772,7 +772,7 @@ public void OnViewHide()
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
///
/// Filters with the current right away.
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index 5454deca1a..346c15df04 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -1162,7 +1162,7 @@ public void OnClose()
/// Gets the command to filter the results right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
///
/// Filters the results with the current right away (e.g. Enter in the search box).
diff --git a/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs b/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs
index 1c022e123c..7e454280fd 100644
--- a/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs
@@ -310,7 +310,7 @@ private void OnApplicationOptionChanged(object sender, PropertyChangedEventArgs
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
///
/// Filters with the current right away.
diff --git a/Source/NETworkManager/ViewModels/ListenersViewModel.cs b/Source/NETworkManager/ViewModels/ListenersViewModel.cs
index 3494da8d3f..0430f71f6c 100644
--- a/Source/NETworkManager/ViewModels/ListenersViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ListenersViewModel.cs
@@ -440,7 +440,7 @@ public void OnViewHide()
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
///
/// Filters with the current right away.
diff --git a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs
index 9e5ac0359c..1c227e2682 100644
--- a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs
+++ b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs
@@ -518,7 +518,7 @@ private async void AutoRefreshTimer_Tick(object sender, EventArgs e)
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
///
/// Filters with the current right away.
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 2ef5997c8f..abd2b0a3e0 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -1123,7 +1123,7 @@ await DialogHelper.ShowMessageAsync(window, Strings.Error,
/// Gets the command to filter the results right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
///
/// Filters the results with the current right away (e.g. Enter in the search box).
diff --git a/Source/NETworkManager/ViewModels/ProfilesViewModel.cs b/Source/NETworkManager/ViewModels/ProfilesViewModel.cs
index ca71abe814..821e88860b 100644
--- a/Source/NETworkManager/ViewModels/ProfilesViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ProfilesViewModel.cs
@@ -444,7 +444,7 @@ private void ProfileManager_OnProfilesUpdated(object sender, EventArgs e)
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
///
/// Filters with the current right away.
diff --git a/Source/NETworkManager/ViewModels/WiFiViewModel.cs b/Source/NETworkManager/ViewModels/WiFiViewModel.cs
index 842f712b1f..d43735014b 100644
--- a/Source/NETworkManager/ViewModels/WiFiViewModel.cs
+++ b/Source/NETworkManager/ViewModels/WiFiViewModel.cs
@@ -1359,7 +1359,7 @@ private void HideConnectionStatusMessageTimer_Tick(object sender, EventArgs e)
/// Gets the command to filter right away (Enter in the search box, see
/// ).
///
- public ICommand ApplySearchCommand => new RelayCommand(_ => _searchDelay.Apply());
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
///
/// Filters with the current right away.
From 3374afde24bb98225b5119572140915a4fa95f43 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:42:19 +0200
Subject: [PATCH 116/146] Fix: Hostname without the trailing dot only for
applications that resolve it via Windows, shared helper (final review #3, #4)
DNSClientHelper.SanitizeHostname removes the trailing dot (also of a
single-label name like "gateway."). ApplicationManager.GetHostForApplication
uses it for Remote Desktop, PuTTY, TigerVNC, PowerShell and the Web Console;
tools that resolve via NETworkManager keep "gateway." so the DNS suffix is not
appended. Used by the IP Scanner, Port Scanner and Traceroute, the Web Console
URL, custom commands and the profile name. WebConsoleUrlHelper.CreateUrl throws
for a missing host instead of a NullReferenceException.
Co-Authored-By: Claude Opus 5.5
---
.../ApplicationManager.cs | 18 ++++++++++
.../Network/WebConsoleUrlHelper.cs | 11 +++---
.../ApplicationManagerTests.cs | 34 +++++++++++++++++++
.../Network/WebConsoleUrlHelperTests.cs | 12 ++++++-
.../Utilities/DNSClientHelperTests.cs | 12 +++++++
.../DNSClientHelper.cs | 21 +++++++++++-
.../ViewModels/IPScannerViewModel.cs | 12 ++++---
.../ViewModels/PortScannerViewModel.cs | 7 ++--
.../ViewModels/TracerouteViewModel.cs | 5 ++-
9 files changed, 118 insertions(+), 14 deletions(-)
create mode 100644 Source/NETworkManager.Tests/ApplicationManagerTests.cs
diff --git a/Source/NETworkManager.Models/ApplicationManager.cs b/Source/NETworkManager.Models/ApplicationManager.cs
index 8e7220793b..1fa2ba8297 100644
--- a/Source/NETworkManager.Models/ApplicationManager.cs
+++ b/Source/NETworkManager.Models/ApplicationManager.cs
@@ -1,4 +1,5 @@
using MahApps.Metro.IconPacks;
+using NETworkManager.Utilities;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -32,6 +33,23 @@ public static IEnumerable GetDefaultList()
#pragma warning restore CS0618
}
+ ///
+ /// Gets the host to open in an application from the hostname or IP address of a result (e.g. IP Scanner): for an
+ /// application that resolves it via Windows (Remote Desktop, PuTTY, TigerVNC, PowerShell, Web Console) without
+ /// the trailing dot (see ). The other applications resolve it via
+ /// NETworkManager and keep the dot of a single-label name (see ).
+ ///
+ /// Application to open.
+ /// Hostname or IP address.
+ /// Host for the application.
+ public static string GetHostForApplication(ApplicationName name, string host)
+ {
+ return name is ApplicationName.RemoteDesktop or ApplicationName.PuTTY or ApplicationName.TigerVNC
+ or ApplicationName.PowerShell or ApplicationName.WebConsole
+ ? DNSClientHelper.SanitizeHostname(host)
+ : host;
+ }
+
///
/// Method will return the icon based on .
///
diff --git a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
index c522430a83..93368c39ad 100644
--- a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
+++ b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
@@ -1,7 +1,9 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
+using NETworkManager.Utilities;
namespace NETworkManager.Models.Network;
@@ -50,6 +52,8 @@ public static string CreateUrl(string host, IEnumerable openPorts)
/// URL.
public static string CreateUrl(string host, int port)
{
+ ArgumentException.ThrowIfNullOrWhiteSpace(host);
+
var scheme = port is 443 or 8443 ? "https" : "http";
// The zone id of an IPv6 address has to be encoded ("%" -> "%25", RFC 6874)
@@ -60,9 +64,8 @@ public static string CreateUrl(string host, int port)
}
else
{
- // Trim the dot a single-label name still has (e.g. "gateway."), so Windows resolves it like a name typed
- // by the user (DNS suffix search, LLMNR / NetBIOS) and the name matches the certificate (https)
- host = host.TrimEnd('.');
+ // The browser resolves it via Windows (e.g. "gateway." -> "gateway")
+ host = DNSClientHelper.SanitizeHostname(host);
}
return port is 80 or 443 ? $"{scheme}://{host}/" : $"{scheme}://{host}:{port}/";
diff --git a/Source/NETworkManager.Tests/ApplicationManagerTests.cs b/Source/NETworkManager.Tests/ApplicationManagerTests.cs
new file mode 100644
index 0000000000..34bcb9f75a
--- /dev/null
+++ b/Source/NETworkManager.Tests/ApplicationManagerTests.cs
@@ -0,0 +1,34 @@
+using NETworkManager.Models;
+using Xunit;
+
+namespace NETworkManager.Tests;
+
+public class ApplicationManagerTests
+{
+ [Theory]
+ [InlineData(ApplicationName.RemoteDesktop)]
+ [InlineData(ApplicationName.PuTTY)]
+ [InlineData(ApplicationName.TigerVNC)]
+ [InlineData(ApplicationName.PowerShell)]
+ [InlineData(ApplicationName.WebConsole)]
+ public void HostForApplicationResolvedViaWindowsWithoutTrailingDot(ApplicationName name)
+ {
+ Assert.Equal("gateway", ApplicationManager.GetHostForApplication(name, "gateway."));
+ }
+
+ [Theory]
+ [InlineData(ApplicationName.PingMonitor)]
+ [InlineData(ApplicationName.Traceroute)]
+ [InlineData(ApplicationName.DNSLookup)]
+ [InlineData(ApplicationName.PortScanner)]
+ public void HostForApplicationResolvedViaNETworkManagerKeepsTrailingDot(ApplicationName name)
+ {
+ Assert.Equal("gateway.", ApplicationManager.GetHostForApplication(name, "gateway."));
+ }
+
+ [Fact]
+ public void HostForApplicationKeepsIPAddress()
+ {
+ Assert.Equal("10.0.0.1", ApplicationManager.GetHostForApplication(ApplicationName.RemoteDesktop, "10.0.0.1"));
+ }
+}
diff --git a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
index e31eb70866..9689b2435a 100644
--- a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
+++ b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
@@ -1,4 +1,5 @@
-using NETworkManager.Models.Network;
+using System;
+using NETworkManager.Models.Network;
using Xunit;
namespace NETworkManager.Tests.Network;
@@ -19,6 +20,15 @@ public void CreatesUrlForPort(string host, int port, string expected)
Assert.Equal(expected, WebConsoleUrlHelper.CreateUrl(host, port));
}
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void ThrowsWithoutHost(string host)
+ {
+ Assert.ThrowsAny(() => WebConsoleUrlHelper.CreateUrl(host, 80));
+ }
+
[Theory]
[InlineData(new[] { 22, 80, 443 }, "https://10.0.0.1/")]
[InlineData(new[] { 80, 8443 }, "http://10.0.0.1/")]
diff --git a/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs b/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs
index 3edafdcd34..5fce1a4b6a 100644
--- a/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs
+++ b/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs
@@ -30,6 +30,18 @@ public void KeepsNullOrEmpty(string name)
Assert.Equal(name, DNSClientHelper.TrimTrailingDot(name));
}
+ [Theory]
+ [InlineData("gateway.", "gateway")]
+ [InlineData("server.example.com.", "server.example.com")]
+ [InlineData("gateway", "gateway")]
+ [InlineData(".", ".")]
+ [InlineData("", "")]
+ [InlineData(null, null)]
+ public void SanitizesHostname(string hostname, string expected)
+ {
+ Assert.Equal(expected, DNSClientHelper.SanitizeHostname(hostname));
+ }
+
[Fact]
public void TrimmedNameIsNotBareHostname()
{
diff --git a/Source/NETworkManager.Utilities/DNSClientHelper.cs b/Source/NETworkManager.Utilities/DNSClientHelper.cs
index 038f8439d4..326a424eff 100644
--- a/Source/NETworkManager.Utilities/DNSClientHelper.cs
+++ b/Source/NETworkManager.Utilities/DNSClientHelper.cs
@@ -25,7 +25,8 @@ public static bool IsBareHostname(string query)
/// Removes the trailing dot of a fully qualified domain name (e.g. "server.example.com." ->
/// "server.example.com"). A single-label name (e.g. "gateway.") keeps it: without the dot it would be
/// a bare hostname (see ) and the DNS suffix would be appended when it is
- /// resolved again (e.g. when opened in another tool).
+ /// resolved again by NETworkManager (e.g. when opened in Ping or Traceroute). For an application that
+ /// resolves it via Windows, see .
///
/// Domain name, e.g. from a PTR record.
/// Domain name without the trailing dot, unless it is a single-label name.
@@ -39,6 +40,24 @@ public static string TrimTrailingDot(string name)
return trimmed.Contains('.') ? trimmed : name;
}
+ ///
+ /// Prepares a hostname (e.g. from a PTR record) for an application that resolves it via Windows (e.g.
+ /// Remote Desktop, PuTTY, the browser of the Web Console or a custom command): also removes the trailing dot of
+ /// a single-label name (e.g. "gateway." -> "gateway"), so Windows resolves it like a name typed by the user
+ /// (DNS suffix search, LLMNR / NetBIOS) and it matches the name of a certificate (https).
+ ///
+ /// Hostname.
+ /// Hostname without the trailing dot, the hostname itself if it is null, empty or only dots.
+ public static string SanitizeHostname(string hostname)
+ {
+ if (string.IsNullOrEmpty(hostname))
+ return hostname;
+
+ var trimmed = hostname.TrimEnd('.');
+
+ return trimmed.Length > 0 ? trimmed : hostname;
+ }
+
///
/// Detect the DNS suffix to use for suffix-appending. Prefers the Windows "Primary DNS Suffix"
/// (), falling back to the connection-specific suffix
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index 346c15df04..b4245aea3b 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -498,7 +498,10 @@ private void RedirectDataToApplicationAction(object name)
if (name is not ApplicationName applicationName)
return;
- NavigationManager.Open(new ApplicationRequest(applicationName) { Target = GetSelectedHost() });
+ NavigationManager.Open(new ApplicationRequest(applicationName)
+ {
+ Target = ApplicationManager.GetHostForApplication(applicationName, GetSelectedHost())
+ });
}
///
@@ -603,7 +606,7 @@ private async void AddProfileSelectedHostAction()
// Only displayed - also trim the dot a single-label name still has (e.g. "gateway.")
Name = string.IsNullOrEmpty(SelectedResult.Hostname)
? SelectedResult.PingInfo.IPAddress.ToString()
- : SelectedResult.Hostname.TrimEnd('.'),
+ : DNSClientHelper.SanitizeHostname(SelectedResult.Hostname),
Host = SelectedResult.PingInfo.IPAddress.ToString(),
// Additional data
@@ -1028,9 +1031,8 @@ private async Task CustomCommand(object guid)
return;
}
- // Replace vars. Also trim the dot a single-label name still has (e.g. "gateway."), so Windows resolves
- // it like a name typed by the user (DNS suffix search, LLMNR / NetBIOS)
- var hostname = SelectedResult.Hostname?.TrimEnd('.') ?? "";
+ // Replace vars. The command resolves the hostname via Windows (e.g. "gateway." -> "gateway")
+ var hostname = DNSClientHelper.SanitizeHostname(SelectedResult.Hostname) ?? "";
// The hostname comes from the network (PTR record or NetBIOS name) and could contain characters
// like "&" that would run additional commands (e.g. "cmd.exe /c ... {{Hostname}}") - only use valid ones
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index abd2b0a3e0..54fc1184d2 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -578,7 +578,10 @@ private void RedirectDataToApplicationAction(object name)
if (name is not ApplicationName applicationName)
return;
- NavigationManager.Open(new ApplicationRequest(applicationName) { Target = GetSelectedHost() });
+ NavigationManager.Open(new ApplicationRequest(applicationName)
+ {
+ Target = ApplicationManager.GetHostForApplication(applicationName, GetSelectedHost())
+ });
}
///
@@ -646,7 +649,7 @@ private async void AddProfileSelectedHostAction()
// Only displayed - also trim the dot a single-label name still has (e.g. "gateway.")
Name = string.IsNullOrEmpty(SelectedResult.Hostname)
? SelectedResult.IPAddress.ToString()
- : SelectedResult.Hostname.TrimEnd('.'),
+ : DNSClientHelper.SanitizeHostname(SelectedResult.Hostname),
Host = SelectedResult.IPAddress.ToString(),
// Additional data
diff --git a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs
index 1cb383e31e..193dd392a5 100644
--- a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs
+++ b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs
@@ -310,7 +310,10 @@ private void RedirectDataToApplicationAction(object name)
? SelectedResult.Hostname
: SelectedResult.IPAddress.ToString();
- NavigationManager.Open(new ApplicationRequest(applicationName) { Target = host });
+ NavigationManager.Open(new ApplicationRequest(applicationName)
+ {
+ Target = ApplicationManager.GetHostForApplication(applicationName, host)
+ });
}
///
From 6f605b905236e9933780b2d3ffd4cc3b01ef7257 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:56:27 +0200
Subject: [PATCH 117/146] Fix: Port Scanner message of the tab replaced by a
message of the first scan (final final review #1)
Co-Authored-By: Claude Opus 5.5
---
.../ViewModels/PortScannerViewModel.cs | 40 +++++++++----------
1 file changed, 19 insertions(+), 21 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 54fc1184d2..4b384a1bef 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -714,6 +714,17 @@ private void ExportAction()
#region Methods
+ ///
+ /// Shows a message below the input, after the message that is already shown (e.g. the message of the tab or
+ /// hostnames that could not be resolved). The messages are cleared when a scan starts.
+ ///
+ /// Message to show.
+ private void AddStatusMessage(string message)
+ {
+ StatusMessage = IsStatusMessageDisplayed ? StatusMessage + Environment.NewLine + message : message;
+ IsStatusMessageDisplayed = true;
+ }
+
private async Task Start()
{
// Port profiles in the input (e.g. "Webserver; 8080") are replaced by their ports. The input is validated, but
@@ -848,11 +859,8 @@ private async Task Start()
// Show error message if (some) hostnames could not be resolved
if (hosts.hostnamesNotResolved.Count > 0)
- {
- StatusMessage =
- $"{Strings.TheFollowingHostnamesCouldNotBeResolved} {string.Join(", ", hosts.hostnamesNotResolved)}";
- IsStatusMessageDisplayed = true;
- }
+ AddStatusMessage(
+ $"{Strings.TheFollowingHostnamesCouldNotBeResolved} {string.Join(", ", hosts.hostnamesNotResolved)}");
PortsToScan = ports.Length * hosts.hosts.Count;
@@ -1296,36 +1304,26 @@ private void ScanComplete(object sender, ScanCompleteArgs e)
PortsToScan = _rescanPortsToScan;
}
+ // Keep a message from the preparation (e.g. the message of the tab or hostnames that could not be resolved)
if (e.IsCanceled)
{
- StatusMessage = Strings.CanceledByUserMessage;
- IsStatusMessageDisplayed = true;
+ AddStatusMessage(Strings.CanceledByUserMessage);
}
else if (e.Exception != null)
{
Log.Error("Error while scanning", e.Exception);
- StatusMessage = e.Exception.Message;
- IsStatusMessageDisplayed = true;
+ AddStatusMessage(e.Exception.Message);
}
else if (Results.Count == 0)
{
- // Keep a message from the preparation (e.g. hostnames that could not be resolved)
- StatusMessage = IsStatusMessageDisplayed
- ? StatusMessage + Environment.NewLine + Strings.NoOpenPortsFound
- : Strings.NoOpenPortsFound;
- IsStatusMessageDisplayed = true;
+ AddStatusMessage(Strings.NoOpenPortsFound);
}
// Hosts that were not scanned because they didn't answer the ping before the scan (option)
if (!e.IsCanceled && e.Exception == null && sender is PortScanner { HostsSkipped: > 0 } pingScanner)
- {
- var message = string.Format(Strings.XHostsDidNotAnswerThePingAndWereNotScanned,
- pingScanner.HostsSkipped);
-
- StatusMessage = IsStatusMessageDisplayed ? StatusMessage + Environment.NewLine + message : message;
- IsStatusMessageDisplayed = true;
- }
+ AddStatusMessage(string.Format(Strings.XHostsDidNotAnswerThePingAndWereNotScanned,
+ pingScanner.HostsSkipped));
PreparingScan = false;
IsCanceling = false;
From 1537d16e76ba137d33e11863bddccd6de77c0da6 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:56:56 +0200
Subject: [PATCH 118/146] Refactor: Target prepared for the application in
NavigationManager.Open instead of every caller (final final review #2)
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Models/ApplicationManager.cs | 11 ++++++-----
Source/NETworkManager/NavigationManager.cs | 13 +++++++++++--
.../NETworkManager/ViewModels/IPScannerViewModel.cs | 5 +----
.../ViewModels/PortScannerViewModel.cs | 5 +----
.../ViewModels/TracerouteViewModel.cs | 5 +----
5 files changed, 20 insertions(+), 19 deletions(-)
diff --git a/Source/NETworkManager.Models/ApplicationManager.cs b/Source/NETworkManager.Models/ApplicationManager.cs
index 1fa2ba8297..7230a86585 100644
--- a/Source/NETworkManager.Models/ApplicationManager.cs
+++ b/Source/NETworkManager.Models/ApplicationManager.cs
@@ -34,13 +34,14 @@ public static IEnumerable GetDefaultList()
}
///
- /// Gets the host to open in an application from the hostname or IP address of a result (e.g. IP Scanner): for an
- /// application that resolves it via Windows (Remote Desktop, PuTTY, TigerVNC, PowerShell, Web Console) without
- /// the trailing dot (see ). The other applications resolve it via
- /// NETworkManager and keep the dot of a single-label name (see ).
+ /// Gets the host to open in an application (e.g. the hostname of an IP Scanner result or the target of the run
+ /// command): for an application that resolves it via Windows (Remote Desktop, PuTTY, TigerVNC, PowerShell)
+ /// without the trailing dot (see ). The other applications resolve
+ /// it via NETworkManager and keep the dot of a single-label name (see ).
+ /// Used by NavigationManager.Open for every request.
///
/// Application to open.
- /// Hostname or IP address.
+ /// Hostname or IP address (or another target, e.g. a URL or IP range).
/// Host for the application.
public static string GetHostForApplication(ApplicationName name, string host)
{
diff --git a/Source/NETworkManager/NavigationManager.cs b/Source/NETworkManager/NavigationManager.cs
index 1212629d41..72a25b6734 100644
--- a/Source/NETworkManager/NavigationManager.cs
+++ b/Source/NETworkManager/NavigationManager.cs
@@ -1,4 +1,5 @@
-using System;
+using NETworkManager.Models;
+using System;
namespace NETworkManager;
@@ -18,11 +19,19 @@ public static class NavigationManager
public static event EventHandler SettingsRequested;
///
- /// Opens an application and passes the request (target, profile, options) to it.
+ /// Opens an application and passes the request (target, profile, options) to it. The target is prepared for
+ /// the application (see ), e.g. "gateway." from a PTR
+ /// record -> "gateway" for Remote Desktop.
///
/// Request with the application to open.
public static void Open(ApplicationRequest request)
{
+ if (request.Target != null)
+ request = request with
+ {
+ Target = ApplicationManager.GetHostForApplication(request.Application, request.Target)
+ };
+
ApplicationRequested?.Invoke(null, request);
}
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index b4245aea3b..711759d344 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -498,10 +498,7 @@ private void RedirectDataToApplicationAction(object name)
if (name is not ApplicationName applicationName)
return;
- NavigationManager.Open(new ApplicationRequest(applicationName)
- {
- Target = ApplicationManager.GetHostForApplication(applicationName, GetSelectedHost())
- });
+ NavigationManager.Open(new ApplicationRequest(applicationName) { Target = GetSelectedHost() });
}
///
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 4b384a1bef..3be5a548d2 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -578,10 +578,7 @@ private void RedirectDataToApplicationAction(object name)
if (name is not ApplicationName applicationName)
return;
- NavigationManager.Open(new ApplicationRequest(applicationName)
- {
- Target = ApplicationManager.GetHostForApplication(applicationName, GetSelectedHost())
- });
+ NavigationManager.Open(new ApplicationRequest(applicationName) { Target = GetSelectedHost() });
}
///
diff --git a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs
index 193dd392a5..1cb383e31e 100644
--- a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs
+++ b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs
@@ -310,10 +310,7 @@ private void RedirectDataToApplicationAction(object name)
? SelectedResult.Hostname
: SelectedResult.IPAddress.ToString();
- NavigationManager.Open(new ApplicationRequest(applicationName)
- {
- Target = ApplicationManager.GetHostForApplication(applicationName, host)
- });
+ NavigationManager.Open(new ApplicationRequest(applicationName) { Target = host });
}
///
From cea23c974cdcc300816683ab412ba43ea2f5e30a Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:57:05 +0200
Subject: [PATCH 119/146] Docs: Changelog trailing dot of single-label names
when opened in another feature (final final review #3)
Co-Authored-By: Claude Opus 5.5
---
Website/docs/changelog/next-release.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index eee279f9ae..6194535e5f 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -208,7 +208,7 @@ Release date: **xx.xx.2026**
**Traceroute**
- Fixed the trace not working when the ICMP **Buffer** was set to more than `65500` bytes (Windows limit). The maximum is now `65500`, higher values are lowered to `65500`. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
-- Fixed hostnames of the hops resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the export and when opened in another feature (e.g. **RDAP**). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Fixed hostnames of the hops resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the export and when opened in another feature (e.g. **RDAP**). A single-label name (e.g. `gateway.`) keeps the dot, so the DNS suffix isn't appended, except for **Remote Desktop**, **PuTTY**, **TigerVNC** and **PowerShell**, which resolve it via Windows. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed columns that couldn't be made wider than the window (by dragging or double-clicking the gripper), e.g. to read a long hostname. The grid scrolls horizontally now, the last (or main) column still fills the remaining width. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557)
**Network Interface**
@@ -274,7 +274,7 @@ Release date: **xx.xx.2026**
- Fixed a hostname received from the network (DNS PTR record or NetBIOS name) with special characters like `&` being able to run additional commands through a custom command with `{{Hostname}}` (e.g. `cmd.exe /c ...`). Such a hostname is now replaced with an empty value. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Fixed **Edit** (`F2`) and **Delete** (`Del`) of a custom command in the settings being available without a selected command. The delete confirmation shows the name of the command. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Fixed columns, **Copy** menu entries and row details being shown or hidden according to the current settings instead of the settings the shown scan was started with (e.g. **MAC address** column shown, but empty, after enabling **Resolve MAC address and vendor** without a new scan). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
-- Fixed hostnames resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the export and when opened in another feature (e.g. **DNS Lookup**, **Remote Desktop**). [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Fixed hostnames resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the export and when opened in another feature (e.g. **DNS Lookup**, **Remote Desktop**). A single-label name (e.g. `gateway.`) keeps the dot, so the DNS suffix isn't appended, except for **Remote Desktop**, **PuTTY**, **TigerVNC** and **PowerShell**, which resolve it via Windows. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed a NetBIOS name (shown as hostname if there is no DNS hostname) being passed to other features (e.g. **Remote Desktop**, **Port Scanner**, **DNS Lookup**, **RDAP**), where it may not be resolvable via DNS. The DNS hostname is used, otherwise the IP address; **DNS lookup...** / **RDAP...** > **Hostname** need a DNS hostname. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed the progress and the up / down counters possibly staying below the final values (e.g. 255 of 256 hosts scanned) when the results of several hosts arrived at the same time. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed the application crashing when the results were sorted by **MAC address** and a host had no MAC address (e.g. a host behind a router, or while the MAC address was still resolved). Hosts without a MAC address are shown at the end. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
@@ -299,7 +299,7 @@ Release date: **xx.xx.2026**
- Fixed the application crashing when the results of multiple hosts were sorted by IP address. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Results are sorted by IP address and port (before, in the order the ports were finished), and the hostname is resolved while the ports are scanned instead of before. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Fixed the **Hostname** column and **Copy** > **Hostname** being shown or hidden according to the current settings instead of the settings the shown scan was started with. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
-- Fixed hostnames resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the group header and the export. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
+- Fixed hostnames resolved via DNS (`PTR`) being shown with a trailing dot (e.g. `server.example.com.`), also in the group header, the export and when opened in another feature (e.g. **DNS Lookup**, **Remote Desktop**). A single-label name (e.g. `gateway.`) keeps the dot, so the DNS suffix isn't appended, except for **Remote Desktop**, **PuTTY**, **TigerVNC** and **PowerShell**, which resolve it via Windows. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed the progress and the open / closed counters possibly staying below the final values (e.g. 1023 of 1024 ports scanned) when the results of several ports arrived at the same time. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
- Fixed columns that couldn't be made wider than the window (by dragging or double-clicking the gripper) in the result and the port profiles (settings). The grid scrolls horizontally now, the last (or main) column still fills the remaining width. [#3557](https://github.com/BornToBeRoot/NETworkManager/issues/3557)
- Fixed the ports of different hosts with the same hostname (DNS `PTR`, e.g. a generic name of a provider or a server with two IP addresses) being shown in one group. The results are grouped per IP address, the group header shows the hostname and the IP address. [#3660](https://github.com/BornToBeRoot/NETworkManager/pull/3660)
From 3a75d8db3f8ed4ec21cba1e246e7a7e99c1c3905 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:57:12 +0200
Subject: [PATCH 120/146] Refactor: Web Console not in the applications whose
host is sanitized, its target is a URL (final final review #4)
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Models/ApplicationManager.cs | 3 ++-
Source/NETworkManager.Tests/ApplicationManagerTests.cs | 1 -
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Source/NETworkManager.Models/ApplicationManager.cs b/Source/NETworkManager.Models/ApplicationManager.cs
index 7230a86585..0e76c60306 100644
--- a/Source/NETworkManager.Models/ApplicationManager.cs
+++ b/Source/NETworkManager.Models/ApplicationManager.cs
@@ -45,8 +45,9 @@ public static IEnumerable GetDefaultList()
/// Host for the application.
public static string GetHostForApplication(ApplicationName name, string host)
{
+ // Not the Web Console: its target is a URL, created by WebConsoleUrlHelper (which also removes the dot)
return name is ApplicationName.RemoteDesktop or ApplicationName.PuTTY or ApplicationName.TigerVNC
- or ApplicationName.PowerShell or ApplicationName.WebConsole
+ or ApplicationName.PowerShell
? DNSClientHelper.SanitizeHostname(host)
: host;
}
diff --git a/Source/NETworkManager.Tests/ApplicationManagerTests.cs b/Source/NETworkManager.Tests/ApplicationManagerTests.cs
index 34bcb9f75a..41dc818a99 100644
--- a/Source/NETworkManager.Tests/ApplicationManagerTests.cs
+++ b/Source/NETworkManager.Tests/ApplicationManagerTests.cs
@@ -10,7 +10,6 @@ public class ApplicationManagerTests
[InlineData(ApplicationName.PuTTY)]
[InlineData(ApplicationName.TigerVNC)]
[InlineData(ApplicationName.PowerShell)]
- [InlineData(ApplicationName.WebConsole)]
public void HostForApplicationResolvedViaWindowsWithoutTrailingDot(ApplicationName name)
{
Assert.Equal("gateway", ApplicationManager.GetHostForApplication(name, "gateway."));
From 22c3c9d6773aef89d4bffab3cc19471376829370 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:57:40 +0200
Subject: [PATCH 121/146] Fix: Sanitized hostname of only dots is empty,
profile name falls back to the IP address (final final review #5)
Co-Authored-By: Claude Opus 5.5
---
.../Network/WebConsoleUrlHelper.cs | 17 +++++++----------
.../Network/WebConsoleUrlHelperTests.cs | 1 +
.../Utilities/DNSClientHelperTests.cs | 2 +-
.../NETworkManager.Utilities/DNSClientHelper.cs | 9 ++-------
.../ViewModels/IPScannerViewModel.cs | 8 ++++----
.../ViewModels/PortScannerViewModel.cs | 8 ++++----
6 files changed, 19 insertions(+), 26 deletions(-)
diff --git a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
index 93368c39ad..1740ae0da0 100644
--- a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
+++ b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
@@ -52,21 +52,18 @@ public static string CreateUrl(string host, IEnumerable openPorts)
/// URL.
public static string CreateUrl(string host, int port)
{
+ // The browser resolves a hostname via Windows (e.g. "gateway." -> "gateway")
+ if (!IPAddress.TryParse(host, out var ipAddress))
+ host = DNSClientHelper.SanitizeHostname(host);
+
+ // Also a hostname of only dots
ArgumentException.ThrowIfNullOrWhiteSpace(host);
var scheme = port is 443 or 8443 ? "https" : "http";
// The zone id of an IPv6 address has to be encoded ("%" -> "%25", RFC 6874)
- if (IPAddress.TryParse(host, out var ipAddress))
- {
- if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6)
- host = $"[{ipAddress.ToString().Replace("%", "%25")}]";
- }
- else
- {
- // The browser resolves it via Windows (e.g. "gateway." -> "gateway")
- host = DNSClientHelper.SanitizeHostname(host);
- }
+ if (ipAddress?.AddressFamily == AddressFamily.InterNetworkV6)
+ host = $"[{ipAddress.ToString().Replace("%", "%25")}]";
return port is 80 or 443 ? $"{scheme}://{host}/" : $"{scheme}://{host}:{port}/";
}
diff --git a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
index 9689b2435a..2837c800d4 100644
--- a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
+++ b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
@@ -24,6 +24,7 @@ public void CreatesUrlForPort(string host, int port, string expected)
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
+ [InlineData(".")]
public void ThrowsWithoutHost(string host)
{
Assert.ThrowsAny(() => WebConsoleUrlHelper.CreateUrl(host, 80));
diff --git a/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs b/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs
index 5fce1a4b6a..e098986362 100644
--- a/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs
+++ b/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs
@@ -34,7 +34,7 @@ public void KeepsNullOrEmpty(string name)
[InlineData("gateway.", "gateway")]
[InlineData("server.example.com.", "server.example.com")]
[InlineData("gateway", "gateway")]
- [InlineData(".", ".")]
+ [InlineData(".", "")]
[InlineData("", "")]
[InlineData(null, null)]
public void SanitizesHostname(string hostname, string expected)
diff --git a/Source/NETworkManager.Utilities/DNSClientHelper.cs b/Source/NETworkManager.Utilities/DNSClientHelper.cs
index 326a424eff..6f4b9484ae 100644
--- a/Source/NETworkManager.Utilities/DNSClientHelper.cs
+++ b/Source/NETworkManager.Utilities/DNSClientHelper.cs
@@ -47,15 +47,10 @@ public static string TrimTrailingDot(string name)
/// (DNS suffix search, LLMNR / NetBIOS) and it matches the name of a certificate (https).
///
/// Hostname.
- /// Hostname without the trailing dot, the hostname itself if it is null, empty or only dots.
+ /// Hostname without the trailing dot (empty if it is only dots), null if it is null.
public static string SanitizeHostname(string hostname)
{
- if (string.IsNullOrEmpty(hostname))
- return hostname;
-
- var trimmed = hostname.TrimEnd('.');
-
- return trimmed.Length > 0 ? trimmed : hostname;
+ return hostname?.TrimEnd('.');
}
///
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index 711759d344..9861d6d216 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -598,12 +598,12 @@ private void CustomCommandAction(object guid)
private async void AddProfileSelectedHostAction()
{
+ // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.")
+ var hostname = DNSClientHelper.SanitizeHostname(SelectedResult.Hostname);
+
ProfileInfo profileInfo = new()
{
- // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.")
- Name = string.IsNullOrEmpty(SelectedResult.Hostname)
- ? SelectedResult.PingInfo.IPAddress.ToString()
- : DNSClientHelper.SanitizeHostname(SelectedResult.Hostname),
+ Name = string.IsNullOrEmpty(hostname) ? SelectedResult.PingInfo.IPAddress.ToString() : hostname,
Host = SelectedResult.PingInfo.IPAddress.ToString(),
// Additional data
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 3be5a548d2..1d367afef7 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -641,12 +641,12 @@ private void PerformRDAPQueryHostnameAction()
private async void AddProfileSelectedHostAction()
{
+ // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.")
+ var hostname = DNSClientHelper.SanitizeHostname(SelectedResult.Hostname);
+
ProfileInfo profileInfo = new()
{
- // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.")
- Name = string.IsNullOrEmpty(SelectedResult.Hostname)
- ? SelectedResult.IPAddress.ToString()
- : DNSClientHelper.SanitizeHostname(SelectedResult.Hostname),
+ Name = string.IsNullOrEmpty(hostname) ? SelectedResult.IPAddress.ToString() : hostname,
Host = SelectedResult.IPAddress.ToString(),
// Additional data
From 62f1c9c464f0bbda8948ee67ea44b864ca7e1d5c Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:57:50 +0200
Subject: [PATCH 122/146] Refactor: Ping Monitor uses the shared helper to
remove the trailing dot of the hostname (final final review #6)
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager/ViewModels/PingMonitorViewModel.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
index 55ddb9fd08..ab7395266f 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
@@ -106,7 +106,7 @@ private set
/// "gateway." or a hostname entered with a dot).
///
private string NotificationHostDisplay =>
- string.IsNullOrEmpty(Hostname) ? IPAddress.ToString() : Hostname.TrimEnd('.');
+ DNSClientHelper.SanitizeHostname(Hostname) is { Length: > 0 } hostname ? hostname : IPAddress.ToString();
///
/// Gets the hostname of the monitored host.
@@ -803,7 +803,7 @@ private void Ping_HostnameResolved(object sender, HostnameArgs e)
{
// Update title if name was not set in the constructor
if (string.IsNullOrEmpty(Hostname))
- Title = $"{e.Hostname.TrimEnd('.')} # {IPAddress}";
+ Title = $"{DNSClientHelper.SanitizeHostname(e.Hostname)} # {IPAddress}";
Hostname = e.Hostname;
}
From a333adde6fef4a3b59759ff486e3247267295914 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:58:07 +0200
Subject: [PATCH 123/146] Refactor: TrimTrailingDot builds on SanitizeHostname
(final final review #7)
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Utilities/DNSClientHelper.cs | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/Source/NETworkManager.Utilities/DNSClientHelper.cs b/Source/NETworkManager.Utilities/DNSClientHelper.cs
index 6f4b9484ae..90e0fa659d 100644
--- a/Source/NETworkManager.Utilities/DNSClientHelper.cs
+++ b/Source/NETworkManager.Utilities/DNSClientHelper.cs
@@ -32,12 +32,9 @@ public static bool IsBareHostname(string query)
/// Domain name without the trailing dot, unless it is a single-label name.
public static string TrimTrailingDot(string name)
{
- if (string.IsNullOrEmpty(name) || !name.EndsWith('.'))
- return name;
+ var trimmed = SanitizeHostname(name);
- var trimmed = name.TrimEnd('.');
-
- return trimmed.Contains('.') ? trimmed : name;
+ return trimmed != null && trimmed.Contains('.') ? trimmed : name;
}
///
From c7c9783e2ee0062fcdb5c70b22a807200a2ecd5a Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 22:58:24 +0200
Subject: [PATCH 124/146] Fix: Web Console URL checks the host before the open
ports (final final review #8)
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs | 3 +++
.../NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs | 1 +
2 files changed, 4 insertions(+)
diff --git a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
index 1740ae0da0..a82ddb1e71 100644
--- a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
+++ b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
@@ -36,6 +36,9 @@ public static bool IsWebPort(int port)
/// URL.
public static string CreateUrl(string host, IEnumerable openPorts)
{
+ ArgumentException.ThrowIfNullOrWhiteSpace(host);
+ ArgumentNullException.ThrowIfNull(openPorts);
+
var ports = openPorts.ToHashSet();
var port = WebPorts.FirstOrDefault(ports.Contains);
diff --git a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
index 2837c800d4..20f87c4a5f 100644
--- a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
+++ b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
@@ -28,6 +28,7 @@ public void CreatesUrlForPort(string host, int port, string expected)
public void ThrowsWithoutHost(string host)
{
Assert.ThrowsAny(() => WebConsoleUrlHelper.CreateUrl(host, 80));
+ Assert.ThrowsAny(() => WebConsoleUrlHelper.CreateUrl(host, [80]));
}
[Theory]
From 2cf57477d3ad442f876dca7fba1d15245c46ef5a Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:02:49 +0200
Subject: [PATCH 125/146] Fix: IP Scanner / Port Scanner don't start the scan
if no host is left after resolving the hostnames (B8)
Only "The following hostnames could not be resolved" is shown instead of also
"No reachable hosts found" / "No open ports found", and the input is not added
to the history.
Co-Authored-By: Claude Opus 5.5
---
Source/NETworkManager/ViewModels/IPScannerViewModel.cs | 9 ++++++++-
Source/NETworkManager/ViewModels/PortScannerViewModel.cs | 9 ++++++++-
Website/docs/changelog/next-release.md | 4 ++--
3 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index 9861d6d216..c51439abb6 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -788,6 +788,13 @@ private async Task Start()
HostsToScan = hosts.hosts.Count;
+ // No host left (e.g. only hostnames that could not be resolved): don't start the scan, only the message above
+ if (HostsToScan == 0)
+ {
+ ScanComplete(this, new ScanCompleteArgs(false));
+ return;
+ }
+
PreparingScan = false;
// Add host(s) to the history
@@ -1418,7 +1425,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e)
StatusMessage = e.Exception.Message;
IsStatusMessageDisplayed = true;
}
- else if (Results.Count == 0)
+ else if (Results.Count == 0 && HostsToScan > 0)
{
// Keep a message from the preparation (e.g. hostnames that could not be resolved)
StatusMessage = IsStatusMessageDisplayed
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 1d367afef7..25ea0070a1 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -861,6 +861,13 @@ private async Task Start()
PortsToScan = ports.Length * hosts.hosts.Count;
+ // No host left (e.g. only hostnames that could not be resolved): don't start the scan, only the message above
+ if (PortsToScan == 0)
+ {
+ ScanComplete(this, new ScanCompleteArgs(false));
+ return;
+ }
+
PreparingScan = false;
// Add host(s) to the history
@@ -1312,7 +1319,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e)
AddStatusMessage(e.Exception.Message);
}
- else if (Results.Count == 0)
+ else if (Results.Count == 0 && PortsToScan > 0)
{
AddStatusMessage(Strings.NoOpenPortsFound);
}
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 6194535e5f..20ffbd5c42 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -261,7 +261,7 @@ Release date: **xx.xx.2026**
- Fixed the ICMP **Attempts** setting being ignored: a host was shown as unreachable after the first lost ping. Another ping is now sent if the previous one timed out. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Fixed the scan getting stuck (button disabled, scan never ends) when it was canceled right before the scan started, and when an unexpected error occurred. Errors are now shown as status message. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Fixed the message "Canceled by user" being replaced by "No reachable hosts found" when the scan was canceled before the first result, and a new scan started right after canceling showing no progress or stopping too early. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
-- Fixed the message "The following hostnames could not be resolved" being replaced by "No reachable hosts found". Both messages are shown now. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
+- Fixed the message "The following hostnames could not be resolved" being replaced by "No reachable hosts found". Both messages are shown now if other hosts were scanned; if no host is left, the scan is not started. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Fixed the scan getting stuck when an error occurred while the hosts or ports were prepared, and the subnet detection button staying disabled after an error. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Fixed ports being shown as closed when there was no answer at all (e.g. host or network unreachable). Closed now means the host refused the connection, otherwise the port is timed out. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Fixed large scans using up the local (ephemeral) ports: every open port left a connection in the `TIME_WAIT` state for several minutes, so later connections failed (also in other applications). Connections are now closed with a reset. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
@@ -288,7 +288,7 @@ Release date: **xx.xx.2026**
- Fixed the application becoming unresponsive (including the window not reacting to input) during a large scan. Scan results and progress were both being pushed to the UI one item/update at a time via a dispatcher call per port, which could flood the UI thread's message queue on large scans. Both are now batched and flushed periodically (every 150ms) instead. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564)
- Fixed the scan getting stuck (button disabled, scan never ends) when it was canceled right before the scan started, and when an unexpected error occurred. Errors are now shown as status message. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Fixed the message "Canceled by user" being replaced by "No open ports found" when the scan was canceled before the first result, and a new scan started right after canceling showing no progress or stopping too early. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
-- Fixed the message "The following hostnames could not be resolved" being replaced by "No open ports found". Both messages are shown now. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
+- Fixed the message "The following hostnames could not be resolved" being replaced by "No open ports found". Both messages are shown now if other hosts were scanned; if no host is left, the scan is not started. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Fixed the scan getting stuck when an error occurred while the hosts or ports were prepared. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Fixed ports being shown as closed when there was no answer at all (e.g. host or network unreachable). Closed now means the host refused the connection, otherwise the port is timed out. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
- Fixed large scans using up the local (ephemeral) ports: every open port left a connection in the `TIME_WAIT` state for several minutes, so later connections failed (also in other applications). Connections are now closed with a reset. [#3653](https://github.com/BornToBeRoot/NETworkManager/pull/3653)
From a47e4b14aeca554334aed6ae22c93feead7568b9 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:13:38 +0200
Subject: [PATCH 126/146] Fix: ICMP buffer above 65500 lowered on every load of
the settings, not only on an upgrade
A settings file edited (or restored) after the upgrade to the current version
kept a buffer of e.g. 65535, which makes .NET's Ping throw.
Co-Authored-By: Claude Opus 5.5
---
.../SettingsManager.cs | 52 ++++++++++++-------
1 file changed, 33 insertions(+), 19 deletions(-)
diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs
index 2dc5a326ff..f73abbe2fa 100644
--- a/Source/NETworkManager.Settings/SettingsManager.cs
+++ b/Source/NETworkManager.Settings/SettingsManager.cs
@@ -223,6 +223,9 @@ public static void Load()
// Reset change tracking
Current.SettingsChanged = false;
+ // After the reset, so lowered values are saved
+ ApplyLimits();
+
return;
}
catch (Exception ex)
@@ -247,6 +250,8 @@ public static void Load()
Current.SettingsChanged = false;
+ ApplyLimits();
+
// Save in new JSON format
Save();
@@ -331,6 +336,34 @@ public static void Save()
// Reset change tracking
Current.SettingsChanged = false;
}
+ ///
+ /// Lowers values above a technical limit (e.g. edited in the settings file or from an older version that allowed
+ /// them), on every load - not only on an upgrade, which runs only once per version.
+ ///
+ private static void ApplyLimits()
+ {
+ // ICMP buffer - .NET's Ping throws for more than 65500 bytes, older versions allowed up to 65535
+ const int maxICMPBuffer = 65500;
+
+ if (Current.IPScanner_ICMPBuffer > maxICMPBuffer)
+ {
+ Log.Info($"Update \"IPScanner_ICMPBuffer\" from {Current.IPScanner_ICMPBuffer} to {maxICMPBuffer}...");
+ Current.IPScanner_ICMPBuffer = maxICMPBuffer;
+ }
+
+ if (Current.PingMonitor_Buffer > maxICMPBuffer)
+ {
+ Log.Info($"Update \"PingMonitor_Buffer\" from {Current.PingMonitor_Buffer} to {maxICMPBuffer}...");
+ Current.PingMonitor_Buffer = maxICMPBuffer;
+ }
+
+ if (Current.Traceroute_Buffer > maxICMPBuffer)
+ {
+ Log.Info($"Update \"Traceroute_Buffer\" from {Current.Traceroute_Buffer} to {maxICMPBuffer}...");
+ Current.Traceroute_Buffer = maxICMPBuffer;
+ }
+ }
+
#endregion
#region Backup
@@ -862,25 +895,6 @@ private static void UpgradeToLatest(Version version)
Current.IPScanner_PortScanPorts = GlobalStaticConfiguration.IPScanner_PortScanPorts;
}
- // ICMP buffer - .NET's Ping throws for more than 65500 bytes, the settings allowed up to 65535
- if (Current.IPScanner_ICMPBuffer > 65500)
- {
- Log.Info($"Update \"IPScanner_ICMPBuffer\" from {Current.IPScanner_ICMPBuffer} to 65500...");
- Current.IPScanner_ICMPBuffer = 65500;
- }
-
- if (Current.PingMonitor_Buffer > 65500)
- {
- Log.Info($"Update \"PingMonitor_Buffer\" from {Current.PingMonitor_Buffer} to 65500...");
- Current.PingMonitor_Buffer = 65500;
- }
-
- if (Current.Traceroute_Buffer > 65500)
- {
- Log.Info($"Update \"Traceroute_Buffer\" from {Current.Traceroute_Buffer} to 65500...");
- Current.Traceroute_Buffer = 65500;
- }
-
// Add new Port Scanner port profiles
foreach (var portProfile in PortProfile.GetDefaultList())
{
From 0f9be91acfb249bab5f0601426bffe5c66bef177 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:17:35 +0200
Subject: [PATCH 127/146] Revert "Fix: ICMP buffer above 65500 lowered on every
load of the settings, not only on an upgrade"
This reverts commit a47e4b14a. The buffer is lowered inline in UpgradeToLatest again.
Co-Authored-By: Claude Opus 5.5
---
.../SettingsManager.cs | 52 +++++++------------
1 file changed, 19 insertions(+), 33 deletions(-)
diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs
index f73abbe2fa..2dc5a326ff 100644
--- a/Source/NETworkManager.Settings/SettingsManager.cs
+++ b/Source/NETworkManager.Settings/SettingsManager.cs
@@ -223,9 +223,6 @@ public static void Load()
// Reset change tracking
Current.SettingsChanged = false;
- // After the reset, so lowered values are saved
- ApplyLimits();
-
return;
}
catch (Exception ex)
@@ -250,8 +247,6 @@ public static void Load()
Current.SettingsChanged = false;
- ApplyLimits();
-
// Save in new JSON format
Save();
@@ -336,34 +331,6 @@ public static void Save()
// Reset change tracking
Current.SettingsChanged = false;
}
- ///
- /// Lowers values above a technical limit (e.g. edited in the settings file or from an older version that allowed
- /// them), on every load - not only on an upgrade, which runs only once per version.
- ///
- private static void ApplyLimits()
- {
- // ICMP buffer - .NET's Ping throws for more than 65500 bytes, older versions allowed up to 65535
- const int maxICMPBuffer = 65500;
-
- if (Current.IPScanner_ICMPBuffer > maxICMPBuffer)
- {
- Log.Info($"Update \"IPScanner_ICMPBuffer\" from {Current.IPScanner_ICMPBuffer} to {maxICMPBuffer}...");
- Current.IPScanner_ICMPBuffer = maxICMPBuffer;
- }
-
- if (Current.PingMonitor_Buffer > maxICMPBuffer)
- {
- Log.Info($"Update \"PingMonitor_Buffer\" from {Current.PingMonitor_Buffer} to {maxICMPBuffer}...");
- Current.PingMonitor_Buffer = maxICMPBuffer;
- }
-
- if (Current.Traceroute_Buffer > maxICMPBuffer)
- {
- Log.Info($"Update \"Traceroute_Buffer\" from {Current.Traceroute_Buffer} to {maxICMPBuffer}...");
- Current.Traceroute_Buffer = maxICMPBuffer;
- }
- }
-
#endregion
#region Backup
@@ -895,6 +862,25 @@ private static void UpgradeToLatest(Version version)
Current.IPScanner_PortScanPorts = GlobalStaticConfiguration.IPScanner_PortScanPorts;
}
+ // ICMP buffer - .NET's Ping throws for more than 65500 bytes, the settings allowed up to 65535
+ if (Current.IPScanner_ICMPBuffer > 65500)
+ {
+ Log.Info($"Update \"IPScanner_ICMPBuffer\" from {Current.IPScanner_ICMPBuffer} to 65500...");
+ Current.IPScanner_ICMPBuffer = 65500;
+ }
+
+ if (Current.PingMonitor_Buffer > 65500)
+ {
+ Log.Info($"Update \"PingMonitor_Buffer\" from {Current.PingMonitor_Buffer} to 65500...");
+ Current.PingMonitor_Buffer = 65500;
+ }
+
+ if (Current.Traceroute_Buffer > 65500)
+ {
+ Log.Info($"Update \"Traceroute_Buffer\" from {Current.Traceroute_Buffer} to 65500...");
+ Current.Traceroute_Buffer = 65500;
+ }
+
// Add new Port Scanner port profiles
foreach (var portProfile in PortProfile.GetDefaultList())
{
From effca61a80cae49d52b1e8c52657200a816e9de0 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:33:17 +0200
Subject: [PATCH 128/146] Fix: Column that fills the remaining width could
switch between two widths in a loop and hang the window
DataGridHelper.FillRemainingWidth: a horizontal scrollbar can make the vertical one
appear, the narrower fill column removes both, the wider one shows the horizontal
scrollbar again ... Each update is queued with Loaded priority (before input), so
the window hangs without an exception. Going back to the wider width of two updates
ago within 500 ms (without a size change of the grid) keeps the narrower width.
Seen once in a Port Scanner run 1-65535 (stuck in the middle, no exception);
cause not confirmed with a stack trace.
Co-Authored-By: Claude Opus 5.5
---
.../DataGridHelper.cs | 30 ++++++++++++++++++-
1 file changed, 29 insertions(+), 1 deletion(-)
diff --git a/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs b/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs
index 4d8c51df23..906a0253e1 100644
--- a/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs
+++ b/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs
@@ -1,5 +1,6 @@
using System;
using System.ComponentModel;
+using System.Diagnostics;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
@@ -123,6 +124,14 @@ private sealed class FillState(DataGrid dataGrid)
private bool _isUpdating;
private bool _isUpdateQueued;
+ // Width set before the last one and when, to detect switching between two widths (see Update)
+ private double _secondLastSetWidth = double.NaN;
+ private long _secondLastSetTimestamp;
+ private bool _isSizeChanged;
+
+ // Switching back to the second last width within this time is a loop, not a change of the user
+ private static readonly TimeSpan OscillationTime = TimeSpan.FromMilliseconds(500);
+
public void OnLoaded(object sender, RoutedEventArgs e) => Attach();
public void OnUnloaded(object sender, RoutedEventArgs e) => Detach();
@@ -167,7 +176,12 @@ public void Detach()
_scrollViewer = null;
}
- private void OnSizeChanged(object sender, SizeChangedEventArgs e) => QueueUpdate();
+ private void OnSizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ _isSizeChanged = true;
+
+ QueueUpdate();
+ }
private void OnScrollChanged(object sender, ScrollChangedEventArgs e)
{
@@ -207,6 +221,9 @@ private void QueueUpdate()
private void Update()
{
+ var isSizeChanged = _isSizeChanged;
+ _isSizeChanged = false;
+
if (!dataGrid.IsLoaded)
return;
@@ -230,6 +247,7 @@ private void Update()
_fillColumn = fillColumn;
_lastSetWidth = double.NaN;
+ _secondLastSetWidth = double.NaN;
if (_fillColumn == null)
return;
@@ -255,10 +273,20 @@ private void Update()
width = Math.Min(Math.Max(width, _fillColumn.MinWidth), _fillColumn.MaxWidth);
+ // Back to the wider width of two updates ago right away, without a size change of the grid: the
+ // widths switch in a loop (e.g. a horizontal scrollbar makes the vertical one appear, the narrower
+ // column removes both, the wider column shows the horizontal scrollbar again ...) - each update is
+ // queued before input, the window would hang. Keep the narrower width (no horizontal scrollbar).
+ if (!isSizeChanged && width > _lastSetWidth && Math.Abs(width - _secondLastSetWidth) < 0.5 &&
+ Stopwatch.GetElapsedTime(_secondLastSetTimestamp) < OscillationTime)
+ width = _lastSetWidth;
+
if (!double.IsNaN(_lastSetWidth) && Math.Abs(width - _lastSetWidth) < 0.5 &&
Math.Abs(_fillColumn.ActualWidth - width) < 0.5)
return;
+ _secondLastSetWidth = _lastSetWidth;
+ _secondLastSetTimestamp = Stopwatch.GetTimestamp();
_lastSetWidth = width;
_fillColumn.Width = new DataGridLength(width);
}
From da769c06e48834a6b965c89d7415159a0ecdbde0 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:41:06 +0200
Subject: [PATCH 129/146] Fix: IP Scanner settings delete several selected
custom commands at once
The custom commands grid allowed selecting several rows, but Edit / Delete only
used one of them. Like the profiles: MultiSelectDataGrid with the selected
commands, a context menu with Delete for several rows, Del deletes all selected
after a confirmation that lists them, Edit (F2, context menu) only with exactly
one selected command. New strings DeleteCustomCommands / DeleteCustomCommandsMessage.
Co-Authored-By: Claude Opus 5.5
---
.../Resources/Strings.Designer.cs | 18 +++++++
.../Resources/Strings.resx | 6 +++
.../ViewModels/IPScannerSettingsViewModel.cs | 52 +++++++++++++++----
.../Views/IPScannerSettingsView.xaml | 24 +++++++--
Website/docs/changelog/next-release.md | 1 +
5 files changed, 86 insertions(+), 15 deletions(-)
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index b2d9f945a6..2af3f443c7 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -3029,6 +3029,24 @@ public static string DeleteCustomCommandMessage {
}
}
+ ///
+ /// Looks up a localized string similar to Delete custom commands.
+ ///
+ public static string DeleteCustomCommands {
+ get {
+ return ResourceManager.GetString("DeleteCustomCommands", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The selected custom commands will be deleted permanently..
+ ///
+ public static string DeleteCustomCommandsMessage {
+ get {
+ return ResourceManager.GetString("DeleteCustomCommandsMessage", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Delete DNS server.
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index b4b95f2682..86ee7347a8 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -2274,6 +2274,12 @@ is disabled!
The selected custom command will be deleted permanently.
+
+ Delete custom commands
+
+
+ The selected custom commands will be deleted permanently.
+
Restart
diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs
index bcd128c988..19a8870cf8 100644
--- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs
@@ -4,7 +4,9 @@
using NETworkManager.Utilities;
using NETworkManager.Views;
using System;
+using System.Collections;
using System.ComponentModel;
+using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Data;
@@ -292,6 +294,22 @@ public CustomCommandInfo SelectedCustomCommand
}
} = new();
+ ///
+ /// Gets or sets the selected custom commands (several can be deleted at once).
+ ///
+ public IList SelectedCustomCommands
+ {
+ get;
+ set
+ {
+ if (Equals(value, field))
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = new ArrayList();
+
///
/// Gets or sets the maximum number of host threads.
///
@@ -393,14 +411,14 @@ private void AddCustomCommandAction()
/// Gets the command to edit a custom command.
///
public ICommand EditCustomCommandCommand =>
- new RelayCommand(_ => EditCustomCommandAction(), CustomCommandSelected_CanExecute);
+ new RelayCommand(_ => EditCustomCommandAction(), EditCustomCommand_CanExecute);
///
- /// Checks if a custom command is selected (edit and delete need one, e.g. F2 / Del without a selection).
+ /// Checks if exactly one custom command is selected (e.g. F2 without a selection or with several selected).
///
- private bool CustomCommandSelected_CanExecute(object parameter)
+ private bool EditCustomCommand_CanExecute(object parameter)
{
- return SelectedCustomCommand != null;
+ return SelectedCustomCommand != null && SelectedCustomCommands.Count == 1;
}
///
@@ -412,10 +430,18 @@ private void EditCustomCommandAction()
}
///
- /// Gets the command to delete a custom command.
+ /// Gets the command to delete the selected custom commands.
///
public ICommand DeleteCustomCommandCommand =>
- new RelayCommand(_ => DeleteCustomCommandAction(), CustomCommandSelected_CanExecute);
+ new RelayCommand(_ => DeleteCustomCommandAction(), DeleteCustomCommand_CanExecute);
+
+ ///
+ /// Checks if at least one custom command is selected (e.g. Del without a selection).
+ ///
+ private bool DeleteCustomCommand_CanExecute(object parameter)
+ {
+ return SelectedCustomCommands.Count > 0;
+ }
///
/// Action to delete a custom command.
@@ -494,21 +520,25 @@ public Task EditCustomCommand()
}
///
- /// Deletes the selected custom command.
+ /// Deletes the selected custom commands (after a confirmation that lists them).
///
private async Task DeleteCustomCommand()
{
+ var customCommands = SelectedCustomCommands.Cast().ToList();
+
var result = await DialogHelper.ShowConfirmationMessageAsync(Application.Current.MainWindow,
- Strings.DeleteCustomCommand,
- Strings.DeleteCustomCommandMessage + Environment.NewLine + Environment.NewLine +
- DialogHelper.FormatItemList([SelectedCustomCommand.Name]),
+ customCommands.Count == 1 ? Strings.DeleteCustomCommand : Strings.DeleteCustomCommands,
+ (customCommands.Count == 1 ? Strings.DeleteCustomCommandMessage : Strings.DeleteCustomCommandsMessage) +
+ Environment.NewLine + Environment.NewLine +
+ DialogHelper.FormatItemList([.. customCommands.Select(x => x.Name)]),
ChildWindowIcon.Info,
Strings.Delete);
if (!result)
return;
- SettingsManager.Current.IPScanner_CustomCommands.Remove(SelectedCustomCommand);
+ foreach (var customCommand in customCommands)
+ SettingsManager.Current.IPScanner_CustomCommands.Remove(customCommand);
}
#endregion
diff --git a/Source/NETworkManager/Views/IPScannerSettingsView.xaml b/Source/NETworkManager/Views/IPScannerSettingsView.xaml
index 6435fc8e01..7b4b5572b2 100644
--- a/Source/NETworkManager/Views/IPScannerSettingsView.xaml
+++ b/Source/NETworkManager/Views/IPScannerSettingsView.xaml
@@ -8,6 +8,7 @@
xmlns:viewModels="clr-namespace:NETworkManager.ViewModels"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
xmlns:utilities="clr-namespace:NETworkManager.Utilities;assembly=NETworkManager.Utilities"
+ xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls"
xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:IPScannerSettingsViewModel}">
@@ -73,10 +74,24 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -116,6 +131,7 @@
-
+