From 6860b65631b2bc3a9cd2a738ad1396b33ad7b539 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:23:56 -0700 Subject: [PATCH 01/19] docs(ui): record UI overhaul decisions and the cut status surfaces Records the framework, navigation, setting-model, elevation, testing and search decisions so they survive across sessions, plus a Decided against section for the status surfaces that were cut (managed count, last-scan timestamp, pause reason, pause persistence, suspended-set exposure). Drifted count is the only status surface. --- docs/ui-overhaul.md | 173 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/ui-overhaul.md diff --git a/docs/ui-overhaul.md b/docs/ui-overhaul.md new file mode 100644 index 0000000..8ccde64 --- /dev/null +++ b/docs/ui-overhaul.md @@ -0,0 +1,173 @@ +# UI overhaul — recorded decisions + +Decisions made for the UI and navigation overhaul. This file exists so the +decisions survive across sessions. It records what was decided, not why, and not +what remains to be built. + +Status: decisions recorded. Tab extraction not started. + +## Framework + +- Staying on **WPF** with **WPF-UI 3.0.5**. +- Not moving to WinUI 3. Not moving to Avalonia. + +## Window and navigation + +- **One window.** The existing `TabControl` is replaced by the WPF-UI + **`NavigationView`**. +- Navigation is grouped by **user intent, not subsystem**: + +``` +Status (pinned, above the groups) +Performance + ├─ Gaming + ├─ Display + └─ CPU and power +Privacy + ├─ Telemetry + ├─ Windows AI + └─ Network +Cleanup + ├─ Debloat + └─ Services +Reference + └─ BIOS +General (footer) +``` + +- **Network under Privacy is provisional**, pending what that section actually + contains. + +## Setting model + +- Every managed setting surfaces **three properties** in the UI: + 1. **Desired state** + 2. **Current state** + 3. **Enforcement mode** — one of *auto apply* / *monitor only* / *unmanaged* +- Enforcement mode has a **section-level default with per-setting override**. + +## Monitoring surface + +- **Drifted count is the only status surface.** It is shown in the window, per + section, and in aggregate. +- Pause remains visible in the window, not tray-only. + +### Decided against + +Cut from the plan. Not deferred — decided against. + +- Managed count. +- Last-scan timestamp. +- Pause reason exposure (fullscreen / benchmark / user). +- Persisting user pause across restarts. +- Exposing the verify-backoff and circuit-breaker suspended set. + +## Elevation + +- **UAC stays per-action, `asInvoker`.** No manifest elevation change. + +## Testing + +- **FlaUI + xUnit** for UI tests. + +## Search + +- **Search index metadata is built during the tab extraction**, not added later. + +--- + +## Open questions + +Items from the enforcement-model investigation where the code does not currently +support a decision recorded above. + +### Section-level enforcement default is not supported today + +There is no section, group, or category concept anywhere in the config layer. +`GlobalPreferences` (`src/GamerGuardian/Models/AppConfig.cs:97-173`) is a flat +list of 40 individually-named `ToggleSettingPref` properties. Grouping exists +only as C# comments in that file (lines 124, 138, 148, 153, 165, 170) and as +which XAML tab a row is built into. There is no inheritance or override +mechanism to build on. + +Open: where the section default is stored, and how an unset per-setting override +is represented (the current `bool Monitor` / `bool AutoApply` cannot express +"inherit" without becoming nullable or moving to an enum). + +### Enforcement mode is two independent booleans, not a tri-state + +The three modes exist and work per setting today, but they are derived from two +independent flags (`Monitor`, `AutoApply`) rather than one value. The +combination `Monitor=false, AutoApply=true` is representable in config and +resolves to unmanaged (`Monitor=false` wins, filtered at +`src/GamerGuardian/Services/MonitorService.cs:238`). + +Open: whether the UI models enforcement as a single tri-state that is projected +onto the two booleans, or the config is migrated to an explicit enum, and what +happens to existing config files containing the fourth combination. + +### Pause state is not persisted and its reason is not readable + +`MonitorService.IsUserPaused` (`MonitorService.cs:65`) and the `PauseChanged` +event (line 66) are public, so the window can read and display user pause today. +Two gaps: + +- The paused state is **not persisted** — `_userPaused` (line 31) is a private + field with no `AppConfig` counterpart, so it resets to false on restart. +- The **automatic** pause reasons (fullscreen app, benchmark running) are held in + the private `_activePauseReason` (line 32) with no accessor. The UI cannot show + *why* monitoring is paused, only that the user paused it. + +Open: whether pause should persist across restarts, and whether the automatic +pause reason needs to be exposed. + +### There is no last-scan timestamp to bind to + +No last-scan, last-check, or scan-time value is recorded anywhere in the +codebase. `_lastVerified` (`MonitorService.cs:51`) is a private per-setting +record of the last successful *apply*, not a scan time, and `ChangeLogger` +timestamps go to `changes.log` only. + +Open: whether the Status page shows a last-scan time, which requires adding that +state to `MonitorService`. + +### There is no single settings catalog for the search index + +Setting metadata is scattered across `SettingDocsCatalog`, `SettingDocs`, +`SettingRecommendations`, `ServiceCatalog` (28 entries), `ScheduledTaskCatalog` +(5), `WindowsAiAppCatalog` (4), `CpuTuneCatalog`, and inline string literals in +`src/GamerGuardian/UI/SettingsWindow.xaml.cs`. The display names and +descriptions actually shown in the UI are the inline literals, not a catalog. +Section membership exists nowhere in data. + +Open: the shape of the unified catalog the search index is built from, given the +decision that it is built during tab extraction. + +### Requires-restart and needs-elevation are not queryable metadata + +- **Requires restart** is per-`DriftItem` at runtime + (`src/GamerGuardian/Models/DriftReport.cs:12`), set by 9 monitors, plus + `ServiceDefinition.RequiresReboot` + (`src/GamerGuardian/Models/ServiceDefinition.cs:15`). It cannot be read for a + setting without running `CheckDrift`. +- **Needs elevation** has no flag anywhere. It is implicit in whether a monitor + calls `ElevatedRegistry` (25 monitor files do) and appears as free prose in + some `SettingDocsCatalog` `What:` text. + +Open: whether the UI needs these as static per-setting metadata, which would +require adding them to a catalog. + +### Network section contents + +The Network tab today contains two settings: Nagle's algorithm and NIC power +management (`AppConfig.cs:170-172`), both latency/throughput tweaks. This is the +input to the provisional decision to place Network under Privacy. + +### ConsolidateNotifications is stored but never read + +`AppConfig.ConsolidateNotifications` (`AppConfig.cs:9`) is persisted, cloned +(`AppConfigCloner.cs:45`), and bound to a checkbox +(`SettingsWindow.xaml.cs:75, 1214`), but no code reads it to change notification +behavior. `Notifier` does not consult it. + +Open: whether this setting is implemented, removed, or carried forward as-is. From 2fb86b8b754a9e66b27fb68a0a657e6bcc4b63e9 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:24:09 -0700 Subject: [PATCH 02/19] feat(settings): map every setting id to one of nine UI sections The UI overhaul needs to count drift per section, which requires knowing which section a setting belongs to. Nothing in the code recorded that: section membership existed only as which Load*() method built the row and which TabItem hosted it. Adds SettingSectionMap.SectionFor(id) -> SettingSection, covering the 46 global ids plus the prefixed families (service:, task:, ai.app:, hdr:, refresh:, resolution:, drr:). Unmapped ids return an explicit Unknown -- never a thrown exception, never a silent default. Prefix parsing is not duplicated: it is extracted out of SettingDocsCatalog.Get into SettingDocsCatalog.ParseId, which both now call, so the two can never disagree about what "service:foo" means. Get's behavior is unchanged (its 172 existing tests pass untouched). Section membership follows the Load*() method and hosting TabItem, not the AppConfig grouping comments. Two places they disagree, documented inline: faststartup/visualfx sit under a "System toggles" comment with powerthrottling but are built onto the Global gaming tab, while powerthrottling is on CPU / Power; netthrottle is stored in AppConfig's ungrouped block but has been presented on the Network tab since v0.1.46. The table deliberately knows nothing about navigation groups -- grouping is a shell concern. Per-section counts (asserted, so a new catalog entry without a section decision fails the build): Gaming 13, Display 4, CpuPower 3, Telemetry 6, WindowsAi 13, Network 3, Debloat 8, Services 33, Bios 0, Unknown 0. --- .../Services/SettingDocsCatalog.cs | 75 ++++++-- .../Services/SettingSectionMap.cs | 162 ++++++++++++++++ .../SettingSectionMapTests.cs | 174 ++++++++++++++++++ 3 files changed, 391 insertions(+), 20 deletions(-) create mode 100644 src/GamerGuardian/Services/SettingSectionMap.cs create mode 100644 tests/GamerGuardian.Tests/SettingSectionMapTests.cs diff --git a/src/GamerGuardian/Services/SettingDocsCatalog.cs b/src/GamerGuardian/Services/SettingDocsCatalog.cs index c204509..e669e49 100644 --- a/src/GamerGuardian/Services/SettingDocsCatalog.cs +++ b/src/GamerGuardian/Services/SettingDocsCatalog.cs @@ -18,33 +18,68 @@ namespace GamerGuardian.Services; /// recommendation. Reversibility is included on every entry so a worried /// user can see how to undo any change before they make it. /// +/// +/// The family a setting id belongs to, as determined by its prefix. See +/// — that method is the only place the +/// prefix convention is implemented. +/// +public enum SettingIdKind +{ + /// No recognized prefix — a global setting id such as "hags", and also + /// the bare, colon-less display ids ("hdr", "refresh", "resolution", "drr"). + Global, + Service, + AiApp, + ScheduledTask, + Hdr, + RefreshRate, + Resolution, + Drr, +} + public static class SettingDocsCatalog { + /// + /// Splits a setting id into the family its prefix names and the remainder after + /// that prefix. This is the single implementation of the id-prefix convention + /// (service:, ai.app:, task:, hdr:, refresh:, + /// resolution:, drr:) shared by and + /// so the two can never drift apart. + /// + /// An id with no recognized prefix is + /// and the remainder is the whole id. Note the bare display ids ("hdr", + /// "refresh", "resolution", "drr") carry no colon and so parse as Global — + /// callers that care must handle both spellings. + /// + public static (SettingIdKind Kind, string Remainder) ParseId(string? settingId) + { + if (settingId is null) return (SettingIdKind.Global, string.Empty); + if (settingId.StartsWith("service:")) return (SettingIdKind.Service, settingId["service:".Length..]); + if (settingId.StartsWith("ai.app:")) return (SettingIdKind.AiApp, settingId["ai.app:".Length..]); + if (settingId.StartsWith("task:")) return (SettingIdKind.ScheduledTask, settingId["task:".Length..]); + if (settingId.StartsWith("hdr:")) return (SettingIdKind.Hdr, settingId["hdr:".Length..]); + if (settingId.StartsWith("refresh:")) return (SettingIdKind.RefreshRate, settingId["refresh:".Length..]); + if (settingId.StartsWith("resolution:")) return (SettingIdKind.Resolution, settingId["resolution:".Length..]); + if (settingId.StartsWith("drr:")) return (SettingIdKind.Drr, settingId["drr:".Length..]); + return (SettingIdKind.Global, settingId); + } + public static SettingDetails? Get(string settingId) { if (settingId is null) return null; - if (settingId.StartsWith("service:")) - { - var name = settingId["service:".Length..]; - return Services.TryGetValue(name, out var d) ? d : null; - } - if (settingId.StartsWith("ai.app:")) + var (kind, rest) = ParseId(settingId); + return kind switch { - var pkg = settingId["ai.app:".Length..]; - return AiApps.TryGetValue(pkg, out var d) ? d : null; - } - if (settingId.StartsWith("task:")) - { - var path = settingId["task:".Length..]; - return ScheduledTasks.TryGetValue(path, out var d) ? d : null; - } - if (settingId.StartsWith("hdr:")) return Hdr; - if (settingId.StartsWith("refresh:")) return RefreshRate; - if (settingId.StartsWith("resolution:")) return Resolution; - if (settingId.StartsWith("drr:")) return Drr; - - return Globals.TryGetValue(settingId, out var g) ? g : null; + SettingIdKind.Service => Services.TryGetValue(rest, out var s) ? s : null, + SettingIdKind.AiApp => AiApps.TryGetValue(rest, out var a) ? a : null, + SettingIdKind.ScheduledTask => ScheduledTasks.TryGetValue(rest, out var t) ? t : null, + SettingIdKind.Hdr => Hdr, + SettingIdKind.RefreshRate => RefreshRate, + SettingIdKind.Resolution => Resolution, + SettingIdKind.Drr => Drr, + _ => Globals.TryGetValue(rest, out var g) ? g : null, + }; } /// Every documented setting. Used by docs generation and tests. diff --git a/src/GamerGuardian/Services/SettingSectionMap.cs b/src/GamerGuardian/Services/SettingSectionMap.cs new file mode 100644 index 0000000..91bfb01 --- /dev/null +++ b/src/GamerGuardian/Services/SettingSectionMap.cs @@ -0,0 +1,162 @@ +namespace GamerGuardian.Services; + +/// +/// The nine sections a managed setting can belong to. These mirror where a setting +/// is actually presented to the user today, not how it is stored. +/// +/// This is deliberately flat. Sections do not know which navigation +/// group they sit under — grouping is a shell concern and stays out of this +/// table. +/// +public enum SettingSection +{ + /// The id is not mapped. Returned explicitly rather than defaulting or + /// throwing, so an unmapped id is visible to callers and to tests. + Unknown, + Gaming, + Display, + CpuPower, + Telemetry, + WindowsAi, + Network, + Debloat, + Services, + Bios, +} + +/// +/// Maps a setting id to the one section it belongs to. +/// +/// Source of truth is the Load*() methods in +/// UI/SettingsWindow.xaml.cs and which TabItem in +/// UI/SettingsWindow.xaml hosts the row they build — i.e. where the user +/// actually sees the setting. The grouping comments in Models/AppConfig.cs +/// corroborate most of this but are not authoritative; where they disagree, the +/// Load*() method wins. The known disagreements are called out on the +/// entries below. +/// +/// Prefixed ids (service:, task:, ai.app:, hdr:, +/// refresh:, resolution:, drr:) are resolved by their family +/// via — the same parsing +/// uses, not a second copy of it. Everything +/// else is looked up in . +/// +public static class SettingSectionMap +{ + /// + /// Section for a setting id, or when the id + /// is unmapped, empty, or null. Never throws; never guesses a default. + /// + public static SettingSection SectionFor(string? settingId) + { + if (string.IsNullOrWhiteSpace(settingId)) return SettingSection.Unknown; + + var (kind, rest) = SettingDocsCatalog.ParseId(settingId); + return kind switch + { + // Both service and scheduled-task rows are built by LoadServices() / + // LoadScheduledTasks() into the one "Windows services" TabItem. + SettingIdKind.Service => SettingSection.Services, + SettingIdKind.ScheduledTask => SettingSection.Services, + // LoadWindowsAi() builds the UWP app-removal rows into the same + // "Windows AI" TabItem as the policy toggles. + SettingIdKind.AiApp => SettingSection.WindowsAi, + // Per-display instance ids from LoadDisplays() -> "Display" TabItem. + SettingIdKind.Hdr => SettingSection.Display, + SettingIdKind.RefreshRate => SettingSection.Display, + SettingIdKind.Resolution => SettingSection.Display, + SettingIdKind.Drr => SettingSection.Display, + _ => Globals.TryGetValue(rest, out var s) ? s : SettingSection.Unknown, + }; + } + + /// True when the id maps to a real section. + public static bool IsMapped(string? settingId) => SectionFor(settingId) != SettingSection.Unknown; + + /// Every explicitly mapped id. Exposed for tests and for the section + /// counts; prefix-resolved families are not listed here because they are + /// unbounded (one id per installed service, task, package, and display). + public static IReadOnlyDictionary MappedIds => Globals; + + private static readonly Dictionary Globals = + new(StringComparer.OrdinalIgnoreCase) + { + // ---- Gaming: LoadGlobals() -> "Global gaming" TabItem ---- + ["gamemode"] = SettingSection.Gaming, + ["gamedvr"] = SettingSection.Gaming, + ["hags"] = SettingSection.Gaming, + ["memintegrity"] = SettingSection.Gaming, + ["vbs"] = SettingSection.Gaming, + ["sysresponse"] = SettingSection.Gaming, + ["usbsuspend"] = SettingSection.Gaming, + ["gamestask"] = SettingSection.Gaming, + ["mouseaccel"] = SettingSection.Gaming, + ["fso"] = SettingSection.Gaming, + ["vrr"] = SettingSection.Gaming, + // faststartup + visualfx are built by LoadGlobals() onto the Global + // gaming tab, even though AppConfig.cs:165 groups them under a "System + // toggles" comment together with powerthrottling, which lives on the + // CPU / Power tab. Load*() wins: these two are Gaming. + ["faststartup"] = SettingSection.Gaming, + ["visualfx"] = SettingSection.Gaming, + + // ---- Display: LoadDisplays() -> "Display" TabItem ---- + // The bare, colon-less base ids. Per-display instance ids ("hdr:KEY") + // are resolved by prefix in SectionFor. + ["hdr"] = SettingSection.Display, + ["refresh"] = SettingSection.Display, + ["resolution"] = SettingSection.Display, + ["drr"] = SettingSection.Display, + + // ---- CPU and power: "CPU / Power" TabItem ---- + // powerthrottling comes from LoadPowerToggles(); powerplan is populated + // by LoadGlobals() but its card is hosted in the CPU / Power TabItem, + // and cpuplan is the plan-builder action on the same tab. Section + // follows where the user sees them. + ["powerthrottling"] = SettingSection.CpuPower, + ["powerplan"] = SettingSection.CpuPower, + ["cpuplan"] = SettingSection.CpuPower, + + // ---- Telemetry: LoadPrivacy() -> "Privacy" TabItem ---- + ["privacy.advertisingid"] = SettingSection.Telemetry, + ["privacy.tailoredexp"] = SettingSection.Telemetry, + ["privacy.cdp"] = SettingSection.Telemetry, + ["privacy.activityhistory"] = SettingSection.Telemetry, + ["privacy.speech"] = SettingSection.Telemetry, + ["privacy.inking"] = SettingSection.Telemetry, + + // ---- Windows AI: LoadWindowsAi() -> "Windows AI" TabItem ---- + ["ai.copilot"] = SettingSection.WindowsAi, + ["ai.recall"] = SettingSection.WindowsAi, + ["ai.clicktodo"] = SettingSection.WindowsAi, + ["ai.edge"] = SettingSection.WindowsAi, + ["ai.notepadpaint"] = SettingSection.WindowsAi, + ["ai.settingssearch"] = SettingSection.WindowsAi, + ["ai.actions"] = SettingSection.WindowsAi, + ["ai.inputinsights"] = SettingSection.WindowsAi, + ["ai.office"] = SettingSection.WindowsAi, + + // ---- Network: LoadNetwork() -> "Network" TabItem ---- + // netthrottle is stored in AppConfig's ungrouped top block but has been + // presented on the Network tab since v0.1.46. Load*() wins. + ["netthrottle"] = SettingSection.Network, + ["network.nagle"] = SettingSection.Network, + ["network.nicpower"] = SettingSection.Network, + + // ---- Debloat: LoadDebloat() -> "Debloat" TabItem ---- + // Split across two hosts on one tab (DebloatAdsList and + // DebloatBackgroundList); both are the Debloat section. + ["debloat.suggestedcontent"] = SettingSection.Debloat, + ["debloat.spotlight"] = SettingSection.Debloat, + ["debloat.finishsetup"] = SettingSection.Debloat, + ["debloat.startrecommend"] = SettingSection.Debloat, + ["debloat.explorerads"] = SettingSection.Debloat, + ["debloat.feedback"] = SettingSection.Debloat, + ["debloat.widgets"] = SettingSection.Debloat, + ["debloat.edge"] = SettingSection.Debloat, + + // ---- BIOS: the "Recommended BIOS" TabItem is advisory text only + // (BiosGuidanceList renders CpuTuneCatalog BiosRecommendation entries). + // It hosts no managed setting, so no id maps to SettingSection.Bios. + }; +} diff --git a/tests/GamerGuardian.Tests/SettingSectionMapTests.cs b/tests/GamerGuardian.Tests/SettingSectionMapTests.cs new file mode 100644 index 0000000..74d2c3b --- /dev/null +++ b/tests/GamerGuardian.Tests/SettingSectionMapTests.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GamerGuardian.Services; +using Xunit; +using Xunit.Abstractions; + +namespace GamerGuardian.Tests; + +/// +/// Guards the setting-id to section map. The central promise: every id the app can +/// actually produce resolves to a real section, and nothing silently lands in +/// . +/// +public class SettingSectionMapTests +{ + private readonly ITestOutputHelper _out; + + public SettingSectionMapTests(ITestOutputHelper output) => _out = output; + + /// + /// Every setting id the app can produce, built the same way the monitors build + /// them: the documented globals plus one id per catalog entry. Per-display + /// instance ids ("hdr:KEY") are machine-dependent and covered separately. + /// + private static IEnumerable AllKnownIds() => + SettingSectionMap.MappedIds.Keys + .Concat(SettingDocsCatalog.All.Select(d => d.SettingId)) + .Concat(ServiceCatalog.All.Select(d => $"service:{d.Name.ToLowerInvariant()}")) + .Concat(ScheduledTaskCatalog.All.Select(d => $"task:{d.TaskPath.ToLowerInvariant()}")) + .Concat(WindowsAiAppCatalog.All.Select(d => $"ai.app:{d.PackageName}")) + .Distinct(StringComparer.OrdinalIgnoreCase); + + [Fact] + public void EveryKnownId_ResolvesToASection_NoneUnknown() + { + var unmapped = AllKnownIds() + .Where(id => SettingSectionMap.SectionFor(id) == SettingSection.Unknown) + .ToList(); + + Assert.True(unmapped.Count == 0, + "These setting ids do not map to a section: " + string.Join(", ", unmapped)); + } + + [Fact] + public void EverySettingDocsCatalogEntry_ResolvesToASection() + { + foreach (var d in SettingDocsCatalog.All) + Assert.True(SettingSectionMap.IsMapped(d.SettingId), $"unmapped: {d.SettingId}"); + } + + [Fact] + public void EveryServiceCatalogEntry_MapsToServices() + { + Assert.NotEmpty(ServiceCatalog.All); + foreach (var d in ServiceCatalog.All) + Assert.Equal(SettingSection.Services, + SettingSectionMap.SectionFor($"service:{d.Name.ToLowerInvariant()}")); + } + + [Fact] + public void EveryScheduledTaskCatalogEntry_MapsToServices() + { + Assert.NotEmpty(ScheduledTaskCatalog.All); + foreach (var d in ScheduledTaskCatalog.All) + Assert.Equal(SettingSection.Services, + SettingSectionMap.SectionFor($"task:{d.TaskPath.ToLowerInvariant()}")); + } + + [Fact] + public void EveryWindowsAiAppCatalogEntry_MapsToWindowsAi() + { + Assert.NotEmpty(WindowsAiAppCatalog.All); + foreach (var d in WindowsAiAppCatalog.All) + Assert.Equal(SettingSection.WindowsAi, + SettingSectionMap.SectionFor($"ai.app:{d.PackageName}")); + } + + /// + /// CpuTuneCatalog entries are keyed by recipe key ("amd-single-x3d-exact"), which + /// are not setting ids. The one setting id the CPU tuning feature produces is + /// "cpuplan" (see CpuPlanApply), and it must land on the CPU / Power section. + /// + [Fact] + public void CpuTuneCatalog_ContributesCpuPlanId_MappedToCpuPower() + { + Assert.NotEmpty(CpuTuneCatalog.All); + Assert.Equal(SettingSection.CpuPower, SettingSectionMap.SectionFor("cpuplan")); + Assert.Equal(SettingSection.CpuPower, SettingSectionMap.SectionFor("powerplan")); + } + + [Theory] + [InlineData("hdr")] + [InlineData("refresh")] + [InlineData("resolution")] + [InlineData("drr")] + public void DisplayIds_MapToDisplay_BareAndPerInstance(string baseId) + { + // The catalog carries the bare id; the monitors emit ":". + Assert.Equal(SettingSection.Display, SettingSectionMap.SectionFor(baseId)); + Assert.Equal(SettingSection.Display, SettingSectionMap.SectionFor($"{baseId}:DISPLAY1")); + Assert.Equal(SettingSection.Display, SettingSectionMap.SectionFor($"{baseId}:\\\\.\\DISPLAY2")); + } + + [Fact] + public void PrefixedIds_ResolveByFamily_EvenWhenTheEntryIsUnknownToTheDocsCatalog() + { + // Section is a property of the family, so an id for a service/task/package + // with no docs entry still lands in the right section. + Assert.Equal(SettingSection.Services, SettingSectionMap.SectionFor("service:notarealservice")); + Assert.Equal(SettingSection.Services, SettingSectionMap.SectionFor(@"task:\Some\Unknown\Task")); + Assert.Equal(SettingSection.WindowsAi, SettingSectionMap.SectionFor("ai.app:Not.A.Real.Package")); + } + + [Fact] + public void ServiceIdMatching_IsCaseInsensitive() + { + // The monitor lowercases the service name; the docs catalog keeps original + // casing. Both spellings must resolve. + Assert.Equal(SettingSection.Services, SettingSectionMap.SectionFor("service:DiagTrack")); + Assert.Equal(SettingSection.Services, SettingSectionMap.SectionFor("service:diagtrack")); + Assert.Equal(SettingSection.Gaming, SettingSectionMap.SectionFor("GameMode")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("definitely.not.a.real.setting")] + [InlineData("privacy.notreal")] + public void UnmappedOrEmptyIds_ReturnUnknown_AndDoNotThrow(string? id) + { + Assert.Equal(SettingSection.Unknown, SettingSectionMap.SectionFor(id)); + Assert.False(SettingSectionMap.IsMapped(id)); + } + + [Fact] + public void NoIdMapsToBios_TheTabIsAdvisoryOnly() + { + Assert.DoesNotContain(SettingSection.Bios, SettingSectionMap.MappedIds.Values); + Assert.DoesNotContain(AllKnownIds(), id => SettingSectionMap.SectionFor(id) == SettingSection.Bios); + } + + /// + /// Reports the per-section counts and pins the totals, so a setting added to a + /// catalog without a section decision shows up as a failure here. + /// + [Fact] + public void ReportPerSectionCounts() + { + var bySection = AllKnownIds() + .GroupBy(SettingSectionMap.SectionFor) + .ToDictionary(g => g.Key, g => g.Count()); + + int Count(SettingSection s) => bySection.TryGetValue(s, out var n) ? n : 0; + + _out.WriteLine("Setting ids per section:"); + foreach (var s in Enum.GetValues()) + _out.WriteLine($" {s,-10} {Count(s)}"); + _out.WriteLine($" {"TOTAL",-10} {bySection.Values.Sum()}"); + + Assert.Equal(13, Count(SettingSection.Gaming)); + Assert.Equal(4, Count(SettingSection.Display)); + Assert.Equal(3, Count(SettingSection.CpuPower)); + Assert.Equal(6, Count(SettingSection.Telemetry)); + Assert.Equal(13, Count(SettingSection.WindowsAi)); // 9 policy toggles + 4 UWP packages + Assert.Equal(3, Count(SettingSection.Network)); + Assert.Equal(8, Count(SettingSection.Debloat)); + Assert.Equal(33, Count(SettingSection.Services)); // 28 services + 5 scheduled tasks + Assert.Equal(0, Count(SettingSection.Bios)); + Assert.Equal(0, Count(SettingSection.Unknown)); + Assert.Equal(83, bySection.Values.Sum()); + } +} From c4c6693c7d3dbd271e4b4a744bfe7dc3eaa7d208 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:24:24 -0700 Subject: [PATCH 03/19] feat(monitor): publish the current drift set for the UI to count The UI needs a drifted count per section without re-running CheckDrift. MonitorService already computes exactly that set each scan and then threw it away. Exposes three members and nothing else: CurrentDrift (read-only, keyed by setting id), a DriftChanged event, and the pure MergeDrift the two are built on. Purely additive -- the diff removes no line, and scanning, applying, backoff and the circuit breaker are untouched. Two things the merge has to get right: Tier scoping. A tick only re-checks its own tier, so only that tier's entries are authoritative; the rest carry forward. Replacing wholesale would flush the ~40 stable settings on every 30-second display poll and flicker the count to near-zero. Same reasoning the circuit-breaker recovery sweep already uses. Auto-applied settings. A setting that drifted at scan start and was auto-applied and verified during the same tick is no longer drifting, so it is dropped from the snapshot. Otherwise the count would report drift the app had already corrected, for up to a full stable backstop (10 minutes). Uses the Verified flags ChangeApplier already returned -- nothing is re-read. DriftChanged fires only when the set of drifted ids changes, so a quiet machine raises no events. It is raised on the poll-timer thread, like AutoAppliedRebootRequired, so UI handlers must marshal. MergeDrift is pure and unit-tested headlessly, following the SelectNotifiable precedent in the same file, including a test that the snapshot groups cleanly through SettingSectionMap into per-section counts. --- src/GamerGuardian/Services/MonitorService.cs | 98 +++++++++ .../MonitorServiceDriftSetTests.cs | 194 ++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 tests/GamerGuardian.Tests/MonitorServiceDriftSetTests.cs diff --git a/src/GamerGuardian/Services/MonitorService.cs b/src/GamerGuardian/Services/MonitorService.cs index 71abeb2..423d062 100644 --- a/src/GamerGuardian/Services/MonitorService.cs +++ b/src/GamerGuardian/Services/MonitorService.cs @@ -66,6 +66,38 @@ public sealed class MonitorService : IDisposable public event Action? PauseChanged; public event Action>? AutoAppliedRebootRequired; + /// + /// The settings currently observed as drifted, keyed by setting id. This is the + /// same filtered set the scan already computes (monitored settings whose observed + /// value differs from the desired one), published so the UI can count drift — + /// per section or in total — without re-running CheckDrift. + /// + /// Replaced wholesale on each publish, so the reference a caller holds is + /// an immutable snapshot and safe to enumerate on any thread. + /// + public IReadOnlyDictionary CurrentDrift => _publishedDrift; + + /// + /// Raised after a scan finishes, when the set of drifted setting ids has changed + /// since the previous publish. Carries the same snapshot as + /// . + /// + /// Raised on a background thread (the poll timer), like + /// — a UI handler must marshal to the + /// dispatcher before touching controls. + /// + public event Action>? DriftChanged; + + private static readonly IReadOnlyDictionary EmptyDrift = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// Published snapshot, and also the accumulator across ticks (each + /// publish replaces it with a freshly built dictionary, so it is never mutated + /// in place). Single-writer: only publishes, and the + /// guard means ticks never overlap. Volatile so a reader + /// on another thread sees the newest one. + private volatile IReadOnlyDictionary _publishedDrift = EmptyDrift; + public void SetPaused(bool paused) { if (_userPaused == paused) return; @@ -301,6 +333,13 @@ private async Task TickAsync(MonitorTier? tier) .Where(d => !_breaker.IsTripped(d.SettingId, now)) .Where(d => !_autoApplyBackoff.TryGetValue(d.SettingId, out var until) || now >= until) .ToList(); + // Ids auto-applied and verified this tick. They are no longer drifted, so + // the published snapshot drops them -- otherwise the count would report + // drift the app just corrected, for as long as a full tier re-check away + // (up to the 10-minute stable backstop). Bookkeeping only: nothing below + // reads this to decide whether to apply. + var appliedAndVerified = new HashSet(StringComparer.OrdinalIgnoreCase); + if (auto.Count > 0) { // Split into corrective (was previously verified, now drifted) @@ -347,6 +386,7 @@ private async Task TickAsync(MonitorTier? tier) _autoApplyBackoff.Remove(allResults[i].SettingId); _lastVerified[allResults[i].SettingId] = new LastVerified( allResults[i].RawAfter, allResults[i].After, now); + appliedAndVerified.Add(allResults[i].SettingId); } else { @@ -378,6 +418,9 @@ private async Task TickAsync(MonitorTier? tier) if (prompt.Count > 0) await _onDriftAsync(new DriftReport(prompt)); + // The scan is finished: publish what is drifting now. + PublishDrift(tier, drifted, appliedAndVerified); + if (++_ticksSinceTrim >= 5) { _ticksSinceTrim = 0; @@ -392,6 +435,61 @@ private async Task TickAsync(MonitorTier? tier) } } + /// + /// Merges this tick's findings into the accumulated drift set and publishes a + /// fresh immutable snapshot, raising when the set of + /// drifted ids actually changed. + /// + /// The merge is tier-scoped: a tick only re-checks its own tier, so only + /// that tier's entries are authoritative. On a volatile-only fast poll a stable + /// setting's absence from means "not checked", not + /// "no longer drifting" — the same reasoning the circuit-breaker recovery sweep + /// uses. Clearing everything here would make the count flicker to near-zero on + /// every 30-second display poll. + /// + /// Pure bookkeeping: it observes the scan, it never influences it. + /// + private void PublishDrift(MonitorTier? tier, List drifted, HashSet appliedAndVerified) + { + var previous = _publishedDrift; + var snapshot = MergeDrift(previous, tier, drifted, appliedAndVerified); + + // A count surface only cares about which ids are drifting, so an unchanged + // id set raises nothing and the UI doesn't re-render on every quiet poll. + bool changed = snapshot.Count != previous.Count + || !snapshot.Keys.All(previous.ContainsKey); + + _publishedDrift = snapshot; + if (changed) DriftChanged?.Invoke(snapshot); + } + + /// + /// The merge rule, pure so it can be unit-tested without timers or the registry + /// (same approach as ). Returns a new dictionary; + /// neither argument is mutated. + /// + public static Dictionary MergeDrift( + IReadOnlyDictionary previous, + MonitorTier? tier, + IEnumerable drifted, + IReadOnlySet appliedAndVerified) + { + var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Carry forward only the tiers this tick did NOT re-check. + foreach (var (id, item) in previous) + if (tier is not null && MonitorVolatility.TierFor(id) != tier.Value) + merged[id] = item; + + foreach (var d in drifted) + merged[d.SettingId] = d; + + foreach (var id in appliedAndVerified) + merged.Remove(id); + + return merged; + } + /// /// The notification-eligibility rule, pulled out pure so it can be unit-tested /// without a timer or the registry. A drifted setting is shown to the user only diff --git a/tests/GamerGuardian.Tests/MonitorServiceDriftSetTests.cs b/tests/GamerGuardian.Tests/MonitorServiceDriftSetTests.cs new file mode 100644 index 0000000..c66be12 --- /dev/null +++ b/tests/GamerGuardian.Tests/MonitorServiceDriftSetTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GamerGuardian.Models; +using GamerGuardian.Services; +using Xunit; + +namespace GamerGuardian.Tests; + +/// +/// Covers — the pure rule behind the +/// published CurrentDrift snapshot the UI counts. Two guarantees matter: +/// a tier-scoped tick must not erase the tier it didn't check, and a setting the +/// app just auto-applied and verified must not still read as drifted. +/// +public class MonitorServiceDriftSetTests +{ + // "hags" is Stable; "hdr"/"refresh" are Volatile (see MonitorVolatility). + private const string StableId = "hags"; + private const string StableId2 = "gamemode"; + private const string VolatileId = "hdr:DISPLAY1"; + private const string VolatileId2 = "refresh:DISPLAY1"; + + private static DriftItem Drift(string id) => new( + SettingId: id, + DisplayKey: "global", + DisplayLabel: "Global", + Description: id, + CurrentValue: "On", + DesiredValue: "Off", + AutoApply: false, + Apply: () => Task.CompletedTask); + + private static IReadOnlyDictionary Snapshot(params string[] ids) => + ids.ToDictionary(i => i, Drift, StringComparer.OrdinalIgnoreCase); + + private static readonly IReadOnlySet NothingApplied = new HashSet(); + + private static IReadOnlySet Applied(params string[] ids) => + new HashSet(ids, StringComparer.OrdinalIgnoreCase); + + [Fact] + public void FullScan_ReplacesEverything() + { + var previous = Snapshot(StableId, VolatileId); + + var merged = MonitorService.MergeDrift( + previous, tier: null, new[] { Drift(StableId2) }, NothingApplied); + + Assert.Equal(new[] { StableId2 }, merged.Keys); + } + + [Fact] + public void VolatileOnlyTick_DoesNotEraseStableEntries() + { + // The regression this guards: a 30-second display poll must not flush the + // ~40 stable settings it never looked at, or the count flickers to near-zero. + var previous = Snapshot(StableId, VolatileId); + + var merged = MonitorService.MergeDrift( + previous, MonitorTier.Volatile, new[] { Drift(VolatileId2) }, NothingApplied); + + Assert.Contains(StableId, merged.Keys); // untouched tier carried forward + Assert.Contains(VolatileId2, merged.Keys); // this tick's finding + Assert.DoesNotContain(VolatileId, merged.Keys); // volatile entry that cleared + } + + [Fact] + public void StableOnlyTick_DoesNotEraseVolatileEntries() + { + var previous = Snapshot(StableId, VolatileId); + + var merged = MonitorService.MergeDrift( + previous, MonitorTier.Stable, new[] { Drift(StableId2) }, NothingApplied); + + Assert.Contains(VolatileId, merged.Keys); + Assert.Contains(StableId2, merged.Keys); + Assert.DoesNotContain(StableId, merged.Keys); + } + + [Fact] + public void SettingThatStoppedDrifting_IsDropped_WhenItsTierWasChecked() + { + var previous = Snapshot(StableId); + + var merged = MonitorService.MergeDrift( + previous, MonitorTier.Stable, Array.Empty(), NothingApplied); + + Assert.Empty(merged); + } + + [Fact] + public void AutoAppliedAndVerified_IsNotReportedAsDrifted() + { + // It was drifting when the scan started, the app fixed it during the same + // tick, so the published count must not still include it. + var merged = MonitorService.MergeDrift( + MonitorService.MergeDrift(Snapshot(), null, Array.Empty(), NothingApplied), + tier: null, + new[] { Drift(StableId), Drift(StableId2) }, + Applied(StableId)); + + Assert.DoesNotContain(StableId, merged.Keys); + Assert.Contains(StableId2, merged.Keys); + } + + [Fact] + public void AutoApplyThatFailedToVerify_StaysDrifted() + { + var merged = MonitorService.MergeDrift( + Snapshot(), tier: null, new[] { Drift(StableId) }, NothingApplied); + + Assert.Contains(StableId, merged.Keys); + } + + [Fact] + public void AppliedAndVerified_AlsoClearsAnEntryCarriedFromAnEarlierTick() + { + var previous = Snapshot(VolatileId); + + // A stable-tier tick that auto-fixed a volatile setting still clears it. + var merged = MonitorService.MergeDrift( + previous, MonitorTier.Stable, Array.Empty(), Applied(VolatileId)); + + Assert.Empty(merged); + } + + [Fact] + public void Merge_IsCaseInsensitive_OnSettingIds() + { + var merged = MonitorService.MergeDrift( + Snapshot(), tier: null, new[] { Drift("HAGS") }, Applied("hags")); + + Assert.Empty(merged); + } + + [Fact] + public void Merge_DoesNotMutateItsInputs() + { + var previous = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [StableId] = Drift(StableId), + }; + var incoming = new List { Drift(VolatileId) }; + + var merged = MonitorService.MergeDrift(previous, null, incoming, NothingApplied); + + Assert.Single(previous); + Assert.Single(incoming); + Assert.NotSame(previous, merged); + } + + [Fact] + public void EmptyScan_WithNoPreviousDrift_YieldsEmptySnapshot() + { + var merged = MonitorService.MergeDrift( + Snapshot(), tier: null, Array.Empty(), NothingApplied); + + Assert.Empty(merged); + } + + /// + /// The snapshot is what the UI groups by section, so the two must compose: + /// every drifted id resolves to a section and the per-section counts add up. + /// + [Fact] + public void Snapshot_GroupsBySection_ForTheCountSurface() + { + var merged = MonitorService.MergeDrift( + Snapshot(), + tier: null, + new[] + { + Drift("hags"), // Gaming + Drift("gamemode"), // Gaming + Drift("hdr:DISPLAY1"), // Display + Drift("service:diagtrack"), // Services + Drift("ai.copilot"), // WindowsAi + }, + NothingApplied); + + var bySection = merged.Keys + .GroupBy(SettingSectionMap.SectionFor) + .ToDictionary(g => g.Key, g => g.Count()); + + Assert.Equal(2, bySection[SettingSection.Gaming]); + Assert.Equal(1, bySection[SettingSection.Display]); + Assert.Equal(1, bySection[SettingSection.Services]); + Assert.Equal(1, bySection[SettingSection.WindowsAi]); + Assert.DoesNotContain(SettingSection.Unknown, bySection.Keys); + Assert.Equal(5, merged.Count); + } +} From 0c347ddc8aa0e49a58a5129c979f6e8d6e819967 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:33:26 -0700 Subject: [PATCH 04/19] feat(beta): add a BETA build flavor that runs isolated from a stable install A beta build must be safe to run beside a stable install and must never be able to update itself. Adds a BETA compile constant, set with -p:Beta=true, and routes everything that has to differ through one new type, Services/AppIdentity.cs. Mechanism is an MSBuild property with a default, not a fourth Build Configuration: beta builds are Release builds plus one constant, so the Debug/Release matrix, the determinism settings and every publish flag stay as they are. Both paths must compile warning-free independently because TreatWarningsAsErrors is on and #if-excluded code is invisible to the other compile -- verified locally on each file, and CI builds both. Gated on BETA: - Config root moves to %APPDATA%\GamerGuardian-Beta. ConfigStore and ChangeLogger built that path independently, so moving one would have left the other in the stable root; both now call AppIdentity. As part of that, the session-header line that reported the config path by inferring it from the change log's own directory now reports the real path -- the inference assumed the two always share a folder. - Mutex name gets a .Beta suffix so both instances can run at once. - The update path is compiled out, not disabled at runtime: the startup check, CheckForUpdatesAsync itself, and the body of the Settings "Check now" handler. The button and its XAML are untouched, so there is no orphaned Click target and no unused handler -- the handler simply reports that updates are disabled. A beta binary contains no code that can reach the update feed. - Window title, title bar and tray tooltip carry " [BETA ]", parsed from the InformationalVersion the beta workflow stamps as "-beta." (AssemblyVersion/FileVersion must stay a pure a.b.c.d, so the suffix can only live there). - HKCU Run value becomes GamerGuardian-Beta. A distinct name rather than extending the dev-build skip: launch-at-startup is a feature testers need to exercise, and a shared name would have the two builds overwrite each other's entry. - %TEMP% diagnostics become gamerguardian-beta_*, so two running instances never interleave into one log, and TempCleanup's patterns move with them. TempCleanup's installer sweep is compiled out under BETA so a beta build cannot delete a stable install's in-flight download. Non-beta behavior is unchanged. Every gated member resolves to the exact literal the code used before, enforced by AppIdentityTests rather than asserted: the test project compiles without BETA, so a beta-only value leaking into the default build fails the suite. The only XAML change is an x:Name on the existing TitleBar so the marker can be appended in code; markup is otherwise identical between flavors. --- src/GamerGuardian/App.xaml.cs | 12 +- src/GamerGuardian/GamerGuardian.csproj | 20 ++++ src/GamerGuardian/Services/AppIdentity.cs | 104 ++++++++++++++++++ src/GamerGuardian/Services/ChangeLogger.cs | 10 +- src/GamerGuardian/Services/ConfigStore.cs | 9 +- .../Services/StartupRegistration.cs | 6 +- src/GamerGuardian/Services/TempCleanup.cs | 7 +- src/GamerGuardian/Tray/TrayIconHost.cs | 15 ++- src/GamerGuardian/UI/SettingsWindow.xaml | 2 +- src/GamerGuardian/UI/SettingsWindow.xaml.cs | 25 +++++ tests/GamerGuardian.Tests/AppIdentityTests.cs | 85 ++++++++++++++ 11 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 src/GamerGuardian/Services/AppIdentity.cs create mode 100644 tests/GamerGuardian.Tests/AppIdentityTests.cs diff --git a/src/GamerGuardian/App.xaml.cs b/src/GamerGuardian/App.xaml.cs index 501c0fa..9ca334d 100644 --- a/src/GamerGuardian/App.xaml.cs +++ b/src/GamerGuardian/App.xaml.cs @@ -64,7 +64,7 @@ protected override void OnStartup(StartupEventArgs e) } } - _singleInstanceMutex = new Mutex(initiallyOwned: true, "GamerGuardian.SingleInstance", out bool created); + _singleInstanceMutex = new Mutex(initiallyOwned: true, AppIdentity.MutexName, out bool created); if (!created) { Shutdown(); @@ -189,8 +189,12 @@ protected override void OnStartup(StartupEventArgs e) bool isFirstRun = !System.IO.File.Exists(_store.ConfigPath); if (isFirstRun || e.Args.Any(a => a == "--show-settings")) ShowSettings(); +#if !BETA if (cfg.CheckForUpdatesOnStartup && !IsDevBuild()) _ = Task.Run(async () => await CheckForUpdatesAsync()); +#endif + // BETA: the update path is compiled out, not disabled at runtime -- a beta + // build contains no code that can reach the update feed or replace itself. _ = Dispatcher.BeginInvoke(() => { @@ -199,6 +203,7 @@ protected override void OnStartup(StartupEventArgs e) }, System.Windows.Threading.DispatcherPriority.ApplicationIdle); } +#if !BETA private async Task CheckForUpdatesAsync() { try @@ -230,6 +235,7 @@ await Dispatcher.InvokeAsync(() => } catch (Exception ex) { LogException("UpdateCheck", ex); } } +#endif private void ShowSettings() { @@ -309,7 +315,7 @@ private static void LogException(string source, Exception? ex) { try { - var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "gamerguardian_error.log"); + var path = AppIdentity.ErrorLogFile; // Cap at ~1 MB by rotating to .1 try { @@ -401,7 +407,7 @@ void Run(string name, Func f) () => GamerGuardian.Monitors.ResolutionMonitor.GetCurrent(d.GdiDeviceName)?.ToString() ?? "(null)"); } - var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "gamerguardian_selftest.txt"); + var path = AppIdentity.SelfTestFile; System.IO.File.WriteAllLines(path, log); Environment.ExitCode = log.Any(l => l.StartsWith("FAIL")) ? 1 : 0; } diff --git a/src/GamerGuardian/GamerGuardian.csproj b/src/GamerGuardian/GamerGuardian.csproj index 11b86d5..f1626c8 100644 --- a/src/GamerGuardian/GamerGuardian.csproj +++ b/src/GamerGuardian/GamerGuardian.csproj @@ -32,6 +32,26 @@ true + + + false + + + $(DefineConstants);BETA + + true diff --git a/src/GamerGuardian/Services/AppIdentity.cs b/src/GamerGuardian/Services/AppIdentity.cs new file mode 100644 index 0000000..9f70f9e --- /dev/null +++ b/src/GamerGuardian/Services/AppIdentity.cs @@ -0,0 +1,104 @@ +using System.IO; +using System.Reflection; + +namespace GamerGuardian.Services; + +/// +/// Everything that must differ between a stable build and a beta build, in one +/// place: where state is written, what the single-instance mutex is called, what +/// the Windows startup entry is named, and what marker the UI shows. +/// +/// Gated on the BETA compile constant, set by +/// -p:Beta=true (see GamerGuardian.csproj). A beta build keeps its +/// config, change log and diagnostics entirely separate from a stable install and +/// can run side by side with one. In a non-beta build every member below compiles +/// to exactly the literal it had before this type existed, so behavior is +/// unchanged. +/// +/// This is the single source for these paths. and +/// both used to build %APPDATA%\GamerGuardian +/// independently, which meant moving one would silently leave the other behind. +/// +public static class AppIdentity +{ +#if BETA + /// Folder under %APPDATA% holding config.json and changes.log. + public const string ProductFolderName = "GamerGuardian-Beta"; + + /// Filename stem for the %TEMP% diagnostics, so a beta and a stable + /// instance running together never interleave into the same log. + public const string DiagnosticPrefix = "gamerguardian-beta"; + + /// Distinct so a beta and a stable instance can run at the same time. + /// Unqualified, therefore session-local, matching the original. + public const string MutexName = "GamerGuardian.SingleInstance.Beta"; + + /// HKCU Run value name. Distinct rather than suppressed: a beta tester + /// wants launch-at-startup to actually work, and a shared name would have the + /// two builds overwrite each other's entry. + public const string StartupRegistryValueName = "GamerGuardian-Beta"; +#else + public const string ProductFolderName = "GamerGuardian"; + public const string DiagnosticPrefix = "gamerguardian"; + public const string MutexName = "GamerGuardian.SingleInstance"; + public const string StartupRegistryValueName = "GamerGuardian"; +#endif + + /// %APPDATA%\<ProductFolderName>. Resolved once at type-init. + public static string ConfigDirectory { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + ProductFolderName); + + public static string ConfigFile { get; } = Path.Combine(ConfigDirectory, "config.json"); + + public static string ChangeLogFile { get; } = Path.Combine(ConfigDirectory, "changes.log"); + + public static string ErrorLogFile { get; } = + Path.Combine(Path.GetTempPath(), $"{DiagnosticPrefix}_error.log"); + + public static string SelfTestFile { get; } = + Path.Combine(Path.GetTempPath(), $"{DiagnosticPrefix}_selftest.txt"); + + /// + /// The stable install's config folder. A beta build reads this once on first + /// launch to seed its own config; it is never written to. In a stable build it + /// is the same as . + /// + public static string StableConfigDirectory { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "GamerGuardian"); + + /// + /// Marker appended to the window title and tray tooltip. Empty in a stable + /// build, so callers can concatenate it unconditionally. + /// +#if BETA + public static string DisplaySuffix { get; } = BuildBetaSuffix(); + + /// " [BETA a1b2c3d]", or just " [BETA]" when no sha was stamped. + /// The sha comes from the InformationalVersion the beta workflow sets as + /// "<base>-beta.<sha>" (AssemblyVersion/FileVersion must stay a pure + /// a.b.c.d, so the suffix can only live here). + private static string BuildBetaSuffix() + { + try + { + var info = typeof(AppIdentity).Assembly + .GetCustomAttribute()?.InformationalVersion ?? ""; + var plus = info.IndexOf('+'); + if (plus > 0) info = info[..plus]; + + const string marker = "-beta."; + var i = info.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + var sha = i >= 0 ? info[(i + marker.Length)..] : ""; + return string.IsNullOrWhiteSpace(sha) ? " [BETA]" : $" [BETA {sha}]"; + } + catch + { + return " [BETA]"; + } + } +#else + public static string DisplaySuffix => string.Empty; +#endif +} diff --git a/src/GamerGuardian/Services/ChangeLogger.cs b/src/GamerGuardian/Services/ChangeLogger.cs index 5e46f94..f6a8944 100644 --- a/src/GamerGuardian/Services/ChangeLogger.cs +++ b/src/GamerGuardian/Services/ChangeLogger.cs @@ -30,9 +30,7 @@ namespace GamerGuardian.Services; /// public static class ChangeLogger { - public static string LogPath { get; } = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "GamerGuardian", "changes.log"); + public static string LogPath { get; } = AppIdentity.ChangeLogFile; private const long MaxBytes = 1_000_000; private const string Divider = "--------------------------------------------------------------------------------"; @@ -148,7 +146,11 @@ public static void LogSessionStart() sb.AppendLine($" Machine : {Environment.MachineName}"); sb.AppendLine($" User : {Environment.UserName} (elevated: {elevated})"); sb.AppendLine($" PID : {p.Id}"); - sb.AppendLine($" ConfigPath : {Path.Combine(Path.GetDirectoryName(LogPath) ?? "", "config.json")}"); + // Report the real config path rather than inferring one from this log's + // own directory -- the inference silently assumed the two always share a + // folder, so it would have reported a path that doesn't exist the moment + // they diverged. + sb.AppendLine($" ConfigPath : {AppIdentity.ConfigFile}"); sb.AppendLine(Divider); File.AppendAllText(LogPath, sb.ToString(), Encoding.UTF8); } diff --git a/src/GamerGuardian/Services/ConfigStore.cs b/src/GamerGuardian/Services/ConfigStore.cs index c26b30e..e767ef5 100644 --- a/src/GamerGuardian/Services/ConfigStore.cs +++ b/src/GamerGuardian/Services/ConfigStore.cs @@ -17,10 +17,11 @@ public sealed class ConfigStore public ConfigStore() { - ConfigDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "GamerGuardian"); - ConfigPath = Path.Combine(ConfigDirectory, "config.json"); + // Paths come from AppIdentity so a beta build's state lands under its own + // root -- and so ChangeLogger, which writes into the same folder, can never + // disagree about where that folder is. + ConfigDirectory = AppIdentity.ConfigDirectory; + ConfigPath = AppIdentity.ConfigFile; } public AppConfig Load() diff --git a/src/GamerGuardian/Services/StartupRegistration.cs b/src/GamerGuardian/Services/StartupRegistration.cs index a0bb3bb..ee815dc 100644 --- a/src/GamerGuardian/Services/StartupRegistration.cs +++ b/src/GamerGuardian/Services/StartupRegistration.cs @@ -5,7 +5,11 @@ namespace GamerGuardian.Services; public static class StartupRegistration { private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; - private const string ValueName = "GamerGuardian"; + + /// A beta build registers under its own value name rather than being + /// skipped: launch-at-startup is a feature testers need to exercise, and a + /// shared name would have the two builds overwrite each other's entry. + private const string ValueName = AppIdentity.StartupRegistryValueName; public static bool IsRegistered() { diff --git a/src/GamerGuardian/Services/TempCleanup.cs b/src/GamerGuardian/Services/TempCleanup.cs index f865932..9439e28 100644 --- a/src/GamerGuardian/Services/TempCleanup.cs +++ b/src/GamerGuardian/Services/TempCleanup.cs @@ -19,12 +19,17 @@ public static void Run() var temp = Path.GetTempPath(); var cutoff = DateTime.Now.AddDays(-KeepDays); +#if !BETA + // Only the stable build downloads installers, so only it cleans them up. + // A beta build has the whole update path compiled out and must not delete + // a stable install's in-flight download. foreach (var path in Directory.EnumerateFiles(temp, "GamerGuardian-Setup-*.exe")) { TryDeleteIfOlder(path, cutoff); } +#endif - var stale = Path.Combine(temp, "gamerguardian_trace.log"); + var stale = Path.Combine(temp, $"{AppIdentity.DiagnosticPrefix}_trace.log"); TryDeleteIfOlder(stale, DateTime.MaxValue); // always remove — code no longer writes it } catch { /* best-effort */ } diff --git a/src/GamerGuardian/Tray/TrayIconHost.cs b/src/GamerGuardian/Tray/TrayIconHost.cs index 104ef61..67abc77 100644 --- a/src/GamerGuardian/Tray/TrayIconHost.cs +++ b/src/GamerGuardian/Tray/TrayIconHost.cs @@ -1,6 +1,7 @@ using System.Drawing; using System.Windows; using System.Windows.Forms; +using GamerGuardian.Services; namespace GamerGuardian.Tray; @@ -31,7 +32,9 @@ public TrayIconHost() _icon = new NotifyIcon { Icon = LoadAppIcon() ?? SystemIcons.Application, - Text = "GamerGuardian", + // DisplaySuffix is "" in a stable build and " [BETA ]" in a beta + // one, so a tester can tell the two tray icons apart at a glance. + Text = Truncate("GamerGuardian" + AppIdentity.DisplaySuffix), Visible = true, ContextMenuStrip = menu, }; @@ -42,9 +45,17 @@ public void SetPaused(bool paused) { _paused = paused; _pauseItem.Text = paused ? "Resume monitoring" : "Pause monitoring"; - _icon.Text = paused ? "GamerGuardian (paused)" : "GamerGuardian"; + _icon.Text = Truncate( + paused ? "GamerGuardian (paused)" + AppIdentity.DisplaySuffix + : "GamerGuardian" + AppIdentity.DisplaySuffix); } + /// NotifyIcon.Text throws above 63 characters. The stable text is far + /// short of that, but a beta suffix carrying a sha pushes it closer, so clamp + /// rather than risk a crash on a build flavor nobody tests as hard. + private static string Truncate(string text) => + text.Length <= 63 ? text : text[..63]; + public void ShowBalloon(string title, string text) { _icon.BalloonTipTitle = title; diff --git a/src/GamerGuardian/UI/SettingsWindow.xaml b/src/GamerGuardian/UI/SettingsWindow.xaml index 21d41e8..0687edb 100644 --- a/src/GamerGuardian/UI/SettingsWindow.xaml +++ b/src/GamerGuardian/UI/SettingsWindow.xaml @@ -102,7 +102,7 @@ - + diff --git a/src/GamerGuardian/UI/SettingsWindow.xaml.cs b/src/GamerGuardian/UI/SettingsWindow.xaml.cs index e534034..76f46e4 100644 --- a/src/GamerGuardian/UI/SettingsWindow.xaml.cs +++ b/src/GamerGuardian/UI/SettingsWindow.xaml.cs @@ -104,6 +104,13 @@ public SettingsWindow( LoadNetwork(); LoadCpuTabs(); UpdatePendingStatus(); + + // Beta marker on the window title and the title bar. Appended in code rather + // than in XAML so the markup stays identical between build flavors; + // DisplaySuffix is "" in a stable build, making both lines no-ops there. + Title += AppIdentity.DisplaySuffix; + if (WindowTitleBar is not null) + WindowTitleBar.Title += AppIdentity.DisplaySuffix; } /// @@ -1027,10 +1034,27 @@ private void OpenChangeLogButton_Click(object sender, RoutedEventArgs e) private async void CheckUpdatesNowButton_Click(object sender, RoutedEventArgs e) { +#if BETA + // The whole update path is compiled out of a beta build. The button and its + // XAML stay exactly as they are -- only the body changes -- so there is no + // orphaned Click target and no unused handler, and the binary genuinely + // contains no call into UpdateService from here. + await Task.CompletedTask; + System.Windows.MessageBox.Show( + this, + "Updates are disabled in beta builds.", + "GamerGuardian", + System.Windows.MessageBoxButton.OK, + System.Windows.MessageBoxImage.Information); +#else // Dev builds must never self-update. The startup check has always been // gated on this (App.OnStartup), but this manual path was not: clicking // "Check now" in a dev build would download the newest *stable* installer // and launch it over the running dev build. Same guard, both paths. + // + // Both guards coexist deliberately: BETA compiles the update path out of + // the binary entirely, while this runtime check covers the dev builds that + // are still compiled with the update path present. if (App.IsDevBuild()) { System.Windows.MessageBox.Show( @@ -1077,6 +1101,7 @@ private async void CheckUpdatesNowButton_Click(object sender, RoutedEventArgs e) btn.Content = prev; btn.IsEnabled = true; } +#endif } /// Guards against re-entrant Apply / Save&close while one is in flight. diff --git a/tests/GamerGuardian.Tests/AppIdentityTests.cs b/tests/GamerGuardian.Tests/AppIdentityTests.cs new file mode 100644 index 0000000..f907e9f --- /dev/null +++ b/tests/GamerGuardian.Tests/AppIdentityTests.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +using GamerGuardian.Services; +using Xunit; + +namespace GamerGuardian.Tests; + +/// +/// Pins the non-beta identity to the exact literals the code used before +/// existed. The test project compiles without the BETA +/// constant, so these assertions are the enforced proof that a stable build's +/// paths, mutex name and startup entry are behaviorally unchanged -- a beta-only +/// value leaking into the default build fails here. +/// +public class AppIdentityTests +{ + private static string AppData => Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + + [Fact] + public void StableBuild_UsesTheOriginalProductFolder() + { + Assert.Equal("GamerGuardian", AppIdentity.ProductFolderName); + Assert.Equal(Path.Combine(AppData, "GamerGuardian"), AppIdentity.ConfigDirectory); + } + + [Fact] + public void StableBuild_ConfigAndChangeLogPaths_MatchTheOriginals() + { + Assert.Equal(Path.Combine(AppData, "GamerGuardian", "config.json"), AppIdentity.ConfigFile); + Assert.Equal(Path.Combine(AppData, "GamerGuardian", "changes.log"), AppIdentity.ChangeLogFile); + } + + [Fact] + public void StableBuild_DiagnosticPaths_MatchTheOriginals() + { + Assert.Equal("gamerguardian", AppIdentity.DiagnosticPrefix); + Assert.Equal(Path.Combine(Path.GetTempPath(), "gamerguardian_error.log"), AppIdentity.ErrorLogFile); + Assert.Equal(Path.Combine(Path.GetTempPath(), "gamerguardian_selftest.txt"), AppIdentity.SelfTestFile); + } + + [Fact] + public void StableBuild_MutexAndStartupNames_MatchTheOriginals() + { + Assert.Equal("GamerGuardian.SingleInstance", AppIdentity.MutexName); + Assert.Equal("GamerGuardian", AppIdentity.StartupRegistryValueName); + } + + [Fact] + public void StableBuild_DisplaySuffix_IsEmpty_SoTitlesAreUnchanged() + { + // Callers concatenate this unconditionally; empty is what keeps the stable + // window title and tray tooltip byte-identical to before. + Assert.Equal(string.Empty, AppIdentity.DisplaySuffix); + Assert.Equal("GamerGuardian", "GamerGuardian" + AppIdentity.DisplaySuffix); + Assert.Equal("GamerGuardian - Settings", "GamerGuardian - Settings" + AppIdentity.DisplaySuffix); + } + + [Fact] + public void ConfigStore_ResolvesToTheSamePathsAsAppIdentity() + { + // The whole point of the shared source: ConfigStore and ChangeLogger used to + // build this path independently and could drift apart. + var store = new ConfigStore(); + Assert.Equal(AppIdentity.ConfigDirectory, store.ConfigDirectory); + Assert.Equal(AppIdentity.ConfigFile, store.ConfigPath); + } + + [Fact] + public void ChangeLogger_WritesIntoTheSameFolderAsTheConfig() + { + Assert.Equal(AppIdentity.ChangeLogFile, ChangeLogger.LogPath); + Assert.Equal( + Path.GetDirectoryName(AppIdentity.ConfigFile), + Path.GetDirectoryName(ChangeLogger.LogPath)); + } + + [Fact] + public void StableConfigDirectory_IsTheStableRoot_EvenInAStableBuild() + { + // In a stable build the seed source and the live root are the same folder, + // which is what makes the beta first-launch copy a no-op here. + Assert.Equal(Path.Combine(AppData, "GamerGuardian"), AppIdentity.StableConfigDirectory); + Assert.Equal(AppIdentity.ConfigDirectory, AppIdentity.StableConfigDirectory); + } +} From 58e74ea4d179e71cf32e4a1e9a7b93f83fd86c4c Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:37:26 -0700 Subject: [PATCH 05/19] ci(beta): add a manual-only beta build workflow Builds the portable self-contained single-file EXE with the BETA constant set and uploads it as a workflow artifact. Nothing else. Trigger is workflow_dispatch only -- no push trigger, no tag trigger -- so it cannot fire as a side effect of committing, merging or tagging. The workflow token is contents: read at workflow scope with no job-level elevation, so it structurally cannot create a release, push a tag, or write to the repository even if a step tried to. release.yml needs contents/id-token/attestations write to publish; this has none of them. No installer is built and no release action is referenced. Publish flags are identical to release.yml plus -p:Beta=true. Version stamping follows dev-build.yml: a numeric base for AssemblyVersion and FileVersion, which must stay a pure a.b.c.d, and an explicit InformationalVersion of "-beta." that AppIdentity parses back for the in-app BETA marker. Stable tags only when picking the bump base. Two guards beyond the ask: the suite must pass before a build goes to testers (which also proves the non-BETA path still compiles warning-free), and the published binary is scanned for the update-feed URL so a run fails loudly if the BETA constant ever stops taking effect. Artifact name carries the run number and short sha. --- .github/workflows/beta.yml | 109 +++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .github/workflows/beta.yml diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml new file mode 100644 index 0000000..ce7eec0 --- /dev/null +++ b/.github/workflows/beta.yml @@ -0,0 +1,109 @@ +name: beta + +# Manual only. There is deliberately no push trigger and no tag trigger, so this +# cannot fire as a side effect of committing, merging, or tagging anything. +on: + workflow_dispatch: + +# Read-only token. This workflow cannot create a release, push a tag, or write to +# the repository even if a step tried to: the GITHUB_TOKEN it is handed has no +# write scope. The beta EXE leaves as a workflow artifact and nothing else. +permissions: + contents: read + +concurrency: + group: beta-${{ github.ref }} + cancel-in-progress: true + +jobs: + beta: + runs-on: windows-latest + # No permissions block here either -- the job inherits the read-only set above. + # release.yml needs contents/id-token/attestations write to publish; this one + # intentionally has none of them. + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Resolve beta version + id: ver + shell: pwsh + run: | + # Same shape as dev-build.yml: a numeric base for AssemblyVersion and + # FileVersion (which must stay a pure a.b.c.d), and a descriptive + # InformationalVersion carrying the -beta. suffix. AppIdentity parses + # the sha back out of it for the in-app BETA marker. + # Stable tags only -- 'v*.*.*' also matches prerelease tags, and [int] on + # a "65-beta" patch component throws. + $latest = (git tag --list 'v*.*.*' --sort=-v:refname | Where-Object { $_ -notmatch '-' } | Select-Object -First 1) + if ([string]::IsNullOrWhiteSpace($latest)) { $latest = 'v0.0.0' } + $parts = ($latest -replace '^v','').Split('.') + $patch = [int]$parts[2] + 1 + $base = "$($parts[0]).$($parts[1]).$patch" + $sha = $env:GITHUB_SHA.Substring(0,7) + $full = "$base-beta.$sha" + "base=$base" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "version=$full" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "sha=$sha" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + Write-Host "Building beta $full (numeric base $base) from $env:GITHUB_REF_NAME" + + - name: Setup .NET 8 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore GamerGuardian.sln + + # A beta goes to real testers, so it does not ship without the suite passing. + # This builds the default (non-BETA) flavor, which is also the check that the + # non-beta path still compiles warning-free. + - name: Test + run: dotnet test GamerGuardian.sln --nologo + + - name: Publish beta (self-contained, single-file, win-x64) + # Identical publish flags to release.yml, plus -p:Beta=true to define the + # BETA constant and an explicit InformationalVersion for the sha marker. + run: > + dotnet publish src/GamerGuardian/GamerGuardian.csproj + -c Release + -r win-x64 + --self-contained true + -p:PublishSingleFile=true + -p:IncludeNativeLibrariesForSelfExtract=true + -p:EnableCompressionInSingleFile=true + -p:Beta=true + -p:Version=${{ steps.ver.outputs.base }} + -p:AssemblyVersion=${{ steps.ver.outputs.base }}.0 + -p:FileVersion=${{ steps.ver.outputs.base }}.0 + -p:InformationalVersion=${{ steps.ver.outputs.version }} + -o publish + + # Fails the run if the BETA constant somehow did not take: a beta binary must + # not contain the update endpoint string at all. + - name: Verify the update path is absent from the binary + shell: pwsh + run: | + $exe = "publish\GamerGuardian.exe" + $bytes = [System.IO.File]::ReadAllBytes($exe) + $text = [System.Text.Encoding]::Unicode.GetString($bytes) + + [System.Text.Encoding]::UTF8.GetString($bytes) + if ($text -match 'api\.github\.com/repos/carterscode/GamerGuardian/releases') { + throw "BETA build still contains the update-feed URL -- the update path was not compiled out." + } + Write-Host "OK: no update-feed URL found in the beta binary." + + - name: List artifacts + shell: pwsh + run: Get-ChildItem -Recurse publish | Select-Object FullName, Length + + # The only output. No installer is built, no release is created, no tag is + # pushed, and nothing is published to the update feed. + - name: Upload beta EXE artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: GamerGuardian-beta-r${{ github.run_number }}-${{ steps.ver.outputs.sha }} + path: publish/GamerGuardian.exe + if-no-files-found: error From 6703aa3ea8d542614f171b961611f5991d06f44b Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:37:37 -0700 Subject: [PATCH 06/19] feat(beta): seed beta config from the stable install, and pin unknown-field loading Two config-safety changes. First launch of a beta build now starts from the stable install's settings instead of defaults: if the beta root does not exist and the stable one has a config.json, it is copied across. Strictly one-way -- SeedConfigFrom reads the source and only ever writes under the target, so a beta build can never modify the stable config. It no-ops when the two directories are the same (a stable build), when the target already exists (not a first launch), or when there is nothing to copy, and a failure just leaves the caller on defaults. ConfigStore.Load catches everything and returns a fresh AppConfig on failure, which means a deserialization problem wipes every setting silently. Adds tests pinning that a config.json carrying unknown fields -- from an older or newer build -- keeps its known values rather than taking that branch, at both the top level and nested under global. The malformed-JSON reset is also pinned, so the lossy path stays a deliberate, documented behavior instead of an accident. To make Load and Save testable at all, ConfigStore gains a constructor taking an explicit directory; the default constructor is unchanged and still resolves through AppIdentity. --- src/GamerGuardian/App.xaml.cs | 7 + src/GamerGuardian/Services/ConfigStore.cs | 49 +++++ tests/GamerGuardian.Tests/ConfigStoreTests.cs | 199 ++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 tests/GamerGuardian.Tests/ConfigStoreTests.cs diff --git a/src/GamerGuardian/App.xaml.cs b/src/GamerGuardian/App.xaml.cs index 9ca334d..322514a 100644 --- a/src/GamerGuardian/App.xaml.cs +++ b/src/GamerGuardian/App.xaml.cs @@ -71,6 +71,13 @@ protected override void OnStartup(StartupEventArgs e) return; } +#if BETA + // First launch of a beta build with no state of its own: start from the + // stable install's settings rather than from defaults. One-way copy -- the + // stable config is read and never written. No-ops on every later launch. + ConfigStore.SeedConfigFrom(AppIdentity.StableConfigDirectory, AppIdentity.ConfigDirectory); +#endif + _store = new ConfigStore(); ChangeLogger.LogSessionStart(); var cfg = _store.Load(); diff --git a/src/GamerGuardian/Services/ConfigStore.cs b/src/GamerGuardian/Services/ConfigStore.cs index e767ef5..6ac34db 100644 --- a/src/GamerGuardian/Services/ConfigStore.cs +++ b/src/GamerGuardian/Services/ConfigStore.cs @@ -24,6 +24,55 @@ public ConfigStore() ConfigPath = AppIdentity.ConfigFile; } + /// Points the store at an explicit directory. Used by tests so + /// and can be exercised against a temp + /// folder instead of the real %APPDATA% root. + public ConfigStore(string configDirectory) + { + ConfigDirectory = configDirectory; + ConfigPath = Path.Combine(configDirectory, "config.json"); + } + + /// + /// First-launch seed for a beta build: copies config.json out of the + /// stable install's folder so a tester starts from their real settings instead + /// of defaults. + /// + /// Strictly one-way. It reads from and + /// only ever writes under , so a beta build + /// can never modify the stable install's config. It no-ops when the two + /// directories are the same (a stable build), when the target already exists + /// (not a first launch), or when there is nothing to copy. Failure is silent + /// and simply leaves the caller starting from defaults, which is the current + /// behavior anyway. + /// + /// Returns true only when a file was actually copied. + /// + public static bool SeedConfigFrom(string sourceDirectory, string targetDirectory) + { + try + { + if (string.IsNullOrWhiteSpace(sourceDirectory) || string.IsNullOrWhiteSpace(targetDirectory)) + return false; + if (string.Equals(sourceDirectory, targetDirectory, StringComparison.OrdinalIgnoreCase)) + return false; + // Only on a genuine first launch: an existing target root means this + // build already has state of its own, which must not be overwritten. + if (Directory.Exists(targetDirectory)) return false; + + var source = Path.Combine(sourceDirectory, "config.json"); + if (!File.Exists(source)) return false; + + Directory.CreateDirectory(targetDirectory); + File.Copy(source, Path.Combine(targetDirectory, "config.json"), overwrite: false); + return true; + } + catch + { + return false; + } + } + public AppConfig Load() { try diff --git a/tests/GamerGuardian.Tests/ConfigStoreTests.cs b/tests/GamerGuardian.Tests/ConfigStoreTests.cs new file mode 100644 index 0000000..ea2db0c --- /dev/null +++ b/tests/GamerGuardian.Tests/ConfigStoreTests.cs @@ -0,0 +1,199 @@ +using System; +using System.IO; +using GamerGuardian.Models; +using GamerGuardian.Services; +using Xunit; + +namespace GamerGuardian.Tests; + +/// +/// Covers the two ways a user's settings can silently vanish: ConfigStore.Load +/// swallowing a deserialization problem into a fresh AppConfig, and the beta +/// first-launch seed touching the stable install's config. +/// +public sealed class ConfigStoreTests : IDisposable +{ + private readonly string _root; + + public ConfigStoreTests() + { + _root = Path.Combine(Path.GetTempPath(), "gg-cfgtests-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + private string Dir(string name) + { + var d = Path.Combine(_root, name); + return d; + } + + private static void WriteConfig(string dir, string json) + { + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "config.json"), json); + } + + // ---- Unknown fields must not reset the config ---- + + [Fact] + public void Load_ConfigWithUnknownFields_KeepsKnownValues_DoesNotResetToDefaults() + { + // The failure mode this guards: Load() catches everything and returns a + // fresh AppConfig, so a deserialization change would wipe every setting + // silently. An older or newer build's extra fields must be ignored, not + // fatal. + var dir = Dir("unknown-fields"); + WriteConfig(dir, """ + { + "launchAtStartup": false, + "pollIntervalSeconds": 77, + "aFieldFromANewerBuild": { "nested": [1, 2, 3] }, + "anotherUnknownField": "whatever", + "global": { + "gameMode": { "monitor": true, "desiredOn": false, "autoApply": true }, + "someUnknownToggle": { "monitor": true } + } + } + """); + + var cfg = new ConfigStore(dir).Load(); + + Assert.False(cfg.LaunchAtStartup); // non-default, preserved + Assert.Equal(77, cfg.PollIntervalSeconds); // non-default, preserved + Assert.True(cfg.Global.GameMode.Monitor); + Assert.False(cfg.Global.GameMode.DesiredOn); + Assert.True(cfg.Global.GameMode.AutoApply); + } + + [Fact] + public void Load_ConfigWithUnknownFields_RoundTripsThroughSave() + { + // Unknown fields are dropped on save (System.Text.Json does not retain + // them), but the known settings must survive the round trip intact. + var dir = Dir("roundtrip"); + WriteConfig(dir, """ + { "pollIntervalSeconds": 45, "futureField": true } + """); + + var store = new ConfigStore(dir); + var cfg = store.Load(); + store.Save(cfg); + + Assert.Equal(45, new ConfigStore(dir).Load().PollIntervalSeconds); + } + + [Fact] + public void Load_MissingFile_ReturnsDefaults() + { + var cfg = new ConfigStore(Dir("nothing-here")).Load(); + Assert.Equal(new AppConfig().PollIntervalSeconds, cfg.PollIntervalSeconds); + } + + [Fact] + public void Load_MalformedJson_FallsBackToDefaults_DocumentedBehavior() + { + // Pins the known lossy path: genuinely unparseable JSON resets to defaults + // rather than throwing. Unknown *fields* must never take this branch -- + // that distinction is the point of the tests above. + var dir = Dir("malformed"); + WriteConfig(dir, "{ this is not json"); + + var cfg = new ConfigStore(dir).Load(); + + Assert.Equal(new AppConfig().PollIntervalSeconds, cfg.PollIntervalSeconds); + } + + // ---- Beta first-launch seed ---- + + [Fact] + public void SeedConfigFrom_CopiesStableConfig_WhenTargetDoesNotExist() + { + var stable = Dir("stable"); + var beta = Dir("beta"); + WriteConfig(stable, """{ "pollIntervalSeconds": 99 }"""); + + Assert.True(ConfigStore.SeedConfigFrom(stable, beta)); + Assert.Equal(99, new ConfigStore(beta).Load().PollIntervalSeconds); + } + + [Fact] + public void SeedConfigFrom_NeverWritesToTheSource() + { + var stable = Dir("stable-untouched"); + var beta = Dir("beta-untouched"); + WriteConfig(stable, """{ "pollIntervalSeconds": 31 }"""); + var sourceFile = Path.Combine(stable, "config.json"); + var before = File.ReadAllText(sourceFile); + var writtenBefore = File.GetLastWriteTimeUtc(sourceFile); + + ConfigStore.SeedConfigFrom(stable, beta); + + // Then the beta build changes its own settings. + var betaStore = new ConfigStore(beta); + var cfg = betaStore.Load(); + cfg.PollIntervalSeconds = 5; + betaStore.Save(cfg); + + Assert.Equal(before, File.ReadAllText(sourceFile)); + Assert.Equal(writtenBefore, File.GetLastWriteTimeUtc(sourceFile)); + Assert.Equal(31, new ConfigStore(stable).Load().PollIntervalSeconds); + Assert.Equal(5, new ConfigStore(beta).Load().PollIntervalSeconds); + } + + [Fact] + public void SeedConfigFrom_DoesNothing_WhenTargetAlreadyExists() + { + var stable = Dir("s2"); + var beta = Dir("b2"); + WriteConfig(stable, """{ "pollIntervalSeconds": 99 }"""); + WriteConfig(beta, """{ "pollIntervalSeconds": 11 }"""); + + Assert.False(ConfigStore.SeedConfigFrom(stable, beta)); + Assert.Equal(11, new ConfigStore(beta).Load().PollIntervalSeconds); // not clobbered + } + + [Fact] + public void SeedConfigFrom_DoesNothing_WhenSourceHasNoConfig() + { + var stable = Dir("s3-empty"); + Directory.CreateDirectory(stable); + var beta = Dir("b3"); + + Assert.False(ConfigStore.SeedConfigFrom(stable, beta)); + Assert.False(Directory.Exists(beta)); + } + + [Fact] + public void SeedConfigFrom_DoesNothing_WhenSourceAndTargetAreTheSame() + { + // The stable-build case: AppIdentity.ConfigDirectory == StableConfigDirectory, + // so the seed must be inert rather than copying a file onto itself. + var dir = Dir("same"); + WriteConfig(dir, """{ "pollIntervalSeconds": 22 }"""); + + Assert.False(ConfigStore.SeedConfigFrom(dir, dir)); + Assert.False(ConfigStore.SeedConfigFrom(dir, dir.ToUpperInvariant())); + Assert.Equal(22, new ConfigStore(dir).Load().PollIntervalSeconds); + } + + [Fact] + public void SeedConfigFrom_InStableBuild_IsAlwaysANoOp() + { + // Wiring check against the real AppIdentity values, not temp paths. + Assert.False(ConfigStore.SeedConfigFrom( + AppIdentity.StableConfigDirectory, AppIdentity.ConfigDirectory)); + } + + [Fact] + public void SeedConfigFrom_EmptyOrNullPaths_ReturnFalse() + { + Assert.False(ConfigStore.SeedConfigFrom("", Dir("x"))); + Assert.False(ConfigStore.SeedConfigFrom(Dir("y"), "")); + Assert.False(ConfigStore.SeedConfigFrom(null!, null!)); + } +} From f1da998a71c4a2f5f515ac750e13c2ea680ba3d8 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:02:52 -0700 Subject: [PATCH 07/19] fix(beta): genuinely remove the update path, and make the CI guard able to fail The binary scan added in beta.yml was false assurance. Verified empirically: it passes against a NON-BETA published EXE too, so it could never have detected a leak. Two independent reasons. EnableCompressionInSingleFile means the managed assemblies inside publish/GamerGuardian.exe are compressed, so no literal from them appears in the file's bytes -- scanning the shipped EXE finds nothing in either flavor. And "compiled out" was overstated. #if BETA gated the two call sites but left UpdateService and UpdateAvailableWindow in the assembly, URL literal included, with no PublishTrimmed to drop them. The code was present and callable, merely uncalled. Measured on the uncompressed assembly, the update URL was in both flavors. Both are now fixed rather than the check being deleted. The csproj drops Services/UpdateService.cs, UI/UpdateAvailableWindow.xaml and its code-behind from the compile when Beta=true. Every reference to them already sat in a NON-BETA region, so no source change was needed. Measured after: the beta assembly is 22 KB smaller and contains none of the update URL, CheckLatestAsync, or UpdateAvailableWindow. A beta build now genuinely cannot reach the update feed, which is what Part Two asked for. beta.yml scans the managed assembly that gets bundled instead of the compressed EXE, using UTF-16 because .NET stores string literals in the #US heap as UTF-16 -- the earlier scan's UTF-8 pass would have missed the literal even uncompressed. build.yml gains a beta-compile job. It builds the BETA flavor on every PR, which matters because beta.yml is dispatch-only and TreatWarningsAsErrors plus #if-excluded code means the beta path can otherwise rot unnoticed between manual runs. It also builds the stable flavor and asserts the differential: URL present in stable, absent in beta. Asserting only "absent in beta" would pass against a scan that can never match anything, which is precisely the trap the original check fell into, so the job fails loudly if the stable side ever stops matching. --- .github/workflows/beta.yml | 27 +++++++++----- .github/workflows/build.yml | 50 ++++++++++++++++++++++++++ src/GamerGuardian/GamerGuardian.csproj | 12 +++++++ 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index ce7eec0..5d5a757 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -81,19 +81,30 @@ jobs: -p:InformationalVersion=${{ steps.ver.outputs.version }} -o publish - # Fails the run if the BETA constant somehow did not take: a beta binary must - # not contain the update endpoint string at all. - - name: Verify the update path is absent from the binary + # Fails the run if the update path somehow survived into a beta build. + # + # Scans the managed assembly, NOT publish\GamerGuardian.exe. The shipped EXE is + # a single-file bundle published with EnableCompressionInSingleFile, so its + # assemblies are compressed: scanning the EXE's bytes finds nothing even in a + # stable build. An earlier version of this step did exactly that and passed on + # both flavors, which made it false assurance rather than a check. The DLL + # below is the same assembly that gets bundled into that EXE. + # + # build.yml proves this check can actually fail, by asserting the differential + # against a stable build. Here we only assert the absolute property. + - name: Verify the update path is absent from the beta assembly shell: pwsh run: | - $exe = "publish\GamerGuardian.exe" - $bytes = [System.IO.File]::ReadAllBytes($exe) - $text = [System.Text.Encoding]::Unicode.GetString($bytes) + - [System.Text.Encoding]::UTF8.GetString($bytes) + $dll = Get-ChildItem -Recurse -Path "src/GamerGuardian/bin/Release" -Filter "GamerGuardian.dll" | + Select-Object -First 1 + if (-not $dll) { throw "Could not find the built GamerGuardian.dll to scan." } + $bytes = [System.IO.File]::ReadAllBytes($dll.FullName) + # .NET stores string literals as UTF-16 in the #US heap. + $text = [System.Text.Encoding]::Unicode.GetString($bytes) if ($text -match 'api\.github\.com/repos/carterscode/GamerGuardian/releases') { throw "BETA build still contains the update-feed URL -- the update path was not compiled out." } - Write-Host "OK: no update-feed URL found in the beta binary." + Write-Host "OK: no update-feed URL in $($dll.FullName)" - name: List artifacts shell: pwsh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ea750b6..9446ec0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,3 +26,53 @@ jobs: - name: Test run: dotnet test GamerGuardian.sln -c Release --no-build --verbosity normal + + # beta.yml is workflow_dispatch-only by design, so without this nothing routinely + # proves the BETA flavor still compiles. TreatWarningsAsErrors is on and + # #if-excluded code is invisible to the other compile, so the beta path can rot + # silently between manual dispatches. + beta-compile: + runs-on: windows-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore GamerGuardian.sln + + - name: Build the BETA flavor + run: dotnet build src/GamerGuardian/GamerGuardian.csproj -c Release -p:Beta=true --no-restore -o out-beta + + # Differential proof that the update path really is absent from a beta build -- + # and, just as importantly, that the check is capable of failing. Asserting only + # "absent in beta" would pass just as happily against a scan that can never + # match anything, which is exactly the trap the previous byte-scan of the + # compressed single-file EXE fell into. + - name: Build the stable flavor for comparison + run: dotnet build src/GamerGuardian/GamerGuardian.csproj -c Release --no-restore -o out-stable + + - name: Assert the update path is present in stable and absent in beta + shell: pwsh + run: | + function Test-UpdateUrl($path) { + $bytes = [System.IO.File]::ReadAllBytes($path) + # .NET stores string literals as UTF-16 in the #US heap. + $text = [System.Text.Encoding]::Unicode.GetString($bytes) + return $text -match 'api\.github\.com/repos/carterscode/GamerGuardian/releases' + } + $stable = Test-UpdateUrl "out-stable/GamerGuardian.dll" + $beta = Test-UpdateUrl "out-beta/GamerGuardian.dll" + Write-Host "stable contains update URL : $stable (expected True)" + Write-Host "beta contains update URL : $beta (expected False)" + + if (-not $stable) { + throw "The scan found no update URL in the STABLE build. The check is broken and would pass against anything -- fix the scan, do not trust the beta result." + } + if ($beta) { + throw "The BETA build still contains the update-feed URL. The update path was not compiled out." + } + Write-Host "OK: update path present in stable, absent in beta." diff --git a/src/GamerGuardian/GamerGuardian.csproj b/src/GamerGuardian/GamerGuardian.csproj index f1626c8..c722405 100644 --- a/src/GamerGuardian/GamerGuardian.csproj +++ b/src/GamerGuardian/GamerGuardian.csproj @@ -52,6 +52,18 @@ $(DefineConstants);BETA + + + + + + + true From a6b27c0bb9ed2819adc81e37c8de379c041cb196 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:04:09 -0700 Subject: [PATCH 08/19] refactor(config): remove the dead ConsolidateNotifications setting It was persisted in AppConfig, copied in AppConfigCloner, and bound to a "Group multiple drifts into one notification" checkbox, but nothing ever read it -- Notifier does not consult it. Users were offered a toggle that did nothing. Removed from all four places. Every existing config.json still carries the field, so the removal turns it into an unknown property on upgrade. ConfigStore.Load catches everything and returns a fresh AppConfig on failure, so if unknown fields were fatal this would silently wipe every user's settings. They are not -- System.Text.Json ignores them -- and there is now a test pinning exactly that case by name, plus one confirming the field is dropped on the next Save. --- src/GamerGuardian/Models/AppConfig.cs | 1 - src/GamerGuardian/Services/AppConfigCloner.cs | 1 - src/GamerGuardian/UI/SettingsWindow.xaml | 1 - src/GamerGuardian/UI/SettingsWindow.xaml.cs | 2 - tests/GamerGuardian.Tests/ConfigStoreTests.cs | 46 +++++++++++++++++++ 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/GamerGuardian/Models/AppConfig.cs b/src/GamerGuardian/Models/AppConfig.cs index 19a1e73..03b4c8d 100644 --- a/src/GamerGuardian/Models/AppConfig.cs +++ b/src/GamerGuardian/Models/AppConfig.cs @@ -6,7 +6,6 @@ public sealed class AppConfig { public bool LaunchAtStartup { get; set; } = true; public int PollIntervalSeconds { get; set; } = 30; - public bool ConsolidateNotifications { get; set; } = true; public AppThemeChoice Theme { get; set; } = AppThemeChoice.System; public bool CheckForUpdatesOnStartup { get; set; } = true; public string? SkippedUpdateVersion { get; set; } diff --git a/src/GamerGuardian/Services/AppConfigCloner.cs b/src/GamerGuardian/Services/AppConfigCloner.cs index 24121b1..b24e4b0 100644 --- a/src/GamerGuardian/Services/AppConfigCloner.cs +++ b/src/GamerGuardian/Services/AppConfigCloner.cs @@ -42,7 +42,6 @@ public static void CopyInto(AppConfig source, AppConfig target) target.LaunchAtStartup = source.LaunchAtStartup; target.PollIntervalSeconds = source.PollIntervalSeconds; - target.ConsolidateNotifications = source.ConsolidateNotifications; target.Theme = source.Theme; target.CheckForUpdatesOnStartup = source.CheckForUpdatesOnStartup; target.SkippedUpdateVersion = source.SkippedUpdateVersion; diff --git a/src/GamerGuardian/UI/SettingsWindow.xaml b/src/GamerGuardian/UI/SettingsWindow.xaml index 0687edb..aa71cb6 100644 --- a/src/GamerGuardian/UI/SettingsWindow.xaml +++ b/src/GamerGuardian/UI/SettingsWindow.xaml @@ -207,7 +207,6 @@ - diff --git a/src/GamerGuardian/UI/SettingsWindow.xaml.cs b/src/GamerGuardian/UI/SettingsWindow.xaml.cs index 76f46e4..6413e58 100644 --- a/src/GamerGuardian/UI/SettingsWindow.xaml.cs +++ b/src/GamerGuardian/UI/SettingsWindow.xaml.cs @@ -72,7 +72,6 @@ public SettingsWindow( _draft = AppConfigCloner.Clone(_config); LaunchAtStartupCheck.IsChecked = _draft.LaunchAtStartup; - ConsolidateCheck.IsChecked = _draft.ConsolidateNotifications; CheckForUpdatesCheck.IsChecked = _draft.CheckForUpdatesOnStartup; PollSecondsBox.Value = _draft.PollIntervalSeconds; @@ -1252,7 +1251,6 @@ private async Task ApplyChangesCoreAsync(bool closeAfter) private void PersistFormToDraft() { _draft.LaunchAtStartup = LaunchAtStartupCheck.IsChecked == true; - _draft.ConsolidateNotifications = ConsolidateCheck.IsChecked == true; _draft.CheckForUpdatesOnStartup = CheckForUpdatesCheck.IsChecked == true; if (PollSecondsBox.Value is double pv && pv >= 5) _draft.PollIntervalSeconds = (int)pv; diff --git a/tests/GamerGuardian.Tests/ConfigStoreTests.cs b/tests/GamerGuardian.Tests/ConfigStoreTests.cs index ea2db0c..46fe2a9 100644 --- a/tests/GamerGuardian.Tests/ConfigStoreTests.cs +++ b/tests/GamerGuardian.Tests/ConfigStoreTests.cs @@ -87,6 +87,52 @@ public void Load_ConfigWithUnknownFields_RoundTripsThroughSave() Assert.Equal(45, new ConfigStore(dir).Load().PollIntervalSeconds); } + [Fact] + public void Load_ConfigWithRemovedConsolidateNotifications_LoadsWithoutResetting() + { + // consolidateNotifications was persisted for every user before it was + // removed, so every existing config.json on disk still carries it. It is now + // an unknown field and must be ignored, not treated as a parse failure -- + // otherwise the removal would silently wipe everyone's settings on upgrade. + var dir = Dir("removed-field"); + WriteConfig(dir, """ + { + "launchAtStartup": false, + "pollIntervalSeconds": 61, + "consolidateNotifications": true, + "theme": "Light", + "global": { + "gameDvr": { "monitor": true, "desiredOn": false, "autoApply": true } + } + } + """); + + var cfg = new ConfigStore(dir).Load(); + + Assert.False(cfg.LaunchAtStartup); + Assert.Equal(61, cfg.PollIntervalSeconds); + Assert.Equal(AppThemeChoice.Light, cfg.Theme); + Assert.True(cfg.Global.GameDvr.Monitor); + Assert.False(cfg.Global.GameDvr.DesiredOn); + Assert.True(cfg.Global.GameDvr.AutoApply); + } + + [Fact] + public void Save_DropsTheRemovedField_OnNextWrite() + { + var dir = Dir("removed-field-save"); + WriteConfig(dir, """ + { "pollIntervalSeconds": 61, "consolidateNotifications": true } + """); + + var store = new ConfigStore(dir); + store.Save(store.Load()); + + var written = File.ReadAllText(Path.Combine(dir, "config.json")); + Assert.DoesNotContain("consolidateNotifications", written, StringComparison.OrdinalIgnoreCase); + Assert.Equal(61, new ConfigStore(dir).Load().PollIntervalSeconds); + } + [Fact] public void Load_MissingFile_ReturnsDefaults() { From 58e1cc1913cecb3b44a94750a1e733b6d3b2e0a1 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:06:11 -0700 Subject: [PATCH 09/19] fix(ci): scan both UTF-16 byte alignments, or the guard misses odd-aligned literals The scan decoded the assembly as UTF-16 from offset 0. Literals live in the #US heap at arbitrary byte offsets, so an odd-aligned literal decodes to garbage and is never seen -- the check was a coin flip on every build. Found while verifying the BETA gating at binary level: the " [BETA]" marker literal reported as absent from a beta build, which was wrong. It is odd-aligned. The update URL happened to be even-aligned, which is the only reason the differential passed. A recompile shifting it odd would have made the guard silently pass on a real leak. Both scans now decode at offset 0 and offset 1 and match on either. Re-verified: update URL present in stable, absent in beta, marker present in beta only. --- .github/workflows/beta.yml | 10 +++++++--- .github/workflows/build.yml | 13 ++++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 5d5a757..3374abe 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -99,9 +99,13 @@ jobs: Select-Object -First 1 if (-not $dll) { throw "Could not find the built GamerGuardian.dll to scan." } $bytes = [System.IO.File]::ReadAllBytes($dll.FullName) - # .NET stores string literals as UTF-16 in the #US heap. - $text = [System.Text.Encoding]::Unicode.GetString($bytes) - if ($text -match 'api\.github\.com/repos/carterscode/GamerGuardian/releases') { + # .NET stores string literals as UTF-16 in the #US heap, but at arbitrary + # byte offsets. Decoding from offset 0 only sees even-aligned literals; an + # odd-aligned one decodes to garbage and is missed. Check both alignments. + $even = [System.Text.Encoding]::Unicode.GetString($bytes, 0, $bytes.Length - ($bytes.Length % 2)) + $odd = [System.Text.Encoding]::Unicode.GetString($bytes, 1, $bytes.Length - 1 - (($bytes.Length - 1) % 2)) + $pattern = 'api\.github\.com/repos/carterscode/GamerGuardian/releases' + if ([regex]::IsMatch($even, $pattern) -or [regex]::IsMatch($odd, $pattern)) { throw "BETA build still contains the update-feed URL -- the update path was not compiled out." } Write-Host "OK: no update-feed URL in $($dll.FullName)" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9446ec0..d189f08 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,9 +60,16 @@ jobs: run: | function Test-UpdateUrl($path) { $bytes = [System.IO.File]::ReadAllBytes($path) - # .NET stores string literals as UTF-16 in the #US heap. - $text = [System.Text.Encoding]::Unicode.GetString($bytes) - return $text -match 'api\.github\.com/repos/carterscode/GamerGuardian/releases' + # .NET stores string literals as UTF-16 in the #US heap, but at arbitrary + # byte offsets. Decoding the file as UTF-16 from offset 0 only sees + # literals that happen to start on an even boundary -- an odd-aligned one + # decodes to garbage and is missed. Measured: the BETA marker literal in + # this very assembly is odd-aligned while the update URL is even-aligned, + # so a single-alignment scan is a coin flip. Check both. + $even = [System.Text.Encoding]::Unicode.GetString($bytes, 0, $bytes.Length - ($bytes.Length % 2)) + $odd = [System.Text.Encoding]::Unicode.GetString($bytes, 1, $bytes.Length - 1 - (($bytes.Length - 1) % 2)) + $pattern = 'api\.github\.com/repos/carterscode/GamerGuardian/releases' + return [regex]::IsMatch($even, $pattern) -or [regex]::IsMatch($odd, $pattern) } $stable = Test-UpdateUrl "out-stable/GamerGuardian.dll" $beta = Test-UpdateUrl "out-beta/GamerGuardian.dll" From 3f6fa4d8ee3ff553d503483602da0b07ca457e25 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:27:02 -0700 Subject: [PATCH 10/19] docs: pre-extraction feature inventory of all 10 tabs and 5 windows The contract for the NavigationView overhaul. Records, per surface: what it displays, the config keys it reads and writes, whether applying elevates, and what test coverage exists today. The finding that matters most: UI surfaces have zero automated coverage. No test references any window type. The compiler and the suite will not catch a lost surface during the rewrite, so this document is the only check and verification has to be manual, entry by entry. Flags the highest-risk items to lose -- both are read-only information screens that nothing would fail without: the CPU/Power tab's plan comparison chart and CCD dependency card, and the entire Recommended BIOS tab, which hosts no managed setting and so will never show a drift count. --- docs/feature-inventory.md | 209 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/feature-inventory.md diff --git a/docs/feature-inventory.md b/docs/feature-inventory.md new file mode 100644 index 0000000..2f29fbd --- /dev/null +++ b/docs/feature-inventory.md @@ -0,0 +1,209 @@ +# GamerGuardian feature inventory + +**Purpose.** A complete record of every user-facing surface as it exists *before* the +NavigationView overhaul. This is the contract for that work: every entry below must +still exist and still function after the rewrite. Read-only information screens count +— losing a panel that only *shows* something is still a regression. + +**Captured at:** commit `58e1cc1` on `feature/ui-overhaul`, 2026-08-07. + +**How to read the columns** + +- **Displays** — what the user sees, including read-only text. +- **Reads** — config keys (`AppConfig` paths) and live system state. +- **Writes** — config keys and/or system state. +- **Elevates** — whether applying raises UAC. GamerGuardian runs `asInvoker`; HKLM + writes spawn an elevated child (`Services/ElevatedRegistry.cs`, + `WindowsServiceController`, `ScheduledTaskController`). +- **Tests** — coverage *today*. No test references any UI type; the column records + coverage of the logic behind the surface. + +--- + +## Shared window chrome (`UI/SettingsWindow.xaml`, outside the tabs) + +| Element | Displays | Reads | Writes | Elevates | Tests | +|---|---|---|---|---|---| +| `WindowTitleBar` | Title "GamerGuardian" + BETA marker under `-p:Beta=true` | `AppIdentity.DisplaySuffix` | — | No | `AppIdentity` | +| `VersionLink` | App version, dev/beta suffix; tooltip with informational/file version, .NET runtime, build flavor; opens releases page | Assembly attributes | — | No | — | +| `PendingStatusText` | "N pending changes" staged-edit counter | in-memory `_pendingCount` | — | No | — | +| `VerifyAllButton` | Runs a full drift check, reports in-sync vs drifting counts, writes a state snapshot to the change log | all monitors, live system | `changes.log` | No | `ChangeLogger` | +| `CancelButton` | Discards staged edits (`_suppressSaveOnClose`) | — | — | No | — | +| `ApplyButton` | Commits draft → config, applies drift, opens Apply Results, raises reboot prompt | full config | config + system + `changes.log` | Per setting | `ChangeApplier` via `ApplyCommand` | +| `SaveButton` | Apply + close; short-circuits to plain close when nothing is staged | as Apply | as Apply | Per setting | as Apply | +| Learn-more `Expander` template | Per-setting long-form docs: what/why/how it helps/scenarios/recommended/risks/reversible; selectable text so commands can be copied | `SettingDocsCatalog` | — | No | `SettingDocsCatalog`, `SettingDocs` | +| Row template (shared) | Per setting: name, description, Current/Default/Recommended line, Monitor checkbox, desired-state radios, Auto-apply checkbox, reboot badge | per-setting pref + live state | draft pref | No (staging only) | `SettingRecommendations` | + +--- + +## Tab 1 — General + +| Aspect | Detail | +|---|---| +| **Displays** | Three one-click presets (Recommended / Extreme / Reset to defaults) each with an explanatory confirmation dialog; `RecommendedStatusText` staging summary; theme selector; launch-at-startup; check-for-updates-on-startup; manual "Check now" with result dialog; polling-interval number box; "Open change log" | +| **Reads** | `LaunchAtStartup`, `CheckForUpdatesOnStartup`, `PollIntervalSeconds`, `Theme`; `SkippedUpdateVersion`; installed version | +| **Writes** | Those same root keys; `StartupRegistration` HKCU `Run` value on apply; theme applied live; launches installer on update | +| **Elevates** | No (HKCU only). Update install runs an external installer | +| **Tests** | `RecommendedPreset` (all three presets, idempotency, dual-CCD guardrail, power-plan exclusion), `UpdateService` (selection, prerelease/draft skip, history extraction), `ReleaseNotesFormatter`, `AppConfigCloner` | +| **Notes** | Extreme's confirmation text warns about VBS/Memory Integrity and anti-cheat. Presets stage only — nothing writes until Apply. `CheckUpdatesNowButton_Click` is `#if BETA`-compiled to a "disabled in beta builds" message | + +## Tab 2 — Global gaming + +| Aspect | Detail | +|---|---| +| **Displays** | 13 toggle rows: Game Mode, Game DVR, HAGS, Memory Integrity, VBS, System Responsiveness, USB Selective Suspend, Games Task Profile, Mouse Precision, Fullscreen Optimizations, VRR, Fast Startup, Visual Effects. Memory Integrity's row shows "overridden by the VBS full-stack toggle below" when VBS owns the key | +| **Reads** | `Global.{GameMode,GameDvr,Hags,MemoryIntegrity,Vbs,SystemResponsiveness,UsbSelectiveSuspend,GamesTaskProfile,MousePrecision,FullscreenOptimizations,Vrr,FastStartup,VisualFx}`; live registry per monitor | +| **Writes** | Those prefs; on apply, HKCU and HKLM registry per monitor | +| **Elevates** | Yes for HAGS, Memory Integrity, VBS, System Responsiveness, USB Selective Suspend, Games Task Profile, Fast Startup, VRR, Game DVR (policy). No for Game Mode, Mouse Precision, FSO | +| **Tests** | `VbsMonitor` (compliance predicates, disable/enable op building, UEFI-lock detect, HVCI-blocked), `VisualEffectsMask`, `SettingSectionMap`, `SettingRecommendations`, `ApplyCommand` | +| **Notes** | VBS ↔ Memory Integrity defer rule (`MemoryIntegrityMonitor.DefersToVbs`) must survive. Several rows use inverted Gaming/Default labels | + +## Tab 3 — Privacy + +| Aspect | Detail | +|---|---| +| **Displays** | 6 toggle rows: Advertising ID, Tailored experiences, Cross-Device Platform, Activity History, Online speech recognition, Inking & typing personalization | +| **Reads** | `Global.{AdvertisingId,TailoredExperiences,Cdp,ActivityHistory,OnlineSpeech,InkingTyping}`; live registry | +| **Writes** | Those prefs; HKCU and HKLM policy values on apply | +| **Elevates** | Yes for CDP and Activity History (HKLM policy). No for the other four (HKCU) | +| **Tests** | `SettingSectionMap`, `SettingRecommendations`, `SettingDocsCatalog` | + +## Tab 4 — Debloat + +| Aspect | Detail | +|---|---| +| **Displays** | Two grouped lists — **Ads & suggestions** (`DebloatAdsList`) and **Background & bloat** (`DebloatBackgroundList`) — covering Suggested content, Lock screen tips, Finish-setup nag, Start recommendations, File Explorer ads, Feedback popups, Widgets, Edge startup boost/background | +| **Reads** | `Global.{SuggestedContent,LockScreenSpotlight,FinishSetupNag,StartRecommendations,ExplorerAds,FeedbackNag,Widgets,EdgeBackground}`; live registry | +| **Writes** | Those prefs; registry on apply | +| **Elevates** | Yes for Widgets and Edge background (HKLM policy). No for the rest (HKCU) | +| **Tests** | `Debloat` (defaults, drift, clone round-trip), `SettingSectionMap` | +| **Notes** | The two-list split is presentational and must be preserved as two groups | + +## Tab 5 — Network + +| Aspect | Detail | +|---|---| +| **Displays** | 3 rows: Network Throttling, Nagle's algorithm, NIC power management | +| **Reads** | `Global.{NetworkThrottling,Nagle,NicPower}`; live registry; enumerated physical adapters | +| **Writes** | Those prefs; per-adapter HKLM values on apply | +| **Elevates** | Yes, all three. Nagle and NIC power assert across every active physical adapter in one elevation prompt | +| **Tests** | `NetworkAdapters`, `SettingSectionMap`, `SettingDocsCatalog` | +| **Notes** | Network Throttling lives here (moved in v0.1.46) despite being stored in `AppConfig`'s ungrouped block | + +## Tab 6 — Windows services + +| Aspect | Detail | +|---|---| +| **Displays** | Two preset radio buttons (Gaming optimized / Windows default) reflecting current state; `ServicesList` with 28 catalog services — display name, description, current start type, policy-managed indicator, recommended badge, tri-state Default/Manual/Disabled, Monitor, Auto-apply, reboot badge. Plus `ScheduledTasksList` with 5 Application Experience tasks (Monitor, Disabled, Auto-apply) | +| **Reads** | `Services[name]`, `ScheduledTasks[path]`; live SCM start types; live task enabled state; `ServiceCatalog`, `ScheduledTaskCatalog` | +| **Writes** | Those prefs; on apply, service start type via `sc.exe`, or the documented Group Policy override for policy-managed services (e.g. DoSvc); scheduled task enabled flag via `schtasks` | +| **Elevates** | Yes for both services and tasks | +| **Tests** | `ServiceCatalog`, `WindowsServiceController`, `ScheduledTaskCatalog`, `ScheduledTaskController`, `ScheduledTaskMonitor`, `ScheduledTaskConfig` | +| **Notes** | Preset radios are two-way — they reflect state as well as set it. Policy-override display state must survive | + +## Tab 7 — Windows AI + +| Aspect | Detail | +|---|---| +| **Displays** | `WindowsAiRows` with 9 policy toggles (Copilot, Recall, Click-to-Do, Edge AI, Notepad/Paint AI, Search-box AI, AI Actions, Input insights, Office Copilot) using Enabled/Disabled labels; `WindowsAiAppRows` with 4 UWP packages offering removal (Monitor, Remove, Auto-apply) | +| **Reads** | `Global.{Copilot,Recall,ClickToDo,EdgeAi,NotepadPaintAi,SettingsSearchAi,AiActions,InputInsights,OfficeCopilot}`, `WindowsAiApps[package]`; live registry policy; installed AppX packages | +| **Writes** | Those prefs; HKLM+HKCU policy values; AppX package removal | +| **Elevates** | Yes for the policy toggles (HKLM). App removal uses the AppX APIs | +| **Tests** | `WindowsAi`, `SettingSectionMap`, `SettingDocsCatalog` | +| **Notes** | App removal is effectively irreversible without the Store — the warning text must survive | + +## Tab 8 — Display + +| Aspect | Detail | +|---|---| +| **Displays** | One card per active display: label, and a "Now" status line with current HDR / refresh / resolution / DRR. Controls for HDR (Monitor/On/Auto-apply), DRR (shown only when supported), Refresh (Maximum vs Fixed + rate dropdown), Resolution (dropdown + Monitor/Auto-apply) | +| **Reads** | `Displays[stableKey].{Hdr,RefreshRate,Resolution,Drr}`; live display state — HDR support/enabled, current + supported refresh rates, max supported, current + supported resolutions, DRR support | +| **Writes** | Those prefs; on apply, display configuration via the CCD/DisplayConfig APIs and `ChangeDisplaySettingsEx` | +| **Elevates** | No — display APIs are user-mode | +| **Tests** | `DisplayPreferenceResolver` (stable keys, dedupe), `DrrInterop` | +| **Notes** | Per-display keys must stay stable (v0.1.41 fix). A saved Fixed refresh target is kept selectable even if the panel momentarily reports fewer modes. Displays are the only `Volatile`-tier settings — they drive the 30s poll | + +## Tab 9 — CPU / Power + +| Aspect | Detail | +|---|---| +| **Displays** | Detected CPU model; recipe tier line (exact/family/generic, topology, parking strategy, recommended prebuilt); Power Throttling toggle row; power-plan card with Current plan, Recommended plan, Monitor, Want dropdown of all installed plans, Auto-apply; plan-build card with status text and two actions (Suggest best prebuilt, Build optimized plan); **"What this plan changes" expander** with the base-plan summary, the Windows-vs-GamerGuardian side-by-side comparison chart, and the per-CPU rationale; **CCD dependency card** (asymmetric X3D only) listing AMD 3D V-Cache Optimizer service state, Xbox Game Bar state, advisory BIOS CPPC note, and a met/unmet summary | +| **Reads** | `Global.PowerThrottling`, `Global.PowerPlan.*`, `Global.CpuPlan.*`; `CpuDetector`, `CpuTuneCatalog`; installed power schemes via `Powrprof`; live AC values of the base scheme for the comparison chart; AMD service + Game Bar state | +| **Writes** | Those prefs; on action, creates/re-tunes a GG power scheme, writes processor overrides on both rails, sets the active scheme, persists scheme identity | +| **Elevates** | Yes for Power Throttling (HKLM). Plan build/activate uses `Powrprof` (may prompt depending on operation) | +| **Tests** | `CpuDetector`, `CpuTuneCatalog`, `CpuPlanBuilder` (create/reuse/re-tune, delete guard, machine-token binding), `CpuPlanDetails` (comparison rows, rationale, plan naming), `CpuPlanStatus`, `PowerPlanMonitor` | +| **Notes** | **Densest tab by far.** The comparison chart and CCD dependency card are read-only information surfaces and are the highest-risk items to lose. Power-plan combo preselects the CPU-recommended plan and must not stage a phantom pending change on load | + +## Tab 10 — Recommended BIOS + +| Aspect | Detail | +|---|---| +| **Displays** | Advisory-only list of BIOS recommendations for the detected CPU — name → recommended value, plus rationale per item. Falls back to "No CPU-specific BIOS recommendations" when the catalog has none | +| **Reads** | `CpuTuneCatalog` `BiosRecommendation` entries for the resolved CPU | +| **Writes** | Nothing | +| **Elevates** | No | +| **Tests** | `CpuTuneCatalog` | +| **Notes** | **Pure read-only information screen.** Hosts no managed setting, so it will never show a drift count. Easiest surface to accidentally drop | + +--- + +## Windows + +### `SettingsWindow` (`UI/SettingsWindow.xaml`) +Covered above. Single instance owned by `App`; minimize sends it to the tray; closing with staged edits discards them and logs the discard. + +### `NotificationWindow` (`UI/NotificationWindow.xaml`) +| Aspect | Detail | +|---|---| +| **Displays** | Drift toast, bottom-right: header summarising the drifted set, list of drifted items, Apply and Dismiss | +| **Reads** | `DriftReport` from `MonitorService` (notify-only settings) | +| **Writes** | Applies the listed items on Apply; raises the reboot prompt for reboot-required items that applied | +| **Elevates** | Per setting | +| **Tests** | `NotificationHeader`, `MonitorService` (`SelectNotifiable` — auto-apply settings must never reach here) | + +### `ApplyResultsWindow` (`UI/ApplyResultsWindow.xaml`) +| Aspect | Detail | +|---|---| +| **Displays** | Per-setting before → want → now, success/failure icon, "reboot to take effect" badge, mechanism line, copyable verify command, Open change log, Close | +| **Reads** | `ApplyResult` list | +| **Writes** | Clipboard on copy; opens `changes.log` | +| **Elevates** | No | +| **Tests** | `ApplyCommand`, `SettingDocs` (mechanism/verify strings) | + +### `RebootPendingWindow` (`UI/RebootPendingWindow.xaml`) +| Aspect | Detail | +|---|---| +| **Displays** | Bottom-right prompt listing settings that need a restart; Reboot now / Later | +| **Reads** | Descriptions passed by `RebootPrompt` | +| **Writes** | Triggers `shutdown /r /f /t 0` | +| **Elevates** | No (shutdown as the current user) | +| **Tests** | — (raised from `MonitorService` reboot path and `SettingsWindow` apply path) | + +### `UpdateAvailableWindow` (`UI/UpdateAvailableWindow.xaml`) +| Aspect | Detail | +|---|---| +| **Displays** | New version header, current version, scrollable full release-notes history, download progress, Skip this version / Later / Download and install | +| **Reads** | `UpdateInfo`; `SkippedUpdateVersion` | +| **Writes** | `SkippedUpdateVersion`; downloads and launches the installer | +| **Elevates** | No (installer is per-user) | +| **Tests** | `UpdateService`, `ReleaseNotesFormatter` | +| **Notes** | **Excluded from the compile entirely under `-p:Beta=true`** — the beta flavor has no update path | + +--- + +## Cross-cutting behaviour that must survive + +1. **Staged apply.** Toggles mutate a draft, not live config. Nothing is written until Apply or Save & close. Cancel discards; closing with pending edits discards and logs it. +2. **Apply ignores the Monitor checkbox.** Clicking Apply means "do it now" regardless of whether the setting is monitored. +3. **Silent auto-apply stays silent.** A setting with Auto-apply on must never raise a notification — including when it is in verify-backoff or breaker cooldown (`MonitorService.SelectNotifiable`). +4. **Per-action UAC, `asInvoker`.** No manifest elevation. HKLM writes batch into one elevated child where possible. +5. **Reboot prompt fires once per apply**, at the end, from an unowned window that survives the Settings window closing. +6. **Drifted count** is the only status surface (per `docs/ui-overhaul.md`). No managed count, last-scan timestamp, pause reason, or pause persistence. +7. **Memory hygiene.** `OnClosed` releases `Content`, `DataContext`, and every `ItemsSource`; periodic GC + LOH compaction. Regressing this took working set from 23 MB to 135 MB historically. +8. **WPF-UI 3.0.5** — do not downgrade. + +## Test coverage summary + +- **UI surfaces: zero automated coverage.** No test source references `SettingsWindow`, any other window, or `System.Windows.*`. +- **Logic behind the surfaces: well covered** — 37 test files, 682 tests at `58e1cc1`. +- Consequence for the overhaul: the compiler and the test suite will *not* catch a lost UI surface. This document is the only check. Verification must be manual, entry by entry. From fbead66af427389499cdbc7e238af7cc2edfeed3 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:36:14 -0700 Subject: [PATCH 11/19] feat(ui): replace the TabControl with a grouped NavigationView and extract 10 views One window, WPF-UI 3.0.5 NavigationView, grouped by user intent rather than subsystem: Status pinned above Performance (Gaming, Display, CPU and power), Privacy (Telemetry, Windows AI, Network), Cleanup (Debloat, Services), Reference (BIOS), with General in the footer. Each tab became its own UserControl under UI/Views rather than moving the TabControl wholesale. SettingsWindow.xaml drops from 966 lines to 120; the 851 lines of tab content moved verbatim into the views, so no markup was rewritten in the same step that relocated it. The two shared resources (CopyableLearnMoreText, ToggleRowTemplate) moved to UI/SharedResources.xaml and are merged app-level -- they lived on SettingsWindow, which only worked while every tab was inside that window. Views are long-lived fields, not per-navigation constructions, so scroll and expander state survive moving between sections and the Load*() methods keep a stable target. Navigation is a content swap via NavigationView.ReplaceContent, so no page service or DI container is needed. Interactive views forward their handlers to SettingsWindow, which still owns the draft and every apply path -- the extraction split the XAML without also rewriting 2,300 lines of working behavior at the same time. New Status view is the payoff for the section map and the published drift set: aggregate drifted count, per-section counts via SettingSectionMap, and the pause toggle. Nothing else -- no managed count, last-scan timestamp, pause reason, or pause persistence, all recorded as decided against. OnClosed now detaches Status from the monitor before releasing the tree; those handlers sit on a long-lived service and would otherwise keep the whole window graph alive. --- src/GamerGuardian/App.xaml | 5 + src/GamerGuardian/UI/SettingsWindow.xaml | 1054 +---------------- src/GamerGuardian/UI/SettingsWindow.xaml.cs | 250 ++-- src/GamerGuardian/UI/SharedResources.xaml | 88 ++ src/GamerGuardian/UI/Views/BiosView.xaml | 17 + src/GamerGuardian/UI/Views/BiosView.xaml.cs | 14 + src/GamerGuardian/UI/Views/CpuPowerView.xaml | 163 +++ .../UI/Views/CpuPowerView.xaml.cs | 35 + src/GamerGuardian/UI/Views/DebloatView.xaml | 23 + .../UI/Views/DebloatView.xaml.cs | 14 + src/GamerGuardian/UI/Views/DisplayView.xaml | 57 + .../UI/Views/DisplayView.xaml.cs | 14 + src/GamerGuardian/UI/Views/GamingView.xaml | 65 + src/GamerGuardian/UI/Views/GamingView.xaml.cs | 14 + src/GamerGuardian/UI/Views/GeneralView.xaml | 104 ++ .../UI/Views/GeneralView.xaml.cs | 38 + src/GamerGuardian/UI/Views/NetworkView.xaml | 70 ++ .../UI/Views/NetworkView.xaml.cs | 14 + src/GamerGuardian/UI/Views/ServicesView.xaml | 175 +++ .../UI/Views/ServicesView.xaml.cs | 22 + src/GamerGuardian/UI/Views/StatusView.xaml | 70 ++ src/GamerGuardian/UI/Views/StatusView.xaml.cs | 162 +++ src/GamerGuardian/UI/Views/TelemetryView.xaml | 70 ++ .../UI/Views/TelemetryView.xaml.cs | 14 + src/GamerGuardian/UI/Views/WindowsAiView.xaml | 127 ++ .../UI/Views/WindowsAiView.xaml.cs | 14 + 26 files changed, 1617 insertions(+), 1076 deletions(-) create mode 100644 src/GamerGuardian/UI/SharedResources.xaml create mode 100644 src/GamerGuardian/UI/Views/BiosView.xaml create mode 100644 src/GamerGuardian/UI/Views/BiosView.xaml.cs create mode 100644 src/GamerGuardian/UI/Views/CpuPowerView.xaml create mode 100644 src/GamerGuardian/UI/Views/CpuPowerView.xaml.cs create mode 100644 src/GamerGuardian/UI/Views/DebloatView.xaml create mode 100644 src/GamerGuardian/UI/Views/DebloatView.xaml.cs create mode 100644 src/GamerGuardian/UI/Views/DisplayView.xaml create mode 100644 src/GamerGuardian/UI/Views/DisplayView.xaml.cs create mode 100644 src/GamerGuardian/UI/Views/GamingView.xaml create mode 100644 src/GamerGuardian/UI/Views/GamingView.xaml.cs create mode 100644 src/GamerGuardian/UI/Views/GeneralView.xaml create mode 100644 src/GamerGuardian/UI/Views/GeneralView.xaml.cs create mode 100644 src/GamerGuardian/UI/Views/NetworkView.xaml create mode 100644 src/GamerGuardian/UI/Views/NetworkView.xaml.cs create mode 100644 src/GamerGuardian/UI/Views/ServicesView.xaml create mode 100644 src/GamerGuardian/UI/Views/ServicesView.xaml.cs create mode 100644 src/GamerGuardian/UI/Views/StatusView.xaml create mode 100644 src/GamerGuardian/UI/Views/StatusView.xaml.cs create mode 100644 src/GamerGuardian/UI/Views/TelemetryView.xaml create mode 100644 src/GamerGuardian/UI/Views/TelemetryView.xaml.cs create mode 100644 src/GamerGuardian/UI/Views/WindowsAiView.xaml create mode 100644 src/GamerGuardian/UI/Views/WindowsAiView.xaml.cs diff --git a/src/GamerGuardian/App.xaml b/src/GamerGuardian/App.xaml index ee5bb7d..2b3a8d2 100644 --- a/src/GamerGuardian/App.xaml +++ b/src/GamerGuardian/App.xaml @@ -8,6 +8,11 @@ + + diff --git a/src/GamerGuardian/UI/SettingsWindow.xaml b/src/GamerGuardian/UI/SettingsWindow.xaml index aa71cb6..8c01f13 100644 --- a/src/GamerGuardian/UI/SettingsWindow.xaml +++ b/src/GamerGuardian/UI/SettingsWindow.xaml @@ -1,4 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -141,912 +55,66 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/SettingsWindow.xaml.cs b/src/GamerGuardian/UI/SettingsWindow.xaml.cs index 6413e58..a41524f 100644 --- a/src/GamerGuardian/UI/SettingsWindow.xaml.cs +++ b/src/GamerGuardian/UI/SettingsWindow.xaml.cs @@ -28,6 +28,22 @@ public partial class SettingsWindow : FluentWindow private readonly IReadOnlyList _monitors; private readonly MonitorService? _monitorService; private readonly Action _exitApp; + + // One instance per navigation destination, created once and kept alive. Held + // rather than rebuilt on navigation so scroll position, expander state and + // selection survive moving between sections -- and so the Load*() methods below + // keep a stable target to write into, exactly as they did with the TabControl. + private readonly Views.StatusView _status = new(); + private readonly Views.GeneralView _general = new(); + private readonly Views.GamingView _gaming = new(); + private readonly Views.DisplayView _display = new(); + private readonly Views.CpuPowerView _cpuPower = new(); + private readonly Views.TelemetryView _telemetry = new(); + private readonly Views.WindowsAiView _windowsAi = new(); + private readonly Views.NetworkView _network = new(); + private readonly Views.DebloatView _debloat = new(); + private readonly Views.ServicesView _services = new(); + private readonly Views.BiosView _bios = new(); public ObservableCollection DisplayRows { get; } = new(); public ObservableCollection GlobalToggleRows { get; } = new(); public ObservableCollection PrivacyToggleRows { get; } = new(); @@ -71,27 +87,34 @@ public SettingsWindow( _config = store.Load(); _draft = AppConfigCloner.Clone(_config); - LaunchAtStartupCheck.IsChecked = _draft.LaunchAtStartup; - CheckForUpdatesCheck.IsChecked = _draft.CheckForUpdatesOnStartup; - PollSecondsBox.Value = _draft.PollIntervalSeconds; + // Views forward their interactive handlers back here; this window still owns + // the draft and every apply path, exactly as it did behind the TabControl. + _general.Owner = this; + _services.Owner = this; + _cpuPower.Owner = this; + _status.Bind(_monitorService); + + _general.LaunchAtStartupCheck.IsChecked = _draft.LaunchAtStartup; + _general.CheckForUpdatesCheck.IsChecked = _draft.CheckForUpdatesOnStartup; + _general.PollSecondsBox.Value = _draft.PollIntervalSeconds; - ThemeCombo.ItemsSource = Enum.GetValues(); - ThemeCombo.SelectedItem = _draft.Theme; + _general.ThemeCombo.ItemsSource = Enum.GetValues(); + _general.ThemeCombo.SelectedItem = _draft.Theme; VersionLink.Content = GetVersionDisplay(); VersionLink.ToolTip = GetVersionTooltip(); - DisplaysList.ItemsSource = DisplayRows; - GlobalTogglesList.ItemsSource = GlobalToggleRows; - PrivacyTogglesList.ItemsSource = PrivacyToggleRows; - DebloatAdsList.ItemsSource = DebloatAdsRows; - DebloatBackgroundList.ItemsSource = DebloatBackgroundRows; - NetworkTogglesList.ItemsSource = NetworkToggleRows; - PowerTogglesList.ItemsSource = PowerToggleRows; - ServicesList.ItemsSource = ServiceRows; - ScheduledTasksList.ItemsSource = ScheduledTaskRows; - WindowsAiRows.ItemsSource = WindowsAiRowsCollection; - WindowsAiAppRows.ItemsSource = WindowsAiAppRowsCollection; + _display.DisplaysList.ItemsSource = DisplayRows; + _gaming.GlobalTogglesList.ItemsSource = GlobalToggleRows; + _telemetry.PrivacyTogglesList.ItemsSource = PrivacyToggleRows; + _debloat.DebloatAdsList.ItemsSource = DebloatAdsRows; + _debloat.DebloatBackgroundList.ItemsSource = DebloatBackgroundRows; + _network.NetworkTogglesList.ItemsSource = NetworkToggleRows; + _cpuPower.PowerTogglesList.ItemsSource = PowerToggleRows; + _services.ServicesList.ItemsSource = ServiceRows; + _services.ScheduledTasksList.ItemsSource = ScheduledTaskRows; + _windowsAi.WindowsAiRows.ItemsSource = WindowsAiRowsCollection; + _windowsAi.WindowsAiAppRows.ItemsSource = WindowsAiAppRowsCollection; LoadGlobals(); LoadDisplays(); @@ -110,6 +133,52 @@ public SettingsWindow( Title += AppIdentity.DisplaySuffix; if (WindowTitleBar is not null) WindowTitleBar.Title += AppIdentity.DisplaySuffix; + + // Land on Status. NavigationView.SelectedItem is read-only, so the initial + // content is set directly and the first item is marked active for the pane + // highlight; every later change comes through SelectionChanged. + if (MainNav.MenuItems.Count > 0 && + MainNav.MenuItems[0] is Wpf.Ui.Controls.NavigationViewItem first) + { + first.IsActive = true; + } + Navigate("status"); + } + + /// + /// Maps a navigation item's Tag to its view. The views are long-lived fields, so + /// this is a content swap rather than a page construction -- no page service, no + /// per-navigation rebuild, and no loss of scroll or expander state. + /// + private void Navigate(string? tag) + { + System.Windows.UIElement view = tag switch + { + "status" => _status, + "gaming" => _gaming, + "display" => _display, + "cpupower" => _cpuPower, + "telemetry" => _telemetry, + "windowsai" => _windowsAi, + "network" => _network, + "debloat" => _debloat, + "services" => _services, + "bios" => _bios, + "general" => _general, + _ => _status, + }; + + // Refresh the drift numbers on arrival so Status is current even if no scan + // finished while the window was open. + if (ReferenceEquals(view, _status)) _status.Refresh(); + + MainNav.ReplaceContent(view, null); + } + + private void MainNav_SelectionChanged(Wpf.Ui.Controls.NavigationView sender, RoutedEventArgs e) + { + if (sender.SelectedItem is FrameworkElement fe && fe.Tag is string tag) + Navigate(tag); } /// @@ -405,8 +474,8 @@ private void UpdatePresetRadio() _suppressPresetEvents = true; try { - ServicesPresetGaming.IsChecked = matchesGaming; - ServicesPresetDefault.IsChecked = matchesDefault; + _services.ServicesPresetGaming.IsChecked = matchesGaming; + _services.ServicesPresetDefault.IsChecked = matchesDefault; } finally { _suppressPresetEvents = false; } } @@ -416,13 +485,13 @@ private static ServiceTargetState GetDesired(ServiceRow r) => : r.DesiredManual ? ServiceTargetState.Manual : ServiceTargetState.Default; - private void ServicesPresetGaming_Checked(object sender, RoutedEventArgs e) + internal void ServicesPresetGaming_Checked(object sender, RoutedEventArgs e) { if (_suppressPresetEvents) return; ApplyServicesPreset(useRecommended: true); } - private void ServicesPresetDefault_Checked(object sender, RoutedEventArgs e) + internal void ServicesPresetDefault_Checked(object sender, RoutedEventArgs e) { if (_suppressPresetEvents) return; ApplyServicesPreset(useRecommended: false); @@ -838,7 +907,7 @@ private void LoadGlobals() var planNames = PowerPlanMonitor.ListAvailablePlans(); var active = SafeRunGuid(PowerPlanMonitor.GetActivePlan); var activeName = active is not null && planNames.TryGetValue(active.Value, out var name) ? name : "unknown"; - PowerPlanCurrentText.Text = $"Current: {activeName}"; + _cpuPower.PowerPlanCurrentText.Text = $"Current: {activeName}"; // CPU-aware recommendation: the prebuilt plan the catalog picks for this // CPU (Balanced on modern CPUs -- never blindly High Performance), shown // by its installed plan name so it matches the dropdown. Mirrors what the @@ -848,16 +917,16 @@ private void LoadGlobals() var recPlanName = planNames.TryGetValue(recPlanGuid, out var rpn) ? rpn : planRecipe.RecommendedPrebuilt.ToString(); - PowerPlanRecommendedText.Text = $"Recommended: {recPlanName}"; - PowerPlanMonitorCheck.IsChecked = g.PowerPlan.Monitor; - PowerPlanAutoApplyCheck.IsChecked = g.PowerPlan.AutoApply; + _cpuPower.PowerPlanRecommendedText.Text = $"Recommended: {recPlanName}"; + _cpuPower.PowerPlanMonitorCheck.IsChecked = g.PowerPlan.Monitor; + _cpuPower.PowerPlanAutoApplyCheck.IsChecked = g.PowerPlan.AutoApply; var planItems = planNames .OrderBy(kv => kv.Value, StringComparer.OrdinalIgnoreCase) .Select(kv => new PowerPlanItem(kv.Key, kv.Value)) .ToList(); - PowerPlanCombo.ItemsSource = planItems; - PowerPlanCombo.DisplayMemberPath = nameof(PowerPlanItem.Name); + _cpuPower.PowerPlanCombo.ItemsSource = planItems; + _cpuPower.PowerPlanCombo.DisplayMemberPath = nameof(PowerPlanItem.Name); // Preselect priority: the user's own explicit pick, else the CPU-aware // recommended prebuilt (Balanced) so the "Want" dropdown agrees with the @@ -873,7 +942,7 @@ private void LoadGlobals() _loadingPowerPlan = true; try { - PowerPlanCombo.SelectedItem = planItems.FirstOrDefault(p => p.Guid == preselect) + _cpuPower.PowerPlanCombo.SelectedItem = planItems.FirstOrDefault(p => p.Guid == preselect) ?? planItems.FirstOrDefault(); } finally { _loadingPowerPlan = false; } @@ -966,18 +1035,18 @@ private static string GetVersionTooltip() return $"Informational: {info}\nFile: {fileV}\n.NET: {rt}\nBuild: {build}\n\nClick to open releases page"; } - private void ThemeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) + internal void ThemeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) { - if (ThemeCombo.SelectedItem is AppThemeChoice c) + if (_general.ThemeCombo.SelectedItem is AppThemeChoice c) ThemeService.Apply(c); } - private void PowerPlanCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) + internal void PowerPlanCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) { // Ignore the selection we set ourselves while loading — only a real user // change should stage a pending edit. if (_loadingPowerPlan) return; - if (PowerPlanCombo.SelectedItem is not PowerPlanItem pi) return; + if (_cpuPower.PowerPlanCombo.SelectedItem is not PowerPlanItem pi) return; var oldGuid = _draft.Global.PowerPlan.DesiredGuid; var oldName = _draft.Global.PowerPlan.DesiredName; if (oldGuid == pi.Guid.ToString()) return; @@ -989,9 +1058,9 @@ private void PowerPlanCombo_SelectionChanged(object sender, SelectionChangedEven UpdatePendingStatus(); } - private void PowerPlanMonitorCheck_Changed(object sender, RoutedEventArgs e) + internal void PowerPlanMonitorCheck_Changed(object sender, RoutedEventArgs e) { - var v = PowerPlanMonitorCheck.IsChecked == true; + var v = _cpuPower.PowerPlanMonitorCheck.IsChecked == true; if (_draft.Global.PowerPlan.Monitor == v) return; var before = _draft.Global.PowerPlan.Monitor; _draft.Global.PowerPlan.Monitor = v; @@ -1000,9 +1069,9 @@ private void PowerPlanMonitorCheck_Changed(object sender, RoutedEventArgs e) UpdatePendingStatus(); } - private void PowerPlanAutoApplyCheck_Changed(object sender, RoutedEventArgs e) + internal void PowerPlanAutoApplyCheck_Changed(object sender, RoutedEventArgs e) { - var v = PowerPlanAutoApplyCheck.IsChecked == true; + var v = _cpuPower.PowerPlanAutoApplyCheck.IsChecked == true; if (_draft.Global.PowerPlan.AutoApply == v) return; var before = _draft.Global.PowerPlan.AutoApply; _draft.Global.PowerPlan.AutoApply = v; @@ -1011,7 +1080,7 @@ private void PowerPlanAutoApplyCheck_Changed(object sender, RoutedEventArgs e) UpdatePendingStatus(); } - private void OpenChangeLogButton_Click(object sender, RoutedEventArgs e) + internal void OpenChangeLogButton_Click(object sender, RoutedEventArgs e) { try { @@ -1031,7 +1100,7 @@ private void OpenChangeLogButton_Click(object sender, RoutedEventArgs e) catch { } } - private async void CheckUpdatesNowButton_Click(object sender, RoutedEventArgs e) + internal async void CheckUpdatesNowButton_Click(object sender, RoutedEventArgs e) { #if BETA // The whole update path is compiled out of a beta build. The button and its @@ -1066,7 +1135,7 @@ private async void CheckUpdatesNowButton_Click(object sender, RoutedEventArgs e) return; } - var btn = CheckUpdatesNowButton; + var btn = _general.CheckUpdatesNowButton; var prev = btn.Content; btn.IsEnabled = false; btn.Content = "Checking…"; @@ -1250,16 +1319,16 @@ private async Task ApplyChangesCoreAsync(bool closeAfter) /// private void PersistFormToDraft() { - _draft.LaunchAtStartup = LaunchAtStartupCheck.IsChecked == true; - _draft.CheckForUpdatesOnStartup = CheckForUpdatesCheck.IsChecked == true; - if (PollSecondsBox.Value is double pv && pv >= 5) + _draft.LaunchAtStartup = _general.LaunchAtStartupCheck.IsChecked == true; + _draft.CheckForUpdatesOnStartup = _general.CheckForUpdatesCheck.IsChecked == true; + if (_general.PollSecondsBox.Value is double pv && pv >= 5) _draft.PollIntervalSeconds = (int)pv; - if (ThemeCombo.SelectedItem is AppThemeChoice tc) + if (_general.ThemeCombo.SelectedItem is AppThemeChoice tc) _draft.Theme = tc; - _draft.Global.PowerPlan.Monitor = PowerPlanMonitorCheck.IsChecked == true; - _draft.Global.PowerPlan.AutoApply = PowerPlanAutoApplyCheck.IsChecked == true; - if (PowerPlanCombo.SelectedItem is PowerPlanItem pi) + _draft.Global.PowerPlan.Monitor = _cpuPower.PowerPlanMonitorCheck.IsChecked == true; + _draft.Global.PowerPlan.AutoApply = _cpuPower.PowerPlanAutoApplyCheck.IsChecked == true; + if (_cpuPower.PowerPlanCombo.SelectedItem is PowerPlanItem pi) { _draft.Global.PowerPlan.DesiredGuid = pi.Guid.ToString(); _draft.Global.PowerPlan.DesiredName = pi.Name; @@ -1310,15 +1379,30 @@ protected override void OnClosed(EventArgs e) base.OnClosed(e); try { - DisplaysList.ItemsSource = null; - GlobalTogglesList.ItemsSource = null; - PrivacyTogglesList.ItemsSource = null; - DebloatAdsList.ItemsSource = null; - DebloatBackgroundList.ItemsSource = null; - NetworkTogglesList.ItemsSource = null; - PowerTogglesList.ItemsSource = null; - ServicesList.ItemsSource = null; - ScheduledTasksList.ItemsSource = null; + // Unsubscribe Status from the monitor first: it holds handlers on a + // long-lived service, so leaving them attached would keep this whole + // window graph alive after close -- the exact leak shape the app's + // memory hygiene exists to prevent. + _status.Detach(); + + // Drop the navigation content and the views' own references so the + // visual tree is reachable for collection. + MainNav.ReplaceContent(null!, null); + _general.Owner = null; + _services.Owner = null; + _cpuPower.Owner = null; + + _windowsAi.WindowsAiRows.ItemsSource = null; + _windowsAi.WindowsAiAppRows.ItemsSource = null; + _display.DisplaysList.ItemsSource = null; + _gaming.GlobalTogglesList.ItemsSource = null; + _telemetry.PrivacyTogglesList.ItemsSource = null; + _debloat.DebloatAdsList.ItemsSource = null; + _debloat.DebloatBackgroundList.ItemsSource = null; + _network.NetworkTogglesList.ItemsSource = null; + _cpuPower.PowerTogglesList.ItemsSource = null; + _services.ServicesList.ItemsSource = null; + _services.ScheduledTasksList.ItemsSource = null; DisplayRows.Clear(); GlobalToggleRows.Clear(); PrivacyToggleRows.Clear(); @@ -1356,10 +1440,10 @@ private void OnRowPrefChanged(string settingName, string field, string before, s /// that adds new settings to the preset can be picked up by the user /// re-clicking this button -- only the new deltas land. /// - private void ApplyRecommendedPresetButton_Click(object sender, RoutedEventArgs e) => + internal void ApplyRecommendedPresetButton_Click(object sender, RoutedEventArgs e) => RunPreset("Recommended preset", () => RecommendedPreset.ApplyToDraft(_draft)); - private void ApplyExtremePresetButton_Click(object sender, RoutedEventArgs e) + internal void ApplyExtremePresetButton_Click(object sender, RoutedEventArgs e) { var confirm = System.Windows.MessageBox.Show(this, "Apply EXTREME staging will turn on every gaming tweak GamerGuardian knows -- " @@ -1376,7 +1460,7 @@ private void ApplyExtremePresetButton_Click(object sender, RoutedEventArgs e) RunPreset("Extreme preset", () => RecommendedPreset.ApplyExtremeToDraft(_draft)); } - private void ResetToDefaultsButton_Click(object sender, RoutedEventArgs e) + internal void ResetToDefaultsButton_Click(object sender, RoutedEventArgs e) { var confirm = System.Windows.MessageBox.Show(this, "Reset all to defaults will stage every setting back to its Windows default and turn " @@ -1424,9 +1508,9 @@ private void RunPreset(string presetName, Func apply) LoadNetwork(); LoadCpuTabs(); - if (RecommendedStatusText is not null) + if (_general.RecommendedStatusText is not null) { - RecommendedStatusText.Text = result.SettingsChanged == 0 + _general.RecommendedStatusText.Text = result.SettingsChanged == 0 ? $"{presetName}: all {result.SettingsAlreadyCorrect} setting(s) already in that state. Nothing to do." : $"{presetName}: staged {result.SettingsChanged} setting(s); {result.SettingsAlreadyCorrect} already correct. Click Apply or Save & close to commit."; } @@ -1467,9 +1551,9 @@ private void LoadCpuTabs() var r = CpuTuneCatalog.Resolve(cpu); _cpuRecipe = r; - if (CpuDetectedText is null) return; // XAML not ready yet + if (_cpuPower.CpuDetectedText is null) return; // XAML not ready yet - CpuDetectedText.Text = cpu.IsDetected + _cpuPower.CpuDetectedText.Text = cpu.IsDetected ? cpu.RawModel : "CPU: not detected -- using a generic tune"; @@ -1485,21 +1569,21 @@ private void LoadCpuTabs() CcdTopology.Dual => ", dual-CCD", _ => "", }; - CpuTierText.Text = + _cpuPower.CpuTierText.Text = $"Recipe: {tier}{topo}, parking: {ParkingText(r.Parking)}. Recommended prebuilt plan: {r.RecommendedPrebuilt}."; - CpuPlanStatusText.Text = r.IsGeneric + _cpuPower.CpuPlanStatusText.Text = r.IsGeneric ? "No CPU-specific recipe -- 'Build optimized' creates a safe generic tune (aggressive boost, no parking changes)." : $"'Build optimized' will create: {r.PlanName}."; if (r.NeedsCcdRoutingStack) { - CcdDependencyCard.Visibility = Visibility.Visible; + _cpuPower.CcdDependencyCard.Visibility = Visibility.Visible; BuildDependencyRows(r); } else { - CcdDependencyCard.Visibility = Visibility.Collapsed; + _cpuPower.CcdDependencyCard.Visibility = Visibility.Collapsed; } BuildPlanDetails(r); @@ -1514,15 +1598,15 @@ private void LoadCpuTabs() /// private void BuildPlanDetails(CpuTuneResult r) { - if (PlanDetailsList is null) return; - PlanDetailsList.Children.Clear(); + if (_cpuPower.PlanDetailsList is null) return; + _cpuPower.PlanDetailsList.Children.Clear(); - if (PlanDetailsHeader is not null) - PlanDetailsHeader.Text = $"What the optimized plan changes (vs Windows {r.BasePlanDisplayName})"; + if (_cpuPower.PlanDetailsHeader is not null) + _cpuPower.PlanDetailsHeader.Text = $"What the optimized plan changes (vs Windows {r.BasePlanDisplayName})"; var secondary = (System.Windows.Media.Brush)FindResource("TextFillColorSecondaryBrush"); - PlanDetailsList.Children.Add(new System.Windows.Controls.TextBlock + _cpuPower.PlanDetailsList.Children.Add(new System.Windows.Controls.TextBlock { Text = CpuPlanDetails.BaseSummary(r), FontSize = 12, @@ -1530,7 +1614,7 @@ private void BuildPlanDetails(CpuTuneResult r) Foreground = secondary, }); - PlanDetailsList.Children.Add(new System.Windows.Controls.TextBlock + _cpuPower.PlanDetailsList.Children.Add(new System.Windows.Controls.TextBlock { Text = $"Side by side (plugged in) — bold values are what GamerGuardian changes:", FontWeight = FontWeights.SemiBold, @@ -1547,9 +1631,9 @@ private void BuildPlanDetails(CpuTuneResult r) ? (_, _) => null : (sub, set) => Powrprof.ReadAcValue(baseGuid, sub, set); var comparison = CpuPlanDetails.Comparison(r, readBase); - PlanDetailsList.Children.Add(BuildComparisonTable(comparison, r.BasePlanDisplayName)); + _cpuPower.PlanDetailsList.Children.Add(BuildComparisonTable(comparison, r.BasePlanDisplayName)); - PlanDetailsList.Children.Add(new System.Windows.Controls.TextBlock + _cpuPower.PlanDetailsList.Children.Add(new System.Windows.Controls.TextBlock { Text = "Why this suits your CPU:", FontWeight = FontWeights.SemiBold, @@ -1557,7 +1641,7 @@ private void BuildPlanDetails(CpuTuneResult r) TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 10, 0, 0), }); - PlanDetailsList.Children.Add(new System.Windows.Controls.TextBlock + _cpuPower.PlanDetailsList.Children.Add(new System.Windows.Controls.TextBlock { Text = CpuPlanDetails.Rationale(r), FontSize = 12, @@ -1633,7 +1717,7 @@ void AddCell(int row, int col, string text, FontWeight weight, System.Windows.Me private void BuildDependencyRows(CpuTuneResult r) { - CcdDependencyList.Children.Clear(); + _cpuPower.CcdDependencyList.Children.Clear(); var svc = CpuPlanStatus.ReadAmdVCacheService(); var gameBar = CpuPlanStatus.ReadGameBarEnabled(); @@ -1678,12 +1762,12 @@ private void BuildDependencyRows(CpuTuneResult r) Margin = new Thickness(0, 8, 0, 0), FontWeight = FontWeights.SemiBold, }; - CcdDependencyList.Children.Add(summaryBlock); + _cpuPower.CcdDependencyList.Children.Add(summaryBlock); } private void AddDependencyRow(string text) { - CcdDependencyList.Children.Add(new System.Windows.Controls.TextBlock + _cpuPower.CcdDependencyList.Children.Add(new System.Windows.Controls.TextBlock { Text = text, TextWrapping = TextWrapping.Wrap, @@ -1693,12 +1777,12 @@ private void AddDependencyRow(string text) private void BuildBiosRows(CpuTuneResult r) { - if (BiosGuidanceList is null) return; - BiosGuidanceList.Children.Clear(); + if (_bios.BiosGuidanceList is null) return; + _bios.BiosGuidanceList.Children.Clear(); if (r.Bios.Count == 0) { - BiosGuidanceList.Children.Add(new System.Windows.Controls.TextBlock + _bios.BiosGuidanceList.Children.Add(new System.Windows.Controls.TextBlock { Text = "No CPU-specific BIOS recommendations are available for your processor.", TextWrapping = TextWrapping.Wrap, @@ -1724,7 +1808,7 @@ private void BuildBiosRows(CpuTuneResult r) Margin = new Thickness(0, 2, 0, 0), Foreground = (System.Windows.Media.Brush)FindResource("TextFillColorSecondaryBrush"), }); - BiosGuidanceList.Children.Add(block); + _bios.BiosGuidanceList.Children.Add(block); first = false; } } @@ -1736,10 +1820,10 @@ private void BuildBiosRows(CpuTuneResult r) _ => "leave default", }; - private async void BuildOptimizedButton_Click(object sender, RoutedEventArgs e) => + internal async void BuildOptimizedButton_Click(object sender, RoutedEventArgs e) => await RunCpuActionAsync(CpuPlanApply.BuildOptimizedDriftItem, (System.Windows.Controls.ContentControl)sender, "Building…"); - private async void SuggestPrebuiltButton_Click(object sender, RoutedEventArgs e) => + internal async void SuggestPrebuiltButton_Click(object sender, RoutedEventArgs e) => await RunCpuActionAsync(CpuPlanApply.SuggestPrebuiltDriftItem, (System.Windows.Controls.ContentControl)sender, "Applying…"); private async Task RunCpuActionAsync( diff --git a/src/GamerGuardian/UI/SharedResources.xaml b/src/GamerGuardian/UI/SharedResources.xaml new file mode 100644 index 0000000..5c3ad7c --- /dev/null +++ b/src/GamerGuardian/UI/SharedResources.xaml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/BiosView.xaml b/src/GamerGuardian/UI/Views/BiosView.xaml new file mode 100644 index 0000000..f8de4b8 --- /dev/null +++ b/src/GamerGuardian/UI/Views/BiosView.xaml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/BiosView.xaml.cs b/src/GamerGuardian/UI/Views/BiosView.xaml.cs new file mode 100644 index 0000000..9089030 --- /dev/null +++ b/src/GamerGuardian/UI/Views/BiosView.xaml.cs @@ -0,0 +1,14 @@ +using System.Windows.Controls; + +namespace GamerGuardian.UI.Views; + +/// Presentation-only view. Rows are supplied by SettingsWindow via the +/// bound ObservableCollections; this class owns no logic. +public partial class +BiosView + : System.Windows.Controls.UserControl +{ + public +BiosView +() => InitializeComponent(); +} diff --git a/src/GamerGuardian/UI/Views/CpuPowerView.xaml b/src/GamerGuardian/UI/Views/CpuPowerView.xaml new file mode 100644 index 0000000..f543836 --- /dev/null +++ b/src/GamerGuardian/UI/Views/CpuPowerView.xaml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/CpuPowerView.xaml.cs b/src/GamerGuardian/UI/Views/CpuPowerView.xaml.cs new file mode 100644 index 0000000..f5f1af4 --- /dev/null +++ b/src/GamerGuardian/UI/Views/CpuPowerView.xaml.cs @@ -0,0 +1,35 @@ +using System.Windows; +using System.Windows.Controls; + +namespace GamerGuardian.UI.Views; + +/// +/// CPU / Power view: detected CPU and recipe tier, Power Throttling, the power-plan +/// selector, the plan build actions, the "What this plan changes" comparison chart, +/// and the dual-CCD dependency checklist. +/// +/// The densest view, and the one carrying the most read-only information — +/// the comparison chart and the CCD dependency card exist only to inform, so nothing +/// would fail if they were lost. Handlers forward to . +/// +public partial class CpuPowerView : System.Windows.Controls.UserControl +{ + public CpuPowerView() => InitializeComponent(); + + internal SettingsWindow? Owner { get; set; } + + private void PowerPlanMonitorCheck_Changed(object sender, RoutedEventArgs e) => + Owner?.PowerPlanMonitorCheck_Changed(sender, e); + + private void PowerPlanAutoApplyCheck_Changed(object sender, RoutedEventArgs e) => + Owner?.PowerPlanAutoApplyCheck_Changed(sender, e); + + private void PowerPlanCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) => + Owner?.PowerPlanCombo_SelectionChanged(sender, e); + + private void SuggestPrebuiltButton_Click(object sender, RoutedEventArgs e) => + Owner?.SuggestPrebuiltButton_Click(sender, e); + + private void BuildOptimizedButton_Click(object sender, RoutedEventArgs e) => + Owner?.BuildOptimizedButton_Click(sender, e); +} diff --git a/src/GamerGuardian/UI/Views/DebloatView.xaml b/src/GamerGuardian/UI/Views/DebloatView.xaml new file mode 100644 index 0000000..45f0bf7 --- /dev/null +++ b/src/GamerGuardian/UI/Views/DebloatView.xaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/DebloatView.xaml.cs b/src/GamerGuardian/UI/Views/DebloatView.xaml.cs new file mode 100644 index 0000000..ad8ec27 --- /dev/null +++ b/src/GamerGuardian/UI/Views/DebloatView.xaml.cs @@ -0,0 +1,14 @@ +using System.Windows.Controls; + +namespace GamerGuardian.UI.Views; + +/// Presentation-only view. Rows are supplied by SettingsWindow via the +/// bound ObservableCollections; this class owns no logic. +public partial class +DebloatView + : System.Windows.Controls.UserControl +{ + public +DebloatView +() => InitializeComponent(); +} diff --git a/src/GamerGuardian/UI/Views/DisplayView.xaml b/src/GamerGuardian/UI/Views/DisplayView.xaml new file mode 100644 index 0000000..c2f3d73 --- /dev/null +++ b/src/GamerGuardian/UI/Views/DisplayView.xaml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/DisplayView.xaml.cs b/src/GamerGuardian/UI/Views/DisplayView.xaml.cs new file mode 100644 index 0000000..5c388b2 --- /dev/null +++ b/src/GamerGuardian/UI/Views/DisplayView.xaml.cs @@ -0,0 +1,14 @@ +using System.Windows.Controls; + +namespace GamerGuardian.UI.Views; + +/// Presentation-only view. Rows are supplied by SettingsWindow via the +/// bound ObservableCollections; this class owns no logic. +public partial class +DisplayView + : System.Windows.Controls.UserControl +{ + public +DisplayView +() => InitializeComponent(); +} diff --git a/src/GamerGuardian/UI/Views/GamingView.xaml b/src/GamerGuardian/UI/Views/GamingView.xaml new file mode 100644 index 0000000..fdde2bf --- /dev/null +++ b/src/GamerGuardian/UI/Views/GamingView.xaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/GamingView.xaml.cs b/src/GamerGuardian/UI/Views/GamingView.xaml.cs new file mode 100644 index 0000000..a32eb66 --- /dev/null +++ b/src/GamerGuardian/UI/Views/GamingView.xaml.cs @@ -0,0 +1,14 @@ +using System.Windows.Controls; + +namespace GamerGuardian.UI.Views; + +/// Presentation-only view. Rows are supplied by SettingsWindow via the +/// bound ObservableCollections; this class owns no logic. +public partial class +GamingView + : System.Windows.Controls.UserControl +{ + public +GamingView +() => InitializeComponent(); +} diff --git a/src/GamerGuardian/UI/Views/GeneralView.xaml b/src/GamerGuardian/UI/Views/GeneralView.xaml new file mode 100644 index 0000000..30737ac --- /dev/null +++ b/src/GamerGuardian/UI/Views/GeneralView.xaml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/GeneralView.xaml.cs b/src/GamerGuardian/UI/Views/GeneralView.xaml.cs new file mode 100644 index 0000000..18770db --- /dev/null +++ b/src/GamerGuardian/UI/Views/GeneralView.xaml.cs @@ -0,0 +1,38 @@ +using System.Windows; +using System.Windows.Controls; + +namespace GamerGuardian.UI.Views; + +/// +/// General view: one-click presets, theme, startup, update check, polling +/// interval, change log. +/// +/// Interactive handlers forward to , which still +/// owns the draft and every apply path. Keeping the logic there rather than moving +/// it per-view was deliberate for this extraction — the goal was to split the XAML +/// without also rewriting 2,300 lines of working behavior in the same step. +/// +public partial class GeneralView : System.Windows.Controls.UserControl +{ + public GeneralView() => InitializeComponent(); + + internal SettingsWindow? Owner { get; set; } + + private void ResetToDefaultsButton_Click(object sender, RoutedEventArgs e) => + Owner?.ResetToDefaultsButton_Click(sender, e); + + private void ApplyRecommendedPresetButton_Click(object sender, RoutedEventArgs e) => + Owner?.ApplyRecommendedPresetButton_Click(sender, e); + + private void ApplyExtremePresetButton_Click(object sender, RoutedEventArgs e) => + Owner?.ApplyExtremePresetButton_Click(sender, e); + + private void ThemeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) => + Owner?.ThemeCombo_SelectionChanged(sender, e); + + private void CheckUpdatesNowButton_Click(object sender, RoutedEventArgs e) => + Owner?.CheckUpdatesNowButton_Click(sender, e); + + private void OpenChangeLogButton_Click(object sender, RoutedEventArgs e) => + Owner?.OpenChangeLogButton_Click(sender, e); +} diff --git a/src/GamerGuardian/UI/Views/NetworkView.xaml b/src/GamerGuardian/UI/Views/NetworkView.xaml new file mode 100644 index 0000000..415afca --- /dev/null +++ b/src/GamerGuardian/UI/Views/NetworkView.xaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/NetworkView.xaml.cs b/src/GamerGuardian/UI/Views/NetworkView.xaml.cs new file mode 100644 index 0000000..6b9a443 --- /dev/null +++ b/src/GamerGuardian/UI/Views/NetworkView.xaml.cs @@ -0,0 +1,14 @@ +using System.Windows.Controls; + +namespace GamerGuardian.UI.Views; + +/// Presentation-only view. Rows are supplied by SettingsWindow via the +/// bound ObservableCollections; this class owns no logic. +public partial class +NetworkView + : System.Windows.Controls.UserControl +{ + public +NetworkView +() => InitializeComponent(); +} diff --git a/src/GamerGuardian/UI/Views/ServicesView.xaml b/src/GamerGuardian/UI/Views/ServicesView.xaml new file mode 100644 index 0000000..31dcfba --- /dev/null +++ b/src/GamerGuardian/UI/Views/ServicesView.xaml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/ServicesView.xaml.cs b/src/GamerGuardian/UI/Views/ServicesView.xaml.cs new file mode 100644 index 0000000..851f928 --- /dev/null +++ b/src/GamerGuardian/UI/Views/ServicesView.xaml.cs @@ -0,0 +1,22 @@ +using System.Windows; +using System.Windows.Controls; + +namespace GamerGuardian.UI.Views; + +/// +/// Windows services view: the 28-service catalog plus the Application Experience +/// scheduled tasks, and the two preset radios that both reflect and set state. +/// Handlers forward to , which owns the draft. +/// +public partial class ServicesView : System.Windows.Controls.UserControl +{ + public ServicesView() => InitializeComponent(); + + internal SettingsWindow? Owner { get; set; } + + private void ServicesPresetGaming_Checked(object sender, RoutedEventArgs e) => + Owner?.ServicesPresetGaming_Checked(sender, e); + + private void ServicesPresetDefault_Checked(object sender, RoutedEventArgs e) => + Owner?.ServicesPresetDefault_Checked(sender, e); +} diff --git a/src/GamerGuardian/UI/Views/StatusView.xaml b/src/GamerGuardian/UI/Views/StatusView.xaml new file mode 100644 index 0000000..fdf6cdb --- /dev/null +++ b/src/GamerGuardian/UI/Views/StatusView.xaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/StatusView.xaml.cs b/src/GamerGuardian/UI/Views/StatusView.xaml.cs new file mode 100644 index 0000000..782a008 --- /dev/null +++ b/src/GamerGuardian/UI/Views/StatusView.xaml.cs @@ -0,0 +1,162 @@ +using System.Windows; +using System.Windows.Controls; +using GamerGuardian.Models; +using GamerGuardian.Services; + +namespace GamerGuardian.UI.Views; + +/// +/// The pinned Status view. Shows the drifted count in aggregate and per section, +/// and the monitoring pause state. +/// +/// Deliberately nothing else: no managed count, no last-scan timestamp, no +/// pause reason, no pause persistence — all recorded as decided against in +/// docs/ui-overhaul.md. The drifted count is the only status surface. +/// +/// Reads , which the scan already +/// publishes, so nothing here re-runs a drift check. Grouping uses +/// . +/// +public partial class StatusView : System.Windows.Controls.UserControl +{ + private MonitorService? _monitor; + + public StatusView() => InitializeComponent(); + + /// Attach to the live monitor. Safe to call with null (design time, + /// or a window constructed without a monitor service). + internal void Bind(MonitorService? monitor) + { + _monitor = monitor; + if (monitor is not null) + { + // Raised on the poll thread -- marshal before touching controls. + monitor.DriftChanged += OnDriftChanged; + monitor.PauseChanged += OnPauseChanged; + } + Refresh(); + } + + internal void Detach() + { + if (_monitor is null) return; + _monitor.DriftChanged -= OnDriftChanged; + _monitor.PauseChanged -= OnPauseChanged; + _monitor = null; + } + + private void OnDriftChanged(IReadOnlyDictionary _) => + Dispatcher.BeginInvoke(new Action(Refresh)); + + private void OnPauseChanged(bool _) => + Dispatcher.BeginInvoke(new Action(Refresh)); + + /// Re-render from the published snapshot. Cheap — no system reads. + internal void Refresh() + { + try + { + var drift = _monitor?.CurrentDrift; + int total = drift?.Count ?? 0; + + DriftCountText.Text = total.ToString(); + DriftHeadlineText.Text = total == 0 + ? "Everything matches your preferences" + : total == 1 + ? "1 setting has drifted" + : $"{total} settings have drifted"; + + var okBg = (System.Windows.Media.Brush)FindResource("SystemFillColorSuccessBackgroundBrush"); + var okFg = (System.Windows.Media.Brush)FindResource("SystemFillColorSuccessBrush"); + var warnBg = (System.Windows.Media.Brush)FindResource("SystemFillColorCautionBackgroundBrush"); + var warnFg = (System.Windows.Media.Brush)FindResource("SystemFillColorCautionBrush"); + DriftCountBadge.Background = total == 0 ? okBg : warnBg; + DriftCountText.Foreground = total == 0 ? okFg : warnFg; + + BuildSectionCounts(drift); + RefreshPause(); + } + catch { /* status is informational; never let it break the window */ } + } + + private void BuildSectionCounts(IReadOnlyDictionary? drift) + { + SectionCountsList.Children.Clear(); + + var counts = new Dictionary(); + if (drift is not null) + { + foreach (var id in drift.Keys) + { + var s = SettingSectionMap.SectionFor(id); + counts[s] = counts.GetValueOrDefault(s) + 1; + } + } + + // Every section is listed, including the ones at zero, so the view reads as + // a complete picture rather than a list that mysteriously grows and shrinks. + foreach (var (section, label) in SectionLabels) + { + int n = counts.GetValueOrDefault(section); + SectionCountsList.Children.Add(BuildRow(label, n)); + } + + // Anything unmapped would otherwise be invisible; surface it rather than + // silently dropping it from the total. + int unknown = counts.GetValueOrDefault(SettingSection.Unknown); + if (unknown > 0) SectionCountsList.Children.Add(BuildRow("Unmapped", unknown)); + } + + private static readonly (SettingSection Section, string Label)[] SectionLabels = + { + (SettingSection.Gaming, "Gaming"), + (SettingSection.Display, "Display"), + (SettingSection.CpuPower, "CPU and power"), + (SettingSection.Telemetry, "Telemetry"), + (SettingSection.WindowsAi, "Windows AI"), + (SettingSection.Network, "Network"), + (SettingSection.Debloat, "Debloat"), + (SettingSection.Services, "Services"), + }; + + private UIElement BuildRow(string label, int count) + { + var grid = new Grid { Margin = new Thickness(0, 3, 0, 3) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var name = new TextBlock { Text = label, FontSize = 13, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(name, 0); + grid.Children.Add(name); + + var value = new TextBlock + { + Text = count.ToString(), + FontSize = 13, + FontWeight = count > 0 ? FontWeights.SemiBold : FontWeights.Normal, + VerticalAlignment = VerticalAlignment.Center, + }; + if (count == 0) + value.Foreground = (System.Windows.Media.Brush)FindResource("TextFillColorTertiaryBrush"); + Grid.SetColumn(value, 1); + grid.Children.Add(value); + + return grid; + } + + private void RefreshPause() + { + bool paused = _monitor?.IsUserPaused ?? false; + PauseStateText.Text = paused + ? "Paused. Nothing is being checked or corrected until you resume." + : "Running. Monitored settings are checked in the background."; + PauseToggleButton.Content = paused ? "Resume monitoring" : "Pause monitoring"; + PauseToggleButton.IsEnabled = _monitor is not null; + } + + private void PauseToggleButton_Click(object sender, RoutedEventArgs e) + { + _monitor?.TogglePaused(); + RefreshPause(); + } +} diff --git a/src/GamerGuardian/UI/Views/TelemetryView.xaml b/src/GamerGuardian/UI/Views/TelemetryView.xaml new file mode 100644 index 0000000..f3c3fcd --- /dev/null +++ b/src/GamerGuardian/UI/Views/TelemetryView.xaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/TelemetryView.xaml.cs b/src/GamerGuardian/UI/Views/TelemetryView.xaml.cs new file mode 100644 index 0000000..47adca7 --- /dev/null +++ b/src/GamerGuardian/UI/Views/TelemetryView.xaml.cs @@ -0,0 +1,14 @@ +using System.Windows.Controls; + +namespace GamerGuardian.UI.Views; + +/// Presentation-only view. Rows are supplied by SettingsWindow via the +/// bound ObservableCollections; this class owns no logic. +public partial class +TelemetryView + : System.Windows.Controls.UserControl +{ + public +TelemetryView +() => InitializeComponent(); +} diff --git a/src/GamerGuardian/UI/Views/WindowsAiView.xaml b/src/GamerGuardian/UI/Views/WindowsAiView.xaml new file mode 100644 index 0000000..de95cec --- /dev/null +++ b/src/GamerGuardian/UI/Views/WindowsAiView.xaml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/WindowsAiView.xaml.cs b/src/GamerGuardian/UI/Views/WindowsAiView.xaml.cs new file mode 100644 index 0000000..24df8e6 --- /dev/null +++ b/src/GamerGuardian/UI/Views/WindowsAiView.xaml.cs @@ -0,0 +1,14 @@ +using System.Windows.Controls; + +namespace GamerGuardian.UI.Views; + +/// Presentation-only view. Rows are supplied by SettingsWindow via the +/// bound ObservableCollections; this class owns no logic. +public partial class +WindowsAiView + : System.Windows.Controls.UserControl +{ + public +WindowsAiView +() => InitializeComponent(); +} From 93324ef3e15c2b4aee792e8da38abb8df2de8f48 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:47:08 -0700 Subject: [PATCH 12/19] feat(beta): add the beta installer, and make the test suite flavor-aware Adds installer/GamerGuardian-Beta.iss, modeled on the stable script but distinct in every identity that would otherwise collide: AppId GUID, AppName, DefaultDirName, Start Menu group, uninstall entry, and output filename. Per user install like stable. Two collisions the stable script's shape would have caused: Both flavors ship an executable named GamerGuardian.exe, so the uninstall step's Stop-Process by name would have killed a running STABLE install while uninstalling the beta. It now filters on image path under {app}. UninstallDelete and the Run-value cleanup target the beta's own config root and Run value, so uninstalling the beta cannot take the stable install's settings or startup entry with it. The test project also gains a beta flavor. `dotnet test -p:Beta=true` used to fail to compile, because the beta app compile removes UpdateService while UpdateServiceTests still referenced it; that file is now excluded under BETA. The identity tests were then asserting the stable literals against a beta build and failing an app that was behaving correctly -- they are now flavor-aware, so the beta run positively asserts the beta identity (separate config root, .Beta mutex, own Run value, visible marker) rather than skipping the question. Verified on the built artifact: update URL absent from the beta assembly, BETA marker present, beta run creates %APPDATA%\GamerGuardian-Beta containing both config.json and changes.log, stable root untouched. --- .gitignore | 1 + installer/GamerGuardian-Beta.iss | 100 ++++++++++++++++++ src/GamerGuardian/UI/SettingsWindow.xaml.cs | 25 +++-- tests/GamerGuardian.Tests/AppIdentityTests.cs | 100 +++++++++++++----- tests/GamerGuardian.Tests/ConfigStoreTests.cs | 7 +- .../GamerGuardian.Tests.csproj | 18 ++++ 6 files changed, 213 insertions(+), 38 deletions(-) create mode 100644 installer/GamerGuardian-Beta.iss diff --git a/.gitignore b/.gitignore index 136861d..5a18e78 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ bin/ obj/ out/ publish/ +publish-beta/ .tmp_*/ *.user *.suo diff --git a/installer/GamerGuardian-Beta.iss b/installer/GamerGuardian-Beta.iss new file mode 100644 index 0000000..33c8f81 --- /dev/null +++ b/installer/GamerGuardian-Beta.iss @@ -0,0 +1,100 @@ +; GamerGuardian BETA Inno Setup script +; +; Build with: ISCC.exe /DAppVersion=1.2.3 installer\GamerGuardian-Beta.iss +; Expects the BETA payload in ..\publish-beta (publish with -p:Beta=true). +; +; Every identity this script declares is deliberately distinct from +; GamerGuardian.iss so a beta install sits alongside a stable one instead of +; upgrading over it: AppId, AppName, install directory, Start Menu group, +; uninstall entry, and output filename. The uninstall cleanup targets the beta +; config root and the beta Run value, which AppIdentity.cs defines under the +; BETA compile constant -- these strings must stay in step with that file. + +#ifndef AppVersion + #define AppVersion "0.0.0" +#endif + +#define AppName "GamerGuardian Beta" +#define AppPublisher "GamerGuardian Contributors" +#define AppURL "https://github.com/GamerGuardian/GamerGuardian" +#define AppExeName "GamerGuardian.exe" +#define PublishDir "..\publish-beta" + +; Must match AppIdentity.ProductFolderName and AppIdentity.StartupRegistryValueName +; under BETA. +#define BetaConfigDir "GamerGuardian-Beta" +#define BetaRunValue "GamerGuardian-Beta" + +[Setup] +; Distinct from the stable AppId (B6C2D7E1-...). Sharing it would make this +; installer upgrade over -- and then uninstall -- the stable install. +AppId={{9EC25C38-17D8-4AEC-B25B-A914B8C90C98} +AppName={#AppName} +AppVersion={#AppVersion} +AppVerName={#AppName} {#AppVersion} +AppPublisher={#AppPublisher} +AppPublisherURL={#AppURL} +AppSupportURL={#AppURL}/issues +AppUpdatesURL={#AppURL}/releases +DefaultDirName={userpf}\GamerGuardian Beta +DefaultGroupName=GamerGuardian Beta +DisableProgramGroupPage=yes +DisableDirPage=auto +; Per-user, same as stable -- no elevation to install. +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +OutputDir=Output +OutputBaseFilename=GamerGuardian-Beta-Setup-{#AppVersion} +SetupIconFile=..\src\GamerGuardian\Assets\AppIcon.ico +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +UninstallDisplayName={#AppName} +; Restart Manager works off the files being written under {app}, so this only +; targets a running beta -- it will not close a stable install. +CloseApplications=yes +RestartApplications=no +ShowLanguageDialog=no + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked + +[Files] +Source: "{#PublishDir}\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}" +Name: "{group}\Uninstall {#AppName}"; Filename: "{uninstallexe}" +Name: "{userdesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#AppExeName}"; Description: "Launch {#AppName}"; Flags: nowait postinstall skipifsilent + +[UninstallRun] +; Both flavors ship an executable named GamerGuardian.exe, so stopping by name +; alone would kill a running STABLE install while uninstalling the beta. Filter +; on the image path so only the beta process is stopped. +Filename: "powershell.exe"; Parameters: "-NoProfile -Command ""Get-Process -Name GamerGuardian -ErrorAction SilentlyContinue | Where-Object {{ $_.Path -like '{app}\*' } | Stop-Process -Force"""; Flags: runhidden; RunOnceId: "StopGamerGuardianBeta" + +[UninstallDelete] +; The beta's own config root only. The stable root (%APPDATA%\GamerGuardian) is +; never touched -- a beta uninstall must not take the user's real settings with it. +Type: filesandordirs; Name: "{userappdata}\{#BetaConfigDir}" + +[Code] +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +var + RootKey: Integer; +begin + if CurUninstallStep = usUninstall then + begin + RootKey := HKEY_CURRENT_USER; + { Beta's own Run value only; the stable 'GamerGuardian' value is left alone. } + RegDeleteValue(RootKey, 'Software\Microsoft\Windows\CurrentVersion\Run', '{#BetaRunValue}'); + end; +end; diff --git a/src/GamerGuardian/UI/SettingsWindow.xaml.cs b/src/GamerGuardian/UI/SettingsWindow.xaml.cs index a41524f..be7851a 100644 --- a/src/GamerGuardian/UI/SettingsWindow.xaml.cs +++ b/src/GamerGuardian/UI/SettingsWindow.xaml.cs @@ -134,15 +134,22 @@ public SettingsWindow( if (WindowTitleBar is not null) WindowTitleBar.Title += AppIdentity.DisplaySuffix; - // Land on Status. NavigationView.SelectedItem is read-only, so the initial - // content is set directly and the first item is marked active for the pane - // highlight; every later change comes through SelectionChanged. - if (MainNav.MenuItems.Count > 0 && - MainNav.MenuItems[0] is Wpf.Ui.Controls.NavigationViewItem first) - { - first.IsActive = true; - } - Navigate("status"); + // Land on Status -- but only once the NavigationView has applied its + // template. Calling ReplaceContent from the constructor throws inside + // NavigationView.UpdateContent, because the content host it writes into does + // not exist until the control loads. NavigationView.SelectedItem is + // read-only, so the first item is marked active for the pane highlight and + // the content is set directly; every later change comes through + // SelectionChanged. + Loaded += (_, _) => + { + if (MainNav.MenuItems.Count > 0 && + MainNav.MenuItems[0] is Wpf.Ui.Controls.NavigationViewItem first) + { + first.IsActive = true; + } + Navigate("status"); + }; } /// diff --git a/tests/GamerGuardian.Tests/AppIdentityTests.cs b/tests/GamerGuardian.Tests/AppIdentityTests.cs index f907e9f..ff9bbc5 100644 --- a/tests/GamerGuardian.Tests/AppIdentityTests.cs +++ b/tests/GamerGuardian.Tests/AppIdentityTests.cs @@ -6,53 +6,70 @@ namespace GamerGuardian.Tests; /// -/// Pins the non-beta identity to the exact literals the code used before -/// existed. The test project compiles without the BETA -/// constant, so these assertions are the enforced proof that a stable build's -/// paths, mutex name and startup entry are behaviorally unchanged -- a beta-only -/// value leaking into the default build fails here. +/// Pins the per-flavor identity. +/// +/// In a default build these assert the exact literals the code used before +/// existed — the enforced proof that a stable build's +/// paths, mutex name and startup entry are behaviorally unchanged. +/// +/// Built with -p:Beta=true they assert the beta identity instead, so +/// dotnet test -p:Beta=true proves a beta build really is isolated: its own +/// config root, its own mutex, its own startup entry, its own diagnostics. Asserting +/// the stable values against a beta build would fail an app that is behaving +/// correctly. /// public class AppIdentityTests { private static string AppData => Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); +#if BETA + private const string Folder = "GamerGuardian-Beta"; + private const string DiagPrefix = "gamerguardian-beta"; + private const string Mutex = "GamerGuardian.SingleInstance.Beta"; + private const string RunValue = "GamerGuardian-Beta"; +#else + private const string Folder = "GamerGuardian"; + private const string DiagPrefix = "gamerguardian"; + private const string Mutex = "GamerGuardian.SingleInstance"; + private const string RunValue = "GamerGuardian"; +#endif + [Fact] - public void StableBuild_UsesTheOriginalProductFolder() + public void ProductFolder_MatchesTheFlavor() { - Assert.Equal("GamerGuardian", AppIdentity.ProductFolderName); - Assert.Equal(Path.Combine(AppData, "GamerGuardian"), AppIdentity.ConfigDirectory); + Assert.Equal(Folder, AppIdentity.ProductFolderName); + Assert.Equal(Path.Combine(AppData, Folder), AppIdentity.ConfigDirectory); } [Fact] - public void StableBuild_ConfigAndChangeLogPaths_MatchTheOriginals() + public void ConfigAndChangeLog_ShareTheFlavorRoot() { - Assert.Equal(Path.Combine(AppData, "GamerGuardian", "config.json"), AppIdentity.ConfigFile); - Assert.Equal(Path.Combine(AppData, "GamerGuardian", "changes.log"), AppIdentity.ChangeLogFile); + Assert.Equal(Path.Combine(AppData, Folder, "config.json"), AppIdentity.ConfigFile); + Assert.Equal(Path.Combine(AppData, Folder, "changes.log"), AppIdentity.ChangeLogFile); } [Fact] - public void StableBuild_DiagnosticPaths_MatchTheOriginals() + public void DiagnosticPaths_MatchTheFlavor() { - Assert.Equal("gamerguardian", AppIdentity.DiagnosticPrefix); - Assert.Equal(Path.Combine(Path.GetTempPath(), "gamerguardian_error.log"), AppIdentity.ErrorLogFile); - Assert.Equal(Path.Combine(Path.GetTempPath(), "gamerguardian_selftest.txt"), AppIdentity.SelfTestFile); + Assert.Equal(DiagPrefix, AppIdentity.DiagnosticPrefix); + Assert.Equal(Path.Combine(Path.GetTempPath(), DiagPrefix + "_error.log"), AppIdentity.ErrorLogFile); + Assert.Equal(Path.Combine(Path.GetTempPath(), DiagPrefix + "_selftest.txt"), AppIdentity.SelfTestFile); } [Fact] - public void StableBuild_MutexAndStartupNames_MatchTheOriginals() + public void MutexAndStartupNames_MatchTheFlavor() { - Assert.Equal("GamerGuardian.SingleInstance", AppIdentity.MutexName); - Assert.Equal("GamerGuardian", AppIdentity.StartupRegistryValueName); + Assert.Equal(Mutex, AppIdentity.MutexName); + Assert.Equal(RunValue, AppIdentity.StartupRegistryValueName); } [Fact] - public void StableBuild_DisplaySuffix_IsEmpty_SoTitlesAreUnchanged() + public void StableConfigDirectory_AlwaysPointsAtTheStableRoot() { - // Callers concatenate this unconditionally; empty is what keeps the stable - // window title and tray tooltip byte-identical to before. - Assert.Equal(string.Empty, AppIdentity.DisplaySuffix); - Assert.Equal("GamerGuardian", "GamerGuardian" + AppIdentity.DisplaySuffix); - Assert.Equal("GamerGuardian - Settings", "GamerGuardian - Settings" + AppIdentity.DisplaySuffix); + // The seed source. In a stable build it is the same folder as + // ConfigDirectory (making the seed inert); in a beta build it is the + // separate stable install's folder, which the beta only ever reads. + Assert.Equal(Path.Combine(AppData, "GamerGuardian"), AppIdentity.StableConfigDirectory); } [Fact] @@ -74,12 +91,39 @@ public void ChangeLogger_WritesIntoTheSameFolderAsTheConfig() Path.GetDirectoryName(ChangeLogger.LogPath)); } +#if BETA [Fact] - public void StableConfigDirectory_IsTheStableRoot_EvenInAStableBuild() + public void BetaBuild_IsIsolatedFromStable() + { + // The isolation guarantee, asserted directly rather than inferred. + Assert.NotEqual(AppIdentity.ConfigDirectory, AppIdentity.StableConfigDirectory); + Assert.Contains("Beta", AppIdentity.ProductFolderName, StringComparison.Ordinal); + Assert.EndsWith(".Beta", AppIdentity.MutexName, StringComparison.Ordinal); + } + + [Fact] + public void BetaBuild_CarriesAVisibleMarker() + { + Assert.NotEqual(string.Empty, AppIdentity.DisplaySuffix); + Assert.Contains("BETA", AppIdentity.DisplaySuffix, StringComparison.Ordinal); + } +#else + [Fact] + public void StableBuild_DisplaySuffix_IsEmpty_SoTitlesAreUnchanged() + { + // Callers concatenate this unconditionally; empty is what keeps the stable + // window title and tray tooltip byte-identical to before. + Assert.Equal(string.Empty, AppIdentity.DisplaySuffix); + Assert.Equal("GamerGuardian", "GamerGuardian" + AppIdentity.DisplaySuffix); + Assert.Equal("GamerGuardian - Settings", "GamerGuardian - Settings" + AppIdentity.DisplaySuffix); + } + + [Fact] + public void StableBuild_SeedIsInert_BecauseSourceAndTargetMatch() { - // In a stable build the seed source and the live root are the same folder, - // which is what makes the beta first-launch copy a no-op here. - Assert.Equal(Path.Combine(AppData, "GamerGuardian"), AppIdentity.StableConfigDirectory); Assert.Equal(AppIdentity.ConfigDirectory, AppIdentity.StableConfigDirectory); + Assert.False(ConfigStore.SeedConfigFrom( + AppIdentity.StableConfigDirectory, AppIdentity.ConfigDirectory)); } +#endif } diff --git a/tests/GamerGuardian.Tests/ConfigStoreTests.cs b/tests/GamerGuardian.Tests/ConfigStoreTests.cs index 46fe2a9..d0396fc 100644 --- a/tests/GamerGuardian.Tests/ConfigStoreTests.cs +++ b/tests/GamerGuardian.Tests/ConfigStoreTests.cs @@ -227,13 +227,18 @@ public void SeedConfigFrom_DoesNothing_WhenSourceAndTargetAreTheSame() Assert.Equal(22, new ConfigStore(dir).Load().PollIntervalSeconds); } +#if !BETA [Fact] public void SeedConfigFrom_InStableBuild_IsAlwaysANoOp() { - // Wiring check against the real AppIdentity values, not temp paths. + // Wiring check against the real AppIdentity values, not temp paths. Only + // meaningful in a stable build, where source and target are the same folder. + // Under BETA they genuinely differ and seeding is the intended behavior, so + // asserting a no-op here would assert the opposite of what beta must do. Assert.False(ConfigStore.SeedConfigFrom( AppIdentity.StableConfigDirectory, AppIdentity.ConfigDirectory)); } +#endif [Fact] public void SeedConfigFrom_EmptyOrNullPaths_ReturnFalse() diff --git a/tests/GamerGuardian.Tests/GamerGuardian.Tests.csproj b/tests/GamerGuardian.Tests/GamerGuardian.Tests.csproj index f44b147..a8cba58 100644 --- a/tests/GamerGuardian.Tests/GamerGuardian.Tests.csproj +++ b/tests/GamerGuardian.Tests/GamerGuardian.Tests.csproj @@ -22,4 +22,22 @@ + + + + + + + + + $(DefineConstants);BETA + From 5e4def4f424cd409e483ac4894c5cf6910ae24bc Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:57:52 -0700 Subject: [PATCH 13/19] fix(ui): make the navigation pane actually clickable The side pane was completely dead -- no nav item responded to a click. NavigationView.SelectionChanged never fires for these items. They carry no TargetPageType, because navigation here is a content swap via ReplaceContent rather than a page service, and WPF-UI's internal navigate returns before raising the event. Nothing was listening to anything. NavigationViewItem derives from ButtonBase, so each item now handles Click directly, which is independent of the navigation machinery. The pane highlight is driven by hand for the same reason: NavigationView normally maintains IsActive as part of navigating, so with navigation bypassed nothing cleared the previously active item and the highlight would have stuck on Status permanently. Also widens the window from 900x720 to 1150x760 (min 920x560). The pane takes 210px that the old TabControl never did, which squeezed the per-setting name and description column badly enough to clip -- "Advertisin g ID" wrapping mid-word, "Current: Dis...", "Recommende..." truncated. The extra width restores roughly the content area the tabs had. Verified by running the app and clicking through: content swaps, the highlight follows, and the row template resolves from the app-level resource dictionary. --- src/GamerGuardian/UI/SettingsWindow.xaml | 30 +++++++++---------- src/GamerGuardian/UI/SettingsWindow.xaml.cs | 32 +++++++++++++++++++-- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/GamerGuardian/UI/SettingsWindow.xaml b/src/GamerGuardian/UI/SettingsWindow.xaml index 8c01f13..4b515ec 100644 --- a/src/GamerGuardian/UI/SettingsWindow.xaml +++ b/src/GamerGuardian/UI/SettingsWindow.xaml @@ -4,8 +4,8 @@ xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="GamerGuardian - Settings" Closing="OnWindowClosing" - Width="900" Height="720" - MinWidth="680" MinHeight="500" + Width="1150" Height="760" + MinWidth="920" MinHeight="560" WindowStartupLocation="CenterScreen" WindowBackdropType="Mica" ExtendsContentIntoTitleBar="True" @@ -59,50 +59,50 @@ PaneDisplayMode="Left" IsBackButtonVisible="Collapsed" IsPaneToggleVisible="True" - OpenPaneLength="210" - SelectionChanged="MainNav_SelectionChanged"> + + OpenPaneLength="210"> - + - + - + - + - + - + - + - + - + - + @@ -110,7 +110,7 @@ - + diff --git a/src/GamerGuardian/UI/SettingsWindow.xaml.cs b/src/GamerGuardian/UI/SettingsWindow.xaml.cs index be7851a..c3096e8 100644 --- a/src/GamerGuardian/UI/SettingsWindow.xaml.cs +++ b/src/GamerGuardian/UI/SettingsWindow.xaml.cs @@ -182,10 +182,36 @@ private void Navigate(string? tag) MainNav.ReplaceContent(view, null); } - private void MainNav_SelectionChanged(Wpf.Ui.Controls.NavigationView sender, RoutedEventArgs e) + /// + /// Nav item click. Wired per item rather than through + /// NavigationView.SelectionChanged, which never fires here: these items + /// have no TargetPageType (navigation is a content swap, not a page + /// service), so WPF-UI's internal navigate returns before raising it. The pane + /// looked completely dead as a result. NavigationViewItem derives from + /// ButtonBase, so Click is reliable regardless of the navigation machinery. + /// + private void NavItem_Click(object sender, RoutedEventArgs e) { - if (sender.SelectedItem is FrameworkElement fe && fe.Tag is string tag) - Navigate(tag); + if (sender is not Wpf.Ui.Controls.NavigationViewItem item) return; + SetActiveNavItem(item); + if (item.Tag is string tag) Navigate(tag); + } + + /// + /// Drives the pane highlight by hand. NavigationView normally maintains this as + /// part of navigating; with navigation bypassed, nothing else clears the + /// previously active item and the highlight would stick to Status forever. + /// + private void SetActiveNavItem(Wpf.Ui.Controls.NavigationViewItem active) + { + foreach (var source in new[] { MainNav.MenuItems, MainNav.FooterMenuItems }) + { + foreach (var entry in source) + { + if (entry is Wpf.Ui.Controls.NavigationViewItem nvi) + nvi.IsActive = ReferenceEquals(nvi, active); + } + } } /// From 4c002fa1af37b05ef3439252b351328e4586a037 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:04:04 -0700 Subject: [PATCH 14/19] fix(ui): repair em-dashes mangled during the view extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows PowerShell 5.1's Get-Content reads using the system ANSI codepage, not UTF-8. The extraction script that split the tabs into views therefore read each em-dash (UTF-8 E2 80 94) as three CP1252 characters and wrote them back as UTF-8, so "power scheme — High Performance" shipped as "power scheme â€" High Performance". Visible in the running app on the CPU / Power page. Three lines were affected, in CpuPowerView, NetworkView and SharedResources. Repaired in place; the em-dash count now matches the pre-extraction original exactly (3), and no other mojibake signature remains. Caught by looking at the running beta rather than by any build or test -- the corruption is valid UTF-8, just wrong characters, so nothing downstream had any reason to complain. --- src/GamerGuardian/UI/SharedResources.xaml | 2 +- src/GamerGuardian/UI/Views/CpuPowerView.xaml | 2 +- src/GamerGuardian/UI/Views/NetworkView.xaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/GamerGuardian/UI/SharedResources.xaml b/src/GamerGuardian/UI/SharedResources.xaml index 5c3ad7c..e16b377 100644 --- a/src/GamerGuardian/UI/SharedResources.xaml +++ b/src/GamerGuardian/UI/SharedResources.xaml @@ -2,7 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"> diff --git a/src/GamerGuardian/UI/Views/CpuPowerView.xaml b/src/GamerGuardian/UI/Views/CpuPowerView.xaml index f543836..5868dd3 100644 --- a/src/GamerGuardian/UI/Views/CpuPowerView.xaml +++ b/src/GamerGuardian/UI/Views/CpuPowerView.xaml @@ -78,7 +78,7 @@ - diff --git a/src/GamerGuardian/UI/Views/NetworkView.xaml b/src/GamerGuardian/UI/Views/NetworkView.xaml index 415afca..8c0ecc8 100644 --- a/src/GamerGuardian/UI/Views/NetworkView.xaml +++ b/src/GamerGuardian/UI/Views/NetworkView.xaml @@ -2,7 +2,7 @@ - From de75a900f4b0df8d0ee2620bb4141dd3d40fe8c5 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:42:27 -0700 Subject: [PATCH 15/19] feat(ui): add a "This PC" dashboard grid to the Status page Borrows Sparkle's dashboard shape -- a grid of stat cards with tinted icon chips -- but not its card set. Each card here is context for something GamerGuardian actually manages: Processor model + which tuning recipe matched Graphics adapter + VRAM (new detection) Memory installed physical RAM (new detection) Windows edition + version/build, which gate several policies Displays count + primary panel's resolution and refresh Power plan active scheme vs the CPU-aware recommendation No storage card. Sparkle has one because it ships a junk cleaner; this app manages nothing about disks, so it would be decoration rather than context. The drift count stays the hero at the top -- it remains the only status surface, per the recorded decision. GPU and memory detection are both new. GPU comes from the display-class registry key rather than WMI, and memory from GlobalMemoryStatusEx, so the app keeps its pure P/Invoke-plus-registry shape and takes no new package dependency. This also closes the GPU-detection gap flagged in the Sparkle assessment, where HAGS is the obvious future consumer. Two bugs found by running it rather than by building it: The registry's ProductName still reads "Windows 10 ..." on Windows 11 -- Microsoft never updated the value -- so the card showed "Windows 10 Pro" on a build-26200 machine. CorrectEdition rewrites it above the 22000 boundary, which matters for an app that only supports Windows 11. An accent brush key was built as "BrushBackground" instead of "BackgroundBrush", which threw ResourceReferenceKeyNotFound and took the window down on open. Keys are now composed correctly and resolved through a fallback, so a missing theme brush degrades to a plain chip instead of crashing. 13 new tests covering the formatters, the edition fix, card shape, and the guarantee that the readers degrade to "Unknown" rather than throwing on unpredictable hardware. --- src/GamerGuardian/Native/SystemMetrics.cs | 44 ++++ src/GamerGuardian/Services/SystemInfo.cs | 240 ++++++++++++++++++ src/GamerGuardian/UI/Views/StatusView.xaml | 50 +++- src/GamerGuardian/UI/Views/StatusView.xaml.cs | 85 ++++++- tests/GamerGuardian.Tests/SystemInfoTests.cs | 141 ++++++++++ 5 files changed, 558 insertions(+), 2 deletions(-) create mode 100644 src/GamerGuardian/Native/SystemMetrics.cs create mode 100644 src/GamerGuardian/Services/SystemInfo.cs create mode 100644 tests/GamerGuardian.Tests/SystemInfoTests.cs diff --git a/src/GamerGuardian/Native/SystemMetrics.cs b/src/GamerGuardian/Native/SystemMetrics.cs new file mode 100644 index 0000000..e205772 --- /dev/null +++ b/src/GamerGuardian/Native/SystemMetrics.cs @@ -0,0 +1,44 @@ +using System.Runtime.InteropServices; + +namespace GamerGuardian.Native; + +/// +/// Installed-memory read. Uses GlobalMemoryStatusEx rather than WMI so the +/// app keeps its "pure user-mode P/Invoke + registry" shape and picks up no new +/// package dependency for one number. +/// +internal static class SystemMetrics +{ + [StructLayout(LayoutKind.Sequential)] + private struct MEMORYSTATUSEX + { + public uint dwLength; + public uint dwMemoryLoad; + public ulong ullTotalPhys; + public ulong ullAvailPhys; + public ulong ullTotalPageFile; + public ulong ullAvailPageFile; + public ulong ullTotalVirtual; + public ulong ullAvailVirtual; + public ulong ullAvailExtendedVirtual; + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer); + + /// Total installed physical memory in bytes, or null when unreadable. + public static ulong? TotalPhysicalBytes() + { + try + { + var s = new MEMORYSTATUSEX(); + s.dwLength = (uint)Marshal.SizeOf(); + return GlobalMemoryStatusEx(ref s) ? s.ullTotalPhys : null; + } + catch + { + return null; + } + } +} diff --git a/src/GamerGuardian/Services/SystemInfo.cs b/src/GamerGuardian/Services/SystemInfo.cs new file mode 100644 index 0000000..375ea05 --- /dev/null +++ b/src/GamerGuardian/Services/SystemInfo.cs @@ -0,0 +1,240 @@ +using GamerGuardian.Models; +using GamerGuardian.Monitors; +using GamerGuardian.Native; +using Microsoft.Win32; + +namespace GamerGuardian.Services; + +/// One row of the Status page's "This PC" grid. +public sealed record SystemInfoCard(string Title, string Subtitle, IReadOnlyList<(string Label, string Value)> Rows); + +/// +/// Read-only "what machine is this" summary for the Status page. +/// +/// Scoped to things that give context to the settings GamerGuardian actually +/// manages — the CPU behind the power-plan recipe, the GPU behind HAGS/VRR, the +/// Windows build that gates several policies, the displays whose HDR and refresh it +/// controls, and the active power plan. Storage is deliberately absent: the app +/// manages nothing about disks, so a disk card would be decoration. +/// +/// Every read is best-effort and returns "Unknown" rather than throwing — +/// this is an informational surface and must never be able to break the window. +/// +public static class SystemInfo +{ + private const string Unknown = "Unknown"; + + /// Human-readable byte size. Pure, so it is unit-tested directly. + public static string FormatBytes(ulong? bytes) + { + if (bytes is null || bytes == 0) return Unknown; + double gb = bytes.Value / 1024d / 1024d / 1024d; + if (gb >= 1024) return $"{gb / 1024d:0.##} TB"; + // Installed RAM reports slightly under the marketed figure (firmware + // reserves some), so round to a sensible precision rather than pretending + // to exactness: 31.93 GB reads better as "31.9 GB". + return gb >= 100 ? $"{gb:0} GB" : $"{gb:0.#} GB"; + } + + /// + /// Corrects the Windows edition string. ProductName under + /// CurrentVersion still reads "Windows 10 ..." on Windows 11 — Microsoft + /// never updated the value — so an unfiltered read shows "Windows 10 Pro" on a + /// Windows 11 machine. Build 22000 is the 10-to-11 boundary. Pure, so it is + /// unit-tested directly. + /// + public static string CorrectEdition(string? productName, string? currentBuild) + { + if (string.IsNullOrWhiteSpace(productName)) return Unknown; + var name = productName.Trim(); + if (int.TryParse(currentBuild, out var build) && build >= 22000 && + name.StartsWith("Windows 10", StringComparison.OrdinalIgnoreCase)) + { + return string.Concat("Windows 11", name.AsSpan("Windows 10".Length)); + } + return name; + } + + /// Collapses the GPU's registry description to something short enough + /// for a card. Pure, so it is unit-tested directly. + public static string ShortenGpuName(string? driverDesc) + { + if (string.IsNullOrWhiteSpace(driverDesc)) return Unknown; + var s = driverDesc.Trim(); + // Registry descriptions carry vendor noise the card has no room for. + foreach (var noise in new[] { "(R)", "(TM)", "®", "™" }) + s = s.Replace(noise, string.Empty, StringComparison.Ordinal); + return string.Join(' ', s.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } + + // ---- Cards ------------------------------------------------------------ + + public static SystemInfoCard Cpu() + { + try + { + var cpu = CpuDetector.Current; + var recipe = CpuTuneCatalog.Resolve(cpu); + var name = cpu.IsDetected && !string.IsNullOrWhiteSpace(cpu.RawModel) + ? cpu.RawModel.Trim() + : Unknown; + var tier = recipe.Definition.Tier switch + { + TuneTier.Exact => "exact match", + TuneTier.Family => "family match", + _ => "generic", + }; + return new SystemInfoCard("Processor", "Drives the power-plan recipe", new[] + { + ("Model", name), + ("Tuning recipe", tier), + }); + } + catch { return new SystemInfoCard("Processor", "Drives the power-plan recipe", new[] { ("Model", Unknown) }); } + } + + public static SystemInfoCard Gpu() + { + var (name, vram) = ReadPrimaryGpu(); + return new SystemInfoCard("Graphics", "Behind HAGS and VRR", new[] + { + ("Adapter", name), + ("Video memory", vram), + }); + } + + public static SystemInfoCard Memory() + { + return new SystemInfoCard("Memory", "Installed physical RAM", new[] + { + ("Total", FormatBytes(SystemMetrics.TotalPhysicalBytes())), + }); + } + + public static SystemInfoCard Windows() + { + try + { + using var k = Registry.LocalMachine.OpenSubKey( + @"SOFTWARE\Microsoft\Windows NT\CurrentVersion", writable: false); + var product = k?.GetValue("ProductName") as string; + var display = k?.GetValue("DisplayVersion") as string; // e.g. 24H2 + var build = k?.GetValue("CurrentBuild") as string; + var ubr = k?.GetValue("UBR"); + + var version = string.IsNullOrWhiteSpace(display) ? Unknown : display!; + var buildText = string.IsNullOrWhiteSpace(build) + ? Unknown + : (ubr is int u ? $"{build}.{u}" : build!); + + return new SystemInfoCard("Windows", "Gates which settings apply", new[] + { + ("Edition", CorrectEdition(product, build)), + ("Version", $"{version} (build {buildText})"), + }); + } + catch { return new SystemInfoCard("Windows", "Gates which settings apply", new[] { ("Edition", Unknown) }); } + } + + public static SystemInfoCard Displays() + { + try + { + var list = DisplayHelper.EnumerateActiveDisplays(); + if (list.Count == 0) + return new SystemInfoCard("Displays", "HDR, refresh and resolution", new[] { ("Detected", Unknown) }); + + var first = list[0]; + var res = string.IsNullOrEmpty(first.GdiDeviceName) + ? null + : ResolutionMonitor.GetCurrent(first.GdiDeviceName); + var hz = string.IsNullOrEmpty(first.GdiDeviceName) + ? null + : RefreshRateMonitor.GetCurrentRefresh(first.GdiDeviceName); + + var primary = res is { } r && hz is { } h + ? $"{first.DisplayLabel} — {r.Width}x{r.Height} @ {h.Hz} Hz" + : first.DisplayLabel; + + return new SystemInfoCard("Displays", "HDR, refresh and resolution", new[] + { + ("Connected", list.Count == 1 ? "1 display" : $"{list.Count} displays"), + ("Primary", primary), + }); + } + catch { return new SystemInfoCard("Displays", "HDR, refresh and resolution", new[] { ("Detected", Unknown) }); } + } + + public static SystemInfoCard PowerPlan() + { + try + { + var active = PowerPlanMonitor.GetActivePlan(); + var plans = PowerPlanMonitor.ListAvailablePlans(); + var name = active != Guid.Empty && plans.TryGetValue(active, out var n) ? n : Unknown; + var recommended = CpuTuneCatalog.Resolve(CpuDetector.Current).RecommendedPrebuilt.ToString(); + return new SystemInfoCard("Power plan", "Active Windows scheme", new[] + { + ("Active", name), + ("Recommended", recommended), + }); + } + catch { return new SystemInfoCard("Power plan", "Active Windows scheme", new[] { ("Active", Unknown) }); } + } + + /// All cards, in display order. + public static IReadOnlyList All() => new[] + { + Cpu(), Gpu(), Memory(), Windows(), Displays(), PowerPlan(), + }; + + // ---- GPU read --------------------------------------------------------- + + /// + /// Primary display adapter from the display-class registry key. Registry rather + /// than WMI so no new dependency is taken; the enumerated subkeys are the same + /// ones Device Manager shows. Picks the first adapter that reports a driver + /// description, skipping the Microsoft Basic Display Adapter when a real one is + /// present. + /// + private static (string Name, string Vram) ReadPrimaryGpu() + { + try + { + const string classKey = + @"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}"; + using var root = Registry.LocalMachine.OpenSubKey(classKey, writable: false); + if (root is null) return (Unknown, Unknown); + + (string Name, string Vram)? fallback = null; + foreach (var sub in root.GetSubKeyNames()) + { + // Adapter instances are the four-digit numeric subkeys. + if (sub.Length != 4 || !sub.All(char.IsDigit)) continue; + using var k = root.OpenSubKey(sub, writable: false); + if (k?.GetValue("DriverDesc") is not string desc || string.IsNullOrWhiteSpace(desc)) continue; + + var name = ShortenGpuName(desc); + var vram = k.GetValue("HardwareInformation.qwMemorySize") switch + { + long l when l > 0 => FormatBytes((ulong)l), + // Older drivers store a 32-bit MemorySize instead. + int i when i > 0 => FormatBytes((ulong)i), + _ => Unknown, + }; + + if (name.Contains("Basic Display", StringComparison.OrdinalIgnoreCase)) + { + fallback ??= (name, vram); + continue; + } + return (name, vram); + } + return fallback ?? (Unknown, Unknown); + } + catch + { + return (Unknown, Unknown); + } + } +} diff --git a/src/GamerGuardian/UI/Views/StatusView.xaml b/src/GamerGuardian/UI/Views/StatusView.xaml index fdf6cdb..f9d26f2 100644 --- a/src/GamerGuardian/UI/Views/StatusView.xaml +++ b/src/GamerGuardian/UI/Views/StatusView.xaml @@ -35,7 +35,55 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + InitializeComponent(); + public StatusView() + { + InitializeComponent(); + BuildSystemCards(); + } + + /// + /// Fills the "This PC" grid. Read once at construction: none of it changes while + /// the window is open, and the reads touch the registry and display APIs, so + /// re-running them on every drift tick would be waste. + /// + private void BuildSystemCards() + { + try + { + // Icon + accent per card. Tinted chips are the one visual idea taken + // from Sparkle's dashboard; the card set is GamerGuardian's own -- each + // one is context for settings the app actually manages, which is why + // there is no storage card. + // Accent is the theme-brush stem: "Brush" is the icon colour and + // "BackgroundBrush" the chip fill. Both are WPF-UI theme brushes, + // so the chips re-colour correctly in light and dark. + var meta = new (SymbolRegular Icon, string Stem)[] + { + (SymbolRegular.DeveloperBoard24, "SystemFillColorAttention"), // CPU + (SymbolRegular.Desktop24, "SystemFillColorSuccess"), // GPU + (SymbolRegular.Ram20, "SystemFillColorCaution"), // Memory + (SymbolRegular.Window24, "SystemFillColorAttention"), // Windows + (SymbolRegular.DualScreen20, "SystemFillColorSuccess"), // Displays + (SymbolRegular.BatteryCharge24, "SystemFillColorCaution"), // Power plan + }; + + var cards = SystemInfo.All(); + var rows = new List(cards.Count); + for (int i = 0; i < cards.Count; i++) + { + var m = meta[i % meta.Length]; + rows.Add(new SystemCardRow( + cards[i], + m.Icon, + AccentBrush(m.Stem + "Brush", "TextFillColorPrimaryBrush"), + AccentBrush(m.Stem + "BackgroundBrush", "ControlFillColorDefaultBrush"))); + } + SystemCardsList.ItemsSource = rows; + } + catch { /* informational only -- never break the window */ } + } + + /// + /// Theme brush by key, falling back to a brush that always exists. A missing + /// accent must degrade to a plain chip rather than take the window down -- + /// which is exactly what an earlier wrong key name did. + /// + private Brush AccentBrush(string key, string fallbackKey) => + TryFindResource(key) as Brush + ?? TryFindResource(fallbackKey) as Brush + ?? System.Windows.Media.Brushes.Gray; /// Attach to the live monitor. Safe to call with null (design time, /// or a window constructed without a monitor service). @@ -160,3 +219,27 @@ private void PauseToggleButton_Click(object sender, RoutedEventArgs e) RefreshPause(); } } + +/// One card in the Status page's This PC grid, with its icon and accent +/// resolved to brushes so the DataTemplate can bind them directly. +public sealed class SystemCardRow +{ + public SystemCardRow(SystemInfoCard card, SymbolRegular icon, Brush accent, Brush accentBackground) + { + Title = card.Title; + Subtitle = card.Subtitle; + Rows = card.Rows.Select(r => new SystemCardValue(r.Label, r.Value)).ToList(); + Icon = icon; + Accent = accent; + AccentBackground = accentBackground; + } + + public string Title { get; } + public string Subtitle { get; } + public IReadOnlyList Rows { get; } + public SymbolRegular Icon { get; } + public Brush Accent { get; } + public Brush AccentBackground { get; } +} + +public sealed record SystemCardValue(string Label, string Value); diff --git a/tests/GamerGuardian.Tests/SystemInfoTests.cs b/tests/GamerGuardian.Tests/SystemInfoTests.cs new file mode 100644 index 0000000..90d45a0 --- /dev/null +++ b/tests/GamerGuardian.Tests/SystemInfoTests.cs @@ -0,0 +1,141 @@ +using System.Linq; +using GamerGuardian.Services; +using Xunit; + +namespace GamerGuardian.Tests; + +/// +/// Covers the Status page's "This PC" cards. The formatters are pure and tested +/// directly; the card builders are exercised for shape and for the guarantee that +/// they never throw, since they read the registry and display APIs on machines whose +/// hardware we cannot predict. +/// +public class SystemInfoTests +{ + [Theory] + [InlineData(null, "Unknown")] + [InlineData(0UL, "Unknown")] + public void FormatBytes_UnknownForMissingOrZero(ulong? input, string expected) + { + Assert.Equal(expected, SystemInfo.FormatBytes(input)); + } + + [Fact] + public void FormatBytes_RendersGigabytes() + { + Assert.Equal("8 GB", SystemInfo.FormatBytes(8UL * 1024 * 1024 * 1024)); + Assert.Equal("31.9 GB", SystemInfo.FormatBytes(34300000000UL)); + } + + [Fact] + public void FormatBytes_RendersTerabytesAboveAThousandGigabytes() + { + Assert.Equal("2 TB", SystemInfo.FormatBytes(2UL * 1024 * 1024 * 1024 * 1024)); + } + + [Fact] + public void FormatBytes_DropsDecimalsOnLargeGigabyteValues() + { + // Above 100 GB the decimal is noise on a card this size. + Assert.Equal("128 GB", SystemInfo.FormatBytes(128UL * 1024 * 1024 * 1024)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ShortenGpuName_UnknownForMissing(string? input) + { + Assert.Equal("Unknown", SystemInfo.ShortenGpuName(input)); + } + + [Fact] + public void ShortenGpuName_StripsVendorNoiseAndCollapsesWhitespace() + { + Assert.Equal("NVIDIA GeForce RTX 4080", + SystemInfo.ShortenGpuName("NVIDIA(R) GeForce RTX 4080 ")); + Assert.Equal("AMD Radeon RX 7900 XT", + SystemInfo.ShortenGpuName("AMD Radeon™ RX 7900 XT")); + } + + [Fact] + public void ShortenGpuName_LeavesACleanNameAlone() + { + Assert.Equal("Intel Arc A770", SystemInfo.ShortenGpuName("Intel Arc A770")); + } + + [Fact] + public void CorrectEdition_RewritesWindows10ToWindows11AboveBuild22000() + { + // The registry's ProductName still says "Windows 10" on Windows 11, so an + // unfiltered read shows the wrong OS on every Win11 machine -- which is + // every machine this app supports. + Assert.Equal("Windows 11 Pro", SystemInfo.CorrectEdition("Windows 10 Pro", "26200")); + Assert.Equal("Windows 11 Home", SystemInfo.CorrectEdition("Windows 10 Home", "22000")); + } + + [Fact] + public void CorrectEdition_LeavesGenuineWindows10Alone() + { + Assert.Equal("Windows 10 Pro", SystemInfo.CorrectEdition("Windows 10 Pro", "19045")); + } + + [Fact] + public void CorrectEdition_LeavesAlreadyCorrectNamesAlone() + { + Assert.Equal("Windows 11 Enterprise", SystemInfo.CorrectEdition("Windows 11 Enterprise", "26200")); + } + + [Theory] + [InlineData(null, "26200")] + [InlineData("", "26200")] + public void CorrectEdition_UnknownForMissingProduct(string? product, string build) + { + Assert.Equal("Unknown", SystemInfo.CorrectEdition(product, build)); + } + + [Fact] + public void CorrectEdition_UnparseableBuild_LeavesNameAlone() + { + Assert.Equal("Windows 10 Pro", SystemInfo.CorrectEdition("Windows 10 Pro", "not-a-number")); + Assert.Equal("Windows 10 Pro", SystemInfo.CorrectEdition("Windows 10 Pro", null)); + } + + [Fact] + public void All_ReturnsTheSixCards_InOrder() + { + var cards = SystemInfo.All(); + Assert.Equal(6, cards.Count); + Assert.Equal( + new[] { "Processor", "Graphics", "Memory", "Windows", "Displays", "Power plan" }, + cards.Select(c => c.Title).ToArray()); + } + + [Fact] + public void All_CardsAlwaysCarryAtLeastOneRow_AndNeverThrow() + { + // These read the registry, display APIs and power schemes on whatever + // hardware the suite runs on. They must degrade to "Unknown", not throw. + foreach (var card in SystemInfo.All()) + { + Assert.False(string.IsNullOrWhiteSpace(card.Title)); + Assert.False(string.IsNullOrWhiteSpace(card.Subtitle)); + Assert.NotEmpty(card.Rows); + foreach (var (label, value) in card.Rows) + { + Assert.False(string.IsNullOrWhiteSpace(label)); + Assert.False(string.IsNullOrWhiteSpace(value)); + } + } + } + + [Fact] + public void NoStorageCard_TheAppManagesNothingAboutDisks() + { + // Sparkle's dashboard has one because it ships a junk cleaner; GamerGuardian + // does not, so a disk card would be decoration rather than context. + Assert.DoesNotContain(SystemInfo.All(), c => + c.Title.Contains("Storage", System.StringComparison.OrdinalIgnoreCase) || + c.Title.Contains("Disk", System.StringComparison.OrdinalIgnoreCase)); + } +} From 28b6f277c579735bc4d794a5e9b574ba3bdc41e5 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:04:10 -0700 Subject: [PATCH 16/19] fix(ui): make the This PC cards uniform, legible and lighter, and fill the Memory card Three complaints about the Status dashboard, one root cause between the first two. Uniform size and level icons. The cards were ui:Card, whose template centres its content presenter, so the one-row Memory card floated its header lower than its two-row neighbours and the six icons did not line up. Swapped to a plain Border that stretches into its UniformGrid cell, with a fixed-height (34px) header grid so every icon and every first data row starts at the same offset. Subtitles ellipsize rather than wrap so one long one cannot push a single header taller. Too dark. ui:Card's dark-theme fill is 5% white, which measured 50/255 against a 39/255 page -- near-invisible. ControlFillColorSecondaryBrush measures 58 and takes a CardStrokeColorDefaultBrush outline, so the cards actually read as cards. Both brushes are WPF-UI theme brushes, so light theme still works. Black text. Dropping ui:Card also dropped the Foreground its template supplied, and TextBlock's own default is Black -- every value in the grid rendered black on dark. Verified by sampling the capture: glyphs inside the cards measured 0-58 while text elsewhere reached 255. The Border now sets TextElement.Foreground explicitly and descendants inherit it. Memory card. It carried a single "Total" row and looked empty next to its neighbours. It now reads the SMBIOS table via GetSystemFirmwareTable('RSMB') -- plain P/Invoke, no WMI, no new package -- and reports installed size with the memory type plus the module layout and speed: "31.2 GB DDR5" / "2 x 16 GB @ 5600 MT/s". Configured speed wins over rated speed because that is what the machine actually runs at, and the slowest module wins when they disagree. Mismatched capacities are listed rather than collapsed, since a mismatched pair is worth noticing. The parse is a pure function over a byte buffer with 20 tests covering the extended-size sentinel, the kilobyte unit bit, empty and unknown slots, short SMBIOS 2.1 structures, string-set walking, end-of-table, and 200 rounds of random garbage that must not throw. 721 stable / 703 beta tests pass. Co-Authored-By: Claude Opus 5 --- src/GamerGuardian/Native/Smbios.cs | 154 +++++++++++++ src/GamerGuardian/Native/SystemMetrics.cs | 34 +++ src/GamerGuardian/Services/SystemInfo.cs | 40 +++- src/GamerGuardian/UI/Views/StatusView.xaml | 47 +++- tests/GamerGuardian.Tests/SmbiosTests.cs | 256 +++++++++++++++++++++ 5 files changed, 520 insertions(+), 11 deletions(-) create mode 100644 src/GamerGuardian/Native/Smbios.cs create mode 100644 tests/GamerGuardian.Tests/SmbiosTests.cs diff --git a/src/GamerGuardian/Native/Smbios.cs b/src/GamerGuardian/Native/Smbios.cs new file mode 100644 index 0000000..859ae06 --- /dev/null +++ b/src/GamerGuardian/Native/Smbios.cs @@ -0,0 +1,154 @@ +namespace GamerGuardian.Native; + +/// One populated memory slot, as reported by SMBIOS. +/// Module capacity. +/// Configured speed in MT/s, or 0 when the firmware does not report one. +/// "DDR5", "DDR4", … or null when the type code is unrecognised. +public sealed record MemoryModule(ulong SizeBytes, int SpeedMts, string? TypeName); + +/// +/// Minimal SMBIOS reader: just enough to describe installed memory. +/// +/// The raw table comes from GetSystemFirmwareTable('RSMB'), which is a +/// plain P/Invoke — no WMI, no new package — so it fits the app's "user-mode +/// P/Invoke + registry" shape. The parse is a pure function over a byte buffer and +/// is unit-tested directly against synthetic tables. +/// +/// Reference: DMTF DSP0134, structure Type 17 (Memory Device). +/// +public static class Smbios +{ + // RawSMBIOSData header the provider prepends: Used20CallingMethod, + // SMBIOSMajorVersion, SMBIOSMinorVersion, DmiRevision, then a DWORD Length. + private const int RawHeaderLength = 8; + + private const byte MemoryDeviceType = 17; + + // Field offsets inside a Type 17 structure. + private const int OffsetSize = 0x0C; // WORD + private const int OffsetMemoryType = 0x12; // BYTE + private const int OffsetSpeed = 0x15; // WORD, MT/s (SMBIOS 2.3+) + private const int OffsetExtendedSize = 0x1C; // DWORD, MB (SMBIOS 2.7+) + private const int OffsetConfiguredSpeed = 0x20; // WORD, MT/s (SMBIOS 2.7+) + + /// + /// Every populated memory slot in the table, in firmware order. Empty slots + /// (Size == 0) and unreadable entries are skipped. Returns an empty list rather + /// than throwing on any malformed input — this feeds an informational card. + /// + public static IReadOnlyList ParseMemoryDevices(byte[]? rawSmbios) + { + var modules = new List(); + if (rawSmbios is null || rawSmbios.Length <= RawHeaderLength) return modules; + + try + { + uint declared = BitConverter.ToUInt32(rawSmbios, 4); + int end = RawHeaderLength + (int)Math.Min(declared, (uint)(rawSmbios.Length - RawHeaderLength)); + + int i = RawHeaderLength; + while (i + 4 <= end) + { + byte type = rawSmbios[i]; + byte formattedLength = rawSmbios[i + 1]; + + // A structure is at least its 4-byte header; anything shorter means + // the table is corrupt and walking further would be guesswork. + if (formattedLength < 4 || i + formattedLength > end) break; + + if (type == MemoryDeviceType) + { + var module = ReadMemoryDevice(rawSmbios, i, formattedLength); + if (module is not null) modules.Add(module); + } + + // Type 127 (End-of-Table) terminates the table. + if (type == 127) break; + + i = SkipStrings(rawSmbios, i + formattedLength, end); + if (i < 0) break; + } + } + catch + { + return modules; + } + + return modules; + } + + /// + /// Advances past a structure's unformatted string set, which ends at a double + /// NUL. A structure with no strings is a single pair of NULs. Returns -1 when + /// the terminator is missing. + /// + private static int SkipStrings(byte[] data, int start, int end) + { + int i = start; + while (i + 1 < end) + { + if (data[i] == 0 && data[i + 1] == 0) return i + 2; + i++; + } + return -1; + } + + private static MemoryModule? ReadMemoryDevice(byte[] data, int start, int length) + { + if (length < OffsetSize + 2) return null; + + ushort rawSize = BitConverter.ToUInt16(data, start + OffsetSize); + if (rawSize == 0) return null; // slot present but empty + if (rawSize == 0xFFFF) return null; // size unknown — nothing worth showing + + ulong sizeBytes; + if (rawSize == 0x7FFF) + { + // 0x7FFF means "too large for the WORD, read Extended Size (in MB)". + if (length < OffsetExtendedSize + 4) return null; + uint extendedMb = BitConverter.ToUInt32(data, start + OffsetExtendedSize) & 0x7FFFFFFF; + if (extendedMb == 0) return null; + sizeBytes = (ulong)extendedMb * 1024UL * 1024UL; + } + else + { + // Bit 15 selects the unit: set means KB, clear means MB. + ulong unit = (rawSize & 0x8000) != 0 ? 1024UL : 1024UL * 1024UL; + sizeBytes = (ulong)(rawSize & 0x7FFF) * unit; + } + + // Configured speed is what the modules actually run at (the XMP/EXPO figure); + // Speed is the module's rated maximum. Prefer the former, fall back to the + // latter, and treat 0 / 0xFFFF as "not reported". + int speed = 0; + if (length >= OffsetConfiguredSpeed + 2) + speed = Normalise(BitConverter.ToUInt16(data, start + OffsetConfiguredSpeed)); + if (speed == 0 && length >= OffsetSpeed + 2) + speed = Normalise(BitConverter.ToUInt16(data, start + OffsetSpeed)); + + string? typeName = length >= OffsetMemoryType + 1 + ? MemoryTypeName(data[start + OffsetMemoryType]) + : null; + + return new MemoryModule(sizeBytes, speed, typeName); + + static int Normalise(ushort v) => v == 0xFFFF ? 0 : v; + } + + /// Type codes from DSP0134 Table 76. Only the ones a machine running + /// this app could plausibly report are named; anything else stays null. + private static string? MemoryTypeName(byte code) => code switch + { + 0x13 => "DDR", + 0x14 => "DDR2", + 0x18 => "DDR3", + 0x1A => "DDR4", + 0x1B => "LPDDR", + 0x1C => "LPDDR2", + 0x1D => "LPDDR3", + 0x1E => "LPDDR4", + 0x22 => "DDR5", + 0x23 => "LPDDR5", + _ => null, + }; +} diff --git a/src/GamerGuardian/Native/SystemMetrics.cs b/src/GamerGuardian/Native/SystemMetrics.cs index e205772..e26a4f4 100644 --- a/src/GamerGuardian/Native/SystemMetrics.cs +++ b/src/GamerGuardian/Native/SystemMetrics.cs @@ -41,4 +41,38 @@ private struct MEMORYSTATUSEX return null; } } + + // 'R','S','M','B' — the raw SMBIOS firmware table provider. + private const uint RawSmbiosProvider = 0x52534D42; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetSystemFirmwareTable( + uint firmwareTableProviderSignature, + uint firmwareTableId, + byte[]? firmwareTableBuffer, + uint bufferSize); + + /// + /// The raw SMBIOS table, or null when the firmware does not expose one. Called + /// twice as the API requires: once with a null buffer to learn the size, once to + /// fill it. Parsing lives in so it stays testable. + /// + public static byte[]? ReadRawSmbios() + { + try + { + uint size = GetSystemFirmwareTable(RawSmbiosProvider, 0, null, 0); + if (size == 0) return null; + + var buffer = new byte[size]; + uint written = GetSystemFirmwareTable(RawSmbiosProvider, 0, buffer, size); + // A second call returning 0, or more than we allocated, means the table + // changed or the call failed; either way there is nothing safe to parse. + return written == 0 || written > size ? null : buffer; + } + catch + { + return null; + } + } } diff --git a/src/GamerGuardian/Services/SystemInfo.cs b/src/GamerGuardian/Services/SystemInfo.cs index 375ea05..ff9ccee 100644 --- a/src/GamerGuardian/Services/SystemInfo.cs +++ b/src/GamerGuardian/Services/SystemInfo.cs @@ -105,12 +105,50 @@ public static SystemInfoCard Gpu() public static SystemInfoCard Memory() { + var modules = Smbios.ParseMemoryDevices(SystemMetrics.ReadRawSmbios()); + + var installed = FormatBytes(SystemMetrics.TotalPhysicalBytes()); + // Type is a property of the installed sticks, so it belongs with the total + // rather than in the per-module line. + var type = SharedTypeName(modules); + if (type is not null && installed != Unknown) installed = $"{installed} {type}"; + return new SystemInfoCard("Memory", "Installed physical RAM", new[] { - ("Total", FormatBytes(SystemMetrics.TotalPhysicalBytes())), + ("Installed", installed), + ("Modules", DescribeModules(modules)), }); } + /// The memory type when every module agrees on one, else null. Pure. + public static string? SharedTypeName(IReadOnlyList modules) + { + if (modules.Count == 0) return null; + var first = modules[0].TypeName; + if (string.IsNullOrWhiteSpace(first)) return null; + return modules.All(m => m.TypeName == first) ? first : null; + } + + /// + /// One line describing the populated slots — "2 × 16 GB @ 6000 MT/s". Mixed + /// capacities are listed rather than averaged, because a mismatched pair is + /// exactly the sort of thing worth noticing. Pure, so it is unit-tested directly. + /// + public static string DescribeModules(IReadOnlyList modules) + { + if (modules.Count == 0) return Unknown; + + var sizes = modules.Select(m => m.SizeBytes).ToList(); + var layout = sizes.Distinct().Count() == 1 + ? (modules.Count == 1 ? FormatBytes(sizes[0]) : $"{modules.Count} × {FormatBytes(sizes[0])}") + : string.Join(" + ", sizes.Select(s => FormatBytes(s))); + + // Modules can report different speeds; the machine runs at the slowest, so + // that is the honest number to show. + var speeds = modules.Where(m => m.SpeedMts > 0).Select(m => m.SpeedMts).ToList(); + return speeds.Count == 0 ? layout : $"{layout} @ {speeds.Min()} MT/s"; + } + public static SystemInfoCard Windows() { try diff --git a/src/GamerGuardian/UI/Views/StatusView.xaml b/src/GamerGuardian/UI/Views/StatusView.xaml index f9d26f2..036d468 100644 --- a/src/GamerGuardian/UI/Views/StatusView.xaml +++ b/src/GamerGuardian/UI/Views/StatusView.xaml @@ -44,12 +44,37 @@ - - - + + + + + + + + + + - - - + + - - + + @@ -78,7 +105,7 @@ - + diff --git a/tests/GamerGuardian.Tests/SmbiosTests.cs b/tests/GamerGuardian.Tests/SmbiosTests.cs new file mode 100644 index 0000000..84e5b81 --- /dev/null +++ b/tests/GamerGuardian.Tests/SmbiosTests.cs @@ -0,0 +1,256 @@ +using System; +using System.Linq; +using GamerGuardian.Native; +using GamerGuardian.Services; +using Xunit; + +namespace GamerGuardian.Tests; + +/// +/// Covers the SMBIOS memory parse behind the Status page's Memory card. The parser +/// is a pure function over a byte buffer, so it is driven here with synthetic tables +/// shaped exactly like the ones GetSystemFirmwareTable('RSMB') returns. +/// +public class SmbiosTests +{ + private const int FullLength = 0x22; // Type 17 formatted area, SMBIOS 2.7+ + private const byte Ddr5 = 0x22; + private const byte Ddr4 = 0x1A; + + /// Wraps structures in the RawSMBIOSData header the provider prepends. + private static byte[] Table(params byte[][] structures) + { + var body = structures.SelectMany(s => s).ToArray(); + var buffer = new byte[8 + body.Length]; + buffer[0] = 0; // Used20CallingMethod + buffer[1] = 3; // major + buffer[2] = 4; // minor + buffer[3] = 0; // DmiRevision + BitConverter.GetBytes((uint)body.Length).CopyTo(buffer, 4); + body.CopyTo(buffer, 8); + return buffer; + } + + private static byte[] MemoryDevice( + ushort rawSize, + byte memoryType = Ddr5, + ushort ratedSpeed = 5600, + ushort configuredSpeed = 6000, + uint extendedSizeMb = 0, + byte length = FullLength) + { + var s = new byte[length + 2]; // formatted area + the empty string set's double NUL + s[0] = 17; + s[1] = length; + BitConverter.GetBytes((ushort)0x1000).CopyTo(s, 2); + if (length >= 0x0E) BitConverter.GetBytes(rawSize).CopyTo(s, 0x0C); + if (length >= 0x13) s[0x12] = memoryType; + if (length >= 0x17) BitConverter.GetBytes(ratedSpeed).CopyTo(s, 0x15); + if (length >= 0x20) BitConverter.GetBytes(extendedSizeMb).CopyTo(s, 0x1C); + if (length >= 0x22) BitConverter.GetBytes(configuredSpeed).CopyTo(s, 0x20); + return s; + } + + private static byte[] EndOfTable() => new byte[] { 127, 4, 0x20, 0x00, 0, 0 }; + + /// A structure carrying a string set, to prove the walker skips it. + private static byte[] BiosInformation() + { + var formatted = new byte[] { 0, 0x12, 0x01, 0x00 }.Concat(new byte[0x12 - 4]).ToArray(); + var strings = new byte[] { (byte)'A', (byte)'C', (byte)'M', (byte)'E', 0, 0 }; + return formatted.Concat(strings).ToArray(); + } + + // ---- ParseMemoryDevices ------------------------------------------------ + + [Fact] + public void ParseMemoryDevices_NullOrTruncated_ReturnsEmpty() + { + Assert.Empty(Smbios.ParseMemoryDevices(null)); + Assert.Empty(Smbios.ParseMemoryDevices(Array.Empty())); + Assert.Empty(Smbios.ParseMemoryDevices(new byte[8])); + } + + [Fact] + public void ParseMemoryDevices_ReadsSizeTypeAndConfiguredSpeed() + { + // 16384 MB with bit 15 clear = 16 GB. + var raw = Table(MemoryDevice(16384), MemoryDevice(16384), EndOfTable()); + + var modules = Smbios.ParseMemoryDevices(raw); + + Assert.Equal(2, modules.Count); + Assert.All(modules, m => + { + Assert.Equal(16UL * 1024 * 1024 * 1024, m.SizeBytes); + Assert.Equal("DDR5", m.TypeName); + // Configured speed wins over the module's rated 5600. + Assert.Equal(6000, m.SpeedMts); + }); + } + + [Fact] + public void ParseMemoryDevices_FallsBackToRatedSpeedWhenConfiguredIsUnreported() + { + var raw = Table(MemoryDevice(8192, configuredSpeed: 0), EndOfTable()); + Assert.Equal(5600, Assert.Single(Smbios.ParseMemoryDevices(raw)).SpeedMts); + + // 0xFFFF is SMBIOS for "unknown" and must not be read as a speed. + raw = Table(MemoryDevice(8192, configuredSpeed: 0xFFFF, ratedSpeed: 0xFFFF), EndOfTable()); + Assert.Equal(0, Assert.Single(Smbios.ParseMemoryDevices(raw)).SpeedMts); + } + + [Fact] + public void ParseMemoryDevices_SkipsEmptyAndUnknownSlots() + { + // Size 0 = slot present but empty; 0xFFFF = size unknown. + var raw = Table( + MemoryDevice(0), + MemoryDevice(16384), + MemoryDevice(0xFFFF), + MemoryDevice(0), + EndOfTable()); + + Assert.Single(Smbios.ParseMemoryDevices(raw)); + } + + [Fact] + public void ParseMemoryDevices_HonoursTheKilobyteUnitBit() + { + // Bit 15 set means the value is in KB rather than MB. + var raw = Table(MemoryDevice(unchecked((ushort)(0x8000 | 2048))), EndOfTable()); + Assert.Equal(2048UL * 1024, Assert.Single(Smbios.ParseMemoryDevices(raw)).SizeBytes); + } + + [Fact] + public void ParseMemoryDevices_UsesExtendedSizeForLargeModules() + { + // 0x7FFF is the sentinel for "read Extended Size instead" — 65536 MB = 64 GB. + var raw = Table(MemoryDevice(0x7FFF, extendedSizeMb: 65536), EndOfTable()); + Assert.Equal(64UL * 1024 * 1024 * 1024, Assert.Single(Smbios.ParseMemoryDevices(raw)).SizeBytes); + } + + [Fact] + public void ParseMemoryDevices_WalksPastStructuresThatCarryStrings() + { + var raw = Table(BiosInformation(), MemoryDevice(16384), EndOfTable()); + Assert.Single(Smbios.ParseMemoryDevices(raw)); + } + + [Fact] + public void ParseMemoryDevices_StopsAtEndOfTable() + { + var raw = Table(MemoryDevice(16384), EndOfTable(), MemoryDevice(16384)); + Assert.Single(Smbios.ParseMemoryDevices(raw)); + } + + [Fact] + public void ParseMemoryDevices_ShortStructureIsIgnoredRatherThanRead() + { + // An SMBIOS 2.1 entry stops before the speed fields; the size still parses. + var raw = Table(MemoryDevice(16384, length: 0x15), EndOfTable()); + var module = Assert.Single(Smbios.ParseMemoryDevices(raw)); + Assert.Equal(16UL * 1024 * 1024 * 1024, module.SizeBytes); + Assert.Equal(0, module.SpeedMts); + } + + [Fact] + public void ParseMemoryDevices_GarbageNeverThrows() + { + var rng = new Random(1234); + for (int i = 0; i < 200; i++) + { + var buffer = new byte[rng.Next(0, 256)]; + rng.NextBytes(buffer); + var ex = Record.Exception(() => Smbios.ParseMemoryDevices(buffer)); + Assert.Null(ex); + } + } + + [Fact] + public void ParseMemoryDevices_DeclaredLengthLongerThanBufferDoesNotOverrun() + { + var raw = Table(MemoryDevice(16384), EndOfTable()); + BitConverter.GetBytes(uint.MaxValue).CopyTo(raw, 4); + var ex = Record.Exception(() => Smbios.ParseMemoryDevices(raw)); + Assert.Null(ex); + } + + // ---- Presentation ------------------------------------------------------ + + [Fact] + public void DescribeModules_UnknownWhenNothingWasReadable() + { + Assert.Equal("Unknown", SystemInfo.DescribeModules(Array.Empty())); + } + + [Fact] + public void DescribeModules_CollapsesMatchedModulesToACount() + { + var m = new[] + { + new MemoryModule(16UL * 1024 * 1024 * 1024, 6000, "DDR5"), + new MemoryModule(16UL * 1024 * 1024 * 1024, 6000, "DDR5"), + }; + Assert.Equal("2 × 16 GB @ 6000 MT/s", SystemInfo.DescribeModules(m)); + } + + [Fact] + public void DescribeModules_SingleModuleIsNotWrittenAsOneTimes() + { + var m = new[] { new MemoryModule(32UL * 1024 * 1024 * 1024, 5600, "DDR5") }; + Assert.Equal("32 GB @ 5600 MT/s", SystemInfo.DescribeModules(m)); + } + + [Fact] + public void DescribeModules_ListsMismatchedCapacitiesRatherThanHidingThem() + { + var m = new[] + { + new MemoryModule(16UL * 1024 * 1024 * 1024, 6000, "DDR5"), + new MemoryModule(8UL * 1024 * 1024 * 1024, 6000, "DDR5"), + }; + Assert.Equal("16 GB + 8 GB @ 6000 MT/s", SystemInfo.DescribeModules(m)); + } + + [Fact] + public void DescribeModules_ReportsTheSlowestSpeedBecauseThatIsWhatTheMachineRunsAt() + { + var m = new[] + { + new MemoryModule(16UL * 1024 * 1024 * 1024, 6000, "DDR5"), + new MemoryModule(16UL * 1024 * 1024 * 1024, 4800, "DDR5"), + }; + Assert.Equal("2 × 16 GB @ 4800 MT/s", SystemInfo.DescribeModules(m)); + } + + [Fact] + public void DescribeModules_OmitsSpeedWhenTheFirmwareReportsNone() + { + var m = new[] { new MemoryModule(16UL * 1024 * 1024 * 1024, 0, "DDR4") }; + Assert.Equal("16 GB", SystemInfo.DescribeModules(m)); + } + + [Fact] + public void SharedTypeName_OnlyWhenEveryModuleAgrees() + { + var ddr5 = new MemoryModule(1, 0, "DDR5"); + var ddr4 = new MemoryModule(1, 0, "DDR4"); + var untyped = new MemoryModule(1, 0, null); + + Assert.Equal("DDR5", SystemInfo.SharedTypeName(new[] { ddr5, ddr5 })); + Assert.Null(SystemInfo.SharedTypeName(new[] { ddr5, ddr4 })); + Assert.Null(SystemInfo.SharedTypeName(new[] { untyped })); + Assert.Null(SystemInfo.SharedTypeName(Array.Empty())); + } + + [Fact] + public void MemoryCard_AlwaysHasBothRows_EvenWithoutSmbios() + { + // The card must keep its shape on a machine whose firmware exposes nothing, + // otherwise the This PC grid goes ragged. + var card = SystemInfo.Memory(); + Assert.Equal(new[] { "Installed", "Modules" }, card.Rows.Select(r => r.Label).ToArray()); + Assert.All(card.Rows, r => Assert.False(string.IsNullOrWhiteSpace(r.Value))); + } +} From cd48a5e3641928dd0fbb1b966261bb46578208d8 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:23:38 -0700 Subject: [PATCH 17/19] feat(ui): recolour the app with GitHub's dark (Primer) palette The colour scheme is now GitHub dark rather than stock WinUI: canvas #0d1117, cards #151b23 on a #3d444d border, text #f0f6fc / #9198a1, and Primer's semantic green, amber and red for the status surfaces. Accent is Primer's #1f6feb, handed to WPF-UI's accent manager so the derived variants stay in the family. UI/Themes/GitHubDark.xaml holds the mapping. WPF-UI's theme dictionary is flat -- every key is a concrete brush, not a reference to a smaller set of primitives -- so recolouring means restating the keys the control templates actually read. The Primer tokens are declared once at the top and referenced by name so a palette tweak stays a one-line change. Two things had to be worked out by running the window rather than reading source: Merging the dictionary was not enough. WPF searches a ResourceDictionary's own entries before any of its merged ones, and WPF-UI writes part of the chrome into Application.Resources directly. A merged palette recoloured the content area and left the title bar, navigation pane and footer grey. ThemeService now copies the entries in at the same level and removes them again for the light theme, which restores whatever the theme dictionary underneath says. The window background is not usable either. FluentWindow's Background is overwritten at load so the backdrop can show through -- setting it to literal Red in XAML changed nothing on screen, which is how this was pinned down. The five windows now paint their root Grid instead, and drop Mica for WindowBackdropType None, which is also the setting the perf notes call for. Light theme is deliberately left stock: the request was for GitHub's dark scheme, and a half-translated light palette would look worse than what WPF-UI ships. Also switches the This PC cards to CardBackgroundFillColorDefaultBrush now that the palette makes it an opaque #151b23 with a real border, so they match every other card in the app. 721 stable / 703 beta tests pass. Co-Authored-By: Claude Opus 5 --- src/GamerGuardian/Services/ThemeService.cs | 63 ++++ src/GamerGuardian/UI/ApplyResultsWindow.xaml | 8 +- src/GamerGuardian/UI/NotificationWindow.xaml | 8 +- src/GamerGuardian/UI/RebootPendingWindow.xaml | 8 +- src/GamerGuardian/UI/SettingsWindow.xaml | 8 +- src/GamerGuardian/UI/Themes/GitHubDark.xaml | 356 ++++++++++++++++++ .../UI/UpdateAvailableWindow.xaml | 8 +- src/GamerGuardian/UI/Views/StatusView.xaml | 2 +- 8 files changed, 450 insertions(+), 11 deletions(-) create mode 100644 src/GamerGuardian/UI/Themes/GitHubDark.xaml diff --git a/src/GamerGuardian/Services/ThemeService.cs b/src/GamerGuardian/Services/ThemeService.cs index 45c9d94..60e1d6d 100644 --- a/src/GamerGuardian/Services/ThemeService.cs +++ b/src/GamerGuardian/Services/ThemeService.cs @@ -1,3 +1,7 @@ +using System.Windows; +// WinForms is referenced for the tray icon, so these bare names are ambiguous. +using Color = System.Windows.Media.Color; +using Application = System.Windows.Application; using GamerGuardian.Models; using Microsoft.Win32; using Wpf.Ui.Appearance; @@ -6,6 +10,17 @@ namespace GamerGuardian.Services; public static class ThemeService { + /// + /// GitHub Primer's accent blue (accent.emphasis). Handed to WPF-UI's + /// accent manager, which derives the light/dark variants the control templates + /// read, so accented surfaces match the palette dictionary rather than fighting + /// it. + /// + private static readonly Color GitHubAccent = Color.FromRgb(0x1F, 0x6F, 0xEB); + + internal static readonly Uri GitHubDarkPaletteUri = + new("pack://application:,,,/UI/Themes/GitHubDark.xaml", UriKind.Absolute); + public static void Apply(AppThemeChoice choice) { var theme = choice switch @@ -15,6 +30,54 @@ public static void Apply(AppThemeChoice choice) _ => GetSystemTheme(), }; ApplicationThemeManager.Apply(theme); + ApplyPalette(theme); + } + + /// Keys this service has written at the application level, so the light + /// theme can put them back exactly as they were. + private static readonly List AppliedKeys = new(); + + /// + /// Layers the GitHub dark palette over WPF-UI's dark theme, or strips it for + /// light. + /// + /// The entries are copied into Application.Resources itself rather + /// than merged as a dictionary. Merging is not enough: WPF looks in a + /// dictionary's own entries before any of its merged ones, and WPF-UI writes + /// part of the window chrome — the title bar, the navigation pane and the footer + /// — straight into the application resources. A merged palette recoloured the + /// content area and left those three grey. Writing at the same level wins, and + /// removing the keys again restores whatever the theme dictionary underneath + /// says, which is what the light theme needs. + /// + /// This has to run after every , + /// not once at startup, because Apply swaps the theme dictionary on each change. + /// + /// Light is left stock on purpose: the request was for GitHub's dark + /// scheme, and a half-translated light palette would look worse than the theme + /// WPF-UI already ships. + /// + private static void ApplyPalette(ApplicationTheme theme) + { + var app = Application.Current; + if (app is null) return; // unit tests and design time have no Application + + foreach (var key in AppliedKeys) app.Resources.Remove(key); + AppliedKeys.Clear(); + + if (theme != ApplicationTheme.Dark) return; + + // Accent first: this one legitimately writes at the application level, and + // the palette below deliberately overrides some of what it derives (WPF-UI + // puts black text on accent, which is unreadable on Primer's darker blue). + ApplicationAccentColorManager.Apply(GitHubAccent, theme); + + var palette = new ResourceDictionary { Source = GitHubDarkPaletteUri }; + foreach (System.Collections.DictionaryEntry entry in palette) + { + app.Resources[entry.Key] = entry.Value; + AppliedKeys.Add(entry.Key); + } } public static ApplicationTheme GetSystemTheme() diff --git a/src/GamerGuardian/UI/ApplyResultsWindow.xaml b/src/GamerGuardian/UI/ApplyResultsWindow.xaml index 7ef9b8a..64fb6e7 100644 --- a/src/GamerGuardian/UI/ApplyResultsWindow.xaml +++ b/src/GamerGuardian/UI/ApplyResultsWindow.xaml @@ -8,10 +8,14 @@ WindowStartupLocation="CenterOwner" ResizeMode="CanResize" ShowInTaskbar="True" - WindowBackdropType="Mica" + WindowBackdropType="None" ExtendsContentIntoTitleBar="True" WindowCornerPreference="Round"> - + + diff --git a/src/GamerGuardian/UI/NotificationWindow.xaml b/src/GamerGuardian/UI/NotificationWindow.xaml index 3941227..bb8009b 100644 --- a/src/GamerGuardian/UI/NotificationWindow.xaml +++ b/src/GamerGuardian/UI/NotificationWindow.xaml @@ -9,10 +9,14 @@ ResizeMode="NoResize" ShowInTaskbar="False" Topmost="True" - WindowBackdropType="Mica" + WindowBackdropType="None" ExtendsContentIntoTitleBar="True" WindowCornerPreference="Round"> - + + diff --git a/src/GamerGuardian/UI/RebootPendingWindow.xaml b/src/GamerGuardian/UI/RebootPendingWindow.xaml index 159e613..f8c4c60 100644 --- a/src/GamerGuardian/UI/RebootPendingWindow.xaml +++ b/src/GamerGuardian/UI/RebootPendingWindow.xaml @@ -9,10 +9,14 @@ ResizeMode="NoResize" ShowInTaskbar="False" Topmost="True" - WindowBackdropType="Mica" + WindowBackdropType="None" ExtendsContentIntoTitleBar="True" WindowCornerPreference="Round"> - + + diff --git a/src/GamerGuardian/UI/SettingsWindow.xaml b/src/GamerGuardian/UI/SettingsWindow.xaml index 4b515ec..71140eb 100644 --- a/src/GamerGuardian/UI/SettingsWindow.xaml +++ b/src/GamerGuardian/UI/SettingsWindow.xaml @@ -7,10 +7,14 @@ Width="1150" Height="760" MinWidth="920" MinHeight="560" WindowStartupLocation="CenterScreen" - WindowBackdropType="Mica" + WindowBackdropType="None" ExtendsContentIntoTitleBar="True" WindowCornerPreference="Round"> - + + diff --git a/src/GamerGuardian/UI/Themes/GitHubDark.xaml b/src/GamerGuardian/UI/Themes/GitHubDark.xaml new file mode 100644 index 0000000..ae1e9c7 --- /dev/null +++ b/src/GamerGuardian/UI/Themes/GitHubDark.xaml @@ -0,0 +1,356 @@ + + + + + + + #FF010409 + #FF0D1117 + #FF151B23 + #FF1C2129 + #FF12171F + + + #FF212830 + #FF262C36 + #FF2A313C + + #FF3D444D + #FF2A3038 + + #FFF0F6FC + #FF9198A1 + #FF656C76 + #FF484F58 + + #FF4493F8 + #FF1F6FEB + #FF3FB950 + #FFD29922 + #FFF85149 + + + #261F6FEB + #262EA043 + #26BB8009 + #26F85149 + #26656C76 + + #FFFFFFFF + #C5FFFFFF + #99010409 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/UpdateAvailableWindow.xaml b/src/GamerGuardian/UI/UpdateAvailableWindow.xaml index a5fc0d7..8fd9011 100644 --- a/src/GamerGuardian/UI/UpdateAvailableWindow.xaml +++ b/src/GamerGuardian/UI/UpdateAvailableWindow.xaml @@ -9,10 +9,14 @@ ResizeMode="NoResize" ShowInTaskbar="False" Topmost="True" - WindowBackdropType="Mica" + WindowBackdropType="None" ExtendsContentIntoTitleBar="True" WindowCornerPreference="Round"> - + + diff --git a/src/GamerGuardian/UI/Views/StatusView.xaml b/src/GamerGuardian/UI/Views/StatusView.xaml index 036d468..e5bd645 100644 --- a/src/GamerGuardian/UI/Views/StatusView.xaml +++ b/src/GamerGuardian/UI/Views/StatusView.xaml @@ -56,7 +56,7 @@ TextBlock's own default is Black — which rendered every value in here black-on-dark. Descendants inherit this. --> From dcc99694e3365cdd3d44a47ceddaae0ab30e0b39 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:29:27 -0700 Subject: [PATCH 18/19] fix(ui): the view headings rendered black on the dark canvas "This PC" on the Status page was pure black -- glyphs measured 0-13 brightness against a background of 13. Same defect as the card values fixed in 28b6f27, one level up: a bare TextBlock has no Foreground of its own and WPF's default is Black, so any text that is not inside a control which sets one (ui:Card does, a Border does not) renders black. The earlier fix only covered the card contents, not the heading sitting directly in the view. Fixed at the root of all eleven views rather than per-element, so the whole class is closed instead of one instance: descendants inherit it, and anything with its own Foreground still wins. The five windows get the same treatment on their root grid -- ApplyResults, Notification and RebootPending appear at moments where unreadable text would be worst, and they had never been looked at under the new palette. Drops the now-redundant TextElement.Foreground from the This PC card border so there is one mechanism rather than two. 721 stable / 703 beta tests pass. Co-Authored-By: Claude Opus 5 --- src/GamerGuardian/UI/ApplyResultsWindow.xaml | 3 ++- src/GamerGuardian/UI/NotificationWindow.xaml | 3 ++- src/GamerGuardian/UI/RebootPendingWindow.xaml | 3 ++- src/GamerGuardian/UI/SettingsWindow.xaml | 3 ++- src/GamerGuardian/UI/UpdateAvailableWindow.xaml | 3 ++- src/GamerGuardian/UI/Views/BiosView.xaml | 5 ++++- src/GamerGuardian/UI/Views/CpuPowerView.xaml | 5 ++++- src/GamerGuardian/UI/Views/DebloatView.xaml | 5 ++++- src/GamerGuardian/UI/Views/DisplayView.xaml | 5 ++++- src/GamerGuardian/UI/Views/GamingView.xaml | 5 ++++- src/GamerGuardian/UI/Views/GeneralView.xaml | 5 ++++- src/GamerGuardian/UI/Views/NetworkView.xaml | 5 ++++- src/GamerGuardian/UI/Views/ServicesView.xaml | 5 ++++- src/GamerGuardian/UI/Views/StatusView.xaml | 15 ++++++++------- src/GamerGuardian/UI/Views/TelemetryView.xaml | 5 ++++- src/GamerGuardian/UI/Views/WindowsAiView.xaml | 5 ++++- 16 files changed, 58 insertions(+), 22 deletions(-) diff --git a/src/GamerGuardian/UI/ApplyResultsWindow.xaml b/src/GamerGuardian/UI/ApplyResultsWindow.xaml index 64fb6e7..4c03ded 100644 --- a/src/GamerGuardian/UI/ApplyResultsWindow.xaml +++ b/src/GamerGuardian/UI/ApplyResultsWindow.xaml @@ -15,7 +15,8 @@ FluentWindow.Background at load to let the backdrop show through, so a value set in XAML is discarded. Painting the root panel is what actually puts the palette behind the title bar, the navigation pane and the footer. --> - + diff --git a/src/GamerGuardian/UI/NotificationWindow.xaml b/src/GamerGuardian/UI/NotificationWindow.xaml index bb8009b..c69b1af 100644 --- a/src/GamerGuardian/UI/NotificationWindow.xaml +++ b/src/GamerGuardian/UI/NotificationWindow.xaml @@ -16,7 +16,8 @@ FluentWindow.Background at load to let the backdrop show through, so a value set in XAML is discarded. Painting the root panel is what actually puts the palette behind the title bar, the navigation pane and the footer. --> - + diff --git a/src/GamerGuardian/UI/RebootPendingWindow.xaml b/src/GamerGuardian/UI/RebootPendingWindow.xaml index f8c4c60..f67c8a6 100644 --- a/src/GamerGuardian/UI/RebootPendingWindow.xaml +++ b/src/GamerGuardian/UI/RebootPendingWindow.xaml @@ -16,7 +16,8 @@ FluentWindow.Background at load to let the backdrop show through, so a value set in XAML is discarded. Painting the root panel is what actually puts the palette behind the title bar, the navigation pane and the footer. --> - + diff --git a/src/GamerGuardian/UI/SettingsWindow.xaml b/src/GamerGuardian/UI/SettingsWindow.xaml index 71140eb..40ccee1 100644 --- a/src/GamerGuardian/UI/SettingsWindow.xaml +++ b/src/GamerGuardian/UI/SettingsWindow.xaml @@ -14,7 +14,8 @@ FluentWindow.Background at load to let the backdrop show through, so a value set in XAML is discarded. Painting the root panel is what actually puts the palette behind the title bar, the navigation pane and the footer. --> - + diff --git a/src/GamerGuardian/UI/UpdateAvailableWindow.xaml b/src/GamerGuardian/UI/UpdateAvailableWindow.xaml index 8fd9011..1408e94 100644 --- a/src/GamerGuardian/UI/UpdateAvailableWindow.xaml +++ b/src/GamerGuardian/UI/UpdateAvailableWindow.xaml @@ -16,7 +16,8 @@ FluentWindow.Background at load to let the backdrop show through, so a value set in XAML is discarded. Painting the root panel is what actually puts the palette behind the title bar, the navigation pane and the footer. --> - + diff --git a/src/GamerGuardian/UI/Views/BiosView.xaml b/src/GamerGuardian/UI/Views/BiosView.xaml index f8de4b8..f9d1d35 100644 --- a/src/GamerGuardian/UI/Views/BiosView.xaml +++ b/src/GamerGuardian/UI/Views/BiosView.xaml @@ -1,4 +1,7 @@ - + + diff --git a/src/GamerGuardian/UI/Views/CpuPowerView.xaml b/src/GamerGuardian/UI/Views/CpuPowerView.xaml index 5868dd3..761f70f 100644 --- a/src/GamerGuardian/UI/Views/CpuPowerView.xaml +++ b/src/GamerGuardian/UI/Views/CpuPowerView.xaml @@ -1,4 +1,7 @@ - + + diff --git a/src/GamerGuardian/UI/Views/DebloatView.xaml b/src/GamerGuardian/UI/Views/DebloatView.xaml index 45f0bf7..20711c1 100644 --- a/src/GamerGuardian/UI/Views/DebloatView.xaml +++ b/src/GamerGuardian/UI/Views/DebloatView.xaml @@ -1,4 +1,7 @@ - + + diff --git a/src/GamerGuardian/UI/Views/DisplayView.xaml b/src/GamerGuardian/UI/Views/DisplayView.xaml index c2f3d73..9659df9 100644 --- a/src/GamerGuardian/UI/Views/DisplayView.xaml +++ b/src/GamerGuardian/UI/Views/DisplayView.xaml @@ -1,4 +1,7 @@ - + + diff --git a/src/GamerGuardian/UI/Views/GamingView.xaml b/src/GamerGuardian/UI/Views/GamingView.xaml index fdde2bf..039417f 100644 --- a/src/GamerGuardian/UI/Views/GamingView.xaml +++ b/src/GamerGuardian/UI/Views/GamingView.xaml @@ -1,4 +1,7 @@ - + + diff --git a/src/GamerGuardian/UI/Views/GeneralView.xaml b/src/GamerGuardian/UI/Views/GeneralView.xaml index 30737ac..774e259 100644 --- a/src/GamerGuardian/UI/Views/GeneralView.xaml +++ b/src/GamerGuardian/UI/Views/GeneralView.xaml @@ -1,4 +1,7 @@ - + + diff --git a/src/GamerGuardian/UI/Views/NetworkView.xaml b/src/GamerGuardian/UI/Views/NetworkView.xaml index 8c0ecc8..6ddd2a4 100644 --- a/src/GamerGuardian/UI/Views/NetworkView.xaml +++ b/src/GamerGuardian/UI/Views/NetworkView.xaml @@ -1,4 +1,7 @@ - + + diff --git a/src/GamerGuardian/UI/Views/ServicesView.xaml b/src/GamerGuardian/UI/Views/ServicesView.xaml index 31dcfba..48afe57 100644 --- a/src/GamerGuardian/UI/Views/ServicesView.xaml +++ b/src/GamerGuardian/UI/Views/ServicesView.xaml @@ -1,4 +1,7 @@ - + + diff --git a/src/GamerGuardian/UI/Views/StatusView.xaml b/src/GamerGuardian/UI/Views/StatusView.xaml index e5bd645..d3e28e6 100644 --- a/src/GamerGuardian/UI/Views/StatusView.xaml +++ b/src/GamerGuardian/UI/Views/StatusView.xaml @@ -1,10 +1,13 @@ + @@ -51,15 +54,13 @@ fill is also a 5% white wash that reads as black against this window. A Border stretches to fill the UniformGrid cell, top-aligns its child, and takes an opaque fill. --> - + + BorderThickness="1"> + diff --git a/src/GamerGuardian/UI/Views/WindowsAiView.xaml b/src/GamerGuardian/UI/Views/WindowsAiView.xaml index de95cec..dd9c505 100644 --- a/src/GamerGuardian/UI/Views/WindowsAiView.xaml +++ b/src/GamerGuardian/UI/Views/WindowsAiView.xaml @@ -1,4 +1,7 @@ - + + From d06b67eca2a045480c01b8d6646ab36d3c68e232 Mon Sep 17 00:00:00 2001 From: Carter <4911475+carterscode@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:30:26 -0700 Subject: [PATCH 19/19] docs: release notes for 0.1.65 Renames the Unreleased heading per the process documented at the top of the file, and describes the overhaul in the terms a user experiences it: a home screen, a grouped sidebar instead of tabs, the This PC summary, and the GitHub-dark recolour. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c7e16..7a86d3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,36 @@ Versions before 1.0.0 are pre-release: features and defaults may still change. ## [Unreleased] +## [0.1.65] - 2026-08-07 + +The biggest visual change since the app was first released: the window has been +rebuilt around a grouped sidebar, and it now has a home screen. + +### Added +- **A Status home screen.** Opening GamerGuardian now lands you on Status + instead of the first settings tab. It leads with a single number — how many of + the settings you monitor have drifted from what you asked for — and breaks + that down by section, so you can see at a glance whether anything needs you. +- **A "This PC" summary.** Status shows six cards describing the machine: + processor, graphics, memory, Windows version, displays, and the active power + plan. Each one is context for settings GamerGuardian actually manages — the + CPU behind the power-plan recommendation, the GPU behind hardware-accelerated + scheduling, the Windows build that decides which policies apply. +- **Real memory detail.** The memory card reads your firmware directly and + reports the installed total with its type, plus the module layout and speed — + for example "31.2 GB DDR5" and "2 × 16 GB @ 5600 MT/s". Mismatched sticks are + listed rather than averaged away, because a mismatched pair is worth noticing. +- **Pause monitoring from Status.** Stopping and resuming background checks no + longer means hunting through the settings. + ### Changed +- **New navigation.** The row of tabs is gone. Settings are now grouped in a + sidebar under Performance, Privacy, Cleanup and Reference, so related settings + sit together and the window no longer runs out of horizontal room. Everything + that was in the old tabs is still there — nothing was dropped in the move. +- **A GitHub-dark colour scheme.** The app is recoloured to match GitHub's dark + theme: a deep navy-black canvas, cards that actually read as cards, and + GitHub's green, amber and red for status. Light theme is unchanged. - **Clearer upgrade notes.** The "what's new" text shown when a new version is offered is now plain-English highlights only — no more raw list of internal pull-request titles — and Markdown formatting is cleaned up so it reads