diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 9bdb5486be..33bee494ed 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -2052,6 +2052,24 @@ public static string CommandLineArguments { } } + /// + /// Looks up a localized string similar to The command "{0}" does not accept arguments!. + /// + public static string CommandXDoesNotAcceptArguments { + get { + return ResourceManager.GetString("CommandXDoesNotAcceptArguments", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The command "{0}" does not support profiles!. + /// + public static string CommandXDoesNotSupportProfiles { + get { + return ResourceManager.GetString("CommandXDoesNotSupportProfiles", resourceCulture); + } + } + /// /// Looks up a localized string similar to Comment. /// @@ -4057,6 +4075,15 @@ public static string EndTime { } } + /// + /// Looks up a localized string similar to Enter a command!. + /// + public static string EnterACommand { + get { + return ResourceManager.GetString("EnterACommand", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enter a domain to query whois.... /// @@ -4156,6 +4183,15 @@ public static string EnterSubnetsToCreateAWideSubnet { } } + /// + /// Looks up a localized string similar to Enter the name of a profile after "@"!. + /// + public static string EnterTheNameOfAProfileAfterAt { + get { + return ResourceManager.GetString("EnterTheNameOfAProfileAfterAt", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enter a valid AS number (e.g., "AS15169")!. /// @@ -9292,6 +9328,15 @@ public static string ProfileWithThisNameAlreadyExists { } } + /// + /// Looks up a localized string similar to Profile "{0}" not found!. + /// + public static string ProfileXNotFound { + get { + return ResourceManager.GetString("ProfileXNotFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to Program. /// @@ -9752,6 +9797,15 @@ public static string ReceivedLost { } } + /// + /// Looks up a localized string similar to Recent. + /// + public static string Recent { + get { + return ResourceManager.GetString("Recent", resourceCulture); + } + } + /// /// Looks up a localized string similar to Reconnect. /// @@ -11367,6 +11421,15 @@ public static string RunBackgroundJobEveryXMinute { } } + /// + /// Looks up a localized string similar to Run command. + /// + public static string RunCommand { + get { + return ResourceManager.GetString("RunCommand", resourceCulture); + } + } + /// /// Looks up a localized string similar to Run command.... /// @@ -11884,6 +11947,15 @@ public static string SettingsResetExclamationMark { } } + /// + /// Looks up a localized string similar to Several profiles are named "{0}", select one!. + /// + public static string SeveralProfilesAreNamedXSelectOne { + get { + return ResourceManager.GetString("SeveralProfilesAreNamedXSelectOne", resourceCulture); + } + } + /// /// Looks up a localized string similar to Share Gateway credentials with remote computer. /// @@ -13244,6 +13316,24 @@ public static string Unknown { } } + /// + /// Looks up a localized string similar to Unknown argument "{0}"!. + /// + public static string UnknownArgumentX { + get { + return ResourceManager.GetString("UnknownArgumentX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unknown command "{0}"!. + /// + public static string UnknownCommandX { + get { + return ResourceManager.GetString("UnknownCommandX", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unkown error!. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 17a09901cd..667accf8ac 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -4857,4 +4857,34 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri 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. + + Profile "{0}" not found! + + + Several profiles are named "{0}", select one! + + + Enter a command! + + + Unknown command "{0}"! + + + The command "{0}" does not accept arguments! + + + The command "{0}" does not support profiles! + + + Enter the name of a profile after "@"! + + + Unknown argument "{0}"! + + + Run command + + + Recent + \ No newline at end of file diff --git a/Source/NETworkManager.Models/EventSystem/EventSystem.cs b/Source/NETworkManager.Models/EventSystem/EventSystem.cs deleted file mode 100644 index a5f6617773..0000000000 --- a/Source/NETworkManager.Models/EventSystem/EventSystem.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; - -namespace NETworkManager.Models.EventSystem; - -public static class EventSystem -{ - // This will notify the main window, to change the view to another application and redirect some data (hostname, ip) - public static event EventHandler OnRedirectDataToApplicationEvent; - - public static void RedirectToApplication(ApplicationName application, string data) - { - OnRedirectDataToApplicationEvent?.Invoke(null, new EventSystemRedirectArgs(application, data)); - } - - // This will notify the main window, to change the view to the settings... - public static event EventHandler OnRedirectToSettingsEvent; - - public static void RedirectToSettings() - { - OnRedirectToSettingsEvent?.Invoke(null, EventArgs.Empty); - } -} \ No newline at end of file diff --git a/Source/NETworkManager.Models/EventSystem/EventSystemRedirectArgs.cs b/Source/NETworkManager.Models/EventSystem/EventSystemRedirectArgs.cs deleted file mode 100644 index 42c47ece31..0000000000 --- a/Source/NETworkManager.Models/EventSystem/EventSystemRedirectArgs.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace NETworkManager.Models.EventSystem; - -public class EventSystemRedirectArgs : EventArgs -{ - public EventSystemRedirectArgs(ApplicationName application, string args) - { - Application = application; - Args = args; - } - - public ApplicationName Application { get; set; } - public string Args { get; set; } -} \ No newline at end of file diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandArgument.cs b/Source/NETworkManager.Models/RunCommand/RunCommandArgument.cs new file mode 100644 index 0000000000..0271cb442f --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandArgument.cs @@ -0,0 +1,12 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// Second argument of a run command after the target. +/// +public enum RunCommandArgument +{ + None, + Ports, + DNSServer, + Broadcast +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandDefinition.cs b/Source/NETworkManager.Models/RunCommand/RunCommandDefinition.cs new file mode 100644 index 0000000000..3b79913499 --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandDefinition.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NETworkManager.Models.RunCommand; + +/// +/// Command of the run command dialog (e.g. "dnslookup" with the alias "dns"), which opens an application or the +/// settings. +/// +public sealed class RunCommandDefinition +{ + /// + /// Whether the command opens an application or the settings. + /// + public RunCommandType Type { get; init; } + + /// + /// Application to open ( for the settings). + /// + public ApplicationName Application { get; init; } + + /// + /// Command (lowercase, e.g. "dnslookup"). + /// + public string Command { get; init; } + + /// + /// Short forms of the command (lowercase, e.g. "dns", "nslookup"). + /// + public IReadOnlyList Aliases { get; init; } = []; + + /// + /// The command accepts a target (e.g. host, IP range, query). + /// + public bool SupportsTarget { get; init; } + + /// + /// The command accepts a profile ("@name") instead of a target. + /// + public bool SupportsProfile { get; init; } + + /// + /// Second argument after the target (e.g. ports or DNS server). + /// + public RunCommandArgument Argument { get; init; } + + /// + /// Type keywords that can follow the target (lowercase, e.g. "a", "aaaa" for the DNS lookup). + /// + public IReadOnlyList Types { get; init; } = []; + + /// + /// Syntax of the arguments for the hint (e.g. "<hosts> [server] [type]"), empty if there are none. + /// + public string Syntax { get; init; } = string.Empty; + + /// + /// Checks if the text is the command or one of its aliases (case-insensitive). + /// + /// Command text. + /// True if the text matches. + public bool Matches(string text) + { + return string.Equals(Command, text, StringComparison.OrdinalIgnoreCase) || + Aliases.Any(x => string.Equals(x, text, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParseError.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParseError.cs new file mode 100644 index 0000000000..bac326a81b --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandParseError.cs @@ -0,0 +1,15 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// Errors of a run command input. +/// +public enum RunCommandParseError +{ + None, + Empty, + UnknownCommand, + NoArguments, + NoProfiles, + MissingProfileName, + UnknownArgument +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs new file mode 100644 index 0000000000..898166f108 --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs @@ -0,0 +1,63 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// Result of . +/// +public sealed class RunCommandParseResult +{ + /// + /// Command that matches the first word, or null if there is none. + /// + public RunCommandDefinition Command { get; init; } + + /// + /// First word as typed. + /// + public string CommandText { get; init; } = string.Empty; + + /// + /// Target (e.g. "10.0.0.1;10.0.0.2"), or null if there is none. + /// + public string Target { get; init; } + + /// + /// Name of the profile ("@name"), or null if no profile is used. Empty if only "@" was typed. + /// + public string ProfileName { get; init; } + + /// + /// Second argument (e.g. ports or DNS server), or null if there is none. + /// + public string Argument { get; init; } + + /// + /// Type keyword in lowercase (e.g. "aaaa"), or null if there is none. + /// + public string Type { get; init; } + + /// + /// Part of the input that is being typed. + /// + public RunCommandPosition Position { get; init; } + + /// + /// Word that is being typed (empty after a space), used to filter the suggestions. + /// + public string CurrentWord { get; init; } = string.Empty; + + /// + /// Input without the word that is being typed (e.g. "dns borntoberoot.net " for "dns borntoberoot.net aa"), + /// used to complete a suggestion. + /// + public string InputBeforeCurrentWord { get; init; } = string.Empty; + + /// + /// Error, if the input can't be run. + /// + public RunCommandParseError Error { get; init; } + + /// + /// Value the error refers to (e.g. the unknown command or argument). + /// + public string ErrorValue { get; init; } +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs new file mode 100644 index 0000000000..51787a4ff7 --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace NETworkManager.Models.RunCommand; + +/// +/// Parses the input of the run command dialog: "command target [argument] [type]" or "command @profile". +/// +public static partial class RunCommandParser +{ + /// + /// A list separator with whitespace around it ("10.0.0.1; 10.0.0.2"), or a value in quotes, which is kept as it is. + /// + [GeneratedRegex("\"[^\"]*(?:\"|$)|\\s*;\\s*")] + private static partial Regex ListSeparatorOrQuoteRegex(); + + /// + /// A word: a value in quotes (can contain spaces, the closing quote can be missing while typing) or a value + /// without spaces. + /// + [GeneratedRegex("\"[^\"]*(?:\"|$)|[^\\s\"]+")] + private static partial Regex WordRegex(); + + /// + /// Parses the input. + /// - Lists use ";" as everywhere in the application. Whitespace around ";" is removed, so whitespace only + /// separates the arguments. + /// - The first word is the command (name or alias), the second word the target. "@name" (the rest of the input, + /// since profile names can contain spaces) is a profile instead of a target. + /// - A value with spaces is written in quotes (e.g. a DNS server "Google Public DNS"). + /// - Further words are a type keyword of the command or its second argument (e.g. ports, DNS server), in any + /// order. + /// + /// Input of the dialog. + /// Available commands. + /// Parse result, see . + public static RunCommandParseResult Parse(string input, IEnumerable commands) + { + input = (input ?? string.Empty).TrimStart(); + + // Whitespace around ";" is removed (outside of quotes), so a list that is being typed ("10.0.0.1; ") is still + // the same word. + var normalized = ListSeparatorOrQuoteRegex().Replace(input, x => x.Value.StartsWith('"') ? x.Value : ";"); + + // Words as typed (with quotes, to rebuild the input) and their values (without quotes) + var tokens = WordRegex().Matches(normalized).Select(x => x.Value).ToArray(); + var words = tokens.Select(Unquote).ToArray(); + + // Whitespace in an open quote (e.g. "Google ) is part of the word that is being typed. + var endsWithWhitespace = normalized.Length > 0 && char.IsWhiteSpace(normalized[^1]) && + !(tokens.Length > 0 && IsOpenQuote(tokens[^1])); + + if (words.Length == 0) + return new RunCommandParseResult { Position = RunCommandPosition.Command, Error = RunCommandParseError.Empty }; + + var commandText = words[0]; + var command = commands.FirstOrDefault(x => x.Matches(commandText)); + + // The command is being typed. + if (command == null || words.Length == 1 && !endsWithWhitespace) + return new RunCommandParseResult + { + Command = command, + CommandText = commandText, + Position = RunCommandPosition.Command, + CurrentWord = commandText, + Error = command == null ? RunCommandParseError.UnknownCommand : RunCommandParseError.None, + ErrorValue = command == null ? commandText : null + }; + + var commandPrefix = commandText + " "; + + // Only the command (e.g. "ping "). + if (words.Length == 1) + return new RunCommandParseResult + { + Command = command, + CommandText = commandText, + Position = RunCommandPosition.Target, + InputBeforeCurrentWord = commandPrefix + }; + + // Profile: the rest of the input after "@" is the name. + if (words[1].StartsWith('@')) + { + var rest = input[(input.IndexOf('@') + 1)..].Trim(); + string profileName; + string textAfterName = null; + + // Name in quotes (e.g. @"My Server"): text after the closing quote is not part of the name. + var closingQuote = rest.StartsWith('"') ? rest.IndexOf('"', 1) : -1; + + if (closingQuote > 0) + { + profileName = rest[1..closingQuote].Trim(); + + var remainder = rest[(closingQuote + 1)..].Trim(); + + if (remainder.Length > 0) + textAfterName = remainder; + } + else + { + // Quotes around the whole target (e.g. "@My Server") or an open quote while typing are removed. + profileName = rest.Trim('"').Trim(); + } + + var error = !command.SupportsProfile + ? RunCommandParseError.NoProfiles + : profileName.Length == 0 + ? RunCommandParseError.MissingProfileName + : textAfterName != null + ? RunCommandParseError.UnknownArgument + : RunCommandParseError.None; + + return new RunCommandParseResult + { + Command = command, + CommandText = commandText, + ProfileName = profileName, + Position = RunCommandPosition.Target, + CurrentWord = "@" + profileName, + InputBeforeCurrentWord = commandPrefix, + Error = error, + ErrorValue = error switch + { + RunCommandParseError.NoProfiles => command.Command, + RunCommandParseError.UnknownArgument => textAfterName, + _ => null + } + }; + } + + if (!command.SupportsTarget) + return new RunCommandParseResult + { + Command = command, + CommandText = commandText, + Position = RunCommandPosition.Target, + CurrentWord = endsWithWhitespace ? string.Empty : words[^1], + InputBeforeCurrentWord = string.Join(" ", tokens[..^(endsWithWhitespace ? 0 : 1)]) + " ", + Error = RunCommandParseError.NoArguments, + ErrorValue = command.Command + }; + + // Further words: type keyword or second argument, in any order. A type keyword is never the second argument. + string type = null; + string argument = null; + string unknownArgument = null; + + foreach (var word in words[2..]) + { + var keyword = word.ToLowerInvariant(); + + if (command.Types.Contains(keyword)) + { + if (type == null) + type = keyword; + else + unknownArgument ??= word; + } + else if (argument == null && command.Argument != RunCommandArgument.None) + { + argument = word; + } + else + { + unknownArgument ??= word; + } + } + + var isTypingTarget = words.Length == 2 && !endsWithWhitespace; + var currentWord = endsWithWhitespace ? string.Empty : words[^1]; + + return new RunCommandParseResult + { + Command = command, + CommandText = commandText, + Target = words[1], + Argument = argument, + Type = type, + Position = isTypingTarget ? RunCommandPosition.Target : RunCommandPosition.Argument, + CurrentWord = currentWord, + InputBeforeCurrentWord = string.Join(" ", tokens[..^(endsWithWhitespace ? 0 : 1)]) + " ", + Error = unknownArgument == null ? RunCommandParseError.None : RunCommandParseError.UnknownArgument, + ErrorValue = unknownArgument + }; + } + + /// + /// Removes the quotes of a value (e.g. "Google Public DNS"). + /// + private static string Unquote(string word) + { + return word.StartsWith('"') ? word.Trim('"') : word; + } + + private static bool IsOpenQuote(string token) + { + return token.StartsWith('"') && (token.Length == 1 || !token.EndsWith('"')); + } +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandPosition.cs b/Source/NETworkManager.Models/RunCommand/RunCommandPosition.cs new file mode 100644 index 0000000000..66efa88f6b --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandPosition.cs @@ -0,0 +1,11 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// Part of the input that is being typed (used for the suggestions). +/// +public enum RunCommandPosition +{ + Command, + Target, + Argument +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandType.cs b/Source/NETworkManager.Models/RunCommand/RunCommandType.cs new file mode 100644 index 0000000000..6f10bbbc56 --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandType.cs @@ -0,0 +1,10 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// What a run command opens. +/// +public enum RunCommandType +{ + Application, + Setting +} diff --git a/Source/NETworkManager.Profiles/ProfileInfo.cs b/Source/NETworkManager.Profiles/ProfileInfo.cs index 05f61c63bb..ebbe5562f2 100644 --- a/Source/NETworkManager.Profiles/ProfileInfo.cs +++ b/Source/NETworkManager.Profiles/ProfileInfo.cs @@ -1,5 +1,6 @@ //using System.Collections.Generic; using NETworkManager.Controls; +using NETworkManager.Models; using NETworkManager.Models.Network; using NETworkManager.Models.PowerShell; using NETworkManager.Models.PuTTY; @@ -504,4 +505,61 @@ public ProfileInfo(ProfileInfo profile) public bool IPGeolocation_Enabled { get; set; } public bool IPGeolocation_InheritHost { get; set; } = true; public string IPGeolocation_Host { get; set; } + + /// + /// Checks if the profile is enabled for an application. + /// + /// Application to check. + /// True if the profile is enabled for the application. + public bool IsEnabled(ApplicationName application) + { + return application switch + { + ApplicationName.NetworkInterface => NetworkInterface_Enabled, + ApplicationName.IPScanner => IPScanner_Enabled, + ApplicationName.PortScanner => PortScanner_Enabled, + ApplicationName.PingMonitor => PingMonitor_Enabled, + ApplicationName.Traceroute => Traceroute_Enabled, + ApplicationName.DNSLookup => DNSLookup_Enabled, + ApplicationName.RemoteDesktop => RemoteDesktop_Enabled, + ApplicationName.PowerShell => PowerShell_Enabled, + ApplicationName.PuTTY => PuTTY_Enabled, + ApplicationName.TigerVNC => TigerVNC_Enabled, + ApplicationName.WebConsole => WebConsole_Enabled, + ApplicationName.SNMP => SNMP_Enabled, + ApplicationName.WakeOnLAN => WakeOnLAN_Enabled, + ApplicationName.RDAP => RDAP_Enabled, + ApplicationName.Whois => Whois_Enabled, + ApplicationName.IPGeolocation => IPGeolocation_Enabled, + _ => false + }; + } + + /// + /// Gets the target of the profile in an application, e.g. the host, IP range, domain, URL or MAC address. + /// + /// Application to get the target for. + /// Target, or null if the application has none. + public string GetTarget(ApplicationName application) + { + return application switch + { + ApplicationName.IPScanner => IPScanner_HostOrIPRange, + ApplicationName.PortScanner => PortScanner_Host, + ApplicationName.PingMonitor => PingMonitor_Host, + ApplicationName.Traceroute => Traceroute_Host, + ApplicationName.DNSLookup => DNSLookup_Host, + ApplicationName.RemoteDesktop => RemoteDesktop_Host, + ApplicationName.PowerShell => PowerShell_Host, + ApplicationName.PuTTY => PuTTY_HostOrSerialLine, + ApplicationName.TigerVNC => TigerVNC_Host, + ApplicationName.WebConsole => WebConsole_Url, + ApplicationName.SNMP => SNMP_Host, + ApplicationName.WakeOnLAN => WakeOnLAN_MACAddress, + ApplicationName.RDAP => RDAP_Query, + ApplicationName.Whois => Whois_Domain, + ApplicationName.IPGeolocation => IPGeolocation_Host, + _ => null + }; + } } \ No newline at end of file diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index 2338dddb2e..3281631f50 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -808,6 +808,22 @@ public bool ExpandApplicationView } } + // Run command + + public ObservableCollection RunCommand_History + { + get; + set + { + if (value == field) + return; + + UpdateCollectionChanged(field, value); + field = value; + OnPropertyChanged(); + } + } = []; + #endregion #region Dashboard @@ -4056,6 +4072,9 @@ public SettingsInfo() // General General_ApplicationList.CollectionChanged += CollectionChanged; + // Run command + RunCommand_History.CollectionChanged += CollectionChanged; + // Network Network_CustomDNSServers.CollectionChanged += CollectionChanged; diff --git a/Source/NETworkManager.Tests/NETworkManager.Tests.csproj b/Source/NETworkManager.Tests/NETworkManager.Tests.csproj new file mode 100644 index 0000000000..233c58941c --- /dev/null +++ b/Source/NETworkManager.Tests/NETworkManager.Tests.csproj @@ -0,0 +1,18 @@ + + + Exe + NETworkManager.Tests + NETworkManager.Tests + net10.0-windows10.0.22621.0 + false + true + + + + + + + + + + diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs new file mode 100644 index 0000000000..dccb2af8f9 --- /dev/null +++ b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs @@ -0,0 +1,317 @@ +using NETworkManager.Models; +using NETworkManager.Models.RunCommand; +using Xunit; + +namespace NETworkManager.Tests.RunCommand; + +public class RunCommandParserTests +{ + private static readonly RunCommandDefinition[] Commands = + [ + new() + { + Type = RunCommandType.Application, Application = ApplicationName.PingMonitor, Command = "pingmonitor", + Aliases = ["ping"], SupportsTarget = true, SupportsProfile = true + }, + new() + { + Type = RunCommandType.Application, Application = ApplicationName.RemoteDesktop, Command = "remotedesktop", + Aliases = ["rdp"], SupportsTarget = true, SupportsProfile = true + }, + new() + { + Type = RunCommandType.Application, Application = ApplicationName.DNSLookup, Command = "dnslookup", + Aliases = ["dns", "nslookup"], SupportsTarget = true, SupportsProfile = true, + Argument = RunCommandArgument.DNSServer, Types = ["a", "aaaa", "cname", "ptr"] + }, + new() + { + Type = RunCommandType.Application, Application = ApplicationName.PortScanner, Command = "portscanner", + Aliases = ["portscan"], SupportsTarget = true, SupportsProfile = true, Argument = RunCommandArgument.Ports + }, + new() + { + Type = RunCommandType.Application, Application = ApplicationName.SubnetCalculator, + Command = "subnetcalculator" + }, + new() { Type = RunCommandType.Setting, Command = "settings" } + ]; + + private static RunCommandParseResult Parse(string input) + { + return RunCommandParser.Parse(input, Commands); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Empty(string input) + { + var result = Parse(input); + + Assert.Equal(RunCommandParseError.Empty, result.Error); + Assert.Equal(RunCommandPosition.Command, result.Position); + } + + [Theory] + [InlineData("ping", "pingmonitor")] + [InlineData("PING", "pingmonitor")] + [InlineData("nslookup", "dnslookup")] + [InlineData("dnslookup", "dnslookup")] + public void CommandAndAliases(string input, string command) + { + var result = Parse(input); + + Assert.Equal(command, result.Command.Command); + Assert.Equal(RunCommandParseError.None, result.Error); + Assert.Equal(RunCommandPosition.Command, result.Position); + Assert.Equal(input, result.CurrentWord); + } + + [Fact] + public void UnknownCommand() + { + var result = Parse("pin 10.0.0.1"); + + Assert.Null(result.Command); + Assert.Equal(RunCommandParseError.UnknownCommand, result.Error); + Assert.Equal("pin", result.ErrorValue); + } + + [Fact] + public void CommandWithoutTarget() + { + var result = Parse("ping "); + + Assert.Equal(RunCommandParseError.None, result.Error); + Assert.Null(result.Target); + Assert.Equal(RunCommandPosition.Target, result.Position); + Assert.Equal("ping ", result.InputBeforeCurrentWord); + } + + [Fact] + public void Target() + { + var result = Parse("ping 10.0.0.1"); + + Assert.Equal("10.0.0.1", result.Target); + Assert.Equal(RunCommandPosition.Target, result.Position); + Assert.Equal("10.0.0.1", result.CurrentWord); + Assert.Equal("ping ", result.InputBeforeCurrentWord); + } + + [Theory] + [InlineData("ping 10.0.0.1; 10.0.0.2")] + [InlineData("ping 10.0.0.1 ;10.0.0.2")] + [InlineData("ping 10.0.0.1 ; 10.0.0.2")] + public void ListWithWhitespace(string input) + { + var result = Parse(input); + + Assert.Equal("10.0.0.1;10.0.0.2", result.Target); + Assert.Equal(RunCommandParseError.None, result.Error); + } + + [Theory] + [InlineData("dns borntoberoot.net 1.1.1.1 cname")] + [InlineData("dns borntoberoot.net CNAME 1.1.1.1")] + public void DNSServerAndTypeInAnyOrder(string input) + { + var result = Parse(input); + + Assert.Equal("borntoberoot.net", result.Target); + Assert.Equal("1.1.1.1", result.Argument); + Assert.Equal("cname", result.Type); + Assert.Equal(RunCommandParseError.None, result.Error); + } + + [Theory] + [InlineData("dns borntoberoot.net \"Google Public DNS\" aaaa")] + [InlineData("dns borntoberoot.net aaaa \"Google Public DNS\"")] + public void QuotedArgument(string input) + { + var result = Parse(input); + + Assert.Equal("Google Public DNS", result.Argument); + Assert.Equal("aaaa", result.Type); + Assert.Equal(RunCommandParseError.None, result.Error); + } + + [Fact] + public void OpenQuoteIsTheWordThatIsBeingTyped() + { + // Whitespace in an open quote doesn't start a new word. + var result = Parse("dns borntoberoot.net \"Google "); + + Assert.Equal(RunCommandPosition.Argument, result.Position); + Assert.Equal("Google ", result.CurrentWord); + Assert.Equal("dns borntoberoot.net ", result.InputBeforeCurrentWord); + } + + [Fact] + public void CompletionKeepsQuotes() + { + var result = Parse("dns borntoberoot.net \"Google Public DNS\" aa"); + + Assert.Equal("aa", result.CurrentWord); + Assert.Equal("dns borntoberoot.net \"Google Public DNS\" ", result.InputBeforeCurrentWord); + } + + [Theory] + [InlineData("rdp @\"Web Server 01\"")] + [InlineData("rdp \"@Web Server 01\"")] + public void QuotedProfileName(string input) + { + var result = Parse(input); + + Assert.Equal("Web Server 01", result.ProfileName); + } + + [Fact] + public void TextAfterAQuotedProfileName() + { + var result = Parse("rdp @\"Web Server\" x"); + + Assert.Equal("Web Server", result.ProfileName); + Assert.Equal(RunCommandParseError.UnknownArgument, result.Error); + Assert.Equal("x", result.ErrorValue); + } + + [Fact] + public void ListThatIsBeingTyped() + { + // "; " is the separator of a list that is still being typed, not the end of the target. + var result = Parse("ping 10.0.0.1; "); + + Assert.Equal(RunCommandPosition.Target, result.Position); + Assert.Equal("10.0.0.1;", result.Target); + Assert.Equal("ping ", result.InputBeforeCurrentWord); + } + + [Fact] + public void SemicolonInQuotesIsKept() + { + var result = Parse("dns borntoberoot.net \"DNS ; Server\""); + + Assert.Equal("DNS ; Server", result.Argument); + } + + [Fact] + public void HostsAndType() + { + var result = Parse("dns 1.1.1.1; 1.0.0.1 ptr"); + + Assert.Equal("1.1.1.1;1.0.0.1", result.Target); + Assert.Equal("ptr", result.Type); + Assert.Null(result.Argument); + } + + [Fact] + public void PortsWithWhitespace() + { + var result = Parse("portscan 10.0.0.1; 10.0.0.2 22; 80; 443"); + + Assert.Equal("10.0.0.1;10.0.0.2", result.Target); + Assert.Equal("22;80;443", result.Argument); + Assert.Equal(RunCommandParseError.None, result.Error); + } + + [Fact] + public void TargetThatLooksLikeAType() + { + // The second word is always the target. + var result = Parse("dns a"); + + Assert.Equal("a", result.Target); + Assert.Null(result.Type); + } + + [Theory] + [InlineData("ping 10.0.0.1 foo", "foo")] + [InlineData("dns borntoberoot.net 1.1.1.1 8.8.8.8", "8.8.8.8")] + [InlineData("dns borntoberoot.net a aaaa 1.1.1.1", "aaaa")] + public void UnknownArgument(string input, string unknown) + { + var result = Parse(input); + + Assert.Equal(RunCommandParseError.UnknownArgument, result.Error); + Assert.Equal(unknown, result.ErrorValue); + } + + [Theory] + [InlineData("subnetcalculator 10.0.0.0/24")] + [InlineData("settings general")] + public void NoArguments(string input) + { + var result = Parse(input); + + Assert.Equal(RunCommandParseError.NoArguments, result.Error); + } + + [Fact] + public void NoArgumentsCurrentWordIsTheLastWord() + { + var result = Parse("settings foo ba"); + + Assert.Equal("ba", result.CurrentWord); + Assert.Equal("settings foo ", result.InputBeforeCurrentWord); + } + + [Theory] + [InlineData("ping @dns-01", "dns-01")] + [InlineData("ping @Web Server 01", "Web Server 01")] + [InlineData("ping @Web Server 01 ", "Web Server 01")] + [InlineData("ping @a;b", "a;b")] + public void Profile(string input, string profileName) + { + var result = Parse(input); + + Assert.Equal(profileName, result.ProfileName); + Assert.Null(result.Target); + Assert.Equal(RunCommandParseError.None, result.Error); + Assert.Equal(RunCommandPosition.Target, result.Position); + } + + [Fact] + public void ProfileWithoutName() + { + var result = Parse("ping @"); + + Assert.Equal(string.Empty, result.ProfileName); + Assert.Equal(RunCommandParseError.MissingProfileName, result.Error); + Assert.Equal("@", result.CurrentWord); + } + + [Fact] + public void ProfileNotSupported() + { + var result = Parse("settings @dns-01"); + + Assert.Equal(RunCommandParseError.NoProfiles, result.Error); + } + + [Fact] + public void HostWithAtSign() + { + // "@" is only a profile at the start of the target (e.g. "ssh admin@server01"). + var result = Parse("ping admin@server01"); + + Assert.Equal("admin@server01", result.Target); + Assert.Null(result.ProfileName); + } + + [Theory] + [InlineData("dns borntoberoot.net ", RunCommandPosition.Argument, "", "dns borntoberoot.net ")] + [InlineData("dns borntoberoot.net aa", RunCommandPosition.Argument, "aa", "dns borntoberoot.net ")] + [InlineData("dns borntoberoot.net 1.1.1.1 cn", RunCommandPosition.Argument, "cn", "dns borntoberoot.net 1.1.1.1 ")] + [InlineData("dns border", RunCommandPosition.Target, "border", "dns ")] + public void PositionForSuggestions(string input, RunCommandPosition position, string currentWord, string before) + { + var result = Parse(input); + + Assert.Equal(position, result.Position); + Assert.Equal(currentWord, result.CurrentWord); + Assert.Equal(before, result.InputBeforeCurrentWord); + } +} diff --git a/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs b/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs new file mode 100644 index 0000000000..924f9a1aad --- /dev/null +++ b/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using NETworkManager.Utilities; +using Xunit; + +namespace NETworkManager.Tests.Utilities; + +public class ListHelperTests +{ + [Fact] + public void AddsNewEntryAtTheTop() + { + ObservableCollection list = ["b", "c"]; + + ListHelper.Modify(list, "a", 5); + + Assert.Equal(["a", "b", "c"], list); + } + + [Fact] + public void MovesExistingEntryToTheTop() + { + ObservableCollection list = ["a", "b", "c"]; + + ListHelper.Modify(list, "c", 5); + + Assert.Equal(["c", "a", "b"], list); + } + + [Fact] + public void RemovesOldestEntriesBeyondTheLength() + { + ObservableCollection list = ["b", "c", "d"]; + + ListHelper.Modify(list, "a", 3); + + Assert.Equal(["a", "b", "c"], list); + } + + [Fact] + public void ShortensAListThatIsTooLong() + { + // E.g. after the history length was reduced in the settings. + ObservableCollection list = ["a", "b", "c", "d"]; + + ListHelper.Modify(list, "a", 2); + + Assert.Equal(["a", "b"], list); + } + + [Fact] + public void RemovesDuplicatesOfTheEntry() + { + ObservableCollection list = [1, 2, 3, 2]; + + ListHelper.Modify(list, 2, 10); + + Assert.Equal([2, 1, 3], list); + } + + [Fact] + public void DoesNotChangeTheListIfTheEntryIsAlreadyTheNewest() + { + ObservableCollection list = ["a", "b"]; + var changes = 0; + list.CollectionChanged += (_, _) => changes++; + + ListHelper.Modify(list, "a", 5); + + Assert.Equal(0, changes); + } + + [Fact] + public void MovesInsteadOfResetting() + { + // A reset (Clear) would remove the entry that a ComboBox shows, which can clear its text. + ObservableCollection list = ["a", "b", "c"]; + var actions = new System.Collections.Generic.List(); + list.CollectionChanged += (_, e) => actions.Add(e.Action); + + ListHelper.Modify(list, "b", 5); + + Assert.Equal([NotifyCollectionChangedAction.Move], actions); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void IgnoresEmptyEntries(string entry) + { + ObservableCollection list = ["a"]; + + ListHelper.Modify(list, entry, 5); + + Assert.Equal(["a"], list); + } + + [Fact] + public void IgnoresDefaultValue() + { + // E.g. a port of 0 if the port is not used. + ObservableCollection list = [22]; + + ListHelper.Modify(list, 0, 5); + + Assert.Equal([22], list); + } + + [Fact] + public void UsesTheComparerAndKeepsTheLatestSpelling() + { + ObservableCollection list = ["a", "ping 10.0.0.1"]; + + ListHelper.Modify(list, "Ping 10.0.0.1", 5, StringComparer.OrdinalIgnoreCase); + + Assert.Equal(["Ping 10.0.0.1", "a"], list); + } + + [Fact] + public void ThrowsIfTheLengthIsNotPositive() + { + Assert.Throws(() => ListHelper.Modify(new ObservableCollection(), "a", 0)); + } +} diff --git a/Source/NETworkManager.Utilities.WPF/FrameworkElementExtensions.cs b/Source/NETworkManager.Utilities.WPF/FrameworkElementExtensions.cs new file mode 100644 index 0000000000..ccd28ae6f0 --- /dev/null +++ b/Source/NETworkManager.Utilities.WPF/FrameworkElementExtensions.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading.Tasks; +using System.Windows; + +namespace NETworkManager.Utilities.WPF; + +/// +/// Extension methods for . +/// +public static class FrameworkElementExtensions +{ + /// + /// Default time to wait for the event. A view that is shown is loaded + /// within milliseconds. + /// + private static readonly TimeSpan DefaultLoadedTimeout = TimeSpan.FromSeconds(5); + + /// + /// Waits until the element is loaded (e.g. before showing a dialog from a view that was just created, which + /// MahApps doesn't allow before the view is loaded). Completes immediately if the element is already loaded. + /// Stops waiting after the timeout (e.g. the user switched to another view before it was loaded), so an action + /// doesn't run unexpectedly when the view is shown later. + /// + /// Element to wait for. + /// Maximum time to wait. Default: 5 seconds. + /// True if the element is loaded, false if the timeout elapsed. + public static async Task WaitForLoadedAsync(this FrameworkElement element, TimeSpan? timeout = null) + { + if (element.IsLoaded) + return true; + + var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnLoaded(object sender, RoutedEventArgs e) + { + taskCompletionSource.TrySetResult(); + } + + element.Loaded += OnLoaded; + + try + { + var completedTask = await Task.WhenAny(taskCompletionSource.Task, + Task.Delay(timeout ?? DefaultLoadedTimeout)); + + return completedTask == taskCompletionSource.Task; + } + finally + { + element.Loaded -= OnLoaded; + } + } +} diff --git a/Source/NETworkManager.Utilities/ListHelper.cs b/Source/NETworkManager.Utilities/ListHelper.cs index 93815c0c3c..e1336b997c 100644 --- a/Source/NETworkManager.Utilities/ListHelper.cs +++ b/Source/NETworkManager.Utilities/ListHelper.cs @@ -1,38 +1,70 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; namespace NETworkManager.Utilities; /// -/// Helper class for modifying lists by adding new entries and removing old ones if the list exceeds a specified length. +/// Helper class for history lists: add an entry as the newest one and remove the oldest ones if the list exceeds a +/// specified length. /// public static class ListHelper { /// - /// Modify a list by adding the and removing the oldest entry if the list is full. - /// If an entry or multiple ones already exist in the list, they will be removed before adding the new entry. + /// Adds the as the newest (first) entry of the list. An existing entry is moved to the top + /// (duplicates are removed) and the oldest entries are removed if the list exceeds the . + /// The list is changed in place, so controls bound to it (e.g. the history of a ComboBox) keep their text and are + /// notified only about the actual changes. Default values (null, 0) and empty strings are not added (e.g. optional + /// inputs). /// - /// List to modify. Used with to add and remove entries. + /// 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. + /// Comparer for existing entries (e.g. case-insensitive). If null, the default comparer is used. /// Type of the list entries. Currently or . - /// Modified list with the new entry added and oldest entries removed if necessary. - public static List Modify(List list, T entry, int length) + public static void Modify(ObservableCollection list, T entry, int length, + IEqualityComparer comparer = null) { - int index; + comparer ??= EqualityComparer.Default; - while ((index = list.IndexOf(entry)) != -1) + if (length <= 0) + throw new ArgumentOutOfRangeException(nameof(length), "Length must be greater than zero."); + + if (EqualityComparer.Default.Equals(entry, default) || entry is string text && string.IsNullOrEmpty(text)) + return; + + var index = -1; + + for (var i = 0; i < list.Count; i++) { - list.RemoveAt(index); + if (!comparer.Equals(list[i], entry)) + continue; + + index = i; + break; } - if (length <= 0) - throw new ArgumentOutOfRangeException(nameof(length), "Length must be greater than zero."); + if (index < 0) + { + list.Insert(0, entry); + } + else + { + if (index > 0) + list.Move(index, 0); - while (list.Count >= length) - list.RemoveAt(list.Count - 1); + // Keep the latest spelling (e.g. with a case-insensitive comparer) + if (!EqualityComparer.Default.Equals(list[0], entry)) + list[0] = entry; + } - list.Insert(0, entry); + // Remove duplicates (e.g. from older settings files) + for (var i = list.Count - 1; i > 0; i--) + { + if (comparer.Equals(list[i], entry)) + list.RemoveAt(i); + } - return list; + while (list.Count > length) + list.RemoveAt(list.Count - 1); } } diff --git a/Source/NETworkManager.sln b/Source/NETworkManager.sln index bffc498bae..feaef76599 100644 --- a/Source/NETworkManager.sln +++ b/Source/NETworkManager.sln @@ -43,6 +43,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dragablz", "3rdparty\Dragab EndProject Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "NETworkManager.Setup", "NETworkManager.Setup\NETworkManager.Setup.wixproj", "{B474758D-E657-4DA6-AB2B-63764C5FBAF1}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NETworkManager.Tests", "NETworkManager.Tests\NETworkManager.Tests.csproj", "{53C05A40-D2B3-42F1-AA1D-E220BBF9E426}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -338,6 +340,26 @@ Global {B474758D-E657-4DA6-AB2B-63764C5FBAF1}.Release|x64.Build.0 = Release|x64 {B474758D-E657-4DA6-AB2B-63764C5FBAF1}.Release|x86.ActiveCfg = Release|x86 {B474758D-E657-4DA6-AB2B-63764C5FBAF1}.Release|x86.Build.0 = Release|x86 + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|ARM.ActiveCfg = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|ARM.Build.0 = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|ARM64.Build.0 = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|x64.ActiveCfg = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|x64.Build.0 = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|x86.ActiveCfg = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|x86.Build.0 = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|Any CPU.Build.0 = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|ARM.ActiveCfg = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|ARM.Build.0 = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|ARM64.ActiveCfg = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|ARM64.Build.0 = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|x64.ActiveCfg = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|x64.Build.0 = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|x86.ActiveCfg = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Source/NETworkManager/ApplicationRequest.cs b/Source/NETworkManager/ApplicationRequest.cs new file mode 100644 index 0000000000..d9728eaf11 --- /dev/null +++ b/Source/NETworkManager/ApplicationRequest.cs @@ -0,0 +1,74 @@ +using DnsClient; +using NETworkManager.Models; +using NETworkManager.Models.RDAP; +using NETworkManager.Profiles; + +namespace NETworkManager; + +/// +/// Request to open an application (tool), optionally with a target or a profile, e.g. from the run command or from +/// another tool ("Open in ..."). Applications that support more options have a derived request. +/// +/// Application to open. +public record ApplicationRequest(ApplicationName Application) +{ + /// + /// Target of the application, e.g. host(s), IP range, query or URL. If null (and no is set), + /// the application is only opened. + /// + public string Target { get; init; } + + /// + /// Profile to run in the application with its settings. is ignored if set. + /// + public ProfileInfo Profile { get; init; } +} + +/// +/// Request to open the DNS Lookup. +/// +public sealed record DNSLookupRequest() : ApplicationRequest(ApplicationName.DNSLookup) +{ + /// + /// DNS server(s) to use (e.g. "1.1.1.1"). If null, the DNS server selected in the application is used. + /// + public string DNSServer { get; init; } + + /// + /// Query type. If null, the query type selected in the application is used. + /// + public QueryType? QueryType { get; init; } +} + +/// +/// Request to open the Port Scanner. +/// +public sealed record PortScannerRequest() : ApplicationRequest(ApplicationName.PortScanner) +{ + /// + /// Ports to scan (e.g. "22;80;443" or "1-1024"). If null, the ports of the application are used. + /// + public string Ports { get; init; } +} + +/// +/// Request to open Wake on LAN. +/// +public sealed record WakeOnLANRequest() : ApplicationRequest(ApplicationName.WakeOnLAN) +{ + /// + /// Broadcast address. If null, the broadcast address of the application is used. + /// + public string Broadcast { get; init; } +} + +/// +/// Request to open RDAP. +/// +public sealed record RDAPRequest() : ApplicationRequest(ApplicationName.RDAP) +{ + /// + /// Query type. If null, the type is detected from the query (). + /// + public RDAPQueryType? QueryType { get; init; } +} diff --git a/Source/NETworkManager/Controls/RunCommandComboBox.cs b/Source/NETworkManager/Controls/RunCommandComboBox.cs deleted file mode 100644 index df6489c31c..0000000000 --- a/Source/NETworkManager/Controls/RunCommandComboBox.cs +++ /dev/null @@ -1,337 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Data; -using System.Windows.Input; - -namespace NETworkManager.Controls; - -/* - * Modified from https://stackoverflow.com/a/58066259 - */ - -public class RunCommandComboBox : ComboBox -{ - private readonly UserChange _isDropDownOpenUc; - private string _currentFilter = string.Empty; - private bool _textBoxFreezed; - - public RunCommandComboBox() - { - _isDropDownOpenUc = new UserChange(v => IsDropDownOpen = v); - DropDownOpened += FilteredComboBox_DropDownOpened; - - IsEditable = true; - IsTextSearchEnabled = false; - StaysOpenOnEdit = true; - IsReadOnly = false; - - Loaded += (_, _) => - { - if (EditableTextBox != null) - new TextBoxBaseUserChangeTracker(EditableTextBox).UserTextChanged += FilteredComboBox_UserTextChange; - }; - } - - private TextBox EditableTextBox => GetTemplateChild("PART_EditableTextBox") as TextBox; - - /// - /// Open the dropdown when the control gets focus. - /// - /// Focus change information. - protected override void OnPreviewGotKeyboardFocus(KeyboardFocusChangedEventArgs e) - { - base.OnPreviewGotKeyboardFocus(e); - - Width = 350; - IsDropDownOpen = true; - } - - protected override void OnPreviewLostKeyboardFocus(KeyboardFocusChangedEventArgs e) - { - base.OnPreviewLostKeyboardFocus(e); - - Width = 250; - } - - protected override void OnPreviewKeyDown(KeyEventArgs e) - { - switch (e.Key) - { - case Key.Down when !IsDropDownOpen: - IsDropDownOpen = true; - e.Handled = true; - break; - case Key.Escape: - ClearFilter(); - Text = string.Empty; - IsDropDownOpen = true; - e.Handled = true; - break; - // Auto-complete the text when the user presses the enter key, tab key, or right arrow - // and the drop-down is open. - case Key.Enter: - case Key.Tab: - case Key.Right: - if (IsDropDownOpen) - { - // Insert the selected item into the text box. - var result = InsertSelectedItem(); - - // Close the drop-down. - IsDropDownOpen = false; - - // If the enter key was pressed and the item has no arguments then use the default - // behavior (which will fire the enter key command in the control - to redirect - // to another application). - if (e.Key == Key.Enter && result == 0) - { - e.Handled = false; - break; - } - - // Handle the key press (tab, enter or right arrow) to auto-complete the text and - // don't pass it to the control. - if (result != -1) - e.Handled = true; - - // If no item was selected, the result will be -1 and the key press will be - // passed to the control. - } - - break; - } - - base.OnPreviewKeyDown(e); - } - - /// - /// Inserts the selected item into the text box. - /// - /// - /// Returns 0 if the item was inserted, 1 if the item was inserted with example arguments, and -1 if the item was - /// not inserted. - /// - private int InsertSelectedItem() - { - if (SelectedItem is not RunCommandInfo info) - return -1; - - Text = $"{info.Command}"; - - if (info.CanHandleArguments) - { - Text += $" {info.ExampleArguments}"; - EditableTextBox.SelectionStart = Text.Length - info.ExampleArguments.Length; - EditableTextBox.SelectionLength = info.ExampleArguments.Length; - - return 1; - } - - EditableTextBox.SelectionStart = Text.Length; - EditableTextBox.SelectionLength = 0; - - return 0; - } - - private void ClearFilter() - { - if (string.IsNullOrEmpty(_currentFilter)) - return; - - _currentFilter = string.Empty; - - CollectionViewSource.GetDefaultView(ItemsSource).Refresh(); - } - - private void FilteredComboBox_DropDownOpened(object sender, EventArgs e) - { - if (_isDropDownOpenUc.IsUserChange) - ClearFilter(); - } - - private void FilteredComboBox_UserTextChange(object sender, EventArgs e) - { - if (_textBoxFreezed) - return; - - var tb = EditableTextBox; - _currentFilter = tb.SelectionStart + tb.SelectionLength == tb.Text.Length - ? tb.Text[..tb.SelectionStart].ToLower() - : tb.Text.ToLower(); - - RefreshFilter(); - } - - protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue) - { - if (newValue != null) - { - var view = CollectionViewSource.GetDefaultView(newValue); - view.Filter += FilterItem; - } - - if (oldValue != null) - { - var view = CollectionViewSource.GetDefaultView(oldValue); - if (view != null) view.Filter -= FilterItem; - } - - base.OnItemsSourceChanged(oldValue, newValue); - } - - private void RefreshFilter() - { - if (ItemsSource == null) - return; - - var view = CollectionViewSource.GetDefaultView(ItemsSource); - - FreezeTextBoxState(() => - { - var isDropDownOpen = IsDropDownOpen; - //always hide because showing it enables the user to pick with up and down keys, otherwise it's not working because of the glitch in view.Refresh() - _isDropDownOpenUc.Set(false); - view.Refresh(); - - if (!string.IsNullOrEmpty(_currentFilter) || isDropDownOpen) - _isDropDownOpenUc.Set(true); - - if (SelectedItem != null) - return; - - // Loop through all items - foreach (RunCommandInfo item in ItemsSource) - { - // Check if any item contains the text - if (!item.TranslatedName.Contains(Text, StringComparison.OrdinalIgnoreCase) && - !item.Name.Contains(Text, StringComparison.OrdinalIgnoreCase) && - !item.Command.Contains(Text, StringComparison.OrdinalIgnoreCase)) - continue; - - SelectedItem = item; - break; - } - }); - } - - private void FreezeTextBoxState(Action action) - { - _textBoxFreezed = true; - var tb = EditableTextBox; - var text = Text; - var selStart = tb.SelectionStart; - var selLen = tb.SelectionLength; - action(); - Text = text; - tb.SelectionStart = selStart; - tb.SelectionLength = selLen; - _textBoxFreezed = false; - } - - private bool FilterItem(object value) - { - if (value is not RunCommandInfo info) - return false; - - return _currentFilter.Length == 0 || - info.TranslatedName.Contains(Text, StringComparison.OrdinalIgnoreCase) || - info.Name.Contains(Text, StringComparison.OrdinalIgnoreCase) || - info.Command.Contains(Text, StringComparison.OrdinalIgnoreCase); - - //value.ToString()!.ToLower().Contains(_currentFilter); - } - - private class TextBoxBaseUserChangeTracker - { - private readonly List _pressedKeys = new(); - - public TextBoxBaseUserChangeTracker(TextBoxBase textBoxBase) - { - TextBoxBase = textBoxBase; - var lastText = TextBoxBase.ToString(); - - textBoxBase.PreviewTextInput += (_, _) => { IsTextInput = true; }; - - textBoxBase.TextChanged += (_, e) => - { - var isUserChange = _pressedKeys.Count > 0 || IsTextInput || lastText == TextBoxBase.ToString(); - IsTextInput = false; - lastText = TextBoxBase.ToString(); - if (isUserChange) - UserTextChanged?.Invoke(this, e); - }; - - textBoxBase.PreviewKeyDown += (_, e) => - { - switch (e.Key) - { - case Key.Back: - case Key.Space: - if (!_pressedKeys.Contains(e.Key)) - _pressedKeys.Add(e.Key); - break; - } - - if (e.Key != Key.Back) - return; - - var textBox = textBoxBase as TextBox; - if (textBox!.SelectionStart <= 0 || textBox.SelectionLength <= 0 || - textBox.SelectionStart + textBox.SelectionLength != textBox.Text.Length) - return; - - textBox.SelectionStart--; - textBox.SelectionLength++; - - e.Handled = true; - - UserTextChanged?.Invoke(this, e); - }; - - textBoxBase.PreviewKeyUp += (_, e) => - { - if (_pressedKeys.Contains(e.Key)) - _pressedKeys.Remove(e.Key); - }; - - textBoxBase.LostFocus += (_, _) => - { - _pressedKeys.Clear(); - IsTextInput = false; - }; - } - - private bool IsTextInput { get; set; } - - private TextBoxBase TextBoxBase { get; } - public event EventHandler UserTextChanged; - } - - private class UserChange - { - private readonly Action _action; - - public UserChange(Action action) - { - _action = action; - } - - public bool IsUserChange { get; private set; } = true; - - public void Set(T val) - { - try - { - IsUserChange = false; - _action(val); - } - finally - { - IsUserChange = true; - } - } - } -} \ No newline at end of file diff --git a/Source/NETworkManager/IApplicationRequestHandler.cs b/Source/NETworkManager/IApplicationRequestHandler.cs new file mode 100644 index 0000000000..aad22b5fc9 --- /dev/null +++ b/Source/NETworkManager/IApplicationRequestHandler.cs @@ -0,0 +1,14 @@ +namespace NETworkManager; + +/// +/// Application view that can handle an with a target or a profile (e.g. open a new +/// tab and start a scan). Views without this interface are only opened. +/// +public interface IApplicationRequestHandler +{ + /// + /// Handles the request. Called after the application view is shown. + /// + /// Request with a target or a profile. + void Open(ApplicationRequest request); +} diff --git a/Source/NETworkManager/MainWindow.xaml b/Source/NETworkManager/MainWindow.xaml index 2fb168ba6e..a41009ad5a 100644 --- a/Source/NETworkManager/MainWindow.xaml +++ b/Source/NETworkManager/MainWindow.xaml @@ -90,7 +90,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -640,6 +533,7 @@ Style="{StaticResource FadeOutScrollViewer}"> x.IsDefault) ?? applicationList.FirstOrDefault(x => x.IsVisible); - // Scroll into view - if (SelectedApplication != null) - ListViewApplication.ScrollIntoView(SelectedApplication); - // Expand application view ApplicationViewIsExpanded = SettingsManager.Current.ExpandApplicationView; } @@ -1044,96 +1023,54 @@ private void ClearSearchOnApplicationListMinimize() ApplicationSearch = string.Empty; - // Scroll into view + // Scroll into view. The selection doesn't change here, so ListViewApplication_OnSelectionChanged doesn't scroll. ListViewApplication.ScrollIntoView(SelectedApplication); } - private async void EventSystem_RedirectDataToApplicationEvent(object sender, EventArgs e) + /// + /// Scrolls the selected application into view, also if it was selected from somewhere else (e.g. the dashboard, + /// another application or the run command). + /// + private void ListViewApplication_OnSelectionChanged(object sender, + System.Windows.Controls.SelectionChangedEventArgs e) { - if (e is not EventSystemRedirectArgs data) - return; + if (ListViewApplication.SelectedItem != null) + ListViewApplication.ScrollIntoView(ListViewApplication.SelectedItem); + } - // Try to find the application - var application = Applications.Cast().FirstOrDefault(x => x.Name == data.Application); + private async void NavigationManager_ApplicationRequested(object sender, ApplicationRequest request) + { + // Clear the search, otherwise the application may be filtered out + ApplicationSearch = string.Empty; + + // Try to find the application (hidden applications can't be opened) + var application = Applications.Cast().FirstOrDefault(x => x.Name == request.Application); // Show error message if the application was not found if (application == null) { await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Error, string.Format(Strings.CouldNotFindApplicationXXXMessage, - $"{data.Application}"), ChildWindowIcon.Error); + $"{request.Application}"), ChildWindowIcon.Error); return; } + if (SettingsViewIsOpen) + CloseSettings(); + // Change application view SelectedApplication = application; - if (string.IsNullOrEmpty(data.Args)) + if (string.IsNullOrEmpty(request.Target) && request.Profile == null) return; - // Crate a new tab / perform action - switch (data.Application) - { - case ApplicationName.Dashboard: - case ApplicationName.NetworkInterface: - case ApplicationName.WiFi: - break; - case ApplicationName.IPScanner: - _ipScannerHostView.AddTab(data.Args); - break; - case ApplicationName.PortScanner: - _portScannerHostView.AddTab(data.Args); - break; - case ApplicationName.PingMonitor: - _pingMonitorHostView.AddHost(data.Args); - break; - case ApplicationName.Traceroute: - _tracerouteHostView.AddTab(data.Args); - break; - case ApplicationName.DNSLookup: - _dnsLookupHostView.AddTab(data.Args); - break; - case ApplicationName.RemoteDesktop: - _remoteDesktopHostView.AddTab(data.Args); - break; - case ApplicationName.PowerShell: - _powerShellHostView.AddTab(data.Args); - break; - case ApplicationName.PuTTY: - _puttyHostView.AddTab(data.Args); - break; - case ApplicationName.TigerVNC: - _tigerVNCHostView.AddTab(data.Args); - break; - case ApplicationName.WebConsole: - break; - case ApplicationName.SNMP: - _snmpHostView.AddTab(data.Args); - break; - case ApplicationName.RDAP: - _rdapHostView.AddTab(data.Args); - break; - case ApplicationName.Whois: - _whoisHostView.AddTab(data.Args); - break; - case ApplicationName.SNTPLookup: - case ApplicationName.HostsFileEditor: - case ApplicationName.Firewall: - case ApplicationName.DiscoveryProtocol: - case ApplicationName.WakeOnLAN: - case ApplicationName.IPGeolocation: - case ApplicationName.SubnetCalculator: - case ApplicationName.BitCalculator: - case ApplicationName.Lookup: - case ApplicationName.Connections: - case ApplicationName.Listeners: - case ApplicationName.NeighborTable: - break; - default: - Log.Error($"Cannot redirect data to unknown application: {data.Application}"); - break; - } + // Create a new tab / perform the action in the application (only if it is shown) + if (SelectedApplication?.Name == request.Application && + ContentControlApplication.Content is IApplicationRequestHandler handler) + handler.Open(request); + else + Log.Warn($"Application {request.Application} cannot handle a request with a target or profile."); } /// @@ -1154,178 +1091,45 @@ private void TextBoxApplicationSearch_OnPreviewExecuted(object sender, ExecutedR #region Run Command - #region Variables - - public ICollectionView RunCommands - { - get; - private set - { - if (value == field) - return; - - field = value; - OnPropertyChanged(); - } - } - - public RunCommandInfo SelectedRunCommand - { - get; - set - { - if (value == field) - return; - - field = value; - OnPropertyChanged(); - } - } - - public string RunCommandSearch - { - get; - set - { - if (value == field) - return; - - field = value; - - RefreshRunCommandsView(); - - OnPropertyChanged(); - } - } - - #endregion - - #region ICommands & Actions - public ICommand OpenRunCommand => new RelayCommand(_ => OpenRunAction()); private void OpenRunAction() { - ConfigurationManager.OnDialogOpen(); - - FlyoutRunCommandIsOpen = true; - } - - public ICommand RunCommandDoCommand => new RelayCommand(_ => RunCommandDoAction()); - - private void RunCommandDoAction() - { - RunCommandDo(); - } - - public ICommand RunCommandCloseCommand => new RelayCommand(_ => RunCommandCloseAction()); - - private void RunCommandCloseAction() - { - RunCommandFlyoutClose(); - } - - #endregion - - #region Methods - - private void SetRunCommandsView(RunCommandInfo selectedRunCommand = null) - { - RunCommands = new CollectionViewSource { Source = RunCommandManager.GetList() }.View; - - RunCommands.Filter = o => - { - if (o is not RunCommandInfo info) - return false; - - if (string.IsNullOrEmpty(RunCommandSearch)) - return true; - - return info.TranslatedName.IndexOf(RunCommandSearch, StringComparison.OrdinalIgnoreCase) > -1 || - info.Name.IndexOf(RunCommandSearch, StringComparison.OrdinalIgnoreCase) > -1; - }; - - if (selectedRunCommand != null) - SelectedRunCommand = RunCommands.Cast() - .FirstOrDefault(x => x.Name == selectedRunCommand.Name); - - SelectedRunCommand ??= RunCommands.Cast().FirstOrDefault(); - } - - private void RefreshRunCommandsView() - { - SetRunCommandsView(SelectedRunCommand); - } - - /// - /// Execute the selected run command. - /// - private void RunCommandDo() - { - // Do nothing if no command is selected - if (SelectedRunCommand == null) + // Only one dialog at a time (e.g. the shortcut while another dialog is open) + if (ConfigurationManager.Current.IsChildWindowOpen) return; - // Do the command - switch (SelectedRunCommand.Type) + var childWindow = new RunCommandChildWindow(); + + void CloseDialog() { - // Redirect to application - case RunCommandType.Application: - if (SettingsViewIsOpen) - CloseSettings(); + childWindow.IsOpen = false; + ConfigurationManager.Current.IsChildWindowOpen = false; - var applicationName = (ApplicationName)Enum.Parse(typeof(ApplicationName), SelectedRunCommand.Name); - EventSystem.RedirectToApplication(applicationName, string.Empty); - break; - // Redirect to settings - case RunCommandType.Setting: - EventSystem.RedirectToSettings(); - break; + ConfigurationManager.OnDialogClose(); } - // Close the flyout - RunCommandFlyoutClose(true); - } - - /// - /// Close the run command flyout and clear the search. - /// - private void RunCommandFlyoutClose(bool clearSearch = false) - { - if (!FlyoutRunCommandIsOpen) - return; - - FlyoutRunCommandIsOpen = false; - - ConfigurationManager.OnDialogClose(); - - // Clear the search - if (clearSearch) - RunCommandSearch = string.Empty; - } + var childWindowViewModel = new RunCommandViewModel(instance => + { + CloseDialog(); - #endregion + if (instance.IsSettings) + NavigationManager.OpenSettings(); + else + NavigationManager.Open(instance.Request); + }, _ => CloseDialog()); - #region Events + childWindow.DataContext = childWindowViewModel; - private void ListViewRunCommand_OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - RunCommandDo(); - } + ConfigurationManager.Current.IsChildWindowOpen = true; - private void FlyoutRunCommand_IsKeyboardFocusWithinChanged(object sender, DependencyPropertyChangedEventArgs e) - { - // Close flyout if the focus is lost. - if (e.NewValue is not false) - return; + ConfigurationManager.OnDialogOpen(); - RunCommandFlyoutClose(); + _ = this.ShowChildWindowAsync(childWindow); } #endregion - #endregion - #region Settings private void OpenSettings() @@ -1348,8 +1152,15 @@ private void OpenSettings() SettingsViewIsOpen = true; } - private void EventSystem_RedirectToSettingsEvent(object sender, EventArgs e) + private void NavigationManager_SettingsRequested(object sender, EventArgs e) { + // The settings are already open (e.g. run command "settings"), keep the current page. + if (SettingsViewIsOpen) + return; + + // Clear the search, otherwise no application may be selected (the settings page depends on it) + ApplicationSearch = string.Empty; + OpenSettings(); } diff --git a/Source/NETworkManager/NavigationManager.cs b/Source/NETworkManager/NavigationManager.cs new file mode 100644 index 0000000000..1212629d41 --- /dev/null +++ b/Source/NETworkManager/NavigationManager.cs @@ -0,0 +1,36 @@ +using System; + +namespace NETworkManager; + +/// +/// Navigation between the applications (tools) and the settings. The main window handles the requests. +/// +public static class NavigationManager +{ + /// + /// Occurs when an application should be opened, see . + /// + public static event EventHandler ApplicationRequested; + + /// + /// Occurs when the settings should be opened, see . + /// + public static event EventHandler SettingsRequested; + + /// + /// Opens an application and passes the request (target, profile, options) to it. + /// + /// Request with the application to open. + public static void Open(ApplicationRequest request) + { + ApplicationRequested?.Invoke(null, request); + } + + /// + /// Opens the settings of the current application. + /// + public static void OpenSettings() + { + SettingsRequested?.Invoke(null, EventArgs.Empty); + } +} diff --git a/Source/NETworkManager/RunCommandInfo.cs b/Source/NETworkManager/RunCommandInfo.cs deleted file mode 100644 index 4a86602f5b..0000000000 --- a/Source/NETworkManager/RunCommandInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace NETworkManager; - -public class RunCommandInfo -{ - public RunCommandType Type { get; init; } - - /// - /// Name. - /// - public string Name { get; init; } - - /// - /// Translated name. - /// - public string TranslatedName { get; init; } - - /// - /// Command to run. - /// - public string Command { get; set; } - - /// - /// Indicates whether (optional) arguments can be passed. - /// - public bool CanHandleArguments { get; init; } - - /// - /// Example arguments. - /// - public string ExampleArguments { get; set; } - - public override string ToString() - { - return $"{Command}"; - } -} \ No newline at end of file diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs index 8308c96170..9bb85f8466 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -1,82 +1,349 @@ -using System.Collections.Generic; -using System.Linq; +using DnsClient; using NETworkManager.Localization; using NETworkManager.Localization.Resources; using NETworkManager.Models; +using NETworkManager.Models.Network; +using NETworkManager.Models.RDAP; +using NETworkManager.Models.RunCommand; +using NETworkManager.Profiles; +using NETworkManager.Settings; +using NETworkManager.Validators; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Windows.Controls; namespace NETworkManager; +/// +/// Commands of the run command dialog: definitions, profiles, validation and the request to run. +/// public static class RunCommandManager { - private const string DefaultArguments = ""; + /// + /// Type keywords of RDAP and their query type. + /// + private static readonly Dictionary RDAPTypes = new() + { + ["domain"] = RDAPQueryType.Domain, + ["nameserver"] = RDAPQueryType.Nameserver, + ["tld"] = RDAPQueryType.TLD, + ["ip"] = RDAPQueryType.IPAddress, + ["asn"] = RDAPQueryType.ASN, + ["entity"] = RDAPQueryType.Entity + }; - public static IEnumerable GetList() + /// + /// Gets the commands of the visible applications and the settings. + /// + /// Available commands. + public static IReadOnlyList GetCommands() { - return GetApplicationList().Concat(GetSettingsList()); + var visibleApplications = SettingsManager.Current.General_ApplicationList.Where(x => x.IsVisible) + .Select(x => x.Name).ToHashSet(); + + // Created on each call, since the syntax is translated. + return [.. CreateCommands().Where(x => x.Type == RunCommandType.Setting || visibleApplications.Contains(x.Application))]; } - private static IEnumerable GetSettingsList() + /// + /// Gets the names of the DNS servers configured in the DNS lookup (e.g. "Cloudflare"). + /// + /// Names of the DNS servers. + public static IEnumerable GetDNSServers() { - return - [ - new RunCommandInfo - { - Type = RunCommandType.Setting, - Name = "Settings", - TranslatedName = Strings.Settings, - Command = "settings", - CanHandleArguments = false, - ExampleArguments = string.Empty - } - ]; + return SettingsManager.Current.DNSLookup_DNSServers.Select(x => x.Name); + } + + /// + /// Gets the translated name of the application (or settings) of a command. + /// + /// Command. + /// Translated name. + public static string GetTranslatedName(RunCommandDefinition command) + { + return command.Type == RunCommandType.Setting + ? Strings.Settings + : ResourceTranslator.Translate(ResourceIdentifier.ApplicationName, command.Application); } - private static IEnumerable GetApplicationList() + /// + /// Gets the profiles that are enabled for the application of a command. + /// + /// Command. + /// Profiles, ordered by group and name. + public static IEnumerable GetProfiles(RunCommandDefinition command) { - return ApplicationManager.GetNames().Where(name => name != ApplicationName.None).Select(name => - new RunCommandInfo + if (!command.SupportsProfile || ProfileManager.LoadedProfileFileData == null) + return []; + + return ProfileManager.LoadedProfileFileData.Groups.SelectMany(x => x.Profiles) + .Where(x => x.IsEnabled(command.Application)) + .OrderBy(x => x.Group).ThenBy(x => x.Name); + } + + /// + /// Validates the input and creates the request to run. + /// + /// Parsed input. + /// + /// Profile selected in the suggestions. If null, the profile is looked up by its name ("@name"), which must be + /// unique. + /// + /// Request to open the application, or null for the settings or on error. + /// Error message, or null if the input is valid. + public static string TryCreateRequest(RunCommandParseResult result, ProfileInfo profile, + out ApplicationRequest request) + { + request = null; + + var error = GetParseErrorMessage(result); + + if (error != null) + return error; + + if (result.Command.Type == RunCommandType.Setting) + return null; + + // Profile ("@name" or selected in the suggestions) + if (result.ProfileName != null) + { + if (profile == null) { - Type = RunCommandType.Application, - Name = name.ToString(), - TranslatedName = ResourceTranslator.Translate(ResourceIdentifier.ApplicationName, name), - Command = name switch - { - _ => name.ToString().ToLower() - }, - CanHandleArguments = name switch - { - ApplicationName.IPScanner => true, - ApplicationName.PortScanner => true, - ApplicationName.PingMonitor => true, - ApplicationName.Traceroute => true, - ApplicationName.DNSLookup => true, - ApplicationName.RemoteDesktop => true, - ApplicationName.PowerShell => true, - ApplicationName.PuTTY => true, - ApplicationName.TigerVNC => true, - ApplicationName.WebConsole => true, - ApplicationName.SNMP => true, - ApplicationName.RDAP => true, - ApplicationName.Whois => true, - _ => false - }, - ExampleArguments = name switch + // 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, + StringComparison.OrdinalIgnoreCase)) + .ToList(); + + switch (profiles.Count) { - ApplicationName.IPScanner => "<10.0.0.0/24>", - ApplicationName.PortScanner => "<10.0.0.10;10.0.0.20> <1-1024>", - ApplicationName.PingMonitor => DefaultArguments, - ApplicationName.Traceroute => DefaultArguments, - ApplicationName.DNSLookup => " ", - ApplicationName.RemoteDesktop => DefaultArguments, - ApplicationName.PowerShell => DefaultArguments, - ApplicationName.PuTTY => DefaultArguments, - ApplicationName.TigerVNC => DefaultArguments, - ApplicationName.WebConsole => "", - ApplicationName.SNMP => DefaultArguments, - ApplicationName.RDAP => "", - ApplicationName.Whois => "", - _ => string.Empty + case 0: + return string.Format(Strings.ProfileXNotFound, result.ProfileName); + case > 1: + return string.Format(Strings.SeveralProfilesAreNamedXSelectOne, result.ProfileName); } - }).ToList(); + + profile = profiles[0]; + } + + request = new ApplicationRequest(result.Command.Application) { Profile = profile }; + + return null; + } + + // Only open the application + if (result.Target == null) + { + request = new ApplicationRequest(result.Command.Application); + + return null; + } + + error = ValidateTarget(result); + + if (error != null) + return error; + + request = CreateRequest(result); + + return null; + } + + /// + /// Gets the message for a parse error. + /// + private static string GetParseErrorMessage(RunCommandParseResult result) + { + return result.Error switch + { + RunCommandParseError.None => null, + RunCommandParseError.Empty => Strings.EnterACommand, + RunCommandParseError.UnknownCommand => string.Format(Strings.UnknownCommandX, result.ErrorValue), + RunCommandParseError.NoArguments => string.Format(Strings.CommandXDoesNotAcceptArguments, result.ErrorValue), + RunCommandParseError.NoProfiles => string.Format(Strings.CommandXDoesNotSupportProfiles, result.ErrorValue), + RunCommandParseError.MissingProfileName => Strings.EnterTheNameOfAProfileAfterAt, + RunCommandParseError.UnknownArgument => string.Format(Strings.UnknownArgumentX, result.ErrorValue), + _ => throw new ArgumentOutOfRangeException(nameof(result), result.Error, null) + }; + } + + /// + /// Validates the target and the second argument with the validators of the application's input fields. + /// + private static string ValidateTarget(RunCommandParseResult result) + { + ValidationRule targetValidator = result.Command.Application switch + { + ApplicationName.IPScanner or ApplicationName.PortScanner or ApplicationName.PingMonitor => + new MultipleHostsRangeValidator(), + ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTTY + or ApplicationName.TigerVNC => new IPAddressOrHostnameValidator(), + ApplicationName.RemoteDesktop => new RemoteDesktopHostnameAndPortValidator(), + ApplicationName.WebConsole => new HttpAndHttpsUriValidator(), + ApplicationName.Whois => new DomainValidator(), + ApplicationName.IPGeolocation => new IPAddressOrHostnameValidator(), + ApplicationName.WakeOnLAN => new MACAddressValidator(), + _ => null + }; + + var error = Validate(targetValidator, result.Target); + + if (error != null) + return error; + + if (result.Argument != null) + { + ValidationRule argumentValidator = result.Command.Argument switch + { + RunCommandArgument.Ports => new PortRangeValidator(), + RunCommandArgument.Broadcast => new IPv4AddressValidator(), + _ => null + }; + + error = result.Command.Argument == RunCommandArgument.DNSServer + ? ValidateDNSServer(result.Argument) + : Validate(argumentValidator, result.Argument); + + if (error != null) + return error; + } + + // RDAP: the query must match the type (e.g. "1.10" as ASN) + if (result.Command.Application == ApplicationName.RDAP) + { + var parseError = RDAPQueryParser.TryParse(GetRDAPQueryType(result.Type), result.Target, out _); + + if (parseError != RDAPQueryParseError.None) + return RDAPQueryValidator.GetErrorMessage(parseError); + } + + return null; + } + + /// + /// Validates the DNS server argument: the name of a configured DNS server, or custom DNS server(s) in the format + /// of the DNS lookup (IP address or host with a domain, optional port). A single word that is neither (e.g. a + /// mistyped type like "aaa") is an unknown argument instead of a DNS server. + /// + private static string ValidateDNSServer(string dnsServer) + { + if (GetDNSServers().Any(x => string.Equals(x, dnsServer, StringComparison.OrdinalIgnoreCase))) + return null; + + foreach (var server in dnsServer.Split(';', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!ServerConnectionInfo.TryParse(server, out var serverInfo, 53, TransportProtocol.Udp) || + !IPAddress.TryParse(serverInfo.Server, out _) && !serverInfo.Server.Contains('.')) + return string.Format(Strings.UnknownArgumentX, server); + } + + return null; + } + + private static string Validate(ValidationRule validator, string value) + { + if (validator == null) + return null; + + var validationResult = validator.Validate(value, CultureInfo.CurrentCulture); + + return validationResult.IsValid ? null : validationResult.ErrorContent as string; + } + + /// + /// Creates the request with the options of the application. + /// + private static ApplicationRequest CreateRequest(RunCommandParseResult result) + { + return result.Command.Application switch + { + ApplicationName.DNSLookup => new DNSLookupRequest + { + Target = result.Target, + // A configured DNS server is matched by its name (case-insensitive), otherwise it's a custom server. + DNSServer = GetDNSServers().FirstOrDefault(x => + string.Equals(x, result.Argument, StringComparison.OrdinalIgnoreCase)) ?? result.Argument, + // 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 }, + ApplicationName.WakeOnLAN => new WakeOnLANRequest { Target = result.Target, Broadcast = result.Argument }, + ApplicationName.RDAP => new RDAPRequest { Target = result.Target, QueryType = GetRDAPQueryType(result.Type) }, + _ => new ApplicationRequest(result.Command.Application) { Target = result.Target } + }; + } + + private static RDAPQueryType GetRDAPQueryType(string type) + { + return type != null ? RDAPTypes[type] : RDAPQueryType.Auto; + } + + /// + /// Creates the commands of all applications and the settings. Commands and aliases are lowercase English keywords + /// (not translated); the applications are shown with their translated name. + /// + private static List CreateCommands() + { + // Syntax of the arguments, e.g. " [Ports]" + static string Required(string name) => $"<{name}>"; + static string Optional(string name) => $"[{name}]"; + + 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.DNSLookup => Create(name, ["dns", "nslookup"], + $"{Required(Strings.Hosts)} {Optional(Strings.DNSServer)} {Optional(Strings.Type)}", true, + 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) + }).ToList(); + + commands.Add(new RunCommandDefinition + { + Type = RunCommandType.Setting, + Application = ApplicationName.None, + Command = "settings" + }); + + return commands; + } + + private static RunCommandDefinition Create(ApplicationName application, string[] aliases, string syntax, + bool supportsTarget, RunCommandArgument argument = RunCommandArgument.None, string[] types = null) + { + return new RunCommandDefinition + { + Type = RunCommandType.Application, + Application = application, + Command = application.ToString().ToLowerInvariant(), + Aliases = aliases, + SupportsTarget = supportsTarget, + // Every application that accepts a target also supports profiles. + SupportsProfile = supportsTarget, + Argument = argument, + Types = types ?? [], + Syntax = supportsTarget ? $"{syntax} | @<{Strings.Profile}>" : string.Empty + }; } -} \ No newline at end of file +} diff --git a/Source/NETworkManager/RunCommandSuggestion.cs b/Source/NETworkManager/RunCommandSuggestion.cs new file mode 100644 index 0000000000..cd2d32bd85 --- /dev/null +++ b/Source/NETworkManager/RunCommandSuggestion.cs @@ -0,0 +1,75 @@ +using NETworkManager.Models; +using NETworkManager.Profiles; + +namespace NETworkManager; + +/// +/// Kind of a suggestion in the run command dialog. +/// +public enum RunCommandSuggestionKind +{ + /// + /// Run the input as typed. + /// + Run, + + /// + /// Command of an application (or the settings). + /// + Command, + + /// + /// Profile of the application. + /// + Profile, + + /// + /// Recently run command. + /// + History, + + /// + /// Type keyword of the command (e.g. DNS record type). + /// + Type, + + /// + /// DNS server configured in the DNS lookup. + /// + DNSServer +} + +/// +/// Suggestion in the run command dialog. +/// +public sealed class RunCommandSuggestion +{ + public RunCommandSuggestionKind Kind { get; init; } + + /// + /// Application of the suggestion (for the icon), for the settings. + /// + public ApplicationName Application { get; init; } + + public string Title { get; init; } + + /// + /// Second line (e.g. group and host of a profile). + /// + public string Description { get; init; } + + /// + /// Text on the right (e.g. "@name" of a profile). + /// + public string Info { get; init; } + + /// + /// Input after completing the suggestion (Tab) and that is run (Enter). + /// + public string CompletionText { get; init; } + + /// + /// Profile to run (). + /// + public ProfileInfo Profile { get; init; } +} diff --git a/Source/NETworkManager/RunCommandType.cs b/Source/NETworkManager/RunCommandType.cs deleted file mode 100644 index ba94a8abc0..0000000000 --- a/Source/NETworkManager/RunCommandType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NETworkManager; - -public enum RunCommandType -{ - Application, - - Setting -} \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/BitCalculatorViewModel.cs b/Source/NETworkManager/ViewModels/BitCalculatorViewModel.cs index 01a24481e0..9d9141aa88 100644 --- a/Source/NETworkManager/ViewModels/BitCalculatorViewModel.cs +++ b/Source/NETworkManager/ViewModels/BitCalculatorViewModel.cs @@ -264,27 +264,10 @@ private async void Calculate() IsResultVisible = true; - AddInputToHistory(Input); - - IsRunning = false; - } - - /// - /// Adds the input to the history. - /// - /// The input string. - private void AddInputToHistory(string input) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.BitCalculator_InputHistory.ToList(), input, + ListHelper.Modify(SettingsManager.Current.BitCalculator_InputHistory, Input, SettingsManager.Current.General_HistoryListEntries); - // Clear the old items - SettingsManager.Current.BitCalculator_InputHistory.Clear(); - OnPropertyChanged(nameof(Input)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.BitCalculator_InputHistory.Add(x)); + IsRunning = false; } /// diff --git a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs index 14e9865f44..1fecad4366 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs @@ -1,3 +1,4 @@ +using DnsClient; using Dragablz; using NETworkManager.Controls; using NETworkManager.Localization.Resources; @@ -8,6 +9,9 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; +using System.Net; +using System.Net.Sockets; using System.Windows.Input; namespace NETworkManager.ViewModels; @@ -89,10 +93,6 @@ public DNSLookupHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.DNSLookup; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.DNSLookup_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.DNSLookup_Host; - #endregion #region ICommand & Actions @@ -128,7 +128,7 @@ private bool LookupProfile_CanExecute(object obj) /// private void LookupProfileAction() { - AddTab(SelectedProfile.DNSLookup_Host); + AddTab(SelectedProfile); } /// @@ -152,15 +152,43 @@ private static void CloseItemAction(ItemActionCallbackArgs args) /// Adds a new tab for the specified host. /// /// The host to lookup. - public void AddTab(string host = null) + /// DNS server(s) to use. If null, the selected DNS server is used. + /// + /// 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) { + if (queryType == null && IsIPAddresses(host)) + queryType = QueryType.PTR; + var tabId = Guid.NewGuid(); - TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab, new DNSLookupView(tabId, host), + TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab, new DNSLookupView(tabId, host, dnsServer, queryType), tabId)); SelectedTabIndex = TabItems.Count - 1; } + /// + /// Checks if the host(s) are only IP addresses (shortened IPv4 forms like "10" are host names). + /// + private static bool IsIPAddresses(string hosts) + { + return !string.IsNullOrWhiteSpace(hosts) && hosts.Split(';').Select(x => x.Trim()).All(x => + RegexHelper.IPv4AddressRegex().IsMatch(x) || + x.Contains(':') && IPAddress.TryParse(x, out var ipAddress) && + ipAddress.AddressFamily == AddressFamily.InterNetworkV6); + } + + /// + /// Adds a new tab for the host of the specified profile. + /// + /// The profile to lookup. + public void AddTab(ProfileInfo profile) + { + AddTab(profile.DNSLookup_Host); + } + #endregion } diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs index 0f38a429a9..ac2b0d9122 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs @@ -248,7 +248,9 @@ private set /// /// The ID of the tab. /// The host to lookup. - public DNSLookupViewModel(Guid tabId, string host) + /// 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) { _isLoading = true; @@ -276,6 +278,13 @@ public DNSLookupViewModel(Guid tabId, string host) LoadSettings(); + // Options of a request (e.g. run command), which are not saved as default. + if (!string.IsNullOrEmpty(dnsServer)) + DNSServer = dnsServer; + + if (queryType != null && QueryTypes.Contains(queryType.Value)) + QueryType = queryType.Value; + _isLoading = false; } @@ -376,7 +385,8 @@ private async Task QueryAsync() DragablzTabItem.SetTabHeader(_tabId, Host); - AddHostToHistory(Host); + ListHelper.Modify(SettingsManager.Current.DNSLookup_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); DNSLookupSettings dnsSettings = new() { @@ -508,24 +518,6 @@ private void AppendStatusMessage(string message) } // Modify history list - /// - /// Adds the host to the history. - /// - /// The host to add. - private void AddHostToHistory(string host) - { - // Create the new list - var list = ListHelper.Modify([.. SettingsManager.Current.DNSLookup_HostHistory], host, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.DNSLookup_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.DNSLookup_HostHistory.Add(x)); - } - /// /// Exports the results. diff --git a/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs b/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs index dc6cac0a84..3b1e44685a 100644 --- a/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs @@ -88,10 +88,6 @@ public IPGeolocationHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.IPGeolocation; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.IPGeolocation_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.IPGeolocation_Host; - #endregion #region ICommand & Actions @@ -127,7 +123,7 @@ private bool QueryProfile_CanExecute(object obj) /// private void QueryProfileAction() { - AddTab(SelectedProfile.IPGeolocation_Host); + AddTab(SelectedProfile); } /// @@ -151,7 +147,7 @@ private static void CloseItemAction(ItemActionCallbackArgs args) /// Adds a new tab for the specified domain. /// /// The domain to query. - private void AddTab(string domain = null) + public void AddTab(string domain = null) { var tabId = Guid.NewGuid(); @@ -161,5 +157,14 @@ private void AddTab(string domain = null) SelectedTabIndex = TabItems.Count - 1; } + /// + /// Adds a new tab for the host of the specified profile. + /// + /// The profile to query. + public void AddTab(ProfileInfo profile) + { + AddTab(profile.IPGeolocation_Host); + } + #endregion } diff --git a/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs b/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs index 40aa8d54e3..ea79459e4e 100644 --- a/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs @@ -262,7 +262,8 @@ private async Task Query() Result = result.Info; IsResultVisible = true; - AddHostToHistory(Host); + ListHelper.Modify(SettingsManager.Current.IPGeolocation_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); } catch (Exception ex) { @@ -335,23 +336,5 @@ public void OnClose() ConfigurationManager.Current.IPGeolocationTabCount--; } - /// - /// Adds the host to the history. - /// - /// The host to add. - private void AddHostToHistory(string host) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.IPGeolocation_HostHistory.ToList(), host, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.IPGeolocation_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.IPGeolocation_HostHistory.Add(x)); - } - #endregion } \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs index 9ca9e59493..4808a0e2c9 100644 --- a/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs @@ -88,10 +88,6 @@ public IPScannerHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.IPScanner; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.IPScanner_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.IPScanner_HostOrIPRange; - #endregion #region ICommand & Actions @@ -165,7 +161,7 @@ public void AddTab(string hostOrIPRange = null) /// Adds a new tab for the specified profile. /// /// The profile to scan. - private void AddTab(ProfileInfo profile) + public void AddTab(ProfileInfo profile) { AddTab(profile.IPScanner_HostOrIPRange); } diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 4905fdb68d..d4fe8eeafc 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -5,7 +5,6 @@ using NETworkManager.Localization; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.Export; using NETworkManager.Models.Network; using NETworkManager.Profiles; @@ -385,7 +384,7 @@ private void RedirectDataToApplicationAction(object name) ? SelectedResult.Hostname : SelectedResult.PingInfo.IPAddress.ToString(); - EventSystem.RedirectToApplication(applicationName, host); + NavigationManager.Open(new ApplicationRequest(applicationName) { Target = host }); } /// @@ -395,7 +394,7 @@ private void RedirectDataToApplicationAction(object name) private void PerformDNSLookupIPAddressAction() { - EventSystem.RedirectToApplication(ApplicationName.DNSLookup, SelectedResult.PingInfo.IPAddress.ToString()); + NavigationManager.Open(new DNSLookupRequest { Target = SelectedResult.PingInfo.IPAddress.ToString() }); } /// @@ -405,7 +404,7 @@ private void PerformDNSLookupIPAddressAction() private void PerformDNSLookupHostnameAction() { - EventSystem.RedirectToApplication(ApplicationName.DNSLookup, SelectedResult.Hostname); + NavigationManager.Open(new DNSLookupRequest { Target = SelectedResult.Hostname }); } /// @@ -415,7 +414,7 @@ private void PerformDNSLookupHostnameAction() private void PerformRDAPQueryIPAddressAction() { - EventSystem.RedirectToApplication(ApplicationName.RDAP, SelectedResult.PingInfo.IPAddress.ToString()); + NavigationManager.Open(new RDAPRequest { Target = SelectedResult.PingInfo.IPAddress.ToString() }); } /// @@ -425,7 +424,7 @@ private void PerformRDAPQueryIPAddressAction() private void PerformRDAPQueryHostnameAction() { - EventSystem.RedirectToApplication(ApplicationName.RDAP, SelectedResult.Hostname); + NavigationManager.Open(new RDAPRequest { Target = SelectedResult.Hostname }); } /// @@ -553,7 +552,8 @@ private async Task Start() PreparingScan = false; // Add host(s) to the history - AddHostToHistory(Host); + ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); var ipScanner = new IPScanner(new IPScannerOptions( SettingsManager.Current.IPScanner_MaxHostThreads, @@ -700,24 +700,6 @@ private async Task CustomCommand(object guid) } } - /// - /// Adds the scanned host/range to the history. - /// - /// The host or IP range to add. - private void AddHostToHistory(string ipRange) - { - // Create the new list - var list = ListHelper.Modify([.. SettingsManager.Current.IPScanner_HostHistory], ipRange, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.IPScanner_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(SettingsManager.Current.IPScanner_HostHistory.Add); - } - /// /// Exports the scan results. /// diff --git a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs index fdd320882b..9ae439de75 100644 --- a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs @@ -43,28 +43,6 @@ public LookupOUILookupViewModel() #endregion - #region Methods - - /// - /// Adds the search term to the history. - /// - /// The MAC address or vendor. - private void AddSearchToHistory(string macAddressOrVendor) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.Lookup_OUI_SearchHistory.ToList(), - macAddressOrVendor, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.Lookup_OUI_SearchHistory.Clear(); - OnPropertyChanged( - nameof(Search)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.Lookup_OUI_SearchHistory.Add(x)); - } - - #endregion #region Variables @@ -260,7 +238,8 @@ private async void OUILookupAction() NothingFound = Results.Count == 0; // Add the MAC-Address or Vendor to the history - AddSearchToHistory(Search); + ListHelper.Modify(SettingsManager.Current.Lookup_OUI_SearchHistory, Search, + SettingsManager.Current.General_HistoryListEntries); IsRunning = false; } diff --git a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs index 62e6aea1e6..955e6f8ee8 100644 --- a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs @@ -40,27 +40,6 @@ public LookupPortLookupViewModel() #endregion - #region Methods - - /// - /// Adds the search term to the history. - /// - /// The port or service. - private void AddSearchToHistory(string portOrService) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.Lookup_Port_SearchHistory.ToList(), portOrService, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.Lookup_Port_SearchHistory.Clear(); - OnPropertyChanged(nameof(Search)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.Lookup_Port_SearchHistory.Add(x)); - } - - #endregion #region Variables @@ -320,7 +299,8 @@ await PortLookup.LookupByPortAndProtocolAsync( NothingFound = Results.Count == 0; // Add the MAC-Address or Vendor to the history - AddSearchToHistory(Search); + ListHelper.Modify(SettingsManager.Current.Lookup_Port_SearchHistory, Search, + SettingsManager.Current.General_HistoryListEntries); IsRunning = false; } diff --git a/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs b/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs index dc48aa71b0..70f1e90188 100644 --- a/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs +++ b/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs @@ -9,7 +9,6 @@ using MahApps.Metro.SimpleChildWindow; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.Export; using NETworkManager.Models.Network; using NETworkManager.Profiles; @@ -733,11 +732,6 @@ private async Task LoadNetworkInterfaces() protected override ApplicationName ApplicationName => ApplicationName.NetworkInterface; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.NetworkInterface_Enabled; - - // Unlike other tools, profile search here only matches the profile name (no secondary field). - protected override string GetSearchableField(ProfileInfo profile) => string.Empty; - #endregion #region ICommands & Actions @@ -899,8 +893,10 @@ private void IPScannerAction() if (ipTuple == null) return; - EventSystem.RedirectToApplication(ApplicationName.IPScanner, - $"{ipTuple.Item1}/{Subnetmask.ConvertSubnetmaskToCidr(ipTuple.Item2)}"); + NavigationManager.Open(new ApplicationRequest(ApplicationName.IPScanner) + { + Target = $"{ipTuple.Item1}/{Subnetmask.ConvertSubnetmaskToCidr(ipTuple.Item2)}" + }); } /// diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs index a25bf063a8..28a7e46956 100644 --- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs @@ -185,10 +185,6 @@ public PingMonitorHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.PingMonitor; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.PingMonitor_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.PingMonitor_Host; - #endregion #region ICommands & Actions @@ -225,8 +221,7 @@ private bool PingProfile_CanExecute(object obj) private void PingProfileAction() { - if (SetHost(SelectedProfile.PingMonitor_Host, SelectedProfile.Group)) - _ = Start(); + AddHost(SelectedProfile); } /// @@ -265,6 +260,26 @@ private void PauseGroupAction(object group) #region Methods + /// + /// Adds the host(s) and starts the ping monitor. + /// + /// Host(s) to ping. + public void AddHost(string host) + { + if (SetHost(host)) + _ = Start(); + } + + /// + /// Adds the host(s) of the profile to its group and starts the ping monitor. + /// + /// Profile to ping. + public void AddHost(ProfileInfo profile) + { + if (SetHost(profile.PingMonitor_Host, profile.Group)) + _ = Start(); + } + /// /// Set the host to ping. /// @@ -325,7 +340,8 @@ public async Task Start() } // Add host(s) to history - AddHostToHistory(Host); + ListHelper.Modify(SettingsManager.Current.PingMonitor_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); // Add host(s) to list and start the ping foreach (var hostView in hosts.hosts.Select(currentHost => @@ -390,20 +406,6 @@ private void RemoveHostByGuid(Guid hostId) Hosts.RemoveAt(i); } - private void AddHostToHistory(string host) - { - // Create the new list - var list = ListHelper.Modify([.. SettingsManager.Current.PingMonitor_HostHistory], host, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.PingMonitor_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(SettingsManager.Current.PingMonitor_HostHistory.Add); - } - #endregion #region Event diff --git a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs index d2b982af47..a83df00221 100644 --- a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs @@ -88,10 +88,6 @@ public PortScannerHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.PortScanner; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.PortScanner_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.PortScanner_Host; - #endregion #region ICommand & Actions @@ -150,7 +146,7 @@ public void AddTab(string host = null, string ports = null) SelectedTabIndex = TabItems.Count - 1; } - private void AddTab(ProfileInfo profile) + public void AddTab(ProfileInfo profile) { AddTab(profile.PortScanner_Host, profile.PortScanner_Ports); } diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 926037f288..d455551963 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -487,8 +487,10 @@ private async Task Start() PreparingScan = false; // Add host(s) to the history - AddHostToHistory(Host); - AddPortToHistory(Ports); + ListHelper.Modify(SettingsManager.Current.PortScanner_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports, + SettingsManager.Current.General_HistoryListEntries); var portScanner = new PortScanner(new PortScannerOptions( SettingsManager.Current.PortScanner_MaxHostThreads, @@ -571,34 +573,6 @@ await DialogHelper.ShowMessageAsync(window, Strings.Error, return window.ShowChildWindowAsync(childWindow); } - private void AddHostToHistory(string host) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.PortScanner_HostHistory.ToList(), host, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.PortScanner_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.PortScanner_HostHistory.Add(x)); - } - - private void AddPortToHistory(string port) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory.ToList(), port, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.PortScanner_PortHistory.Clear(); - OnPropertyChanged(nameof(Ports)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.PortScanner_PortHistory.Add(x)); - } - #endregion #region Events diff --git a/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs b/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs index 2f3607b342..80527ee038 100644 --- a/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs @@ -4,7 +4,6 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.PowerShell; using NETworkManager.Profiles; using NETworkManager.Settings; @@ -136,10 +135,6 @@ public PowerShellHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.PowerShell; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.PowerShell_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.PowerShell_Host; - /// /// Also mirrors the popup state into so the main window can suppress /// global focus-stealing behavior (e.g. re-focusing the embedded terminal) while the filter popup is open. @@ -233,7 +228,7 @@ private bool ConnectProfile_CanExecute(object obj) private void ConnectProfileAction() { - ConnectProfile(); + ConnectProfile(SelectedProfile); } public ICommand ConnectProfileExternalCommand => new RelayCommand(_ => ConnectProfileExternalAction()); @@ -264,7 +259,7 @@ private void ClearSearchAction() private static void OpenSettingsAction() { - EventSystem.RedirectToSettings(); + NavigationManager.OpenSettings(); } #endregion @@ -408,7 +403,8 @@ private Task Connect(string host = null) // Add to history // Note: The history can only be updated after the values have been read. // Otherwise, in some cases, incorrect values are taken over. - AddHostToHistory(instance.Host); + ListHelper.Modify(SettingsManager.Current.PowerShell_HostHistory, instance.Host, + SettingsManager.Current.General_HistoryListEntries); Connect(sessionInfo); }, _ => @@ -430,10 +426,13 @@ private Task Connect(string host = null) return Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } - private void ConnectProfile() + /// + /// Connects to the profile. + /// + /// Profile to connect to. + public void ConnectProfile(ProfileInfo profileInfo) { - Connect(PowerShellProfile.CreateSessionInfo(SelectedProfile), - SelectedProfile.Name); + Connect(PowerShellProfile.CreateSessionInfo(profileInfo), profileInfo.Name); } private void ConnectProfileExternal() @@ -468,16 +467,6 @@ public void AddTab(string host) } // Modify history list - private static void AddHostToHistory(string host) - { - if (string.IsNullOrEmpty(host)) - return; - - SettingsManager.Current.PowerShell_HostHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PowerShell_HostHistory.ToList(), host, - SettingsManager.Current.General_HistoryListEntries)); - } - public void FocusEmbeddedWindow() { /* Don't continue if diff --git a/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs b/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs index dd58d3e331..1cf7e58328 100644 --- a/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs +++ b/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs @@ -36,13 +36,13 @@ public abstract class ProfileHostViewModelBase : ViewModelBase, IProfileHostView /// /// Determines whether the given profile is enabled for this tool. /// - protected abstract bool IsProfileEnabled(ProfileInfo profile); + private bool IsProfileEnabled(ProfileInfo profile) => profile.IsEnabled(ApplicationName); /// - /// Gets the tool-specific field of the given profile that should also be matched against the search text + /// Gets the tool-specific field of the given profile (e.g. host) that is also matched against the search text /// (in addition to the profile name). /// - protected abstract string GetSearchableField(ProfileInfo profile); + private string GetSearchableField(ProfileInfo profile) => profile.GetTarget(ApplicationName) ?? string.Empty; /// /// Gets the view for the profiles. diff --git a/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs b/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs index 9ffc59c06d..244e2fb7ad 100644 --- a/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs @@ -4,7 +4,6 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.PuTTY; using NETworkManager.Profiles; using NETworkManager.Settings; @@ -136,10 +135,6 @@ public PuTTYHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.PuTTY; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.PuTTY_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.PuTTY_HostOrSerialLine; - /// /// Also mirrors the popup state into so the main window can suppress /// global focus-stealing behavior (e.g. re-focusing the embedded terminal) while the filter popup is open. @@ -241,7 +236,7 @@ private bool ConnectProfile_CanExecute(object obj) private void ConnectProfileAction() { - ConnectProfile(); + ConnectProfile(SelectedProfile); } public ICommand ConnectProfileExternalCommand => new RelayCommand(_ => ConnectProfileExternalAction()); @@ -265,7 +260,7 @@ public ICommand TextBoxSearchLostFocusCommand private static void OpenSettingsAction() { - EventSystem.RedirectToSettings(); + NavigationManager.OpenSettings(); } #endregion @@ -334,13 +329,26 @@ private Task Connect(string host = null) // Add to history // Note: The history can only be updated after the values have been read. // Otherwise, in some cases, incorrect values are taken over. - AddHostToHistory(instance.Host); - AddSerialLineToHistory(instance.SerialLine); - AddPortToHistory(instance.Port); - AddBaudToHistory(instance.Baud); - AddUsernameToHistory(instance.Username); - AddPrivateKeyToHistory(instance.PrivateKeyFile); - AddProfileToHistory(instance.Profile); + ListHelper.Modify(SettingsManager.Current.PuTTY_HostHistory, instance.Host, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.PuTTY_SerialLineHistory, instance.SerialLine, + SettingsManager.Current.General_HistoryListEntries); + + // Port and baud are 0 if they are not set. + if (instance.Port > 0) + ListHelper.Modify(SettingsManager.Current.PuTTY_PortHistory, instance.Port.ToString(), + SettingsManager.Current.General_HistoryListEntries); + + if (instance.Baud > 0) + ListHelper.Modify(SettingsManager.Current.PuTTY_BaudHistory, instance.Baud.ToString(), + SettingsManager.Current.General_HistoryListEntries); + + ListHelper.Modify(SettingsManager.Current.PuTTY_UsernameHistory, instance.Username, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.PuTTY_PrivateKeyFileHistory, instance.PrivateKeyFile, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.PuTTY_ProfileHistory, instance.Profile, + SettingsManager.Current.General_HistoryListEntries); Connect(sessionInfo); }, _ => @@ -362,9 +370,13 @@ private Task Connect(string host = null) return Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } - private void ConnectProfile() + /// + /// Connects to the profile. + /// + /// Profile to connect to. + public void ConnectProfile(ProfileInfo profileInfo) { - Connect(NETworkManager.Profiles.Application.PuTTY.CreateSessionInfo(SelectedProfile), SelectedProfile.Name); + Connect(NETworkManager.Profiles.Application.PuTTY.CreateSessionInfo(profileInfo), profileInfo.Name); } private void ConnectProfileExternal() @@ -403,76 +415,6 @@ public void AddTab(string host) } // Modify history list - private static void AddHostToHistory(string host) - { - if (string.IsNullOrEmpty(host)) - return; - - SettingsManager.Current.PuTTY_HostHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_HostHistory.ToList(), host, - SettingsManager.Current.General_HistoryListEntries)); - } - - private static void AddSerialLineToHistory(string serialLine) - { - if (string.IsNullOrEmpty(serialLine)) - return; - - SettingsManager.Current.PuTTY_SerialLineHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_SerialLineHistory.ToList(), serialLine, - SettingsManager.Current.General_HistoryListEntries)); - } - - private static void AddPortToHistory(int port) - { - if (port == 0) - return; - - SettingsManager.Current.PuTTY_PortHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_PortHistory.ToList(), port.ToString(), - SettingsManager.Current.General_HistoryListEntries)); - } - - private static void AddBaudToHistory(int baud) - { - if (baud == 0) - return; - - SettingsManager.Current.PuTTY_BaudHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_BaudHistory.ToList(), baud.ToString(), - SettingsManager.Current.General_HistoryListEntries)); - } - - private static void AddUsernameToHistory(string username) - { - if (string.IsNullOrEmpty(username)) - return; - - SettingsManager.Current.PuTTY_UsernameHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_UsernameHistory.ToList(), username, - SettingsManager.Current.General_HistoryListEntries)); - } - - private static void AddPrivateKeyToHistory(string privateKey) - { - if (string.IsNullOrEmpty(privateKey)) - return; - - SettingsManager.Current.PuTTY_PrivateKeyFileHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_PrivateKeyFileHistory.ToList(), privateKey, - SettingsManager.Current.General_HistoryListEntries)); - } - - private static void AddProfileToHistory(string profile) - { - if (string.IsNullOrEmpty(profile)) - return; - - SettingsManager.Current.PuTTY_ProfileHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_ProfileHistory.ToList(), profile, - SettingsManager.Current.General_HistoryListEntries)); - } - public void FocusEmbeddedWindow() { /* Don't continue if diff --git a/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs b/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs index 72fe499c95..84ccc2acfa 100644 --- a/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs @@ -71,10 +71,6 @@ public RDAPHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.RDAP; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.RDAP_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.RDAP_Query; - #endregion #region ICommand & Actions @@ -95,7 +91,7 @@ private bool QueryProfile_CanExecute(object obj) private void QueryProfileAction() { - AddTab(SelectedProfile.RDAP_Query, SelectedProfile.RDAP_QueryType); + AddTab(SelectedProfile); } public ItemActionCallback CloseItemCommand => CloseItemAction; @@ -130,5 +126,14 @@ public void AddTab(string query = null, RDAPQueryType? queryType = null) SelectedTabIndex = TabItems.Count - 1; } + /// + /// Adds a new tab and starts the query of the profile. + /// + /// Profile to query. + public void AddTab(ProfileInfo profile) + { + AddTab(profile.RDAP_Query, profile.RDAP_QueryType); + } + #endregion } diff --git a/Source/NETworkManager/ViewModels/RDAPViewModel.cs b/Source/NETworkManager/ViewModels/RDAPViewModel.cs index 5dfa44cb1d..33f052ebb6 100644 --- a/Source/NETworkManager/ViewModels/RDAPViewModel.cs +++ b/Source/NETworkManager/ViewModels/RDAPViewModel.cs @@ -4,7 +4,6 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.Export; using NETworkManager.Models.RDAP; using NETworkManager.Settings; @@ -263,7 +262,7 @@ private void QueryAction() private void OpenInWhoisAction() { - EventSystem.RedirectToApplication(ApplicationName.Whois, _whoisDomain); + NavigationManager.Open(new ApplicationRequest(ApplicationName.Whois) { Target = _whoisDomain }); } /// @@ -350,7 +349,8 @@ private async Task RunQuery() if (messages.Count > 0) ShowStatusMessage(string.Join(Environment.NewLine, messages)); - AddQueryToHistory(Query); + ListHelper.Modify(SettingsManager.Current.RDAP_QueryHistory, Query, + SettingsManager.Current.General_HistoryListEntries); } catch (OperationCanceledException) { @@ -447,20 +447,6 @@ public void OnClose() ConfigurationManager.Current.RDAPTabCount--; } - private void AddQueryToHistory(string query) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.RDAP_QueryHistory.ToList(), query, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.RDAP_QueryHistory.Clear(); - OnPropertyChanged(nameof(Query)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.RDAP_QueryHistory.Add(x)); - } - /// /// Gets the content for the export. JSON contains the responses exactly as sent by the server(s), as an array /// if the registrar referral was followed. diff --git a/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs b/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs index 1de5001b77..27b990ecb7 100644 --- a/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs @@ -74,10 +74,6 @@ public RemoteDesktopHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.RemoteDesktop; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.RemoteDesktop_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.RemoteDesktop_Host; - public override void OnProfileManagerDialogOpen() { ConfigurationManager.OnDialogOpen(); @@ -213,7 +209,7 @@ private bool ConnectProfile_CanExecute(object obj) private void ConnectProfileAction() { - ConnectProfile(); + ConnectProfile(SelectedProfile); } public ICommand ConnectProfileAsCommand => new RelayCommand(_ => ConnectProfileAsAction()); @@ -289,7 +285,8 @@ private Task Connect(string host = null) // Add to history // Note: The history can only be updated after the values have been read. // Otherwise, in some cases, incorrect values are taken over. - AddHostToHistory(instance.Host); + ListHelper.Modify(SettingsManager.Current.RemoteDesktop_HostHistory, instance.Host, + SettingsManager.Current.General_HistoryListEntries); Connect(sessionInfo); }, _ => @@ -315,10 +312,12 @@ private Task Connect(string host = null) } // Connect via Profile - private void ConnectProfile() + /// + /// Connects to the profile. + /// + /// Profile to connect to. + public void ConnectProfile(ProfileInfo profileInfo) { - var profileInfo = SelectedProfile; - var sessionInfo = RemoteDesktop.CreateSessionInfo(profileInfo); Connect(sessionInfo, profileInfo.Name); @@ -394,15 +393,5 @@ public void AddTab(string host) } // Modify history list - private static void AddHostToHistory(string host) - { - if (string.IsNullOrEmpty(host)) - return; - - SettingsManager.Current.RemoteDesktop_HostHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.RemoteDesktop_HostHistory.ToList(), host, - SettingsManager.Current.General_HistoryListEntries)); - } - #endregion } diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs new file mode 100644 index 0000000000..d3868fd400 --- /dev/null +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -0,0 +1,485 @@ +using NETworkManager.Localization.Resources; +using NETworkManager.Models.RunCommand; +using NETworkManager.Profiles; +using NETworkManager.Settings; +using NETworkManager.Utilities; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows.Input; + +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. +/// +public class RunCommandViewModel : ViewModelBase +{ + /// + /// Maximum number of suggestions shown for a target or argument (profiles, history, types). The commands are + /// always shown completely. + /// + private const int MaxSuggestions = 10; + + /// + /// Maximum number of recent commands shown with an empty input (before the commands). + /// + private const int MaxHistorySuggestions = 5; + + /// + /// Maximum number of recent commands of a command shown before its profiles. + /// + private const int MaxCommandHistorySuggestions = 3; + + 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). + /// + private readonly List<(string Text, RunCommandDefinition Command)> _history; + + /// + /// Names (command and aliases) and translated name per command. + /// + private readonly List<(RunCommandDefinition Command, List Names, string TranslatedName)> _commandNames; + + /// + /// Profiles per command (loaded once per command while the dialog is open). + /// + private readonly Dictionary> _profiles = []; + + private RunCommandParseResult _result; + + /// + /// Initializes a new instance of the class. + /// + /// Called with the request to run (, ). + /// Called when the dialog is canceled. + public RunCommandViewModel(Action runHandler, Action cancelHandler) + { + _commands = RunCommandManager.GetCommands(); + _runHandler = runHandler; + + _commandNames = + [ + .. _commands.Select(x => (x, new List { x.Command }.Concat(x.Aliases).ToList(), + RunCommandManager.GetTranslatedName(x))) + ]; + + // Entries of commands that are not available anymore (e.g. a hidden application) are not shown. + _history = + [ + .. SettingsManager.Current.RunCommand_History.Select(x => (x, RunCommandParser.Parse(x, _commands).Command)) + .Where(x => x.Command != null) + ]; + + CancelCommand = new RelayCommand(_ => cancelHandler(this)); + + UpdateSuggestions(); + } + + #region Properties + + public ICommand CancelCommand { get; } + + /// + /// Input of the user (e.g. "dns borntoberoot.net aaaa"). + /// + public string Input + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + + ErrorMessage = null; + UpdateSuggestions(); + } + } = string.Empty; + + public ObservableCollection Suggestions { get; } = []; + + public RunCommandSuggestion SelectedSuggestion + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Syntax of the recognized command (e.g. "dnslookup <hosts> [server] [type] | @<profile>"). + /// + public string Hint + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Error of the last run (e.g. invalid host), null if there is none. + /// + public string ErrorMessage + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Request to run (set before the run handler is called), null for the settings. + /// + public ApplicationRequest Request { get; private set; } + + /// + /// The settings should be opened (set before the run handler is called). + /// + public bool IsSettings { get; private set; } + + #endregion + + #region Methods + + /// + /// Selects the next suggestion (Down). + /// + public void SelectNext() + { + Select(1); + } + + /// + /// Selects the previous suggestion (Up). + /// + public void SelectPrevious() + { + Select(-1); + } + + private void Select(int offset) + { + if (Suggestions.Count == 0) + return; + + var index = SelectedSuggestion == null ? -1 : Suggestions.IndexOf(SelectedSuggestion); + + SelectedSuggestion = Suggestions[Math.Clamp(index + offset, 0, Suggestions.Count - 1)]; + } + + /// + /// Inserts the selected suggestion into the input (Tab). + /// + public void Complete() + { + if (SelectedSuggestion == null || SelectedSuggestion.Kind == RunCommandSuggestionKind.Run) + return; + + Input = SelectedSuggestion.CompletionText; + } + + /// + /// Runs the selected suggestion, or the input if none is selected (Enter). + /// + public void Run() + { + var suggestion = SelectedSuggestion; + + var text = suggestion?.CompletionText ?? Input; + var result = RunCommandParser.Parse(text, _commands); + + 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) + Input = text; + + ErrorMessage = error; + + return; + } + + Request = request; + IsSettings = result.Command.Type == RunCommandType.Setting; + + // 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(), + SettingsManager.Current.General_HistoryListEntries, StringComparer.OrdinalIgnoreCase); + + _runHandler(this); + } + + /// + /// Updates the suggestions and the hint for the input. + /// + private void UpdateSuggestions() + { + _result = RunCommandParser.Parse(Input, _commands); + + Hint = _result.Command == null + ? null + : $"{_result.Command.Command} {_result.Command.Syntax}".TrimEnd(); + + IEnumerable suggestions = _result.Position switch + { + RunCommandPosition.Command => GetCommandSuggestions(), + RunCommandPosition.Target when _result.ProfileName != null => GetProfileSuggestions(), + RunCommandPosition.Target => + [ + GetRunSuggestion(), .. GetHistorySuggestions().Take(MaxCommandHistorySuggestions), + .. GetProfileSuggestions() + ], + _ => + [ + GetRunSuggestion(), .. GetTypeSuggestions(), .. GetDNSServerSuggestions(), + .. GetHistorySuggestions() + ] + }; + + if (_result.Position != RunCommandPosition.Command) + suggestions = suggestions.Take(MaxSuggestions); + + Suggestions.Clear(); + + foreach (var suggestion in suggestions) + Suggestions.Add(suggestion); + + SelectedSuggestion = GetPreselectedSuggestion(); + } + + /// + /// 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. + /// - 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). + /// + private RunCommandSuggestion GetPreselectedSuggestion() + { + return _result.Position switch + { + RunCommandPosition.Command when string.IsNullOrWhiteSpace(Input) || + _result.Command == null && GetInputAfterCommand().Length > 0 => null, + RunCommandPosition.Target when _result.ProfileName != null => Suggestions.Count == 1 ? Suggestions[0] : null, + _ => Suggestions.FirstOrDefault() + }; + } + + /// + /// Gets the input after the first word (the command), e.g. "10.0.0.1" for "pin 10.0.0.1". + /// + private string GetInputAfterCommand() + { + var input = Input.TrimStart(); + var index = input.IndexOfAny([' ', '\t']); + + return index < 0 ? string.Empty : input[index..].Trim(); + } + + /// + /// Suggestion to run the input as typed. + /// + private RunCommandSuggestion GetRunSuggestion() + { + return new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.Run, + Application = _result.Command.Application, + Title = Input.Trim(), + Description = RunCommandManager.GetTranslatedName(_result.Command), + CompletionText = Input + }; + } + + /// + /// Recent commands (empty input) and the commands that match the typed command. + /// + private IEnumerable GetCommandSuggestions() + { + var text = _result.CurrentWord; + + var suggestions = new List(); + + if (string.IsNullOrEmpty(text)) + suggestions.AddRange(GetHistorySuggestions().Take(MaxHistorySuggestions)); + + // Completing the command keeps the arguments (e.g. "pin 10.0.0.1" → "ping 10.0.0.1"). + var arguments = GetInputAfterCommand(); + + var commands = new List<(int Rank, RunCommandSuggestion Suggestion)>(); + + foreach (var (command, names, translatedName) in _commandNames) + { + // Complete with the command or alias that was typed, or the shortest one that starts with the text (e.g. + // "pin" → "ping" instead of "pingmonitor"). + var exactName = names.FirstOrDefault(x => string.Equals(x, text, StringComparison.OrdinalIgnoreCase)); + var name = exactName ?? names.Where(x => x.StartsWith(text, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.Length).FirstOrDefault(); + + if (name == null && !translatedName.Contains(text, StringComparison.OrdinalIgnoreCase)) + continue; + + // Exact command or alias first, then commands that start with the text, then translated names that contain + // it (e.g. "lookup" before "DNS Lookup"). + var rank = exactName != null ? 0 : name != null ? 1 : 2; + + commands.Add((rank, new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.Command, + Application = command.Application, + Title = translatedName, + Description = string.Join(", ", names), + Info = command.Syntax, + CompletionText = (name ?? command.Command) + " " + arguments + })); + } + + suggestions.AddRange(commands.OrderBy(x => x.Rank).Select(x => x.Suggestion)); + + return suggestions; + } + + /// + /// Profiles of the command that match the typed profile name (or target). + /// + private IEnumerable GetProfileSuggestions() + { + var text = _result.ProfileName ?? _result.CurrentWord; + var application = _result.Command.Application; + + // Exact name first, then names that start with the text, then names and targets (e.g. host) that contain it. + static int? GetRank(ProfileInfo profile, string target, string text) + { + if (string.IsNullOrEmpty(text) || string.Equals(profile.Name, text, StringComparison.OrdinalIgnoreCase)) + return 0; + + if (profile.Name.StartsWith(text, StringComparison.OrdinalIgnoreCase)) + return 1; + + if (profile.Name.Contains(text, StringComparison.OrdinalIgnoreCase) || + (target?.Contains(text, StringComparison.OrdinalIgnoreCase) ?? false)) + return 2; + + return null; + } + + if (!_profiles.TryGetValue(_result.Command, out var profiles)) + { + profiles = [.. RunCommandManager.GetProfiles(_result.Command)]; + _profiles[_result.Command] = profiles; + } + + return profiles + .Select(x => (Profile: x, Target: x.GetTarget(application))) + .Select(x => (x.Profile, x.Target, Rank: GetRank(x.Profile, x.Target, text))) + .Where(x => x.Rank != null) + .OrderBy(x => x.Rank) + .Select(x => new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.Profile, + Application = application, + Title = x.Profile.Name, + // The target of the application (e.g. the RDP host), which can differ from the general host. + 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 + }); + } + + /// + /// Type keywords of the command that start with the typed word. + /// + private IEnumerable GetTypeSuggestions() + { + // A complete type was already entered (and is not being typed). + if (_result.Type != null && !string.Equals(_result.Type, _result.CurrentWord, StringComparison.OrdinalIgnoreCase)) + return []; + + return _result.Command.Types + .Where(x => x.StartsWith(_result.CurrentWord, StringComparison.OrdinalIgnoreCase)) + .Select(x => new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.Type, + Application = _result.Command.Application, + Title = x.ToUpperInvariant(), + Description = Strings.Type, + CompletionText = _result.InputBeforeCurrentWord + x + " " + }); + } + + /// + /// 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. + /// + private IEnumerable GetDNSServerSuggestions() + { + if (_result.Command.Argument != RunCommandArgument.DNSServer || + _result.Argument != null && !string.Equals(_result.Argument, _result.CurrentWord)) + return []; + + return RunCommandManager.GetDNSServers() + .Where(x => x.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase)) + .Select(x => new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.DNSServer, + Application = _result.Command.Application, + Title = x, + Description = Strings.DNSServer, + CompletionText = _result.InputBeforeCurrentWord + (x.Contains(' ') ? $"\"{x}\"" : x) + " " + }); + } + + /// + /// Recent commands. With a command, only its entries that contain the input. + /// + private IEnumerable GetHistorySuggestions() + { + var input = Input.Trim(); + + return _history + .Where(x => _result.Command == null && string.IsNullOrEmpty(input) || + _result.Command != null && + 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 + }); + } + + #endregion +} diff --git a/Source/NETworkManager/ViewModels/SNMPHostViewModel.cs b/Source/NETworkManager/ViewModels/SNMPHostViewModel.cs index f73e4a0601..4d215d8002 100644 --- a/Source/NETworkManager/ViewModels/SNMPHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/SNMPHostViewModel.cs @@ -68,10 +68,6 @@ public SNMPHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.SNMP; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.SNMP_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.SNMP_Host; - #endregion #region ICommand & Actions @@ -132,7 +128,7 @@ public void AddTab(string host) AddTab(sessionInfo); } - private void AddTab(ProfileInfo profile) + public void AddTab(ProfileInfo profile) { var sessionInfo = SNMP.CreateSessionInfo(profile); diff --git a/Source/NETworkManager/ViewModels/SNMPViewModel.cs b/Source/NETworkManager/ViewModels/SNMPViewModel.cs index 8119896d37..ac69b07627 100644 --- a/Source/NETworkManager/ViewModels/SNMPViewModel.cs +++ b/Source/NETworkManager/ViewModels/SNMPViewModel.cs @@ -581,8 +581,10 @@ await DNSClientHelper.ResolveAorAaaaAsync(Host, _cancellationTokenSource.CancelAfter(SettingsManager.Current.SNMP_Timeout); // Add to history... - AddHostToHistory(Host); - AddOidToHistory(Oid); + ListHelper.Modify(SettingsManager.Current.SNMP_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.SNMP_OidHistory, Oid, + SettingsManager.Current.General_HistoryListEntries); } private void StopWork() @@ -633,34 +635,6 @@ private async Task OpenOIDProfileSelection() await window.ShowChildWindowAsync(childWindow); } - private void AddHostToHistory(string host) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SNMP_HostHistory.ToList(), host, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.SNMP_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SNMP_HostHistory.Add(x)); - } - - private void AddOidToHistory(string oid) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SNMP_OidHistory.ToList(), oid, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.SNMP_OidHistory.Clear(); - OnPropertyChanged(nameof(Oid)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SNMP_OidHistory.Add(x)); - } - private Task Export() { var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive); diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs index 947254e44e..6e9c117ab9 100644 --- a/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs +++ b/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs @@ -112,23 +112,10 @@ private void Calculate() IsResultVisible = true; - AddSubnetToHistory(subnet); - - IsRunning = false; - } - - private void AddSubnetToHistory(string subnet) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Calculator_SubnetHistory.ToList(), subnet, + ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Calculator_SubnetHistory, subnet, SettingsManager.Current.General_HistoryListEntries); - // Clear the old items - SettingsManager.Current.SubnetCalculator_Calculator_SubnetHistory.Clear(); - OnPropertyChanged(nameof(Subnet)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SubnetCalculator_Calculator_SubnetHistory.Add(x)); + IsRunning = false; } #endregion diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs index 45dcc67af1..8f4da94fba 100644 --- a/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs +++ b/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs @@ -253,38 +253,12 @@ await Task.Run(() => IsResultVisible = true; - AddSubnetToHistory(subnet); - AddNewSubnetmaskOrCidrToHistory(newSubnetmaskOrCidr); - - IsRunning = false; - } - - private void AddSubnetToHistory(string subnet) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Subnetting_SubnetHistory.ToList(), subnet, + ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Subnetting_SubnetHistory, subnet, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Subnetting_NewSubnetmaskHistory, newSubnetmaskOrCidr, SettingsManager.Current.General_HistoryListEntries); - // Clear the old items - SettingsManager.Current.SubnetCalculator_Subnetting_SubnetHistory.Clear(); - OnPropertyChanged(nameof(Subnet)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SubnetCalculator_Subnetting_SubnetHistory.Add(x)); - } - - private void AddNewSubnetmaskOrCidrToHistory(string newSubnetmaskOrCidr) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Subnetting_NewSubnetmaskHistory.ToList(), - newSubnetmaskOrCidr, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.SubnetCalculator_Subnetting_NewSubnetmaskHistory.Clear(); - OnPropertyChanged(nameof(NewSubnetmask)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SubnetCalculator_Subnetting_NewSubnetmaskHistory.Add(x)); + IsRunning = false; } public void OnShutdown() diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs index c351a0dbad..e743d11ca4 100644 --- a/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs +++ b/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs @@ -134,38 +134,12 @@ private void Calculate() IsResultVisible = true; - AddSubnet1ToHistory(subnet1); - AddSubnet2ToHistory(subnet2); - - IsRunning = false; - } - - private void AddSubnet1ToHistory(string subnet) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet1.ToList(), subnet, + ListHelper.Modify(SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet1, subnet1, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet1.Clear(); - OnPropertyChanged(nameof(Subnet1)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet1.Add(x)); - } - - private void AddSubnet2ToHistory(string subnet) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet2.ToList(), subnet, + ListHelper.Modify(SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet2, subnet2, SettingsManager.Current.General_HistoryListEntries); - // Clear the old items - SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet2.Clear(); - OnPropertyChanged(nameof(Subnet2)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet2.Add(x)); + IsRunning = false; } public void OnShutdown() diff --git a/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs b/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs index e9f47e675d..0656b89157 100644 --- a/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs @@ -3,7 +3,6 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.TigerVNC; using NETworkManager.Profiles; using NETworkManager.Settings; @@ -93,10 +92,6 @@ public TigerVNCHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.TigerVNC; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.TigerVNC_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.TigerVNC_Host; - public override void OnProfileManagerDialogOpen() { ConfigurationManager.OnDialogOpen(); @@ -150,7 +145,7 @@ private bool ConnectProfile_CanExecute(object obj) private void ConnectProfileAction() { - ConnectProfile(); + ConnectProfile(SelectedProfile); } public ICommand ConnectProfileExternalCommand => new RelayCommand(_ => ConnectProfileExternalAction()); @@ -164,7 +159,7 @@ private void ConnectProfileExternalAction() private static void OpenSettingsAction() { - EventSystem.RedirectToSettings(); + NavigationManager.OpenSettings(); } #endregion @@ -198,8 +193,10 @@ private Task Connect(string host = null) // Add to history // Note: The history can only be updated after the values have been read. // Otherwise, in some cases, incorrect values are taken over. - AddHostToHistory(instance.Host); - AddPortToHistory(instance.Port); + ListHelper.Modify(SettingsManager.Current.TigerVNC_HostHistory, instance.Host, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.TigerVNC_PortHistory, instance.Port, + SettingsManager.Current.General_HistoryListEntries); // Connect Connect(sessionInfo); @@ -223,9 +220,13 @@ private Task Connect(string host = null) return Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } - private void ConnectProfile() + /// + /// Connects to the profile. + /// + /// Profile to connect to. + public void ConnectProfile(ProfileInfo profileInfo) { - Connect(TigerVNC.CreateSessionInfo(SelectedProfile), SelectedProfile.Name); + Connect(TigerVNC.CreateSessionInfo(profileInfo), profileInfo.Name); } private void ConnectProfileExternal() @@ -256,26 +257,6 @@ public void AddTab(string host) } // Modify history list - private static void AddHostToHistory(string host) - { - if (string.IsNullOrEmpty(host)) - return; - - SettingsManager.Current.TigerVNC_HostHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.TigerVNC_HostHistory.ToList(), host, - SettingsManager.Current.General_HistoryListEntries)); - } - - private static void AddPortToHistory(int port) - { - if (port == 0) - return; - - SettingsManager.Current.TigerVNC_PortHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.TigerVNC_PortHistory.ToList(), port, - SettingsManager.Current.General_HistoryListEntries)); - } - #endregion #region Event diff --git a/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs index 40407b6d5f..f0ec9dacd8 100644 --- a/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs @@ -70,10 +70,6 @@ public TracerouteHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.Traceroute; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.Traceroute_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.Traceroute_Host; - #endregion #region ICommand & Actions diff --git a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs index c5b9f66207..4fb34e6b97 100644 --- a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs +++ b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs @@ -4,9 +4,9 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.Export; using NETworkManager.Models.Network; +using NETworkManager.Models.RDAP; using NETworkManager.Settings; using NETworkManager.Utilities; using NETworkManager.Views; @@ -307,7 +307,7 @@ private void RedirectDataToApplicationAction(object name) ? SelectedResult.Hostname : SelectedResult.IPAddress.ToString(); - EventSystem.RedirectToApplication(applicationName, host); + NavigationManager.Open(new ApplicationRequest(applicationName) { Target = host }); } /// @@ -317,19 +317,29 @@ private void RedirectDataToApplicationAction(object name) private void PerformDNSLookupAction(object data) { - EventSystem.RedirectToApplication(ApplicationName.DNSLookup, data.ToString()); + NavigationManager.Open(new DNSLookupRequest { Target = data.ToString() }); } /// - /// Gets the command to query the registration data of the selected hop's IP address, hostname (its registered - /// domain) or AS number via RDAP. + /// Gets the command to query the registration data of the selected hop's IP address or hostname (its registered + /// domain) via RDAP. /// public ICommand PerformRDAPQueryCommand => new RelayCommand(PerformRDAPQueryAction); private void PerformRDAPQueryAction(object data) + { + NavigationManager.Open(new RDAPRequest { Target = data.ToString() }); + } + + /// + /// Gets the command to query the registration data of the selected hop's AS number via RDAP. + /// + public ICommand PerformRDAPQueryASNCommand => new RelayCommand(PerformRDAPQueryASNAction); + + private void PerformRDAPQueryASNAction(object data) { // The AS of the IP geolocation contains the name as well (e.g. "AS15169 Google LLC"). - EventSystem.RedirectToApplication(ApplicationName.RDAP, data.ToString().Split(' ')[0]); + NavigationManager.Open(new RDAPRequest { Target = data.ToString().Split(' ')[0], QueryType = RDAPQueryType.ASN }); } /// @@ -423,7 +433,8 @@ await DNSClientHelper.ResolveAorAaaaAsync(Host, traceroute.TraceAsync(ipAddress, _cancellationTokenSource.Token); // Add the host to history - AddHostToHistory(Host); + ListHelper.Modify(SettingsManager.Current.Traceroute_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); } catch (Exception ex) // This will catch any exception { @@ -480,20 +491,6 @@ await DialogHelper.ShowMessageAsync(window, Strings.Error, return window.ShowChildWindowAsync(childWindow); } - private void AddHostToHistory(string host) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.Traceroute_HostHistory.ToList(), host, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.Traceroute_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.Traceroute_HostHistory.Add(x)); - } - public void OnClose() { // Prevent multiple calls diff --git a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs index 335d64d2f8..81a8ed8e66 100644 --- a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs +++ b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs @@ -158,10 +158,6 @@ public WakeOnLANViewModel() protected override ApplicationName ApplicationName => ApplicationName.WakeOnLAN; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.WakeOnLAN_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.WakeOnLAN_MACAddress; - #endregion #region ICommands & Actions @@ -187,8 +183,10 @@ private void WakeUpAction() Port = SettingsManager.Current.WakeOnLAN_Port }; - AddMACAddressToHistory(MACAddress); - AddBroadcastToHistory(Broadcast); + ListHelper.Modify(SettingsManager.Current.WakeOnLan_MACAddressHistory, MACAddress, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.WakeOnLan_BroadcastHistory, Broadcast, + SettingsManager.Current.General_HistoryListEntries); _ = WakeUp(info); } @@ -200,13 +198,39 @@ private void WakeUpAction() private void WakeUpProfileAction() { - _ = WakeUp(NETworkManager.Profiles.Application.WakeOnLAN.CreateInfo(SelectedProfile)); + WakeUp(SelectedProfile); } #endregion #region Methods + /// + /// Sends the magic packet to the computer of the profile. + /// + /// Profile to wake up. + public void WakeUp(ProfileInfo profile) + { + _ = WakeUp(NETworkManager.Profiles.Application.WakeOnLAN.CreateInfo(profile)); + } + + /// + /// Sets the MAC address (and broadcast address) and sends the magic packet, if the broadcast address is valid + /// (e.g. from a previous wake up). Otherwise, the values are only filled in. + /// + /// MAC address of the computer. + /// Broadcast address. If null, the current broadcast address is used. + public void WakeUp(string macAddress, string broadcast) + { + MACAddress = macAddress; + + if (broadcast != null) + Broadcast = broadcast; + + if (Broadcast != null && RegexHelper.IPv4AddressRegex().IsMatch(Broadcast)) + WakeUpAction(); + } + private async Task WakeUp(WakeOnLANInfo info) { IsStatusMessageDisplayed = false; @@ -231,33 +255,5 @@ private async Task WakeUp(WakeOnLANInfo info) IsRunning = false; } - private void AddMACAddressToHistory(string macAddress) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.WakeOnLan_MACAddressHistory.ToList(), macAddress, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.WakeOnLan_MACAddressHistory.Clear(); - OnPropertyChanged(nameof(MACAddress)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.WakeOnLan_MACAddressHistory.Add(x)); - } - - private void AddBroadcastToHistory(string broadcast) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.WakeOnLan_BroadcastHistory.ToList(), broadcast, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.WakeOnLan_BroadcastHistory.Clear(); - OnPropertyChanged(nameof(Broadcast)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.WakeOnLan_BroadcastHistory.Add(x)); - } - #endregion } diff --git a/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs b/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs index 951174d9eb..e359419d6a 100644 --- a/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs @@ -4,7 +4,6 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.WebConsole; using NETworkManager.Profiles; using NETworkManager.Profiles.Application; @@ -100,10 +99,6 @@ public WebConsoleHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.WebConsole; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.WebConsole_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.WebConsole_Url; - public override void OnProfileManagerDialogOpen() { ConfigurationManager.OnDialogOpen(); @@ -150,14 +145,14 @@ private bool ConnectProfile_CanExecute(object obj) private void ConnectProfileAction() { - ConnectProfile(); + ConnectProfile(SelectedProfile); } public ICommand OpenSettingsCommand => new RelayCommand(_ => OpenSettingsAction()); private static void OpenSettingsAction() { - EventSystem.RedirectToSettings(); + NavigationManager.OpenSettings(); } public ICommand OpenWebsiteCommand => new RelayCommand(OpenWebsiteAction); @@ -171,7 +166,7 @@ private static void OpenWebsiteAction(object url) #region Methods - private Task Connect() + private Task Connect(string url = null) { var childWindow = new WebConsoleConnectChildWindow(); @@ -191,7 +186,8 @@ private Task Connect() // Add to history // Note: The history can only be updated after the values have been read. // Otherwise, in some cases, incorrect values are taken over. - AddUrlToHistory(instance.Url); + ListHelper.Modify(SettingsManager.Current.WebConsole_UrlHistory, instance.Url, + SettingsManager.Current.General_HistoryListEntries); Connect(info); }, _ => @@ -200,7 +196,10 @@ private Task Connect() ConfigurationManager.Current.IsChildWindowOpen = false; ConfigurationManager.OnDialogClose(); - }); + }) + { + Url = url + }; childWindow.Title = Strings.Connect; @@ -213,10 +212,22 @@ private Task Connect() return Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } - private void ConnectProfile() + /// + /// Connects to the profile. + /// + /// Profile to connect to. + public void ConnectProfile(ProfileInfo profileInfo) + { + Connect(WebConsole.CreateSessionInfo(profileInfo), profileInfo.Name); + } + + /// + /// Opens the connect dialog with the URL. + /// + /// URL to connect to. + public void AddTab(string url) { - Connect(WebConsole.CreateSessionInfo(SelectedProfile), - SelectedProfile.Name); + _ = Connect(url); } private void Connect(WebConsoleSessionInfo sessionInfo, string header = null) @@ -229,15 +240,5 @@ private void Connect(WebConsoleSessionInfo sessionInfo, string header = null) } // Modify history list - private static void AddUrlToHistory(string url) - { - if (string.IsNullOrEmpty(url)) - return; - - SettingsManager.Current.WebConsole_UrlHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.WebConsole_UrlHistory.ToList(), url, - SettingsManager.Current.General_HistoryListEntries)); - } - #endregion } diff --git a/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs b/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs index 846daada81..b4954a91d1 100644 --- a/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs @@ -70,10 +70,6 @@ public WhoisHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.Whois; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.Whois_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.Whois_Domain; - #endregion #region ICommand & Actions @@ -94,7 +90,7 @@ private bool QueryProfile_CanExecute(object obj) private void QueryProfileAction() { - AddTab(SelectedProfile.Whois_Domain); + AddTab(SelectedProfile); } public ItemActionCallback CloseItemCommand => CloseItemAction; @@ -118,5 +114,10 @@ public void AddTab(string domain = null) SelectedTabIndex = TabItems.Count - 1; } + public void AddTab(ProfileInfo profile) + { + AddTab(profile.Whois_Domain); + } + #endregion } diff --git a/Source/NETworkManager/ViewModels/WhoisViewModel.cs b/Source/NETworkManager/ViewModels/WhoisViewModel.cs index c272c874ca..9a6aeed3c5 100644 --- a/Source/NETworkManager/ViewModels/WhoisViewModel.cs +++ b/Source/NETworkManager/ViewModels/WhoisViewModel.cs @@ -192,7 +192,8 @@ private async Task Query() Result = await Whois.QueryAsync(Domain, whoisServer); IsResultVisible = true; - AddDomainToHistory(Domain); + ListHelper.Modify(SettingsManager.Current.Whois_DomainHistory, Domain, + SettingsManager.Current.General_HistoryListEntries); } } catch (Exception ex) @@ -215,20 +216,6 @@ public void OnClose() ConfigurationManager.Current.WhoisTabCount--; } - private void AddDomainToHistory(string domain) - { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.Whois_DomainHistory.ToList(), domain, - SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.Whois_DomainHistory.Clear(); - OnPropertyChanged(nameof(Domain)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.Whois_DomainHistory.Add(x)); - } - private Task Export() { var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive); diff --git a/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs b/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs index fc6e032285..1b1f604aff 100644 --- a/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs +++ b/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class DNSLookupHostView +public partial class DNSLookupHostView : IApplicationRequestHandler { private readonly DNSLookupHostViewModel _viewModel = new(); @@ -12,9 +12,17 @@ public DNSLookupHostView() DataContext = _viewModel; } - public void AddTab(string host) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(host); + if (request.Profile != null) + { + _viewModel.AddTab(request.Profile); + return; + } + + var dnsLookupRequest = request as DNSLookupRequest; + + _viewModel.AddTab(request.Target, dnsLookupRequest?.DNSServer, dnsLookupRequest?.QueryType); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/DNSLookupView.xaml.cs b/Source/NETworkManager/Views/DNSLookupView.xaml.cs index b00f030485..8edfa206d4 100644 --- a/Source/NETworkManager/Views/DNSLookupView.xaml.cs +++ b/Source/NETworkManager/Views/DNSLookupView.xaml.cs @@ -1,6 +1,7 @@ using System; using System.Windows; using System.Windows.Controls; +using DnsClient; using NETworkManager.Controls; using NETworkManager.ViewModels; @@ -10,11 +11,11 @@ public partial class DNSLookupView : IDragablzTabItem { private readonly DNSLookupViewModel _viewModel; - public DNSLookupView(Guid tabId, string host = null) + public DNSLookupView(Guid tabId, string host = null, string dnsServer = null, QueryType? queryType = null) { InitializeComponent(); - _viewModel = new DNSLookupViewModel(tabId, host); + _viewModel = new DNSLookupViewModel(tabId, host, dnsServer, queryType); DataContext = _viewModel; diff --git a/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs b/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs index 4f8813acd3..43dc89ec95 100644 --- a/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs +++ b/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class IPGeolocationHostView +public partial class IPGeolocationHostView : IApplicationRequestHandler { private readonly IPGeolocationHostViewModel _viewModel = new(); @@ -12,6 +12,14 @@ public IPGeolocationHostView() DataContext = _viewModel; } + public void Open(ApplicationRequest request) + { + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target); + } + public void OnViewHide() { _viewModel.OnViewHide(); diff --git a/Source/NETworkManager/Views/IPScannerHostView.xaml.cs b/Source/NETworkManager/Views/IPScannerHostView.xaml.cs index 25d1d3a18d..6593c40508 100644 --- a/Source/NETworkManager/Views/IPScannerHostView.xaml.cs +++ b/Source/NETworkManager/Views/IPScannerHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class IPScannerHostView +public partial class IPScannerHostView : IApplicationRequestHandler { private readonly IPScannerHostViewModel _viewModel = new(); @@ -12,9 +12,12 @@ public IPScannerHostView() DataContext = _viewModel; } - public void AddTab(string host) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(host); + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs b/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs index 42366d6a88..7ff2b61c9b 100644 --- a/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs +++ b/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs @@ -4,7 +4,7 @@ namespace NETworkManager.Views; -public partial class PingMonitorHostView +public partial class PingMonitorHostView : IApplicationRequestHandler { private readonly PingMonitorHostViewModel _viewModel = new(); @@ -14,10 +14,12 @@ public PingMonitorHostView() DataContext = _viewModel; } - public void AddHost(string host) + public void Open(ApplicationRequest request) { - if (_viewModel.SetHost(host)) - _ = _viewModel.Start(); + if (request.Profile != null) + _viewModel.AddHost(request.Profile); + else + _viewModel.AddHost(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/PortScannerHostView.xaml.cs b/Source/NETworkManager/Views/PortScannerHostView.xaml.cs index 30e612a289..99d078cc8c 100644 --- a/Source/NETworkManager/Views/PortScannerHostView.xaml.cs +++ b/Source/NETworkManager/Views/PortScannerHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class PortScannerHostView +public partial class PortScannerHostView : IApplicationRequestHandler { private readonly PortScannerHostViewModel _viewModel = new(); @@ -12,9 +12,12 @@ public PortScannerHostView() DataContext = _viewModel; } - public void AddTab(string host) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(host); + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target, (request as PortScannerRequest)?.Ports); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/PowerShellHostView.xaml b/Source/NETworkManager/Views/PowerShellHostView.xaml index 4b402065b6..2b2966419a 100644 --- a/Source/NETworkManager/Views/PowerShellHostView.xaml +++ b/Source/NETworkManager/Views/PowerShellHostView.xaml @@ -18,7 +18,6 @@ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:networkManager="clr-namespace:NETworkManager" xmlns:hostControls="clr-namespace:NETworkManager.Views.Controls" - Loaded="UserControl_Loaded" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:PowerShellHostViewModel}"> diff --git a/Source/NETworkManager/Views/PowerShellHostView.xaml.cs b/Source/NETworkManager/Views/PowerShellHostView.xaml.cs index 9c80c79016..bc3e33362e 100644 --- a/Source/NETworkManager/Views/PowerShellHostView.xaml.cs +++ b/Source/NETworkManager/Views/PowerShellHostView.xaml.cs @@ -1,35 +1,32 @@ +using NETworkManager.Utilities.WPF; using NETworkManager.ViewModels; -using System.Threading.Tasks; -using System.Windows; namespace NETworkManager.Views; -public partial class PowerShellHostView +public partial class PowerShellHostView : IApplicationRequestHandler { private readonly PowerShellHostViewModel _viewModel = new(); - private bool _loaded; - public PowerShellHostView() { InitializeComponent(); DataContext = _viewModel; } - private void UserControl_Loaded(object sender, RoutedEventArgs e) - { - _loaded = true; - } - - public async void AddTab(string host) + public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new profile... // MahApps will throw an exception... - while (!_loaded) - await Task.Delay(250); + if (!await this.WaitForLoadedAsync()) + return; + + if (!_viewModel.IsExecutableConfigured) + return; - if (_viewModel.IsExecutableConfigured) - _viewModel.AddTab(host); + if (request.Profile != null) + _viewModel.ConnectProfile(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/PuTTYHostView.xaml b/Source/NETworkManager/Views/PuTTYHostView.xaml index f158446330..ea813e7f0c 100644 --- a/Source/NETworkManager/Views/PuTTYHostView.xaml +++ b/Source/NETworkManager/Views/PuTTYHostView.xaml @@ -18,7 +18,6 @@ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:networkManager="clr-namespace:NETworkManager" xmlns:hostControls="clr-namespace:NETworkManager.Views.Controls" - Loaded="UserControl_Loaded" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:PuTTYHostViewModel}"> diff --git a/Source/NETworkManager/Views/PuTTYHostView.xaml.cs b/Source/NETworkManager/Views/PuTTYHostView.xaml.cs index 819d3310cb..b14aef3d1b 100644 --- a/Source/NETworkManager/Views/PuTTYHostView.xaml.cs +++ b/Source/NETworkManager/Views/PuTTYHostView.xaml.cs @@ -1,35 +1,32 @@ +using NETworkManager.Utilities.WPF; using NETworkManager.ViewModels; -using System.Threading.Tasks; -using System.Windows; namespace NETworkManager.Views; -public partial class PuTTYHostView +public partial class PuTTYHostView : IApplicationRequestHandler { private readonly PuTTYHostViewModel _viewModel = new(); - private bool _loaded; - public PuTTYHostView() { InitializeComponent(); DataContext = _viewModel; } - private void UserControl_Loaded(object sender, RoutedEventArgs e) - { - _loaded = true; - } - - public async void AddTab(string host) + public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new profile... // MahApps will throw an exception... - while (!_loaded) - await Task.Delay(250); + if (!await this.WaitForLoadedAsync()) + return; + + if (!_viewModel.IsExecutableConfigured) + return; - if (_viewModel.IsExecutableConfigured) - _viewModel.AddTab(host); + if (request.Profile != null) + _viewModel.ConnectProfile(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/RDAPHostView.xaml.cs b/Source/NETworkManager/Views/RDAPHostView.xaml.cs index 13405e9924..36118d9575 100644 --- a/Source/NETworkManager/Views/RDAPHostView.xaml.cs +++ b/Source/NETworkManager/Views/RDAPHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class RDAPHostView +public partial class RDAPHostView : IApplicationRequestHandler { private readonly RDAPHostViewModel _viewModel = new(); @@ -12,9 +12,12 @@ public RDAPHostView() DataContext = _viewModel; } - public void AddTab(string query) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(query); + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target, (request as RDAPRequest)?.QueryType); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml index a47620e7b8..6ed47f1c38 100644 --- a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml +++ b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml @@ -17,7 +17,6 @@ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:networkManager="clr-namespace:NETworkManager" xmlns:hostControls="clr-namespace:NETworkManager.Views.Controls" - Loaded="UserControl_Loaded" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:RemoteDesktopHostViewModel}"> diff --git a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs index d33633292b..92040196a3 100644 --- a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs +++ b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs @@ -1,34 +1,29 @@ +using NETworkManager.Utilities.WPF; using NETworkManager.ViewModels; -using System.Threading.Tasks; -using System.Windows; namespace NETworkManager.Views; -public partial class RemoteDesktopHostView +public partial class RemoteDesktopHostView : IApplicationRequestHandler { private readonly RemoteDesktopHostViewModel _viewModel = new(); - private bool _loaded; - public RemoteDesktopHostView() { InitializeComponent(); DataContext = _viewModel; } - private void UserControl_Loaded(object sender, RoutedEventArgs e) - { - _loaded = true; - } - - public async void AddTab(string host) + public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new Profile... // MahApps will throw an exception... - while (!_loaded) - await Task.Delay(250); + if (!await this.WaitForLoadedAsync()) + return; - _viewModel.AddTab(host); + if (request.Profile != null) + _viewModel.ConnectProfile(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml b/Source/NETworkManager/Views/RunCommandChildWindow.xaml new file mode 100644 index 0000000000..3b90ff6d5a --- /dev/null +++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs b/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs new file mode 100644 index 0000000000..f3ccd7cd0c --- /dev/null +++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs @@ -0,0 +1,74 @@ +using NETworkManager.ViewModels; +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +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. +/// +public partial class RunCommandChildWindow +{ + public RunCommandChildWindow() + { + InitializeComponent(); + } + + private RunCommandViewModel ViewModel => (RunCommandViewModel)DataContext; + + private void ChildWindow_OnLoaded(object sender, RoutedEventArgs e) + { + Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(delegate { TextBoxInput.Focus(); })); + } + + private void TextBoxInput_OnPreviewKeyDown(object sender, KeyEventArgs e) + { + switch (e.Key) + { + case Key.Down: + ViewModel.SelectNext(); + break; + case Key.Up: + ViewModel.SelectPrevious(); + break; + case Key.Tab: + ViewModel.Complete(); + TextBoxInput.CaretIndex = TextBoxInput.Text.Length; + break; + case Key.Enter: + ViewModel.Run(); + break; + case Key.Escape: + ViewModel.CancelCommand.Execute(null); + break; + default: + return; + } + + e.Handled = true; + } + + private void ListBoxSuggestions_OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + 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). + if (IsOpen) + TextBoxInput.Focus(); + } + + private void ListBoxSuggestions_OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (ListBoxSuggestions.SelectedItem != null) + ListBoxSuggestions.ScrollIntoView(ListBoxSuggestions.SelectedItem); + } +} diff --git a/Source/NETworkManager/Views/SNMPHostView.xaml.cs b/Source/NETworkManager/Views/SNMPHostView.xaml.cs index fb7a33a8b7..2755b8e09e 100644 --- a/Source/NETworkManager/Views/SNMPHostView.xaml.cs +++ b/Source/NETworkManager/Views/SNMPHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class SNMPHostView +public partial class SNMPHostView : IApplicationRequestHandler { private readonly SNMPHostViewModel _viewModel = new(); @@ -12,9 +12,12 @@ public SNMPHostView() DataContext = _viewModel; } - public void AddTab(string host) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(host); + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/TigerVNCHostView.xaml b/Source/NETworkManager/Views/TigerVNCHostView.xaml index 228da7c123..a50e0a27d7 100644 --- a/Source/NETworkManager/Views/TigerVNCHostView.xaml +++ b/Source/NETworkManager/Views/TigerVNCHostView.xaml @@ -17,7 +17,6 @@ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:networkManager="clr-namespace:NETworkManager" xmlns:hostControls="clr-namespace:NETworkManager.Views.Controls" - Loaded="UserControl_Loaded" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:TigerVNCHostViewModel}"> diff --git a/Source/NETworkManager/Views/TigerVNCHostView.xaml.cs b/Source/NETworkManager/Views/TigerVNCHostView.xaml.cs index d556299387..4164384504 100644 --- a/Source/NETworkManager/Views/TigerVNCHostView.xaml.cs +++ b/Source/NETworkManager/Views/TigerVNCHostView.xaml.cs @@ -1,35 +1,32 @@ +using NETworkManager.Utilities.WPF; using NETworkManager.ViewModels; -using System.Threading.Tasks; -using System.Windows; namespace NETworkManager.Views; -public partial class TigerVNCHostView +public partial class TigerVNCHostView : IApplicationRequestHandler { private readonly TigerVNCHostViewModel _viewModel = new(); - private bool _loaded; - public TigerVNCHostView() { InitializeComponent(); DataContext = _viewModel; } - private void UserControl_Loaded(object sender, RoutedEventArgs e) - { - _loaded = true; - } - - public async void AddTab(string host) + public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new profile... // MahApps will throw an exception... - while (!_loaded) - await Task.Delay(250); + if (!await this.WaitForLoadedAsync()) + return; + + if (!_viewModel.IsConfigured) + return; - if (_viewModel.IsConfigured) - _viewModel.AddTab(host); + if (request.Profile != null) + _viewModel.ConnectProfile(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/TracerouteHostView.xaml.cs b/Source/NETworkManager/Views/TracerouteHostView.xaml.cs index 2805de869b..84c6255eee 100644 --- a/Source/NETworkManager/Views/TracerouteHostView.xaml.cs +++ b/Source/NETworkManager/Views/TracerouteHostView.xaml.cs @@ -1,9 +1,8 @@ -using NETworkManager.Profiles; using NETworkManager.ViewModels; namespace NETworkManager.Views; -public partial class TracerouteHostView +public partial class TracerouteHostView : IApplicationRequestHandler { private readonly TracerouteHostViewModel _viewModel = new(); @@ -13,14 +12,12 @@ public TracerouteHostView() DataContext = _viewModel; } - public void AddTab(string host) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(host); - } - - public void AddTab(ProfileInfo profile) - { - _viewModel.AddTab(profile); + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/TracerouteView.xaml b/Source/NETworkManager/Views/TracerouteView.xaml index f1cd088f0c..35b5eb05ad 100644 --- a/Source/NETworkManager/Views/TracerouteView.xaml +++ b/Source/NETworkManager/Views/TracerouteView.xaml @@ -285,7 +285,7 @@ diff --git a/Source/NETworkManager/Views/WakeOnLANView.xaml.cs b/Source/NETworkManager/Views/WakeOnLANView.xaml.cs index 07e67ab28d..6e5977a651 100644 --- a/Source/NETworkManager/Views/WakeOnLANView.xaml.cs +++ b/Source/NETworkManager/Views/WakeOnLANView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class WakeOnLANView +public partial class WakeOnLANView : IApplicationRequestHandler { private readonly WakeOnLANViewModel _viewModel = new(); @@ -12,6 +12,14 @@ public WakeOnLANView() DataContext = _viewModel; } + public void Open(ApplicationRequest request) + { + if (request.Profile != null) + _viewModel.WakeUp(request.Profile); + else + _viewModel.WakeUp(request.Target, (request as WakeOnLANRequest)?.Broadcast); + } + public void OnViewHide() { _viewModel.OnViewHide(); diff --git a/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs b/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs index 2f5f3c937c..5c928c0185 100644 --- a/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs +++ b/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs @@ -1,8 +1,9 @@ +using NETworkManager.Utilities.WPF; using NETworkManager.ViewModels; namespace NETworkManager.Views; -public partial class WebConsoleHostView +public partial class WebConsoleHostView : IApplicationRequestHandler { private readonly WebConsoleHostViewModel _viewModel = new(); @@ -12,6 +13,23 @@ public WebConsoleHostView() DataContext = _viewModel; } + public async void Open(ApplicationRequest request) + { + // Wait for the interface to load, before displaying the dialog to connect a new profile... + // MahApps will throw an exception... + if (!await this.WaitForLoadedAsync()) + return; + + // WebView2 runtime is required for the web console. + if (!_viewModel.IsRuntimeAvailable) + return; + + if (request.Profile != null) + _viewModel.ConnectProfile(request.Profile); + else + _viewModel.AddTab(request.Target); + } + public void OnViewHide() { _viewModel.OnViewHide(); diff --git a/Source/NETworkManager/Views/WhoisHostView.xaml.cs b/Source/NETworkManager/Views/WhoisHostView.xaml.cs index 8debf8621c..ba30c03c69 100644 --- a/Source/NETworkManager/Views/WhoisHostView.xaml.cs +++ b/Source/NETworkManager/Views/WhoisHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class WhoisHostView +public partial class WhoisHostView : IApplicationRequestHandler { private readonly WhoisHostViewModel _viewModel = new(); @@ -12,9 +12,12 @@ public WhoisHostView() DataContext = _viewModel; } - public void AddTab(string domain) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(domain); + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/global.json b/Source/global.json index d258315f46..a688739275 100644 --- a/Source/global.json +++ b/Source/global.json @@ -3,5 +3,8 @@ "version": "10.0.401", "rollForward": "latestFeature", "allowPrerelease": false + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } \ No newline at end of file diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 5057cd278b..a280bc5a22 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -25,6 +25,10 @@ Release date: **xx.xx.2026** - ARM64 builds are now available. [#3538](https://github.com/BornToBeRoot/NETworkManager/issues/3538) +**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) + **RDAP** - New [**RDAP**](../application/rdap.md) tool to look up registration data via the Registration Data Access Protocol, the standardized, JSON-based successor of Whois. Supports domains, nameservers, top-level domains, IP addresses/CIDR prefixes, AS numbers and entity handles; the type is detected automatically or can be selected. The RDAP server is determined automatically using the IANA bootstrap files, which are cached locally and can be updated in the new **Settings > RDAP** page. Results are shown in a structured view (details, status, events, nameservers, DNSSEC, networks and AS numbers of an organization, contacts, notices) or as raw JSON, and can be exported as JSON or text. For domains (e.g. `.com`/`.net`), the referral to the registrar's RDAP server is followed as well (can be disabled in **Settings > RDAP**). Includes profile support. If no RDAP server is known for a domain, it can be opened in **Whois** with a single click. IP addresses and hostnames can be queried from the context menu of **IP Scanner** and **Traceroute**, AS numbers from **Traceroute**; for a hostname (e.g. `www.borntoberoot.net`), the registered domain is queried (determined with the included [Public Suffix List](https://publicsuffix.org/)). [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607) [#3642](https://github.com/BornToBeRoot/NETworkManager/pull/3642) @@ -84,6 +88,10 @@ Release date: **xx.xx.2026** - Added **RDAP...** to the context menu to query the registration data of the IP address, hostname or AS number of a hop in the [**RDAP**](../application/rdap.md) tool. [#3642](https://github.com/BornToBeRoot/NETworkManager/pull/3642) +**DNS Lookup** + +- IP addresses opened from another feature (e.g. **IP Scanner** > **DNS lookup** > **IP address**), from a profile or with the run command are looked up with `PTR` instead of the selected record type. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) + **Hosts File Editor** - Added **Copy as...** to the context menu to create a new entry based on an existing one. The copy is added directly below the original entry. [0544698](https://github.com/BornToBeRoot/NETworkManager/commit/0544698aefc325ab36ca4c8e80250e1fe0b62d4a) @@ -112,6 +120,7 @@ Release date: **xx.xx.2026** - Fixed changes to lists in the settings (e.g. query histories, port profiles, custom commands) not being saved if no other setting was changed in the same session. This happened since the settings are stored as JSON. [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607) - Fixed the profile search (profile list in the tools, **Profiles** settings and import result) filtering the profiles while still typing. The profiles are now filtered once after the user stopped typing, instead of every 750 ms after the first key press. [#3638](https://github.com/BornToBeRoot/NETworkManager/pull/3638) +- Fixed the application list not scrolling to the selected application if it was opened from somewhere else (e.g. from the **Dashboard**, another application or the run command). [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) **Firewall** @@ -181,5 +190,8 @@ Release date: **xx.xx.2026** - Converted the **IP Scanner** and **Port Scanner** engines from `Parallel.ForEach` with blocking calls to genuinely asynchronous `Parallel.ForEachAsync`, removing the risk of exhausting the application's ThreadPool at high concurrency settings. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) - Refactored the **Network Connection** widget's check logic to reduce duplicated code and share the local IP address detection between the Computer and Router checks instead of detecting it twice. [#3553](https://github.com/BornToBeRoot/NETworkManager/pull/3553) - Consolidated the duplicated **Profiles** side panel (search, tag filter, grouped list, context menu, add/edit/copy/delete) used across 15 tool views into a single shared control, reducing code duplication. As part of this, the profile panel's expanded/width state is now shared across all tools instead of being tracked per tool. [#3537](https://github.com/BornToBeRoot/NETworkManager/pull/3537) +- Replaced the internal event system for opening a feature with data (e.g. **Open in RDAP** from the **IP Scanner**) with typed requests, which can also carry options (e.g. DNS server and record type) and profiles. Features no longer fail to open from another feature while the application list is filtered by a search. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) +- Input histories (e.g. hosts, domains, ports) are updated in place instead of being cleared and refilled, which avoids clearing the text of the input field and unnecessary updates. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) +- Added a unit test project (xUnit) with tests for the run command parser and the history lists. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) - Language files updated via [#transifex](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Ftransifex-integration) - Dependencies updated via [#dependabot](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Fdependabot) diff --git a/Website/docs/commandline-arguments.md b/Website/docs/commandline-arguments.md index 2ea97809a9..fb9d4a400c 100644 --- a/Website/docs/commandline-arguments.md +++ b/Website/docs/commandline-arguments.md @@ -1,5 +1,5 @@ --- -sidebar_position: 5 +sidebar_position: 6 description: "Use command line arguments to control NETworkManager startup behavior, reset settings, and open a specific feature on launch." keywords: [NETworkManager, command line arguments, CLI, command line options, automation, scripting, parameters] --- diff --git a/Website/docs/faq/_category_.json b/Website/docs/faq/_category_.json index 689a9e74de..47d98a233e 100644 --- a/Website/docs/faq/_category_.json +++ b/Website/docs/faq/_category_.json @@ -1,6 +1,6 @@ { "label": "FAQ", - "position": 7, + "position": 8, "link": { "type": "generated-index", "description": "Frequently Asked Questions" diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md new file mode 100644 index 0000000000..859e7310af --- /dev/null +++ b/Website/docs/run-command.md @@ -0,0 +1,80 @@ +--- +sidebar_position: 5 +description: "Open a feature, run a lookup or connect to a host directly from the keyboard with the run command (Ctrl+Shift+P), including profiles and suggestions." +keywords: [NETworkManager, run command, command palette, keyboard shortcut, Ctrl+Shift+P, quick launch, ping, DNS lookup, remote desktop] +--- + +# Run Command + +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. + +## Syntax + +``` +command [target] [argument] [type] +command @profile +``` + +- **Command:** Name or alias of the feature (e.g. `dnslookup` or `dns`), see [Commands](#commands). Without a target, the feature is only opened. +- **Target:** Host(s), IP range, query or URL, depending on the feature. Lists are separated with `;` as everywhere in NETworkManager, spaces around `;` are allowed (e.g. `ping 10.0.0.1; 10.0.0.2`). +- **Argument** and **type:** Additional values of some features (e.g. DNS server and record type of the DNS lookup), in any order after the target. A value with spaces is written in quotes (e.g. `dns borntoberoot.net "Google Public DNS" aaaa`). +- **Profile:** `@` at the beginning of the target, followed by the name of a profile, runs the profile with its settings (e.g. `rdp @server-01`). Only profiles that are enabled for the feature can be used. The name can contain spaces (e.g. `rdp @Web Server 01`). If several profiles have the same name, an error is shown; select the profile in the [suggestions](#suggestions), which show its group. + +The input is validated like the input field of the feature (e.g. a valid host or IP range). If it is not valid, an error is shown and the dialog stays open. + +## Commands + +| Command | Aliases | Arguments | Example | +| ------------------ | ------------------ | ----------------------------------- | ---------------------------------------- | +| `ipscanner` | `scan` | `` | `scan 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` | +| `dnslookup` | `dns`, `nslookup` | ` [server] [type]` | `dns borntoberoot.net 1.1.1.1 aaaa` | +| `remotedesktop` | `rdp`, `mstsc` | `` | `rdp server-01` | +| `powershell` | `ps`, `pwsh` | `` | `ps server-01` | +| `putty` | `ssh` | `` | `ssh server-01` | +| `tigervnc` | `vnc` | `` | `vnc server-01` | +| `webconsole` | `web` | `` | `web https://borntoberoot.net` | +| `snmp` | | `` | `snmp 10.0.0.1` | +| `rdap` | | ` [type]` | `rdap 1.10 asn` | +| `whois` | | `` | `whois borntoberoot.net` | +| `ipgeolocation` | `geo` | `` | `geo 1.1.1.1` | +| `wakeonlan` | `wol` | ` [broadcast]` | `wol 00:11:22:33:44:55 192.168.1.255` | +| `settings` | | | `settings` | + +All features that accept a target also accept a profile (`@profile`). The other features (e.g. `subnetcalculator`, `firewall`) can be opened with their name. Features that are hidden in [Settings > General](./settings/general.md) are not available. + +:::note + +- **Remote Desktop, PowerShell, PuTTY, TigerVNC and Web Console** open the connect dialog with the host, so the connection settings can be adjusted before connecting. A profile connects directly with its settings. +- **DNS lookup:** The type is the DNS record type (`a`, `aaaa`, `any`, `caa`, `cname`, `dnskey`, `mx`, `ns`, `ptr`, `soa`, `srv`, `txt`). Without a type, IP addresses are looked up with `ptr` and other hosts with the type selected in the feature. The DNS server is the name of a configured DNS server (case-insensitive, e.g. `cloudflare`) or custom DNS server(s): an IP address or a host with a domain, with an optional port, separated by `;` (e.g. `1.1.1.1`, `dns.google:53`). +- **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)). +- **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. + +::: + +## Suggestions + +While typing, suggestions are shown below the input: + +| 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 | +| `@` (e.g. `rdp @web`) | Profiles of the feature (with group and host); exact names first | +| After the target | The input as typed, types of the feature (e.g. DNS record types), configured DNS servers (DNS lookup) and recent commands | + +`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. + +| 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) | + +Run commands are saved in a history (the number of entries is set in [Settings > General](./settings/general.md)). diff --git a/Website/docs/system-wide-policies.md b/Website/docs/system-wide-policies.md index 929f50c0fa..f0dc43cffa 100644 --- a/Website/docs/system-wide-policies.md +++ b/Website/docs/system-wide-policies.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 7 description: "Configure system-wide policies to enforce NETworkManager settings for all users in enterprise environments." keywords: [NETworkManager, system-wide policies, enterprise deployment, Group Policy, Intune, SCCM, centralized configuration, IT administration] ---