From 0e724d3f72560605cd11707f2a978e2de348a7fb Mon Sep 17 00:00:00 2001 From: LAYE77IE Date: Sun, 28 Jun 2026 12:37:36 +0300 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20v2.1.0=20=E2=80=94=20single-insta?= =?UTF-8?q?nce=20signal=20+=20Memory=20card=20layout=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled minor release instead of two separate patches, per the new "don't ship a release for every fix" cadence. - Single-instance launcher: re-launching while the app is in the tray no longer pops an "already running" dialog. The secondary process signals a named EventWaitHandle, grants ASFW_ANY foreground rights, and exits silently; the primary's listener thread dispatches to the UI and surfaces the window from offscreen via SetForegroundWindow. - Memory Stats card: switched the inner layout from a top-anchored StackPanel to a 3-row Grid (Auto / * / Auto) so the KPI block sits vertically centered in the cell and the Total / Free strip pins to the bottom — eliminates the ~40 % dead band that appeared when the sibling Standby Purge card stretched the row taller. - Clearable subtitle FontSize bumped 10 → 12 for legibility next to the 28-pt KPI. Version metadata bumped in csproj + Setup.iss + winget manifests (InstallerSha256 reset to a placeholder; the release pipeline fills it after build-installer.ps1 hashes the produced .exe). Co-Authored-By: Claude Opus 4.7 --- StandbyAndTimer/App.xaml.cs | 92 ++++++++++++++++--- .../Infrastructure/AppConstants.cs | 7 ++ .../Services/Native/NativeMethods.cs | 23 +++++ StandbyAndTimer/StandbyAndTimer.csproj | 2 +- .../Views/Cards/MemoryStatsCard.xaml | 72 +++++++++------ installer/Setup.iss | 2 +- .../Layellie.StandbyAndTimer.installer.yaml | 6 +- ...Layellie.StandbyAndTimer.locale.en-US.yaml | 4 +- .../Layellie.StandbyAndTimer.yaml | 2 +- 9 files changed, 162 insertions(+), 48 deletions(-) diff --git a/StandbyAndTimer/App.xaml.cs b/StandbyAndTimer/App.xaml.cs index f986395..30ae8b8 100644 --- a/StandbyAndTimer/App.xaml.cs +++ b/StandbyAndTimer/App.xaml.cs @@ -28,13 +28,15 @@ public partial class App : Application private readonly CrashHandler _crashHandler = new(); - private IServiceProvider? _services; - private ILocalizationService? _localization; - private IThemeService? _themeService; - private ITrayIconService? _tray; - private MainViewModel? _viewModel; - private MainWindow? _mainWindow; - private DispatcherTimer? _tooltipTicker; + private IServiceProvider? _services; + private ILocalizationService? _localization; + private IThemeService? _themeService; + private ITrayIconService? _tray; + private MainViewModel? _viewModel; + private MainWindow? _mainWindow; + private DispatcherTimer? _tooltipTicker; + private EventWaitHandle? _showWindowSignal; + private CancellationTokenSource? _showWindowSignalCts; private System.Drawing.Icon? _iconBase; private System.Drawing.Icon? _iconActive; @@ -61,18 +63,37 @@ void Mark(string step) Mark("crash handlers + hardening"); // ── 1. Single-instance guard ────────────────────────────────────────── + // Primary acquires the mutex AND owns the named EventWaitHandle the + // listener thread blocks on. A secondary launch opens that handle by + // name, grants foreground rights (ASFW_ANY) so the primary's + // SetForegroundWindow call isn't muted by the foreground-lock + // timeout, signals it, then exits silently — no "already running" + // dialog. The primary listener dispatches to the UI thread and + // surfaces the window from offscreen / tray. _singleInstanceMutex = new Mutex(true, AppConstants.SingleInstanceMutexName, out bool isNewInstance); if (!isNewInstance) { - MessageBox.Show( - "Application is already running in the background.\nLook for the icon in the system tray.", - "StandbyAndTimer", - MessageBoxButton.OK, - MessageBoxImage.Information); + try + { + using var existing = EventWaitHandle.OpenExisting(AppConstants.ShowWindowSignalName); + NativeMethods.AllowSetForegroundWindow(NativeMethods.ASFW_ANY); + existing.Set(); + } + catch (WaitHandleCannotBeOpenedException) + { + // Primary hasn't published the handle yet (startup race) — nothing we can do but bail. + } + catch (UnauthorizedAccessException ex) + { + Logger.Warn($"Single-instance signal: {ex.Message}"); + } Current.Shutdown(); return; } + _showWindowSignal = new EventWaitHandle(false, EventResetMode.AutoReset, AppConstants.ShowWindowSignalName); + _showWindowSignalCts = new CancellationTokenSource(); + base.OnStartup(e); Mark("single-instance + base.OnStartup"); @@ -127,6 +148,11 @@ void Mark(string step) _mainWindow.Show(); Mark("window shown"); + // Background listener for second-launch "show me" pulses. Started + // only after MainWindow exists so the dispatched ShowMainWindow() + // call actually has a window to surface. + StartShowWindowListener(_showWindowSignal!, _showWindowSignalCts!.Token); + // Live tooltip updater — every 3 s, refresh icon and text. Cheap. _tooltipTicker = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) }; _tooltipTicker.Tick += (_, _) => RefreshTray(); @@ -185,12 +211,54 @@ protected override void OnExit(ExitEventArgs e) Task.Run(_viewModel.ShutdownAsync).GetAwaiter().GetResult(); (_services as IDisposable)?.Dispose(); + // Shut down the second-launch listener: cancel first (wakes WaitAny + // via the cancellation handle), then dispose. The handle itself is + // disposed last so the listener never observes a disposed handle. + _showWindowSignalCts?.Cancel(); + _showWindowSignal?.Dispose(); + _showWindowSignalCts?.Dispose(); + _singleInstanceMutex?.ReleaseMutex(); _singleInstanceMutex?.Dispose(); base.OnExit(e); } + // ── Single-instance "show me" listener ─────────────────────────────────── + + private void StartShowWindowListener(EventWaitHandle signal, CancellationToken ct) + { + var thread = new Thread(() => + { + var handles = new WaitHandle[] { signal, ct.WaitHandle }; + try + { + while (true) + { + int idx = WaitHandle.WaitAny(handles); + if (idx == 1) return; // cancelled — shutting down + Dispatcher.BeginInvoke(new Action(SurfaceMainWindow)); + } + } + catch (ObjectDisposedException) { /* handles disposed during shutdown */ } + catch (Exception ex) { Logger.Warn($"ShowWindowListener: {ex.Message}"); } + }) + { + IsBackground = true, + Name = "ShowWindowListener" + }; + thread.Start(); + } + + private void SurfaceMainWindow() + { + if (_mainWindow is null) return; + _mainWindow.ShowFromOffscreen(); + var hwnd = new System.Windows.Interop.WindowInteropHelper(_mainWindow).Handle; + if (hwnd != IntPtr.Zero) + NativeMethods.SetForegroundWindow(hwnd); + } + // ── DLL search-path hardening ───────────────────────────────────────────── private static void HardenDllSearchPath() diff --git a/StandbyAndTimer/Infrastructure/AppConstants.cs b/StandbyAndTimer/Infrastructure/AppConstants.cs index 7bc7563..f0b8a18 100644 --- a/StandbyAndTimer/Infrastructure/AppConstants.cs +++ b/StandbyAndTimer/Infrastructure/AppConstants.cs @@ -14,6 +14,13 @@ internal static class AppConstants // the name if we ever ship a breaking IPC change. internal const string SingleInstanceMutexName = @"Global\StandbyAndTimer_v1"; + // Named auto-reset EventWaitHandle the primary instance listens on. A + // second launch opens it by name and pulses Set() to ask the primary + // instance to surface its main window, instead of showing a "already + // running" dialog. Same Global\ scope as the mutex so cross-session + // launches reach the right process. + internal const string ShowWindowSignalName = @"Global\StandbyAndTimer_ShowWindow_v1"; + // schtasks /tn name — used by AutoStartService for both create and // delete. Must match exactly; a typo would orphan the scheduled task. internal const string AutoStartTaskName = "StandbyAndTimer_AutoStart"; diff --git a/StandbyAndTimer/Services/Native/NativeMethods.cs b/StandbyAndTimer/Services/Native/NativeMethods.cs index d12ce1a..0427794 100644 --- a/StandbyAndTimer/Services/Native/NativeMethods.cs +++ b/StandbyAndTimer/Services/Native/NativeMethods.cs @@ -96,6 +96,29 @@ internal static extern IntPtr AvSetMmThreadCharacteristics( [DllImport("user32.dll")] internal static extern IntPtr GetForegroundWindow(); + /// + /// Pulls a window to the top and gives it keyboard focus. Subject to + /// foreground-lock rules — only works when the calling process already + /// has foreground rights or was granted them by another process via + /// . + /// + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool SetForegroundWindow(IntPtr hWnd); + + /// + /// Allows another process to call . + /// Used by the secondary single-instance launcher: before signaling the + /// primary process to surface itself, we grant ASFW_ANY so the primary + /// can call SetForegroundWindow without being silently rejected by the + /// foreground-lock timeout. + /// + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool AllowSetForegroundWindow(uint dwProcessId); + + internal const uint ASFW_ANY = 0xFFFFFFFFu; + /// Returns the bounding rectangle of a window in screen coordinates. [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] diff --git a/StandbyAndTimer/StandbyAndTimer.csproj b/StandbyAndTimer/StandbyAndTimer.csproj index aa20f8e..db116df 100644 --- a/StandbyAndTimer/StandbyAndTimer.csproj +++ b/StandbyAndTimer/StandbyAndTimer.csproj @@ -24,7 +24,7 @@ this default, dev builds defaulted to AssemblyVersion 1.0.0.0 and the in-app update checker reported a phantom "new version" against the current release. --> - 2.0.11 + 2.1.0 diff --git a/StandbyAndTimer/Views/Cards/MemoryStatsCard.xaml b/StandbyAndTimer/Views/Cards/MemoryStatsCard.xaml index e3aa66f..a5baf3a 100644 --- a/StandbyAndTimer/Views/Cards/MemoryStatsCard.xaml +++ b/StandbyAndTimer/Views/Cards/MemoryStatsCard.xaml @@ -8,34 +8,48 @@ + TotalRamMb / FreeRamMb on MainViewModel. + + Three-row Grid (Auto / * / Auto): title pinned top, KPI vertically + centered in the slack, Total/Free strip pinned to the bottom. The + middle * row absorbs whatever extra height the sibling StandbyPurge + card forces on this row, so the box never has a dead band at the + bottom — content is distributed evenly through the full cell. --> - - + + + + + + - - - - + - + + + + + + + + - + Padding="0,10,0,0"> @@ -43,24 +57,26 @@ + FontSize="10" + Foreground="{DynamicResource AppFgDim}" + Margin="0,0,0,2"/> + FontSize="10" + Foreground="{DynamicResource AppFgDim}" + Margin="0,0,0,2"/> - + diff --git a/installer/Setup.iss b/installer/Setup.iss index bae7491..0bba4f8 100644 --- a/installer/Setup.iss +++ b/installer/Setup.iss @@ -12,7 +12,7 @@ #define AppName "StandbyAndTimer" #ifndef AppVersion - #define AppVersion "2.0.11" + #define AppVersion "2.1.0" #endif #define AppPublisher "LAYE77IE" #define AppExeName "StandbyAndTimer.exe" diff --git a/winget-manifests/Layellie.StandbyAndTimer.installer.yaml b/winget-manifests/Layellie.StandbyAndTimer.installer.yaml index d50f019..78ecce4 100644 --- a/winget-manifests/Layellie.StandbyAndTimer.installer.yaml +++ b/winget-manifests/Layellie.StandbyAndTimer.installer.yaml @@ -2,7 +2,7 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.6.0.schema.json PackageIdentifier: Layellie.StandbyAndTimer -PackageVersion: 2.0.11 +PackageVersion: 2.1.0 Platform: - Windows.Desktop MinimumOSVersion: 10.0.19041.0 @@ -16,9 +16,9 @@ UpgradeBehavior: install ReleaseDate: 2026-06-29 Installers: - Architecture: x64 - InstallerUrl: https://github.com/Layellie/StandbyAndTimer/releases/download/v2.0.11/StandbyAndTimer_Setup_2.0.11.exe + InstallerUrl: https://github.com/Layellie/StandbyAndTimer/releases/download/v2.1.0/StandbyAndTimer_Setup_2.1.0.exe # SHA256 is computed by the release pipeline (build-installer.ps1 prints it). # Update this hash on every release before opening the winget-pkgs PR. - InstallerSha256: AFF2B02D5E06A7F3AD2519227EC4A26210DF635685D183E51074A74C52FECDCC + InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000 ManifestType: installer ManifestVersion: 1.6.0 diff --git a/winget-manifests/Layellie.StandbyAndTimer.locale.en-US.yaml b/winget-manifests/Layellie.StandbyAndTimer.locale.en-US.yaml index dd6a1c9..e7c019d 100644 --- a/winget-manifests/Layellie.StandbyAndTimer.locale.en-US.yaml +++ b/winget-manifests/Layellie.StandbyAndTimer.locale.en-US.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.6.0.schema.json PackageIdentifier: Layellie.StandbyAndTimer -PackageVersion: 2.0.11 +PackageVersion: 2.1.0 PackageLocale: en-US Publisher: LAYE77IE PublisherUrl: https://github.com/Layellie @@ -31,6 +31,6 @@ Tags: - timer - tweak - utility -ReleaseNotesUrl: https://github.com/Layellie/StandbyAndTimer/releases/tag/v2.0.11 +ReleaseNotesUrl: https://github.com/Layellie/StandbyAndTimer/releases/tag/v2.1.0 ManifestType: defaultLocale ManifestVersion: 1.6.0 diff --git a/winget-manifests/Layellie.StandbyAndTimer.yaml b/winget-manifests/Layellie.StandbyAndTimer.yaml index f830159..a7b162c 100644 --- a/winget-manifests/Layellie.StandbyAndTimer.yaml +++ b/winget-manifests/Layellie.StandbyAndTimer.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.6.0.schema.json PackageIdentifier: Layellie.StandbyAndTimer -PackageVersion: 2.0.11 +PackageVersion: 2.1.0 DefaultLocale: en-US ManifestType: version ManifestVersion: 1.6.0 From 65638726e8289dadb0d0938076adf89968eadcce Mon Sep 17 00:00:00 2001 From: LAYE77IE Date: Sun, 28 Jun 2026 12:45:05 +0300 Subject: [PATCH 02/16] chore: winget locale license MIT (match repo) The locale yaml shipped "License: Proprietary" while the repository itself is MIT-licensed. The mismatch surfaced as confusing metadata on the winget package page. Aligns with LICENSE; also adds LicenseUrl so users can read the terms inline from winget output, and fills in the missing copyright year. Co-Authored-By: Claude Opus 4.7 --- winget-manifests/Layellie.StandbyAndTimer.locale.en-US.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/winget-manifests/Layellie.StandbyAndTimer.locale.en-US.yaml b/winget-manifests/Layellie.StandbyAndTimer.locale.en-US.yaml index e7c019d..6307571 100644 --- a/winget-manifests/Layellie.StandbyAndTimer.locale.en-US.yaml +++ b/winget-manifests/Layellie.StandbyAndTimer.locale.en-US.yaml @@ -8,8 +8,9 @@ PublisherUrl: https://github.com/Layellie PublisherSupportUrl: https://github.com/Layellie/StandbyAndTimer/issues PackageName: StandbyAndTimer PackageUrl: https://github.com/Layellie/StandbyAndTimer -License: Proprietary -Copyright: Copyright (c) LAYE77IE +License: MIT +LicenseUrl: https://github.com/Layellie/StandbyAndTimer/blob/main/LICENSE +Copyright: Copyright (c) 2026 LAYE77IE ShortDescription: Lock Windows timer to 0.5 ms, auto-purge standby memory, optimize games. Description: |- StandbyAndTimer is a Windows desktop utility for gamers and power users. From ccc08efbd8f66038e5a59b76dea3a8adc70c488f Mon Sep 17 00:00:00 2001 From: LAYE77IE Date: Sun, 28 Jun 2026 13:18:27 +0300 Subject: [PATCH 03/16] feat: extend AppSettings + localization for v2.1.0 features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the next 11 tasks (hotkey + tray toggle + notify prefs + idle-only auto-purge). Pure data + strings — no behavior change yet; subsequent tasks wire the UI and runtime guards. - AppSettings: 7 new fields with sensible defaults (notify flags default ON for upgrade parity, idle threshold 5 min, hotkeys Ctrl+Alt+P / Ctrl+Alt+T) - SettingsService: persist and round-trip the new fields; ints via DWORD, hotkeys as REG_SZ so a future capture-textbox can edit them in-place - Strings (en + tr): 10 new keys for Memory Purges KPI, idle-only controls, Notifications section, Hotkeys section - Plan: docs/superpowers/plans/2026-06-28-v2.1.0-bundle.md Co-Authored-By: Claude Opus 4.7 --- StandbyAndTimer/Core/Models/AppSettings.cs | 19 + StandbyAndTimer/Services/SettingsService.cs | 18 + .../Views/Strings/Strings.en-US.xaml | 12 + .../Views/Strings/Strings.tr-TR.xaml | 12 + .../plans/2026-06-28-v2.1.0-bundle.md | 1085 +++++++++++++++++ 5 files changed, 1146 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-v2.1.0-bundle.md diff --git a/StandbyAndTimer/Core/Models/AppSettings.cs b/StandbyAndTimer/Core/Models/AppSettings.cs index f30cf64..2e8bfda 100644 --- a/StandbyAndTimer/Core/Models/AppSettings.cs +++ b/StandbyAndTimer/Core/Models/AppSettings.cs @@ -15,5 +15,24 @@ public sealed class AppSettings // means *every* fresh install gets the onboarding overlay; the wizard's // Finish handler sets this to false + persists so it never reappears. public bool FirstRunCompleted { get; set; } + + // v2.1.0 — notification preferences. Default to true so the upgrade path + // for existing users keeps balloons firing; opt-out happens in Settings. + public bool NotifyOnPurge { get; set; } = true; + public bool NotifyOnTimerToggle { get; set; } = true; + public bool NotifyOnGameDetected { get; set; } = true; + + // v2.1.0 — idle gate for auto-purge. When the flag is on, the monitor + // only fires a threshold-triggered purge once the user has been idle for + // at least IdleThresholdMinutes, so gaming / typing isn't interrupted. + public bool AutoPurgeIdleOnly { get; set; } + public int IdleThresholdMinutes { get; set; } = 5; + + // v2.1.0 — global hotkeys for manual purge / timer toggle. Stored as + // "Ctrl+Alt+P"-style strings so the registry value is human-readable and + // a future capture-textbox can round-trip via HotkeyBinding.Parse. + public string PurgeHotkey { get; set; } = "Ctrl+Alt+P"; + public string TimerHotkey { get; set; } = "Ctrl+Alt+T"; + public List Games { get; set; } = []; } diff --git a/StandbyAndTimer/Services/SettingsService.cs b/StandbyAndTimer/Services/SettingsService.cs index f832b3f..5ad0f7d 100644 --- a/StandbyAndTimer/Services/SettingsService.cs +++ b/StandbyAndTimer/Services/SettingsService.cs @@ -31,6 +31,16 @@ public AppSettings Load() Theme = GetEnum(key, "Theme", Theme.Dark), UpdateCheckEnabled = GetBool(key, "UpdateCheckEnabled", defaultValue: true), FirstRunCompleted = GetBool(key, "FirstRunCompleted"), + + NotifyOnPurge = GetBool(key, "NotifyOnPurge", defaultValue: true), + NotifyOnTimerToggle = GetBool(key, "NotifyOnTimerToggle", defaultValue: true), + NotifyOnGameDetected = GetBool(key, "NotifyOnGameDetected", defaultValue: true), + + AutoPurgeIdleOnly = GetBool(key, "AutoPurgeIdleOnly"), + IdleThresholdMinutes = GetInt (key, "IdleThresholdMinutes", 5), + + PurgeHotkey = key.GetValue("PurgeHotkey")?.ToString() ?? "Ctrl+Alt+P", + TimerHotkey = key.GetValue("TimerHotkey")?.ToString() ?? "Ctrl+Alt+T", }; string pathsRaw = key.GetValue("GamePaths")?.ToString() ?? string.Empty; @@ -76,6 +86,14 @@ public void Save(AppSettings settings) key.SetValue("UpdateCheckEnabled", settings.UpdateCheckEnabled ? "1" : "0"); key.SetValue("FirstRunCompleted", settings.FirstRunCompleted ? "1" : "0"); + key.SetValue("NotifyOnPurge", settings.NotifyOnPurge ? "1" : "0"); + key.SetValue("NotifyOnTimerToggle", settings.NotifyOnTimerToggle ? "1" : "0"); + key.SetValue("NotifyOnGameDetected", settings.NotifyOnGameDetected ? "1" : "0"); + key.SetValue("AutoPurgeIdleOnly", settings.AutoPurgeIdleOnly ? "1" : "0"); + key.SetValue("IdleThresholdMinutes", settings.IdleThresholdMinutes, RegistryValueKind.DWord); + key.SetValue("PurgeHotkey", settings.PurgeHotkey ?? "Ctrl+Alt+P"); + key.SetValue("TimerHotkey", settings.TimerHotkey ?? "Ctrl+Alt+T"); + key.SetValue("Language", settings.Language.ToString()); key.SetValue("Theme", settings.Theme.ToString()); diff --git a/StandbyAndTimer/Views/Strings/Strings.en-US.xaml b/StandbyAndTimer/Views/Strings/Strings.en-US.xaml index 98dc168..cb7481d 100644 --- a/StandbyAndTimer/Views/Strings/Strings.en-US.xaml +++ b/StandbyAndTimer/Views/Strings/Strings.en-US.xaml @@ -139,4 +139,16 @@ Add "{0}" to the Game Mode list? Auto-added game: {0} + + Purges + Only when idle + Idle threshold (min) + NOTIFICATIONS + Purge notifications + Timer state notifications + Game-detected notifications + HOTKEYS + Manual purge + Toggle timer + diff --git a/StandbyAndTimer/Views/Strings/Strings.tr-TR.xaml b/StandbyAndTimer/Views/Strings/Strings.tr-TR.xaml index 2f3460c..281f04c 100644 --- a/StandbyAndTimer/Views/Strings/Strings.tr-TR.xaml +++ b/StandbyAndTimer/Views/Strings/Strings.tr-TR.xaml @@ -139,4 +139,16 @@ "{0}" Game Mode listesine eklensin mi? Otomatik eklendi: {0} + + Temizleme + Sadece boştayken + Boşta süre (dk) + BİLDİRİMLER + Temizleme bildirimleri + Zamanlayıcı bildirimleri + Oyun algılandı bildirimleri + KISAYOLLAR + Manuel temizle + Zamanlayıcı aç/kapa + diff --git a/docs/superpowers/plans/2026-06-28-v2.1.0-bundle.md b/docs/superpowers/plans/2026-06-28-v2.1.0-bundle.md new file mode 100644 index 0000000..69693d7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-v2.1.0-bundle.md @@ -0,0 +1,1085 @@ +# v2.1.0 Bundle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to walk this plan checkpoint-by-checkpoint. + +**Goal:** Bundle high-value features (global hotkeys, tray single-click toggle, notification preferences, idle-only auto-purge) and a card-layout cleanup into a single v2.1.0 release, on top of the work already on the `v2.1.0` branch (single-instance + Memory card layout polish already pushed in PR #28). + +**Architecture:** Each feature adds one service (hotkey IPC, idle detector) or extends one (tray, settings, memory monitor). All new P/Invoke stays in `Services/Native/NativeMethods.cs`. The Memory card grows a third KPI column (Purges); the Standby Purge card loses its `× count` header chip and gains an idle-only toggle row above the existing auto-purge row. SettingsViewModel + AppSettings carry the new toggles and persist them to HKCU via the existing pattern. Hotkeys are hosted on the MainWindow's HwndSource and routed through a thin `IHotkeyService` so other consumers can register/unregister cleanly. + +**Tech stack:** WPF .NET 10, CommunityToolkit.Mvvm, Microsoft.Extensions.DI 10.x, Inno Setup 6 (build), winget. P/Invoke against `user32` for `RegisterHotKey`/`UnregisterHotKey`/`GetLastInputInfo`. + +## Global Constraints + +- **No new test infra**: this project ships zero unit tests today. Verification is `dotnet build -c Release -warnaserror` (must pass) + manual smoke (see end of each task). Do NOT spin up a test project for this PR — that's a separate scope. +- **MVVM discipline**: views own no logic beyond `MainWindow.xaml.cs`'s current responsibilities (DataContext wiring, drag/drop forwarding, HwndSource exposure). Hotkey WndProc routing is hosted on MainWindow because that's where the HWND lives. +- **All P/Invoke in `Services/Native/NativeMethods.cs`.** Nothing else may import `System.Runtime.InteropServices`. +- **Two-language string parity**: every new `Str_*` key must be added to both `Strings.en-US.xaml` and `Strings.tr-TR.xaml` in the same task. +- **Layout symmetry**: the user explicitly called out font / spacing asymmetry on the Standby Purge card. Any new row must use the same `FontSize=11 / FontWeight=SemiBold / Foreground=AppFgDim` label style and the same `Height=26 / FontSize=12` input style already used by Standby Limit / Free Limit. +- **No bypass of the `Build (Release)` required check.** Branch protection is now active on `main`. +- **Resume on the existing `v2.1.0` branch**, not a new branch — these tasks extend the PR #28 commit. +- **Squash-only merge**: irrelevant during dev, but means a single squashed commit will land on `main`. + +--- + +## File Map + +**New files:** +- `Core/Interfaces/IHotkeyService.cs` +- `Core/Interfaces/IIdleMonitor.cs` +- `Core/Models/HotkeyBinding.cs` +- `Core/Models/NotificationKind.cs` +- `Services/HotkeyService.cs` +- `Services/IdleMonitor.cs` + +**Modified files:** +- `Services/Native/NativeMethods.cs` — RegisterHotKey, UnregisterHotKey, GetLastInputInfo, LASTINPUTINFO struct +- `Core/Models/AppSettings.cs` — 5 new fields (3 notify flags + idle-only + idle threshold) + hotkey binding strings +- `Services/SettingsService.cs` — load/save new fields +- `Core/Interfaces/IMemoryMonitorService.cs` — `IdleOnlyAutoPurge`, `IdleThresholdMs` +- `Services/MemoryMonitorService.cs` — idle gate inside the auto-purge condition (uses `IIdleMonitor`) +- `Core/Interfaces/ITrayIconService.cs` — `ToggleRequested` event +- `Services/Tray/TrayIconService.cs` — left-click and double-click raise `ToggleRequested` +- `Infrastructure/AppBootstrapper.cs` — register `IHotkeyService`, `IIdleMonitor` +- `App.xaml.cs` — handle `ToggleRequested`, register hotkeys after MainWindow is shown, guard each balloon by the matching `NotifyOnX` flag +- `MainWindow.xaml.cs` — expose `WindowHandle` after `SourceInitialized`; HotkeyService consumes it +- `ViewModels/MainViewModel.cs` — sync new memory-monitor properties on `ApplySettings`; expose `IdleOnlyAutoPurge` and `IdleThresholdMinutes` as `[ObservableProperty]` +- `ViewModels/SettingsViewModel.cs` — 3 notification toggles + 2 hotkey binding strings +- `Views/Cards/MemoryStatsCard.xaml` — 3-column bottom strip (Total | Free | Purges) +- `Views/Cards/StandbyPurgeCard.xaml` — drop header chip; insert idle-only toggle row + idle minutes input above the existing auto-purge row +- `Views/SettingsPanelView.xaml` — Notifications section + Hotkeys section (read-only labels for v2.1.0; rebinding UI in v2.2) +- `Views/Strings/Strings.en-US.xaml` + `Strings.tr-TR.xaml` — all new keys + +--- + +## Verification protocol (used by every task) + +After each task: + +```powershell +# Kill any running instance (admin in this shell) +Get-Process StandbyAndTimer -ErrorAction SilentlyContinue | Stop-Process -Force + +# Strict release build — required check on main +dotnet build StandbyAndTimer/StandbyAndTimer.csproj -c Release -warnaserror +``` + +Expected: `Oluşturma başarılı oldu. 0 Uyarı 0 Hata`. Any warning fails the task — fix and re-run. + +Then perform the **smoke** noted at the end of the task. Only commit when build is green and smoke passes. + +--- + +### Task 1: Localization strings + AppSettings extension (foundation) + +**Files:** +- Modify: `Core/Models/AppSettings.cs` +- Modify: `Services/SettingsService.cs` +- Modify: `Views/Strings/Strings.en-US.xaml` +- Modify: `Views/Strings/Strings.tr-TR.xaml` + +**Interfaces:** +- Produces: 5 new properties on `AppSettings` consumed by Tasks 4, 5, 7, 8. + +- [ ] **Step 1 — Extend `AppSettings`** (add at the end of the property list) + +```csharp +public bool NotifyOnPurge { get; set; } = true; +public bool NotifyOnTimerToggle { get; set; } = true; +public bool NotifyOnGameDetected { get; set; } = true; +public bool AutoPurgeIdleOnly { get; set; } +public int IdleThresholdMinutes { get; set; } = 5; +public string PurgeHotkey { get; set; } = "Ctrl+Alt+P"; +public string TimerHotkey { get; set; } = "Ctrl+Alt+T"; +``` + +- [ ] **Step 2 — Persist in `SettingsService.Save`** (just before `if (settings.Games.Count > 0)`) + +```csharp +key.SetValue("NotifyOnPurge", settings.NotifyOnPurge ? "1" : "0"); +key.SetValue("NotifyOnTimerToggle", settings.NotifyOnTimerToggle ? "1" : "0"); +key.SetValue("NotifyOnGameDetected", settings.NotifyOnGameDetected ? "1" : "0"); +key.SetValue("AutoPurgeIdleOnly", settings.AutoPurgeIdleOnly ? "1" : "0"); +key.SetValue("IdleThresholdMinutes", settings.IdleThresholdMinutes, RegistryValueKind.DWord); +key.SetValue("PurgeHotkey", settings.PurgeHotkey); +key.SetValue("TimerHotkey", settings.TimerHotkey); +``` + +- [ ] **Step 3 — Load in `SettingsService.Load`** (inside the `new AppSettings { ... }` block, add) + +```csharp +NotifyOnPurge = GetBool(key, "NotifyOnPurge", defaultValue: true), +NotifyOnTimerToggle = GetBool(key, "NotifyOnTimerToggle", defaultValue: true), +NotifyOnGameDetected = GetBool(key, "NotifyOnGameDetected", defaultValue: true), +AutoPurgeIdleOnly = GetBool(key, "AutoPurgeIdleOnly"), +IdleThresholdMinutes = GetInt (key, "IdleThresholdMinutes", 5), +PurgeHotkey = key.GetValue("PurgeHotkey")?.ToString() ?? "Ctrl+Alt+P", +TimerHotkey = key.GetValue("TimerHotkey")?.ToString() ?? "Ctrl+Alt+T", +``` + +- [ ] **Step 4 — Add string keys** to *both* `Strings.en-US.xaml` and `Strings.tr-TR.xaml` (group at the end of each file) + +```xml + +Purges +Only when idle +Idle threshold (min) +NOTIFICATIONS +Purge notifications +Timer state notifications +Game-detected notifications +HOTKEYS +Manual purge +Toggle timer +``` + +- [ ] **Step 5 — Build + commit** + +```powershell +dotnet build StandbyAndTimer/StandbyAndTimer.csproj -c Release -warnaserror +git add StandbyAndTimer/Core/Models/AppSettings.cs StandbyAndTimer/Services/SettingsService.cs StandbyAndTimer/Views/Strings/Strings.en-US.xaml StandbyAndTimer/Views/Strings/Strings.tr-TR.xaml +git commit -m "feat: extend AppSettings + localization for v2.1.0 features" +``` + +**Smoke:** launch app, open Settings — nothing visible should have changed. Run regedit to `HKCU\SOFTWARE\StandbyAndTimer`: after opening Settings once, new keys `NotifyOnPurge=1`, `IdleThresholdMinutes=5`, etc. should appear. + +--- + +### Task 2: NativeMethods additions (hotkey + idle) + +**Files:** +- Modify: `Services/Native/NativeMethods.cs` + +**Interfaces:** +- Produces: `RegisterHotKey`, `UnregisterHotKey`, `GetLastInputInfo`, `LASTINPUTINFO`, `WM_HOTKEY`, `MOD_*` constants consumed by Tasks 5 and 10. + +- [ ] **Step 1 — Add to `user32.dll` section** (right after `AllowSetForegroundWindow` from the v2.1.0 single-instance work) + +```csharp +/// Registers a system-wide hotkey. is delivered as wParam in WM_HOTKEY. +[DllImport("user32.dll", SetLastError = true)] +[return: MarshalAs(UnmanagedType.Bool)] +internal static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + +/// Releases a hotkey previously registered with . +[DllImport("user32.dll", SetLastError = true)] +[return: MarshalAs(UnmanagedType.Bool)] +internal static extern bool UnregisterHotKey(IntPtr hWnd, int id); + +/// Fills with the last input event tick count. +[DllImport("user32.dll", SetLastError = true)] +[return: MarshalAs(UnmanagedType.Bool)] +internal static extern bool GetLastInputInfo(ref LASTINPUTINFO plii); + +internal const uint MOD_ALT = 0x0001; +internal const uint MOD_CONTROL = 0x0002; +internal const uint MOD_SHIFT = 0x0004; +internal const uint MOD_WIN = 0x0008; +internal const uint MOD_NOREPEAT = 0x4000; +internal const int WM_HOTKEY = 0x0312; +``` + +- [ ] **Step 2 — Add to Structures section** (below `MEMORYSTATUSEX`) + +```csharp +[StructLayout(LayoutKind.Sequential)] +internal struct LASTINPUTINFO +{ + internal uint cbSize; + internal uint dwTime; +} +``` + +- [ ] **Step 3 — Build + commit** + +```powershell +dotnet build StandbyAndTimer/StandbyAndTimer.csproj -c Release -warnaserror +git add StandbyAndTimer/Services/Native/NativeMethods.cs +git commit -m "feat: native bindings for global hotkeys + idle detection" +``` + +**Smoke:** build clean (these are declarations only; no behavior change yet). + +--- + +### Task 3: Memory card 3-column bottom strip (Total | Free | Purges) + +**Files:** +- Modify: `Views/Cards/MemoryStatsCard.xaml` + +**Interfaces:** +- Consumes: existing `MainViewModel.PurgeCount` binding (already on the inherited DataContext). + +- [ ] **Step 1 — Replace the 2-column `Grid.ColumnDefinitions` block** inside the bottom Border (`Grid.Row="2"`) with a 3-column layout and add the third StackPanel for purges. + +```xml + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 2 — Build + commit** + +```powershell +dotnet build StandbyAndTimer/StandbyAndTimer.csproj -c Release -warnaserror +git add StandbyAndTimer/Views/Cards/MemoryStatsCard.xaml +git commit -m "feat: Memory card — add Purges KPI as 3rd column" +``` + +**Smoke:** launch, confirm three equally-sized columns at the bottom of Memory Stats card. "Purges" column shows `0` until the first purge fires. + +--- + +### Task 4: StandbyPurgeCard cleanup — drop header `× count` chip + +**Files:** +- Modify: `Views/Cards/StandbyPurgeCard.xaml` + +- [ ] **Step 1 — Replace the header `Grid` with a flat `TextBlock`** (lines ~13–28 today). + +```xml + +``` + +- [ ] **Step 2 — Build + commit** + +```powershell +dotnet build StandbyAndTimer/StandbyAndTimer.csproj -c Release -warnaserror +git add StandbyAndTimer/Views/Cards/StandbyPurgeCard.xaml +git commit -m "feat: StandbyPurge card — drop header purge-count chip" +``` + +**Smoke:** launch, right card now has only the title in the header, no `× 0` on the right. Symmetry with left card header is correct. + +--- + +### Task 5: Idle monitor — backend + memory monitor gate + +**Files:** +- Create: `Core/Interfaces/IIdleMonitor.cs` +- Create: `Services/IdleMonitor.cs` +- Modify: `Core/Interfaces/IMemoryMonitorService.cs` +- Modify: `Services/MemoryMonitorService.cs` +- Modify: `Infrastructure/AppBootstrapper.cs` +- Modify: `ViewModels/MainViewModel.cs` + +**Interfaces:** +- Produces: `IIdleMonitor` consumed by Task 5 (this one — by `MemoryMonitorService`) and not externally re-used in v2.1.0. + +- [ ] **Step 1 — Create `IIdleMonitor`** + +```csharp +namespace StandbyAndTimer.Core.Interfaces; + +/// +/// Reports how long since the user's last keyboard / mouse / touch input +/// reached the input queue. Wraps GetLastInputInfo and survives +/// session lock — locking the workstation does NOT reset the counter. +/// +public interface IIdleMonitor +{ + /// Time elapsed since the most recent input event. + TimeSpan TimeSinceLastInput { get; } +} +``` + +- [ ] **Step 2 — Create `IdleMonitor`** + +```csharp +using StandbyAndTimer.Core.Interfaces; +using StandbyAndTimer.Services.Native; + +namespace StandbyAndTimer.Services; + +internal sealed class IdleMonitor : IIdleMonitor +{ + public TimeSpan TimeSinceLastInput + { + get + { + var info = new NativeMethods.LASTINPUTINFO + { + cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf() + }; + if (!NativeMethods.GetLastInputInfo(ref info)) + return TimeSpan.Zero; + + // Environment.TickCount wraps every ~24.8 days; unchecked subtraction + // yields the correct positive delta across the wrap boundary. + uint delta = unchecked((uint)Environment.TickCount - info.dwTime); + return TimeSpan.FromMilliseconds(delta); + } + } +} +``` + +- [ ] **Step 3 — Extend `IMemoryMonitorService`** with two writable properties: + +```csharp +bool AutoPurgeIdleOnly { get; set; } +int IdleThresholdMs { get; set; } +``` + +- [ ] **Step 4 — Inject `IIdleMonitor` into `MemoryMonitorService`** and gate the auto-purge condition: + +```csharp +public MemoryMonitorService( + IStandbyPurgeService purgeService, + IProcessOptimizationService processService, + IIdleMonitor idleMonitor) // NEW +{ + _purgeService = purgeService; + _processService = processService; + _idleMonitor = idleMonitor; // NEW +} + +private readonly IIdleMonitor _idleMonitor; // NEW field + +public bool AutoPurgeIdleOnly { get; set; } +public int IdleThresholdMs { get; set; } +``` + +Update the purge condition inside `RunLoopAsync`: + +```csharp +bool idleGate = !AutoPurgeIdleOnly + || _idleMonitor.TimeSinceLastInput.TotalMilliseconds >= IdleThresholdMs; + +bool purgeNeeded = AutoPurgeEnabled + && StandbyLimitMb > 0 + && FreeLimitMb > 0 + && snapshot.StandbyMb >= StandbyLimitMb + && snapshot.FreeMb <= FreeLimitMb + && idleGate; +``` + +- [ ] **Step 5 — Register `IIdleMonitor` in `AppBootstrapper.Build()`** (before MemoryMonitorService line): + +```csharp +services.AddSingleton(); +``` + +- [ ] **Step 6 — Sync the two new properties in `MainViewModel`.** Add two `[ObservableProperty]` fields: + +```csharp +[ObservableProperty] private bool _autoPurgeIdleOnly; +[ObservableProperty] private int _idleThresholdMinutes = 5; +``` + +In `ApplySettings` (inside the InitScope block), add: + +```csharp +AutoPurgeIdleOnly = s.AutoPurgeIdleOnly; +IdleThresholdMinutes = s.IdleThresholdMinutes; +``` + +In the property where `_memoryService.*` is mirrored (around line 154–157), add: + +```csharp +_memoryService.AutoPurgeIdleOnly = AutoPurgeIdleOnly; +_memoryService.IdleThresholdMs = Math.Max(0, IdleThresholdMinutes) * 60_000; +``` + +In `BuildCurrentSettings`, add: + +```csharp +AutoPurgeIdleOnly = AutoPurgeIdleOnly, +IdleThresholdMinutes = IdleThresholdMinutes, +``` + +Add property-changed partials so changes persist + re-mirror: + +```csharp +partial void OnAutoPurgeIdleOnlyChanged(bool value) +{ + _memoryService.AutoPurgeIdleOnly = value; + PersistSettings(); +} + +partial void OnIdleThresholdMinutesChanged(int value) +{ + _memoryService.IdleThresholdMs = Math.Max(0, value) * 60_000; + PersistSettings(); +} +``` + +- [ ] **Step 7 — Build + commit** + +```powershell +dotnet build StandbyAndTimer/StandbyAndTimer.csproj -c Release -warnaserror +git add StandbyAndTimer/Core/Interfaces/IIdleMonitor.cs StandbyAndTimer/Services/IdleMonitor.cs StandbyAndTimer/Core/Interfaces/IMemoryMonitorService.cs StandbyAndTimer/Services/MemoryMonitorService.cs StandbyAndTimer/Infrastructure/AppBootstrapper.cs StandbyAndTimer/ViewModels/MainViewModel.cs +git commit -m "feat: idle-only auto-purge gate (backend)" +``` + +**Smoke:** flip `AutoPurgeIdleOnly` on via regedit (no UI yet), set a low IdleThresholdMinutes (e.g. `1`), force a threshold breach (small StandbyLimitMb), leave keyboard/mouse alone for a minute → purge fires. Move mouse during the threshold breach → no purge until you stop moving for the threshold. + +--- + +### Task 6: Idle-only toggle UI in StandbyPurgeCard + +**Files:** +- Modify: `Views/Cards/StandbyPurgeCard.xaml` + +- [ ] **Step 1 — Insert idle-only row ABOVE the existing AUTO PURGE row** (between header and the existing `` that holds the AUTO PURGE toggle). + +```xml + + + + + + + + + +``` + +- [ ] **Step 2 — Build + commit** + +```powershell +dotnet build StandbyAndTimer/StandbyAndTimer.csproj -c Release -warnaserror +git add StandbyAndTimer/Views/Cards/StandbyPurgeCard.xaml +git commit -m "feat: StandbyPurge card — idle-only toggle row above AUTO PURGE" +``` + +**Smoke:** open app, toggle AUTO PURGE on → the new idle-only row becomes interactive. Check it on → numeric box becomes editable. With AUTO PURGE off → both controls greyed. No layout shift on the rest of the card. + +--- + +### Task 7: Tray single-click toggles window + +**Files:** +- Modify: `Core/Interfaces/ITrayIconService.cs` +- Modify: `Services/Tray/TrayIconService.cs` +- Modify: `App.xaml.cs` +- Modify: `MainWindow.xaml.cs` + +**Interfaces:** +- Produces: `ITrayIconService.ToggleRequested` event consumed by `App.xaml.cs`. + +- [ ] **Step 1 — Add `ToggleRequested` event to interface** + +```csharp +/// Raised on a single left-click of the tray icon — App toggles window visibility. +event EventHandler? ToggleRequested; +``` + +- [ ] **Step 2 — Wire it in `TrayIconService.Initialize`** (replace the existing `MouseClick` lambda): + +```csharp +public event EventHandler? ToggleRequested; +``` + +Replace the existing two click handlers (lines ~53–58) with: + +```csharp +_notifyIcon.MouseClick += (_, e) => +{ + if (e.Button == WinForms.MouseButtons.Left) + ToggleRequested?.Invoke(this, EventArgs.Empty); +}; +_notifyIcon.DoubleClick += (_, _) => ToggleRequested?.Invoke(this, EventArgs.Empty); +``` + +The context-menu "Show" item still raises `ShowRequested` — that semantically means "always show," whereas left-click is "toggle." + +- [ ] **Step 3 — Expose visibility state on MainWindow** (add a public read-only property): + +```csharp +internal bool IsOffscreen => _offscreen; +``` + +- [ ] **Step 4 — Handle `ToggleRequested` in `App.xaml.cs`** (next to the existing `ShowRequested` wiring): + +```csharp +_tray.ToggleRequested += (_, _) => ToggleMainWindow(); +``` + +Add the handler: + +```csharp +private void ToggleMainWindow() +{ + if (_mainWindow is null) return; + if (_mainWindow.IsOffscreen) SurfaceMainWindow(); + else _mainWindow.HideOffscreen(); +} +``` + +`SurfaceMainWindow` already exists from the single-instance work. + +- [ ] **Step 5 — Build + commit** + +```powershell +dotnet build StandbyAndTimer/StandbyAndTimer.csproj -c Release -warnaserror +git add StandbyAndTimer/Core/Interfaces/ITrayIconService.cs StandbyAndTimer/Services/Tray/TrayIconService.cs StandbyAndTimer/MainWindow.xaml.cs StandbyAndTimer/App.xaml.cs +git commit -m "feat: tray single-click toggles window visibility" +``` + +**Smoke:** app open → single-click tray icon → window hides offscreen. Single-click again → window comes back foreground. Right-click → "Show" still works. + +--- + +### Task 8: Notification preferences — backend guards + +**Files:** +- Modify: `App.xaml.cs` +- Modify: `ViewModels/MainViewModel.cs` + +- [ ] **Step 1 — Cache notification settings on `App`.** Add a field: + +```csharp +private AppSettings? _userSettings; +``` + +In `OnStartup`, after the existing `var savedSettings = _services.GetRequiredService().Load();`, also assign: + +```csharp +_userSettings = savedSettings; +``` + +Add a refresh hook so when MainViewModel persists, the cache stays current: + +```csharp +_viewModel.SettingsPersisted += (_, latest) => _userSettings = latest; +``` + +- [ ] **Step 2 — Add a `SettingsPersisted` event on `MainViewModel`.** + +```csharp +public event EventHandler? SettingsPersisted; +``` + +Raise it inside `PersistSettings()` just after `_settingsService.Save(...)`: + +```csharp +private void PersistSettings() +{ + var snapshot = BuildCurrentSettings(); + _settingsService.Save(snapshot); + SettingsPersisted?.Invoke(this, snapshot); +} +``` + +- [ ] **Step 3 — Guard each balloon in App.xaml.cs.** Edit the three existing handlers: + +```csharp +private void OnPurgeNotification(object? sender, int totalPurges) +{ + if (_userSettings is { NotifyOnPurge: false }) return; + // ... existing balloon body +} + +private void OnGameAutoDetected(object? sender, string exePath) +{ + if (_userSettings is { NotifyOnGameDetected: false }) return; + // ... existing balloon body +} + +private void OnTimerToggledNotification(object? sender, TimerToggledArgs args) +{ + if (_userSettings is { NotifyOnTimerToggle: false }) return; + // ... existing balloon body +} +``` + +Also gate the `SilentUpdateCheckAsync` balloon by `_userSettings?.UpdateCheckEnabled` (already true by default). + +- [ ] **Step 4 — Build + commit** + +```powershell +dotnet build StandbyAndTimer/StandbyAndTimer.csproj -c Release -warnaserror +git add StandbyAndTimer/App.xaml.cs StandbyAndTimer/ViewModels/MainViewModel.cs +git commit -m "feat: notification preferences — guard balloons by user toggle" +``` + +**Smoke:** with all 3 notify flags ON (default), manual purge → balloon. Set `NotifyOnPurge=false` via regedit, restart app, manual purge → no balloon. (UI for toggling these is in Task 9.) + +--- + +### Task 9: Notifications + Hotkeys sections in Settings panel + +**Files:** +- Modify: `Views/SettingsPanelView.xaml` +- Modify: `ViewModels/SettingsViewModel.cs` + +- [ ] **Step 1 — Expose 5 new `[ObservableProperty]` fields on `SettingsViewModel`** (after the existing `_updateAvailable` block): + +```csharp +[ObservableProperty] private bool _notifyOnPurge = true; +[ObservableProperty] private bool _notifyOnTimerToggle = true; +[ObservableProperty] private bool _notifyOnGameDetected = true; +[ObservableProperty] private string _purgeHotkeyText = "Ctrl+Alt+P"; +[ObservableProperty] private string _timerHotkeyText = "Ctrl+Alt+T"; +``` + +Persistence: extend the existing `Initialize` signature to pass these in: + +```csharp +public void Initialize( + bool autoStartEnabled, Language language, bool updateCheckEnabled, Theme theme, + bool notifyOnPurge, bool notifyOnTimerToggle, bool notifyOnGameDetected, + string purgeHotkey, string timerHotkey) +{ + using (new InitScope(v => _isInitializing = v)) + { + AutoStartEnabled = autoStartEnabled; + SelectedLanguage = language; + UpdateCheckEnabled = updateCheckEnabled; + SelectedTheme = theme; + NotifyOnPurge = notifyOnPurge; + NotifyOnTimerToggle = notifyOnTimerToggle; + NotifyOnGameDetected = notifyOnGameDetected; + PurgeHotkeyText = purgeHotkey; + TimerHotkeyText = timerHotkey; + } +} +``` + +Add partials that fire `SettingsChanged`: + +```csharp +partial void OnNotifyOnPurgeChanged (bool value) { if (!_isInitializing) SettingsChanged?.Invoke(this, EventArgs.Empty); } +partial void OnNotifyOnTimerToggleChanged (bool value) { if (!_isInitializing) SettingsChanged?.Invoke(this, EventArgs.Empty); } +partial void OnNotifyOnGameDetectedChanged(bool value) { if (!_isInitializing) SettingsChanged?.Invoke(this, EventArgs.Empty); } +``` + +Hotkey text is read-only in the UI for v2.1.0 (no rebinding control yet), so its setter is never called from the view — no partial needed. + +- [ ] **Step 2 — Update `MainViewModel.ApplySettings` to pass the new fields**: + +```csharp +Settings.Initialize( + s.AutoStartEnabled, s.Language, s.UpdateCheckEnabled, s.Theme, + s.NotifyOnPurge, s.NotifyOnTimerToggle, s.NotifyOnGameDetected, + s.PurgeHotkey, s.TimerHotkey); +``` + +And update `BuildCurrentSettings` to round-trip them: + +```csharp +NotifyOnPurge = Settings.NotifyOnPurge, +NotifyOnTimerToggle = Settings.NotifyOnTimerToggle, +NotifyOnGameDetected = Settings.NotifyOnGameDetected, +PurgeHotkey = Settings.PurgeHotkeyText, +TimerHotkey = Settings.TimerHotkeyText, +``` + +- [ ] **Step 3 — Insert two sections in `Views/SettingsPanelView.xaml`** between the existing Backup section (row 14) and the UpdateStatus text (row 24). Bump the row-definition count and re-number; the panel still needs no outer scrollbar because the * Logs row absorbs the slack. + +Add row definitions (insert after row 22 = Open Log, renumber the rest): + +```xml + + + + + + + + + + + + + + +``` + +(Engineer: shift all subsequent `Grid.Row` indices by the count of inserted rows. Run `dotnet build` after to catch any miscount as XAML parse errors.) + +Add the section content immediately after the `