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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,34 @@ 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.109.4] - 2026-09-17

**Saving a second maintenance schedule quietly threw away the first.** Scheduled Maintenance keeps one
schedule, by design — it is what makes it safe to set up without administrator rights and impossible for it
to disturb anything else Windows has scheduled. But nothing on the page said so. Pick an action and a time,
press Save, and the schedule you already had was gone, with the tab then reporting the new one as though
nothing had happened. The page now says there is one schedule, and the confirmation says plainly that Save
is replacing the existing one and when that one was next due.

### Fixed

- **The Scheduled Maintenance page states that there is one schedule**, in the header and again beside the
Save button, where the wording changes to "saving this replaces the one above" as soon as a schedule
exists. Previously the only hint was the header's passing mention of "one Windows scheduled task", which
reads as an internal detail rather than a limit that affects you.
- **The confirmation before saving now asks the question that matches what the button will do.** One text
served both cases and described only the first: "This creates a Windows scheduled task" appeared while
about to overwrite an existing one, so the dialog whose whole purpose is to stop an unwanted change hid
which change it was. Replacing now says so in those words and names the time the old schedule was next
due, so you can tell which one you are about to lose.

### Changed

- **Scheduled Maintenance keeps exactly one schedule, and that is now a stated decision rather than an
unstated limit.** Supporting several was considered and deliberately not done: the whole design rests on
touching exactly one Windows task by name, which is what keeps it from needing administrator rights and
what makes it incapable of disturbing anything else on the machine.

## [1.109.3] - 2026-09-17

**A tab that failed to load could say nothing at all.** Every tab starts loading in the background the
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,11 @@ Answers "what is actually using my space?" by listing the biggest files in one p
- **Optional "only when I'm not using the PC"** condition, off by default
- **The schedule you are about to save is spelled out in words**, conditions included, and
updates as you change the settings
- **One schedule at a time, said out loud.** Saving replaces the schedule you already had
rather than adding a second, so the page says so — in the header and again beside the Save
button — and the confirmation names the time the old one was next due, so you can tell what
you are about to lose. One task by name is what keeps this feature from needing
administrator rights and from being able to touch anything else Windows schedules
- **Windows' own count of skipped runs is shown** when it is not zero — the only signal
Windows gives for a run its conditions blocked
- Update or remove the schedule any time, each with a confirmation
Expand Down
120 changes: 120 additions & 0 deletions SysManager/SysManager.Tests/ScheduledMaintenanceViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,126 @@ await ps.DidNotReceive().RunAsync(Arg.Any<string>(), Arg.Any<IDictionary<string,
Arg.Any<CancellationToken>());
}

/// <summary>
/// A view model over a runner that answers "a task IS registered", with the next run Windows reports.
/// </summary>
/// <remarks>
/// Mirrors <c>MaintenanceSchedulerServiceTests.StatusRow</c>. The next run matters here rather than being
/// filler: the replace confirmation names it, so it is the thing that tells the user WHICH schedule they
/// are about to lose.
/// </remarks>
private static async Task<(ScheduledMaintenanceViewModel vm, IPowerShellRunner ps)> NewScheduledVmAsync()
{
var row = new PSObject();
row.Properties.Add(new PSNoteProperty("State", "Ready"));
row.Properties.Add(new PSNoteProperty("LastRunTime", new DateTime(2026, 6, 29, 3, 0, 0)));
row.Properties.Add(new PSNoteProperty("NextRunTime", new DateTime(2026, 6, 30, 3, 0, 0)));
row.Properties.Add(new PSNoteProperty("LastTaskResult", 0));

var ps = Substitute.For<IPowerShellRunner>();
ps.RunAsync(Arg.Any<string>(), Arg.Any<IDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(new Collection<PSObject> { row });
var vm = new ScheduledMaintenanceViewModel(new MaintenanceSchedulerService(ps));
await vm.InitializationComplete;
return (vm, ps);
}

/// <summary>
/// The one-schedule rule is on screen before the user acts, and it says which of the two situations they
/// are in.
/// </summary>
/// <remarks>
/// Nothing stated it. Saving does not add a second schedule, it overwrites the first, and someone who
/// wanted a weekly cleanup AND a monthly standby purge would have set the second and silently lost the
/// first (#1509).
/// </remarks>
[Fact]
public void OneScheduleNote_WithNothingScheduled_SaysTheRuleWithoutWarning()
{
var (vm, _) = NewVm();

Assert.False(vm.IsScheduled);
Assert.Contains("one schedule at a time", vm.OneScheduleNote, StringComparison.Ordinal);
Assert.DoesNotContain("replaces", vm.OneScheduleNote, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task OneScheduleNote_WithOneScheduled_SaysSavingReplacesIt()
{
var (vm, _) = await NewScheduledVmAsync();

Assert.True(vm.IsScheduled);
Assert.Contains("one schedule at a time", vm.OneScheduleNote, StringComparison.Ordinal);
Assert.Contains("replaces the one above", vm.OneScheduleNote, StringComparison.Ordinal);
}

/// <summary>
/// The save confirmation asks the question that matches what the click will do.
/// </summary>
/// <remarks>
/// One text served both cases and described only the first: "This creates a Windows scheduled task" was
/// shown while about to overwrite an existing one, so the dialog whose whole job is to stop an unwanted
/// change concealed which change it was. Asserted through <see cref="DialogAnswer"/> rather than by
/// calling a helper directly, because what matters is the text that reaches the user from the real command
/// path — and the surrounding tests' hand-rolled swap cannot read the wording at all.
/// </remarks>
[Fact]
public async Task SaveSchedule_WithNothingScheduled_SaysItCreatesATask()
{
var (vm, _) = NewVm();

using var dialog = new DialogAnswer(confirm: false);
vm.SaveScheduleCommand.Execute(null);
if (vm.SaveScheduleCommand.ExecutionTask is { } running) await running;

var shown = Assert.Single(dialog.Messages);
Assert.Contains("Schedule Maintenance — Confirm", shown, StringComparison.Ordinal);
Assert.Contains("creates a Windows scheduled task", shown, StringComparison.Ordinal);
// Even here it says the rule, so the limit is known before there is anything to lose.
Assert.Contains("one schedule at a time", shown, StringComparison.Ordinal);
}

[Fact]
public async Task SaveSchedule_WithOneAlreadyScheduled_SaysItReplacesAndNamesTheNextRun()
{
var (vm, _) = await NewScheduledVmAsync();

using var dialog = new DialogAnswer(confirm: false);
vm.SaveScheduleCommand.Execute(null);
if (vm.SaveScheduleCommand.ExecutionTask is { } running) await running;

var shown = Assert.Single(dialog.Messages);
Assert.Contains("Replace Schedule — Confirm", shown, StringComparison.Ordinal);
Assert.Contains("REPLACES", shown, StringComparison.Ordinal);
// The one detail available about the schedule being lost, and it comes from the same status read the
// card above displays, so the dialog and the card cannot disagree.
Assert.Contains("2026-06-30 03:00", shown, StringComparison.Ordinal);
Assert.DoesNotContain("creates a Windows scheduled task", shown, StringComparison.Ordinal);
}

/// <summary>
/// Declining the replace confirmation leaves the existing schedule alone.
/// </summary>
/// <remarks>
/// The counterpart to the wording tests: a dialog that says the right thing is worth nothing if No does
/// not mean no. Asserted on the runner, because "the task is unchanged" is only observable as "no register
/// script was sent".
/// </remarks>
[Fact]
public async Task SaveSchedule_WhenTheUserDeclinesAReplacement_LeavesTheTaskAlone()
{
var (vm, ps) = await NewScheduledVmAsync();
ps.ClearReceivedCalls(); // the constructor's status read is not what this asserts about

using var dialog = new DialogAnswer(confirm: false);
vm.SaveScheduleCommand.Execute(null);
if (vm.SaveScheduleCommand.ExecutionTask is { } running) await running;

Assert.Equal(1, dialog.Calls);
await ps.DidNotReceive().RunAsync(Arg.Any<string>(), Arg.Any<IDictionary<string, object?>?>(),
Arg.Any<CancellationToken>());
}

[Fact]
public void MissedRunsWarning_IsEmptyWhenNoTaskIsRegistered()
{
Expand Down
6 changes: 3 additions & 3 deletions SysManager/SysManager/SysManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
<RootNamespace>SysManager</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>NU1603;NU1701</NoWarn>
<Version>1.109.3</Version>
<FileVersion>1.109.3.0</FileVersion>
<AssemblyVersion>1.109.3.0</AssemblyVersion>
<Version>1.109.4</Version>
<FileVersion>1.109.4.0</FileVersion>
<AssemblyVersion>1.109.4.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
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,24 @@ private MaintenanceSchedule BuildSchedule() =>
/// </remarks>
public string PendingSummary => BuildSchedule().Summary;

/// <summary>
/// The one-schedule rule, stated where the user is about to act on it.
/// </summary>
/// <remarks>
/// This tab registers a single Windows task at a fixed name, so Save does not add a second schedule — it
/// overwrites the first. Nothing said so. The header mentioned "one Windows scheduled task" as an
/// implementation detail, the Configure card offered an action and a time as though each Save were a new
/// entry, and the confirmation dialog said "This creates a Windows scheduled task" even when one already
/// existed. Someone who wanted a weekly cleanup AND a monthly standby purge would have set the second and
/// silently lost the first, with the tab then reporting the survivor as though nothing had gone (#1509).
/// <para>Supporting more than one schedule was considered and deliberately not done: the whole design
/// rests on touching exactly one task by name, which is what makes it safe to register without admin and
/// impossible for it to disturb anything else Windows schedules. Saying so plainly is the fix.</para>
/// </remarks>
public string OneScheduleNote => IsScheduled
? "SysManager keeps one schedule at a time, so saving this replaces the one above."
: "SysManager keeps one schedule at a time. You can change it or remove it whenever you like.";

[RelayCommand(CanExecute = nameof(NotBusy))]
private async Task RefreshAsync()
{
Expand Down Expand Up @@ -154,14 +172,43 @@ private async Task LoadStatusAsync()
RemoveScheduleCommand.NotifyCanExecuteChanged();
}

/// <summary>
/// What the Save confirmation asks. Two different questions, because Save does two different things.
/// </summary>
/// <remarks>
/// The wording IS the behaviour for this gate. One text served both cases and it described only the first:
/// "This creates a Windows scheduled task" was shown while about to overwrite an existing one, so the
/// dialog that exists to stop an unwanted change actively concealed which change it was (#1509).
/// <para>The replacement text names the existing task's next run, which is as specific as it can be: the
/// status read-back reports state and times, not which action or trigger Windows is holding. That is still
/// enough to tell the user WHICH schedule they are about to lose, and it comes from the same read the
/// card above displays, so the two cannot disagree.</para>
/// </remarks>
private string ConfirmSavePrompt(MaintenanceSchedule schedule)
{
if (!IsScheduled)
{
return $"Schedule \"{schedule.ActionLabel}\" to run automatically?\n\n{schedule.Summary}\n\n"
+ "This creates a Windows scheduled task that launches SysManager in the background. "
+ "SysManager keeps one schedule at a time, so saving again later replaces this one.";
}

var existing = string.IsNullOrEmpty(NextRun) || NextRun == "—"
? "the schedule already registered"
: $"the schedule already registered, whose next run was {NextRun}";

return $"Replace the maintenance schedule with \"{schedule.ActionLabel}\"?\n\n{schedule.Summary}\n\n"
+ $"SysManager keeps one schedule at a time, so this REPLACES {existing}. Nothing else on your "
+ "PC is changed, and you can remove the schedule at any time.";
}

[RelayCommand(CanExecute = nameof(NotBusy))]
private async Task SaveScheduleAsync()
{
var schedule = BuildSchedule();
if (!DialogService.Instance.Confirm(
$"Schedule \"{schedule.ActionLabel}\" to run automatically?\n\n{schedule.Summary}\n\n" +
"This creates a Windows scheduled task that launches SysManager in the background.",
"Schedule Maintenance — Confirm"))
ConfirmSavePrompt(schedule),
IsScheduled ? "Replace Schedule — Confirm" : "Schedule Maintenance — Confirm"))
return;

IsBusy = true;
Expand Down Expand Up @@ -204,7 +251,13 @@ private async Task RemoveScheduleAsync()
finally { IsBusy = false; }
}

partial void OnIsScheduledChanged(bool value) => RemoveScheduleCommand.NotifyCanExecuteChanged();
partial void OnIsScheduledChanged(bool value)
{
RemoveScheduleCommand.NotifyCanExecuteChanged();
// OneScheduleNote reads this, and the whole point of the note is that it changes from "you can
// change it whenever" to "saving replaces the one above" the moment a schedule exists.
OnPropertyChanged(nameof(OneScheduleNote));
}

protected override void Dispose(bool disposing)
{
Expand Down
13 changes: 12 additions & 1 deletion SysManager/SysManager/Views/ScheduledMaintenanceView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
<!-- Header -->
<StackPanel Grid.Row="1" Margin="28,16,28,0">
<TextBlock Text="Scheduled Maintenance" Style="{StaticResource Display}"/>
<TextBlock Text="Run maintenance automatically on a schedule. SysManager creates one Windows scheduled task that launches the app in the background to clean temporary files or purge standby memory — no need to remember to do it yourself."
<!-- "One schedule" is stated as the rule it is, not as the implementation detail it used to read
as. Saving replaces rather than adds, and a header that mentioned "one Windows scheduled
task" in passing left that for the user to discover by losing a schedule (#1509). -->
<TextBlock Text="Run maintenance automatically so you don't have to remember. SysManager keeps one schedule at a time: pick an action and a time, and it runs SysManager in the background to clean temporary files or purge standby memory. Saving a new schedule replaces the previous one."
Style="{StaticResource Subtle}" Margin="0,4,0,0" TextWrapping="Wrap" MaxWidth="820" HorizontalAlignment="Left"/>
</StackPanel>

Expand Down Expand Up @@ -139,6 +142,14 @@
<TextBlock Text="{Binding PendingSummary}" Style="{StaticResource Subtle}"
TextWrapping="Wrap" Margin="0,10,0,0"/>

<!-- The one-schedule rule, next to the button that acts on it. This tab registers a single
task at a fixed name, so Save replaces rather than adds — and nothing here said so, which
made losing a schedule a silent outcome of a button labelled Save (#1509). The text
changes with IsScheduled, so it reads as a replacement warning only when there is
something to replace. -->
<TextBlock Text="{Binding OneScheduleNote}" Style="{StaticResource Caption}"
TextWrapping="Wrap" Margin="0,6,0,0"/>

<WrapPanel Orientation="Horizontal" Margin="0,12,0,0">
<Button Content="Save schedule" Command="{Binding SaveScheduleCommand}"
Style="{StaticResource PrimaryButton}" Margin="0,0,8,0"
Expand Down