Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions SysManager/SysManager.Tests/AppUpdatesViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var pending = new TaskCompletionSource<WingetResult>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken runningToken = default;
var winget = Substitute.For<IWingetService>();
winget.UpgradeAsync("a", Arg.Any<CancellationToken>()).Returns(callInfo =>
{
runningToken = callInfo.ArgAt<CancellationToken>(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<CancellationToken>());
}
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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var pending = new TaskCompletionSource<WingetResult>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken runningToken = default;
var winget = Substitute.For<IWingetService>();
winget.UpgradeAsync("a", Arg.Any<CancellationToken>()).Returns(callInfo =>
{
runningToken = callInfo.ArgAt<CancellationToken>(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()
{
Expand Down
96 changes: 96 additions & 0 deletions SysManager/SysManager.Tests/BulkInstallerViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var pending = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken runningToken = default;
var installs = 0;
var runner = Substitute.For<IPowerShellRunner>();
runner.RunProcessAsync("winget", Arg.Is<string>(args => args.StartsWith("install", StringComparison.Ordinal)),
Arg.Any<CancellationToken>(), Arg.Any<System.Text.Encoding?>())
.Returns(callInfo =>
{
if (Interlocked.Increment(ref installs) != 1) return Task.FromResult(0);
runningToken = callInfo.ArgAt<CancellationToken>(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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var pending = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken runningToken = default;
var runner = Substitute.For<IPowerShellRunner>();
runner.RunProcessAsync("winget", Arg.Is<string>(args => args.StartsWith("install", StringComparison.Ordinal)),
Arg.Any<CancellationToken>(), Arg.Any<System.Text.Encoding?>())
.Returns(callInfo =>
{
runningToken = callInfo.ArgAt<CancellationToken>(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));
}
}
}
Loading