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
12 changes: 12 additions & 0 deletions KeyStats.Windows/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,18 @@ powershell -ExecutionPolicy Bypass -File .\build.ps1 -Configuration Release
- Git commit messages must follow Conventional Commits, for example `feat: ...`, `fix: ...`, `docs: ...`, `refactor: ...`, `test: ...`, or `chore: ...`.
- When adding a new page/window/popup, add matching analytics in the same change: emit a `pageview` for the page and `click` events for key entry/actions, using the shared tracking helpers and stable cross-platform names.

## Window Material Rules

- Prefer the shared DWM backdrop path for all app windows. Use `KeyStats/Helpers/WindowBackdropHelper.cs` rather than per-window ad hoc interop.
- Match the current visual baseline: regular app windows, dialogs, and popup-like windows should use `NativeInterop.DwmSystemBackdropType.TransientWindow` unless there is a deliberate reason to diverge.
- Do not rely on `AllowsTransparency="True"` for main app windows or custom dialogs when the goal is Acrylic-like blur. Prefer `AllowsTransparency="False"` plus DWM backdrop.
- For windows using backdrop, extend the frame into the client area and keep the WPF composition background transparent via the shared helper. Do not reimplement this differently per window unless required.
- Use `WindowSurfaceBrush` for top-level window backgrounds and keep it semi-transparent so the backdrop remains visible.
- Use semi-transparent tint layers for internal cards and panels (`CardBrush`, `SubtleFillBrush`). Do not use opaque white or opaque dark fills for surfaces that are meant to sit above a backdrop.
- Treat popup parity as a design requirement: if a new window is intended to feel like the tray stats popup, align its backdrop type, top-level tint, and card translucency with the popup implementation.
- When adjusting translucency, change shared theme resources in `App.xaml` and `KeyStats/Helpers/ThemeManager.cs` first, instead of hardcoding per-window colors.
- After changing window materials, manually verify at least one light-theme and one dark-theme window, because small alpha changes can collapse contrast or make the backdrop disappear visually.

## High-Risk Files (Review Carefully)

- `KeyStats/Services/InputMonitorService.cs`
Expand Down
76 changes: 76 additions & 0 deletions KeyStats.Windows/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

KeyStats for Windows — a system tray app that tracks keyboard and mouse usage statistics. Ported from the macOS version. Built with WPF + WinForms interop on .NET Framework 4.8 (`net48`), C# with nullable enabled, LangVersion 10.0.

## Build Commands

```bash
# Debug build
dotnet build KeyStats/KeyStats.csproj -c Debug

# Release build
dotnet build KeyStats/KeyStats.csproj -c Release

# Package for distribution (produces dist/KeyStats-Windows-<version>.zip)
powershell -ExecutionPolicy Bypass -File ./build.ps1 -Configuration Release
```

There are no automated tests in this project. Validation is manual (see AGENTS.md for checklist).

## Architecture

**Entry point**: `App.xaml.cs` — single-instance (mutex guard), initializes theme, services, tray icon, context menu, and all windows. Handles import/export. Exit sequence: analytics flush → monitor stop → stats flush → cleanup.

**Core singleton services** (all in `Services/`):
- `InputMonitorService` — global `WH_KEYBOARD_LL` / `WH_MOUSE_LL` hooks via `SetWindowsHookEx`. Emits events for key/click/move/scroll. Mouse sampled at 30 FPS, jumps >500px filtered.
- `StatsManager` — central aggregation, persistence, formatting, history queries. Receives events from InputMonitorService. Debounced save (2s) and UI update (300ms). Midnight rollover timer. Import/export with merge/overwrite modes.
- `NotificationService` — Windows toast notifications via Microsoft.Toolkit.Uwp.Notifications.
- `StartupManager` — manages `HKCU\...\Run` registry entry.

**UI pattern**: light MVVM (View + ViewModel, no framework). ViewModels in `ViewModels/`, Views in `Views/`.
- `StatsPopupWindow` — main stats popup, positioned near tray icon based on taskbar location
- `SettingsWindow`, `AppStatsWindow`, `KeyboardHeatmapWindow`, `KeyHistoryWindow`, `MouseCalibrationWindow`, `NotificationSettingsWindow`
- Custom controls in `Views/Controls/`: `StatItemControl`, `KeyBreakdownControl`, `StatsChartControl`, `KeyDistributionPieChartControl`, `KeyboardHeatmapControl`

**Helpers** (`Helpers/`):
- `NativeInterop` — all P/Invoke declarations (hooks, keyboard state, window info)
- `KeyNameMapper` — virtual key code → display name with modifier detection (outputs `"Ctrl+Shift+A"`)
- `ThemeManager` — runtime light/dark theme switching via dynamic resource replacement
- `ActiveWindowManager` — foreground window title/process detection for per-app attribution
- `Converters` — XAML value converters (`IntToBool`, `BoolToVisibility`, `InverseBool`)

**Data models** (`Models/`):
- `DailyStats` — daily totals, key breakdown dict, per-app stats, mouse/scroll distance
- `AppStats` — per-app key/click/scroll counts
- `AppSettings` — notifications, startup, analytics, mouse calibration settings

**Persistence**: JSON files in `%LOCALAPPDATA%\KeyStats\` — `daily_stats.json`, `history.json`, `settings.json`. Keep backward-compatible when adding fields.

## Key Constraints

- **Privacy**: only store aggregate counts/distances. Never persist keystroke content, raw mouse paths, or clipboard data.
- **Hook safety**: hook callbacks must be non-blocking. Always call `CallNextHookEx`. No file IO, serialization, or UI work in callbacks — dispatch via thread pool.
- **Threading**: `StatsManager` shared state is lock-protected. UI updates must go through WPF Dispatcher. Never mutate WPF-bound collections from background threads.
- **Shutdown**: preserve the exit sequence in `App.OnExit`. New background/timer resources must be disposed on exit.
- **UI language**: user-facing copy is Chinese-first. Preserve this unless explicitly adding bilingual UI.

## Conventions

- Git commits: Conventional Commits format (`feat:`, `fix:`, `refactor:`, etc.)
- One primary class per file, follow existing namespace/folder boundaries.
- Charts are hand-drawn on WPF Canvas (no charting library).
- Tray icon uses `Hardcodet.NotifyIcon.Wpf` (`TaskbarIcon`).
- Analytics: when adding a new page/window, emit `pageview` and `click` events using shared tracking helpers.

## High-Risk Files

Changes to these files can impact data integrity, input capture, or app stability — review carefully:
- `Services/InputMonitorService.cs`
- `Services/StatsManager.cs`
- `App.xaml.cs`
- `Helpers/NativeInterop.cs`
- `Helpers/ThemeManager.cs`
10 changes: 8 additions & 2 deletions KeyStats.Windows/KeyStats/App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@
<Color x:Key="TextSecondaryColor">#5C5C5C</Color>
<Color x:Key="TextTertiaryColor">#8A8A8A</Color>
<Color x:Key="SurfaceColor">#FAFAFA</Color>
<Color x:Key="CardColor">#FFFFFF</Color>
<Color x:Key="WindowSurfaceColor">#B8FAFAFA</Color>
<Color x:Key="CardColor">#CCFFFFFF</Color>
<Color x:Key="TrayPopupBorderColor">#20000000</Color>
<Color x:Key="TrayBackdropTintColor">#B8FAFAFA</Color>
<Color x:Key="DividerColor">#E5E5E5</Color>
<Color x:Key="SubtleFillColor">#09000000</Color>
<Color x:Key="SubtleFillColor">#A8FFFFFF</Color>
<Color x:Key="SubtleHoverColor">#12000000</Color>

<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}"/>
Expand All @@ -31,7 +34,10 @@
<SolidColorBrush x:Key="TextSecondaryBrush" Color="{StaticResource TextSecondaryColor}"/>
<SolidColorBrush x:Key="TextTertiaryBrush" Color="{StaticResource TextTertiaryColor}"/>
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource SurfaceColor}"/>
<SolidColorBrush x:Key="WindowSurfaceBrush" Color="{StaticResource WindowSurfaceColor}"/>
<SolidColorBrush x:Key="CardBrush" Color="{StaticResource CardColor}"/>
<SolidColorBrush x:Key="TrayPopupBorderBrush" Color="{StaticResource TrayPopupBorderColor}"/>
<SolidColorBrush x:Key="TrayBackdropTintBrush" Color="{StaticResource TrayBackdropTintColor}"/>
<SolidColorBrush x:Key="DividerBrush" Color="{StaticResource DividerColor}"/>
<SolidColorBrush x:Key="SubtleFillBrush" Color="{StaticResource SubtleFillColor}"/>
<SolidColorBrush x:Key="SubtleHoverBrush" Color="{StaticResource SubtleHoverColor}"/>
Expand Down
77 changes: 77 additions & 0 deletions KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ public struct RECT
public int Bottom;
}

[StructLayout(LayoutKind.Sequential)]
public struct MARGINS
{
public int cxLeftWidth;
public int cxRightWidth;
public int cyTopHeight;
public int cyBottomHeight;
}

[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);

Expand Down Expand Up @@ -183,8 +192,25 @@ public struct MSG
[DllImport("dwmapi.dll", PreserveSig = true)]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int dwAttribute, ref int pvAttribute, int cbAttribute);

[DllImport("dwmapi.dll", PreserveSig = true)]
private static extern int DwmExtendFrameIntoClientArea(IntPtr hwnd, ref MARGINS margins);

private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
private const int DWMWA_USE_IMMERSIVE_DARK_MODE_FALLBACK = 19;
private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33;
private const int DWMWA_BORDER_COLOR = 34;
private const int DWMWA_SYSTEMBACKDROP_TYPE = 38;
private const int DWMWCP_ROUND = 2;
private const int DWMWA_COLOR_NONE = unchecked((int)0xFFFFFFFE);

public enum DwmSystemBackdropType
{
Auto = 0,
None = 1,
MainWindow = 2,
TransientWindow = 3,
TabbedWindow = 4
}

public static void TrySetImmersiveDarkMode(IntPtr hwnd, bool enabled)
{
Expand All @@ -200,4 +226,55 @@ public static void TrySetImmersiveDarkMode(IntPtr hwnd, bool enabled)
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_FALLBACK, ref useDarkMode, sizeof(int));
}
}

public static bool TrySetSystemBackdrop(IntPtr hwnd, DwmSystemBackdropType backdropType)
{
if (hwnd == IntPtr.Zero || Environment.OSVersion.Version.Build < 22621)
{
return false;
}

var value = (int)backdropType;
return DwmSetWindowAttribute(hwnd, DWMWA_SYSTEMBACKDROP_TYPE, ref value, sizeof(int)) == 0;
}

public static void TrySetRoundedCorners(IntPtr hwnd)
{
if (hwnd == IntPtr.Zero || Environment.OSVersion.Version.Build < 22000)
{
return;
}

var preference = DWMWCP_ROUND;
DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ref preference, sizeof(int));
}

public static void TryClearWindowBorder(IntPtr hwnd)
{
if (hwnd == IntPtr.Zero || Environment.OSVersion.Version.Build < 22000)
{
return;
}

var borderColor = DWMWA_COLOR_NONE;
DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, ref borderColor, sizeof(int));
}

public static bool TryExtendFrameIntoClientArea(IntPtr hwnd)
{
if (hwnd == IntPtr.Zero)
{
return false;
}

var margins = new MARGINS
{
cxLeftWidth = -1,
cxRightWidth = -1,
cyTopHeight = -1,
cyBottomHeight = -1
};

return DwmExtendFrameIntoClientArea(hwnd, ref margins) == 0;
}
}
28 changes: 20 additions & 8 deletions KeyStats.Windows/KeyStats/Helpers/ThemeManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,12 @@ private static void ApplyLightTheme(ResourceDictionary res)
SetColor(res, "TextSecondaryColor", "#5C5C5C");
SetColor(res, "TextTertiaryColor", "#8A8A8A");
SetColor(res, "SurfaceColor", "#FAFAFA");
SetColor(res, "CardColor", "#FFFFFF");
SetColor(res, "WindowSurfaceColor", "#B8FAFAFA");
SetColor(res, "CardColor", "#CCFFFFFF");
SetColor(res, "TrayPopupBorderColor", "#20000000");
SetColor(res, "TrayBackdropTintColor", "#B8FAFAFA");
SetColor(res, "DividerColor", "#E5E5E5");
SetColor(res, "SubtleFillColor", "#09000000");
SetColor(res, "SubtleFillColor", "#A8FFFFFF");
SetColor(res, "SubtleHoverColor", "#12000000");

SetBrush(res, "AccentBrush", "#0067C0");
Expand All @@ -96,9 +99,12 @@ private static void ApplyLightTheme(ResourceDictionary res)
SetBrush(res, "TextSecondaryBrush", "#5C5C5C");
SetBrush(res, "TextTertiaryBrush", "#8A8A8A");
SetBrush(res, "SurfaceBrush", "#FAFAFA");
SetBrush(res, "CardBrush", "#FFFFFF");
SetBrush(res, "WindowSurfaceBrush", "#B8FAFAFA");
SetBrush(res, "CardBrush", "#CCFFFFFF");
SetBrush(res, "TrayPopupBorderBrush", "#20000000");
SetBrush(res, "TrayBackdropTintBrush", "#B8FAFAFA");
SetBrush(res, "DividerBrush", "#E5E5E5");
SetBrush(res, "SubtleFillBrush", "#09000000");
SetBrush(res, "SubtleFillBrush", "#A8FFFFFF");
SetBrush(res, "SubtleHoverBrush", "#12000000");
SetBrush(res, "ChartLineBrush", "#0067C0");
SetBrush(res, "ChartFillBrush", "#200067C0");
Expand All @@ -119,9 +125,12 @@ private static void ApplyDarkTheme(ResourceDictionary res)
SetColor(res, "TextSecondaryColor", "#C5C5C5");
SetColor(res, "TextTertiaryColor", "#8A8A8A");
SetColor(res, "SurfaceColor", "#202020");
SetColor(res, "CardColor", "#2D2D2D");
SetColor(res, "WindowSurfaceColor", "#C8141414");
SetColor(res, "CardColor", "#CC1A1A1A");
SetColor(res, "TrayPopupBorderColor", "#33FFFFFF");
SetColor(res, "TrayBackdropTintColor", "#A8202020");
SetColor(res, "DividerColor", "#3D3D3D");
SetColor(res, "SubtleFillColor", "#0FFFFFFF");
SetColor(res, "SubtleFillColor", "#90161616");
SetColor(res, "SubtleHoverColor", "#15FFFFFF");

SetBrush(res, "AccentBrush", "#0078D4");
Expand All @@ -130,9 +139,12 @@ private static void ApplyDarkTheme(ResourceDictionary res)
SetBrush(res, "TextSecondaryBrush", "#C5C5C5");
SetBrush(res, "TextTertiaryBrush", "#8A8A8A");
SetBrush(res, "SurfaceBrush", "#202020");
SetBrush(res, "CardBrush", "#2D2D2D");
SetBrush(res, "WindowSurfaceBrush", "#C8141414");
SetBrush(res, "CardBrush", "#CC1A1A1A");
SetBrush(res, "TrayPopupBorderBrush", "#33FFFFFF");
SetBrush(res, "TrayBackdropTintBrush", "#A8202020");
SetBrush(res, "DividerBrush", "#3D3D3D");
SetBrush(res, "SubtleFillBrush", "#0FFFFFFF");
SetBrush(res, "SubtleFillBrush", "#90161616");
SetBrush(res, "SubtleHoverBrush", "#15FFFFFF");
SetBrush(res, "ChartLineBrush", "#0078D4");
SetBrush(res, "ChartFillBrush", "#200078D4");
Expand Down
31 changes: 31 additions & 0 deletions KeyStats.Windows/KeyStats/Helpers/WindowBackdropHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;

namespace KeyStats.Helpers;

public static class WindowBackdropHelper
{
public static bool Apply(Window window, NativeInterop.DwmSystemBackdropType backdropType)
{
var handle = new WindowInteropHelper(window).Handle;
if (handle == IntPtr.Zero)
{
return false;
}

NativeInterop.TryExtendFrameIntoClientArea(handle);
NativeInterop.TrySetImmersiveDarkMode(handle, ThemeManager.Instance.IsDarkTheme);
var backdropApplied = NativeInterop.TrySetSystemBackdrop(handle, backdropType);
NativeInterop.TrySetRoundedCorners(handle);
NativeInterop.TryClearWindowBorder(handle);

if (PresentationSource.FromVisual(window) is HwndSource hwndSource)
{
hwndSource.CompositionTarget.BackgroundColor = Colors.Transparent;
}

return backdropApplied;
}
}
Loading