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
5 changes: 3 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions SysManager/SysManager.Tests/ArchitectureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// <c>[RelayCommand(CanExecute = …)]</c> names where it names one.</para>
/// <para><b>A tab on which every run changes Windows declines Escape</b>, 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 <c>EscapeCancel</c>, or one
/// that returns null outright. A name whose view has no Cancel button any more fails, so the list cannot go
/// stale.</para>
/// </remarks>
[Fact]
public void EveryCancellableTab_LetsEscapeReachItsOwnCancelCommand()
Expand All @@ -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+(?<name>\w+)", RegexOptions.CultureInvariant);

// Views on which every run changes Windows, so Escape stops none of them.
var declinesEscape = new Dictionary<string, string>(StringComparer.Ordinal)
{
["SystemFixesView"] = "every repair on it changes Windows; Escape stopped SFC and DISM half-way (#2834)",
};
var declined = new HashSet<string>(StringComparer.Ordinal);

var offenders = new List<string>();
var pairs = 0;
var alwaysShown = 0;
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions SysManager/SysManager.Tests/SystemFixesViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,40 @@ await runner.Received(1).RunProcessAsync(
"sfc.exe", "/scannow", Arg.Any<CancellationToken>(), Arg.Any<System.Text.Encoding?>());
}

[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<int>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken sfcToken = default;
var runner = Substitute.For<IPowerShellRunner>();
runner.RunProcessAsync("sfc.exe", "/scannow", Arg.Any<CancellationToken>(), Arg.Any<System.Text.Encoding?>())
.Returns(call =>
{
sfcToken = call.ArgAt<CancellationToken>(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()
{
Expand Down
80 changes: 69 additions & 11 deletions SysManager/SysManager.Tests/UninstallerViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var pending = new TaskCompletionSource<int>(
TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken runningToken = default;
var callCount = 0;
var runner = Substitute.For<IPowerShellRunner>();
runner.RunProcessAsync(
Expand All @@ -394,16 +397,15 @@ public async Task UninstallSelected_WhenCancelled_StopsBatchAndReportsPartialPro
Arg.Any<System.Text.Encoding?>())
.Returns(callInfo =>
{
if (Interlocked.Increment(ref callCount) == 1)
if (Interlocked.Increment(ref callCount) != 2)
return Task.FromResult(0);

var token = callInfo.ArgAt<CancellationToken>(2);
token.Register(() => pending.TrySetCanceled(token));
runningToken = callInfo.ArgAt<CancellationToken>(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";
Expand All @@ -414,17 +416,22 @@ public async Task UninstallSelected_WhenCancelled_StopsBatchAndReportsPartialPro
var dialog = Substitute.For<IDialogService>();
dialog.Confirm(Arg.Any<string>(), Arg.Any<string>()).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<string>(),
Expand All @@ -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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var pending = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken runningToken = default;
var runner = Substitute.For<IPowerShellRunner>();
runner.RunProcessAsync(
"winget",
Arg.Any<string>(),
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 = NewVm(runner);
Seed(vm, 1);
vm.FilteredApps[0].Source = "winget";
vm.FilteredApps[0].IsSelected = true;

var previousDialog = DialogService.Instance;
var dialog = Substitute.For<IDialogService>();
dialog.Confirm(Arg.Any<string>(), Arg.Any<string>()).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;
}
}
Expand Down
6 changes: 3 additions & 3 deletions SysManager/SysManager/SysManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
<NoWarn>$(NoWarn);NU1603;NU1701</NoWarn>
<!-- The documentation file exists for the compiler's checks; it is not part of what ships. -->
<PublishDocumentationFile>false</PublishDocumentationFile>
<Version>1.130.51</Version>
<FileVersion>1.130.51.0</FileVersion>
<AssemblyVersion>1.130.51.0</AssemblyVersion>
<Version>1.130.52</Version>
<FileVersion>1.130.52.0</FileVersion>
<AssemblyVersion>1.130.52.0</AssemblyVersion>
<Product>SysManager</Product>
<Description>SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup.</Description>
<PackageProjectUrl>https://github.com/laurentiu021/SystemManager</PackageProjectUrl>
Expand Down
6 changes: 3 additions & 3 deletions SysManager/SysManager/ViewModels/SystemFixesViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ namespace SysManager.ViewModels;
/// </remarks>
public sealed partial class SystemFixesViewModel : ViewModelBase
{
/// <inheritdoc/>
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;

Expand Down
Loading