diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml new file mode 100644 index 0000000..3374abe --- /dev/null +++ b/.github/workflows/beta.yml @@ -0,0 +1,124 @@ +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 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: | + $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, 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)" + + - 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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ea750b6..d189f08 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,3 +26,60 @@ 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, 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" + 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/.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/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 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. 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. 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/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/App.xaml.cs b/src/GamerGuardian/App.xaml.cs index 501c0fa..322514a 100644 --- a/src/GamerGuardian/App.xaml.cs +++ b/src/GamerGuardian/App.xaml.cs @@ -64,13 +64,20 @@ 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(); 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(); @@ -189,8 +196,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 +210,7 @@ protected override void OnStartup(StartupEventArgs e) }, System.Windows.Threading.DispatcherPriority.ApplicationIdle); } +#if !BETA private async Task CheckForUpdatesAsync() { try @@ -230,6 +242,7 @@ await Dispatcher.InvokeAsync(() => } catch (Exception ex) { LogException("UpdateCheck", ex); } } +#endif private void ShowSettings() { @@ -309,7 +322,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 +414,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..c722405 100644 --- a/src/GamerGuardian/GamerGuardian.csproj +++ b/src/GamerGuardian/GamerGuardian.csproj @@ -32,6 +32,38 @@ true + + + false + + + $(DefineConstants);BETA + + + + + + + + + true 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/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 new file mode 100644 index 0000000..e26a4f4 --- /dev/null +++ b/src/GamerGuardian/Native/SystemMetrics.cs @@ -0,0 +1,78 @@ +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; + } + } + + // '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/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/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..6ac34db 100644 --- a/src/GamerGuardian/Services/ConfigStore.cs +++ b/src/GamerGuardian/Services/ConfigStore.cs @@ -17,10 +17,60 @@ 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; + } + + /// 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() 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/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/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/SystemInfo.cs b/src/GamerGuardian/Services/SystemInfo.cs new file mode 100644 index 0000000..ff9ccee --- /dev/null +++ b/src/GamerGuardian/Services/SystemInfo.cs @@ -0,0 +1,278 @@ +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() + { + 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[] + { + ("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 + { + 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/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/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/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/ApplyResultsWindow.xaml b/src/GamerGuardian/UI/ApplyResultsWindow.xaml index 7ef9b8a..4c03ded 100644 --- a/src/GamerGuardian/UI/ApplyResultsWindow.xaml +++ b/src/GamerGuardian/UI/ApplyResultsWindow.xaml @@ -8,10 +8,15 @@ 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..c69b1af 100644 --- a/src/GamerGuardian/UI/NotificationWindow.xaml +++ b/src/GamerGuardian/UI/NotificationWindow.xaml @@ -9,10 +9,15 @@ 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..f67c8a6 100644 --- a/src/GamerGuardian/UI/RebootPendingWindow.xaml +++ b/src/GamerGuardian/UI/RebootPendingWindow.xaml @@ -9,10 +9,15 @@ 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 21d41e8..40ccee1 100644 --- a/src/GamerGuardian/UI/SettingsWindow.xaml +++ b/src/GamerGuardian/UI/SettingsWindow.xaml @@ -1,108 +1,27 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + @@ -141,913 +60,66 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/SettingsWindow.xaml.cs b/src/GamerGuardian/UI/SettingsWindow.xaml.cs index e534034..c3096e8 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,28 +87,34 @@ public SettingsWindow( _config = store.Load(); _draft = AppConfigCloner.Clone(_config); - LaunchAtStartupCheck.IsChecked = _draft.LaunchAtStartup; - ConsolidateCheck.IsChecked = _draft.ConsolidateNotifications; - 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(); @@ -104,6 +126,92 @@ 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; + + // 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"); + }; + } + + /// + /// 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); + } + + /// + /// 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 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); + } + } } /// @@ -399,8 +507,8 @@ private void UpdatePresetRadio() _suppressPresetEvents = true; try { - ServicesPresetGaming.IsChecked = matchesGaming; - ServicesPresetDefault.IsChecked = matchesDefault; + _services.ServicesPresetGaming.IsChecked = matchesGaming; + _services.ServicesPresetDefault.IsChecked = matchesDefault; } finally { _suppressPresetEvents = false; } } @@ -410,13 +518,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); @@ -832,7 +940,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 @@ -842,16 +950,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 @@ -867,7 +975,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; } @@ -960,18 +1068,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; @@ -983,9 +1091,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; @@ -994,9 +1102,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; @@ -1005,7 +1113,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 { @@ -1025,12 +1133,29 @@ 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 + // 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( @@ -1043,7 +1168,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…"; @@ -1077,6 +1202,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. @@ -1226,17 +1352,16 @@ 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.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; @@ -1287,15 +1412,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(); @@ -1333,10 +1473,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 -- " @@ -1353,7 +1493,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 " @@ -1401,9 +1541,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."; } @@ -1444,9 +1584,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"; @@ -1462,21 +1602,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); @@ -1491,15 +1631,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, @@ -1507,7 +1647,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, @@ -1524,9 +1664,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, @@ -1534,7 +1674,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, @@ -1610,7 +1750,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(); @@ -1655,12 +1795,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, @@ -1670,12 +1810,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, @@ -1701,7 +1841,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; } } @@ -1713,10 +1853,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..e16b377 --- /dev/null +++ b/src/GamerGuardian/UI/SharedResources.xaml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..1408e94 100644 --- a/src/GamerGuardian/UI/UpdateAvailableWindow.xaml +++ b/src/GamerGuardian/UI/UpdateAvailableWindow.xaml @@ -9,10 +9,15 @@ ResizeMode="NoResize" ShowInTaskbar="False" Topmost="True" - WindowBackdropType="Mica" + WindowBackdropType="None" ExtendsContentIntoTitleBar="True" WindowCornerPreference="Round"> - + + diff --git a/src/GamerGuardian/UI/Views/BiosView.xaml b/src/GamerGuardian/UI/Views/BiosView.xaml new file mode 100644 index 0000000..f9d1d35 --- /dev/null +++ b/src/GamerGuardian/UI/Views/BiosView.xaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + 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..761f70f --- /dev/null +++ b/src/GamerGuardian/UI/Views/CpuPowerView.xaml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..20711c1 --- /dev/null +++ b/src/GamerGuardian/UI/Views/DebloatView.xaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + 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..9659df9 --- /dev/null +++ b/src/GamerGuardian/UI/Views/DisplayView.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..039417f --- /dev/null +++ b/src/GamerGuardian/UI/Views/GamingView.xaml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..774e259 --- /dev/null +++ b/src/GamerGuardian/UI/Views/GeneralView.xaml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..6ddd2a4 --- /dev/null +++ b/src/GamerGuardian/UI/Views/NetworkView.xaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..48afe57 --- /dev/null +++ b/src/GamerGuardian/UI/Views/ServicesView.xaml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..d3e28e6 --- /dev/null +++ b/src/GamerGuardian/UI/Views/StatusView.xaml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GamerGuardian/UI/Views/StatusView.xaml.cs b/src/GamerGuardian/UI/Views/StatusView.xaml.cs new file mode 100644 index 0000000..a5c2813 --- /dev/null +++ b/src/GamerGuardian/UI/Views/StatusView.xaml.cs @@ -0,0 +1,245 @@ +using System.Linq; +using Brush = System.Windows.Media.Brush; +using SymbolRegular = Wpf.Ui.Controls.SymbolRegular; +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(); + 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). + 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(); + } +} + +/// 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/src/GamerGuardian/UI/Views/TelemetryView.xaml b/src/GamerGuardian/UI/Views/TelemetryView.xaml new file mode 100644 index 0000000..58d5943 --- /dev/null +++ b/src/GamerGuardian/UI/Views/TelemetryView.xaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..dd9c505 --- /dev/null +++ b/src/GamerGuardian/UI/Views/WindowsAiView.xaml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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(); +} diff --git a/tests/GamerGuardian.Tests/AppIdentityTests.cs b/tests/GamerGuardian.Tests/AppIdentityTests.cs new file mode 100644 index 0000000..ff9bbc5 --- /dev/null +++ b/tests/GamerGuardian.Tests/AppIdentityTests.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using GamerGuardian.Services; +using Xunit; + +namespace GamerGuardian.Tests; + +/// +/// 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 ProductFolder_MatchesTheFlavor() + { + Assert.Equal(Folder, AppIdentity.ProductFolderName); + Assert.Equal(Path.Combine(AppData, Folder), AppIdentity.ConfigDirectory); + } + + [Fact] + public void ConfigAndChangeLog_ShareTheFlavorRoot() + { + Assert.Equal(Path.Combine(AppData, Folder, "config.json"), AppIdentity.ConfigFile); + Assert.Equal(Path.Combine(AppData, Folder, "changes.log"), AppIdentity.ChangeLogFile); + } + + [Fact] + public void DiagnosticPaths_MatchTheFlavor() + { + 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 MutexAndStartupNames_MatchTheFlavor() + { + Assert.Equal(Mutex, AppIdentity.MutexName); + Assert.Equal(RunValue, AppIdentity.StartupRegistryValueName); + } + + [Fact] + public void StableConfigDirectory_AlwaysPointsAtTheStableRoot() + { + // 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] + 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)); + } + +#if BETA + [Fact] + 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() + { + 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 new file mode 100644 index 0000000..d0396fc --- /dev/null +++ b/tests/GamerGuardian.Tests/ConfigStoreTests.cs @@ -0,0 +1,250 @@ +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_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() + { + 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); + } + +#if !BETA + [Fact] + public void SeedConfigFrom_InStableBuild_IsAlwaysANoOp() + { + // 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() + { + Assert.False(ConfigStore.SeedConfigFrom("", Dir("x"))); + Assert.False(ConfigStore.SeedConfigFrom(Dir("y"), "")); + Assert.False(ConfigStore.SeedConfigFrom(null!, null!)); + } +} 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 + 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); + } +} 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()); + } +} 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))); + } +} 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)); + } +}