From 1ff098d76e702379bcde2b0fb8fc5a2a048a3d1b Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:33:40 +0200 Subject: [PATCH 01/10] Feature: Run command for Subnet Calculator and Lookup - Subnet Calculator (subnetcalculator, subnet, ipcalc): "subnet 10.0.0.0/24" calculates the subnet, a new subnet mask or CIDR as second argument ("subnet 10.0.0.0/16 /24") opens Subnetting, a second subnet ("subnet 10.0.0.0/24 10.0.1.0/24") opens Wide Subnet. - Lookup: "lookup oui " and "lookup port ". The type is the first word after the command (RunCommandDefinition.TypeBeforeTarget), the rest of the input is the query, so vendors can contain spaces. Without a query, the lookup is only shown. Suggestions offer the lookups after "lookup " and preselect the matching one while typing. - Commands can opt out of profiles (Subnet Calculator, Lookup), the syntax hint then has no "@". - Subnet Calculator and Lookup host views handle requests and select the tab (SubnetCalculatorRequest, LookupRequest). - Fix: Subnetting got stuck if the new subnet mask was smaller than the one of the subnet or too large for the address family (e.g. /40 for IPv4). An error is shown instead; the run command validates the same range (Subnetmask.ConvertSubnetmaskOrCidrToCidr). - Parser tests, docs and changelog. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 9 +++ .../Resources/Strings.resx | 5 +- .../Lookup/LookupType.cs | 10 +++ .../Network/Subnetmask.cs | 16 ++++ .../RunCommand/RunCommandArgument.cs | 6 +- .../RunCommand/RunCommandDefinition.cs | 6 ++ .../RunCommand/RunCommandParser.cs | 26 ++++++- .../RunCommand/RunCommandPosition.cs | 1 + .../RunCommand/RunCommandParserTests.cs | 76 ++++++++++++++++++- Source/NETworkManager/ApplicationRequest.cs | 29 +++++++ Source/NETworkManager/MainWindow.xaml.cs | 3 +- Source/NETworkManager/RunCommandManager.cs | 74 +++++++++++++++--- .../ViewModels/LookupOUILookupViewModel.cs | 11 +++ .../ViewModels/LookupPortViewModel.cs | 11 +++ .../ViewModels/RunCommandViewModel.cs | 5 ++ .../SubnetCalculatorCalculatorViewModel.cs | 11 +++ .../SubnetCalculatorSubnettingViewModel.cs | 38 ++++++++-- .../SubnetCalculatorWideSubnetViewModel.cs | 13 ++++ .../NETworkManager/Views/LookupHostView.xaml | 8 +- .../Views/LookupHostView.xaml.cs | 29 ++++++- .../Views/LookupOUILookupView.xaml.cs | 5 ++ .../Views/LookupPortLookupView.xaml.cs | 5 ++ .../SubnetCalculatorCalculatorView.xaml.cs | 5 ++ .../Views/SubnetCalculatorHostView.xaml | 12 +-- .../Views/SubnetCalculatorHostView.xaml.cs | 23 +++++- .../SubnetCalculatorSubnettingView.xaml.cs | 5 ++ .../SubnetCalculatorWideSubnetView.xaml.cs | 5 ++ Website/docs/changelog/next-release.md | 5 ++ Website/docs/run-command.md | 9 ++- 29 files changed, 422 insertions(+), 39 deletions(-) create mode 100644 Source/NETworkManager.Models/Lookup/LookupType.cs diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 33bee494ed..4df1275a63 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -4174,6 +4174,15 @@ public static string EnterSubnetAndANewSubnetmaskToCreateSubnets { } } + /// + /// Looks up a localized string similar to Enter a subnet mask or CIDR between /{0} and /{1}!. + /// + public static string EnterSubnetmaskOrCIDRBetweenXAndY { + get { + return ResourceManager.GetString("EnterSubnetmaskOrCIDRBetweenXAndY", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enter subnets to create a wide subnet.... /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 667accf8ac..609391a7c1 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -1,4 +1,4 @@ - + Show window and open the run command + if (msg == WmHotkey && wParam.ToInt32() == 2) + { + ShowWindow(); + OpenRunAction(); + handled = true; + + return IntPtr.Zero; + } + // Window size events switch (msg) { @@ -1444,6 +1454,7 @@ private void UpdateOnWindowResize() /* ID | Command * ---|------------------- * 1 | ShowWindow() + * 2 | ShowWindow() + OpenRunAction() */ private readonly List _registeredHotKeys = []; @@ -1456,6 +1467,13 @@ private void RegisterHotKeys() SettingsManager.Current.HotKey_ShowWindowKey); _registeredHotKeys.Add(1); } + + if (SettingsManager.Current.HotKey_RunCommandEnabled) + { + RegisterHotKey(new WindowInteropHelper(this).Handle, 2, SettingsManager.Current.HotKey_RunCommandModifier, + SettingsManager.Current.HotKey_RunCommandKey); + _registeredHotKeys.Add(2); + } } private void UnregisterHotKeys() diff --git a/Source/NETworkManager/ViewModels/SettingsHotKeysViewModel.cs b/Source/NETworkManager/ViewModels/SettingsHotKeysViewModel.cs index 1ef0e6974c..b26438b1b1 100644 --- a/Source/NETworkManager/ViewModels/SettingsHotKeysViewModel.cs +++ b/Source/NETworkManager/ViewModels/SettingsHotKeysViewModel.cs @@ -51,6 +51,47 @@ public HotKey HotKeyShowWindow } } + public bool HotKeyRunCommandEnabled + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + { + SettingsManager.Current.HotKey_RunCommandEnabled = value; + + SettingsManager.HotKeysChanged = true; + } + + field = value; + OnPropertyChanged(); + } + } + + public HotKey HotKeyRunCommand + { + get; + set + { + if (Equals(value, field)) + return; + + if (!_isLoading && value != null) + { + SettingsManager.Current.HotKey_RunCommandKey = (int)HotKeys.WpfKeyToFormsKeys(value.Key); + SettingsManager.Current.HotKey_RunCommandModifier = HotKeys.GetModifierKeysSum(value.ModifierKeys); + + SettingsManager.HotKeysChanged = true; + } + + field = value; + OnPropertyChanged(); + } + } + #endregion #region Constructor, LoadSettings @@ -69,6 +110,9 @@ private void LoadSettings() HotKeyShowWindowEnabled = SettingsManager.Current.HotKey_ShowWindowEnabled; HotKeyShowWindow = new HotKey(HotKeys.FormsKeysToWpfKey(SettingsManager.Current.HotKey_ShowWindowKey), HotKeys.GetModifierKeysFromInt(SettingsManager.Current.HotKey_ShowWindowModifier)); + HotKeyRunCommandEnabled = SettingsManager.Current.HotKey_RunCommandEnabled; + HotKeyRunCommand = new HotKey(HotKeys.FormsKeysToWpfKey(SettingsManager.Current.HotKey_RunCommandKey), + HotKeys.GetModifierKeysFromInt(SettingsManager.Current.HotKey_RunCommandModifier)); } #endregion diff --git a/Source/NETworkManager/Views/SettingsHotKeysView.xaml b/Source/NETworkManager/Views/SettingsHotKeysView.xaml index 4697c1ffdb..94f6b4ea00 100644 --- a/Source/NETworkManager/Views/SettingsHotKeysView.xaml +++ b/Source/NETworkManager/Views/SettingsHotKeysView.xaml @@ -12,6 +12,10 @@ + + \ No newline at end of file diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index f44578befe..c044eba8e8 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -29,6 +29,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`). [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX) +- 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). [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX) **RDAP** diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md index 7e10c9565e..8f384f23c5 100644 --- a/Website/docs/run-command.md +++ b/Website/docs/run-command.md @@ -8,7 +8,7 @@ keywords: [NETworkManager, run command, command palette, keyboard shortcut, Ctrl The **Run Command** opens a feature and runs it directly from the keyboard: press `Ctrl+Shift+P`, type a command like `ping 10.0.0.1` and press `Enter`. -The run command can also be opened with the **Run command...** button in the title bar. +The run command can also be opened with the **Run command...** button in the title bar, or from anywhere with a global hotkey (default `Ctrl+Alt+P`, disabled by default), which brings the window to the foreground, see [Settings > HotKeys](./settings/hotkeys.md). ## Syntax diff --git a/Website/docs/settings/hotkeys.md b/Website/docs/settings/hotkeys.md index 8a305d082d..2ae75f2ed4 100644 --- a/Website/docs/settings/hotkeys.md +++ b/Website/docs/settings/hotkeys.md @@ -1,6 +1,6 @@ --- sidebar_position: 6 -description: "Configure keyboard shortcuts in NETworkManager to quickly bring the application to the foreground from the system tray." +description: "Configure global keyboard shortcuts in NETworkManager to bring the application to the foreground from the system tray or to open the run command." keywords: [NETworkManager, hotkeys, keyboard shortcuts, key bindings, system tray shortcut] --- @@ -14,4 +14,14 @@ Untray the application and bring it to the foreground. **Default:** `Disabled` -**Default Hotkey:** `ALT + STRG + O` +**Default Hotkey:** `Ctrl+Alt+O` + +### Bring window to foreground and open the run command + +Untray the application, bring it to the foreground and open the [run command](../run-command.md), so a feature can be run from anywhere (e.g. `ping 10.0.0.1`). + +**Type:** `Boolean` + +**Default:** `Disabled` + +**Default Hotkey:** `Ctrl+Alt+P` From c602565bb5e514d5950499d6c11a2b3d732a97c9 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:15:44 +0200 Subject: [PATCH 04/10] Feature: Improve run command dialog & behaviour --- .../Resources/Strings.Designer.cs | 429 ++++++++-- .../Resources/Strings.resx | 108 +++ .../Network/Subnetmask.cs | 12 + .../RunCommand/RunCommandDefinition.cs | 12 +- .../RunCommand/RunCommandParseResult.cs | 39 +- .../RunCommand/RunCommandSyntaxPart.cs | 35 + .../RunCommand/RunCommandSyntaxPartKind.cs | 12 + .../RunCommand/RunCommandTarget.cs | 105 +++ .../RunCommand/RunCommandTargetKind.cs | 63 ++ .../GlobalStaticConfiguration.cs | 3 + .../NETworkManager.Settings/SettingsInfo.cs | 26 + .../RunCommand/RunCommandParserTests.cs | 52 +- .../RunCommand/RunCommandTargetTests.cs | 74 ++ .../TextBlockHighlightHelper.cs | 79 ++ Source/NETworkManager/ApplicationRequest.cs | 7 + .../NETworkManager/Controls/TitleBarPanel.cs | 176 ++++ Source/NETworkManager/MainWindow.xaml | 88 +- Source/NETworkManager/MainWindow.xaml.cs | 6 +- Source/NETworkManager/RunCommandHint.cs | 15 + Source/NETworkManager/RunCommandManager.cs | 193 ++++- Source/NETworkManager/RunCommandRunMode.cs | 22 + Source/NETworkManager/RunCommandSuggestion.cs | 32 +- .../ViewModels/DNSLookupHostViewModel.cs | 12 +- .../ViewModels/DNSLookupViewModel.cs | 6 +- .../ViewModels/IPGeolocationHostViewModel.cs | 10 +- .../ViewModels/IPGeolocationViewModel.cs | 5 +- .../ViewModels/IPScannerHostViewModel.cs | 10 +- .../ViewModels/IPScannerViewModel.cs | 5 +- .../ViewModels/LookupOUILookupViewModel.cs | 10 +- .../ViewModels/LookupPortViewModel.cs | 9 +- .../ViewModels/PingMonitorHostViewModel.cs | 10 +- .../ViewModels/PortScannerHostViewModel.cs | 9 +- .../ViewModels/PortScannerViewModel.cs | 5 +- .../ViewModels/RDAPHostViewModel.cs | 10 +- .../ViewModels/RDAPViewModel.cs | 4 +- .../ViewModels/RunCommandViewModel.cs | 778 ++++++++++++++++-- .../ViewModels/SettingsGeneralViewModel.cs | 34 + .../SubnetCalculatorCalculatorViewModel.cs | 6 +- .../SubnetCalculatorSubnettingViewModel.cs | 16 +- .../SubnetCalculatorWideSubnetViewModel.cs | 6 +- .../ViewModels/TracerouteHostViewModel.cs | 8 +- .../ViewModels/TracerouteViewModel.cs | 5 +- .../ViewModels/WakeOnLANViewModel.cs | 16 +- .../ViewModels/WhoisHostViewModel.cs | 8 +- .../ViewModels/WhoisViewModel.cs | 4 +- .../Views/DNSLookupHostView.xaml.cs | 4 +- .../Views/DNSLookupView.xaml.cs | 5 +- .../Views/IPGeolocationHostView.xaml.cs | 4 +- .../Views/IPGeolocationView.xaml.cs | 4 +- .../Views/IPScannerHostView.xaml.cs | 4 +- .../Views/IPScannerView.xaml.cs | 4 +- .../Views/LookupHostView.xaml.cs | 4 +- .../Views/LookupOUILookupView.xaml.cs | 4 +- .../Views/LookupPortLookupView.xaml.cs | 4 +- .../Views/PingMonitorHostView.xaml.cs | 4 +- .../Views/PortScannerHostView.xaml.cs | 4 +- .../Views/PortScannerView.xaml.cs | 4 +- .../NETworkManager/Views/RDAPHostView.xaml.cs | 4 +- Source/NETworkManager/Views/RDAPView.xaml.cs | 4 +- .../Views/RunCommandChildWindow.xaml | 364 ++++++-- .../Views/RunCommandChildWindow.xaml.cs | 178 +++- .../Views/SettingsGeneralView.xaml | 8 +- .../SubnetCalculatorCalculatorView.xaml.cs | 4 +- .../Views/SubnetCalculatorHostView.xaml.cs | 6 +- .../SubnetCalculatorSubnettingView.xaml.cs | 4 +- .../SubnetCalculatorWideSubnetView.xaml.cs | 4 +- .../Views/TracerouteHostView.xaml.cs | 4 +- .../Views/TracerouteView.xaml.cs | 4 +- .../Views/WakeOnLANView.xaml.cs | 4 +- .../Views/WhoisHostView.xaml.cs | 4 +- Source/NETworkManager/Views/WhoisView.xaml.cs | 4 +- Website/docs/changelog/next-release.md | 1 + Website/docs/run-command.md | 64 +- Website/docs/settings/general.md | 16 + 74 files changed, 2928 insertions(+), 372 deletions(-) create mode 100644 Source/NETworkManager.Models/RunCommand/RunCommandSyntaxPart.cs create mode 100644 Source/NETworkManager.Models/RunCommand/RunCommandSyntaxPartKind.cs create mode 100644 Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs create mode 100644 Source/NETworkManager.Models/RunCommand/RunCommandTargetKind.cs create mode 100644 Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs create mode 100644 Source/NETworkManager.Utilities.WPF/TextBlockHighlightHelper.cs create mode 100644 Source/NETworkManager/Controls/TitleBarPanel.cs create mode 100644 Source/NETworkManager/RunCommandHint.cs create mode 100644 Source/NETworkManager/RunCommandRunMode.cs diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index ffa0a8e7d0..09e3b49738 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. @@ -1953,6 +1954,15 @@ public static string Client { } } + /// + /// Looks up a localized string similar to Clipboard. + /// + public static string Clipboard { + get { + return ResourceManager.GetString("Clipboard", resourceCulture); + } + } + /// /// Looks up a localized string similar to Close. /// @@ -2062,7 +2072,16 @@ public static string CommandLineArguments { } /// - /// Looks up a localized string similar to The command "{0}" does not accept arguments!. + /// Looks up a localized string similar to Commands. + /// + public static string Commands { + get { + return ResourceManager.GetString("Commands", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The command "{0}" does not accept arguments!. /// public static string CommandXDoesNotAcceptArguments { get { @@ -2071,7 +2090,7 @@ public static string CommandXDoesNotAcceptArguments { } /// - /// Looks up a localized string similar to The command "{0}" does not support profiles!. + /// Looks up a localized string similar to The command "{0}" does not support profiles!. /// public static string CommandXDoesNotSupportProfiles { get { @@ -2097,6 +2116,15 @@ public static string Community { } } + /// + /// Looks up a localized string similar to Complete. + /// + public static string Complete { + get { + return ResourceManager.GetString("Complete", resourceCulture); + } + } + /// /// Looks up a localized string similar to Computer. /// @@ -2404,7 +2432,7 @@ public static string CopyEntry { } /// - /// Looks up a localized string similar to The rule "{0}" uses the following properties, which are not supported by NETworkManager and are not copied: + /// Looks up a localized string similar to The rule "{0}" uses the following properties, which are not supported by NETworkManager and are not copied: /// ///{1} /// @@ -2659,6 +2687,24 @@ public static string CtrlAltDel { } } + /// + /// Looks up a localized string similar to Ctrl+Enter. + /// + public static string CtrlEnter { + get { + return ResourceManager.GetString("CtrlEnter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Ctrl+Shift+P. + /// + public static string CtrlShiftP { + get { + return ResourceManager.GetString("CtrlShiftP", resourceCulture); + } + } + /// /// Looks up a localized string similar to Currency. /// @@ -3630,6 +3676,15 @@ public static string Domain { } } + /// + /// Looks up a localized string similar to This domain cannot be queried via RDAP. Try Whois instead.. + /// + public static string DomainCannotBeQueriedViaRDAPTryWhois { + get { + return ResourceManager.GetString("DomainCannotBeQueriedViaRDAPTryWhois", resourceCulture); + } + } + /// /// Looks up a localized string similar to Domain name. /// @@ -3812,7 +3867,7 @@ public static string EditEntryDots { } /// - /// Looks up a localized string similar to Editing rules that are not managed by NETworkManager is not supported (yet), because they may use properties that can't be shown in the dialog (e.g. rule group, service or ICMP types). Use "Copy as..." to create a managed copy of the rule.. + /// Looks up a localized string similar to Editing rules that are not managed by NETworkManager is not supported (yet), because they may use properties that can't be shown in the dialog (e.g. rule group, service or ICMP types). Use "Copy as..." to create a managed copy of the rule.. /// public static string EditFirewallRuleNotManagedMessage { get { @@ -4202,7 +4257,7 @@ public static string EnterSubnetsToCreateAWideSubnet { } /// - /// Looks up a localized string similar to Enter the name of a profile after "@"!. + /// Looks up a localized string similar to Enter the name of a profile after "@"!. /// public static string EnterTheNameOfAProfileAfterAt { get { @@ -4211,7 +4266,7 @@ public static string EnterTheNameOfAProfileAfterAt { } /// - /// Looks up a localized string similar to Enter a valid AS number (e.g., "AS15169")!. + /// Looks up a localized string similar to Enter a valid AS number (e.g., "AS15169")!. /// public static string EnterValidASN { get { @@ -4238,7 +4293,7 @@ public static string EnterValidDomain { } /// - /// Looks up a localized string similar to Enter a valid entity handle (e.g., "ORG-RIEN1-RIPE")!. + /// Looks up a localized string similar to Enter a valid entity handle (e.g., "ORG-RIEN1-RIPE")!. /// public static string EnterValidEntityHandle { get { @@ -4346,7 +4401,7 @@ public static string EnterValidIPAddress { } /// - /// Looks up a localized string similar to Enter a valid IP address or CIDR (e.g., "192.0.2.0/24")!. + /// Looks up a localized string similar to Enter a valid IP address or CIDR (e.g., "192.0.2.0/24")!. /// public static string EnterValidIPAddressOrCIDR { get { @@ -4391,7 +4446,7 @@ public static string EnterValidMACAddress { } /// - /// Looks up a localized string similar to Enter a valid nameserver (e.g., "ns1.example.com")!. + /// Looks up a localized string similar to Enter a valid nameserver (e.g., "ns1.example.com")!. /// public static string EnterValidNameserver { get { @@ -4472,7 +4527,7 @@ public static string EnterValidSubnetmaskOrCIDR { } /// - /// Looks up a localized string similar to Enter a valid top-level domain (e.g., "de")!. + /// Looks up a localized string similar to Enter a valid top-level domain (e.g., "de")!. /// public static string EnterValidTLD { get { @@ -4624,6 +4679,15 @@ public static string ExampleProfileFileName { } } + /// + /// Looks up a localized string similar to Examples. + /// + public static string Examples { + get { + return ResourceManager.GetString("Examples", resourceCulture); + } + } + /// /// Looks up a localized string similar to production. /// @@ -4894,6 +4958,15 @@ public static string FilePath { } } + /// + /// Looks up a localized string similar to Fill in without starting. + /// + public static string FillInWithoutStarting { + get { + return ResourceManager.GetString("FillInWithoutStarting", resourceCulture); + } + } + /// /// Looks up a localized string similar to Filter. /// @@ -5156,7 +5229,7 @@ public static string FirewallRuleDirection_Outbound { } /// - /// Looks up a localized string similar to Managed by Group Policy. The rule can't be changed locally, but it can be copied.. + /// Looks up a localized string similar to Managed by Group Policy. The rule can't be changed locally, but it can be copied.. /// public static string FirewallRuleManagedByGroupPolicyMessage { get { @@ -5165,7 +5238,7 @@ public static string FirewallRuleManagedByGroupPolicyMessage { } /// - /// Looks up a localized string similar to Managed by Mobile Device Management (MDM), e.g. Microsoft Intune. The rule can't be changed locally, but it can be copied.. + /// Looks up a localized string similar to Managed by Mobile Device Management (MDM), e.g. Microsoft Intune. The rule can't be changed locally, but it can be copied.. /// public static string FirewallRuleManagedByMDMMessage { get { @@ -5174,7 +5247,7 @@ public static string FirewallRuleManagedByMDMMessage { } /// - /// Looks up a localized string similar to Created by Windows at runtime (e.g. dynamic or hardcoded rule). The rule can't be changed locally, but it can be copied.. + /// Looks up a localized string similar to Created by Windows at runtime (e.g. dynamic or hardcoded rule). The rule can't be changed locally, but it can be copied.. /// public static string FirewallRuleManagedByOtherMessage { get { @@ -5219,7 +5292,7 @@ public static string FirewallRulesLoadedWithErrorsMessage { } /// - /// Looks up a localized string similar to At least one of the selected rules is managed by a policy (e.g. Group Policy or MDM) or created by Windows at runtime and can't be changed locally.. + /// Looks up a localized string similar to At least one of the selected rules is managed by a policy (e.g. Group Policy or MDM) or created by Windows at runtime and can't be changed locally.. /// public static string FirewallRulesManagedByPolicyMessage { get { @@ -6752,6 +6825,24 @@ public static string KeyboardShortcuts { } } + /// + /// Looks up a localized string similar to Enter. + /// + public static string KeyEnter { + get { + return ResourceManager.GetString("KeyEnter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Esc. + /// + public static string KeyEsc { + get { + return ResourceManager.GetString("KeyEsc", resourceCulture); + } + } + /// /// Looks up a localized string similar to Key must have 8 characters or more!. /// @@ -6761,6 +6852,15 @@ public static string KeyMustHave8CharactersOrMore { } } + /// + /// Looks up a localized string similar to Tab. + /// + public static string KeyTab { + get { + return ResourceManager.GetString("KeyTab", resourceCulture); + } + } + /// /// Looks up a localized string similar to Key tag. /// @@ -7914,7 +8014,7 @@ public static string NetworkInterfaceConfigurationAppliedSuccessfully { } /// - /// Looks up a localized string similar to Configuring the network interface requires elevated rights (administrator or member of the "Network Configuration Operators" group)!. + /// Looks up a localized string similar to Configuring the network interface requires elevated rights (administrator or member of the "Network Configuration Operators" group)!. /// public static string NetworkInterfaceConfigureAdminMessage { get { @@ -8112,7 +8212,7 @@ public static string NoEnabledNetworkAdaptersFound { } /// - /// Looks up a localized string similar to No firewall rules found. Use "Add rule..." to create a rule or enable "Show all rules" in the settings to show all Windows Firewall rules.. + /// Looks up a localized string similar to No firewall rules found. Use "Add rule..." to create a rule or enable "Show all rules" in the settings to show all Windows Firewall rules.. /// public static string NoFirewallRulesFoundMessage { get { @@ -9347,7 +9447,7 @@ public static string ProfileWithThisNameAlreadyExists { } /// - /// Looks up a localized string similar to Profile "{0}" not found!. + /// Looks up a localized string similar to Profile "{0}" not found!. /// public static string ProfileXNotFound { get { @@ -9645,7 +9745,7 @@ public static string RDAPNetworkErrorX { } /// - /// Looks up a localized string similar to No RDAP server is known for "{0}". Entity handles can only be resolved if they end with a known service provider tag (e.g., "-RIPE" or "-ARIN").. + /// Looks up a localized string similar to No RDAP server is known for "{0}". Entity handles can only be resolved if they end with a known service provider tag (e.g., "-RIPE" or "-ARIN").. /// public static string RDAPNoServerForEntityX { get { @@ -9654,7 +9754,7 @@ public static string RDAPNoServerForEntityX { } /// - /// Looks up a localized string similar to No RDAP server is known for "{0}". The registry may only provide Whois.. + /// Looks up a localized string similar to No RDAP server is known for "{0}". The registry may only provide Whois.. /// public static string RDAPNoServerForX { get { @@ -9663,7 +9763,7 @@ public static string RDAPNoServerForX { } /// - /// Looks up a localized string similar to No data found for "{0}".. + /// Looks up a localized string similar to No data found for "{0}".. /// public static string RDAPNotFoundX { get { @@ -9770,6 +9870,24 @@ public static string RDAPReferralFailedX { } } + /// + /// Looks up a localized string similar to The referral to the RDAP server of the registrar was not followed, because it does not use a secure connection (HTTPS): {0}. + /// + public static string RDAPReferralInsecureX { + get { + return ResourceManager.GetString("RDAPReferralInsecureX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The referral to the RDAP server of the registrar was not followed, because it points to a local or private address: {0}. + /// + public static string RDAPReferralPrivateAddressX { + get { + return ResourceManager.GetString("RDAPReferralPrivateAddressX", resourceCulture); + } + } + /// /// Looks up a localized string similar to The RDAP server did not respond within {0} seconds.. /// @@ -9789,7 +9907,7 @@ public static string RDAPTooManyRedirects { } /// - /// Looks up a localized string similar to "{0}" is not a registered domain, showing the registration data of "{1}".. + /// Looks up a localized string similar to "{0}" is not a registered domain, showing the registration data of "{1}".. /// public static string RDAPXIsNotARegisteredDomainShowingY { get { @@ -10967,6 +11085,15 @@ public static string RemotePorts { } } + /// + /// Looks up a localized string similar to Remove from history. + /// + public static string RemoveFromHistory { + get { + return ResourceManager.GetString("RemoveFromHistory", resourceCulture); + } + } + /// /// Looks up a localized string similar to Remove IPv4 address. /// @@ -11184,7 +11311,7 @@ public static string Resource_OUI_Description { } /// - /// Looks up a localized string similar to List of public suffixes (e.g. "co.uk") from publicsuffix.org (MPL-2.0), which is used to get the registered domain of a hostname for RDAP queries.. + /// Looks up a localized string similar to List of public suffixes (e.g. "co.uk") from publicsuffix.org (MPL-2.0), which is used to get the registered domain of a hostname for RDAP queries.. /// public static string Resource_PublicSuffixList_Description { get { @@ -11430,6 +11557,24 @@ public static string Routing { } } + /// + /// Looks up a localized string similar to Run. + /// + public static string Run { + get { + return ResourceManager.GetString("Run", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Run and keep open. + /// + public static string RunAndKeepOpen { + get { + return ResourceManager.GetString("RunAndKeepOpen", resourceCulture); + } + } + /// /// Looks up a localized string similar to Run background job every x-minute. /// @@ -11466,6 +11611,168 @@ public static string RunCommandDotsWithHotKey { } } + /// + /// Looks up a localized string similar to Ping it, trace it, scan it.... + /// + public static string RunCommandWatermark1 { + get { + return ResourceManager.GetString("RunCommandWatermark1", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Before you reboot it, ping it.... + /// + public static string RunCommandWatermark10 { + get { + return ResourceManager.GetString("RunCommandWatermark10", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Ping it, trace it, fix it.... + /// + public static string RunCommandWatermark11 { + get { + return ResourceManager.GetString("RunCommandWatermark11", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Is it up? Let's find out.... + /// + public static string RunCommandWatermark12 { + get { + return ResourceManager.GetString("RunCommandWatermark12", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Need to connect? Just type the host.... + /// + public static string RunCommandWatermark13 { + get { + return ResourceManager.GetString("RunCommandWatermark13", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to It's not the network. Probably.... + /// + public static string RunCommandWatermark14 { + get { + return ResourceManager.GetString("RunCommandWatermark14", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Blame the firewall later, ping first.... + /// + public static string RunCommandWatermark15 { + get { + return ResourceManager.GetString("RunCommandWatermark15", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Works on my machine? Let's check yours.... + /// + public static string RunCommandWatermark16 { + get { + return ResourceManager.GetString("RunCommandWatermark16", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Somewhere between here and there.... + /// + public static string RunCommandWatermark17 { + get { + return ResourceManager.GetString("RunCommandWatermark17", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Ping first, panic later.... + /// + public static string RunCommandWatermark18 { + get { + return ResourceManager.GetString("RunCommandWatermark18", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Got an IP address? Drop it here.... + /// + public static string RunCommandWatermark2 { + get { + return ResourceManager.GetString("RunCommandWatermark2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your whole network, one command away.... + /// + public static string RunCommandWatermark3 { + get { + return ResourceManager.GetString("RunCommandWatermark3", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Every tool, one line away.... + /// + public static string RunCommandWatermark4 { + get { + return ResourceManager.GetString("RunCommandWatermark4", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Something down? Let's check it.... + /// + public static string RunCommandWatermark5 { + get { + return ResourceManager.GetString("RunCommandWatermark5", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Is it DNS? It's always DNS.... + /// + public static string RunCommandWatermark6 { + get { + return ResourceManager.GetString("RunCommandWatermark6", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Have you tried pinging it?. + /// + public static string RunCommandWatermark7 { + get { + return ResourceManager.GetString("RunCommandWatermark7", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Packets in, answers out.... + /// + public static string RunCommandWatermark8 { + get { + return ResourceManager.GetString("RunCommandWatermark8", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Ready when your network isn't.... + /// + public static string RunCommandWatermark9 { + get { + return ResourceManager.GetString("RunCommandWatermark9", resourceCulture); + } + } + /// /// Looks up a localized string similar to Run dialog (Win+R). /// @@ -11646,6 +11953,15 @@ public static string Security { } } + /// + /// Looks up a localized string similar to Select. + /// + public static string Select { + get { + return ResourceManager.GetString("Select", resourceCulture); + } + } + /// /// Looks up a localized string similar to Select all. /// @@ -11966,7 +12282,7 @@ public static string SettingsResetExclamationMark { } /// - /// Looks up a localized string similar to Several profiles are named "{0}", select one!. + /// Looks up a localized string similar to Several profiles are named "{0}", select one!. /// public static string SeveralProfilesAreNamedXSelectOne { get { @@ -11983,6 +12299,24 @@ public static string ShareGatewayCredentialsWithRemoteComputer { } } + /// + /// Looks up a localized string similar to Shift+Del. + /// + public static string ShiftDel { + get { + return ResourceManager.GetString("ShiftDel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Shift+Enter. + /// + public static string ShiftEnter { + get { + return ResourceManager.GetString("ShiftEnter", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show. /// @@ -12046,6 +12380,15 @@ public static string ShowErrorMessage { } } + /// + /// Looks up a localized string similar to Show key hints. + /// + public static string ShowKeyHints { + get { + return ResourceManager.GetString("ShowKeyHints", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show local licenses. /// @@ -12731,6 +13074,15 @@ public static string SuccessfullyConnectedToXXX { } } + /// + /// Looks up a localized string similar to Suggest the clipboard content (e.g. an IP address) as target. + /// + public static string SuggestTheClipboardAsTarget { + get { + return ResourceManager.GetString("SuggestTheClipboardAsTarget", resourceCulture); + } + } + /// /// Looks up a localized string similar to Support this project with a donation.. /// @@ -13335,7 +13687,7 @@ public static string Unknown { } /// - /// Looks up a localized string similar to Unknown argument "{0}"!. + /// Looks up a localized string similar to Unknown argument "{0}"!. /// public static string UnknownArgumentX { get { @@ -13344,7 +13696,7 @@ public static string UnknownArgumentX { } /// - /// Looks up a localized string similar to Unknown command "{0}"!. + /// Looks up a localized string similar to Unknown command "{0}"!. /// public static string UnknownCommandX { get { @@ -14212,32 +14564,5 @@ public static string ZipCode { return ResourceManager.GetString("ZipCode", resourceCulture); } } - /// - /// Looks up a localized string similar to This domain cannot be queried via RDAP. Try Whois instead.. - /// - public static string DomainCannotBeQueriedViaRDAPTryWhois { - get { - return ResourceManager.GetString("DomainCannotBeQueriedViaRDAPTryWhois", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The referral to the RDAP server of the registrar was not followed, because it does not use a secure connection (HTTPS): {0}. - /// - public static string RDAPReferralInsecureX { - get { - return ResourceManager.GetString("RDAPReferralInsecureX", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The referral to the RDAP server of the registrar was not followed, because it points to a local or private address: {0}. - /// - public static string RDAPReferralPrivateAddressX { - get { - return ResourceManager.GetString("RDAPReferralPrivateAddressX", resourceCulture); - } - } - } } diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index ea3ffbd8cc..746064eb1c 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -4893,4 +4893,112 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri Bring window to foreground and open the run command + + Commands + + + Clipboard + + + Examples + + + Select + + + Complete + + + Run + + + Run and keep open + + + Fill in without starting + + + Remove from history + + + Suggest the clipboard content (e.g. an IP address) as target + + + Show key hints + + + Ctrl+Shift+P + + + Ctrl+Enter + + + Shift+Enter + + + Shift+Del + + + Enter + + + Tab + + + Esc + + + Ping it, trace it, scan it... + + + Got an IP address? Drop it here... + + + Your whole network, one command away... + + + Every tool, one line away... + + + Something down? Let's check it... + + + Is it DNS? It's always DNS... + + + Have you tried pinging it? + + + Packets in, answers out... + + + Ready when your network isn't... + + + Before you reboot it, ping it... + + + Ping it, trace it, fix it... + + + Is it up? Let's find out... + + + Need to connect? Just type the host... + + + It's not the network. Probably... + + + Blame the firewall later, ping first... + + + Works on my machine? Let's check yours... + + + Somewhere between here and there... + + + Ping first, panic later... + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Network/Subnetmask.cs b/Source/NETworkManager.Models/Network/Subnetmask.cs index 34ed586717..278e62ed20 100644 --- a/Source/NETworkManager.Models/Network/Subnetmask.cs +++ b/Source/NETworkManager.Models/Network/Subnetmask.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Net; +using System.Net.Sockets; using NETworkManager.Utilities; namespace NETworkManager.Models.Network; @@ -39,6 +40,17 @@ public static int ConvertSubnetmaskToCidr(IPAddress subnetmask) return string.Join("", IPv4Address.ToBinaryString(subnetmask.ToString()).Replace(".", "").TrimEnd('0')).Length; } + /// + /// Gets the CIDRs a subnet can be split into (Subnetting): from the CIDR of the subnet to the maximum of the + /// address family (32 for IPv4, 128 for IPv6). + /// + /// Subnet to split. + /// Minimum and maximum CIDR. + public static (int Min, int Max) GetSubnettingCidrRange(IPNetwork2 network) + { + return (network.Cidr, network.AddressFamily == AddressFamily.InterNetwork ? 32 : 128); + } + /// /// Converts a subnet mask (e.g. 255.255.255.0) or a CIDR (e.g. /24 or 24) to the CIDR. The value must be valid /// (see IPv4IPv6SubnetmaskOrCIDRValidator). diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandDefinition.cs b/Source/NETworkManager.Models/RunCommand/RunCommandDefinition.cs index 57b425c21b..04ab528256 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandDefinition.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandDefinition.cs @@ -57,9 +57,17 @@ public sealed class RunCommandDefinition public bool TypeBeforeTarget { get; init; } /// - /// Syntax of the arguments for the hint (e.g. "<hosts> [server] [type]"), empty if there are none. + /// Parts of the syntax of the arguments for the hint (e.g. "<Hosts>", "[DNS server]", "[Type]", + /// "@<Profile>"), empty if there are none. /// - public string Syntax { get; init; } = string.Empty; + public IReadOnlyList SyntaxParts { get; init; } = []; + + /// + /// Syntax of the arguments (e.g. "<Hosts> [DNS server] [Type] | @<Profile>"), empty if there are + /// none. + /// + public string Syntax => string.Join(" ", + SyntaxParts.Select(x => x.Kind == RunCommandSyntaxPartKind.Profile ? $"| {x.Text}" : x.Text)); /// /// Checks if the text is the command or one of its aliases (case-insensitive). diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs index 898166f108..32230999e3 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs @@ -1,4 +1,6 @@ -namespace NETworkManager.Models.RunCommand; +using System; + +namespace NETworkManager.Models.RunCommand; /// /// Result of . @@ -60,4 +62,39 @@ public sealed class RunCommandParseResult /// Value the error refers to (e.g. the unknown command or argument). /// public string ErrorValue { get; init; } + + /// + /// Part of the syntax that is being typed (highlighted in the hint), or null if there is none (e.g. while typing + /// the command). After the target, the second argument comes first until it is entered (e.g. "[DNS server]"), + /// then the type, unless a complete type keyword is typed (e.g. "aaaa"). + /// + public RunCommandSyntaxPartKind? CurrentSyntaxPart + { + get + { + if (Command == null || Position == RunCommandPosition.Command) + return null; + + if (ProfileName != null) + return RunCommandSyntaxPartKind.Profile; + + switch (Position) + { + case RunCommandPosition.Type: + return RunCommandSyntaxPartKind.Type; + case RunCommandPosition.Target: + return RunCommandSyntaxPartKind.Target; + } + + // A complete type keyword is being typed (e.g. "aaaa"). + if (Type != null && string.Equals(Type, CurrentWord, StringComparison.OrdinalIgnoreCase)) + return RunCommandSyntaxPartKind.Type; + + // The second argument comes before the type (e.g. "[DNS server] [Type]"), until it is entered. + if (Command.Argument != RunCommandArgument.None && (Argument == null || Argument == CurrentWord)) + return RunCommandSyntaxPartKind.Argument; + + return Command.Types.Count > 0 && Type == null ? RunCommandSyntaxPartKind.Type : null; + } + } } diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandSyntaxPart.cs b/Source/NETworkManager.Models/RunCommand/RunCommandSyntaxPart.cs new file mode 100644 index 0000000000..038636b74e --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandSyntaxPart.cs @@ -0,0 +1,35 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// Part of the syntax of a command (e.g. "<Hosts>" or "[DNS server]"), shown in the hint of the run command +/// dialog. +/// +/// What the part is. +/// Name of the value (e.g. "DNS server"), also the label of the parsed value. +/// Text in the hint (e.g. "[DNS server]"). +public sealed record RunCommandSyntaxPart(RunCommandSyntaxPartKind Kind, string Name, string Text) +{ + /// + /// Creates a required part (e.g. "<Hosts>"). + /// + public static RunCommandSyntaxPart Required(RunCommandSyntaxPartKind kind, string name) + { + return new RunCommandSyntaxPart(kind, name, $"<{name}>"); + } + + /// + /// Creates an optional part (e.g. "[DNS server]"). + /// + public static RunCommandSyntaxPart Optional(RunCommandSyntaxPartKind kind, string name) + { + return new RunCommandSyntaxPart(kind, name, $"[{name}]"); + } + + /// + /// Creates the profile part ("@<Profile>"). + /// + public static RunCommandSyntaxPart Profile(string name) + { + return new RunCommandSyntaxPart(RunCommandSyntaxPartKind.Profile, name, $"@<{name}>"); + } +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandSyntaxPartKind.cs b/Source/NETworkManager.Models/RunCommand/RunCommandSyntaxPartKind.cs new file mode 100644 index 0000000000..eb8d90a038 --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandSyntaxPartKind.cs @@ -0,0 +1,12 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// What a part of the syntax of a command is. +/// +public enum RunCommandSyntaxPartKind +{ + Target, + Argument, + Type, + Profile +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs new file mode 100644 index 0000000000..5044abd8c0 --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs @@ -0,0 +1,105 @@ +using System; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; + +namespace NETworkManager.Models.RunCommand; + +/// +/// Detects the kind of a target that is typed without a command, so the commands that accept it can be suggested +/// (e.g. "10.0.0.1" → ping, traceroute, ...). A single word without a domain is +/// , since it can also be a mistyped command. +/// +public static partial class RunCommandTarget +{ + [GeneratedRegex("^https?://", RegexOptions.IgnoreCase)] + private static partial Regex UrlRegex(); + + [GeneratedRegex("^(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}$|^[0-9a-f]{12}$|^(?:[0-9a-f]{4}\\.){2}[0-9a-f]{4}$", + RegexOptions.IgnoreCase)] + private static partial Regex MACAddressRegex(); + + [GeneratedRegex("^(?:[0-9a-f]{2}[:-]){2}[0-9a-f]{2}$", RegexOptions.IgnoreCase)] + private static partial Regex OUIRegex(); + + [GeneratedRegex("^\\d{1,5}(?:-\\d{1,5})?(?:/(?:tcp|udp|sctp))?(?:;\\d{1,5}(?:-\\d{1,5})?(?:/(?:tcp|udp|sctp))?)*$", + RegexOptions.IgnoreCase)] + private static partial Regex PortsRegex(); + + [GeneratedRegex("^as\\d{1,10}$", RegexOptions.IgnoreCase)] + private static partial Regex ASNRegex(); + + [GeneratedRegex("^[0-9.\\-\\[\\],]+$")] + private static partial Regex IPv4RangeRegex(); + + // The last label (top-level domain) has at least one letter, so a shortened or partly typed IPv4 address (e.g. + // "192.168.1") is not a host name. + [GeneratedRegex( + "^(?=.{1,253}\\.?$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+" + + "(?=[a-z0-9-]*[a-z])[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.?$", + RegexOptions.IgnoreCase)] + private static partial Regex HostnameWithDomainRegex(); + + // A label with at least one letter (only digits is not a host name) + [GeneratedRegex("^(?=[a-z0-9-]*[a-z])[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", RegexOptions.IgnoreCase)] + private static partial Regex HostnameRegex(); + + /// + /// Gets the kind of the target. + /// + /// Target without spaces (e.g. "10.0.0.1" or "10.0.0.1;10.0.0.2"). + /// Kind of the target, if it is not clearly a target. + public static RunCommandTargetKind GetKind(string target) + { + if (string.IsNullOrEmpty(target) || target.Any(char.IsWhiteSpace)) + return RunCommandTargetKind.None; + + if (UrlRegex().IsMatch(target)) + return Uri.TryCreate(target, UriKind.Absolute, out _) ? RunCommandTargetKind.URL : RunCommandTargetKind.None; + + if (MACAddressRegex().IsMatch(target)) + return RunCommandTargetKind.MACAddress; + + if (OUIRegex().IsMatch(target)) + return RunCommandTargetKind.OUI; + + if (PortsRegex().IsMatch(target)) + return RunCommandTargetKind.Ports; + + if (ASNRegex().IsMatch(target)) + return RunCommandTargetKind.ASN; + + // Several hosts, IP ranges or subnets + if (target.Contains(';')) + { + var parts = target.Split(';', StringSplitOptions.RemoveEmptyEntries); + + return parts.Length > 0 && parts.All(x => GetKind(x) is RunCommandTargetKind.Host + or RunCommandTargetKind.Hostname or RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet) + ? RunCommandTargetKind.Hosts + : RunCommandTargetKind.None; + } + + // Subnet with CIDR or subnet mask (e.g. 10.0.0.0/24, 10.0.0.0/255.255.255.0, 2001:db8::/32) + var slash = target.IndexOf('/'); + + if (slash > 0) + return IPAddress.TryParse(target[..slash], out _) && + (int.TryParse(target[(slash + 1)..], out _) || IPAddress.TryParse(target[(slash + 1)..], out _)) + ? RunCommandTargetKind.Subnet + : RunCommandTargetKind.None; + + // IPv4 range (e.g. 10.0.0.1-10.0.0.100, 10.0.0.1-100, 10.0.[0-1].1) + if ((target.Contains('-') || target.Contains('[')) && target.Contains('.') && IPv4RangeRegex().IsMatch(target)) + return RunCommandTargetKind.IPRange; + + // IP address: IPv4 with all 4 parts (a shortened or partly typed form like "10.1" is not a target) or IPv6 + if (IPAddress.TryParse(target, out _) && (target.Count(x => x == '.') == 3 || target.Contains(':'))) + return RunCommandTargetKind.Host; + + if (HostnameWithDomainRegex().IsMatch(target)) + return RunCommandTargetKind.Host; + + return HostnameRegex().IsMatch(target) ? RunCommandTargetKind.Hostname : RunCommandTargetKind.None; + } +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandTargetKind.cs b/Source/NETworkManager.Models/RunCommand/RunCommandTargetKind.cs new file mode 100644 index 0000000000..925b8098f4 --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandTargetKind.cs @@ -0,0 +1,63 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// Kind of a target that is typed without a command (e.g. "10.0.0.1"), see . +/// +public enum RunCommandTargetKind +{ + /// + /// Not a target. + /// + None, + + /// + /// IP address or host name with a domain (e.g. "10.0.0.1", "server-01.borntoberoot.net"). + /// + Host, + + /// + /// Host name without a domain (e.g. "server-01" in a company network). It can also be a mistyped command, so the + /// commands are only suggested if no command starts with it, and not preselected. + /// + Hostname, + + /// + /// Several hosts separated by ";". + /// + Hosts, + + /// + /// IPv4 range (e.g. "10.0.0.1-10.0.0.100", "10.0.[0-1].1"). + /// + IPRange, + + /// + /// Subnet (e.g. "10.0.0.0/24"). + /// + Subnet, + + /// + /// MAC address (e.g. "00:11:22:33:44:55"). + /// + MACAddress, + + /// + /// First 3 bytes of a MAC address (e.g. "00:11:22"). + /// + OUI, + + /// + /// Port(s) or port range(s), optional with protocol (e.g. "22", "80-90/tcp"). + /// + Ports, + + /// + /// AS number (e.g. "AS13335"). + /// + ASN, + + /// + /// HTTP or HTTPS URL. + /// + URL +} diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index ff6f855ed0..d5b0ddffe9 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -89,6 +89,9 @@ public static class GlobalStaticConfiguration public static int HotKey_RunCommandKey => 80; public static int HotKey_RunCommandModifier => 3; + // Settings: Run command + public static bool RunCommand_ShowKeyHints => true; + // Settings: Update public static bool Update_CheckForUpdatesAtStartup => true; public static bool Update_CheckForPreReleases => false; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index bfd75ffe93..80cd9f651e 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -863,6 +863,32 @@ public ObservableCollection RunCommand_History } } = []; + public bool RunCommand_SuggestClipboard + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public bool RunCommand_ShowKeyHints + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.RunCommand_ShowKeyHints; + #endregion #region Dashboard diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs index 212fc6de7a..401d52c4b7 100644 --- a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs +++ b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs @@ -22,7 +22,14 @@ public class RunCommandParserTests { Type = RunCommandType.Application, Application = ApplicationName.DNSLookup, Command = "dnslookup", Aliases = ["dns", "nslookup"], SupportsTarget = true, SupportsProfile = true, - Argument = RunCommandArgument.DNSServer, Types = ["a", "aaaa", "cname", "ptr"] + Argument = RunCommandArgument.DNSServer, Types = ["a", "aaaa", "cname", "ptr"], + SyntaxParts = + [ + RunCommandSyntaxPart.Required(RunCommandSyntaxPartKind.Target, "Hosts"), + RunCommandSyntaxPart.Optional(RunCommandSyntaxPartKind.Argument, "DNS server"), + RunCommandSyntaxPart.Optional(RunCommandSyntaxPartKind.Type, "Type"), + RunCommandSyntaxPart.Profile("Profile") + ] }, new() { @@ -133,6 +140,18 @@ public void DNSServerAndTypeInAnyOrder(string input) Assert.Equal(RunCommandParseError.None, result.Error); } + // A configured DNS server (by name) after the type, e.g. for a reverse lookup + [Fact] + public void DNSServerNameAfterType() + { + var result = Parse("dns 1.1.1.1 PTR Cloudflare"); + + Assert.Equal("1.1.1.1", result.Target); + Assert.Equal("ptr", result.Type); + Assert.Equal("Cloudflare", result.Argument); + Assert.Equal(RunCommandParseError.None, result.Error); + } + [Theory] [InlineData("dns borntoberoot.net \"Google Public DNS\" aaaa")] [InlineData("dns borntoberoot.net aaaa \"Google Public DNS\"")] @@ -386,4 +405,35 @@ public void TypeBeforeTargetPositionForSuggestions(string input, RunCommandPosit Assert.Equal(currentWord, result.CurrentWord); Assert.Equal(before, result.InputBeforeCurrentWord); } + + [Fact] + public void Syntax() + { + var result = Parse("dns "); + + Assert.Equal(" [DNS server] [Type] | @", result.Command.Syntax); + } + + [Theory] + [InlineData("dns", null)] + [InlineData("dns ", RunCommandSyntaxPartKind.Target)] + [InlineData("dns border", RunCommandSyntaxPartKind.Target)] + [InlineData("dns @web", RunCommandSyntaxPartKind.Profile)] + [InlineData("dns borntoberoot.net ", RunCommandSyntaxPartKind.Argument)] + [InlineData("dns borntoberoot.net 1.1", RunCommandSyntaxPartKind.Argument)] + // The DNS server comes before the type, unless a complete type is typed. + [InlineData("dns borntoberoot.net aa", RunCommandSyntaxPartKind.Argument)] + [InlineData("dns borntoberoot.net c", RunCommandSyntaxPartKind.Argument)] + [InlineData("dns borntoberoot.net aaaa", RunCommandSyntaxPartKind.Type)] + [InlineData("dns borntoberoot.net 1.1.1.1 c", RunCommandSyntaxPartKind.Type)] + [InlineData("dns borntoberoot.net 1.1.1.1 ", RunCommandSyntaxPartKind.Type)] + [InlineData("dns borntoberoot.net aaaa ", RunCommandSyntaxPartKind.Argument)] + [InlineData("dns borntoberoot.net 1.1.1.1 aaaa ", null)] + [InlineData("lookup ", RunCommandSyntaxPartKind.Type)] + [InlineData("lookup port 2", RunCommandSyntaxPartKind.Target)] + [InlineData("portscan 10.0.0.1 ", RunCommandSyntaxPartKind.Argument)] + public void CurrentSyntaxPart(string input, RunCommandSyntaxPartKind? part) + { + Assert.Equal(part, Parse(input).CurrentSyntaxPart); + } } diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs new file mode 100644 index 0000000000..dd8170607e --- /dev/null +++ b/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs @@ -0,0 +1,74 @@ +using NETworkManager.Models.RunCommand; +using Xunit; + +namespace NETworkManager.Tests.RunCommand; + +public class RunCommandTargetTests +{ + [Theory] + [InlineData("10.0.0.1", RunCommandTargetKind.Host)] + [InlineData("2001:db8::1", RunCommandTargetKind.Host)] + [InlineData("server-01.borntoberoot.net", RunCommandTargetKind.Host)] + [InlineData("borntoberoot.net", RunCommandTargetKind.Host)] + [InlineData("10.0.0.1;10.0.0.2", RunCommandTargetKind.Hosts)] + [InlineData("borntoberoot.net;10.0.0.0/24", RunCommandTargetKind.Hosts)] + [InlineData("10.0.0.1-10.0.0.100", RunCommandTargetKind.IPRange)] + [InlineData("10.0.0.1-100", RunCommandTargetKind.IPRange)] + [InlineData("10.0.[0-1].1", RunCommandTargetKind.IPRange)] + [InlineData("10.0.0.0/24", RunCommandTargetKind.Subnet)] + [InlineData("10.0.0.0/255.255.255.0", RunCommandTargetKind.Subnet)] + [InlineData("2001:db8::/32", RunCommandTargetKind.Subnet)] + [InlineData("00:11:22:33:44:55", RunCommandTargetKind.MACAddress)] + [InlineData("00-11-22-33-44-55", RunCommandTargetKind.MACAddress)] + [InlineData("001122334455", RunCommandTargetKind.MACAddress)] + [InlineData("0011.2233.4455", RunCommandTargetKind.MACAddress)] + [InlineData("00:11:22", RunCommandTargetKind.OUI)] + [InlineData("22", RunCommandTargetKind.Ports)] + [InlineData("22;80;443", RunCommandTargetKind.Ports)] + [InlineData("80-90/tcp", RunCommandTargetKind.Ports)] + [InlineData("AS13335", RunCommandTargetKind.ASN)] + [InlineData("https://borntoberoot.net", RunCommandTargetKind.URL)] + public void Kind(string target, RunCommandTargetKind kind) + { + Assert.Equal(kind, RunCommandTarget.GetKind(target)); + } + + // A host name without a domain (e.g. in a company network), which can also be a mistyped command (e.g. "pnig") + [Theory] + [InlineData("server01")] + [InlineData("server-01")] + [InlineData("pnig")] + [InlineData("web")] + [InlineData("facade")] + public void Hostname(string target) + { + Assert.Equal(RunCommandTargetKind.Hostname, RunCommandTarget.GetKind(target)); + } + + [Theory] + [InlineData("server01;server02")] + [InlineData("10.0.0.1;server01")] + public void HostsWithHostnames(string target) + { + Assert.Equal(RunCommandTargetKind.Hosts, RunCommandTarget.GetKind(target)); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData("10.0.0.1 10.0.0.2")] + [InlineData("server/24")] + [InlineData("123456")] + [InlineData("-server")] + [InlineData("server_01")] + [InlineData("@server")] + [InlineData("10.0.0.1;server_01")] + // A shortened or partly typed IPv4 address + [InlineData("10.1")] + [InlineData("192.168.1")] + [InlineData("192.168.1.")] + public void NotATarget(string target) + { + Assert.Equal(RunCommandTargetKind.None, RunCommandTarget.GetKind(target)); + } +} diff --git a/Source/NETworkManager.Utilities.WPF/TextBlockHighlightHelper.cs b/Source/NETworkManager.Utilities.WPF/TextBlockHighlightHelper.cs new file mode 100644 index 0000000000..ea5e8bb94b --- /dev/null +++ b/Source/NETworkManager.Utilities.WPF/TextBlockHighlightHelper.cs @@ -0,0 +1,79 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; + +namespace NETworkManager.Utilities.WPF; + +/// +/// Attached-property helper to show a text in a with the first occurrence of a search text +/// highlighted (case-insensitive), e.g. the typed text in a suggestion list. +/// Use instead of . +/// +public static class TextBlockHighlightHelper +{ + /// + /// Resource key of the brush of the highlighted text. + /// + private const string HighlightBrushKey = "MahApps.Brushes.Accent"; + + /// + /// Text to show. + /// + public static readonly DependencyProperty TextProperty = DependencyProperty.RegisterAttached( + "Text", typeof(string), typeof(TextBlockHighlightHelper), new PropertyMetadata(null, OnChanged)); + + /// + /// Text to highlight. Nothing is highlighted if it is empty or not found. + /// + public static readonly DependencyProperty HighlightProperty = DependencyProperty.RegisterAttached( + "Highlight", typeof(string), typeof(TextBlockHighlightHelper), new PropertyMetadata(null, OnChanged)); + + public static string GetText(DependencyObject element) + { + return (string)element.GetValue(TextProperty); + } + + public static void SetText(DependencyObject element, string value) + { + element.SetValue(TextProperty, value); + } + + public static string GetHighlight(DependencyObject element) + { + return (string)element.GetValue(HighlightProperty); + } + + public static void SetHighlight(DependencyObject element, string value) + { + element.SetValue(HighlightProperty, value); + } + + private static void OnChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not TextBlock textBlock) + return; + + var text = GetText(textBlock) ?? string.Empty; + var highlight = GetHighlight(textBlock); + + textBlock.Inlines.Clear(); + + var index = string.IsNullOrEmpty(highlight) + ? -1 + : text.IndexOf(highlight, StringComparison.OrdinalIgnoreCase); + + if (index < 0) + { + textBlock.Inlines.Add(new Run(text)); + return; + } + + var match = new Run(text.Substring(index, highlight.Length)) { FontWeight = FontWeights.SemiBold }; + match.SetResourceReference(TextElement.ForegroundProperty, HighlightBrushKey); + + textBlock.Inlines.Add(new Run(text[..index])); + textBlock.Inlines.Add(match); + textBlock.Inlines.Add(new Run(text[(index + highlight.Length)..])); + } +} diff --git a/Source/NETworkManager/ApplicationRequest.cs b/Source/NETworkManager/ApplicationRequest.cs index e074b4d0a9..ad2953dbcf 100644 --- a/Source/NETworkManager/ApplicationRequest.cs +++ b/Source/NETworkManager/ApplicationRequest.cs @@ -23,6 +23,13 @@ public record ApplicationRequest(ApplicationName Application) /// Profile to run in the application with its settings. is ignored if set. /// public ProfileInfo Profile { get; init; } + + /// + /// Starts the application with the target or profile (e.g. the scan). If false, the input is only filled in, so + /// the options can be adjusted before starting. Remote tools always open the connect dialog (target) or connect + /// (profile). + /// + public bool Start { get; init; } = true; } /// diff --git a/Source/NETworkManager/Controls/TitleBarPanel.cs b/Source/NETworkManager/Controls/TitleBarPanel.cs new file mode 100644 index 0000000000..95ae742111 --- /dev/null +++ b/Source/NETworkManager/Controls/TitleBarPanel.cs @@ -0,0 +1,176 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; + +namespace NETworkManager.Controls; + +/// +/// Panel for the title of a window: the first child (title) on the left, the second child (e.g. the run command) +/// centered in the window (not only in the title area between the window commands, like the command center of VS +/// Code). If there is not enough space in the center (e.g. wide window commands on the right), the second child is +/// moved only as far as needed and only made smaller (e.g. text is trimmed) if the free space is smaller than its +/// , so it doesn't overlap the title or the end of the panel. +/// +public class TitleBarPanel : Panel +{ + /// + /// Minimum space between the title, the centered child and the end of the panel. + /// + private const double Spacing = 10; + + /// + /// Position of the panel in the window and width of the window at the last measure. + /// + private double _measuredOffset = double.NaN; + + private double _measuredWindowWidth = double.NaN; + + private Window _window; + + public TitleBarPanel() + { + // The position in the window and the width of the window are only known after the layout, so the children are + // measured again if they changed: after the first layout, when the window is resized (e.g. maximized) and when + // the panel is resized (e.g. the window commands change). Not on every layout pass (LayoutUpdated), which would + // also run while e.g. scan results are added. + Loaded += (_, _) => + { + _window = Window.GetWindow(this); + + if (_window != null) + _window.SizeChanged += Window_SizeChanged; + + Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(MeasureIfMoved)); + }; + + Unloaded += (_, _) => + { + if (_window != null) + _window.SizeChanged -= Window_SizeChanged; + + _window = null; + }; + + SizeChanged += (_, _) => MeasureIfMoved(); + } + + private void Window_SizeChanged(object sender, SizeChangedEventArgs e) + { + MeasureIfMoved(); + } + + /// + /// Measures the children again if the position of the panel in the window or the width of the window changed + /// since the last measure. + /// + private void MeasureIfMoved() + { + var offset = GetOffsetInWindow(); + var windowWidth = Window.GetWindow(this)?.ActualWidth ?? double.NaN; + + if (double.IsNaN(offset) || double.IsNaN(windowWidth)) + return; + + // "!(... <= 0.5)" is also true if it was not measured yet (NaN). + if (!(Math.Abs(offset - _measuredOffset) <= 0.5) || !(Math.Abs(windowWidth - _measuredWindowWidth) <= 0.5)) + InvalidateMeasure(); + } + + protected override Size MeasureOverride(Size availableSize) + { + var width = double.IsInfinity(availableSize.Width) ? 0 : availableSize.Width; + var height = 0.0; + + _measuredOffset = GetOffsetInWindow(); + _measuredWindowWidth = Window.GetWindow(this)?.ActualWidth ?? double.NaN; + + if (InternalChildren.Count > 0) + { + var title = InternalChildren[0]; + + title.Measure(availableSize); + height = title.DesiredSize.Height; + + if (InternalChildren.Count > 1) + { + var center = InternalChildren[1]; + var (_, centerWidth) = GetCenterLayout(width, title.DesiredSize.Width, GetPreferredWidth(center)); + + center.Measure(new Size(centerWidth, availableSize.Height)); + height = Math.Max(height, center.DesiredSize.Height); + } + } + + // Use the full width (the title area of the window). + return new Size(width, height); + } + + protected override Size ArrangeOverride(Size finalSize) + { + if (InternalChildren.Count == 0) + return finalSize; + + var title = InternalChildren[0]; + var titleWidth = Math.Min(title.DesiredSize.Width, finalSize.Width); + + title.Arrange(new Rect(0, 0, titleWidth, finalSize.Height)); + + if (InternalChildren.Count < 2) + return finalSize; + + var center = InternalChildren[1]; + var (x, width) = GetCenterLayout(finalSize.Width, titleWidth, GetPreferredWidth(center)); + + center.Arrange(new Rect(x, 0, width, finalSize.Height)); + + return finalSize; + } + + /// + /// Gets the position and width of the centered child. + /// + /// Width of the panel. + /// Width of the title. + /// Width of the child if there is enough space. + private (double X, double Width) GetCenterLayout(double panelWidth, double titleWidth, double preferredWidth) + { + // Center of the window in the coordinates of the panel (center of the panel until the window is measured) + var windowCenter = double.IsNaN(_measuredOffset) || !(_measuredWindowWidth > 0) + ? panelWidth / 2 + : _measuredWindowWidth / 2 - _measuredOffset; + + // Free space between the title and the end of the panel + var left = titleWidth + Spacing; + var right = panelWidth - Spacing; + + // Only smaller than preferred if the free space is smaller, then as close to the center as possible. + var width = Math.Max(0, Math.Min(preferredWidth, right - left)); + var x = Math.Clamp(windowCenter - width / 2, left, Math.Max(left, right - width)); + + return (x, width); + } + + /// + /// Gets the width of the child if there is enough space: its maximum width, or the width of its content. + /// + private static double GetPreferredWidth(UIElement child) + { + if (child is FrameworkElement { MaxWidth: var maxWidth } && !double.IsInfinity(maxWidth)) + return maxWidth; + + child.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + return child.DesiredSize.Width; + } + + /// + /// Gets the horizontal position of the panel in its window, NaN if it is not in a window. + /// + private double GetOffsetInWindow() + { + var window = Window.GetWindow(this); + + return window != null && window.IsAncestorOf(this) ? TranslatePoint(new Point(0, 0), window).X : double.NaN; + } +} diff --git a/Source/NETworkManager/MainWindow.xaml b/Source/NETworkManager/MainWindow.xaml index a41009ad5a..d1aa88e1f2 100644 --- a/Source/NETworkManager/MainWindow.xaml +++ b/Source/NETworkManager/MainWindow.xaml @@ -7,6 +7,8 @@ xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls" xmlns:dd="clr-namespace:GongSolutions.Wpf.DragDrop;assembly=GongSolutions.Wpf.DragDrop" xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" + xmlns:shell="clr-namespace:ControlzEx.Windows.Shell;assembly=ControlzEx" + xmlns:controls="clr-namespace:NETworkManager.Controls" xmlns:interactivity="http://schemas.microsoft.com/xaml/behaviors" xmlns:asf="clr-namespace:AirspaceFixer;assembly=AirspaceFixer" xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" @@ -18,7 +20,7 @@ Style="{DynamicResource ResourceKey=DefaultWindow}" MinWidth="800" MinHeight="600" Width="1024" Height="768" - SaveWindowPosition="True" TitleAlignment="Left" + SaveWindowPosition="True" TitleAlignment="Stretch" ContentRendered="MetroMainWindow_ContentRendered" StateChanged="MetroWindowMain_StateChanged" Activated="MetroMainWindow_Activated" Closing="MetroWindowMain_Closing" d:DataContext="{d:DesignInstance networkManager:MainWindow}"> @@ -89,19 +91,6 @@ - + + + diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs index 3db37be8d6..1e6801e565 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -1100,7 +1100,7 @@ private void OpenRunAction() if (ConfigurationManager.Current.IsChildWindowOpen) return; - var childWindow = new RunCommandChildWindow(); + var childWindow = new RunCommandChildWindow(this); void CloseDialog() { @@ -1112,7 +1112,9 @@ void CloseDialog() var childWindowViewModel = new RunCommandViewModel(instance => { - CloseDialog(); + // Ctrl+Enter: the dialog stays open to run another command. + if (!instance.KeepOpen) + CloseDialog(); if (instance.IsSettings) NavigationManager.OpenSettings(); diff --git a/Source/NETworkManager/RunCommandHint.cs b/Source/NETworkManager/RunCommandHint.cs new file mode 100644 index 0000000000..c33659b01e --- /dev/null +++ b/Source/NETworkManager/RunCommandHint.cs @@ -0,0 +1,15 @@ +namespace NETworkManager; + +/// +/// Part of the syntax hint in the run command dialog (e.g. "[DNS server]"). +/// +/// Text of the part. +/// The part is being typed (highlighted). +public sealed record RunCommandHintPart(string Text, bool IsCurrent); + +/// +/// Value of the input as it was understood (e.g. "DNS server: 1.1.1.1"), shown below the input. +/// +/// Name of the value (e.g. "DNS server"). +/// Value (e.g. "1.1.1.1"). +public sealed record RunCommandChip(string Label, string Value); diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs index c0ff24c92a..019428ed6b 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -14,7 +14,6 @@ using System.Globalization; using System.Linq; using System.Net; -using System.Net.Sockets; using System.Windows.Controls; namespace NETworkManager; @@ -46,6 +45,54 @@ public static class RunCommandManager ["port"] = LookupType.Port }; + /// + /// Applications that are suggested for a target typed without a command, in the order they are suggested (if + /// they were not used with this kind of target before). + /// + private static readonly Dictionary TargetApplications = new() + { + [RunCommandTargetKind.Host] = + [ + ApplicationName.PingMonitor, ApplicationName.Traceroute, ApplicationName.DNSLookup, + ApplicationName.PortScanner, ApplicationName.RemoteDesktop, ApplicationName.PuTTY, + ApplicationName.PowerShell, ApplicationName.TigerVNC, ApplicationName.IPGeolocation, ApplicationName.Whois, + ApplicationName.RDAP, ApplicationName.SNMP, ApplicationName.IPScanner + ], + // Without a domain (e.g. in a company network): no tools for public hosts (Whois, RDAP, IP Geolocation) + [RunCommandTargetKind.Hostname] = + [ + ApplicationName.PingMonitor, ApplicationName.Traceroute, ApplicationName.DNSLookup, + ApplicationName.PortScanner, ApplicationName.RemoteDesktop, ApplicationName.PuTTY, + ApplicationName.PowerShell, ApplicationName.TigerVNC, ApplicationName.SNMP, ApplicationName.IPScanner + ], + [RunCommandTargetKind.Hosts] = + [ + ApplicationName.PingMonitor, ApplicationName.DNSLookup, ApplicationName.PortScanner, + ApplicationName.IPScanner + ], + [RunCommandTargetKind.IPRange] = + [ApplicationName.IPScanner, ApplicationName.PortScanner, ApplicationName.PingMonitor], + [RunCommandTargetKind.Subnet] = + [ + ApplicationName.IPScanner, ApplicationName.SubnetCalculator, ApplicationName.PortScanner, + ApplicationName.PingMonitor, ApplicationName.RDAP + ], + [RunCommandTargetKind.MACAddress] = [ApplicationName.WakeOnLAN, ApplicationName.Lookup], + [RunCommandTargetKind.OUI] = [ApplicationName.Lookup], + [RunCommandTargetKind.Ports] = [ApplicationName.Lookup], + [RunCommandTargetKind.ASN] = [ApplicationName.RDAP], + [RunCommandTargetKind.URL] = [ApplicationName.WebConsole] + }; + + /// + /// Remote tools, which open the connect dialog for a target (instead of connecting directly). + /// + private static readonly HashSet ConnectDialogApplications = + [ + ApplicationName.RemoteDesktop, ApplicationName.PowerShell, ApplicationName.PuTTY, ApplicationName.TigerVNC, + ApplicationName.WebConsole + ]; + /// /// Gets the commands of the visible applications and the settings. /// @@ -95,6 +142,59 @@ public static IEnumerable GetProfiles(RunCommandDefinition command) .OrderBy(x => x.Group).ThenBy(x => x.Name); } + /// + /// Gets the commands that accept a target typed without a command (e.g. "ping 10.0.0.1", "trace 10.0.0.1" for + /// "10.0.0.1"). Only valid commands are returned (validated like the input). + /// + /// Target (e.g. "10.0.0.1"). + /// Available commands. + /// Commands with the input to run, in the default order of the kind of the target. + public static IEnumerable<(RunCommandDefinition Command, string Text)> GetCommandsForTarget(string target, + IReadOnlyList commands) + { + var kind = RunCommandTarget.GetKind(target); + + if (!TargetApplications.TryGetValue(kind, out var applications)) + yield break; + + foreach (var application in applications) + { + var command = commands.FirstOrDefault(x => x.Application == application); + + // Hidden application + if (command == null) + continue; + + // The shortest name (e.g. "ping" instead of "pingmonitor") + var name = command.Aliases.FirstOrDefault() ?? command.Command; + + var text = application == ApplicationName.Lookup + ? $"{name} {(kind == RunCommandTargetKind.Ports ? "port" : "oui")} {target}" + : $"{name} {target}"; + + if (TryCreateRequest(RunCommandParser.Parse(text, commands), null, out _) == null) + yield return (command, text); + } + } + + /// + /// Checks if the request opens (or can open) a dialog, so the run command dialog must be closed: the settings, + /// the connect dialog of a remote tool, or the Subnetting, which asks for confirmation for a large calculation + /// and shows an error for an invalid subnet mask. + /// + /// Request, or null for the settings. + /// True if the request opens a dialog. + public static bool OpensDialog(ApplicationRequest request) + { + return request switch + { + null => true, + SubnetCalculatorRequest { NewSubnetmask: not null } => true, + _ => request.Profile == null && !string.IsNullOrEmpty(request.Target) && + ConnectDialogApplications.Contains(request.Application) + }; + } + /// /// Validates the input and creates the request to run. /// @@ -270,14 +370,12 @@ private static string ValidateSubnetArgument(string subnet, string argument) if (!IsSubnetmaskOrCidr(argument)) return Validate(new IPv4SubnetValidator(), subnet) ?? Validate(new IPv4SubnetValidator(), argument); - var ipNetwork = IPNetwork2.Parse(subnet.Trim()); - var maxCidr = ipNetwork.AddressFamily == AddressFamily.InterNetwork ? 32 : 128; - + var (minCidr, maxCidr) = Subnetmask.GetSubnettingCidrRange(IPNetwork2.Parse(subnet.Trim())); var newCidr = Subnetmask.ConvertSubnetmaskOrCidrToCidr(argument); - return newCidr >= ipNetwork.Cidr && newCidr <= maxCidr + return newCidr >= minCidr && newCidr <= maxCidr ? null - : string.Format(Strings.EnterSubnetmaskOrCIDRBetweenXAndY, ipNetwork.Cidr, maxCidr); + : string.Format(Strings.EnterSubnetmaskOrCIDRBetweenXAndY, minCidr, maxCidr); } private static bool IsSubnetmaskOrCidr(string value) @@ -334,41 +432,50 @@ private static RDAPQueryType GetRDAPQueryType(string type) /// private static List CreateCommands() { - // Syntax of the arguments, e.g. " [Ports]" - static string Required(string name) => $"<{name}>"; - static string Optional(string name) => $"[{name}]"; + // Parts of the syntax, e.g. " [Ports]" + static RunCommandSyntaxPart Target(string name) => + RunCommandSyntaxPart.Required(RunCommandSyntaxPartKind.Target, name); + + static RunCommandSyntaxPart Argument(string name) => + RunCommandSyntaxPart.Optional(RunCommandSyntaxPartKind.Argument, name); + + var type = RunCommandSyntaxPart.Optional(RunCommandSyntaxPartKind.Type, Strings.Type); var commands = ApplicationManager.GetNames().Where(x => x != ApplicationName.None).Select(name => name switch { - ApplicationName.IPScanner => Create(name, ["scan"], Required(Strings.IPRange), true), - ApplicationName.PortScanner => Create(name, ["portscan"], - $"{Required(Strings.Hosts)} {Optional(Strings.Ports)}", true, RunCommandArgument.Ports), - ApplicationName.PingMonitor => Create(name, ["ping"], Required(Strings.Hosts), true), - ApplicationName.Traceroute => Create(name, ["trace", "tracert"], Required(Strings.Host), true), + ApplicationName.IPScanner => Create(name, ["ipscan"], [Target(Strings.IPRange)]), + ApplicationName.PortScanner => Create(name, ["portscan"], [Target(Strings.Hosts), Argument(Strings.Ports)], + RunCommandArgument.Ports), + ApplicationName.PingMonitor => Create(name, ["ping"], [Target(Strings.Hosts)]), + ApplicationName.Traceroute => Create(name, ["trace", "tracert"], [Target(Strings.Host)]), ApplicationName.DNSLookup => Create(name, ["dns", "nslookup"], - $"{Required(Strings.Hosts)} {Optional(Strings.DNSServer)} {Optional(Strings.Type)}", true, - RunCommandArgument.DNSServer, + [Target(Strings.Hosts), Argument(Strings.DNSServer), type], RunCommandArgument.DNSServer, [.. DNSLookup.QueryTypes.Select(x => x.ToString().ToLowerInvariant()).Order()]), ApplicationName.RemoteDesktop => Create(name, ["rdp", "mstsc"], - $"{Required(Strings.Host)}[:{Required(Strings.Port)}]", true), - ApplicationName.PowerShell => Create(name, ["ps", "pwsh"], Required(Strings.Host), true), - ApplicationName.PuTTY => Create(name, ["ssh"], Required(Strings.Host), true), - ApplicationName.TigerVNC => Create(name, ["vnc"], Required(Strings.Host), true), - ApplicationName.WebConsole => Create(name, ["web"], Required(Strings.URL), true), - ApplicationName.SNMP => Create(name, [], Required(Strings.Host), true), - ApplicationName.RDAP => Create(name, [], $"{Required(Strings.Query)} {Optional(Strings.Type)}", true, - types: [.. RDAPTypes.Keys]), - ApplicationName.Whois => Create(name, [], Required(Strings.Domain), true), - ApplicationName.IPGeolocation => Create(name, ["geo"], Required(Strings.Host), true), - ApplicationName.WakeOnLAN => Create(name, ["wol"], - $"{Required(Strings.MACAddress)} {Optional(Strings.Broadcast)}", true, RunCommandArgument.Broadcast), + [ + new RunCommandSyntaxPart(RunCommandSyntaxPartKind.Target, Strings.Host, + $"<{Strings.Host}>[:<{Strings.Port}>]") + ]), + ApplicationName.PowerShell => Create(name, ["ps", "pwsh"], [Target(Strings.Host)]), + ApplicationName.PuTTY => Create(name, ["ssh"], [Target(Strings.Host)]), + ApplicationName.TigerVNC => Create(name, ["vnc"], [Target(Strings.Host)]), + ApplicationName.WebConsole => Create(name, ["web"], [Target(Strings.URL)]), + ApplicationName.SNMP => Create(name, [], [Target(Strings.Host)]), + ApplicationName.RDAP => Create(name, [], [Target(Strings.Query), type], types: [.. RDAPTypes.Keys]), + ApplicationName.Whois => Create(name, [], [Target(Strings.Domain)]), + ApplicationName.IPGeolocation => Create(name, ["geo"], [Target(Strings.Host)]), + ApplicationName.WakeOnLAN => Create(name, ["wol"], [Target(Strings.MACAddress), Argument(Strings.Broadcast)], + RunCommandArgument.Broadcast), ApplicationName.SubnetCalculator => Create(name, ["subnet", "ipcalc"], - $"{Required(Strings.Subnet)} {Optional($"{Strings.NewSubnetMask} | {Strings.Subnet2}")}", true, + [Target(Strings.Subnet), Argument($"{Strings.NewSubnetMask} | {Strings.Subnet2}")], RunCommandArgument.Subnet, supportsProfile: false), ApplicationName.Lookup => Create(name, [], - $"{Required(string.Join("|", LookupTypes.Keys))} {Required(Strings.Query)}", true, - types: [.. LookupTypes.Keys], supportsProfile: false, typeBeforeTarget: true), - _ => Create(name, [], string.Empty, false) + [ + new RunCommandSyntaxPart(RunCommandSyntaxPartKind.Type, Strings.Type, + $"<{string.Join("|", LookupTypes.Keys)}>"), + Target(Strings.Query) + ], types: [.. LookupTypes.Keys], supportsProfile: false, typeBeforeTarget: true), + _ => Create(name, [], null) }).ToList(); commands.Add(new RunCommandDefinition @@ -381,10 +488,22 @@ [.. DNSLookup.QueryTypes.Select(x => x.ToString().ToLowerInvariant()).Order()]), return commands; } - private static RunCommandDefinition Create(ApplicationName application, string[] aliases, string syntax, - bool supportsTarget, RunCommandArgument argument = RunCommandArgument.None, string[] types = null, - bool supportsProfile = true, bool typeBeforeTarget = false) + /// + /// Creates the command of an application. + /// + /// Application. + /// Short forms of the command. + /// Syntax of the arguments, or null if the command accepts no target. + /// Second argument after the target. + /// Type keywords. + /// The application has profiles (only with a target). + /// The type keyword comes before the target. + private static RunCommandDefinition Create(ApplicationName application, string[] aliases, + RunCommandSyntaxPart[] syntaxParts, RunCommandArgument argument = RunCommandArgument.None, + string[] types = null, bool supportsProfile = true, bool typeBeforeTarget = false) { + var supportsTarget = syntaxParts != null; + // Applications that accept a target support profiles, unless they have none (e.g. Subnet Calculator). supportsProfile &= supportsTarget; @@ -399,7 +518,9 @@ private static RunCommandDefinition Create(ApplicationName application, string[] Argument = argument, Types = types ?? [], TypeBeforeTarget = typeBeforeTarget, - Syntax = supportsProfile ? $"{syntax} | @<{Strings.Profile}>" : syntax + SyntaxParts = supportsProfile + ? [.. syntaxParts, RunCommandSyntaxPart.Profile(Strings.Profile)] + : syntaxParts ?? [] }; } } diff --git a/Source/NETworkManager/RunCommandRunMode.cs b/Source/NETworkManager/RunCommandRunMode.cs new file mode 100644 index 0000000000..9254a23c10 --- /dev/null +++ b/Source/NETworkManager/RunCommandRunMode.cs @@ -0,0 +1,22 @@ +namespace NETworkManager; + +/// +/// How the run command dialog runs a command. +/// +public enum RunCommandRunMode +{ + /// + /// Run the command and close the dialog (Enter). + /// + Run, + + /// + /// Run the command and keep the dialog open to run another one (Ctrl+Enter), unless the command opens a dialog. + /// + RunAndKeepOpen, + + /// + /// Open the application with the input filled in, without starting it (Shift+Enter). + /// + FillIn +} diff --git a/Source/NETworkManager/RunCommandSuggestion.cs b/Source/NETworkManager/RunCommandSuggestion.cs index cd2d32bd85..0f53145fb1 100644 --- a/Source/NETworkManager/RunCommandSuggestion.cs +++ b/Source/NETworkManager/RunCommandSuggestion.cs @@ -8,11 +8,6 @@ namespace NETworkManager; /// public enum RunCommandSuggestionKind { - /// - /// Run the input as typed. - /// - Run, - /// /// Command of an application (or the settings). /// @@ -36,7 +31,22 @@ public enum RunCommandSuggestionKind /// /// DNS server configured in the DNS lookup. /// - DNSServer + DNSServer, + + /// + /// Command for a target typed without a command (e.g. "ping 10.0.0.1" for "10.0.0.1"). + /// + Target, + + /// + /// Command for the target in the clipboard (e.g. "ping 10.0.0.1"). + /// + Clipboard, + + /// + /// Example command (empty input without history). + /// + Example } /// @@ -53,6 +63,16 @@ public sealed class RunCommandSuggestion public string Title { get; init; } + /// + /// Typed text that is highlighted in the title and description. + /// + public string Highlight { get; init; } + + /// + /// Header of the group in the list (e.g. "Recent"), null for no header. + /// + public string Group { get; init; } + /// /// Second line (e.g. group and host of a profile). /// diff --git a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs index 1fecad4366..1d95cb4978 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs @@ -157,15 +157,16 @@ private static void CloseItemAction(ItemActionCallbackArgs args) /// Query type. If null, PTR is used for IP addresses (e.g. from the IP Scanner) and the selected query type for /// hosts. /// - public void AddTab(string host = null, string dnsServer = null, QueryType? queryType = null) + /// 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) { if (queryType == null && IsIPAddresses(host)) queryType = QueryType.PTR; var tabId = Guid.NewGuid(); - TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab, new DNSLookupView(tabId, host, dnsServer, queryType), - tabId)); + TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab, + new DNSLookupView(tabId, host, dnsServer, queryType, start), tabId)); SelectedTabIndex = TabItems.Count - 1; } @@ -185,9 +186,10 @@ private static bool IsIPAddresses(string hosts) /// Adds a new tab for the host of the specified profile. /// /// The profile to lookup. - public void AddTab(ProfileInfo profile) + /// 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); + AddTab(profile.DNSLookup_Host, start: start); } #endregion diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs index ac2b0d9122..5233973d29 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs @@ -250,8 +250,12 @@ private set /// The host to lookup. /// 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. - public DNSLookupViewModel(Guid tabId, string host, string dnsServer = null, QueryType? queryType = null) + /// Starts it on the first load. If false, the input is only filled in. + public DNSLookupViewModel(Guid tabId, string host, string dnsServer = null, QueryType? queryType = null, + bool start = true) { + _firstLoad = start; + _isLoading = true; ConfigurationManager.Current.DNSLookupTabCount++; diff --git a/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs b/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs index 3b1e44685a..cae5201d56 100644 --- a/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs @@ -147,12 +147,13 @@ private static void CloseItemAction(ItemActionCallbackArgs args) /// Adds a new tab for the specified domain. /// /// The domain to query. - public void AddTab(string domain = null) + /// Starts it on the first load. If false, the input is only filled in. + public void AddTab(string domain = null, bool start = true) { var tabId = Guid.NewGuid(); TabItems.Add(new DragablzTabItem(domain ?? Strings.NewTab, - new IPGeolocationView(tabId, domain), tabId)); + new IPGeolocationView(tabId, domain, start), tabId)); SelectedTabIndex = TabItems.Count - 1; } @@ -161,9 +162,10 @@ public void AddTab(string domain = null) /// Adds a new tab for the host of the specified profile. /// /// The profile to query. - public void AddTab(ProfileInfo profile) + /// Starts it on the first load. If false, the input is only filled in. + public void AddTab(ProfileInfo profile, bool start = true) { - AddTab(profile.IPGeolocation_Host); + AddTab(profile.IPGeolocation_Host, start: start); } #endregion diff --git a/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs b/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs index ea79459e4e..8f24b9eabd 100644 --- a/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs @@ -141,8 +141,11 @@ private set /// /// The ID of the tab. /// The host to query. - public IPGeolocationViewModel(Guid tabId, string host) + /// Starts it on the first load. If false, the input is only filled in. + public IPGeolocationViewModel(Guid tabId, string host, bool start = true) { + _firstLoad = start; + ConfigurationManager.Current.IPGeolocationTabCount++; _tabId = tabId; diff --git a/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs index 4808a0e2c9..0667ecd3df 100644 --- a/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs @@ -147,11 +147,12 @@ private static void CloseItemAction(ItemActionCallbackArgs args) /// Adds a new tab for the specified host or IP range. /// /// The host or IP range to scan. - public void AddTab(string hostOrIPRange = null) + /// Starts it on the first load. If false, the input is only filled in. + public void AddTab(string hostOrIPRange = null, bool start = true) { var tabId = Guid.NewGuid(); - TabItems.Add(new DragablzTabItem(Strings.NewTab, new IPScannerView(tabId, hostOrIPRange), + TabItems.Add(new DragablzTabItem(Strings.NewTab, new IPScannerView(tabId, hostOrIPRange, start), tabId)); SelectedTabIndex = TabItems.Count - 1; @@ -161,9 +162,10 @@ public void AddTab(string hostOrIPRange = null) /// Adds a new tab for the specified profile. /// /// The profile to scan. - public void AddTab(ProfileInfo profile) + /// Starts it on the first load. If false, the input is only filled in. + public void AddTab(ProfileInfo profile, bool start = true) { - AddTab(profile.IPScanner_HostOrIPRange); + AddTab(profile.IPScanner_HostOrIPRange, start: start); } #endregion diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index d4fe8eeafc..ca92e67b44 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -304,8 +304,11 @@ private set /// /// The unique identifier for the tab. /// The initial host or IP range to scan. - public IPScannerViewModel(Guid tabId, string hostOrIPRange) + /// Starts it on the first load. If false, the input is only filled in. + public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true) { + _firstLoad = start; + ConfigurationManager.Current.IPScannerTabCount++; _tabId = tabId; diff --git a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs index c4b43235eb..d06f4ad57b 100644 --- a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs @@ -198,11 +198,17 @@ private bool OUILookup_CanExecute(object parameter) /// Looks up MAC addresses or vendors (e.g. from the run command). /// /// MAC addresses or vendors, separated by ";". - public void Lookup(string search) + /// Starts the lookup. If false, the search is only filled in. + public void Lookup(string search, bool start = true) { + // Not while a lookup is running (the results would be mixed). + if (IsRunning) + return; + Search = search; - OUILookupAction(); + if (start) + OUILookupAction(); } /// diff --git a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs index 5f12575669..b7acda1c6a 100644 --- a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs @@ -196,11 +196,16 @@ private bool PortLookup_CanExecute(object parameter) /// Looks up ports or services (e.g. from the run command). /// /// Ports, port ranges or services, separated by ";". - public Task Lookup(string search) + /// Starts the lookup. If false, the search is only filled in. + public Task Lookup(string search, bool start = true) { + // Not while a lookup is running (the results would be mixed). + if (IsRunning) + return Task.CompletedTask; + Search = search; - return PortLookupAction(); + return start ? PortLookupAction() : Task.CompletedTask; } /// diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs index 28a7e46956..7e3b50c6d3 100644 --- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs @@ -264,9 +264,10 @@ private void PauseGroupAction(object group) /// Adds the host(s) and starts the ping monitor. /// /// Host(s) to ping. - public void AddHost(string host) + /// Starts the ping monitor. If false, the host is only filled in. + public void AddHost(string host, bool start = true) { - if (SetHost(host)) + if (SetHost(host) && start) _ = Start(); } @@ -274,9 +275,10 @@ public void AddHost(string host) /// Adds the host(s) of the profile to its group and starts the ping monitor. /// /// Profile to ping. - public void AddHost(ProfileInfo profile) + /// Starts the ping monitor. If false, the host is only filled in. + public void AddHost(ProfileInfo profile, bool start = true) { - if (SetHost(profile.PingMonitor_Host, profile.Group)) + if (SetHost(profile.PingMonitor_Host, profile.Group) && start) _ = Start(); } diff --git a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs index a83df00221..7e86dae89d 100644 --- a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs @@ -136,19 +136,20 @@ private static void CloseItemAction(ItemActionCallbackArgs args) /// /// The host to scan. /// The ports to scan. - public void AddTab(string host = null, string ports = null) + /// 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) { var tabId = Guid.NewGuid(); TabItems.Add(new DragablzTabItem(string.IsNullOrEmpty(host) ? Strings.NewTab : host, - new PortScannerView(tabId, host, ports), tabId)); + new PortScannerView(tabId, host, ports, start), tabId)); SelectedTabIndex = TabItems.Count - 1; } - public void AddTab(ProfileInfo profile) + public void AddTab(ProfileInfo profile, bool start = true) { - AddTab(profile.PortScanner_Host, profile.PortScanner_Ports); + AddTab(profile.PortScanner_Host, profile.PortScanner_Ports, start: start); } #endregion diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index d455551963..25de7c9763 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -301,8 +301,11 @@ private set /// The unique identifier for the tab. /// The initial host to scan. /// The initial ports to scan. - public PortScannerViewModel(Guid tabId, string host, string port) + /// 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) { + _firstLoad = start; + ConfigurationManager.Current.PortScannerTabCount++; _tabId = tabId; diff --git a/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs b/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs index 84ccc2acfa..eadb7b0d7b 100644 --- a/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs @@ -113,14 +113,15 @@ private static void CloseItemAction(ItemActionCallbackArgs args) /// Type of the query. If null, the type is detected from the query (, e.g. data /// redirected from another tool or a run command), or the last selected type is used for an empty tab. /// - public void AddTab(string query = null, RDAPQueryType? queryType = null) + /// Starts it on the first load. If false, the input is only filled in. + public void AddTab(string query = null, RDAPQueryType? queryType = null, bool start = true) { if (queryType == null && !string.IsNullOrWhiteSpace(query)) queryType = RDAPQueryType.Auto; var tabId = Guid.NewGuid(); - TabItems.Add(new DragablzTabItem(query ?? Strings.NewTab, new RDAPView(tabId, query, queryType), + TabItems.Add(new DragablzTabItem(query ?? Strings.NewTab, new RDAPView(tabId, query, queryType, start), tabId)); SelectedTabIndex = TabItems.Count - 1; @@ -130,9 +131,10 @@ public void AddTab(string query = null, RDAPQueryType? queryType = null) /// Adds a new tab and starts the query of the profile. /// /// Profile to query. - public void AddTab(ProfileInfo profile) + /// Starts it on the first load. If false, the input is only filled in. + public void AddTab(ProfileInfo profile, bool start = true) { - AddTab(profile.RDAP_Query, profile.RDAP_QueryType); + AddTab(profile.RDAP_Query, profile.RDAP_QueryType, start: start); } #endregion diff --git a/Source/NETworkManager/ViewModels/RDAPViewModel.cs b/Source/NETworkManager/ViewModels/RDAPViewModel.cs index 33f052ebb6..487560ab72 100644 --- a/Source/NETworkManager/ViewModels/RDAPViewModel.cs +++ b/Source/NETworkManager/ViewModels/RDAPViewModel.cs @@ -200,8 +200,10 @@ public bool IsOpenInWhoisVisible #region Contructor, load settings - public RDAPViewModel(Guid tabId, string query, RDAPQueryType? queryType) + public RDAPViewModel(Guid tabId, string query, RDAPQueryType? queryType, bool start = true) { + _firstLoad = start; + _isLoading = true; ConfigurationManager.Current.RDAPTabCount++; diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs index 75be9fcaf3..807588915b 100644 --- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -1,4 +1,5 @@ using NETworkManager.Localization.Resources; +using NETworkManager.Models; using NETworkManager.Models.RunCommand; using NETworkManager.Profiles; using NETworkManager.Settings; @@ -6,19 +7,27 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.ComponentModel; using System.Linq; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Windows; +using System.Windows.Data; using System.Windows.Input; +using System.Windows.Threading; namespace NETworkManager.ViewModels; /// -/// View model of the run command dialog: type a command (e.g. "ping 10.0.0.1"), select a suggestion and run it. +/// View model of the run command dialog: type a command (e.g. "ping 10.0.0.1") or only a target (e.g. "10.0.0.1"), +/// select a suggestion and run it. /// public class RunCommandViewModel : ViewModelBase { /// - /// Maximum number of suggestions shown for a target or argument (profiles, history, types). The commands are - /// always shown completely. + /// Maximum number of suggestions per group shown for a target or argument (profiles, history, types, DNS + /// servers), so a large group (e.g. the DNS record types) doesn't hide the others. The commands are always shown + /// completely. /// private const int MaxSuggestions = 10; @@ -32,14 +41,41 @@ public class RunCommandViewModel : ViewModelBase /// private const int MaxCommandHistorySuggestions = 3; + /// + /// Maximum number of commands suggested for the clipboard content (empty input). + /// + private const int MaxClipboardSuggestions = 3; + + /// + /// Maximum length of the clipboard content that is suggested as target. + /// + private const int MaxClipboardLength = 255; + + /// + /// Time without typing after which the input is validated. + /// + private static readonly TimeSpan ValidationDelay = TimeSpan.FromMilliseconds(300); + + /// + /// Examples shown with an empty input if there are no recent commands yet (only of available commands). + /// + private static readonly string[] ExampleCommands = + [ + "ping 10.0.0.1", + "trace borntoberoot.net", + "dns borntoberoot.net 1.1.1.1 aaaa", + "subnet 10.0.0.0/16 /24", + "lookup port 22; 443" + ]; + private readonly IReadOnlyList _commands; private readonly Action _runHandler; /// - /// Recent commands with their command and profile name (parsed once, the history doesn't change while the dialog - /// is open). + /// Recent commands (parsed once, the history only changes while the dialog is open if a command is run and the + /// dialog is kept open, or an entry is removed). /// - private readonly List<(string Text, RunCommandDefinition Command, string ProfileName)> _history; + private readonly List _history; /// /// Names (command and aliases) and translated name per command. @@ -51,12 +87,41 @@ public class RunCommandViewModel : ViewModelBase /// private readonly Dictionary> _profiles = []; + /// + /// Content of the clipboard if it is a target (e.g. an IP address) and the setting is enabled, otherwise null. + /// + private readonly string _clipboardTarget; + + /// + /// Commands for the clipboard content (validated once, the clipboard is only read when the dialog is opened). + /// + private readonly List<(RunCommandDefinition Command, string Text)> _clipboardCommands = []; + + private readonly DispatcherTimer _validationTimer; + private RunCommandParseResult _result; + /// + /// Command that Enter runs for a target typed without a command (e.g. "ping 10.0.0.1" for "10.0.0.1"): the first + /// command for the target, also if it is shown as a recent command. Null if there is none. + /// + private string _targetOnlyPreselection; + + /// + /// Recent command with the parsed values used for the suggestions. + /// + private sealed record HistoryEntry( + string Text, + RunCommandDefinition Command, + string ProfileName, + RunCommandTargetKind TargetKind); + /// /// Initializes a new instance of the class. /// - /// Called with the request to run (, ). + /// + /// Called with the request to run (, , ). + /// /// Called when the dialog is canceled. public RunCommandViewModel(Action runHandler, Action cancelHandler) { @@ -75,9 +140,25 @@ public RunCommandViewModel(Action runHandler, Action (Text: x, Result: RunCommandParser.Parse(x, _commands))) .Where(x => x.Result.Command != null) - .Select(x => (x.Text, x.Result.Command, x.Result.ProfileName)) + .Select(x => CreateHistoryEntry(x.Text, x.Result)) ]; + if (SettingsManager.Current.RunCommand_SuggestClipboard) + _clipboardTarget = GetClipboardTarget(); + + if (_clipboardTarget != null) + _clipboardCommands = [.. RunCommandManager.GetCommandsForTarget(_clipboardTarget, _commands)]; + + SuggestionsView = CollectionViewSource.GetDefaultView(Suggestions); + SuggestionsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(RunCommandSuggestion.Group))); + + _validationTimer = new DispatcherTimer { Interval = ValidationDelay }; + _validationTimer.Tick += (_, _) => + { + _validationTimer.Stop(); + Validate(); + }; + CancelCommand = new RelayCommand(_ => cancelHandler(this)); UpdateSuggestions(); @@ -87,6 +168,12 @@ .. SettingsManager.Current.RunCommand_History public ICommand CancelCommand { get; } + /// + /// Placeholder of the input, a random line each time the dialog is opened (e.g. "Ping it, trace it, scan + /// it..."). + /// + public string Watermark { get; } = GetRandomWatermark(); + /// /// Input of the user (e.g. "dns borntoberoot.net aaaa"). /// @@ -101,17 +188,46 @@ public string Input field = value; OnPropertyChanged(); - ErrorMessage = null; UpdateSuggestions(); } } = string.Empty; public ObservableCollection Suggestions { get; } = []; + /// + /// Suggestions grouped by (e.g. "Recent", "Profiles"). + /// + public ICollectionView SuggestionsView { get; } + public RunCommandSuggestion SelectedSuggestion { get; set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(CanRemoveSelectedSuggestion)); + + // The validation shows what Enter runs (the selected suggestion or the input). + RestartValidation(); + } + } + + /// + /// The selected suggestion is a recent command, which can be removed from the history (Shift+Del). + /// + public bool CanRemoveSelectedSuggestion => SelectedSuggestion?.Kind == RunCommandSuggestionKind.History; + + /// + /// A command is recognized (its icon is shown in front of the input). + /// + public bool HasCommand + { + get; + private set { if (value == field) return; @@ -122,7 +238,24 @@ public RunCommandSuggestion SelectedSuggestion } /// - /// Syntax of the recognized command (e.g. "dnslookup <hosts> [server] [type] | @<profile>"). + /// Application of the recognized command ( for the settings). + /// + public ApplicationName CommandApplication + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Syntax of the recognized command (e.g. "dnslookup <hosts> [server] [type] | @<profile>"), + /// null if no command is recognized. /// public string Hint { @@ -138,9 +271,75 @@ private set } /// - /// Error of the last run (e.g. invalid host), null if there is none. + /// Parts of the , the part that is being typed is highlighted. + /// + public IReadOnlyList HintParts + { + get; + private set + { + field = value; + OnPropertyChanged(); + } + } = []; + + /// + /// Values of the input as they were understood (e.g. hosts, DNS server, type). /// - public string ErrorMessage + public IReadOnlyList Chips + { + get; + private set + { + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasChips)); + } + } = []; + + public bool HasChips => Chips.Count > 0; + + /// + /// Show the key hints at the bottom of the dialog (setting). + /// + public bool ShowKeyHints { get; } = SettingsManager.Current.RunCommand_ShowKeyHints; + + /// + /// What Enter runs is valid (checked while typing). + /// + public bool IsValid + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// What Enter runs is not valid (checked while typing), see . + /// + public bool IsInvalid + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Error of the validation while typing (tooltip of the warning icon), null if there is none. + /// + public string ValidationMessage { get; private set @@ -163,6 +362,12 @@ private set /// public bool IsSettings { get; private set; } + /// + /// The dialog stays open after the run (set before the run handler is called), see + /// . + /// + public bool KeepOpen { get; private set; } + #endregion #region Methods @@ -190,7 +395,10 @@ private void Select(int offset) var index = SelectedSuggestion == null ? -1 : Suggestions.IndexOf(SelectedSuggestion); - SelectedSuggestion = Suggestions[Math.Clamp(index + offset, 0, Suggestions.Count - 1)]; + // Up from the first suggestion selects none, so Enter runs the input as typed. + SelectedSuggestion = index + offset < 0 + ? null + : Suggestions[Math.Min(index + offset, Suggestions.Count - 1)]; } /// @@ -198,91 +406,349 @@ private void Select(int offset) /// public void Complete() { - if (SelectedSuggestion == null || SelectedSuggestion.Kind == RunCommandSuggestionKind.Run) + // Nothing selected: the first DNS server or type that matches the typed word (e.g. "dns host c" → the DNS + // server "Cloudflare", then the types), in the order of the syntax. + var suggestion = SelectedSuggestion ?? (_result.Position == RunCommandPosition.Argument && + _result.CurrentWord.Length > 0 + ? Suggestions.FirstOrDefault(x => + x.Kind is RunCommandSuggestionKind.DNSServer or RunCommandSuggestionKind.Type) + : null); + + if (suggestion == null) return; - Input = SelectedSuggestion.CompletionText; + Input = suggestion.CompletionText; } /// - /// Runs the selected suggestion, or the input if none is selected (Enter). + /// Runs the selected suggestion, or the input if none is selected. /// - public void Run() + /// Run (Enter), run and keep the dialog open (Ctrl+Enter) or only fill in (Shift+Enter). + /// False if the input is not valid (the error is shown with the warning icon). + public bool Run(RunCommandRunMode mode = RunCommandRunMode.Run) { var suggestion = SelectedSuggestion; var text = suggestion?.CompletionText ?? Input; var result = RunCommandParser.Parse(text, _commands); + StopValidation(); + KeepOpen = false; + var error = RunCommandManager.TryCreateRequest(result, suggestion?.Profile, out var request); if (error != null) { // Show the text of a failed suggestion (e.g. a recent command with an ambiguous profile name), so it can be // corrected or completed with the suggestions. - if (suggestion != null && suggestion.Kind != RunCommandSuggestionKind.Run) + if (suggestion != null) Input = text; - ErrorMessage = error; + ShowRunError(error); - return; + return false; } + // Only fill in the input, so the options can be adjusted before starting (e.g. the scan). + if (mode == RunCommandRunMode.FillIn && request != null) + request = request with { Start = false }; + Request = request; IsSettings = result.Command.Type == RunCommandType.Setting; + // A dialog that is opened (e.g. the connect dialog of a remote tool) needs the dialog to be closed. + KeepOpen = mode == RunCommandRunMode.RunAndKeepOpen && + !RunCommandManager.OpensDialog(IsSettings ? null : request); + // Commands are case-insensitive, so "Ping 10.0.0.1" and "ping 10.0.0.1" are the same entry. - ListHelper.Modify(SettingsManager.Current.RunCommand_History, text.Trim(), + var historyText = text.Trim(); + + ListHelper.Modify(SettingsManager.Current.RunCommand_History, historyText, SettingsManager.Current.General_HistoryListEntries, StringComparer.OrdinalIgnoreCase); _runHandler(this); + + if (!KeepOpen) + return true; + + // Show the command in the recent commands and keep it in the input (e.g. "ping "), so it can be run with the + // next target. + _history.RemoveAll(x => string.Equals(x.Text, historyText, StringComparison.OrdinalIgnoreCase)); + _history.Insert(0, CreateHistoryEntry(historyText, result)); + + var commandPrefix = result.Command.TypeBeforeTarget && result.Type != null + ? $"{result.CommandText} {result.Type} " + : $"{result.CommandText} "; + + if (Input == commandPrefix) + UpdateSuggestions(); + else + Input = commandPrefix; + + return true; + } + + /// + /// Removes a recent command from the history (Shift+Del on the selected one, or its remove button). + /// + /// Suggestion of the recent command. + /// True if a recent command was removed. + public bool RemoveSuggestion(RunCommandSuggestion suggestion) + { + if (suggestion?.Kind != RunCommandSuggestionKind.History) + return false; + + var text = suggestion.Title; + var index = Suggestions.IndexOf(suggestion); + var wasSelected = suggestion == SelectedSuggestion; + + var entry = SettingsManager.Current.RunCommand_History.FirstOrDefault(x => + string.Equals(x, text, StringComparison.OrdinalIgnoreCase)); + + if (entry != null) + SettingsManager.Current.RunCommand_History.Remove(entry); + + _history.RemoveAll(x => string.Equals(x.Text, text, StringComparison.OrdinalIgnoreCase)); + + UpdateSuggestions(); + + // Keep the position in the list, so several entries can be removed one after the other. + if (wasSelected && Suggestions.Count > 0) + SelectedSuggestion = Suggestions[Math.Min(index, Suggestions.Count - 1)]; + + return true; + } + + private static string GetRandomWatermark() + { + // Created on each call, since the lines are translated. + string[] watermarks = + [ + Strings.RunCommandWatermark1, Strings.RunCommandWatermark2, Strings.RunCommandWatermark3, + Strings.RunCommandWatermark4, Strings.RunCommandWatermark5, Strings.RunCommandWatermark6, + Strings.RunCommandWatermark7, Strings.RunCommandWatermark8, Strings.RunCommandWatermark9, + Strings.RunCommandWatermark10, Strings.RunCommandWatermark11, Strings.RunCommandWatermark12, + Strings.RunCommandWatermark13, Strings.RunCommandWatermark14, Strings.RunCommandWatermark15, + Strings.RunCommandWatermark16, Strings.RunCommandWatermark17, Strings.RunCommandWatermark18 + ]; + + return watermarks[Random.Shared.Next(watermarks.Length)]; + } + + private static HistoryEntry CreateHistoryEntry(string text, RunCommandParseResult result) + { + return new HistoryEntry(text, result.Command, result.ProfileName, RunCommandTarget.GetKind(result.Target)); + } + + /// + /// Gets the clipboard content if it is a target (e.g. an IP address, a subnet or a MAC address). + /// + /// Target, or null if the clipboard contains no target. + private static string GetClipboardTarget() + { + try + { + if (!Clipboard.ContainsText()) + return null; + + // Whitespace around ";" is removed like in the input (e.g. "10.0.0.1; 10.0.0.2"). + var text = Regex.Replace(Clipboard.GetText().Trim(), @"\s*;\s*", ";"); + + // A single word (host name without a domain) is not suggested, since it can be any copied text. + return text.Length <= MaxClipboardLength && + RunCommandTarget.GetKind(text) is not (RunCommandTargetKind.None or RunCommandTargetKind.Hostname) + ? text + : null; + } + catch (ExternalException) + { + // The clipboard is used by another application. + return null; + } } /// - /// Updates the suggestions and the hint for the input. + /// Updates the suggestions, the hint and the parsed values for the input. /// private void UpdateSuggestions() { _result = RunCommandParser.Parse(Input, _commands); - Hint = _result.Command == null - ? null - : $"{_result.Command.Command} {_result.Command.Syntax}".TrimEnd(); + HasCommand = _result.Command != null; + CommandApplication = _result.Command?.Application ?? ApplicationName.None; + + UpdateHint(); + UpdateChips(); IEnumerable suggestions = _result.Position switch { - RunCommandPosition.Command => GetCommandSuggestions(), + RunCommandPosition.Command when string.IsNullOrWhiteSpace(Input) => GetEmptyInputSuggestions(), + RunCommandPosition.Command => GetFirstWordSuggestions(), RunCommandPosition.Type => - [GetRunSuggestion(), .. GetTypeSuggestions(), .. GetHistorySuggestions()], - RunCommandPosition.Target when _result.ProfileName != null => GetProfileSuggestions(), + [ + .. GetTypeSuggestions().Take(MaxSuggestions), + .. GetHistorySuggestions().Take(MaxSuggestions) + ], + RunCommandPosition.Target when _result.ProfileName != null => GetProfileSuggestions().Take(MaxSuggestions), RunCommandPosition.Target => [ - GetRunSuggestion(), .. GetHistorySuggestions().Take(MaxCommandHistorySuggestions), - .. GetProfileSuggestions() + .. GetHistorySuggestions().Take(MaxCommandHistorySuggestions), + .. GetProfileSuggestions().Take(MaxSuggestions) ], + // In the order of the syntax (e.g. "[DNS server] [Type]") _ => [ - GetRunSuggestion(), .. GetTypeSuggestions(), .. GetDNSServerSuggestions(), - .. GetHistorySuggestions() + .. GetDNSServerSuggestions().Take(MaxSuggestions), + .. GetTypeSuggestions().Take(MaxSuggestions), .. GetHistorySuggestions().Take(MaxSuggestions) ] }; - if (_result.Position != RunCommandPosition.Command) - suggestions = suggestions.Take(MaxSuggestions); - Suggestions.Clear(); foreach (var suggestion in suggestions) Suggestions.Add(suggestion); SelectedSuggestion = GetPreselectedSuggestion(); + + RestartValidation(); + } + + /// + /// Updates the hint with the syntax of the command and highlights the part that is being typed. + /// + private void UpdateHint() + { + var command = _result.Command; + + if (command == null) + { + Hint = null; + HintParts = []; + + return; + } + + Hint = $"{command.Command} {command.Syntax}".TrimEnd(); + + var currentPart = _result.CurrentSyntaxPart; + + var parts = new List + { + new(command.Command, _result.Position == RunCommandPosition.Command) + }; + + foreach (var part in command.SyntaxParts) + { + if (part.Kind == RunCommandSyntaxPartKind.Profile) + parts.Add(new RunCommandHintPart("|", false)); + + parts.Add(new RunCommandHintPart(part.Text, part.Kind == currentPart)); + } + + HintParts = parts; + } + + /// + /// Updates the values of the input as they were understood, in the order of the syntax. The application is not + /// included, since its icon is shown in the input and its command in the hint. + /// + private void UpdateChips() + { + var command = _result.Command; + + if (command == null || _result.Position == RunCommandPosition.Command || + _result.Target == null && string.IsNullOrEmpty(_result.ProfileName) && _result.Type == null) + { + Chips = []; + + return; + } + + var chips = new List(); + + foreach (var part in command.SyntaxParts) + { + var value = part.Kind switch + { + RunCommandSyntaxPartKind.Target => _result.Target, + RunCommandSyntaxPartKind.Argument => _result.Argument, + RunCommandSyntaxPartKind.Type => _result.Type?.ToUpperInvariant(), + _ => string.IsNullOrEmpty(_result.ProfileName) ? null : "@" + _result.ProfileName + }; + + if (value != null) + chips.Add(new RunCommandChip(part.Name, value)); + } + + Chips = chips; } /// - /// Gets the suggestion that Enter runs without selecting one, so Enter runs what was typed: - /// - After the command: the input as typed (first suggestion). - /// - While typing the command: the best matching command, but not if the input is empty or an unknown command has - /// arguments (e.g. a typo like "pin 10.0.0.1"), which would drop the input. + /// Shows the error of a run with the warning icon, like the validation while typing (without waiting). + /// + private void ShowRunError(string error) + { + // A failed suggestion was put into the input, which restarted the validation. + _validationTimer.Stop(); + + IsValid = false; + IsInvalid = true; + ValidationMessage = error; + } + + /// + /// Validates what Enter runs (the selected suggestion or the input) after a short time without typing. + /// + private void RestartValidation() + { + StopValidation(); + + if (ShouldValidate()) + _validationTimer.Start(); + } + + private void StopValidation() + { + _validationTimer.Stop(); + + IsValid = false; + IsInvalid = false; + ValidationMessage = null; + } + + /// + /// The input is validated after the command (or for an unknown command with arguments like "pin 10.0.0.1"), not + /// while the command is being typed. + /// + private bool ShouldValidate() + { + return !string.IsNullOrWhiteSpace(Input) && + (_result.Position != RunCommandPosition.Command || _result.Command == null && HasArguments()); + } + + private void Validate() + { + if (!ShouldValidate()) + return; + + var suggestion = SelectedSuggestion; + var result = suggestion == null ? _result : RunCommandParser.Parse(suggestion.CompletionText, _commands); + + var error = RunCommandManager.TryCreateRequest(result, suggestion?.Profile, out _); + + IsValid = error == null; + IsInvalid = error != null; + ValidationMessage = error; + } + + /// + /// Gets the suggestion that Enter runs without selecting one. Without a selected suggestion, Enter runs the input + /// as typed: + /// - Empty input: the first command for the clipboard content (if enabled), otherwise none. + /// - After the command: none (profiles, recent commands, types and DNS servers are selected with the arrow keys). + /// - While typing the command: the best matching command (or the first command for a target without a command, + /// e.g. "10.0.0.1", but not for a host name without a domain, which can also be a typo), but not if an unknown + /// command has arguments (e.g. a typo like "pin 10.0.0.1"), which would drop the input. /// - While typing a profile ("@"): the profile, only if exactly one matches. Otherwise, the typed name is used, /// which must be unique (e.g. two profiles with the same name in different groups). /// - While typing a type before the target (e.g. "lookup po"): the matching type, like the command. @@ -291,15 +757,33 @@ private RunCommandSuggestion GetPreselectedSuggestion() { return _result.Position switch { - RunCommandPosition.Command when string.IsNullOrWhiteSpace(Input) || - _result.Command == null && GetInputAfterCommand().Length > 0 => null, + RunCommandPosition.Command when string.IsNullOrWhiteSpace(Input) => + Suggestions.FirstOrDefault(x => x.Kind == RunCommandSuggestionKind.Clipboard), + RunCommandPosition.Command when _result.Command == null && HasArguments() => null, RunCommandPosition.Target when _result.ProfileName != null => Suggestions.Count == 1 ? Suggestions[0] : null, RunCommandPosition.Type when _result.CurrentWord.Length > 0 => - Suggestions.FirstOrDefault(x => x.Kind == RunCommandSuggestionKind.Type) ?? Suggestions.FirstOrDefault(), - _ => Suggestions.FirstOrDefault() + Suggestions.FirstOrDefault(x => x.Kind == RunCommandSuggestionKind.Type), + // The best matching command, otherwise the first command for a typed target (also if it is shown as recent + // command). Other clipboard and recent commands are selected with the arrow keys. + RunCommandPosition.Command => + Suggestions.FirstOrDefault(x => x.Kind == RunCommandSuggestionKind.Command) ?? + (_targetOnlyPreselection == null + ? null + : Suggestions.FirstOrDefault(x => + string.Equals(x.CompletionText, _targetOnlyPreselection, StringComparison.OrdinalIgnoreCase))), + _ => null }; } + /// + /// Checks if the input has arguments after the first word (e.g. "pin 10.0.0.1"). Like in the parser, whitespace + /// around ";" doesn't separate arguments, so "10.0.0.1; 10.0.0.2" is one word. + /// + private bool HasArguments() + { + return Regex.Replace(Input.Trim(), @"\s*;\s*", ";").Any(char.IsWhiteSpace); + } + /// /// Gets the input after the first word (the command), e.g. "10.0.0.1" for "pin 10.0.0.1". /// @@ -312,31 +796,82 @@ private string GetInputAfterCommand() } /// - /// Suggestion to run the input as typed. + /// Suggestions for an empty input: commands for the clipboard content, recent commands, examples (if there are + /// no recent commands yet) and all commands. /// - private RunCommandSuggestion GetRunSuggestion() + private IEnumerable GetEmptyInputSuggestions() { - return new RunCommandSuggestion - { - Kind = RunCommandSuggestionKind.Run, - Application = _result.Command.Application, - Title = Input.Trim(), - Description = RunCommandManager.GetTranslatedName(_result.Command), - CompletionText = Input - }; + var suggestions = new List(); + + if (_clipboardTarget != null) + suggestions.AddRange(GetTargetSuggestions(_clipboardTarget, Strings.Clipboard, + RunCommandSuggestionKind.Clipboard, commands: _clipboardCommands).Take(MaxClipboardSuggestions)); + + suggestions.AddRange(GetHistorySuggestions().Take(MaxHistorySuggestions)); + + if (_history.Count == 0) + suggestions.AddRange(GetExampleSuggestions()); + + suggestions.AddRange(GetCommandSuggestions()); + + return Distinct(suggestions); } /// - /// Recent commands (empty input) and the commands that match the typed command. + /// Suggestions while typing the first word: the matching commands, the recent commands that contain the word + /// (e.g. a host), the commands for a target typed without a command (e.g. "10.0.0.1") and the commands for the + /// clipboard content that start with the word (e.g. "pi" → "ping 10.0.0.1"). /// - private IEnumerable GetCommandSuggestions() + private IEnumerable GetFirstWordSuggestions() { - var text = _result.CurrentWord; + var word = _result.CurrentWord; - var suggestions = new List(); + var targetOnlySuggestions = GetTargetOnlySuggestions(CommandStartsWith(word)).ToList(); + + // Not for a host name without a domain, which can also be a mistyped command (e.g. "pnig"), and not for a port, + // which can also be the start of an IP address (e.g. "192"). + _targetOnlyPreselection = + RunCommandTarget.GetKind(_result.CommandText) is RunCommandTargetKind.Hostname or RunCommandTargetKind.Ports + ? null + : targetOnlySuggestions.FirstOrDefault()?.CompletionText; + + var suggestions = new List(GetCommandSuggestions()); + + suggestions.AddRange(GetFirstWordHistorySuggestions().Take(MaxHistorySuggestions)); + suggestions.AddRange(targetOnlySuggestions); - if (string.IsNullOrEmpty(text)) - suggestions.AddRange(GetHistorySuggestions().Take(MaxHistorySuggestions)); + if (_clipboardTarget != null) + suggestions.AddRange(GetTargetSuggestions(_clipboardTarget, Strings.Clipboard, + RunCommandSuggestionKind.Clipboard, word, _clipboardCommands)); + + // A command for the target that was already run is shown as recent command (only once). + return Distinct(suggestions); + } + + /// + /// Removes suggestions with the same command as a previous one (e.g. a recent command that is also suggested + /// for the target or the clipboard). Not trimmed, so a command that is completed (e.g. "ping ") and a recent + /// command without a target (e.g. "ping") are both kept. + /// + private static IEnumerable Distinct(IEnumerable suggestions) + { + return suggestions.DistinctBy(x => x.CompletionText, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Checks if a command or alias starts with the text (e.g. "pi" for "ping"). + /// + private bool CommandStartsWith(string text) + { + return _commandNames.Any(x => x.Names.Any(name => name.StartsWith(text, StringComparison.OrdinalIgnoreCase))); + } + + /// + /// Commands that match the typed command. + /// + private IEnumerable GetCommandSuggestions() + { + var text = _result.CurrentWord; // Completing the command keeps the arguments (e.g. "pin 10.0.0.1" → "ping 10.0.0.1"). var arguments = GetInputAfterCommand(); @@ -365,13 +900,83 @@ private IEnumerable GetCommandSuggestions() Title = translatedName, Description = string.Join(", ", names), Info = command.Syntax, - CompletionText = (name ?? command.Command) + " " + arguments + CompletionText = (name ?? command.Command) + " " + arguments, + Highlight = text, + Group = Strings.Applications })); } - suggestions.AddRange(commands.OrderBy(x => x.Rank).Select(x => x.Suggestion)); + return commands.OrderBy(x => x.Rank).Select(x => x.Suggestion); + } + + /// + /// Commands for a target typed without a command (e.g. "ping 10.0.0.1", "trace 10.0.0.1" for "10.0.0.1"). + /// + /// A command starts with the typed word. + private IEnumerable GetTargetOnlySuggestions(bool commandStartsWithWord) + { + if (_result.Command != null || HasArguments()) + return []; - return suggestions; + // A host name without a domain can also be a command that is being typed (e.g. "pi"). + if (commandStartsWithWord && RunCommandTarget.GetKind(_result.CommandText) == RunCommandTargetKind.Hostname) + return []; + + return GetTargetSuggestions(_result.CommandText, Strings.Commands, RunCommandSuggestionKind.Target); + } + + /// + /// Commands that accept the target. Commands that were used with this kind of target (e.g. IP addresses) come + /// first, the most used first. + /// + /// Target (e.g. "10.0.0.1"). + /// Group of the suggestions. + /// Kind of the suggestions (typed target or clipboard). + /// Only commands of which a name starts with it (e.g. "pi" for "ping"), or null. + /// Commands for the target if they are already known (clipboard), otherwise null. + private IEnumerable GetTargetSuggestions(string target, string group, + RunCommandSuggestionKind suggestionKind, string commandPrefix = null, + IEnumerable<(RunCommandDefinition Command, string Text)> commands = null) + { + var kind = RunCommandTarget.GetKind(target); + + var usage = _history.Where(x => x.TargetKind == kind) + .GroupBy(x => x.Command) + .ToDictionary(x => x.Key, x => x.Count()); + + return (commands ?? RunCommandManager.GetCommandsForTarget(target, _commands)) + .Where(x => commandPrefix == null || _commandNames.Any(c => + c.Command == x.Command && + c.Names.Any(name => name.StartsWith(commandPrefix, StringComparison.OrdinalIgnoreCase)))) + .OrderByDescending(x => usage.GetValueOrDefault(x.Command)) + .Select(x => new RunCommandSuggestion + { + Kind = suggestionKind, + Application = x.Command.Application, + Title = x.Text, + Description = RunCommandManager.GetTranslatedName(x.Command), + CompletionText = x.Text, + Group = group + }); + } + + /// + /// Example commands of the available commands. + /// + private IEnumerable GetExampleSuggestions() + { + return ExampleCommands + .Select(x => (Text: x, RunCommandParser.Parse(x, _commands).Command)) + .Where(x => x.Command != null) + .Select(x => new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.Example, + Application = x.Command.Application, + Title = x.Text, + Description = RunCommandManager.GetTranslatedName(x.Command), + CompletionText = x.Text, + Group = Strings.Examples + }); } /// @@ -430,7 +1035,9 @@ int GetRecentIndex(ProfileInfo profile) => Description = string.IsNullOrEmpty(x.Target) ? x.Profile.Group : $"{x.Profile.Group} · {x.Target}", Info = "@" + x.Profile.Name, CompletionText = _result.InputBeforeCurrentWord + "@" + x.Profile.Name, - Profile = x.Profile + Profile = x.Profile, + Highlight = text, + Group = Strings.Profiles }); } @@ -451,7 +1058,9 @@ private IEnumerable GetTypeSuggestions() Application = _result.Command.Application, Title = x.ToUpperInvariant(), Description = Strings.Type, - CompletionText = _result.InputBeforeCurrentWord + x + " " + CompletionText = _result.InputBeforeCurrentWord + x + " ", + Highlight = _result.CurrentWord, + Group = Strings.Type }); } @@ -473,7 +1082,9 @@ private IEnumerable GetDNSServerSuggestions() Application = _result.Command.Application, Title = x, Description = Strings.DNSServer, - CompletionText = _result.InputBeforeCurrentWord + (x.Contains(' ') ? $"\"{x}\"" : x) + " " + CompletionText = _result.InputBeforeCurrentWord + (x.Contains(' ') ? $"\"{x}\"" : x) + " ", + Highlight = _result.CurrentWord, + Group = Strings.DNSServers }); } @@ -490,15 +1101,34 @@ private IEnumerable GetHistorySuggestions() x.Command == _result.Command && x.Text.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase) && !string.Equals(x.Text, input, StringComparison.OrdinalIgnoreCase)) - .Select(x => new RunCommandSuggestion - { - Kind = RunCommandSuggestionKind.History, - Application = x.Command?.Application ?? Models.ApplicationName.None, - Title = x.Text, - Description = x.Command == null ? null : RunCommandManager.GetTranslatedName(x.Command), - Info = Strings.Recent, - CompletionText = x.Text - }); + .Select(CreateHistorySuggestion); + } + + /// + /// Recent commands that contain the first word that is being typed (e.g. "server01" or "pi"), of any command. + /// + private IEnumerable GetFirstWordHistorySuggestions() + { + var input = Input.Trim(); + + return _history + .Where(x => x.Text.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase) && + !string.Equals(x.Text, input, StringComparison.OrdinalIgnoreCase)) + .Select(CreateHistorySuggestion); + } + + private RunCommandSuggestion CreateHistorySuggestion(HistoryEntry entry) + { + return new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.History, + Application = entry.Command.Application, + Title = entry.Text, + Description = RunCommandManager.GetTranslatedName(entry.Command), + CompletionText = entry.Text, + Highlight = _result.CurrentWord, + Group = Strings.Recent + }; } #endregion diff --git a/Source/NETworkManager/ViewModels/SettingsGeneralViewModel.cs b/Source/NETworkManager/ViewModels/SettingsGeneralViewModel.cs index ddad5752de..9ef3f922c1 100644 --- a/Source/NETworkManager/ViewModels/SettingsGeneralViewModel.cs +++ b/Source/NETworkManager/ViewModels/SettingsGeneralViewModel.cs @@ -61,6 +61,38 @@ public int HistoryListEntries } } + public bool RunCommandSuggestClipboard + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.RunCommand_SuggestClipboard = value; + + field = value; + OnPropertyChanged(); + } + } + + public bool RunCommandShowKeyHints + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.RunCommand_ShowKeyHints = value; + + field = value; + OnPropertyChanged(); + } + } + #endregion #region Constructor, LoadSettings @@ -85,6 +117,8 @@ private void LoadSettings() BackgroundJobInterval = SettingsManager.Current.General_BackgroundJobInterval; HistoryListEntries = SettingsManager.Current.General_HistoryListEntries; + RunCommandSuggestClipboard = SettingsManager.Current.RunCommand_SuggestClipboard; + RunCommandShowKeyHints = SettingsManager.Current.RunCommand_ShowKeyHints; } #endregion diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs index 60a58463c4..6c62b44e98 100644 --- a/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs +++ b/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs @@ -106,11 +106,13 @@ private void CalculateAction() /// Calculates the subnet (e.g. from the run command). /// /// Subnet (e.g. 10.0.0.0/24). - public void Calculate(string subnet) + /// Calculates the subnet. If false, the subnet is only filled in. + public void Calculate(string subnet, bool start = true) { Subnet = subnet; - Calculate(); + if (start) + Calculate(); } private void Calculate() diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs index ca0be04587..904b9162e4 100644 --- a/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs +++ b/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs @@ -13,7 +13,6 @@ using System.ComponentModel; using System.Linq; using System.Net; -using System.Net.Sockets; using System.Threading.Tasks; using System.Windows; using System.Windows.Data; @@ -217,12 +216,17 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro /// /// Subnet (e.g. 10.0.0.0/16). /// New subnet mask or CIDR (e.g. /24). - public Task Calculate(string subnet, string newSubnetmask) + /// Calculates the subnets. If false, the values are only filled in. + public Task Calculate(string subnet, string newSubnetmask, bool start = true) { + // Like the calculate button, not while a calculation is running (the results would be mixed). + if (IsRunning) + return Task.CompletedTask; + Subnet = subnet; NewSubnetmask = newSubnetmask; - return Calculate(); + return start ? Calculate() : Task.CompletedTask; } private async Task Calculate() @@ -239,13 +243,13 @@ private async Task Calculate() // Subnet mask (e.g. 255.255.255.0) or CIDR (e.g. /24) var newCidr = Subnetmask.ConvertSubnetmaskOrCidrToCidr(newSubnetmaskOrCidr); - var baseCidr = ipNetwork.AddressFamily == AddressFamily.InterNetwork ? 32 : 128; + var (minCidr, baseCidr) = Subnetmask.GetSubnettingCidrRange(ipNetwork); // The new subnet mask must split the subnet (e.g. /24 into /26), otherwise the subnetting fails. - if (newCidr < ipNetwork.Cidr || newCidr > baseCidr) + if (newCidr < minCidr || newCidr > baseCidr) { await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Error, - string.Format(Strings.EnterSubnetmaskOrCIDRBetweenXAndY, ipNetwork.Cidr, baseCidr), + string.Format(Strings.EnterSubnetmaskOrCIDRBetweenXAndY, minCidr, baseCidr), ChildWindowIcon.Error); IsRunning = false; diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs index 09e8f7415e..4410227493 100644 --- a/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs +++ b/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs @@ -125,12 +125,14 @@ private void CalculateAction() /// /// First subnet (e.g. 10.0.0.0/24). /// Second subnet (e.g. 10.0.1.0/24). - public void Calculate(string subnet1, string subnet2) + /// Calculates the wide subnet. If false, the subnets are only filled in. + public void Calculate(string subnet1, string subnet2, bool start = true) { Subnet1 = subnet1; Subnet2 = subnet2; - Calculate(); + if (start) + Calculate(); } private void Calculate() diff --git a/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs index f0ec9dacd8..c32f43070a 100644 --- a/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs @@ -104,19 +104,19 @@ private void CloseItemAction(ItemActionCallbackArgs args) #region Methods - public void AddTab(string host = null) + public void AddTab(string host = null, bool start = true) { var tabId = Guid.NewGuid(); - TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab, new TracerouteView(tabId, host), + TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab, new TracerouteView(tabId, host, start), tabId)); SelectedTabIndex = TabItems.Count - 1; } - public void AddTab(ProfileInfo profile) + public void AddTab(ProfileInfo profile, bool start = true) { - AddTab(profile.Traceroute_Host); + AddTab(profile.Traceroute_Host, start: start); } #endregion diff --git a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs index 4fb34e6b97..1cb383e31e 100644 --- a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs +++ b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs @@ -217,8 +217,11 @@ public bool ExpandMapView /// /// The unique identifier for the tab. /// The initial host to trace. - public TracerouteViewModel(Guid tabId, string host) + /// Starts it on the first load. If false, the input is only filled in. + public TracerouteViewModel(Guid tabId, string host, bool start = true) { + _firstLoad = start; + ConfigurationManager.Current.TracerouteTabCount++; _tabId = tabId; diff --git a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs index 81a8ed8e66..f34adb2788 100644 --- a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs +++ b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs @@ -209,8 +209,17 @@ private void WakeUpProfileAction() /// Sends the magic packet to the computer of the profile. /// /// Profile to wake up. - public void WakeUp(ProfileInfo profile) + /// Sends the magic packet. If false, the values of the profile are only filled in. + public void WakeUp(ProfileInfo profile, bool start = true) { + if (!start) + { + MACAddress = profile.WakeOnLAN_MACAddress; + Broadcast = profile.WakeOnLAN_Broadcast; + + return; + } + _ = WakeUp(NETworkManager.Profiles.Application.WakeOnLAN.CreateInfo(profile)); } @@ -220,14 +229,15 @@ public void WakeUp(ProfileInfo profile) /// /// MAC address of the computer. /// Broadcast address. If null, the current broadcast address is used. - public void WakeUp(string macAddress, string broadcast) + /// Sends the magic packet. If false, the values are only filled in. + public void WakeUp(string macAddress, string broadcast, bool start = true) { MACAddress = macAddress; if (broadcast != null) Broadcast = broadcast; - if (Broadcast != null && RegexHelper.IPv4AddressRegex().IsMatch(Broadcast)) + if (start && Broadcast != null && RegexHelper.IPv4AddressRegex().IsMatch(Broadcast)) WakeUpAction(); } diff --git a/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs b/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs index b4954a91d1..d6356fb6db 100644 --- a/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs @@ -104,19 +104,19 @@ private static void CloseItemAction(ItemActionCallbackArgs args) #region Methods - public void AddTab(string domain = null) + public void AddTab(string domain = null, bool start = true) { var tabId = Guid.NewGuid(); - TabItems.Add(new DragablzTabItem(domain ?? Strings.NewTab, new WhoisView(tabId, domain), + TabItems.Add(new DragablzTabItem(domain ?? Strings.NewTab, new WhoisView(tabId, domain, start), tabId)); SelectedTabIndex = TabItems.Count - 1; } - public void AddTab(ProfileInfo profile) + public void AddTab(ProfileInfo profile, bool start = true) { - AddTab(profile.Whois_Domain); + AddTab(profile.Whois_Domain, start: start); } #endregion diff --git a/Source/NETworkManager/ViewModels/WhoisViewModel.cs b/Source/NETworkManager/ViewModels/WhoisViewModel.cs index 9a6aeed3c5..03f0c4d8d5 100644 --- a/Source/NETworkManager/ViewModels/WhoisViewModel.cs +++ b/Source/NETworkManager/ViewModels/WhoisViewModel.cs @@ -111,8 +111,10 @@ private set #region Contructor, load settings - public WhoisViewModel(Guid tabId, string domain) + public WhoisViewModel(Guid tabId, string domain, bool start = true) { + _firstLoad = start; + ConfigurationManager.Current.WhoisTabCount++; _tabId = tabId; diff --git a/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs b/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs index 1b1f604aff..72c8b5c524 100644 --- a/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs +++ b/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs @@ -16,13 +16,13 @@ public void Open(ApplicationRequest request) { if (request.Profile != null) { - _viewModel.AddTab(request.Profile); + _viewModel.AddTab(request.Profile, request.Start); return; } var dnsLookupRequest = request as DNSLookupRequest; - _viewModel.AddTab(request.Target, dnsLookupRequest?.DNSServer, dnsLookupRequest?.QueryType); + _viewModel.AddTab(request.Target, dnsLookupRequest?.DNSServer, dnsLookupRequest?.QueryType, request.Start); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/DNSLookupView.xaml.cs b/Source/NETworkManager/Views/DNSLookupView.xaml.cs index 8edfa206d4..335981648f 100644 --- a/Source/NETworkManager/Views/DNSLookupView.xaml.cs +++ b/Source/NETworkManager/Views/DNSLookupView.xaml.cs @@ -11,11 +11,12 @@ public partial class DNSLookupView : IDragablzTabItem { private readonly DNSLookupViewModel _viewModel; - public DNSLookupView(Guid tabId, string host = null, string dnsServer = null, QueryType? queryType = null) + public DNSLookupView(Guid tabId, string host = null, string dnsServer = null, QueryType? queryType = null, + bool start = true) { InitializeComponent(); - _viewModel = new DNSLookupViewModel(tabId, host, dnsServer, queryType); + _viewModel = new DNSLookupViewModel(tabId, host, dnsServer, queryType, start); DataContext = _viewModel; diff --git a/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs b/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs index 43dc89ec95..6db9368339 100644 --- a/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs +++ b/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs @@ -15,9 +15,9 @@ public IPGeolocationHostView() public void Open(ApplicationRequest request) { if (request.Profile != null) - _viewModel.AddTab(request.Profile); + _viewModel.AddTab(request.Profile, request.Start); else - _viewModel.AddTab(request.Target); + _viewModel.AddTab(request.Target, start: request.Start); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/IPGeolocationView.xaml.cs b/Source/NETworkManager/Views/IPGeolocationView.xaml.cs index daf31b4822..be635b9f20 100644 --- a/Source/NETworkManager/Views/IPGeolocationView.xaml.cs +++ b/Source/NETworkManager/Views/IPGeolocationView.xaml.cs @@ -9,11 +9,11 @@ public partial class IPGeolocationView : IDragablzTabItem { private readonly IPGeolocationViewModel _viewModel; - public IPGeolocationView(Guid tabId, string domain = null) + public IPGeolocationView(Guid tabId, string domain = null, bool start = true) { InitializeComponent(); - _viewModel = new IPGeolocationViewModel(tabId, domain); + _viewModel = new IPGeolocationViewModel(tabId, domain, start); DataContext = _viewModel; diff --git a/Source/NETworkManager/Views/IPScannerHostView.xaml.cs b/Source/NETworkManager/Views/IPScannerHostView.xaml.cs index 6593c40508..bb30ba9bb0 100644 --- a/Source/NETworkManager/Views/IPScannerHostView.xaml.cs +++ b/Source/NETworkManager/Views/IPScannerHostView.xaml.cs @@ -15,9 +15,9 @@ public IPScannerHostView() public void Open(ApplicationRequest request) { if (request.Profile != null) - _viewModel.AddTab(request.Profile); + _viewModel.AddTab(request.Profile, request.Start); else - _viewModel.AddTab(request.Target); + _viewModel.AddTab(request.Target, start: request.Start); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/IPScannerView.xaml.cs b/Source/NETworkManager/Views/IPScannerView.xaml.cs index 322727dc1a..5f497fce09 100644 --- a/Source/NETworkManager/Views/IPScannerView.xaml.cs +++ b/Source/NETworkManager/Views/IPScannerView.xaml.cs @@ -17,11 +17,11 @@ public partial class IPScannerView : IDragablzTabItem { private readonly IPScannerViewModel _viewModel; - public IPScannerView(Guid tabId, string hostOrIPRange = null) + public IPScannerView(Guid tabId, string hostOrIPRange = null, bool start = true) { InitializeComponent(); - _viewModel = new IPScannerViewModel(tabId, hostOrIPRange); + _viewModel = new IPScannerViewModel(tabId, hostOrIPRange, start); DataContext = _viewModel; diff --git a/Source/NETworkManager/Views/LookupHostView.xaml.cs b/Source/NETworkManager/Views/LookupHostView.xaml.cs index ddb51a439c..d7667dbbab 100644 --- a/Source/NETworkManager/Views/LookupHostView.xaml.cs +++ b/Source/NETworkManager/Views/LookupHostView.xaml.cs @@ -24,14 +24,14 @@ public void Open(ApplicationRequest request) TabItemOUILookup.IsSelected = true; if (!string.IsNullOrEmpty(request.Target)) - OUILookupView.Lookup(request.Target); + OUILookupView.Lookup(request.Target, request.Start); break; case LookupType.Port: TabItemPortLookup.IsSelected = true; if (!string.IsNullOrEmpty(request.Target)) - PortLookupView.Lookup(request.Target); + PortLookupView.Lookup(request.Target, request.Start); break; } diff --git a/Source/NETworkManager/Views/LookupOUILookupView.xaml.cs b/Source/NETworkManager/Views/LookupOUILookupView.xaml.cs index 31bbcf1e4a..294366981c 100644 --- a/Source/NETworkManager/Views/LookupOUILookupView.xaml.cs +++ b/Source/NETworkManager/Views/LookupOUILookupView.xaml.cs @@ -15,9 +15,9 @@ public LookupOUILookupView() DataContext = _viewModel; } - public void Lookup(string search) + public void Lookup(string search, bool start) { - _viewModel.Lookup(search); + _viewModel.Lookup(search, start); } private void ContextMenu_Opened(object sender, RoutedEventArgs e) diff --git a/Source/NETworkManager/Views/LookupPortLookupView.xaml.cs b/Source/NETworkManager/Views/LookupPortLookupView.xaml.cs index 416b4614dc..7110d80c32 100644 --- a/Source/NETworkManager/Views/LookupPortLookupView.xaml.cs +++ b/Source/NETworkManager/Views/LookupPortLookupView.xaml.cs @@ -15,9 +15,9 @@ public LookupPortLookupView() DataContext = _viewModel; } - public void Lookup(string search) + public void Lookup(string search, bool start) { - _ = _viewModel.Lookup(search); + _ = _viewModel.Lookup(search, start); } private void ContextMenu_Opened(object sender, RoutedEventArgs e) diff --git a/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs b/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs index 7ff2b61c9b..fcc4350ddd 100644 --- a/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs +++ b/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs @@ -17,9 +17,9 @@ public PingMonitorHostView() public void Open(ApplicationRequest request) { if (request.Profile != null) - _viewModel.AddHost(request.Profile); + _viewModel.AddHost(request.Profile, request.Start); else - _viewModel.AddHost(request.Target); + _viewModel.AddHost(request.Target, request.Start); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/PortScannerHostView.xaml.cs b/Source/NETworkManager/Views/PortScannerHostView.xaml.cs index 99d078cc8c..61166f7bac 100644 --- a/Source/NETworkManager/Views/PortScannerHostView.xaml.cs +++ b/Source/NETworkManager/Views/PortScannerHostView.xaml.cs @@ -15,9 +15,9 @@ public PortScannerHostView() public void Open(ApplicationRequest request) { if (request.Profile != null) - _viewModel.AddTab(request.Profile); + _viewModel.AddTab(request.Profile, request.Start); else - _viewModel.AddTab(request.Target, (request as PortScannerRequest)?.Ports); + _viewModel.AddTab(request.Target, (request as PortScannerRequest)?.Ports, request.Start); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/PortScannerView.xaml.cs b/Source/NETworkManager/Views/PortScannerView.xaml.cs index 4daa36e746..2265162acf 100644 --- a/Source/NETworkManager/Views/PortScannerView.xaml.cs +++ b/Source/NETworkManager/Views/PortScannerView.xaml.cs @@ -10,11 +10,11 @@ public partial class PortScannerView : IDragablzTabItem { private readonly PortScannerViewModel _viewModel; - public PortScannerView(Guid tabId, string host = null, string ports = null) + public PortScannerView(Guid tabId, string host = null, string ports = null, bool start = true) { InitializeComponent(); - _viewModel = new PortScannerViewModel(tabId, host, ports); + _viewModel = new PortScannerViewModel(tabId, host, ports, start); DataContext = _viewModel; diff --git a/Source/NETworkManager/Views/RDAPHostView.xaml.cs b/Source/NETworkManager/Views/RDAPHostView.xaml.cs index 36118d9575..6c03c3c6fd 100644 --- a/Source/NETworkManager/Views/RDAPHostView.xaml.cs +++ b/Source/NETworkManager/Views/RDAPHostView.xaml.cs @@ -15,9 +15,9 @@ public RDAPHostView() public void Open(ApplicationRequest request) { if (request.Profile != null) - _viewModel.AddTab(request.Profile); + _viewModel.AddTab(request.Profile, request.Start); else - _viewModel.AddTab(request.Target, (request as RDAPRequest)?.QueryType); + _viewModel.AddTab(request.Target, (request as RDAPRequest)?.QueryType, request.Start); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/RDAPView.xaml.cs b/Source/NETworkManager/Views/RDAPView.xaml.cs index 2322c645ed..e1d428a5a3 100644 --- a/Source/NETworkManager/Views/RDAPView.xaml.cs +++ b/Source/NETworkManager/Views/RDAPView.xaml.cs @@ -12,11 +12,11 @@ public partial class RDAPView : IDragablzTabItem { private readonly RDAPViewModel _viewModel; - public RDAPView(Guid tabId, string query = null, RDAPQueryType? queryType = null) + public RDAPView(Guid tabId, string query = null, RDAPQueryType? queryType = null, bool start = true) { InitializeComponent(); - _viewModel = new RDAPViewModel(tabId, query, queryType); + _viewModel = new RDAPViewModel(tabId, query, queryType, start); DataContext = _viewModel; diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml b/Source/NETworkManager/Views/RunCommandChildWindow.xaml index 3b90ff6d5a..6615b871a8 100644 --- a/Source/NETworkManager/Views/RunCommandChildWindow.xaml +++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml @@ -5,9 +5,11 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:viewModels="clr-namespace:NETworkManager.ViewModels" xmlns:networkManager="clr-namespace:NETworkManager" + xmlns:views="clr-namespace:NETworkManager.Views" xmlns:models="clr-namespace:NETworkManager.Models;assembly=NETworkManager.Models" xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters" xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" + xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:simpleChildWindow="clr-namespace:MahApps.Metro.SimpleChildWindow;assembly=MahApps.Metro.SimpleChildWindow" xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls" xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" @@ -15,7 +17,6 @@ Title="{x:Static localization:Strings.RunCommand}" CloseButtonCommand="{Binding CancelCommand}" AllowMove="False" - ChildWindowWidth="650" VerticalContentAlignment="Top" Padding="0,80,0,0" Loaded="ChildWindow_OnLoaded" @@ -24,6 +25,10 @@ + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs b/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs index f3ccd7cd0c..e648be736c 100644 --- a/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs +++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs @@ -2,31 +2,122 @@ using System; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Input; +using System.Windows.Media; using System.Windows.Threading; namespace NETworkManager.Views; /// -/// Run command dialog. The input keeps the focus; Up/Down select a suggestion, Tab completes it, Enter runs it and -/// Escape closes the dialog. +/// Run command dialog. The input keeps the focus; Up/Down select a suggestion, Tab completes it, Enter runs it +/// (Ctrl+Enter keeps the dialog open, Shift+Enter only fills in), Shift+Del removes a recent command and Escape +/// closes the dialog. /// public partial class RunCommandChildWindow { - public RunCommandChildWindow() + /// + /// Width of the dialog in relation to the main window, between and + /// . + /// + private const double DialogWidthFactor = 0.5; + + private const double MinDialogWidth = 650; + private const double MaxDialogWidth = 800; + + /// + /// Height of the main window that is not available for the dialog (title bar of the main window, the space + /// above the dialog (padding) and below it). + /// + private const double ReservedHeight = 160; + + /// + /// Maximum height of the dialog: at least the default of the child windows, at most + /// . The suggestions get the height that is left after the other rows and scroll, + /// so the key hints at the bottom stay visible. + /// + private const double MinDialogHeight = 500; + + private const double MaxDialogHeight = 900; + + /// + /// Width of the dialog that is not available for the text on the right of a suggestion (e.g. the syntax of a + /// command): icon, name and description, margins and scroll bar. + /// + private const double ReservedInfoWidth = 300; + + /// + /// Maximum width of the text on the right of a suggestion, grows with the dialog (see ). + /// + public static readonly DependencyProperty InfoMaxWidthProperty = DependencyProperty.Register( + nameof(InfoMaxWidth), typeof(double), typeof(RunCommandChildWindow), new PropertyMetadata(250.0)); + + public double InfoMaxWidth + { + get => (double)GetValue(InfoMaxWidthProperty); + set => SetValue(InfoMaxWidthProperty, value); + } + + private readonly Window _parentWindow; + + public RunCommandChildWindow(Window parentWindow) { InitializeComponent(); + + _parentWindow = parentWindow; + + UpdateSize(); + + // Update the size when the main window is resized (while the dialog is open) + _parentWindow.SizeChanged += ParentWindow_SizeChanged; + Unloaded += (_, _) => + { + _parentWindow.SizeChanged -= ParentWindow_SizeChanged; + ToolTipInvalid.IsOpen = false; + }; + } + + private void ParentWindow_SizeChanged(object sender, SizeChangedEventArgs e) + { + UpdateSize(); + } + + /// + /// Uses more space for the dialog and the suggestions in a large main window. + /// + private void UpdateSize() + { + ChildWindowWidth = Math.Clamp(_parentWindow.ActualWidth * DialogWidthFactor, MinDialogWidth, MaxDialogWidth); + InfoMaxWidth = ChildWindowWidth - ReservedInfoWidth; + ChildWindowMaxHeight = Math.Clamp(_parentWindow.ActualHeight - ReservedHeight, MinDialogHeight, + MaxDialogHeight); } private RunCommandViewModel ViewModel => (RunCommandViewModel)DataContext; private void ChildWindow_OnLoaded(object sender, RoutedEventArgs e) { - Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(delegate { TextBoxInput.Focus(); })); + FocusInput(); + } + + /// + /// Focuses the input with the caret at the end, after the opened application (if the dialog is kept open) is + /// loaded and may have taken the focus. + /// + private void FocusInput() + { + Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(delegate + { + TextBoxInput.Focus(); + TextBoxInput.CaretIndex = TextBoxInput.Text.Length; + })); } private void TextBoxInput_OnPreviewKeyDown(object sender, KeyEventArgs e) { + // The error of a failed run is shown until the next key. + ToolTipInvalid.IsOpen = false; + switch (e.Key) { case Key.Down: @@ -40,7 +131,11 @@ private void TextBoxInput_OnPreviewKeyDown(object sender, KeyEventArgs e) TextBoxInput.CaretIndex = TextBoxInput.Text.Length; break; case Key.Enter: - ViewModel.Run(); + RunAndShowError(); + break; + // Only for a recent command, otherwise Shift+Del cuts the text. + case Key.Delete when Keyboard.Modifiers == ModifierKeys.Shift && ViewModel.CanRemoveSelectedSuggestion: + ViewModel.RemoveSuggestion(ViewModel.SelectedSuggestion); break; case Key.Escape: ViewModel.CancelCommand.Execute(null); @@ -54,16 +149,83 @@ private void TextBoxInput_OnPreviewKeyDown(object sender, KeyEventArgs e) private void ListBoxSuggestions_OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) { + // A button in the suggestion (e.g. remove from history) handles the click itself. + if (IsInButton((DependencyObject)e.OriginalSource)) + return; + if (ItemsControl.ContainerFromElement(ListBoxSuggestions, (DependencyObject)e.OriginalSource) is not ListBoxItem item) return; ViewModel.SelectedSuggestion = (RunCommandSuggestion)item.DataContext; - ViewModel.Run(); - // The dialog stays open if the input is not valid (otherwise the opened application or dialog gets the focus). + RunAndShowError(); + } + + /// + /// Runs the selected suggestion or the input (Enter or click), with Ctrl to keep the dialog open and with Shift to + /// only fill in. The dialog stays open if the input is not valid (the error is shown) or with Ctrl, then the input + /// gets the focus again (otherwise the opened application or dialog gets the focus). + /// + private void RunAndShowError() + { + var success = ViewModel.Run(Keyboard.Modifiers switch + { + ModifierKeys.Control => RunCommandRunMode.RunAndKeepOpen, + ModifierKeys.Shift => RunCommandRunMode.FillIn, + _ => RunCommandRunMode.Run + }); + + if (!success) + ShowError(); + if (IsOpen) - TextBoxInput.Focus(); + FocusInput(); + } + + /// + /// Opens the tooltip of the warning icon with the error of a failed run, so it is visible without hovering. + /// + private void ShowError() + { + // After the layout, since the icon may have just been shown. + Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(delegate + { + if (!RectangleInvalid.IsVisible) + return; + + ToolTipInvalid.PlacementTarget = RectangleInvalid; + ToolTipInvalid.Placement = PlacementMode.Bottom; + ToolTipInvalid.IsOpen = true; + })); + } + + private void RectangleInvalid_OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) + { + // The input is valid again or was cleared (e.g. with the clear button). + if (!RectangleInvalid.IsVisible) + ToolTipInvalid.IsOpen = false; + } + + private void ButtonRemoveSuggestion_OnClick(object sender, RoutedEventArgs e) + { + ViewModel.RemoveSuggestion((RunCommandSuggestion)((FrameworkElement)sender).DataContext); + + TextBoxInput.Focus(); + } + + private bool IsInButton(DependencyObject element) + { + while (element != null && element != ListBoxSuggestions) + { + if (element is Button) + return true; + + // Text elements (e.g. a Run in a TextBlock) are not visuals + element = element is Visual ? VisualTreeHelper.GetParent(element) : LogicalTreeHelper.GetParent(element); + } + + return false; } private void ListBoxSuggestions_OnSelectionChanged(object sender, SelectionChangedEventArgs e) diff --git a/Source/NETworkManager/Views/SettingsGeneralView.xaml b/Source/NETworkManager/Views/SettingsGeneralView.xaml index 4215a2dc17..e980fcece7 100644 --- a/Source/NETworkManager/Views/SettingsGeneralView.xaml +++ b/Source/NETworkManager/Views/SettingsGeneralView.xaml @@ -186,6 +186,12 @@ - + + + + \ No newline at end of file diff --git a/Source/NETworkManager/Views/SubnetCalculatorCalculatorView.xaml.cs b/Source/NETworkManager/Views/SubnetCalculatorCalculatorView.xaml.cs index aac5380a76..ca1d865454 100644 --- a/Source/NETworkManager/Views/SubnetCalculatorCalculatorView.xaml.cs +++ b/Source/NETworkManager/Views/SubnetCalculatorCalculatorView.xaml.cs @@ -12,8 +12,8 @@ public SubnetCalculatorCalculatorView() DataContext = _viewModel; } - public void Calculate(string subnet) + public void Calculate(string subnet, bool start) { - _viewModel.Calculate(subnet); + _viewModel.Calculate(subnet, start); } } \ No newline at end of file diff --git a/Source/NETworkManager/Views/SubnetCalculatorHostView.xaml.cs b/Source/NETworkManager/Views/SubnetCalculatorHostView.xaml.cs index 28f7057ba2..61dc6489ee 100644 --- a/Source/NETworkManager/Views/SubnetCalculatorHostView.xaml.cs +++ b/Source/NETworkManager/Views/SubnetCalculatorHostView.xaml.cs @@ -19,17 +19,17 @@ public void Open(ApplicationRequest request) if (subnetCalculatorRequest?.NewSubnetmask != null) { TabItemSubnetting.IsSelected = true; - SubnettingView.Calculate(request.Target, subnetCalculatorRequest.NewSubnetmask); + SubnettingView.Calculate(request.Target, subnetCalculatorRequest.NewSubnetmask, request.Start); } else if (subnetCalculatorRequest?.Subnet2 != null) { TabItemWideSubnet.IsSelected = true; - WideSubnetView.Calculate(request.Target, subnetCalculatorRequest.Subnet2); + WideSubnetView.Calculate(request.Target, subnetCalculatorRequest.Subnet2, request.Start); } else { TabItemCalculator.IsSelected = true; - CalculatorView.Calculate(request.Target); + CalculatorView.Calculate(request.Target, request.Start); } } diff --git a/Source/NETworkManager/Views/SubnetCalculatorSubnettingView.xaml.cs b/Source/NETworkManager/Views/SubnetCalculatorSubnettingView.xaml.cs index 120a91db3b..de11eff792 100644 --- a/Source/NETworkManager/Views/SubnetCalculatorSubnettingView.xaml.cs +++ b/Source/NETworkManager/Views/SubnetCalculatorSubnettingView.xaml.cs @@ -17,9 +17,9 @@ public SubnetCalculatorSubnettingView() Dispatcher.ShutdownStarted += Dispatcher_ShutdownStarted; } - public void Calculate(string subnet, string newSubnetmask) + public void Calculate(string subnet, string newSubnetmask, bool start) { - _ = _viewModel.Calculate(subnet, newSubnetmask); + _ = _viewModel.Calculate(subnet, newSubnetmask, start); } private void Dispatcher_ShutdownStarted(object sender, EventArgs e) diff --git a/Source/NETworkManager/Views/SubnetCalculatorWideSubnetView.xaml.cs b/Source/NETworkManager/Views/SubnetCalculatorWideSubnetView.xaml.cs index 9a51523ece..3e5b368b3f 100644 --- a/Source/NETworkManager/Views/SubnetCalculatorWideSubnetView.xaml.cs +++ b/Source/NETworkManager/Views/SubnetCalculatorWideSubnetView.xaml.cs @@ -15,9 +15,9 @@ public SubnetCalculatorWideSubnetView() Dispatcher.ShutdownStarted += Dispatcher_ShutdownStarted; } - public void Calculate(string subnet1, string subnet2) + public void Calculate(string subnet1, string subnet2, bool start) { - _viewModel.Calculate(subnet1, subnet2); + _viewModel.Calculate(subnet1, subnet2, start); } private void Dispatcher_ShutdownStarted(object sender, EventArgs e) diff --git a/Source/NETworkManager/Views/TracerouteHostView.xaml.cs b/Source/NETworkManager/Views/TracerouteHostView.xaml.cs index 84c6255eee..35c325fb2d 100644 --- a/Source/NETworkManager/Views/TracerouteHostView.xaml.cs +++ b/Source/NETworkManager/Views/TracerouteHostView.xaml.cs @@ -15,9 +15,9 @@ public TracerouteHostView() public void Open(ApplicationRequest request) { if (request.Profile != null) - _viewModel.AddTab(request.Profile); + _viewModel.AddTab(request.Profile, request.Start); else - _viewModel.AddTab(request.Target); + _viewModel.AddTab(request.Target, start: request.Start); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/TracerouteView.xaml.cs b/Source/NETworkManager/Views/TracerouteView.xaml.cs index 24bdd78923..4cc94e8186 100644 --- a/Source/NETworkManager/Views/TracerouteView.xaml.cs +++ b/Source/NETworkManager/Views/TracerouteView.xaml.cs @@ -19,11 +19,11 @@ public partial class TracerouteView : IDragablzTabItem // window size. private const double MapRowMaxHeightFraction = 2.0 / 3.0; - public TracerouteView(Guid tabId, string host = null) + public TracerouteView(Guid tabId, string host = null, bool start = true) { InitializeComponent(); - _viewModel = new TracerouteViewModel(tabId, host); + _viewModel = new TracerouteViewModel(tabId, host, start); DataContext = _viewModel; diff --git a/Source/NETworkManager/Views/WakeOnLANView.xaml.cs b/Source/NETworkManager/Views/WakeOnLANView.xaml.cs index 6e5977a651..2728e3dba5 100644 --- a/Source/NETworkManager/Views/WakeOnLANView.xaml.cs +++ b/Source/NETworkManager/Views/WakeOnLANView.xaml.cs @@ -15,9 +15,9 @@ public WakeOnLANView() public void Open(ApplicationRequest request) { if (request.Profile != null) - _viewModel.WakeUp(request.Profile); + _viewModel.WakeUp(request.Profile, request.Start); else - _viewModel.WakeUp(request.Target, (request as WakeOnLANRequest)?.Broadcast); + _viewModel.WakeUp(request.Target, (request as WakeOnLANRequest)?.Broadcast, request.Start); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/WhoisHostView.xaml.cs b/Source/NETworkManager/Views/WhoisHostView.xaml.cs index ba30c03c69..2340985f7b 100644 --- a/Source/NETworkManager/Views/WhoisHostView.xaml.cs +++ b/Source/NETworkManager/Views/WhoisHostView.xaml.cs @@ -15,9 +15,9 @@ public WhoisHostView() public void Open(ApplicationRequest request) { if (request.Profile != null) - _viewModel.AddTab(request.Profile); + _viewModel.AddTab(request.Profile, request.Start); else - _viewModel.AddTab(request.Target); + _viewModel.AddTab(request.Target, start: request.Start); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/WhoisView.xaml.cs b/Source/NETworkManager/Views/WhoisView.xaml.cs index de23752f5a..372f6f5835 100644 --- a/Source/NETworkManager/Views/WhoisView.xaml.cs +++ b/Source/NETworkManager/Views/WhoisView.xaml.cs @@ -9,11 +9,11 @@ public partial class WhoisView : IDragablzTabItem { private readonly WhoisViewModel _viewModel; - public WhoisView(Guid tabId, string domain = null) + public WhoisView(Guid tabId, string domain = null, bool start = true) { InitializeComponent(); - _viewModel = new WhoisViewModel(tabId, domain); + _viewModel = new WhoisViewModel(tabId, domain, start); DataContext = _viewModel; diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index c044eba8e8..702b6c891a 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -30,6 +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`). [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX) - 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). [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX) +- 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. [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX) **RDAP** diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md index 8f384f23c5..71664e2b47 100644 --- a/Website/docs/run-command.md +++ b/Website/docs/run-command.md @@ -8,7 +8,7 @@ keywords: [NETworkManager, run command, command palette, keyboard shortcut, Ctrl The **Run Command** opens a feature and runs it directly from the keyboard: press `Ctrl+Shift+P`, type a command like `ping 10.0.0.1` and press `Enter`. -The run command can also be opened with the **Run command...** button in the title bar, or from anywhere with a global hotkey (default `Ctrl+Alt+P`, disabled by default), which brings the window to the foreground, see [Settings > HotKeys](./settings/hotkeys.md). +The run command can also be opened with the **Run command...** box in the center of the title bar, or from anywhere with a global hotkey (default `Ctrl+Alt+P`, disabled by default), which brings the window to the foreground, see [Settings > HotKeys](./settings/hotkeys.md). ## Syntax @@ -28,7 +28,7 @@ The input is validated like the input field of the feature (e.g. a valid host or | Command | Aliases | Arguments | Example | | ------------------ | ------------------ | ----------------------------------- | ---------------------------------------- | -| `ipscanner` | `scan` | `` | `scan 10.0.0.0/24` | +| `ipscanner` | `ipscan` | `` | `ipscan 10.0.0.0/24` | | `portscanner` | `portscan` | ` [ports]` | `portscan 10.0.0.1 22; 80; 443` | | `pingmonitor` | `ping` | `` | `ping 10.0.0.1; 10.0.0.2` | | `traceroute` | `trace`, `tracert` | `` | `trace borntoberoot.net` | @@ -60,26 +60,60 @@ All features that accept a target also accept a profile (`@profile`), except the ::: +## Target without a command + +Type only the target (e.g. `10.0.0.1`) to get the commands that accept it, e.g. `ping 10.0.0.1`, `trace 10.0.0.1`, `dns 10.0.0.1`. Commands that were used with the same kind of target before (e.g. IP addresses) come first, otherwise they are in the order of the table. `Enter` runs the first command, which is selected; if it was already run with this target, it is shown under the recent commands, which are listed above. A host without a domain and a port are not selected, since they can also be a mistyped command or the start of an IP address (e.g. `192`). A shortened or partly typed IP address (e.g. `192.168.1`) is not a target. + +| Target | Suggested commands | +| --------------------------------------------------- | ----------------------------------------------------------------------------------- | +| IP address or host with a domain (`server.lan`) | Ping Monitor, Traceroute, DNS Lookup, Port Scanner, remote tools, IP Geolocation, … | +| Host without a domain (`server01`) | Ping Monitor, Traceroute, DNS Lookup, Port Scanner, remote tools, SNMP, IP Scanner | +| Several hosts (`10.0.0.1; 10.0.0.2`) | Ping Monitor, DNS Lookup, Port Scanner, IP Scanner | +| IP range (`10.0.0.1-100`) or subnet (`10.0.0.0/24`) | IP Scanner, Subnet Calculator (subnet), Port Scanner, Ping Monitor, RDAP (subnet) | +| MAC address (`00:11:22:33:44:55`) | Wake on LAN, `lookup oui` | +| First 3 bytes of a MAC address (`00:11:22`) | `lookup oui` | +| Port(s) (`22`, `80-90/tcp`) | `lookup port` | +| AS number (`AS13335`) | RDAP | +| URL (`https://...`) | Web Console | + +A command always has priority (e.g. `web` is the Web Console). A host without a domain (e.g. `server01`) can also be a mistyped command, so its commands are only suggested if no command starts with the word (e.g. not for `pi`), and none is selected: select one with the arrow keys. + +**Clipboard:** If enabled in [Settings > General](./settings/general.md#suggest-the-clipboard-content-as-target), the commands for a target in the clipboard (e.g. a copied IP address) are suggested when the dialog is opened (up to 3), and `Enter` runs the first one. While typing the command, the commands for the clipboard that start with it are suggested (e.g. `pi` → `ping 10.0.0.1`). The clipboard is read when the dialog is opened; a single word (host without a domain) is not suggested. Disabled by default. + +## Dialog + +- **Icon:** The icon of the recognized feature is shown in front of the input. +- **Syntax:** Below the input, the syntax of the command is shown; the part that is being typed is highlighted (e.g. `[DNS server]`). After the target, the argument comes before the type (e.g. DNS server, then record type), unless a complete type is typed (e.g. `aaaa`). +- **Values:** The values as they were understood are shown (e.g. `Hosts: borntoberoot.net` · `DNS server: 1.1.1.1` · `Type: AAAA`). +- **Validation:** While typing, what `Enter` runs is validated: a green check mark for valid input, or a red warning icon with the error as tooltip (e.g. an invalid host). If `Enter` is pressed with an invalid input, the dialog stays open and the tooltip with the error is shown until the next key. +- **Size:** The dialog and the suggestions use more space in a large window. + ## Suggestions -While typing, suggestions are shown below the input: +While typing, suggestions are shown below the input, grouped (e.g. _Recent_, _Profiles_, _Type_). The typed text is highlighted. | Input | Suggestions | | ---------------------------- | ------------------------------------------------------------------------ | -| _empty_ | Recently run commands and all features | -| Command (e.g. `tr`) | Matching features | -| After the command (`rdp `) | The input as typed, recent commands of the feature and its profiles | -| After `lookup ` | The input as typed, the lookups (`oui`, `port`) and recent commands | +| _empty_ | Commands for the clipboard (if enabled), recently run commands, examples (without recent commands) and all features | +| Command (e.g. `tr`) | Matching features, recent commands that contain the word (e.g. a host) and commands for the clipboard (if enabled) that start with it | +| Target (e.g. `10.0.0.1`) | Recent commands that contain the target and the commands that accept it, see [Target without a command](#target-without-a-command) | +| After the command (`rdp `) | Recent commands of the feature and its profiles | +| After `lookup ` | The lookups (`oui`, `port`) and recent commands | | `@` (e.g. `rdp @web`) | Profiles of the feature (with group and host); exact names first, recently run profiles first within the same match | -| After the target | The input as typed, types of the feature (e.g. DNS record types), configured DNS servers (DNS lookup) and recent commands | +| After the target | Configured DNS servers (DNS lookup), types of the feature (e.g. DNS record types) and recent commands, up to 10 of each | + +`Enter` runs what was typed if no suggestion is selected: after the command, nothing is selected; profiles, recent commands, types and DNS servers are selected with the arrow keys (`Up` on the first suggestion selects none again). While typing the command, the best matching feature is selected (completing it keeps the arguments, e.g. `pin 10.0.0.1` → `ping 10.0.0.1`), but not if an unknown command has arguments; for a target without a command, see [Target without a command](#target-without-a-command). With an empty input, only the first command for the clipboard is selected (if enabled). While typing `@`, a profile is only selected if exactly one matches. While typing the lookup (e.g. `lookup po`), the matching lookup is selected. -`Enter` runs what was typed: after the command, the input as typed is the first suggestion; profiles, recent commands, types and DNS servers are selected with the arrow keys. While typing the command, the best matching feature is selected (completing it keeps the arguments, e.g. `pin 10.0.0.1` → `ping 10.0.0.1`), but not if the input is empty or an unknown command has arguments. While typing `@`, a profile is only selected if exactly one matches. While typing the lookup (e.g. `lookup po`), the matching lookup is selected. +| Key | Action | +| ------------- | ----------------------------------------------------------------------------------------------- | +| `Up`, `Down` | Select a suggestion | +| `Tab` | Insert the selected suggestion into the input. If none is selected, the first DNS server or type that matches the typed word (e.g. `dns borntoberoot.net c` → `cloudflare`) | +| `Enter` | Run the selected suggestion, or the input if none is selected | +| `Ctrl+Enter` | Run and keep the dialog open, with the command in the input (e.g. `ping `) to run it with the next target. Commands that open a dialog (connect dialog of the remote tools, settings, Subnetting, which can ask for confirmation) close the dialog. | +| `Shift+Enter` | Open the feature with the input filled in, without starting it (e.g. to adjust the options of a scan). The remote tools open the connect dialog (target) or connect (profile) as with `Enter`. | +| `Shift+Del` | Remove the selected recent command from the history (or click the remove button of a recent command) | +| `Esc` | Close the dialog (or the close button) | -| Key | Action | -| ------------ | ---------------------------------------------- | -| `Up`, `Down` | Select a suggestion | -| `Tab` | Insert the selected suggestion into the input | -| `Enter` | Run the selected suggestion | -| `Esc` | Close the dialog (or the close button) | +The keys are also shown at the bottom of the dialog (can be hidden in [Settings > General](./settings/general.md#show-key-hints)). Run commands are saved in a history (the number of entries is set in [Settings > General](./settings/general.md)). diff --git a/Website/docs/settings/general.md b/Website/docs/settings/general.md index 7b10fb579c..b0e70186d2 100644 --- a/Website/docs/settings/general.md +++ b/Website/docs/settings/general.md @@ -47,3 +47,19 @@ Maximum number of entries stored in the history for several application inputs. Type: `Integer` Default: `5` [Min `0`, Max `25`] + +### Suggest the clipboard content as target + +Suggest the commands for a target in the clipboard (e.g. a copied IP address, subnet or MAC address) when the [run command](../run-command.md#target-without-a-command) is opened. `Enter` runs the first one. While typing a command, the commands for the clipboard that start with it are suggested (e.g. `pi` → `ping 10.0.0.1`). A single word (host without a domain) is not suggested. The clipboard is only read when the run command is opened and is not stored. + +Type: `Boolean` + +Default: `Disabled` + +### Show key hints + +Show the keys (e.g. `Tab`, `Ctrl+Enter`) at the bottom of the [run command](../run-command.md#suggestions). + +Type: `Boolean` + +Default: `Enabled` From b2afde8fb5af493465ef626ecc397b3d236f042c Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:17:23 +0200 Subject: [PATCH 05/10] Update next-release.md --- Website/docs/changelog/next-release.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 702b6c891a..2b4bd6665b 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -28,9 +28,9 @@ Release date: **xx.xx.2026** **Run command** - 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`). [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX) -- 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). [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX) -- 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. [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX) +- 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 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** @@ -189,7 +189,7 @@ Release date: **xx.xx.2026** **Subnet Calculator** -- Fixed **Subnetting** getting stuck when the new subnet mask is smaller than the subnet mask of the subnet (e.g. `10.0.0.0/24` with `/16`) or too large for the address family (e.g. `/40` for IPv4). An error is shown instead. [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX) +- Fixed **Subnetting** getting stuck when the new subnet mask is smaller than the subnet mask of the subnet (e.g. `10.0.0.0/24` with `/16`) or too large for the address family (e.g. `/40` for IPv4). An error is shown instead. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) ## Dependencies, Refactoring & Documentation From d62fa46507cf30801c3e300e095cacbe0cc90bfa Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:57:17 +0200 Subject: [PATCH 06/10] Fix: input validation --- .../RunCommand/RunCommandTarget.cs | 74 +++----- .../Utilities/RegexHelperTests.cs | 160 ++++++++++++++++++ .../NETworkManager.Utilities/RegexHelper.cs | 110 ++++++++++-- .../RemoteDesktopHostnameAndPortValidator.cs | 13 +- .../ViewModels/LookupPortViewModel.cs | 9 +- Website/docs/changelog/next-release.md | 9 + 6 files changed, 308 insertions(+), 67 deletions(-) create mode 100644 Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs index 5044abd8c0..c5dd8563a5 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Net; using System.Text.RegularExpressions; +using NETworkManager.Utilities; namespace NETworkManager.Models.RunCommand; @@ -10,40 +11,8 @@ namespace NETworkManager.Models.RunCommand; /// (e.g. "10.0.0.1" → ping, traceroute, ...). A single word without a domain is /// , since it can also be a mistyped command. /// -public static partial class RunCommandTarget +public static class RunCommandTarget { - [GeneratedRegex("^https?://", RegexOptions.IgnoreCase)] - private static partial Regex UrlRegex(); - - [GeneratedRegex("^(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}$|^[0-9a-f]{12}$|^(?:[0-9a-f]{4}\\.){2}[0-9a-f]{4}$", - RegexOptions.IgnoreCase)] - private static partial Regex MACAddressRegex(); - - [GeneratedRegex("^(?:[0-9a-f]{2}[:-]){2}[0-9a-f]{2}$", RegexOptions.IgnoreCase)] - private static partial Regex OUIRegex(); - - [GeneratedRegex("^\\d{1,5}(?:-\\d{1,5})?(?:/(?:tcp|udp|sctp))?(?:;\\d{1,5}(?:-\\d{1,5})?(?:/(?:tcp|udp|sctp))?)*$", - RegexOptions.IgnoreCase)] - private static partial Regex PortsRegex(); - - [GeneratedRegex("^as\\d{1,10}$", RegexOptions.IgnoreCase)] - private static partial Regex ASNRegex(); - - [GeneratedRegex("^[0-9.\\-\\[\\],]+$")] - private static partial Regex IPv4RangeRegex(); - - // The last label (top-level domain) has at least one letter, so a shortened or partly typed IPv4 address (e.g. - // "192.168.1") is not a host name. - [GeneratedRegex( - "^(?=.{1,253}\\.?$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+" + - "(?=[a-z0-9-]*[a-z])[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.?$", - RegexOptions.IgnoreCase)] - private static partial Regex HostnameWithDomainRegex(); - - // A label with at least one letter (only digits is not a host name) - [GeneratedRegex("^(?=[a-z0-9-]*[a-z])[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", RegexOptions.IgnoreCase)] - private static partial Regex HostnameRegex(); - /// /// Gets the kind of the target. /// @@ -54,19 +23,20 @@ public static RunCommandTargetKind GetKind(string target) if (string.IsNullOrEmpty(target) || target.Any(char.IsWhiteSpace)) return RunCommandTargetKind.None; - if (UrlRegex().IsMatch(target)) + if (RegexHelper.HttpOrHttpsSchemeRegex().IsMatch(target)) return Uri.TryCreate(target, UriKind.Absolute, out _) ? RunCommandTargetKind.URL : RunCommandTargetKind.None; - if (MACAddressRegex().IsMatch(target)) + if (Regex.IsMatch(target, RegexHelper.MACAddressRegex)) return RunCommandTargetKind.MACAddress; - if (OUIRegex().IsMatch(target)) + // Only with separators: 6 hex digits without separators (e.g. "facade") are more likely a word. + if (RegexHelper.MACAddressFirst3BytesWithSeparatorRegex().IsMatch(target)) return RunCommandTargetKind.OUI; - if (PortsRegex().IsMatch(target)) + if (RegexHelper.PortLookupEntriesRegex().IsMatch(target)) return RunCommandTargetKind.Ports; - if (ASNRegex().IsMatch(target)) + if (RegexHelper.ASNumberWithPrefixRegex().IsMatch(target)) return RunCommandTargetKind.ASN; // Several hosts, IP ranges or subnets @@ -80,26 +50,32 @@ public static RunCommandTargetKind GetKind(string target) : RunCommandTargetKind.None; } - // Subnet with CIDR or subnet mask (e.g. 10.0.0.0/24, 10.0.0.0/255.255.255.0, 2001:db8::/32) - var slash = target.IndexOf('/'); - - if (slash > 0) - return IPAddress.TryParse(target[..slash], out _) && - (int.TryParse(target[(slash + 1)..], out _) || IPAddress.TryParse(target[(slash + 1)..], out _)) + // Subnet with CIDR or subnet mask (e.g. 10.0.0.0/24, 10.0.0.0/255.255.255.0, 2001:db8::/32), like the input + // validation of the tools + if (target.Contains('/')) + return RegexHelper.IPv4AddressCidrRegex().IsMatch(target) || + RegexHelper.IPv4AddressSubnetmaskRegex().IsMatch(target) || + Regex.IsMatch(target, RegexHelper.IPv6AddressCidrRegex) ? RunCommandTargetKind.Subnet : RunCommandTargetKind.None; - // IPv4 range (e.g. 10.0.0.1-10.0.0.100, 10.0.0.1-100, 10.0.[0-1].1) - if ((target.Contains('-') || target.Contains('[')) && target.Contains('.') && IPv4RangeRegex().IsMatch(target)) + // IPv4 range (e.g. 10.0.0.1-10.0.0.100, 10.0.0.1-100, 10.0.[0-1].1). The special range also matches an IPv4 + // address without a range, so only with "-" or "[". + if ((target.Contains('-') || target.Contains('[')) && + (RegexHelper.IPv4AddressRangeRegex().IsMatch(target) || + RegexHelper.IPv4AddressShortRangeRegex().IsMatch(target) || + RegexHelper.IPv4AddressSpecialRangeRegex().IsMatch(target))) return RunCommandTargetKind.IPRange; // IP address: IPv4 with all 4 parts (a shortened or partly typed form like "10.1" is not a target) or IPv6 if (IPAddress.TryParse(target, out _) && (target.Count(x => x == '.') == 3 || target.Contains(':'))) return RunCommandTargetKind.Host; - if (HostnameWithDomainRegex().IsMatch(target)) - return RunCommandTargetKind.Host; + // Host name with or without a domain (a shortened or partly typed IPv4 address like "192.168.1" is not a host + // name, since the last label must contain a letter) + if (!RegexHelper.HostnameOrDomainRegex().IsMatch(target)) + return RunCommandTargetKind.None; - return HostnameRegex().IsMatch(target) ? RunCommandTargetKind.Hostname : RunCommandTargetKind.None; + return target.TrimEnd('.').Contains('.') ? RunCommandTargetKind.Host : RunCommandTargetKind.Hostname; } } diff --git a/Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs b/Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs new file mode 100644 index 0000000000..ffdebfd032 --- /dev/null +++ b/Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs @@ -0,0 +1,160 @@ +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; +using NETworkManager.Utilities; +using Xunit; + +namespace NETworkManager.Tests.Utilities; + +public class RegexHelperTests +{ + [Theory] + [InlineData("00:11:22:33:44:55", true)] + [InlineData("00-11-22-33-44-55", true)] + [InlineData("001122334455", true)] + [InlineData("0011.2233.4455", true)] + [InlineData("0011 2233 4455", false)] + [InlineData("0011x2233x4455", false)] + [InlineData("00:11:22:33:44", false)] + public void MACAddress(string input, bool isMatch) + { + Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.MACAddressRegex)); + } + + [Theory] + [InlineData("00:11:22", true)] + [InlineData("001122", true)] + [InlineData("0011.22", true)] + [InlineData("0011 22", false)] + [InlineData("0011x22", false)] + public void MACAddressFirst3Bytes(string input, bool isMatch) + { + Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.MACAddressFirst3BytesRegex)); + } + + [Theory] + [InlineData("2001:db8::/32", true)] + [InlineData("::ffff:192.168.0.1/96", true)] + [InlineData("sss2001:db8::/32", false)] + [InlineData("2001:db8::/129", false)] + public void IPv6AddressCidr(string input, bool isMatch) + { + Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.IPv6AddressCidrRegex)); + } + + [Theory] + [InlineData("1", true)] + [InlineData("3389", true)] + [InlineData("65535", true)] + [InlineData("", false)] + [InlineData("0", false)] + [InlineData("65536", false)] + [InlineData("05555", false)] + public void Port(string input, bool isMatch) + { + Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.PortRegex)); + } + + [Theory] + [InlineData("server-01", true)] + [InlineData("server-01.example.com", true)] + [InlineData("example.com.", true)] + [InlineData("123abc", true)] + [InlineData("-server", false)] + [InlineData("server-", false)] + [InlineData("server.-bad-.com", false)] + [InlineData("a..b", false)] + // IPv4 addresses, shortened or partly typed ones are not hostnames (checked separately) + [InlineData("10.0.0.1", false)] + [InlineData("10.0.0.", false)] + [InlineData("192.168.1", false)] + [InlineData("1234", false)] + public void HostnameOrDomain(string input, bool isMatch) + { + Assert.Equal(isMatch, RegexHelper.HostnameOrDomainRegex().IsMatch(input)); + } + + [Theory] + [InlineData("borntoberoot.net", true)] + [InlineData("local.example.com", true)] + [InlineData("example", false)] + public void Domain(string input, bool isMatch) + { + Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.DomainRegex)); + } + + [Theory] + [InlineData("22", true)] + [InlineData("22/tcp", true)] + [InlineData("22/TCP", true)] + [InlineData("80-90", true)] + [InlineData("80-90/udp", true)] + [InlineData("22;80-90/tcp;443", true)] + [InlineData("22;", false)] + [InlineData("ssh", false)] + [InlineData("22/http", false)] + [InlineData("123456", false)] + public void PortLookupEntries(string input, bool isMatch) + { + Assert.Equal(isMatch, RegexHelper.PortLookupEntriesRegex().IsMatch(input)); + } + + [Theory] + [InlineData("22", true, false, false, false)] + [InlineData("22/tcp", false, true, false, false)] + [InlineData("80-90", false, false, true, false)] + [InlineData("80-90/sctp", false, false, false, true)] + [InlineData("ssh", false, false, false, false)] + public void PortLookupEntry(string input, bool port, bool portAndProtocol, bool portRange, + bool portRangeAndProtocol) + { + Assert.Equal(port, RegexHelper.PortLookupPortRegex().IsMatch(input)); + Assert.Equal(portAndProtocol, RegexHelper.PortLookupPortAndProtocolRegex().IsMatch(input)); + Assert.Equal(portRange, RegexHelper.PortLookupPortRangeRegex().IsMatch(input)); + Assert.Equal(portRangeAndProtocol, RegexHelper.PortLookupPortRangeAndProtocolRegex().IsMatch(input)); + } + + [Theory] + [InlineData("00:11:22", true)] + [InlineData("00-11-22", true)] + [InlineData("001122", false)] + [InlineData("facade", false)] + public void MACAddressFirst3BytesWithSeparator(string input, bool isMatch) + { + Assert.Equal(isMatch, RegexHelper.MACAddressFirst3BytesWithSeparatorRegex().IsMatch(input)); + } + + [Theory] + [InlineData("AS13335", true)] + [InlineData("as13335", true)] + [InlineData("13335", false)] + [InlineData("AS", false)] + public void ASNumberWithPrefix(string input, bool isMatch) + { + Assert.Equal(isMatch, RegexHelper.ASNumberWithPrefixRegex().IsMatch(input)); + } + + [Theory] + [InlineData("https://borntoberoot.net", true)] + [InlineData("HTTP://borntoberoot.net", true)] + [InlineData("ftp://borntoberoot.net", false)] + [InlineData("borntoberoot.net", false)] + public void HttpOrHttpsScheme(string input, bool isMatch) + { + Assert.Equal(isMatch, RegexHelper.HttpOrHttpsSchemeRegex().IsMatch(input)); + } + + // An invalid input with many labels took exponentially long (catastrophic backtracking). + [Fact] + public void DomainAndHostnameNoBacktracking() + { + var input = "a" + string.Concat(Enumerable.Repeat(".a", 100)) + "!"; + + var stopwatch = Stopwatch.StartNew(); + + Assert.DoesNotMatch(RegexHelper.DomainRegex, input); + Assert.DoesNotMatch(RegexHelper.HostnameOrDomainRegex(), input); + + Assert.True(stopwatch.ElapsedMilliseconds < 1000); + } +} diff --git a/Source/NETworkManager.Utilities/RegexHelper.cs b/Source/NETworkManager.Utilities/RegexHelper.cs index 93a45adaa9..fbbd5ed465 100644 --- a/Source/NETworkManager.Utilities/RegexHelper.cs +++ b/Source/NETworkManager.Utilities/RegexHelper.cs @@ -21,11 +21,19 @@ public static partial class RegexHelper /// private const string CidrRegexValues = @"([1-9]|[1-2][0-9]|3[0-2])"; + /// + /// Represents a regular expression pattern that matches a label of a hostname (1-63 characters, letters, digits and + /// hyphens, not starting or ending with a hyphen). + /// + private const string HostnameLabelValues = @"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"; + /// /// Represents a regular expression pattern that matches valid hostnames or fully qualified domain names (FQDNs). + /// Every label follows . The last label (or the hostname) must contain a letter, + /// so IPv4 addresses, shortened or partly typed ones (e.g. "10.0.0.", "192.168.1") are not hostnames. /// private const string HostnameOrDomainValues = - @"(?=.{1,255}$)(?!-)[A-Za-z0-9-]{1,63}(? /// Provides a compiled regular expression that matches valid IPv4 addresses in dot-decimal notation like "192.168.178.0". @@ -133,23 +141,23 @@ public static partial class RegexHelper [GeneratedRegex($@"^\\\\[a-zA-Z0-9_\-\.]+(\\[a-zA-Z0-9_\-\s\.]+\$?)(\\[a-zA-Z0-9_\-\s\.]+)*\\?$")] public static partial Regex UncPathRegex(); - // Match a MAC-Address 000000000000 00:00:00:00:00:00, 00-00-00-00-00-00-00 or 0000.0000.0000 + // Match a MAC-Address 000000000000, 00:00:00:00:00:00, 00-00-00-00-00-00 or 0000.0000.0000 public const string MACAddressRegex = - @"^^[A-Fa-f0-9]{12}$|^[A-Fa-f0-9]{2}(:|-){1}[A-Fa-f0-9]{2}(:|-){1}[A-Fa-f0-9]{2}(:|-){1}[A-Fa-f0-9]{2}(:|-){1}[A-Fa-f0-9]{2}(:|-){1}[A-Fa-f0-9]{2}$|^[A-Fa-f0-9]{4}.[A-Fa-f0-9]{4}.[A-Fa-f0-9]{4}$$"; + @"^[A-Fa-f0-9]{12}$|^[A-Fa-f0-9]{2}([:-][A-Fa-f0-9]{2}){5}$|^[A-Fa-f0-9]{4}\.[A-Fa-f0-9]{4}\.[A-Fa-f0-9]{4}$"; - // Match the first 3 bytes of a MAC-Address 000000, 00:00:00, 00-00-00 + // Match the first 3 bytes of a MAC-Address 000000, 00:00:00, 00-00-00 or 0000.00 public const string MACAddressFirst3BytesRegex = - @"^[A-Fa-f0-9]{6}$|^[A-Fa-f0-9]{2}(:|-){1}[A-Fa-f0-9]{2}(:|-){1}[A-Fa-f0-9]{2}$|^[A-Fa-f0-9]{4}.[A-Fa-f0-9]{2}$"; + @"^[A-Fa-f0-9]{6}$|^[A-Fa-f0-9]{2}[:-][A-Fa-f0-9]{2}[:-][A-Fa-f0-9]{2}$|^[A-Fa-f0-9]{4}\.[A-Fa-f0-9]{2}$"; // Match IPv6 address like ::1 // ReSharper disable once InconsistentNaming public const string IPv6AddressRegex = @"(?:^|(?<=\s))(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(?=\s|$)"; - // Match a subnet like 2001:0db8::/64 + // Match a subnet like 2001:0db8::/64 (also with an embedded IPv4 address like ::ffff:192.168.0.1/96) // ReSharper disable once InconsistentNaming public const string IPv6AddressCidrRegex = - @"^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){1}?$"; + @"^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){1}?$"; // Match a range like [0-255], [0,2,4] and [2,4-6] public const string SpecialRangeRegex = @@ -161,17 +169,97 @@ public static partial class RegexHelper // Match a hostname with subnetmask like server-01.example.com/255.255.255.0 public const string HostnameOrDomainWithSubnetmaskRegex = $@"^{HostnameOrDomainValues}\/{SubnetmaskValues}$"; - // Match a domain local.example.com + // Match a domain local.example.com (labels without nested quantifiers, which backtrack exponentially for an invalid + // input with many labels) public const string DomainRegex = - @"^(?=.{1,255}$)[a-zA-Z0-9\-_]{1,63}((\.[a-zA-Z0-9\-_]+){1,63})*\.[a-zA-Z]{2,}\.?$"; + @"^(?=.{1,255}$)[a-zA-Z0-9\-_]{1,63}(\.[a-zA-Z0-9\-_]{1,63})*\.[a-zA-Z]{2,63}\.?$"; - // Private port values + // Private port values (1-65535, without leading zeros) private const string PortValues = - @"((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4}))"; + @"(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3})"; // Match a port between 1-65535 public const string PortRegex = $@"^{PortValues}$"; + /// + /// Represents a regular expression pattern that matches a port number of the port lookup (1-5 digits, the range is + /// checked separately). + /// + private const string PortLookupPortValues = @"[0-9]{1,5}"; + + /// + /// Represents a regular expression pattern that matches a transport protocol of the port lookup. + /// + private const string PortLookupProtocolValues = @"(tcp|udp|sctp)"; + + /// + /// Represents a regular expression pattern that matches an entry of the port lookup: a port or port range, optional + /// with a protocol, like "22", "22/tcp", "80-90" or "80-90/tcp". + /// + private const string PortLookupEntryValues = + $@"{PortLookupPortValues}(-{PortLookupPortValues})?(/{PortLookupProtocolValues})?"; + + /// + /// Provides a compiled regular expression that matches a port of the port lookup like "22". + /// + /// A instance that matches a port. + [GeneratedRegex($"^{PortLookupPortValues}$")] + public static partial Regex PortLookupPortRegex(); + + /// + /// Provides a compiled regular expression that matches a port with a protocol of the port lookup like "22/tcp". + /// + /// A instance that matches a port with a protocol. + [GeneratedRegex($"^{PortLookupPortValues}/{PortLookupProtocolValues}$", RegexOptions.IgnoreCase)] + public static partial Regex PortLookupPortAndProtocolRegex(); + + /// + /// Provides a compiled regular expression that matches a port range of the port lookup like "80-90". + /// + /// A instance that matches a port range. + [GeneratedRegex($"^{PortLookupPortValues}-{PortLookupPortValues}$")] + public static partial Regex PortLookupPortRangeRegex(); + + /// + /// Provides a compiled regular expression that matches a port range with a protocol of the port lookup like + /// "80-90/tcp". + /// + /// A instance that matches a port range with a protocol. + [GeneratedRegex($"^{PortLookupPortValues}-{PortLookupPortValues}/{PortLookupProtocolValues}$", + RegexOptions.IgnoreCase)] + public static partial Regex PortLookupPortRangeAndProtocolRegex(); + + /// + /// Provides a compiled regular expression that matches entries of the port lookup separated by ";" like + /// "22;80-90/tcp". + /// + /// A instance that matches a list of ports, port ranges and protocols. + [GeneratedRegex($"^{PortLookupEntryValues}(;{PortLookupEntryValues})*$", RegexOptions.IgnoreCase)] + public static partial Regex PortLookupEntriesRegex(); + + /// + /// Provides a compiled regular expression that matches the first 3 bytes of a MAC address with separators like + /// "00:11:22" or "00-11-22". Stricter than , since 6 hex digits without + /// separators (e.g. "facade") are more likely a word. + /// + /// A instance that matches the first 3 bytes of a MAC address with separators. + [GeneratedRegex("^[A-Fa-f0-9]{2}[:-][A-Fa-f0-9]{2}[:-][A-Fa-f0-9]{2}$")] + public static partial Regex MACAddressFirst3BytesWithSeparatorRegex(); + + /// + /// Provides a compiled regular expression that matches an AS number with the prefix "AS" like "AS13335". + /// + /// A instance that matches an AS number with the prefix. + [GeneratedRegex("^AS[0-9]{1,10}$", RegexOptions.IgnoreCase)] + public static partial Regex ASNumberWithPrefixRegex(); + + /// + /// Provides a compiled regular expression that matches the scheme of an HTTP or HTTPS URL like "https://". + /// + /// A instance that matches the start of an HTTP or HTTPS URL. + [GeneratedRegex("^https?://", RegexOptions.IgnoreCase)] + public static partial Regex HttpOrHttpsSchemeRegex(); + // Match any fullname (like "c:\temp\test.txt") --> https://www.codeproject.com/Tips/216238/Regular-Expression-to-Validate-File-Path-and-Exten public const string FullNameRegex = @"^(?:[\w]\:|\\)(\\[a-zA-Z0-9_\-\s\.()~!@#$%^&=+';,{}\[\]]+)+\.[a-zA-z0-9]{1,4}$"; diff --git a/Source/NETworkManager.Validators/RemoteDesktopHostnameAndPortValidator.cs b/Source/NETworkManager.Validators/RemoteDesktopHostnameAndPortValidator.cs index 32b78db059..2f7efa003a 100644 --- a/Source/NETworkManager.Validators/RemoteDesktopHostnameAndPortValidator.cs +++ b/Source/NETworkManager.Validators/RemoteDesktopHostnameAndPortValidator.cs @@ -17,7 +17,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) { var hostnameAndPortValues = hostnameAndPort.Split(':'); - if (RegexHelper.HostnameOrDomainRegex().IsMatch(hostnameAndPortValues[0]) && + if (IsHost(hostnameAndPortValues[0]) && !string.IsNullOrEmpty(hostnameAndPortValues[1]) && Regex.IsMatch(hostnameAndPortValues[1], RegexHelper.PortRegex)) return ValidationResult.ValidResult; @@ -25,8 +25,17 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) return new ValidationResult(false, Strings.EnterValidHostnameAndPort); } - return RegexHelper.HostnameOrDomainRegex().IsMatch((string)value) + return IsHost(hostnameAndPort) ? ValidationResult.ValidResult : new ValidationResult(false, Strings.EnterValidHostname); } + + /// + /// Checks if the value is an IPv4 address or a hostname (a hostname must contain a letter, so IPv4 addresses are + /// checked separately). + /// + private static bool IsHost(string value) + { + return RegexHelper.IPv4AddressRegex().IsMatch(value) || RegexHelper.HostnameOrDomainRegex().IsMatch(value); + } } \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs index b7acda1c6a..b75a8e655f 100644 --- a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs @@ -14,7 +14,6 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; -using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows; using System.Windows.Data; @@ -226,7 +225,7 @@ private async Task PortLookupAction() var searchTrim = search.Trim(); // Check if the search is a port number like 22 - if (Regex.IsMatch(searchTrim, "^[0-9]{1,5}$")) + if (RegexHelper.PortLookupPortRegex().IsMatch(searchTrim)) if (int.TryParse(searchTrim, out var port)) if (port is > 0 and < 65536) { @@ -235,7 +234,7 @@ private async Task PortLookupAction() } // Check if the search is a port number with protocol like 22/tcp - if (Regex.IsMatch(searchTrim, "^[0-9]{1,5}/(tcp|udp|sctp)$")) + if (RegexHelper.PortLookupPortAndProtocolRegex().IsMatch(searchTrim)) { var portAndProtocol = searchTrim.Split('/'); @@ -248,7 +247,7 @@ private async Task PortLookupAction() } // Check if the search is a port range like 1-100 - if (Regex.IsMatch(searchTrim, "^[0-9]{1,5}-[0-9]{1,5}$")) + if (RegexHelper.PortLookupPortRangeRegex().IsMatch(searchTrim)) { var portRange = searchTrim.Split('-'); @@ -263,7 +262,7 @@ private async Task PortLookupAction() } // Check if the search is a port range with protocol like 1-100/tcp - if (Regex.IsMatch(searchTrim, "^[0-9]{1,5}-[0-9]{1,5}/(tcp|udp|sctp)$")) + if (RegexHelper.PortLookupPortRangeAndProtocolRegex().IsMatch(searchTrim)) { var portRangeAndProtocol = searchTrim.Split('/'); diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 2b4bd6665b..c37dc450e3 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -187,6 +187,15 @@ Release date: **xx.xx.2026** - Fixed the connection status message (e.g. "Connecting to ...") being hidden too early, while still connecting, if a previous message (e.g. after connecting or disconnecting) was shown shortly before. Each message is now shown for 15 seconds. [#3638](https://github.com/BornToBeRoot/NETworkManager/pull/3638) +**Input validation** + +- Fixed host names being accepted with a hyphen at the start or end of a label other than the first (e.g. `server.-bad-.com`) and with only digits (e.g. `10.0.0.` or `192.168.1`, which are an incomplete IPv4 address). IPv4 addresses are still accepted in all host fields, now checked explicitly in the **Remote Desktop** host field. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) +- Fixed MAC addresses and their first 3 bytes (**Wake on LAN**, **Lookup** > **OUI**, profiles) being accepted with any character instead of a dot in the format `0000.0000.0000` (e.g. `0011 2233 4455`). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) +- Fixed IPv6 subnets in the **Subnet Calculator** being accepted with a leading `s` (e.g. `sss2001:db8::/32`) and rejected with an embedded IPv4 address (e.g. `::ffff:192.168.0.1/96`). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) +- Fixed the port of the **Remote Desktop** host field (`host:port`) being accepted empty or as `0`. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) +- Fixed the application freezing when validating an invalid domain with many labels in **Whois** (exponential backtracking of the regular expression). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) +- Fixed **Lookup** > **Port** searching for a port with an upper-case protocol (e.g. `22/TCP`) as a service instead of the port and protocol. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) + **Subnet Calculator** - Fixed **Subnetting** getting stuck when the new subnet mask is smaller than the subnet mask of the subnet (e.g. `10.0.0.0/24` with `/16`) or too large for the address family (e.g. `/40` for IPv4). An error is shown instead. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) From 8c6e3a9dda065a8a22510ec4ae7d72a10ff079d2 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:47:30 +0200 Subject: [PATCH 07/10] Feature: Improve run command --- .../Resources/Strings.Designer.cs | 9 ++ .../Resources/Strings.resx | 3 + .../RunCommand/RunCommandTarget.cs | 24 +++- .../RunCommand/RunCommandTargetKind.cs | 4 +- .../GlobalStaticConfiguration.cs | 1 + .../NETworkManager.Settings/SettingsInfo.cs | 13 ++ .../RunCommand/RunCommandTargetTests.cs | 13 +- .../Utilities/RegexHelperTests.cs | 3 + .../NETworkManager.Utilities/RegexHelper.cs | 5 +- Source/NETworkManager/RunCommandManager.cs | 58 +++++++- Source/NETworkManager/RunCommandSuggestion.cs | 5 + .../ViewModels/LookupOUILookupViewModel.cs | 4 + .../ViewModels/LookupPortViewModel.cs | 4 + .../ViewModels/RunCommandViewModel.cs | 131 +++++++++++++----- .../ViewModels/SettingsGeneralViewModel.cs | 17 +++ .../Views/RunCommandChildWindow.xaml | 7 + .../Views/RunCommandChildWindow.xaml.cs | 13 +- .../Views/SettingsGeneralView.xaml | 4 + Website/docs/changelog/next-release.md | 3 + Website/docs/run-command.md | 22 +-- Website/docs/settings/general.md | 8 ++ 21 files changed, 288 insertions(+), 63 deletions(-) diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 09e3b49738..717cbe9f33 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -9094,6 +9094,15 @@ public static string PortOrService { } } + /// + /// Looks up a localized string similar to Port profile. + /// + public static string PortProfile { + get { + return ResourceManager.GetString("PortProfile", resourceCulture); + } + } + /// /// Looks up a localized string similar to Port profiles. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 746064eb1c..a6cbcd9cf9 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -5001,4 +5001,7 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri Ping first, panic later... + + Port profile + \ No newline at end of file diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs index c5dd8563a5..dae9c27c87 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs @@ -33,8 +33,11 @@ public static RunCommandTargetKind GetKind(string target) if (RegexHelper.MACAddressFirst3BytesWithSeparatorRegex().IsMatch(target)) return RunCommandTargetKind.OUI; + // Only ports from 1 to 65535 (e.g. not "99999", which the port lookup searches as a service) if (RegexHelper.PortLookupEntriesRegex().IsMatch(target)) - return RunCommandTargetKind.Ports; + return Regex.Matches(target, "[0-9]+").All(x => int.TryParse(x.Value, out var port) && port is > 0 and < 65536) + ? RunCommandTargetKind.Ports + : RunCommandTargetKind.None; if (RegexHelper.ASNumberWithPrefixRegex().IsMatch(target)) return RunCommandTargetKind.ASN; @@ -42,12 +45,21 @@ public static RunCommandTargetKind GetKind(string target) // Several hosts, IP ranges or subnets if (target.Contains(';')) { - var parts = target.Split(';', StringSplitOptions.RemoveEmptyEntries); + var kinds = target.Split(';', StringSplitOptions.RemoveEmptyEntries).Select(GetKind).ToList(); - return parts.Length > 0 && parts.All(x => GetKind(x) is RunCommandTargetKind.Host - or RunCommandTargetKind.Hostname or RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet) - ? RunCommandTargetKind.Hosts - : RunCommandTargetKind.None; + if (kinds.Count == 0 || kinds.Any(x => x is not (RunCommandTargetKind.Host or RunCommandTargetKind.Hostname + or RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet))) + return RunCommandTargetKind.None; + + // Only host names without a domain (e.g. "foo;" or "a;b" from copied text) can also be any words, like a + // single host name without a domain is not a list. + if (kinds.All(x => x == RunCommandTargetKind.Hostname)) + return RunCommandTargetKind.None; + + // With an IP range or subnet, it is a range for the scanners (e.g. not for a DNS lookup). + return kinds.Any(x => x is RunCommandTargetKind.IPRange or RunCommandTargetKind.Subnet) + ? RunCommandTargetKind.IPRange + : RunCommandTargetKind.Hosts; } // Subnet with CIDR or subnet mask (e.g. 10.0.0.0/24, 10.0.0.0/255.255.255.0, 2001:db8::/32), like the input diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandTargetKind.cs b/Source/NETworkManager.Models/RunCommand/RunCommandTargetKind.cs index 925b8098f4..f3feeee7b6 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandTargetKind.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandTargetKind.cs @@ -22,12 +22,12 @@ public enum RunCommandTargetKind Hostname, /// - /// Several hosts separated by ";". + /// Several hosts separated by ";", at least one is an IP address or a host with a domain. /// Hosts, /// - /// IPv4 range (e.g. "10.0.0.1-10.0.0.100", "10.0.[0-1].1"). + /// IPv4 range (e.g. "10.0.0.1-10.0.0.100", "10.0.[0-1].1"), or several hosts with at least one range or subnet. /// IPRange, diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index d5b0ddffe9..ee187255ba 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -90,6 +90,7 @@ public static class GlobalStaticConfiguration public static int HotKey_RunCommandModifier => 3; // Settings: Run command + public static int RunCommand_HistoryEntries => 20; public static bool RunCommand_ShowKeyHints => true; // Settings: Update diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index 80cd9f651e..46bff0369c 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -863,6 +863,19 @@ public ObservableCollection RunCommand_History } } = []; + public int RunCommand_HistoryEntries + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.RunCommand_HistoryEntries; + public bool RunCommand_SuggestClipboard { get; diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs index dd8170607e..80a230e66e 100644 --- a/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs +++ b/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs @@ -11,7 +11,9 @@ public class RunCommandTargetTests [InlineData("server-01.borntoberoot.net", RunCommandTargetKind.Host)] [InlineData("borntoberoot.net", RunCommandTargetKind.Host)] [InlineData("10.0.0.1;10.0.0.2", RunCommandTargetKind.Hosts)] - [InlineData("borntoberoot.net;10.0.0.0/24", RunCommandTargetKind.Hosts)] + // A list with a range or subnet is a range (for the scanners, not for a DNS lookup). + [InlineData("borntoberoot.net;10.0.0.0/24", RunCommandTargetKind.IPRange)] + [InlineData("10.0.0.0/24;10.0.0.1", RunCommandTargetKind.IPRange)] [InlineData("10.0.0.1-10.0.0.100", RunCommandTargetKind.IPRange)] [InlineData("10.0.0.1-100", RunCommandTargetKind.IPRange)] [InlineData("10.0.[0-1].1", RunCommandTargetKind.IPRange)] @@ -46,8 +48,8 @@ public void Hostname(string target) } [Theory] - [InlineData("server01;server02")] [InlineData("10.0.0.1;server01")] + [InlineData("server01;server02.borntoberoot.net")] public void HostsWithHostnames(string target) { Assert.Equal(RunCommandTargetKind.Hosts, RunCommandTarget.GetKind(target)); @@ -63,6 +65,13 @@ public void HostsWithHostnames(string target) [InlineData("server_01")] [InlineData("@server")] [InlineData("10.0.0.1;server_01")] + // Only host names without a domain can be any words (e.g. copied text) + [InlineData("server01;server02")] + [InlineData("foo;")] + // Ports only from 1 to 65535 + [InlineData("0")] + [InlineData("99999")] + [InlineData("22;99999")] // A shortened or partly typed IPv4 address [InlineData("10.1")] [InlineData("192.168.1")] diff --git a/Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs b/Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs index ffdebfd032..598b473440 100644 --- a/Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs +++ b/Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs @@ -37,6 +37,9 @@ public void MACAddressFirst3Bytes(string input, bool isMatch) [InlineData("::ffff:192.168.0.1/96", true)] [InlineData("sss2001:db8::/32", false)] [InlineData("2001:db8::/129", false)] + // Whitespace and zone index can't be parsed as a subnet + [InlineData("2001:db8:: /32", false)] + [InlineData("fe80::1%eth0/64", false)] public void IPv6AddressCidr(string input, bool isMatch) { Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.IPv6AddressCidrRegex)); diff --git a/Source/NETworkManager.Utilities/RegexHelper.cs b/Source/NETworkManager.Utilities/RegexHelper.cs index fbbd5ed465..0a3eb2d595 100644 --- a/Source/NETworkManager.Utilities/RegexHelper.cs +++ b/Source/NETworkManager.Utilities/RegexHelper.cs @@ -154,10 +154,11 @@ public static partial class RegexHelper public const string IPv6AddressRegex = @"(?:^|(?<=\s))(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(?=\s|$)"; - // Match a subnet like 2001:0db8::/64 (also with an embedded IPv4 address like ::ffff:192.168.0.1/96) + // Match a subnet like 2001:0db8::/64 (also with an embedded IPv4 address like ::ffff:192.168.0.1/96). Without + // whitespace and zone index (e.g. "%eth0"), which a subnet can't be parsed with. // ReSharper disable once InconsistentNaming public const string IPv6AddressCidrRegex = - @"^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){1}?$"; + @"^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){1}?$"; // Match a range like [0-255], [0,2,4] and [2,4-6] public const string SpecialRangeRegex = diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs index 019428ed6b..bbb504cafd 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -115,6 +115,23 @@ public static IEnumerable GetDNSServers() return SettingsManager.Current.DNSLookup_DNSServers.Select(x => x.Name); } + /// + /// Gets the port profiles of the Port Scanner (e.g. "Webserver"), which can be used instead of ports. + /// + /// Port profiles. + 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. /// @@ -148,13 +165,14 @@ public static IEnumerable GetProfiles(RunCommandDefinition command) /// /// Target (e.g. "10.0.0.1"). /// Available commands. + /// Kind of the target if it is already known, otherwise it is detected. /// Commands with the input to run, in the default order of the kind of the target. public static IEnumerable<(RunCommandDefinition Command, string Text)> GetCommandsForTarget(string target, - IReadOnlyList commands) + IReadOnlyList commands, RunCommandTargetKind? kind = null) { - var kind = RunCommandTarget.GetKind(target); + kind ??= RunCommandTarget.GetKind(target); - if (!TargetApplications.TryGetValue(kind, out var applications)) + if (!TargetApplications.TryGetValue(kind.Value, out var applications)) yield break; foreach (var application in applications) @@ -210,6 +228,15 @@ public static string TryCreateRequest(RunCommandParseResult result, ProfileInfo { request = null; + // A profile selected in the suggestions is run with its settings, also if its name can't be typed (e.g. it + // starts with a quote, which is parsed as a quoted name). + if (profile != null && result.Command is { Type: RunCommandType.Application }) + { + request = new ApplicationRequest(result.Command.Application) { Profile = profile }; + + return null; + } + var error = GetParseErrorMessage(result); if (error != null) @@ -223,6 +250,10 @@ public static string TryCreateRequest(RunCommandParseResult result, ProfileInfo { if (profile == null) { + // The profiles are not loaded until the (encrypted) profile file is unlocked. + if (ConfigurationManager.Current.ProfileManagerShowUnlock) + return Strings.UnlockTheProfileFileMessage; + // The name is compared like it is parsed (without surrounding whitespace and quotes). var profiles = GetProfiles(result.Command) .Where(x => string.Equals(x.Name.Trim().Trim('"').Trim(), result.ProfileName, @@ -307,6 +338,10 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT if (error != null) return error; + // The Port Scanner needs ports (or a port profile), a new tab has none. + if (result.Command.Argument == RunCommandArgument.Ports && result.Argument == null) + return Strings.EnterValidPortOrPortRange; + if (result.Argument != null) { ValidationRule argumentValidator = result.Command.Argument switch @@ -319,6 +354,8 @@ 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) }; @@ -409,7 +446,12 @@ 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 }, - ApplicationName.PortScanner => new PortScannerRequest { Target = result.Target, Ports = result.Argument }, + // A port profile is used with its ports. + ApplicationName.PortScanner => new PortScannerRequest + { + Target = result.Target, + Ports = GetPortProfile(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) }, // The argument is a new subnet mask (Subnetting) or a second subnet (Wide Subnet). @@ -444,8 +486,12 @@ static RunCommandSyntaxPart Argument(string name) => var commands = ApplicationManager.GetNames().Where(x => x != ApplicationName.None).Select(name => name switch { ApplicationName.IPScanner => Create(name, ["ipscan"], [Target(Strings.IPRange)]), - ApplicationName.PortScanner => Create(name, ["portscan"], [Target(Strings.Hosts), Argument(Strings.Ports)], - RunCommandArgument.Ports), + ApplicationName.PortScanner => Create(name, ["portscan"], + [ + Target(Strings.Hosts), + RunCommandSyntaxPart.Required(RunCommandSyntaxPartKind.Argument, + $"{Strings.Ports} | {Strings.PortProfile}") + ], RunCommandArgument.Ports), ApplicationName.PingMonitor => Create(name, ["ping"], [Target(Strings.Hosts)]), ApplicationName.Traceroute => Create(name, ["trace", "tracert"], [Target(Strings.Host)]), ApplicationName.DNSLookup => Create(name, ["dns", "nslookup"], diff --git a/Source/NETworkManager/RunCommandSuggestion.cs b/Source/NETworkManager/RunCommandSuggestion.cs index 0f53145fb1..57ce6dda23 100644 --- a/Source/NETworkManager/RunCommandSuggestion.cs +++ b/Source/NETworkManager/RunCommandSuggestion.cs @@ -33,6 +33,11 @@ public enum RunCommandSuggestionKind /// DNSServer, + /// + /// Port profile of the Port Scanner (e.g. "Webserver"). + /// + PortProfile, + /// /// Command for a target typed without a command (e.g. "ping 10.0.0.1" for "10.0.0.1"). /// diff --git a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs index d06f4ad57b..0a3ca4a7ff 100644 --- a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs @@ -228,6 +228,10 @@ private async void OUILookupAction() { var searchTrim = search.Trim(); + // Empty entry (e.g. "Cisco;;Juniper"), which would find every vendor + if (searchTrim.Length == 0) + continue; + if (Regex.IsMatch(searchTrim, RegexHelper.MACAddressRegex) || Regex.IsMatch(searchTrim, RegexHelper.MACAddressFirst3BytesRegex)) macAddresses.Add(searchTrim); diff --git a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs index b75a8e655f..38b190062e 100644 --- a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs @@ -224,6 +224,10 @@ private async Task PortLookupAction() { var searchTrim = search.Trim(); + // Empty entry (e.g. "22;;80"), which would find every service + if (searchTrim.Length == 0) + continue; + // Check if the search is a port number like 22 if (RegexHelper.PortLookupPortRegex().IsMatch(searchTrim)) if (int.TryParse(searchTrim, out var port)) diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs index 807588915b..c4dff12fa2 100644 --- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -1,6 +1,7 @@ using NETworkManager.Localization.Resources; using NETworkManager.Models; using NETworkManager.Models.RunCommand; +using NETworkManager.Controls; using NETworkManager.Profiles; using NETworkManager.Settings; using NETworkManager.Utilities; @@ -46,6 +47,11 @@ public class RunCommandViewModel : ViewModelBase /// private const int MaxClipboardSuggestions = 3; + /// + /// Number of suggestions that PageUp and PageDown move the selection. + /// + private const int SuggestionsPerPage = 5; + /// /// Maximum length of the clipboard content that is suggested as target. /// @@ -97,6 +103,8 @@ public class RunCommandViewModel : ViewModelBase /// private readonly List<(RunCommandDefinition Command, string Text)> _clipboardCommands = []; + private readonly RunCommandTargetKind _clipboardKind; + private readonly DispatcherTimer _validationTimer; private RunCommandParseResult _result; @@ -147,7 +155,10 @@ .. SettingsManager.Current.RunCommand_History _clipboardTarget = GetClipboardTarget(); if (_clipboardTarget != null) - _clipboardCommands = [.. RunCommandManager.GetCommandsForTarget(_clipboardTarget, _commands)]; + { + _clipboardKind = RunCommandTarget.GetKind(_clipboardTarget); + _clipboardCommands = [.. RunCommandManager.GetCommandsForTarget(_clipboardTarget, _commands, _clipboardKind)]; + } SuggestionsView = CollectionViewSource.GetDefaultView(Suggestions); SuggestionsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(RunCommandSuggestion.Group))); @@ -192,7 +203,10 @@ public string Input } } = string.Empty; - public ObservableCollection Suggestions { get; } = []; + /// + /// Suggestions, replaced completely on every input (one notification for the grouped view). + /// + public ObservableRangeCollection Suggestions { get; } = []; /// /// Suggestions grouped by (e.g. "Recent", "Profiles"). @@ -388,6 +402,22 @@ public void SelectPrevious() Select(-1); } + /// + /// Selects the suggestion one page below (PageDown). + /// + public void SelectNextPage() + { + Select(SuggestionsPerPage); + } + + /// + /// Selects the suggestion one page above (PageUp). + /// + public void SelectPreviousPage() + { + Select(-SuggestionsPerPage); + } + private void Select(int offset) { if (Suggestions.Count == 0) @@ -395,10 +425,13 @@ private void Select(int offset) var index = SelectedSuggestion == null ? -1 : Suggestions.IndexOf(SelectedSuggestion); - // Up from the first suggestion selects none, so Enter runs the input as typed. - SelectedSuggestion = index + offset < 0 - ? null - : Suggestions[Math.Min(index + offset, Suggestions.Count - 1)]; + // Up from the first suggestion selects none, so Enter runs the input as typed. A page up stops at the first. + SelectedSuggestion = (index + offset) switch + { + < 0 when index > 0 && offset < -1 => Suggestions[0], + < 0 => null, + var newIndex => Suggestions[Math.Min(newIndex, Suggestions.Count - 1)] + }; } /// @@ -406,13 +439,18 @@ private void Select(int offset) /// public void Complete() { - // Nothing selected: the first DNS server or type that matches the typed word (e.g. "dns host c" → the DNS - // server "Cloudflare", then the types), in the order of the syntax. - var suggestion = SelectedSuggestion ?? (_result.Position == RunCommandPosition.Argument && - _result.CurrentWord.Length > 0 - ? Suggestions.FirstOrDefault(x => - x.Kind is RunCommandSuggestionKind.DNSServer or RunCommandSuggestionKind.Type) - : null); + // Nothing selected: the first value of the part that is being typed, in the order of the syntax, e.g. a profile + // ("rdp @ser" → the first matching profile) or a DNS server, port profile or type ("dns host c" → the DNS + // server "Cloudflare", then the types). + var suggestion = SelectedSuggestion ?? (_result.Position switch + { + RunCommandPosition.Target when _result.ProfileName is { Length: > 0 } => + Suggestions.FirstOrDefault(x => x.Kind == RunCommandSuggestionKind.Profile), + RunCommandPosition.Argument when _result.CurrentWord.Length > 0 => + Suggestions.FirstOrDefault(x => x.Kind is RunCommandSuggestionKind.DNSServer + or RunCommandSuggestionKind.PortProfile or RunCommandSuggestionKind.Type), + _ => null + }); if (suggestion == null) return; @@ -464,7 +502,7 @@ public bool Run(RunCommandRunMode mode = RunCommandRunMode.Run) var historyText = text.Trim(); ListHelper.Modify(SettingsManager.Current.RunCommand_History, historyText, - SettingsManager.Current.General_HistoryListEntries, StringComparer.OrdinalIgnoreCase); + SettingsManager.Current.RunCommand_HistoryEntries, StringComparer.OrdinalIgnoreCase); _runHandler(this); @@ -598,15 +636,12 @@ .. GetProfileSuggestions().Take(MaxSuggestions) // In the order of the syntax (e.g. "[DNS server] [Type]") _ => [ - .. GetDNSServerSuggestions().Take(MaxSuggestions), + .. GetDNSServerSuggestions().Take(MaxSuggestions), .. GetPortProfileSuggestions().Take(MaxSuggestions), .. GetTypeSuggestions().Take(MaxSuggestions), .. GetHistorySuggestions().Take(MaxSuggestions) ] }; - Suggestions.Clear(); - - foreach (var suggestion in suggestions) - Suggestions.Add(suggestion); + Suggestions.ReplaceAll(suggestions); SelectedSuggestion = GetPreselectedSuggestion(); @@ -805,7 +840,8 @@ private IEnumerable GetEmptyInputSuggestions() if (_clipboardTarget != null) suggestions.AddRange(GetTargetSuggestions(_clipboardTarget, Strings.Clipboard, - RunCommandSuggestionKind.Clipboard, commands: _clipboardCommands).Take(MaxClipboardSuggestions)); + RunCommandSuggestionKind.Clipboard, commands: _clipboardCommands, targetKind: _clipboardKind) + .Take(MaxClipboardSuggestions)); suggestions.AddRange(GetHistorySuggestions().Take(MaxHistorySuggestions)); @@ -826,14 +862,16 @@ private IEnumerable GetFirstWordSuggestions() { var word = _result.CurrentWord; - var targetOnlySuggestions = GetTargetOnlySuggestions(CommandStartsWith(word)).ToList(); + // Detected once per input (also used for the ranking and the commands of the target) + var targetKind = RunCommandTarget.GetKind(_result.CommandText); + + var targetOnlySuggestions = GetTargetOnlySuggestions(CommandStartsWith(word), targetKind).ToList(); // Not for a host name without a domain, which can also be a mistyped command (e.g. "pnig"), and not for a port, // which can also be the start of an IP address (e.g. "192"). - _targetOnlyPreselection = - RunCommandTarget.GetKind(_result.CommandText) is RunCommandTargetKind.Hostname or RunCommandTargetKind.Ports - ? null - : targetOnlySuggestions.FirstOrDefault()?.CompletionText; + _targetOnlyPreselection = targetKind is RunCommandTargetKind.Hostname or RunCommandTargetKind.Ports + ? null + : targetOnlySuggestions.FirstOrDefault()?.CompletionText; var suggestions = new List(GetCommandSuggestions()); @@ -842,7 +880,7 @@ private IEnumerable GetFirstWordSuggestions() if (_clipboardTarget != null) suggestions.AddRange(GetTargetSuggestions(_clipboardTarget, Strings.Clipboard, - RunCommandSuggestionKind.Clipboard, word, _clipboardCommands)); + RunCommandSuggestionKind.Clipboard, word, _clipboardCommands, _clipboardKind)); // A command for the target that was already run is shown as recent command (only once). return Distinct(suggestions); @@ -913,16 +951,19 @@ private IEnumerable GetCommandSuggestions() /// Commands for a target typed without a command (e.g. "ping 10.0.0.1", "trace 10.0.0.1" for "10.0.0.1"). /// /// A command starts with the typed word. - private IEnumerable GetTargetOnlySuggestions(bool commandStartsWithWord) + /// Kind of the typed word as target. + private IEnumerable GetTargetOnlySuggestions(bool commandStartsWithWord, + RunCommandTargetKind targetKind) { if (_result.Command != null || HasArguments()) return []; // A host name without a domain can also be a command that is being typed (e.g. "pi"). - if (commandStartsWithWord && RunCommandTarget.GetKind(_result.CommandText) == RunCommandTargetKind.Hostname) + if (commandStartsWithWord && targetKind == RunCommandTargetKind.Hostname) return []; - return GetTargetSuggestions(_result.CommandText, Strings.Commands, RunCommandSuggestionKind.Target); + return GetTargetSuggestions(_result.CommandText, Strings.Commands, RunCommandSuggestionKind.Target, + targetKind: targetKind); } /// @@ -934,17 +975,18 @@ private IEnumerable GetTargetOnlySuggestions(bool commandS /// Kind of the suggestions (typed target or clipboard). /// Only commands of which a name starts with it (e.g. "pi" for "ping"), or null. /// Commands for the target if they are already known (clipboard), otherwise null. + /// Kind of the target if it is already known, otherwise it is detected. private IEnumerable GetTargetSuggestions(string target, string group, RunCommandSuggestionKind suggestionKind, string commandPrefix = null, - IEnumerable<(RunCommandDefinition Command, string Text)> commands = null) + IEnumerable<(RunCommandDefinition Command, string Text)> commands = null, RunCommandTargetKind? targetKind = null) { - var kind = RunCommandTarget.GetKind(target); + var kind = targetKind ?? RunCommandTarget.GetKind(target); var usage = _history.Where(x => x.TargetKind == kind) .GroupBy(x => x.Command) .ToDictionary(x => x.Key, x => x.Count()); - return (commands ?? RunCommandManager.GetCommandsForTarget(target, _commands)) + return (commands ?? RunCommandManager.GetCommandsForTarget(target, _commands, kind)) .Where(x => commandPrefix == null || _commandNames.Any(c => c.Command == x.Command && c.Names.Any(name => name.StartsWith(commandPrefix, StringComparison.OrdinalIgnoreCase)))) @@ -1064,6 +1106,31 @@ private IEnumerable GetTypeSuggestions() }); } + /// + /// Port profiles of the Port Scanner (e.g. "Webserver") that contain the typed word, if no ports were entered + /// yet. Names with spaces are inserted in quotes. + /// + private IEnumerable GetPortProfileSuggestions() + { + if (_result.Command.Argument != RunCommandArgument.Ports || + _result.Argument != null && !string.Equals(_result.Argument, _result.CurrentWord)) + return []; + + return RunCommandManager.GetPortProfiles() + .Where(x => x.Name.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase)) + .Select(x => new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.PortProfile, + Application = _result.Command.Application, + Title = x.Name, + Description = x.Ports, + CompletionText = + _result.InputBeforeCurrentWord + (x.Name.Contains(' ') ? $"\"{x.Name}\"" : x.Name) + " ", + Highlight = _result.CurrentWord, + Group = Strings.PortProfiles + }); + } + /// /// DNS servers configured in the DNS lookup (e.g. "Cloudflare") that contain the typed word, if no DNS server was /// entered yet. Names with spaces are inserted in quotes. diff --git a/Source/NETworkManager/ViewModels/SettingsGeneralViewModel.cs b/Source/NETworkManager/ViewModels/SettingsGeneralViewModel.cs index 9ef3f922c1..288b24b1fe 100644 --- a/Source/NETworkManager/ViewModels/SettingsGeneralViewModel.cs +++ b/Source/NETworkManager/ViewModels/SettingsGeneralViewModel.cs @@ -61,6 +61,22 @@ public int HistoryListEntries } } + public int RunCommandHistoryEntries + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.RunCommand_HistoryEntries = value; + + field = value; + OnPropertyChanged(); + } + } + public bool RunCommandSuggestClipboard { get; @@ -117,6 +133,7 @@ private void LoadSettings() BackgroundJobInterval = SettingsManager.Current.General_BackgroundJobInterval; HistoryListEntries = SettingsManager.Current.General_HistoryListEntries; + RunCommandHistoryEntries = SettingsManager.Current.RunCommand_HistoryEntries; RunCommandSuggestClipboard = SettingsManager.Current.RunCommand_SuggestClipboard; RunCommandShowKeyHints = SettingsManager.Current.RunCommand_ShowKeyHints; } diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml b/Source/NETworkManager/Views/RunCommandChildWindow.xaml index 6615b871a8..483da6535c 100644 --- a/Source/NETworkManager/Views/RunCommandChildWindow.xaml +++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml @@ -39,6 +39,8 @@ Visual="{iconPacks:Material Kind=TagOutline}" /> + diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs b/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs index e648be736c..33073b6c96 100644 --- a/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs +++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs @@ -10,9 +10,9 @@ namespace NETworkManager.Views; /// -/// Run command dialog. The input keeps the focus; Up/Down select a suggestion, Tab completes it, Enter runs it -/// (Ctrl+Enter keeps the dialog open, Shift+Enter only fills in), Shift+Del removes a recent command and Escape -/// closes the dialog. +/// Run command dialog. The input keeps the focus; Up/Down (PageUp/PageDown) select a suggestion, Tab completes +/// it, Enter runs it (Ctrl+Enter keeps the dialog open, Shift+Enter only fills in), Shift+Del removes a recent +/// command and Escape closes the dialog. /// public partial class RunCommandChildWindow { @@ -126,6 +126,13 @@ private void TextBoxInput_OnPreviewKeyDown(object sender, KeyEventArgs e) case Key.Up: ViewModel.SelectPrevious(); break; + // Home and End move the caret in the input. + case Key.PageDown: + ViewModel.SelectNextPage(); + break; + case Key.PageUp: + ViewModel.SelectPreviousPage(); + break; case Key.Tab: ViewModel.Complete(); TextBoxInput.CaretIndex = TextBoxInput.Text.Length; diff --git a/Source/NETworkManager/Views/SettingsGeneralView.xaml b/Source/NETworkManager/Views/SettingsGeneralView.xaml index e980fcece7..358940e7ee 100644 --- a/Source/NETworkManager/Views/SettingsGeneralView.xaml +++ b/Source/NETworkManager/Views/SettingsGeneralView.xaml @@ -189,6 +189,10 @@ + + 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 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** @@ -194,6 +195,8 @@ Release date: **xx.xx.2026** - Fixed IPv6 subnets in the **Subnet Calculator** being accepted with a leading `s` (e.g. `sss2001:db8::/32`) and rejected with an embedded IPv4 address (e.g. `::ffff:192.168.0.1/96`). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) - Fixed the port of the **Remote Desktop** host field (`host:port`) being accepted empty or as `0`. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) - Fixed the application freezing when validating an invalid domain with many labels in **Whois** (exponential backtracking of the regular expression). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) +- Fixed IPv6 subnets with whitespace or a zone index (e.g. `fe80::1%eth0/64`) being accepted in the **Subnet Calculator**, which can't calculate them. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) +- Fixed an empty entry in **Lookup** > **OUI** / **Port** (e.g. `22;;80`) finding every vendor or service. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) - Fixed **Lookup** > **Port** searching for a port with an upper-case protocol (e.g. `22/TCP`) as a service instead of the port and protocol. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) **Subnet Calculator** diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md index 71664e2b47..23b6c078ae 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` | ` [ports]` | `portscan 10.0.0.1 22; 80; 443` | +| `portscanner` | `portscan` | ` ` | `portscan 10.0.0.1 22; 80; 443` | | `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,6 +56,8 @@ 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. +- **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. ::: @@ -66,17 +68,17 @@ Type only the target (e.g. `10.0.0.1`) to get the commands that accept it, e.g. | Target | Suggested commands | | --------------------------------------------------- | ----------------------------------------------------------------------------------- | -| IP address or host with a domain (`server.lan`) | Ping Monitor, Traceroute, DNS Lookup, Port Scanner, remote tools, IP Geolocation, … | -| Host without a domain (`server01`) | Ping Monitor, Traceroute, DNS Lookup, Port Scanner, remote tools, SNMP, IP Scanner | -| Several hosts (`10.0.0.1; 10.0.0.2`) | Ping Monitor, DNS Lookup, Port Scanner, IP Scanner | -| IP range (`10.0.0.1-100`) or subnet (`10.0.0.0/24`) | IP Scanner, Subnet Calculator (subnet), Port Scanner, Ping Monitor, RDAP (subnet) | +| IP address or host with a domain (`server.lan`) | Ping Monitor, Traceroute, DNS Lookup, remote tools, IP Geolocation, Whois, … | +| Host without a domain (`server01`) | Ping Monitor, Traceroute, DNS Lookup, remote tools, SNMP, IP Scanner | +| Several hosts (`10.0.0.1; 10.0.0.2`) | Ping Monitor, DNS Lookup, IP Scanner | +| IP range (`10.0.0.1-100`) or subnet (`10.0.0.0/24`) | IP Scanner, Subnet Calculator (subnet), Ping Monitor, RDAP (subnet) | | MAC address (`00:11:22:33:44:55`) | Wake on LAN, `lookup oui` | | First 3 bytes of a MAC address (`00:11:22`) | `lookup oui` | | Port(s) (`22`, `80-90/tcp`) | `lookup port` | | AS number (`AS13335`) | RDAP | | URL (`https://...`) | Web Console | -A command always has priority (e.g. `web` is the Web Console). A host without a domain (e.g. `server01`) can also be a mistyped command, so its commands are only suggested if no command starts with the word (e.g. not for `pi`), and none is selected: select one with the arrow keys. +A command always has priority (e.g. `web` is the Web Console). A list (e.g. `10.0.0.1; server01`) is a target if at least one entry is an IP address, a host with a domain, a range or a subnet; with a range or subnet, it is handled like an IP range. Ports are `1` to `65535`. A host without a domain (e.g. `server01`) can also be a mistyped command, so its commands are only suggested if no command starts with the word (e.g. not for `pi`), and none is selected: select one with the arrow keys. **Clipboard:** If enabled in [Settings > General](./settings/general.md#suggest-the-clipboard-content-as-target), the commands for a target in the clipboard (e.g. a copied IP address) are suggested when the dialog is opened (up to 3), and `Enter` runs the first one. While typing the command, the commands for the clipboard that start with it are suggested (e.g. `pi` → `ping 10.0.0.1`). The clipboard is read when the dialog is opened; a single word (host without a domain) is not suggested. Disabled by default. @@ -100,14 +102,14 @@ While typing, suggestions are shown below the input, grouped (e.g. _Recent_, _Pr | After the command (`rdp `) | Recent commands of the feature and its profiles | | After `lookup ` | The lookups (`oui`, `port`) and recent commands | | `@` (e.g. `rdp @web`) | Profiles of the feature (with group and host); exact names first, recently run profiles first within the same match | -| After the target | Configured DNS servers (DNS lookup), types of the feature (e.g. DNS record types) and recent commands, up to 10 of each | +| After the target | Configured DNS servers (DNS lookup), port profiles (Port Scanner), types of the feature (e.g. DNS record types) and recent commands, up to 10 of each | `Enter` runs what was typed if no suggestion is selected: after the command, nothing is selected; profiles, recent commands, types and DNS servers are selected with the arrow keys (`Up` on the first suggestion selects none again). While typing the command, the best matching feature is selected (completing it keeps the arguments, e.g. `pin 10.0.0.1` → `ping 10.0.0.1`), but not if an unknown command has arguments; for a target without a command, see [Target without a command](#target-without-a-command). With an empty input, only the first command for the clipboard is selected (if enabled). While typing `@`, a profile is only selected if exactly one matches. While typing the lookup (e.g. `lookup po`), the matching lookup is selected. | Key | Action | | ------------- | ----------------------------------------------------------------------------------------------- | -| `Up`, `Down` | Select a suggestion | -| `Tab` | Insert the selected suggestion into the input. If none is selected, the first DNS server or type that matches the typed word (e.g. `dns borntoberoot.net c` → `cloudflare`) | +| `Up`, `Down` | Select a suggestion (`PageUp`, `PageDown`: 5 suggestions) | +| `Tab` | Insert the selected suggestion into the input. If none is selected, the first profile, DNS server, port profile or type that matches the typed word (e.g. `dns borntoberoot.net c` → `cloudflare`) | | `Enter` | Run the selected suggestion, or the input if none is selected | | `Ctrl+Enter` | Run and keep the dialog open, with the command in the input (e.g. `ping `) to run it with the next target. Commands that open a dialog (connect dialog of the remote tools, settings, Subnetting, which can ask for confirmation) close the dialog. | | `Shift+Enter` | Open the feature with the input filled in, without starting it (e.g. to adjust the options of a scan). The remote tools open the connect dialog (target) or connect (profile) as with `Enter`. | @@ -116,4 +118,4 @@ While typing, suggestions are shown below the input, grouped (e.g. _Recent_, _Pr The keys are also shown at the bottom of the dialog (can be hidden in [Settings > General](./settings/general.md#show-key-hints)). -Run commands are saved in a history (the number of entries is set in [Settings > General](./settings/general.md)). +Run commands are saved in a history (20 entries by default, set in [Settings > General](./settings/general.md#number-of-stored-entries-run-command)). diff --git a/Website/docs/settings/general.md b/Website/docs/settings/general.md index b0e70186d2..71f64a3e16 100644 --- a/Website/docs/settings/general.md +++ b/Website/docs/settings/general.md @@ -48,6 +48,14 @@ Type: `Integer` Default: `5` [Min `0`, Max `25`] +### Number of stored entries (Run command) + +Maximum number of commands stored in the history of the [run command](../run-command.md#suggestions). + +Type: `Integer` + +Default: `20` [Min `0`, Max `50`] + ### Suggest the clipboard content as target Suggest the commands for a target in the clipboard (e.g. a copied IP address, subnet or MAC address) when the [run command](../run-command.md#target-without-a-command) is opened. `Enter` runs the first one. While typing a command, the commands for the clipboard that start with it are suggested (e.g. `pi` → `ping 10.0.0.1`). A single word (host without a domain) is not suggested. The clipboard is only read when the run command is opened and is not stored. From e6ef255b36837529383ec6874d23decd227bc477 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:53:39 +0200 Subject: [PATCH 08/10] Fix: Copilot feedback --- .github/dependabot.yml | 6 ++ .../ComboBoxHistoryBehavior.cs | 69 +++++++++++++++ .../ObservableRangeCollection.cs | 6 +- .../RunCommand/RunCommandTarget.cs | 18 +++- .../RunCommand/RunCommandTargetTests.cs | 4 + .../Utilities/ListHelperTests.cs | 15 +++- Source/NETworkManager.Utilities/ListHelper.cs | 19 ++++- .../Resources/Styles/ComboBoxStyles.xaml | 5 +- Source/NETworkManager/RunCommandManager.cs | 24 +++--- .../ViewModels/RunCommandViewModel.cs | 83 +++++++++++-------- Website/docs/changelog/next-release.md | 4 + Website/docs/settings/general.md | 4 +- Website/package.json | 2 +- Website/yarn.lock | 8 +- 14 files changed, 201 insertions(+), 66 deletions(-) create mode 100644 Source/NETworkManager.Controls/ComboBoxHistoryBehavior.cs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 67cfdb6963..6c10014235 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -25,3 +25,9 @@ updates: target-branch: main commit-message: prefix: "Dependency (dependabot/npm):" + groups: + # react and react-dom must have the exact same version (otherwise the website build fails) + react: + patterns: + - "react" + - "react-dom" diff --git a/Source/NETworkManager.Controls/ComboBoxHistoryBehavior.cs b/Source/NETworkManager.Controls/ComboBoxHistoryBehavior.cs new file mode 100644 index 0000000000..f7e0ba7b87 --- /dev/null +++ b/Source/NETworkManager.Controls/ComboBoxHistoryBehavior.cs @@ -0,0 +1,69 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; + +namespace NETworkManager.Controls; + +/// +/// Attached property that keeps the text of an editable that shows a history (e.g. the hosts of +/// the Traceroute) when its selected entry is removed from the history. +/// +/// The history is changed in place (ListHelper.Modify): the oldest entries are removed and, if the number of +/// stored entries is 0, all of them. If the removed entry is the selected item (e.g. the host that was just used, or the +/// host of another tab that shares the history), WPF clears the text of the ComboBox. The text is restored after the +/// ComboBox has updated it. If the entry is only deselected because another text was typed, it is still in the list and +/// nothing is changed. +/// +public static class ComboBoxHistoryBehavior +{ + public static readonly DependencyProperty KeepTextWhenSelectedItemIsRemovedProperty = + DependencyProperty.RegisterAttached( + "KeepTextWhenSelectedItemIsRemoved", + typeof(bool), + typeof(ComboBoxHistoryBehavior), + new PropertyMetadata(false, OnKeepTextWhenSelectedItemIsRemovedChanged)); + + public static void SetKeepTextWhenSelectedItemIsRemoved(UIElement element, bool value) => + element.SetValue(KeepTextWhenSelectedItemIsRemovedProperty, value); + + public static bool GetKeepTextWhenSelectedItemIsRemoved(UIElement element) => + (bool)element.GetValue(KeepTextWhenSelectedItemIsRemovedProperty); + + private static void OnKeepTextWhenSelectedItemIsRemovedChanged(DependencyObject d, + DependencyPropertyChangedEventArgs e) + { + if (d is not ComboBox comboBox) + return; + + if ((bool)e.NewValue) + comboBox.SelectionChanged += ComboBox_SelectionChanged; + else + comboBox.SelectionChanged -= ComboBox_SelectionChanged; + } + + private static void ComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not ComboBox { IsEditable: true } comboBox || comboBox.SelectedItem != null || + e.RemovedItems.Count == 0) + return; + + var removedItem = e.RemovedItems[0]; + + // Deselected because another text was typed: the entry is still in the list. + if (comboBox.Items.Contains(removedItem)) + return; + + var text = removedItem?.ToString(); + + if (string.IsNullOrEmpty(text)) + return; + + // After the ComboBox has cleared its text (and only if it did) + comboBox.Dispatcher.BeginInvoke(DispatcherPriority.DataBind, new Action(delegate + { + if (string.IsNullOrEmpty(comboBox.Text)) + comboBox.Text = text; + })); + } +} diff --git a/Source/NETworkManager.Controls/ObservableRangeCollection.cs b/Source/NETworkManager.Controls/ObservableRangeCollection.cs index f222a46263..88d6fb7af2 100644 --- a/Source/NETworkManager.Controls/ObservableRangeCollection.cs +++ b/Source/NETworkManager.Controls/ObservableRangeCollection.cs @@ -2,6 +2,7 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; +using System.Linq; namespace NETworkManager.Controls; @@ -23,9 +24,12 @@ public void ReplaceAll(IEnumerable items) { CheckReentrancy(); + // Read the items first: if a lazy sequence throws, the collection is unchanged (and in sync with the views). + var newItems = items.ToList(); + Items.Clear(); - foreach (var item in items) + foreach (var item in newItems) Items.Add(item); OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs index dae9c27c87..2ce02a2258 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs @@ -13,6 +13,17 @@ namespace NETworkManager.Models.RunCommand; /// public static class RunCommandTarget { + /// + /// Checks if the ports of an entry of the port lookup (e.g. "22", "80-90/tcp") are valid: from 1 to 65535, a range + /// from a lower to a higher port. + /// + private static bool IsValidPortLookupEntry(string entry) + { + var ports = entry.Split('/')[0].Split('-').Select(x => int.TryParse(x, out var port) ? port : 0).ToArray(); + + return ports.All(x => x is > 0 and < 65536) && (ports.Length == 1 || ports[0] < ports[1]); + } + /// /// Gets the kind of the target. /// @@ -33,11 +44,10 @@ public static RunCommandTargetKind GetKind(string target) if (RegexHelper.MACAddressFirst3BytesWithSeparatorRegex().IsMatch(target)) return RunCommandTargetKind.OUI; - // Only ports from 1 to 65535 (e.g. not "99999", which the port lookup searches as a service) + // Like the port lookup: ports from 1 to 65535 and ranges from a lower to a higher port (e.g. not "99999" or + // "90-80", which the port lookup searches as a service) if (RegexHelper.PortLookupEntriesRegex().IsMatch(target)) - return Regex.Matches(target, "[0-9]+").All(x => int.TryParse(x.Value, out var port) && port is > 0 and < 65536) - ? RunCommandTargetKind.Ports - : RunCommandTargetKind.None; + return target.Split(';').All(IsValidPortLookupEntry) ? RunCommandTargetKind.Ports : RunCommandTargetKind.None; if (RegexHelper.ASNumberWithPrefixRegex().IsMatch(target)) return RunCommandTargetKind.ASN; diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs index 80a230e66e..32ad9bc0ce 100644 --- a/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs +++ b/Source/NETworkManager.Tests/RunCommand/RunCommandTargetTests.cs @@ -72,6 +72,10 @@ public void HostsWithHostnames(string target) [InlineData("0")] [InlineData("99999")] [InlineData("22;99999")] + // A range from a lower to a higher port, like the port lookup + [InlineData("90-80")] + [InlineData("80-80")] + [InlineData("22;90-80/tcp")] // A shortened or partly typed IPv4 address [InlineData("10.1")] [InlineData("192.168.1")] diff --git a/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs b/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs index 924f9a1aad..a1f172db8c 100644 --- a/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs +++ b/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs @@ -117,9 +117,20 @@ public void UsesTheComparerAndKeepsTheLatestSpelling() Assert.Equal(["Ping 10.0.0.1", "a"], list); } + // "Number of stored entries" can be set to 0 in the settings. [Fact] - public void ThrowsIfTheLengthIsNotPositive() + public void LengthZeroStoresNoEntries() { - Assert.Throws(() => ListHelper.Modify(new ObservableCollection(), "a", 0)); + var list = new ObservableCollection { "a", "b" }; + + ListHelper.Modify(list, "c", 0); + + Assert.Empty(list); + } + + [Fact] + public void ThrowsIfTheLengthIsNegative() + { + Assert.Throws(() => ListHelper.Modify(new ObservableCollection(), "a", -1)); } } diff --git a/Source/NETworkManager.Utilities/ListHelper.cs b/Source/NETworkManager.Utilities/ListHelper.cs index e1336b997c..f325d6c7f1 100644 --- a/Source/NETworkManager.Utilities/ListHelper.cs +++ b/Source/NETworkManager.Utilities/ListHelper.cs @@ -18,7 +18,10 @@ public static class ListHelper /// /// List to modify (e.g. a history of the settings). /// Entry to add to the list. - /// Maximum length of the list. Oldest entries will be removed if the list exceeds this length. + /// + /// Maximum length of the list. Oldest entries will be removed if the list exceeds this length. 0 stores no + /// entries (e.g. "Number of stored entries" is set to 0), the list is emptied. + /// /// Comparer for existing entries (e.g. case-insensitive). If null, the default comparer is used. /// Type of the list entries. Currently or . public static void Modify(ObservableCollection list, T entry, int length, @@ -26,8 +29,18 @@ public static void Modify(ObservableCollection list, T entry, int length, { comparer ??= EqualityComparer.Default; - if (length <= 0) - throw new ArgumentOutOfRangeException(nameof(length), "Length must be greater than zero."); + if (length < 0) + throw new ArgumentOutOfRangeException(nameof(length), "Length must not be negative."); + + // No history: all entries are removed, one by one like the oldest entries below. An editable ComboBox that shows + // the list keeps its text (ComboBoxHistoryBehavior in the HistoryComboBox style). + if (length == 0) + { + while (list.Count > 0) + list.RemoveAt(list.Count - 1); + + return; + } if (EqualityComparer.Default.Equals(entry, default) || entry is string text && string.IsNullOrEmpty(text)) return; diff --git a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml index 792cf30b8d..a248a87516 100644 --- a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml +++ b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml @@ -1,6 +1,7 @@  + xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls" + xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls"> \ No newline at end of file diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs index bbb504cafd..e466e99b39 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -51,31 +51,27 @@ public static class RunCommandManager /// private static readonly Dictionary TargetApplications = new() { + // The Port Scanner is not suggested, since it needs ports (or a port profile). [RunCommandTargetKind.Host] = [ ApplicationName.PingMonitor, ApplicationName.Traceroute, ApplicationName.DNSLookup, - ApplicationName.PortScanner, ApplicationName.RemoteDesktop, ApplicationName.PuTTY, - ApplicationName.PowerShell, ApplicationName.TigerVNC, ApplicationName.IPGeolocation, ApplicationName.Whois, - ApplicationName.RDAP, ApplicationName.SNMP, ApplicationName.IPScanner + ApplicationName.RemoteDesktop, ApplicationName.PuTTY, ApplicationName.PowerShell, ApplicationName.TigerVNC, + ApplicationName.IPGeolocation, ApplicationName.Whois, ApplicationName.RDAP, ApplicationName.SNMP, + ApplicationName.IPScanner ], // Without a domain (e.g. in a company network): no tools for public hosts (Whois, RDAP, IP Geolocation) [RunCommandTargetKind.Hostname] = [ ApplicationName.PingMonitor, ApplicationName.Traceroute, ApplicationName.DNSLookup, - ApplicationName.PortScanner, ApplicationName.RemoteDesktop, ApplicationName.PuTTY, - ApplicationName.PowerShell, ApplicationName.TigerVNC, ApplicationName.SNMP, ApplicationName.IPScanner - ], - [RunCommandTargetKind.Hosts] = - [ - ApplicationName.PingMonitor, ApplicationName.DNSLookup, ApplicationName.PortScanner, - ApplicationName.IPScanner + ApplicationName.RemoteDesktop, ApplicationName.PuTTY, ApplicationName.PowerShell, ApplicationName.TigerVNC, + ApplicationName.SNMP, ApplicationName.IPScanner ], - [RunCommandTargetKind.IPRange] = - [ApplicationName.IPScanner, ApplicationName.PortScanner, ApplicationName.PingMonitor], + [RunCommandTargetKind.Hosts] = [ApplicationName.PingMonitor, ApplicationName.DNSLookup, ApplicationName.IPScanner], + [RunCommandTargetKind.IPRange] = [ApplicationName.IPScanner, ApplicationName.PingMonitor], [RunCommandTargetKind.Subnet] = [ - ApplicationName.IPScanner, ApplicationName.SubnetCalculator, ApplicationName.PortScanner, - ApplicationName.PingMonitor, ApplicationName.RDAP + ApplicationName.IPScanner, ApplicationName.SubnetCalculator, ApplicationName.PingMonitor, + ApplicationName.RDAP ], [RunCommandTargetKind.MACAddress] = [ApplicationName.WakeOnLAN, ApplicationName.Lookup], [RunCommandTargetKind.OUI] = [ApplicationName.Lookup], diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs index c4dff12fa2..c365ce85cc 100644 --- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -146,6 +146,8 @@ public RunCommandViewModel(Action runHandler, Action (Text: x, Result: RunCommandParser.Parse(x, _commands))) .Where(x => x.Result.Command != null) .Select(x => CreateHistoryEntry(x.Text, x.Result)) @@ -425,8 +427,9 @@ private void Select(int offset) var index = SelectedSuggestion == null ? -1 : Suggestions.IndexOf(SelectedSuggestion); - // Up from the first suggestion selects none, so Enter runs the input as typed. A page up stops at the first. - SelectedSuggestion = (index + offset) switch + // Nothing selected: Down and PageDown select the first. Up from the first suggestion selects none, so Enter runs + // the input as typed. A page up stops at the first. + SelectedSuggestion = (index < 0 && offset > 0 ? 0 : index + offset) switch { < 0 when index > 0 && offset < -1 => Suggestions[0], < 0 => null, @@ -456,6 +459,10 @@ public void Complete() return; Input = suggestion.CompletionText; + + // Keep the completed profile selected, so Enter runs it (also if several profiles have the same name). + if (suggestion.Profile != null) + SelectedSuggestion = Suggestions.FirstOrDefault(x => x.Profile == suggestion.Profile) ?? SelectedSuggestion; } /// @@ -498,11 +505,13 @@ public bool Run(RunCommandRunMode mode = RunCommandRunMode.Run) KeepOpen = mode == RunCommandRunMode.RunAndKeepOpen && !RunCommandManager.OpensDialog(IsSettings ? null : request); - // Commands are case-insensitive, so "Ping 10.0.0.1" and "ping 10.0.0.1" are the same entry. + // Commands are case-insensitive, so "Ping 10.0.0.1" and "ping 10.0.0.1" are the same entry. Not if the text can't + // be run again (e.g. a profile selected in the suggestions, whose name starts with a quote). var historyText = text.Trim(); - ListHelper.Modify(SettingsManager.Current.RunCommand_History, historyText, - SettingsManager.Current.RunCommand_HistoryEntries, StringComparer.OrdinalIgnoreCase); + if (result.Error == RunCommandParseError.None) + ListHelper.Modify(SettingsManager.Current.RunCommand_History, historyText, + SettingsManager.Current.RunCommand_HistoryEntries, StringComparer.OrdinalIgnoreCase); _runHandler(this); @@ -511,8 +520,11 @@ public bool Run(RunCommandRunMode mode = RunCommandRunMode.Run) // Show the command in the recent commands and keep it in the input (e.g. "ping "), so it can be run with the // next target. - _history.RemoveAll(x => string.Equals(x.Text, historyText, StringComparison.OrdinalIgnoreCase)); - _history.Insert(0, CreateHistoryEntry(historyText, result)); + if (result.Error == RunCommandParseError.None && SettingsManager.Current.RunCommand_HistoryEntries > 0) + { + _history.RemoveAll(x => string.Equals(x.Text, historyText, StringComparison.OrdinalIgnoreCase)); + _history.Insert(0, CreateHistoryEntry(historyText, result)); + } var commandPrefix = result.Command.TypeBeforeTarget && result.Type != null ? $"{result.CommandText} {result.Type} " @@ -1108,50 +1120,53 @@ private IEnumerable GetTypeSuggestions() /// /// Port profiles of the Port Scanner (e.g. "Webserver") that contain the typed word, if no ports were entered - /// yet. Names with spaces are inserted in quotes. + /// yet. /// private IEnumerable GetPortProfileSuggestions() { - if (_result.Command.Argument != RunCommandArgument.Ports || - _result.Argument != null && !string.Equals(_result.Argument, _result.CurrentWord)) - return []; - - return RunCommandManager.GetPortProfiles() - .Where(x => x.Name.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase)) - .Select(x => new RunCommandSuggestion - { - Kind = RunCommandSuggestionKind.PortProfile, - Application = _result.Command.Application, - Title = x.Name, - Description = x.Ports, - CompletionText = - _result.InputBeforeCurrentWord + (x.Name.Contains(' ') ? $"\"{x.Name}\"" : x.Name) + " ", - Highlight = _result.CurrentWord, - Group = Strings.PortProfiles - }); + return GetArgumentValueSuggestions(RunCommandArgument.Ports, + RunCommandManager.GetPortProfiles().Select(x => (x.Name, x.Ports)), RunCommandSuggestionKind.PortProfile, + Strings.PortProfiles); } /// /// DNS servers configured in the DNS lookup (e.g. "Cloudflare") that contain the typed word, if no DNS server was - /// entered yet. Names with spaces are inserted in quotes. + /// entered yet. /// private IEnumerable GetDNSServerSuggestions() { - if (_result.Command.Argument != RunCommandArgument.DNSServer || + return GetArgumentValueSuggestions(RunCommandArgument.DNSServer, + RunCommandManager.GetDNSServers().Select(x => (x, Strings.DNSServer)), RunCommandSuggestionKind.DNSServer, + Strings.DNSServers); + } + + /// + /// Named values of the second argument of the command (e.g. DNS servers, port profiles) that contain the typed + /// word, if the argument was not entered yet. Names with spaces are inserted in quotes. + /// + /// Argument the values are for. + /// Names and descriptions of the values. + /// Kind of the suggestions. + /// Group of the suggestions. + private IEnumerable GetArgumentValueSuggestions(RunCommandArgument argument, + IEnumerable<(string Name, string Description)> values, RunCommandSuggestionKind kind, string group) + { + if (_result.Command.Argument != argument || _result.Argument != null && !string.Equals(_result.Argument, _result.CurrentWord)) return []; - return RunCommandManager.GetDNSServers() - .Where(x => x.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase)) + return values + .Where(x => x.Name.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase)) .Select(x => new RunCommandSuggestion { - Kind = RunCommandSuggestionKind.DNSServer, + Kind = kind, Application = _result.Command.Application, - Title = x, - Description = Strings.DNSServer, - CompletionText = _result.InputBeforeCurrentWord + (x.Contains(' ') ? $"\"{x}\"" : x) + " ", + Title = x.Name, + Description = x.Description, + CompletionText = + _result.InputBeforeCurrentWord + (x.Name.Contains(' ') ? $"\"{x.Name}\"" : x.Name) + " ", Highlight = _result.CurrentWord, - Group = Strings.DNSServers + Group = group }); } diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 6430716fff..9bd034f7d5 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -188,6 +188,10 @@ Release date: **xx.xx.2026** - Fixed the connection status message (e.g. "Connecting to ...") being hidden too early, while still connecting, if a previous message (e.g. after connecting or disconnecting) was shown shortly before. Each message is now shown for 15 seconds. [#3638](https://github.com/BornToBeRoot/NETworkManager/pull/3638) +**History** + +- Fixed adding an entry to the history of an input (e.g. hosts, ports, domains) failing when the number of stored entries is set to `0` in [Settings > General](../settings/general.md#number-of-stored-entries). With `0`, no entries are stored. Removing an entry from the history (also the oldest one, shared by several tabs) no longer clears the input that shows it. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) + **Input validation** - Fixed host names being accepted with a hyphen at the start or end of a label other than the first (e.g. `server.-bad-.com`) and with only digits (e.g. `10.0.0.` or `192.168.1`, which are an incomplete IPv4 address). IPv4 addresses are still accepted in all host fields, now checked explicitly in the **Remote Desktop** host field. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) diff --git a/Website/docs/settings/general.md b/Website/docs/settings/general.md index 71f64a3e16..8f85399d7d 100644 --- a/Website/docs/settings/general.md +++ b/Website/docs/settings/general.md @@ -42,7 +42,7 @@ The value 0 will disable the background job. Changes to this value will take eff ### Number of stored entries -Maximum number of entries stored in the history for several application inputs. +Maximum number of entries stored in the history for several application inputs. With `0`, no entries are stored (existing entries are removed with the next input). Type: `Integer` @@ -50,7 +50,7 @@ Default: `5` [Min `0`, Max `25`] ### Number of stored entries (Run command) -Maximum number of commands stored in the history of the [run command](../run-command.md#suggestions). +Maximum number of commands stored in the history of the [run command](../run-command.md#suggestions). With `0`, no commands are stored. Type: `Integer` diff --git a/Website/package.json b/Website/package.json index dcd258afcc..f361ca429e 100644 --- a/Website/package.json +++ b/Website/package.json @@ -24,7 +24,7 @@ "clsx": "^2.0.0", "prism-react-renderer": "^2.4.1", "react": "^19.3.1", - "react-dom": "^19.3.0", + "react-dom": "^19.3.1", "react-image-gallery": "^2.1.2" }, "devDependencies": { diff --git a/Website/yarn.lock b/Website/yarn.lock index 12b66dbadf..56a5f31622 100644 --- a/Website/yarn.lock +++ b/Website/yarn.lock @@ -7683,10 +7683,10 @@ rc@1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-dom@^19.3.0: - version "19.3.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.3.0.tgz#a0ff19161d7f4e9be5469a07278b0c06e00f9001" - integrity sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q== +react-dom@^19.3.1: + version "19.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.3.1.tgz#b384c53cfcbe9c2a5d09d863b98455de9d68afe2" + integrity sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ== dependencies: scheduler "^0.28.0" From 153fddb6900e490799271b58a7a1a55bfafdb89a Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:19:36 +0200 Subject: [PATCH 09/10] Fix: Code review fixes --- .../RunCommand/RunCommandTarget.cs | 7 +++-- .../Utilities/PortRangeHelperTests.cs | 31 +++++++++++++++++++ .../PortRangeHelper.cs | 21 +++++++++++++ .../EmptyOrPortRangeValidator.cs | 6 ++-- .../PortRangeValidator.cs | 6 ++-- .../PortValidator.cs | 3 +- Source/NETworkManager/DialogHelper.cs | 10 ++++++ Source/NETworkManager/MainWindow.xaml.cs | 30 +++++++++++++----- Source/NETworkManager/RunCommandManager.cs | 15 +++------ .../ViewModels/LookupPortViewModel.cs | 8 ++--- .../ViewModels/RunCommandViewModel.cs | 5 +++ Website/docs/run-command.md | 2 +- 12 files changed, 111 insertions(+), 33 deletions(-) create mode 100644 Source/NETworkManager.Tests/Utilities/PortRangeHelperTests.cs diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs index 2ce02a2258..4c32588196 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandTarget.cs @@ -14,14 +14,15 @@ namespace NETworkManager.Models.RunCommand; public static class RunCommandTarget { /// - /// Checks if the ports of an entry of the port lookup (e.g. "22", "80-90/tcp") are valid: from 1 to 65535, a range - /// from a lower to a higher port. + /// Checks if the ports of an entry of the port lookup (e.g. "22", "80-90/tcp") are valid, like the port lookup. /// private static bool IsValidPortLookupEntry(string entry) { var ports = entry.Split('/')[0].Split('-').Select(x => int.TryParse(x, out var port) ? port : 0).ToArray(); - return ports.All(x => x is > 0 and < 65536) && (ports.Length == 1 || ports[0] < ports[1]); + return ports.Length == 1 + ? PortRangeHelper.IsValidPort(ports[0]) + : PortRangeHelper.IsValidPortRange(ports[0], ports[1]); } /// diff --git a/Source/NETworkManager.Tests/Utilities/PortRangeHelperTests.cs b/Source/NETworkManager.Tests/Utilities/PortRangeHelperTests.cs new file mode 100644 index 0000000000..5d841aaeec --- /dev/null +++ b/Source/NETworkManager.Tests/Utilities/PortRangeHelperTests.cs @@ -0,0 +1,31 @@ +using NETworkManager.Utilities; +using Xunit; + +namespace NETworkManager.Tests.Utilities; + +public class PortRangeHelperTests +{ + [Theory] + [InlineData(1, true)] + [InlineData(22, true)] + [InlineData(65535, true)] + [InlineData(0, false)] + [InlineData(65536, false)] + [InlineData(-1, false)] + public void IsValidPort(int port, bool isValid) + { + Assert.Equal(isValid, PortRangeHelper.IsValidPort(port)); + } + + [Theory] + [InlineData(80, 90, true)] + [InlineData(1, 65535, true)] + [InlineData(90, 80, false)] + [InlineData(80, 80, false)] + [InlineData(0, 80, false)] + [InlineData(80, 65536, false)] + public void IsValidPortRange(int startPort, int endPort, bool isValid) + { + Assert.Equal(isValid, PortRangeHelper.IsValidPortRange(startPort, endPort)); + } +} diff --git a/Source/NETworkManager.Utilities/PortRangeHelper.cs b/Source/NETworkManager.Utilities/PortRangeHelper.cs index b2b40586b7..af7be3f295 100644 --- a/Source/NETworkManager.Utilities/PortRangeHelper.cs +++ b/Source/NETworkManager.Utilities/PortRangeHelper.cs @@ -5,6 +5,27 @@ namespace NETworkManager.Utilities; public static class PortRangeHelper { + /// + /// Checks if the port is valid (1 to 65535). + /// + /// Port. + /// True if the port is valid. + public static bool IsValidPort(int port) + { + return port is > 0 and < 65536; + } + + /// + /// Checks if the port range is valid: both ports are valid and the range goes from a lower to a higher port. + /// + /// First port. + /// Last port. + /// True if the range is valid. + public static bool IsValidPortRange(int startPort, int endPort) + { + return IsValidPort(startPort) && IsValidPort(endPort) && startPort < endPort; + } + public static Task ConvertPortRangeToIntArrayAsync(string portRange) { return Task.Run(() => ConvertPortRangeToIntArray(portRange)); diff --git a/Source/NETworkManager.Validators/EmptyOrPortRangeValidator.cs b/Source/NETworkManager.Validators/EmptyOrPortRangeValidator.cs index 888a9e101b..8a1524ac54 100644 --- a/Source/NETworkManager.Validators/EmptyOrPortRangeValidator.cs +++ b/Source/NETworkManager.Validators/EmptyOrPortRangeValidator.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Windows.Controls; using NETworkManager.Localization.Resources; +using NETworkManager.Utilities; namespace NETworkManager.Validators; @@ -20,8 +21,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) if (int.TryParse(portRange[0], out var startPort) && int.TryParse(portRange[1], out var endPort)) { - if (!(startPort > 0 && startPort < 65536 && endPort > 0 && endPort < 65536 && - startPort < endPort)) + if (!PortRangeHelper.IsValidPortRange(startPort, endPort)) isValid = false; } else @@ -33,7 +33,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) { if (int.TryParse(portOrRange, out var portNumber)) { - if (!(portNumber > 0 && portNumber < 65536)) + if (!PortRangeHelper.IsValidPort(portNumber)) isValid = false; } else diff --git a/Source/NETworkManager.Validators/PortRangeValidator.cs b/Source/NETworkManager.Validators/PortRangeValidator.cs index 526b255662..8c12eb2303 100644 --- a/Source/NETworkManager.Validators/PortRangeValidator.cs +++ b/Source/NETworkManager.Validators/PortRangeValidator.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Windows.Controls; using NETworkManager.Localization.Resources; +using NETworkManager.Utilities; namespace NETworkManager.Validators; @@ -20,8 +21,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) if (int.TryParse(portRange[0], out var startPort) && int.TryParse(portRange[1], out var endPort)) { - if (!(startPort > 0 && startPort < 65536 && endPort > 0 && endPort < 65536 && - startPort < endPort)) + if (!PortRangeHelper.IsValidPortRange(startPort, endPort)) isValid = false; } else @@ -33,7 +33,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) { if (int.TryParse(portOrRange, out var portNumber)) { - if (!(portNumber > 0 && portNumber < 65536)) + if (!PortRangeHelper.IsValidPort(portNumber)) isValid = false; } else diff --git a/Source/NETworkManager.Validators/PortValidator.cs b/Source/NETworkManager.Validators/PortValidator.cs index 6e51b0d372..bd50276604 100644 --- a/Source/NETworkManager.Validators/PortValidator.cs +++ b/Source/NETworkManager.Validators/PortValidator.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Windows.Controls; using NETworkManager.Localization.Resources; +using NETworkManager.Utilities; namespace NETworkManager.Validators; @@ -11,7 +12,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) if (!int.TryParse(value as string, out var portNumber)) return new ValidationResult(false, Strings.EnterValidPort); - if (portNumber > 0 && portNumber < 65536) + if (PortRangeHelper.IsValidPort(portNumber)) return ValidationResult.ValidResult; return new ValidationResult(false, Strings.EnterValidPort); diff --git a/Source/NETworkManager/DialogHelper.cs b/Source/NETworkManager/DialogHelper.cs index ddf550274e..59d40aa040 100644 --- a/Source/NETworkManager/DialogHelper.cs +++ b/Source/NETworkManager/DialogHelper.cs @@ -17,6 +17,12 @@ namespace NETworkManager /// public static class DialogHelper { + /// + /// Occurs before a message or confirmation dialog is shown, e.g. so a dialog that stays open (the run command + /// with Ctrl+Enter) can close itself first instead of the dialogs being shown on top of each other. + /// + public static event EventHandler DialogOpening; + /// /// Displays a modal message dialog with an OK button, allowing the user to acknowledge the message before /// continuing. @@ -33,6 +39,8 @@ public static Task ShowMessageAsync(Window parentWindow, string title, string me { confirmButtonText ??= Strings.OK; + DialogOpening?.Invoke(null, EventArgs.Empty); + var childWindow = new MessageChildWindow(); var childWindowViewModel = new MessageViewModel(_ => @@ -72,6 +80,8 @@ public static async Task ShowConfirmationMessageAsync(Window parentWindow, var result = false; + DialogOpening?.Invoke(null, EventArgs.Empty); + var childWindow = new MessageConfirmationChildWindow(); var childWindowViewModel = new MessageConfirmationViewModel(_ => diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs index 1e6801e565..b6f9bb0141 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -1102,14 +1102,22 @@ private void OpenRunAction() var childWindow = new RunCommandChildWindow(this); + // A message of an application that was run with Ctrl+Enter (the dialog stays open, e.g. an error of the Ping + // Monitor) closes the dialog first, so the dialogs are not shown on top of each other. + void DialogHelper_DialogOpening(object sender, EventArgs e) => CloseDialog(); + void CloseDialog() { + DialogHelper.DialogOpening -= DialogHelper_DialogOpening; + childWindow.IsOpen = false; ConfigurationManager.Current.IsChildWindowOpen = false; ConfigurationManager.OnDialogClose(); } + DialogHelper.DialogOpening += DialogHelper_DialogOpening; + var childWindowViewModel = new RunCommandViewModel(instance => { // Ctrl+Enter: the dialog stays open to run another command. @@ -1464,18 +1472,24 @@ private void UpdateOnWindowResize() private void RegisterHotKeys() { if (SettingsManager.Current.HotKey_ShowWindowEnabled) - { - RegisterHotKey(new WindowInteropHelper(this).Handle, 1, SettingsManager.Current.HotKey_ShowWindowModifier, + RegisterHotKey(1, SettingsManager.Current.HotKey_ShowWindowModifier, SettingsManager.Current.HotKey_ShowWindowKey); - _registeredHotKeys.Add(1); - } if (SettingsManager.Current.HotKey_RunCommandEnabled) - { - RegisterHotKey(new WindowInteropHelper(this).Handle, 2, SettingsManager.Current.HotKey_RunCommandModifier, + RegisterHotKey(2, SettingsManager.Current.HotKey_RunCommandModifier, SettingsManager.Current.HotKey_RunCommandKey); - _registeredHotKeys.Add(2); - } + } + + /// + /// Registers a hotkey. It fails if the combination is already used (e.g. by the other hotkey or another + /// application), then it is logged and not unregistered later. + /// + private void RegisterHotKey(int id, int modifier, int key) + { + if (RegisterHotKey(new WindowInteropHelper(this).Handle, id, modifier, key)) + _registeredHotKeys.Add(id); + else + Log.Warn($"Hotkey {id} could not be registered, the key combination is already in use."); } private void UnregisterHotKeys() diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs index e466e99b39..f591928c21 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -192,21 +192,16 @@ public static IEnumerable GetProfiles(RunCommandDefinition command) } /// - /// Checks if the request opens (or can open) a dialog, so the run command dialog must be closed: the settings, - /// the connect dialog of a remote tool, or the Subnetting, which asks for confirmation for a large calculation - /// and shows an error for an invalid subnet mask. + /// Checks if the request opens a dialog, so the run command dialog must be closed: the settings or the connect + /// dialog of a remote tool. Messages of the applications (e.g. an error or a confirmation) close the run command + /// dialog when they are shown (). /// /// Request, or null for the settings. /// True if the request opens a dialog. public static bool OpensDialog(ApplicationRequest request) { - return request switch - { - null => true, - SubnetCalculatorRequest { NewSubnetmask: not null } => true, - _ => request.Profile == null && !string.IsNullOrEmpty(request.Target) && - ConnectDialogApplications.Contains(request.Application) - }; + return request == null || request.Profile == null && !string.IsNullOrEmpty(request.Target) && + ConnectDialogApplications.Contains(request.Application); } /// diff --git a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs index 38b190062e..7a5cda2a85 100644 --- a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs @@ -231,7 +231,7 @@ private async Task PortLookupAction() // Check if the search is a port number like 22 if (RegexHelper.PortLookupPortRegex().IsMatch(searchTrim)) if (int.TryParse(searchTrim, out var port)) - if (port is > 0 and < 65536) + if (PortRangeHelper.IsValidPort(port)) { ports.Add(port); continue; @@ -243,7 +243,7 @@ private async Task PortLookupAction() var portAndProtocol = searchTrim.Split('/'); if (int.TryParse(portAndProtocol[0], out var port)) - if (port is > 0 and < 65536) + if (PortRangeHelper.IsValidPort(port)) { portsAndProtocols.Add(new Tuple(port, portAndProtocol[1])); continue; @@ -256,7 +256,7 @@ private async Task PortLookupAction() var portRange = searchTrim.Split('-'); if (int.TryParse(portRange[0], out var startPort) && int.TryParse(portRange[1], out var endPort)) - if (startPort is > 0 and < 65536 && endPort is > 0 and < 65536 && startPort < endPort) + if (PortRangeHelper.IsValidPortRange(startPort, endPort)) { for (var i = startPort; i < endPort + 1; i++) ports.Add(i); @@ -273,7 +273,7 @@ private async Task PortLookupAction() var portRange = portRangeAndProtocol[0].Split('-'); if (int.TryParse(portRange[0], out var startPort) && int.TryParse(portRange[1], out var endPort)) - if (startPort is > 0 and < 65536 && endPort is > 0 and < 65536 && startPort < endPort) + if (PortRangeHelper.IsValidPortRange(startPort, endPort)) { for (var i = startPort; i < endPort + 1; i++) portsAndProtocols.Add(new Tuple(i, portRangeAndProtocol[1])); diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs index c365ce85cc..36b2a4e59b 100644 --- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -524,6 +524,11 @@ public bool Run(RunCommandRunMode mode = RunCommandRunMode.Run) { _history.RemoveAll(x => string.Equals(x.Text, historyText, StringComparison.OrdinalIgnoreCase)); _history.Insert(0, CreateHistoryEntry(historyText, result)); + + // Like the stored history + if (_history.Count > SettingsManager.Current.RunCommand_HistoryEntries) + _history.RemoveRange(SettingsManager.Current.RunCommand_HistoryEntries, + _history.Count - SettingsManager.Current.RunCommand_HistoryEntries); } var commandPrefix = result.Command.TypeBeforeTarget && result.Type != null diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md index 23b6c078ae..81f9fcfc4e 100644 --- a/Website/docs/run-command.md +++ b/Website/docs/run-command.md @@ -111,7 +111,7 @@ While typing, suggestions are shown below the input, grouped (e.g. _Recent_, _Pr | `Up`, `Down` | Select a suggestion (`PageUp`, `PageDown`: 5 suggestions) | | `Tab` | Insert the selected suggestion into the input. If none is selected, the first profile, DNS server, port profile or type that matches the typed word (e.g. `dns borntoberoot.net c` → `cloudflare`) | | `Enter` | Run the selected suggestion, or the input if none is selected | -| `Ctrl+Enter` | Run and keep the dialog open, with the command in the input (e.g. `ping `) to run it with the next target. Commands that open a dialog (connect dialog of the remote tools, settings, Subnetting, which can ask for confirmation) close the dialog. | +| `Ctrl+Enter` | Run and keep the dialog open, with the command in the input (e.g. `ping `) to run it with the next target. Commands that open a dialog (connect dialog of the remote tools, settings) close the dialog, as does a message of a feature (e.g. an error or the confirmation of a large subnetting). | | `Shift+Enter` | Open the feature with the input filled in, without starting it (e.g. to adjust the options of a scan). The remote tools open the connect dialog (target) or connect (profile) as with `Enter`. | | `Shift+Del` | Remove the selected recent command from the history (or click the remove button of a recent command) | | `Esc` | Close the dialog (or the close button) | From ee8a764070f3d63ee7e44d4057f1875202417fc8 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:31:00 +0200 Subject: [PATCH 10/10] Fix: copilot feedback --- Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs | 7 +++++++ Source/NETworkManager.Utilities/RegexHelper.cs | 7 ++++--- Website/docs/changelog/next-release.md | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs b/Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs index 598b473440..3a2de23c0b 100644 --- a/Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs +++ b/Source/NETworkManager.Tests/Utilities/RegexHelperTests.cs @@ -80,7 +80,14 @@ public void HostnameOrDomain(string input, bool isMatch) [Theory] [InlineData("borntoberoot.net", true)] [InlineData("local.example.com", true)] + [InlineData("server-01.example.com", true)] + [InlineData("example.com.", true)] [InlineData("example", false)] + [InlineData("example.c", false)] + // A hyphen at the start or end of a label, like hostnames + [InlineData("server.-bad-.com", false)] + [InlineData("-example.com", false)] + [InlineData("example-.com", false)] public void Domain(string input, bool isMatch) { Assert.Equal(isMatch, Regex.IsMatch(input, RegexHelper.DomainRegex)); diff --git a/Source/NETworkManager.Utilities/RegexHelper.cs b/Source/NETworkManager.Utilities/RegexHelper.cs index 0a3eb2d595..4e13deb629 100644 --- a/Source/NETworkManager.Utilities/RegexHelper.cs +++ b/Source/NETworkManager.Utilities/RegexHelper.cs @@ -170,10 +170,11 @@ public static partial class RegexHelper // Match a hostname with subnetmask like server-01.example.com/255.255.255.0 public const string HostnameOrDomainWithSubnetmaskRegex = $@"^{HostnameOrDomainValues}\/{SubnetmaskValues}$"; - // Match a domain local.example.com (labels without nested quantifiers, which backtrack exponentially for an invalid - // input with many labels) + // Match a domain local.example.com: labels like a hostname (see HostnameLabelValues, no hyphen at the start or end) + // and a top-level domain of letters. Without nested quantifiers, which backtrack exponentially for an invalid input + // with many labels. public const string DomainRegex = - @"^(?=.{1,255}$)[a-zA-Z0-9\-_]{1,63}(\.[a-zA-Z0-9\-_]{1,63})*\.[a-zA-Z]{2,63}\.?$"; + $@"^(?=.{{1,255}}$)(?:{HostnameLabelValues}\.)+[a-zA-Z]{{2,63}}\.?$"; // Private port values (1-65535, without leading zeros) private const string PortValues = diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 9bd034f7d5..2f9d19a0f8 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -194,7 +194,7 @@ Release date: **xx.xx.2026** **Input validation** -- Fixed host names being accepted with a hyphen at the start or end of a label other than the first (e.g. `server.-bad-.com`) and with only digits (e.g. `10.0.0.` or `192.168.1`, which are an incomplete IPv4 address). IPv4 addresses are still accepted in all host fields, now checked explicitly in the **Remote Desktop** host field. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) +- Fixed host names being accepted with a hyphen at the start or end of a label other than the first (e.g. `server.-bad-.com`) and with only digits (e.g. `10.0.0.` or `192.168.1`, which are an incomplete IPv4 address). The same applies to the domain in **Whois**. IPv4 addresses are still accepted in all host fields, now checked explicitly in the **Remote Desktop** host field. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) - Fixed MAC addresses and their first 3 bytes (**Wake on LAN**, **Lookup** > **OUI**, profiles) being accepted with any character instead of a dot in the format `0000.0000.0000` (e.g. `0011 2233 4455`). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) - Fixed IPv6 subnets in the **Subnet Calculator** being accepted with a leading `s` (e.g. `sss2001:db8::/32`) and rejected with an embedded IPv4 address (e.g. `::ffff:192.168.0.1/96`). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646) - Fixed the port of the **Remote Desktop** host field (`host:port`) being accepted empty or as `0`. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)