From 5927a50052857dd24a7f98e8284a2ecdf734aa66 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Fri, 3 Apr 2026 11:41:41 +0800 Subject: [PATCH 1/7] feat(posthog-cli): add CLI scripts for dashboard queries and listing --- .agents/skills/posthog-cli-queries/SKILL.md | 138 ++++++++++++++++++ .../scripts/dashboard_fetch.sh | 57 ++++++++ .../scripts/dashboard_list.sh | 31 ++++ 3 files changed, 226 insertions(+) create mode 100644 .agents/skills/posthog-cli-queries/SKILL.md create mode 100755 .agents/skills/posthog-cli-queries/scripts/dashboard_fetch.sh create mode 100755 .agents/skills/posthog-cli-queries/scripts/dashboard_list.sh diff --git a/.agents/skills/posthog-cli-queries/SKILL.md b/.agents/skills/posthog-cli-queries/SKILL.md new file mode 100644 index 0000000..970f267 --- /dev/null +++ b/.agents/skills/posthog-cli-queries/SKILL.md @@ -0,0 +1,138 @@ +--- +name: posthog-cli-queries +description: Use when querying this project's PostHog data from the terminal, including recent events, event breakdowns, active-user metrics, and dashboard metadata or cached insight results. +--- + +# PostHog CLI Queries + +Query this project's PostHog data from the terminal without guessing command syntax. + +## When To Use + +- The user asks to inspect PostHog events, trends, or dashboard data +- The task needs real data from this project's PostHog environment +- The user wants a repeatable terminal command instead of clicking in the PostHog UI + +## Preconditions + +- `posthog-cli` must be installed and authenticated +- Query commands need a personal API key with `query:read` +- Dashboard API helpers read `~/.posthog/credentials.json` +- This project currently uses PostHog environment `292804` on `https://us.posthog.com`, but scripts read the active local credentials instead of hardcoding values + +## Default Workflow + +1. Verify auth: + +```bash +posthog-cli exp query run 'SELECT 1 AS ok' +``` + +2. For event data, use `posthog-cli exp query run ''` +3. For dashboard metadata or cached dashboard insight results, use the scripts in this skill because the CLI has no dedicated dashboard command +4. Return: + - the exact command used + - the key rows or aggregates + - any metric caveats such as partial-day data, test-account filtering, or missing properties + +Shell quoting rule: + +- Use single quotes around HogQL when the query contains properties like `$app_version` or `$os`, otherwise the shell may expand them before the CLI sees the query + +## Common Queries + +Recent events: + +```bash +posthog-cli exp query run 'SELECT event, timestamp FROM events ORDER BY timestamp DESC LIMIT 20' +``` + +Top events in the last 7 days: + +```bash +posthog-cli exp query run 'SELECT event, count() AS c FROM events WHERE timestamp > now() - INTERVAL 7 DAY GROUP BY event ORDER BY c DESC LIMIT 15' +``` + +Top `pageview` pages in the last 7 days: + +```bash +posthog-cli exp query run "SELECT properties.page_name AS page_name, count() AS c FROM events WHERE event = 'pageview' AND timestamp > now() - INTERVAL 7 DAY GROUP BY page_name ORDER BY c DESC LIMIT 15" +``` + +Top `click` targets in the last 7 days: + +```bash +posthog-cli exp query run "SELECT properties.element_name AS element_name, count() AS c FROM events WHERE event = 'click' AND timestamp > now() - INTERVAL 7 DAY GROUP BY element_name ORDER BY c DESC LIMIT 15" +``` + +Recent app versions from events: + +```bash +posthog-cli exp query run 'SELECT properties.$app_version AS app_version, count() AS c FROM events WHERE timestamp > now() - INTERVAL 7 DAY GROUP BY app_version ORDER BY c DESC LIMIT 20' +``` + +Recent OS breakdown: + +```bash +posthog-cli exp query run 'SELECT properties.$os AS os, count(DISTINCT person_id) AS users FROM events WHERE timestamp > now() - INTERVAL 7 DAY GROUP BY os ORDER BY users DESC LIMIT 20' +``` + +Hourly volume in the last 24 hours: + +```bash +posthog-cli exp query run 'SELECT toStartOfHour(timestamp) AS hour, count() AS c FROM events WHERE timestamp > now() - INTERVAL 24 HOUR GROUP BY hour ORDER BY hour DESC LIMIT 24' +``` + +## Dashboard Commands + +List dashboards: + +```bash +bash .agents/skills/posthog-cli-queries/scripts/dashboard_list.sh +``` + +Fetch a dashboard as JSON: + +```bash +bash .agents/skills/posthog-cli-queries/scripts/dashboard_fetch.sh 1075953 +``` + +Fetch a dashboard summary: + +```bash +bash .agents/skills/posthog-cli-queries/scripts/dashboard_fetch.sh 1075953 --summary +``` + +Current known dashboard: + +```bash +bash .agents/skills/posthog-cli-queries/scripts/dashboard_fetch.sh 1075953 --summary +``` + +## Dashboard Analysis Notes + +- Dashboard results are usually cached insight payloads, so `last_refresh` matters +- Do not compare a partial current day against a full previous day without saying so explicitly +- Check `filterTestAccounts` before comparing tiles with each other +- If `$os` or `$app_version` has a large `null` bucket, call out that the property coverage is incomplete +- If event names overlap like `app_open` and `Application Opened`, mention that the taxonomy is split + +## Useful jq Snippets + +Extract tile names from a fetched dashboard JSON: + +```bash +jq -r '.tiles[] | select(.insight != null) | [.id, .insight.id, .insight.name] | @tsv' +``` + +Show top breakdown rows from one insight result: + +```bash +jq -r '.tiles[] | select(.insight.id==6334935) | .insight.result[] | [.label, .count, (.data[-1] // 0)] | @tsv' +``` + +## Failure Handling + +- If `posthog-cli exp query run` says `missing required scope 'query:read'`, fix the personal API key scopes first +- If a query times out, narrow the date range or aggregate more aggressively +- If dashboard scripts fail, confirm `~/.posthog/credentials.json` exists and contains `host`, `token`, and `env_id` diff --git a/.agents/skills/posthog-cli-queries/scripts/dashboard_fetch.sh b/.agents/skills/posthog-cli-queries/scripts/dashboard_fetch.sh new file mode 100755 index 0000000..1d0f3f9 --- /dev/null +++ b/.agents/skills/posthog-cli-queries/scripts/dashboard_fetch.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 [--summary]" >&2 + exit 1 +fi + +dashboard_id="$1" +mode="${2:-}" +cred_file="${HOME}/.posthog/credentials.json" + +if [[ ! -f "${cred_file}" ]]; then + echo "Missing credentials file: ${cred_file}" >&2 + exit 1 +fi + +token="$(jq -r '.token // empty' "${cred_file}")" +host="$(jq -r '.host // empty' "${cred_file}")" + +if [[ -z "${token}" || -z "${host}" ]]; then + echo "Expected host and token in ${cred_file}" >&2 + exit 1 +fi + +response="$( + curl -fsS \ + -H "Authorization: Bearer ${token}" \ + "${host%/}/api/dashboard/${dashboard_id}" +)" + +if [[ "${mode}" == "--summary" ]]; then + jq -r ' + "Dashboard\t" + (.id | tostring) + "\t" + .name, + ( + .tiles[] + | select(.insight != null) + | [ + (.id | tostring), + (.insight.id | tostring), + ( + if (.insight.name // "") != "" then .insight.name + elif (.insight.derived_name // "") != "" then .insight.derived_name + else (.insight.short_id // "") + end + ), + (.insight.query.source.kind // ""), + (.insight.query.source.interval // ""), + ((.insight.result | length) | tostring), + (.last_refresh // .insight.last_refresh // "") + ] + | @tsv + ) + ' <<<"${response}" +else + jq '.' <<<"${response}" +fi diff --git a/.agents/skills/posthog-cli-queries/scripts/dashboard_list.sh b/.agents/skills/posthog-cli-queries/scripts/dashboard_list.sh new file mode 100755 index 0000000..00e9672 --- /dev/null +++ b/.agents/skills/posthog-cli-queries/scripts/dashboard_list.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +cred_file="${HOME}/.posthog/credentials.json" + +if [[ ! -f "${cred_file}" ]]; then + echo "Missing credentials file: ${cred_file}" >&2 + exit 1 +fi + +token="$(jq -r '.token // empty' "${cred_file}")" +host="$(jq -r '.host // empty' "${cred_file}")" +env_id="$(jq -r '.env_id // empty' "${cred_file}")" + +if [[ -z "${token}" || -z "${host}" || -z "${env_id}" ]]; then + echo "Expected host, token, and env_id in ${cred_file}" >&2 + exit 1 +fi + +curl -fsS \ + -H "Authorization: Bearer ${token}" \ + "${host%/}/api/dashboard/" | + jq -r --arg env_id "${env_id}" ' + if (.results | length) == 0 then + "No dashboards found for environment \($env_id)" + else + .results[] + | [.id, .name, (.pinned // false), (.last_accessed_at // ""), (.last_viewed_at // "")] + | @tsv + end + ' From d6a0326f4dda099587da36d92810ccfd658ae286 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Fri, 3 Apr 2026 15:20:04 +0800 Subject: [PATCH 2/7] fix(windows): recover tray and hooks after resume --- KeyStats.Windows/KeyStats/App.xaml.cs | 279 ++++++++++++++---- .../KeyStats/Helpers/NativeInterop.cs | 3 + .../KeyStats/Services/InputMonitorService.cs | 218 ++++++++++---- .../KeyStats/Services/StatsManager.cs | 83 ++++-- 4 files changed, 449 insertions(+), 134 deletions(-) diff --git a/KeyStats.Windows/KeyStats/App.xaml.cs b/KeyStats.Windows/KeyStats/App.xaml.cs index bf329bc..4349fe7 100644 --- a/KeyStats.Windows/KeyStats/App.xaml.cs +++ b/KeyStats.Windows/KeyStats/App.xaml.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Globalization; using System.IO; using System.Runtime.InteropServices; @@ -26,6 +27,7 @@ public partial class App : System.Windows.Application private Forms.NotifyIcon? _trayIcon; private TrayIconViewModel? _trayIconViewModel; private TrayContextMenuHost? _trayContextMenuHost; + private TaskbarCreatedWatcher? _taskbarCreatedWatcher; private SettingsWindow? _settingsWindow; private NotificationSettingsWindow? _notificationSettingsWindow; private MouseCalibrationWindow? _mouseCalibrationWindow; @@ -35,6 +37,7 @@ public partial class App : System.Windows.Application private System.Threading.Mutex? _singleInstanceMutex; private string? _appVersion; private IPostHogAnalytics? _postHogClient; + private DateTime _lastResumeRecoveryUtc = DateTime.MinValue; protected override void OnStartup(StartupEventArgs e) { @@ -75,6 +78,7 @@ protected override void OnStartup(StartupEventArgs e) Console.WriteLine("Applying theme..."); ThemeManager.Instance.Initialize(); + RegisterSystemEventHandlers(); Console.WriteLine("Initializing services..."); // Initialize services @@ -85,66 +89,20 @@ protected override void OnStartup(StartupEventArgs e) InputMonitorService.Instance.StartMonitoring(); Console.WriteLine("Creating tray icon..."); - // Create tray icon _trayIconViewModel = new TrayIconViewModel(); - var contextMenu = CreateContextMenu(); - _trayContextMenuHost = new TrayContextMenuHost(contextMenu); - _trayIcon = new Forms.NotifyIcon + _trayIconViewModel.PropertyChanged += OnTrayIconViewModelPropertyChanged; + _taskbarCreatedWatcher = new TaskbarCreatedWatcher(() => { - Icon = _trayIconViewModel.TrayIcon, - Text = _trayIconViewModel.TooltipText, - Visible = true - }; - _trayIcon.MouseClick += (s, e) => - { - if (e.Button == Forms.MouseButtons.Right) - { - Application.Current?.Dispatcher.BeginInvoke(new Action(() => - { - _trayContextMenuHost?.ShowAtCursor(); - })); - return; - } - - if (e.Button != Forms.MouseButtons.Left) - { - return; - } - - Console.WriteLine("NotifyIcon left click fired - showing stats"); - Task.Run(() => - { - try - { - TrackClick("tray_icon"); - } - catch - { - // Ignore analytics failures. - } - }); - var anchorPoint = Forms.Control.MousePosition; - Application.Current?.Dispatcher.BeginInvoke(new Action(() => + Dispatcher.BeginInvoke(new Action(() => { - _trayIconViewModel?.ShowStats(anchorPoint); + Console.WriteLine("TaskbarCreated received, recreating tray integration."); + RecreateTrayIntegration(); })); - }; + }); + RecreateTrayIntegration(); Console.WriteLine("Tray icon created successfully!"); Console.WriteLine("App is running. Look for the icon in the system tray."); - - // Bind icon and tooltip updates - _trayIconViewModel.PropertyChanged += (s, ev) => - { - if (ev.PropertyName == nameof(TrayIconViewModel.TrayIcon)) - { - _trayIcon.Icon = _trayIconViewModel.TrayIcon; - } - else if (ev.PropertyName == nameof(TrayIconViewModel.TooltipText)) - { - _trayIcon.Text = _trayIconViewModel.TooltipText; - } - }; } catch (Exception ex) { @@ -458,13 +416,22 @@ private static string MakeExportFileName() protected override void OnExit(ExitEventArgs e) { + UnregisterSystemEventHandlers(); + if (_trayIconViewModel != null) + { + _trayIconViewModel.PropertyChanged -= OnTrayIconViewModelPropertyChanged; + } TrackAnalyticsExit(); _trayIconViewModel?.Cleanup(); _trayContextMenuHost?.Dispose(); + _taskbarCreatedWatcher?.Dispose(); + _taskbarCreatedWatcher = null; if (_trayIcon != null) { + _trayIcon.MouseClick -= OnTrayIconMouseClick; _trayIcon.Visible = false; _trayIcon.Dispose(); + _trayIcon = null; } InputMonitorService.Instance.StopMonitoring(); StatsManager.Instance.FlushPendingSave(); @@ -874,6 +841,177 @@ public void TrackClick(string elementName, Dictionary? extraPro /// public static App? CurrentApp => Current as App; + private void RegisterSystemEventHandlers() + { + SystemEvents.PowerModeChanged += OnPowerModeChanged; + SystemEvents.SessionSwitch += OnSessionSwitch; + } + + private void UnregisterSystemEventHandlers() + { + SystemEvents.PowerModeChanged -= OnPowerModeChanged; + SystemEvents.SessionSwitch -= OnSessionSwitch; + } + + private void OnPowerModeChanged(object? sender, PowerModeChangedEventArgs e) + { + switch (e.Mode) + { + case PowerModes.Suspend: + Console.WriteLine("System suspend detected, flushing pending stats."); + StatsManager.Instance.FlushPendingSave(); + InputMonitorService.Instance.ResetLastMousePosition(); + break; + case PowerModes.Resume: + ScheduleResumeRecovery("power_resume"); + break; + } + } + + private void OnSessionSwitch(object? sender, SessionSwitchEventArgs e) + { + switch (e.Reason) + { + case SessionSwitchReason.SessionLock: + Console.WriteLine("Session lock detected, flushing pending stats."); + StatsManager.Instance.FlushPendingSave(); + break; + case SessionSwitchReason.SessionUnlock: + case SessionSwitchReason.ConsoleConnect: + case SessionSwitchReason.RemoteConnect: + ScheduleResumeRecovery($"session_{e.Reason}"); + break; + } + } + + private void ScheduleResumeRecovery(string trigger) + { + var nowUtc = DateTime.UtcNow; + if (nowUtc - _lastResumeRecoveryUtc < TimeSpan.FromSeconds(5)) + { + return; + } + + _lastResumeRecoveryUtc = nowUtc; + Dispatcher.BeginInvoke(new Action(() => RecoverAfterResume(trigger))); + } + + private void RecoverAfterResume(string trigger) + { + Console.WriteLine($"Running resume recovery triggered by {trigger}."); + + try + { + StatsManager.Instance.HandleSystemResume(); + } + catch (Exception ex) + { + Console.WriteLine($"Stats resume recovery failed: {ex}"); + } + + // HandleSystemResume 包含 Thread.Join / readyEvent.Wait 等阻塞操作, + // 不能在 Dispatcher 线程执行,否则 UI 会冻结 15-20 秒。 + Task.Run(() => + { + try + { + InputMonitorService.Instance.HandleSystemResume(); + } + catch (Exception ex) + { + Console.WriteLine($"Input monitor resume recovery failed: {ex}"); + } + }); + + try + { + RecreateTrayIntegration(); + } + catch (Exception ex) + { + Console.WriteLine($"Tray icon resume recovery failed: {ex}"); + } + } + + private void RecreateTrayIntegration() + { + if (_trayIconViewModel == null) + { + return; + } + + _trayContextMenuHost?.Dispose(); + _trayContextMenuHost = new TrayContextMenuHost(CreateContextMenu()); + + if (_trayIcon != null) + { + _trayIcon.MouseClick -= OnTrayIconMouseClick; + _trayIcon.Visible = false; + _trayIcon.Dispose(); + _trayIcon = null; + } + + _trayIcon = new Forms.NotifyIcon + { + Icon = _trayIconViewModel.TrayIcon, + Text = _trayIconViewModel.TooltipText, + Visible = true + }; + _trayIcon.MouseClick += OnTrayIconMouseClick; + } + + private void OnTrayIconMouseClick(object? sender, Forms.MouseEventArgs e) + { + if (e.Button == Forms.MouseButtons.Right) + { + Application.Current?.Dispatcher.BeginInvoke(new Action(() => + { + _trayContextMenuHost?.ShowAtCursor(); + })); + return; + } + + if (e.Button != Forms.MouseButtons.Left) + { + return; + } + + Console.WriteLine("NotifyIcon left click fired - showing stats"); + Task.Run(() => + { + try + { + TrackClick("tray_icon"); + } + catch + { + // Ignore analytics failures. + } + }); + var anchorPoint = Forms.Control.MousePosition; + Application.Current?.Dispatcher.BeginInvoke(new Action(() => + { + _trayIconViewModel?.ShowStats(anchorPoint); + })); + } + + private void OnTrayIconViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_trayIcon == null || _trayIconViewModel == null) + { + return; + } + + if (e.PropertyName == nameof(TrayIconViewModel.TrayIcon)) + { + _trayIcon.Icon = _trayIconViewModel.TrayIcon; + } + else if (e.PropertyName == nameof(TrayIconViewModel.TooltipText)) + { + _trayIcon.Text = _trayIconViewModel.TooltipText; + } + } + private sealed class TrayContextMenuHost : IDisposable { private readonly ContextMenu _menu; @@ -1013,4 +1151,39 @@ public HostWindow() } } } + + private sealed class TaskbarCreatedWatcher : Forms.NativeWindow, IDisposable + { + private readonly Action _taskbarCreatedHandler; + private readonly int _taskbarCreatedMessage; + + public TaskbarCreatedWatcher(Action taskbarCreatedHandler) + { + _taskbarCreatedHandler = taskbarCreatedHandler; + _taskbarCreatedMessage = unchecked((int)NativeInterop.RegisterWindowMessage("TaskbarCreated")); + + CreateHandle(new Forms.CreateParams + { + Caption = "KeyStatsTaskbarWatcher" + }); + } + + protected override void WndProc(ref Forms.Message m) + { + if (_taskbarCreatedMessage != 0 && m.Msg == _taskbarCreatedMessage) + { + _taskbarCreatedHandler(); + } + + base.WndProc(ref m); + } + + public void Dispose() + { + if (Handle != IntPtr.Zero) + { + DestroyHandle(); + } + } + } } diff --git a/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs b/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs index 6e7f18c..2caed99 100644 --- a/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs +++ b/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs @@ -177,6 +177,9 @@ public struct MSG [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetCursorPos(out POINT lpPoint); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern uint RegisterWindowMessage(string lpString); + [DllImport("dwmapi.dll", PreserveSig = true)] private static extern int DwmSetWindowAttribute(IntPtr hwnd, int dwAttribute, ref int pvAttribute, int cbAttribute); diff --git a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs index d0f4d7b..e2df29a 100644 --- a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs +++ b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs @@ -35,6 +35,9 @@ public class InputMonitorService : IDisposable private NativeInterop.POINT _lastCursorPos; private const int WatchdogIntervalMs = 3000; private const int HookDeadThresholdMs = 5000; + private const int HookInstallReadyTimeoutMs = 5000; + private const int HookReinstallRetryCount = 3; + private const int HookReinstallRetryDelayMs = 750; public event Action? KeyPressed; public event Action? LeftMouseClicked; @@ -53,43 +56,10 @@ public void StartMonitoring() _keyboardProc = KeyboardHookCallback; _mouseProc = MouseHookCallback; + ResetTransientState(); _lastMouseHookTick = Environment.TickCount; - - // 在专用线程上安装 hook 并运行消息循环,使 hook 回调不受 UI 线程阻塞影响 - var readyEvent = new ManualResetEventSlim(false); - Exception? hookError = null; - - _hookThread = new Thread(() => - { - try - { - _hookThreadId = NativeInterop.GetCurrentThreadId(); - InstallHooks(); - readyEvent.Set(); - - // 低级钩子需要消息循环来分发回调 - while (NativeInterop.GetMessage(out var msg, IntPtr.Zero, 0, 0) > 0) - { - NativeInterop.TranslateMessage(ref msg); - NativeInterop.DispatchMessage(ref msg); - } - } - catch (Exception ex) - { - hookError = ex; - readyEvent.Set(); - } - }); - _hookThread.IsBackground = true; - _hookThread.Name = "InputHookThread"; - _hookThread.Start(); - - readyEvent.Wait(); - if (hookError != null) - { - throw hookError; - } + StartHookThread(); _isMonitoring = true; @@ -161,8 +131,16 @@ private void ReinstallHooks() } _lastMouseHookTick = Environment.TickCount; + StartHookThread(); + ResetTransientState(); + Debug.WriteLine("Watchdog: hooks reinstalled"); + } + private void StartHookThread() + { + // 在专用线程上安装 hook 并运行消息循环,使 hook 回调不受 UI 线程阻塞影响 var readyEvent = new ManualResetEventSlim(false); + Exception? hookError = null; _hookThread = new Thread(() => { @@ -172,6 +150,7 @@ private void ReinstallHooks() InstallHooks(); readyEvent.Set(); + // 低级钩子需要消息循环来分发回调 while (NativeInterop.GetMessage(out var msg, IntPtr.Zero, 0, 0) > 0) { NativeInterop.TranslateMessage(ref msg); @@ -180,7 +159,7 @@ private void ReinstallHooks() } catch (Exception ex) { - Debug.WriteLine($"Watchdog: hook reinstall failed: {ex.Message}"); + hookError = ex; readyEvent.Set(); } }); @@ -188,8 +167,16 @@ private void ReinstallHooks() _hookThread.Name = "InputHookThread"; _hookThread.Start(); - readyEvent.Wait(); - Debug.WriteLine("Watchdog: hooks reinstalled"); + if (!readyEvent.Wait(HookInstallReadyTimeoutMs)) + { + TryStopHookThread(); + throw new TimeoutException($"Timed out waiting {HookInstallReadyTimeoutMs}ms for hook installation."); + } + + if (hookError != null) + { + throw hookError; + } } private void WatchdogCallback(object? state) @@ -199,8 +186,9 @@ private void WatchdogCallback(object? state) NativeInterop.GetCursorPos(out var currentPos); var cursorMoved = currentPos.x != _lastCursorPos.x || currentPos.y != _lastCursorPos.y; _lastCursorPos = currentPos; + var keyboardActivity = HasRecentKeyboardActivity(); - if (!cursorMoved) return; + if (!cursorMoved && !keyboardActivity) return; // 光标在移动,但 hook 回调长时间未被触发 → hook 可能已被 Windows 静默移除 var elapsed = unchecked((uint)(Environment.TickCount - Volatile.Read(ref _lastMouseHookTick))); @@ -211,10 +199,14 @@ private void WatchdogCallback(object? state) return; } - Debug.WriteLine($"Watchdog: mouse hook appears dead (no callback for {elapsed}ms), reinstalling..."); + Debug.WriteLine($"Watchdog: hook appears dead (no callback for {elapsed}ms), reinstalling..."); try { - ReinstallHooks(); + RetryHookRecovery("watchdog"); + } + catch (Exception ex) + { + Debug.WriteLine($"Watchdog: recovery failed after retries. {ex.Message}"); } finally { @@ -231,12 +223,7 @@ public void StopMonitoring() _watchdogTimer = null; // 终止 hook 线程的消息循环 - if (_hookThreadId != 0) - { - NativeInterop.PostThreadMessage(_hookThreadId, NativeInterop.WM_QUIT, IntPtr.Zero, IntPtr.Zero); - } - - _hookThread?.Join(2000); + TryStopHookThread(); if (_keyboardHookId != IntPtr.Zero) { @@ -250,7 +237,7 @@ public void StopMonitoring() _mouseHookId = IntPtr.Zero; } - _pressedKeys.Clear(); + ResetTransientState(); _isMonitoring = false; Debug.WriteLine("Input monitoring stopped"); @@ -266,24 +253,30 @@ private IntPtr KeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam) if (message == NativeInterop.WM_KEYDOWN || message == NativeInterop.WM_SYSKEYDOWN) { - if (!_pressedKeys.Contains(vkCode)) + lock (_pressedKeys) { - _pressedKeys.Add(vkCode); - // GetKeyName 需在 hook 回调中同步调用以准确获取修饰键状态 - var keyName = KeyNameMapper.GetKeyName(vkCode); - // 捕获前台窗口句柄和进程 ID(轻量 P/Invoke),完整解析异步进行 - var hWnd = NativeInterop.GetForegroundWindow(); - NativeInterop.GetWindowThreadProcessId(hWnd, out uint pid); - ThreadPool.QueueUserWorkItem(_ => + if (!_pressedKeys.Contains(vkCode)) { - var activeApp = ActiveWindowManager.ResolveAppInfo(hWnd, pid); - KeyPressed?.Invoke(keyName, activeApp.AppName, activeApp.DisplayName); - }); + _pressedKeys.Add(vkCode); + // GetKeyName 需在 hook 回调中同步调用以准确获取修饰键状态 + var keyName = KeyNameMapper.GetKeyName(vkCode); + // 捕获前台窗口句柄和进程 ID(轻量 P/Invoke),完整解析异步进行 + var hWnd = NativeInterop.GetForegroundWindow(); + NativeInterop.GetWindowThreadProcessId(hWnd, out uint pid); + ThreadPool.QueueUserWorkItem(_ => + { + var activeApp = ActiveWindowManager.ResolveAppInfo(hWnd, pid); + KeyPressed?.Invoke(keyName, activeApp.AppName, activeApp.DisplayName); + }); + } } } else if (message == NativeInterop.WM_KEYUP || message == NativeInterop.WM_SYSKEYUP) { - _pressedKeys.Remove(vkCode); + lock (_pressedKeys) + { + _pressedKeys.Remove(vkCode); + } } } @@ -422,6 +415,111 @@ public void ResetLastMousePosition() _accumulatedDistance = 0.0; } + public void HandleSystemResume() + { + if (!_isMonitoring) + { + StartMonitoring(); + return; + } + + if (Interlocked.CompareExchange(ref _isReinstallingHooks, 1, 0) != 0) + { + return; + } + + try + { + Debug.WriteLine("Handling system resume: refreshing hooks and transient state."); + RetryHookRecovery("resume"); + } + catch (Exception ex) + { + Debug.WriteLine($"Resume recovery failed after retries. {ex.Message}"); + } + finally + { + Volatile.Write(ref _isReinstallingHooks, 0); + } + } + + private void RestartMonitoring() + { + StopMonitoring(); + StartMonitoring(); + } + + private void RetryHookRecovery(string reason) + { + Exception? lastError = null; + + for (var attempt = 1; attempt <= HookReinstallRetryCount; attempt++) + { + try + { + Debug.WriteLine($"Hook recovery attempt {attempt}/{HookReinstallRetryCount} ({reason})."); + ReinstallHooks(); + return; + } + catch (Exception ex) + { + lastError = ex; + Debug.WriteLine($"Hook recovery attempt {attempt} failed: {ex.Message}"); + if (attempt < HookReinstallRetryCount) + { + Thread.Sleep(HookReinstallRetryDelayMs); + } + } + } + + Debug.WriteLine("Hook reinstall retries exhausted, restarting monitoring."); + RestartMonitoring(); + + if (!_isMonitoring) + { + throw lastError ?? new InvalidOperationException("Hook recovery failed and monitoring did not restart."); + } + } + + private bool HasRecentKeyboardActivity() + { + for (var vk = 0x08; vk <= 0xFE; vk++) + { + var state = NativeInterop.GetAsyncKeyState(vk); + if ((state & 0x0001) != 0 || (state & 0x8000) != 0) + { + return true; + } + } + + return false; + } + + private void TryStopHookThread() + { + var threadId = _hookThreadId; + if (threadId != 0) + { + NativeInterop.PostThreadMessage(threadId, NativeInterop.WM_QUIT, IntPtr.Zero, IntPtr.Zero); + } + + _hookThread?.Join(2000); + _hookThread = null; + _hookThreadId = 0; + } + + private void ResetTransientState() + { + lock (_pressedKeys) + { + _pressedKeys.Clear(); + } + + _lastMousePosition = null; + _accumulatedDistance = 0.0; + _lastMouseSampleTime = DateTime.MinValue; + } + public void Dispose() { StopMonitoring(); diff --git a/KeyStats.Windows/KeyStats/Services/StatsManager.cs b/KeyStats.Windows/KeyStats/Services/StatsManager.cs index d8f6747..7a1c4bf 100644 --- a/KeyStats.Windows/KeyStats/Services/StatsManager.cs +++ b/KeyStats.Windows/KeyStats/Services/StatsManager.cs @@ -66,12 +66,7 @@ private StatsManager() Settings = LoadSettings(); History = LoadHistory(); CurrentStats = LoadStats() ?? new DailyStats(); - - // Check if stats are from today - if (CurrentStats.Date.Date != DateTime.Today) - { - CurrentStats = new DailyStats(); - } + SynchronizeCurrentDay(DateTime.Today, notifyStatsUpdate: false); UpdateNotificationBaselines(); SaveStats(); @@ -261,10 +256,7 @@ private void OnMouseScrolled(double distance, string appName, string displayName private void EnsureCurrentDay() { - if (CurrentStats.Date.Date != DateTime.Today) - { - ResetStats(DateTime.Today); - } + SynchronizeCurrentDay(DateTime.Today, notifyStatsUpdate: true); } private void ScheduleSave() @@ -876,20 +868,17 @@ private void ScheduleNextMidnightReset() private void PerformMidnightReset() { - var now = DateTime.Now; - if (CurrentStats.Date.Date != now.Date) - { - ResetStats(now); - } - Dictionary historySnapshot; - lock (_lock) - { - historySnapshot = CloneHistorySnapshot(History); - } - SaveHistorySnapshot(historySnapshot); + SynchronizeCurrentDay(DateTime.Now, notifyStatsUpdate: true); ScheduleNextMidnightReset(); } + public void HandleSystemResume() + { + SynchronizeCurrentDay(DateTime.Now, notifyStatsUpdate: true); + ScheduleNextMidnightReset(); + SaveStats(); + } + public void ResetStats() { ResetStats(DateTime.Today); @@ -914,6 +903,58 @@ private void ResetStats(DateTime date) SaveStats(); } + private void SynchronizeCurrentDay(DateTime targetDate, bool notifyStatsUpdate) + { + var normalizedTargetDate = targetDate.Date; + Dictionary? historySnapshot = null; + var changed = false; + + lock (_lock) + { + var currentDate = CurrentStats.Date.Date; + if (currentDate == normalizedTargetDate) + { + return; + } + + History[currentDate.ToString("yyyy-MM-dd")] = CloneDailyStats(CurrentStats, currentDate); + + if (currentDate < normalizedTargetDate) + { + for (var missingDate = currentDate.AddDays(1); missingDate < normalizedTargetDate; missingDate = missingDate.AddDays(1)) + { + var key = missingDate.ToString("yyyy-MM-dd"); + if (!History.ContainsKey(key)) + { + History[key] = new DailyStats(missingDate); + } + } + } + + CurrentStats = new DailyStats(normalizedTargetDate); + historySnapshot = CloneHistorySnapshot(History); + UpdateNotificationBaselines(); + changed = true; + } + + if (historySnapshot != null) + { + SaveHistorySnapshot(historySnapshot); + } + + if (!changed) + { + return; + } + + if (notifyStatsUpdate) + { + NotifyStatsUpdate(); + } + + SaveStats(); + } + #endregion #region Notifications From 1ad802d5634097f497ffa143e545367b24acdc7b Mon Sep 17 00:00:00 2001 From: pipizhu Date: Fri, 3 Apr 2026 15:33:44 +0800 Subject: [PATCH 3/7] fix(windows): address PR review feedback for resume recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename _lastMouseHookTick → _lastHookCallbackTick, update in keyboard hook callback too (prevents false watchdog reinstalls during keyboard-only use) - Move StatsManager.HandleSystemResume to Task.Run (avoids blocking UI thread with sync file I/O) - Remove redundant SaveStats/SaveHistorySnapshot calls in HandleSystemResume and SynchronizeCurrentDay - Fix constructor double-save: only call UpdateNotificationBaselines+SaveStats when date hasn't changed - Add _midnightTimerLock to prevent race between resume recovery and midnight timer callback - Clean up hook thread on StartHookThread failure, use 'using' for ManualResetEventSlim --- KeyStats.Windows/KeyStats/App.xaml.cs | 20 ++++----- .../KeyStats/Services/InputMonitorService.cs | 17 ++++---- .../KeyStats/Services/StatsManager.cs | 41 +++++++++++-------- 3 files changed, 41 insertions(+), 37 deletions(-) diff --git a/KeyStats.Windows/KeyStats/App.xaml.cs b/KeyStats.Windows/KeyStats/App.xaml.cs index 4349fe7..ee8826e 100644 --- a/KeyStats.Windows/KeyStats/App.xaml.cs +++ b/KeyStats.Windows/KeyStats/App.xaml.cs @@ -900,19 +900,17 @@ private void RecoverAfterResume(string trigger) { Console.WriteLine($"Running resume recovery triggered by {trigger}."); - try - { - StatsManager.Instance.HandleSystemResume(); - } - catch (Exception ex) - { - Console.WriteLine($"Stats resume recovery failed: {ex}"); - } - - // HandleSystemResume 包含 Thread.Join / readyEvent.Wait 等阻塞操作, - // 不能在 Dispatcher 线程执行,否则 UI 会冻结 15-20 秒。 Task.Run(() => { + try + { + StatsManager.Instance.HandleSystemResume(); + } + catch (Exception ex) + { + Console.WriteLine($"Stats resume recovery failed: {ex}"); + } + try { InputMonitorService.Instance.HandleSystemResume(); diff --git a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs index e2df29a..d3d27ce 100644 --- a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs +++ b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs @@ -30,7 +30,7 @@ public class InputMonitorService : IDisposable // hook 健康检查:watchdog 定时检测 hook 是否被 Windows 静默移除 private Timer? _watchdogTimer; - private int _lastMouseHookTick; + private int _lastHookCallbackTick; private int _isReinstallingHooks; private NativeInterop.POINT _lastCursorPos; private const int WatchdogIntervalMs = 3000; @@ -58,7 +58,7 @@ public void StartMonitoring() _mouseProc = MouseHookCallback; ResetTransientState(); - _lastMouseHookTick = Environment.TickCount; + _lastHookCallbackTick = Environment.TickCount; StartHookThread(); _isMonitoring = true; @@ -130,7 +130,7 @@ private void ReinstallHooks() _mouseHookId = IntPtr.Zero; } - _lastMouseHookTick = Environment.TickCount; + _lastHookCallbackTick = Environment.TickCount; StartHookThread(); ResetTransientState(); Debug.WriteLine("Watchdog: hooks reinstalled"); @@ -138,8 +138,7 @@ private void ReinstallHooks() private void StartHookThread() { - // 在专用线程上安装 hook 并运行消息循环,使 hook 回调不受 UI 线程阻塞影响 - var readyEvent = new ManualResetEventSlim(false); + using var readyEvent = new ManualResetEventSlim(false); Exception? hookError = null; _hookThread = new Thread(() => @@ -150,7 +149,6 @@ private void StartHookThread() InstallHooks(); readyEvent.Set(); - // 低级钩子需要消息循环来分发回调 while (NativeInterop.GetMessage(out var msg, IntPtr.Zero, 0, 0) > 0) { NativeInterop.TranslateMessage(ref msg); @@ -175,6 +173,7 @@ private void StartHookThread() if (hookError != null) { + TryStopHookThread(); throw hookError; } } @@ -191,7 +190,7 @@ private void WatchdogCallback(object? state) if (!cursorMoved && !keyboardActivity) return; // 光标在移动,但 hook 回调长时间未被触发 → hook 可能已被 Windows 静默移除 - var elapsed = unchecked((uint)(Environment.TickCount - Volatile.Read(ref _lastMouseHookTick))); + var elapsed = unchecked((uint)(Environment.TickCount - Volatile.Read(ref _lastHookCallbackTick))); if (elapsed > HookDeadThresholdMs) { if (Interlocked.CompareExchange(ref _isReinstallingHooks, 1, 0) != 0) @@ -247,6 +246,8 @@ private IntPtr KeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam) { if (nCode >= 0) { + Interlocked.Exchange(ref _lastHookCallbackTick, Environment.TickCount); + var message = (int)wParam; var hookStruct = Marshal.PtrToStructure(lParam); var vkCode = (int)hookStruct.vkCode; @@ -287,7 +288,7 @@ private IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam) { if (nCode >= 0) { - Interlocked.Exchange(ref _lastMouseHookTick, Environment.TickCount); + Interlocked.Exchange(ref _lastHookCallbackTick, Environment.TickCount); var message = (int)wParam; var hookStruct = Marshal.PtrToStructure(lParam); diff --git a/KeyStats.Windows/KeyStats/Services/StatsManager.cs b/KeyStats.Windows/KeyStats/Services/StatsManager.cs index 7a1c4bf..f5aedb5 100644 --- a/KeyStats.Windows/KeyStats/Services/StatsManager.cs +++ b/KeyStats.Windows/KeyStats/Services/StatsManager.cs @@ -66,10 +66,16 @@ private StatsManager() Settings = LoadSettings(); History = LoadHistory(); CurrentStats = LoadStats() ?? new DailyStats(); - SynchronizeCurrentDay(DateTime.Today, notifyStatsUpdate: false); - UpdateNotificationBaselines(); - SaveStats(); + if (CurrentStats.Date.Date != DateTime.Today) + { + SynchronizeCurrentDay(DateTime.Today, notifyStatsUpdate: false); + } + else + { + UpdateNotificationBaselines(); + SaveStats(); + } SetupMidnightReset(); SetupInputMonitor(); @@ -851,19 +857,24 @@ private void SetupMidnightReset() ScheduleNextMidnightReset(); } + private readonly object _midnightTimerLock = new(); + private void ScheduleNextMidnightReset() { - _midnightTimer?.Stop(); - _midnightTimer?.Dispose(); + lock (_midnightTimerLock) + { + _midnightTimer?.Stop(); + _midnightTimer?.Dispose(); - var now = DateTime.Now; - var nextMidnight = DateTime.Today.AddDays(1); - var timeUntilMidnight = nextMidnight - now; + var now = DateTime.Now; + var nextMidnight = DateTime.Today.AddDays(1); + var timeUntilMidnight = nextMidnight - now; - _midnightTimer = new Timer(timeUntilMidnight.TotalMilliseconds); - _midnightTimer.Elapsed += (_, _) => PerformMidnightReset(); - _midnightTimer.AutoReset = false; - _midnightTimer.Start(); + _midnightTimer = new Timer(timeUntilMidnight.TotalMilliseconds); + _midnightTimer.Elapsed += (_, _) => PerformMidnightReset(); + _midnightTimer.AutoReset = false; + _midnightTimer.Start(); + } } private void PerformMidnightReset() @@ -876,7 +887,6 @@ public void HandleSystemResume() { SynchronizeCurrentDay(DateTime.Now, notifyStatsUpdate: true); ScheduleNextMidnightReset(); - SaveStats(); } public void ResetStats() @@ -937,11 +947,6 @@ private void SynchronizeCurrentDay(DateTime targetDate, bool notifyStatsUpdate) changed = true; } - if (historySnapshot != null) - { - SaveHistorySnapshot(historySnapshot); - } - if (!changed) { return; From b5f4ccd25b4d5e58dd0112ce192cff316e678f29 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Fri, 3 Apr 2026 16:03:52 +0800 Subject: [PATCH 4/7] fix(windows): address PR #94 review feedback - Remove dead historySnapshot variable from SynchronizeCurrentDay - Use TryStopHookThread in ReinstallHooks for consistent thread cleanup - Narrow _pressedKeys lock scope: only guard HashSet.Add/Remove, keep GetKeyName and P/Invoke outside - Replace DateTime _lastResumeRecoveryUtc with long ticks + Interlocked for thread-safe debounce - Add null safety to jq result length in dashboard_fetch.sh --- .../scripts/dashboard_fetch.sh | 2 +- KeyStats.Windows/KeyStats/App.xaml.cs | 9 ++--- .../KeyStats/Services/InputMonitorService.cs | 36 ++++++++----------- .../KeyStats/Services/StatsManager.cs | 2 -- 4 files changed, 20 insertions(+), 29 deletions(-) diff --git a/.agents/skills/posthog-cli-queries/scripts/dashboard_fetch.sh b/.agents/skills/posthog-cli-queries/scripts/dashboard_fetch.sh index 1d0f3f9..b12a980 100755 --- a/.agents/skills/posthog-cli-queries/scripts/dashboard_fetch.sh +++ b/.agents/skills/posthog-cli-queries/scripts/dashboard_fetch.sh @@ -46,7 +46,7 @@ if [[ "${mode}" == "--summary" ]]; then ), (.insight.query.source.kind // ""), (.insight.query.source.interval // ""), - ((.insight.result | length) | tostring), + ((.insight.result // [] | length) | tostring), (.last_refresh // .insight.last_refresh // "") ] | @tsv diff --git a/KeyStats.Windows/KeyStats/App.xaml.cs b/KeyStats.Windows/KeyStats/App.xaml.cs index ee8826e..d80e11c 100644 --- a/KeyStats.Windows/KeyStats/App.xaml.cs +++ b/KeyStats.Windows/KeyStats/App.xaml.cs @@ -37,7 +37,7 @@ public partial class App : System.Windows.Application private System.Threading.Mutex? _singleInstanceMutex; private string? _appVersion; private IPostHogAnalytics? _postHogClient; - private DateTime _lastResumeRecoveryUtc = DateTime.MinValue; + private long _lastResumeRecoveryTicks; protected override void OnStartup(StartupEventArgs e) { @@ -886,13 +886,14 @@ private void OnSessionSwitch(object? sender, SessionSwitchEventArgs e) private void ScheduleResumeRecovery(string trigger) { - var nowUtc = DateTime.UtcNow; - if (nowUtc - _lastResumeRecoveryUtc < TimeSpan.FromSeconds(5)) + var nowTicks = DateTime.UtcNow.Ticks; + var lastTicks = Interlocked.Read(ref _lastResumeRecoveryTicks); + if (nowTicks - lastTicks < TimeSpan.FromSeconds(5).Ticks) { return; } - _lastResumeRecoveryUtc = nowUtc; + Interlocked.Exchange(ref _lastResumeRecoveryTicks, nowTicks); Dispatcher.BeginInvoke(new Action(() => RecoverAfterResume(trigger))); } diff --git a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs index d3d27ce..2403686 100644 --- a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs +++ b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs @@ -109,16 +109,8 @@ private void ReinstallHooks() { Debug.WriteLine("Watchdog: reinstalling hooks..."); - // 在 hook 线程上卸载旧 hook 并重新安装 - if (_hookThreadId != 0) - { - // 终止旧的消息循环 - NativeInterop.PostThreadMessage(_hookThreadId, NativeInterop.WM_QUIT, IntPtr.Zero, IntPtr.Zero); - } - - _hookThread?.Join(2000); + TryStopHookThread(); - // 清理可能残留的 hook handle if (_keyboardHookId != IntPtr.Zero) { NativeInterop.UnhookWindowsHookEx(_keyboardHookId); @@ -254,22 +246,22 @@ private IntPtr KeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam) if (message == NativeInterop.WM_KEYDOWN || message == NativeInterop.WM_SYSKEYDOWN) { + bool isNewKey; lock (_pressedKeys) { - if (!_pressedKeys.Contains(vkCode)) + isNewKey = _pressedKeys.Add(vkCode); + } + + if (isNewKey) + { + var keyName = KeyNameMapper.GetKeyName(vkCode); + var hWnd = NativeInterop.GetForegroundWindow(); + NativeInterop.GetWindowThreadProcessId(hWnd, out uint pid); + ThreadPool.QueueUserWorkItem(_ => { - _pressedKeys.Add(vkCode); - // GetKeyName 需在 hook 回调中同步调用以准确获取修饰键状态 - var keyName = KeyNameMapper.GetKeyName(vkCode); - // 捕获前台窗口句柄和进程 ID(轻量 P/Invoke),完整解析异步进行 - var hWnd = NativeInterop.GetForegroundWindow(); - NativeInterop.GetWindowThreadProcessId(hWnd, out uint pid); - ThreadPool.QueueUserWorkItem(_ => - { - var activeApp = ActiveWindowManager.ResolveAppInfo(hWnd, pid); - KeyPressed?.Invoke(keyName, activeApp.AppName, activeApp.DisplayName); - }); - } + var activeApp = ActiveWindowManager.ResolveAppInfo(hWnd, pid); + KeyPressed?.Invoke(keyName, activeApp.AppName, activeApp.DisplayName); + }); } } else if (message == NativeInterop.WM_KEYUP || message == NativeInterop.WM_SYSKEYUP) diff --git a/KeyStats.Windows/KeyStats/Services/StatsManager.cs b/KeyStats.Windows/KeyStats/Services/StatsManager.cs index f5aedb5..fff3605 100644 --- a/KeyStats.Windows/KeyStats/Services/StatsManager.cs +++ b/KeyStats.Windows/KeyStats/Services/StatsManager.cs @@ -916,7 +916,6 @@ private void ResetStats(DateTime date) private void SynchronizeCurrentDay(DateTime targetDate, bool notifyStatsUpdate) { var normalizedTargetDate = targetDate.Date; - Dictionary? historySnapshot = null; var changed = false; lock (_lock) @@ -942,7 +941,6 @@ private void SynchronizeCurrentDay(DateTime targetDate, bool notifyStatsUpdate) } CurrentStats = new DailyStats(normalizedTargetDate); - historySnapshot = CloneHistorySnapshot(History); UpdateNotificationBaselines(); changed = true; } From 1ed90b914515f2cc4ba16eae7486cf85936bca6a Mon Sep 17 00:00:00 2001 From: pipizhu Date: Fri, 3 Apr 2026 16:30:05 +0800 Subject: [PATCH 5/7] fix(windows): harden resume recovery edge cases --- KeyStats.Windows/KeyStats/App.xaml.cs | 18 +++++++-- .../KeyStats/Services/InputMonitorService.cs | 39 ++++++++++++++++--- .../KeyStats/Services/StatsManager.cs | 13 +++++-- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/KeyStats.Windows/KeyStats/App.xaml.cs b/KeyStats.Windows/KeyStats/App.xaml.cs index d80e11c..baf1360 100644 --- a/KeyStats.Windows/KeyStats/App.xaml.cs +++ b/KeyStats.Windows/KeyStats/App.xaml.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Runtime.InteropServices; +using System.Threading; using System.Windows; using System.Windows.Data; using System.Threading.Tasks; @@ -887,13 +888,22 @@ private void OnSessionSwitch(object? sender, SessionSwitchEventArgs e) private void ScheduleResumeRecovery(string trigger) { var nowTicks = DateTime.UtcNow.Ticks; - var lastTicks = Interlocked.Read(ref _lastResumeRecoveryTicks); - if (nowTicks - lastTicks < TimeSpan.FromSeconds(5).Ticks) + var debounceWindowTicks = TimeSpan.FromSeconds(5).Ticks; + + while (true) { - return; + var lastTicks = Interlocked.Read(ref _lastResumeRecoveryTicks); + if (nowTicks - lastTicks < debounceWindowTicks) + { + return; + } + + if (Interlocked.CompareExchange(ref _lastResumeRecoveryTicks, nowTicks, lastTicks) == lastTicks) + { + break; + } } - Interlocked.Exchange(ref _lastResumeRecoveryTicks, nowTicks); Dispatcher.BeginInvoke(new Action(() => RecoverAfterResume(trigger))); } diff --git a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs index 2403686..6b4c23c 100644 --- a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs +++ b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs @@ -36,6 +36,7 @@ public class InputMonitorService : IDisposable private const int WatchdogIntervalMs = 3000; private const int HookDeadThresholdMs = 5000; private const int HookInstallReadyTimeoutMs = 5000; + private const int HookThreadStopTimeoutMs = 2000; private const int HookReinstallRetryCount = 3; private const int HookReinstallRetryDelayMs = 750; @@ -109,7 +110,10 @@ private void ReinstallHooks() { Debug.WriteLine("Watchdog: reinstalling hooks..."); - TryStopHookThread(); + if (!TryStopHookThread()) + { + throw new InvalidOperationException("Existing hook thread did not stop within the timeout."); + } if (_keyboardHookId != IntPtr.Zero) { @@ -159,13 +163,21 @@ private void StartHookThread() if (!readyEvent.Wait(HookInstallReadyTimeoutMs)) { - TryStopHookThread(); + if (!TryStopHookThread()) + { + throw new TimeoutException($"Timed out waiting {HookInstallReadyTimeoutMs}ms for hook installation, and the hook thread did not stop within {HookThreadStopTimeoutMs}ms."); + } + throw new TimeoutException($"Timed out waiting {HookInstallReadyTimeoutMs}ms for hook installation."); } if (hookError != null) { - TryStopHookThread(); + if (!TryStopHookThread()) + { + throw new InvalidOperationException($"Hook installation failed and the hook thread did not stop within {HookThreadStopTimeoutMs}ms.", hookError); + } + throw hookError; } } @@ -214,7 +226,10 @@ public void StopMonitoring() _watchdogTimer = null; // 终止 hook 线程的消息循环 - TryStopHookThread(); + if (!TryStopHookThread()) + { + throw new InvalidOperationException("Failed to stop the hook thread within the timeout."); + } if (_keyboardHookId != IntPtr.Zero) { @@ -488,17 +503,29 @@ private bool HasRecentKeyboardActivity() return false; } - private void TryStopHookThread() + private bool TryStopHookThread() { + var thread = _hookThread; var threadId = _hookThreadId; + if (thread == null && threadId == 0) + { + return true; + } + if (threadId != 0) { NativeInterop.PostThreadMessage(threadId, NativeInterop.WM_QUIT, IntPtr.Zero, IntPtr.Zero); } - _hookThread?.Join(2000); + if (thread != null && !thread.Join(HookThreadStopTimeoutMs)) + { + Debug.WriteLine($"Hook thread did not exit within {HookThreadStopTimeoutMs}ms."); + return false; + } + _hookThread = null; _hookThreadId = 0; + return true; } private void ResetTransientState() diff --git a/KeyStats.Windows/KeyStats/Services/StatsManager.cs b/KeyStats.Windows/KeyStats/Services/StatsManager.cs index fff3605..0b10056 100644 --- a/KeyStats.Windows/KeyStats/Services/StatsManager.cs +++ b/KeyStats.Windows/KeyStats/Services/StatsManager.cs @@ -38,6 +38,7 @@ public enum StatsUpdateKind private readonly double _saveInterval = 2000; // 2 seconds private readonly double _statsUpdateDebounceInterval = 300; // 0.3 seconds private readonly double _mouseMoveIdleUpdateInterval = 350; // 0.35 seconds + private const int MaxMissingDayBackfillDays = 31; private bool _pendingSave; private bool _pendingStatsUpdate; private bool _pendingMouseMoveUpdate; @@ -930,12 +931,16 @@ private void SynchronizeCurrentDay(DateTime targetDate, bool notifyStatsUpdate) if (currentDate < normalizedTargetDate) { - for (var missingDate = currentDate.AddDays(1); missingDate < normalizedTargetDate; missingDate = missingDate.AddDays(1)) + var missingDayCount = (normalizedTargetDate - currentDate).Days - 1; + if (missingDayCount > 0 && missingDayCount <= MaxMissingDayBackfillDays) { - var key = missingDate.ToString("yyyy-MM-dd"); - if (!History.ContainsKey(key)) + for (var missingDate = currentDate.AddDays(1); missingDate < normalizedTargetDate; missingDate = missingDate.AddDays(1)) { - History[key] = new DailyStats(missingDate); + var key = missingDate.ToString("yyyy-MM-dd"); + if (!History.ContainsKey(key)) + { + History[key] = new DailyStats(missingDate); + } } } } From f70b157a67defbbc77a3c984a1b1175104947ed9 Mon Sep 17 00:00:00 2001 From: pipizhu Date: Fri, 3 Apr 2026 16:32:03 +0800 Subject: [PATCH 6/7] fix(windows): close hook thread startup races --- .../KeyStats/Services/InputMonitorService.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs index 6b4c23c..7773de9 100644 --- a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs +++ b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading; +using System.Threading.Tasks; using KeyStats.Helpers; namespace KeyStats.Services; @@ -134,8 +135,7 @@ private void ReinstallHooks() private void StartHookThread() { - using var readyEvent = new ManualResetEventSlim(false); - Exception? hookError = null; + var hookStartup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _hookThread = new Thread(() => { @@ -143,7 +143,7 @@ private void StartHookThread() { _hookThreadId = NativeInterop.GetCurrentThreadId(); InstallHooks(); - readyEvent.Set(); + hookStartup.TrySetResult(null); while (NativeInterop.GetMessage(out var msg, IntPtr.Zero, 0, 0) > 0) { @@ -153,15 +153,14 @@ private void StartHookThread() } catch (Exception ex) { - hookError = ex; - readyEvent.Set(); + hookStartup.TrySetResult(ex); } }); _hookThread.IsBackground = true; _hookThread.Name = "InputHookThread"; _hookThread.Start(); - if (!readyEvent.Wait(HookInstallReadyTimeoutMs)) + if (!hookStartup.Task.Wait(HookInstallReadyTimeoutMs)) { if (!TryStopHookThread()) { @@ -171,6 +170,7 @@ private void StartHookThread() throw new TimeoutException($"Timed out waiting {HookInstallReadyTimeoutMs}ms for hook installation."); } + var hookError = hookStartup.Task.Result; if (hookError != null) { if (!TryStopHookThread()) @@ -517,6 +517,12 @@ private bool TryStopHookThread() NativeInterop.PostThreadMessage(threadId, NativeInterop.WM_QUIT, IntPtr.Zero, IntPtr.Zero); } + if (thread == null) + { + Debug.WriteLine("Hook thread reference is missing while thread id is still set; keeping thread state for a later retry."); + return false; + } + if (thread != null && !thread.Join(HookThreadStopTimeoutMs)) { Debug.WriteLine($"Hook thread did not exit within {HookThreadStopTimeoutMs}ms."); From 08e3e355d1ae7aba94124c3c610015efbe763d03 Mon Sep 17 00:00:00 2001 From: pipizhu <62830430+debugtheworldbot@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:47:44 +0800 Subject: [PATCH 7/7] Update KeyStats.Windows/KeyStats/Services/InputMonitorService.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- KeyStats.Windows/KeyStats/Services/InputMonitorService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs index 7773de9..ad03d0a 100644 --- a/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs +++ b/KeyStats.Windows/KeyStats/Services/InputMonitorService.cs @@ -228,7 +228,7 @@ public void StopMonitoring() // 终止 hook 线程的消息循环 if (!TryStopHookThread()) { - throw new InvalidOperationException("Failed to stop the hook thread within the timeout."); + Debug.WriteLine("Failed to stop the hook thread within the timeout. Continuing shutdown cleanup."); } if (_keyboardHookId != IntPtr.Zero)