diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e064df..947999d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 14b8ad87..c3412c67 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SysManager/SysManager.Tests/ScheduledMaintenanceViewModelTests.cs b/SysManager/SysManager.Tests/ScheduledMaintenanceViewModelTests.cs index 1ef571ae..68d2a384 100644 --- a/SysManager/SysManager.Tests/ScheduledMaintenanceViewModelTests.cs +++ b/SysManager/SysManager.Tests/ScheduledMaintenanceViewModelTests.cs @@ -198,6 +198,126 @@ await ps.DidNotReceive().RunAsync(Arg.Any(), Arg.Any()); } + /// + /// A view model over a runner that answers "a task IS registered", with the next run Windows reports. + /// + /// + /// Mirrors MaintenanceSchedulerServiceTests.StatusRow. 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. + /// + 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(); + ps.RunAsync(Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns(new Collection { row }); + var vm = new ScheduledMaintenanceViewModel(new MaintenanceSchedulerService(ps)); + await vm.InitializationComplete; + return (vm, ps); + } + + /// + /// The one-schedule rule is on screen before the user acts, and it says which of the two situations they + /// are in. + /// + /// + /// 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). + /// + [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); + } + + /// + /// The save confirmation asks the question that matches what the click will do. + /// + /// + /// 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 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. + /// + [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); + } + + /// + /// Declining the replace confirmation leaves the existing schedule alone. + /// + /// + /// 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". + /// + [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(), Arg.Any?>(), + Arg.Any()); + } + [Fact] public void MissedRunsWarning_IsEmptyWhenNoTaskIsRegistered() { diff --git a/SysManager/SysManager/SysManager.csproj b/SysManager/SysManager/SysManager.csproj index 30fce1b7..be8ae5cd 100644 --- a/SysManager/SysManager/SysManager.csproj +++ b/SysManager/SysManager/SysManager.csproj @@ -10,9 +10,9 @@ SysManager true NU1603;NU1701 - 1.109.3 - 1.109.3.0 - 1.109.3.0 + 1.109.4 + 1.109.4.0 + 1.109.4.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/ScheduledMaintenanceViewModel.cs b/SysManager/SysManager/ViewModels/ScheduledMaintenanceViewModel.cs index 047c303b..76860baf 100644 --- a/SysManager/SysManager/ViewModels/ScheduledMaintenanceViewModel.cs +++ b/SysManager/SysManager/ViewModels/ScheduledMaintenanceViewModel.cs @@ -119,6 +119,24 @@ private MaintenanceSchedule BuildSchedule() => /// public string PendingSummary => BuildSchedule().Summary; + /// + /// The one-schedule rule, stated where the user is about to act on it. + /// + /// + /// 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). + /// 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. + /// + 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() { @@ -154,14 +172,43 @@ private async Task LoadStatusAsync() RemoveScheduleCommand.NotifyCanExecuteChanged(); } + /// + /// What the Save confirmation asks. Two different questions, because Save does two different things. + /// + /// + /// 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). + /// 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. + /// + 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; @@ -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) { diff --git a/SysManager/SysManager/Views/ScheduledMaintenanceView.xaml b/SysManager/SysManager/Views/ScheduledMaintenanceView.xaml index 2dc9fb89..b2036c2b 100644 --- a/SysManager/SysManager/Views/ScheduledMaintenanceView.xaml +++ b/SysManager/SysManager/Views/ScheduledMaintenanceView.xaml @@ -26,7 +26,10 @@ - + @@ -139,6 +142,14 @@ + + +