diff --git a/.claude/skills/feature-review/SKILL.md b/.claude/skills/feature-review/SKILL.md
index ba73fa8aca..78d9d3a8bd 100644
--- a/.claude/skills/feature-review/SKILL.md
+++ b/.claude/skills/feature-review/SKILL.md
@@ -147,8 +147,8 @@ Reference code as `path:line`. Rate each finding (High / Medium / Low) in the fi
prioritize. Proposed fixes describe the smallest change that follows the existing patterns.
For larger missing features, add an issue draft at the end that follows the repository's feature request
-template (`.github/ISSUE_TEMPLATE/Feature_request.md`): a short title, "Describe your feature request",
-the motivation, a proposed behaviour (UI + logic), and which existing feature it could be modelled on.
+form (`.github/ISSUE_TEMPLATE/feature_request.yml`): a short title, the affected feature, "Problem / motivation",
+"Proposed solution" (UI + logic), and "Alternatives / workarounds" incl. which existing feature it could be modelled on.
### 7. After the report
diff --git a/.claude/skills/feature-review/references/conventions.md b/.claude/skills/feature-review/references/conventions.md
index ea7d9c67e8..0715193403 100644
--- a/.claude/skills/feature-review/references/conventions.md
+++ b/.claude/skills/feature-review/references/conventions.md
@@ -102,6 +102,17 @@ Reference: `HostsFileEditorView.xaml`, `FirewallView.xaml`.
Other users: `RunCommandViewModel.Suggestions`. Not yet used (same `Clear()` + `ForEach(Results.Add)` pattern on every
refresh / auto refresh): `ConnectionsViewModel`, `ListenersViewModel`, `NeighborTableViewModel`,
`HostsFileEditorViewModel` โ report as a performance finding when reviewing them.
+- Column widths: no star (`Width="*"`) columns in a `DataGrid` โ with a visible star column WPF only lets a column
+ grow by taking the width from the columns to its right, so columns can't be made wider than the window (#3557).
+ Use `wpfHelpers:DataGridHelper.FillRemainingWidth="True"` on the grid: the last visible column (or the one marked with
+ `wpfHelpers:DataGridHelper.IsFillColumn="True"`) fills the remaining width with a pixel width, every column can be
+ resized freely and the grid scrolls horizontally. Give the fill column a `MinWidth`.
+- Bindings on items that implement `INotifyPropertyChanged` (e.g. `IPScannerHostInfo`, view models via `BindingProxy`)
+ use **plain paths** (`IsPingPending`, `PingInfo.Status`, `Data.ScanOptions.ResolveHostname`), not the typed form
+ `(network:Type.Property)`. WPF registers a `PropertyChanged` listener with the property name the path parser stored;
+ for a typed part that name stays `null` if the owner type can't be resolved early (templates, child bindings of a
+ `MultiBinding`) โ `ArgumentNullException` (`Parameter 'key'`) in `PropertyChangedEventManager.AddListener` as soon as
+ the item is shown. The typed form is only safe on plain (non-notifying) items.
- Don't use a `Reset` for results that are *appended* while a scan runs (IP Scanner, Port Scanner, Traceroute): with a
sorted view every `Reset` re-sorts the whole list and the grid loses its selection / scroll position. Those tools buffer
results and flush them with a `DispatcherTimer` (reference: `IPScannerViewModel.FlushResultsBuffer`).
@@ -130,6 +141,12 @@ Reference: `HostsFileEditorView.xaml`, `FirewallView.xaml`.
- Tool view: `Grid Margin="10"`, 10px spacer rows/columns. Header row: `HeaderTextBlock` left, refresh
(`CleanButton` with `ReloadAnimationHelper.IsReloading`) and search (`SearchTextBox`, width 250) right.
+- Search box (`SearchTextBox`): filters while typing. Lists that can be large or slow to filter use a delay
+ (`DispatcherTimer` with `GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan`, reference: `FirewallViewModel`),
+ short fixed lists filter on every key press. `Enter` only filters: `wpfHelpers:SearchTextBoxHelper.EnterCommand`
+ (delayed search, e.g. `ApplySearchCommand` = stop the timer + `Refresh()`) or `SearchTextBoxHelper.HandleEnter="True"`
+ (immediate search), so `Enter` never triggers the default button of the view / dialog / window. Exception: the
+ profile panel search runs the selected profile on `Enter` (`ProfileExpanderPanel`).
- Bottom row: secondary actions left (e.g. Open hosts file, Windows Firewall settings), primary action right
(e.g. Add entry...), `ImageWithTextButton`.
- Dialog: two-column grid (`1*`, 10, `2*`), 10px spacing between rows, OK (`HighlightedButton`, `IsDefault`)
diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md
deleted file mode 100644
index 37f5895da5..0000000000
--- a/.github/ISSUE_TEMPLATE/Bug_report.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-name: Bug report
-about: Report an issue
-labels: ":mag: needs triage"
----
-
-**Describe your issue**:
-
-**Steps to reproduce the issue**:
-
-1.
-2.
-3.
-
-**NETworkManager version**:
-
-- [ ] Portable
-
-**Windows version** (like 10.0.x.x):
-
-**.NET version** (like 6.0.x):
-
-**Screenshots**
-
-
-Eventlog (if application crashes)
-
-
diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md
deleted file mode 100644
index 377f9a53f5..0000000000
--- a/.github/ISSUE_TEMPLATE/Feature_request.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-name: Feature request
-about: Create a feature request
-labels: ":rocket: feature-request"
----
-
-**Describe your feature request**:
diff --git a/.github/ISSUE_TEMPLATE/Question.md b/.github/ISSUE_TEMPLATE/Question.md
deleted file mode 100644
index f941c3be7f..0000000000
--- a/.github/ISSUE_TEMPLATE/Question.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-name: Question
-about: Ask a question
-labels: ":question: question"
----
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000000..f25368f9f2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,149 @@
+name: ๐ Bug report
+description: Something doesn't work as expected, or the application crashes.
+labels: [":mag: needs triage"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to report a bug! ๐
+
+ Please check the [existing issues](https://github.com/BornToBeRoot/NETworkManager/issues?q=is%3Aissue) first and whether the bug still happens with the [latest version](https://github.com/BornToBeRoot/NETworkManager/releases) (or pre-release).
+
+ > [!IMPORTANT]
+ > **Security vulnerabilities** must not be reported here - please use a [private security advisory](https://github.com/BornToBeRoot/NETworkManager/security/advisories/new).
+
+ - type: dropdown
+ id: area
+ attributes:
+ label: Affected feature
+ description: Where does the bug happen?
+ options:
+ - General (start, settings, profiles, update, ...)
+ - Run command
+ - Dashboard
+ - Network Interface
+ - WiFi
+ - IP Scanner
+ - Port Scanner
+ - Ping Monitor
+ - Traceroute
+ - DNS Lookup
+ - Remote Desktop
+ - PowerShell
+ - PuTTY
+ - TigerVNC
+ - Web Console
+ - SNMP
+ - SNTP Lookup
+ - Hosts File Editor
+ - Firewall
+ - Discovery Protocol
+ - Wake on LAN
+ - RDAP
+ - Whois
+ - IP Geolocation
+ - Subnet Calculator
+ - Bit Calculator
+ - Lookup
+ - Connections
+ - Listeners
+ - Neighbor Table
+ - Documentation / website
+ - Other
+ validations:
+ required: true
+
+ - type: textarea
+ id: description
+ attributes:
+ label: What happened?
+ description: Describe the bug and what you expected to happen instead.
+ placeholder: When I scan a range, hosts are shown as down, but I expected them to be up because ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to reproduce
+ description: How can the bug be reproduced? Include the input and settings you used (without sensitive data).
+ placeholder: |
+ 1. Open the IP Scanner
+ 2. Enter `10.0.0.0/24` and click **Scan**
+ 3. ...
+ validations:
+ required: true
+
+ - type: input
+ id: version
+ attributes:
+ label: NETworkManager version
+ description: Shown in **Settings > About**.
+ placeholder: e.g. 2026.7.7.0
+ validations:
+ required: true
+
+ - type: dropdown
+ id: installation
+ attributes:
+ label: Installation
+ options:
+ - Setup (MSI)
+ - Portable
+ - Archive
+ - WinGet
+ - Chocolatey
+ - Built from source
+ validations:
+ required: true
+
+ - type: dropdown
+ id: architecture
+ attributes:
+ label: Architecture
+ options:
+ - x64
+ - ARM64
+ validations:
+ required: true
+
+ - type: input
+ id: windows
+ attributes:
+ label: Windows version
+ description: Run `winver` to see the version and build.
+ placeholder: e.g. Windows 11 24H2 (Build 26100.4061)
+ validations:
+ required: true
+
+ - type: input
+ id: dotnet
+ attributes:
+ label: .NET Desktop Runtime version
+ description: Only needed if the application doesn't start - run `dotnet --list-runtimes`.
+ placeholder: e.g. Microsoft.WindowsDesktop.App 10.0.0
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Error message / log
+ description: |
+ The error message shown by NETworkManager, or the relevant part of the log file `%LocalAppData%\NETworkManager\NETworkManager.log`.
+ If the application crashes, also the error in the Event Viewer (**Windows Logs > Application**).
+ render: text
+
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Screenshots / additional information
+ description: Drag & drop screenshots or files here, or add anything else that helps to understand the bug.
+
+ - type: checkboxes
+ id: checklist
+ attributes:
+ label: Checklist
+ options:
+ - label: I have searched the existing issues and this bug hasn't been reported yet.
+ required: true
+ - label: I have removed sensitive data (e.g. passwords, IP addresses, hostnames) from logs and screenshots.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000000..87c23fc802
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,14 @@
+blank_issues_enabled: false
+contact_links:
+ - name: ๐ Documentation
+ url: https://borntoberoot.net/NETworkManager/docs/introduction
+ about: Features, settings, FAQ and how to use NETworkManager.
+ - name: ๐ฌ Discussions
+ url: https://github.com/BornToBeRoot/NETworkManager/discussions
+ about: Share ideas, show what you built or talk with the community.
+ - name: ๐ Translations
+ url: https://app.transifex.com/BornToBeRoot/NETworkManager/dashboard/
+ about: Translations are managed on Transifex - help translate NETworkManager there.
+ - name: ๐ Security vulnerability
+ url: https://github.com/BornToBeRoot/NETworkManager/security/advisories/new
+ about: Please report security vulnerabilities privately, not as a public issue.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000000..9047944f3c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,90 @@
+name: ๐ก Feature request
+description: Suggest a new feature or an improvement of an existing one.
+labels: [":rocket: feature-request"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for helping to make NETworkManager better! ๐
+
+ Please check the [existing issues](https://github.com/BornToBeRoot/NETworkManager/issues?q=is%3Aissue) first - maybe your idea was already suggested. If so, give it a ๐ instead of creating a new issue.
+
+ - type: dropdown
+ id: area
+ attributes:
+ label: Feature
+ description: Which feature is it about?
+ options:
+ - New feature / tool
+ - General (start, settings, profiles, update, ...)
+ - Run command
+ - Dashboard
+ - Network Interface
+ - WiFi
+ - IP Scanner
+ - Port Scanner
+ - Ping Monitor
+ - Traceroute
+ - DNS Lookup
+ - Remote Desktop
+ - PowerShell
+ - PuTTY
+ - TigerVNC
+ - Web Console
+ - SNMP
+ - SNTP Lookup
+ - Hosts File Editor
+ - Firewall
+ - Discovery Protocol
+ - Wake on LAN
+ - RDAP
+ - Whois
+ - IP Geolocation
+ - Subnet Calculator
+ - Bit Calculator
+ - Lookup
+ - Connections
+ - Listeners
+ - Neighbor Table
+ - Documentation / website
+ - Other
+ validations:
+ required: true
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem / motivation
+ description: What do you want to achieve, or what is missing or annoying today?
+ placeholder: I often need to ... but currently I have to ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: solution
+ attributes:
+ label: Proposed solution
+ description: How should it work? Describe the behavior and the UI (e.g. button, setting, context menu entry).
+ placeholder: Add a ... to the ... that ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives / workarounds
+ description: Other solutions you have considered, or how other tools solve it.
+
+ - type: textarea
+ id: context
+ attributes:
+ label: Additional information
+ description: Screenshots, mockups, links or anything else that helps.
+
+ - type: checkboxes
+ id: checklist
+ attributes:
+ label: Checklist
+ options:
+ - label: I have searched the existing issues and this feature hasn't been requested yet.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
new file mode 100644
index 0000000000..a2c5c7746f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/question.yml
@@ -0,0 +1,29 @@
+name: โ Question
+description: Ask a question about NETworkManager or how to use a feature.
+labels: [":question: question"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Have a question? Maybe the [documentation](https://borntoberoot.net/NETworkManager/docs/introduction) or the [FAQ](https://borntoberoot.net/NETworkManager/docs/category/faq) already has the answer. For ideas and general discussions, you can also use the [discussions](https://github.com/BornToBeRoot/NETworkManager/discussions).
+
+ - type: textarea
+ id: question
+ attributes:
+ label: Question
+ description: What would you like to know?
+ validations:
+ required: true
+
+ - type: textarea
+ id: context
+ attributes:
+ label: Context
+ description: What are you trying to achieve, and what have you tried? Screenshots are welcome.
+
+ - type: input
+ id: version
+ attributes:
+ label: NETworkManager version
+ description: Shown in **Settings > About** (if the question is about the application).
+ placeholder: e.g. 2026.7.7.0
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index c0570eda3a..48a570cee5 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,32 +1,51 @@
-## Changes proposed in this pull request
+
--
--
+## Description
-## Related issue(s)
+
-- Fixes #{issue number}
-## Copilot generated summary
-Provide a Copilot generated summary of the changes in this pull request.
+## Related issue
-
-Copilot summary
+
-{generated summary}
+Closes #
-
+## Type of change
-## To-Do
+- [ ] ๐ Bug fix
+- [ ] โจ New feature / improvement
+- [ ] ๐ Documentation / website
+- [ ] ๐งน Refactoring / maintenance (no functional change)
-- [ ] Update [documentation](https://github.com/BornToBeRoot/NETworkManager/tree/main/Website/docs) to reflect this changes
-- [ ] Update [changelog](https://github.com/BornToBeRoot/NETworkManager/tree/main/Website/docs/changelog) to reflect this changes
+## How has this been tested?
+
+
+
+
+
+## Screenshots
+
+
+
+
+
+## Checklist
+
+- [ ] The solution builds and was tested on Windows (`dotnet build`, `dotnet test`).
+- [ ] The [documentation](https://github.com/BornToBeRoot/NETworkManager/tree/main/Website/docs) is updated (if needed).
+- [ ] An entry is added to the [changelog](https://github.com/BornToBeRoot/NETworkManager/blob/main/Website/docs/changelog/next-release.md).
+- [ ] New texts are only added to `Strings.resx` (translations are done on [Transifex](https://app.transifex.com/BornToBeRoot/NETworkManager/dashboard/)).
+- [ ] No files in `Source/3rdparty/` are changed.
## Contributing
-**By submitting this pull request, I confirm the following:**
+**By submitting this pull request, I confirm that:**
-- [ ] I have read and understood the [contributing guidelines](https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTING.md) and the [code of conduct](https://github.com/BornToBeRoot/NETworkManager/blob/main/CODE_OF_CONDUCT.md).
-- [ ] I have have added my name, username or email to the [contributors](https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTORS.md) list or don't want to.
-- [ ] The code or resource is compatible with the [GNU General Public License v3.0](https://github.com/BornToBeRoot/NETworkManager/blob/main/LICENSE).
+- [ ] I have read the [contributing guidelines](https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTING.md) and the [code of conduct](https://github.com/BornToBeRoot/NETworkManager/blob/main/CODE_OF_CONDUCT.md).
+- [ ] My contribution is compatible with the [GNU General Public License v3.0](https://github.com/BornToBeRoot/NETworkManager/blob/main/LICENSE).
+- [ ] I have added myself to the [contributors](https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTORS.md) list, or I don't want to be listed.
diff --git a/README.md b/README.md
index 062e8c98ab..7d07bd4d26 100644
--- a/README.md
+++ b/README.md
@@ -46,10 +46,10 @@
-
+
-
+
diff --git a/Source/GlobalAssemblyInfo.cs b/Source/GlobalAssemblyInfo.cs
index 5ed49d3b5d..2d5beb97d3 100644
--- a/Source/GlobalAssemblyInfo.cs
+++ b/Source/GlobalAssemblyInfo.cs
@@ -6,5 +6,5 @@
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
-[assembly: AssemblyVersion("2026.10.11.0")]
-[assembly: AssemblyFileVersion("2026.10.11.0")]
+[assembly: AssemblyVersion("2026.10.27.1")]
+[assembly: AssemblyFileVersion("2026.10.27.1")]
diff --git a/Source/NETworkManager.Controls/ComboBoxListCompletionBehavior.cs b/Source/NETworkManager.Controls/ComboBoxListCompletionBehavior.cs
new file mode 100644
index 0000000000..b10fcb7298
--- /dev/null
+++ b/Source/NETworkManager.Controls/ComboBoxListCompletionBehavior.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Threading;
+using NETworkManager.Utilities;
+
+namespace NETworkManager.Controls;
+
+///
+/// Tab completion for an editable with a list separated by ";" (e.g. the ports of the Port
+/// Scanner with port profile names): Tab completes the last entry with a name of
+/// (e.g. "webserver; dat" -> "webserver; Database"), the entries before are kept. Tab again shows the next
+/// name that matches the typed entry, after the last one it moves the focus. If there is nothing to complete (empty
+/// entry, no match, already the name), Tab moves the focus as usual. See
+/// .
+///
+public static class ComboBoxListCompletionBehavior
+{
+ public static readonly DependencyProperty ItemsProperty =
+ DependencyProperty.RegisterAttached(
+ "Items",
+ typeof(IEnumerable),
+ typeof(ComboBoxListCompletionBehavior),
+ new PropertyMetadata(null, OnItemsChanged));
+
+ public static void SetItems(UIElement element, IEnumerable value) =>
+ element.SetValue(ItemsProperty, value);
+
+ public static IEnumerable GetItems(UIElement element) =>
+ (IEnumerable)element.GetValue(ItemsProperty);
+
+ // Completion that is shown, to show the next name with the next Tab
+ private static readonly DependencyProperty StateProperty =
+ DependencyProperty.RegisterAttached(
+ "State",
+ typeof(CompletionState),
+ typeof(ComboBoxListCompletionBehavior),
+ new PropertyMetadata(null));
+
+ private sealed record CompletionState(string Prefix, List Candidates, int Index, string Text);
+
+ private static void OnItemsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not ComboBox comboBox)
+ return;
+
+ comboBox.PreviewKeyDown -= ComboBox_PreviewKeyDown;
+
+ if (e.NewValue != null)
+ comboBox.PreviewKeyDown += ComboBox_PreviewKeyDown;
+ }
+
+ private static void ComboBox_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key != Key.Tab || Keyboard.Modifiers != ModifierKeys.None ||
+ sender is not ComboBox { IsEditable: true, IsReadOnly: false } comboBox)
+ return;
+
+ var textBox = comboBox.Template?.FindName("PART_EditableTextBox", comboBox) as TextBox;
+ var text = comboBox.Text ?? string.Empty;
+
+ // Tab again: the next name for the same entry, after the last one Tab moves the focus
+ if (comboBox.GetValue(StateProperty) is CompletionState state && state.Text == text)
+ {
+ if (state.Index + 1 >= state.Candidates.Count)
+ {
+ comboBox.ClearValue(StateProperty);
+ return;
+ }
+
+ Complete(comboBox, textBox, state with { Index = state.Index + 1 }, e);
+ return;
+ }
+
+ // The text typed by the user, without a rest that the ComboBox completed and selected (e.g. "Webs[erver]")
+ if (textBox is { SelectionLength: > 0 } &&
+ textBox.SelectionStart + textBox.SelectionLength == text.Length)
+ text = text[..textBox.SelectionStart];
+
+ var (prefix, entry) = ListEntryCompletionHelper.SplitLastEntry(text);
+ var candidates = ListEntryCompletionHelper.GetCandidates(entry, GetItems(comboBox) ?? []);
+
+ // Nothing to complete: Tab moves the focus
+ if (candidates.Count == 0)
+ return;
+
+ // The entry is already the first name: start with the next one (if there is none, Tab moves the focus)
+ var index = string.Equals(candidates[0], entry.Trim(), StringComparison.OrdinalIgnoreCase) ? 1 : 0;
+
+ if (index >= candidates.Count)
+ return;
+
+ Complete(comboBox, textBox, new CompletionState(prefix, candidates, index, null), e);
+ }
+
+ private static void Complete(ComboBox comboBox, TextBox textBox, CompletionState state, KeyEventArgs e)
+ {
+ var text = state.Prefix + state.Candidates[state.Index];
+
+ comboBox.Text = text;
+ comboBox.SetValue(StateProperty, state with { Text = text });
+
+ // Caret at the end, nothing selected - also after the ComboBox updated its text box
+ if (textBox != null)
+ {
+ textBox.Select(text.Length, 0);
+ textBox.Dispatcher.BeginInvoke(DispatcherPriority.Input,
+ new Action(() => textBox.Select(textBox.Text.Length, 0)));
+ }
+
+ e.Handled = true;
+ }
+}
diff --git a/Source/NETworkManager.Controls/NETworkManager.Controls.csproj b/Source/NETworkManager.Controls/NETworkManager.Controls.csproj
index 4de1ff2b59..9203863a62 100644
--- a/Source/NETworkManager.Controls/NETworkManager.Controls.csproj
+++ b/Source/NETworkManager.Controls/NETworkManager.Controls.csproj
@@ -13,4 +13,7 @@
+
+
+
diff --git a/Source/NETworkManager.Converters/MultiValueToArrayConverter.cs b/Source/NETworkManager.Converters/MultiValueToArrayConverter.cs
new file mode 100644
index 0000000000..a6fc115d47
--- /dev/null
+++ b/Source/NETworkManager.Converters/MultiValueToArrayConverter.cs
@@ -0,0 +1,23 @@
+๏ปฟusing System;
+using System.Globalization;
+using System.Windows.Data;
+
+namespace NETworkManager.Converters;
+
+///
+/// Passes the values of a as an array, e.g. a command parameter with several values (an
+/// application and the host of a group header in the Port Scanner).
+///
+public sealed class MultiValueToArrayConverter : IMultiValueConverter
+{
+ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
+ {
+ // Copy - the array of the MultiBinding is reused
+ return values.Clone();
+ }
+
+ public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/Source/NETworkManager.Converters/PendingToStringConverter.cs b/Source/NETworkManager.Converters/PendingToStringConverter.cs
new file mode 100644
index 0000000000..bdc51f5cd6
--- /dev/null
+++ b/Source/NETworkManager.Converters/PendingToStringConverter.cs
@@ -0,0 +1,25 @@
+๏ปฟusing System;
+using System.Globalization;
+using System.Windows.Data;
+
+namespace NETworkManager.Converters;
+
+///
+/// Shows "..." while a value is pending (e.g. a probe of the IP Scanner is still running), otherwise the value.
+/// Values: [0] pending (bool), [1] value (usually converted to a string by its own binding).
+///
+public sealed class PendingToStringConverter : IMultiValueConverter
+{
+ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (values is not { Length: 2 })
+ return null;
+
+ return values[0] is true ? "..." : values[1]?.ToString();
+ }
+
+ public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/Source/NETworkManager.Converters/PingMonitorGroupSummaryConverter.cs b/Source/NETworkManager.Converters/PingMonitorGroupSummaryConverter.cs
index 97161a0c4d..0fdc6387e9 100644
--- a/Source/NETworkManager.Converters/PingMonitorGroupSummaryConverter.cs
+++ b/Source/NETworkManager.Converters/PingMonitorGroupSummaryConverter.cs
@@ -9,9 +9,10 @@ namespace NETworkManager.Converters;
///
/// Formats the count of hosts up (reachable), down (unreachable) or paused (not running) within
-/// a Ping Monitor group, selected via ConverterParameter ("Up", "Down", "Paused" - formats
-/// "{count} {label}" using the label bound as the third value - or "PausedVisibility", which
-/// instead returns a so the paused count can be hidden while zero).
+/// a Ping Monitor group, selected via ConverterParameter ("Up", "Down", "Paused" - formats the
+/// count with the format string bound as the third value, e.g. Strings.XUp = "{0} Up", so the
+/// word order can be translated - or "PausedVisibility", which instead returns a
+/// so the paused count can be hidden while zero).
///
///
/// Bound as a with the as the first
@@ -41,11 +42,13 @@ public object Convert(object[] values, Type targetType, object parameter, Cultur
down++;
}
+ var format = values.Length > 2 ? values[2] as string ?? "{0}" : "{0}";
+
return parameter as string switch
{
- "Up" => $"{up} {values[2]}",
- "Down" => $"{down} {values[2]}",
- "Paused" => $"{paused} {values[2]}",
+ "Up" => string.Format(culture, format, up),
+ "Down" => string.Format(culture, format, down),
+ "Paused" => string.Format(culture, format, paused),
"PausedVisibility" => paused > 0 ? Visibility.Visible : Visibility.Collapsed,
_ => string.Empty
};
diff --git a/Source/NETworkManager.Converters/PingTimeToStringConverter.cs b/Source/NETworkManager.Converters/PingTimeToStringConverter.cs
index 935914bad9..e6b03b10bb 100644
--- a/Source/NETworkManager.Converters/PingTimeToStringConverter.cs
+++ b/Source/NETworkManager.Converters/PingTimeToStringConverter.cs
@@ -6,11 +6,22 @@
namespace NETworkManager.Converters;
+///
+/// Converts the status and the time of a ping to a string (e.g. "4 ms", "-/-" if it failed).
+/// Values: [0] status (), [1] time (long), [2] optional: pending (bool) - "..." while the
+/// ping is still running. Other values (e.g. {DisconnectedItem} of a recycled row, or
+/// ) -> empty string.
+///
public sealed class PingTimeToStringConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
- return Ping.TimeToString((IPStatus)values[0], (long)values[1]);
+ if (values.Length > 2 && values[2] is true)
+ return "...";
+
+ return values.Length > 1 && values[0] is IPStatus status && values[1] is long time
+ ? Ping.TimeToString(status, time)
+ : string.Empty;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
diff --git a/Source/NETworkManager.Converters/PortScannerGroupOpenPortsConverter.cs b/Source/NETworkManager.Converters/PortScannerGroupOpenPortsConverter.cs
new file mode 100644
index 0000000000..ed208f4aa2
--- /dev/null
+++ b/Source/NETworkManager.Converters/PortScannerGroupOpenPortsConverter.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Windows.Data;
+using NETworkManager.Models.Network;
+
+namespace NETworkManager.Converters;
+
+///
+/// Formats the number of open ports of a Port Scanner group (one host) with the format string bound as the fourth
+/// value (e.g. Strings.XOpen = "{0} Open", so the word order can be translated).
+///
+///
+/// Bound as a with the as the first value, its
+/// as the second and the open ports per IP address (counted by the view
+/// model when a row is added) as the third. The second value isn't used directly, it only forces re-evaluation when a
+/// port is added to (or removed from) the group while the scan is running; an optional fifth value (a version number)
+/// does the same when a rescanned port changes in place. The rows of the group are not counted
+/// here: that would take rows x rows for a large group (e.g. 1-65535 with closed ports shown).
+///
+public sealed class PortScannerGroupOpenPortsConverter : IMultiValueConverter
+{
+ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (values.Length < 3 || values[0] is not CollectionViewGroup group ||
+ values[2] is not IReadOnlyDictionary openPortsPerHost)
+ return string.Empty;
+
+ // One host per group
+ var open = group.Items.FirstOrDefault() is PortScannerPortInfo info
+ ? openPortsPerHost.GetValueOrDefault(info.IPAddress)
+ : 0;
+
+ var format = values.Length > 3 ? values[3] as string ?? "{0}" : "{0}";
+
+ return string.Format(culture, format, open);
+ }
+
+ public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index d63ed84ab6..94b162c792 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -1,6 +1,7 @@
๏ปฟ//------------------------------------------------------------------------------
//
// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
@@ -779,6 +780,15 @@ public static string AdjustScreenAutomatically {
}
}
+ ///
+ /// Looks up a localized string similar to Adjust threads automatically.
+ ///
+ public static string AdjustThreadsAutomatically {
+ get {
+ return ResourceManager.GetString("AdjustThreadsAutomatically", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Admin (console) session.
///
@@ -3019,6 +3029,24 @@ public static string DeleteCustomCommandMessage {
}
}
+ ///
+ /// Looks up a localized string similar to Delete custom commands.
+ ///
+ public static string DeleteCustomCommands {
+ get {
+ return ResourceManager.GetString("DeleteCustomCommands", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The selected custom commands will be deleted permanently..
+ ///
+ public static string DeleteCustomCommandsMessage {
+ get {
+ return ResourceManager.GetString("DeleteCustomCommandsMessage", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Delete DNS server.
///
@@ -3171,6 +3199,24 @@ public static string DeletePortProfileMessage {
}
}
+ ///
+ /// Looks up a localized string similar to Delete port profiles.
+ ///
+ public static string DeletePortProfiles {
+ get {
+ return ResourceManager.GetString("DeletePortProfiles", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The selected port profiles will be deleted permanently..
+ ///
+ public static string DeletePortProfilesMessage {
+ get {
+ return ResourceManager.GetString("DeletePortProfilesMessage", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Delete profile.
///
@@ -3639,6 +3685,15 @@ public static string DNSServer {
}
}
+ ///
+ /// Looks up a localized string similar to A DNS server of this profile was not found, the DNS servers saved with the profile are used. DNS servers are saved in the settings and the profile refers to them by an internal ID - if the profile file was shared from another computer, create the DNS server and then select it in the profile again (edit the profile)..
+ ///
+ public static string DNSServerOfProfileNotFoundMessage {
+ get {
+ return ResourceManager.GetString("DNSServerOfProfileNotFoundMessage", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to DNS server(s).
///
@@ -4282,6 +4337,15 @@ public static string EnterValidBaud {
}
}
+ ///
+ /// Looks up a localized string similar to Enter valid DNS servers (IP address or hostname, optional port) and/or names of DNS servers!.
+ ///
+ public static string EnterValidDNSServersOrDNSServerProfiles {
+ get {
+ return ResourceManager.GetString("EnterValidDNSServersOrDNSServerProfiles", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Enter a valid domain (e.g., "example.com")!.
///
@@ -4489,6 +4553,15 @@ public static string EnterValidPortOrPortRange {
}
}
+ ///
+ /// Looks up a localized string similar to Enter valid ports, port ranges (1 - 65535) and/or port profiles!.
+ ///
+ public static string EnterValidPortsOrPortProfiles {
+ get {
+ return ResourceManager.GetString("EnterValidPortsOrPortProfiles", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Enter a valid domain, IP address, AS number or entity handle!.
///
@@ -4606,6 +4679,24 @@ public static string ErrorMessage_NameIsAlreadyUsed {
}
}
+ ///
+ /// Looks up a localized string similar to The name must not contain ";"..
+ ///
+ public static string ErrorMessage_NameMustNotContainSemicolon {
+ get {
+ return ResourceManager.GetString("ErrorMessage_NameMustNotContainSemicolon", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The name must not contain ";" and must not be a port or port range..
+ ///
+ public static string ErrorMessage_PortProfileNameInvalid {
+ get {
+ return ResourceManager.GetString("ErrorMessage_PortProfileNameInvalid", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The settings on this page contain errors. Correct them to be able to save..
///
@@ -5670,6 +5761,15 @@ public static string Help {
}
}
+ ///
+ /// Looks up a localized string similar to Adjusts the number of hosts and ports scanned at the same time to the scan: up to 512 connections at the same time, at most 128 hosts and 128 ports per host (e.g. 4 hosts with 1-1024, 128 hosts with one port). Disable it to set the threads yourself, e.g. to slow down a scan..
+ ///
+ public static string HelpMessage_AdjustThreadsAutomatically {
+ get {
+ return ResourceManager.GetString("HelpMessage_AdjustThreadsAutomatically", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to If you enable this option, the default PowerShell console settings in the registry under HKCU:\Console are overridden so that the PowerShell console window matches the application theme. This is a global system setting that may affect the appearance of the normal PowerShell window.
///
@@ -5866,6 +5966,24 @@ public static string HelpMessage_PasswordNotDisplayedCanBeOverwritten {
}
}
+ ///
+ /// Looks up a localized string similar to Only hosts that answer a ping (ICMP echo, with the timeout and attempts above) are scanned. Faster for ranges with many unused IP addresses. Hosts that block ICMP (e.g. Windows with the firewall enabled) are not scanned..
+ ///
+ public static string HelpMessage_PingHostsBeforeScan {
+ get {
+ return ResourceManager.GetString("HelpMessage_PingHostsBeforeScan", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Host threads ร port threads can be at most 8,192 connections at the same time (every connection uses a local port, this is half of them). Many connections at the same time may be blocked by a firewall or overload small routers..
+ ///
+ public static string HelpMessage_PortScannerMaxThreads {
+ get {
+ return ResourceManager.GetString("HelpMessage_PortScannerMaxThreads", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The following placeholder can be used:.
///
@@ -5959,6 +6077,15 @@ public static string HelpMessage_SaveCredentials {
}
}
+ ///
+ /// Looks up a localized string similar to Only IPv4 addresses are scanned for NetBIOS, NetBIOS doesn't exist over IPv6..
+ ///
+ public static string HelpMessage_ScanForNetBIOS {
+ get {
+ return ResourceManager.GetString("HelpMessage_ScanForNetBIOS", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Displays the status bar in the bottom-left of the WebView when hovering over a link..
///
@@ -8653,11 +8780,11 @@ public static string OpenOIDProfilesDots {
}
///
- /// Looks up a localized string similar to Open port profiles....
+ /// Looks up a localized string similar to Open ports.
///
- public static string OpenPortProfilesDots {
+ public static string OpenPorts {
get {
- return ResourceManager.GetString("OpenPortProfilesDots", resourceCulture);
+ return ResourceManager.GetString("OpenPorts", resourceCulture);
}
}
@@ -8985,6 +9112,15 @@ public static string PingDetails {
}
}
+ ///
+ /// Looks up a localized string similar to Ping hosts before the scan.
+ ///
+ public static string PingHostsBeforeScan {
+ get {
+ return ResourceManager.GetString("PingHostsBeforeScan", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Ping Monitor.
///
@@ -9102,6 +9238,15 @@ public static string PortProfile {
}
}
+ ///
+ /// Looks up a localized string similar to A port profile of this profile was not found, the ports saved with the profile are used. Port profiles are saved in the settings and the profile refers to them by an internal ID - if the profile file was shared from another computer, create the port profile and then select it in the profile again (edit the profile)..
+ ///
+ public static string PortProfileOfProfileNotFoundMessage {
+ get {
+ return ResourceManager.GetString("PortProfileOfProfileNotFoundMessage", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Port profiles.
///
@@ -11165,6 +11310,24 @@ public static string ReportOnGitHub {
}
}
+ ///
+ /// Looks up a localized string similar to Rescan.
+ ///
+ public static string Rescan {
+ get {
+ return ResourceManager.GetString("Rescan", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Rescan host.
+ ///
+ public static string RescanHost {
+ get {
+ return ResourceManager.GetString("RescanHost", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Reset.
///
@@ -12208,15 +12371,6 @@ public static string SelectOIDProfile {
}
}
- ///
- /// Looks up a localized string similar to Select port profile.
- ///
- public static string SelectPortProfile {
- get {
- return ResourceManager.GetString("SelectPortProfile", resourceCulture);
- }
- }
-
///
/// Looks up a localized string similar to Send.
///
@@ -14676,6 +14830,69 @@ public static string XADNSRecordsResolvedForXXXMessage {
}
}
+ ///
+ /// Looks up a localized string similar to {0} Closed.
+ ///
+ public static string XClosed {
+ get {
+ return ResourceManager.GetString("XClosed", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} Down.
+ ///
+ public static string XDown {
+ get {
+ return ResourceManager.GetString("XDown", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} host(s) did not answer the ping and were not scanned..
+ ///
+ public static string XHostsDidNotAnswerThePingAndWereNotScanned {
+ get {
+ return ResourceManager.GetString("XHostsDidNotAnswerThePingAndWereNotScanned", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} Open.
+ ///
+ public static string XOpen {
+ get {
+ return ResourceManager.GetString("XOpen", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} Paused.
+ ///
+ public static string XPaused {
+ get {
+ return ResourceManager.GetString("XPaused", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} Timed out.
+ ///
+ public static string XTimedOut {
+ get {
+ return ResourceManager.GetString("XTimedOut", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} Up.
+ ///
+ public static string XUp {
+ get {
+ return ResourceManager.GetString("XUp", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to {0} seconds remaining....
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index 0217f96ead..3fadda001b 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -1056,6 +1056,9 @@ Create one...
Open
+
+ Open ports
+
Open license
@@ -2274,6 +2277,12 @@ is disabled!
The selected custom command will be deleted permanently.
+
+ Delete custom commands
+
+
+ The selected custom commands will be deleted permanently.
+
Restart
@@ -2871,11 +2880,11 @@ is disabled!
The selected port profile will be deleted permanently.
-
- Select port profile
+
+ Delete port profiles
-
- Open port profiles...
+
+ The selected port profiles will be deleted permanently.Show SplashScreen on start
@@ -5071,4 +5080,67 @@ Do you want to start the scan?
If it resolves, it exists. If it doesn't, clear the cache.
+
+ Rescan
+
+
+ Rescan host
+
+
+ {0} Closed
+
+
+ {0} Down
+
+
+ {0} Open
+
+
+ {0} Paused
+
+
+ {0} Up
+
+
+ {0} Timed out
+
+
+ Only IPv4 addresses are scanned for NetBIOS, NetBIOS doesn't exist over IPv6.
+
+
+ Adjust threads automatically
+
+
+ Adjusts the number of hosts and ports scanned at the same time to the scan: up to 512 connections at the same time, at most 128 hosts and 128 ports per host (e.g. 4 hosts with 1-1024, 128 hosts with one port). Disable it to set the threads yourself, e.g. to slow down a scan.
+
+
+ Host threads ร port threads can be at most 8,192 connections at the same time (every connection uses a local port, this is half of them). Many connections at the same time may be blocked by a firewall or overload small routers.
+
+
+ Enter valid ports, port ranges (1 - 65535) and/or port profiles!
+
+
+ The name must not contain ";" and must not be a port or port range.
+
+
+ A port profile of this profile was not found, the ports saved with the profile are used. Port profiles are saved in the settings and the profile refers to them by an internal ID - if the profile file was shared from another computer, create the port profile and then select it in the profile again (edit the profile).
+
+
+ Ping hosts before the scan
+
+
+ Only hosts that answer a ping (ICMP echo, with the timeout and attempts above) are scanned. Faster for ranges with many unused IP addresses. Hosts that block ICMP (e.g. Windows with the firewall enabled) are not scanned.
+
+
+ {0} host(s) did not answer the ping and were not scanned.
+
+
+ Enter valid DNS servers (IP address or hostname, optional port) and/or names of DNS servers!
+
+
+ The name must not contain ";".
+
+
+ A DNS server of this profile was not found, the DNS servers saved with the profile are used. DNS servers are saved in the settings and the profile refers to them by an internal ID - if the profile file was shared from another computer, create the DNS server and then select it in the profile again (edit the profile).
+
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/ApplicationManager.cs b/Source/NETworkManager.Models/ApplicationManager.cs
index 8e7220793b..0e76c60306 100644
--- a/Source/NETworkManager.Models/ApplicationManager.cs
+++ b/Source/NETworkManager.Models/ApplicationManager.cs
@@ -1,4 +1,5 @@
๏ปฟusing MahApps.Metro.IconPacks;
+using NETworkManager.Utilities;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -32,6 +33,25 @@ public static IEnumerable GetDefaultList()
#pragma warning restore CS0618
}
+ ///
+ /// Gets the host to open in an application (e.g. the hostname of an IP Scanner result or the target of the run
+ /// command): for an application that resolves it via Windows (Remote Desktop, PuTTY, TigerVNC, PowerShell)
+ /// without the trailing dot (see ). The other applications resolve
+ /// it via NETworkManager and keep the dot of a single-label name (see ).
+ /// Used by NavigationManager.Open for every request.
+ ///
+ /// Application to open.
+ /// Hostname or IP address (or another target, e.g. a URL or IP range).
+ /// Host for the application.
+ public static string GetHostForApplication(ApplicationName name, string host)
+ {
+ // Not the Web Console: its target is a URL, created by WebConsoleUrlHelper (which also removes the dot)
+ return name is ApplicationName.RemoteDesktop or ApplicationName.PuTTY or ApplicationName.TigerVNC
+ or ApplicationName.PowerShell
+ ? DNSClientHelper.SanitizeHostname(host)
+ : host;
+ }
+
///
/// Method will return the icon based on .
///
diff --git a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs
index 07ab949f82..509d6fdc08 100644
--- a/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs
+++ b/Source/NETworkManager.Models/Export/ExportManager.IPScannerHostInfo.cs
@@ -66,7 +66,9 @@ private static void CreateCsv(IEnumerable collection, string
$"NetBIOSMACAddress," +
$"NetBIOSVendor," +
$"{nameof(IPScannerHostInfo.MACAddress)}," +
- $"{nameof(IPScannerHostInfo.Vendor)}"
+ $"{nameof(IPScannerHostInfo.Vendor)}," +
+ // Added later - last column, so the position of the other columns doesn't change
+ $"{nameof(IPScannerHostInfo.DNSHostname)}"
);
foreach (var info in collection)
@@ -75,6 +77,11 @@ private static void CreateCsv(IEnumerable collection, string
if (info?.PingInfo == null)
continue;
+ // Values of a probe that is still running (export during a scan, or a scan canceled before the probe
+ // finished) are placeholders - exported as empty values instead of a result
+ var ping = info.IsPingPending ? null : info.PingInfo;
+ var netBIOSInfo = info.IsNetBIOSPending ? null : info.NetBIOSInfo;
+
var stringBuilderPorts = new StringBuilder();
if (info.Ports != null)
@@ -88,22 +95,23 @@ private static void CreateCsv(IEnumerable collection, string
$"{info.IsReachable}," +
$"{info.PingInfo.IPAddress}," +
$"{CsvHelper.QuoteString(info.Hostname)}," +
- $"{info.PingInfo.Status}," +
- $"{DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp)}," +
- $"{Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true)}," +
- $"{info.PingInfo.TTL}," +
- $"{info.PingInfo.Bytes}," +
- $"{(info.IsAnyPortOpen ? PortState.Open : PortState.Closed)}," +
+ $"{ping?.Status}," +
+ $"{(ping == null ? null : DateTimeHelper.DateTimeToFullDateTimeString(ping.Timestamp))}," +
+ $"{(ping == null ? null : Ping.TimeToString(ping.Status, ping.Time, true))}," +
+ $"{ping?.TTL}," +
+ $"{ping?.Bytes}," +
+ $"{GetPortStatus(info)}," +
$"{CsvHelper.QuoteString(stringBuilderPorts.ToString().TrimEnd(';'))}," +
- $"{info.NetBIOSInfo?.IsReachable}," +
- $"{info.NetBIOSInfo?.IPAddress}," +
- $"{CsvHelper.QuoteString(info.NetBIOSInfo?.ComputerName)}," +
- $"{CsvHelper.QuoteString(info.NetBIOSInfo?.UserName)}," +
- $"{CsvHelper.QuoteString(info.NetBIOSInfo?.GroupName)}," +
- $"{info.NetBIOSInfo?.MACAddress}," +
- $"{CsvHelper.QuoteString(info.NetBIOSInfo?.Vendor)}," +
+ $"{netBIOSInfo?.IsReachable}," +
+ $"{netBIOSInfo?.IPAddress}," +
+ $"{CsvHelper.QuoteString(netBIOSInfo?.ComputerName)}," +
+ $"{CsvHelper.QuoteString(netBIOSInfo?.UserName)}," +
+ $"{CsvHelper.QuoteString(netBIOSInfo?.GroupName)}," +
+ $"{netBIOSInfo?.MACAddress}," +
+ $"{CsvHelper.QuoteString(netBIOSInfo?.Vendor)}," +
$"{info.MACAddress}," +
- $"{CsvHelper.QuoteString(info.Vendor)}"
+ $"{CsvHelper.QuoteString(info.Vendor)}," +
+ $"{CsvHelper.QuoteString(info.DNSHostname)}"
);
}
@@ -122,19 +130,22 @@ private static void CreateXml(IEnumerable collection, string
new XElement(nameof(IPScannerHostInfo) + "s",
from info in collection
where info?.PingInfo != null
+ // Placeholders of running probes are exported as empty values (see CreateCsv)
+ let ping = info.IsPingPending ? null : info.PingInfo
+ let netBIOSInfo = info.IsNetBIOSPending ? null : info.NetBIOSInfo
select
new XElement(nameof(IPScannerHostInfo),
new XElement(nameof(IPScannerHostInfo.IsReachable), info.IsReachable),
new XElement(nameof(PingInfo.IPAddress), info.PingInfo.IPAddress),
new XElement(nameof(IPScannerHostInfo.Hostname), info.Hostname),
- new XElement("PingStatus", info.PingInfo.Status),
+ new XElement("PingStatus", ping?.Status),
new XElement(nameof(PingInfo.Timestamp),
- DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp)),
+ ping == null ? null : DateTimeHelper.DateTimeToFullDateTimeString(ping.Timestamp)),
new XElement(nameof(PingInfo.Time),
- Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true)),
- new XElement(nameof(PingInfo.TTL), info.PingInfo.TTL),
- new XElement(nameof(PingInfo.Bytes), info.PingInfo.Bytes),
- new XElement("PortStatus", info.IsAnyPortOpen ? PortState.Open : PortState.Closed),
+ ping == null ? null : Ping.TimeToString(ping.Status, ping.Time, true)),
+ new XElement(nameof(PingInfo.TTL), ping?.TTL),
+ new XElement(nameof(PingInfo.Bytes), ping?.Bytes),
+ new XElement("PortStatus", GetPortStatus(info)),
info.Ports != null
? from port in info.Ports
select new XElement(nameof(PortInfo),
@@ -144,15 +155,17 @@ from info in collection
new XElement(nameof(PortInfo.LookupInfo.Description), port.LookupInfo.Description),
new XElement(nameof(PortInfo.State), port.State))
: Enumerable.Empty(),
- new XElement("NetBIOSIsReachable", info.NetBIOSInfo?.IsReachable),
- new XElement("NetBIOSIPAddress", info.NetBIOSInfo?.IPAddress),
- new XElement("NetBIOSComputerName", info.NetBIOSInfo?.ComputerName),
- new XElement("NetBIOSUserName", info.NetBIOSInfo?.UserName),
- new XElement("NetBIOSGroupName", info.NetBIOSInfo?.GroupName),
- new XElement("NetBIOSMACAddress", info.NetBIOSInfo?.MACAddress),
- new XElement("NetBIOSVendor", info.NetBIOSInfo?.Vendor),
+ new XElement("NetBIOSIsReachable", netBIOSInfo?.IsReachable),
+ new XElement("NetBIOSIPAddress", netBIOSInfo?.IPAddress),
+ new XElement("NetBIOSComputerName", netBIOSInfo?.ComputerName),
+ new XElement("NetBIOSUserName", netBIOSInfo?.UserName),
+ new XElement("NetBIOSGroupName", netBIOSInfo?.GroupName),
+ new XElement("NetBIOSMACAddress", netBIOSInfo?.MACAddress),
+ new XElement("NetBIOSVendor", netBIOSInfo?.Vendor),
new XElement(nameof(IPScannerHostInfo.MACAddress), info.MACAddress),
- new XElement(nameof(IPScannerHostInfo.Vendor), info.Vendor)
+ new XElement(nameof(IPScannerHostInfo.Vendor), info.Vendor),
+ // Added later - last element, so the position of the other elements doesn't change
+ new XElement(nameof(IPScannerHostInfo.DNSHostname), info.DNSHostname)
)
)
)
@@ -174,6 +187,11 @@ private static void CreateJson(IReadOnlyList collection, stri
for (var i = 0; i < validCollection.Count; i++)
{
var info = validCollection[i];
+
+ // Placeholders of running probes are exported as empty values (see CreateCsv)
+ var ping = info.IsPingPending ? null : info.PingInfo;
+ var netBIOSInfo = info.IsNetBIOSPending ? null : info.NetBIOSInfo;
+
object[] jsonDataPorts;
if (info.Ports != null && info.Ports.Count > 0)
@@ -199,21 +217,21 @@ private static void CreateJson(IReadOnlyList collection, stri
info.IsReachable,
IPAddress = info.PingInfo.IPAddress.ToString(),
info.Hostname,
- PingStatus = info.PingInfo.Status.ToString(),
- Timestamp = DateTimeHelper.DateTimeToFullDateTimeString(info.PingInfo.Timestamp),
- Time = Ping.TimeToString(info.PingInfo.Status, info.PingInfo.Time, true),
- info.PingInfo.TTL,
- info.PingInfo.Bytes,
+ PingStatus = ping?.Status.ToString(),
+ Timestamp = ping == null ? null : DateTimeHelper.DateTimeToFullDateTimeString(ping.Timestamp),
+ Time = ping == null ? null : Ping.TimeToString(ping.Status, ping.Time, true),
+ TTL = ping?.TTL,
+ Bytes = ping?.Bytes,
info.DNSHostname,
- PortStatus = info.IsAnyPortOpen ? PortState.Open.ToString() : PortState.Closed.ToString(),
+ PortStatus = GetPortStatus(info)?.ToString(),
Ports = jsonDataPorts,
- NetBIOSIsReachable = info.NetBIOSInfo?.IsReachable,
- NetBIOSIPAddress = info.NetBIOSInfo?.IPAddress?.ToString(),
- NetBIOSComputerName = info.NetBIOSInfo?.ComputerName,
- NetBIOSUserName = info.NetBIOSInfo?.UserName,
- NetBIOSGroupName = info.NetBIOSInfo?.GroupName,
- NetBIOSMACAddress = info.NetBIOSInfo?.MACAddress,
- NetBIOSVendor = info.NetBIOSInfo?.Vendor,
+ NetBIOSIsReachable = netBIOSInfo?.IsReachable,
+ NetBIOSIPAddress = netBIOSInfo?.IPAddress?.ToString(),
+ NetBIOSComputerName = netBIOSInfo?.ComputerName,
+ NetBIOSUserName = netBIOSInfo?.UserName,
+ NetBIOSGroupName = netBIOSInfo?.GroupName,
+ NetBIOSMACAddress = netBIOSInfo?.MACAddress,
+ NetBIOSVendor = netBIOSInfo?.Vendor,
info.MACAddress,
info.Vendor
};
@@ -221,4 +239,18 @@ private static void CreateJson(IReadOnlyList collection, stri
File.WriteAllText(filePath, JsonConvert.SerializeObject(jsonData, Formatting.Indented));
}
+
+ ///
+ /// Gets the port status of a host: open if any port is open, otherwise closed - null while the port scan is
+ /// still running (no result yet).
+ ///
+ /// Host to get the port status for.
+ /// Port status or null.
+ private static PortState? GetPortStatus(IPScannerHostInfo info)
+ {
+ if (info.IsPortScanPending)
+ return null;
+
+ return info.IsAnyPortOpen ? PortState.Open : PortState.Closed;
+ }
}
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Export/ExportManager.PortScannerInfo.cs b/Source/NETworkManager.Models/Export/ExportManager.PortScannerInfo.cs
index 2299851c84..3306d06d56 100644
--- a/Source/NETworkManager.Models/Export/ExportManager.PortScannerInfo.cs
+++ b/Source/NETworkManager.Models/Export/ExportManager.PortScannerInfo.cs
@@ -100,7 +100,7 @@ private static void CreateJson(IReadOnlyList collection, st
Protocol = collection[i].LookupInfo.Protocol.ToString(),
collection[i].LookupInfo.Service,
collection[i].LookupInfo.Description,
- Status = collection[i].State.ToString()
+ State = collection[i].State.ToString()
};
File.WriteAllText(filePath, JsonConvert.SerializeObject(jsonData, Formatting.Indented));
diff --git a/Source/NETworkManager.Models/Network/DNSServer.cs b/Source/NETworkManager.Models/Network/DNSServer.cs
index acbd0270ee..034ca1ea08 100644
--- a/Source/NETworkManager.Models/Network/DNSServer.cs
+++ b/Source/NETworkManager.Models/Network/DNSServer.cs
@@ -1,4 +1,8 @@
-๏ปฟusing System.Collections.Generic;
+๏ปฟusing System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using DnsClient;
namespace NETworkManager.Models.Network;
@@ -7,6 +11,182 @@ namespace NETworkManager.Models.Network;
///
public static class DNSServer
{
+ ///
+ /// Separator of the entries of a DNS server input (e.g. "Google Public DNS; 9.9.9.9").
+ ///
+ private const char EntrySeparator = NamedReferenceInput.EntrySeparator;
+
+ ///
+ /// Default port of a DNS server entered without a port.
+ ///
+ private const int DefaultPort = 53;
+
+ ///
+ /// Finds a DNS server profile by its name (case-insensitive, leading / trailing spaces are ignored).
+ ///
+ /// DNS server profiles.
+ /// Name of the DNS server profile.
+ /// DNS server profile, or null if there is none with the name.
+ public static DNSServerConnectionInfoProfile FindByName(IEnumerable profiles,
+ string name)
+ {
+ return NamedReferenceInput.FindByName(profiles, name);
+ }
+
+ ///
+ /// Gets the entries of a DNS server input that are neither the name of a DNS server profile nor a DNS server
+ /// (see ), without resolving anything.
+ ///
+ /// DNS server input.
+ /// DNS server profiles that can be used by their name.
+ ///
+ /// A hostname must contain a dot (e.g. the run command, where a single word can also be a mistyped type).
+ ///
+ /// Invalid entries, the input itself if it is empty.
+ public static List GetInvalidEntries(string input, IEnumerable profiles,
+ bool hostnameNeedsDomain = false)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return [input ?? string.Empty];
+
+ var profileList = profiles.ToList();
+
+ return
+ [
+ .. input.Split(EntrySeparator)
+ .Where(entry => FindByName(profileList, entry) == null &&
+ !TryParseServer(entry, hostnameNeedsDomain, out _))
+ .Select(entry => entry.Trim())
+ ];
+ }
+
+ ///
+ /// Resolves a DNS server input into DNS servers. Every entry (separated by ";") is the name of a DNS server
+ /// profile (e.g. "Google Public DNS", case-insensitive), which is replaced by its servers, or a DNS server (IP
+ /// address or hostname, optional port, e.g. "9.9.9.9" or "dns.example.com:5353") - e.g. "Google Public DNS;
+ /// 9.9.9.9". The Windows DNS profile in a list is replaced by the DNS servers of Windows. Every server only once.
+ ///
+ /// DNS server input.
+ /// DNS server profiles that can be used by their name.
+ ///
+ /// True if the input is only the Windows DNS profile: the DNS servers of Windows are used (no servers are
+ /// returned).
+ ///
+ /// Entries that are neither a DNS server profile nor a DNS server (skipped).
+ /// DNS servers (hostnames are not resolved).
+ public static List ResolveServers(string input,
+ IEnumerable profiles, out bool useWindowsDNSServer,
+ out List invalidEntries)
+ {
+ useWindowsDNSServer = false;
+ invalidEntries = [];
+
+ List servers = [];
+
+ if (string.IsNullOrWhiteSpace(input))
+ return servers;
+
+ var profileList = profiles.ToList();
+ var entries = input.Split(EntrySeparator);
+
+ foreach (var entry in entries)
+ {
+ var profile = FindByName(profileList, entry);
+
+ if (profile is { UseWindowsDNSServer: true })
+ {
+ // Only the Windows DNS profile: DNS lookup uses the DNS servers of Windows itself
+ if (entries.Length == 1)
+ {
+ useWindowsDNSServer = true;
+ return servers;
+ }
+
+ servers.AddRange(NameServer.ResolveNameServers(true, false).Select(x =>
+ new ServerConnectionInfo(x.Address, x.Port, TransportProtocol.Udp)));
+ }
+ else if (profile != null)
+ {
+ servers.AddRange(profile.Servers);
+ }
+ else if (TryParseServer(entry, false, out var server))
+ {
+ servers.Add(server);
+ }
+ else
+ {
+ invalidEntries.Add(entry.Trim());
+ }
+ }
+
+ return [.. servers.DistinctBy(x => (x.Server.ToLowerInvariant(), x.Port, x.TransportProtocol))];
+ }
+
+ ///
+ /// Replaces the names of DNS server profiles in a DNS server input by references to their ID (e.g. "Cloudflare;
+ /// 9.9.9.9" -> "{8f1c...}; 9.9.9.9"), so a profile still finds the DNS server profile after it was renamed. The
+ /// Windows DNS profile is kept by name (it exists on every computer, with another ID), other entries are kept.
+ ///
+ /// DNS server input (see ).
+ /// DNS server profiles.
+ /// Input with references, null if it contains no DNS server profile with an ID.
+ public static string EncodeReferences(string input, IEnumerable profiles)
+ {
+ var profileList = profiles.ToList();
+
+ // The Windows DNS profile is kept by name
+ return NamedReferenceInput.EncodeReferences(input,
+ entry => FindByName(profileList, entry) is { UseWindowsDNSServer: false } profile ? profile : null);
+ }
+
+ ///
+ /// Replaces the references in a DNS server input (see ) by the current names of
+ /// the DNS server profiles (e.g. "{8f1c...}; 9.9.9.9" -> "Cloudflare; 9.9.9.9").
+ ///
+ /// DNS server input with references.
+ /// DNS server profiles.
+ /// DNS server input with the names, null if a DNS server profile was not found.
+ /// True if every referenced DNS server profile was found.
+ public static bool TryDecodeReferences(string inputWithReferences,
+ IEnumerable profiles, out string input)
+ {
+ return NamedReferenceInput.TryDecodeReferences(inputWithReferences, profiles, out input);
+ }
+
+ ///
+ /// Replaces the names of DNS server profiles in a DNS server input by their servers (e.g. "Cloudflare; 9.9.9.9"
+ /// -> "1.1.1.1:53; 1.0.0.1:53; 9.9.9.9"), so the input still works without the DNS server profiles (e.g. on another
+ /// computer). The Windows DNS profile and other entries are kept.
+ ///
+ /// DNS server input.
+ /// DNS server profiles.
+ /// Input with servers instead of DNS server profiles.
+ public static string ReplaceProfilesByServers(string input, IEnumerable profiles)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return input;
+
+ var profileList = profiles.ToList();
+
+ return string.Join("; ", input.Split(EntrySeparator).Select(entry =>
+ {
+ var profile = FindByName(profileList, entry);
+
+ return profile == null || profile.UseWindowsDNSServer
+ ? entry.Trim()
+ : string.Join("; ", profile.Servers.Select(x => x.ToString()));
+ }));
+ }
+
+ ///
+ /// Parses a DNS server entry (IP address or hostname, optional port).
+ ///
+ private static bool TryParseServer(string entry, bool hostnameNeedsDomain, out ServerConnectionInfo server)
+ {
+ return ServerConnectionInfo.TryParse(entry, out server, DefaultPort, TransportProtocol.Udp) &&
+ (!hostnameNeedsDomain || IPAddress.TryParse(server.Server, out _) || server.Server.Contains('.'));
+ }
+
///
/// Gets the default list of common dns servers.
///
@@ -15,7 +195,7 @@ public static List GetDefaultList()
{
return
[
- new(), // Windows DNS server
+ new() { Id = Guid.NewGuid() }, // Windows DNS server
new("Cloudflare",
[
new("1.1.1.1", 53, TransportProtocol.Udp),
diff --git a/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs b/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs
index ccc9209aae..956f1db248 100644
--- a/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs
+++ b/Source/NETworkManager.Models/Network/DNSServerConnectionInfoProfile.cs
@@ -1,11 +1,12 @@
-๏ปฟusing System.Collections.Generic;
+๏ปฟusing System;
+using System.Collections.Generic;
namespace NETworkManager.Models.Network;
///
/// Class is used to store informations about a DNS server profile.
///
-public class DNSServerConnectionInfoProfile : ServerConnectionInfoProfile
+public class DNSServerConnectionInfoProfile : ServerConnectionInfoProfile, INamedReference
{
///
/// Create an instance of . This will use the
@@ -24,9 +25,16 @@ public DNSServerConnectionInfoProfile()
/// List of servers as .
public DNSServerConnectionInfoProfile(string name, List servers) : base(name, servers)
{
-
+ Id = Guid.NewGuid();
}
+ ///
+ /// Internal ID of the DNS server profile - profiles reference a DNS server profile by it, so a renamed DNS
+ /// server profile is still found. Kept when the DNS server profile is edited. The Windows DNS profile is
+ /// referenced by its name (it exists on every computer, with another ID).
+ ///
+ public Guid Id { get; set; }
+
///
/// Use the DNS server from Windows.
///
diff --git a/Source/NETworkManager.Models/Network/HostRangeHelper.cs b/Source/NETworkManager.Models/Network/HostRangeHelper.cs
index fe1889fc9a..d32413b5bc 100644
--- a/Source/NETworkManager.Models/Network/HostRangeHelper.cs
+++ b/Source/NETworkManager.Models/Network/HostRangeHelper.cs
@@ -198,10 +198,16 @@ private static long GetIPv4RangeCount(IPAddress start, IPAddress end)
///
/// Inputs like "10.0.0.1", "example.com", "10.0.0.0/24" (see ).
/// Prefer the A record over the AAAA record for hostnames.
+ ///
+ /// Keep one entry per hostname for an IP address entered with different hostnames (e.g. the Ping Monitor shows
+ /// the hostname of the input). Otherwise every IP address is returned once, with the first hostname (e.g. the
+ /// IP Scanner and the Port Scanner resolve the hostname themselves, a second entry would be scanned again).
+ ///
/// Token to monitor for cancellation requests.
/// Sorted, distinct list of hosts and the hostnames that could not be resolved.
public static async Task<(List<(IPAddress ipAddress, string hostname)> hosts, List hostnamesNotResolved)>
- ResolveAsync(IEnumerable hosts, bool dnsResolveHostnamePreferIPv4, CancellationToken cancellationToken)
+ ResolveAsync(IEnumerable hosts, bool dnsResolveHostnamePreferIPv4, bool keepAllHostnames,
+ CancellationToken cancellationToken)
{
var inputs = hosts.ToList();
@@ -300,20 +306,20 @@ await Parallel.ForEachAsync(Enumerable.Range(0, inputs.Count),
}
}).ConfigureAwait(false);
- return (SortAndRemoveDuplicates(hostsPerInput),
+ return (SortAndRemoveDuplicates(hostsPerInput, keepAllHostnames),
[.. hostnameNotResolvedPerInput.Where(x => x != null)]);
}
///
/// Merges the hosts of all inputs into one list sorted by IP address, with every IP address only once
/// (e.g. overlapping ranges). If the same IP address was entered with different hostnames (e.g. two names of
- /// one server), every hostname is kept once; an entry without hostname is only kept if there is no entry
- /// with a hostname. Entries with the same IP address keep the input order (stable sort), so the result is
+ /// one server), every hostname is kept once with , otherwise only the
+ /// first one; an entry without hostname is only kept if there is no entry with a hostname. Entries with the same IP address keep the input order (stable sort), so the result is
/// the same on every run. IPv6 addresses with a different scope id (e.g. "fe80::1%3" and "fe80::1%7",
/// two devices on different interfaces) are different hosts.
///
private static List<(IPAddress ipAddress, string hostname)> SortAndRemoveDuplicates(
- List<(IPAddress ipAddress, string hostname)>[] hostsPerInput)
+ List<(IPAddress ipAddress, string hostname)>[] hostsPerInput, bool keepAllHostnames)
{
var hosts = hostsPerInput.Length == 1 ? hostsPerInput[0] : [.. hostsPerInput.SelectMany(x => x)];
@@ -353,7 +359,7 @@ await Parallel.ForEachAsync(Enumerable.Range(0, inputs.Count),
.ToList();
if (entriesWithHostname.Count > 0)
- distinctHosts.AddRange(entriesWithHostname);
+ distinctHosts.AddRange(keepAllHostnames ? entriesWithHostname : [entriesWithHostname[0]]);
else
distinctHosts.Add(sortedHosts[start]);
diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs
index 7e1456f437..f62f11742b 100644
--- a/Source/NETworkManager.Models/Network/IPScanner.cs
+++ b/Source/NETworkManager.Models/Network/IPScanner.cs
@@ -37,6 +37,12 @@ public sealed class IPScanner(IPScannerOptions options)
private Dictionary _neighborTable = [];
private DateTime _neighborTableTimestamp = DateTime.MinValue;
+ ///
+ /// Gets the number of hosts finished so far (all probes and lookups). Thread-safe; may be read from
+ /// any thread while the scan is running.
+ ///
+ public int HostsScanned => Volatile.Read(ref _progressValue);
+
///
/// Gets the number of hosts found to be reachable so far. Thread-safe; may be read from
/// any thread while the scan is running.
@@ -54,18 +60,31 @@ public sealed class IPScanner(IPScannerOptions options)
#region Events
///
- /// Occurs when a host has been scanned.
+ /// Occurs when a host has been found (reachable, or unreachable if unreachable hosts are shown). Further
+ /// results of the host arrive via .
///
- public event EventHandler HostScanned;
+ public event EventHandler HostFound;
- private void OnHostScanned(IPScannerHostScannedArgs e)
+ private void OnHostFound(IPScannerHostFoundArgs e)
{
- HostScanned?.Invoke(this, e);
+ HostFound?.Invoke(this, e);
+ }
+
+ ///
+ /// Occurs when a result of a found host arrived (e.g. DNS hostname, MAC address). Never raised for a host
+ /// before its and never after .
+ ///
+ public event EventHandler HostUpdated;
+
+ private void OnHostUpdated(IPScannerHostUpdatedArgs e)
+ {
+ HostUpdated?.Invoke(this, e);
}
///
/// Occurs when the scan is complete. Raised exactly once per scan, also when the scan was canceled
- /// or failed (see ).
+ /// or failed (see ) - only after every host is finished, including all
+ /// lookups (no / afterwards).
///
public event EventHandler ScanComplete;
@@ -123,12 +142,13 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
};
// Spread the start of the hosts over time instead of starting all at once - based on the time
- // an unreachable host takes (the slowest of ping attempts, port scan and NetBIOS)
- var hostDuration = Math.Max(options.ICMPAttempts * options.ICMPTimeout,
+ // an unreachable host takes (the slowest of ping attempts, port scan and NetBIOS). In long, the port
+ // scan can exceed int (e.g. 15000 ms x 10 attempts x 65535 ports with one port thread)
+ var hostDuration = Math.Max((long)options.ICMPAttempts * options.ICMPTimeout,
Math.Max(
options.PortScanEnabled
- ? options.PortScanTimeout * options.PortScanAttempts *
- (int)Math.Ceiling(options.PortScanPorts.Length / (double)options.MaxPortThreads)
+ ? (long)options.PortScanTimeout * options.PortScanAttempts *
+ (long)Math.Ceiling(options.PortScanPorts.Length / (double)options.MaxPortThreads)
: 0,
options.NetBIOSEnabled ? options.NetBIOSTimeout : 0));
@@ -145,126 +165,15 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) =>
// Spread the start of the hosts over time instead of starting all at once
await hostStartScheduler.WaitAsync(ct).ConfigureAwait(false);
- // Start ping, port scan and netbios lookup concurrently - none of these block a thread anymore
- var pingTask = PingAsync(host.ipAddress, ct);
-
- var portScanTask = options.PortScanEnabled
- ? PortScanAsync(host.ipAddress, portScanParallelOptions, ct)
- : Task.FromResult(new List());
-
- var netbiosTask = options.NetBIOSEnabled
- ? NetBIOSResolver.ResolveAsync(host.ipAddress, options.NetBIOSTimeout, ct)
- : Task.FromResult(new NetBIOSInfo(host.ipAddress));
-
- await Task.WhenAll(pingTask, portScanTask, netbiosTask).ConfigureAwait(false);
-
- // A reachable host is in the neighbor table (ARP/NDP) from now on (before it can answer, the
- // local system has to resolve its MAC address)
- var discoveredAt = DateTime.UtcNow;
-
- var pingInfo = pingTask.Result;
- var portScanResults = portScanTask.Result;
- var netBIOSInfo = netbiosTask.Result;
-
- // Cancel if the user has canceled
- ct.ThrowIfCancellationRequested();
-
- // Check if host is up
- var isAnyPortOpen = portScanResults.Any(x => x.State == PortState.Open);
- var isReachable = pingInfo.Status == IPStatus.Success || // ICMP response
- isAnyPortOpen || // Any port is open
- portScanResults.Any(x => x.State == PortState.Closed) || // Connection refused (TCP reset)
- netBIOSInfo.IsReachable; // NetBIOS response
-
- // Count reachable/unreachable hosts unconditionally, since ShowAllResults
- // (below) may prevent unreachable hosts from ever reaching HostScanned
- if (isReachable)
- _hostsUp.Increment();
- else
- _hostsDown.Increment();
-
- // DNS & ARP
- if (isReachable || options.ShowAllResults)
- {
- // DNS
- var dnsHostname = string.Empty;
-
- if (options.ResolveHostname)
- {
- var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(host.ipAddress)
- .WaitAsync(ct).ConfigureAwait(false);
-
- if (!dnsResult.HasError)
- dnsHostname = dnsResult.Value;
- }
-
- // ARP
- var arpMACAddress = string.Empty;
- var arpVendor = string.Empty;
-
- if (options.ResolveMACAddress)
- {
- // Get info from neighbor table. Only hosts in a local subnet are in it (for any other
- // host it contains the router), an unreachable host never needs a new snapshot.
- if (IsInLocalSubnet(host.ipAddress, networkInterfaces))
- arpMACAddress = await GetMACAddressFromNeighborTableAsync(host.ipAddress,
- isReachable ? discoveredAt : DateTime.MinValue, ct).ConfigureAwait(false);
-
- // Check if it is the local mac
- if (string.IsNullOrEmpty(arpMACAddress))
- {
- var ipAddress = IPAddressHelper.WithoutScopeId(host.ipAddress);
-
- var networkInterfaceInfo = networkInterfaces.FirstOrDefault(p =>
- p.IPv4Address.Any(x => x.Item1.Equals(ipAddress)) ||
- p.IPv6Address.Concat(p.IPv6AddressLinkLocal)
- .Any(x => IPAddressHelper.WithoutScopeId(x).Equals(ipAddress)));
-
- if (networkInterfaceInfo != null)
- arpMACAddress = networkInterfaceInfo.PhysicalAddress.ToString();
- }
-
- // Vendor lookup & default format
- if (!string.IsNullOrEmpty(arpMACAddress))
- {
- var info = OUILookup.LookupByMacAddress(arpMACAddress).FirstOrDefault();
-
- if (info != null)
- arpVendor = info.Vendor;
-
- // Apply default format
- arpMACAddress = MACAddressHelper.GetDefaultFormat(arpMACAddress);
- }
- }
-
- OnHostScanned(new IPScannerHostScannedArgs(
- new IPScannerHostInfo(
- isReachable,
- pingInfo,
- // DNS is default, fallback to netbios
- !string.IsNullOrEmpty(dnsHostname)
- ? dnsHostname
- : netBIOSInfo?.ComputerName ?? string.Empty,
- dnsHostname,
- isAnyPortOpen,
- portScanResults
- .Where(x => x.State == PortState.Open || options.ShowAllResults)
- .OrderBy(x => x.Port).ToList(),
- netBIOSInfo,
- // ARP/NDP is preferred, fallback to NetBIOS
- !string.IsNullOrEmpty(arpMACAddress)
- ? arpMACAddress
- : netBIOSInfo?.MACAddress ?? string.Empty,
- !string.IsNullOrEmpty(arpMACAddress)
- ? arpVendor
- : netBIOSInfo?.Vendor ?? string.Empty
- )
- )
- );
- }
+ // Returns only when every probe and lookup of the host is finished
+ await ScanHostAsync(host.ipAddress, portScanParallelOptions, networkInterfaces, ct)
+ .ConfigureAwait(false);
IncreaseProgress();
}).ConfigureAwait(false);
+
+ // Parallel.ForEachAsync completes (also on cancellation or an exception) only after every host body
+ // has returned, so nothing is still running when ScanComplete is raised below
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -281,6 +190,305 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) =>
});
}
+ ///
+ /// Scans a host: ping, port scan and NetBIOS request run concurrently (none of these block a thread). The host
+ /// is raised in as soon as it answers the first probe (ping reply, open or refused
+ /// port, NetBIOS answer) with placeholders for the probes that are still running - their results follow as
+ /// , and the DNS hostname and MAC address are resolved right away
+ /// (). An unreachable host is raised when all probes are finished, if unreachable
+ /// hosts are shown. Returns only when every probe and lookup of the host is finished, also on cancellation or
+ /// an error.
+ ///
+ /// IP address of the host.
+ /// Options for the concurrent port probes of the host.
+ /// Local network interfaces (for the MAC address lookup).
+ /// Token to monitor for cancellation requests.
+ private async Task ScanHostAsync(IPAddress ipAddress, ParallelOptions portScanParallelOptions,
+ List networkInterfaces, CancellationToken cancellationToken)
+ {
+ // Completed by the port scan when the first port answers (open or refused), so the host is found before
+ // all ports are finished
+ var portAnswered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var pingTask = PingProbe.PingAsync(ipAddress, options.ICMPTimeout, options.ICMPAttempts,
+ options.ICMPBuffer, cancellationToken);
+
+ var portScanTask = options.PortScanEnabled
+ ? PortScanAsync(ipAddress, portScanParallelOptions, () => portAnswered.TrySetResult(), cancellationToken)
+ : Task.FromResult(new List());
+
+ // NetBIOS only exists over IPv4 - an IPv6 host would never answer and wait for the timeout
+ var netBIOSTask = options.NetBIOSEnabled && ipAddress.AddressFamily == AddressFamily.InterNetwork
+ ? NetBIOSResolver.ResolveAsync(ipAddress, options.NetBIOSTimeout, cancellationToken)
+ : Task.FromResult(new NetBIOSInfo(ipAddress));
+
+ var enrichTask = Task.CompletedTask;
+
+ // Results of the finished probes, null while a probe is running
+ PingInfo pingInfo = null;
+ List ports = null;
+ NetBIOSInfo netBIOSInfo = null;
+
+ IPScannerHostInfo hostInfo = null;
+
+ try
+ {
+ List pending = [pingTask, portScanTask, netBIOSTask, portAnswered.Task];
+
+ while (pending.Count > 0)
+ {
+ var completed = await Task.WhenAny(pending).ConfigureAwait(false);
+ pending.Remove(completed);
+
+ // Rethrow the cancellation / error of the probe
+ await completed.ConfigureAwait(false);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ bool isAnswer;
+ Action update = null;
+
+ if (completed == pingTask)
+ {
+ var value = pingInfo = pingTask.Result;
+
+ isAnswer = value.Status == IPStatus.Success; // ICMP response
+ update = x =>
+ {
+ x.PingInfo = value;
+ x.IsPingPending = false;
+ };
+ }
+ else if (completed == portScanTask)
+ {
+ ports = portScanTask.Result;
+
+ // If no port answered, the signal never completes
+ pending.Remove(portAnswered.Task);
+
+ // Any port is open or the connection was refused (TCP reset)
+ isAnswer = ports.Any(x => x.State is PortState.Open or PortState.Closed);
+
+ var shownPorts = GetShownPorts(ports);
+ var isAnyPortOpen = ports.Any(x => x.State == PortState.Open);
+
+ update = x =>
+ {
+ x.Ports = shownPorts;
+ x.IsAnyPortOpen = isAnyPortOpen;
+ x.IsPortScanPending = false;
+ };
+ }
+ else if (completed == netBIOSTask)
+ {
+ var value = netBIOSInfo = netBIOSTask.Result;
+
+ isAnswer = value.IsReachable; // NetBIOS response
+ update = x =>
+ {
+ x.NetBIOSInfo = value;
+ x.IsNetBIOSPending = false;
+ };
+ }
+ else
+ {
+ // A port answered - its result follows with the port scan
+ isAnswer = true;
+ }
+
+ if (hostInfo != null)
+ {
+ if (update != null)
+ OnHostUpdated(new IPScannerHostUpdatedArgs(hostInfo, update));
+
+ continue;
+ }
+
+ if (!isAnswer)
+ continue;
+
+ // First answer: the host is up. Before it could answer, the local system had to resolve its MAC
+ // address, so it is in the neighbor table (ARP/NDP) from now on.
+ hostInfo = CreateHostInfo(ipAddress, true, pingInfo, ports, netBIOSInfo);
+
+ _hostsUp.Increment();
+
+ OnHostFound(new IPScannerHostFoundArgs(hostInfo));
+ OnProgressChanged();
+
+ enrichTask = EnrichAsync(ipAddress, hostInfo, DateTime.UtcNow, networkInterfaces, cancellationToken);
+ }
+
+ if (hostInfo == null)
+ {
+ // Count unreachable hosts unconditionally, since ShowAllResults may prevent them from ever
+ // reaching HostFound
+ _hostsDown.Increment();
+
+ if (options.ShowAllResults || options.ReportAllHosts)
+ {
+ hostInfo = CreateHostInfo(ipAddress, false, pingInfo, ports, netBIOSInfo);
+
+ OnHostFound(new IPScannerHostFoundArgs(hostInfo));
+
+ // An unreachable host doesn't need a new snapshot of the neighbor table - the table is only read
+ // if it was not read yet (e.g. a host that blocks ICMP still has an ARP entry from the ping)
+ enrichTask = EnrichAsync(ipAddress, hostInfo, DateTime.MinValue, networkInterfaces,
+ cancellationToken);
+ }
+ }
+
+ await enrichTask.ConfigureAwait(false);
+ }
+ finally
+ {
+ // Never return while a probe or lookup is still running (e.g. another probe was canceled or failed)
+ await WaitIgnoringErrorsAsync(pingTask, portScanTask, netBIOSTask, enrichTask).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Waits until all tasks are finished. Their errors are ignored - the caller already handles (or rethrows)
+ /// the first one.
+ ///
+ /// Tasks to wait for.
+ private static async Task WaitIgnoringErrorsAsync(params Task[] tasks)
+ {
+ try
+ {
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ // Handled by the caller (see summary)
+ }
+ }
+
+ ///
+ /// Gets the ports shown in the result: open ports, all ports if unreachable ones are shown.
+ ///
+ /// All probed ports.
+ /// Ports to show, sorted by port.
+ private List GetShownPorts(List ports)
+ {
+ return ports
+ .Where(x => x.State == PortState.Open || options.ShowAllResults)
+ .OrderBy(x => x.Port).ToList();
+ }
+
+ ///
+ /// Creates the result of a host with the results of the finished probes and placeholders for the running ones
+ /// (marked as pending).
+ ///
+ /// IP address of the host.
+ /// Indicates whether the host answered a probe.
+ /// Result of the ping, null if it is still running.
+ /// All probed ports, null if the port scan is still running.
+ /// Result of the NetBIOS request, null if it is still running.
+ /// Result of the host.
+ private IPScannerHostInfo CreateHostInfo(IPAddress ipAddress, bool isReachable, PingInfo pingInfo,
+ List ports, NetBIOSInfo netBIOSInfo)
+ {
+ return new IPScannerHostInfo(
+ ipAddress,
+ isReachable,
+ pingInfo ?? new PingInfo(DateTime.Now, ipAddress, IPStatus.Unknown),
+ ports?.Any(x => x.State == PortState.Open) ?? false,
+ ports != null ? GetShownPorts(ports) : [],
+ netBIOSInfo ?? new NetBIOSInfo(ipAddress))
+ {
+ IsPingPending = pingInfo == null,
+ IsPortScanPending = ports == null,
+ IsNetBIOSPending = netBIOSInfo == null
+ };
+ }
+
+ ///
+ /// Enriches a found host with the DNS hostname (PTR), MAC address and vendor. Each result is raised as
+ /// as soon as it arrives; the task completes only when all lookups are finished, so
+ /// the host (and the scan) is never finished while a lookup is still running.
+ ///
+ /// IP address of the host.
+ /// Result of the host, already raised in .
+ /// Time the host answered, if it is unreachable.
+ /// Local network interfaces (for the MAC address lookup).
+ /// Token to monitor for cancellation requests.
+ private async Task EnrichAsync(IPAddress ipAddress, IPScannerHostInfo host, DateTime discoveredAt,
+ List networkInterfaces, CancellationToken cancellationToken)
+ {
+ async Task UpdateHostnameAsync()
+ {
+ var dnsHostname = await DNSClient.GetInstance().ResolvePtrOrEmptyAsync(ipAddress, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!string.IsNullOrEmpty(dnsHostname))
+ OnHostUpdated(new IPScannerHostUpdatedArgs(host, x => x.DNSHostname = dnsHostname));
+ }
+
+ async Task UpdateMACAddressAsync()
+ {
+ var (macAddress, vendor) = await ResolveMACAddressAsync(ipAddress, discoveredAt, networkInterfaces,
+ cancellationToken).ConfigureAwait(false);
+
+ if (!string.IsNullOrEmpty(macAddress))
+ OnHostUpdated(new IPScannerHostUpdatedArgs(host, x =>
+ {
+ x.NeighborMACAddress = macAddress;
+ x.NeighborVendor = vendor;
+ }));
+ }
+
+ // DNS and MAC address lookup don't depend on each other - run them concurrently, the host is finished
+ // after the slower one instead of after both
+ await Task.WhenAll(
+ options.ResolveHostname ? UpdateHostnameAsync() : Task.CompletedTask,
+ options.ResolveMACAddress ? UpdateMACAddressAsync() : Task.CompletedTask).ConfigureAwait(false);
+ }
+
+ ///
+ /// Resolves the MAC address of a host from the neighbor table (ARP/NDP) or the local network interfaces,
+ /// and its vendor.
+ ///
+ /// IP address of the host.
+ /// Time the host answered, if it is unreachable.
+ /// Local network interfaces.
+ /// Token to monitor for cancellation requests.
+ /// MAC address (default format) and vendor, empty strings if not found.
+ private async Task<(string macAddress, string vendor)> ResolveMACAddressAsync(IPAddress ipAddress,
+ DateTime discoveredAt, List networkInterfaces, CancellationToken cancellationToken)
+ {
+ var macAddress = string.Empty;
+
+ // Get info from neighbor table. Only hosts in a local subnet are in it (for any other
+ // host it contains the router), an unreachable host only reads it if it was not read yet.
+ if (IsInLocalSubnet(ipAddress, networkInterfaces))
+ macAddress = await GetMACAddressFromNeighborTableAsync(ipAddress, discoveredAt, cancellationToken)
+ .ConfigureAwait(false);
+
+ // Check if it is the local mac
+ if (string.IsNullOrEmpty(macAddress))
+ {
+ var ipAddressWithoutScopeId = IPAddressHelper.WithoutScopeId(ipAddress);
+
+ var networkInterfaceInfo = networkInterfaces.FirstOrDefault(p =>
+ p.IPv4Address.Any(x => x.Item1.Equals(ipAddressWithoutScopeId)) ||
+ p.IPv6Address.Concat(p.IPv6AddressLinkLocal)
+ .Any(x => IPAddressHelper.WithoutScopeId(x).Equals(ipAddressWithoutScopeId)));
+
+ // Only a MAC address (6 bytes) - e.g. a tunnel adapter has none or a link-layer address of another length
+ if (networkInterfaceInfo?.PhysicalAddress?.GetAddressBytes().Length == 6)
+ macAddress = networkInterfaceInfo.PhysicalAddress.ToString();
+ }
+
+ if (string.IsNullOrEmpty(macAddress))
+ return (string.Empty, string.Empty);
+
+ // Vendor lookup & default format
+ var vendor = OUILookup.LookupByMacAddress(macAddress).FirstOrDefault()?.Vendor ?? string.Empty;
+
+ return (MACAddressHelper.GetDefaultFormat(macAddress), vendor);
+ }
+
///
/// Gets the MAC address of from the neighbor table (ARP / NDP). The table is
/// read once and cached for the scan instead of reading it for every host. It is only read again on a miss
@@ -289,20 +497,23 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) =>
/// that finish at the same time share one read.
///
/// IP address of the host (in a local subnet, see ).
- /// Time the host answered, to use the cache only.
+ ///
+ /// Time the host answered, to use the cache - or to read the table if it was not
+ /// read yet.
+ ///
/// Token to monitor for cancellation requests.
/// MAC address or an empty string.
private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddress, DateTime discoveredAt,
CancellationToken cancellationToken)
{
- var key = IPAddressHelper.WithoutScopeId(ipAddress);
-
// Hosts wait without blocking a thread while another host reads the table
await _neighborTableSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- if (_neighborTable.TryGetValue(key, out var macAddress))
+ // With the scope id if it was entered (e.g. "fe80::1%3" and "fe80::1%7" are two hosts on different
+ // interfaces), see NeighborTable.GetMACAddressMap
+ if (_neighborTable.TryGetValue(ipAddress, out var macAddress))
return macAddress;
// Already read after the host answered -> no entry
@@ -313,7 +524,7 @@ private async Task GetMACAddressFromNeighborTableAsync(IPAddress ipAddre
_neighborTableTimestamp = DateTime.UtcNow;
_neighborTable = NeighborTable.GetMACAddressMap();
- return _neighborTable.GetValueOrDefault(key, string.Empty);
+ return _neighborTable.GetValueOrDefault(ipAddress, string.Empty);
}
finally
{
@@ -357,72 +568,16 @@ private static bool IsInLocalSubnet(IPAddress ipAddress, IEnumerable PingAsync(IPAddress ipAddress, CancellationToken cancellationToken)
- {
- using var ping = new System.Net.NetworkInformation.Ping();
-
- PingInfo pingInfo = null;
-
- for (var i = 0; i < options.ICMPAttempts; i++)
- {
- // Get timestamp
- var timestamp = DateTime.Now;
-
- try
- {
- // Note: the CancellationToken-accepting overload requires a TimeSpan timeout,
- // unlike the legacy int-based overloads used elsewhere in .NET's Ping API.
- var pingReply = await ping.SendPingAsync(ipAddress, TimeSpan.FromMilliseconds(options.ICMPTimeout),
- options.ICMPBuffer, cancellationToken: cancellationToken).ConfigureAwait(false);
-
- // Success
- if (pingReply is { Status: IPStatus.Success })
- {
- switch (ipAddress.AddressFamily)
- {
- case AddressFamily.InterNetwork:
- return new PingInfo(
- timestamp,
- pingReply.Address,
- pingReply.Buffer.Length,
- pingReply.RoundtripTime,
- pingReply.Options!.Ttl,
- pingReply.Status);
- case AddressFamily.InterNetworkV6:
- return new PingInfo(
- timestamp,
- pingReply.Address,
- pingReply.Buffer.Length,
- pingReply.RoundtripTime,
- pingReply.Status);
- }
- }
-
- // Failed
- pingInfo = new PingInfo(timestamp, ipAddress, pingReply.Status);
-
- // Only try again if the request timed out (e.g. a lost packet). Other states like
- // "destination host unreachable" are a definitive answer.
- if (pingReply.Status != IPStatus.TimedOut)
- break;
- }
- catch (PingException)
- {
- // Ping failed with unknown status
- return new PingInfo(timestamp, ipAddress, IPStatus.Unknown);
- }
- }
-
- // Result of the last attempt (falls back to unknown status if there was no attempt)
- return pingInfo ?? new PingInfo(DateTime.Now, ipAddress, IPStatus.Unknown);
- }
-
///
/// Probes all configured ports of a host. Returns every port (also closed / timed out ones), because
/// a closed port is used to detect a reachable host as well - the caller filters what is shown.
///
+ /// IP address of the host.
+ /// Options for the concurrent port probes.
+ /// Called when a port answered (open or refused), may be called multiple times.
+ /// Token to monitor for cancellation requests.
private async Task> PortScanAsync(IPAddress ipAddress, ParallelOptions parallelOptions,
- CancellationToken cancellationToken)
+ Action onAnswer, CancellationToken cancellationToken)
{
ConcurrentBag results = [];
@@ -433,6 +588,9 @@ await Parallel.ForEachAsync(options.PortScanPorts, parallelOptions, async (port,
.ConfigureAwait(false);
results.Add(new PortInfo(port, PortLookup.LookupByPortAndProtocol(port), portState));
+
+ if (portState is PortState.Open or PortState.Closed)
+ onAnswer();
}).ConfigureAwait(false);
return results.ToList();
diff --git a/Source/NETworkManager.Models/Network/IPScannerCustomCommand.cs b/Source/NETworkManager.Models/Network/IPScannerCustomCommand.cs
index 7ab60cd08b..d618618a70 100644
--- a/Source/NETworkManager.Models/Network/IPScannerCustomCommand.cs
+++ b/Source/NETworkManager.Models/Network/IPScannerCustomCommand.cs
@@ -13,7 +13,11 @@ public static List GetDefaultList()
new(Guid.NewGuid(), "Edge", "cmd.exe", @"/c start microsoft-edge:http://{{IPAddress}}/"),
new(Guid.NewGuid(), "Edge (https)", "cmd.exe",
@"/c start microsoft-edge:https://{{IPAddress}}/"),
- new(Guid.NewGuid(), "Windows Explorer (c$)", "explorer.exe", @"\\{{IPAddress}}\c$")
+ new(Guid.NewGuid(), "Windows Explorer (c$)", "explorer.exe", @"\\{{IPAddress}}\c$"),
+ new(Guid.NewGuid(), "Windows Explorer (shares)", "explorer.exe", @"\\{{IPAddress}}"),
+ new(Guid.NewGuid(), "Computer Management", "mmc.exe", @"compmgmt.msc /computer:\\{{IPAddress}}"),
+ new(Guid.NewGuid(), "Event Viewer", "eventvwr.exe", @"\\{{IPAddress}}"),
+ new(Guid.NewGuid(), "System Information", "msinfo32.exe", "/computer {{IPAddress}}")
};
}
}
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Network/IPScannerHostFoundArgs.cs b/Source/NETworkManager.Models/Network/IPScannerHostFoundArgs.cs
new file mode 100644
index 0000000000..f1245a5bee
--- /dev/null
+++ b/Source/NETworkManager.Models/Network/IPScannerHostFoundArgs.cs
@@ -0,0 +1,15 @@
+๏ปฟusing System;
+
+namespace NETworkManager.Models.Network;
+
+///
+/// Contains a host found by the .
+///
+/// IP Scanner host information. Further results arrive via .
+public class IPScannerHostFoundArgs(IPScannerHostInfo host) : EventArgs
+{
+ ///
+ /// IP Scanner host information.
+ ///
+ public IPScannerHostInfo Host { get; } = host;
+}
diff --git a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs
index 4e579d77c0..a2b929ee5e 100644
--- a/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs
+++ b/Source/NETworkManager.Models/Network/IPScannerHostInfo.cs
@@ -1,81 +1,285 @@
๏ปฟusing System.Collections.Generic;
+using System.Net;
+using NETworkManager.Utilities;
namespace NETworkManager.Models.Network;
///
-/// Class containing information about a host in a .
+/// Class containing information about a host in a . Created by the scanner when a host
+/// answered the first probe and updated afterwards, as further results arrive (see
+/// ): values of probes that are still running are placeholders, marked by the
+/// Is...Pending properties. Values are only changed through the updates of the scanner, which the consumer
+/// applies on the UI thread.
///
-public class IPScannerHostInfo
+public class IPScannerHostInfo : PropertyChangedBase
{
///
- /// Creates a new instance of with the specified parameters.
+ /// Creates a new instance of with the results of the discovery.
///
+ /// Scanned IP address.
/// Indicates whether the host is reachable.
/// Information about the ping to the host.
- /// Hostname of the host (dns or netbios).
- /// DNS hostname of the host.
/// Indicates whether any port is open.
- /// List of open ports.
+ /// List of ports to show (open ports, all ports if unreachable ones are shown).
/// NetBIOS information about the host.
- /// MAC address of the host (ARP/NDP preferred, NetBIOS as fallback).
- /// Vendor of the host based on the MAC address.
- public IPScannerHostInfo(bool isReachable, PingInfo pingInfo, string hostname, string dnsHostname,
- bool isAnyPortOpen, List ports,
- NetBIOSInfo netBIOSInfo, string macAddress, string vendor)
+ public IPScannerHostInfo(IPAddress ipAddress, bool isReachable, PingInfo pingInfo, bool isAnyPortOpen,
+ List ports, NetBIOSInfo netBIOSInfo)
{
+ IPAddress = ipAddress;
IsReachable = isReachable;
PingInfo = pingInfo;
- Hostname = hostname;
- DNSHostname = dnsHostname;
IsAnyPortOpen = isAnyPortOpen;
Ports = ports;
NetBIOSInfo = netBIOSInfo;
- MACAddress = macAddress;
- Vendor = vendor;
}
+ ///
+ /// Scanned IP address (input of the scan, e.g. with the IPv6 scope id). contains the
+ /// address of the ping reply.
+ ///
+ public IPAddress IPAddress { get; }
+
///
/// Indicates whether the host is reachable.
///
- public bool IsReachable { get; set; }
+ public bool IsReachable
+ {
+ get;
+ internal set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
///
/// Information about the ping to the host.
///
- public PingInfo PingInfo { get; set; }
+ public PingInfo PingInfo
+ {
+ get;
+ internal set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
///
- /// Hostname of the host (dns or netbios).
+ /// Hostname of the host: DNS hostname, fallback to the NetBIOS computer name.
///
- public string Hostname { get; set; }
+ public string Hostname => !string.IsNullOrEmpty(DNSHostname)
+ ? DNSHostname
+ : NetBIOSInfo?.ComputerName ?? string.Empty;
///
- /// DNS hostname of the host.
+ /// DNS hostname of the host (PTR record), empty if not resolved (yet).
///
- public string DNSHostname { get; set; }
+ public string DNSHostname
+ {
+ get;
+ internal set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(Hostname));
+ }
+ } = string.Empty;
///
/// Indicates whether any port is open.
///
- public bool IsAnyPortOpen { get; set; }
+ public bool IsAnyPortOpen
+ {
+ get;
+ internal set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
///
- /// List of open ports.
+ /// List of ports to show (open ports, all ports if unreachable ones are shown). Replaced, never changed.
///
- public List Ports { get; set; }
+ public List Ports
+ {
+ get;
+ internal set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
///
/// NetBIOS information about the host.
///
- public NetBIOSInfo NetBIOSInfo { get; set; }
+ public NetBIOSInfo NetBIOSInfo
+ {
+ get;
+ internal set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(Hostname));
+ OnPropertyChanged(nameof(MACAddress));
+ OnPropertyChanged(nameof(Vendor));
+ }
+ }
+
+ ///
+ /// Indicates whether the ping is still running ( is a placeholder).
+ ///
+ public bool IsPingPending
+ {
+ get;
+ internal set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Indicates whether the port scan is still running ( and
+ /// are placeholders).
+ ///
+ public bool IsPortScanPending
+ {
+ get;
+ internal set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
///
- /// MAC address of the host (ARP/NDP preferred, NetBIOS as fallback).
+ /// Indicates whether the NetBIOS request is still running ( is a placeholder).
///
- public string MACAddress { get; set; }
+ public bool IsNetBIOSPending
+ {
+ get;
+ internal set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
///
- /// Vendor of the host based on the MAC address.
+ /// MAC address from the neighbor table (ARP / NDP) or of a local network interface, empty if not resolved
+ /// (yet).
///
- public string Vendor { get; set; }
-}
\ No newline at end of file
+ public string NeighborMACAddress
+ {
+ get;
+ internal set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(MACAddress));
+ OnPropertyChanged(nameof(Vendor));
+ }
+ } = string.Empty;
+
+ ///
+ /// Vendor of .
+ ///
+ public string NeighborVendor
+ {
+ get;
+ internal set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(Vendor));
+ }
+ } = string.Empty;
+
+ ///
+ /// MAC address of the host: neighbor table (ARP / NDP) is preferred, fallback to NetBIOS.
+ ///
+ public string MACAddress => !string.IsNullOrEmpty(NeighborMACAddress)
+ ? NeighborMACAddress
+ : NetBIOSInfo?.MACAddress ?? string.Empty;
+
+ ///
+ /// Vendor of the host based on .
+ ///
+ public string Vendor => !string.IsNullOrEmpty(NeighborMACAddress)
+ ? NeighborVendor
+ : NetBIOSInfo?.Vendor ?? string.Empty;
+
+ ///
+ /// Takes over all values of another result of the same host (e.g. a rescan), so the shown row keeps its
+ /// identity (selection, expanded details). Call it on the thread that owns the host (UI thread).
+ ///
+ /// New result of the host.
+ public void CopyFrom(IPScannerHostInfo source)
+ {
+ IsReachable = source.IsReachable;
+ PingInfo = source.PingInfo;
+ IsAnyPortOpen = source.IsAnyPortOpen;
+ Ports = source.Ports;
+ NetBIOSInfo = source.NetBIOSInfo;
+ IsPingPending = source.IsPingPending;
+ IsPortScanPending = source.IsPortScanPending;
+ IsNetBIOSPending = source.IsNetBIOSPending;
+ DNSHostname = source.DNSHostname;
+ NeighborMACAddress = source.NeighborMACAddress;
+ NeighborVendor = source.NeighborVendor;
+ }
+
+ ///
+ /// Creates a copy with the current values, e.g. for an export in the background while the scan still updates
+ /// the host. Call it on the thread that applies the updates (UI thread).
+ ///
+ /// Copy of the host information (without event subscribers).
+ public IPScannerHostInfo Clone()
+ {
+ return new IPScannerHostInfo(IPAddress, IsReachable, PingInfo, IsAnyPortOpen, Ports, NetBIOSInfo)
+ {
+ IsPingPending = IsPingPending,
+ IsPortScanPending = IsPortScanPending,
+ IsNetBIOSPending = IsNetBIOSPending,
+ DNSHostname = DNSHostname,
+ NeighborMACAddress = NeighborMACAddress,
+ NeighborVendor = NeighborVendor
+ };
+ }
+}
diff --git a/Source/NETworkManager.Models/Network/IPScannerHostScannedArgs.cs b/Source/NETworkManager.Models/Network/IPScannerHostScannedArgs.cs
deleted file mode 100644
index a5d279ebed..0000000000
--- a/Source/NETworkManager.Models/Network/IPScannerHostScannedArgs.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-๏ปฟusing System;
-
-namespace NETworkManager.Models.Network;
-
-///
-/// Contains the information of a scanned host in a .
-///
-public class IPScannerHostScannedArgs : EventArgs
-{
- ///
- /// Creates a new instance of with the given .
- ///
- /// IP Scanner host information.
- public IPScannerHostScannedArgs(IPScannerHostInfo args)
- {
- Args = args;
- }
-
- ///
- /// IP Scanner host information.
- ///
- public IPScannerHostInfo Args { get; }
-}
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Network/IPScannerHostUpdatedArgs.cs b/Source/NETworkManager.Models/Network/IPScannerHostUpdatedArgs.cs
new file mode 100644
index 0000000000..f3e00ad458
--- /dev/null
+++ b/Source/NETworkManager.Models/Network/IPScannerHostUpdatedArgs.cs
@@ -0,0 +1,37 @@
+๏ปฟusing System;
+
+namespace NETworkManager.Models.Network;
+
+///
+/// Contains an update of a host found by the (e.g. the DNS hostname or the MAC address
+/// arrived). The scanner never changes a host itself after it was raised in :
+/// the consumer applies on the thread that owns the host (UI thread), in the order the updates
+/// were raised.
+///
+/// Host to update.
+/// Changes to apply to the host.
+public class IPScannerHostUpdatedArgs(IPScannerHostInfo host, Action update) : EventArgs
+{
+ ///
+ /// Host to update.
+ ///
+ public IPScannerHostInfo Host { get; } = host;
+
+ ///
+ /// Applies the update to . Call it on the thread that owns the host (UI thread).
+ ///
+ public void Apply()
+ {
+ update(Host);
+ }
+
+ ///
+ /// Applies the update to another host with the same values (e.g. the shown result of a host that is rescanned,
+ /// see ). Call it on the thread that owns the host (UI thread).
+ ///
+ /// Host to update.
+ public void ApplyTo(IPScannerHostInfo host)
+ {
+ update(host);
+ }
+}
diff --git a/Source/NETworkManager.Models/Network/IPScannerOptions.cs b/Source/NETworkManager.Models/Network/IPScannerOptions.cs
index b7c4fdcb3a..54e08dba1a 100644
--- a/Source/NETworkManager.Models/Network/IPScannerOptions.cs
+++ b/Source/NETworkManager.Models/Network/IPScannerOptions.cs
@@ -1,114 +1,84 @@
๏ปฟnamespace NETworkManager.Models.Network;
///
-/// Class contains the options for the traceroute.
+/// Class contains the options for the IP scanner. A record, so a copy with other values can be created with
+/// with (e.g. for a rescan).
///
-public class IPScannerOptions
+public record IPScannerOptions
{
- ///
- /// Create an instance of with parameters.
- ///
- /// Max concurrent threads for the host scan.
- /// Max concurrent threads for the port scan of each host.
- /// Number of attempts to ping a host.
- /// Timeout in milliseconds after which a ping is considered lost.
- /// Size of the buffer used in the ping in bytes.
- /// Scan the ports of the host.
- /// List of ports to scan.
- /// Timeout in milliseconds after which a port is considered closed.
- /// Number of attempts to connect to a port if there was no answer.
- /// Resolve the hostname for an IP address.
- /// Resolve the NetBIOS name for an IP address.
- /// Timeout in milliseconds after which a NetBIOS request is considered lost.
- /// Resolve the MAC address for the host from ARP.
- /// Include unreachable IP addresses in the result.
- public IPScannerOptions(int maxHostThreads, int maxPortThreads, int icmpAttempts, int icmpTimeout,
- byte[] icmpBuffer, bool resolveHostname, bool portScanEnabled, int[] portScanPorts, int portScanTimeout,
- int portScanAttempts, bool netBIOSEnabled, int netBIOSTimeout, bool resolveMACAddress, bool showAllResults)
- {
- MaxHostThreads = maxHostThreads;
- MaxPortThreads = maxPortThreads;
- ICMPAttempts = icmpAttempts;
- ICMPTimeout = icmpTimeout;
- ICMPBuffer = icmpBuffer;
- ResolveHostname = resolveHostname;
- PortScanEnabled = portScanEnabled;
- PortScanPorts = portScanPorts;
- PortScanTimeout = portScanTimeout;
- PortScanAttempts = portScanAttempts;
- NetBIOSEnabled = netBIOSEnabled;
- NetBIOSTimeout = netBIOSTimeout;
- ResolveMACAddress = resolveMACAddress;
- ShowAllResults = showAllResults;
- }
-
///
/// Max concurrent threads for the host scan.
///
- public int MaxHostThreads { get; }
+ public required int MaxHostThreads { get; init; }
///
/// Max concurrent threads for the port scan of each host.
///
- public int MaxPortThreads { get; }
+ public required int MaxPortThreads { get; init; }
///
/// Number of attempts to ping a host.
///
- public int ICMPAttempts { get; }
+ public required int ICMPAttempts { get; init; }
///
/// Timeout in milliseconds after which a ping is considered lost.
///
- public int ICMPTimeout { get; }
+ public required int ICMPTimeout { get; init; }
///
/// Size of the buffer used in the ping in bytes.
///
- public byte[] ICMPBuffer { get; }
+ public required byte[] ICMPBuffer { get; init; }
///
/// Resolve the hostname for an IP address.
///
- public bool ResolveHostname { get; }
+ public required bool ResolveHostname { get; init; }
///
/// Scan the ports of the host.
///
- public bool PortScanEnabled { get; }
+ public required bool PortScanEnabled { get; init; }
///
/// Ports to scan.
///
- public int[] PortScanPorts { get; }
+ public required int[] PortScanPorts { get; init; }
///
/// Timeout in milliseconds after which a port is considered closed.
///
- public int PortScanTimeout { get; }
+ public required int PortScanTimeout { get; init; }
///
/// Number of attempts to connect to a port if there was no answer (timed out).
///
- public int PortScanAttempts { get; }
+ public required int PortScanAttempts { get; init; }
///
/// Resolve the NetBIOS name for an IP address.
///
- public bool NetBIOSEnabled { get; }
+ public required bool NetBIOSEnabled { get; init; }
///
/// Timeout in milliseconds after which a NetBIOS request is considered lost.
///
- public int NetBIOSTimeout { get; }
+ public required int NetBIOSTimeout { get; init; }
///
/// Resolve the MAC address for the host from ARP.
///
- public bool ResolveMACAddress { get; }
+ public required bool ResolveMACAddress { get; init; }
///
/// Include unreachable IP addresses in the result.
///
- public bool ShowAllResults { get; }
-}
\ No newline at end of file
+ public required bool ShowAllResults { get; init; }
+
+ ///
+ /// Raise every host in , also unreachable ones (e.g. for a rescan of shown
+ /// hosts), without showing all ports like .
+ ///
+ public required bool ReportAllHosts { get; init; }
+}
diff --git a/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs
new file mode 100644
index 0000000000..385c71fce0
--- /dev/null
+++ b/Source/NETworkManager.Models/Network/IPScannerResultOptions.cs
@@ -0,0 +1,25 @@
+๏ปฟnamespace NETworkManager.Models.Network;
+
+///
+/// Options of an IP scan that decide which columns and details of its result are shown. Only these flags of the
+/// , without the ports and the ICMP buffer of the scan.
+///
+/// The hostname was resolved via DNS.
+/// The ports of the hosts were scanned.
+/// The NetBIOS information was requested.
+/// The MAC address and vendor were resolved.
+public sealed record IPScannerResultOptions(
+ bool ResolveHostname,
+ bool PortScanEnabled,
+ bool NetBIOSEnabled,
+ bool ResolveMACAddress)
+{
+ ///
+ /// Creates the result options from the options of a scan.
+ ///
+ /// Options of the scan.
+ public IPScannerResultOptions(IPScannerOptions options)
+ : this(options.ResolveHostname, options.PortScanEnabled, options.NetBIOSEnabled, options.ResolveMACAddress)
+ {
+ }
+}
diff --git a/Source/NETworkManager.Models/Network/NamedReferenceInput.cs b/Source/NETworkManager.Models/Network/NamedReferenceInput.cs
new file mode 100644
index 0000000000..ff3c614ddb
--- /dev/null
+++ b/Source/NETworkManager.Models/Network/NamedReferenceInput.cs
@@ -0,0 +1,122 @@
+๏ปฟusing System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace NETworkManager.Models.Network;
+
+///
+/// Item that can be entered by its name in an input with several entries (e.g. a port profile in a ports input)
+/// and is saved as a reference to its ID (see ).
+///
+public interface INamedReference
+{
+ ///
+ /// Internal ID, kept when the item is renamed.
+ ///
+ Guid Id { get; }
+
+ ///
+ /// Name that is entered in the input.
+ ///
+ string Name { get; }
+}
+
+///
+/// Input with entries separated by ";" (e.g. "Webserver; 8080"), where an entry can be the name of an item (e.g. a
+/// port profile or DNS server profile). The names can be replaced by references to the ID of the item (e.g.
+/// "{8f1c...}; 8080"), so a profile still finds the item after it was renamed.
+///
+public static class NamedReferenceInput
+{
+ ///
+ /// Separator of the entries of an input.
+ ///
+ public const char EntrySeparator = ';';
+
+ ///
+ /// Finds an item by its name (case-insensitive, leading / trailing spaces are ignored).
+ ///
+ /// Items.
+ /// Name of the item.
+ /// Item, or null if there is none with the name.
+ public static T FindByName(IEnumerable items, string name) where T : class, INamedReference
+ {
+ var trimmedName = name?.Trim();
+
+ return string.IsNullOrEmpty(trimmedName)
+ ? null
+ : items.FirstOrDefault(x => string.Equals(x.Name?.Trim(), trimmedName, StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Replaces the names of items in an input by references to their ID (e.g. "Webserver; 8080" ->
+ /// "{8f1c...}; 8080"). Entries without an item (or an item without an ID) are kept, trimmed.
+ ///
+ /// Input.
+ /// Gets the item an entry is replaced by, null to keep the entry.
+ /// Input with references, null if it contains no item.
+ public static string EncodeReferences(string input, Func findItem) where T : class, INamedReference
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return null;
+
+ var hasReference = false;
+ List entries = [];
+
+ foreach (var entry in input.Split(EntrySeparator))
+ {
+ var item = findItem(entry);
+
+ if (item == null || item.Id == Guid.Empty)
+ {
+ entries.Add(entry.Trim());
+ continue;
+ }
+
+ entries.Add(item.Id.ToString("B"));
+ hasReference = true;
+ }
+
+ return hasReference ? string.Join("; ", entries) : null;
+ }
+
+ ///
+ /// Replaces the references in an input (see ) by the current names of the
+ /// items (e.g. "{8f1c...}; 8080" -> "Webserver; 8080").
+ ///
+ /// Input with references.
+ /// Items.
+ /// Input with the names of the items, null if an item was not found.
+ /// True if every referenced item was found.
+ public static bool TryDecodeReferences(string inputWithReferences, IEnumerable items, out string input)
+ where T : class, INamedReference
+ {
+ input = null;
+
+ if (string.IsNullOrWhiteSpace(inputWithReferences))
+ return false;
+
+ var itemList = items as IList ?? [.. items];
+ List entries = [];
+
+ foreach (var entry in inputWithReferences.Split(EntrySeparator))
+ {
+ if (!Guid.TryParseExact(entry.Trim(), "B", out var id))
+ {
+ entries.Add(entry.Trim());
+ continue;
+ }
+
+ var item = itemList.FirstOrDefault(x => x.Id == id);
+
+ if (item == null)
+ return false;
+
+ entries.Add(item.Name);
+ }
+
+ input = string.Join("; ", entries);
+
+ return true;
+ }
+}
diff --git a/Source/NETworkManager.Models/Network/NeighborTable.cs b/Source/NETworkManager.Models/Network/NeighborTable.cs
index 32ccd9d0e0..2de34aa297 100644
--- a/Source/NETworkManager.Models/Network/NeighborTable.cs
+++ b/Source/NETworkManager.Models/Network/NeighborTable.cs
@@ -313,8 +313,10 @@ private static Dictionary BuildInterfaceAliasMap()
///
/// Returns a snapshot of the neighbor cache (IPv4 and IPv6) as map from IP address to MAC address
- /// (e.g. "001122AABBCC"). IPv6 addresses are stored without scope id (see
- /// ), entries without a MAC address are skipped.
+ /// (e.g. "001122AABBCC"). IPv6 addresses are stored with their scope id (e.g. "fe80::1%3", for an address entered
+ /// with one) and without it (see , for an address entered without one -
+ /// if the address is on several interfaces, the last entry wins). Entries without a MAC address, or with a link-layer
+ /// address that is not a 6 byte MAC address (e.g. the IPv4 address of a tunnel interface), are skipped.
/// Reading the table once and looking up many addresses is much cheaper than reading it per address.
///
public static Dictionary GetMACAddressMap()
@@ -323,10 +325,13 @@ public static Dictionary GetMACAddressMap()
foreach (var entry in GetTable())
{
+ if (entry.MACAddress.GetAddressBytes().Length != 6)
+ continue;
+
var macAddress = entry.MACAddress.ToString();
- if (!string.IsNullOrEmpty(macAddress))
- map[IPAddressHelper.WithoutScopeId(entry.IPAddress)] = macAddress;
+ map[entry.IPAddress] = macAddress;
+ map[IPAddressHelper.WithoutScopeId(entry.IPAddress)] = macAddress;
}
return map;
diff --git a/Source/NETworkManager.Models/Network/PingProbe.cs b/Source/NETworkManager.Models/Network/PingProbe.cs
new file mode 100644
index 0000000000..b5dff7a4cc
--- /dev/null
+++ b/Source/NETworkManager.Models/Network/PingProbe.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace NETworkManager.Models.Network;
+
+///
+/// Provides a shared ICMP echo (ping) probe used by and .
+///
+internal static class PingProbe
+{
+ ///
+ /// Pings the host. Another echo request is only sent if the previous one timed out (e.g. a lost packet),
+ /// other states like "destination host unreachable" are a definitive answer.
+ ///
+ /// IP address of the host.
+ /// Timeout in milliseconds of one echo request.
+ /// Number of attempts.
+ /// Data sent with the echo request.
+ /// Token to monitor for cancellation requests.
+ /// Result of the successful attempt, otherwise of the last attempt.
+ public static async Task PingAsync(IPAddress ipAddress, int timeoutMs, int attempts, byte[] buffer,
+ CancellationToken cancellationToken)
+ {
+ using var ping = new System.Net.NetworkInformation.Ping();
+
+ PingInfo pingInfo = null;
+
+ for (var i = 0; i < attempts; i++)
+ {
+ // Get timestamp
+ var timestamp = DateTime.Now;
+
+ try
+ {
+ // Note: the CancellationToken-accepting overload requires a TimeSpan timeout,
+ // unlike the legacy int-based overloads used elsewhere in .NET's Ping API.
+ var pingReply = await ping.SendPingAsync(ipAddress, TimeSpan.FromMilliseconds(timeoutMs),
+ buffer, cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ // Success
+ if (pingReply is { Status: IPStatus.Success })
+ {
+ switch (ipAddress.AddressFamily)
+ {
+ case AddressFamily.InterNetwork:
+ return new PingInfo(
+ timestamp,
+ pingReply.Address,
+ pingReply.Buffer.Length,
+ pingReply.RoundtripTime,
+ pingReply.Options!.Ttl,
+ pingReply.Status);
+ case AddressFamily.InterNetworkV6:
+ return new PingInfo(
+ timestamp,
+ pingReply.Address,
+ pingReply.Buffer.Length,
+ pingReply.RoundtripTime,
+ pingReply.Status);
+ }
+ }
+
+ // Failed
+ pingInfo = new PingInfo(timestamp, ipAddress, pingReply.Status);
+
+ // Only try again if the request timed out (e.g. a lost packet). Other states like
+ // "destination host unreachable" are a definitive answer.
+ if (pingReply.Status != IPStatus.TimedOut)
+ break;
+ }
+ catch (PingException)
+ {
+ // Ping failed with unknown status
+ return new PingInfo(timestamp, ipAddress, IPStatus.Unknown);
+ }
+ }
+
+ // Result of the last attempt (falls back to unknown status if there was no attempt)
+ return pingInfo ?? new PingInfo(DateTime.Now, ipAddress, IPStatus.Unknown);
+ }
+}
diff --git a/Source/NETworkManager.Models/Network/PortInfo.cs b/Source/NETworkManager.Models/Network/PortInfo.cs
index 30b89e4c3d..b1b89c78e1 100644
--- a/Source/NETworkManager.Models/Network/PortInfo.cs
+++ b/Source/NETworkManager.Models/Network/PortInfo.cs
@@ -1,11 +1,12 @@
๏ปฟusing NETworkManager.Models.Lookup;
+using NETworkManager.Utilities;
namespace NETworkManager.Models.Network;
///
/// Class representing a port info.
///
-public class PortInfo
+public class PortInfo : PropertyChangedBase
{
///
/// Create an instance of with parameters.
@@ -31,7 +32,18 @@ public PortInfo(int port, PortLookupInfo lookupInfo, PortState state)
public PortLookupInfo LookupInfo { get; set; }
///
- /// State of the port.
+ /// State of the port. Observable, a rescan of the port updates it in place (Port Scanner).
///
- public PortState State { get; set; }
+ public PortState State
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
}
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Network/PortProfile.cs b/Source/NETworkManager.Models/Network/PortProfile.cs
index f124078cef..bbc0297057 100644
--- a/Source/NETworkManager.Models/Network/PortProfile.cs
+++ b/Source/NETworkManager.Models/Network/PortProfile.cs
@@ -1,9 +1,109 @@
๏ปฟusing System.Collections.Generic;
+using System.Linq;
+using NETworkManager.Utilities;
namespace NETworkManager.Models.Network;
public static class PortProfile
{
+ ///
+ /// Separator of the entries of a ports input (e.g. "Webserver; 8080").
+ ///
+ private const char EntrySeparator = NamedReferenceInput.EntrySeparator;
+
+ ///
+ /// Finds a port profile by its name (case-insensitive, leading / trailing spaces are ignored).
+ ///
+ /// Port profiles.
+ /// Name of the port profile.
+ /// Port profile, or null if there is none with the name.
+ public static PortProfileInfo FindByName(IEnumerable portProfiles, string name)
+ {
+ return NamedReferenceInput.FindByName(portProfiles, name);
+ }
+
+ ///
+ /// Checks if a name can be used for a port profile: the name can be entered in a ports input, so it must not
+ /// contain the entry separator ";" and must not be a port or port range (e.g. "80" would be read as port).
+ ///
+ /// Name to check.
+ /// True if the name can be used.
+ public static bool IsValidName(string name)
+ {
+ return !string.IsNullOrWhiteSpace(name) && !name.Contains(EntrySeparator) &&
+ !PortRangeHelper.IsValidPortOrPortRange(name);
+ }
+
+ ///
+ /// Resolves a ports input into ports. Every entry (separated by ";") is a port (e.g. "80"), a port range (e.g.
+ /// "1-1024") or the name of a port profile (e.g. "Webserver"), which is replaced by its ports - e.g.
+ /// "Webserver; 8080" -> "80; 443; 8080".
+ ///
+ /// Ports input.
+ /// Port profiles that can be used by their name.
+ /// Ports (ports and port ranges separated by ";"), null if the input is not valid.
+ /// True if every entry is a port, port range or port profile.
+ public static bool TryResolvePorts(string input, IEnumerable portProfiles, out string ports)
+ {
+ ports = null;
+
+ if (string.IsNullOrWhiteSpace(input))
+ return false;
+
+ List resolvedEntries = [];
+
+ foreach (var entry in input.Split(EntrySeparator))
+ {
+ if (PortRangeHelper.IsValidPortOrPortRange(entry))
+ {
+ resolvedEntries.Add(entry.Replace(" ", ""));
+ continue;
+ }
+
+ var portProfile = FindByName(portProfiles, entry);
+
+ if (portProfile == null)
+ return false;
+
+ resolvedEntries.Add(portProfile.Ports);
+ }
+
+ ports = string.Join("; ", resolvedEntries);
+
+ return true;
+ }
+
+ ///
+ /// Replaces the names of port profiles in a ports input by references to their ID (e.g. "Webserver; 8080" ->
+ /// "{8f1c...}; 8080"), so a profile still finds the port profile after it was renamed. Entries that are no
+ /// port profile (ports, unknown names) are kept.
+ ///
+ /// Ports input (see ).
+ /// Port profiles.
+ /// Input with references, null if it contains no port profile.
+ public static string EncodeReferences(string input, IEnumerable portProfiles)
+ {
+ var portProfileList = portProfiles.ToList();
+
+ // A port or port range is never a port profile (see TryResolvePorts)
+ return NamedReferenceInput.EncodeReferences(input,
+ entry => PortRangeHelper.IsValidPortOrPortRange(entry) ? null : FindByName(portProfileList, entry));
+ }
+
+ ///
+ /// Replaces the references in a ports input (see ) by the current names of the
+ /// port profiles (e.g. "{8f1c...}; 8080" -> "Webserver; 8080").
+ ///
+ /// Ports input with references.
+ /// Port profiles.
+ /// Ports input with the names of the port profiles, null if a port profile was not found.
+ /// True if every referenced port profile was found.
+ public static bool TryDecodeReferences(string inputWithReferences, IEnumerable portProfiles,
+ out string input)
+ {
+ return NamedReferenceInput.TryDecodeReferences(inputWithReferences, portProfiles, out input);
+ }
+
public static List GetDefaultList()
{
return new List
diff --git a/Source/NETworkManager.Models/Network/PortProfileInfo.cs b/Source/NETworkManager.Models/Network/PortProfileInfo.cs
index 11f4641622..8d25cd7bd4 100644
--- a/Source/NETworkManager.Models/Network/PortProfileInfo.cs
+++ b/Source/NETworkManager.Models/Network/PortProfileInfo.cs
@@ -1,17 +1,31 @@
-๏ปฟnamespace NETworkManager.Models.Network;
+๏ปฟusing System;
-public class PortProfileInfo
+namespace NETworkManager.Models.Network;
+
+public class PortProfileInfo : INamedReference
{
public PortProfileInfo()
{
}
+ ///
+ /// Creates a new port profile with a new .
+ ///
+ /// Name of the port profile.
+ /// Ports of the port profile.
public PortProfileInfo(string name, string ports)
{
+ Id = Guid.NewGuid();
Name = name;
Ports = ports;
}
+ ///
+ /// Internal ID of the port profile - profiles reference a port profile by it, so a renamed port profile is
+ /// still found. Kept when the port profile is edited.
+ ///
+ public Guid Id { get; set; }
+
public string Name { get; set; }
public string Ports { get; set; }
}
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs
index 2716b0e342..0aa38b6cb4 100644
--- a/Source/NETworkManager.Models/Network/PortScanner.cs
+++ b/Source/NETworkManager.Models/Network/PortScanner.cs
@@ -1,8 +1,11 @@
๏ปฟusing System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net;
+using System.Net.NetworkInformation;
using System.Threading;
+using System.Threading.Channels;
using System.Threading.Tasks;
using NETworkManager.Models.Lookup;
using NETworkManager.Utilities;
@@ -31,12 +34,53 @@ public PortScanner(PortScannerOptions options)
///
private static readonly TimeSpan MaxPortStartInterval = TimeSpan.FromMilliseconds(1);
+ ///
+ /// Threads adjusted automatically (see ): connections at the same time (the old
+ /// defaults 4 hosts x 128 ports).
+ ///
+ private const int AutoMaxConnections = 512;
+
+ ///
+ /// Threads adjusted automatically: hosts at the same time - also the number of concurrent DNS (PTR) lookups,
+ /// like the default of the IP Scanner.
+ ///
+ private const int AutoMaxHostThreads = 128;
+
+ ///
+ /// Threads adjusted automatically: ports of one host at the same time.
+ ///
+ private const int AutoMaxPortThreads = 128;
+
+ ///
+ /// Threads adjusted automatically: pings before the scan at the same time (see
+ /// ), like the default host threads of the IP Scanner.
+ ///
+ private const int AutoMaxPingThreads = 128;
+
private int _progressValue;
private readonly InterlockedCounter _portsOpen = new();
private readonly InterlockedCounter _portsClosed = new();
+ private readonly InterlockedCounter _portsTimedOut = new();
+ private readonly InterlockedCounter _hostsSkipped = new();
+
+ ///
+ /// Data of the ping before the scan (see ), like the
+ /// default buffer of the IP Scanner.
+ ///
+ private static readonly byte[] PingBuffer = new byte[32];
+
+ // Closed and timed out ports per host, see GetClosedAndTimedOutPortsPerHost
+ private readonly ConcurrentDictionary
+ _portsPerHost = new();
private readonly PortScannerOptions _options;
+ ///
+ /// Gets the number of ports scanned so far. Thread-safe; may be read from any thread while the scan is
+ /// running.
+ ///
+ public int PortsScanned => Volatile.Read(ref _progressValue);
+
///
/// Gets the number of ports found to be open so far. Thread-safe; may be read from any
/// thread while the scan is running.
@@ -44,11 +88,35 @@ public PortScanner(PortScannerOptions options)
public int PortsOpen => _portsOpen.Value;
///
- /// Gets the number of ports found to be closed (or timed out) so far. Thread-safe; may be
+ /// Gets the number of ports found to be closed (the host refused the connection) so far. Thread-safe; may be
/// read from any thread while the scan is running.
///
public int PortsClosed => _portsClosed.Value;
+ ///
+ /// Gets the number of ports without an answer (timed out) so far. Thread-safe; may be read from any thread
+ /// while the scan is running.
+ ///
+ public int PortsTimedOut => _portsTimedOut.Value;
+
+ ///
+ /// Gets the number of hosts that were not scanned because they didn't answer the ping before the scan (see
+ /// ). Thread-safe; may be read from any thread while the
+ /// scan is running.
+ ///
+ public int HostsSkipped => _hostsSkipped.Value;
+
+ ///
+ /// Gets the number of closed and timed out ports per host scanned so far, only hosts with such a port. These
+ /// ports are only raised in if they are shown - e.g. a rescan of some hosts needs
+ /// them to keep the counters of the other hosts.
+ ///
+ /// Number of closed and timed out ports per IP address.
+ public Dictionary GetClosedAndTimedOutPortsPerHost()
+ {
+ return _portsPerHost.ToDictionary(x => x.Key, x => (x.Value.closed.Value, x.Value.timedOut.Value));
+ }
+
#endregion
#region Events
@@ -82,8 +150,45 @@ private void OnProgressChanged()
#region Methods
+ ///
+ /// Scans the ports of the hosts. Results are raised in , the end in
+ /// .
+ ///
+ /// Hosts to scan.
+ /// Ports to scan on every host.
+ /// Token to monitor for cancellation requests.
public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts, IEnumerable ports,
CancellationToken cancellationToken)
+ {
+ var portList = ports as IReadOnlyCollection ?? [.. ports];
+
+ Scan(hosts, _ => portList, portList.Count, cancellationToken);
+ }
+
+ ///
+ /// Scans single ports of hosts (e.g. a rescan of selected ports), every host only with its ports.
+ ///
+ /// IP address and port of every port to scan.
+ /// Token to monitor for cancellation requests.
+ public void ScanPortsAsync(IEnumerable<(IPAddress ipAddress, int port)> hostPorts,
+ CancellationToken cancellationToken)
+ {
+ var portsPerHost = hostPorts.GroupBy(x => x.ipAddress).ToDictionary(x => x.Key,
+ x => (IReadOnlyCollection)[.. x.Select(y => y.port).Distinct()]);
+
+ Scan([.. portsPerHost.Keys.Select(x => (x, string.Empty))], x => portsPerHost[x],
+ portsPerHost.Count == 0 ? 0 : portsPerHost.Values.Max(x => x.Count), cancellationToken);
+ }
+
+ ///
+ /// Scans the hosts, each with its ports.
+ ///
+ /// Hosts to scan.
+ /// Gets the ports of a host.
+ /// Most ports of a host (threads and the spread of the start).
+ /// Token to monitor for cancellation requests.
+ private void Scan(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
+ Func> getPorts, int maxPortCount, CancellationToken cancellationToken)
{
_progressValue = 0;
@@ -96,36 +201,58 @@ public void ScanAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
try
{
+ var portCount = maxPortCount;
+ var (maxHostThreads, maxPortThreads) = GetThreads(portCount);
+
var hostParallelOptions = new ParallelOptions
{
CancellationToken = cancellationToken,
- MaxDegreeOfParallelism = _options.MaxHostThreads
+ MaxDegreeOfParallelism = maxHostThreads
};
var portParallelOptions = new ParallelOptions
{
CancellationToken = cancellationToken,
- MaxDegreeOfParallelism = _options.MaxPortThreads
+ MaxDegreeOfParallelism = maxPortThreads
};
// Spread the start of the port probes over time instead of starting all at once
var hostConcurrency = hosts.TryGetNonEnumeratedCount(out var hostCount)
- ? Math.Min(hostCount, _options.MaxHostThreads)
- : _options.MaxHostThreads;
+ ? Math.Min(hostCount, maxHostThreads)
+ : maxHostThreads;
- var portConcurrency = hostConcurrency * (ports.TryGetNonEnumeratedCount(out var portCount)
- ? Math.Min(portCount, _options.MaxPortThreads)
- : _options.MaxPortThreads);
+ var portConcurrency = hostConcurrency * Math.Min(portCount, maxPortThreads);
var portStartScheduler = new StartScheduler(StartScheduler.GetInterval(MaxPortStartInterval,
TimeSpan.FromMilliseconds(_options.Timeout * _options.Attempts), portConcurrency), portConcurrency);
- await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, hostCt) =>
+ // Only hosts that answer a ping are scanned (option). Adjusted automatically, the pings run ahead of
+ // the port scan with their own concurrency - otherwise an unused address would take one of the few
+ // hosts of the port scan (e.g. 4 for 1-1024) until its ping timed out. With the threads of the
+ // settings, every host is pinged in its slot of the port scan, so Max. concurrent host threads also
+ // limits the pings (e.g. to slow down a scan).
+ var pingAhead = _options.PingHostsBeforeScan && _options.AutoAdjustThreads;
+ var pingInSlot = _options.PingHostsBeforeScan && !pingAhead;
+
+ if (pingAhead)
+ await ScanReachableHostsAsync(hosts, getPorts, hostParallelOptions, ScanHostAsync)
+ .ConfigureAwait(false);
+ else
+ await Parallel.ForEachAsync(hosts, hostParallelOptions, ScanHostAsync).ConfigureAwait(false);
+
+ async ValueTask ScanHostAsync((IPAddress ipAddress, string hostname) host, CancellationToken hostCt)
{
- // Resolve Hostname (PTR) while the ports are scanned
- var hostnameTask = _options.ResolveHostname
- ? ResolveHostnameAsync(host.ipAddress, hostCt)
- : Task.FromResult(string.Empty);
+ var ports = getPorts(host.ipAddress);
+
+ if (pingInSlot &&
+ !await IsReachableAsync(host.ipAddress, ports.Count, hostCt).ConfigureAwait(false))
+ return;
+
+ // Resolve Hostname (PTR) while the ports are scanned. Started with the first port probe of the
+ // host, so the lookups of many hosts are spread over time like the probes instead of all at once
+ var hostnameTask = new Lazy>(() => _options.ResolveHostname
+ ? DNSClient.GetInstance().ResolvePtrOrEmptyAsync(host.ipAddress, hostCt)
+ : Task.FromResult(string.Empty));
// Check each port
await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
@@ -133,20 +260,38 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
// Spread the start of the port probes over time instead of starting all at once
await portStartScheduler.WaitAsync(portCt).ConfigureAwait(false);
+ _ = hostnameTask.Value;
+
var portState = await PortProbe.ProbeAsync(host.ipAddress, port, _options.Timeout,
_options.Attempts, portCt)
.ConfigureAwait(false);
// Every result needs the hostname (results are grouped by it) - usually resolved
// long before the first port is finished
- var hostname = await hostnameTask.ConfigureAwait(false);
+ var hostname = await hostnameTask.Value.ConfigureAwait(false);
- // Count open/closed ports unconditionally, since ShowAllResults (below)
+ // Count open/closed/timed out ports unconditionally, since ShowAllResults (below)
// may prevent closed ports from ever reaching PortScanned
if (portState == PortState.Open)
+ {
_portsOpen.Increment();
+ }
else
- _portsClosed.Increment();
+ {
+ var portsOfHost = _portsPerHost.GetOrAdd(host.ipAddress,
+ static _ => (new InterlockedCounter(), new InterlockedCounter()));
+
+ if (portState == PortState.Closed)
+ {
+ _portsClosed.Increment();
+ portsOfHost.closed.Increment();
+ }
+ else
+ {
+ _portsTimedOut.Increment();
+ portsOfHost.timedOut.Increment();
+ }
+ }
if (_options.ShowAllResults || portState == PortState.Open)
OnPortScanned(new PortScannerPortScannedArgs(
@@ -155,7 +300,7 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
IncreaseProgress();
}).ConfigureAwait(false);
- }).ConfigureAwait(false);
+ }
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -172,18 +317,123 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
});
}
- private static async Task ResolveHostnameAsync(IPAddress ipAddress, CancellationToken cancellationToken)
+ ///
+ /// Pings the hosts with at the same time and scans the hosts that answer (as
+ /// soon as they answer) with . Returns when all pings and scans are finished, also on
+ /// cancellation or an error.
+ ///
+ /// Hosts to ping and scan.
+ /// Gets the ports of a host (counted as scanned for a host that doesn't answer).
+ ///
+ /// Options for the hosts that are scanned at the same time, with the token to monitor for cancellation requests.
+ ///
+ /// Scans the ports of a host.
+ private async Task ScanReachableHostsAsync(IEnumerable<(IPAddress ipAddress, string hostname)> hosts,
+ Func> getPorts, ParallelOptions hostParallelOptions,
+ Func<(IPAddress ipAddress, string hostname), CancellationToken, ValueTask> scanHost)
{
- var dnsResult = await DNSClient.GetInstance().ResolvePtrAsync(ipAddress)
- .WaitAsync(cancellationToken).ConfigureAwait(false);
+ var reachableHosts = Channel.CreateUnbounded<(IPAddress ipAddress, string hostname)>();
+
+ // Also stops the pings if the port scan fails
+ using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(hostParallelOptions.CancellationToken);
+
+ var pingTask = PingHostsAsync();
+
+ try
+ {
+ await Parallel.ForEachAsync(reachableHosts.Reader.ReadAllAsync(hostParallelOptions.CancellationToken),
+ hostParallelOptions, scanHost).ConfigureAwait(false);
+ }
+ finally
+ {
+ // Never return while pings are still running (e.g. the port scan failed or was canceled), they would
+ // change the progress after ScanComplete
+ await pingCts.CancelAsync().ConfigureAwait(false);
+
+ try
+ {
+ await pingTask.ConfigureAwait(false);
+ }
+ catch
+ {
+ // Raised below if the port scan was finished
+ }
+ }
+
+ // Error of the pings
+ await pingTask.ConfigureAwait(false);
+
+ // Writes the hosts that answer to the channel, the port scan reads them while the other hosts are pinged
+ async Task PingHostsAsync()
+ {
+ try
+ {
+ var pingParallelOptions = new ParallelOptions
+ {
+ CancellationToken = pingCts.Token,
+ MaxDegreeOfParallelism = AutoMaxPingThreads
+ };
+
+ await Parallel.ForEachAsync(hosts, pingParallelOptions, async (host, ct) =>
+ {
+ if (await IsReachableAsync(host.ipAddress, getPorts(host.ipAddress).Count, ct)
+ .ConfigureAwait(false))
+ reachableHosts.Writer.TryWrite(host); // Unbounded, always written
+ }).ConfigureAwait(false);
+ }
+ finally
+ {
+ // Also on cancellation or an error (raised above), the port scan ends with the hosts written so far
+ reachableHosts.Writer.TryComplete();
+ }
+ }
+ }
+
+ ///
+ /// Pings a host before the scan (see ). If it doesn't
+ /// answer, it is counted as skipped and its ports as scanned, so the progress is complete.
+ ///
+ /// IP address of the host.
+ /// Number of ports per host.
+ /// Token to monitor for cancellation requests.
+ /// True if the host answered the ping.
+ private async Task IsReachableAsync(IPAddress ipAddress, int portCount, CancellationToken cancellationToken)
+ {
+ var pingInfo = await PingProbe.PingAsync(ipAddress, _options.Timeout, _options.Attempts, PingBuffer,
+ cancellationToken).ConfigureAwait(false);
+
+ if (pingInfo.Status == IPStatus.Success)
+ return true;
+
+ _hostsSkipped.Increment();
+ IncreaseProgress(portCount);
+
+ return false;
+ }
+
+ ///
+ /// Gets the number of hosts and the number of ports per host scanned at the same time: the settings, or -
+ /// adjusted automatically - connections spread over as many hosts as needed
+ /// (at most ) with up to ports each. E.g.
+ /// 4 hosts x 128 ports for 1-1024, 128 hosts x 1 port for one port (with the fixed 4 host threads only 4
+ /// connections at a time).
+ ///
+ /// Number of ports per host.
+ /// Hosts and ports per host at the same time.
+ private (int hostThreads, int portThreads) GetThreads(int portCount)
+ {
+ if (!_options.AutoAdjustThreads)
+ return (_options.MaxHostThreads, _options.MaxPortThreads);
+
+ var portsAtOnce = Math.Max(1, Math.Min(portCount, AutoMaxPortThreads));
- return dnsResult.HasError ? string.Empty : dnsResult.Value;
+ return (Math.Clamp(AutoMaxConnections / portsAtOnce, 1, AutoMaxHostThreads), AutoMaxPortThreads);
}
- private void IncreaseProgress()
+ private void IncreaseProgress(int count = 1)
{
- // Increase the progress
- Interlocked.Increment(ref _progressValue);
+ // Increase the progress
+ Interlocked.Add(ref _progressValue, count);
OnProgressChanged();
}
diff --git a/Source/NETworkManager.Models/Network/PortScannerOptions.cs b/Source/NETworkManager.Models/Network/PortScannerOptions.cs
index ef32159425..3aefedf054 100644
--- a/Source/NETworkManager.Models/Network/PortScannerOptions.cs
+++ b/Source/NETworkManager.Models/Network/PortScannerOptions.cs
@@ -3,55 +3,47 @@
///
/// Class contains the options for the port scanner.
///
-public class PortScannerOptions
+public record PortScannerOptions
{
///
- /// Create an instance of with parameters.
+ /// Adjust the number of hosts and ports scanned at the same time to the scan (see
+ /// ) instead of using and .
///
- /// Max cuncurrent threads for the host scan.
- /// Max cuncurrent threads for the port scan of each host.
- /// Timeout in ms after which the port is considered closed.
- /// Number of attempts to connect to a port if there was no answer.
- /// Resolve the hostname for an IP address.
- /// Include closed ports in the result.
- public PortScannerOptions(int maxHostThreads, int maxPortThreads, int timeout, int attempts,
- bool resolveHostname, bool showAllResults)
- {
- MaxHostThreads = maxHostThreads;
- MaxPortThreads = maxPortThreads;
- Timeout = timeout;
- Attempts = attempts;
- ResolveHostname = resolveHostname;
- ShowAllResults = showAllResults;
- }
+ public required bool AutoAdjustThreads { get; init; }
///
- /// Max cuncurrent threads for the host scan.
+ /// Max concurrent threads for the host scan (if the threads are not adjusted automatically).
///
- public int MaxHostThreads { get; set; }
+ public required int MaxHostThreads { get; init; }
///
- /// Max cuncurrent threads for the port scan of each host.
+ /// Max concurrent threads for the port scan of each host (if the threads are not adjusted automatically).
///
- public int MaxPortThreads { get; set; }
+ public required int MaxPortThreads { get; init; }
///
/// Timeout in ms after which the port is considered closed.
///
- public int Timeout { get; set; }
+ public required int Timeout { get; init; }
///
/// Number of attempts to connect to a port if there was no answer (timed out).
///
- public int Attempts { get; set; }
+ public required int Attempts { get; init; }
+
+ ///
+ /// Ping every host before its ports are scanned (with and ), a host
+ /// that doesn't answer is not scanned.
+ ///
+ public required bool PingHostsBeforeScan { get; init; }
///
/// Resolve the hostname for an IP address.
///
- public bool ResolveHostname { get; set; }
+ public required bool ResolveHostname { get; init; }
///
/// Include closed ports in the result.
///
- public bool ShowAllResults { get; set; }
-}
\ No newline at end of file
+ public required bool ShowAllResults { get; init; }
+}
diff --git a/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs b/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs
index 2a4add8cbd..c6eb3b7454 100644
--- a/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs
+++ b/Source/NETworkManager.Models/Network/PortScannerPortInfo.cs
@@ -34,7 +34,9 @@ public PortScannerPortInfo(IPAddress ipAddress, string hostname, int port, PortL
public string Hostname { get; set; }
///
- /// Hostname (if available) or IP address of the host.
+ /// Hostname and IP address of the host (e.g. "server.example.com (10.0.0.1)"), only the IP address if there is
+ /// no hostname. The results are grouped by it - one group per IP address, also if several hosts have the same
+ /// hostname (e.g. a generic PTR record of a provider).
///
- public string HostAsString => string.IsNullOrEmpty(Hostname) ? IPAddress.ToString() : Hostname;
+ public string HostAsString => string.IsNullOrEmpty(Hostname) ? IPAddress.ToString() : $"{Hostname} ({IPAddress})";
}
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
new file mode 100644
index 0000000000..a82ddb1e71
--- /dev/null
+++ b/Source/NETworkManager.Models/Network/WebConsoleUrlHelper.cs
@@ -0,0 +1,73 @@
+๏ปฟusing System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using NETworkManager.Utilities;
+
+namespace NETworkManager.Models.Network;
+
+///
+/// Creates the URL to open a host in the Web Console from scan results (IP Scanner, Port Scanner).
+///
+public static class WebConsoleUrlHelper
+{
+ ///
+ /// Web ports in the order of preference (https before http, default ports first).
+ ///
+ private static readonly int[] WebPorts = [443, 80, 8443, 8080];
+
+ ///
+ /// Checks if a port is a web port (80, 443, 8080, 8443).
+ ///
+ /// Port number.
+ /// True if it is a web port.
+ public static bool IsWebPort(int port)
+ {
+ return WebPorts.Contains(port);
+ }
+
+ ///
+ /// Creates the URL for a host: the preferred open web port (443, 80, 8443, 8080), http://host/ if
+ /// none of them is open.
+ ///
+ /// Hostname or IP address.
+ /// Open ports of the host.
+ /// URL.
+ public static string CreateUrl(string host, IEnumerable openPorts)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(host);
+ ArgumentNullException.ThrowIfNull(openPorts);
+
+ var ports = openPorts.ToHashSet();
+
+ var port = WebPorts.FirstOrDefault(ports.Contains);
+
+ return CreateUrl(host, port == 0 ? 80 : port);
+ }
+
+ ///
+ /// Creates the URL for a host and a web port: https for 443 / 8443, otherwise http; default ports (80 / 443)
+ /// are left out, IPv6 addresses in brackets, the trailing dot of a hostname is removed.
+ ///
+ /// Hostname or IP address.
+ /// Port number.
+ /// URL.
+ public static string CreateUrl(string host, int port)
+ {
+ // The browser resolves a hostname via Windows (e.g. "gateway." -> "gateway")
+ if (!IPAddress.TryParse(host, out var ipAddress))
+ host = DNSClientHelper.SanitizeHostname(host);
+
+ // Also a hostname of only dots
+ ArgumentException.ThrowIfNullOrWhiteSpace(host);
+
+ var scheme = port is 443 or 8443 ? "https" : "http";
+
+ // The zone id of an IPv6 address has to be encoded ("%" -> "%25", RFC 6874)
+ if (ipAddress?.AddressFamily == AddressFamily.InterNetworkV6)
+ host = $"[{ipAddress.ToString().Replace("%", "%25")}]";
+
+ return port is 80 or 443 ? $"{scheme}://{host}/" : $"{scheme}://{host}:{port}/";
+ }
+}
diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs
index 32230999e3..d071d92cf6 100644
--- a/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs
+++ b/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs
@@ -47,6 +47,12 @@ public sealed class RunCommandParseResult
///
public string CurrentWord { get; init; } = string.Empty;
+ ///
+ /// Word that is being typed as entered, with its quotes (e.g. "DNS (via TCP)";Webs for the
+ /// DNS (via TCP);Webs), used to complete an entry of a list.
+ ///
+ public string CurrentToken { get; init; } = string.Empty;
+
///
/// Input without the word that is being typed (e.g. "dns borntoberoot.net " for "dns borntoberoot.net aa"),
/// used to complete a suggestion.
diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs
index f0dad503bd..9c0d9aa834 100644
--- a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs
+++ b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs
@@ -17,10 +17,10 @@ public static partial class RunCommandParser
private static partial Regex ListSeparatorOrQuoteRegex();
///
- /// A word: a value in quotes (can contain spaces, the closing quote can be missing while typing) or a value
- /// without spaces.
+ /// A word: parts in quotes (can contain spaces, the closing quote can be missing while typing) and parts without
+ /// spaces, e.g. a list with a value in quotes "DNS (via TCP)";Webserver.
///
- [GeneratedRegex("\"[^\"]*(?:\"|$)|[^\\s\"]+")]
+ [GeneratedRegex("(?:\"[^\"]*(?:\"|$)|[^\\s\"])+")]
private static partial Regex WordRegex();
///
@@ -207,6 +207,7 @@ public static RunCommandParseResult Parse(string input, IEnumerable
- /// Removes the quotes of a value (e.g. "Google Public DNS").
+ /// Removes the quotes of a value (e.g. "Google Public DNS", or "DNS (via TCP)";Webserver in a list).
///
private static string Unquote(string word)
{
- return word.StartsWith('"') ? word.Trim('"') : word;
+ return word.Replace("\"", string.Empty);
}
+ ///
+ /// Checks if the word ends in a quote that is not closed (while typing, e.g. "Google ).
+ ///
private static bool IsOpenQuote(string token)
{
- return token.StartsWith('"') && (token.Length == 1 || !token.EndsWith('"'));
+ return token.Count(x => x == '"') % 2 == 1;
}
}
diff --git a/Source/NETworkManager.Profiles/ProfileInfo.cs b/Source/NETworkManager.Profiles/ProfileInfo.cs
index ebbe5562f2..ade5fa6376 100644
--- a/Source/NETworkManager.Profiles/ProfileInfo.cs
+++ b/Source/NETworkManager.Profiles/ProfileInfo.cs
@@ -63,6 +63,7 @@ public ProfileInfo(ProfileInfo profile)
PortScanner_InheritHost = profile.PortScanner_InheritHost;
PortScanner_Host = profile.PortScanner_Host;
PortScanner_Ports = profile.PortScanner_Ports;
+ PortScanner_PortsInput = profile.PortScanner_PortsInput;
// Ping Monitor
PingMonitor_Enabled = profile.PingMonitor_Enabled;
@@ -78,6 +79,9 @@ public ProfileInfo(ProfileInfo profile)
DNSLookup_Enabled = profile.DNSLookup_Enabled;
DNSLookup_InheritHost = profile.DNSLookup_InheritHost;
DNSLookup_Host = profile.DNSLookup_Host;
+ DNSLookup_OverrideDNSServer = profile.DNSLookup_OverrideDNSServer;
+ DNSLookup_DNSServer = profile.DNSLookup_DNSServer;
+ DNSLookup_DNSServerInput = profile.DNSLookup_DNSServerInput;
// Remote Desktop
RemoteDesktop_Enabled = profile.RemoteDesktop_Enabled;
@@ -310,8 +314,21 @@ public ProfileInfo(ProfileInfo profile)
public bool PortScanner_Enabled { get; set; }
public bool PortScanner_InheritHost { get; set; } = true;
public string PortScanner_Host { get; set; }
+
+ ///
+ /// Ports to scan (ports and port ranges). If port profiles were entered, their ports at the time the profile was
+ /// saved - used if a port profile of can't be found (e.g. a profile file
+ /// from another computer, port profiles are saved in the settings).
+ ///
public string PortScanner_Ports { get; set; }
+ ///
+ /// Ports as entered, if they contain port profiles: port profiles are referenced by their ID (e.g.
+ /// "{8f1c...}; 8080", see PortProfile.EncodeReferences), so they are also found after a rename. Null if no
+ /// port profile was entered.
+ ///
+ public string PortScanner_PortsInput { get; set; }
+
public bool PingMonitor_Enabled { get; set; }
public bool PingMonitor_InheritHost { get; set; } = true;
public string PingMonitor_Host { get; set; }
@@ -324,6 +341,26 @@ public ProfileInfo(ProfileInfo profile)
public bool DNSLookup_InheritHost { get; set; } = true;
public string DNSLookup_Host { get; set; }
+ ///
+ /// Use the DNS server of the profile instead of the DNS server selected in DNS Lookup.
+ ///
+ public bool DNSLookup_OverrideDNSServer { get; set; }
+
+ ///
+ /// DNS server of the profile (names of DNS servers and / or DNS servers). If DNS servers were entered by name,
+ /// their servers at the time the profile was saved - used if a DNS server of
+ /// can't be found (e.g. a profile file from another computer, DNS servers
+ /// are saved in the settings).
+ ///
+ public string DNSLookup_DNSServer { get; set; }
+
+ ///
+ /// DNS server as entered, if it contains DNS servers by name: they are referenced by their ID (e.g. "{8f1c...};
+ /// 9.9.9.9", see DNSServer.EncodeReferences), so they are also found after a rename. Null if no DNS
+ /// server was entered by name.
+ ///
+ public string DNSLookup_DNSServerInput { get; set; }
+
public bool RemoteDesktop_Enabled { get; set; }
public bool RemoteDesktop_InheritHost { get; set; } = true;
public string RemoteDesktop_Host { get; set; }
diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs
index 94759bce27..9ddfd821b1 100644
--- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs
+++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs
@@ -150,16 +150,23 @@ public static class GlobalStaticConfiguration
public static bool IPScanner_NetBIOSEnabled => true;
public static int IPScanner_NetBIOSTimeout => 2000;
public static bool IPScanner_ResolveMACAddress => true;
+ public static bool IPScanner_ExpandHostView => false;
public static ExportFileType IPScanner_ExportFileType => ExportFileType.Csv;
// Larger scans need a confirmation, even larger ones are refused (every IP address / port is kept in memory)
public static long IPScanner_ConfirmHostCount => 65536;
public static long IPScanner_MaxHostCount => 16777216;
// Application: Port Scanner
+ public static bool PortScanner_AutoAdjustThreads => true;
public static int PortScanner_MaxHostThreads => 4;
public static int PortScanner_MaxPortThreads => 128;
+
+ // Max. connections at the same time if the threads are set manually (host threads x port threads): half of the
+ // local (ephemeral) ports of Windows (16,384), like the maximum of the IP Scanner (256 x 32)
+ public static int PortScanner_MaxConnections => 8192;
public static int PortScanner_Timeout => 2000;
public static int PortScanner_Attempts => 1;
+ public static bool PortScanner_PingHostsBeforeScan => false;
public static ExportFileType PortScanner_ExportFileType => ExportFileType.Csv;
// Larger scans (hosts x ports) need a confirmation, even larger ones are refused
public static long PortScanner_ConfirmPortCount => 65536;
diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs
index 97891fd233..a460ab433b 100644
--- a/Source/NETworkManager.Settings/SettingsInfo.cs
+++ b/Source/NETworkManager.Settings/SettingsInfo.cs
@@ -1207,6 +1207,19 @@ public bool IPScanner_ShowAllResults
}
}
+ public bool IPScanner_ExpandHostView
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = GlobalStaticConfiguration.IPScanner_ExpandHostView;
+
public int IPScanner_ICMPTimeout
{
get;
@@ -1528,6 +1541,32 @@ public int PortScanner_Attempts
}
} = GlobalStaticConfiguration.PortScanner_Attempts;
+ public bool PortScanner_PingHostsBeforeScan
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = GlobalStaticConfiguration.PortScanner_PingHostsBeforeScan;
+
+ public bool PortScanner_AutoAdjustThreads
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = GlobalStaticConfiguration.PortScanner_AutoAdjustThreads;
+
public int PortScanner_MaxHostThreads
{
get;
@@ -1946,6 +1985,20 @@ public ObservableCollection DNSLookup_HostHistory
}
} = [];
+ public ObservableCollection DNSLookup_DNSServerHistory
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ UpdateCollectionChanged(field, value);
+ field = value;
+ OnPropertyChanged();
+ }
+ } = [];
+
public ObservableCollection DNSLookup_DNSServers
{
get;
@@ -4200,6 +4253,7 @@ public SettingsInfo()
// DNS Lookup
DNSLookup_HostHistory.CollectionChanged += CollectionChanged;
DNSLookup_DNSServers.CollectionChanged += CollectionChanged;
+ DNSLookup_DNSServerHistory.CollectionChanged += CollectionChanged;
// Remote Desktop
RemoteDesktop_HostHistory.CollectionChanged += CollectionChanged;
diff --git a/Source/NETworkManager.Settings/SettingsManager.cs b/Source/NETworkManager.Settings/SettingsManager.cs
index f7a5395208..2dc5a326ff 100644
--- a/Source/NETworkManager.Settings/SettingsManager.cs
+++ b/Source/NETworkManager.Settings/SettingsManager.cs
@@ -792,6 +792,46 @@ private static void UpgradeToLatest(Version version)
Current.PortScanner_MaxPortThreads = GlobalStaticConfiguration.PortScanner_MaxPortThreads;
}
+ // DNS Lookup - DNS servers can be entered by name in a list (e.g. "Google Public DNS; Cloudflare"), where ";"
+ // separates the entries: replace it in the names
+ foreach (var dnsServer in Current.DNSLookup_DNSServers.Where(x => x.Name?.Contains(';') == true))
+ {
+ Log.Info($"Replace \";\" in the name of the DNS server \"{dnsServer.Name}\"...");
+ dnsServer.Name = dnsServer.Name.Replace(';', ',');
+ }
+
+ // DNS Lookup - DNS servers have an internal ID (profiles reference them by it)
+ foreach (var dnsServer in Current.DNSLookup_DNSServers.Where(x => x.Id == Guid.Empty))
+ {
+ Log.Info($"Add an ID to the DNS server \"{dnsServer.Name}\"...");
+ dnsServer.Id = Guid.NewGuid();
+ }
+
+ // Port Scanner - port profiles have an internal ID (profiles reference them by it)
+ foreach (var portProfile in Current.PortScanner_PortProfiles.Where(x => x.Id == Guid.Empty))
+ {
+ Log.Info($"Add an ID to the port profile \"{portProfile.Name}\"...");
+ portProfile.Id = Guid.NewGuid();
+ }
+
+ // Port Scanner - port profiles can be entered by name in the ports input (e.g. "Webserver; 8080"), where ";"
+ // separates the entries: replace it in the names
+ foreach (var portProfile in Current.PortScanner_PortProfiles.Where(x => x.Name?.Contains(';') == true))
+ {
+ Log.Info($"Replace \";\" in the name of the port profile \"{portProfile.Name}\"...");
+ portProfile.Name = portProfile.Name.Replace(';', ',');
+ }
+
+ // Port Scanner - new setting "Adjust threads automatically" (default on). Customized threads (not the
+ // defaults after the update above) are kept: automatic off, so they still apply.
+ if (Current.PortScanner_AutoAdjustThreads &&
+ (Current.PortScanner_MaxHostThreads != GlobalStaticConfiguration.PortScanner_MaxHostThreads ||
+ Current.PortScanner_MaxPortThreads != GlobalStaticConfiguration.PortScanner_MaxPortThreads))
+ {
+ Log.Info("Disable \"PortScanner_AutoAdjustThreads\", the threads were customized...");
+ Current.PortScanner_AutoAdjustThreads = false;
+ }
+
if (Current.IPScanner_ICMPTimeout == 4000)
{
Log.Info($"Update \"IPScanner_ICMPTimeout\" from 4000 to {GlobalStaticConfiguration.IPScanner_ICMPTimeout}...");
diff --git a/Source/NETworkManager.Tests/ApplicationManagerTests.cs b/Source/NETworkManager.Tests/ApplicationManagerTests.cs
new file mode 100644
index 0000000000..41dc818a99
--- /dev/null
+++ b/Source/NETworkManager.Tests/ApplicationManagerTests.cs
@@ -0,0 +1,33 @@
+๏ปฟusing NETworkManager.Models;
+using Xunit;
+
+namespace NETworkManager.Tests;
+
+public class ApplicationManagerTests
+{
+ [Theory]
+ [InlineData(ApplicationName.RemoteDesktop)]
+ [InlineData(ApplicationName.PuTTY)]
+ [InlineData(ApplicationName.TigerVNC)]
+ [InlineData(ApplicationName.PowerShell)]
+ public void HostForApplicationResolvedViaWindowsWithoutTrailingDot(ApplicationName name)
+ {
+ Assert.Equal("gateway", ApplicationManager.GetHostForApplication(name, "gateway."));
+ }
+
+ [Theory]
+ [InlineData(ApplicationName.PingMonitor)]
+ [InlineData(ApplicationName.Traceroute)]
+ [InlineData(ApplicationName.DNSLookup)]
+ [InlineData(ApplicationName.PortScanner)]
+ public void HostForApplicationResolvedViaNETworkManagerKeepsTrailingDot(ApplicationName name)
+ {
+ Assert.Equal("gateway.", ApplicationManager.GetHostForApplication(name, "gateway."));
+ }
+
+ [Fact]
+ public void HostForApplicationKeepsIPAddress()
+ {
+ Assert.Equal("10.0.0.1", ApplicationManager.GetHostForApplication(ApplicationName.RemoteDesktop, "10.0.0.1"));
+ }
+}
diff --git a/Source/NETworkManager.Tests/Network/DNSServerTests.cs b/Source/NETworkManager.Tests/Network/DNSServerTests.cs
new file mode 100644
index 0000000000..054722ce6b
--- /dev/null
+++ b/Source/NETworkManager.Tests/Network/DNSServerTests.cs
@@ -0,0 +1,91 @@
+๏ปฟusing System.Collections.Generic;
+using System.Linq;
+using NETworkManager.Models.Network;
+using Xunit;
+
+namespace NETworkManager.Tests.Network;
+
+public class DNSServerTests
+{
+ private static readonly List Profiles = DNSServer.GetDefaultList();
+
+ [Theory]
+ [InlineData("Cloudflare", "1.1.1.1:53; 1.0.0.1:53")]
+ [InlineData("cloudflare", "1.1.1.1:53; 1.0.0.1:53")]
+ [InlineData("Google Public DNS;Cloudflare", "8.8.8.8:53; 8.8.4.4:53; 1.1.1.1:53; 1.0.0.1:53")]
+ [InlineData(" Quad9 ; 1.1.1.1", "9.9.9.9:53; 149.112.112.112:53; 1.1.1.1:53")]
+ [InlineData("1.1.1.1:5353; dns.example.com", "1.1.1.1:5353; dns.example.com:53")]
+ [InlineData("Cloudflare; 1.1.1.1", "1.1.1.1:53; 1.0.0.1:53")]
+ public void ResolveServers(string input, string expected)
+ {
+ var servers = DNSServer.ResolveServers(input, Profiles, out var useWindowsDNSServer, out var invalidEntries);
+
+ Assert.False(useWindowsDNSServer);
+ Assert.Empty(invalidEntries);
+ Assert.Equal(expected, string.Join("; ", servers.Select(x => $"{x.Server}:{x.Port}")));
+ }
+
+ [Fact]
+ public void ResolveServers_WindowsDNSServerAlone()
+ {
+ var servers = DNSServer.ResolveServers("[windows dns]", Profiles, out var useWindowsDNSServer, out _);
+
+ Assert.True(useWindowsDNSServer);
+ Assert.Empty(servers);
+ }
+
+ [Fact]
+ public void ResolveServers_InvalidEntriesAreSkipped()
+ {
+ var servers = DNSServer.ResolveServers("Cloudflare; not valid!", Profiles, out _, out var invalidEntries);
+
+ Assert.Equal(2, servers.Count);
+ Assert.Equal(["not valid!"], invalidEntries);
+ }
+
+ [Fact]
+ public void EncodeAndDecodeReferences()
+ {
+ var cloudflare = Profiles.First(x => x.Name == "Cloudflare");
+
+ var input = DNSServer.EncodeReferences("[Windows DNS]; cloudflare; 9.9.9.9", Profiles);
+
+ Assert.Equal($"[Windows DNS]; {cloudflare.Id:B}; 9.9.9.9", input);
+ Assert.True(DNSServer.TryDecodeReferences(input, Profiles, out var decoded));
+ Assert.Equal("[Windows DNS]; Cloudflare; 9.9.9.9", decoded);
+ }
+
+ [Theory]
+ [InlineData("9.9.9.9")]
+ [InlineData("[Windows DNS]")]
+ public void EncodeReferences_NullWithoutProfileReference(string input)
+ {
+ Assert.Null(DNSServer.EncodeReferences(input, Profiles));
+ }
+
+ [Fact]
+ public void DecodeReferences_FalseIfProfileIsMissing()
+ {
+ Assert.False(DNSServer.TryDecodeReferences($"{System.Guid.NewGuid():B}", Profiles, out var decoded));
+ Assert.Null(decoded);
+ }
+
+ [Fact]
+ public void ReplaceProfilesByServers()
+ {
+ Assert.Equal("[Windows DNS]; 1.1.1.1:53; 1.0.0.1:53; 9.9.9.9",
+ DNSServer.ReplaceProfilesByServers("[Windows DNS]; Cloudflare; 9.9.9.9", Profiles));
+ }
+
+ [Theory]
+ [InlineData("Cloudflare", false, 0)]
+ [InlineData("google public dns;9.9.9.9", true, 0)]
+ [InlineData("dns01", false, 0)]
+ [InlineData("dns01", true, 1)]
+ [InlineData("Cloudflare;aaa", true, 1)]
+ [InlineData("", false, 1)]
+ public void GetInvalidEntries(string input, bool hostnameNeedsDomain, int expectedCount)
+ {
+ Assert.Equal(expectedCount, DNSServer.GetInvalidEntries(input, Profiles, hostnameNeedsDomain).Count);
+ }
+}
diff --git a/Source/NETworkManager.Tests/Network/NamedReferenceInputTests.cs b/Source/NETworkManager.Tests/Network/NamedReferenceInputTests.cs
new file mode 100644
index 0000000000..edf2fd06cc
--- /dev/null
+++ b/Source/NETworkManager.Tests/Network/NamedReferenceInputTests.cs
@@ -0,0 +1,95 @@
+๏ปฟusing System;
+using NETworkManager.Models.Network;
+using Xunit;
+
+namespace NETworkManager.Tests.Network;
+
+public class NamedReferenceInputTests
+{
+ private sealed class Item(Guid id, string name) : INamedReference
+ {
+ public Guid Id { get; } = id;
+ public string Name { get; } = name;
+ }
+
+ private static readonly Item Alpha = new(Guid.Parse("11111111-1111-1111-1111-111111111111"), "Alpha");
+ private static readonly Item Beta = new(Guid.Parse("22222222-2222-2222-2222-222222222222"), " Beta ");
+ private static readonly Item WithoutId = new(Guid.Empty, "Legacy");
+
+ private static readonly Item[] Items = [Alpha, Beta, WithoutId];
+
+ private static Item Find(string entry) => NamedReferenceInput.FindByName(Items, entry);
+
+ [Theory]
+ [InlineData("Alpha", "Alpha")]
+ [InlineData("alpha", "Alpha")]
+ [InlineData(" ALPHA ", "Alpha")]
+ [InlineData("Beta", " Beta ")]
+ public void FindByName_IgnoresCaseAndSpaces(string name, string expected)
+ {
+ Assert.Equal(expected, NamedReferenceInput.FindByName(Items, name)?.Name);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("Gamma")]
+ public void FindByName_NullIfNotFound(string name)
+ {
+ Assert.Null(NamedReferenceInput.FindByName(Items, name));
+ }
+
+ [Fact]
+ public void EncodeReferences_ReplacesNamesAndTrimsOtherEntries()
+ {
+ Assert.Equal("{11111111-1111-1111-1111-111111111111}; 8080; {22222222-2222-2222-2222-222222222222}",
+ NamedReferenceInput.EncodeReferences(" alpha ;8080 ; Beta", Find));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(" ")]
+ [InlineData("8080; Gamma")]
+ [InlineData("Legacy")]
+ public void EncodeReferences_NullWithoutReference(string input)
+ {
+ Assert.Null(NamedReferenceInput.EncodeReferences(input, Find));
+ }
+
+ [Fact]
+ public void EncodeReferences_KeepsEntriesSkippedByFindItem()
+ {
+ Assert.Equal("Alpha; {22222222-2222-2222-2222-222222222222}",
+ NamedReferenceInput.EncodeReferences("Alpha; Beta", entry => entry.Trim() == "Alpha" ? null : Find(entry)));
+ }
+
+ [Fact]
+ public void DecodeReferences_RoundTrip()
+ {
+ var encoded = NamedReferenceInput.EncodeReferences("Alpha; 8080", Find);
+
+ Assert.True(NamedReferenceInput.TryDecodeReferences(encoded, Items, out var input));
+ Assert.Equal("Alpha; 8080", input);
+ }
+
+ [Fact]
+ public void DecodeReferences_CurrentName()
+ {
+ var renamed = new Item(Alpha.Id, "Alpha (renamed)");
+
+ Assert.True(NamedReferenceInput.TryDecodeReferences("{11111111-1111-1111-1111-111111111111}; 8080 ",
+ [renamed], out var input));
+ Assert.Equal("Alpha (renamed); 8080", input);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("{33333333-3333-3333-3333-333333333333}; 8080")]
+ public void DecodeReferences_FalseIfEmptyOrItemIsMissing(string inputWithReferences)
+ {
+ Assert.False(NamedReferenceInput.TryDecodeReferences(inputWithReferences, Items, out var input));
+ Assert.Null(input);
+ }
+}
diff --git a/Source/NETworkManager.Tests/Network/PortProfileTests.cs b/Source/NETworkManager.Tests/Network/PortProfileTests.cs
new file mode 100644
index 0000000000..a20fccd9a2
--- /dev/null
+++ b/Source/NETworkManager.Tests/Network/PortProfileTests.cs
@@ -0,0 +1,92 @@
+๏ปฟusing System;
+using NETworkManager.Models.Network;
+using Xunit;
+
+namespace NETworkManager.Tests.Network;
+
+public class PortProfileTests
+{
+ private static readonly PortProfileInfo[] PortProfiles =
+ [
+ new("Webserver", "80; 443"),
+ new("Well-known ports", "1-1024"),
+ new("Database", "1433-1434; 3306")
+ ];
+
+ [Theory]
+ [InlineData("80", "80")]
+ [InlineData("80; 443; 1-1024", "80; 443; 1-1024")]
+ [InlineData("Webserver", "80; 443")]
+ [InlineData("webserver", "80; 443")]
+ [InlineData(" Webserver ;8080", "80; 443; 8080")]
+ [InlineData("Webserver;Database;22", "80; 443; 1433-1434; 3306; 22")]
+ [InlineData("Well-known ports; 8080", "1-1024; 8080")]
+ public void TryResolvePorts_Valid(string input, string expectedPorts)
+ {
+ Assert.True(PortProfile.TryResolvePorts(input, PortProfiles, out var ports));
+ Assert.Equal(expectedPorts, ports);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("Unknown")]
+ [InlineData("Webserver; Unknown")]
+ [InlineData("80;")]
+ [InlineData("70000")]
+ public void TryResolvePorts_Invalid(string input)
+ {
+ Assert.False(PortProfile.TryResolvePorts(input, PortProfiles, out var ports));
+ Assert.Null(ports);
+ }
+
+ [Fact]
+ public void EncodeReferences_ReplacesNamesById()
+ {
+ var input = PortProfile.EncodeReferences("webserver; 8080; Database", PortProfiles);
+
+ Assert.Equal($"{PortProfiles[0].Id:B}; 8080; {PortProfiles[2].Id:B}", input);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("22; 80")]
+ [InlineData("Unknown")]
+ public void EncodeReferences_NullWithoutPortProfile(string input)
+ {
+ Assert.Null(PortProfile.EncodeReferences(input, PortProfiles));
+ }
+
+ [Fact]
+ public void DecodeReferences_CurrentNameAfterRename()
+ {
+ var portProfile = new PortProfileInfo("Web", "80");
+ var input = PortProfile.EncodeReferences("Web; 22", [portProfile]);
+
+ portProfile.Name = "Web (renamed)";
+
+ Assert.True(PortProfile.TryDecodeReferences(input, [portProfile], out var decoded));
+ Assert.Equal("Web (renamed); 22", decoded);
+ }
+
+ [Fact]
+ public void DecodeReferences_FalseIfPortProfileIsMissing()
+ {
+ Assert.False(PortProfile.TryDecodeReferences($"{Guid.NewGuid():B}; 22", PortProfiles, out var decoded));
+ Assert.Null(decoded);
+ }
+
+ [Theory]
+ [InlineData("Webserver", true)]
+ [InlineData("Well-known ports", true)]
+ [InlineData("Web; Mail", false)]
+ [InlineData("80", false)]
+ [InlineData("1-1024", false)]
+ [InlineData(" ", false)]
+ [InlineData(null, false)]
+ public void IsValidName(string name, bool isValid)
+ {
+ Assert.Equal(isValid, PortProfile.IsValidName(name));
+ }
+}
diff --git a/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
new file mode 100644
index 0000000000..20f87c4a5f
--- /dev/null
+++ b/Source/NETworkManager.Tests/Network/WebConsoleUrlHelperTests.cs
@@ -0,0 +1,56 @@
+๏ปฟusing System;
+using NETworkManager.Models.Network;
+using Xunit;
+
+namespace NETworkManager.Tests.Network;
+
+public class WebConsoleUrlHelperTests
+{
+ [Theory]
+ [InlineData("server.example.com", 80, "http://server.example.com/")]
+ [InlineData("server.example.com", 443, "https://server.example.com/")]
+ [InlineData("10.0.0.1", 8080, "http://10.0.0.1:8080/")]
+ [InlineData("10.0.0.1", 8443, "https://10.0.0.1:8443/")]
+ [InlineData("2001:db8::1", 443, "https://[2001:db8::1]/")]
+ [InlineData("fe80::1%12", 8080, "http://[fe80::1%2512]:8080/")]
+ [InlineData("gateway.", 80, "http://gateway/")]
+ [InlineData("gateway.", 8443, "https://gateway:8443/")]
+ public void CreatesUrlForPort(string host, int port, string expected)
+ {
+ Assert.Equal(expected, WebConsoleUrlHelper.CreateUrl(host, port));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(".")]
+ public void ThrowsWithoutHost(string host)
+ {
+ Assert.ThrowsAny(() => WebConsoleUrlHelper.CreateUrl(host, 80));
+ Assert.ThrowsAny(() => WebConsoleUrlHelper.CreateUrl(host, [80]));
+ }
+
+ [Theory]
+ [InlineData(new[] { 22, 80, 443 }, "https://10.0.0.1/")]
+ [InlineData(new[] { 80, 8443 }, "http://10.0.0.1/")]
+ [InlineData(new[] { 22, 8443, 8080 }, "https://10.0.0.1:8443/")]
+ [InlineData(new[] { 8080 }, "http://10.0.0.1:8080/")]
+ [InlineData(new[] { 22, 3389 }, "http://10.0.0.1/")]
+ [InlineData(new int[0], "http://10.0.0.1/")]
+ public void PrefersHttpsAndDefaultPorts(int[] openPorts, string expected)
+ {
+ Assert.Equal(expected, WebConsoleUrlHelper.CreateUrl("10.0.0.1", openPorts));
+ }
+
+ [Theory]
+ [InlineData(80, true)]
+ [InlineData(443, true)]
+ [InlineData(8080, true)]
+ [InlineData(8443, true)]
+ [InlineData(22, false)]
+ public void DetectsWebPorts(int port, bool expected)
+ {
+ Assert.Equal(expected, WebConsoleUrlHelper.IsWebPort(port));
+ }
+}
diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs
index 401d52c4b7..a636e37b29 100644
--- a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs
+++ b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs
@@ -243,6 +243,66 @@ public void PortsWithWhitespace()
Assert.Equal(RunCommandParseError.None, result.Error);
}
+ [Theory]
+ [InlineData("portscan 10.8.0.1 \"DNS (via TCP)\";Webs")]
+ [InlineData("portscan 10.8.0.1 \"DNS (via TCP)\" ; Webs")]
+ public void ListWithAQuotedEntry(string input)
+ {
+ // A value in quotes is an entry of the list, not a word on its own
+ var result = Parse(input);
+
+ Assert.Equal("DNS (via TCP);Webs", result.Argument);
+ Assert.Equal("DNS (via TCP);Webs", result.CurrentWord);
+ Assert.Equal("\"DNS (via TCP)\";Webs", result.CurrentToken);
+ Assert.Equal("portscan 10.8.0.1 ", result.InputBeforeCurrentWord);
+ Assert.Equal(RunCommandParseError.None, result.Error);
+ }
+
+ [Fact]
+ public void ListWithAQuotedEntryAndType()
+ {
+ var result = Parse("dns borntoberoot.net \"Google Public DNS\";1.1.1.1 aaaa");
+
+ Assert.Equal("Google Public DNS;1.1.1.1", result.Argument);
+ Assert.Equal("aaaa", result.Type);
+ }
+
+ [Theory]
+ [InlineData("dns borntoberoot.net \"Google Public DNS\";cloudflare;9.9.9.9 aaaa")]
+ [InlineData("dns borntoberoot.net \"Google Public DNS\"; cloudflare; 9.9.9.9 aaaa")]
+ [InlineData("dns borntoberoot.net aaaa \"Google Public DNS\" ; cloudflare;9.9.9.9")]
+ public void DNSServerListWithNamesAndType(string input)
+ {
+ // Several DNS servers: names (one in quotes) and an IP address, the type before or after them
+ var result = Parse(input);
+
+ Assert.Equal("borntoberoot.net", result.Target);
+ Assert.Equal("Google Public DNS;cloudflare;9.9.9.9", result.Argument);
+ Assert.Equal("aaaa", result.Type);
+ Assert.Equal(RunCommandParseError.None, result.Error);
+ }
+
+ [Fact]
+ public void DNSServerListThatIsBeingTyped()
+ {
+ // Completion of the entry after the last ";", the entries before as typed
+ var result = Parse("dns borntoberoot.net \"Google Public DNS\";clo");
+
+ Assert.Equal(RunCommandPosition.Argument, result.Position);
+ Assert.Equal("Google Public DNS;clo", result.CurrentWord);
+ Assert.Equal("\"Google Public DNS\";clo", result.CurrentToken);
+ Assert.Equal("dns borntoberoot.net ", result.InputBeforeCurrentWord);
+ }
+
+ [Fact]
+ public void ListInAnOpenQuote()
+ {
+ var result = Parse("portscan 10.8.0.1 \"DNS (via TCP); Webs");
+
+ Assert.Equal("DNS (via TCP); Webs", result.CurrentWord);
+ Assert.Equal("\"DNS (via TCP); Webs", result.CurrentToken);
+ }
+
[Fact]
public void TargetThatLooksLikeAType()
{
diff --git a/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs b/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs
new file mode 100644
index 0000000000..e098986362
--- /dev/null
+++ b/Source/NETworkManager.Tests/Utilities/DNSClientHelperTests.cs
@@ -0,0 +1,50 @@
+๏ปฟusing NETworkManager.Utilities;
+using Xunit;
+
+namespace NETworkManager.Tests.Utilities;
+
+public class DNSClientHelperTests
+{
+ [Theory]
+ [InlineData("server.example.com.", "server.example.com")]
+ [InlineData("server.example.com", "server.example.com")]
+ [InlineData("server.example.com..", "server.example.com")]
+ public void TrimsTrailingDotOfFullyQualifiedName(string name, string expected)
+ {
+ Assert.Equal(expected, DNSClientHelper.TrimTrailingDot(name));
+ }
+
+ [Theory]
+ [InlineData("gateway.")]
+ [InlineData("gateway")]
+ public void KeepsSingleLabelName(string name)
+ {
+ Assert.Equal(name, DNSClientHelper.TrimTrailingDot(name));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void KeepsNullOrEmpty(string name)
+ {
+ Assert.Equal(name, DNSClientHelper.TrimTrailingDot(name));
+ }
+
+ [Theory]
+ [InlineData("gateway.", "gateway")]
+ [InlineData("server.example.com.", "server.example.com")]
+ [InlineData("gateway", "gateway")]
+ [InlineData(".", "")]
+ [InlineData("", "")]
+ [InlineData(null, null)]
+ public void SanitizesHostname(string hostname, string expected)
+ {
+ Assert.Equal(expected, DNSClientHelper.SanitizeHostname(hostname));
+ }
+
+ [Fact]
+ public void TrimmedNameIsNotBareHostname()
+ {
+ Assert.False(DNSClientHelper.IsBareHostname(DNSClientHelper.TrimTrailingDot("gateway.")));
+ }
+}
diff --git a/Source/NETworkManager.Tests/Utilities/ListEntryCompletionHelperTests.cs b/Source/NETworkManager.Tests/Utilities/ListEntryCompletionHelperTests.cs
new file mode 100644
index 0000000000..943639d56e
--- /dev/null
+++ b/Source/NETworkManager.Tests/Utilities/ListEntryCompletionHelperTests.cs
@@ -0,0 +1,54 @@
+๏ปฟusing NETworkManager.Utilities;
+using Xunit;
+
+namespace NETworkManager.Tests.Utilities;
+
+public class ListEntryCompletionHelperTests
+{
+ private static readonly string[] Names = ["Webserver", "Webserver (Other)", "Database", "DNS (via TCP)", "Mailserver"];
+
+ [Theory]
+ [InlineData("", "", "")]
+ [InlineData("dat", "", "dat")]
+ [InlineData("webserver; dat", "webserver; ", "dat")]
+ [InlineData("webserver;dat", "webserver;", "dat")]
+ [InlineData("webserver; ", "webserver; ", "")]
+ [InlineData("22; 80; Mail", "22; 80; ", "Mail")]
+ public void SplitLastEntry(string text, string expectedPrefix, string expectedEntry)
+ {
+ var (prefix, entry) = ListEntryCompletionHelper.SplitLastEntry(text);
+
+ Assert.Equal(expectedPrefix, prefix);
+ Assert.Equal(expectedEntry, entry);
+ }
+
+ [Fact]
+ public void GetCandidates_StartsWithBeforeContains()
+ {
+ // No name starts with "server", all three contain it
+ Assert.Equal(["Mailserver", "Webserver", "Webserver (Other)"],
+ ListEntryCompletionHelper.GetCandidates("server", Names));
+
+ Assert.Equal(["Webserver", "Webserver (Other)"], ListEntryCompletionHelper.GetCandidates("web", Names));
+ Assert.Equal(["Database", "DNS (via TCP)"], ListEntryCompletionHelper.GetCandidates("d", Names));
+
+ // Case-insensitive, also in the middle of a name
+ Assert.Equal(["DNS (via TCP)"], ListEntryCompletionHelper.GetCandidates("tcp", Names));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ [InlineData("xyz")]
+ public void GetCandidates_None(string entry)
+ {
+ Assert.Empty(ListEntryCompletionHelper.GetCandidates(entry, Names));
+ }
+
+ [Fact]
+ public void GetCandidates_DuplicateNamesOnce()
+ {
+ Assert.Equal(["Webserver"], ListEntryCompletionHelper.GetCandidates("web", ["Webserver", "webserver"]));
+ }
+}
diff --git a/Source/NETworkManager.Tests/Utilities/PortRangeHelperTests.cs b/Source/NETworkManager.Tests/Utilities/PortRangeHelperTests.cs
index 5d841aaeec..99a62b34c7 100644
--- a/Source/NETworkManager.Tests/Utilities/PortRangeHelperTests.cs
+++ b/Source/NETworkManager.Tests/Utilities/PortRangeHelperTests.cs
@@ -28,4 +28,19 @@ public void IsValidPortRange(int startPort, int endPort, bool isValid)
{
Assert.Equal(isValid, PortRangeHelper.IsValidPortRange(startPort, endPort));
}
+
+ [Theory]
+ [InlineData("80", true)]
+ [InlineData(" 80 ", true)]
+ [InlineData("1-1024", true)]
+ [InlineData("1 - 1024", true)]
+ [InlineData("0", false)]
+ [InlineData("1024-1", false)]
+ [InlineData("1-2-3", false)]
+ [InlineData("", false)]
+ [InlineData("Webserver", false)]
+ public void IsValidPortOrPortRange(string value, bool isValid)
+ {
+ Assert.Equal(isValid, PortRangeHelper.IsValidPortOrPortRange(value));
+ }
}
diff --git a/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs b/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs
index 1d2aaf1ba6..906a0253e1 100644
--- a/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs
+++ b/Source/NETworkManager.Utilities.WPF/DataGridHelper.cs
@@ -1,6 +1,10 @@
using System;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Linq;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Media;
using System.Windows.Threading;
namespace NETworkManager.Utilities.WPF;
@@ -10,51 +14,319 @@ namespace NETworkManager.Utilities.WPF;
///
public static class DataGridHelper
{
+ #region FillRemainingWidth
+
///
- /// When set to , forces star-sized columns to recompute their width after
- /// the first row is realized. Without this, an initially empty sizes star
- /// columns to MinWidth because the inner
- /// measures with infinite width; only a window resize would otherwise trigger a correct re-measure.
- /// The handler unsubscribes itself after the first row so there is no ongoing overhead.
+ /// When set to , a column fills the remaining width of the - like a
+ /// star (*) column, but with a pixel width: the visible column marked with ,
+ /// otherwise the last visible column. With a visible star column the
+ /// DataGrid only lets a column grow by taking the width from the columns to its right (down to their
+ /// MinWidth), so a column can't be resized wider than the grid (also not with a double-click on the
+ /// gripper), there is never a horizontal scrollbar (#3557). Without star columns a column grows freely and the
+ /// grid scrolls horizontally.
+ ///
+ /// The fill column gets max(base width, available width - other visible columns). The base width is its
+ /// MinWidth, or the width the user resized it to. It is updated when the size of the grid / its viewport
+ /// changes and when another column is resized or shown / hidden. A column that no longer fills (e.g. hidden)
+ /// gets its original width back. Don't use star columns in the same grid.
+ ///
///
- public static readonly DependencyProperty RefreshStarColumnsOnFirstRowProperty =
+ public static readonly DependencyProperty FillRemainingWidthProperty =
DependencyProperty.RegisterAttached(
- "RefreshStarColumnsOnFirstRow",
+ "FillRemainingWidth",
typeof(bool),
typeof(DataGridHelper),
- new PropertyMetadata(false, OnRefreshStarColumnsOnFirstRowChanged));
+ new PropertyMetadata(false, OnFillRemainingWidthChanged));
+
+ public static void SetFillRemainingWidth(DataGrid element, bool value) =>
+ element.SetValue(FillRemainingWidthProperty, value);
- public static void SetRefreshStarColumnsOnFirstRow(DataGrid element, bool value) =>
- element.SetValue(RefreshStarColumnsOnFirstRowProperty, value);
+ public static bool GetFillRemainingWidth(DataGrid element) =>
+ (bool)element.GetValue(FillRemainingWidthProperty);
+
+ ///
+ /// Marks the column that fills the remaining width (see ), if it isn't the
+ /// last column (e.g. a description in the middle of the columns). While it is hidden, the last visible column
+ /// fills.
+ ///
+ public static readonly DependencyProperty IsFillColumnProperty =
+ DependencyProperty.RegisterAttached(
+ "IsFillColumn",
+ typeof(bool),
+ typeof(DataGridHelper),
+ new PropertyMetadata(false));
+
+ public static void SetIsFillColumn(DataGridColumn element, bool value) =>
+ element.SetValue(IsFillColumnProperty, value);
+
+ public static bool GetIsFillColumn(DataGridColumn element) =>
+ (bool)element.GetValue(IsFillColumnProperty);
+
+ // State of a grid with FillRemainingWidth (subscriptions, current fill column)
+ private static readonly DependencyProperty FillStateProperty =
+ DependencyProperty.RegisterAttached(
+ "FillState",
+ typeof(FillState),
+ typeof(DataGridHelper),
+ new PropertyMetadata(null));
- public static bool GetRefreshStarColumnsOnFirstRow(DataGrid element) =>
- (bool)element.GetValue(RefreshStarColumnsOnFirstRowProperty);
+ private static readonly DependencyPropertyDescriptor ColumnActualWidthDescriptor =
+ DependencyPropertyDescriptor.FromProperty(DataGridColumn.ActualWidthProperty, typeof(DataGridColumn));
- private static void OnRefreshStarColumnsOnFirstRowChanged(DependencyObject d,
- DependencyPropertyChangedEventArgs e)
+ private static readonly DependencyPropertyDescriptor ColumnVisibilityDescriptor =
+ DependencyPropertyDescriptor.FromProperty(DataGridColumn.VisibilityProperty, typeof(DataGridColumn));
+
+ private static void OnFillRemainingWidthChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
- if (d is not DataGrid dataGrid || e.NewValue is not bool enabled || !enabled)
+ if (d is not DataGrid dataGrid)
return;
- dataGrid.LoadingRow += Handler;
- return;
+ if (e.NewValue is true)
+ {
+ var state = new FillState(dataGrid);
+
+ dataGrid.SetValue(FillStateProperty, state);
+
+ dataGrid.Loaded += state.OnLoaded;
+ dataGrid.Unloaded += state.OnUnloaded;
+
+ if (dataGrid.IsLoaded)
+ state.Attach();
+ }
+ else if (dataGrid.GetValue(FillStateProperty) is FillState state)
+ {
+ dataGrid.Loaded -= state.OnLoaded;
+ dataGrid.Unloaded -= state.OnUnloaded;
+
+ state.Detach();
+
+ dataGrid.ClearValue(FillStateProperty);
+ }
+ }
+
+ ///
+ /// Subscriptions and the current fill column of a grid. The column change notifications
+ /// () hold strong references, so they are only
+ /// subscribed while the grid is loaded.
+ ///
+ private sealed class FillState(DataGrid dataGrid)
+ {
+ private ScrollViewer _scrollViewer;
+ private DataGridColumn[] _columns = [];
+
+ // Current fill column, its original width (restored when another column fills) and base width
+ private DataGridColumn _fillColumn;
+ private DataGridLength _fillColumnOriginalWidth;
+ private double _fillColumnBaseWidth;
+
+ // Width set by Update - another width of the fill column was set by the user (resize / double-click)
+ private double _lastSetWidth = double.NaN;
+ private bool _isUpdating;
+ private bool _isUpdateQueued;
+
+ // Width set before the last one and when, to detect switching between two widths (see Update)
+ private double _secondLastSetWidth = double.NaN;
+ private long _secondLastSetTimestamp;
+ private bool _isSizeChanged;
+
+ // Switching back to the second last width within this time is a loop, not a change of the user
+ private static readonly TimeSpan OscillationTime = TimeSpan.FromMilliseconds(500);
+
+ public void OnLoaded(object sender, RoutedEventArgs e) => Attach();
+
+ public void OnUnloaded(object sender, RoutedEventArgs e) => Detach();
+
+ public void Attach()
+ {
+ Detach();
+
+ _columns = [.. dataGrid.Columns];
+
+ foreach (var column in _columns)
+ {
+ ColumnActualWidthDescriptor.AddValueChanged(column, OnColumnChanged);
+ ColumnVisibilityDescriptor.AddValueChanged(column, OnColumnChanged);
+ }
+
+ dataGrid.SizeChanged += OnSizeChanged;
+
+ _scrollViewer = FindScrollViewer(dataGrid);
+
+ if (_scrollViewer != null)
+ _scrollViewer.ScrollChanged += OnScrollChanged;
+
+ QueueUpdate();
+ }
+
+ public void Detach()
+ {
+ foreach (var column in _columns)
+ {
+ ColumnActualWidthDescriptor.RemoveValueChanged(column, OnColumnChanged);
+ ColumnVisibilityDescriptor.RemoveValueChanged(column, OnColumnChanged);
+ }
+
+ _columns = [];
+
+ dataGrid.SizeChanged -= OnSizeChanged;
+
+ if (_scrollViewer != null)
+ _scrollViewer.ScrollChanged -= OnScrollChanged;
- void Handler(object sender, DataGridRowEventArgs args)
+ _scrollViewer = null;
+ }
+
+ private void OnSizeChanged(object sender, SizeChangedEventArgs e)
{
- dataGrid.LoadingRow -= Handler;
+ _isSizeChanged = true;
+
+ QueueUpdate();
+ }
+
+ private void OnScrollChanged(object sender, ScrollChangedEventArgs e)
+ {
+ // Viewport width changes e.g. when the vertical scrollbar appears; extent changes are caused by the
+ // columns (handled via OnColumnChanged)
+ if (!DoubleUtilIsZero(e.ViewportWidthChange))
+ QueueUpdate();
+ }
+
+ private void OnColumnChanged(object sender, EventArgs e)
+ {
+ if (_isUpdating)
+ return;
+
+ // The user resized the fill column (or double-clicked its gripper): that is its new base width
+ if (ReferenceEquals(sender, _fillColumn) && !double.IsNaN(_lastSetWidth) &&
+ Math.Abs(_fillColumn.ActualWidth - _lastSetWidth) > 0.5)
+ _fillColumnBaseWidth = _fillColumn.ActualWidth;
+
+ QueueUpdate();
+ }
+
+ // Several changes in one layout pass (e.g. all columns while rows are added) -> one update
+ private void QueueUpdate()
+ {
+ if (_isUpdateQueued)
+ return;
+
+ _isUpdateQueued = true;
dataGrid.Dispatcher.BeginInvoke(new Action(() =>
{
- foreach (var column in dataGrid.Columns)
+ _isUpdateQueued = false;
+ Update();
+ }), DispatcherPriority.Loaded);
+ }
+
+ private void Update()
+ {
+ var isSizeChanged = _isSizeChanged;
+ _isSizeChanged = false;
+
+ if (!dataGrid.IsLoaded)
+ return;
+
+ var visibleColumns = dataGrid.Columns
+ .Where(x => x.Visibility == Visibility.Visible)
+ .OrderBy(x => x.DisplayIndex)
+ .ToList();
+
+ // Marked fill column, otherwise the last visible column
+ var fillColumn = visibleColumns.FirstOrDefault(GetIsFillColumn) ?? visibleColumns.LastOrDefault();
+
+ _isUpdating = true;
+
+ try
+ {
+ if (!ReferenceEquals(fillColumn, _fillColumn))
{
- if (!column.Width.IsStar)
- continue;
+ // Give the previous fill column its original width back
+ if (_fillColumn != null && dataGrid.Columns.Contains(_fillColumn))
+ _fillColumn.Width = _fillColumnOriginalWidth;
+
+ _fillColumn = fillColumn;
+ _lastSetWidth = double.NaN;
+ _secondLastSetWidth = double.NaN;
- var width = column.Width;
- column.Width = 0;
- column.Width = width;
+ if (_fillColumn == null)
+ return;
+
+ _fillColumnOriginalWidth = _fillColumn.Width;
+ _fillColumnBaseWidth = _fillColumn.MinWidth;
}
- }), DispatcherPriority.ContextIdle);
+
+ if (_fillColumn == null)
+ return;
+
+ var availableWidth = GetAvailableWidth();
+
+ if (availableWidth <= 0)
+ return;
+
+ var otherColumnsWidth = dataGrid.Columns
+ .Where(x => x.Visibility == Visibility.Visible && !ReferenceEquals(x, _fillColumn))
+ .Sum(x => x.ActualWidth);
+
+ // Floor - a fraction of a pixel too much would show the horizontal scrollbar
+ var width = Math.Max(_fillColumnBaseWidth, Math.Floor(availableWidth - otherColumnsWidth));
+
+ width = Math.Min(Math.Max(width, _fillColumn.MinWidth), _fillColumn.MaxWidth);
+
+ // Back to the wider width of two updates ago right away, without a size change of the grid: the
+ // widths switch in a loop (e.g. a horizontal scrollbar makes the vertical one appear, the narrower
+ // column removes both, the wider column shows the horizontal scrollbar again ...) - each update is
+ // queued before input, the window would hang. Keep the narrower width (no horizontal scrollbar).
+ if (!isSizeChanged && width > _lastSetWidth && Math.Abs(width - _secondLastSetWidth) < 0.5 &&
+ Stopwatch.GetElapsedTime(_secondLastSetTimestamp) < OscillationTime)
+ width = _lastSetWidth;
+
+ if (!double.IsNaN(_lastSetWidth) && Math.Abs(width - _lastSetWidth) < 0.5 &&
+ Math.Abs(_fillColumn.ActualWidth - width) < 0.5)
+ return;
+
+ _secondLastSetWidth = _lastSetWidth;
+ _secondLastSetTimestamp = Stopwatch.GetTimestamp();
+ _lastSetWidth = width;
+ _fillColumn.Width = new DataGridLength(width);
+ }
+ finally
+ {
+ _isUpdating = false;
+ }
+ }
+
+ ///
+ /// Gets the width available for the columns: the viewport of the grid's scroll viewer (without the vertical
+ /// scrollbar) minus the row headers.
+ ///
+ private double GetAvailableWidth()
+ {
+ var width = _scrollViewer is { ViewportWidth: > 0 } ? _scrollViewer.ViewportWidth : dataGrid.ActualWidth;
+
+ return width - dataGrid.CellsPanelHorizontalOffset;
+ }
+
+ private static bool DoubleUtilIsZero(double value) => Math.Abs(value) < 0.01;
+
+ private static ScrollViewer FindScrollViewer(DependencyObject parent)
+ {
+ for (var i = 0; i < System.Windows.Media.VisualTreeHelper.GetChildrenCount(parent); i++)
+ {
+ var child = System.Windows.Media.VisualTreeHelper.GetChild(parent, i);
+
+ if (child is ScrollViewer scrollViewer)
+ return scrollViewer;
+
+ var result = FindScrollViewer(child);
+
+ if (result != null)
+ return result;
+ }
+
+ return null;
}
}
+
+ #endregion
}
diff --git a/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj b/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj
index 34ffbec737..59c1349ae6 100644
--- a/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj
+++ b/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj
@@ -16,4 +16,7 @@
+
+
+
diff --git a/Source/NETworkManager.Utilities.WPF/SearchDelay.cs b/Source/NETworkManager.Utilities.WPF/SearchDelay.cs
new file mode 100644
index 0000000000..d4b02977c1
--- /dev/null
+++ b/Source/NETworkManager.Utilities.WPF/SearchDelay.cs
@@ -0,0 +1,56 @@
+๏ปฟusing System;
+using System.Windows.Input;
+using System.Windows.Threading;
+
+namespace NETworkManager.Utilities.WPF;
+
+///
+/// Delays a search while the user is typing, so a large list is not filtered on every key press:
+/// on every change of the search text, the search is applied once the user stopped typing
+/// for the interval - or right away with (e.g. Enter in the search box, see
+/// ).
+///
+public sealed class SearchDelay
+{
+ private readonly DispatcherTimer _timer;
+ private readonly Action _applySearch;
+
+ ///
+ /// Creates a new search delay. Must be created on the UI thread.
+ ///
+ /// Time without a change after which the search is applied.
+ /// Applies the search (e.g. refreshes the filtered view).
+ public SearchDelay(TimeSpan interval, Action applySearch)
+ {
+ _applySearch = applySearch;
+
+ _timer = new DispatcherTimer { Interval = interval };
+ _timer.Tick += (_, _) => Apply();
+
+ ApplyCommand = new RelayCommand(_ => Apply());
+ }
+
+ ///
+ /// Gets the command to apply the search right away (see ), e.g. bound to Enter in the search
+ /// box.
+ ///
+ public ICommand ApplyCommand { get; }
+
+ ///
+ /// Starts the delay again (the search text was changed).
+ ///
+ public void Restart()
+ {
+ _timer.Stop();
+ _timer.Start();
+ }
+
+ ///
+ /// Applies the search right away, a pending delay is stopped.
+ ///
+ public void Apply()
+ {
+ _timer.Stop();
+ _applySearch();
+ }
+}
diff --git a/Source/NETworkManager.Utilities.WPF/SearchTextBoxHelper.cs b/Source/NETworkManager.Utilities.WPF/SearchTextBoxHelper.cs
new file mode 100644
index 0000000000..419b2c496c
--- /dev/null
+++ b/Source/NETworkManager.Utilities.WPF/SearchTextBoxHelper.cs
@@ -0,0 +1,73 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+
+namespace NETworkManager.Utilities.WPF;
+
+///
+/// Attached-property helpers for search es: Enter in a search box only filters. It is
+/// handled, so it doesn't trigger the default button () of the view, dialog or window
+/// (e.g. start a scan or close a dialog with OK). With the command runs first, e.g.
+/// to filter right away instead of after the delay while typing.
+///
+public static class SearchTextBoxHelper
+{
+ ///
+ /// When set to , Enter in the text box is handled (see ).
+ ///
+ public static readonly DependencyProperty HandleEnterProperty =
+ DependencyProperty.RegisterAttached(
+ "HandleEnter",
+ typeof(bool),
+ typeof(SearchTextBoxHelper),
+ new PropertyMetadata(false, OnChanged));
+
+ public static void SetHandleEnter(TextBox element, bool value) =>
+ element.SetValue(HandleEnterProperty, value);
+
+ public static bool GetHandleEnter(TextBox element) =>
+ (bool)element.GetValue(HandleEnterProperty);
+
+ ///
+ /// Command executed on Enter in the text box (e.g. apply the search right away). Setting it also handles
+ /// Enter (see ).
+ ///
+ public static readonly DependencyProperty EnterCommandProperty =
+ DependencyProperty.RegisterAttached(
+ "EnterCommand",
+ typeof(ICommand),
+ typeof(SearchTextBoxHelper),
+ new PropertyMetadata(null, OnChanged));
+
+ public static void SetEnterCommand(TextBox element, ICommand value) =>
+ element.SetValue(EnterCommandProperty, value);
+
+ public static ICommand GetEnterCommand(TextBox element) =>
+ (ICommand)element.GetValue(EnterCommandProperty);
+
+ private static void OnChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not TextBox textBox)
+ return;
+
+ // Subscribe once, whichever of the two properties is set
+ textBox.PreviewKeyDown -= TextBox_PreviewKeyDown;
+
+ if (GetHandleEnter(textBox) || GetEnterCommand(textBox) != null)
+ textBox.PreviewKeyDown += TextBox_PreviewKeyDown;
+ }
+
+ private static void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key != Key.Enter || sender is not TextBox textBox)
+ return;
+
+ // Handled in the preview, so neither the default button nor other key bindings of the window get it
+ e.Handled = true;
+
+ var command = GetEnterCommand(textBox);
+
+ if (command?.CanExecute(null) == true)
+ command.Execute(null);
+ }
+}
diff --git a/Source/NETworkManager.Utilities/DNSClient.cs b/Source/NETworkManager.Utilities/DNSClient.cs
index a72103face..2e060feac6 100644
--- a/Source/NETworkManager.Utilities/DNSClient.cs
+++ b/Source/NETworkManager.Utilities/DNSClient.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
+using System.Threading;
using System.Threading.Tasks;
using log4net;
@@ -220,7 +221,7 @@ public async Task ResolveCnameAsync(string query)
/// Resolve a PTR for an IP address.
///
/// IP address of the host.
- /// PTR domain name.
+ /// PTR domain name without the trailing dot (e.g. "server.example.com"), see .
public async Task ResolvePtrAsync(IPAddress ipAddress)
{
if (!_isConfigured)
@@ -246,8 +247,11 @@ public async Task ResolvePtrAsync(IPAddress ipAddress)
// Validate result because of https://github.com/BornToBeRoot/NETworkManager/issues/1934
var record = result.Answers.PtrRecords().FirstOrDefault();
+ // The PTR domain name is fully qualified (trailing dot) - trimmed once here for every tool (grid,
+ // export, copy and when opened in another tool), except for single-label names
return record != null
- ? new DNSClientResultString(record.PtrDomainName, $"{result.NameServer}")
+ ? new DNSClientResultString(DNSClientHelper.TrimTrailingDot(record.PtrDomainName.Value),
+ $"{result.NameServer}")
: new DNSClientResultString(true,
$"PTR for \"{ipAddress}\" could not be resolved and the DNS server did not return an error. Try to check your DNS server with: dig @{result.NameServer.Address} -x {ipAddress}",
$"{result.NameServer}")
@@ -264,6 +268,19 @@ public async Task ResolvePtrAsync(IPAddress ipAddress)
}
}
+ ///
+ /// Resolve the hostname (PTR) of an IP address, e.g. for a scan where an error is not shown.
+ ///
+ /// IP address of the host.
+ /// Token to monitor for cancellation requests (stops waiting for the answer).
+ /// Hostname (see ) or an empty string if there is none or an error occurred.
+ public async Task ResolvePtrOrEmptyAsync(IPAddress ipAddress, CancellationToken cancellationToken)
+ {
+ var dnsResult = await ResolvePtrAsync(ipAddress).WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ return dnsResult.HasError ? string.Empty : dnsResult.Value;
+ }
+
///
/// Appends the configured DNS suffix to a bare hostname (forward lookups only).
/// FQDNs and IP literals are returned unchanged.
diff --git a/Source/NETworkManager.Utilities/DNSClientHelper.cs b/Source/NETworkManager.Utilities/DNSClientHelper.cs
index 0d1913eba6..90e0fa659d 100644
--- a/Source/NETworkManager.Utilities/DNSClientHelper.cs
+++ b/Source/NETworkManager.Utilities/DNSClientHelper.cs
@@ -21,6 +21,35 @@ public static bool IsBareHostname(string query)
return !string.IsNullOrEmpty(query) && !query.Contains('.') && !IPAddress.TryParse(query, out _);
}
+ ///
+ /// Removes the trailing dot of a fully qualified domain name (e.g. "server.example.com." ->
+ /// "server.example.com"). A single-label name (e.g. "gateway.") keeps it: without the dot it would be
+ /// a bare hostname (see ) and the DNS suffix would be appended when it is
+ /// resolved again by NETworkManager (e.g. when opened in Ping or Traceroute). For an application that
+ /// resolves it via Windows, see .
+ ///
+ /// Domain name, e.g. from a PTR record.
+ /// Domain name without the trailing dot, unless it is a single-label name.
+ public static string TrimTrailingDot(string name)
+ {
+ var trimmed = SanitizeHostname(name);
+
+ return trimmed != null && trimmed.Contains('.') ? trimmed : name;
+ }
+
+ ///
+ /// Prepares a hostname (e.g. from a PTR record) for an application that resolves it via Windows (e.g.
+ /// Remote Desktop, PuTTY, the browser of the Web Console or a custom command): also removes the trailing dot of
+ /// a single-label name (e.g. "gateway." -> "gateway"), so Windows resolves it like a name typed by the user
+ /// (DNS suffix search, LLMNR / NetBIOS) and it matches the name of a certificate (https).
+ ///
+ /// Hostname.
+ /// Hostname without the trailing dot (empty if it is only dots), null if it is null.
+ public static string SanitizeHostname(string hostname)
+ {
+ return hostname?.TrimEnd('.');
+ }
+
///
/// Detect the DNS suffix to use for suffix-appending. Prefers the Windows "Primary DNS Suffix"
/// (), falling back to the connection-specific suffix
diff --git a/Source/NETworkManager.Utilities/ListEntryCompletionHelper.cs b/Source/NETworkManager.Utilities/ListEntryCompletionHelper.cs
new file mode 100644
index 0000000000..9362636f69
--- /dev/null
+++ b/Source/NETworkManager.Utilities/ListEntryCompletionHelper.cs
@@ -0,0 +1,57 @@
+๏ปฟusing System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace NETworkManager.Utilities;
+
+///
+/// Completes the last entry of a list separated by ";" with a name (e.g. "webserver; dat" -> "webserver; Database"),
+/// used by the Tab completion of an input (e.g. the ports of the Port Scanner with port profile names).
+///
+public static class ListEntryCompletionHelper
+{
+ ///
+ /// Splits a list into the text before the last entry (as entered, with the separator and the whitespace after
+ /// it) and the last entry (e.g. "webserver; dat" -> "webserver; " and "dat").
+ ///
+ /// List separated by ";".
+ /// Text before the last entry and the last entry.
+ public static (string prefix, string entry) SplitLastEntry(string text)
+ {
+ text ??= string.Empty;
+
+ var start = text.LastIndexOf(';') + 1;
+
+ while (start < text.Length && char.IsWhiteSpace(text[start]))
+ start++;
+
+ return (text[..start], text[start..]);
+ }
+
+ ///
+ /// Gets the names an entry can be completed with: names that start with the entry first, then names that
+ /// contain it (case-insensitive, each sorted by name, every name once).
+ ///
+ /// Entry that is being typed.
+ /// Available names.
+ /// Names, empty if the entry is empty or nothing matches.
+ public static List GetCandidates(string entry, IEnumerable names)
+ {
+ var typed = entry?.Trim();
+
+ if (string.IsNullOrEmpty(typed))
+ return [];
+
+ var distinctNames = names.Where(x => !string.IsNullOrEmpty(x))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ return
+ [
+ .. distinctNames.Where(x => x.StartsWith(typed, StringComparison.OrdinalIgnoreCase)),
+ .. distinctNames.Where(x => !x.StartsWith(typed, StringComparison.OrdinalIgnoreCase) &&
+ x.Contains(typed, StringComparison.OrdinalIgnoreCase))
+ ];
+ }
+}
diff --git a/Source/NETworkManager.Utilities/PortRangeHelper.cs b/Source/NETworkManager.Utilities/PortRangeHelper.cs
index f53bf0de43..42c6666ec7 100644
--- a/Source/NETworkManager.Utilities/PortRangeHelper.cs
+++ b/Source/NETworkManager.Utilities/PortRangeHelper.cs
@@ -27,6 +27,24 @@ public static bool IsValidPortRange(int startPort, int endPort)
return IsValidPort(startPort) && IsValidPort(endPort) && startPort < endPort;
}
+ ///
+ /// Checks if the value is a port (e.g. "80") or a port range (e.g. "1-1024"), spaces are ignored.
+ ///
+ /// Value to check.
+ /// True if it is a valid port or port range.
+ public static bool IsValidPortOrPortRange(string value)
+ {
+ var parts = value.Replace(" ", "").Split('-');
+
+ return parts.Length switch
+ {
+ 1 => int.TryParse(parts[0], out var port) && IsValidPort(port),
+ 2 => int.TryParse(parts[0], out var startPort) && int.TryParse(parts[1], out var endPort) &&
+ IsValidPortRange(startPort, endPort),
+ _ => false
+ };
+ }
+
public static Task ConvertPortRangeToIntArrayAsync(string portRange)
{
return Task.Run(() => ConvertPortRangeToIntArray(portRange));
diff --git a/Source/NETworkManager.Validators/DNSServersValidator.cs b/Source/NETworkManager.Validators/DNSServersValidator.cs
new file mode 100644
index 0000000000..4976a50776
--- /dev/null
+++ b/Source/NETworkManager.Validators/DNSServersValidator.cs
@@ -0,0 +1,29 @@
+๏ปฟusing System.Globalization;
+using System.Windows.Controls;
+using NETworkManager.Localization.Resources;
+using NETworkManager.Models.Network;
+using NETworkManager.Settings;
+
+namespace NETworkManager.Validators;
+
+///
+/// Validates a DNS server input of DNS Lookup: names of DNS servers and DNS servers (IP address or hostname,
+/// optional port) separated by ";" (e.g. "Google Public DNS; 9.9.9.9"), see .
+///
+public class DNSServersValidator : ValidationRule
+{
+ ///
+ /// An empty input is valid (e.g. the DNS server of a profile).
+ ///
+ public bool AllowEmpty { get; set; }
+
+ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
+ {
+ if (AllowEmpty && string.IsNullOrWhiteSpace(value as string))
+ return ValidationResult.ValidResult;
+
+ return DNSServer.GetInvalidEntries(value as string, SettingsManager.Current.DNSLookup_DNSServers).Count == 0
+ ? ValidationResult.ValidResult
+ : new ValidationResult(false, Strings.EnterValidDNSServersOrDNSServerProfiles);
+ }
+}
diff --git a/Source/NETworkManager.Validators/NoSemicolonValidator.cs b/Source/NETworkManager.Validators/NoSemicolonValidator.cs
new file mode 100644
index 0000000000..f5e21b25c3
--- /dev/null
+++ b/Source/NETworkManager.Validators/NoSemicolonValidator.cs
@@ -0,0 +1,19 @@
+๏ปฟusing System.Globalization;
+using System.Windows.Controls;
+using NETworkManager.Localization.Resources;
+
+namespace NETworkManager.Validators;
+
+///
+/// Validates that a name doesn't contain ";" - e.g. the name of a DNS server, which can be entered in a list
+/// separated by ";".
+///
+public class NoSemicolonValidator : ValidationRule
+{
+ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
+ {
+ return (value as string)?.Contains(';') == true
+ ? new ValidationResult(false, Strings.ErrorMessage_NameMustNotContainSemicolon)
+ : ValidationResult.ValidResult;
+ }
+}
diff --git a/Source/NETworkManager.Validators/PortProfileNameValidator.cs b/Source/NETworkManager.Validators/PortProfileNameValidator.cs
new file mode 100644
index 0000000000..6831001206
--- /dev/null
+++ b/Source/NETworkManager.Validators/PortProfileNameValidator.cs
@@ -0,0 +1,20 @@
+๏ปฟusing System.Globalization;
+using System.Windows.Controls;
+using NETworkManager.Localization.Resources;
+using NETworkManager.Models.Network;
+
+namespace NETworkManager.Validators;
+
+///
+/// Validates the name of a port profile, see (the name can be entered in the
+/// ports input of the Port Scanner).
+///
+public class PortProfileNameValidator : ValidationRule
+{
+ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
+ {
+ return PortProfile.IsValidName(value as string)
+ ? ValidationResult.ValidResult
+ : new ValidationResult(false, Strings.ErrorMessage_PortProfileNameInvalid);
+ }
+}
diff --git a/Source/NETworkManager.Validators/PortsOrPortProfilesValidator.cs b/Source/NETworkManager.Validators/PortsOrPortProfilesValidator.cs
new file mode 100644
index 0000000000..b469329dfb
--- /dev/null
+++ b/Source/NETworkManager.Validators/PortsOrPortProfilesValidator.cs
@@ -0,0 +1,29 @@
+๏ปฟusing System.Globalization;
+using System.Windows.Controls;
+using NETworkManager.Localization.Resources;
+using NETworkManager.Models.Network;
+using NETworkManager.Settings;
+
+namespace NETworkManager.Validators;
+
+///
+/// Validates a ports input of the Port Scanner: ports, port ranges and names of port profiles separated by ";"
+/// (e.g. "Webserver; 8080"), see .
+///
+public class PortsOrPortProfilesValidator : ValidationRule
+{
+ ///
+ /// An empty input is valid (e.g. the ports of a profile).
+ ///
+ public bool AllowEmpty { get; set; }
+
+ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
+ {
+ if (AllowEmpty && string.IsNullOrWhiteSpace(value as string))
+ return ValidationResult.ValidResult;
+
+ return PortProfile.TryResolvePorts(value as string, SettingsManager.Current.PortScanner_PortProfiles, out _)
+ ? ValidationResult.ValidResult
+ : new ValidationResult(false, Strings.EnterValidPortsOrPortProfiles);
+ }
+}
diff --git a/Source/NETworkManager/InputSuggestions.cs b/Source/NETworkManager/InputSuggestions.cs
new file mode 100644
index 0000000000..630ec51630
--- /dev/null
+++ b/Source/NETworkManager/InputSuggestions.cs
@@ -0,0 +1,128 @@
+๏ปฟusing System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Linq;
+using System.Windows.Data;
+using NETworkManager.Controls;
+using NETworkManager.Localization.Resources;
+using NETworkManager.Settings;
+
+namespace NETworkManager;
+
+///
+/// Entry of an input with profiles and history (e.g. the ports of the Port Scanner): a profile (with a description,
+/// e.g. its ports) or an entry of the history.
+///
+/// Text inserted into the input: name of the profile or the history entry.
+/// Description of the profile (e.g. its ports), null for a history entry.
+/// Group in the list (profiles, history).
+public sealed record InputSuggestion(string Text, string Description, string Group)
+{
+ ///
+ /// The text, e.g. kept in an editable ComboBox when the entry is removed from the list (see
+ /// ).
+ ///
+ public override string ToString()
+ {
+ return Text;
+ }
+}
+
+///
+/// History and profiles for an input (an editable ComboBox like the ports of the Port Scanner or the DNS server of
+/// DNS Lookup): the history first (recently used, at most the number of history entries, a single profile name is
+/// not added), then the profiles, grouped, and the names of the profiles for the Tab completion.
+/// Updated when the profiles or the history change (weak events, the list doesn't keep a closed tab or dialog
+/// alive).
+///
+public sealed class InputSuggestions
+{
+ private readonly ObservableRangeCollection _suggestions = [];
+ private readonly Func> _getProfiles;
+ private readonly string _profilesGroup;
+ private readonly ObservableCollection _history;
+
+ ///
+ /// Creates the list with the current profiles and history.
+ ///
+ /// Collection of the profiles (to update the list when it changes).
+ /// Gets the names and descriptions of the profiles, in the order shown.
+ /// Name of the group of the profiles.
+ /// History of the input.
+ private InputSuggestions(INotifyCollectionChanged profiles,
+ Func> getProfiles, string profilesGroup,
+ ObservableCollection history)
+ {
+ _getProfiles = getProfiles;
+ _profilesGroup = profilesGroup;
+ _history = history;
+
+ View = new CollectionViewSource { Source = _suggestions }.View;
+ View.GroupDescriptions.Add(new PropertyGroupDescription(nameof(InputSuggestion.Group)));
+
+ CollectionChangedEventManager.AddHandler(profiles, OnSourceChanged);
+ CollectionChangedEventManager.AddHandler(history, OnSourceChanged);
+
+ Refresh();
+ }
+
+ ///
+ /// Creates the list for the ports of the Port Scanner: the port profiles (sorted by name, with their ports) and
+ /// the ports history.
+ ///
+ public static InputSuggestions ForPortScannerPorts()
+ {
+ return new InputSuggestions(SettingsManager.Current.PortScanner_PortProfiles,
+ () => SettingsManager.Current.PortScanner_PortProfiles
+ .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(x => (x.Name, x.Ports)),
+ Strings.PortProfiles, SettingsManager.Current.PortScanner_PortHistory);
+ }
+
+ ///
+ /// Creates the list for the DNS server of DNS Lookup: the DNS servers (Windows DNS first, then sorted by name,
+ /// with their servers) and the DNS server history.
+ ///
+ public static InputSuggestions ForDNSLookupDNSServers()
+ {
+ return new InputSuggestions(SettingsManager.Current.DNSLookup_DNSServers,
+ () => SettingsManager.Current.DNSLookup_DNSServers
+ .OrderByDescending(x => x.UseWindowsDNSServer)
+ .ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(x => (x.Name, x.UseWindowsDNSServer
+ ? null
+ : string.Join("; ", x.Servers.Select(server => server.Port == 53 ? server.Server : server.ToString())))),
+ Strings.DNSServers, SettingsManager.Current.DNSLookup_DNSServerHistory);
+ }
+
+ ///
+ /// Gets the view of the entries, grouped (history, profiles).
+ ///
+ public ICollectionView View { get; }
+
+ ///
+ /// Gets the names of the profiles, for the Tab completion of an entry (see
+ /// ).
+ ///
+ public ObservableRangeCollection ProfileNames { get; } = [];
+
+ private void OnSourceChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ Refresh();
+ }
+
+ private void Refresh()
+ {
+ var profiles = _getProfiles().ToList();
+
+ ProfileNames.ReplaceAll(profiles.Select(x => x.Name));
+
+ // History first: the recently used inputs (few, usually custom ones) are visible without scrolling below the
+ // profiles; the groups are shown in the order of their first entry
+ _suggestions.ReplaceAll(_history
+ .Select(x => new InputSuggestion(x, null, Strings.History))
+ .Concat(profiles.Select(x => new InputSuggestion(x.Name, x.Description, _profilesGroup))));
+ }
+}
diff --git a/Source/NETworkManager/MainWindow.xaml b/Source/NETworkManager/MainWindow.xaml
index d1aa88e1f2..986958e772 100644
--- a/Source/NETworkManager/MainWindow.xaml
+++ b/Source/NETworkManager/MainWindow.xaml
@@ -11,6 +11,7 @@
xmlns:controls="clr-namespace:NETworkManager.Controls"
xmlns:interactivity="http://schemas.microsoft.com/xaml/behaviors"
xmlns:asf="clr-namespace:AirspaceFixer;assembly=AirspaceFixer"
+ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
xmlns:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings"
xmlns:resources="clr-namespace:NETworkManager.Properties"
@@ -444,6 +445,7 @@
VerticalAlignment="Center"
BorderThickness="0"
Text="{Binding ApplicationSearch, UpdateSourceTrigger=PropertyChanged}"
+ wpfHelpers:SearchTextBoxHelper.HandleEnter="True"
mah:TextBoxHelper.Watermark="{x:Static localization:Strings.SearchDots}"
mah:TextBoxHelper.ClearTextButton="True"
CommandManager.PreviewExecuted="TextBoxApplicationSearch_OnPreviewExecuted"
diff --git a/Source/NETworkManager/NavigationManager.cs b/Source/NETworkManager/NavigationManager.cs
index 1212629d41..72a25b6734 100644
--- a/Source/NETworkManager/NavigationManager.cs
+++ b/Source/NETworkManager/NavigationManager.cs
@@ -1,4 +1,5 @@
-๏ปฟusing System;
+๏ปฟusing NETworkManager.Models;
+using System;
namespace NETworkManager;
@@ -18,11 +19,19 @@ public static class NavigationManager
public static event EventHandler SettingsRequested;
///
- /// Opens an application and passes the request (target, profile, options) to it.
+ /// Opens an application and passes the request (target, profile, options) to it. The target is prepared for
+ /// the application (see ), e.g. "gateway." from a PTR
+ /// record -> "gateway" for Remote Desktop.
///
/// Request with the application to open.
public static void Open(ApplicationRequest request)
{
+ if (request.Target != null)
+ request = request with
+ {
+ Target = ApplicationManager.GetHostForApplication(request.Application, request.Target)
+ };
+
ApplicationRequested?.Invoke(null, request);
}
diff --git a/Source/NETworkManager/ProfileDialogManager.cs b/Source/NETworkManager/ProfileDialogManager.cs
index 9107f937eb..2c62478873 100644
--- a/Source/NETworkManager/ProfileDialogManager.cs
+++ b/Source/NETworkManager/ProfileDialogManager.cs
@@ -6,6 +6,7 @@
using NETworkManager.Models.PuTTY;
using NETworkManager.Models.RemoteDesktop;
using NETworkManager.Profiles;
+using NETworkManager.Settings;
using NETworkManager.Utilities;
using NETworkManager.ViewModels;
using NETworkManager.Views;
@@ -54,7 +55,14 @@ private static ProfileInfo ParseProfileInfo(ProfileViewModel instance)
PortScanner_Host = instance.PortScanner_InheritHost
? instance.Host?.Trim()
: instance.PortScanner_Host?.Trim(),
- PortScanner_Ports = instance.PortScanner_Ports?.Trim(),
+ // Port profiles are referenced by their ID (found after a rename), their ports are saved as well (used if
+ // a port profile can't be found, e.g. a profile file from another computer)
+ PortScanner_Ports = PortProfile.TryResolvePorts(instance.PortScanner_Ports,
+ SettingsManager.Current.PortScanner_PortProfiles, out var portScannerPorts)
+ ? portScannerPorts
+ : instance.PortScanner_Ports?.Trim(),
+ PortScanner_PortsInput = PortProfile.EncodeReferences(instance.PortScanner_Ports,
+ SettingsManager.Current.PortScanner_PortProfiles),
// Ping Monitor
PingMonitor_Enabled = instance.PingMonitor_Enabled,
@@ -71,8 +79,15 @@ private static ProfileInfo ParseProfileInfo(ProfileViewModel instance)
// DNS Lookup
DNSLookup_Enabled = instance.DNSLookup_Enabled,
- DNSLookup_InheritHost = instance.Traceroute_InheritHost,
+ DNSLookup_InheritHost = instance.DNSLookup_InheritHost,
DNSLookup_Host = instance.DNSLookup_InheritHost ? instance.Host?.Trim() : instance.DNSLookup_Host?.Trim(),
+ DNSLookup_OverrideDNSServer = instance.DNSLookup_OverrideDNSServer,
+ // DNS servers by name are referenced by their ID (found after a rename), their servers are saved as well
+ // (used if a DNS server can't be found, e.g. a profile file from another computer)
+ DNSLookup_DNSServer = DNSServer.ReplaceProfilesByServers(instance.DNSLookup_DNSServer?.Trim(),
+ SettingsManager.Current.DNSLookup_DNSServers),
+ DNSLookup_DNSServerInput = DNSServer.EncodeReferences(instance.DNSLookup_DNSServer,
+ SettingsManager.Current.DNSLookup_DNSServers),
// Remote Desktop
RemoteDesktop_Enabled = instance.RemoteDesktop_Enabled,
diff --git a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
index a248a87516..568f649f88 100644
--- a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
+++ b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
@@ -1,7 +1,8 @@
๏ปฟ
+ xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls"
+ xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs
index f591928c21..65da272890 100644
--- a/Source/NETworkManager/RunCommandManager.cs
+++ b/Source/NETworkManager/RunCommandManager.cs
@@ -120,14 +120,6 @@ public static IEnumerable GetPortProfiles()
return SettingsManager.Current.PortScanner_PortProfiles;
}
- ///
- /// Gets the port profile with the name (case-insensitive), or null if there is none.
- ///
- private static PortProfileInfo GetPortProfile(string name)
- {
- return GetPortProfiles().FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase));
- }
-
///
/// Gets the translated name of the application (or settings) of a command.
///
@@ -337,7 +329,7 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT
{
ValidationRule argumentValidator = result.Command.Argument switch
{
- RunCommandArgument.Ports => new PortRangeValidator(),
+ RunCommandArgument.Ports => new PortsOrPortProfilesValidator(),
RunCommandArgument.Broadcast => new IPv4AddressValidator(),
_ => null
};
@@ -345,8 +337,6 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT
error = result.Command.Argument switch
{
RunCommandArgument.DNSServer => ValidateDNSServer(result.Argument),
- // A port profile (e.g. "Webserver") or ports
- RunCommandArgument.Ports when GetPortProfile(result.Argument) != null => null,
RunCommandArgument.Subnet => ValidateSubnetArgument(result.Target, result.Argument),
_ => Validate(argumentValidator, result.Argument)
};
@@ -368,24 +358,17 @@ RunCommandArgument.Ports when GetPortProfile(result.Argument) != null => 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.
+ /// Validates the DNS server argument: names of configured DNS servers (case-insensitive) and / or custom DNS
+ /// servers in the format of the DNS lookup (IP address or host with a domain, optional port), separated by ";"
+ /// (e.g. "google;cloudflare;9.9.9.9"). A single word that is neither (e.g. a mistyped type like "aaa") is an
+ /// unknown argument instead of a DNS server.
///
private static string ValidateDNSServer(string dnsServer)
{
- if (GetDNSServers().Any(x => string.Equals(x, dnsServer, StringComparison.OrdinalIgnoreCase)))
- return null;
+ var invalidEntry = DNSServer.GetInvalidEntries(dnsServer, SettingsManager.Current.DNSLookup_DNSServers,
+ hostnameNeedsDomain: true).FirstOrDefault();
- foreach (var server in dnsServer.Split(';',
- StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
- {
- if (!ServerConnectionInfo.TryParse(server, out var serverInfo, 53, TransportProtocol.Udp) ||
- !IPAddress.TryParse(serverInfo.Server, out _) && !serverInfo.Server.Contains('.'))
- return string.Format(Strings.UnknownArgumentX, server);
- }
-
- return null;
+ return invalidEntry == null ? null : string.Format(Strings.UnknownArgumentX, invalidEntry);
}
///
@@ -437,11 +420,11 @@ private static ApplicationRequest CreateRequest(RunCommandParseResult result)
// Without a type, the DNS lookup uses PTR for IP addresses and its selected type for hosts.
QueryType = result.Type != null ? Enum.Parse(result.Type, true) : null
},
- // A port profile is used with its ports.
+ // Ports and / or port profiles (e.g. "webserver;8080"), resolved by the Port Scanner (shown as entered).
ApplicationName.PortScanner => new PortScannerRequest
{
Target = result.Target,
- Ports = GetPortProfile(result.Argument)?.Ports ?? result.Argument
+ Ports = result.Argument
},
ApplicationName.WakeOnLAN => new WakeOnLANRequest { Target = result.Target, Broadcast = result.Argument },
ApplicationName.RDAP => new RDAPRequest { Target = result.Target, QueryType = GetRDAPQueryType(result.Type) },
diff --git a/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs b/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs
index 49bed2e95c..8f94813720 100644
--- a/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ConnectionsViewModel.cs
@@ -7,6 +7,7 @@
using NETworkManager.Models.Network;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -34,6 +35,8 @@ public class ConnectionsViewModel : ViewModelBase
///
public ConnectionsViewModel()
{
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
+
_isLoading = true;
// Result view + search
@@ -116,6 +119,12 @@ private async void AutoRefreshTimer_Tick(object sender, EventArgs e)
private readonly DispatcherTimer _autoRefreshTimer = new();
private bool _isViewVisible = true;
+ ///
+ /// Delays filtering while the user is typing (see ), so a large list is not filtered on
+ /// every key press.
+ ///
+ private readonly SearchDelay _searchDelay;
+
///
/// Gets or sets the search text.
///
@@ -129,7 +138,8 @@ public string Search
field = value;
- ResultsView.Refresh();
+ // Filter after a short delay while typing (see _searchDelay)
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -438,5 +448,19 @@ public void OnViewHide()
_autoRefreshTimer.Stop();
}
+ ///
+ /// Gets the command to filter right away (Enter in the search box, see
+ /// ).
+ ///
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
+
+ ///
+ /// Filters with the current right away.
+ ///
+ private void ApplySearch()
+ {
+ ResultsView.Refresh();
+ }
+
#endregion
}
\ No newline at end of file
diff --git a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs
index 1d95cb4978..8ed0d47689 100644
--- a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs
@@ -3,7 +3,9 @@
using NETworkManager.Controls;
using NETworkManager.Localization.Resources;
using NETworkManager.Models;
+using NETworkManager.Models.Network;
using NETworkManager.Profiles;
+using NETworkManager.Settings;
using NETworkManager.Utilities;
using NETworkManager.Views;
using System;
@@ -158,7 +160,8 @@ private static void CloseItemAction(ItemActionCallbackArgs args)
/// hosts.
///
/// Starts it on the first load. If false, the input is only filled in.
- public void AddTab(string host = null, string dnsServer = null, QueryType? queryType = null, bool start = true)
+ public void AddTab(string host = null, string dnsServer = null, QueryType? queryType = null, bool start = true,
+ string message = null)
{
if (queryType == null && IsIPAddresses(host))
queryType = QueryType.PTR;
@@ -166,7 +169,7 @@ public void AddTab(string host = null, string dnsServer = null, QueryType? query
var tabId = Guid.NewGuid();
TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab,
- new DNSLookupView(tabId, host, dnsServer, queryType, start), tabId));
+ new DNSLookupView(tabId, host, dnsServer, queryType, start, message), tabId));
SelectedTabIndex = TabItems.Count - 1;
}
@@ -189,7 +192,28 @@ private static bool IsIPAddresses(string hosts)
/// Starts it on the first load. If false, the input is only filled in.
public void AddTab(ProfileInfo profile, bool start = true)
{
- AddTab(profile.DNSLookup_Host, start: start);
+ // DNS server of DNS Lookup (last selected)
+ if (!profile.DNSLookup_OverrideDNSServer || string.IsNullOrWhiteSpace(profile.DNSLookup_DNSServer))
+ {
+ AddTab(profile.DNSLookup_Host, start: start);
+ return;
+ }
+
+ // DNS servers by name with their current name (also after a rename)
+ if (!string.IsNullOrEmpty(profile.DNSLookup_DNSServerInput) &&
+ DNSServer.TryDecodeReferences(profile.DNSLookup_DNSServerInput,
+ SettingsManager.Current.DNSLookup_DNSServers, out var dnsServer))
+ {
+ AddTab(profile.DNSLookup_Host, dnsServer, start: start);
+ return;
+ }
+
+ // No DNS server by name, or one can't be found (deleted, or a profile file shared from another computer - DNS
+ // servers are saved in the settings): the servers saved with the profile, with a note if one wasn't found
+ AddTab(profile.DNSLookup_Host, profile.DNSLookup_DNSServer, start: start,
+ message: string.IsNullOrEmpty(profile.DNSLookup_DNSServerInput)
+ ? null
+ : Strings.DNSServerOfProfileNotFoundMessage);
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs
index d9dcee5604..383dc3736b 100644
--- a/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/DNSLookupSettingsViewModel.cs
@@ -59,8 +59,8 @@ public DNSServerConnectionInfoProfile SelectedDNSServer
///
private List ServerInfoProfileNames =>
[
- .. SettingsManager.Current.DNSLookup_DNSServers
- .Where(x => !x.UseWindowsDNSServer).Select(x => x.Name)
+ // Also the Windows DNS server - names are used in the DNS server input, they must be unique
+ .. SettingsManager.Current.DNSLookup_DNSServers.Select(x => x.Name)
];
///
@@ -370,21 +370,28 @@ public async Task EditDNSServer()
{
var childWindow = new ServerConnectionInfoProfileChildWindow();
+ var dnsServer = SelectedDNSServer;
+
var childWindowViewModel = new ServerConnectionInfoProfileViewModel(instance =>
{
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
- SettingsManager.Current.DNSLookup_DNSServers.Remove(SelectedDNSServer);
- SettingsManager.Current.DNSLookup_DNSServers.Add(
- new DNSServerConnectionInfoProfile(instance.Name, [.. instance.Servers]));
+ // Same ID, profiles that reference the DNS server still find it (also with a new name)
+ var editedDNSServer = new DNSServerConnectionInfoProfile(instance.Name, [.. instance.Servers]);
+
+ if (dnsServer.Id != Guid.Empty)
+ editedDNSServer.Id = dnsServer.Id;
+
+ SettingsManager.Current.DNSLookup_DNSServers.Remove(dnsServer);
+ SettingsManager.Current.DNSLookup_DNSServers.Add(editedDNSServer);
}, _ =>
{
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
},
(ServerInfoProfileNames, true, false),
- _profileDialogDefaultValues, SelectedDNSServer);
+ _profileDialogDefaultValues, dnsServer);
childWindow.Title = Strings.EditDNSServer;
diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
index 5233973d29..26499769c3 100644
--- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
+++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
@@ -41,6 +41,9 @@ public class DNSLookupViewModel : ViewModelBase
///
private readonly bool _isLoading;
+ // Message of the tab, shown until the first lookup has started (see constructor)
+ private string _message;
+
///
/// Gets or sets the host to lookup.
///
@@ -62,14 +65,21 @@ public string Host
///
public ICollectionView HostHistoryView { get; }
+ private readonly InputSuggestions _dnsServerSuggestions = InputSuggestions.ForDNSLookupDNSServers();
+
+ ///
+ /// Gets the DNS servers and the DNS server history for the DNS server input (grouped).
+ ///
+ public ICollectionView DNSServers => _dnsServerSuggestions.View;
+
///
- /// Gets the collection view of DNS servers.
+ /// Gets the names of the DNS servers for the Tab completion of the DNS server input.
///
- public ICollectionView DNSServers { get; }
+ public IEnumerable DNSServerNames => _dnsServerSuggestions.ProfileNames;
///
- /// Gets or sets the selected DNS server.
- /// This can either be an ip/host:port or a profile name.
+ /// Gets or sets the DNS server input: names of DNS servers (profiles) and / or DNS servers (ip/host:port),
+ /// separated by ";" (see ).
///
public string DNSServer
{
@@ -79,10 +89,6 @@ public string DNSServer
if (field == value)
return;
- // Try finding matching dns server profile by name, otherwise set to null (de-select)
- SelectedDNSServer = SettingsManager.Current.DNSLookup_DNSServers
- .FirstOrDefault(x => x.Name == value);
-
if (!_isLoading)
SettingsManager.Current.DNSLookup_SelectedDNSServer_v2 = value;
@@ -91,19 +97,6 @@ public string DNSServer
}
}
- public DNSServerConnectionInfoProfile SelectedDNSServer
- {
- get;
- set
- {
- if (field == value)
- return;
-
- field = value;
- OnPropertyChanged();
- }
- }
-
///
/// Gets the list of available query types.
///
@@ -251,11 +244,21 @@ private set
/// DNS server(s) to use (not saved as default). If null, the last selected one is used.
/// Query type (not saved as default). If null, the last selected one is used.
/// Starts it on the first load. If false, the input is only filled in.
+ ///
+ /// Message shown below the input, also with the first lookup (e.g. a DNS server of the profile was not found).
+ ///
public DNSLookupViewModel(Guid tabId, string host, string dnsServer = null, QueryType? queryType = null,
- bool start = true)
+ bool start = true, string message = null)
{
_firstLoad = start;
+ if (!string.IsNullOrEmpty(message))
+ {
+ _message = message;
+
+ AppendStatusMessage(message);
+ }
+
_isLoading = true;
ConfigurationManager.Current.DNSLookupTabCount++;
@@ -265,12 +268,6 @@ public DNSLookupViewModel(Guid tabId, string host, string dnsServer = null, Quer
HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.DNSLookup_HostHistory);
- DNSServers = new CollectionViewSource { Source = SettingsManager.Current.DNSLookup_DNSServers }.View;
- DNSServers.SortDescriptions.Add(new SortDescription(nameof(DNSServerConnectionInfoProfile.UseWindowsDNSServer),
- ListSortDirection.Descending));
- DNSServers.SortDescriptions.Add(new SortDescription(nameof(DNSServerConnectionInfoProfile.Name),
- ListSortDirection.Ascending));
-
DNSServer = string.IsNullOrEmpty(SettingsManager.Current.DNSLookup_SelectedDNSServer_v2)
? SettingsManager.Current.DNSLookup_DNSServers.FirstOrDefault()?.Name
: SettingsManager.Current.DNSLookup_SelectedDNSServer_v2;
@@ -383,6 +380,14 @@ private async Task QueryAsync()
IsStatusMessageDisplayed = false;
StatusMessage = string.Empty;
+ // Message of the tab (see constructor): still shown with the first lookup
+ if (_message != null)
+ {
+ AppendStatusMessage(_message);
+
+ _message = null;
+ }
+
IsRunning = true;
Results.Clear();
@@ -392,6 +397,11 @@ private async Task QueryAsync()
ListHelper.Modify(SettingsManager.Current.DNSLookup_HostHistory, Host,
SettingsManager.Current.General_HistoryListEntries);
+ // A single DNS server name is already in the list, only other inputs are added to the history
+ if (Models.Network.DNSServer.FindByName(SettingsManager.Current.DNSLookup_DNSServers, DNSServer) == null)
+ ListHelper.Modify(SettingsManager.Current.DNSLookup_DNSServerHistory, DNSServer.Trim(),
+ SettingsManager.Current.General_HistoryListEntries);
+
DNSLookupSettings dnsSettings = new()
{
AddDNSSuffix = SettingsManager.Current.DNSLookup_AddDNSSuffix,
@@ -410,32 +420,15 @@ private async Task QueryAsync()
dnsSettings.CustomDNSSuffix = SettingsManager.Current.DNSLookup_CustomDNSSuffix?.TrimStart('.');
}
- // Try to find DNS server profile
- var dnsServerProfile = SettingsManager.Current.DNSLookup_DNSServers
- .FirstOrDefault(x => x.Name == DNSServer);
+ // DNS server profiles (by name, case-insensitive) and DNS servers, also several (e.g. "Google Public DNS;
+ // 9.9.9.9")
+ var dnsServersToResolve = Models.Network.DNSServer.ResolveServers(DNSServer,
+ SettingsManager.Current.DNSLookup_DNSServers, out var useWindowsDNSServer, out var invalidEntries);
- DNSLookup dnsLookup = null;
- List dnsServersToResolve = [];
+ foreach (var invalidEntry in invalidEntries)
+ AppendStatusMessage(Strings.DNSServer + ": " + string.Format(Strings.CouldNotParseX, invalidEntry));
- // Use DNS server profile if found
- if (dnsServerProfile != null)
- {
- if (dnsServerProfile.UseWindowsDNSServer)
- dnsLookup = new DNSLookup(dnsSettings);
- else
- dnsServersToResolve = dnsServerProfile.Servers;
- }
- // Parse DNS server input
- else
- {
- foreach (var dnsServerInput in DNSServer.Split(';'))
- {
- if (ServerConnectionInfo.TryParse(dnsServerInput, out var serverInfo, 53, TransportProtocol.Udp))
- dnsServersToResolve.Add(serverInfo);
- else
- AppendStatusMessage(Strings.DNSServer + ": " + string.Format(Strings.CouldNotParseX, dnsServerInput));
- }
- }
+ var dnsLookup = useWindowsDNSServer ? new DNSLookup(dnsSettings) : null;
// Resolve DNS server hostnames (if any) - not required when using Windows DNS servers
if (dnsLookup == null)
diff --git a/Source/NETworkManager/ViewModels/FirewallViewModel.cs b/Source/NETworkManager/ViewModels/FirewallViewModel.cs
index 413e95e447..1a55443650 100644
--- a/Source/NETworkManager/ViewModels/FirewallViewModel.cs
+++ b/Source/NETworkManager/ViewModels/FirewallViewModel.cs
@@ -10,6 +10,7 @@
using NETworkManager.Models.Firewall;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -108,11 +109,11 @@ public IList SelectedResults
/// Delays filtering the rules while the user is typing (see ),
/// so the view (can be 1000+ rules if all rules are shown) is not filtered on every key press.
///
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
///
/// Gets or sets the search text for filtering rules.
- /// The rules are filtered after a short delay (see ).
+ /// The rules are filtered after a short delay (see ).
///
public string RulesSearch
{
@@ -124,8 +125,7 @@ public string RulesSearch
field = value;
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -272,8 +272,7 @@ public FirewallViewModel()
Matches(rule.Description);
};
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
// Load firewall rules
_ = Refresh(true);
@@ -913,12 +912,16 @@ private static List ParseAddressesString(string value)
}
///
- /// Filters the rules once the user stopped typing (see ).
+ /// Gets the command to filter right away (Enter in the search box, see
+ /// ).
///
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
- {
- _searchDispatcherTimer.Stop();
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
+ ///
+ /// Filters with the current right away.
+ ///
+ private void ApplySearch()
+ {
ResultsView.Refresh();
}
diff --git a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
index 7b5c389961..ab7bcc42ee 100644
--- a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
+++ b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
@@ -7,6 +7,7 @@
using NETworkManager.Models.HostsFileEditor;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -18,6 +19,7 @@
using System.Windows;
using System.Windows.Data;
using System.Windows.Input;
+using System.Windows.Threading;
namespace NETworkManager.ViewModels;
@@ -35,6 +37,12 @@ public class HostsFileEditorViewModel : ViewModelBase
///
private readonly bool _isLoading;
+ ///
+ /// Delays filtering while the user is typing (see ), so a large list is not filtered on
+ /// every key press.
+ ///
+ private readonly SearchDelay _searchDelay;
+
///
/// Gets or sets the search text.
///
@@ -48,7 +56,8 @@ public string Search
field = value;
- ResultsView.Refresh();
+ // Filter after a short delay while typing (see _searchDelay)
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -190,6 +199,8 @@ private set
///
public HostsFileEditorViewModel()
{
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
+
_isLoading = true;
// Result view + search
@@ -757,5 +768,19 @@ public void OnViewHide()
{
}
+ ///
+ /// Gets the command to filter right away (Enter in the search box, see
+ /// ).
+ ///
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
+
+ ///
+ /// Filters with the current right away.
+ ///
+ private void ApplySearch()
+ {
+ ResultsView.Refresh();
+ }
+
#endregion
}
\ No newline at end of file
diff --git a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs
index a29f11ece2..a2a62e91d5 100644
--- a/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerSettingsViewModel.cs
@@ -4,7 +4,9 @@
using NETworkManager.Utilities;
using NETworkManager.Views;
using System;
+using System.Collections;
using System.ComponentModel;
+using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Data;
@@ -43,6 +45,25 @@ public bool ShowAllResults
}
}
+ ///
+ /// Gets or sets a value indicating whether the details of every host are expanded in the result.
+ ///
+ public bool ExpandHostView
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.IPScanner_ExpandHostView = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets or sets the number of ICMP attempts.
///
@@ -273,6 +294,22 @@ public CustomCommandInfo SelectedCustomCommand
}
} = new();
+ ///
+ /// Gets or sets the selected custom commands (several can be deleted at once).
+ ///
+ public IList SelectedCustomCommands
+ {
+ get;
+ set
+ {
+ if (Equals(value, field))
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = new ArrayList();
+
///
/// Gets or sets the maximum number of host threads.
///
@@ -337,6 +374,7 @@ public IPScannerSettingsViewModel()
private void LoadSettings()
{
ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults;
+ ExpandHostView = SettingsManager.Current.IPScanner_ExpandHostView;
ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts;
ICMPTimeout = SettingsManager.Current.IPScanner_ICMPTimeout;
ICMPBuffer = SettingsManager.Current.IPScanner_ICMPBuffer;
@@ -356,6 +394,14 @@ private void LoadSettings()
#region ICommand & Actions
+ ///
+ /// Gets the command to restore the default ports of the port scan (e.g. overwritten by accident). Disabled while
+ /// the default ports are set.
+ ///
+ public ICommand RestoreDefaultPortScanPortsCommand => new RelayCommand(
+ _ => PortScanPorts = GlobalStaticConfiguration.IPScanner_PortScanPorts,
+ _ => PortScanPorts != GlobalStaticConfiguration.IPScanner_PortScanPorts);
+
///
/// Gets the command to add a custom command.
///
@@ -373,14 +419,14 @@ private void AddCustomCommandAction()
/// Gets the command to edit a custom command.
///
public ICommand EditCustomCommandCommand =>
- new RelayCommand(_ => EditCustomCommandAction(), CustomCommandSelected_CanExecute);
+ new RelayCommand(_ => EditCustomCommandAction(), EditCustomCommand_CanExecute);
///
- /// Checks if a custom command is selected (edit and delete need one, e.g. F2 / Del without a selection).
+ /// Checks if exactly one custom command is selected (e.g. F2 without a selection or with several selected).
///
- private bool CustomCommandSelected_CanExecute(object parameter)
+ private bool EditCustomCommand_CanExecute(object parameter)
{
- return SelectedCustomCommand != null;
+ return SelectedCustomCommand != null && SelectedCustomCommands.Count == 1;
}
///
@@ -392,10 +438,18 @@ private void EditCustomCommandAction()
}
///
- /// Gets the command to delete a custom command.
+ /// Gets the command to delete the selected custom commands.
///
public ICommand DeleteCustomCommandCommand =>
- new RelayCommand(_ => DeleteCustomCommandAction(), CustomCommandSelected_CanExecute);
+ new RelayCommand(_ => DeleteCustomCommandAction(), DeleteCustomCommand_CanExecute);
+
+ ///
+ /// Checks if at least one custom command is selected (e.g. Del without a selection).
+ ///
+ private bool DeleteCustomCommand_CanExecute(object parameter)
+ {
+ return SelectedCustomCommands.Count > 0;
+ }
///
/// Action to delete a custom command.
@@ -474,21 +528,25 @@ public Task EditCustomCommand()
}
///
- /// Deletes the selected custom command.
+ /// Deletes the selected custom commands (after a confirmation that lists them).
///
private async Task DeleteCustomCommand()
{
+ var customCommands = SelectedCustomCommands.Cast().ToList();
+
var result = await DialogHelper.ShowConfirmationMessageAsync(Application.Current.MainWindow,
- Strings.DeleteCustomCommand,
- Strings.DeleteCustomCommandMessage + Environment.NewLine + Environment.NewLine +
- DialogHelper.FormatItemList([SelectedCustomCommand.Name]),
+ customCommands.Count == 1 ? Strings.DeleteCustomCommand : Strings.DeleteCustomCommands,
+ (customCommands.Count == 1 ? Strings.DeleteCustomCommandMessage : Strings.DeleteCustomCommandsMessage) +
+ Environment.NewLine + Environment.NewLine +
+ DialogHelper.FormatItemList([.. customCommands.Select(x => x.Name)]),
ChildWindowIcon.Info,
Strings.Delete);
if (!result)
return;
- SettingsManager.Current.IPScanner_CustomCommands.Remove(SelectedCustomCommand);
+ foreach (var customCommand in customCommands)
+ SettingsManager.Current.IPScanner_CustomCommands.Remove(customCommand);
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index c36ae8b305..c51439abb6 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -10,6 +10,7 @@
using NETworkManager.Profiles;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -22,6 +23,7 @@
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
+using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Threading;
@@ -42,15 +44,42 @@ public class IPScannerViewModel : ViewModelBase, IProfileManagerMinimal
private bool _firstLoad = true;
private bool _closed;
- // Background HostScanned events append here instead of hopping to the UI thread per item -
- // a DispatcherTimer periodically flushes this into the Results collection instead, so a large
- // scan doesn't flood the dispatcher queue with one BeginInvoke per host.
- private readonly List _resultsBuffer = [];
+ // Background HostFound / HostUpdated events append here instead of hopping to the UI thread per
+ // item - a DispatcherTimer periodically flushes this into the Results collection instead, so a large
+ // scan doesn't flood the dispatcher queue with one BeginInvoke per host. One list for both, so an
+ // update is always applied after its host was added and updates keep their order.
+ // Entry: (host, null) = add the found host, (host, update) = apply the update to the host.
+ private readonly List<(IPScannerHostInfo host, IPScannerHostUpdatedArgs update)> _resultsBuffer = [];
private readonly Lock _resultsBufferLock = new();
private DispatcherTimer _resultsFlushTimer;
+ // Scanner of the running scan - events of another (finished) scanner are ignored
+ private IPScanner _ipScanner;
+
+ // Options of the last scan, to rescan hosts with the same options (the columns follow them, see ScanOptions)
+ private IPScannerOptions _lastScanOptions;
+
+ // Rescan of shown hosts (see RescanSelectedHosts), null if no rescan is running: row per rescanned IP address
+ // (every IP address is scanned once, see HostRangeHelper.ResolveAsync). The scanner creates new results - their
+ // values are copied into the existing rows (selection and expanded details stay), the updates of a new result are
+ // applied to the row it was copied to (_rescanResults: result -> row).
+ private Dictionary _rescanRows;
+ private readonly Dictionary _rescanResults = [];
+
+ // Values of the rescanned rows before the rescan (row -> copy), restored if the rescan is canceled or fails: a
+ // found host is copied into its row with placeholders for the running probes, which would stay otherwise
+ private Dictionary _rescanSnapshots;
+
+ // Counters of the last scan, restored after a rescan (the rescan shows its own progress)
+ private int _rescanHostsToScan;
+ private int _rescanHostsScanned;
+
+ // Hosts of the last scan counted as up or down (a host is up when it was found, down when it was finished),
+ // to recompute the down counter during a rescan
+ private int _rescanHostsCounted;
+
// Same reasoning as the results buffer above - ProgressChanged fires once per host
- // (unconditionally, unlike HostScanned), so it's flushed to the bound property on the same
+ // (unconditionally, unlike HostFound), so it's flushed to the bound property on the same
// timer instead of updating it directly from the background thread on every event.
private int _latestHostsScanned;
private int _latestHostsUp;
@@ -141,6 +170,32 @@ public ObservableCollection Results
}
} = [];
+ ///
+ /// Delays filtering the results while the user is typing (see ), so a large result is not
+ /// filtered on every key press.
+ ///
+ private readonly SearchDelay _searchDelay;
+
+ ///
+ /// Gets or sets the search text for filtering the results (IP address, hostname, MAC address, vendor, ports).
+ /// The results are filtered after a short delay (see ).
+ ///
+ public string Search
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+
+ _searchDelay.Restart();
+
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets the collection view for the scan results.
///
@@ -290,6 +345,33 @@ private set
}
}
+ ///
+ /// Gets the options of the scan whose results are shown. Columns, context menu and row details follow them
+ /// instead of the current settings, which may have been changed since. While no results are shown, they
+ /// follow the current settings.
+ ///
+ public IPScannerResultOptions ScanOptions
+ {
+ get;
+ private set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Gets whether the details of every host are shown (setting ,
+ /// follows the current setting). A row that was expanded / collapsed with its button keeps its state.
+ ///
+ public DataGridRowDetailsVisibilityMode HostDetailsVisibilityMode =>
+ SettingsManager.Current.IPScanner_ExpandHostView
+ ? DataGridRowDetailsVisibilityMode.Visible
+ : DataGridRowDetailsVisibilityMode.Collapsed;
+
///
/// Gets the available custom commands for the IP Scanner.
///
@@ -317,12 +399,45 @@ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true)
// Host history
HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.IPScanner_HostHistory);
+ // Columns etc. until the first scan
+ ScanOptions = CreateResultOptions();
+
+ SettingsManager.Current.PropertyChanged += SettingsManager_PropertyChanged;
+
// Result view
ResultsView = CollectionViewSource.GetDefaultView(Results);
// Custom comparer to sort by IP address
((ListCollectionView)ResultsView).CustomSort = Comparer.Create((x, y) =>
IPAddressHelper.CompareIPAddresses(x.PingInfo.IPAddress, y.PingInfo.IPAddress));
+
+ ResultsView.Filter = o =>
+ {
+ if (string.IsNullOrEmpty(Search))
+ return true;
+
+ if (o is not IPScannerHostInfo info)
+ return false;
+
+ bool Matches(string value) => value?.Contains(Search, StringComparison.OrdinalIgnoreCase) == true;
+
+ return Matches(info.PingInfo.IPAddress.ToString()) ||
+ Matches(info.Hostname) ||
+ Matches(info.MACAddress) ||
+ Matches(info.Vendor) ||
+ info.Ports.Any(x => Matches(x.Port.ToString()) || Matches(x.LookupInfo.Service));
+ };
+
+ // Hostname, MAC address, vendor and ports arrive after the host was added - filter it again when they
+ // change (without live filtering a host found before its hostname arrived would stay hidden). Only enabled
+ // while searching, see ApplySearch.
+ var liveShaping = (ICollectionViewLiveShaping)ResultsView;
+ liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Hostname));
+ liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.MACAddress));
+ liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Vendor));
+ liveShaping.LiveFilteringProperties.Add(nameof(IPScannerHostInfo.Ports));
+
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
}
///
@@ -383,11 +498,47 @@ private void RedirectDataToApplicationAction(object name)
if (name is not ApplicationName applicationName)
return;
- var host = !string.IsNullOrEmpty(SelectedResult.Hostname)
- ? SelectedResult.Hostname
+ NavigationManager.Open(new ApplicationRequest(applicationName) { Target = GetSelectedHost() });
+ }
+
+ ///
+ /// Gets the host of the selected result for other tools: the DNS hostname, otherwise the IP address. Not a NetBIOS
+ /// name (shown as fallback), it may not be resolvable via DNS in the other tool.
+ ///
+ /// DNS hostname or IP address.
+ private string GetSelectedHost()
+ {
+ return !string.IsNullOrEmpty(SelectedResult.DNSHostname)
+ ? SelectedResult.DNSHostname
: SelectedResult.PingInfo.IPAddress.ToString();
+ }
+
+ ///
+ /// Gets the command to open the selected host in the Web Console (https / http depending on the open ports).
+ ///
+ public ICommand OpenWebConsoleCommand => new RelayCommand(_ => OpenWebConsoleAction());
+
+ private void OpenWebConsoleAction()
+ {
+ // Ports may still be scanned - then http on the default port
+ var openPorts = SelectedResult.IsPortScanPending
+ ? []
+ : SelectedResult.Ports.Where(x => x.State == PortState.Open).Select(x => x.Port);
- NavigationManager.Open(new ApplicationRequest(applicationName) { Target = host });
+ NavigationManager.Open(new ApplicationRequest(ApplicationName.WebConsole)
+ {
+ Target = WebConsoleUrlHelper.CreateUrl(GetSelectedHost(), openPorts)
+ });
+ }
+
+ ///
+ /// Gets the command to wake up the selected host (sends the magic packet to its MAC address).
+ ///
+ public ICommand WakeOnLANCommand => new RelayCommand(_ => WakeOnLANAction());
+
+ private void WakeOnLANAction()
+ {
+ NavigationManager.Open(new WakeOnLANRequest { Target = SelectedResult.MACAddress });
}
///
@@ -407,7 +558,7 @@ private void PerformDNSLookupIPAddressAction()
private void PerformDNSLookupHostnameAction()
{
- NavigationManager.Open(new DNSLookupRequest { Target = SelectedResult.Hostname });
+ NavigationManager.Open(new DNSLookupRequest { Target = SelectedResult.DNSHostname });
}
///
@@ -427,7 +578,7 @@ private void PerformRDAPQueryIPAddressAction()
private void PerformRDAPQueryHostnameAction()
{
- NavigationManager.Open(new RDAPRequest { Target = SelectedResult.Hostname });
+ NavigationManager.Open(new RDAPRequest { Target = SelectedResult.DNSHostname });
}
///
@@ -447,11 +598,12 @@ private void CustomCommandAction(object guid)
private async void AddProfileSelectedHostAction()
{
+ // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.")
+ var hostname = DNSClientHelper.SanitizeHostname(SelectedResult.Hostname);
+
ProfileInfo profileInfo = new()
{
- Name = string.IsNullOrEmpty(SelectedResult.Hostname)
- ? SelectedResult.PingInfo.IPAddress.ToString()
- : SelectedResult.Hostname.TrimEnd('.'),
+ Name = string.IsNullOrEmpty(hostname) ? SelectedResult.PingInfo.IPAddress.ToString() : hostname,
Host = SelectedResult.PingInfo.IPAddress.ToString(),
// Additional data
@@ -480,6 +632,34 @@ private void CopySelectedPortsAction()
ClipboardHelper.SetClipboard(stringBuilder.ToString());
}
+ ///
+ /// Gets the command to copy a value of all selected results (one per line, in the order shown in the grid).
+ /// Parameter: IPAddress, Hostname or MACAddress.
+ ///
+ public ICommand CopySelectedResultsCommand => new RelayCommand(CopySelectedResultsAction);
+
+ private void CopySelectedResultsAction(object parameter)
+ {
+ Func selector = parameter switch
+ {
+ "IPAddress" => x => x.PingInfo.IPAddress.ToString(),
+ "Hostname" => x => x.Hostname,
+ "MACAddress" => x => x.MACAddress,
+ _ => null
+ };
+
+ if (selector == null)
+ return;
+
+ ClipboardHelper.SetClipboard(string.Join(Environment.NewLine,
+ GetSelectedResultsInGridOrder().Select(selector).Where(x => !string.IsNullOrEmpty(x)).Distinct()));
+ }
+
+ ///
+ /// Gets the command to rescan the selected hosts (with the options of the last scan).
+ ///
+ public ICommand RescanSelectedHostsCommand => new RelayCommand(_ => RescanSelectedHosts());
+
///
/// Gets the command to export the scan results.
///
@@ -554,6 +734,9 @@ private async Task Start()
Results.Clear();
+ // No results are shown until the scan's options are known - follow the current settings
+ ScanOptions = CreateResultOptions();
+
// Reset before hostname resolution too (not just after), so a cancellation during
// resolution can't flush the previous scan's stale totals - HostsToScan = 0 also hides
// the up/down summary until the new scan's host count is known.
@@ -578,7 +761,8 @@ private async Task Start()
try
{
hosts = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(Host),
- SettingsManager.Current.Network_ResolveHostnamePreferIPv4, _cancellationTokenSource.Token);
+ SettingsManager.Current.Network_ResolveHostnamePreferIPv4, keepAllHostnames: false,
+ _cancellationTokenSource.Token);
ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync(
SettingsManager.Current.IPScanner_PortScanPorts);
@@ -604,30 +788,89 @@ private async Task Start()
HostsToScan = hosts.hosts.Count;
+ // No host left (e.g. only hostnames that could not be resolved): don't start the scan, only the message above
+ if (HostsToScan == 0)
+ {
+ ScanComplete(this, new ScanCompleteArgs(false));
+ return;
+ }
+
PreparingScan = false;
// Add host(s) to the history
ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, Host,
SettingsManager.Current.General_HistoryListEntries);
- var ipScanner = new IPScanner(new IPScannerOptions(
- SettingsManager.Current.IPScanner_MaxHostThreads,
- SettingsManager.Current.IPScanner_MaxPortThreads,
- SettingsManager.Current.IPScanner_ICMPAttempts,
- SettingsManager.Current.IPScanner_ICMPTimeout,
- new byte[SettingsManager.Current.IPScanner_ICMPBuffer],
- SettingsManager.Current.IPScanner_ResolveHostname,
- SettingsManager.Current.IPScanner_PortScanEnabled,
- ports,
- SettingsManager.Current.IPScanner_PortScanTimeout,
- SettingsManager.Current.IPScanner_PortScanAttempts,
- SettingsManager.Current.IPScanner_NetBIOSEnabled,
- SettingsManager.Current.IPScanner_NetBIOSTimeout,
- SettingsManager.Current.IPScanner_ResolveMACAddress,
- SettingsManager.Current.IPScanner_ShowAllResults
- ));
+ var scanOptions = CreateScanOptions(ports);
+
+ ScanOptions = new IPScannerResultOptions(scanOptions);
+
+ _lastScanOptions = scanOptions;
+
+ StartScanner(scanOptions, hosts.hosts);
+ }
+
+ ///
+ /// Rescans the selected hosts with the options of the last scan. The rows are updated in place; a host that is
+ /// unreachable now is shown as down.
+ ///
+ private void RescanSelectedHosts()
+ {
+ if (IsRunning || _lastScanOptions == null)
+ return;
+
+ var selectedIPAddresses = GetSelectedResultsInGridOrder().Select(x => x.IPAddress).ToHashSet();
+
+ if (selectedIPAddresses.Count == 0)
+ return;
+
+ // One row per IP address (DistinctBy only so a broken assumption can't throw)
+ _rescanRows = Results.Where(x => selectedIPAddresses.Contains(x.IPAddress))
+ .DistinctBy(x => x.IPAddress)
+ .ToDictionary(x => x.IPAddress);
+
+ _rescanResults.Clear();
+
+ _rescanSnapshots = _rescanRows.Values.ToDictionary(x => x, x => x.Clone());
+
+ _rescanHostsToScan = HostsToScan;
+ _rescanHostsScanned = HostsScanned;
+ _rescanHostsCounted = HostsUp + HostsDown;
+
+ IsStatusMessageDisplayed = false;
+ IsRunning = true;
+
+ _resultsFlushTimer?.Stop();
+
+ lock (_resultsBufferLock)
+ {
+ _resultsBuffer.Clear();
+ }
+
+ HostsToScan = _rescanRows.Count;
+ HostsScanned = 0;
+ Volatile.Write(ref _latestHostsScanned, 0);
+
+ _cancellationTokenSource?.Dispose();
+ _cancellationTokenSource = new CancellationTokenSource();
- ipScanner.HostScanned += HostScanned;
+ // Same options (columns, ports), but every host is reported - also if it is unreachable now
+ StartScanner(CreateRescanOptions(_lastScanOptions), [.. _rescanRows.Keys.Select(x => (x, string.Empty))]);
+ }
+
+ ///
+ /// Creates the scanner, subscribes to its events, starts the flush timer and the scan.
+ ///
+ /// Options of the scan.
+ /// Hosts to scan.
+ private void StartScanner(IPScannerOptions scanOptions, List<(IPAddress ipAddress, string hostname)> hosts)
+ {
+ var ipScanner = new IPScanner(scanOptions);
+
+ _ipScanner = ipScanner;
+
+ ipScanner.HostFound += HostFound;
+ ipScanner.HostUpdated += HostUpdated;
ipScanner.ScanComplete += ScanComplete;
ipScanner.ProgressChanged += ProgressChanged;
@@ -642,7 +885,58 @@ private async Task Start()
};
_resultsFlushTimer.Start();
- ipScanner.ScanAsync(hosts.hosts, _cancellationTokenSource.Token);
+ ipScanner.ScanAsync(hosts, _cancellationTokenSource.Token);
+ }
+
+ ///
+ /// Creates the scan options from the current settings.
+ ///
+ /// Ports to scan.
+ /// Options for the .
+ private static IPScannerOptions CreateScanOptions(int[] ports)
+ {
+ return new IPScannerOptions
+ {
+ MaxHostThreads = SettingsManager.Current.IPScanner_MaxHostThreads,
+ MaxPortThreads = SettingsManager.Current.IPScanner_MaxPortThreads,
+ ICMPAttempts = SettingsManager.Current.IPScanner_ICMPAttempts,
+ ICMPTimeout = SettingsManager.Current.IPScanner_ICMPTimeout,
+ ICMPBuffer = new byte[SettingsManager.Current.IPScanner_ICMPBuffer],
+ ResolveHostname = SettingsManager.Current.IPScanner_ResolveHostname,
+ PortScanEnabled = SettingsManager.Current.IPScanner_PortScanEnabled,
+ PortScanPorts = ports,
+ PortScanTimeout = SettingsManager.Current.IPScanner_PortScanTimeout,
+ PortScanAttempts = SettingsManager.Current.IPScanner_PortScanAttempts,
+ NetBIOSEnabled = SettingsManager.Current.IPScanner_NetBIOSEnabled,
+ NetBIOSTimeout = SettingsManager.Current.IPScanner_NetBIOSTimeout,
+ ResolveMACAddress = SettingsManager.Current.IPScanner_ResolveMACAddress,
+ ShowAllResults = SettingsManager.Current.IPScanner_ShowAllResults,
+ ReportAllHosts = false
+ };
+ }
+
+ ///
+ /// Creates the options for a rescan: the options of the last scan, every host is reported (also if it is
+ /// unreachable now, so its row is updated).
+ ///
+ /// Options of the last scan.
+ /// Options for the rescan.
+ private static IPScannerOptions CreateRescanOptions(IPScannerOptions options)
+ {
+ return options with { ReportAllHosts = true };
+ }
+
+ ///
+ /// Creates the result options (columns, context menu and row details) from the current settings.
+ ///
+ /// Options for .
+ private static IPScannerResultOptions CreateResultOptions()
+ {
+ return new IPScannerResultOptions(
+ SettingsManager.Current.IPScanner_ResolveHostname,
+ SettingsManager.Current.IPScanner_PortScanEnabled,
+ SettingsManager.Current.IPScanner_NetBIOSEnabled,
+ SettingsManager.Current.IPScanner_ResolveMACAddress);
}
///
@@ -741,8 +1035,8 @@ private async Task CustomCommand(object guid)
return;
}
- // Replace vars
- var hostname = !string.IsNullOrEmpty(SelectedResult.Hostname) ? SelectedResult.Hostname.TrimEnd('.') : "";
+ // Replace vars. The command resolves the hostname via Windows (e.g. "gateway." -> "gateway")
+ var hostname = DNSClientHelper.SanitizeHostname(SelectedResult.Hostname) ?? "";
// The hostname comes from the network (PTR record or NetBIOS name) and could contain characters
// like "&" that would run additional commands (e.g. "cmd.exe /c ... {{Hostname}}") - only use valid ones
@@ -779,6 +1073,18 @@ private async Task CustomCommand(object guid)
}
}
+ ///
+ /// Gets the selected results in the order shown in the grid ( is in the order they
+ /// were selected).
+ ///
+ /// Selected results.
+ private List GetSelectedResultsInGridOrder()
+ {
+ var selectedResults = new HashSet(SelectedResults.Cast());
+
+ return ResultsView.Cast().Where(selectedResults.Contains).ToList();
+ }
+
///
/// Exports the scan results.
///
@@ -796,15 +1102,13 @@ private Task Export()
try
{
- // Copy the results on the UI thread (they may still change while a scan is running) and
- // export them in the background, so a large export doesn't freeze the window. In the order
- // shown in the grid (Results is in the order the hosts were finished).
- var viewResults = ResultsView.Cast();
-
- var results = instance.ExportAll
- ? viewResults.ToList()
- : viewResults.Where(new HashSet(SelectedResults.Cast())
- .Contains).ToList();
+ // Copy the results on the UI thread (they are still updated while a scan is running) and
+ // export the copies in the background, so a large export doesn't freeze the window. In the
+ // order shown in the grid (Results is in the order the hosts were found).
+ var results = (instance.ExportAll
+ ? ResultsView.Cast()
+ : GetSelectedResultsInGridOrder())
+ .Select(x => x.Clone()).ToList();
await Task.Run(() => ExportManager.Export(instance.FilePath, instance.FileType, results));
}
@@ -847,6 +1151,8 @@ public void OnClose()
_closed = true;
+ SettingsManager.Current.PropertyChanged -= SettingsManager_PropertyChanged;
+
// Stop scan
if (IsRunning)
Stop();
@@ -859,38 +1165,150 @@ public void OnClose()
#region Events
///
- /// Handles the HostScanned event. Adds the result to the list.
+ /// Gets the command to filter the results right away (Enter in the search box, see
+ /// ).
+ ///
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
+
+ ///
+ /// Filters the results with the current right away (e.g. Enter in the search box).
+ ///
+ private void ApplySearch()
+ {
+ // Live filtering only while searching - without a search every host is shown anyway, but it would still
+ // listen to the filtered properties of every row and filter a row again on every update
+ var liveShaping = (ICollectionViewLiveShaping)ResultsView;
+ var isSearching = !string.IsNullOrEmpty(Search);
+
+ if (liveShaping.IsLiveFiltering != isSearching)
+ liveShaping.IsLiveFiltering = isSearching;
+
+ ResultsView.Refresh();
+ }
+
+ ///
+ /// Handles changes of the settings. While no results are shown, the columns etc. follow the current
+ /// settings (see ). Any setting, so new columns don't need to be added here.
///
/// The source of the event.
- /// The instance containing the event data.
- private void HostScanned(object sender, IPScannerHostScannedArgs e)
+ /// The instance containing the event data.
+ private void SettingsManager_PropertyChanged(object sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(SettingsInfo.IPScanner_ExpandHostView))
+ OnPropertyChanged(nameof(HostDetailsVisibilityMode));
+
+ UpdateScanOptionsIfNoResults();
+ }
+
+ ///
+ /// While no results are shown and no scan is running, the columns etc. follow the current settings
+ /// (see ).
+ ///
+ private void UpdateScanOptionsIfNoResults()
+ {
+ if (!IsRunning && Results.Count == 0)
+ ScanOptions = CreateResultOptions();
+ }
+
+ ///
+ /// Handles the HostFound event. Buffers the host, it's added to the list in .
+ ///
+ /// The instance raising the event.
+ /// The instance containing the event data.
+ private void HostFound(object sender, IPScannerHostFoundArgs e)
+ {
+ if (!IsCurrentScanner(sender, nameof(HostFound)))
+ return;
+
+ lock (_resultsBufferLock)
+ {
+ _resultsBuffer.Add((e.Host, null));
+ }
+ }
+
+ ///
+ /// Handles the HostUpdated event. Buffers the update, it's applied in .
+ ///
+ /// The instance raising the event.
+ /// The instance containing the event data.
+ private void HostUpdated(object sender, IPScannerHostUpdatedArgs e)
{
+ if (!IsCurrentScanner(sender, nameof(HostUpdated)))
+ return;
+
lock (_resultsBufferLock)
{
- _resultsBuffer.Add(e.Args);
+ _resultsBuffer.Add((e.Host, e));
}
}
///
- /// Moves buffered scan results into . Always called on the UI thread -
- /// either from the tick, or from within the Dispatcher.Invoke
- /// call in .
+ /// Checks that an event comes from the scanner of the running scan. The scanner raises nothing after
+ /// ScanComplete (all hosts and lookups are finished before), so this only guards against a broken contract.
+ ///
+ /// The source of the event.
+ /// Name of the event (for the log).
+ /// True if the event comes from the scanner of the running scan.
+ private bool IsCurrentScanner(object sender, string eventName)
+ {
+ if (ReferenceEquals(sender, Volatile.Read(ref _ipScanner)))
+ return true;
+
+ Log.Warn($"{eventName} of a finished scan ignored.");
+ return false;
+ }
+
+ ///
+ /// Adds buffered hosts to and applies the buffered updates, in the order they were
+ /// raised. Always called on the UI thread - either from the tick, or from
+ /// within the Dispatcher.Invoke call in .
///
private void FlushResultsBuffer()
{
- List itemsToAdd;
+ List<(IPScannerHostInfo host, IPScannerHostUpdatedArgs update)> items;
lock (_resultsBufferLock)
{
if (_resultsBuffer.Count == 0)
return;
- itemsToAdd = [.. _resultsBuffer];
+ items = [.. _resultsBuffer];
_resultsBuffer.Clear();
}
- foreach (var item in itemsToAdd)
- Results.Add(item);
+ foreach (var (host, update) in items)
+ {
+ if (_rescanRows != null)
+ ApplyRescanResult(host, update);
+ else if (update == null)
+ Results.Add(host);
+ else
+ update.Apply();
+ }
+ }
+
+ ///
+ /// Applies a buffered result of a rescan to the existing rows: a found host is copied into the row of its IP
+ /// address, its updates are applied to this row.
+ ///
+ /// Result of the rescan.
+ /// Update of the result, null if the host was found.
+ private void ApplyRescanResult(IPScannerHostInfo host, IPScannerHostUpdatedArgs update)
+ {
+ if (update == null)
+ {
+ if (!_rescanRows.TryGetValue(host.IPAddress, out var row))
+ return;
+
+ row.CopyFrom(host);
+
+ _rescanResults[host] = row;
+
+ return;
+ }
+
+ if (_rescanResults.TryGetValue(update.Host, out var target))
+ update.ApplyTo(target);
}
///
@@ -902,13 +1320,21 @@ private void FlushResultsBuffer()
/// The instance containing the event data.
private void ProgressChanged(object sender, ProgressChangedArgs e)
{
- Volatile.Write(ref _latestHostsScanned, e.Value);
-
if (sender is IPScanner ipScanner)
- {
- Volatile.Write(ref _latestHostsUp, ipScanner.HostsUp);
- Volatile.Write(ref _latestHostsDown, ipScanner.HostsDown);
- }
+ StoreProgress(ipScanner);
+ }
+
+ ///
+ /// Stores the current counters of the scanner for . Read from the scanner instead of
+ /// the event args: events of different hosts may arrive in a different order than they were raised (e.g. a
+ /// found host and a finished host at the same time), the scanner always has the current values.
+ ///
+ /// Scanner of the running scan.
+ private void StoreProgress(IPScanner ipScanner)
+ {
+ Volatile.Write(ref _latestHostsScanned, ipScanner.HostsScanned);
+ Volatile.Write(ref _latestHostsUp, ipScanner.HostsUp);
+ Volatile.Write(ref _latestHostsDown, ipScanner.HostsDown);
}
///
@@ -919,6 +1345,19 @@ private void ProgressChanged(object sender, ProgressChangedArgs e)
private void FlushProgress()
{
HostsScanned = Volatile.Read(ref _latestHostsScanned);
+
+ // Rescan: the counters of the scanner only cover the rescanned hosts - count the shown rows instead (every
+ // reachable host is shown), unreachable = all counted hosts of the last scan minus the reachable ones. Not the
+ // scanned hosts: a found host is counted as up before it is finished, so after a canceled scan there can be
+ // more reachable rows than scanned hosts.
+ if (_rescanRows != null)
+ {
+ HostsUp = Results.Count(x => x.IsReachable);
+ HostsDown = Math.Max(0, _rescanHostsCounted - HostsUp);
+
+ return;
+ }
+
HostsUp = Volatile.Read(ref _latestHostsUp);
HostsDown = Volatile.Read(ref _latestHostsDown);
}
@@ -934,17 +1373,45 @@ private void ScanComplete(object sender, ScanCompleteArgs e)
{
Application.Current.Dispatcher.Invoke(() =>
{
+ // All hosts and lookups are finished when the scanner raises ScanComplete - add the remaining
+ // buffered hosts and apply the remaining updates before the scan ends, with the final counters
+ _resultsFlushTimer?.Stop();
+ FlushResultsBuffer();
+
+ if (sender is IPScanner finishedScanner)
+ StoreProgress(finishedScanner);
+
+ // Canceled / failed rescan: keep the previous results of the rows (before the counters are computed
+ // from them)
+ if (_rescanRows != null && (e.IsCanceled || e.Exception != null))
+ {
+ foreach (var (row, snapshot) in _rescanSnapshots)
+ row.CopyFrom(snapshot);
+ }
+
+ FlushProgress();
+
+ // Rescan: back to the progress of the last scan
+ if (_rescanRows != null)
+ {
+ _rescanRows = null;
+ _rescanResults.Clear();
+ _rescanSnapshots = null;
+
+ HostsToScan = _rescanHostsToScan;
+ HostsScanned = _rescanHostsScanned;
+ }
+
if (sender is IPScanner ipScanner)
{
- ipScanner.HostScanned -= HostScanned;
+ ipScanner.HostFound -= HostFound;
+ ipScanner.HostUpdated -= HostUpdated;
ipScanner.ScanComplete -= ScanComplete;
ipScanner.ProgressChanged -= ProgressChanged;
- }
- // Results are buffered - add the remaining ones
- _resultsFlushTimer?.Stop();
- FlushResultsBuffer();
- FlushProgress();
+ if (ReferenceEquals(ipScanner, _ipScanner))
+ _ipScanner = null;
+ }
if (e.IsCanceled)
{
@@ -958,7 +1425,7 @@ private void ScanComplete(object sender, ScanCompleteArgs e)
StatusMessage = e.Exception.Message;
IsStatusMessageDisplayed = true;
}
- else if (Results.Count == 0)
+ else if (Results.Count == 0 && HostsToScan > 0)
{
// Keep a message from the preparation (e.g. hostnames that could not be resolved)
StatusMessage = IsStatusMessageDisplayed
@@ -970,6 +1437,9 @@ private void ScanComplete(object sender, ScanCompleteArgs e)
PreparingScan = false;
IsCanceling = false;
IsRunning = false;
+
+ // E.g. canceled during the preparation, or nothing found
+ UpdateScanOptionsIfNoResults();
});
}
diff --git a/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs b/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs
index 4ee8c7b218..7e454280fd 100644
--- a/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ImportProfilesResultViewModel.cs
@@ -5,13 +5,13 @@
using System.Linq;
using System.Windows.Data;
using System.Windows.Input;
-using System.Windows.Threading;
using log4net;
using NETworkManager.Localization;
using NETworkManager.Models.RDAP;
using NETworkManager.Profiles;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
namespace NETworkManager.ViewModels;
@@ -20,7 +20,7 @@ public sealed class ImportProfilesResultViewModel : ViewModelBase
private static readonly ILog Log = LogManager.GetLogger(typeof(ImportProfilesResultViewModel));
private readonly Action _completedCallback;
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
public ImportProfilesResultViewModel(IReadOnlyList candidates, ProfileImportSource importSource, string targetGroup, Action backCallback, Action cancelCallback, Action completedCallback)
{
@@ -50,8 +50,7 @@ public ImportProfilesResultViewModel(IReadOnlyList candi
CandidatesView = CollectionViewSource.GetDefaultView(Candidates);
CandidatesView.Filter = FilterCandidate;
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
ApplicationOptions = BuildApplicationOptions();
foreach (var tool in ApplicationOptions)
@@ -78,8 +77,7 @@ public string Search
field = value;
IsSearching = true;
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -308,9 +306,17 @@ private void OnApplicationOptionChanged(object sender, PropertyChangedEventArgs
HasAnyEnabledApplication = ApplicationOptions.Any(t => t.IsEnabled);
}
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
+ ///
+ /// Gets the command to filter right away (Enter in the search box, see
+ /// ).
+ ///
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
+
+ ///
+ /// Filters with the current right away.
+ ///
+ private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
CandidatesView.Refresh();
IsSearching = false;
}
diff --git a/Source/NETworkManager/ViewModels/ListenersViewModel.cs b/Source/NETworkManager/ViewModels/ListenersViewModel.cs
index 257f495121..0430f71f6c 100644
--- a/Source/NETworkManager/ViewModels/ListenersViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ListenersViewModel.cs
@@ -6,6 +6,7 @@
using NETworkManager.Models.Network;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -33,6 +34,8 @@ public class ListenersViewModel : ViewModelBase
///
public ListenersViewModel()
{
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
+
_isLoading = true;
// Result view + search
@@ -107,6 +110,12 @@ private async void AutoRefreshTimer_Tick(object sender, EventArgs e)
private readonly DispatcherTimer _autoRefreshTimer = new();
private bool _isViewVisible = true;
+ ///
+ /// Delays filtering while the user is typing (see ), so a large list is not filtered on
+ /// every key press.
+ ///
+ private readonly SearchDelay _searchDelay;
+
///
/// Gets or sets the search text.
///
@@ -120,7 +129,8 @@ public string Search
field = value;
- ResultsView.Refresh();
+ // Filter after a short delay while typing (see _searchDelay)
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -426,5 +436,19 @@ public void OnViewHide()
_autoRefreshTimer.Stop();
}
+ ///
+ /// Gets the command to filter right away (Enter in the search box, see
+ /// ).
+ ///
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
+
+ ///
+ /// Filters with the current right away.
+ ///
+ private void ApplySearch()
+ {
+ ResultsView.Refresh();
+ }
+
#endregion
}
\ No newline at end of file
diff --git a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs
index 0f30bb1044..1c227e2682 100644
--- a/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs
+++ b/Source/NETworkManager/ViewModels/NeighborTableViewModel.cs
@@ -6,6 +6,7 @@
using NETworkManager.Models.Network;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
@@ -28,11 +29,19 @@ public class NeighborTableViewModel : ViewModelBase
{
#region Contructor, load settings
+ ///
+ /// Delays filtering while the user is typing (see ), so a large list is not filtered on
+ /// every key press.
+ ///
+ private readonly SearchDelay _searchDelay;
+
///
/// Initializes a new instance of the class.
///
public NeighborTableViewModel()
{
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
+
_isLoading = true;
// Result view + search
@@ -101,7 +110,8 @@ public string Search
field = value;
- ResultsView.Refresh();
+ // Filter after a short delay while typing (see _searchDelay)
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -504,5 +514,19 @@ private async void AutoRefreshTimer_Tick(object sender, EventArgs e)
_autoRefreshTimer.Start();
}
+ ///
+ /// Gets the command to filter right away (Enter in the search box, see
+ /// ).
+ ///
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
+
+ ///
+ /// Filters with the current right away.
+ ///
+ private void ApplySearch()
+ {
+ ResultsView.Refresh();
+ }
+
#endregion
}
diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
index 7e3b50c6d3..427f0ce087 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
@@ -324,7 +324,8 @@ public async Task Start()
try
{
hosts = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(Host),
- SettingsManager.Current.Network_ResolveHostnamePreferIPv4, _cancellationTokenSource.Token);
+ SettingsManager.Current.Network_ResolveHostnamePreferIPv4, keepAllHostnames: true,
+ _cancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
diff --git a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
index 264808f225..ab7395266f 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
@@ -102,10 +102,11 @@ private set
///
/// Gets the host display name used as the notification header: the hostname if available,
- /// otherwise the IP address as a fallback.
+ /// otherwise the IP address as a fallback. Without a trailing dot (single-label name from DNS like
+ /// "gateway." or a hostname entered with a dot).
///
private string NotificationHostDisplay =>
- string.IsNullOrEmpty(Hostname) ? IPAddress.ToString() : Hostname.TrimEnd('.');
+ DNSClientHelper.SanitizeHostname(Hostname) is { Length: > 0 } hostname ? hostname : IPAddress.ToString();
///
/// Gets the hostname of the monitored host.
@@ -802,7 +803,7 @@ private void Ping_HostnameResolved(object sender, HostnameArgs e)
{
// Update title if name was not set in the constructor
if (string.IsNullOrEmpty(Hostname))
- Title = $"{e.Hostname.TrimEnd('.')} # {IPAddress}";
+ Title = $"{DNSClientHelper.SanitizeHostname(e.Hostname)} # {IPAddress}";
Hostname = e.Hostname;
}
diff --git a/Source/NETworkManager/ViewModels/PortProfileViewModel.cs b/Source/NETworkManager/ViewModels/PortProfileViewModel.cs
index 996da98ec8..86e386fb72 100644
--- a/Source/NETworkManager/ViewModels/PortProfileViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortProfileViewModel.cs
@@ -1,6 +1,9 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Windows.Input;
using NETworkManager.Models.Network;
+using NETworkManager.Settings;
using NETworkManager.Utilities;
namespace NETworkManager.ViewModels;
@@ -34,6 +37,10 @@ public PortProfileViewModel(Action saveCommand, Action !ReferenceEquals(x, info))
+ .Select(x => x.Name)];
+
Name = _info.Name;
// List to string
@@ -45,6 +52,11 @@ public PortProfileViewModel(Action saveCommand, Action
+ /// Gets the names of the other port profiles (the name must be unique).
+ ///
+ public List UsedNames { get; }
+
///
/// Gets the command to save the profile.
///
diff --git a/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs b/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs
deleted file mode 100644
index 06a21f8033..0000000000
--- a/Source/NETworkManager/ViewModels/PortProfilesViewModel.cs
+++ /dev/null
@@ -1,135 +0,0 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.ComponentModel;
-using System.Linq;
-using System.Windows.Data;
-using System.Windows.Input;
-using NETworkManager.Models.Network;
-using NETworkManager.Settings;
-using NETworkManager.Utilities;
-
-namespace NETworkManager.ViewModels;
-
-///
-/// Represents the view model for managing port profiles.
-///
-public class PortProfilesViewModel : ViewModelBase
-{
- #region Constructor
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The action to execute when the OK command is invoked.
- /// The action to execute when the Cancel command is invoked.
- public PortProfilesViewModel(Action okCommand, Action cancelHandler)
- {
- // Clear the filter on close so it doesn't leak into other views sharing this collection's default view.
- OKCommand = new RelayCommand(_ =>
- {
- PortProfiles.Filter = null;
- okCommand(this);
- });
- CancelCommand = new RelayCommand(_ =>
- {
- PortProfiles.Filter = null;
- cancelHandler(this);
- });
-
- PortProfiles = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_PortProfiles);
- PortProfiles.SortDescriptions.Add(
- new SortDescription(nameof(PortProfileInfo.Name), ListSortDirection.Ascending));
- PortProfiles.Filter = o =>
- {
- if (string.IsNullOrEmpty(Search))
- return true;
-
- if (o is not PortProfileInfo info)
- return false;
-
- var search = Search.Trim();
-
- // Search: Name, Ports
- return info.Name.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1 ||
- info.Ports.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1;
- };
-
- SelectedPortProfiles = new ArrayList {
- PortProfiles.Cast().FirstOrDefault()
- };
- }
-
- #endregion
-
- #region Variables
-
- ///
- /// Gets the command to confirm the selection.
- ///
- public ICommand OKCommand { get; }
-
- ///
- /// Gets the command to cancel the operation.
- ///
- public ICommand CancelCommand { get; }
-
- ///
- /// Gets or sets the search text to filter the port profiles.
- ///
- public string Search
- {
- get;
- set
- {
- if (value == field)
- return;
-
- field = value;
-
- PortProfiles.Refresh();
-
- SelectedPortProfiles = new ArrayList
- {
- PortProfiles.Cast().FirstOrDefault()
- };
-
- OnPropertyChanged();
- }
- }
-
- ///
- /// Gets the collection of port profiles.
- ///
- public ICollectionView PortProfiles { get; }
-
- ///
- /// Gets or sets the list of selected port profiles.
- ///
- public IList SelectedPortProfiles
- {
- get;
- set
- {
- if (Equals(value, field))
- return;
-
- field = value;
- OnPropertyChanged();
- }
- } = new ArrayList();
-
- #endregion
-
- #region Methods
- ///
- /// Gets the selected port profiles as a typed collection.
- ///
- /// A collection of selected .
- public IEnumerable GetSelectedPortProfiles()
- {
- return [.. SelectedPortProfiles.Cast()];
- }
- #endregion
-}
\ No newline at end of file
diff --git a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs
index 7e86dae89d..e095857c2c 100644
--- a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs
@@ -2,7 +2,9 @@
using NETworkManager.Controls;
using NETworkManager.Localization.Resources;
using NETworkManager.Models;
+using NETworkManager.Models.Network;
using NETworkManager.Profiles;
+using NETworkManager.Settings;
using NETworkManager.Utilities;
using NETworkManager.Views;
using System;
@@ -137,19 +139,36 @@ private static void CloseItemAction(ItemActionCallbackArgs args)
/// The host to scan.
/// The ports to scan.
/// Starts it on the first load. If false, the input is only filled in.
- public void AddTab(string host = null, string ports = null, bool start = true)
+ /// Message shown below the input (e.g. a port profile of the profile was not found).
+ public void AddTab(string host = null, string ports = null, bool start = true, string message = null)
{
var tabId = Guid.NewGuid();
TabItems.Add(new DragablzTabItem(string.IsNullOrEmpty(host) ? Strings.NewTab : host,
- new PortScannerView(tabId, host, ports, start), tabId));
+ new PortScannerView(tabId, host, ports, start, message), tabId));
SelectedTabIndex = TabItems.Count - 1;
}
public void AddTab(ProfileInfo profile, bool start = true)
{
- AddTab(profile.PortScanner_Host, profile.PortScanner_Ports, start: start);
+ // Port profiles with their current name and ports. A port profile that can't be found (deleted, or a profile
+ // file shared from another computer - port profiles are saved in the settings): the ports saved with the
+ // profile, with a note.
+ if (string.IsNullOrEmpty(profile.PortScanner_PortsInput))
+ {
+ AddTab(profile.PortScanner_Host, profile.PortScanner_Ports, start);
+ }
+ else if (PortProfile.TryDecodeReferences(profile.PortScanner_PortsInput,
+ SettingsManager.Current.PortScanner_PortProfiles, out var ports))
+ {
+ AddTab(profile.PortScanner_Host, ports, start);
+ }
+ else
+ {
+ AddTab(profile.PortScanner_Host, profile.PortScanner_Ports, start,
+ Strings.PortProfileOfProfileNotFoundMessage);
+ }
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
index a2c07cbfde..66b7e11f67 100644
--- a/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerSettingsViewModel.cs
@@ -4,6 +4,8 @@
using NETworkManager.Settings;
using NETworkManager.Utilities;
using NETworkManager.Views;
+using System;
+using System.Collections;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
@@ -41,7 +43,23 @@ public PortProfileInfo SelectedPortProfile
field = value;
OnPropertyChanged();
}
- } = new();
+ }
+
+ ///
+ /// Gets or sets the selected port profiles (several can be deleted at once).
+ ///
+ public IList SelectedPortProfiles
+ {
+ get;
+ set
+ {
+ if (Equals(value, field))
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = new ArrayList();
///
/// Gets or sets a value indicating whether to show all results (open and closed ports).
@@ -119,6 +137,45 @@ public bool ResolveHostname
}
}
+ ///
+ /// Gets or sets whether every host is pinged before its ports are scanned (hosts without answer are skipped).
+ ///
+ public bool PingHostsBeforeScan
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.PortScanner_PingHostsBeforeScan = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets whether the number of hosts and ports scanned at the same time is adjusted to the scan (instead of
+ /// and ).
+ ///
+ public bool AutoAdjustThreads
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.PortScanner_AutoAdjustThreads = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets or sets the maximum number of threads for scanning hosts.
///
@@ -135,9 +192,17 @@ public int MaxHostThreads
field = value;
OnPropertyChanged();
+ OnPropertyChanged(nameof(MaxPortThreadsMaximum));
}
}
+ ///
+ /// Gets the maximum of : 256, but host threads x port threads at most
+ /// (each connection uses a local port).
+ ///
+ public int MaxHostThreadsMaximum =>
+ Math.Min(256, GlobalStaticConfiguration.PortScanner_MaxConnections / Math.Max(1, MaxPortThreads));
+
///
/// Gets or sets the maximum number of threads for scanning ports.
///
@@ -154,9 +219,17 @@ public int MaxPortThreads
field = value;
OnPropertyChanged();
+ OnPropertyChanged(nameof(MaxHostThreadsMaximum));
}
}
+ ///
+ /// Gets the maximum of : 512, but host threads x port threads at most
+ /// (each connection uses a local port).
+ ///
+ public int MaxPortThreadsMaximum =>
+ Math.Min(512, GlobalStaticConfiguration.PortScanner_MaxConnections / Math.Max(1, MaxHostThreads));
+
#endregion
#region Constructor, load settings
@@ -168,11 +241,12 @@ public PortScannerSettingsViewModel()
{
_isLoading = true;
- PortProfiles = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_PortProfiles);
+ // Own view (not the default view of the collection), sort and selection only apply to the settings
+ PortProfiles = new CollectionViewSource { Source = SettingsManager.Current.PortScanner_PortProfiles }.View;
PortProfiles.SortDescriptions.Add(
new SortDescription(nameof(PortProfileInfo.Name), ListSortDirection.Ascending));
- SelectedPortProfile = PortProfiles.Cast().FirstOrDefault();
+ SelectFirstPortProfile();
LoadSettings();
@@ -184,7 +258,9 @@ private void LoadSettings()
ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults;
Timeout = SettingsManager.Current.PortScanner_Timeout;
Attempts = SettingsManager.Current.PortScanner_Attempts;
+ PingHostsBeforeScan = SettingsManager.Current.PortScanner_PingHostsBeforeScan;
ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname;
+ AutoAdjustThreads = SettingsManager.Current.PortScanner_AutoAdjustThreads;
MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads;
MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads;
}
@@ -200,14 +276,35 @@ private void AddPortProfileAction()
_ = AddPortProfile();
}
- public ICommand EditPortProfileCommand => new RelayCommand(_ => EditPortProfileAction());
+ public ICommand EditPortProfileCommand =>
+ new RelayCommand(_ => EditPortProfileAction(), EditPortProfile_CanExecute);
+
+ ///
+ /// Checks if a port profile is selected (edit and delete need one, e.g. F2 / Del without a selection).
+ ///
+ ///
+ /// Checks if exactly one port profile is selected (e.g. F2 without a selection or with several selected).
+ ///
+ private bool EditPortProfile_CanExecute(object parameter)
+ {
+ return SelectedPortProfile != null && SelectedPortProfiles.Count == 1;
+ }
+
+ ///
+ /// Checks if at least one port profile is selected (e.g. Del without a selection).
+ ///
+ private bool DeletePortProfile_CanExecute(object parameter)
+ {
+ return SelectedPortProfiles.Count > 0;
+ }
private void EditPortProfileAction()
{
_ = EditPortProfile();
}
- public ICommand DeletePortProfileCommand => new RelayCommand(_ => DeletePortProfileAction());
+ public ICommand DeletePortProfileCommand =>
+ new RelayCommand(_ => DeletePortProfileAction(), DeletePortProfile_CanExecute);
private void DeletePortProfileAction()
{
@@ -247,20 +344,27 @@ public Task EditPortProfile()
{
var childWindow = new PortProfileChildWindow();
+ var portProfile = SelectedPortProfile;
+
var childWindowViewModel = new PortProfileViewModel(async instance =>
{
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
- SettingsManager.Current.PortScanner_PortProfiles.Remove(SelectedPortProfile);
- SettingsManager.Current.PortScanner_PortProfiles.Add(new PortProfileInfo(instance.Name,
- instance.Ports));
+ // Same ID, profiles that reference the port profile still find it (also with a new name)
+ var editedPortProfile = new PortProfileInfo(instance.Name, instance.Ports);
+
+ if (portProfile.Id != Guid.Empty)
+ editedPortProfile.Id = portProfile.Id;
+
+ SettingsManager.Current.PortScanner_PortProfiles.Remove(portProfile);
+ SettingsManager.Current.PortScanner_PortProfiles.Add(editedPortProfile);
}, async _ =>
{
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
}, true,
- SelectedPortProfile);
+ portProfile);
childWindow.Title = Strings.EditPortProfile;
@@ -271,21 +375,37 @@ public Task EditPortProfile()
return Application.Current.MainWindow.ShowChildWindowAsync(childWindow);
}
+ ///
+ /// Deletes the selected port profiles (after a confirmation that lists them).
+ ///
private async Task DeletePortProfile()
{
+ var portProfiles = SelectedPortProfiles.Cast().ToList();
+
var result = await DialogHelper.ShowConfirmationMessageAsync(Application.Current.MainWindow,
- Strings.DeletePortProfile,
- Strings.DeletePortProfileMessage,
+ portProfiles.Count == 1 ? Strings.DeletePortProfile : Strings.DeletePortProfiles,
+ (portProfiles.Count == 1 ? Strings.DeletePortProfileMessage : Strings.DeletePortProfilesMessage) +
+ Environment.NewLine + Environment.NewLine +
+ DialogHelper.FormatItemList([.. portProfiles.Select(x => x.Name)]),
ChildWindowIcon.Info,
Strings.Delete);
if (!result)
return;
- SettingsManager.Current.PortScanner_PortProfiles.Remove(SelectedPortProfile);
+ foreach (var portProfile in portProfiles)
+ SettingsManager.Current.PortScanner_PortProfiles.Remove(portProfile);
- // Select first item after deletion
+ SelectFirstPortProfile();
+ }
+
+ ///
+ /// Selects the first port profile (also in the list of selected port profiles, which the grid applies).
+ ///
+ private void SelectFirstPortProfile()
+ {
SelectedPortProfile = PortProfiles.Cast().FirstOrDefault();
+ SelectedPortProfiles = SelectedPortProfile != null ? new ArrayList { SelectedPortProfile } : new ArrayList();
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index f618cc7747..d92c6fc70c 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -2,16 +2,19 @@
using MahApps.Metro.Controls;
using MahApps.Metro.SimpleChildWindow;
using NETworkManager.Controls;
+using NETworkManager.Localization;
using NETworkManager.Localization.Resources;
+using NETworkManager.Models;
using NETworkManager.Models.Export;
using NETworkManager.Models.Network;
+using NETworkManager.Profiles;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using System;
using System.Collections;
using System.Collections.Generic;
-using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Net;
@@ -27,11 +30,18 @@ namespace NETworkManager.ViewModels;
///
/// ViewModel for the Port Scanner feature.
///
-public class PortScannerViewModel : ViewModelBase
+public class PortScannerViewModel : ViewModelBase, IProfileManagerMinimal
{
#region Variables
private static readonly ILog Log = LogManager.GetLogger(typeof(PortScannerViewModel));
+ ///
+ /// Maximum effort (changed rows x all rows) to remove / add the rows one by one when the results of hosts are
+ /// replaced (rescan, see ) or buffered results are added (see
+ /// ). E.g. 1,000 rows of 10,000 or 10 rows of 1,000,000. Above, with one reset.
+ ///
+ private const long MaxRowChangesOneByOne = 10_000_000;
+
private CancellationTokenSource _cancellationTokenSource;
private readonly Guid _tabId;
@@ -51,6 +61,46 @@ public class PortScannerViewModel : ViewModelBase
private int _latestPortsScanned;
private int _latestPortsOpen;
private int _latestPortsClosed;
+ private int _latestPortsTimedOut;
+
+ // Ports of the last scan, to rescan hosts with the same ports (and ScanOptions)
+ private int[] _lastPorts;
+
+ // Ports of the last scan as entered (e.g. "22; 80; 443"), for a profile created from a result
+ private string _lastPortsInput;
+
+ // Message of the tab, shown until the first scan has started or the ports are changed (see constructor)
+ private string _message;
+
+ // Rescan of shown hosts (see RescanHosts): their rows are removed and added again by the rescan. The
+ // counters are the ones of the other hosts (base) plus the ones of the rescan; the progress of the last scan is
+ // restored afterwards.
+ private bool _isRescan;
+ private int _rescanBasePortsOpen;
+ private int _rescanBasePortsClosed;
+ private int _rescanBasePortsTimedOut;
+ private int _rescanPortsToScan;
+ private int _rescanPortsScanned;
+
+ // Rows and counters of the rescanned hosts before the rescan, restored if the rescan is canceled or fails
+ private HashSet _rescanIPAddresses;
+ private List _rescanRemovedRows;
+ private int _rescanPortsOpen;
+ private int _rescanPortsClosed;
+ private int _rescanPortsTimedOut;
+
+ // Ports of the last scan without the rescanned hosts, the ports of the rescan are added when it is finished
+ private int _rescanBasePortsScanned;
+
+ // Rescan of the selected ports (see RescanSelectedPorts): their rows by IP address and port, updated in place
+ // (not removed / added), and their states before the rescan
+ private Dictionary<(IPAddress ipAddress, int port), PortScannerPortInfo> _rescanPortRows;
+ private Dictionary _rescanPortPreviousStates;
+
+ // Closed and timed out ports per host of the shown result. They are not shown unless enabled - a rescan needs them
+ // to keep the counters of the other hosts (also if the last scan was canceled before all ports of a host were
+ // scanned)
+ private readonly Dictionary _portsNotOpenPerHost = [];
///
/// Gets or sets the host to scan.
@@ -86,13 +136,34 @@ public string Ports
field = value;
OnPropertyChanged();
+
+ // The message of the tab is about the ports passed in (e.g. a port profile of the profile was not
+ // found), it no longer applies when they are changed. Hidden only if it is still shown - not an error of
+ // a scan that didn't start (e.g. invalid hosts)
+ if (_message != null)
+ {
+ if (StatusMessage == _message)
+ {
+ IsStatusMessageDisplayed = false;
+ StatusMessage = string.Empty;
+ }
+
+ _message = null;
+ }
}
}
+ private readonly InputSuggestions _portsSuggestions = InputSuggestions.ForPortScannerPorts();
+
+ ///
+ /// Gets the port profiles and the ports history for the ports input (grouped).
+ ///
+ public ICollectionView PortsSuggestions => _portsSuggestions.View;
+
///
- /// Gets the collection view for the ports history.
+ /// Gets the names of the port profiles for the Tab completion of the ports input.
///
- public ICollectionView PortsHistoryView { get; }
+ public IEnumerable PortProfileNames => _portsSuggestions.ProfileNames;
///
/// Gets or sets a value indicating whether the scan is currently running.
@@ -130,7 +201,7 @@ public bool IsCanceling
///
/// Gets or sets the collection of scan results.
///
- public ObservableCollection Results
+ public ObservableRangeCollection Results
{
get;
set
@@ -142,6 +213,27 @@ public ObservableCollection Results
}
} = [];
+ ///
+ /// Gets the number of open ports per host (IP address) of the shown result, for the group headers. Counted when a
+ /// result is added (see ) - counting the rows of a group on every added row would take
+ /// rows x rows with closed ports shown. Changed on the UI thread only, before the row is added.
+ ///
+ public Dictionary OpenPortsPerHost { get; } = [];
+
+ ///
+ /// Gets a number that changes when changes without a row being added or removed
+ /// (a port rescanned in place), so the group headers are updated.
+ ///
+ public int OpenPortsPerHostVersion
+ {
+ get;
+ private set
+ {
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets the collection view for the scan results.
///
@@ -160,9 +252,16 @@ public PortScannerPortInfo SelectedResult
field = value;
OnPropertyChanged();
+ OnPropertyChanged(nameof(IsWebConsoleAvailable));
}
}
+ ///
+ /// Gets a value indicating whether the selected result is an open web port (80, 443, 8080, 8443).
+ ///
+ public bool IsWebConsoleAvailable => SelectedResult is { State: PortState.Open } selectedResult &&
+ WebConsoleUrlHelper.IsWebPort(selectedResult.Port);
+
///
/// Gets or sets the list of currently selected scan results (for multi-selection).
///
@@ -228,7 +327,7 @@ public int PortsOpen
}
///
- /// Gets or sets the number of ports found to be closed so far.
+ /// Gets or sets the number of ports found to be closed (the host refused the connection) so far.
///
public int PortsClosed
{
@@ -243,6 +342,22 @@ public int PortsClosed
}
}
+ ///
+ /// Gets or sets the number of ports without an answer (timed out) so far.
+ ///
+ public int PortsTimedOut
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets or sets a value indicating whether the scan is being prepared.
///
@@ -275,6 +390,32 @@ public bool IsStatusMessageDisplayed
}
}
+ ///
+ /// Delays filtering the results while the user is typing (see ), so a large result is not
+ /// filtered on every key press.
+ ///
+ private readonly SearchDelay _searchDelay;
+
+ ///
+ /// Gets or sets the search text for filtering the results (IP address, hostname, port, protocol, service, status).
+ /// The results are filtered after a short delay (see ).
+ ///
+ public string Search
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+
+ _searchDelay.Restart();
+
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets the status message to display.
///
@@ -291,6 +432,24 @@ private set
}
}
+ ///
+ /// Gets the options of the scan whose results are shown. Columns and context menu follow them instead of
+ /// the current settings, which may have been changed since. While no results are shown, they follow the
+ /// current settings.
+ ///
+ public PortScannerOptions ScanOptions
+ {
+ get;
+ private set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
#endregion
#region Constructor, load settings, shutdown
@@ -302,7 +461,10 @@ private set
/// The initial host to scan.
/// The initial ports to scan.
/// Starts it on the first load. If false, the input is only filled in.
- public PortScannerViewModel(Guid tabId, string host, string port, bool start = true)
+ ///
+ /// Message shown below the input, also with the first scan (e.g. a port profile of the profile was not found).
+ ///
+ public PortScannerViewModel(Guid tabId, string host, string port, bool start = true, string message = null)
{
_firstLoad = start;
@@ -312,9 +474,22 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t
Host = host;
Ports = port;
+ // After the ports, changing them removes the message (see Ports)
+ if (!string.IsNullOrEmpty(message))
+ {
+ _message = message;
+
+ StatusMessage = message;
+ IsStatusMessageDisplayed = true;
+ }
+
// Set collection view
HostHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_HostHistory);
- PortsHistoryView = CollectionViewSource.GetDefaultView(SettingsManager.Current.PortScanner_PortHistory);
+
+ // Columns etc. until the first scan
+ ScanOptions = CreateScanOptions();
+
+ SettingsManager.Current.PropertyChanged += SettingsManager_PropertyChanged;
// Result view
ResultsView = CollectionViewSource.GetDefaultView(Results);
@@ -328,6 +503,30 @@ public PortScannerViewModel(Guid tabId, string host, string port, bool start = t
return result != 0 ? result : x.Port.CompareTo(y.Port);
});
+ // Results don't change after they were added, no live filtering needed
+ ResultsView.Filter = o =>
+ {
+ if (string.IsNullOrEmpty(Search))
+ return true;
+
+ if (o is not PortScannerPortInfo info)
+ return false;
+
+ bool Matches(string value) => value?.Contains(Search, StringComparison.OrdinalIgnoreCase) == true;
+
+ // The status is shown translated, so the translated text is searched as well
+ return Matches(info.IPAddress.ToString()) ||
+ Matches(info.Hostname) ||
+ Matches(info.Port.ToString()) ||
+ Matches(info.LookupInfo.Protocol.ToString()) ||
+ Matches(info.LookupInfo.Service) ||
+ Matches(info.LookupInfo.Description) ||
+ Matches(info.State.ToString()) ||
+ Matches(ResourceTranslator.Translate(ResourceIdentifier.PortState, info.State));
+ };
+
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
+
LoadSettings();
}
@@ -354,6 +553,8 @@ public void OnClose()
_closed = true;
+ SettingsManager.Current.PropertyChanged -= SettingsManager_PropertyChanged;
+
// Stop scan
if (IsRunning)
Stop();
@@ -365,21 +566,6 @@ public void OnClose()
#region ICommands & Actions
- public ICommand OpenPortProfileSelectionCommand =>
- new RelayCommand(_ => OpenPortProfileSelectionAction(), OpenPortProfileSelection_CanExecute);
-
- private bool OpenPortProfileSelection_CanExecute(object parameter)
- {
- return Application.Current.MainWindow != null &&
- !((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen &&
- !ConfigurationManager.Current.IsChildWindowOpen;
- }
-
- private void OpenPortProfileSelectionAction()
- {
- _ = OpenPortProfileSelection();
- }
-
public ICommand ScanCommand => new RelayCommand(_ => ScanAction(), Scan_CanExecute);
private bool Scan_CanExecute(object parameter)
@@ -397,46 +583,238 @@ private void ScanAction()
_ = Start();
}
- public ICommand ExportCommand => new RelayCommand(_ => ExportAction());
+ ///
+ /// Gets the command to rescan the selected ports (rows), updated in place.
+ ///
+ public ICommand RescanSelectedPortsCommand => new RelayCommand(_ => RescanSelectedPorts());
- private void ExportAction()
+ ///
+ /// Gets the command to rescan the host of a group (with the ports and options of the last scan). The parameter is
+ /// the group () of the host.
+ ///
+ public ICommand RescanHostCommand => new RelayCommand(RescanHostAction);
+
+ private void RescanHostAction(object parameter)
{
- _ = Export();
+ if (parameter is CollectionViewGroup && GetHostRow(parameter) is { } row)
+ RescanHosts([row.IPAddress]);
}
- #endregion
+ ///
+ /// Gets the row of the host a command is for: the first row of a group (parameter of the context menu of a group
+ /// header, one host per group), otherwise the selected result (context menu of a row).
+ ///
+ /// Command parameter, a for a group header.
+ /// Row with the IP address and hostname of the host.
+ private PortScannerPortInfo GetHostRow(object parameter)
+ {
+ return parameter is CollectionViewGroup { Items.Count: > 0 } group &&
+ group.Items[0] is PortScannerPortInfo row
+ ? row
+ : SelectedResult;
+ }
- #region Methods
+ ///
+ /// Gets the command to open a host in another application. Parameter: the application (host of the selected
+ /// result), or the application and the group of a group header (see ).
+ ///
+ public ICommand RedirectDataToApplicationCommand => new RelayCommand(RedirectDataToApplicationAction);
- private async Task OpenPortProfileSelection()
+ private void RedirectDataToApplicationAction(object parameter)
{
- var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive);
+ var (name, hostParameter) = parameter is object[] { Length: 2 } values
+ ? (values[0], values[1])
+ : (parameter, null);
+
+ if (name is not ApplicationName applicationName)
+ return;
+
+ NavigationManager.Open(new ApplicationRequest(applicationName) { Target = GetHost(GetHostRow(hostParameter)) });
+ }
+
+ ///
+ /// Gets the host of a result for other tools: the hostname (always a DNS hostname from the PTR record), otherwise
+ /// the IP address.
+ ///
+ /// Result of the host.
+ /// Hostname or IP address.
+ private static string GetHost(PortScannerPortInfo row)
+ {
+ return !string.IsNullOrEmpty(row.Hostname) ? row.Hostname : row.IPAddress.ToString();
+ }
- var childWindow = new PortProfilesChildWindow(window);
+ ///
+ /// Gets the command to perform a DNS lookup for the IP address of a host (see ).
+ ///
+ public ICommand PerformDNSLookupIPAddressCommand =>
+ new RelayCommand(parameter => PerformDNSLookupIPAddressAction(GetHostRow(parameter)));
+
+ private static void PerformDNSLookupIPAddressAction(PortScannerPortInfo row)
+ {
+ NavigationManager.Open(new DNSLookupRequest { Target = row.IPAddress.ToString() });
+ }
+
+ ///
+ /// Gets the command to perform a DNS lookup for the hostname of a host (see ).
+ ///
+ public ICommand PerformDNSLookupHostnameCommand =>
+ new RelayCommand(parameter => PerformDNSLookupHostnameAction(GetHostRow(parameter)));
+
+ private static void PerformDNSLookupHostnameAction(PortScannerPortInfo row)
+ {
+ NavigationManager.Open(new DNSLookupRequest { Target = row.Hostname });
+ }
+
+ ///
+ /// Gets the command to query the registration data of the IP address of a host via RDAP (see
+ /// ).
+ ///
+ public ICommand PerformRDAPQueryIPAddressCommand =>
+ new RelayCommand(parameter => PerformRDAPQueryIPAddressAction(GetHostRow(parameter)));
- var childWindowViewModel = new PortProfilesViewModel(async instance =>
+ private static void PerformRDAPQueryIPAddressAction(PortScannerPortInfo row)
+ {
+ NavigationManager.Open(new RDAPRequest { Target = row.IPAddress.ToString() });
+ }
+
+ ///
+ /// Gets the command to query the registration data of the hostname (its registered domain) of a host via RDAP
+ /// (see ).
+ ///
+ public ICommand PerformRDAPQueryHostnameCommand =>
+ new RelayCommand(parameter => PerformRDAPQueryHostnameAction(GetHostRow(parameter)));
+
+ private static void PerformRDAPQueryHostnameAction(PortScannerPortInfo row)
+ {
+ NavigationManager.Open(new RDAPRequest { Target = row.Hostname });
+ }
+
+ ///
+ /// Gets the command to copy the open ports of the host of a group header (e.g. "22; 80; 443", sorted). Parameter:
+ /// the . Not available if the host has no open port.
+ ///
+ public ICommand CopyOpenPortsCommand => new RelayCommand(CopyOpenPortsAction, CopyOpenPorts_CanExecute);
+
+ private static IEnumerable GetOpenPorts(object parameter)
+ {
+ return parameter is CollectionViewGroup group
+ ? group.Items.OfType().Where(x => x.State == PortState.Open).Select(x => x.Port)
+ .Distinct().Order()
+ : [];
+ }
+
+ private static bool CopyOpenPorts_CanExecute(object parameter)
+ {
+ return GetOpenPorts(parameter).Any();
+ }
+
+ private static void CopyOpenPortsAction(object parameter)
+ {
+ ClipboardHelper.SetClipboard(string.Join("; ", GetOpenPorts(parameter)));
+ }
+
+ ///
+ /// Gets the command to add a host as a profile (with the ports of the shown scan), see .
+ ///
+ public ICommand AddProfileSelectedHostCommand =>
+ new RelayCommand(parameter => AddProfileSelectedHostAction(GetHostRow(parameter)));
+
+ private async void AddProfileSelectedHostAction(PortScannerPortInfo row)
+ {
+ // Only displayed - also trim the dot a single-label name still has (e.g. "gateway.")
+ var hostname = DNSClientHelper.SanitizeHostname(row.Hostname);
+
+ ProfileInfo profileInfo = new()
{
- childWindow.IsOpen = false;
- ConfigurationManager.Current.IsChildWindowOpen = false;
+ Name = string.IsNullOrEmpty(hostname) ? row.IPAddress.ToString() : hostname,
+ Host = row.IPAddress.ToString(),
+
+ // Additional data
+ PortScanner_Ports = _lastPortsInput
+ };
+
+ var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive);
+
+ await ProfileDialogManager.ShowAddProfileDialog(window, this, profileInfo, null,
+ ApplicationName.PortScanner);
+ }
- Ports = string.Join("; ", instance.GetSelectedPortProfiles().Select(x => x.Ports));
- }, async _ =>
+ ///
+ /// Gets the command to open the selected open web port in the Web Console.
+ ///
+ public ICommand OpenWebConsoleCommand => new RelayCommand(_ => OpenWebConsoleAction());
+
+ private void OpenWebConsoleAction()
+ {
+ if (!IsWebConsoleAvailable)
+ return;
+
+ NavigationManager.Open(new ApplicationRequest(ApplicationName.WebConsole)
{
- childWindow.IsOpen = false;
- ConfigurationManager.Current.IsChildWindowOpen = false;
+ Target = WebConsoleUrlHelper.CreateUrl(GetHost(SelectedResult), SelectedResult.Port)
});
+ }
- childWindow.Title = Strings.SelectPortProfile;
+ ///
+ /// Gets the command to copy a value of all selected results (one per line, in the order shown in the grid).
+ /// Parameter: IPAddress, Hostname, Port or IPAddressPort (ip:port).
+ ///
+ public ICommand CopySelectedResultsCommand => new RelayCommand(CopySelectedResultsAction);
- childWindow.DataContext = childWindowViewModel;
+ private void CopySelectedResultsAction(object parameter)
+ {
+ Func selector = parameter switch
+ {
+ "IPAddress" => x => x.IPAddress.ToString(),
+ "Hostname" => x => x.Hostname,
+ "Port" => x => x.Port.ToString(),
+ // IPv6 in brackets, so the port can be separated
+ "IPAddressPort" => x => new IPEndPoint(x.IPAddress, x.Port).ToString(),
+ _ => null
+ };
- ConfigurationManager.Current.IsChildWindowOpen = true;
+ if (selector == null)
+ return;
+
+ ClipboardHelper.SetClipboard(string.Join(Environment.NewLine,
+ GetSelectedResultsInGridOrder().Select(selector).Where(x => !string.IsNullOrEmpty(x)).Distinct()));
+ }
+
+ public ICommand ExportCommand => new RelayCommand(_ => ExportAction());
+
+ private void ExportAction()
+ {
+ _ = Export();
+ }
+
+ #endregion
+
+ #region Methods
- await window.ShowChildWindowAsync(childWindow);
+ ///
+ /// Shows a message below the input, after the message that is already shown (e.g. the message of the tab or
+ /// hostnames that could not be resolved). The messages are cleared when a scan starts.
+ ///
+ /// Message to show.
+ private void AddStatusMessage(string message)
+ {
+ StatusMessage = IsStatusMessageDisplayed ? StatusMessage + Environment.NewLine + message : message;
+ IsStatusMessageDisplayed = true;
}
private async Task Start()
{
+ // Port profiles in the input (e.g. "Webserver; 8080") are replaced by their ports. The input is validated, but
+ // a port profile may have been renamed or deleted since.
+ if (!PortProfile.TryResolvePorts(Ports, SettingsManager.Current.PortScanner_PortProfiles,
+ out var resolvedPorts))
+ {
+ StatusMessage = Strings.EnterValidPortsOrPortProfiles;
+ IsStatusMessageDisplayed = true;
+
+ return;
+ }
+
// Every port is kept in memory - ask before a large scan and refuse a too large one
// (e.g. 10.0.0.0/8 with 1-65535) instead of running out of memory
long hostCount;
@@ -445,7 +823,7 @@ private async Task Start()
try
{
hostCount = HostRangeHelper.GetHostCount(HostRangeHelper.CreateListFromInput(Host));
- portsPerHost = PortRangeHelper.ConvertPortRangeToIntArray(Ports).Length;
+ portsPerHost = PortRangeHelper.ConvertPortRangeToIntArray(resolvedPorts).Length;
}
catch (Exception ex)
{
@@ -488,6 +866,15 @@ private async Task Start()
IsStatusMessageDisplayed = false;
StatusMessage = string.Empty;
+ // Message of the tab (see constructor): still shown with the first scan
+ if (_message != null)
+ {
+ StatusMessage = _message;
+ IsStatusMessageDisplayed = true;
+
+ _message = null;
+ }
+
IsRunning = true;
PreparingScan = true;
@@ -498,7 +885,12 @@ private async Task Start()
_resultsBuffer.Clear();
}
+ OpenPortsPerHost.Clear();
Results.Clear();
+ _portsNotOpenPerHost.Clear();
+
+ // No results are shown until the scan's options are known - follow the current settings
+ ScanOptions = CreateScanOptions();
// Reset before hostname resolution too (not just after), so a cancellation during
// resolution can't flush the previous scan's stale totals - PortsToScan = 0 also hides
@@ -507,9 +899,11 @@ private async Task Start()
PortsScanned = 0;
PortsOpen = 0;
PortsClosed = 0;
+ PortsTimedOut = 0;
Volatile.Write(ref _latestPortsScanned, 0);
Volatile.Write(ref _latestPortsOpen, 0);
Volatile.Write(ref _latestPortsClosed, 0);
+ Volatile.Write(ref _latestPortsTimedOut, 0);
DragablzTabItem.SetTabHeader(_tabId, Host);
@@ -524,9 +918,10 @@ private async Task Start()
try
{
hosts = await HostRangeHelper.ResolveAsync(HostRangeHelper.CreateListFromInput(Host),
- SettingsManager.Current.Network_ResolveHostnamePreferIPv4, _cancellationTokenSource.Token);
+ SettingsManager.Current.Network_ResolveHostnamePreferIPv4, keepAllHostnames: false,
+ _cancellationTokenSource.Token);
- ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync(Ports);
+ ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync(resolvedPorts);
}
catch (OperationCanceledException)
{
@@ -541,30 +936,313 @@ private async Task Start()
// Show error message if (some) hostnames could not be resolved
if (hosts.hostnamesNotResolved.Count > 0)
- {
- StatusMessage =
- $"{Strings.TheFollowingHostnamesCouldNotBeResolved} {string.Join(", ", hosts.hostnamesNotResolved)}";
- IsStatusMessageDisplayed = true;
- }
+ AddStatusMessage(
+ $"{Strings.TheFollowingHostnamesCouldNotBeResolved} {string.Join(", ", hosts.hostnamesNotResolved)}");
PortsToScan = ports.Length * hosts.hosts.Count;
+ // No host left (e.g. only hostnames that could not be resolved): don't start the scan, only the message above
+ if (PortsToScan == 0)
+ {
+ ScanComplete(this, new ScanCompleteArgs(false));
+ return;
+ }
+
PreparingScan = false;
// Add host(s) to the history
ListHelper.Modify(SettingsManager.Current.PortScanner_HostHistory, Host,
SettingsManager.Current.General_HistoryListEntries);
- ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports,
- SettingsManager.Current.General_HistoryListEntries);
+ // A single port profile name is already in the list, only other inputs are added to the history
+ if (PortProfile.FindByName(SettingsManager.Current.PortScanner_PortProfiles, Ports) == null)
+ ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports,
+ SettingsManager.Current.General_HistoryListEntries);
+
+ ScanOptions = CreateScanOptions();
+
+ _lastPorts = ports;
+ _lastPortsInput = Ports;
+
+ StartScanner(ScanOptions, (portScanner, ct) => portScanner.ScanAsync(hosts.hosts, ports, ct));
+ }
+
+ ///
+ /// Rescans hosts with the ports and options of the last scan. Their rows are replaced by the results of the
+ /// rescan (a port may be open / closed now).
+ ///
+ /// Hosts to rescan.
+ private void RescanHosts(List ipAddresses)
+ {
+ if (IsRunning || _lastPorts == null || ipAddresses.Count == 0)
+ return;
+
+ var rescannedIPAddresses = ipAddresses.ToHashSet();
+
+ _rescanIPAddresses = rescannedIPAddresses;
+ _rescanRemovedRows = [.. Results.Where(x => rescannedIPAddresses.Contains(x.IPAddress))];
+ _rescanPortsOpen = PortsOpen;
+ _rescanPortsClosed = PortsClosed;
+ _rescanPortsTimedOut = PortsTimedOut;
+
+ ReplaceResults(rescannedIPAddresses, []);
+
+ var rescanPortCount = ipAddresses.Count * _lastPorts.Length;
+
+ // The other hosts keep their counters: without the open (all shown), closed and timed out ports of the
+ // rescanned hosts
+ var removedPortsOpen = _rescanRemovedRows.Count(x => x.State == PortState.Open);
+ var removedPortsClosed = ipAddresses.Sum(x => _portsNotOpenPerHost.GetValueOrDefault(x).closed);
+ var removedPortsTimedOut = ipAddresses.Sum(x => _portsNotOpenPerHost.GetValueOrDefault(x).timedOut);
+
+ _rescanPortsToScan = PortsToScan;
+ _rescanPortsScanned = PortsScanned;
+ _rescanBasePortsOpen = Math.Max(0, PortsOpen - removedPortsOpen);
+ _rescanBasePortsClosed = Math.Max(0, PortsClosed - removedPortsClosed);
+ _rescanBasePortsTimedOut = Math.Max(0, PortsTimedOut - removedPortsTimedOut);
+ _rescanBasePortsScanned = Math.Max(0,
+ PortsScanned - removedPortsOpen - removedPortsClosed - removedPortsTimedOut);
+ _isRescan = true;
- var portScanner = new PortScanner(new PortScannerOptions(
- SettingsManager.Current.PortScanner_MaxHostThreads,
- SettingsManager.Current.PortScanner_MaxPortThreads,
- SettingsManager.Current.PortScanner_Timeout,
- SettingsManager.Current.PortScanner_Attempts,
- SettingsManager.Current.PortScanner_ResolveHostname,
- SettingsManager.Current.PortScanner_ShowAllResults
- ));
+ IsStatusMessageDisplayed = false;
+ IsRunning = true;
+
+ _resultsFlushTimer?.Stop();
+
+ lock (_resultsBufferLock)
+ {
+ _resultsBuffer.Clear();
+ }
+
+ PortsToScan = rescanPortCount;
+ PortsScanned = 0;
+ Volatile.Write(ref _latestPortsScanned, 0);
+ Volatile.Write(ref _latestPortsOpen, _rescanBasePortsOpen);
+ Volatile.Write(ref _latestPortsClosed, _rescanBasePortsClosed);
+ Volatile.Write(ref _latestPortsTimedOut, _rescanBasePortsTimedOut);
+
+ _cancellationTokenSource?.Dispose();
+ _cancellationTokenSource = new CancellationTokenSource();
+
+ StartScanner(ScanOptions,
+ (portScanner, ct) => portScanner.ScanAsync([.. ipAddresses.Select(x => (x, string.Empty))], _lastPorts, ct));
+ }
+
+ ///
+ /// Rescans the selected ports (rows). The rows are updated in place - selection, collapsed groups and the scroll
+ /// position stay. Every port is reported (also if closed ports are not shown), a port that is closed now keeps its
+ /// row with the new state (like a host that is down now in the IP Scanner). Without a ping before the scan and
+ /// without resolving the hostname (the rows keep theirs).
+ ///
+ private void RescanSelectedPorts()
+ {
+ if (IsRunning || _lastPorts == null)
+ return;
+
+ var rows = GetSelectedResultsInGridOrder();
+
+ if (rows.Count == 0)
+ return;
+
+ _rescanPortRows = rows.DistinctBy(x => (x.IPAddress, x.Port)).ToDictionary(x => (x.IPAddress, x.Port));
+ _rescanPortPreviousStates = rows.ToDictionary(x => x, x => x.State);
+
+ // The other ports keep their counters, the rescanned ones are added by the scanner
+ _rescanPortsToScan = PortsToScan;
+ _rescanPortsScanned = PortsScanned;
+ _rescanBasePortsOpen = Math.Max(0, PortsOpen - rows.Count(x => x.State == PortState.Open));
+ _rescanBasePortsClosed = Math.Max(0, PortsClosed - rows.Count(x => x.State == PortState.Closed));
+ _rescanBasePortsTimedOut = Math.Max(0, PortsTimedOut - rows.Count(x => x.State == PortState.TimedOut));
+ _isRescan = true;
+
+ IsStatusMessageDisplayed = false;
+ IsRunning = true;
+
+ _resultsFlushTimer?.Stop();
+
+ lock (_resultsBufferLock)
+ {
+ _resultsBuffer.Clear();
+ }
+
+ PortsToScan = rows.Count;
+ PortsScanned = 0;
+ Volatile.Write(ref _latestPortsScanned, 0);
+ Volatile.Write(ref _latestPortsOpen, _rescanBasePortsOpen);
+ Volatile.Write(ref _latestPortsClosed, _rescanBasePortsClosed);
+ Volatile.Write(ref _latestPortsTimedOut, _rescanBasePortsTimedOut);
+
+ _cancellationTokenSource?.Dispose();
+ _cancellationTokenSource = new CancellationTokenSource();
+
+ StartScanner(ScanOptions with { ShowAllResults = true, PingHostsBeforeScan = false, ResolveHostname = false },
+ (portScanner, ct) => portScanner.ScanPortsAsync([.. rows.Select(x => (x.IPAddress, x.Port))], ct));
+ }
+
+ ///
+ /// Changes the number of closed or timed out ports of a host (see ), e.g. for a
+ /// port rescanned in place.
+ ///
+ /// IP address of the host.
+ /// State of the port, only closed and timed out are counted.
+ /// +1 or -1.
+ private void UpdatePortsNotOpenPerHost(IPAddress ipAddress, PortState state, int change)
+ {
+ if (state is not (PortState.Closed or PortState.TimedOut))
+ return;
+
+ var (closed, timedOut) = _portsNotOpenPerHost.GetValueOrDefault(ipAddress);
+
+ if (state == PortState.Closed)
+ closed = Math.Max(0, closed + change);
+ else
+ timedOut = Math.Max(0, timedOut + change);
+
+ _portsNotOpenPerHost[ipAddress] = (closed, timedOut);
+ }
+
+ ///
+ /// Applies a result of a port rescan to the row of the port (see ).
+ ///
+ /// Result of the rescan.
+ private void ApplyPortRescanResult(PortScannerPortInfo result)
+ {
+ if (!_rescanPortRows.TryGetValue((result.IPAddress, result.Port), out var row) || row.State == result.State)
+ return;
+
+ if (row.State == PortState.Open || result.State == PortState.Open)
+ {
+ OpenPortsPerHost[row.IPAddress] = Math.Max(0,
+ OpenPortsPerHost.GetValueOrDefault(row.IPAddress) + (result.State == PortState.Open ? 1 : -1));
+
+ OpenPortsPerHostVersion++;
+ }
+
+ row.State = result.State;
+ }
+
+ ///
+ /// Replaces the rows of the given hosts. Few rows are removed / added one by one, so the groups of the other hosts
+ /// keep their state (collapsed) and the scroll position stays. Many rows (or rows of a large result) with one reset
+ /// instead: every single change searches the whole collection and the sorted, grouped view (e.g. a host with
+ /// 1-65535 and closed ports shown would freeze the window for minutes).
+ ///
+ /// Hosts whose rows are replaced.
+ /// New rows of the hosts.
+ private void ReplaceResults(HashSet ipAddresses, IReadOnlyCollection rows)
+ {
+ var oldRows = Results.Where(x => ipAddresses.Contains(x.IPAddress)).ToList();
+
+ foreach (var ipAddress in ipAddresses)
+ OpenPortsPerHost.Remove(ipAddress);
+
+ if ((long)(oldRows.Count + rows.Count) * Results.Count > MaxRowChangesOneByOne)
+ {
+ foreach (var row in rows)
+ CountOpenPort(row);
+
+ Results.ReplaceAll(Results.Where(x => !ipAddresses.Contains(x.IPAddress)).Concat(rows));
+
+ return;
+ }
+
+ try
+ {
+ foreach (var row in oldRows)
+ Results.Remove(row);
+
+ foreach (var row in rows)
+ AddResult(row);
+ }
+ catch (ArgumentException ex) when (IsGroupVirtualizationSizeException(ex))
+ {
+ Log.Warn("Replacing the results of hosts failed in WPF (collapsed groups), the results are reset.", ex);
+
+ // Rows of the hosts (old, not yet removed or already added) replaced at once, counted again
+ foreach (var ipAddress in ipAddresses)
+ OpenPortsPerHost.Remove(ipAddress);
+
+ foreach (var row in rows)
+ CountOpenPort(row);
+
+ Results.ReplaceAll(Results.Where(x => !ipAddresses.Contains(x.IPAddress)).Concat(rows));
+ }
+ }
+
+ ///
+ /// Adds results one by one (see ). If WPF fails to add a row (see
+ /// ), the remaining results are added with one reset.
+ ///
+ /// Results to add.
+ private void AddResults(IReadOnlyList results)
+ {
+ for (var i = 0; i < results.Count; i++)
+ {
+ try
+ {
+ AddResult(results[i]);
+ }
+ catch (ArgumentException ex) when (IsGroupVirtualizationSizeException(ex))
+ {
+ Log.Warn("Adding a result failed in WPF (collapsed groups), the results are reset.", ex);
+
+ // The row is already in the results (the exception comes from the notification of the view), the
+ // reset rebuilds the view and its containers
+ var remaining = results.Skip(i + 1).ToList();
+
+ foreach (var result in remaining)
+ CountOpenPort(result);
+
+ Results.ReplaceAll(Results.Concat(remaining));
+
+ return;
+ }
+ }
+ }
+
+ ///
+ /// Checks if an exception is the WPF bug of the virtualization with grouping: VirtualizingStackPanel
+ /// computes a negative average row size while rows are added / removed and groups are collapsed (e.g. during a
+ /// scan) and throws "Width and Height must be non-negative" (). Also on main (grouping,
+ /// IsVirtualizingWhenGrouping, collapsible group headers).
+ ///
+ /// Exception thrown while a row was added or removed.
+ /// True if it is this WPF exception.
+ private static bool IsGroupVirtualizationSizeException(ArgumentException exception)
+ {
+ return exception.TargetSite?.DeclaringType == typeof(Size);
+ }
+
+ ///
+ /// Adds a result, counted in before it is added (the group header is updated when
+ /// the row is added).
+ ///
+ /// Result to add.
+ private void AddResult(PortScannerPortInfo result)
+ {
+ CountOpenPort(result);
+
+ Results.Add(result);
+ }
+
+ ///
+ /// Counts the result in if the port is open.
+ ///
+ /// Result to count.
+ private void CountOpenPort(PortScannerPortInfo result)
+ {
+ if (result.State == PortState.Open)
+ OpenPortsPerHost[result.IPAddress] = OpenPortsPerHost.GetValueOrDefault(result.IPAddress) + 1;
+ }
+
+ ///
+ /// Creates the scanner (with ), subscribes to its events, starts the flush timer and
+ /// the scan.
+ ///
+ /// Options of the scanner.
+ /// Starts the scan (e.g. ) with the token of the scan.
+ private void StartScanner(PortScannerOptions options, Action scan)
+ {
+ var portScanner = new PortScanner(options);
portScanner.PortScanned += PortScanned;
portScanner.ScanComplete += ScanComplete;
@@ -581,7 +1259,26 @@ private async Task Start()
};
_resultsFlushTimer.Start();
- portScanner.ScanAsync(hosts.hosts, ports, _cancellationTokenSource.Token);
+ scan(portScanner, _cancellationTokenSource.Token);
+ }
+
+ ///
+ /// Creates the scan options from the current settings.
+ ///
+ /// Options for the .
+ private static PortScannerOptions CreateScanOptions()
+ {
+ return new PortScannerOptions
+ {
+ AutoAdjustThreads = SettingsManager.Current.PortScanner_AutoAdjustThreads,
+ MaxHostThreads = SettingsManager.Current.PortScanner_MaxHostThreads,
+ MaxPortThreads = SettingsManager.Current.PortScanner_MaxPortThreads,
+ Timeout = SettingsManager.Current.PortScanner_Timeout,
+ Attempts = SettingsManager.Current.PortScanner_Attempts,
+ PingHostsBeforeScan = SettingsManager.Current.PortScanner_PingHostsBeforeScan,
+ ResolveHostname = SettingsManager.Current.PortScanner_ResolveHostname,
+ ShowAllResults = SettingsManager.Current.PortScanner_ShowAllResults
+ };
}
private void Stop()
@@ -590,6 +1287,28 @@ private void Stop()
_cancellationTokenSource.Cancel();
}
+ ///
+ /// Gets the results in the order shown in the grid: grouped by host, sorted within the group.
+ ///
+ /// Results.
+ private IEnumerable GetResultsInGridOrder()
+ {
+ return ResultsView.Groups.Cast()
+ .SelectMany(group => group.Items.Cast());
+ }
+
+ ///
+ /// Gets the selected results in the order shown in the grid ( is in the order they
+ /// were selected).
+ ///
+ /// Selected results.
+ private List GetSelectedResultsInGridOrder()
+ {
+ var selectedResults = new HashSet(SelectedResults.Cast());
+
+ return GetResultsInGridOrder().Where(selectedResults.Contains).ToList();
+ }
+
private Task Export()
{
var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive);
@@ -605,15 +1324,10 @@ private Task Export()
{
// Copy the results on the UI thread (they may still change while a scan is running) and
// export them in the background, so a large export doesn't freeze the window. In the order
- // shown in the grid: grouped by host, sorted within the group (Results is in the order the
- // ports were finished).
- var viewResults = ResultsView.Groups.Cast()
- .SelectMany(group => group.Items.Cast());
-
+ // shown in the grid (Results is in the order the ports were finished).
var results = instance.ExportAll
- ? viewResults.ToList()
- : viewResults.Where(new HashSet(SelectedResults.Cast())
- .Contains).ToList();
+ ? GetResultsInGridOrder().ToList()
+ : GetSelectedResultsInGridOrder();
await Task.Run(() => ExportManager.Export(instance.FilePath, instance.FileType, results));
}
@@ -650,6 +1364,41 @@ await DialogHelper.ShowMessageAsync(window, Strings.Error,
#region Events
+ ///
+ /// Gets the command to filter the results right away (Enter in the search box, see
+ /// ).
+ ///
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
+
+ ///
+ /// Filters the results with the current right away (e.g. Enter in the search box).
+ ///
+ private void ApplySearch()
+ {
+ ResultsView.Refresh();
+ }
+
+ ///
+ /// Handles changes of the settings. While no results are shown, the columns etc. follow the current
+ /// settings (see ). Any setting, so new columns don't need to be added here.
+ ///
+ /// The source of the event.
+ /// The instance containing the event data.
+ private void SettingsManager_PropertyChanged(object sender, PropertyChangedEventArgs e)
+ {
+ UpdateScanOptionsIfNoResults();
+ }
+
+ ///
+ /// While no results are shown and no scan is running, the columns etc. follow the current settings
+ /// (see ).
+ ///
+ private void UpdateScanOptionsIfNoResults()
+ {
+ if (!IsRunning && Results.Count == 0)
+ ScanOptions = CreateScanOptions();
+ }
+
private void PortScanned(object sender, PortScannerPortScannedArgs e)
{
lock (_resultsBufferLock)
@@ -676,24 +1425,58 @@ private void FlushResultsBuffer()
_resultsBuffer.Clear();
}
- foreach (var item in itemsToAdd)
- Results.Add(item);
+ if (_rescanPortRows != null)
+ {
+ foreach (var item in itemsToAdd)
+ ApplyPortRescanResult(item);
+
+ return;
+ }
+
+ // Many rows (e.g. 1-65535 with closed ports shown): with one reset instead of one by one - WPF inserts every
+ // added row into its group of the sorted view with a linear search of the group (rows x rows, the window hung
+ // for minutes). A reset sorts and groups all rows once.
+ if ((long)itemsToAdd.Count * (Results.Count + itemsToAdd.Count) > MaxRowChangesOneByOne)
+ {
+ foreach (var item in itemsToAdd)
+ CountOpenPort(item);
+
+ Results.ReplaceAll(Results.Concat(itemsToAdd));
+
+ return;
+ }
+
+ AddResults(itemsToAdd);
}
private void ProgressChanged(object sender, ProgressChangedArgs e)
{
- Volatile.Write(ref _latestPortsScanned, e.Value);
-
if (sender is PortScanner portScanner)
- {
- Volatile.Write(ref _latestPortsOpen, portScanner.PortsOpen);
- Volatile.Write(ref _latestPortsClosed, portScanner.PortsClosed);
- }
+ StoreProgress(portScanner);
+ }
+
+ ///
+ /// Stores the current counters of the scanner for . Read from the scanner instead of
+ /// the event args: events of different ports may arrive in a different order than they were raised, the
+ /// scanner always has the current values.
+ ///
+ /// Scanner of the running scan.
+ private void StoreProgress(PortScanner portScanner)
+ {
+ Volatile.Write(ref _latestPortsScanned, portScanner.PortsScanned);
+
+ // Rescan: the scanner only counts the rescanned hosts
+ var isRescan = Volatile.Read(ref _isRescan);
+
+ Volatile.Write(ref _latestPortsOpen, (isRescan ? _rescanBasePortsOpen : 0) + portScanner.PortsOpen);
+ Volatile.Write(ref _latestPortsClosed, (isRescan ? _rescanBasePortsClosed : 0) + portScanner.PortsClosed);
+ Volatile.Write(ref _latestPortsTimedOut,
+ (isRescan ? _rescanBasePortsTimedOut : 0) + portScanner.PortsTimedOut);
}
///
/// Pushes the latest buffered progress value into ,
- /// and . Always called on the UI thread -
+ /// , and . Always called on the UI thread -
/// same calling contexts as .
///
private void FlushProgress()
@@ -701,6 +1484,7 @@ private void FlushProgress()
PortsScanned = Volatile.Read(ref _latestPortsScanned);
PortsOpen = Volatile.Read(ref _latestPortsOpen);
PortsClosed = Volatile.Read(ref _latestPortsClosed);
+ PortsTimedOut = Volatile.Read(ref _latestPortsTimedOut);
}
///
@@ -721,35 +1505,111 @@ private void ScanComplete(object sender, ScanCompleteArgs e)
portScanner.ProgressChanged -= ProgressChanged;
}
- // Results are buffered - add the remaining ones
+ // Results are buffered - add the remaining ones, with the final counters
_resultsFlushTimer?.Stop();
FlushResultsBuffer();
+
+ if (sender is PortScanner finishedScanner)
+ StoreProgress(finishedScanner);
+
FlushProgress();
+ // Canceled / failed rescan: keep the previous results of the hosts (their rows were removed when the
+ // rescan started, only some ports were scanned again)
+ var isPortRescan = _rescanPortRows != null;
+ var isRescanDiscarded = _isRescan && !isPortRescan && (e.IsCanceled || e.Exception != null);
+
+ // Closed and timed out ports per host of the shown result (of a rescan: replace the ones of the rescanned
+ // hosts)
+ if (isPortRescan)
+ {
+ // Rows updated in place: their previous state out, the current one in (not rescanned ports after a
+ // cancel keep theirs)
+ foreach (var (row, previousState) in _rescanPortPreviousStates)
+ {
+ UpdatePortsNotOpenPerHost(row.IPAddress, previousState, -1);
+ UpdatePortsNotOpenPerHost(row.IPAddress, row.State, 1);
+ }
+ }
+ else if (sender is PortScanner finishedPortScanner && !isRescanDiscarded)
+ {
+ if (_isRescan)
+ {
+ foreach (var ipAddress in _rescanIPAddresses)
+ _portsNotOpenPerHost.Remove(ipAddress);
+ }
+
+ foreach (var (ipAddress, count) in finishedPortScanner.GetClosedAndTimedOutPortsPerHost())
+ _portsNotOpenPerHost[ipAddress] = count;
+ }
+
+ // Rescan: back to the progress of the last scan (open / closed / timed out include the rescan)
+ if (_isRescan)
+ {
+ _isRescan = false;
+
+ if (isPortRescan)
+ {
+ // Counters from the rows - also correct if the rescan was canceled (ports not rescanned keep
+ // their state); the ports were already scanned by the last scan
+ var rows = _rescanPortPreviousStates.Keys;
+
+ PortsOpen = _rescanBasePortsOpen + rows.Count(x => x.State == PortState.Open);
+ PortsClosed = _rescanBasePortsClosed + rows.Count(x => x.State == PortState.Closed);
+ PortsTimedOut = _rescanBasePortsTimedOut + rows.Count(x => x.State == PortState.TimedOut);
+ PortsScanned = _rescanPortsScanned;
+
+ _rescanPortRows = null;
+ _rescanPortPreviousStates = null;
+ }
+ else if (isRescanDiscarded)
+ {
+ ReplaceResults(_rescanIPAddresses, _rescanRemovedRows);
+
+ PortsOpen = _rescanPortsOpen;
+ PortsClosed = _rescanPortsClosed;
+ PortsTimedOut = _rescanPortsTimedOut;
+ PortsScanned = _rescanPortsScanned;
+ }
+ else
+ {
+ // Ports of the other hosts + ports of the rescan
+ PortsScanned = _rescanBasePortsScanned + PortsScanned;
+ }
+
+ _rescanIPAddresses = null;
+ _rescanRemovedRows = null;
+
+ PortsToScan = _rescanPortsToScan;
+ }
+
+ // Keep a message from the preparation (e.g. the message of the tab or hostnames that could not be resolved)
if (e.IsCanceled)
{
- StatusMessage = Strings.CanceledByUserMessage;
- IsStatusMessageDisplayed = true;
+ AddStatusMessage(Strings.CanceledByUserMessage);
}
else if (e.Exception != null)
{
Log.Error("Error while scanning", e.Exception);
- StatusMessage = e.Exception.Message;
- IsStatusMessageDisplayed = true;
+ AddStatusMessage(e.Exception.Message);
}
- else if (Results.Count == 0)
+ else if (Results.Count == 0 && PortsToScan > 0)
{
- // Keep a message from the preparation (e.g. hostnames that could not be resolved)
- StatusMessage = IsStatusMessageDisplayed
- ? StatusMessage + Environment.NewLine + Strings.NoOpenPortsFound
- : Strings.NoOpenPortsFound;
- IsStatusMessageDisplayed = true;
+ AddStatusMessage(Strings.NoOpenPortsFound);
}
+ // Hosts that were not scanned because they didn't answer the ping before the scan (option)
+ if (!e.IsCanceled && e.Exception == null && sender is PortScanner { HostsSkipped: > 0 } pingScanner)
+ AddStatusMessage(string.Format(Strings.XHostsDidNotAnswerThePingAndWereNotScanned,
+ pingScanner.HostsSkipped));
+
PreparingScan = false;
IsCanceling = false;
IsRunning = false;
+
+ // E.g. canceled during the preparation, or nothing found
+ UpdateScanOptionsIfNoResults();
});
}
diff --git a/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs b/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs
index 1cf7e58328..ad719ff64e 100644
--- a/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs
+++ b/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs
@@ -3,6 +3,7 @@
using NETworkManager.Profiles;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
@@ -11,7 +12,6 @@
using System.Windows;
using System.Windows.Data;
using System.Windows.Input;
-using System.Windows.Threading;
namespace NETworkManager.ViewModels;
@@ -24,7 +24,7 @@ public abstract class ProfileHostViewModelBase : ViewModelBase, IProfileHostView
{
#region Variables
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
private bool _searchDisabled;
private bool _isViewActive = true;
@@ -95,8 +95,7 @@ public string Search
if (!_searchDisabled)
{
IsSearching = true;
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ _searchDelay.Restart();
}
OnPropertyChanged();
@@ -199,6 +198,15 @@ public bool IsProfileFilterSet
#region Constructor
+ ///
+ /// Initializes the search delay, before the constructor of the derived class (which may already set
+ /// ).
+ ///
+ protected ProfileHostViewModelBase()
+ {
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
+ }
+
///
/// Initializes the shared profile-panel state. Must be called by derived constructors after their own
/// tool-specific initialization (and while _isLoading-equivalent guards, if any, are still active).
@@ -217,9 +225,6 @@ protected void InitializeProfileHost()
ProfileManager.OnProfilesUpdated += ProfileManager_OnProfilesUpdated;
GroupExpanderStateStore.GroupExpandedChanged += GroupExpanderStateStore_GroupExpandedChanged;
-
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
}
#endregion
@@ -505,10 +510,11 @@ private void GroupExpanderStateStore_GroupExpandedChanged(object sender, GroupEx
SettingsManager.Current.SettingsChanged = true;
}
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
+ ///
+ /// Filters the profiles with the current .
+ ///
+ private void ApplySearch()
{
- _searchDispatcherTimer.Stop();
-
RefreshProfiles();
IsSearching = false;
diff --git a/Source/NETworkManager/ViewModels/ProfileViewModel.cs b/Source/NETworkManager/ViewModels/ProfileViewModel.cs
index b67356d8ee..ca4c44aced 100644
--- a/Source/NETworkManager/ViewModels/ProfileViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ProfileViewModel.cs
@@ -89,7 +89,12 @@ public ProfileViewModel(Action saveCommand, Action saveCommand, Action
+ /// Gets the port profiles and the ports history of the Port Scanner for the ports input (grouped).
+ ///
+ public ICollectionView PortScanner_PortsSuggestions => _portScannerPortsSuggestions.View;
+
+ ///
+ /// Gets the names of the port profiles for the Tab completion of the ports input of the Port Scanner.
+ ///
+ public IEnumerable PortScanner_PortProfileNames => _portScannerPortsSuggestions.ProfileNames;
+
public string PortScanner_Ports
{
get;
@@ -879,6 +904,44 @@ public string DNSLookup_Host
}
}
+ public bool DNSLookup_OverrideDNSServer
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string DNSLookup_DNSServer
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ private readonly InputSuggestions _dnsLookupDNSServerSuggestions = InputSuggestions.ForDNSLookupDNSServers();
+
+ ///
+ /// Gets the DNS servers and the DNS server history of DNS Lookup for the DNS server input (grouped).
+ ///
+ public ICollectionView DNSLookup_DNSServerSuggestions => _dnsLookupDNSServerSuggestions.View;
+
+ ///
+ /// Gets the names of the DNS servers for the Tab completion of the DNS server input of DNS Lookup.
+ ///
+ public IEnumerable DNSLookup_DNSServerNames => _dnsLookupDNSServerSuggestions.ProfileNames;
+
#endregion
#region Remote Desktop
diff --git a/Source/NETworkManager/ViewModels/ProfilesViewModel.cs b/Source/NETworkManager/ViewModels/ProfilesViewModel.cs
index a38ea2c606..821e88860b 100644
--- a/Source/NETworkManager/ViewModels/ProfilesViewModel.cs
+++ b/Source/NETworkManager/ViewModels/ProfilesViewModel.cs
@@ -1,6 +1,7 @@
๏ปฟusing NETworkManager.Profiles;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using System;
using System.Collections;
using System.Collections.Generic;
@@ -10,7 +11,6 @@
using System.Windows;
using System.Windows.Data;
using System.Windows.Input;
-using System.Windows.Threading;
namespace NETworkManager.ViewModels;
@@ -28,15 +28,14 @@ public ProfilesViewModel()
ProfileManager.OnProfilesUpdated += ProfileManager_OnProfilesUpdated;
- _searchDispatcherTimer.Interval = GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan;
- _searchDispatcherTimer.Tick += SearchDispatcherTimer_Tick;
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
}
#endregion
#region Variables
- private readonly DispatcherTimer _searchDispatcherTimer = new();
+ private readonly SearchDelay _searchDelay;
private bool _searchDisabled;
private bool _isViewActive = true;
@@ -150,8 +149,7 @@ public string Search
if (!_searchDisabled)
{
IsSearching = true;
- _searchDispatcherTimer.Stop();
- _searchDispatcherTimer.Start();
+ _searchDelay.Restart();
}
OnPropertyChanged();
@@ -442,10 +440,17 @@ private void ProfileManager_OnProfilesUpdated(object sender, EventArgs e)
RefreshProfiles();
}
- private void SearchDispatcherTimer_Tick(object sender, EventArgs e)
- {
- _searchDispatcherTimer.Stop();
+ ///
+ /// Gets the command to filter right away (Enter in the search box, see
+ /// ).
+ ///
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
+ ///
+ /// Filters with the current right away.
+ ///
+ private void ApplySearch()
+ {
RefreshProfiles();
IsSearching = false;
diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
index 101ddec981..07a34dd5df 100644
--- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
+++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
@@ -1131,24 +1131,26 @@ private IEnumerable GetTypeSuggestions()
///
/// Port profiles of the Port Scanner (e.g. "Webserver") that contain the typed word, if no ports were entered
- /// yet.
+ /// yet. The ports can be a list of ports and port profiles (e.g. "webserver;database;22"), the last entry is
+ /// completed.
///
private IEnumerable GetPortProfileSuggestions()
{
return GetArgumentValueSuggestions(RunCommandArgument.Ports,
RunCommandManager.GetPortProfiles().Select(x => (x.Name, x.Ports)), RunCommandSuggestionKind.PortProfile,
- Strings.PortProfiles);
+ Strings.PortProfiles, isList: true);
}
///
/// DNS servers configured in the DNS lookup (e.g. "Cloudflare") that contain the typed word, if no DNS server was
- /// entered yet.
+ /// entered yet. Several DNS servers can be entered (e.g. "\"Google Public DNS\";cloudflare"), the entry after
+ /// the last ";" is completed.
///
private IEnumerable GetDNSServerSuggestions()
{
return GetArgumentValueSuggestions(RunCommandArgument.DNSServer,
RunCommandManager.GetDNSServers().Select(x => (x, Strings.DNSServer)), RunCommandSuggestionKind.DNSServer,
- Strings.DNSServers);
+ Strings.DNSServers, isList: true);
}
///
@@ -1159,24 +1161,41 @@ private IEnumerable GetDNSServerSuggestions()
/// Names and descriptions of the values.
/// Kind of the suggestions.
/// Group of the suggestions.
+ ///
+ /// The argument can be a list separated by ";" (e.g. "webserver;database;22"): the entry after the last ";" is
+ /// completed, the entries before are kept as entered (with their quotes, e.g. "DNS (via TCP)";web).
+ ///
private IEnumerable GetArgumentValueSuggestions(RunCommandArgument argument,
- IEnumerable<(string Name, string Description)> values, RunCommandSuggestionKind kind, string group)
+ IEnumerable<(string Name, string Description)> values, RunCommandSuggestionKind kind, string group,
+ bool isList = false)
{
if (_result.Command.Argument != argument ||
_result.Argument != null && !string.Equals(_result.Argument, _result.CurrentWord))
return [];
+ // Entries before the one that is being typed, as entered (e.g. "webserver;" of "webserver;dat")
+ var separator = isList ? _result.CurrentToken.LastIndexOf(';') : -1;
+ var previousEntries = _result.CurrentToken[..(separator + 1)];
+
+ // Typed in the open quote of the previous entry (e.g. "DNS (via TCP); web) - close it before the ";"
+ if (previousEntries.Count(x => x == '"') % 2 == 1)
+ previousEntries = previousEntries[..^1].TrimEnd() + "\";";
+
+ var entry = separator < 0
+ ? _result.CurrentWord
+ : _result.CurrentToken[(separator + 1)..].Replace("\"", string.Empty).Trim();
+
return values
- .Where(x => x.Name.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase))
+ .Where(x => x.Name.Contains(entry, StringComparison.OrdinalIgnoreCase))
.Select(x => new RunCommandSuggestion
{
Kind = kind,
Application = _result.Command.Application,
Title = x.Name,
Description = x.Description,
- CompletionText =
- _result.InputBeforeCurrentWord + (x.Name.Contains(' ') ? $"\"{x.Name}\"" : x.Name) + " ",
- Highlight = _result.CurrentWord,
+ CompletionText = _result.InputBeforeCurrentWord + previousEntries +
+ (x.Name.Contains(' ') ? $"\"{x.Name}\"" : x.Name) + " ",
+ Highlight = entry,
Group = group
});
}
diff --git a/Source/NETworkManager/ViewModels/WiFiViewModel.cs b/Source/NETworkManager/ViewModels/WiFiViewModel.cs
index 8465229bd0..d43735014b 100644
--- a/Source/NETworkManager/ViewModels/WiFiViewModel.cs
+++ b/Source/NETworkManager/ViewModels/WiFiViewModel.cs
@@ -13,6 +13,7 @@
using NETworkManager.Models.Network;
using NETworkManager.Settings;
using NETworkManager.Utilities;
+using NETworkManager.Utilities.WPF;
using NETworkManager.Views;
using SkiaSharp;
using System;
@@ -183,6 +184,12 @@ public AutoRefreshTimeInfo SelectedAutoRefreshTime
}
}
+ ///
+ /// Delays filtering while the user is typing (see ), so a large list is not filtered on
+ /// every key press.
+ ///
+ private readonly SearchDelay _searchDelay;
+
public string Search
{
get;
@@ -193,7 +200,8 @@ public string Search
field = value;
- NetworksView.Refresh();
+ // Filter after a short delay while typing (see _searchDelay)
+ _searchDelay.Restart();
OnPropertyChanged();
}
@@ -478,6 +486,8 @@ private set
public WiFiViewModel()
{
+ _searchDelay = new SearchDelay(GlobalStaticConfiguration.SearchDispatcherTimerTimeSpan, ApplySearch);
+
_isLoading = true;
// Set up the channel charts (axes, sections, paints) unconditionally so the chart bindings
@@ -1345,6 +1355,20 @@ private void HideConnectionStatusMessageTimer_Tick(object sender, EventArgs e)
IsConnectionStatusMessageDisplayed = false;
}
+ ///
+ /// Gets the command to filter right away (Enter in the search box, see
+ /// ).
+ ///
+ public ICommand ApplySearchCommand => _searchDelay.ApplyCommand;
+
+ ///
+ /// Filters with the current right away.
+ ///
+ private void ApplySearch()
+ {
+ NetworksView.Refresh();
+ }
+
#endregion
}
diff --git a/Source/NETworkManager/Views/AboutView.xaml b/Source/NETworkManager/Views/AboutView.xaml
index bde7aa36f8..45490be198 100644
--- a/Source/NETworkManager/Views/AboutView.xaml
+++ b/Source/NETworkManager/Views/AboutView.xaml
@@ -9,6 +9,7 @@
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters"
xmlns:viewModels="clr-namespace:NETworkManager.ViewModels"
+ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:AboutViewModel}">
@@ -249,7 +250,8 @@
+ Margin="0,0,0,10"
+ wpfHelpers:DataGridHelper.FillRemainingWidth="True">
+ wpfHelpers:DataGridHelper.IsFillColumn="True">
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
@@ -221,33 +221,36 @@
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ VerticalAlignment="Center">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ VirtualizingPanel.ScrollUnit="Pixel">
+
+
+
+
@@ -494,31 +532,37 @@
Style="{StaticResource ResourceKey=CopyMenuItem}">
-
-
+
+
+
+
+
@@ -593,65 +688,93 @@
+ Style="{Binding IsReachable, Converter={StaticResource HostUpStyleConverter}}" />
-
-
+
+
+ MinWidth="150">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Visibility="{Binding Path=Data.ScanOptions.PortScanEnabled, Source={StaticResource BindingProxy}, Converter={StaticResource ResourceKey=BooleanToVisibilityCollapsedConverter}}"
+ MinWidth="150">
+
+
+
+
+
+
+
+ Visibility="{Binding Path=Data.ScanOptions.NetBIOSEnabled, Source={StaticResource BindingProxy}, Converter={StaticResource ResourceKey=BooleanToVisibilityCollapsedConverter}}"
+ MinWidth="150">
+
+
+
+
+
+
+
-
-
+
+
+ MinWidth="200">
-
-
+
+
@@ -698,8 +821,10 @@
Text="{x:Static localization:Strings.PingDetails}"
Style="{StaticResource BoldTextBlock}"
Foreground="{DynamicResource MahApps.Brushes.Gray5}" />
+
+ Background="Transparent"
+ Visibility="{Binding Path=IsPingPending, Converter={StaticResource BooleanReverseToVisibilityCollapsedConverter}}">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/NETworkManager/Views/IPScannerView.xaml.cs b/Source/NETworkManager/Views/IPScannerView.xaml.cs
index 5f497fce09..50d423b484 100644
--- a/Source/NETworkManager/Views/IPScannerView.xaml.cs
+++ b/Source/NETworkManager/Views/IPScannerView.xaml.cs
@@ -1,6 +1,7 @@
๏ปฟusing System;
using System.Collections;
using System.ComponentModel;
+using System.Net.NetworkInformation;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
@@ -120,6 +121,9 @@ private void DataGrid_OnSorting(object sender, DataGridSortingEventArgs e)
case nameof(IPScannerHostInfo.MACAddress):
selectedComparer = 1;
break;
+ case $"{nameof(PingInfo)}.{nameof(PingInfo.Time)}":
+ selectedComparer = 2;
+ break;
default:
return;
}
@@ -132,7 +136,10 @@ private void DataGrid_OnSorting(object sender, DataGridSortingEventArgs e)
? ListSortDirection.Ascending
: ListSortDirection.Descending;
- // Update the sort direction
+ // Update the sort direction (only one column is sorted - the built-in sort would reset the other columns)
+ foreach (var dataGridColumn in ((DataGrid)sender).Columns)
+ dataGridColumn.SortDirection = null;
+
e.Column.SortDirection = direction;
// Get the view
@@ -155,18 +162,68 @@ public int Compare(object x, object y)
return 0;
// Compare the data
- return comparer switch
+ var result = comparer switch
{
// IP address
0 => direction == ListSortDirection.Ascending
? IPAddressHelper.CompareIPAddresses(first.PingInfo.IPAddress, second.PingInfo.IPAddress)
: IPAddressHelper.CompareIPAddresses(second.PingInfo.IPAddress, first.PingInfo.IPAddress),
- // MAC address
- 1 => direction == ListSortDirection.Ascending
- ? MACAddressHelper.CompareMACAddresses(first.MACAddress, second.MACAddress)
- : MACAddressHelper.CompareMACAddresses(second.MACAddress, first.MACAddress),
+ // MAC address - hosts without a MAC address always last
+ 1 => CompareMACAddressNoMACLast(first, second),
+ // Ping time - hosts without a ping reply always last (their time is 0)
+ 2 => direction == ListSortDirection.Ascending
+ ? GetPingTime(first).CompareTo(GetPingTime(second))
+ : CompareDescendingNoReplyLast(first, second),
_ => 0
};
+
+ // Same MAC address / time (e.g. all hosts without a MAC address or ping reply): by IP address - the sort is
+ // not stable, the order of these hosts would be random otherwise
+ return result != 0 || comparer == 0
+ ? result
+ : IPAddressHelper.CompareIPAddresses(first.PingInfo.IPAddress, second.PingInfo.IPAddress);
+ }
+
+ ///
+ /// Compares the MAC address in the sort direction, hosts without a MAC address (not resolved, routed host,
+ /// or still resolving) last. An empty MAC address can't be converted to bytes.
+ ///
+ private int CompareMACAddressNoMACLast(IPScannerHostInfo first, IPScannerHostInfo second)
+ {
+ var x = first.MACAddress;
+ var y = second.MACAddress;
+
+ if (string.IsNullOrEmpty(x) || string.IsNullOrEmpty(y))
+ return string.IsNullOrEmpty(x).CompareTo(string.IsNullOrEmpty(y));
+
+ return direction == ListSortDirection.Ascending
+ ? MACAddressHelper.CompareMACAddresses(x, y)
+ : MACAddressHelper.CompareMACAddresses(y, x);
+ }
+
+ ///
+ /// Gets the ping time for sorting, without a ping reply (or while the ping is
+ /// still running).
+ ///
+ private static long GetPingTime(IPScannerHostInfo info)
+ {
+ return !info.IsPingPending && info.PingInfo.Status == IPStatus.Success
+ ? info.PingInfo.Time
+ : long.MaxValue;
+ }
+
+ ///
+ /// Compares the ping time descending, hosts without a ping reply still last.
+ ///
+ private static int CompareDescendingNoReplyLast(IPScannerHostInfo first, IPScannerHostInfo second)
+ {
+ var x = GetPingTime(first);
+ var y = GetPingTime(second);
+
+ if (x == long.MaxValue || y == long.MaxValue)
+ return x.CompareTo(y);
+
+ return y.CompareTo(x);
}
}
}
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/ImportProfilesResultChildWindow.xaml b/Source/NETworkManager/Views/ImportProfilesResultChildWindow.xaml
index 0f524dc47d..c66593e4ab 100644
--- a/Source/NETworkManager/Views/ImportProfilesResultChildWindow.xaml
+++ b/Source/NETworkManager/Views/ImportProfilesResultChildWindow.xaml
@@ -7,6 +7,7 @@
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters"
xmlns:viewModels="clr-namespace:NETworkManager.ViewModels"
+ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
xmlns:simpleChildWindow="clr-namespace:MahApps.Metro.SimpleChildWindow;assembly=MahApps.Metro.SimpleChildWindow"
@@ -45,6 +46,7 @@
VerticalAlignment="Top"
Width="250"
Text="{Binding Path=Search, UpdateSourceTrigger=PropertyChanged}"
+ wpfHelpers:SearchTextBoxHelper.EnterCommand="{Binding ApplySearchCommand}"
Style="{StaticResource ResourceKey=SearchTextBox}" />
@@ -62,7 +64,8 @@
CanUserResizeRows="False"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
Style="{StaticResource ResourceKey=DefaultDataGrid}"
- PreviewKeyDown="DataGrid_OnPreviewKeyDown">
+ PreviewKeyDown="DataGrid_OnPreviewKeyDown"
+ wpfHelpers:DataGridHelper.FillRemainingWidth="True">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/PortProfilesChildWindow.xaml.cs b/Source/NETworkManager/Views/PortProfilesChildWindow.xaml.cs
deleted file mode 100644
index 8f38f244c3..0000000000
--- a/Source/NETworkManager/Views/PortProfilesChildWindow.xaml.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-๏ปฟusing NETworkManager.ViewModels;
-using System;
-using System.Windows;
-using System.Windows.Threading;
-
-namespace NETworkManager.Views;
-
-public partial class PortProfilesChildWindow
-{
- public PortProfilesChildWindow(Window parentWindow)
- {
- InitializeComponent();
-
- // Set the width and height of the child window based on the parent window size
- ChildWindowMaxWidth = 850;
- ChildWindowMaxHeight = 650;
- ChildWindowWidth = parentWindow.ActualWidth * 0.85;
- //ChildWindowHeight = parentWindow.ActualHeight * 0.85;
-
- // Update the size of the child window when the parent window is resized
- parentWindow.SizeChanged += (_, _) =>
- {
- ChildWindowWidth = parentWindow.ActualWidth * 0.85;
- //ChildWindowHeight = parentWindow.ActualHeight * 0.85;
- };
- }
-
- private void ChildWindow_OnLoaded(object sender, RoutedEventArgs e)
- {
- Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(delegate
- {
- TextBoxSearch.Focus();
- }));
- }
-
- private void DataGridRow_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
- {
- var x = (PortProfilesViewModel)DataContext;
-
- if (x.OKCommand.CanExecute(null))
- x.OKCommand.Execute(null);
- }
-}
diff --git a/Source/NETworkManager/Views/PortScannerSettingsView.xaml b/Source/NETworkManager/Views/PortScannerSettingsView.xaml
index 1c2086bb6b..b0edaf839c 100644
--- a/Source/NETworkManager/Views/PortScannerSettingsView.xaml
+++ b/Source/NETworkManager/Views/PortScannerSettingsView.xaml
@@ -7,15 +7,36 @@
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
xmlns:viewModels="clr-namespace:NETworkManager.ViewModels"
xmlns:network="clr-namespace:NETworkManager.Models.Network;assembly=NETworkManager.Models"
- xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
+ xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls"
+ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
+ xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
+ xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters"
mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:PortScannerSettingsViewModel}">
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -48,8 +69,7 @@
+ MinWidth="200" />
@@ -58,6 +78,7 @@
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml
index 9ee7b6a447..0025700fbd 100644
--- a/Source/NETworkManager/Views/PortScannerView.xaml
+++ b/Source/NETworkManager/Views/PortScannerView.xaml
@@ -9,15 +9,18 @@
xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters"
xmlns:mahappsControls="clr-namespace:MahApps.Metro.Controls;assembly=MahApps.Metro"
xmlns:viewModels="clr-namespace:NETworkManager.ViewModels"
- xmlns:network="clr-namespace:NETworkManager.Models.Network;assembly=NETworkManager.Models"
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls"
- xmlns:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings"
xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
+ xmlns:models="clr-namespace:NETworkManager.Models;assembly=NETworkManager.Models"
Loaded="UserControl_Loaded"
mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:PortScannerViewModel}">
+
+
+
+
@@ -50,8 +53,6 @@
-
-
+
+
+
+
-
+
-
-
-
-
+
+
+
+
+
+
-
+
-
-
+
+
@@ -1867,6 +1897,8 @@
+
+
@@ -1910,6 +1942,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -4321,7 +4379,7 @@
@@ -4367,6 +4425,18 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/NETworkManager/Views/ProfilesView.xaml b/Source/NETworkManager/Views/ProfilesView.xaml
index 55d5478c24..4bf670ed40 100644
--- a/Source/NETworkManager/Views/ProfilesView.xaml
+++ b/Source/NETworkManager/Views/ProfilesView.xaml
@@ -9,6 +9,7 @@
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:viewModels="clr-namespace:NETworkManager.ViewModels"
xmlns:profiles="clr-namespace:NETworkManager.Profiles;assembly=NETworkManager.Profiles"
+ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
xmlns:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings"
xmlns:networkManager="clr-namespace:NETworkManager"
@@ -62,7 +63,8 @@
+ SelectedItem="{Binding SelectedGroup}"
+ wpfHelpers:DataGridHelper.FillRemainingWidth="True">
+ Binding="{Binding Path=(profiles:GroupInfo.Name)}"
+ MinWidth="150"
+ wpfHelpers:DataGridHelper.IsFillColumn="True" />
@@ -179,6 +183,7 @@