diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c03f338b..d89d74ad 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -238,13 +238,15 @@ to scroll. Once it has loaded it asks about a game mode session a previous run left on, through the optional `IGamingProfileService` and `GamingProfileViewModel.OfferToRevertLeftoverAsync`, and shows the outcome as a toast (#2592). -- `AppUpdatesViewModel` — winget scan and bulk upgrade. +- `AppUpdatesViewModel` — winget scan and bulk upgrade. Cancel and Escape stop a batch before its next app: the + running winget has a token of its own, which only `Dispose` cancels (#2942). - `WindowsUpdateViewModel` — user-triggered Windows Update scan/install via the WUA COM API. - `SystemHealthViewModel` — SMART, memory diagnostic, multi-drive chkdsk, and the BIOS and board details with a link to the maker's support site. The tab is busy while a read or chkdsk runs, so a read that ends beside a chkdsk leaves it busy, and chkdsk takes the `Disk` lock, so it never runs beside a clean-up and closing SysManager asks first (#2876). -- `CleanupViewModel` — TEMP, Recycle Bin, component store (background-aware). +- `CleanupViewModel` — TEMP, Recycle Bin, component store (background-aware). Escape stops everything but a component + store cleanup, which only the Cancel button stops (#2942). - `DeepCleanupViewModel` — scan-first deep cleanup over the safe-to-remove category list. Clean is off while a scan runs, and the service is handed the categories the confirmation listed rather than the list on screen, which a scan finishing behind the modal confirmation could replace (#2702). @@ -325,7 +327,7 @@ to scroll. - `ShortcutCleanerViewModel` — scans and removes broken desktop/Start Menu shortcuts. - `AppBlockerViewModel` — block/unblock apps via IFEO (Image File Execution Options) registry mechanism. A block list that could not be read sets `ListFailed`; a failed refresh keeps the list and its warning. - `FileShredderViewModel` — secure multi-pass file overwrite and deletion. -- `BulkInstallerViewModel` — batch app installation via winget with progress tracking. Install Selected confirms first, because winget turns an install of an installed app into an upgrade. +- `BulkInstallerViewModel` — batch app installation via winget with progress tracking. Install Selected confirms first, because winget turns an install of an installed app into an upgrade. Cancel and Escape stop a batch before its next app: the running winget has a token of its own, which only `Dispose` cancels (#2942). A winget search marks the tab busy as an install does, and the install's Cancel, its bar and the Install Selected gate follow `IsInstalling` alone, so a search leaves them as they are (#2876). - `DnsHostsViewModel` — DNS server configuration and hosts file editor in one tab. @@ -1942,9 +1944,9 @@ Four keys are handled at the shell. Two of them ask the OPEN TAB what to do, thr stop. One property rather than a flag beside a command, because the app answers "is something running?" five different ways (`IsBusy` on most tabs, plus `IsShredding`, `IsScanning`, `IsHttpTesting`, `IsOoklaTesting`), so a shell testing `IsBusy` would skip - four tabs. 26 tabs override it: every tab with a Cancel button, whether the button hides - while nothing runs or stays on screen, and Display Profiles, whose Escape is Revert now while - a new mode waits to be kept. One that stays states no flag in the view, so its + four tabs. 25 tabs override it: every tab with a Cancel button but System Fixes, whether the + button hides while nothing runs or stays on screen, and Display Profiles, whose Escape is + Revert now while a new mode waits to be kept. One that stays states no flag in the view, so its override still returns null while nothing runs, on its Cancel command's `CanExecute` flag where it has one (#2597). - `RefreshOnF5` — the command F5 runs. A property per view model because the tabs do not diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f75c3f..17aaac2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,22 @@ 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.56] - 2026-10-22 + +**Escape no longer ends an app update, an install or a component store cleanup part-way, and Cancel stops an update or +install batch before its next app.** + +### Fixed + +- **Escape ended winget, and the installer it had started, in the middle of an update or an install.** On App Updates + and Bulk Installer, Escape ran Cancel, and Cancel ended the winget run, so a bare keypress could leave an app + half-installed. Cancel and Escape now stop a batch before its next app: the update or install already running + finishes first, and the status line says so. Only closing SysManager still ends one that is running. Since 1.76.0 + on Bulk Installer and 1.127.10 on App Updates. +- **Escape stopped a component store cleanup on Quick Cleanup part-way**, which leaves Windows to finish the servicing + transaction on its own. Escape now leaves the cleanup alone, and still stops the temporary-file clean-up, the + Recycle Bin and the analysis. The Cancel button still stops the cleanup, as its confirmation says. Since 1.127.10. + ## [1.130.55] - 2026-10-22 **System Report's list of problems from the last 7 days covers the whole week, so an older critical event is no diff --git a/README.md b/README.md index c1aa2eee..d287a7c9 100644 --- a/README.md +++ b/README.md @@ -235,8 +235,9 @@ cycles the themes inside it, and `Escape` closes it and puts focus back on the b 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 +scan, never a change. On Uninstaller, App Updates and Bulk Installer it stops a batch before its next app, once the +uninstaller, update or install already running has finished, and on Quick Cleanup it stops everything but a component +store cleanup, which only its Cancel button stops. 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. @@ -801,8 +802,9 @@ Across the tab: reachable, for example — the tab says it couldn't check for updates, and why, instead of reporting everything up to date - **Each result says what winget reported.** A download that did not match what winget - expected is named as that, not as "no update"; an update you cancelled reads "Cancelled", - and one that needs administrator says so + expected is named as that, not as "no update", and one that needs administrator says so +- **Cancel stops a batch between apps** — the update already running finishes first, because + ending winget half-way ends the installer it started. `Escape` does the same ### Quick Cleanup Freeing up disk space, and nothing else — the Windows repairs that used to sit here are on @@ -825,6 +827,10 @@ System → System Fixes, where the tab name matches what they do. a confirmation that states the cost — after a cleanup, updates already installed can no longer be uninstalled. `/ResetBase` is never used, and a test makes it impossible to add by accident. The component-store steps and the Windows TEMP folder need administrator rights +- **`Escape` never stops a component store cleanup.** Stopping it part-way leaves Windows to + finish the servicing transaction on its own, so only the Cancel button stops it, as the + confirmation before it says. `Escape` still stops the Temp clean-up, the Recycle Bin and the + analysis ### Deep Cleanup - **Scan-first**: every category is discovered with size + file count @@ -1519,6 +1525,8 @@ Answers "what is actually using my space?" by listing the biggest files in one p - Category filter and text search across the catalog, plus a button named for the chosen category, such as "Select Browsers", that ticks every app in it at once - Per-package install status tracking with ETA +- **Cancel stops a batch between apps** — the install already running finishes first, because + ending winget half-way ends the installer it started. `Escape` does the same - The catalog is grouped under category headings, and an app already on this PC carries an **Installed** badge - Some apps install system-wide and need administrator rights; the banner at the top says so diff --git a/SysManager/SysManager.Tests/AppUpdatesViewModelTests.cs b/SysManager/SysManager.Tests/AppUpdatesViewModelTests.cs index a02f2a70..5eb8bb88 100644 --- a/SysManager/SysManager.Tests/AppUpdatesViewModelTests.cs +++ b/SysManager/SysManager.Tests/AppUpdatesViewModelTests.cs @@ -389,6 +389,94 @@ public void Escape_CancelsTheScanOrTheUpgrade_OnlyWhileOneRuns() Assert.Same(vm.CancelCommand, vm.EscapeCancel); } + // ---------- Cancel and Escape stop a batch between apps (#2942) ---------- + + private static AppPackage Package(string name, string id) => + new() { Name = name, Id = id, CurrentVersion = "1", AvailableVersion = "2", IsSelected = true }; + + [Fact] + public async Task UpgradeSelected_WhenEscapeIsPressed_LetsTheRunningUpdateFinish_ThenStopsBeforeTheNextApp() + { + // Escape ran Cancel, whose token winget had, so a keypress ended winget part-way and the installer it had + // started with it (#2942). + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationToken runningToken = default; + var winget = Substitute.For(); + winget.UpgradeAsync("a", Arg.Any()).Returns(callInfo => + { + runningToken = callInfo.ArgAt(1); + started.TrySetResult(true); + return pending.Task; + }); + var vm = new AppUpdatesViewModel(winget); + vm.Packages.Add(Package("App A", "a")); + vm.Packages.Add(Package("App B", "b")); + + using var dialog = new DialogAnswer(confirm: true); + var execution = Task.CompletedTask; + try + { + execution = vm.UpgradeSelectedCommand.ExecuteAsync(null); + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + vm.EscapeCancel!.Execute(null); + + Assert.False(runningToken.IsCancellationRequested); + Assert.Equal("Stopping after App A. Its update finishes first, so it is not left half-installed.", + vm.StatusMessage); + + pending.SetResult(new WingetResult(0, true, "")); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal("Stopped after 1 of 2. Updated 1.", vm.StatusMessage); + await winget.DidNotReceive().UpgradeAsync("b", Arg.Any()); + } + finally + { + // Ended either way: a batch left running holds the install lock the next test needs. + pending.TrySetResult(new WingetResult(0, true, "")); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task Dispose_WhileAnUpdateRuns_StillEndsIt() + { + // Closing SysManager asks first while the update 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 winget = Substitute.For(); + winget.UpgradeAsync("a", Arg.Any()).Returns(callInfo => + { + runningToken = callInfo.ArgAt(1); + runningToken.Register(() => pending.TrySetCanceled(runningToken)); + started.TrySetResult(true); + return pending.Task; + }); + var vm = new AppUpdatesViewModel(winget); + var package = Package("App A", "a"); + vm.Packages.Add(package); + + using var dialog = new DialogAnswer(confirm: true); + var execution = Task.CompletedTask; + try + { + execution = vm.UpgradeSelectedCommand.ExecuteAsync(null); + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + vm.Dispose(); + + Assert.True(runningToken.IsCancellationRequested); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal("Cancelled", package.Status); + } + finally + { + pending.TrySetResult(new WingetResult(0, true, "")); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + [Fact] public void TheScanStatus_SaysHowManyWereLeftUnticked_AndWhy() { diff --git a/SysManager/SysManager.Tests/BulkInstallerViewModelTests.cs b/SysManager/SysManager.Tests/BulkInstallerViewModelTests.cs index df4c805e..9ec18fbd 100644 --- a/SysManager/SysManager.Tests/BulkInstallerViewModelTests.cs +++ b/SysManager/SysManager.Tests/BulkInstallerViewModelTests.cs @@ -428,4 +428,100 @@ public async Task AWingetSearch_ShowsTheTabBusy_ButNotTheInstallsCancel() Assert.False(vm.IsBusy); Assert.False(vm.IsProgressIndeterminate); } + + // ── Cancel and Escape stop a batch between apps (#2942) ── + + [Fact] + public async Task InstallSelected_WhenEscapeIsPressed_LetsTheRunningInstallFinish_ThenStopsBeforeTheNextApp() + { + // Escape ran Cancel, whose token winget had, so a keypress ended winget part-way and the installer it had + // started with it (#2942). + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationToken runningToken = default; + var installs = 0; + var runner = Substitute.For(); + runner.RunProcessAsync("winget", Arg.Is(args => args.StartsWith("install", StringComparison.Ordinal)), + Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + if (Interlocked.Increment(ref installs) != 1) return Task.FromResult(0); + runningToken = callInfo.ArgAt(2); + started.TrySetResult(true); + return pending.Task; + }); + var vm = VmWithSubstitutedRunner(runner); + vm.DeselectAllCommand.Execute(null); + var first = vm.Apps[0]; + first.IsSelected = true; + vm.Apps[1].IsSelected = true; + + using var dialog = new DialogAnswer(confirm: true); + var execution = Task.CompletedTask; + try + { + execution = vm.InstallSelectedCommand.ExecuteAsync(null); + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + vm.EscapeCancel!.Execute(null); + + Assert.False(runningToken.IsCancellationRequested); + Assert.Equal( + $"Stopping after {first.Name}. Its installer finishes first, so it is not left half-installed.", + vm.StatusMessage); + + pending.SetResult(0); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal("Installed", first.Status); + Assert.Equal("Installation cancelled after 1/2. Installed: 1, Failed: 0.", vm.StatusMessage); + Assert.Equal(1, installs); + } + finally + { + // Ended either way: a batch left running holds the install lock the next test needs. + pending.TrySetResult(0); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task Dispose_WhileAnInstallRuns_StillEndsIt() + { + // Closing SysManager asks first while the install 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.Is(args => args.StartsWith("install", StringComparison.Ordinal)), + Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + runningToken = callInfo.ArgAt(2); + runningToken.Register(() => pending.TrySetCanceled(runningToken)); + started.TrySetResult(true); + return pending.Task; + }); + var vm = VmWithSubstitutedRunner(runner); + vm.DeselectAllCommand.Execute(null); + var app = vm.Apps[0]; + app.IsSelected = true; + + using var dialog = new DialogAnswer(confirm: true); + var execution = Task.CompletedTask; + try + { + execution = vm.InstallSelectedCommand.ExecuteAsync(null); + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + vm.Dispose(); + + Assert.True(runningToken.IsCancellationRequested); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal("Cancelled", app.Status); + } + finally + { + pending.TrySetResult(0); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); + } + } } diff --git a/SysManager/SysManager.Tests/ComponentStoreCleanupTests.cs b/SysManager/SysManager.Tests/ComponentStoreCleanupTests.cs index dba21da6..d3763512 100644 --- a/SysManager/SysManager.Tests/ComponentStoreCleanupTests.cs +++ b/SysManager/SysManager.Tests/ComponentStoreCleanupTests.cs @@ -366,4 +366,93 @@ public void AStoreOperation_CountsAsAnyRunning_SoCancelIsEnabled() Assert.True(vm.IsAnyRunning); Assert.True(vm.CancelCommand.CanExecute(null)); } + + // ── Escape leaves a running cleanup alone (#2942) ──────────────────────── + + /// A runner on which the given DISM run starts and waits until its token is cancelled. + private static IPowerShellRunner HeldDism(string arguments, TaskCompletionSource started) + { + var runner = Substitute.For(); + runner.RunProcessAsync("DISM.exe", arguments, Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var token = callInfo.ArgAt(2); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + token.Register(() => pending.TrySetCanceled(token)); + started.TrySetResult(token); + return pending.Task; + }); + return runner; + } + + [Fact] + public async Task Escape_DoesNotStopARunningCleanup_ButTheCancelButtonStillDoes() + { + // Escape is a bare keypress, and stopping /StartComponentCleanup part-way leaves Windows to finish the + // servicing transaction on its own. The button stays, and the confirmation before the cleanup says what + // stopping costs. + using var elevated = AdminHelper.ForceElevation(true); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var vm = NewVm(HeldDism("/Online /English /Cleanup-Image /StartComponentCleanup", started)); + vm.CanCleanStore = true; + + using var dialog = new DialogAnswer(confirm: true); + var execution = vm.CleanComponentStoreCommand.ExecuteAsync(null); + try + { + var token = await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Null(vm.EscapeCancel); + + vm.CancelCommand.Execute(null); + Assert.True(token.IsCancellationRequested); + } + finally + { + vm.CancelCommand.Execute(null); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); + } + Assert.Equal("Cancelled.", vm.StoreStatus); + Assert.Null(vm.EscapeCancel); // and nothing is left marked as running + } + + [Fact] + public async Task Escape_StopsARunningAnalysis() + { + // The analysis only reads, so a keypress may stop it. + using var elevated = AdminHelper.ForceElevation(true); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var vm = NewVm(HeldDism("/Online /English /Cleanup-Image /AnalyzeComponentStore", started)); + + var execution = vm.AnalyzeComponentStoreCommand.ExecuteAsync(null); + try + { + var token = await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Same(vm.CancelCommand, vm.EscapeCancel); + vm.EscapeCancel!.Execute(null); + Assert.True(token.IsCancellationRequested); + } + finally + { + vm.CancelCommand.Execute(null); + await execution.WaitAsync(TimeSpan.FromSeconds(10)); + } + Assert.Equal("Cancelled.", vm.StoreStatus); + } + + [Fact] + public async Task Escape_StopsTheOtherRuns_OnceACleanupHasEnded() + { + using var elevated = AdminHelper.ForceElevation(true); + var vm = NewVm(); // the cleanup's DISM run ends at once, with exit code 0 + vm.CanCleanStore = true; + using (new DialogAnswer(confirm: true)) + await vm.CleanComponentStoreCommand.ExecuteAsync(null); + Assert.Equal("Completed", vm.StoreStatus); // the premise: it ran + + vm.IsBinRunning = true; + + Assert.Same(vm.CancelCommand, vm.EscapeCancel); + } } diff --git a/SysManager/SysManager/SysManager.csproj b/SysManager/SysManager/SysManager.csproj index d07892e1..7beb7853 100644 --- a/SysManager/SysManager/SysManager.csproj +++ b/SysManager/SysManager/SysManager.csproj @@ -13,9 +13,9 @@ $(NoWarn);NU1603;NU1701 false - 1.130.55 - 1.130.55.0 - 1.130.55.0 + 1.130.56 + 1.130.56.0 + 1.130.56.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/AppUpdatesViewModel.cs b/SysManager/SysManager/ViewModels/AppUpdatesViewModel.cs index 7ac42357..ba19b936 100644 --- a/SysManager/SysManager/ViewModels/AppUpdatesViewModel.cs +++ b/SysManager/SysManager/ViewModels/AppUpdatesViewModel.cs @@ -24,6 +24,11 @@ public sealed partial class AppUpdatesViewModel : ViewModelBase private readonly IWingetService _winget; private readonly EtaCalculator _upgradeEta = new(); private CancellationTokenSource? _cts; + + // The token the running winget gets. Only closing SysManager cancels it: Cancel, and Escape with it, stop a batch + // before its next app, because ending winget part-way ends the installer it started (#2942). + private CancellationTokenSource? _upgraderCts; + private AppPackage? _upgrading; private readonly Action _lineHandler; /// @@ -214,8 +219,11 @@ private async Task UpgradeSelectedAsync() IsBusy = true; _cts?.Dispose(); _cts = new CancellationTokenSource(); + _upgraderCts?.Dispose(); + _upgraderCts = new CancellationTokenSource(); int attempted = 0, succeeded = 0, failed = 0; var wingetUnavailable = false; + var stopped = false; UpgradeEtaText = string.Empty; _upgradeEta.Reset(); _winget.LineReceived += _lineHandler; // op-scoped subscription (see constructor note) @@ -223,20 +231,26 @@ private async Task UpgradeSelectedAsync() { foreach (var pkg in toUpgrade) { - if (_cts.IsCancellationRequested) break; + if (_cts.IsCancellationRequested) + { + stopped = true; + break; + } pkg.Status = "Upgrading…"; StatusMessage = $"Upgrading {pkg.Name} ({attempted + 1}/{toUpgrade.Count})"; Progress = (int)((attempted / (double)toUpgrade.Count) * 100); UpgradeEtaText = _upgradeEta.Update(Progress); + _upgrading = pkg; try { // WingetResult carries a friendly message translated from the exit code, // so the per-app status is human-readable (never a raw "exit 0x8A15…"). - var result = await _winget.UpgradeAsync(pkg.Id, _cts.Token); + var result = await _winget.UpgradeAsync(pkg.Id, _upgraderCts.Token); pkg.Status = result.FriendlyMessage; if (result.Succeeded) succeeded++; else failed++; } - catch (OperationCanceledException) { pkg.Status = "Cancelled"; break; } + // Only closing SysManager ends a running update. + catch (OperationCanceledException) { pkg.Status = "Cancelled"; stopped = true; break; } catch (InvalidOperationException ex) { pkg.Status = $"Error: {ex.Message}"; failed++; } // A single invalid package Id throws ArgumentException from UpgradeAsync // BEFORE any process runs; record it on the row and keep upgrading the rest @@ -250,26 +264,34 @@ private async Task UpgradeSelectedAsync() wingetUnavailable = true; break; } + finally { _upgrading = null; } attempted++; } - Progress = 100; + if (!stopped) Progress = 100; UpgradeEtaText = string.Empty; // Honest summary: separate succeeded from failed, and only mention failures // when there are any. If winget itself is missing, keep the friendly // "install App Installer" message instead of a misleading "Updated 0 of N". + var failures = failed == 0 ? "" : $" · {failed} failed"; StatusMessage = wingetUnavailable ? WingetUnavailableMessage - : failed == 0 - ? $"Updated {succeeded} of {toUpgrade.Count}." - : $"Updated {succeeded} of {toUpgrade.Count} · {failed} failed."; + : stopped + ? $"Stopped after {attempted} of {toUpgrade.Count}. Updated {succeeded}{failures}." + : $"Updated {succeeded} of {toUpgrade.Count}{failures}."; Log.Information("App upgrade batch: {Succeeded} ok, {Failed} failed of {Total}", succeeded, failed, toUpgrade.Count); } finally { _winget.LineReceived -= _lineHandler; IsBusy = false; UpgradeEtaText = string.Empty; } } + /// Stops a scan, or a batch before its next app: the update running now finishes. [RelayCommand] - private void Cancel() => _cts?.Cancel(); + private void Cancel() + { + _cts?.Cancel(); + if (_upgrading is { } pkg) + StatusMessage = $"Stopping after {pkg.Name}. Its update finishes first, so it is not left half-installed."; + } protected override void Dispose(bool disposing) { @@ -279,6 +301,8 @@ protected override void Dispose(bool disposing) PropertyChanged -= OnVmPropertyChanged; _cts?.Cancel(); _cts?.Dispose(); + _upgraderCts?.Cancel(); + _upgraderCts?.Dispose(); } base.Dispose(disposing); } diff --git a/SysManager/SysManager/ViewModels/BulkInstallerViewModel.cs b/SysManager/SysManager/ViewModels/BulkInstallerViewModel.cs index 9be2c296..36ddcdc4 100644 --- a/SysManager/SysManager/ViewModels/BulkInstallerViewModel.cs +++ b/SysManager/SysManager/ViewModels/BulkInstallerViewModel.cs @@ -29,6 +29,11 @@ public sealed partial class BulkInstallerViewModel : ViewModelBase private readonly EtaCalculator _installEta = new(); private CancellationTokenSource? _cts; + // The token the running winget gets. Only closing SysManager cancels it: Cancel, and Escape with it, stop a batch + // before its next app, because ending winget part-way ends the installer it started (#2942). + private CancellationTokenSource? _installerCts; + private InstallableApp? _installing; + public BulkObservableCollection Apps { get; } = new(); public BulkObservableCollection FilteredApps { get; } = new(); public ICollectionView GroupedView { get; } @@ -275,6 +280,8 @@ private async Task InstallSelectedAsync() // install can never have its CTS disposed out from under it by a second click. _cts?.Dispose(); _cts = new CancellationTokenSource(); + _installerCts?.Dispose(); + _installerCts = new CancellationTokenSource(); var installed = 0; var alreadyInstalled = 0; @@ -294,9 +301,10 @@ private async Task InstallSelectedAsync() InstallEtaText = _installEta.Update(Progress); StatusMessage = $"Installing {app.Name} ({i + 1}/{selected.Count})…"; + _installing = app; try { - var exitCode = await _service.InstallAsync(app.WingetId, _cts.Token); + var exitCode = await _service.InstallAsync(app.WingetId, _installerCts.Token); if (exitCode == 0) { app.Status = "Installed"; @@ -318,6 +326,7 @@ private async Task InstallSelectedAsync() failed++; } } + // Only closing SysManager ends a running install. catch (OperationCanceledException) { app.Status = "Cancelled"; @@ -341,6 +350,7 @@ private async Task InstallSelectedAsync() failed++; Log.Warning(ex, "Failed to install {WingetId}", app.WingetId); } + finally { _installing = null; } } Progress = 100; @@ -351,7 +361,8 @@ private async Task InstallSelectedAsync() } catch (OperationCanceledException) { - StatusMessage = "Installation cancelled."; + StatusMessage = $"Installation cancelled after {installed + alreadyInstalled + failed}/{selected.Count}. " + + $"{DescribeInstallRun(installed, alreadyInstalled, failed)}."; } finally { @@ -384,8 +395,15 @@ internal static string DescribeInstallRun(int installed, int alreadyInstalled, i ? $"Installed: {installed}, Failed: {failed}" : $"Installed: {installed}, Already installed: {alreadyInstalled}, Failed: {failed}"; + /// Stops a batch before its next app: the install running now finishes. [RelayCommand] - private void Cancel() => _cts?.Cancel(); + private void Cancel() + { + _cts?.Cancel(); + if (_installing is { } app) + StatusMessage = $"Stopping after {app.Name}. Its installer finishes first, so it is not left " + + "half-installed."; + } [RelayCommand] private void SelectAll() @@ -619,6 +637,8 @@ protected override void Dispose(bool disposing) { _cts?.Cancel(); _cts?.Dispose(); + _installerCts?.Cancel(); + _installerCts?.Dispose(); } base.Dispose(disposing); } diff --git a/SysManager/SysManager/ViewModels/CleanupViewModel.cs b/SysManager/SysManager/ViewModels/CleanupViewModel.cs index 57fca7fd..24f99db5 100644 --- a/SysManager/SysManager/ViewModels/CleanupViewModel.cs +++ b/SysManager/SysManager/ViewModels/CleanupViewModel.cs @@ -20,8 +20,13 @@ public sealed partial class CleanupViewModel : ViewModelBase protected internal override IRelayCommand? RefreshOnF5 => RescanCommand; /// + /// + /// Not while the component store is being cleaned up. Escape is a bare keypress, and stopping + /// /StartComponentCleanup part-way leaves Windows to finish the servicing transaction on its own + /// (#2942). The Cancel button still stops it, and the confirmation before it says what that costs. + /// protected internal override IRelayCommand? EscapeCancel => - IsAnyRunning ? CancelCommand : null; + IsAnyRunning && !_cleaningStore ? CancelCommand : null; private readonly IPowerShellRunner _runner; @@ -40,6 +45,9 @@ public sealed partial class CleanupViewModel : ViewModelBase private CancellationTokenSource? _binCts; private CancellationTokenSource? _storeCts; + // Whether the component store run is the cleanup rather than the analysis: Escape stops the analysis, not this. + private bool _cleaningStore; + // Temp Cleanup and both component-store operations stream through the single shared _runner // and its LineReceived/ProgressChanged events into the one Console, so only one may run at a // time — otherwise their output and progress cross-contaminate. The per-category @@ -422,6 +430,7 @@ private async Task RunComponentStoreAsync(string arguments, bool analyzing) } IsStoreRunning = true; + _cleaningStore = !analyzing; IsProgressIndeterminate = true; StoreStatus = analyzing ? "Analysing — usually under a minute" : "Cleaning up — can take 10–30 minutes"; StoreVerdict = ""; @@ -469,7 +478,7 @@ void Collect(PowerShellLine l) catch (OperationCanceledException) { StoreStatus = "Cancelled."; StoreVerdict = analyzing ? "Analysis was cancelled." : "Cleanup was cancelled — Windows will finish the servicing transaction itself."; StoreVerdictColorHex = StatusColors.Neutral; StatusMessage = StoreStatus; } catch (InvalidOperationException ex) { StoreStatus = $"Error: {ex.Message}"; StoreVerdict = ex.Message; StoreVerdictColorHex = StatusColors.Bad; StatusMessage = StoreStatus; } catch (System.ComponentModel.Win32Exception ex) { StoreStatus = $"Error: {ex.Message}"; StoreVerdict = ex.Message; StoreVerdictColorHex = StatusColors.Bad; StatusMessage = StoreStatus; } - finally { _runner.LineReceived -= Collect; EndConsoleOp(); IsStoreRunning = false; IsProgressIndeterminate = false; StoreEtaText = string.Empty; } + finally { _runner.LineReceived -= Collect; EndConsoleOp(); _cleaningStore = false; IsStoreRunning = false; IsProgressIndeterminate = false; StoreEtaText = string.Empty; } } /// Neither component-store operation may start while one is already running.