diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 75a77055..4713e7a0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -281,7 +281,8 @@ to scroll. a scale from 70% (or lower) to just above 100%, so a small loss is not drawn as a cliff. The same paint lifecycle as `SpeedTrendChart`. - `UninstallerViewModel` — winget-based app uninstaller with batch support, and the Left behind card - that follows an uninstall. + that follows an uninstall. Cancel and Escape stop a batch before its next app: the uninstaller already running + has a token of its own, which only `Dispose` cancels (#2834). - `PerformanceViewModel` — per-tweak performance tuning with snapshot restore. Refresh is off while `IsBusy`, and a read leaves `NeedsReboot` alone, so a graphics change's reboot notice lasts the session (#2607). - `PingViewModel` — live ping monitoring with latency chart and health verdict. @@ -370,7 +371,7 @@ to scroll. `LogsArrival.AppProblems` of the period chosen (#2887). Read-only. - `RestorePointsViewModel` — list, create, and restore Windows System Restore points (admin for all three, since Windows refuses a standard user the list, which the empty state says rather than reporting none; restore reboots, gated by confirmation). - `LegacyPanelsViewModel` — one-click launcher for the fixed catalog of classic Windows applets (pure launchers, no system modification). -- `SystemFixesViewModel` — consolidated one-click repairs (SFC, DISM `/RestoreHealth`, Windows Update reset, WinGet reinstall, Restart Explorer, Rebuild icon & thumbnail cache) with per-fix confirmation + live output; opens netplwiz for secure auto-logon. The network-stack resets live on Network Repair. +- `SystemFixesViewModel` — consolidated one-click repairs (SFC, DISM `/RestoreHealth`, Windows Update reset, WinGet reinstall, Restart Explorer, Rebuild icon & thumbnail cache) with per-fix confirmation + live output; opens netplwiz for secure auto-logon. The network-stack resets live on Network Repair. It overrides no `EscapeCancel`: every run changes Windows, so Escape stops none of them and the Cancel button does (#2834). - `BootAnalyzerViewModel` — read-only boot-time history + slow-component breakdown from the Diagnostics-Performance log, with a trend vs recent average; needs admin to read the log. - `TimerResolutionViewModel` — request the finest Windows timer resolution (≈0.5 ms) for lower game input latency, or release it; shows the live effective value. - `FileLockViewModel` — find which processes are holding a file/folder (Restart Manager) and optionally end a selected one after confirmation; critical processes are protected. A failed check and a path that does not exist are reported as such, never as "no process". End process passes the locker's start time with its ID, so a locker that closed and whose ID Windows gave to another program is reported as already closed rather than that program ended (#2514); an ended or closed locker is followed by a fresh check, with the outcome put in front of its result. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8269f261..fe76de04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ 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.52] - 2026-10-21 + +**Escape no longer stops an uninstall or a System Fixes repair half-way, and Uninstaller's Cancel stops a batch +before its next app instead of ending the uninstaller that is running.** + +### Fixed + +- **Uninstaller's Cancel, and Escape, ended the running uninstaller half-way.** That can leave a program half-removed. + They now stop the batch before its next app, once the uninstaller already running has finished, and the status line + says so. Closing SysManager still ends it, after asking. Since 0.11.0 for Cancel and 1.76.0 for Escape. +- **Escape stopped a System Fixes repair half-way.** SFC, DISM and the scripted repairs were ended by a bare + keypress. Escape stops nothing on System Fixes now; its Cancel button still stops a repair. Since 1.76.0. + ## [1.130.51] - 2026-10-21 **Windows Features says why Windows refused a change, shows each feature the way Windows reports it afterwards, and diff --git a/README.md b/README.md index e5eafeab..bf5a1f24 100644 --- a/README.md +++ b/README.md @@ -231,8 +231,11 @@ on, the way it opens a folder in File Explorer. Opening the appearance panel mov cycles the themes inside it, and `Escape` closes it and puts focus back on the button you opened it from. `Escape` also stops whatever the open tab is doing — a disk scan, a duplicate search, a speed test — on -the 26 tabs that can be cancelled from the keyboard: every tab with a Cancel button, and Display Profiles, where -it puts back the previous mode while a new one waits for "Keep". It only acts +the 25 tabs that can be cancelled from the keyboard: every tab with a Cancel button but System Fixes, and Display +Profiles, where it puts back the previous mode while a new one waits for "Keep". Every run on System Fixes repairs +Windows, so stopping one is left to its Cancel button rather than a keypress. On Windows Features `Escape` stops a +scan, never a change, and on Uninstaller it stops a batch before its next app, once the uninstaller already running +has finished. It only acts while something is running, and only after the control you are on has had its own chance to use the key, so it still closes a drop-down or undoes a text edit first. @@ -688,6 +691,7 @@ not need administrator rights to open it. Across the tab: - Live output, honest success/failure reporting, admin elevation banner +- The Cancel button stops a repair; `Escape` does not, because every run here changes Windows - *Network-stack reset (Winsock / TCP-IP / DNS flush) lives on the Network → Network Repair tab, which offers those as individual one-click tools.* - *Freeing up disk space lives on Cleanup → Quick Cleanup. The two tabs are split by what @@ -1755,6 +1759,8 @@ offers, "rate us" prompts: to a second copy of themselves and return straight away; those apps stay on the list, marked as still installed, until the uninstaller finishes and you scan again - Runs uninstall actions only from an unelevated SysManager session; each package requests its own UAC elevation when required +- **Cancel stops a batch between apps** — the uninstaller already running finishes first, because ending one + half-way can leave its program half-removed. `Escape` does the same - **What it left behind** — after an uninstall, a Left behind card lists what the app left on the PC: the folder it was installed in, folders named after it in AppData, Local AppData and ProgramData, its own settings key under `HKEY_CURRENT_USER\Software`, and a folder named after diff --git a/SysManager/SysManager.Tests/ArchitectureTests.cs b/SysManager/SysManager.Tests/ArchitectureTests.cs index c06b4c1a..666f265e 100644 --- a/SysManager/SysManager.Tests/ArchitectureTests.cs +++ b/SysManager/SysManager.Tests/ArchitectureTests.cs @@ -4039,6 +4039,11 @@ public void EveryPersistedStateFile_IsEitherInAProfileOrDeliberatelyNot() /// skipped nine tabs and Escape did nothing on them (#2597). For such a button the override must name its /// command and return null when nothing runs, gated on the flag the command's own /// [RelayCommand(CanExecute = …)] names where it names one. + /// A tab on which every run changes Windows declines Escape, by name below and with the reason. + /// Escape is a bare keypress, and on System Fixes it stopped SFC, DISM and the scripted repairs half-way (#2834). + /// Its Cancel button stays, for a stop the user means, and its view model overrides no EscapeCancel, or one + /// that returns null outright. A name whose view has no Cancel button any more fails, so the list cannot go + /// stale. /// [Fact] public void EveryCancellableTab_LetsEscapeReachItsOwnCancelCommand() @@ -4047,6 +4052,13 @@ public void EveryCancellableTab_LetsEscapeReachItsOwnCancelCommand() var presentation = XNamespace.Get("http://schemas.microsoft.com/winfx/2006/xaml/presentation"); var binding = new Regex(@"^\{Binding\s+(?\w+)", RegexOptions.CultureInvariant); + // Views on which every run changes Windows, so Escape stops none of them. + var declinesEscape = new Dictionary(StringComparer.Ordinal) + { + ["SystemFixesView"] = "every repair on it changes Windows; Escape stopped SFC and DISM half-way (#2834)", + }; + var declined = new HashSet(StringComparer.Ordinal); + var offenders = new List(); var pairs = 0; var alwaysShown = 0; @@ -4085,6 +4097,18 @@ public void EveryCancellableTab_LetsEscapeReachItsOwnCancelCommand() .Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal))); var overrideAt = vm.IndexOf("override IRelayCommand? EscapeCancel", StringComparison.Ordinal); + if (declinesEscape.ContainsKey(viewName)) + { + declined.Add(viewName); + // No override, or one that returns null outright: either way Escape reaches nothing here. + var declining = overrideAt < 0 ? "EscapeCancel=>null" : string.Concat( + vm[overrideAt..vm.IndexOf(';', overrideAt)].Where(c => !char.IsWhiteSpace(c))); + if (!declining.EndsWith("EscapeCancel=>null", StringComparison.Ordinal)) + offenders.Add($"{viewName}Model is listed as declining Escape, and its EscapeCancel returns " + + "a command"); + continue; + } + if (overrideAt < 0) { offenders.Add($"{viewName}Model has no EscapeCancel override, so Escape does nothing " @@ -4123,6 +4147,10 @@ public void EveryCancellableTab_LetsEscapeReachItsOwnCancelCommand() } } + foreach (var stale in declinesEscape.Keys.Where(k => !declined.Contains(k))) + offenders.Add($"{stale} is listed as declining Escape but has no Cancel button any more; " + + "take it off the list"); + // Vacuity floors: seventeen Cancel buttons carry a visibility flag today and eleven are on screen all the // time. A parse that stopped finding either kind would report success having checked nothing. Assert.True(pairs >= 12, diff --git a/SysManager/SysManager.Tests/SystemFixesViewModelTests.cs b/SysManager/SysManager.Tests/SystemFixesViewModelTests.cs index 6b71b334..281b4973 100644 --- a/SysManager/SysManager.Tests/SystemFixesViewModelTests.cs +++ b/SysManager/SysManager.Tests/SystemFixesViewModelTests.cs @@ -299,6 +299,40 @@ await runner.Received(1).RunProcessAsync( "sfc.exe", "/scannow", Arg.Any(), Arg.Any()); } + [Fact] + public async Task Escape_StopsNoRepair_WhileTheCancelButtonStillDoes() + { + // Escape ended SFC half-way from a bare keypress (#2834). The Cancel button is the way to stop a repair. + using var elevated = AdminHelper.ForceElevation(true); + using var dialog = new DialogAnswer(confirm: true); + var running = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationToken sfcToken = default; + var runner = Substitute.For(); + runner.RunProcessAsync("sfc.exe", "/scannow", Arg.Any(), Arg.Any()) + .Returns(call => + { + sfcToken = call.ArgAt(2); + sfcToken.Register(() => running.TrySetCanceled(sfcToken)); + return running.Task; + }); + var vm = NewVm(runner); + + var sfc = vm.RunSfcCommand.ExecuteAsync(null); + try + { + Assert.True(vm.IsBusy); + Assert.Null(vm.EscapeCancel); + + vm.CancelCommand.Execute(null); + Assert.True(sfcToken.IsCancellationRequested); + } + finally + { + running.TrySetResult(0); + await sfc; + } + } + [Fact] public async Task RunSfc_ReadsSfcsOwnOutput_ForItsProgressAndItsVerdict() { diff --git a/SysManager/SysManager.Tests/UninstallerViewModelTests.cs b/SysManager/SysManager.Tests/UninstallerViewModelTests.cs index 6c1c2264..ae240aa9 100644 --- a/SysManager/SysManager.Tests/UninstallerViewModelTests.cs +++ b/SysManager/SysManager.Tests/UninstallerViewModelTests.cs @@ -379,12 +379,15 @@ public async Task UninstallSelected_WhenRestartIsRequired_ReportsSuccessfulRemov } [Fact] - public async Task UninstallSelected_WhenCancelled_StopsBatchAndReportsPartialProgress() + public async Task UninstallSelected_WhenCancelled_LetsTheRunningUninstallerFinish_ThenStopsBeforeTheNextApp() { + // Cancel, and Escape with it, ended the running uninstaller part-way, which can leave its program half-removed + // (#2834). The second of three is running when Cancel is pressed: it finishes, and the third never starts. var started = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var pending = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); + CancellationToken runningToken = default; var callCount = 0; var runner = Substitute.For(); runner.RunProcessAsync( @@ -394,16 +397,15 @@ public async Task UninstallSelected_WhenCancelled_StopsBatchAndReportsPartialPro Arg.Any()) .Returns(callInfo => { - if (Interlocked.Increment(ref callCount) == 1) + if (Interlocked.Increment(ref callCount) != 2) return Task.FromResult(0); - var token = callInfo.ArgAt(2); - token.Register(() => pending.TrySetCanceled(token)); + runningToken = callInfo.ArgAt(2); started.TrySetResult(true); return pending.Task; }); var vm = NewVm(runner); - Seed(vm, 2); + Seed(vm, 3); foreach (var app in vm.FilteredApps) { app.Source = "winget"; @@ -414,17 +416,22 @@ public async Task UninstallSelected_WhenCancelled_StopsBatchAndReportsPartialPro var dialog = Substitute.For(); dialog.Confirm(Arg.Any(), Arg.Any()).Returns(true); DialogService.Instance = dialog; + var execution = Task.CompletedTask; try { - var execution = vm.UninstallSelectedCommand.ExecuteAsync(null); + execution = vm.UninstallSelectedCommand.ExecuteAsync(null); await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); vm.CancelCommand.Execute(null); - await execution; - Assert.Equal(50, vm.Progress); - Assert.Contains("cancelled after 1/2 completed", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); - Assert.Single(vm.FilteredApps); - Assert.Equal("Cancelled", vm.FilteredApps[0].Status); + Assert.False(runningToken.IsCancellationRequested); + Assert.Equal("Stopping after app001. Its uninstaller finishes first, so it is not left half-removed.", + vm.StatusMessage); + + pending.SetResult(0); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Contains("cancelled after 2/3 completed", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + Assert.Equal("app002", Assert.Single(vm.FilteredApps).Name); await runner.Received(2).RunProcessAsync( "winget", Arg.Any(), @@ -433,6 +440,57 @@ await runner.Received(2).RunProcessAsync( } finally { + // Ended either way: a batch left running holds the install lock every later uninstall in this class needs. + pending.TrySetResult(0); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); + DialogService.Instance = previousDialog; + } + } + + [Fact] + public async Task Dispose_WhileAnUninstallerRuns_StillEndsIt() + { + // Closing SysManager asks first while the uninstall holds its lock, and then ends what is running, as before. + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationToken runningToken = default; + var runner = Substitute.For(); + runner.RunProcessAsync( + "winget", + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(callInfo => + { + runningToken = callInfo.ArgAt(2); + runningToken.Register(() => pending.TrySetCanceled(runningToken)); + started.TrySetResult(true); + return pending.Task; + }); + var vm = NewVm(runner); + Seed(vm, 1); + vm.FilteredApps[0].Source = "winget"; + vm.FilteredApps[0].IsSelected = true; + + var previousDialog = DialogService.Instance; + var dialog = Substitute.For(); + dialog.Confirm(Arg.Any(), Arg.Any()).Returns(true); + DialogService.Instance = dialog; + var execution = Task.CompletedTask; + try + { + execution = vm.UninstallSelectedCommand.ExecuteAsync(null); + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + vm.Dispose(); + + Assert.True(runningToken.IsCancellationRequested); + } + finally + { + // Ended either way: a batch left running holds the install lock every later uninstall in this class needs. + pending.TrySetResult(0); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); DialogService.Instance = previousDialog; } } diff --git a/SysManager/SysManager/SysManager.csproj b/SysManager/SysManager/SysManager.csproj index 4d95aa25..3f1b867b 100644 --- a/SysManager/SysManager/SysManager.csproj +++ b/SysManager/SysManager/SysManager.csproj @@ -13,9 +13,9 @@ $(NoWarn);NU1603;NU1701 false - 1.130.51 - 1.130.51.0 - 1.130.51.0 + 1.130.52 + 1.130.52.0 + 1.130.52.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/SystemFixesViewModel.cs b/SysManager/SysManager/ViewModels/SystemFixesViewModel.cs index 8465305f..7342eed7 100644 --- a/SysManager/SysManager/ViewModels/SystemFixesViewModel.cs +++ b/SysManager/SysManager/ViewModels/SystemFixesViewModel.cs @@ -35,9 +35,9 @@ namespace SysManager.ViewModels; /// public sealed partial class SystemFixesViewModel : ViewModelBase { - /// - protected internal override IRelayCommand? EscapeCancel => - IsBusy ? CancelCommand : null; + // Escape stops nothing here, so there is no EscapeCancel: every run on this tab repairs or changes Windows, and + // Escape is a bare keypress. It stopped SFC, DISM and the scripted repairs half-way (#2834). The Cancel button + // still stops one, for a stop that is meant. private readonly SystemFixService _service; diff --git a/SysManager/SysManager/ViewModels/UninstallerViewModel.cs b/SysManager/SysManager/ViewModels/UninstallerViewModel.cs index d73c0d70..1df106e3 100644 --- a/SysManager/SysManager/ViewModels/UninstallerViewModel.cs +++ b/SysManager/SysManager/ViewModels/UninstallerViewModel.cs @@ -29,6 +29,12 @@ public sealed partial class UninstallerViewModel : ViewModelBase private readonly EtaCalculator _uninstallEta = new(); private CancellationTokenSource? _cts; + // The uninstaller running now has a token of its own, which only closing SysManager cancels. Cancel and Escape + // stop a batch before its next app instead: ending an uninstaller half-way can leave its program half-removed, and + // Escape did that from a bare keypress (#2834). + private CancellationTokenSource? _uninstallerCts; + private InstalledApp? _uninstalling; + public BulkObservableCollection AllApps { get; } = new(); public BulkObservableCollection FilteredApps { get; } = new(); @@ -209,6 +215,8 @@ private async Task UninstallSelectedAsync() IsBusy = true; _cts?.Dispose(); _cts = new CancellationTokenSource(); + _uninstallerCts?.Dispose(); + _uninstallerCts = new CancellationTokenSource(); int completed = 0; int removed = 0; int failed = 0; @@ -238,13 +246,14 @@ private async Task UninstallSelectedAsync() UninstallEtaText = _uninstallEta.Update(Progress); var currentCompleted = false; + _uninstalling = app; try { var local = string.IsNullOrWhiteSpace(app.Source) && !string.IsNullOrWhiteSpace(app.UninstallString); var code = local - ? await _service.UninstallLocalAsync(app, _cts.Token) - : await _service.UninstallAsync(app.Id, _cts.Token); + ? await _service.UninstallLocalAsync(app, _uninstallerCts.Token) + : await _service.UninstallAsync(app.Id, _uninstallerCts.Token); currentCompleted = true; if (IsSuccessfulUninstallExitCode(code) && _service.IsStillRegistered(app)) @@ -284,7 +293,7 @@ private async Task UninstallSelectedAsync() { app.Status = $"Error: {ex.Message}"; failed++; - currentCompleted = !_cts.IsCancellationRequested; + currentCompleted = !_uninstallerCts.IsCancellationRequested; } // A failed uninstaller launch (missing/blocked exe) must not abort the // whole batch - record it on the row and continue with the next app. @@ -308,6 +317,10 @@ private async Task UninstallSelectedAsync() failed++; currentCompleted = true; } + finally + { + _uninstalling = null; + } if (currentCompleted) completed++; @@ -630,8 +643,15 @@ internal static string DescribeRemoval(IReadOnlyList ticked) return $"{ConfirmRemovalQuestion(ticked)}\n\n{lines}\n\n{string.Join(" ", new[] { folders, keys }.Where(s => s.Length > 0))}"; } + /// Stops a scan, or a batch before its next app: the uninstaller running now finishes. [RelayCommand] - private void Cancel() => _cts?.Cancel(); + private void Cancel() + { + _cts?.Cancel(); + if (_uninstalling is { } app) + StatusMessage = $"Stopping after {app.Name}. Its uninstaller finishes first, so it is not left " + + "half-removed."; + } protected override void Dispose(bool disposing) { @@ -640,6 +660,8 @@ protected override void Dispose(bool disposing) PropertyChanged -= OnVmPropertyChanged; _cts?.Cancel(); _cts?.Dispose(); + _uninstallerCts?.Cancel(); + _uninstallerCts?.Dispose(); } base.Dispose(disposing); }