diff --git a/StandbyAndTimer/App.xaml.cs b/StandbyAndTimer/App.xaml.cs index f986395..fcfeeec 100644 --- a/StandbyAndTimer/App.xaml.cs +++ b/StandbyAndTimer/App.xaml.cs @@ -6,6 +6,7 @@ using System.Windows.Threading; using Microsoft.Extensions.DependencyInjection; using StandbyAndTimer.Core.Interfaces; +using StandbyAndTimer.Core.Models; using StandbyAndTimer.Infrastructure; using StandbyAndTimer.Services; using StandbyAndTimer.Services.Native; @@ -28,13 +29,22 @@ 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; + + // Cached latest AppSettings snapshot. Used by the balloon handlers + // (OnPurgeNotification / OnTimerToggledNotification / OnGameAutoDetected) + // to skip raising the WinForms balloon when the user has opted out of + // that particular notification kind. Refreshed via the SettingsPersisted + // event MainViewModel raises after every successful Save. + private AppSettings? _userSettings; private System.Drawing.Icon? _iconBase; private System.Drawing.Icon? _iconActive; @@ -61,18 +71,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"); @@ -93,6 +122,7 @@ void Mark(string step) // ── 4. Apply persisted language + theme before window is created ───── var savedSettings = _services.GetRequiredService().Load(); + _userSettings = savedSettings; if (savedSettings.Language != Core.Models.Language.English) _localization.SetLanguage(savedSettings.Language); if (savedSettings.Theme != Core.Models.Theme.Dark) @@ -105,8 +135,10 @@ void Mark(string step) _viewModel.PurgeNotification += OnPurgeNotification; _viewModel.TimerToggledNotification += OnTimerToggledNotification; _viewModel.GameAutoDetected += OnGameAutoDetected; + _viewModel.SettingsPersisted += (_, latest) => _userSettings = latest; _tray.ShowRequested += (_, _) => ShowMainWindow(); + _tray.ToggleRequested += (_, _) => ToggleMainWindow(); _tray.ExitRequested += (_, _) => Shutdown(); _tray.TimerToggleRequested += (_, _) => _viewModel.ToggleTimerResolutionCommand.Execute(null); _tray.PurgeRequested += (_, _) => _viewModel.ManualPurgeCommand.Execute(null); @@ -127,6 +159,23 @@ 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); + + // Hotkeys require the HWND to exist, which is guaranteed after Show() + // (and after HideOffscreen, since that internally calls Show too). + // Each binding dispatches back to the UI thread; the command itself + // does the dispatcher hop work. + var hotkeys = _services.GetRequiredService(); + hotkeys.AttachTo(_mainWindow.WindowHandle); + RegisterHotkeyFromSetting(hotkeys, "purge", savedSettings.PurgeHotkey, + () => _viewModel.ManualPurgeCommand.Execute(null)); + RegisterHotkeyFromSetting(hotkeys, "timer", savedSettings.TimerHotkey, + () => _viewModel.ToggleTimerResolutionCommand.Execute(null)); + Mark("hotkeys registered"); + // Live tooltip updater — every 3 s, refresh icon and text. Cheap. _tooltipTicker = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) }; _tooltipTicker.Tick += (_, _) => RefreshTray(); @@ -173,6 +222,11 @@ protected override void OnExit(ExitEventArgs e) _viewModel.GameAutoDetected -= OnGameAutoDetected; } + // Release every Win32 RegisterHotKey before the HWND goes away — + // otherwise the OS keeps the registrations bound to a dead window + // and the next launch fails with "hot key already registered." + (_services?.GetService() as IDisposable)?.Dispose(); + _tray?.Dispose(); AppIconLoader.DisposeIcon(ref _iconBase); @@ -185,12 +239,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() @@ -235,6 +331,30 @@ private static void DisablePowerThrottling() private void ShowMainWindow() => _mainWindow?.ShowFromOffscreen(); + private static void RegisterHotkeyFromSetting(IHotkeyService svc, string id, string? text, Action handler) + { + var parsed = HotkeyBinding.Parse(text ?? string.Empty); + if (parsed is null) + { + Logger.Warn($"Hotkey '{id}': could not parse '{text}', skipping"); + return; + } + // Marshal the handler onto the dispatcher — RelayCommand.Execute on + // off-thread can race with the WPF binding system. BeginInvoke is fine + // here; the user won't notice a one-frame latency on a hotkey fire. + svc.Register(id, parsed, () => + Application.Current.Dispatcher.BeginInvoke(handler)); + } + + // Tray left-click semantics: if hidden → surface; if visible → hide. + // Right-click "Show" still always surfaces (via ShowRequested). + private void ToggleMainWindow() + { + if (_mainWindow is null) return; + if (_mainWindow.IsOffscreen) SurfaceMainWindow(); + else _mainWindow.HideOffscreen(); + } + private void OnLanguageChanged(object? sender, EventArgs e) => Dispatcher.InvokeAsync(RefreshTray); @@ -264,6 +384,7 @@ private void RefreshTray() // top of OnExit. Non-null for the lifetime of the subscription. private void OnPurgeNotification(object? sender, int totalPurges) { + if (_userSettings is { NotifyOnPurge: false }) return; string title = _localization!.GetString("Str_Tray_NotifyPurgeTitle"); string body = string.Format( CultureInfo.CurrentCulture, @@ -274,6 +395,7 @@ private void OnPurgeNotification(object? sender, int totalPurges) private void OnGameAutoDetected(object? sender, string exePath) { + if (_userSettings is { NotifyOnGameDetected: false }) return; try { string fileName = Path.GetFileName(exePath); @@ -290,6 +412,7 @@ private void OnGameAutoDetected(object? sender, string exePath) private void OnTimerToggledNotification(object? sender, TimerToggledArgs args) { + if (_userSettings is { NotifyOnTimerToggle: false }) return; string title = _localization!.GetString(args.IsActive ? "Str_Tray_NotifyTimerOnTitle" : "Str_Tray_NotifyTimerOffTitle"); diff --git a/StandbyAndTimer/Core/Interfaces/IHotkeyService.cs b/StandbyAndTimer/Core/Interfaces/IHotkeyService.cs new file mode 100644 index 0000000..b729bd0 --- /dev/null +++ b/StandbyAndTimer/Core/Interfaces/IHotkeyService.cs @@ -0,0 +1,28 @@ +using StandbyAndTimer.Core.Models; + +namespace StandbyAndTimer.Core.Interfaces; + +/// +/// Registers process-global hotkeys against the main window's HWND. +/// Each binding fires its handler on the WPF dispatcher; the WndProc hook +/// dispatches itself, so consumers don't have to. +/// +public interface IHotkeyService : IDisposable +{ + /// + /// Called once after MainWindow's HwndSource is initialized so the service + /// knows which HWND will receive the WM_HOTKEY messages. Subsequent + /// AttachTo calls release any registrations bound to the previous HWND. + /// + void AttachTo(IntPtr hwnd); + + /// + /// Registers under . + /// Returns false if Win32 refused (combo taken by another app, no HWND + /// attached, etc.). Replaces any prior registration for the same id. + /// + bool Register(string id, HotkeyBinding binding, Action handler); + + /// Releases the hotkey by id. No-op if it wasn't registered. + void Unregister(string id); +} diff --git a/StandbyAndTimer/Core/Interfaces/IIdleMonitor.cs b/StandbyAndTimer/Core/Interfaces/IIdleMonitor.cs new file mode 100644 index 0000000..16a86b4 --- /dev/null +++ b/StandbyAndTimer/Core/Interfaces/IIdleMonitor.cs @@ -0,0 +1,14 @@ +namespace StandbyAndTimer.Core.Interfaces; + +/// +/// Reports how long it has been since the user's last keyboard / mouse / +/// touch input reached the input queue. Wraps GetLastInputInfo; +/// the kernel maintains the tick under the lock screen, so locking the +/// workstation does not reset the counter (matches Windows' own "Away" +/// behavior). +/// +public interface IIdleMonitor +{ + /// Time elapsed since the most recent input event. + TimeSpan TimeSinceLastInput { get; } +} diff --git a/StandbyAndTimer/Core/Interfaces/IMemoryMonitorService.cs b/StandbyAndTimer/Core/Interfaces/IMemoryMonitorService.cs index 1915aca..2bb13e9 100644 --- a/StandbyAndTimer/Core/Interfaces/IMemoryMonitorService.cs +++ b/StandbyAndTimer/Core/Interfaces/IMemoryMonitorService.cs @@ -14,6 +14,12 @@ public interface IMemoryMonitorService : IDisposable bool GameModeEnabled { get; set; } IReadOnlyList GamePaths { get; set; } + /// When true, threshold-triggered purge fires only after the user has been idle for . + bool AutoPurgeIdleOnly { get; set; } + + /// Idle window in milliseconds before an idle-gated purge is allowed to fire. + int IdleThresholdMs { get; set; } + Task StartAsync(CancellationToken cancellationToken); Task StopAsync(); } diff --git a/StandbyAndTimer/Core/Interfaces/ITrayIconService.cs b/StandbyAndTimer/Core/Interfaces/ITrayIconService.cs index 6c2f631..783bf24 100644 --- a/StandbyAndTimer/Core/Interfaces/ITrayIconService.cs +++ b/StandbyAndTimer/Core/Interfaces/ITrayIconService.cs @@ -7,9 +7,12 @@ namespace StandbyAndTimer.Core.Interfaces; // needs to know anything about WinForms Shell_NotifyIcon plumbing. public interface ITrayIconService : IDisposable { - /// Invoked when the user picks "Show" or clicks/double-clicks the tray icon. + /// Invoked when the user picks "Show" from the tray context menu — always surfaces the window. event EventHandler? ShowRequested; + /// Invoked on a left-click / double-click of the tray icon — App toggles window visibility. + event EventHandler? ToggleRequested; + /// Invoked when the user picks "Exit" from the tray menu. event EventHandler? ExitRequested; 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/Core/Models/HotkeyBinding.cs b/StandbyAndTimer/Core/Models/HotkeyBinding.cs new file mode 100644 index 0000000..74aae2d --- /dev/null +++ b/StandbyAndTimer/Core/Models/HotkeyBinding.cs @@ -0,0 +1,60 @@ +namespace StandbyAndTimer.Core.Models; + +/// +/// Modifier-flag mask + virtual-key code parsed from a "Ctrl+Alt+P"-style +/// settings string. The mask uses the same bit values as the Win32 +/// MOD_* constants so it can be passed straight to RegisterHotKey. +/// +public sealed record HotkeyBinding(uint Modifiers, uint VirtualKey) +{ + private const uint MOD_ALT = 0x0001; + private const uint MOD_CONTROL = 0x0002; + private const uint MOD_SHIFT = 0x0004; + private const uint MOD_WIN = 0x0008; + + /// + /// Parses "Ctrl+Alt+P" / "Shift+F9" / "Win+Alt+M" into a binding. + /// Returns null for malformed input (no modifier, missing key, etc.) so + /// the caller can log + fall back to default behavior instead of throwing. + /// + public static HotkeyBinding? Parse(string text) + { + if (string.IsNullOrWhiteSpace(text)) return null; + + uint mods = 0; + uint vk = 0; + + foreach (var raw in text.Split('+', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + switch (raw.ToUpperInvariant()) + { + case "CTRL": + case "CONTROL": mods |= MOD_CONTROL; break; + case "ALT": mods |= MOD_ALT; break; + case "SHIFT": mods |= MOD_SHIFT; break; + case "WIN": + case "WINDOWS": mods |= MOD_WIN; break; + default: + if (raw.Length == 1 && char.IsLetterOrDigit(raw[0])) + { + // VK_A..VK_Z + VK_0..VK_9 happen to match the ASCII + // codes of the upper-case characters / digit chars, + // so a direct cast does the job without a lookup table. + vk = char.ToUpperInvariant(raw[0]); + } + else if (raw.Length >= 2 + && (raw[0] == 'F' || raw[0] == 'f') + && int.TryParse(raw[1..], out int fn) + && fn is >= 1 and <= 24) + { + // VK_F1 = 0x70, VK_F2 = 0x71, ... VK_F24 = 0x87 + vk = (uint)(0x70 + fn - 1); + } + break; + } + } + + return (mods != 0 && vk != 0) ? new HotkeyBinding(mods, vk) : null; + } +} diff --git a/StandbyAndTimer/Infrastructure/AppBootstrapper.cs b/StandbyAndTimer/Infrastructure/AppBootstrapper.cs index 831ed46..410ee6f 100644 --- a/StandbyAndTimer/Infrastructure/AppBootstrapper.cs +++ b/StandbyAndTimer/Infrastructure/AppBootstrapper.cs @@ -18,6 +18,8 @@ internal static IServiceProvider Build() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); 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/MainWindow.xaml.cs b/StandbyAndTimer/MainWindow.xaml.cs index 0cc257a..aa3f769 100644 --- a/StandbyAndTimer/MainWindow.xaml.cs +++ b/StandbyAndTimer/MainWindow.xaml.cs @@ -79,6 +79,17 @@ protected override void OnClosed(EventArgs e) // ── Off-screen "tray" mode ──────────────────────────────────────────────── + /// True while the window is parked at (-32000, -32000) with Opacity=0 — i.e. "in the tray". + internal bool IsOffscreen => _offscreen; + + /// + /// HWND of this window's HwndSource. Becomes non-zero after Show() forces + /// SourceInitialized. HotkeyService attaches here so WM_HOTKEY messages + /// route through our WndProc. + /// + internal IntPtr WindowHandle => + new System.Windows.Interop.WindowInteropHelper(this).Handle; + internal void HideOffscreen() { if (_offscreen) return; diff --git a/StandbyAndTimer/Services/HotkeyService.cs b/StandbyAndTimer/Services/HotkeyService.cs new file mode 100644 index 0000000..079222d --- /dev/null +++ b/StandbyAndTimer/Services/HotkeyService.cs @@ -0,0 +1,94 @@ +using System.Runtime.InteropServices; +using System.Windows.Interop; +using StandbyAndTimer.Core.Interfaces; +using StandbyAndTimer.Core.Models; +using StandbyAndTimer.Services.Native; + +namespace StandbyAndTimer.Services; + +internal sealed class HotkeyService : IHotkeyService +{ + private readonly Dictionary _byHotkeyId = new(); + private readonly Dictionary _idLookup = new(StringComparer.Ordinal); + private int _nextHotkeyId; + private IntPtr _hwnd; + private HwndSource? _source; + private bool _disposed; + + public void AttachTo(IntPtr hwnd) + { + if (_hwnd == hwnd) return; + Detach(); + _hwnd = hwnd; + _source = HwndSource.FromHwnd(hwnd); + _source?.AddHook(WndProc); + } + + public bool Register(string id, HotkeyBinding binding, Action handler) + { + if (_hwnd == IntPtr.Zero) + { + Logger.Warn($"HotkeyService.Register('{id}') called before AttachTo"); + return false; + } + + // Replace any prior binding for the same id so re-binding via Settings + // (when we add that UI in v2.2) doesn't leak the previous registration. + Unregister(id); + + int hotkeyId = System.Threading.Interlocked.Increment(ref _nextHotkeyId); + uint mods = binding.Modifiers | NativeMethods.MOD_NOREPEAT; + + if (!NativeMethods.RegisterHotKey(_hwnd, hotkeyId, mods, binding.VirtualKey)) + { + Logger.Warn($"RegisterHotKey failed for '{id}' (mods=0x{binding.Modifiers:X}, vk=0x{binding.VirtualKey:X}): " + + Marshal.GetLastWin32Error()); + return false; + } + + _byHotkeyId[hotkeyId] = (id, handler); + _idLookup[id] = hotkeyId; + return true; + } + + public void Unregister(string id) + { + if (!_idLookup.TryGetValue(id, out int hotkeyId)) return; + NativeMethods.UnregisterHotKey(_hwnd, hotkeyId); + _byHotkeyId.Remove(hotkeyId); + _idLookup.Remove(id); + } + + private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg != NativeMethods.WM_HOTKEY) return IntPtr.Zero; + + int hotkeyId = wParam.ToInt32(); + if (_byHotkeyId.TryGetValue(hotkeyId, out var slot)) + { + try { slot.handler(); } + catch (Exception ex) { Logger.Warn($"Hotkey handler '{slot.id}' threw: {ex.Message}"); } + handled = true; + } + return IntPtr.Zero; + } + + private void Detach() + { + if (_source is null) return; + foreach (int hotkeyId in _byHotkeyId.Keys) + NativeMethods.UnregisterHotKey(_hwnd, hotkeyId); + _byHotkeyId.Clear(); + _idLookup.Clear(); + _source.RemoveHook(WndProc); + _source = null; + _hwnd = IntPtr.Zero; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Detach(); + } +} diff --git a/StandbyAndTimer/Services/IdleMonitor.cs b/StandbyAndTimer/Services/IdleMonitor.cs new file mode 100644 index 0000000..8e569b9 --- /dev/null +++ b/StandbyAndTimer/Services/IdleMonitor.cs @@ -0,0 +1,27 @@ +using System.Runtime.InteropServices; +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)Marshal.SizeOf() + }; + if (!NativeMethods.GetLastInputInfo(ref info)) + return TimeSpan.Zero; + + // Environment.TickCount wraps every ~24.8 days; an unchecked + // subtraction over the wrap point still yields the correct + // positive delta because both sides are read as uint. + uint delta = unchecked((uint)Environment.TickCount - info.dwTime); + return TimeSpan.FromMilliseconds(delta); + } + } +} diff --git a/StandbyAndTimer/Services/MemoryMonitorService.cs b/StandbyAndTimer/Services/MemoryMonitorService.cs index 8f0633b..1bc05b5 100644 --- a/StandbyAndTimer/Services/MemoryMonitorService.cs +++ b/StandbyAndTimer/Services/MemoryMonitorService.cs @@ -8,8 +8,9 @@ namespace StandbyAndTimer.Services; internal sealed class MemoryMonitorService : IMemoryMonitorService { - private readonly IStandbyPurgeService _purgeService; + private readonly IStandbyPurgeService _purgeService; private readonly IProcessOptimizationService _processService; + private readonly IIdleMonitor _idleMonitor; // The total Windows standby list = Normal Priority + Reserve + Core. // Reading only one bucket (as the original did) understated the value by @@ -27,20 +28,24 @@ internal sealed class MemoryMonitorService : IMemoryMonitorService private Task? _loopTask; private bool _disposed; - public int StandbyLimitMb { get; set; } - public int FreeLimitMb { get; set; } - public bool AutoPurgeEnabled { get; set; } - public bool GameModeEnabled { get; set; } + public int StandbyLimitMb { get; set; } + public int FreeLimitMb { get; set; } + public bool AutoPurgeEnabled { get; set; } + public bool GameModeEnabled { get; set; } + public bool AutoPurgeIdleOnly { get; set; } + public int IdleThresholdMs { get; set; } public IReadOnlyList GamePaths { get; set; } = []; public event EventHandler? SnapshotUpdated; public MemoryMonitorService( IStandbyPurgeService purgeService, - IProcessOptimizationService processService) + IProcessOptimizationService processService, + IIdleMonitor idleMonitor) { _purgeService = purgeService; _processService = processService; + _idleMonitor = idleMonitor; // Counters are created in EnsureCountersInitialized on the loop thread. } @@ -114,11 +119,19 @@ private async Task RunLoopAsync(CancellationToken ct) // Master switch: AutoPurgeEnabled must be ON, and both thresholds // must be > 0. Defaults ship as OFF + 0/0 so a fresh install never // touches the standby list until the user explicitly opts in. + // Idle gate: when AutoPurgeIdleOnly is on, suppress the purge + // until the user has been quiet for IdleThresholdMs. Defaults + // to "always allowed" if the flag is off, so existing users + // see no behaviour change on upgrade. + bool idleGate = !AutoPurgeIdleOnly + || _idleMonitor.TimeSinceLastInput.TotalMilliseconds >= IdleThresholdMs; + bool purgeNeeded = AutoPurgeEnabled && StandbyLimitMb > 0 && FreeLimitMb > 0 && snapshot.StandbyMb >= StandbyLimitMb - && snapshot.FreeMb <= FreeLimitMb; + && snapshot.FreeMb <= FreeLimitMb + && idleGate; if (purgeNeeded) await _purgeService.PurgeAsync().ConfigureAwait(false); diff --git a/StandbyAndTimer/Services/Native/NativeMethods.cs b/StandbyAndTimer/Services/Native/NativeMethods.cs index d12ce1a..4ba0460 100644 --- a/StandbyAndTimer/Services/Native/NativeMethods.cs +++ b/StandbyAndTimer/Services/Native/NativeMethods.cs @@ -96,6 +96,64 @@ 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; + + /// + /// Registers a system-wide hotkey. is delivered as + /// wParam in . Returns false if the combo is taken + /// by another app — caller should log the Win32 error code. + /// + [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 system tick count of the most recent input event. + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool GetLastInputInfo(ref LASTINPUTINFO plii); + + // RegisterHotKey modifier flags (winuser.h). + internal const uint MOD_ALT = 0x0001; + internal const uint MOD_CONTROL = 0x0002; + internal const uint MOD_SHIFT = 0x0004; + internal const uint MOD_WIN = 0x0008; + + /// + /// Suppresses auto-repeat when the user holds the chord — without this, + /// holding Ctrl+Alt+P would fire the purge handler tens of times per + /// second until they let go. + /// + internal const uint MOD_NOREPEAT = 0x4000; + + /// Window message id for hotkey events. + internal const int WM_HOTKEY = 0x0312; + /// Returns the bounding rectangle of a window in screen coordinates. [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] @@ -175,6 +233,18 @@ internal struct RECT internal int Bottom; } + /// + /// Out parameter for . dwTime is the + /// system tick count (millisecond wall clock that wraps every ~24.8 days) + /// of the last input event reaching the input queue. + /// + [StructLayout(LayoutKind.Sequential)] + internal struct LASTINPUTINFO + { + internal uint cbSize; + internal uint dwTime; + } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] internal struct MONITORINFO { 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/Services/Tray/TrayIconService.cs b/StandbyAndTimer/Services/Tray/TrayIconService.cs index 2131f89..f76fbbf 100644 --- a/StandbyAndTimer/Services/Tray/TrayIconService.cs +++ b/StandbyAndTimer/Services/Tray/TrayIconService.cs @@ -27,6 +27,7 @@ internal sealed class TrayIconService : ITrayIconService private bool _disposed; public event EventHandler? ShowRequested; + public event EventHandler? ToggleRequested; public event EventHandler? ExitRequested; public event EventHandler? TimerToggleRequested; public event EventHandler? PurgeRequested; @@ -50,12 +51,15 @@ public void Initialize(System.Drawing.Icon baseIcon, System.Drawing.Icon? active ContextMenuStrip = menu, }; + // Left-click and double-click both toggle visibility. The context-menu + // "Show" item still raises ShowRequested — semantically "always show", + // distinct from "toggle" (which can hide a visible window). _notifyIcon.MouseClick += (_, e) => { if (e.Button == WinForms.MouseButtons.Left) - ShowRequested?.Invoke(this, EventArgs.Empty); + ToggleRequested?.Invoke(this, EventArgs.Empty); }; - _notifyIcon.DoubleClick += (_, _) => ShowRequested?.Invoke(this, EventArgs.Empty); + _notifyIcon.DoubleClick += (_, _) => ToggleRequested?.Invoke(this, EventArgs.Empty); RefreshLabels(); } 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/ViewModels/MainViewModel.cs b/StandbyAndTimer/ViewModels/MainViewModel.cs index 572e53f..5c850b5 100644 --- a/StandbyAndTimer/ViewModels/MainViewModel.cs +++ b/StandbyAndTimer/ViewModels/MainViewModel.cs @@ -39,6 +39,8 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable [ObservableProperty] private int _standbyLimitMb; [ObservableProperty] private int _freeLimitMb; [ObservableProperty] private bool _autoPurgeEnabled; + [ObservableProperty] private bool _autoPurgeIdleOnly; + [ObservableProperty] private int _idleThresholdMinutes = 5; [ObservableProperty] private bool _gameModeEnabled; [ObservableProperty] private bool _timerActive; [ObservableProperty] private double _actualTimerMs; @@ -129,18 +131,23 @@ private void ApplySettings(AppSettings s) { using (new InitScope(v => _isInitializing = v)) { - StandbyLimitMb = s.StandbyLimitMb; - FreeLimitMb = s.FreeLimitMb; - AutoPurgeEnabled = s.AutoPurgeEnabled; - GameModeEnabled = s.GameModeEnabled; - TimerActive = s.TimerResolutionActive; - _firstRunCompleted = s.FirstRunCompleted; + StandbyLimitMb = s.StandbyLimitMb; + FreeLimitMb = s.FreeLimitMb; + AutoPurgeEnabled = s.AutoPurgeEnabled; + AutoPurgeIdleOnly = s.AutoPurgeIdleOnly; + IdleThresholdMinutes = s.IdleThresholdMinutes > 0 ? s.IdleThresholdMinutes : 5; + GameModeEnabled = s.GameModeEnabled; + TimerActive = s.TimerResolutionActive; + _firstRunCompleted = s.FirstRunCompleted; Games.Clear(); foreach (var g in s.Games) Games.Add(g); - Settings.Initialize(s.AutoStartEnabled, s.Language, s.UpdateCheckEnabled, s.Theme); + Settings.Initialize( + s.AutoStartEnabled, s.Language, s.UpdateCheckEnabled, s.Theme, + s.NotifyOnPurge, s.NotifyOnTimerToggle, s.NotifyOnGameDetected, + s.PurgeHotkey, s.TimerHotkey); } SyncMonitorThresholds(); SyncMonitorGames(); @@ -151,10 +158,12 @@ private void ApplySettings(AppSettings s) // LINQ + List allocation that GamePaths rebuilding would cost is wasted. private void SyncMonitorThresholds() { - _memoryService.StandbyLimitMb = StandbyLimitMb; - _memoryService.FreeLimitMb = FreeLimitMb; - _memoryService.AutoPurgeEnabled = AutoPurgeEnabled; - _memoryService.GameModeEnabled = GameModeEnabled; + _memoryService.StandbyLimitMb = StandbyLimitMb; + _memoryService.FreeLimitMb = FreeLimitMb; + _memoryService.AutoPurgeEnabled = AutoPurgeEnabled; + _memoryService.AutoPurgeIdleOnly = AutoPurgeIdleOnly; + _memoryService.IdleThresholdMs = Math.Max(0, IdleThresholdMinutes) * 60_000; + _memoryService.GameModeEnabled = GameModeEnabled; } // Pushes the executable-path list to the monitor. Called only when the @@ -172,9 +181,14 @@ private void SyncMonitorGames() private void PersistSettings() { if (_isInitializing) return; - _settingsService.Save(BuildCurrentSettings()); + var snapshot = BuildCurrentSettings(); + _settingsService.Save(snapshot); + SettingsPersisted?.Invoke(this, snapshot); } + /// Raised after every successful Save — App.xaml.cs keeps a cached snapshot for balloon-guard decisions. + public event EventHandler? SettingsPersisted; + private void SchedulePersist() { if (_isInitializing) return; @@ -212,12 +226,19 @@ private void FlushPendingPersist() StandbyLimitMb = StandbyLimitMb, FreeLimitMb = FreeLimitMb, AutoPurgeEnabled = AutoPurgeEnabled, + AutoPurgeIdleOnly = AutoPurgeIdleOnly, + IdleThresholdMinutes = IdleThresholdMinutes, GameModeEnabled = GameModeEnabled, AutoStartEnabled = Settings.AutoStartEnabled, TimerResolutionActive = TimerActive, Language = Settings.SelectedLanguage, Theme = Settings.SelectedTheme, UpdateCheckEnabled = Settings.UpdateCheckEnabled, + NotifyOnPurge = Settings.NotifyOnPurge, + NotifyOnTimerToggle = Settings.NotifyOnTimerToggle, + NotifyOnGameDetected = Settings.NotifyOnGameDetected, + PurgeHotkey = Settings.PurgeHotkeyText, + TimerHotkey = Settings.TimerHotkeyText, FirstRunCompleted = _firstRunCompleted, Games = [.. Games] }; @@ -299,6 +320,18 @@ partial void OnAutoPurgeEnabledChanged(bool value) PersistSettings(); } + partial void OnAutoPurgeIdleOnlyChanged(bool value) + { + SyncMonitorThresholds(); + PersistSettings(); + } + + partial void OnIdleThresholdMinutesChanged(int value) + { + SyncMonitorThresholds(); + SchedulePersist(); // debounced — TextBox emits a change per keystroke + } + partial void OnGameModeEnabledChanged(bool value) { SyncMonitorThresholds(); diff --git a/StandbyAndTimer/ViewModels/SettingsViewModel.cs b/StandbyAndTimer/ViewModels/SettingsViewModel.cs index cfc977d..661089a 100644 --- a/StandbyAndTimer/ViewModels/SettingsViewModel.cs +++ b/StandbyAndTimer/ViewModels/SettingsViewModel.cs @@ -49,6 +49,11 @@ public sealed partial class SettingsViewModel : ObservableObject [ObservableProperty] private Theme _selectedTheme = Theme.Dark; [ObservableProperty] private string _updateStatus = string.Empty; [ObservableProperty] private string _recentLogs = string.Empty; + [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"; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(DownloadAndInstallCommand))] @@ -108,14 +113,22 @@ public SettingsViewModel( _logTailReader = logTailReader; } - public void Initialize(bool autoStartEnabled, Language language, bool updateCheckEnabled, Theme theme) + 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; + AutoStartEnabled = autoStartEnabled; + SelectedLanguage = language; + UpdateCheckEnabled = updateCheckEnabled; + SelectedTheme = theme; + NotifyOnPurge = notifyOnPurge; + NotifyOnTimerToggle = notifyOnTimerToggle; + NotifyOnGameDetected = notifyOnGameDetected; + PurgeHotkeyText = purgeHotkey; + TimerHotkeyText = timerHotkey; } } @@ -171,6 +184,24 @@ partial void OnUpdateCheckEnabledChanged(bool value) SettingsChanged?.Invoke(this, EventArgs.Empty); } + partial void OnNotifyOnPurgeChanged(bool value) + { + if (_isInitializing) return; + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + partial void OnNotifyOnTimerToggleChanged(bool value) + { + if (_isInitializing) return; + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + + partial void OnNotifyOnGameDetectedChanged(bool value) + { + if (_isInitializing) return; + SettingsChanged?.Invoke(this, EventArgs.Empty); + } + partial void OnSelectedThemeChanged(Theme value) { if (_isInitializing) return; diff --git a/StandbyAndTimer/Views/Cards/MemoryStatsCard.xaml b/StandbyAndTimer/Views/Cards/MemoryStatsCard.xaml index e3aa66f..ca67200 100644 --- a/StandbyAndTimer/Views/Cards/MemoryStatsCard.xaml +++ b/StandbyAndTimer/Views/Cards/MemoryStatsCard.xaml @@ -8,59 +8,97 @@ + 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"> + - + + + FontSize="10" + Foreground="{DynamicResource AppFgDim}" + HorizontalAlignment="Left" + Margin="0,0,0,2"/> - + + FontSize="10" + Foreground="{DynamicResource AppFgDim}" + HorizontalAlignment="Center" + Margin="0,0,0,2"/> + + + + - + diff --git a/StandbyAndTimer/Views/Cards/StandbyPurgeCard.xaml b/StandbyAndTimer/Views/Cards/StandbyPurgeCard.xaml index 5e6421b..6d98f3f 100644 --- a/StandbyAndTimer/Views/Cards/StandbyPurgeCard.xaml +++ b/StandbyAndTimer/Views/Cards/StandbyPurgeCard.xaml @@ -10,21 +10,30 @@ Padding="16,14" Margin="0"> - - - - - - + + + + + + + + + + + internal ScrollViewer handles long log content. + + v2.1.0 inserted Notifications (rows 14-20) + Hotkeys (rows 22-26) + between Theme picker and Backup section; the * Logs row absorbed + the height, no overall layout shift below. --> - @@ -37,21 +35,35 @@ - - + + - + - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -122,27 +134,84 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -