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.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 33bee494ed..717cbe9f33 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. @@ -1490,6 +1491,15 @@ public static string BootstrapFilesAreUpToDate { } } + /// + /// Looks up a localized string similar to Bring window to foreground and open the run command. + /// + public static string BringWindowToForegroundAndOpenRunCommand { + get { + return ResourceManager.GetString("BringWindowToForegroundAndOpenRunCommand", resourceCulture); + } + } + /// /// Looks up a localized string similar to Broadcast. /// @@ -1944,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. /// @@ -2053,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 { @@ -2062,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 { @@ -2088,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. /// @@ -2395,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} /// @@ -2650,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. /// @@ -3621,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. /// @@ -3803,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 { @@ -4174,6 +4238,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.... /// @@ -4184,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 { @@ -4193,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 { @@ -4220,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 { @@ -4328,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 { @@ -4373,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 { @@ -4454,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 { @@ -4606,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. /// @@ -4876,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. /// @@ -5138,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 { @@ -5147,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 { @@ -5156,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 { @@ -5201,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 { @@ -6734,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!. /// @@ -6743,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. /// @@ -7896,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 { @@ -8094,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 { @@ -8976,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. /// @@ -9329,7 +9456,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 { @@ -9627,7 +9754,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 { @@ -9636,7 +9763,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 { @@ -9645,7 +9772,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 { @@ -9752,6 +9879,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.. /// @@ -9771,7 +9916,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 { @@ -10949,6 +11094,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. /// @@ -11166,7 +11320,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 { @@ -11412,6 +11566,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. /// @@ -11448,6 +11620,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). /// @@ -11628,6 +11962,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. /// @@ -11948,7 +12291,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 { @@ -11965,6 +12308,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. /// @@ -12028,6 +12389,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. /// @@ -12713,6 +13083,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.. /// @@ -13317,7 +13696,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 { @@ -13326,7 +13705,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 { @@ -14194,32 +14573,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 667accf8ac..a6cbcd9cf9 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -1,4 +1,4 @@ - + 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/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.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/ApplicationRequest.cs b/Source/NETworkManager/ApplicationRequest.cs index d9728eaf11..ad2953dbcf 100644 --- a/Source/NETworkManager/ApplicationRequest.cs +++ b/Source/NETworkManager/ApplicationRequest.cs @@ -1,5 +1,6 @@ using DnsClient; using NETworkManager.Models; +using NETworkManager.Models.Lookup; using NETworkManager.Models.RDAP; using NETworkManager.Profiles; @@ -22,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; } /// @@ -72,3 +80,31 @@ public sealed record RDAPRequest() : ApplicationRequest(ApplicationName.RDAP) /// public RDAPQueryType? QueryType { get; init; } } + +/// +/// Request to open the Subnet Calculator. is the subnet, which is calculated +/// (or subnetted / combined with / ). +/// +public sealed record SubnetCalculatorRequest() : ApplicationRequest(ApplicationName.SubnetCalculator) +{ + /// + /// New subnet mask or CIDR (e.g. "/26") to split the subnet into (Subnetting). + /// + public string NewSubnetmask { get; init; } + + /// + /// Second subnet to calculate the wide subnet with (Wide Subnet). + /// + public string Subnet2 { get; init; } +} + +/// +/// Request to open the Lookup. Without a , only the lookup is shown. +/// +public sealed record LookupRequest() : ApplicationRequest(ApplicationName.Lookup) +{ + /// + /// Lookup to show (and run with the target). + /// + public LookupType Type { get; init; } +} 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/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 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 0b448a7d49..b6f9bb0141 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -1062,7 +1062,8 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro // Change application view SelectedApplication = application; - if (string.IsNullOrEmpty(request.Target) && request.Profile == null) + // Only open the application, unless the request shows a part of it (e.g. the port lookup) + if (string.IsNullOrEmpty(request.Target) && request.Profile == null && request is not LookupRequest) return; // Create a new tab / perform the action in the application (only if it is shown) @@ -1099,19 +1100,29 @@ private void OpenRunAction() if (ConfigurationManager.Current.IsChildWindowOpen) return; - var childWindow = new RunCommandChildWindow(); + 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 => { - CloseDialog(); + // Ctrl+Enter: the dialog stays open to run another command. + if (!instance.KeepOpen) + CloseDialog(); if (instance.IsSettings) NavigationManager.OpenSettings(); @@ -1381,6 +1392,16 @@ private IntPtr HwndHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref return IntPtr.Zero; } + // Hotkey --> 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) { @@ -1443,6 +1464,7 @@ private void UpdateOnWindowResize() /* ID | Command * ---|------------------- * 1 | ShowWindow() + * 2 | ShowWindow() + OpenRunAction() */ private readonly List _registeredHotKeys = []; @@ -1450,11 +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(2, SettingsManager.Current.HotKey_RunCommandModifier, + SettingsManager.Current.HotKey_RunCommandKey); + } + + /// + /// 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/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/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 9bb85f8466..f591928c21 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -2,6 +2,7 @@ using NETworkManager.Localization; using NETworkManager.Localization.Resources; using NETworkManager.Models; +using NETworkManager.Models.Lookup; using NETworkManager.Models.Network; using NETworkManager.Models.RDAP; using NETworkManager.Models.RunCommand; @@ -35,6 +36,59 @@ public static class RunCommandManager ["entity"] = RDAPQueryType.Entity }; + /// + /// Type keywords of the Lookup and their lookup. + /// + private static readonly Dictionary LookupTypes = new() + { + ["oui"] = LookupType.OUI, + ["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() + { + // The Port Scanner is not suggested, since it needs ports (or a port profile). + [RunCommandTargetKind.Host] = + [ + ApplicationName.PingMonitor, ApplicationName.Traceroute, ApplicationName.DNSLookup, + 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.RemoteDesktop, ApplicationName.PuTTY, ApplicationName.PowerShell, ApplicationName.TigerVNC, + ApplicationName.SNMP, ApplicationName.IPScanner + ], + [RunCommandTargetKind.Hosts] = [ApplicationName.PingMonitor, ApplicationName.DNSLookup, ApplicationName.IPScanner], + [RunCommandTargetKind.IPRange] = [ApplicationName.IPScanner, ApplicationName.PingMonitor], + [RunCommandTargetKind.Subnet] = + [ + ApplicationName.IPScanner, ApplicationName.SubnetCalculator, 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. /// @@ -57,6 +111,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. /// @@ -84,6 +155,55 @@ 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. + /// 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, RunCommandTargetKind? kind = null) + { + kind ??= RunCommandTarget.GetKind(target); + + if (!TargetApplications.TryGetValue(kind.Value, 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 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 == null || request.Profile == null && !string.IsNullOrEmpty(request.Target) && + ConnectDialogApplications.Contains(request.Application); + } + /// /// Validates the input and creates the request to run. /// @@ -99,6 +219,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) @@ -112,6 +241,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, @@ -134,10 +267,10 @@ public static string TryCreateRequest(RunCommandParseResult result, ProfileInfo return null; } - // Only open the application + // Only open the application (Lookup: show the lookup of the type) if (result.Target == null) { - request = new ApplicationRequest(result.Command.Application); + request = result.Type != null ? CreateRequest(result) : new ApplicationRequest(result.Command.Application); return null; } @@ -186,6 +319,8 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT ApplicationName.Whois => new DomainValidator(), ApplicationName.IPGeolocation => new IPAddressOrHostnameValidator(), ApplicationName.WakeOnLAN => new MACAddressValidator(), + ApplicationName.SubnetCalculator => new IPv4IPv6SubnetValidator(), + ApplicationName.Lookup => new NotEndWithSemicolonValidator(), _ => null }; @@ -194,6 +329,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 @@ -203,9 +342,14 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT _ => null }; - error = result.Command.Argument == RunCommandArgument.DNSServer - ? ValidateDNSServer(result.Argument) - : Validate(argumentValidator, result.Argument); + 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) + }; if (error != null) return error; @@ -244,6 +388,29 @@ private static string ValidateDNSServer(string dnsServer) return null; } + /// + /// Validates the second argument of the Subnet Calculator: a new subnet mask or CIDR (Subnetting), which must be + /// between the CIDR of the subnet and the maximum of the address family, or a second subnet (Wide Subnet, IPv4 + /// only like in the application). + /// + private static string ValidateSubnetArgument(string subnet, string argument) + { + if (!IsSubnetmaskOrCidr(argument)) + return Validate(new IPv4SubnetValidator(), subnet) ?? Validate(new IPv4SubnetValidator(), argument); + + var (minCidr, maxCidr) = Subnetmask.GetSubnettingCidrRange(IPNetwork2.Parse(subnet.Trim())); + var newCidr = Subnetmask.ConvertSubnetmaskOrCidrToCidr(argument); + + return newCidr >= minCidr && newCidr <= maxCidr + ? null + : string.Format(Strings.EnterSubnetmaskOrCIDRBetweenXAndY, minCidr, maxCidr); + } + + private static bool IsSubnetmaskOrCidr(string value) + { + return Validate(new IPv4IPv6SubnetmaskOrCIDRValidator(), value) == null; + } + private static string Validate(ValidationRule validator, string value) { if (validator == null) @@ -270,9 +437,19 @@ 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). + ApplicationName.SubnetCalculator => result.Argument != null && IsSubnetmaskOrCidr(result.Argument) + ? new SubnetCalculatorRequest { Target = result.Target, NewSubnetmask = result.Argument } + : new SubnetCalculatorRequest { Target = result.Target, Subnet2 = result.Argument }, + ApplicationName.Lookup => new LookupRequest { Target = result.Target, Type = LookupTypes[result.Type] }, _ => new ApplicationRequest(result.Command.Application) { Target = result.Target } }; } @@ -288,35 +465,54 @@ 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.IPScanner => Create(name, ["ipscan"], [Target(Strings.IPRange)]), 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), + [ + 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"], - $"{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), - _ => Create(name, [], string.Empty, false) + [ + 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"], + [Target(Strings.Subnet), Argument($"{Strings.NewSubnetMask} | {Strings.Subnet2}")], + RunCommandArgument.Subnet, supportsProfile: false), + ApplicationName.Lookup => Create(name, [], + [ + 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 @@ -329,9 +525,25 @@ [.. 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) + /// + /// 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; + return new RunCommandDefinition { Type = RunCommandType.Application, @@ -339,11 +551,13 @@ private static RunCommandDefinition Create(ApplicationName application, string[] Command = application.ToString().ToLowerInvariant(), Aliases = aliases, SupportsTarget = supportsTarget, - // Every application that accepts a target also supports profiles. - SupportsProfile = supportsTarget, + SupportsProfile = supportsProfile, Argument = argument, Types = types ?? [], - Syntax = supportsTarget ? $"{syntax} | @<{Strings.Profile}>" : string.Empty + TypeBeforeTarget = typeBeforeTarget, + 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..57ce6dda23 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,27 @@ public enum RunCommandSuggestionKind /// /// DNS server configured in the DNS lookup. /// - DNSServer + 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"). + /// + 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 +68,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 9ae439de75..0a3ca4a7ff 100644 --- a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs @@ -194,6 +194,23 @@ private bool OUILookup_CanExecute(object parameter) !HasError; } + /// + /// Looks up MAC addresses or vendors (e.g. from the run command). + /// + /// MAC addresses or vendors, separated by ";". + /// 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; + + if (start) + OUILookupAction(); + } + /// /// Performs the OUI lookup. /// @@ -211,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 955e6f8ee8..7a5cda2a85 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; @@ -192,6 +191,22 @@ private bool PortLookup_CanExecute(object parameter) !ConfigurationManager.Current.IsChildWindowOpen && !HasError; } + /// + /// Looks up ports or services (e.g. from the run command). + /// + /// Ports, port ranges or services, separated by ";". + /// 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 start ? PortLookupAction() : Task.CompletedTask; + } + /// /// Performs the port lookup. /// @@ -209,22 +224,26 @@ 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 (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) + if (PortRangeHelper.IsValidPort(port)) { ports.Add(port); continue; } // 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('/'); 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; @@ -232,12 +251,12 @@ 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('-'); 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); @@ -247,14 +266,14 @@ 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('/'); 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/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 d3868fd400..36b2a4e59b 100644 --- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -1,24 +1,34 @@ using NETworkManager.Localization.Resources; +using NETworkManager.Models; using NETworkManager.Models.RunCommand; +using NETworkManager.Controls; using NETworkManager.Profiles; using NETworkManager.Settings; using NETworkManager.Utilities; 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,13 +42,46 @@ 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; + + /// + /// 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. + /// + 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 (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)> _history; + private readonly List _history; /// /// Names (command and aliases) and translated name per command. @@ -50,12 +93,43 @@ 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 RunCommandTargetKind _clipboardKind; + + 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) { @@ -71,10 +145,33 @@ public RunCommandViewModel(Action runHandler, Action