diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b02cfd6..2722093b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,23 @@ That paragraph is not decoration: the release workflow copies each entry verbati the GitHub release body and the announcement discussion, so it is the first thing a prospective user reads. CI fails a pull request whose newest entry is missing it. +## [1.130.50] - 2026-10-21 + +**Settings Watchdog and Camera/Mic/Location look again each time you open them, so a link that sends you to a +changed setting shows it, and "In use now" means now.** + +### Fixed + +- **Settings Watchdog and Camera/Mic/Location showed what they read the first time.** Both read when the tab was first + built and then only on Refresh, and SysManager can stay in the notification area for days. Undo Changes and Recent + Changes send you to Settings Watchdog because a watched setting changed, and the page still said "All watched + settings match your baseline." with Restore off. Camera/Mic/Location kept saying "In use now" for an app that had let + go of the camera long ago. Both look again each time you open them. Since 1.32.0 for Camera/Mic/Location and + 1.48.0 for Settings Watchdog. +- **Settings Watchdog's CSV said every change could be restored.** Its "Can restore" column said "yes" for a setting + that was not set when the baseline was saved, which Restore does not put back. It says "no" for those now. Since + 1.123.0. + ## [1.130.49] - 2026-10-21 **Edge/OneDrive Remover says that Edge will show "Your browser is managed by your organization" while it is diff --git a/README.md b/README.md index 0a6f45dc..6d52b4cb 100644 --- a/README.md +++ b/README.md @@ -1164,7 +1164,8 @@ Answers "what is actually using my space?" by listing the biggest files in one p - Shows which apps recently used your **camera, microphone, or location**, and when - Reads the Windows access history (CapabilityAccessManager consent store) — covers both Store apps and desktop programs -- Devices **in use right now** are flagged and sorted to the top +- Devices **in use right now** are flagged and sorted to the top, and the tab reads the history + again each time you open it, so "In use now" means now - If Windows does not let SysManager read a device's history, the tab names that device instead of listing the others as if it had been checked, and a Refresh that reads nothing at all keeps the previous list on screen @@ -1189,10 +1190,12 @@ Answers "what is actually using my space?" by listing the biggest files in one p aside - **Check now** re-reads the live values and lists any drift in plain language, one row per setting with its category and **Was** / **Now** columns — e.g. *Diagnostic data (telemetry): - Was "Off (Security)", Now "Full"* + Was "Off (Security)", Now "Full"*. The tab also checks each time you open it, so following a + link from Undo Changes or Recent Changes shows the change that sent you here - **Export CSV** saves the list of changes before you restore them. Restore overwrites the "now" column, so this file is the only record of what Windows changed — which is why the - button sits to the left of Restore + button sits to the left of Restore. Its "Can restore" column says what Restore will do: "no" + for a setting that was not set when you saved the baseline - **Restore changed** writes the drifted settings back to your baseline values in one step, after one confirmation. Telemetry, activity history and Widgets are machine-wide policies and need administrator rights, surfaced not crashed. A setting that was not set at all diff --git a/SysManager/SysManager.Tests/CsvExportTests.cs b/SysManager/SysManager.Tests/CsvExportTests.cs index cbee9a36..a786cb0b 100644 --- a/SysManager/SysManager.Tests/CsvExportTests.cs +++ b/SysManager/SysManager.Tests/CsvExportTests.cs @@ -159,6 +159,17 @@ public void DriftToCsv_RecordsWhetherEachRowCanBeRestored() Assert.EndsWith(",no\r\n", csv, StringComparison.Ordinal); } + [Fact] + public void DriftToCsv_ASettingThatWasNotSetInTheBaseline_CannotBeRestored() + { + // Every catalog entry can be restored in principle, and Restore leaves this one alone, because putting + // "Not set" back would delete a value and the watchdog never deletes one. The column said "yes" (#2837). + var csv = SettingsWatchdogService.ToCsv([ + new SettingDrift(TelemetrySetting(), BaselineValue: null, CurrentValue: 3)], Invariant); + + Assert.EndsWith(",no\r\n", csv, StringComparison.Ordinal); + } + [Fact] public void DriftToCsv_WithNoDrifts_IsHeaderOnly() { diff --git a/SysManager/SysManager.Tests/PrivacyMonitorViewModelTests.cs b/SysManager/SysManager.Tests/PrivacyMonitorViewModelTests.cs index 925bee63..75f3fee2 100644 --- a/SysManager/SysManager.Tests/PrivacyMonitorViewModelTests.cs +++ b/SysManager/SysManager.Tests/PrivacyMonitorViewModelTests.cs @@ -120,4 +120,54 @@ public async Task AReadThatWorksAfterAFailedOne_ClearsTheFailure() Assert.Equal("No access recorded", vm.EmptyTitle); Assert.Equal("No camera, microphone, or location access has been recorded yet.", vm.StatusMessage); } + + // ── read again when shown (#2837) ── + + [Fact] + public async Task ShowingTheTabAgain_ReadsAgain_SoACameraLetGoOfNoLongerReadsInUse() + { + // The tab read once, when it was built, and a later visit still said "In use now" for an app that had closed. + var camInUse = Cam with { InUse = true }; + var reads = 0; + var vm = await SettledVm(() => ++reads == 1 ? Report([camInUse]) : Report([Cam])); + Assert.True(Assert.Single(vm.Entries).InUse); + + vm.IsActive = true; + await vm.ShownRefresh; + + Assert.Equal(2, reads); + Assert.False(Assert.Single(vm.Entries).InUse); + Assert.DoesNotContain("in use right now", vm.StatusMessage, StringComparison.Ordinal); + } + + [Fact] + public async Task LeavingTheTab_ReadsNothing() + { + var reads = 0; + var vm = await SettledVm(() => { reads++; return Report([Cam]); }); + + vm.IsActive = true; + await vm.ShownRefresh; + vm.IsActive = false; + await vm.ShownRefresh; + + Assert.Equal(2, reads); + } + + [Fact] + public async Task ShowingTheTab_WhileARefreshReads_StartsNoSecondRead() + { + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var reads = 0; + var vm = new PrivacyMonitorViewModel(_ => ++reads == 1 ? Task.FromResult(Report([Cam])) : held.Task); + await vm.InitializationComplete; + var refresh = vm.RefreshCommand.ExecuteAsync(null); + + vm.IsActive = true; + held.SetResult(Report([Cam])); + await refresh; + await vm.ShownRefresh; + + Assert.Equal(2, reads); + } } diff --git a/SysManager/SysManager.Tests/SettingsWatchdogViewModelTests.cs b/SysManager/SysManager.Tests/SettingsWatchdogViewModelTests.cs index ec81aaa7..877a2b6e 100644 --- a/SysManager/SysManager.Tests/SettingsWatchdogViewModelTests.cs +++ b/SysManager/SysManager.Tests/SettingsWatchdogViewModelTests.cs @@ -557,4 +557,48 @@ public async Task Refresh_WhenNothingMoved_MarksNoDrift() Assert.Empty(vm.Drifts); Assert.False(vm.HasDrift); } + + // ── look again when shown (#2837) ── + + [Fact] + public void ShowingTheTabAgain_LooksAgain_SoASettingWindowsResetSinceIsListed() + { + // Undo Changes and Recent Changes send the user here because a watched setting changed, and the page still + // showed the look from before: "All watched settings match your baseline.", with Restore off. + var svc = NewService(); + var vm = new SettingsWatchdogViewModel(svc); + Assert.False(vm.HasDrift); + + var drift = Drift("ads"); + svc.DetectDrift(Arg.Any(), Arg.Any>()).Returns([drift]); + vm.IsActive = true; + + Assert.True(vm.HasDrift); + Assert.Equal(drift, Assert.Single(vm.Drifts).Drift); + Assert.True(vm.RestoreSelectedCommand.CanExecute(null)); + Assert.Equal("1 setting(s) changed since your baseline.", vm.StatusMessage); + } + + [Fact] + public void LeavingTheTab_LooksAtNothing() + { + var svc = NewService(); + var vm = new SettingsWatchdogViewModel(svc); + vm.IsActive = true; + svc.ClearReceivedCalls(); + + vm.IsActive = false; + + svc.DidNotReceive().ReadCurrent(); + } + + [Fact] + public void ADriftWithNoValueInTheBaseline_IsNotOfferedForRestore_OnItsRow() + { + // Restore never puts it back, because the watchdog never deletes a value (#2594). + var notSet = new SettingDrift(Setting("ads"), BaselineValue: null, CurrentValue: 1, CanRestore: true); + var vm = new SettingsWatchdogViewModel(NewService(notSet)); + + Assert.False(Assert.Single(vm.Drifts).CanRestore); + } } diff --git a/SysManager/SysManager/Services/SettingsWatchdogService.cs b/SysManager/SysManager/Services/SettingsWatchdogService.cs index d9590b28..35256be2 100644 --- a/SysManager/SysManager/Services/SettingsWatchdogService.cs +++ b/SysManager/SysManager/Services/SettingsWatchdogService.cs @@ -416,7 +416,9 @@ public static string ToCsv(IEnumerable drifts, CultureInfo culture d.Setting.Description, d.BaselineLabel, d.CurrentLabel, - d.CanRestore ? "yes" : "no"); + // What Restore will do. CanRestore is true for every catalog entry, and a drift the baseline holds no + // value for is never put back, because the watchdog never deletes a value (#2594, #2837). + d.CanWriteBack ? "yes" : "no"); } return sb.ToString(); } diff --git a/SysManager/SysManager/SysManager.csproj b/SysManager/SysManager/SysManager.csproj index 1181e9fb..96a52b37 100644 --- a/SysManager/SysManager/SysManager.csproj +++ b/SysManager/SysManager/SysManager.csproj @@ -13,9 +13,9 @@ $(NoWarn);NU1603;NU1701 false - 1.130.49 - 1.130.49.0 - 1.130.49.0 + 1.130.50 + 1.130.50.0 + 1.130.50.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/MainWindowViewModel.cs b/SysManager/SysManager/ViewModels/MainWindowViewModel.cs index 1551a7ec..bf128520 100644 --- a/SysManager/SysManager/ViewModels/MainWindowViewModel.cs +++ b/SysManager/SysManager/ViewModels/MainWindowViewModel.cs @@ -644,6 +644,8 @@ internal static void SetActive(object content, bool active) case PrivacyViewModel pv: pv.IsActive = active; break; case UndoChangesViewModel uc: uc.IsActive = active; break; case RecentChangesViewModel rc: rc.IsActive = active; break; + case SettingsWatchdogViewModel sw: sw.IsActive = active; break; + case PrivacyMonitorViewModel mo: mo.IsActive = active; break; } } diff --git a/SysManager/SysManager/ViewModels/PrivacyMonitorViewModel.cs b/SysManager/SysManager/ViewModels/PrivacyMonitorViewModel.cs index efc72a19..2d86f179 100644 --- a/SysManager/SysManager/ViewModels/PrivacyMonitorViewModel.cs +++ b/SysManager/SysManager/ViewModels/PrivacyMonitorViewModel.cs @@ -37,6 +37,20 @@ public sealed partial class PrivacyMonitorViewModel : ViewModelBase [ObservableProperty] private string _emptyTitle = "No access recorded"; [ObservableProperty] private string _emptyMessage = "Windows hasn't logged any camera, microphone, or location access yet."; + /// + /// True while the tab is on screen. Set by ; becoming visible reads + /// again. + /// + /// + /// The tab read the history when it was built and then only on Refresh, so a later visit still said "In use now" + /// for an app that had let go of the camera long ago, on the tab someone opens to ask what is watching them right + /// now (#2837). + /// + [ObservableProperty] private bool _isActive; + + /// The read the tab started when it was last shown. Internal so a test can await it. + internal Task ShownRefresh { get; private set; } = Task.CompletedTask; + public PrivacyMonitorViewModel(PrivacyMonitorService service) : this(service.ReadAsync) { } /// Test seam: the consent-store read, so a failed one can be supplied without the registry. @@ -61,6 +75,12 @@ private void OnVmPropertyChanged(object? sender, System.ComponentModel.PropertyC if (e.PropertyName is nameof(IsBusy)) RefreshCommand.NotifyCanExecuteChanged(); } + partial void OnIsActiveChanged(bool value) + { + // Not while the first read is running, which is the fresh one, nor beside a Refresh still reading. + if (value && InitializationComplete.IsCompleted && NotBusy) ShownRefresh = RefreshAsync(); + } + [RelayCommand(CanExecute = nameof(NotBusy))] private async Task RefreshAsync() { diff --git a/SysManager/SysManager/ViewModels/SettingsWatchdogViewModel.cs b/SysManager/SysManager/ViewModels/SettingsWatchdogViewModel.cs index 4df48bc5..1210f62e 100644 --- a/SysManager/SysManager/ViewModels/SettingsWatchdogViewModel.cs +++ b/SysManager/SysManager/ViewModels/SettingsWatchdogViewModel.cs @@ -48,6 +48,17 @@ public sealed partial class SettingsWatchdogViewModel : ViewModelBase [ObservableProperty] private bool _hasDrift; [ObservableProperty] private bool _isElevated; + /// + /// True while the tab is on screen. Set by ; becoming visible looks + /// again. + /// + /// + /// The tab read the settings when it was built and then only on Refresh, and SysManager can stay in the + /// notification area for days. Undo Changes and Recent Changes send the user here because a watched setting + /// changed, and the page still showed the look from before it did (#2837). + /// + [ObservableProperty] private bool _isActive; + // True when a baseline file is there and could not be read or used. It is not "no baseline yet": saving // over it without asking replaced a baseline the user could not see (#2521). private bool _baselineUnusable; @@ -59,6 +70,13 @@ public SettingsWatchdogViewModel(ISettingsWatchdogService service) InitializeAsync(() => { Refresh(); return System.Threading.Tasks.Task.CompletedTask; }); } + partial void OnIsActiveChanged(bool value) + { + // Not while the first look is still running: the tab is shown the moment it is built, and that look is the + // fresh one. + if (value && InitializationComplete.IsCompleted) Refresh(); + } + [RelayCommand] private void RelaunchAsAdmin() { @@ -248,7 +266,9 @@ public sealed partial class DriftRow(SettingDrift drift) : ObservableObject public string Description => Drift.Setting.Description; public string BaselineLabel => Drift.BaselineLabel; public string CurrentLabel => Drift.CurrentLabel; - public bool CanRestore => Drift.CanRestore; + + // What Restore will do: a drift the baseline holds no value for is never put back (#2594). + public bool CanRestore => Drift.CanWriteBack; } ///