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 @@ -