Skip to content

Commit e0a1c38

Browse files
carterscodeclaude
andcommitted
feat(docs+verify): per-setting docs, Learn more expander, Verify all, state snapshot
Three substantial additions on top of the staged-apply + Windows AI work: 1. Per-setting rich documentation (SettingDocsCatalog) - New Models/SettingDetails record: What, Why, HowItHelps, Scenarios{}, Recommended, Risks, ReversibleVia. - SettingDocsCatalog populates entries for every shipped setting: 11 global toggles, power plan, 5 Windows AI policies, 3 Windows AI UWP packages, display HDR/Refresh/Resolution, key services (DiagTrack, MapsBroker, WSearch, SysMain, DoSvc, WerSvc, RetailDemo, Xbox*, WSAIFabricSvc). Content is opinionated -- it reflects what GamerGuardian recommends, and stays honest about risks. - SettingsReferenceGen renders the catalog as docs/SETTINGS-REFERENCE.md (42 KB, fully cross-linked). GamerGuardian.exe --gen-docs regenerates in place. A unit test asserts the committed file matches the catalog so they can't drift silently. 2. In-app "Learn more" expander per row (all 4 row templates) - GlobalToggleRow / ServiceRow / WindowsAiAppRow now expose LearnMoreContent (formatted text from SettingDocsCatalog) and LearnMoreVisibility. XAML row templates wrap their existing Grid in a StackPanel + ui:Expander Header="Learn more" so users can read the full doc without leaving the Settings window. 3. More verification + validation - ChangeLogger.LogStateSnapshot: dumps a [SNAPSHOT] block listing every monitored setting's current vs desired + per-row OK/DRIFT tag + a summary line. Written on session start (App.xaml.cs) and on the new Verify all button. - SettingsWindow Verify all button (footer): re-reads every monitored setting and writes the snapshot; pops a dialog summarizing how many drifted. Applies nothing -- safe to click any time. - ChangeApplier verify pass now retries up to 3x with 200 ms backoff for settings whose Apply succeeded but whose verify read raced the SCM / registry / AppX catalog propagation. - Save & close: short-circuits to pure close when _pendingCount == 0. Eliminates the surprising "I clicked Apply, then Save & close re-prompted UAC for the same items" cycle. - Apply/Save&close buttons disable during the in-flight pass to prevent overlapping handlers from racing UAC streams. 4. Documentation - docs/STAGED-APPLY-ARCHITECTURE.md: how the draft layer, MonitorService, ChangeLogger, ExternalReset detection, and the per-setting docs pipeline fit together. Includes data-shape walkthrough and lifecycle traces. - README: linked the new docs, added Windows AI tab description, expanded the Verification section to list every verification channel (SNAPSHOT, APPLY-START/END, EXTRESET, PREF-STAGE, Verify all button). Tests: 12 new SettingDocsCatalog tests + the catalog<->doc consistency test. 108/108 passing, Debug + Release builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 328bf50 commit e0a1c38

12 files changed

Lines changed: 2306 additions & 104 deletions

README.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ For each setting you choose: **monitor or not**, **desired value**, and whether
8080

8181
A curated catalog of services GamerGuardian can stop + disable (or set to Manual). One-click "Gaming optimized" preset, plus per-service Default/Manual/Disabled. Includes `DiagTrack` (telemetry), `MapsBroker`, `Fax`, `lfsvc` (Geolocation), Xbox services, `DoSvc` (Delivery Optimization), `iphlpsvc` (IP Helper), and more. See [`ServiceCatalog.cs`](src/GamerGuardian/Services/ServiceCatalog.cs) for the full list.
8282

83+
### Windows AI
84+
85+
Policy-toggle disables for Copilot, Recall, Click-to-Do, Edge Copilot/Hubs/GenAI, and Notepad Rewrite / Paint AI. Optional one-way removal of Windows AI UWP packages (`Microsoft.Copilot`, `Microsoft.Windows.Ai.Copilot.Provider`, `MicrosoftWindows.Client.AIX`). Inspired by [zoicware/RemoveWindowsAI](https://github.com/zoicware/RemoveWindowsAI) but stays in the safe "policy toggle + service disable" lane -- every change is reversed by deleting the same registry value. Walk-through in the [Windows AI section of SETTINGS-REFERENCE.md](docs/SETTINGS-REFERENCE.md#windows-ai-policies).
86+
8387
## Performance & gaming impact
8488

8589
Designed to be invisible during gameplay.
@@ -109,14 +113,21 @@ Pure user-mode P/Invoke. Each monitored setting is an `IMonitoredSetting` implem
109113

110114
## Verification
111115

112-
GamerGuardian doesn't ask you to take its word for it. Six independent ways to confirm what it's doing — see [Verification](https://github.com/carterscode/GamerGuardian/wiki/Verification) for the full rundown:
116+
GamerGuardian doesn't ask you to take its word for it. Independent ways to confirm what it's doing — see [Verification](https://github.com/carterscode/GamerGuardian/wiki/Verification) for the full rundown:
113117

114118
1. The Settings UI re-reads from the OS after every Apply
115119
2. The Apply Results window shows before / target / after for each setting
116-
3. Every change writes a copy-pasteable PowerShell verify command to [`changes.log`](https://github.com/carterscode/GamerGuardian/wiki/Logging)
117-
4. `GamerGuardian.exe --test` dumps every monitor's current readout to `%TEMP%`
118-
5. Every Release ships with a [`SHA256SUMS.txt`](https://github.com/carterscode/GamerGuardian/wiki/Security#reproducibility) you can verify against your local download
119-
6. Every monitor is one ~30-line file in [`src/GamerGuardian/Monitors/`](src/GamerGuardian/Monitors/)
120+
3. The footer **Verify all** button re-reads every monitored setting and writes a `[SNAPSHOT]` to `changes.log` -- nothing is applied
121+
4. Every change writes a copy-pasteable PowerShell **apply** and **verify** command to [`changes.log`](https://github.com/carterscode/GamerGuardian/wiki/Logging)
122+
5. Verbose log lines include `[SESSION]` (version + OS + elevation), `[APPLY-START]` / per-change record / `[APPLY-END]`, `[EXTRESET]` (Windows reverted a value we'd applied), and `[PREF-STAGE]` (a draft toggle, not yet applied)
123+
6. `GamerGuardian.exe --test` dumps every monitor's current readout to `%TEMP%`
124+
7. Every Release ships with a [`SHA256SUMS.txt`](https://github.com/carterscode/GamerGuardian/wiki/Security#reproducibility) you can verify against your local download
125+
8. Every monitor is one ~30-line file in [`src/GamerGuardian/Monitors/`](src/GamerGuardian/Monitors/)
126+
127+
## Documentation
128+
129+
- [**Settings reference**](docs/SETTINGS-REFERENCE.md) -- per-setting What / Why / How-it-helps / Per-scenario recommendation / Risks / Reversal. Generated from [`SettingDocsCatalog.cs`](src/GamerGuardian/Services/SettingDocsCatalog.cs); a unit test asserts they can't drift.
130+
- [**Staged-apply + verbose logging architecture**](docs/STAGED-APPLY-ARCHITECTURE.md) -- how the draft config, MonitorService, ChangeLogger, and EXTRESET detection fit together.
120131

121132
## Security
122133

docs/SETTINGS-REFERENCE.md

Lines changed: 899 additions & 0 deletions
Large diffs are not rendered by default.

docs/STAGED-APPLY-ARCHITECTURE.md

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Staged Apply + Verbose Logging Architecture
2+
3+
This document describes the architecture introduced in v0.1.38 that fixed the "every click immediately writes to disk and triggers a UAC prompt" bug from v0.1.37. The key idea: the Settings UI mutates a **draft copy** of `AppConfig`; the live config and `config.json` are untouched until the user explicitly clicks **Apply** or **Save & close**. Cancel (and closing the window without Save) discards the draft.
4+
5+
## Diagram
6+
7+
```
8+
+-------------------------------------------------------+
9+
| SettingsWindow |
10+
| |
11+
| on open: |
12+
| _config = _store.Load(); |
13+
| _draft = AppConfigCloner.Clone(_config); -------+----+
14+
| | |
15+
| every row binds to _draft.* references. | | the UI
16+
| on toggle: row setter writes to _draft, increments | | is fully
17+
| _pendingCount, logs PREF-STAGE. | | isolated
18+
| | | from disk
19+
| Cancel: _suppressSaveOnClose = true; Close() | | and from
20+
| (nothing committed -- draft discarded) | | the
21+
| | | background
22+
| Apply / Save & close: | | monitor
23+
| AppConfigCloner.CopyInto(_draft, _config); -+ | | until the
24+
| _store.Save(_config); | | | user
25+
| CheckDrift + Apply + Verify pass | | | commits.
26+
| RebaseDraftFromConfig(); | | |
27+
| LoadGlobals / Services / WindowsAi (...); | | |
28+
+--------------------------------------------------|----|----|
29+
| |
30+
+-------------------+ +--------------------+| |
31+
| MonitorService | | ConfigStore || |
32+
| | | || |
33+
| on each tick: | reads | config.json |<+ |
34+
| _store.Load() ------> | | |
35+
| CheckDrift | |
36+
| (auto-apply if AutoApply) | |
37+
| | |
38+
| tracks _lastVerified[settingId] for | |
39+
| external-reset detection | |
40+
+-------------------+ +--------------------+ |
41+
|
42+
+----------------+ writes verbose lines for |
43+
| ChangeLogger | every commit, drift, external reset, |
44+
| | session start, etc. |
45+
| changes.log |<--------------------------------------+
46+
+----------------+
47+
```
48+
49+
## Data shapes
50+
51+
### `AppConfig` (`Models/AppConfig.cs`)
52+
53+
Plain POCO. JSON-serialized to `%APPDATA%\GamerGuardian\config.json`. Fully deep-cloneable via JSON round-trip (that's what `AppConfigCloner.Clone` does). Background components like `MonitorService` keep a reference to the live `_config` instance; that's why `AppConfigCloner.CopyInto(source, target)` exists -- it commits the draft's field values into the existing live reference without breaking the captured pointer.
54+
55+
### `IMonitoredSetting` (`Monitors/IMonitoredSetting.cs`)
56+
57+
```csharp
58+
public interface IMonitoredSetting
59+
{
60+
string Id { get; }
61+
IEnumerable<DriftItem> CheckDrift(AppConfig config);
62+
}
63+
```
64+
65+
One implementation per managed setting kind (`HagsMonitor`, `WindowsServiceMonitor`, `CopilotMonitor`, etc.). `CheckDrift` reads the current OS state and yields `DriftItem` records only when current != desired. Each `DriftItem` carries an `Apply` lambda the runner calls; the lambda is responsible for performing the change (often shelling out to `sc.exe`, `reg.exe`, or AppX cmdlets).
66+
67+
### `DriftItem` (`Models/DriftReport.cs`)
68+
69+
```csharp
70+
public sealed record DriftItem(
71+
string SettingId, string DisplayKey, string DisplayLabel, string Description,
72+
string CurrentValue, string DesiredValue, bool AutoApply, Func<Task> Apply,
73+
bool RequiresReboot = false, bool IsMonitored = true,
74+
string RawBefore = "", string RawDesired = "");
75+
```
76+
77+
`RawBefore` / `RawDesired` carry the actual underlying values (registry dwords, sc-start-types, package names) so the log can record both display and raw forms.
78+
79+
### `ApplyResult` (`Models/ApplyResult.cs`)
80+
81+
The verbose record per applied change. Fields the log writes: `SettingId`, `Description`, `Before/Desired/After` (both display + raw), `Mechanism`, `ApplyCommand` (PowerShell), `VerifyCommand` (PowerShell), `ElapsedMs`, `Source` (manual / auto / auto-revert), `SessionId`, `ErrorMessage`, `ExternalResetDetected`, `StickinessCount`.
82+
83+
### `SettingDetails` (`Models/SettingDetails.cs`)
84+
85+
Long-form documentation per setting. Populated in `Services/SettingDocsCatalog`. Rendered to `docs/SETTINGS-REFERENCE.md` by `Services/SettingsReferenceGen` and surfaced in the UI's "Learn more" expander.
86+
87+
## Lifecycle traces
88+
89+
### User opens Settings, toggles a service to Disabled, clicks Apply
90+
91+
1. `SettingsWindow` ctor: `_config = _store.Load(); _draft = AppConfigCloner.Clone(_config);`.
92+
2. `LoadServices()` populates `ServiceRows` with `ServiceRow` instances whose `_pref` references point at `_draft.Services[name]`.
93+
3. User clicks the "Disabled" radio. `ServiceRow.DesiredDisabled` setter calls `SetDesired(ServiceTargetState.Disabled)` which mutates `_draft.Services[name].Desired`. Fires `_onPrefChanged` callback.
94+
4. `SettingsWindow.OnRowPrefChanged` logs `[PREF-STAGE]` to `changes.log` and increments `_pendingCount`. Status text shows "1 pending change."
95+
5. (Nothing else happens. No disk write. No UAC. Background `MonitorService` still sees the un-modified `_config` via `_store.Load()` on next tick.)
96+
6. User clicks Apply. `ApplyButton_Click` -> `ApplyChangesAsync(closeAfter: false)`:
97+
- `PersistFormToDraft()` flushes the form-level controls (LaunchAtStartup, PollSeconds, Theme, PowerPlan combo) into `_draft`.
98+
- `AppConfigCloner.CopyInto(_draft, _config)` commits the draft.
99+
- `_store.Save(_config)` writes `config.json`.
100+
- `CheckDrift` runs against `_config` for every monitor; returns the list of `DriftItem`s the user just caused.
101+
- `ChangeApplier.ApplyAndVerifyAsync(...)` runs each `Apply` lambda, then re-runs `CheckDrift` to verify. Per-item timings captured.
102+
- `ChangeLogger.LogApplyResults(results, "manual")` emits the `[APPLY-START]` / per-change record / `[APPLY-END]` lines.
103+
- `_monitorService.RecordVerifiedApplies(results)` seeds the in-memory `_lastVerified` table so the next background tick can correctly detect external resets without a one-cycle blind spot.
104+
- `RebaseDraftFromConfig()` re-clones `_config` into a fresh `_draft` and resets `_pendingCount`.
105+
- UI rebuilds rows from the new `_draft`. `ApplyResultsWindow.Show(...)` displays the per-change verify result.
106+
107+
### User clicks Save & close after a successful Apply
108+
109+
1. `SaveButton_Click`: `if (_pendingCount == 0) { Close(); return; }`.
110+
2. Because the previous Apply rebased the draft and reset the pending count, this guard short-circuits. No drift check, no UAC, just close.
111+
112+
### Background tick auto-applies a drifted setting and detects an external reset
113+
114+
1. `MonitorService.TickAsync` runs every `PollIntervalSeconds`. Loads `_config` from disk and runs `CheckDrift` for every monitor.
115+
2. For each `DriftItem` where `_lastVerified` has an entry, that's by definition an external reset (Windows or another tool changed a value we'd previously applied). `_stickiness[settingId]` is incremented and `[EXTRESET]` is logged.
116+
3. `auto` (the subset with `AutoApply == true` and not in the 15-minute backoff window) is split into `corrective` (settings with EXTRESET) and `initial`.
117+
4. Each gets its own session id. Corrective applies log as `source=auto-revert` with `ExternalResetDetected = true` and the current stickiness count, so a single `grep '\[EXTRESET'` / `grep 'auto-revert'` answers "what does Windows keep undoing, and what's GamerGuardian doing about it?"
118+
5. Verification failures move the setting into the 15-minute backoff so a stubborn setting doesn't pop UAC every 30 seconds.
119+
120+
## Log schema (`changes.log`)
121+
122+
| Marker | Written by | Meaning |
123+
|---|---|---|
124+
| `[SESSION ]` | `ChangeLogger.LogSessionStart` | App started. Includes version, OS, CLR, machine, user (elevated y/n), PID, config path. |
125+
| `[PREF-STAGE]` | `OnRowPrefChanged` | User toggled a draft preference. Not applied yet. |
126+
| `[APPLY-START]` | `LogApplyResults` | A batch of changes is about to apply. Includes session id, source, count. |
127+
| `[manual ]` etc. per-record | `LogApplyResults` | One verbose entry per change. Multi-line: settingId, location, before/desired/after, applyCmd, verifyCmd, elapsedMs, status. |
128+
| `[APPLY-END ]` | `LogApplyResults` | Same session id. Includes `verified=N/M` summary and total elapsed ms. |
129+
| `[EXTRESET ]` | `LogExternalReset` | Windows or another tool changed a value we'd previously applied. Includes how long the previous applied value held and the current stickiness count. |
130+
| `[PAUSE ]` | `LogPauseEvent` | MonitorService entered or left a paused state (fullscreen, benchmark, user manual). |
131+
| `[MEM ]` | `LogMemorySnapshot` | Periodic process memory snapshot. |
132+
133+
Source tags on per-change records:
134+
135+
| Source | Origin |
136+
|---|---|
137+
| `manual` | User clicked Apply or Save & close. |
138+
| `auto` | Background MonitorService tick auto-applied a setting that had drifted and had no prior verified state (i.e. first time we've seen it drift). |
139+
| `auto-revert` | Background tick auto-applied a setting Windows had externally reset (an EXTRESET line was logged immediately before). |
140+
141+
## Settings-reference doc
142+
143+
`docs/SETTINGS-REFERENCE.md` is **generated** from `Services/SettingDocsCatalog`. To regenerate:
144+
145+
```pwsh
146+
dotnet build src/GamerGuardian/GamerGuardian.csproj -c Debug
147+
.\src\GamerGuardian\bin\Debug\net8.0-windows10.0.22000.0\GamerGuardian.exe --gen-docs docs\SETTINGS-REFERENCE.md
148+
```
149+
150+
A unit test in `tests/GamerGuardian.Tests/SettingsReferenceGenTests.cs` asserts the committed file matches the generated output -- so the doc and the catalog can't drift apart silently.
151+
152+
## File map
153+
154+
| Concern | File |
155+
|---|---|
156+
| Deep clone / commit | `Services/AppConfigCloner.cs` |
157+
| Per-setting docs (data) | `Models/SettingDetails.cs` |
158+
| Per-setting docs (content) | `Services/SettingDocsCatalog.cs` |
159+
| Markdown rendering | `Services/SettingsReferenceGen.cs` |
160+
| Apply orchestration | `Services/ChangeApplier.cs` |
161+
| Verbose logger | `Services/ChangeLogger.cs` |
162+
| Background monitor + external-reset detection | `Services/MonitorService.cs` |
163+
| One-line mechanism / verify / apply PowerShell | `Services/SettingDocs.cs` |
164+
| Draft UI + Apply/Save&close/Cancel | `UI/SettingsWindow.xaml.cs` |
165+
| Verbose per-change result UI | `UI/ApplyResultsWindow.xaml.cs` |

src/GamerGuardian/App.xaml.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,32 @@ protected override void OnStartup(StartupEventArgs e)
3838
return;
3939
}
4040

41+
// --gen-docs DEST -- render the settings catalog as markdown and exit.
42+
// Used to regenerate docs/SETTINGS-REFERENCE.md from CI / pre-commit so
43+
// the doc and the catalog can't drift apart.
44+
for (int i = 0; i < e.Args.Length; i++)
45+
{
46+
if (e.Args[i] == "--gen-docs")
47+
{
48+
var dest = (i + 1 < e.Args.Length) ? e.Args[i + 1] : "docs\\SETTINGS-REFERENCE.md";
49+
try
50+
{
51+
var md = Services.SettingsReferenceGen.Render();
52+
var dir = System.IO.Path.GetDirectoryName(dest);
53+
if (!string.IsNullOrEmpty(dir)) System.IO.Directory.CreateDirectory(dir);
54+
System.IO.File.WriteAllText(dest, md);
55+
Environment.ExitCode = 0;
56+
}
57+
catch (Exception ex)
58+
{
59+
LogException("gen-docs", ex);
60+
Environment.ExitCode = 1;
61+
}
62+
Shutdown();
63+
return;
64+
}
65+
}
66+
4167
_singleInstanceMutex = new Mutex(initiallyOwned: true, "GamerGuardian.SingleInstance", out bool created);
4268
if (!created)
4369
{
@@ -115,6 +141,27 @@ protected override void OnStartup(StartupEventArgs e)
115141

116142
_monitor.Start();
117143

144+
// Verbose baseline: log the current state of every monitored setting
145+
// at session start. Gives users a known baseline to grep against later
146+
// when they see drift; also surfaces "Oh, that one's drifting" without
147+
// requiring the user to open Settings.
148+
try
149+
{
150+
var rows = _allMonitors
151+
.SelectMany(m =>
152+
{
153+
try
154+
{
155+
return m.CheckDrift(cfg).Select(d =>
156+
(d.SettingId, d.DisplayLabel, current: d.CurrentValue, desired: d.DesiredValue, inSync: false));
157+
}
158+
catch { return Enumerable.Empty<(string, string, string, string, bool)>(); }
159+
})
160+
.ToList();
161+
if (rows.Count > 0) ChangeLogger.LogStateSnapshot(rows);
162+
}
163+
catch { }
164+
118165
bool isFirstRun = !System.IO.File.Exists(_store.ConfigPath);
119166
if (isFirstRun || e.Args.Any(a => a == "--show-settings")) ShowSettings();
120167

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
namespace GamerGuardian.Models;
2+
3+
/// <summary>
4+
/// Long-form documentation for a single setting GamerGuardian manages. One
5+
/// entry per <c>settingId</c>; populated in
6+
/// <see cref="GamerGuardian.Services.SettingDocsCatalog"/>. Surfaced in the
7+
/// UI via the per-row "Learn more" expander and also dumped into
8+
/// <c>docs/SETTINGS-REFERENCE.md</c> so the docs and the app can't drift apart.
9+
///
10+
/// <para>The shape is opinionated: every entry is expected to answer the same
11+
/// four questions in the same order so users can scan across settings without
12+
/// re-learning a layout each time.</para>
13+
/// </summary>
14+
/// <param name="SettingId">Matches <c>IMonitoredSetting.Id</c> (e.g. "hags",
15+
/// "service:DiagTrack", "ai.copilot"). Used as the lookup key.</param>
16+
/// <param name="DisplayName">Human-readable name for the docs page header.</param>
17+
/// <param name="What">One paragraph: what this setting actually controls inside
18+
/// Windows. Not a recommendation, not a why -- just plain mechanics.</param>
19+
/// <param name="Why">When and why a user would care about changing this. The
20+
/// "do I need to think about this?" answer.</param>
21+
/// <param name="HowItHelps">The concrete benefit if the recommended value is
22+
/// applied -- frame ratability, input latency, CPU headroom, etc.</param>
23+
/// <param name="Scenarios">Per-use-case recommendations. Keys are scenario
24+
/// names ("Competitive FPS", "Streaming + game", "Casual single-player",
25+
/// "Productivity / not gaming"); values are the recommended state in plain
26+
/// English.</param>
27+
/// <param name="Recommended">The single default GamerGuardian ships with --
28+
/// what the "Gaming optimized" preset (or equivalent) sets.</param>
29+
/// <param name="Risks">What can break if the user changes this away from the
30+
/// Windows default. Honest list of consequences, not marketing.</param>
31+
/// <param name="ReversibleVia">How to undo this change if the user decides to.
32+
/// Often a registry-value delete or a service-startup-type reset.</param>
33+
public sealed record SettingDetails(
34+
string SettingId,
35+
string DisplayName,
36+
string What,
37+
string Why,
38+
string HowItHelps,
39+
IReadOnlyDictionary<string, string> Scenarios,
40+
string Recommended,
41+
string Risks,
42+
string ReversibleVia);

0 commit comments

Comments
 (0)