diff --git a/Source/NETworkManager.Controls/ComboBoxPasteBehavior.cs b/Source/NETworkManager.Controls/ComboBoxPasteBehavior.cs new file mode 100644 index 0000000000..cdcd3a47cd --- /dev/null +++ b/Source/NETworkManager.Controls/ComboBoxPasteBehavior.cs @@ -0,0 +1,99 @@ +using System; +using System.Linq; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace NETworkManager.Controls; + +/// +/// Attached property that converts multiline text (e.g. a column pasted from Excel) into a +/// semicolon-separated single line when pasted into an editable . +/// +/// Without this the WPF TextBox inside the ComboBox ( is +/// false) silently drops everything after the first line when multiline text is pasted. +/// The conversion is applied by reading the clipboard, replacing the editable text box's +/// selection with the converted text, and marking as +/// handled - the system clipboard itself is never modified, so other applications (or a +/// subsequent paste elsewhere) are unaffected. +/// +public static class ComboBoxPasteBehavior +{ + public static readonly DependencyProperty ConvertMultilineToSemicolonProperty = + DependencyProperty.RegisterAttached( + "ConvertMultilineToSemicolon", + typeof(bool), + typeof(ComboBoxPasteBehavior), + new PropertyMetadata(false, OnConvertMultilineToSemicolonChanged)); + + public static void SetConvertMultilineToSemicolon(UIElement element, bool value) => + element.SetValue(ConvertMultilineToSemicolonProperty, value); + + public static bool GetConvertMultilineToSemicolon(UIElement element) => + (bool)element.GetValue(ConvertMultilineToSemicolonProperty); + + private static void OnConvertMultilineToSemicolonChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not ComboBox comboBox) + return; + + if ((bool)e.NewValue) + CommandManager.AddPreviewExecutedHandler(comboBox, OnPreviewExecuted); + else + CommandManager.RemovePreviewExecutedHandler(comboBox, OnPreviewExecuted); + } + + private static void OnPreviewExecuted(object sender, ExecutedRoutedEventArgs e) + { + // Keyboard shortcut (Ctrl+V) and the paste context menu item both route through + // ApplicationCommands.Paste. Editing commands don't need to be handled here. + if (e.Command != ApplicationCommands.Paste) + return; + + // The command originates from the focused editable text box inside the ComboBox + // template, which is where the pasted text actually needs to be inserted. + if (e.OriginalSource is not TextBox textBox) + return; + + string text; + + try + { + if (!Clipboard.ContainsText()) + return; + + text = Clipboard.GetText(); + } + catch (ExternalException) + { + // Clipboard is temporarily locked by another process - fall back to the default paste. + return; + } + + // Only intervene when there is actual multiline content (e.g. a column pasted from + // Excel). Otherwise let the default paste command handle it as usual. + if (!text.Contains('\n') && !text.Contains('\r')) + return; + + var converted = string.Join(";", text + .Replace("\r\n", "\n") + .Replace('\r', '\n') + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim())); + + // Replace the current selection with the converted text ourselves and mark the command + // handled, instead of letting the default paste run - this avoids touching the system + // clipboard entirely (no risk of losing other clipboard formats or racing a paste + // elsewhere). + var selectionStart = textBox.SelectionStart; + var textBefore = textBox.Text[..selectionStart]; + var textAfter = textBox.Text[(selectionStart + textBox.SelectionLength)..]; + + textBox.Text = textBefore + converted + textAfter; + textBox.SelectionStart = selectionStart + converted.Length; + textBox.SelectionLength = 0; + + e.Handled = true; + } +} diff --git a/Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs b/Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs index 08636974d6..7c03a29d38 100644 --- a/Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/StaticStrings.Designer.cs @@ -241,7 +241,7 @@ public static string ExampleHostnameOrIPAddress { } /// - /// Looks up a localized string similar to 192.168.178.0/24; 10.0.0.0 - 10.0.0.9; 10.0.[0-9,20].[1-2]; server-01.borntoberoot.net/24. + /// Looks up a localized string similar to 192.168.178.0/24; 10.8.0.0-100; 10.8.[0-9,254].1; server-01.borntoberoot.net/28. /// public static string ExampleHostRange { get { diff --git a/Source/NETworkManager.Localization/Resources/StaticStrings.resx b/Source/NETworkManager.Localization/Resources/StaticStrings.resx index 897bf115a8..b107040973 100644 --- a/Source/NETworkManager.Localization/Resources/StaticStrings.resx +++ b/Source/NETworkManager.Localization/Resources/StaticStrings.resx @@ -145,7 +145,7 @@ SERVER-01 or 10.0.0.10 - 192.168.178.0/24; 10.0.0.0 - 10.0.0.9; 10.0.[0-9,20].[1-2]; server-01.borntoberoot.net/24 + 192.168.178.0/24; 10.8.0.0-100; 10.8.[0-9,254].1; server-01.borntoberoot.net/28 10.0.0.10 diff --git a/Source/NETworkManager.Models/Network/HostRangeHelper.cs b/Source/NETworkManager.Models/Network/HostRangeHelper.cs index f7b21cadef..be420e8bd2 100644 --- a/Source/NETworkManager.Models/Network/HostRangeHelper.cs +++ b/Source/NETworkManager.Models/Network/HostRangeHelper.cs @@ -1,4 +1,5 @@ using NETworkManager.Utilities; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -17,18 +18,40 @@ namespace NETworkManager.Models.Network; public static class HostRangeHelper { /// - /// Create a list of hosts from a string input like "10.0.0.1; example.com; 10.0.0.0/24" + /// Create a list of hosts from a string input like "10.0.0.1; example.com; 10.0.0.0/24". + /// Inputs can also be separated by newlines (e.g. pasted from Excel, one host/range per line). /// - /// Hosts like "10.0.0.1; example.com; 10.0.0.0/24" + /// Hosts like "10.0.0.1; example.com; 10.0.0.0/24" or newline-separated lines /// List of hosts. public static IEnumerable CreateListFromInput(string hosts) { - return hosts.Replace(" ", "").Split(';') - .Where(x => !string.IsNullOrEmpty(x)) + return hosts.Replace(" ", "") + .Split([';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries) .Select(x => x.Trim()) .ToArray(); } + /// + /// Adds every IPv4 address in the inclusive range [, ] to + /// . Iterates using the unsigned IPv4 value widened to so the + /// inclusive upper bound stays representable even for the last address in the 32-bit space + /// (255.255.255.255), which would otherwise overflow arithmetic. + /// + private static void AddIPv4RangeToBag(IPAddress start, IPAddress end, + ConcurrentBag<(IPAddress ipAddress, string hostname)> hostsBag, CancellationToken ct) + { + var from = (long)unchecked((uint)IPv4Address.ToInt32(start)); + var to = (long)unchecked((uint)IPv4Address.ToInt32(end)); + + Parallel.For(from, to + 1, (i, state) => + { + if (ct.IsCancellationRequested) + state.Break(); + + hostsBag.Add((IPv4Address.FromInt32(unchecked((int)i)), string.Empty)); + }); + } + public static async Task<(List<(IPAddress ipAddress, string hostname)> hosts, List hostnamesNotResolved)> ResolveAsync(IEnumerable hosts, bool dnsResolveHostnamePreferIPv4, CancellationToken cancellationToken) { @@ -54,14 +77,17 @@ public static IEnumerable CreateListFromInput(string hosts) case var _ when RegexHelper.IPv4AddressSubnetmaskRegex().IsMatch(host): var network = IPNetwork2.Parse(host); - Parallel.For(IPv4Address.ToInt32(network.Network), IPv4Address.ToInt32(network.Broadcast) + 1, - (i, state) => - { - if (ct.IsCancellationRequested) - state.Break(); + AddIPv4RangeToBag(network.Network, network.Broadcast, hostsBag, ct); - hostsBag.Add((IPv4Address.FromInt32(i), string.Empty)); - }); + break; + + // 192.168.0.1-100 + case var _ when RegexHelper.IPv4AddressShortRangeRegex().IsMatch(host): + var shortRange = host.Split('-'); + var shortBase = shortRange[0][..shortRange[0].LastIndexOf('.')]; + + AddIPv4RangeToBag(IPAddress.Parse(shortRange[0]), + IPAddress.Parse($"{shortBase}.{shortRange[1]}"), hostsBag, ct); break; @@ -69,14 +95,7 @@ public static IEnumerable CreateListFromInput(string hosts) case var _ when RegexHelper.IPv4AddressRangeRegex().IsMatch(host): var range = host.Split('-'); - Parallel.For(IPv4Address.ToInt32(IPAddress.Parse(range[0])), - IPv4Address.ToInt32(IPAddress.Parse(range[1])) + 1, (i, state) => - { - if (ct.IsCancellationRequested) - state.Break(); - - hostsBag.Add((IPv4Address.FromInt32(i), string.Empty)); - }); + AddIPv4RangeToBag(IPAddress.Parse(range[0]), IPAddress.Parse(range[1]), hostsBag, ct); break; @@ -170,14 +189,7 @@ public static IEnumerable CreateListFromInput(string hosts) network = IPNetwork2.Parse( $"{dnsResultWithSubnet.Value}/{hostAndSubnet[1]}"); - Parallel.For(IPv4Address.ToInt32(network.Network), - IPv4Address.ToInt32(network.Broadcast) + 1, (i, state) => - { - if (ct.IsCancellationRequested) - state.Break(); - - hostsBag.Add((IPv4Address.FromInt32(i), string.Empty)); - }); + AddIPv4RangeToBag(network.Network, network.Broadcast, hostsBag, ct); } else { diff --git a/Source/NETworkManager.Utilities/RegexHelper.cs b/Source/NETworkManager.Utilities/RegexHelper.cs index 0785fc2fa6..8462c2eedf 100644 --- a/Source/NETworkManager.Utilities/RegexHelper.cs +++ b/Source/NETworkManager.Utilities/RegexHelper.cs @@ -43,14 +43,30 @@ public static partial class RegexHelper public static partial Regex IPv4AddressExtractRegex(); /// - /// Provides a compiles regular expression that matches IPv4 address ranges in the format "start-end" like + /// Represents a regular expression pattern that matches valid shorthand IPv4 address ranges like + /// "192.168.178.1-100" (base IP + last octet range). + /// + private const string IPv4AddressShortRangeValues = + @"((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\-(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)"; + + /// + /// Provides a compiled regular expression that matches IPv4 address ranges in the format "start-end" like /// "192.168.178.0-192.168.178.255". - /// + /// /// A instance that matches strings representing IPv4 address ranges, such as /// "192.168.1.1-192.168.1.100". [GeneratedRegex($"^{IPv4AddressValues}-{IPv4AddressValues}$")] public static partial Regex IPv4AddressRangeRegex(); + /// + /// Provides a compiled regular expression that matches shorthand IPv4 address ranges like + /// "192.168.178.1-100" (base IP followed by a last-octet range). + /// + /// A instance that matches strings representing shorthand IPv4 address ranges, + /// such as "192.168.1.1-100" (192.168.1.1 to 192.168.1.100). + [GeneratedRegex($"^{IPv4AddressShortRangeValues}$")] + public static partial Regex IPv4AddressShortRangeRegex(); + /// /// Provides a compiled regular expression that matches valid IPv4 subnet mask like "255.255.0.0". /// diff --git a/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs b/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs index a2674ce8ae..7f79762569 100644 --- a/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs +++ b/Source/NETworkManager.Validators/MultipleHostsRangeValidator.cs @@ -1,6 +1,7 @@ using NETworkManager.Localization.Resources; using NETworkManager.Models.Network; using NETworkManager.Utilities; +using System; using System.DirectoryServices.ActiveDirectory; using System.Globalization; using System.Net; @@ -18,7 +19,9 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) if (value == null) return new ValidationResult(false, Strings.EnterValidIPScanRange); - foreach (var ipHostOrRange in ((string)value).Replace(" ", "").Split(';')) + foreach (var ipHostOrRange in ((string)value) + .Replace(" ", "") + .Split([';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) { // 192.168.0.1 if (RegexHelper.IPv4AddressRegex().IsMatch(ipHostOrRange)) @@ -32,6 +35,19 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) if (RegexHelper.IPv4AddressSubnetmaskRegex().IsMatch(ipHostOrRange)) continue; + // 192.168.0.1-100 + if (RegexHelper.IPv4AddressShortRangeRegex().IsMatch(ipHostOrRange)) + { + var shortRange = ipHostOrRange.Split('-'); + var shortBase = shortRange[0][..shortRange[0].LastIndexOf('.')]; + + if (IPv4Address.ToInt32(IPAddress.Parse(shortRange[0])) > + IPv4Address.ToInt32(IPAddress.Parse($"{shortBase}.{shortRange[1]}"))) + isValid = false; + + continue; + } + // 192.168.0.0 - 192.168.0.100 if (RegexHelper.IPv4AddressRangeRegex().IsMatch(ipHostOrRange)) { diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml index c60cf86ed4..0a85dce0ba 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml +++ b/Source/NETworkManager/Views/IPScannerView.xaml @@ -68,6 +68,7 @@ ItemsSource="{Binding Path=HostHistoryView}" mah:TextBoxHelper.Watermark="{x:Static Member=localization:StaticStrings.ExampleHostRange}" IsReadOnly="{Binding Path=IsRunning}" + controls:ComboBoxPasteBehavior.ConvertMultilineToSemicolon="True" Style="{StaticResource ResourceKey=HistoryComboBox}"> diff --git a/Source/NETworkManager/Views/PingMonitorHostView.xaml b/Source/NETworkManager/Views/PingMonitorHostView.xaml index 0d11f8f732..8971374365 100644 --- a/Source/NETworkManager/Views/PingMonitorHostView.xaml +++ b/Source/NETworkManager/Views/PingMonitorHostView.xaml @@ -97,6 +97,7 @@ ItemsSource="{Binding Path=HostHistoryView}" mah:TextBoxHelper.Watermark="{x:Static Member=localization:StaticStrings.ExampleHostRange}" IsReadOnly="{Binding Path=IsRunning}" + controls:ComboBoxPasteBehavior.ConvertMultilineToSemicolon="True" Style="{StaticResource ResourceKey=HistoryComboBox}"> diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml index 54bb2151fa..bbf3f36012 100644 --- a/Source/NETworkManager/Views/PortScannerView.xaml +++ b/Source/NETworkManager/Views/PortScannerView.xaml @@ -60,6 +60,7 @@ ItemsSource="{Binding HostHistoryView}" mah:TextBoxHelper.Watermark="{x:Static localization:StaticStrings.ExampleHostRange}" IsReadOnly="{Binding Path=IsRunning}" + controls:ComboBoxPasteBehavior.ConvertMultilineToSemicolon="True" Style="{StaticResource HistoryComboBox}"> @@ -79,6 +80,7 @@ ItemsSource="{Binding PortsHistoryView}" mah:TextBoxHelper.Watermark="{x:Static localization:StaticStrings.ExamplePortScanRange}" IsReadOnly="{Binding Path=IsRunning}" + controls:ComboBoxPasteBehavior.ConvertMultilineToSemicolon="True" Style="{StaticResource HistoryComboBox}"> diff --git a/Website/docs/application/ip-scanner.md b/Website/docs/application/ip-scanner.md index 4f41a2f5d7..6c46b528d4 100644 --- a/Website/docs/application/ip-scanner.md +++ b/Website/docs/application/ip-scanner.md @@ -25,6 +25,7 @@ With the **IP Scanner** you can scan for active devices based on the hostname or | -------------------------------- | -------------------------------------------------------------------------------------------- | | `10.0.0.1` | Single IP address (`10.0.0.1`) | | `10.0.0.100 - 10.0.0.199` | All IP addresses in a given range (`10.0.0.100`, `10.0.0.101`, ..., `10.0.0.199`) | +| `10.0.0.100-199` | All IP addresses in a given range, shorthand for the last octet (`10.0.0.100`, ..., `10.0.0.199`) | | `10.0.0.0/23` | All IP addresses in a subnet (`10.0.0.0`, ..., `10.0.1.255`) | | `10.0.0.0/255.255.254.0` | All IP addresses in a subnet (`10.0.0.0`, ..., `10.0.1.255`) | | `10.0.[0-9,20].[1-2]` | Multiple IP addresses like (`10.0.0.1`, `10.0.0.2`, `10.0.1.1`, ...,`10.0.9.2`, `10.0.20.1`) | @@ -34,9 +35,9 @@ With the **IP Scanner** you can scan for active devices based on the hostname or :::note -Multiple inputs can be combined with a semicolon (`;`). +Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically. -Example: `10.0.0.0/24; 10.0.[10-20]1` +Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` ::: diff --git a/Website/docs/application/ping-monitor.md b/Website/docs/application/ping-monitor.md index a1ad0a605d..08f2cc4a4b 100644 --- a/Website/docs/application/ping-monitor.md +++ b/Website/docs/application/ping-monitor.md @@ -22,6 +22,7 @@ ICMP (Internet Control Message Protocol) is a network-layer protocol used to sen | -------------------------------- | ------------------------------------------------------------------------------------------ | | `10.0.0.1` | Single IP address (`10.0.0.1`) | | `10.0.0.100 - 10.0.0.199` | All IP addresses in a given range (`10.0.0.100`, `10.0.0.101`, ..., `10.0.0.199`) | +| `10.0.0.100-199` | All IP addresses in a given range, shorthand for the last octet (`10.0.0.100`, ..., `10.0.0.199`) | | `10.0.0.0/23` | All IP addresses in a subnet (`10.0.0.0`, ..., `10.0.1.255`) | | `10.0.0.0/255.255.254.0` | All IP addresses in a subnet (`10.0.0.0`, ..., `10.0.1.255`) | | `10.0.[0-9,20].[1-2]` | Multiple IP addresses like (`10.0.0.1`, `10.0.0.2`, `10.0.1.1`, ...,`10.0.9.2`, `10.0.20.1`) | @@ -31,9 +32,9 @@ ICMP (Internet Control Message Protocol) is a network-layer protocol used to sen :::note -Multiple inputs can be combined with a semicolon (`;`). +Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically. -Example: `10.0.0.0/24; 10.0.[10-20]1` +Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` ::: diff --git a/Website/docs/application/port-scanner.md b/Website/docs/application/port-scanner.md index 96d7632aca..cdcbd94457 100644 --- a/Website/docs/application/port-scanner.md +++ b/Website/docs/application/port-scanner.md @@ -31,6 +31,7 @@ TCP (Transmission Control Protocol) is a connection-oriented transport-layer pro | -------------------------------- | -------------------------------------------------------------------------------------------- | | `10.0.0.1` | Single IP address (`10.0.0.1`) | | `10.0.0.100 - 10.0.0.199` | All IP addresses in a given range (`10.0.0.100`, `10.0.0.101`, ..., `10.0.0.199`) | +| `10.0.0.100-199` | All IP addresses in a given range, shorthand for the last octet (`10.0.0.100`, ..., `10.0.0.199`) | | `10.0.0.0/23` | All IP addresses in a subnet (`10.0.0.0`, ..., `10.0.1.255`) | | `10.0.0.0/255.255.254.0` | All IP addresses in a subnet (`10.0.0.0`, ..., `10.0.1.255`) | | `10.0.[0-9,20].[1-2]` | Multiple IP addresses like (`10.0.0.1`, `10.0.0.2`, `10.0.1.1`, ...,`10.0.9.2`, `10.0.20.1`) | @@ -45,9 +46,9 @@ TCP (Transmission Control Protocol) is a connection-oriented transport-layer pro :::note -Multiple inputs can be combined with a semicolon (`;`). +Multiple inputs can be combined with a semicolon (`;`). A column pasted from Excel (one entry per line) is converted to the semicolon-separated form automatically. -Example: `10.0.0.0/24; 10.0.[10-20]1` or `1-1024; 8080; 8443` +Example: `10.0.0.0/24; 10.0.[10-20].1; 10.0.0.100-199` or `1-1024; 8080; 8443` ::: diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 81231a080f..13ea8a10a3 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -35,12 +35,21 @@ Release date: **xx.xx.2026** **IP Scanner** +- Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. Thanks to [@dearmb](https://github.com/dearmb) [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) +- Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Added `135` (RPC) and `9100` (raw printing) to the default **Ports** list used to detect if a host is reachable. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) - Reduced the default **Max. concurrent port threads** from `5` to `4`. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) - Reduced the default **Max. concurrent host threads** from `256` to `64`, a more conservative default that puts less simultaneous load on the scanned network. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) +**Ping Monitor** + +- Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) +- Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) + **Port Scanner** +- Host and Ports input fields now accept newline-separated entries (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) +- Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568) - Reduced the default **Max. concurrent host threads** from `5` to `4`. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) - Reduced the default **Max. concurrent port threads** from `256` to `64`, a more conservative default that puts less simultaneous load on the scanned host. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) - Added a new **Well-known ports** (`1-1024`) default port profile. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564)