diff --git a/CLAUDE.md b/CLAUDE.md index f93961b..b4d97c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,13 +108,46 @@ Wails v2 desktop app. Go backend embedded in a WebView2 (Chromium) renderer. Fra - `Modal.svelte` — glassmorphism dialog (info/confirm modes) - `SettingsPanel.svelte` — theme, startup toggles, saves via Go config API +## Model Strategy + +- **Parent agent (this conversation):** `deepseek-v4-pro[1m]` — configured in `.claude/settings.json` as `"model"` +- **Sub-agents (Agent tool):** always use `model="sonnet"` in the Agent call, which maps to `deepseek-v4-flash[1m]`. Pro model reserved for thinking/planning; flash model for parallel exploration — high efficiency, low cost. + +**Multi-agent patterns for this project:** + +| Task | Agent type | Model | Example | +|------|-----------|-------|---------| +| Search Go code | Explore | sonnet (flash) | Find Win32 API patterns in `internal/service/` | +| Search Svelte/JS code | Explore | sonnet (flash) | Find component event handlers in `frontend/src/` | +| Cross-cutting search | 2× Explore (parallel) | sonnet (flash) | Search Go API + all JS callers simultaneously | +| Bug investigation | 2-3× Explore (parallel) | sonnet (flash) | Search Go backend + Svelte frontend + Wails bindings simultaneously | +| Pre-change audit | Explore (background) | sonnet (flash) | Before modifying Go API: find all frontend callers | +| Post-change verification | general-purpose (background) | sonnet (flash) | After edits: go vet, check bindings, verify callers | +| Debug hook/callback issues | general-purpose | sonnet (flash) | Investigate `hookProc` or Svelte event delegation conflicts | +| Plan architecture changes | Plan | default (pro) | Design a new data pipeline or UI feature | +| Build feature (full impl) | — | default (pro) | Complex cross-cutting changes (Go + Svelte + Wails bindings) | + +**How to trigger multi-agent mode — say any of these:** + +| You say | What happens | +|---------|-------------| +| `团队模式` or `team mode` | **Universal trigger.** All independent searches/checks run as parallel sub-agents. Use this before any non-trivial task. | +| `查一下 X 在哪些地方用了` | Spawns parallel Explore agents: Go side + Svelte side simultaneously. | +| `帮我修这个 bug` | Spawns 2-3 Explore agents in parallel to search Go, Svelte, and Wails bindings for root cause. | +| `改一下 Go 的 X 方法` | First spawns a background Explore agent to find all JS callers, then proceeds with the edit. | +| (nothing — implicit) | Post-change verification (`go vet`, binding checks) always runs as a background agent. | + +**Key rule:** Pro model for decisions that require deep reasoning (architecture, debugging race conditions, build gotchas). Flash model for everything else — searches, file reads, formatting, simple edits. Send multiple flash agents in parallel to maximize throughput. + ## Critical Patterns & Gotchas +**Plans always go to the project-level `.claude/plans/`, never global.** The global `~/.claude/plans/` is for user-wide plans. All plans for this project must be written to `key-stats\.claude\plans\`. After writing a plan, verify it's in the right directory. + **Event handling in frameless window:** The title bar `on:mousedown` calls `StartDrag()` (Win32 `SendMessage(WM_NCLBUTTONDOWN)`). This blocks until drag completes and **swallows all child click events**. Must guard with `if (!e.target.closest('button'))` to let buttons work. **Do NOT use document-level event listeners:** Svelte 4 uses event delegation (single document-level listener for clicks). Adding `document.addEventListener('click', ...)` or using `EventsOn` from the Wails runtime causes conflicts with Svelte's event processing, making buttons unresponsive. Use Svelte's `on:click` handlers exclusively. For "click outside" patterns, use `on:click` on a parent element with target checking (see `handleMainClick` in App.svelte). -**Windows hook callback safety:** `hookProc` runs on a Windows callback thread. Never call `runtime.EventsEmit` or do blocking operations directly. Use non-blocking channel sends to `rtChan`; the `rtEmitter` goroutine handles emission. +**Windows hook callback safety:** `hookProc` runs on a Windows callback thread. Never call `runtime.EventsEmit` or do blocking operations directly. Use non-blocking channel sends to `rtChan`; the `rtEmitter` goroutine drains the channel to prevent backpressure. Frontend polls `GetLatestKeyPress()` at 100ms for real-time flash — polling avoids Svelte 4 event delegation conflicts entirely. **SQLite:** Uses `modernc.org/sqlite` (pure Go, no CGO). WAL mode enabled. Data at `%APPDATA%\key-stats\data.db`. diff --git a/docs/code-map.md b/docs/code-map.md new file mode 100644 index 0000000..a84f49a --- /dev/null +++ b/docs/code-map.md @@ -0,0 +1,588 @@ +# KeyStats — Code Map & Architecture Reference + +## Part 1: System Architecture — Big Picture + +``` + WINDOWS KERNEL + │ + WH_KEYBOARD_LL hook + │ + ┌──────────────┴──────────────┐ + │ hookProc (callback) │ ← Windows callback thread + │ internal/service/keyboard │ + └──────────────┬──────────────┘ + │ + ┌────────┴────────┐ + │ │ + non-blocking mutex.Lock() + chan send latestKeyName = name + │ latestKeyTs = ts + │ mutex.Unlock() + │ │ + ┌─────┴─────┐ ┌─────┴──────────┐ + │ rtChan │ │ GetLatestKey() │ ← polled at 100ms + │ (cap 256) │ │ returns (str,64) │ + └─────┬─────┘ └─────────────────┘ + │ + rtEmitter() [Frontend polls] + drains chan │ + (no emit used) pollFlash() @100ms + │ GetLatestKeyPress() + flashKey reactive var + │ │ + ┌─────┴─────┐ ┌─────┴──────────┐ + │ eventChan │ │ KeyboardMap │ + │ (cap 4096) │ │ $: flashKey │ + └─────┬─────┘ │ → currentFlash │ + │ │ → key-flash CSS │ + batchWriter() └────────────────┘ + @500ms or 256 + │ + ┌─────┴─────┐ + │ SQLite │ + │ data.db │ + │ WAL mode │ + └─────┬─────┘ + │ + stats.go queries + GetDateRangeSummary() + │ + ┌─────┴──────────┐ + │ [Frontend] │ + │ poll @500ms │ + │ fetchLiveStats│ + │ GetTodayStats │ + └───────┬────────┘ + │ + statsData reactive + │ + ┌───────────┼───────────┐ + │ │ │ + totalKeys topKeys[] appBreakdown[] + card ranking apps card +``` + +--- + +## Part 2: Go Backend — Package Tree & Function Map + +``` +main.go + └── wails.Run() + ├── config.Load() → restore window size + ├── OnStartup: App.Startup() + └── Bind: [App struct] → all exported methods → JS via Wails bridge + +pkg/app/app.go ─── App struct (12 exported methods) +│ +├── [Lifecycle] +│ ├── Startup(ctx) init DB → start keyboard → start tray +│ └── Shutdown(ctx) stop tray → stop keyboard → close DB +│ +├── [Config API] +│ ├── GetConfig() config.Load() → ToMap() → map[string]any +│ ├── SetConfig(updates map) config.Load() → UpdateFromMap() → config.Save() +│ └── SaveWindowSize(w, h) config.Load() → save width/height → config.Save() +│ +├── [Stats API] +│ ├── GetTodayStats() stats.GetTodaySummary(db) → TodaySummary +│ ├── GetStats(daysAgo) stats.GetStatsSummary(db, offset) → TodaySummary +│ ├── GetDateRangeStats(s, e) stats.GetDateRangeSummary(db, start, end) → TodaySummary +│ └── ResetStats() db.Reset() → DELETE FROM key_events +│ +├── [Real-time API] +│ ├── GetLatestKeyPress() keyboard.GetLatestKey() → {keyName, ts} +│ └── StartDrag() drag_windows.go → SendMessage(WM_NCLBUTTONDOWN) +│ +├── [Font API] +│ ├── GetSystemFonts() registry read + fallback list → []string +│ └── SetWindowIcon() Win32 LoadImage + SendMessage(WM_SETICON) +│ +└── [Internal fields] + ├── ctx context.Context + ├── database *db.DB + ├── keyboard *service.KeyboardService + └── trayMgr *tray.Tray + +internal/service/keyboard.go ─── KeyboardService +│ +├── [Win32 Hook Setup] +│ ├── NewKeyboardService(db) constructor +│ ├── Start() goroutine: SetWindowsHookExW + message pump +│ └── Stop() UnhookWindowsHookEx + context cancel +│ +├── [Real-time Pipeline] +│ ├── GetLatestKey() mutex-guarded read of latestKeyName/ts +│ └── rtEmitter() drains rtChan (no JS emit, avoids Svelte conflict) +│ +├── [Global Callback] +│ └── hookProc(nCode, wParam, lParam) +│ ├── KBDLLHOOKSTRUCT extract VkCode from lParam +│ ├── globalService.db.PushEvent(KeyEvent{...}) +│ ├── getActiveWindowTitle() GetForegroundWindow + GetWindowTextW +│ ├── rtChan <- event non-blocking send (drops if full) +│ └── latestKeyName/Ts mutex-protected write +│ +└── [Helper] + └── getActiveWindowTitle() Win32 GetForegroundWindow → GetWindowTextW + +internal/db/sqlite.go ─── DB +│ +├── InitDB(dataDir) sql.Open → PRAGMA WAL → CREATE TABLE → batchWriter() +├── PushEvent(e) non-blocking send to eventChan (cap 4096) +├── GetConn() returns *sql.DB for stats queries +├── Reset() DELETE FROM key_events +├── Close() cancel → drain channel → flush → close +│ +├── [Internal Goroutine] +│ └── batchWriter() ticker @500ms or batch≥256 → flush() +│ └── flush(batch) BEGIN → PREPARE INSERT → stmt.Exec() → COMMIT +│ +└── [Schema] + CREATE TABLE key_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key_code INTEGER NOT NULL, + app_name TEXT NOT NULL, + timestamp INTEGER NOT NULL + ) + INDEX idx_key_events_timestamp ON key_events(timestamp) + INDEX idx_key_events_key_code ON key_events(key_code) + +internal/stats/stats.go ─── Query & Key Mapping +│ +├── [Query Functions] +│ ├── GetTodaySummary(db) → GetStatsSummary(db, 0) +│ ├── GetStatsSummary(db, days) → GetDateRangeSummary(db, days, days) +│ └── GetDateRangeSummary(db, s, e) +│ ├── COUNT(*) total keys +│ ├── GROUP BY key_code → top 50 → aggregate by printable name → top 10 +│ └── GROUP BY app_name → top 10 apps +│ +└── [Key Code → Name Mapping] + VKToName(vk int) string + ├── 65-90 → A-Z + ├── 48-57 → 0-9 (main row) + ├── 96-105 → 0-9 (numpad → unified) + ├── 112-135 → F1-F24 + ├── 160-165 → L/R Shift/Ctrl/Alt + ├── Media / Browser / Navigation / Symbols + └── 255,232,230 → Fn + +internal/config/config.go ─── .env-based Config +│ +├── Load() defaults() → parse .env lines → AppConfig{} +├── Save(cfg) format .env with comments → WriteFile +├── DataDir() portable: exe dir if .env there, else %APPDATA%/key-stats +├── defaults() factory defaults (1280x800, dark, JetBrains Mono, etc.) +├── ToMap() AppConfig → map[string]any (for JSON/API) +└── UpdateFromMap() map → AppConfig fields, returns changed keys list + +internal/models/models.go ─── Data Structures +│ +├── KeyEvent{ID, KeyCode, AppName, Timestamp} ← raw event from hook +├── TodaySummary{TotalKeys, TopKeys[], AppBreakdown[]} ← API response +├── KeyCount{KeyCode, KeyName, Count} ← ranking item +└── AppCount{AppName, Count} ← app breakdown item + +pkg/tray/tray_windows.go ─── System Tray +│ +└── getlantern/systray wrapper + ├── Run(ctx, onShow, onQuit) + └── Quit() +``` + +--- + +## Part 3: Frontend Component Tree + +``` +main.js (entry point) + └── new App({ target: #app }) + │ + └── App.svelte ─── root component, owns all state + │ + ├── [STATE] + │ ├── statsData ← {totalKeys, topKeys[], appBreakdown[]} + │ ├── flashKey ← {name, ts} (from 100ms polling) + │ ├── isLive ← Live/Paused toggle + │ ├── showMenu ← dropdown/context menu visibility + │ ├── modal* ← modal state (show, title, message, mode, callbacks) + │ ├── showSettingsPanel + │ └── dateRange/startDays/endDays + │ + ├── [LIFECYCLE — onMount] + │ ├── GetConfig() apply theme + font on load + │ ├── fetchLiveStats() initial data load + │ ├── setInterval(fetch, 500) stats polling (guarded by isLive) + │ ├── setInterval(pollFlash, 100) flash polling + │ ├── window resize listener debounced SaveWindowSize() + │ └── return cleanup clearIntervals + removeListener + │ + ├── [CHILD COMPONENTS] + │ ├── KeyboardMap.svelte + │ │ props: data={statsData.topKeys} flashKey={flashKey} + │ │ + │ ├── Modal.svelte + │ │ props: show, title, message, mode, confirmText, cancelText + │ │ events: on:confirm, on:cancel + │ │ + │ └── SettingsPanel.svelte + │ props: show + │ (reads/writes config via GetConfig/SetConfig internally) + │ + ├── [EVENTS] + │ ├── on:click={handleMainClick} close menus on outside click + │ ├── on:keydown={handleMainKeydown} Esc to close menu + │ └── on:contextmenu|preventDefault title bar context menu + │ + └── [TITLE BAR] + ├── Date range dropdown (Today / Yesterday / Last 7 / Last 30) + ├── Live/Paused toggle button + └── Menu button → dropdown menu + ├── Reset Records → Modal confirm + ├── Status → Modal info + ├── Settings → SettingsPanel + ├── Minimize to Tray → WindowHide() + └── Quit → Quit() + +theme.js ─── Theme Manager (called from App.svelte onMount) +├── applyTheme(theme) set data-theme attr + Wails native theme +├── setupAutoTheme(theme) media query listener for auto mode +│ └── returns cleanup() +├── resolveTheme(theme) auto → matchMedia, light/dark → direct +└── setNativeTheme(resolved) WindowSetLightTheme / WindowSetDarkTheme +``` + +--- + +## Part 4: Wails Bridge — JS ↔ Go Method Map + +``` +┌─────────────────────────────── JS Call Site ──────────────────────────────────────────────────────┐ +│ │ +│ window.go.app.App.GetConfig() ──→ App.GetConfig() → map[string]any │ +│ window.go.app.App.SetConfig(updates) ──→ App.SetConfig(map) → []string (changed keys) │ +│ window.go.app.App.SaveWindowSize(w,h) ──→ App.SaveWindowSize(int,int) → error │ +│ │ +│ window.go.app.App.GetTodayStats() ──→ App.GetTodayStats() → models.TodaySummary │ +│ window.go.app.App.GetStats(days) ──→ App.GetStats(int) → models.TodaySummary │ +│ window.go.app.App.GetDateRangeStats(s,e)──→ App.GetDateRangeStats(int,int) → models.TodaySummary │ +│ window.go.app.App.ResetStats() ──→ App.ResetStats() → error │ +│ │ +│ window.go.app.App.GetLatestKeyPress() ──→ App.GetLatestKeyPress() → {keyName, ts} │ +│ window.go.app.App.GetSystemFonts() ──→ App.GetSystemFonts() → []string │ +│ window.go.app.App.SetWindowIcon() ──→ App.SetWindowIcon() → void (Win32 direct) │ +│ window.go.app.App.StartDrag() ──→ App.StartDrag() → void (Win32 direct) │ +│ │ +└───────────────────────────────────────────────────────────────────────────────────────────────────┘ + +Auto-generated bindings (frontend/wailsjs/go/app/): + App.js — thin wrappers around window['go']['app']['App'][Method]() + App.d.ts — TypeScript declarations for all exported methods + +Runtime imports (from App.svelte): + import { WindowHide, Quit } from '../wailsjs/runtime/runtime.js' +``` + +--- + +## Part 5: Data Flow — Detailed Sequence Diagrams + +### 5.1 Keystroke Recording (Time-Critical Path) + +``` +USER PRESSES KEY + + ┌─ Windows Kernel ─┐ + │ WH_KEYBOARD_LL │ + │ low-level hook │ + └────────┬──────────┘ + │ + ┌────────▼──────────────────────────────────────────────────┐ + │ hookProc(nCode, wParam, lParam) [callback thread] │ + │ │ + │ 1. KBDLLHOOKSTRUCT from lParam │ + │ 2. vk = int(kbd.VkCode) │ + │ │ + │ 3. globalService.db.PushEvent(KeyEvent{ │ + │ KeyCode: vk, │ + │ AppName: getActiveWindowTitle(), │ + │ Timestamp: UnixNano() / 1ms │ + │ }) │ + │ │ │ + │ └──→ eventChan (cap 4096, non-blocking send) │ + │ │ + │ 4. name := stats.VKToName(vk) │ + │ rtChan <- {keyCode, keyName} (non-blocking, 256) │ + │ │ │ + │ └──→ rtEmitter() drains (no emit, avoids conflicts) │ + │ │ + │ 5. mu.Lock() │ + │ latestKeyName = name │ + │ latestKeyTs = now_ms │ + │ mu.Unlock() │ + │ │ + │ 6. CallNextHookEx → pass to next hook in chain │ + └──────────────────────────────────────────────────────────┘ + + ┌─ Batch Writer (goroutine) ─┐ + │ eventChan → batch buffer │ + │ @500ms OR batch ≥ 256: │ + │ BEGIN TRANSACTION │ + │ INSERT INTO key_events │ + │ COMMIT │ + └────────────────────────────┘ +``` + +### 5.2 Stats Polling (Frontend Refresh @500ms) + +``` +App.svelte setInterval(500ms) + + isLive == true? + │ + YES │ + ▼ + fetchLiveStats() + │ + ├── startDays==0 && endDays==0? + │ │ + │ YES ├──→ GetTodayStats() + │ NO └──→ GetDateRangeStats(start, end) + │ + ▼ + [Wails Bridge] → Go → stats.GetTodaySummary(db) + │ + ├── SELECT COUNT(*) WHERE date(timestamp/1000) = date('now') + ├── SELECT key_code, COUNT(*) ... GROUP BY key_code ... LIMIT 50 + │ → aggregate by VKToName() → sort → top 10 + └── SELECT app_name, COUNT(*) ... GROUP BY app_name ... LIMIT 10 + │ + ▼ + TodaySummary{TotalKeys, TopKeys[], AppBreakdown[]} + │ + ▼ + statsData = data ← triggers Svelte reactivity + │ + ├── totalKeys card updates + ├── Top Keys ranking re-renders (progress bars animate) + └── App Breakdown list re-renders +``` + +### 5.3 Key Flash Polling (Frontend @100ms) + +``` +App.svelte setInterval(100ms) + + pollFlash() + │ + ├── GetLatestKeyPress() + │ │ + │ ▼ + │ [Go] keyboard.GetLatestKey() + │ → mutex.Lock → read latestKeyName/ts → mutex.Unlock + │ → return {keyName, ts} + │ + ├── data.ts !== lastPollTs ? ← guard: only act on NEW keypress + │ │ + │ YES │ + │ ▼ + │ lastPollTs = data.ts + │ flashKey = { name: data.keyName, ts: data.ts } + │ │ + │ ▼ (new object ref → Svelte reactivity) + │ KeyboardMap.svelte $: block + │ │ + │ ├── flashKey.ts && flashKey.name && ts !== lastFlashTs ? + │ │ │ + │ │ YES │ + │ │ ▼ + │ │ lastFlashTs = flashKey.ts + │ │ currentFlash = normalizeKeyName(flashKey.name) + │ │ flashRef.timer = setTimeout(180ms) + │ │ │ + │ │ ▼ + │ │ CSS class "key-flash" added to matching key div + │ │ (0.04s transition: scale 1.12, purple glow) + │ │ │ + │ │ ▼ (after 180ms) + │ │ currentFlash = '' → key-flash class removed + │ │ + │ NO └── skip (same keypress, no update) + │ + └── [Loop: repeats every 100ms] +``` + +### 5.4 Config Read/Write Flow + +``` +[READ] App loads + │ + App.svelte onMount() + └──→ GetConfig() + └──→ config.Load() + ├── resolve envPath() + │ ├── .env next to exe? → portable mode + │ └── else → %APPDATA%/key-stats/.env + ├── parse key=value lines + └── merge onto defaults() + → AppConfig → ToMap() → map[string]any + ← frontend applies theme + font + +[WRITE] SettingsPanel change + │ + SettingsPanel.svelte + └──→ SetConfig({theme: "light"}) + └──→ config.Load() → UpdateFromMap() → config.Save() + └── WriteFile(envPath(), formatted .env) + ← returns ["theme"] (changed keys list) +``` + +--- + +## Part 6: Data Structures — Shape Reference + +``` +KeyEvent (Go: internal/models) TodaySummary (Go → JSON → JS) +┌──────────────────────┐ ┌─────────────────────────────┐ +│ ID int64 │ │ totalKeys number │ +│ KeyCode int │ │ topKeys KeyCount[] │ +│ AppName string │ │ appBreakdown AppCount[] │ +│ Timestamp int64 (ms) │ └─────────────────────────────┘ +└──────────────────────┘ + KeyCount +AppCount (part of TodaySummary) ┌──────────────────────┐ +┌──────────────────────┐ │ keyCode number │ +│ appName string │ │ keyName string │ +│ count number │ │ count number │ +└──────────────────────┘ └──────────────────────┘ + +KeyboardMap.svelte props: + data: KeyCount[] ← statsData.topKeys + flashKey: { name: string, ts: number } + +AppConfig (Go: internal/config) +┌──────────────────────┐ +│ windowWidth int │ → SaveWindowSize() persists +│ windowHeight int │ → SaveWindowSize() persists +│ theme string │ → "dark" | "light" | "auto" +│ fontFamily string │ → CSS --app-font variable +│ startMinimized bool │ +│ autoStart bool │ +│ portableMode bool │ → detected from .env location +└──────────────────────┘ +``` + +--- + +## Part 7: Svelte Reactive Dependency Graph + +``` +App.svelte (reactive variables) +│ +├── statsData ───────────────────────────┐ +│ (updated by fetchLiveStats @500ms) │ +│ │ +│ ┌────────────────────────────────────┘ +│ ▼ +│ Template binds: +│ ├── {statsData.totalKeys} → total keys card +│ ├── {#each statsData.appBreakdown} → app list +│ └── {#each statsData.topKeys} → ranking list +│ └── props to KeyboardMap: data={statsData.topKeys} +│ +├── flashKey ────────────────────────────┐ +│ (updated by pollFlash @100ms, │ +│ only when data.ts changes) │ +│ │ +│ ┌────────────────────────────────────┘ +│ ▼ +│ props to KeyboardMap: flashKey={flashKey} +│ │ +│ ▼ +│ KeyboardMap.svelte $: block +│ $: if (flashKey.ts && flashKey.name && flashKey.ts !== lastFlashTs) +│ currentFlash = normalizeKeyName(...) +│ timeout → currentFlash = '' +│ +├── isLive ──────────────────────────────┐ +│ (toggled by Live/Paused button) │ +│ │ +│ ├── CSS class on Live/Paused button │ +│ ├── Guard in fetch poll interval │ +│ └── Paused overlay (if applicable) │ +│ +├── showMenu ────────────────────────────┐ +│ (toggled by menu button/context) │ +│ │ +│ └── {#if showMenu} renders dropdown │ +│ +├── modalShow ───────────────────────────┐ +│ (controlled by openModal/close) │ +│ │ +│ └── props to Modal: bind:show │ +│ +├── showSettingsPanel ──────────────────┐ +│ (toggled by Settings menu item) │ +│ │ +│ └── props to SettingsPanel: show │ +│ +└── dateRange / startDays / endDays ─────┐ + (set by date dropdown selection) │ + │ + └── dateFilter used in fetchLiveStats│ + → GetTodayStats vs GetDateRange │ +``` + +--- + +## Part 8: Critical Threading Model + +``` +┌─────────────────────────────────────────────────────────┐ +│ GOROUTINES │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ [Main Goroutine] │ +│ main.go → wails.Run() │ +│ Blocks on WebView2 message loop │ +│ All Wails-bound Go methods execute here │ +│ (GetTodayStats, GetLatestKeyPress, etc.) │ +│ │ +│ [Hook Message Pump Goroutine] │ +│ keyboard.go → Start() goroutine │ +│ SetWindowsHookExW → GetMessageW loop │ +│ hookProc is CALLED BACK on Windows thread │ +│ (NOT a Go goroutine — syscall.NewCallback trampoline)│ +│ │ +│ [Batch Writer Goroutine] │ +│ db/sqlite.go → batchWriter() │ +│ Ticker @500ms + batch≥256 flush │ +│ Drains eventChan (cap 4096) │ +│ │ +│ [rtEmitter Goroutine] │ +│ keyboard.go → rtEmitter() │ +│ Drains rtChan to prevent backpressure │ +│ Does NOT call runtime.EventsEmit │ +│ │ +├─────────────────────────────────────────────────────────┤ +│ THREAD SAFETY │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ hookProc (Windows callback thread) │ +│ ├── db.PushEvent() non-blocking chan send │ +│ ├── rtChan send non-blocking chan send │ +│ └── latestKeyName/Ts sync.Mutex protected │ +│ │ +│ GetLatestKey() (main goroutine — Wails call) │ +│ └── sync.Mutex.Lock() to read latestKeyName/Ts │ +│ │ +│ config.Load/Save (main goroutine — Wails call) │ +│ └── File I/O — single-threaded access │ +│ │ +│ SQLite — modernc.org/sqlite (pure Go, no CGO) │ +│ └── WAL mode — concurrent reads safe │ +│ └── Write via batch writer goroutine only │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 061f7e9..aca02d3 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -12,7 +12,9 @@ topKeys: [], appBreakdown: [] }; - + + let flashKey = { name: '', ts: 0 }; + let isLive = true; let showMenu = false; let menuPos = { x: 0, y: 0 }; @@ -209,6 +211,22 @@ if (isLive) fetchLiveStats(); }, 500); + // Fast poll for real-time key flash (avoids EventsOn + Svelte 4 conflict) + let lastPollTs = 0; + async function pollFlash() { + if (window.go?.app?.App?.GetLatestKeyPress) { + try { + const data = await window.go.app.App.GetLatestKeyPress(); + if (data.keyName && data.ts !== lastPollTs) { + lastPollTs = data.ts; + flashKey = { name: data.keyName, ts: data.ts }; + } + } catch (e) { /* silently ignore */ } + } + } + const flashInterval = setInterval(pollFlash, 100); + pollFlash(); // immediate first poll + // Debounced resize listener to persist window size const saveSize = debounce(async () => { if (window.go?.app?.App?.SaveWindowSize) { @@ -226,6 +244,7 @@ window.addEventListener('resize', saveSize); return () => { + clearInterval(flashInterval); clearInterval(interval); window.removeEventListener('resize', saveSize); cleanupAutoTheme(); @@ -408,7 +427,7 @@
- +
diff --git a/frontend/src/components/KeyboardMap.svelte b/frontend/src/components/KeyboardMap.svelte index 0dbedb2..bf98855 100644 --- a/frontend/src/components/KeyboardMap.svelte +++ b/frontend/src/components/KeyboardMap.svelte @@ -59,10 +59,10 @@ 'Enter': 'Enter', 'Tab': 'Tab', 'Space': 'Space', - 'Shift': 'Shift', - 'Ctrl': 'Ctrl', - 'Alt': 'Alt', - 'Win': 'Win', + 'Shift': 'Shift', 'LShift': 'Shift', 'RShift': 'Shift', + 'Ctrl': 'Ctrl', 'LCtrl': 'Ctrl', 'RCtrl': 'Ctrl', + 'Alt': 'Alt', 'LAlt': 'Alt', 'RAlt': 'Alt', + 'Win': 'Win', 'LWin': 'Win', 'RWin': 'Win', 'Fn': 'Fn', }; @@ -70,13 +70,26 @@ return keyNameToLabel[name] || name; } + // Two separate reactive blocks to avoid a feedback cycle: + // Block A (set): writes currentFlash when flashKey actually changes (new ts). + // Block B (timeout): starts a 180ms clear timer whenever currentFlash is set. + // If both were in one block, currentFlash would be a dependency, and the + // setTimeout clearing it would re-trigger the block → flash never fades. let currentFlash = ''; - let flashTimer; + const flash = { timer: 0, lastTs: 0 }; - $: if (flashKey.ts && flashKey.name) { - currentFlash = normalizeKeyName(flashKey.name); - clearTimeout(flashTimer); - flashTimer = setTimeout(() => { currentFlash = ''; }, 180); + $: { + const ts = flashKey.ts; + const name = flashKey.name; + if (ts && name && ts !== flash.lastTs) { + flash.lastTs = ts; + currentFlash = normalizeKeyName(name); + } + } + + $: if (currentFlash) { + clearTimeout(flash.timer); + flash.timer = setTimeout(() => { currentFlash = ''; }, 180); } import { onMount } from 'svelte'; @@ -88,7 +101,7 @@ } return () => { ro.disconnect(); - clearTimeout(flashTimer); + clearTimeout(flash.timer); }; }); diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 7a9d16d..54c58c9 100644 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -6,6 +6,8 @@ export function GetConfig():Promise>; export function GetDateRangeStats(arg1:number,arg2:number):Promise; +export function GetLatestKeyPress():Promise>; + export function GetStats(arg1:number):Promise; export function GetSystemFonts():Promise>; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 415ef69..94d780d 100644 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -10,6 +10,10 @@ export function GetDateRangeStats(arg1, arg2) { return window['go']['app']['App']['GetDateRangeStats'](arg1, arg2); } +export function GetLatestKeyPress() { + return window['go']['app']['App']['GetLatestKeyPress'](); +} + export function GetStats(arg1) { return window['go']['app']['App']['GetStats'](arg1); } diff --git a/internal/service/keyboard.go b/internal/service/keyboard.go index 4dbcfaa..f5539d8 100644 --- a/internal/service/keyboard.go +++ b/internal/service/keyboard.go @@ -3,6 +3,7 @@ package service import ( "context" "log" + "sync" "syscall" "time" "unsafe" @@ -61,19 +62,20 @@ func getActiveWindowTitle() string { } type KeyboardService struct { - db *db.DB - hook uintptr - active bool - emit func(eventName string, data ...interface{}) - rtChan chan map[string]interface{} - rtCtx context.Context - rtCancel context.CancelFunc + db *db.DB + hook uintptr + active bool + rtChan chan map[string]interface{} + rtCtx context.Context + rtCancel context.CancelFunc + mu sync.Mutex + latestKeyName string + latestKeyTs int64 } -func NewKeyboardService(database *db.DB, emit func(string, ...interface{})) *KeyboardService { +func NewKeyboardService(database *db.DB) *KeyboardService { return &KeyboardService{ db: database, - emit: emit, rtChan: make(chan map[string]interface{}, 256), } } @@ -86,10 +88,9 @@ func (s *KeyboardService) Start() { globalService = s s.rtCtx, s.rtCancel = context.WithCancel(context.Background()) - // Start a dedicated goroutine to safely emit real-time events. - // runtime.EventsEmit must NOT be called from the syscall callback (hookProc) - // because the Go scheduler cannot safely handle blocking/channel operations - // on the Windows callback thread. + // Start a dedicated goroutine to drain rtChan and prevent backpressure. + // rtChan is written to from hookProc (non-blocking); rtEmitter drains it. + // We intentionally do NOT emit events to JS — polling avoids Svelte 4 conflicts. go s.rtEmitter() go func() { @@ -136,25 +137,32 @@ func (s *KeyboardService) Start() { }() } -// rtEmitter runs in its own goroutine and safely calls emit() for each -// real-time key event. This avoids calling runtime.EventsEmit from the -// Windows syscall callback thread. +// rtEmitter drains rtChan to prevent the channel from filling up in hookProc. +// We intentionally do NOT call runtime.EventsEmit here — emitting custom events +// from Go interferes with Svelte 4's document-level event delegation, causing +// button unresponsiveness and broken reactivity. The frontend polls GetLatestKeyPress() +// at 100ms instead. func (s *KeyboardService) rtEmitter() { for { select { case <-s.rtCtx.Done(): return - case ev, ok := <-s.rtChan: + case _, ok := <-s.rtChan: if !ok { return } - if s.emit != nil { - s.emit("key-pressed", ev) - } } } } +// GetLatestKey returns the most recent key press name and timestamp (ms). +// Returns empty string and 0 if no key has been pressed yet. +func (s *KeyboardService) GetLatestKey() (string, int64) { + s.mu.Lock() + defer s.mu.Unlock() + return s.latestKeyName, s.latestKeyTs +} + func (s *KeyboardService) Stop() { if !s.active { return @@ -188,8 +196,8 @@ func hookProc(nCode int32, wParam uintptr, lParam uintptr) uintptr { } // Queue real-time event via non-blocking channel send. - // hookProc runs on a Windows callback thread; we must not call - // runtime.EventsEmit here. The rtEmitter goroutine handles it. + // hookProc runs on a Windows callback thread; keep this fast. + // The rtEmitter goroutine drains rtChan. Frontend polls GetLatestKeyPress(). if globalService != nil && globalService.active { name := stats.VKToName(vk) select { @@ -200,6 +208,10 @@ func hookProc(nCode int32, wParam uintptr, lParam uintptr) uintptr { default: // Channel full — drop real-time event to avoid blocking hook } + globalService.mu.Lock() + globalService.latestKeyName = name + globalService.latestKeyTs = time.Now().UnixNano() / int64(time.Millisecond) + globalService.mu.Unlock() } } } diff --git a/pkg/app/app.go b/pkg/app/app.go index 2e06c51..cdb2a11 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -15,7 +15,6 @@ import ( "key-stats/internal/stats" "key-stats/pkg/tray" - "github.com/wailsapp/wails/v2/pkg/runtime" "golang.org/x/sys/windows/registry" ) @@ -55,9 +54,7 @@ func (a *App) Startup(ctx context.Context) { a.database = d // 3. Start Keyboard Logger - a.keyboard = service.NewKeyboardService(d, func(name string, data ...interface{}) { - runtime.EventsEmit(ctx, name, data...) - }) + a.keyboard = service.NewKeyboardService(d) a.keyboard.Start() // 4. Start system tray @@ -158,6 +155,17 @@ func (a *App) ResetStats() error { return a.database.Reset() } +// GetLatestKeyPress returns the most recent key press (name + timestamp) for +// real-time flash feedback on the keyboard heatmap. Returns zero values if no +// key has been pressed yet. +func (a *App) GetLatestKeyPress() map[string]interface{} { + if a.keyboard == nil { + return map[string]interface{}{"keyName": "", "ts": int64(0)} + } + name, ts := a.keyboard.GetLatestKey() + return map[string]interface{}{"keyName": name, "ts": ts} +} + // -- Font API -- // GetSystemFonts returns a list of installed system font families.