Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 135 additions & 12 deletions StandbyAndTimer/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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");

Expand All @@ -93,6 +122,7 @@ void Mark(string step)

// ── 4. Apply persisted language + theme before window is created ─────
var savedSettings = _services.GetRequiredService<ISettingsService>().Load();
_userSettings = savedSettings;
if (savedSettings.Language != Core.Models.Language.English)
_localization.SetLanguage(savedSettings.Language);
if (savedSettings.Theme != Core.Models.Theme.Dark)
Expand All @@ -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);
Expand All @@ -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<IHotkeyService>();
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();
Expand Down Expand Up @@ -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<IHotkeyService>() as IDisposable)?.Dispose();

_tray?.Dispose();

AppIconLoader.DisposeIcon(ref _iconBase);
Expand All @@ -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()
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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");
Expand Down
28 changes: 28 additions & 0 deletions StandbyAndTimer/Core/Interfaces/IHotkeyService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using StandbyAndTimer.Core.Models;

namespace StandbyAndTimer.Core.Interfaces;

/// <summary>
/// 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.
/// </summary>
public interface IHotkeyService : IDisposable
{
/// <summary>
/// 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.
/// </summary>
void AttachTo(IntPtr hwnd);

/// <summary>
/// Registers <paramref name="binding"/> under <paramref name="id"/>.
/// Returns false if Win32 refused (combo taken by another app, no HWND
/// attached, etc.). Replaces any prior registration for the same id.
/// </summary>
bool Register(string id, HotkeyBinding binding, Action handler);

/// <summary>Releases the hotkey by id. No-op if it wasn't registered.</summary>
void Unregister(string id);
}
14 changes: 14 additions & 0 deletions StandbyAndTimer/Core/Interfaces/IIdleMonitor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace StandbyAndTimer.Core.Interfaces;

/// <summary>
/// Reports how long it has been since the user's last keyboard / mouse /
/// touch input reached the input queue. Wraps <c>GetLastInputInfo</c>;
/// the kernel maintains the tick under the lock screen, so locking the
/// workstation does not reset the counter (matches Windows' own "Away"
/// behavior).
/// </summary>
public interface IIdleMonitor
{
/// <summary>Time elapsed since the most recent input event.</summary>
TimeSpan TimeSinceLastInput { get; }
}
6 changes: 6 additions & 0 deletions StandbyAndTimer/Core/Interfaces/IMemoryMonitorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ public interface IMemoryMonitorService : IDisposable
bool GameModeEnabled { get; set; }
IReadOnlyList<string> GamePaths { get; set; }

/// <summary>When true, threshold-triggered purge fires only after the user has been idle for <see cref="IdleThresholdMs"/>.</summary>
bool AutoPurgeIdleOnly { get; set; }

/// <summary>Idle window in milliseconds before an idle-gated purge is allowed to fire.</summary>
int IdleThresholdMs { get; set; }

Task StartAsync(CancellationToken cancellationToken);
Task StopAsync();
}
5 changes: 4 additions & 1 deletion StandbyAndTimer/Core/Interfaces/ITrayIconService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ namespace StandbyAndTimer.Core.Interfaces;
// needs to know anything about WinForms Shell_NotifyIcon plumbing.
public interface ITrayIconService : IDisposable
{
/// <summary>Invoked when the user picks "Show" or clicks/double-clicks the tray icon.</summary>
/// <summary>Invoked when the user picks "Show" from the tray context menu — always surfaces the window.</summary>
event EventHandler? ShowRequested;

/// <summary>Invoked on a left-click / double-click of the tray icon — App toggles window visibility.</summary>
event EventHandler? ToggleRequested;

/// <summary>Invoked when the user picks "Exit" from the tray menu.</summary>
event EventHandler? ExitRequested;

Expand Down
19 changes: 19 additions & 0 deletions StandbyAndTimer/Core/Models/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GameEntry> Games { get; set; } = [];
}
Loading
Loading