diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index de4abb87d1..80a1962455 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -2981,6 +2981,17 @@ public static string DeleteFirewallRuleMessage {
}
}
+ ///
+ /// Looks up a localized string similar to The selected firewall rules are permanently deleted:
+ ///
+ ///{0}.
+ ///
+ public static string DeleteFirewallRulesMessage {
+ get {
+ return ResourceManager.GetString("DeleteFirewallRulesMessage", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Delete group.
///
@@ -3120,6 +3131,24 @@ public static string DeleteRule {
}
}
+ ///
+ /// Looks up a localized string similar to Delete rules.
+ ///
+ public static string DeleteRules {
+ get {
+ return ResourceManager.GetString("DeleteRules", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Delete rules ({0}).
+ ///
+ public static string DeleteRulesX {
+ get {
+ return ResourceManager.GetString("DeleteRulesX", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Delete SNTP server.
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index b9a01cc5ef..c5c09cdc54 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -4729,4 +4729,15 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri
Copy rule
+
+ The selected firewall rules are permanently deleted:
+
+{0}
+
+
+ Delete rules
+
+
+ Delete rules ({0})
+
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Firewall/Firewall.cs b/Source/NETworkManager.Models/Firewall/Firewall.cs
index 915692d24c..baab14b0e6 100644
--- a/Source/NETworkManager.Models/Firewall/Firewall.cs
+++ b/Source/NETworkManager.Models/Firewall/Firewall.cs
@@ -168,54 +168,77 @@ public static async Task> GetRulesAsync()
}
///
- /// Enables or disables the given by running
- /// Enable-NetFirewallRule or Disable-NetFirewallRule against
- /// the rule's internal .
+ /// Enables or disables the given by running
+ /// Enable-NetFirewallRule or Disable-NetFirewallRule against the rules'
+ /// internal . The cmdlet is run for all rules (also for rules that already
+ /// seem to be in the target state, since the state may have changed outside the application).
+ /// Nothing is changed if one of the rules no longer exists.
///
- ///
- /// The firewall rule to modify.
+ ///
+ /// The firewall rules to modify.
///
///
- /// to enable the rule; to disable it.
+ /// to enable the rules; to disable them.
///
///
- /// Thrown when the PowerShell pipeline reports one or more errors.
+ /// Thrown when a rule no longer exists or the PowerShell pipeline reports one or more errors.
///
- public static async Task SetRuleEnabledAsync(FirewallRule rule, bool enabled)
+ public static Task SetRulesEnabledAsync(IEnumerable rules, bool enabled)
{
- await RunspaceLock.WaitAsync();
- try
- {
- await Task.Run(() =>
- {
- using var ps = SMA.PowerShell.Create();
- ps.Runspace = SharedRunspace;
-
- ps.AddScript($@"{(enabled ? "Enable" : "Disable")}-NetFirewallRule -Name '{rule.Id}'");
- ps.Invoke();
-
- if (ps.Streams.Error.Count > 0)
- throw new Exception(string.Join("; ", ps.Streams.Error));
- });
- }
- finally
- {
- RunspaceLock.Release();
- }
+ return InvokeRuleCommandAsync(enabled ? "Enable-NetFirewallRule" : "Disable-NetFirewallRule",
+ rules.Select(rule => rule.Id).ToList());
}
///
- /// Permanently removes the given by running
- /// Remove-NetFirewallRule against the rule's internal .
+ /// Permanently removes the given .
///
///
/// The firewall rule to delete.
///
///
- /// Thrown when the PowerShell pipeline reports one or more errors.
+ /// Thrown when the rule no longer exists or the PowerShell pipeline reports one or more errors.
+ ///
+ public static Task DeleteRuleAsync(FirewallRule rule)
+ {
+ return DeleteRulesAsync([rule]);
+ }
+
+ ///
+ /// Permanently removes the given by running
+ /// Remove-NetFirewallRule against the rules' internal .
+ /// Nothing is deleted if one of the rules no longer exists.
+ ///
+ ///
+ /// The firewall rules to delete.
+ ///
+ ///
+ /// Thrown when a rule no longer exists or the PowerShell pipeline reports one or more errors.
+ ///
+ public static Task DeleteRulesAsync(IEnumerable rules)
+ {
+ return InvokeRuleCommandAsync("Remove-NetFirewallRule", rules.Select(rule => rule.Id).ToList());
+ }
+
+ ///
+ /// Runs the given firewall cmdlet (e.g. Enable-NetFirewallRule) for all rule IDs in a
+ /// single PowerShell call. It is checked first that all rules still exist, otherwise nothing is changed.
+ /// This is not a transaction: if the cmdlet fails for a single rule (e.g. access denied), the other
+ /// rules may already be changed.
+ ///
+ ///
+ /// The cmdlet to run. Must accept the rule IDs via -Name.
+ ///
+ ///
+ /// The internal IDs of the rules. Nothing is done if the list is empty.
+ ///
+ ///
+ /// Thrown when a rule no longer exists or the PowerShell pipeline reports one or more errors.
///
- public static async Task DeleteRuleAsync(FirewallRule rule)
+ private static async Task InvokeRuleCommandAsync(string command, IReadOnlyCollection ids)
{
+ if (ids.Count == 0)
+ return;
+
await RunspaceLock.WaitAsync();
try
{
@@ -224,7 +247,11 @@ await Task.Run(() =>
using var ps = SMA.PowerShell.Create();
ps.Runspace = SharedRunspace;
- ps.AddScript($@"Remove-NetFirewallRule -Name '{rule.Id}'");
+ // Check that all rules still exist before changing anything
+ ps.AddScript($@"
+$ids = {ToPsArray(ids)}
+Get-NetFirewallRule -Name $ids -ErrorAction Stop | Out-Null
+{command} -Name $ids");
ps.Invoke();
if (ps.Streams.Error.Count > 0)
diff --git a/Source/NETworkManager/DialogHelper.cs b/Source/NETworkManager/DialogHelper.cs
index a5882b633a..036a12e30a 100644
--- a/Source/NETworkManager/DialogHelper.cs
+++ b/Source/NETworkManager/DialogHelper.cs
@@ -4,6 +4,9 @@
using NETworkManager.Utilities;
using NETworkManager.ViewModels;
using NETworkManager.Views;
+using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using System.Windows;
@@ -94,5 +97,24 @@ public static async Task ShowConfirmationMessageAsync(Window parentWindow,
return result;
}
+
+ ///
+ /// Formats the given items as a list (one item per line) to show them in a dialog message,
+ /// e.g. the entries that will be deleted. Only the first items are
+ /// listed, the remaining items are summarized as "(+X more)".
+ ///
+ /// The items to list.
+ /// The maximum number of items to list.
+ /// The formatted list.
+ public static string FormatItemList(IEnumerable items, int maxItems = 10)
+ {
+ var itemList = items.ToList();
+ var lines = itemList.Take(maxItems).ToList();
+
+ if (itemList.Count > maxItems)
+ lines.Add(string.Format(Strings.PlusXMore, itemList.Count - maxItems));
+
+ return string.Join(Environment.NewLine, lines);
+ }
}
}
diff --git a/Source/NETworkManager/ViewModels/FirewallViewModel.cs b/Source/NETworkManager/ViewModels/FirewallViewModel.cs
index 9e05b03eca..35d6039af0 100644
--- a/Source/NETworkManager/ViewModels/FirewallViewModel.cs
+++ b/Source/NETworkManager/ViewModels/FirewallViewModel.cs
@@ -76,6 +76,16 @@ public IList SelectedResults
}
} = new ArrayList();
+ ///
+ /// Indicates whether at least one of the selected rules is enabled (used to show "Disable").
+ ///
+ public bool IsAnySelectedEntryEnabled => GetSelectedRules().Any(rule => rule.IsEnabled);
+
+ ///
+ /// Indicates whether at least one of the selected rules is disabled (used to show "Enable").
+ ///
+ public bool IsAnySelectedEntryDisabled => GetSelectedRules().Any(rule => !rule.IsEnabled);
+
///
/// Gets or sets the search text for filtering rules.
///
@@ -109,6 +119,22 @@ public bool IsRefreshing
}
}
+ ///
+ /// Gets or sets whether a rule is currently being modified (added, edited, enabled, disabled or deleted).
+ ///
+ public bool IsModifying
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets or sets whether the status message bar is shown.
///
@@ -187,9 +213,9 @@ public FirewallViewModel()
public ICommand RefreshCommand => new RelayCommand(parameter => { _ = RefreshAction(); }, Refresh_CanExecute);
///
- /// Returns when no refresh is currently running.
+ /// Returns when no refresh is currently running and no rule is being modified.
///
- private bool Refresh_CanExecute(object _) => !IsRefreshing;
+ private bool Refresh_CanExecute(object _) => !IsRefreshing && !IsModifying;
///
/// Delegates to to reload the firewall rules.
@@ -215,18 +241,7 @@ private async Task AddRule()
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
- try
- {
- await Firewall.AddRuleAsync(BuildRule(instance));
- await Refresh();
- }
- catch (Exception ex)
- {
- Log.Error("Error while adding firewall rule", ex);
-
- StatusMessage = ex.Message;
- IsStatusMessageDisplayed = true;
- }
+ await ModifyRulesAsync(() => Firewall.AddRuleAsync(BuildRule(instance)), "Error while adding firewall rule");
}, _ =>
{
childWindow.IsOpen = false;
@@ -242,49 +257,41 @@ private async Task AddRule()
}
///
- /// Gets the command to enable the selected firewall rule.
- /// Only executable when the rule is currently disabled and modification is allowed.
+ /// Gets the command to enable the selected firewall rule(s).
+ /// Only executable when at least one selected rule is disabled and modification is allowed.
///
- public ICommand EnableRuleCommand => new RelayCommand(parameter => { _ = SetRuleEnabled(SelectedResult, true); }, _ => ModifyRule_CanExecute() && SelectedResult is { IsEnabled: false });
+ public ICommand EnableRuleCommand => new RelayCommand(parameter => { _ = SetRulesEnabled(true); }, _ => ModifyRule_CanExecute() && IsAnySelectedEntryDisabled);
///
- /// Gets the command to disable the selected firewall rule.
- /// Only executable when the rule is currently enabled and modification is allowed.
+ /// Gets the command to disable the selected firewall rule(s).
+ /// Only executable when at least one selected rule is enabled and modification is allowed.
///
- public ICommand DisableRuleCommand => new RelayCommand(parameter => { _ = SetRuleEnabled(SelectedResult, false); }, _ => ModifyRule_CanExecute() && SelectedResult is { IsEnabled: true });
+ public ICommand DisableRuleCommand => new RelayCommand(parameter => { _ = SetRulesEnabled(false); }, _ => ModifyRule_CanExecute() && IsAnySelectedEntryEnabled);
///
- /// Enables or disables the given via PowerShell,
+ /// Enables or disables the selected rule(s) via PowerShell,
/// then reloads the rule list to reflect the updated state.
/// Any PowerShell error is written to the log and shown in the status bar.
///
- ///
- /// The firewall rule to modify.
- ///
///
- /// to enable the rule; to disable it.
+ /// to enable the rules; to disable them.
///
- private async Task SetRuleEnabled(FirewallRule rule, bool enabled)
+ private async Task SetRulesEnabled(bool enabled)
{
- try
- {
- await Firewall.SetRuleEnabledAsync(rule, enabled);
- await Refresh();
- }
- catch (Exception ex)
- {
- Log.Error($"Error while {(enabled ? "enabling" : "disabling")} firewall rule", ex);
+ var rules = GetTargetEntries();
- StatusMessage = ex.Message;
- IsStatusMessageDisplayed = true;
- }
+ if (rules.Count == 0)
+ return;
+
+ await ModifyRulesAsync(() => Firewall.SetRulesEnabledAsync(rules, enabled),
+ $"Error while {(enabled ? "enabling" : "disabling")} firewall rule(s)");
}
///
/// Gets the command to open the dialog for editing the selected firewall rule.
- /// Only executable when a rule is selected and modification is allowed.
+ /// Only executable when exactly one rule is selected and modification is allowed.
///
- public ICommand EditRuleCommand => new RelayCommand(parameter => { _ = EditRule(); }, _ => ModifyRule_CanExecute() && SelectedResult != null);
+ public ICommand EditRuleCommand => new RelayCommand(parameter => { _ = EditRule(); }, _ => ModifyRule_CanExecute() && IsSingleRuleSelected);
///
/// Opens the edit-firewall-rule dialog pre-filled with the selected rule's properties.
@@ -300,19 +307,11 @@ private async Task EditRule()
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
- try
+ await ModifyRulesAsync(async () =>
{
await Firewall.DeleteRuleAsync(instance.Entry);
await Firewall.AddRuleAsync(BuildRule(instance));
- await Refresh();
- }
- catch (Exception ex)
- {
- Log.Error("Error while editing firewall rule", ex);
-
- StatusMessage = ex.Message;
- IsStatusMessageDisplayed = true;
- }
+ }, "Error while editing firewall rule");
}, _ =>
{
childWindow.IsOpen = false;
@@ -329,9 +328,9 @@ private async Task EditRule()
///
/// Gets the command to copy the selected firewall rule.
- /// Only enabled when the application is running as administrator.
+ /// Only executable when exactly one rule is selected and modification is allowed.
///
- public ICommand CopyAsRuleCommand => new RelayCommand(parameter => { _ = CopyAsRule(); }, _ => ModifyRule_CanExecute() && SelectedResult != null);
+ public ICommand CopyAsRuleCommand => new RelayCommand(parameter => { _ = CopyAsRule(); }, _ => ModifyRule_CanExecute() && IsSingleRuleSelected);
///
/// Opens the rule dialog pre-filled with the selected rule's properties (name suffixed with " - Copy").
@@ -347,18 +346,7 @@ private async Task CopyAsRule()
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
- try
- {
- await Firewall.AddRuleAsync(BuildRule(instance));
- await Refresh();
- }
- catch (Exception ex)
- {
- Log.Error("Error while copying firewall rule", ex);
-
- StatusMessage = ex.Message;
- IsStatusMessageDisplayed = true;
- }
+ await ModifyRulesAsync(() => Firewall.AddRuleAsync(BuildRule(instance)), "Error while copying firewall rule");
}, _ =>
{
childWindow.IsOpen = false;
@@ -374,52 +362,117 @@ private async Task CopyAsRule()
}
///
- /// Gets the command to permanently delete the selected firewall rule.
+ /// Gets the command to permanently delete the selected firewall rule(s).
/// Only executable when a rule is selected and modification is allowed.
///
- public ICommand DeleteRuleCommand => new RelayCommand(parameter => { _ = DeleteRule(); }, _ => ModifyRule_CanExecute() && SelectedResult != null);
+ public ICommand DeleteRuleCommand => new RelayCommand(parameter => { _ = DeleteRules(); }, _ => ModifyRule_CanExecute() && SelectedResult != null);
///
- /// Shows a confirmation dialog and, if confirmed, deletes the selected firewall rule
- /// via PowerShell and reloads the rule list.
- /// Any PowerShell error is written to the log and shown in the status bar.
+ /// Asks for confirmation and permanently deletes the selected rule(s) via PowerShell,
+ /// then reloads the rule list. Any PowerShell error is written to the log and shown in the status bar.
///
- private async Task DeleteRule()
+ private async Task DeleteRules()
{
+ var rules = GetTargetEntries();
+
+ if (rules.Count == 0)
+ return;
+
+ var message = rules.Count == 1
+ ? string.Format(Strings.DeleteFirewallRuleMessage, rules[0].Name)
+ : string.Format(Strings.DeleteFirewallRulesMessage, DialogHelper.FormatItemList(rules.Select(rule => rule.Name)));
+
var result = await DialogHelper.ShowConfirmationMessageAsync(
Application.Current.MainWindow,
- Strings.DeleteRule,
- string.Format(Strings.DeleteFirewallRuleMessage, SelectedResult.Name),
+ rules.Count == 1 ? Strings.DeleteRule : string.Format(Strings.DeleteRulesX, rules.Count),
+ message,
ChildWindowIcon.Info,
Strings.Delete);
if (!result)
return;
- try
- {
- await Firewall.DeleteRuleAsync(SelectedResult);
- await Refresh();
- }
- catch (Exception ex)
- {
- Log.Error("Error while deleting firewall rule", ex);
-
- StatusMessage = ex.Message;
- IsStatusMessageDisplayed = true;
- }
+ await ModifyRulesAsync(() => Firewall.DeleteRulesAsync(rules), "Error while deleting firewall rule(s)");
}
///
/// Returns when the application is running as administrator,
- /// no dialog is open, and no child window is open — i.e. it is safe to modify a rule.
+ /// no dialog is open, no child window is open and no rule is being modified — i.e. it is safe to modify a rule.
///
- private static bool ModifyRule_CanExecute()
+ private bool ModifyRule_CanExecute()
{
return ConfigurationManager.Current.IsAdmin &&
Application.Current.MainWindow != null &&
!((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen &&
- !ConfigurationManager.Current.IsChildWindowOpen;
+ !ConfigurationManager.Current.IsChildWindowOpen &&
+ !IsRefreshing &&
+ !IsModifying;
+ }
+
+ ///
+ /// Gets the selected rules (the multi-selection, or the single selected rule) without copying them.
+ /// Used for checks that are evaluated often (e.g. ).
+ ///
+ private IEnumerable GetSelectedRules()
+ {
+ if (SelectedResults is { Count: > 1 })
+ return SelectedResults.Cast();
+
+ if (SelectedResult != null)
+ return [SelectedResult];
+
+ return [];
+ }
+
+ ///
+ /// Indicates whether exactly one rule is selected (e.g. for edit or copy, which only work on a single rule).
+ ///
+ private bool IsSingleRuleSelected => SelectedResult != null && SelectedResults is not { Count: > 1 };
+
+ ///
+ /// Gets the rules to modify: the multi-selection, or the single selected rule.
+ ///
+ private List GetTargetEntries() => [.. GetSelectedRules()];
+
+ ///
+ /// Runs the given modification and reloads the rule list afterwards, also if the modification
+ /// failed (e.g. a rule was removed in the meantime), so the list always shows the actual state.
+ /// Any error is written to the log and shown in the status bar.
+ ///
+ /// The modification to run.
+ /// Message written to the log if the modification fails.
+ private async Task ModifyRulesAsync(Func action, string errorLogMessage)
+ {
+ IsModifying = true;
+
+ try
+ {
+ Exception error = null;
+
+ try
+ {
+ await action();
+ }
+ catch (Exception ex)
+ {
+ Log.Error(errorLogMessage, ex);
+ error = ex;
+ }
+
+ var refreshed = await Refresh();
+
+ // Show the error after the refresh, because the refresh overwrites the status message.
+ // If the refresh failed too, keep its message, so it's visible that the list is not up to date.
+ if (error != null)
+ {
+ StatusMessage = refreshed ? error.Message : error.Message + Environment.NewLine + StatusMessage;
+ IsStatusMessageDisplayed = true;
+ }
+ }
+ finally
+ {
+ IsModifying = false;
+ }
}
///
@@ -535,10 +588,11 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro
/// When the initial UI delay is skipped so the first load
/// on startup feels immediate.
///
- private async Task Refresh(bool init = false)
+ /// if the rules were loaded successfully; otherwise, .
+ private async Task Refresh(bool init = false)
{
if (IsRefreshing)
- return;
+ return false;
IsRefreshing = true;
StatusMessage = Strings.RefreshingDots;
@@ -561,6 +615,8 @@ private async Task Refresh(bool init = false)
StatusMessage = string.Format(Strings.ReloadedAtX, DateTime.Now.ToShortTimeString());
IsStatusMessageDisplayed = true;
+
+ return true;
}
catch (Exception ex)
{
@@ -568,9 +624,13 @@ private async Task Refresh(bool init = false)
StatusMessage = string.Format(Strings.FailedToLoadFirewallRulesMessage, ex.Message);
IsStatusMessageDisplayed = true;
- }
- IsRefreshing = false;
+ return false;
+ }
+ finally
+ {
+ IsRefreshing = false;
+ }
}
///
@@ -638,6 +698,16 @@ private static List ParseAddressesString(string value)
return [.. value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
}
+ ///
+ /// Called when a context menu is opened. Updates the selection dependent properties,
+ /// because is changed in place (no change notification).
+ ///
+ public void OnContextMenuOpened()
+ {
+ OnPropertyChanged(nameof(IsAnySelectedEntryEnabled));
+ OnPropertyChanged(nameof(IsAnySelectedEntryDisabled));
+ }
+
///
/// Called when the view becomes visible.
///
diff --git a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
index 4fa1fc4fd3..b4fb4c9a89 100644
--- a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
+++ b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs
@@ -30,11 +30,6 @@ public class HostsFileEditorViewModel : ViewModelBase
private static readonly ILog Log = LogManager.GetLogger(typeof(HostsFileEditorViewModel));
- ///
- /// Maximum number of entries listed in the delete confirmation dialog.
- ///
- private const int DeleteEntriesMaxDisplayed = 10;
-
///
/// Indicates whether the view model is loading.
///
@@ -580,15 +575,8 @@ private async Task DeleteEntryAction()
}
else
{
- // Show the first entries, the rest is summarized as "(+X more)"
- var lines = entries.Take(DeleteEntriesMaxDisplayed)
- .Select(e => $"{e.IPAddress} {e.Hostname} {(string.IsNullOrEmpty(e.Comment) ? "" : $"# {e.Comment}")}".TrimEnd())
- .ToList();
-
- if (entries.Count > DeleteEntriesMaxDisplayed)
- lines.Add(string.Format(Strings.PlusXMore, entries.Count - DeleteEntriesMaxDisplayed));
-
- message = string.Format(Strings.DeleteHostsFileEntriesMessage, string.Join(Environment.NewLine, lines));
+ message = string.Format(Strings.DeleteHostsFileEntriesMessage, DialogHelper.FormatItemList(entries.Select(e =>
+ $"{e.IPAddress} {e.Hostname} {(string.IsNullOrEmpty(e.Comment) ? "" : $"# {e.Comment}")}".TrimEnd())));
}
var result = await DialogHelper.ShowConfirmationMessageAsync(
diff --git a/Source/NETworkManager/Views/FirewallView.xaml b/Source/NETworkManager/Views/FirewallView.xaml
index dd6e198fb2..2731f6e963 100644
--- a/Source/NETworkManager/Views/FirewallView.xaml
+++ b/Source/NETworkManager/Views/FirewallView.xaml
@@ -227,6 +227,45 @@
+
+
+
+