diff --git a/.gitignore b/.gitignore
index 5cd7253..57e67c9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,12 @@ publish/
*.pidb
*.booproj
+# Local developer notes (never tracked)
+.kiro/
+.session-notes/
+notes-local.md
+scratch.md
+
# NuGet
*.nupkg
*.snupkg
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2851b9e..13a4495 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+## [1.18.1] - 2026-06-03
+
+### Fixed
+- **Privacy Toggles no longer write to the registry on every click.** Toggling a switch now updates local state only; the user must press **Apply** to write pending changes. A live counter shows how many changes are pending, and **Discard** reverts them without touching the registry. Prevents accidental system changes when scrolling through the toggle list.
+- **Dashboard no longer freezes on first load.** Static system info (CPU, OS, RAM modules) is now loaded asynchronously instead of blocking the UI thread on a synchronous WMI capture. The Dashboard tab is responsive immediately on startup.
+- **DNS / Hosts tab loads asynchronously.** Reading the hosts file is now async (`File.ReadAllLinesAsync`); Refresh no longer freezes the UI on slow disks.
+- **Icon cache eviction is now true FIFO.** The icon cache previously evicted random entries because `ConcurrentDictionary.Keys` has no insertion-order guarantee. Frequently-used icons could be dropped while stale ones survived. The cache now tracks insertion order via a queue and evicts the oldest entries first when the size limit is reached.
+- **SpeedTest output read** — replaced `Task.Result` access after `Task.WhenAll` with proper `await` to remove the deadlock-prone pattern (the awaited tasks were already complete, but the style is now safe under all call paths).
+- **Silent exception swallowing** — empty `catch { }` blocks now log at Debug level so failures are diagnosable: ThemePopup custom-color parser, TemperatureService LibreHardwareMonitor close, WindowsUpdateService COM/RuntimeBinder catches in `ExtractKbIds` and `ClassifyCategory`.
+- **Deep Cleanup** — file/directory cleanup errors are now logged in addition to being added to the per-run error list, so unexpected I/O issues surface in the SysManager log.
+- **Admin relaunch** — `RelaunchAsAdmin` now distinguishes the user's UAC decline (Win32 error 1223 → Information) from real Win32 failures (Warning) and logs `InvalidOperationException` instead of swallowing it silently.
+- **`SHGetFileInfo` P/Invoke** — added `SetLastError = true` so callers can inspect the Win32 error code on failure.
+
+### Changed
+- **PrivacyView toolbar** — the **Apply All** button is replaced by **Apply** (writes only pending changes) and **Discard** (reverts to last-applied state). Both are disabled when no changes are pending. The Apply button uses the primary style to highlight the action.
+- **PerformanceService.TakeSnapshotAsync** XML doc now warns callers that the method must run before any state-modifying call; the recommended lazy-initialization pattern is documented inline.
+- **PerformanceService.CreateRestorePointAsync** comment reworded — the previous `// BUG-003:` marker was a design note, not an open bug; replaced with an explanation of why PowerShell `AddParameter` cannot be used here.
+- **App.xaml.cs unhandled-exception dialog** — added inline note explaining why `MessageBox.Show` is used instead of `DialogService` (the dispatcher exception may originate from DialogService itself).
+- **`.gitignore`** — added `.kiro/`, `.session-notes/`, `notes-local.md`, `scratch.md` so local developer notes can never be tracked accidentally.
+
## [1.18.0] - 2026-06-03
### Fixed
diff --git a/README.md b/README.md
index 170b147..79206f1 100644
--- a/README.md
+++ b/README.md
@@ -235,7 +235,9 @@ deleting anything (uses the standard `LegacyDisable` registry mechanism):
- **Telemetry**: disable diagnostic data, activity history, advertising ID, feedback prompts
- **UI Declutter**: disable Start suggestions, tips, lock screen tips, Spotlight ads
- **Features**: disable Copilot, Cortana, web search in Start, widgets
-- Instant apply — toggles take effect immediately via registry writes
+- Explicit apply — flip toggles to stage changes, press **Apply** to write to the
+ registry, or **Discard** to revert pending changes. A live counter shows how
+ many changes are queued, so accidental clicks never modify the system silently.
- Category filter and search
- Requires admin for HKLM-backed toggles
- Fully reversible — re-enable any toggle with one click
diff --git a/SysManager/SysManager.Tests/PrivacyViewModelTests.cs b/SysManager/SysManager.Tests/PrivacyViewModelTests.cs
index bcf1a72..0555a3f 100644
--- a/SysManager/SysManager.Tests/PrivacyViewModelTests.cs
+++ b/SysManager/SysManager.Tests/PrivacyViewModelTests.cs
@@ -4,13 +4,13 @@
using SysManager.Services;
using SysManager.ViewModels;
-using SysManager.Helpers;
namespace SysManager.Tests;
///
/// Tests for . Verifies toggle population,
-/// category filtering, and reset behavior without writing to the registry.
+/// category filtering, pending-change tracking, and discard behavior
+/// without writing to the registry.
///
public class PrivacyViewModelTests
{
@@ -63,30 +63,76 @@ public void FilterByAll_ShowsAllToggles()
}
[Fact]
- public void ResetAll_SetsAllIsEnabledToFalse()
+ public void FilteredToggles_InitiallyMatchesAll()
{
var vm = CreateVm();
- // Some toggles may be enabled from registry reads — force some on
- foreach (var toggle in vm.Toggles)
- toggle.IsEnabled = true;
+ Assert.Equal(vm.Toggles.Count, vm.FilteredToggles.Count);
+ }
- vm.ResetAllCommand.Execute(null);
+ [Fact]
+ public void Constructor_NoPendingChanges_AfterLoad()
+ {
+ var vm = CreateVm();
+ Assert.Equal(0, vm.PendingChangeCount);
+ Assert.False(vm.HasPendingChanges);
+ }
- Assert.All(vm.Toggles, t => Assert.False(t.IsEnabled));
+ [Fact]
+ public void TogglingValue_IncrementsPendingChangeCount()
+ {
+ var vm = CreateVm();
+ var first = vm.Toggles[0];
+ first.IsEnabled = !first.IsEnabled;
+
+ Assert.Equal(1, vm.PendingChangeCount);
+ Assert.True(vm.HasPendingChanges);
}
[Fact]
- public void FilteredToggles_InitiallyMatchesAll()
+ public void TogglingValueBackToBaseline_ResetsPendingCount()
{
var vm = CreateVm();
- Assert.Equal(vm.Toggles.Count, vm.FilteredToggles.Count);
+ var first = vm.Toggles[0];
+ var original = first.IsEnabled;
+
+ first.IsEnabled = !original;
+ first.IsEnabled = original;
+
+ Assert.Equal(0, vm.PendingChangeCount);
}
[Fact]
- public void StatusMessage_UpdatesAfterReset()
+ public void DiscardChanges_RestoresAllTogglesToBaseline()
{
var vm = CreateVm();
- vm.ResetAllCommand.Execute(null);
- Assert.Contains("reset", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
+ var baseline = vm.Toggles.Select(t => t.IsEnabled).ToList();
+
+ // Flip every toggle.
+ foreach (var t in vm.Toggles)
+ t.IsEnabled = !t.IsEnabled;
+
+ vm.DiscardChangesCommand.Execute(null);
+
+ for (int i = 0; i < vm.Toggles.Count; i++)
+ Assert.Equal(baseline[i], vm.Toggles[i].IsEnabled);
+ Assert.Equal(0, vm.PendingChangeCount);
+ }
+
+ [Fact]
+ public void StatusMessage_MentionsPending_WhenChangesQueued()
+ {
+ var vm = CreateVm();
+ vm.Toggles[0].IsEnabled = !vm.Toggles[0].IsEnabled;
+
+ Assert.Contains("pending", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void ApplyChanges_WithNoPending_SetsNoChangesMessage()
+ {
+ var vm = CreateVm();
+ vm.ApplyChangesCommand.Execute(null);
+
+ Assert.Contains("no changes", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
}
diff --git a/SysManager/SysManager/App.xaml.cs b/SysManager/SysManager/App.xaml.cs
index 99ea2b2..aac93c2 100644
--- a/SysManager/SysManager/App.xaml.cs
+++ b/SysManager/SysManager/App.xaml.cs
@@ -179,6 +179,10 @@ private static void OnUi(object s, DispatcherUnhandledExceptionEventArgs e)
try
{
+ // MessageBox is the safe last-resort dialog here: the unhandled
+ // dispatcher exception may itself originate from DialogService or
+ // any of its dependencies, so we cannot rely on the app's own
+ // dialog stack at this point. Direct WPF MessageBox always works.
MessageBox.Show(e.Exception.Message, "SysManager error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
diff --git a/SysManager/SysManager/Helpers/AdminHelper.cs b/SysManager/SysManager/Helpers/AdminHelper.cs
index 6a87dd5..bc56ece 100644
--- a/SysManager/SysManager/Helpers/AdminHelper.cs
+++ b/SysManager/SysManager/Helpers/AdminHelper.cs
@@ -4,6 +4,7 @@
using System.Diagnostics;
using System.Security.Principal;
+using Serilog;
namespace SysManager.Helpers;
@@ -42,14 +43,19 @@ public static bool RelaunchAsAdmin(string? argumentHint = null)
Process.Start(psi);
return true;
}
- catch (InvalidOperationException)
+ catch (InvalidOperationException ex)
{
- // User declined UAC or process path unavailable
+ // Process path unavailable or app shutting down.
+ Log.Debug(ex, "RelaunchAsAdmin: process path unavailable");
return false;
}
- catch (System.ComponentModel.Win32Exception)
+ catch (System.ComponentModel.Win32Exception ex)
{
- // UAC declined or another Win32 error
+ // 1223 = ERROR_CANCELLED (user declined UAC); other codes = real Win32 error.
+ if (ex.NativeErrorCode == 1223)
+ Log.Information("RelaunchAsAdmin: user declined UAC prompt");
+ else
+ Log.Warning(ex, "RelaunchAsAdmin: Win32 error {Code}", ex.NativeErrorCode);
return false;
}
}
diff --git a/SysManager/SysManager/Services/DeepCleanupService.cs b/SysManager/SysManager/Services/DeepCleanupService.cs
index 97ffba1..507e86f 100644
--- a/SysManager/SysManager/Services/DeepCleanupService.cs
+++ b/SysManager/SysManager/Services/DeepCleanupService.cs
@@ -3,6 +3,7 @@
// License: MIT
using System.IO;
+using Serilog;
using SysManager.Models;
namespace SysManager.Services;
@@ -352,14 +353,22 @@ private static CleanupResult Clean(IReadOnlyList categories, IP
freed += len;
filesDeleted++;
}
- catch (Exception ex) { errors.Add($"{file}: {ex.Message}"); }
+ catch (Exception ex)
+ {
+ errors.Add($"{file}: {ex.Message}");
+ Log.Debug(ex, "Deep cleanup: failed to delete file {File}", file);
+ }
}
foreach (var dir in EnumerateDirectoriesDepthFirst(path, ct))
{
try { Directory.Delete(dir, recursive: false); } catch (IOException) { } catch (UnauthorizedAccessException) { }
}
}
- catch (Exception ex) { errors.Add($"{path}: {ex.Message}"); }
+ catch (Exception ex)
+ {
+ errors.Add($"{path}: {ex.Message}");
+ Log.Debug(ex, "Deep cleanup: failed to enumerate path {Path}", path);
+ }
}
}
diff --git a/SysManager/SysManager/Services/HostsFileService.cs b/SysManager/SysManager/Services/HostsFileService.cs
index 6a8757c..d395ae4 100644
--- a/SysManager/SysManager/Services/HostsFileService.cs
+++ b/SysManager/SysManager/Services/HostsFileService.cs
@@ -27,12 +27,13 @@ public sealed partial class HostsFileService
/// Reads and parses the hosts file. Skips pure comment lines (starting with #
/// that don't have an IP pattern). Detects commented-out entries as disabled.
///
- public List ReadHosts()
+ public async Task> ReadHostsAsync(CancellationToken ct = default)
{
List entries = [];
if (!File.Exists(HostsPath)) return entries;
- foreach (string rawLine in File.ReadAllLines(HostsPath))
+ var lines = await File.ReadAllLinesAsync(HostsPath, ct).ConfigureAwait(false);
+ foreach (string rawLine in lines)
{
string line = rawLine.Trim();
if (string.IsNullOrEmpty(line)) continue;
diff --git a/SysManager/SysManager/Services/IconExtractorService.cs b/SysManager/SysManager/Services/IconExtractorService.cs
index 9812d66..2e2205a 100644
--- a/SysManager/SysManager/Services/IconExtractorService.cs
+++ b/SysManager/SysManager/Services/IconExtractorService.cs
@@ -23,6 +23,7 @@ namespace SysManager.Services;
public sealed partial class IconExtractorService
{
private static readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase);
+ private static readonly Queue _insertionOrder = new();
private static readonly object _evictionLock = new();
/// Maximum number of cached icons before eviction kicks in.
@@ -46,23 +47,21 @@ public sealed partial class IconExtractorService
if (string.IsNullOrEmpty(normalized))
return _appFallback.Value;
- // Evict oldest entries when cache exceeds the limit.
- // ConcurrentDictionary has no insertion order, so we clear half
- // the cache to amortize the cost of eviction.
- if (_cache.Count >= MaxCacheSize)
+ if (_cache.TryGetValue(normalized, out var cached))
+ return cached;
+
+ var icon = ExtractIcon(normalized);
+
+ lock (_evictionLock)
{
- lock (_evictionLock)
- {
- // Double-check inside lock to avoid redundant eviction.
- if (_cache.Count >= MaxCacheSize)
- {
- var keys = _cache.Keys.Take(_cache.Count / 2).ToList();
- foreach (var k in keys) _cache.TryRemove(k, out _);
- }
- }
+ if (_cache.TryAdd(normalized, icon))
+ _insertionOrder.Enqueue(normalized);
+
+ while (_cache.Count > MaxCacheSize && _insertionOrder.TryDequeue(out var oldest))
+ _cache.TryRemove(oldest, out _);
}
- return _cache.GetOrAdd(normalized, static path => ExtractIcon(path));
+ return icon;
}
///
@@ -470,7 +469,7 @@ private struct SHFILEINFO
public string szTypeName;
}
- [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr SHGetFileInfo(
string pszPath, uint dwFileAttributes,
ref SHFILEINFO psfi, uint cbSizeFileInfo, uint uFlags);
diff --git a/SysManager/SysManager/Services/PerformanceService.cs b/SysManager/SysManager/Services/PerformanceService.cs
index 94c9eb7..ab47a8f 100644
--- a/SysManager/SysManager/Services/PerformanceService.cs
+++ b/SysManager/SysManager/Services/PerformanceService.cs
@@ -77,7 +77,14 @@ public sealed record OriginalSnapshot(
int ProcessorMinPercentAc,
string? NvidiaSubKey); // null = no NVIDIA GPU
- /// Take a snapshot of the current system state.
+ ///
+ /// Take a snapshot of the current system state.
+ /// IMPORTANT: callers MUST invoke this BEFORE applying any change, otherwise
+ /// the snapshot will capture already-modified state and Restore will not be
+ /// able to revert to the original baseline. The recommended pattern is to
+ /// guard every Apply through a wrapper that lazy-initializes the snapshot
+ /// (see PerformanceViewModel.EnsureSnapshotAsync).
+ ///
public async Task TakeSnapshotAsync(CancellationToken ct = default)
{
var (name, guid) = await GetActivePlanAsync(ct).ConfigureAwait(false);
@@ -517,8 +524,10 @@ await _ps.RunProcessAsync("powercfg.exe",
///
public async Task CreateRestorePointAsync(string description, CancellationToken ct = default)
{
- // BUG-003: Embed description directly in the script with single-quote
- // escaping. AddParameter doesn't create script-scope variables.
+ // NOTE: PowerShell AddParameter binds runtime arguments but does NOT
+ // create script-scope variables, so the description has to be embedded
+ // directly in the script body — with single-quote escaping to avoid
+ // injection via the user-supplied string.
var safeDesc = (description ?? "SysManager Restore Point").Replace("'", "''");
var script = $"Checkpoint-Computer -Description '{safeDesc}' -RestorePointType 'MODIFY_SETTINGS'";
await _ps.RunAsync(script, null, ct).ConfigureAwait(false);
diff --git a/SysManager/SysManager/Services/SpeedTestService.cs b/SysManager/SysManager/Services/SpeedTestService.cs
index 1293849..3233110 100644
--- a/SysManager/SysManager/Services/SpeedTestService.cs
+++ b/SysManager/SysManager/Services/SpeedTestService.cs
@@ -219,8 +219,8 @@ public async Task RunOoklaAsync(
var stdoutTask = proc.StandardOutput.ReadToEndAsync(linked);
var stderrTask = proc.StandardError.ReadToEndAsync(linked);
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
- var stdout = stdoutTask.Result;
- var stderr = stderrTask.Result;
+ var stdout = await stdoutTask.ConfigureAwait(false);
+ var stderr = await stderrTask.ConfigureAwait(false);
try
{
diff --git a/SysManager/SysManager/Services/TemperatureService.cs b/SysManager/SysManager/Services/TemperatureService.cs
index 9d62372..21c84be 100644
--- a/SysManager/SysManager/Services/TemperatureService.cs
+++ b/SysManager/SysManager/Services/TemperatureService.cs
@@ -186,7 +186,7 @@ private static void ReadViaLibreHardwareMonitor(List reading
finally
{
try { computer?.Close(); }
- catch { /* dispose errors — ignore */ }
+ catch (Exception ex) { Log.Debug(ex, "LibreHardwareMonitor close failed"); }
}
}
diff --git a/SysManager/SysManager/Services/WindowsUpdateService.cs b/SysManager/SysManager/Services/WindowsUpdateService.cs
index d4a69d9..7518399 100644
--- a/SysManager/SysManager/Services/WindowsUpdateService.cs
+++ b/SysManager/SysManager/Services/WindowsUpdateService.cs
@@ -250,8 +250,8 @@ private static List ExtractKbIds(dynamic u)
if (!string.IsNullOrWhiteSpace(id)) list.Add(id);
}
}
- catch (COMException) { }
- catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) { }
+ catch (COMException ex) { Serilog.Log.Debug(ex, "ExtractKbIds: COM error"); }
+ catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException ex) { Serilog.Log.Debug(ex, "ExtractKbIds: dynamic binding error"); }
return list;
}
@@ -286,8 +286,8 @@ internal static string ClassifyCategory(string title, dynamic? u = null)
}
Marshal.FinalReleaseComObject(cats);
}
- catch (COMException) { }
- catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) { }
+ catch (COMException ex) { Serilog.Log.Debug(ex, "ClassifyCategory: COM error reading Categories"); }
+ catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException ex) { Serilog.Log.Debug(ex, "ClassifyCategory: dynamic binding error"); }
}
if (title.Contains("Driver", StringComparison.OrdinalIgnoreCase) ||
diff --git a/SysManager/SysManager/SysManager.csproj b/SysManager/SysManager/SysManager.csproj
index 45c5005..bb9162b 100644
--- a/SysManager/SysManager/SysManager.csproj
+++ b/SysManager/SysManager/SysManager.csproj
@@ -10,9 +10,9 @@
SysManager
true
NU1603;NU1701
- 1.18.0
- 1.18.0.0
- 1.18.0.0
+ 1.18.1
+ 1.18.1.0
+ 1.18.1.0
SysManager
SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup.
https://github.com/laurentiu021/SystemManager
diff --git a/SysManager/SysManager/ViewModels/DashboardViewModel.cs b/SysManager/SysManager/ViewModels/DashboardViewModel.cs
index 3c2687c..8a3812d 100644
--- a/SysManager/SysManager/ViewModels/DashboardViewModel.cs
+++ b/SysManager/SysManager/ViewModels/DashboardViewModel.cs
@@ -90,7 +90,7 @@ public DashboardViewModel(SystemInfoService sys, TuneUpService tuneUp,
private async Task InitAsync()
{
- LoadStaticInfo();
+ await LoadStaticInfoAsync();
LoadDrives();
LoadActivity();
StartPollingLoop();
@@ -183,11 +183,11 @@ private void UpdateGpuUsage()
// STATIC INFO (loaded once)
// ══════════════════════════════════════════════════════════════════════
- private void LoadStaticInfo()
+ private async Task LoadStaticInfoAsync()
{
try
{
- var snap = _sys.CaptureAsync().GetAwaiter().GetResult();
+ var snap = await _sys.CaptureAsync().ConfigureAwait(true);
CpuName = snap.Cpu.Name;
CpuCores = $"{snap.Cpu.Cores} cores · {snap.Cpu.LogicalProcessors} threads";
OsLine = $"{snap.Os.Caption} · Build {snap.Os.BuildNumber}";
@@ -678,7 +678,7 @@ private async Task RefreshAsync()
StatusMessage = "Scanning...";
try
{
- LoadStaticInfo();
+ await LoadStaticInfoAsync();
LoadDrives();
await LoadHealthScoreAsync();
await LoadTemperaturesAsync();
diff --git a/SysManager/SysManager/ViewModels/DnsHostsViewModel.cs b/SysManager/SysManager/ViewModels/DnsHostsViewModel.cs
index 4d0cabb..abc07a3 100644
--- a/SysManager/SysManager/ViewModels/DnsHostsViewModel.cs
+++ b/SysManager/SysManager/ViewModels/DnsHostsViewModel.cs
@@ -56,10 +56,7 @@ public DnsHostsViewModel(DnsService dnsService, HostsFileService hostsService)
private async Task LoadInitialDataAsync()
{
await RefreshDnsAsync();
- if (Application.Current?.Dispatcher is { } d)
- await d.InvokeAsync(LoadHosts);
- else
- LoadHosts();
+ await LoadHostsAsync();
}
private async Task RefreshDnsAsync()
@@ -83,14 +80,15 @@ private async Task RefreshDnsAsync()
}
}
- private void LoadHosts()
+ private async Task LoadHostsAsync()
{
try
{
- var entries = _hostsService.ReadHosts();
+ var entries = await _hostsService.ReadHostsAsync(_cts.Token).ConfigureAwait(true);
HostEntries.ReplaceWith(entries);
HostsStatus = $"Loaded {entries.Count} entries.";
}
+ catch (OperationCanceledException) { }
catch (UnauthorizedAccessException)
{
HostsStatus = "Access denied — run as administrator to read hosts file.";
@@ -234,7 +232,7 @@ private void SaveHosts()
}
[RelayCommand]
- private void RefreshHosts() => LoadHosts();
+ private Task RefreshHostsAsync() => LoadHostsAsync();
[RelayCommand]
private void RelaunchAsAdmin()
diff --git a/SysManager/SysManager/ViewModels/PrivacyViewModel.cs b/SysManager/SysManager/ViewModels/PrivacyViewModel.cs
index da7e4fd..327c858 100644
--- a/SysManager/SysManager/ViewModels/PrivacyViewModel.cs
+++ b/SysManager/SysManager/ViewModels/PrivacyViewModel.cs
@@ -12,19 +12,25 @@
namespace SysManager.ViewModels;
///
-/// ViewModel for the Privacy Toggles tab. Loads registry-backed toggles,
-/// groups them by category, and applies changes immediately on toggle flip.
+/// ViewModel for the Privacy Toggles tab. Loads registry-backed toggles
+/// and groups them by category. Toggle flips update local state only;
+/// the user must explicitly press "Apply" to write changes to the registry.
///
public sealed partial class PrivacyViewModel : ViewModelBase
{
private readonly PrivacyService _service;
- private bool _suppressApply;
+ private readonly Dictionary _baselineStates = [];
public BulkObservableCollection Toggles { get; } = new();
[ObservableProperty] private List _categories = [];
[ObservableProperty] private string _selectedCategory = "All";
[ObservableProperty] private bool _isElevated;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasPendingChanges))]
+ private int _pendingChangeCount;
+
+ public bool HasPendingChanges => PendingChangeCount > 0;
public BulkObservableCollection FilteredToggles { get; } = new();
@@ -37,33 +43,30 @@ public PrivacyViewModel(PrivacyService service)
private void LoadToggles()
{
- _suppressApply = true;
- try
- {
- // Unsubscribe from old toggles
- foreach (var t in Toggles)
- t.PropertyChanged -= OnTogglePropertyChanged;
-
- var loaded = _service.LoadToggles();
- Toggles.ReplaceWith(loaded);
-
- // Subscribe to property changes for immediate apply
- foreach (var t in Toggles)
- t.PropertyChanged += OnTogglePropertyChanged;
+ // Unsubscribe from old toggles
+ foreach (var t in Toggles)
+ t.PropertyChanged -= OnTogglePropertyChanged;
- // Build category list
- List cats = ["All"];
- cats.AddRange(Toggles.Select(t => t.Category).Distinct().OrderBy(c => c));
- Categories = cats;
- SelectedCategory = "All";
+ var loaded = _service.LoadToggles();
+ Toggles.ReplaceWith(loaded);
- ApplyFilter();
- UpdateStatus();
- }
- finally
+ // Capture baseline so we can compute the pending-change count.
+ _baselineStates.Clear();
+ foreach (var t in Toggles)
{
- _suppressApply = false;
+ _baselineStates[t] = t.IsEnabled;
+ t.PropertyChanged += OnTogglePropertyChanged;
}
+
+ // Build category list
+ List cats = ["All"];
+ cats.AddRange(Toggles.Select(t => t.Category).Distinct().OrderBy(c => c));
+ Categories = cats;
+ SelectedCategory = "All";
+
+ ApplyFilter();
+ RecomputePendingChanges();
+ UpdateStatus();
}
partial void OnSelectedCategoryChanged(string value) => ApplyFilter();
@@ -80,14 +83,20 @@ private void ApplyFilter()
private void OnTogglePropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
- if (_suppressApply) return;
if (e.PropertyName != nameof(PrivacyToggle.IsEnabled)) return;
- if (sender is not PrivacyToggle toggle) return;
-
- _service.ApplyToggle(toggle);
+ RecomputePendingChanges();
UpdateStatus();
}
+ private void RecomputePendingChanges()
+ {
+ var pending = 0;
+ foreach (var t in Toggles)
+ if (_baselineStates.TryGetValue(t, out var baseline) && baseline != t.IsEnabled)
+ pending++;
+ PendingChangeCount = pending;
+ }
+
[RelayCommand]
private void RelaunchAsAdmin()
{
@@ -96,32 +105,37 @@ private void RelaunchAsAdmin()
}
[RelayCommand]
- private void ApplyAll()
+ private void ApplyChanges()
{
- _service.ApplyAll(Toggles);
- StatusMessage = $"All {Toggles.Count} toggles applied.";
- Log.Information("Privacy: applied all {Count} toggles", Toggles.Count);
+ if (PendingChangeCount == 0)
+ {
+ StatusMessage = "No changes to apply.";
+ return;
+ }
+
+ var changed = Toggles
+ .Where(t => _baselineStates.TryGetValue(t, out var baseline) && baseline != t.IsEnabled)
+ .ToList();
+
+ _service.ApplyAll(changed);
+
+ // Refresh baseline to the just-applied state.
+ foreach (var t in changed)
+ _baselineStates[t] = t.IsEnabled;
+ RecomputePendingChanges();
+
+ StatusMessage = $"Applied {changed.Count} change{(changed.Count == 1 ? "" : "s")}.";
+ Log.Information("Privacy: applied {Count} pending changes", changed.Count);
}
[RelayCommand]
- private void ResetAll()
+ private void DiscardChanges()
{
- _suppressApply = true;
- try
- {
- foreach (var toggle in Toggles)
- toggle.IsEnabled = false;
- }
- finally
- {
- _suppressApply = false;
- }
-
- // Write defaults to registry in batch
- _service.ApplyAll(Toggles);
- UpdateStatus();
- StatusMessage = "All toggles reset to Windows defaults.";
- Log.Information("Privacy: reset all toggles to defaults");
+ foreach (var t in Toggles)
+ if (_baselineStates.TryGetValue(t, out var baseline))
+ t.IsEnabled = baseline;
+ RecomputePendingChanges();
+ StatusMessage = "Pending changes discarded.";
}
[RelayCommand]
@@ -135,7 +149,10 @@ private void Refresh()
private void UpdateStatus()
{
var enabledCount = Toggles.Count(t => t.IsEnabled);
- StatusMessage = $"{enabledCount} of {Toggles.Count} privacy protections active.";
+ var summary = $"{enabledCount} of {Toggles.Count} privacy protections active.";
+ if (PendingChangeCount > 0)
+ summary += $" {PendingChangeCount} pending change{(PendingChangeCount == 1 ? "" : "s")} — press Apply.";
+ StatusMessage = summary;
}
protected override void Dispose(bool disposing)
diff --git a/SysManager/SysManager/Views/PrivacyView.xaml b/SysManager/SysManager/Views/PrivacyView.xaml
index d97c4fb..d0c0e73 100644
--- a/SysManager/SysManager/Views/PrivacyView.xaml
+++ b/SysManager/SysManager/Views/PrivacyView.xaml
@@ -59,12 +59,17 @@
-
-
+
+
+ Style="{StaticResource GhostButton}" Margin="0,0,16,0"
+ ToolTip="Re-read all toggle states from the registry."/>
diff --git a/SysManager/SysManager/Views/ThemePopup.xaml.cs b/SysManager/SysManager/Views/ThemePopup.xaml.cs
index 2416c05..ab307f8 100644
--- a/SysManager/SysManager/Views/ThemePopup.xaml.cs
+++ b/SysManager/SysManager/Views/ThemePopup.xaml.cs
@@ -212,6 +212,9 @@ private void ApplyCustomFromInputs()
ThemeService.Instance.SetCustom(accent, bg, surface, text);
}
- catch { }
+ catch (FormatException ex)
+ {
+ Serilog.Log.Debug(ex, "Theme custom hex parse failed");
+ }
}
}