Skip to content

Commit b67122f

Browse files
carterscodeclaude
andcommitted
fix(apply): reliably prompt to restart after bulk apply and keep silent settings silent
Two related bugs surfaced by running the Extreme preset on a fresh Windows 11 install: Reboot prompt lost on Save & close. The manual Apply / preset path showed its reboot affordance as a button inside ApplyResultsWindow, which was owned by the Settings window. On "Save & close" the Settings window closes immediately after showing it, and WPF destroys owned windows with their owner — so a bulk apply wrote several reboot-required settings but never prompted to restart. Centralize the restart prompt in a new, unowned, app-level RebootPrompt (reusing RebootPendingWindow) that every apply path funnels through: manual Apply/preset, the background auto-apply loop, the drift-notification Apply button, and the CPU power-plan action. Because it is unowned it survives the close; deferred to the end of the batch it prompts once for the whole apply, not per setting. The now-redundant reboot button is removed from ApplyResultsWindow (the per-row "reboot to take effect" badge stays). Silent didn't mean silent. MonitorService's notification list excluded only the settings being auto-applied this tick, so an auto-apply setting that failed to verify (15-min backoff) fell through and popped a toast — on a fresh install the services/tasks Windows resists hit this repeatedly. Notifications are now gated on a pure, unit-tested SelectNotifiable rule: a setting the user set to auto-apply never notifies, on any tick, for any reason. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d7741df commit b67122f

8 files changed

Lines changed: 201 additions & 33 deletions

File tree

src/GamerGuardian/App.xaml.cs

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -154,19 +154,7 @@ protected override void OnStartup(StartupEventArgs e)
154154
.ToArray();
155155
_monitor = new MonitorService(_store, _allMonitors, report => _notifier.ShowAsync(report));
156156
_monitor.AutoAppliedRebootRequired += items =>
157-
{
158-
var descriptions = items.Select(i => i.Description).ToList();
159-
Dispatcher.BeginInvoke(() =>
160-
{
161-
try
162-
{
163-
var win = new GamerGuardian.UI.RebootPendingWindow(descriptions);
164-
win.Closed += (_, _) => ReleaseWindow(win);
165-
win.Show();
166-
}
167-
catch { }
168-
});
169-
};
157+
GamerGuardian.Services.RebootPrompt.Show(items.Select(i => i.Description).ToList());
170158

171159
_tray = new TrayIconHost();
172160
_tray.OpenSettingsRequested += ShowSettings;

src/GamerGuardian/Services/MonitorService.cs

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -367,15 +367,14 @@ private async Task TickAsync(MonitorTier? tier)
367367
AutoAppliedRebootRequired?.Invoke(rebootSettings);
368368
}
369369

370-
// Drifts that aren't auto-applied (or are in cooldown) still surface
371-
// as a notification so the user knows something's drifting and can
372-
// act manually. Settings the breaker has tripped are excluded: they're
373-
// logged once (LogCircuitBreaker) and would otherwise re-notify every
374-
// poll, swapping UAC spam for toast spam.
375-
var prompt = drifted
376-
.Where(a => !auto.Any(b => b.SettingId == a.SettingId))
377-
.Where(a => !_breaker.IsTripped(a.SettingId, now))
378-
.ToList();
370+
// Notify only about drift the user asked to be notified about
371+
// (Monitor on, Auto-apply off). A setting set to auto-apply — "silently
372+
// change to my desired state" — must NEVER produce a notification, even
373+
// on a tick where we couldn't apply it (verify-backoff or breaker
374+
// cooldown). Surfacing a toast for a silent setting is exactly the
375+
// "silent didn't mean silent" complaint: those failures are logged
376+
// (LogApplyResults / LogCircuitBreaker) and retried, not shown.
377+
var prompt = SelectNotifiable(drifted, id => _breaker.IsTripped(id, now));
379378
if (prompt.Count > 0)
380379
await _onDriftAsync(new DriftReport(prompt));
381380

@@ -393,6 +392,20 @@ private async Task TickAsync(MonitorTier? tier)
393392
}
394393
}
395394

395+
/// <summary>
396+
/// The notification-eligibility rule, pulled out pure so it can be unit-tested
397+
/// without a timer or the registry. A drifted setting is shown to the user only
398+
/// when it is NOT set to auto-apply (auto-apply means "silently change", so it
399+
/// never notifies) and is NOT currently tripped by the circuit breaker (tripped
400+
/// settings are logged once and would otherwise re-notify every poll).
401+
/// </summary>
402+
public static List<DriftItem> SelectNotifiable(
403+
IEnumerable<DriftItem> drifted, Func<string, bool> isTripped) =>
404+
drifted
405+
.Where(d => !d.AutoApply)
406+
.Where(d => !isTripped(d.SettingId))
407+
.ToList();
408+
396409
public void Dispose()
397410
{
398411
UnsubscribeSystemEvents();
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using GamerGuardian.UI;
2+
using Application = System.Windows.Application;
3+
4+
namespace GamerGuardian.Services;
5+
6+
/// <summary>
7+
/// Shows the single, app-level "a restart is required" prompt. Every path that
8+
/// applies a reboot-requiring change funnels through here — the manual Settings
9+
/// Apply / preset flow and the background auto-apply loop — so the prompt looks
10+
/// and behaves the same no matter what triggered it.
11+
///
12+
/// <para>The window is deliberately <b>unowned</b>. The manual Apply path shows
13+
/// this prompt and then closes the Settings window; a WPF owned window is
14+
/// destroyed together with its owner, so an owned reboot prompt vanished the
15+
/// instant the user clicked "Save &amp; close" after a bulk apply (the Extreme
16+
/// preset is the worst case — it applies several reboot-required settings at
17+
/// once). That is exactly the "applied the changes but never prompted to
18+
/// restart" bug. Unowned, combined with the app's <c>OnExplicitShutdown</c>
19+
/// mode, keeps the prompt alive until the user acts on it.</para>
20+
///
21+
/// <para>Singleton: a later batch that also needs a reboot replaces the prior
22+
/// prompt rather than stacking a second window (same pattern as
23+
/// <see cref="Notifier"/>). The restart action is identical regardless of which
24+
/// settings triggered it, so replacing the descriptions loses nothing.</para>
25+
/// </summary>
26+
public static class RebootPrompt
27+
{
28+
private static RebootPendingWindow? _current;
29+
30+
/// <summary>
31+
/// Surface the restart prompt for the given human-readable setting
32+
/// descriptions. No-op when the list is empty or the WPF application isn't
33+
/// running (e.g. headless self-test). Safe to call from any thread — the
34+
/// window is created on the UI dispatcher.
35+
/// </summary>
36+
public static void Show(IReadOnlyList<string> settingDescriptions)
37+
{
38+
if (settingDescriptions is null || settingDescriptions.Count == 0) return;
39+
var app = Application.Current;
40+
if (app is null) return;
41+
42+
app.Dispatcher.BeginInvoke(() =>
43+
{
44+
try
45+
{
46+
_current?.Close();
47+
var win = new RebootPendingWindow(settingDescriptions);
48+
_current = win;
49+
// RebootPendingWindow.OnClosed already releases its visual tree.
50+
win.Closed += (_, _) => { if (ReferenceEquals(_current, win)) _current = null; };
51+
win.Show();
52+
}
53+
catch { /* best-effort: a failed prompt must never crash the applier */ }
54+
});
55+
}
56+
}

src/GamerGuardian/UI/ApplyResultsWindow.xaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
</Grid.ColumnDefinitions>
3333
<ui:Button Grid.Column="0" Content="Open change log" MinWidth="140" Click="OpenLogButton_Click"/>
3434
<StackPanel Grid.Column="2" Orientation="Horizontal" HorizontalAlignment="Right">
35-
<ui:Button x:Name="RebootButton" Content="Reboot now" MinWidth="110" Margin="0,0,8,0" Visibility="Collapsed" Click="RebootButton_Click"/>
3635
<ui:Button x:Name="CloseButton" Content="Close" Appearance="Primary" MinWidth="90" Click="CloseButton_Click"/>
3736
</StackPanel>
3837
</Grid>

src/GamerGuardian/UI/ApplyResultsWindow.xaml.cs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ public ApplyResultsWindow(IReadOnlyList<ApplyResult> results)
1717
: $"Applied {ok} of {results.Count} settings ({fail} failed)";
1818
SubText.Text = "Each row shows the value before, what you wanted, and the actual value re-read from the OS after apply. The 'Mechanism' line tells you exactly where the change was written; the PowerShell snippet lets you verify the same value yourself outside the app.";
1919

20+
// The per-row "reboot to take effect" badge stays; the restart prompt
21+
// itself is now the app-level, unowned RebootPrompt (raised by the caller)
22+
// so it survives Save & close and matches the auto-apply path.
2023
ItemsList.ItemsSource = results.Select(r => new ResultRow(r)).ToList();
21-
22-
if (results.Any(r => r.RequiresReboot && r.Verified))
23-
RebootButton.Visibility = Visibility.Visible;
2424
}
2525

2626
private void CloseButton_Click(object sender, RoutedEventArgs e) => Close();
@@ -37,12 +37,6 @@ protected override void OnClosed(EventArgs e)
3737
catch { }
3838
}
3939

40-
private void RebootButton_Click(object sender, RoutedEventArgs e)
41-
{
42-
GamerGuardian.Services.RebootHelper.ForceRebootNow();
43-
Close();
44-
}
45-
4640
private void CopyButton_Click(object sender, RoutedEventArgs e)
4741
{
4842
if (sender is FrameworkElement fe && fe.Tag is string s && !string.IsNullOrEmpty(s))

src/GamerGuardian/UI/NotificationWindow.xaml.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,22 @@ private async void ApplyButton_Click(object sender, RoutedEventArgs e)
2828
{
2929
ApplyButton.IsEnabled = false;
3030
DismissButton.IsEnabled = false;
31+
// Applying from a drift notification is still an apply — if a reboot-required
32+
// setting was among them, the user needs the same restart prompt they'd get
33+
// from the Settings Apply button. Only items whose Apply didn't throw are
34+
// flagged (a declined UAC prompt shouldn't claim a reboot is pending).
35+
var rebootDescriptions = new List<string>();
3136
foreach (var item in _report.Items)
3237
{
33-
try { await item.Apply(); } catch { }
38+
try
39+
{
40+
await item.Apply();
41+
if (item.RequiresReboot) rebootDescriptions.Add(item.Description);
42+
}
43+
catch { }
3444
}
45+
if (rebootDescriptions.Count > 0)
46+
Services.RebootPrompt.Show(rebootDescriptions);
3547
Close();
3648
}
3749

src/GamerGuardian/UI/SettingsWindow.xaml.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,20 @@ private async Task ApplyChangesCoreAsync(bool closeAfter)
11641164
System.Windows.MessageBoxImage.Information);
11651165
}
11661166

1167+
// Surface a single restart prompt for any reboot-requiring change that
1168+
// actually landed this pass. Deferred to the end on purpose: ApplyAndVerify
1169+
// ran every drifted setting first, so a bulk apply (e.g. the Extreme preset)
1170+
// flags reboot across the whole batch and prompts once here, not per setting.
1171+
// RebootPrompt shows an UNOWNED window so it survives the Close() below —
1172+
// the ApplyResultsWindow is owned by this window and would be destroyed with
1173+
// it on Save & close, which is why bulk applies used to reboot-flag nothing.
1174+
var rebootDescriptions = results
1175+
.Where(r => r.RequiresReboot && r.Verified)
1176+
.Select(r => r.Description)
1177+
.ToList();
1178+
if (rebootDescriptions.Count > 0)
1179+
RebootPrompt.Show(rebootDescriptions);
1180+
11671181
if (closeAfter) Close();
11681182
}
11691183

@@ -1590,6 +1604,17 @@ private async Task RunCpuActionAsync(
15901604
"or (on dual-CCD X3D) the BIOS/driver/Game Bar dependencies aren't in place, see the Apply Results window for details.",
15911605
"GamerGuardian", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Warning);
15921606
}
1607+
1608+
// Consistency with the main Apply path: any reboot-required change
1609+
// that landed raises the shared restart prompt. Power-plan actions
1610+
// don't need a reboot today, so this is a no-op guard that keeps
1611+
// every apply path honest if that ever changes.
1612+
var rebootDescriptions = results
1613+
.Where(r => r.RequiresReboot && r.Verified)
1614+
.Select(r => r.Description)
1615+
.ToList();
1616+
if (rebootDescriptions.Count > 0)
1617+
RebootPrompt.Show(rebootDescriptions);
15931618
}
15941619
}
15951620
catch (Exception ex)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using System.Threading.Tasks;
4+
using GamerGuardian.Models;
5+
using GamerGuardian.Services;
6+
using Xunit;
7+
8+
namespace GamerGuardian.Tests;
9+
10+
/// <summary>
11+
/// Covers <see cref="MonitorService.SelectNotifiable"/> — the pure rule that
12+
/// decides which drifted settings raise an on-screen notification. The key
13+
/// guarantee under test: "silent means silent" — a setting the user set to
14+
/// auto-apply must never notify, on any tick, for any reason.
15+
/// </summary>
16+
public class MonitorServiceTests
17+
{
18+
private static DriftItem Drift(string id, bool autoApply) => new(
19+
SettingId: id,
20+
DisplayKey: "global",
21+
DisplayLabel: "Global",
22+
Description: id,
23+
CurrentValue: "On",
24+
DesiredValue: "Off",
25+
AutoApply: autoApply,
26+
Apply: () => Task.CompletedTask);
27+
28+
private static bool NeverTripped(string _) => false;
29+
30+
[Fact]
31+
public void NotifyOnly_Setting_IsNotified()
32+
{
33+
var drifted = new[] { Drift("hags", autoApply: false) };
34+
var result = MonitorService.SelectNotifiable(drifted, NeverTripped);
35+
Assert.Single(result);
36+
Assert.Equal("hags", result[0].SettingId);
37+
}
38+
39+
[Fact]
40+
public void AutoApply_Setting_IsNeverNotified()
41+
{
42+
// The whole point: a "silently change" setting must not produce a toast.
43+
var drifted = new[] { Drift("vrr", autoApply: true) };
44+
Assert.Empty(MonitorService.SelectNotifiable(drifted, NeverTripped));
45+
}
46+
47+
[Fact]
48+
public void AutoApply_Setting_IsNotNotified_EvenWhenNotBeingApplied()
49+
{
50+
// This models the verify-backoff / breaker-cooldown case: the setting is
51+
// auto-apply but couldn't be applied this tick. It must STILL stay silent —
52+
// this was the regression where backed-off auto-apply settings leaked into
53+
// the notification path.
54+
var drifted = new[] { Drift("svc:DoSvc", autoApply: true) };
55+
Assert.Empty(MonitorService.SelectNotifiable(drifted, NeverTripped));
56+
}
57+
58+
[Fact]
59+
public void BreakerTripped_NotifyOnly_Setting_IsSuppressed()
60+
{
61+
var drifted = new[] { Drift("hags", autoApply: false) };
62+
var result = MonitorService.SelectNotifiable(drifted, id => id == "hags");
63+
Assert.Empty(result);
64+
}
65+
66+
[Fact]
67+
public void MixedBatch_KeepsOnlyNotifyOnlyUntrippedSettings()
68+
{
69+
var drifted = new[]
70+
{
71+
Drift("auto1", autoApply: true), // silent -> excluded
72+
Drift("notify1", autoApply: false), // notify -> kept
73+
Drift("notify2", autoApply: false), // notify but tripped -> excluded
74+
Drift("auto2", autoApply: true), // silent -> excluded
75+
};
76+
77+
var result = MonitorService.SelectNotifiable(drifted, id => id == "notify2");
78+
79+
Assert.Equal(new[] { "notify1" }, result.Select(d => d.SettingId).ToArray());
80+
}
81+
}

0 commit comments

Comments
 (0)