feat(keybinding): several shortcuts per action, mouse buttons, searchable picker

Shortcuts were defined in four places that drifted apart: per-action IPC channel
pairs, a hand-written VK table in the service, a second one in the renderer, and
three copies of the keycap styling. Adding an action meant editing all of them,
so two shortcuts stayed hardcoded in bootstrap and one had no settings entry at
all.

packages/core/src/keybinding.ts is now the single source for the binding type,
the selectable key catalog, the action catalog, normalization, validation,
conflict detection, display labels, search and deserialization. Main, preload
and renderer all read from it; nothing redefines keys or rules locally.

- Each action holds a list of bindings instead of one. AppConfig's four
  *Shortcut fields collapse into a single keyBindings map, migrated on launch.
- Mouse buttons can be bound. Left click is refused, right/middle need a
  modifier, side buttons are free. uiohook cannot swallow events, so the
  original click still fires and the UI says so.
- Keys can be picked from a grouped dropdown with a search box, not only by
  recording a keypress.
- HOTKEY's 14 channels become KEYBINDING's 9, taking the action as a parameter,
  so actions no longer multiply channels. The history and command popups moved
  out of bootstrap into ordinary actions.
- displayLabel is gone; labels derive from the binding and follow the app
  language and platform.

Fixes found on the way:
- Double-press hands-free was unreachable: lookup returned only the first
  matching action, and dictation shares its default binding.
- Reserved-combination checks compared joined key names, so a different modifier
  order let Ctrl+C through.
- Disabling shortcuts released every global registration in the process,
  including the popup ones, and never restored them.
- Enabling shortcuts after starting disabled left nothing registered.
- The dashboard stored the caption event payload instead of the state in it.
This commit is contained in:
Yun Chan 2026-09-21 13:41:47 +09:00
parent 0ca9e242fa
commit 4ad1ae6ed4
49 changed files with 5901 additions and 1792 deletions

View file

@ -16,7 +16,7 @@
**Main entry** `src/main/index.ts`: sets app name/AppUserModelId, disables GPU acceleration, EPIPE/uncaught handlers, registers `d3ro-voice://` deep-link protocol (Supabase OAuth implicit + PKCE), single-instance lock, then `bootstrap()` + `setupLifecycle()`.
**Bootstrap** `src/main/bootstrap.ts`: ordered `BootstrapStep[]` — logger, config, **database (critical)**, license, create-windows (critical), tray, **ipc-handlers (critical)**, custom-instructions, voice-commands, sound-effects, auto-launch, popup-preload, hotkey, voice-mode, llm-polling, meeting-summary-wiring, meeting-mode, cloud-sync, auto-update. Wires VoiceMode events to sound + history persistence.
**Bootstrap** `src/main/bootstrap.ts`: ordered `BootstrapStep[]` — logger, config, **database (critical)**, license, create-windows (critical), tray, **ipc-handlers (critical)**, custom-instructions, voice-commands, sound-effects, auto-launch, popup-preload, key-bindings, voice-mode, stt-warmup, llm-polling, meeting-summary-wiring, meeting-mode, cloud-sync, auto-update. Wires VoiceMode events to sound + history persistence, and subscribes to `KeyBindingService` `triggered` for the `history-popup` / `command-popup` actions (`bootstrap.ts:159`) — those two were hardcoded accelerators before and are now rebindable like everything else.
---
@ -30,7 +30,7 @@ Singleton + `EventEmitter` pattern (`getXService()` accessors).
| `VoiceModeService` | Orchestrator: 9-state `RecognitionState` + 4-state `AudioState`, dual-condition flush, action queue. Events: session-started/completed/cancelled, transcription-update, audio-level, recognition/audio-state-changed, premium-llm-fallback, error |
| `AudioCaptureService` | Mic PCM16 16kHz mono (bundled SoX on Windows, node-record-lpcm16 elsewhere). Spawns hidden (`windowsHide`); a missing SoX fails with the exact fix command |
| `LocalSTTService` | faster-whisper Python sidecar manager (state machine, dual-flush, model download/cancel, background warm-up, live partial transcription). Connects over IPv4 loopback (`getSidecarBaseUrl`) and fails fast with an actionable message when the bundled engine or virtualenv is missing |
| `HotkeyService` | uiohook-napi global hooking (dictation/hands-free/command/caption). Events: hotkey-pressed/released, double-press, error |
| `KeyBindingService` | uiohook-napi global hooking for **keyboard and mouse**, driven by the `@d3ro/core/keybinding` contract: 6 rebindable actions (dictation, hands-free, command, caption, history-popup, command-popup), several bindings per action, structural reserved-combo checks. Events: `triggered` (in-process payload carries `actionId`, `type` (`pressed`/`released`), `isDoublePress`, `holdMode`, `timestamp`; the renderer-facing `keybinding:triggered` event is the narrower `KeyBindingTriggeredEvent`, `keybinding.ts:1092`), `changed`, `error`. `globalShortcut` is used only to mute the macOS system beep, and only for accelerators it registered itself. Mouse events cannot be suppressed by uiohook, so a bound button also performs its native action |
| `TextInsertService` | Clipboard save→set→Ctrl+V→restore via nut-js |
| `SoundEffectService` | Preloaded WAV feedback (start/stop/error/cancel/chime) |
@ -118,8 +118,8 @@ Registry: `src/main/ipc/index.ts` calls 29 `registerXHandlers()` in fixed order.
| `dictionary-handlers` | DICTIONARY |
| `file-transcription-handlers` | FILE_TRANSCRIPTION |
| `history-handlers` | HISTORY + `stats:getSummary` |
| `hotkey-handlers` | HOTKEY |
| `instruction-handlers` | INSTRUCTION |
| `keybinding-handlers` | KEYBINDING |
| `license-handlers` | LICENSE |
| `llm-handlers` | LLM + `llm:premium:*` + ONLINE_AUTH |
| `meeting-doc-template-handlers` | MEETING_DOC_TEMPLATE |
@ -138,7 +138,9 @@ Registry: `src/main/ipc/index.ts` calls 29 `registerXHandlers()` in fixed order.
| `voice-handlers` | VOICE |
| `window-handlers` | WINDOW + `SYSTEM.OPEN_EXTERNAL` |
Preload exposes **`window.electronAPI`** with 33 namespaces: `platform, audio, config, voice, stt, hotkey, llm (incl. premium), history, dictionary, stats, window, system, instruction, app, memo, voiceCommand, context, chain, caption, license, fileTranscription, meetingSummary, dictationTemplate, rag, voiceAction, voiceConversation, meetingMode, meetingChat, meetingDocTemplate, cloudSync, onlineAuth, ads, support, payment`. Envelope: `IPCResult<T>` (success/error); `app.onDataChanged` is the global refresh channel.
The **`KEYBINDING`** group replaced the old per-action `HOTKEY` group. `HOTKEY` had 14 channels — a get/set pair per action plus three that were never implemented — so every new action meant new channels. `KEYBINDING` is 9 channels that take the action **as a parameter**: `getMap`, `setBindings`, `resetAction`, `resetAll`, `validate`, `isEnabled`, `setEnabled`, plus the `triggered` / `changed` events (`packages/core/src/ipc-channels.ts:104`). Adding an action now costs zero channels.
Preload exposes **`window.electronAPI`** with 33 namespaces: `platform, audio, config, voice, stt, keybinding, llm (incl. premium), history, dictionary, stats, window, system, instruction, app, memo, voiceCommand, context, chain, caption, license, fileTranscription, meetingSummary, dictationTemplate, rag, voiceAction, voiceConversation, meetingMode, meetingChat, meetingDocTemplate, cloudSync, onlineAuth, ads, support, payment`. The `keybinding` bridge is 9 methods mirroring the channels above (`src/preload/index.ts:323`), replacing the 11-method `hotkey` bridge. Envelope: `IPCResult<T>` (success/error); `app.onDataChanged` is the global refresh channel.
---
@ -157,8 +159,8 @@ Vanilla popups (`src/renderer/popups/`):
|---|---|
| `recording-tip` | 9-bar waveform indicator, partial transcript |
| `result-popup` | Transcription result + copy, auto-close with hover pause |
| `history-popup` | Recent transcriptions; ↑↓/Enter/1-9/ESC |
| `command-popup` | Command selection (Ctrl+Shift+C) |
| `history-popup` | Recent transcriptions; ↑↓/Enter/1-9/ESC. Opened by the `history-popup` action (default `Ctrl+Shift+V`, rebindable) |
| `command-popup` | Command selection. Opened by the `command-popup` action (default `Ctrl+Shift+C`, rebindable) |
| `caption-overlay` | Live caption overlay (font/opacity/maxLines) |
---
@ -177,9 +179,11 @@ Routing is state-based in `AppLayout.tsx` (`Route` union + `NAV_ITEMS`), no reac
| `KnowledgeBasePage` | knowledge | Local RAG: add/index docs, semantic query, reindex/remove |
| `MeetingModePage` | meeting | Meeting studio: live transcript, memos, doc generation/export, diarization |
Modals/components: `SettingsModal` (tabs General/Audio/STT/LLM/License/Cloud/About), `LicenseModal`, `LicenseTab`, `CloudSyncSection`, `OnboardingModal`, `UpgradePromptModal`, `ProBadge`, `TemplateSection`, `FileDropZone`, `HotkeyRecordModal`, `OllamaGuideModal`, `CodexOAuthGuideModal`, `TitleBar`, `StatusBar`, meeting components (9), voice-conversation, payment (`CheckoutModal`, `checkout-flow.ts`), support (`SupportModal`), ads (`AdBanner`, `RewardedQuotaModal`), shared cards.
Modals/components: `SettingsModal` (tabs General/Audio/STT/LLM/License/Cloud/About), `LicenseModal`, `LicenseTab`, `CloudSyncSection`, `OnboardingModal`, `UpgradePromptModal`, `ProBadge`, `TemplateSection`, `FileDropZone`, `OllamaGuideModal`, `CodexOAuthGuideModal`, `TitleBar`, `StatusBar`, meeting components (9), voice-conversation, payment (`CheckoutModal`, `checkout-flow.ts`), support (`SupportModal`), ads (`AdBanner`, `RewardedQuotaModal`), shared cards.
Hooks: `useRealtimeConversation` (OpenAI Realtime WebRTC), `useLicenseState`, `useProFeature`.
Key-binding UI lives in `components/keybinding/` (`Keycap`, `KeyBindingPicker`, `KeyBindingField`, `translation-key`), embedded in the Settings **General** tab (`SettingsModal.tsx:239`) — one field per action plus a global on/off switch. The picker offers both key recording and a searchable grouped dropdown (MUI `Autocomplete` over `KEY_CATALOG`, `KeyBindingPicker.tsx:536`). It replaced `HotkeyRecordModal`. `renderer/utils/format-hotkey.ts` is now a 17-line platform adapter only; key names, modifier glyphs, and join rules come from `@d3ro/core/keybinding`.
Hooks: `useRealtimeConversation` (OpenAI Realtime WebRTC), `useLicenseState`, `useProFeature`, `useKeyBindingMap` (subscribes to `keybinding:changed`; the dashboard renders the live `dictation` binding through `BindingKeycaps`).
DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `stats`, `memo_tags`, `daily_usage`, `rag_documents`, `rag_chunks`, `meeting_sessions`, `meeting_memos`, `meeting_documents`.
@ -187,12 +191,14 @@ DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `s
## 6. Desktop status summary
- Core dictation/LLM/history pipeline: **implemented + tested** (~590 desktop tests; vitest + playwright).
- Core dictation/LLM/history pipeline: **implemented + tested**. Measured 2026-09-21: 1314 vitest cases in `apps/desktop`, 1311 passing; playwright e2e is separate. The failures are environment-dependent rather than regressions — two need a local sidecar venv or embedding server, one pins an error message that has since changed (`11` GAP-QA-02). These numbers hold with `better-sqlite3` built for the host Node ABI; rebuilding it for Electron to run the app invalidates them until you rebuild back (`11` GAP-INFRA-06).
- Cross-platform packaging: Windows NSIS (signed, `forceCodeSigning`), macOS DMG/ZIP arm64 (ad-hoc signing); auto-update via canonical Forgejo feed with update policy (`release/update-policy.json`).
- Local-first AI (SoX + faster-whisper sidecar + bundled Ollama) and cloud paths both present.
- **Local STT is packaged** (`1.3.0`): `electron-builder.yml` `extraResources` copies `sidecar-dist/sidecar` → `resources/sidecar` and `resources/ffmpeg` → `resources/ffmpeg`; `scripts/ci/verify-sidecar-bundle.mjs` gates packaging. Build locally with `npm --prefix apps/desktop run sidecar:setup && npm --prefix apps/desktop run sidecar:build`. The sidecar stays in console mode so `stdout`/`stderr` reach the app log (UTF-8, line-buffered); a packaged sidecar **must** exist or startup fails loudly instead of silently falling back to a system Python.
- All local engine URLs (`LocalSTTService`, `LocalLLMService`, `RAGService`, `OnlineLLMService`, `STTManager`) pass through `src/main/utils/loopback.ts`, which rewrites `localhost` to `127.0.0.1`, because some Windows hosts resolve `localhost` to IPv6 only and local engines bind IPv4.
- Meeting intelligence, RAG, voice conversation (local + Realtime), captions, file transcription: implemented.
- **Key bindings: implemented and verified on Windows.** Every global shortcut now comes from one contract (`@d3ro/core/keybinding`) with multiple bindings per action, mouse-button support, and no hardcoded accelerators left in `bootstrap.ts`. A manual run on 2026-09-21 confirmed legacy migration (custom values preserved), 6 actions loaded, the uiohook keyboard **and** mouse hook active with zero boot errors, and multi-binding working; contract side is `packages/core` 117 tests GREEN with no type errors in the key-binding files (`11` GAP-KEY-01 `[x]`). Two things remain open: `KeyBindingService` has no unit test of its own, and macOS/Linux mouse behavior is unconfirmed (`11` GAP-KEY-02). The rewrite also fixed a dead hands-free double-press path, an order-dependent reserved-combo check, a `globalShortcut.unregisterAll()` that wiped the popup accelerators, and a `setEnabled(true)` that re-enabled hooking with an empty binding set.
- The same pass fixed an unrelated pre-existing dashboard bug: `caption.onStateChanged` delivers `{ state }`, but `DashboardPage` passed the whole object into `setCaptionState`, so the caption status readout never showed the right value (`DashboardPage.tsx:148`).
- **Ad mediation**: `DirectHouseSponsorAdapter` performs real configurable REST bids; the other 9 adapters remain fail-closed stubs pending official SDKs (see `11-gap-backlog.md` GAP-ADS-01/02).
- Tier resolution now routes through `@d3ro/core/entitlement` (`resolveEntitlement`, `normalizeEntitlementTier`); `useLicenseState.isPro` includes `pro_plus`.
- No `TODO`/`FIXME` markers found in `src` (grep clean). `src/main/types/` is an empty directory.
@ -207,6 +213,8 @@ DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `s
| Bootstrap order | `src/main/bootstrap.ts` |
| IPC registry | `src/main/ipc/index.ts` |
| IPC channel SSOT | `packages/core/src/ipc-channels.ts` |
| Key-binding contract SSOT | `packages/core/src/keybinding.ts` (catalog, actions, validation, conflicts, formatting, parsing) |
| Key-binding service / IPC / UI | `src/main/services/KeyBindingService.ts`, `src/main/ipc/keybinding-handlers.ts`, `src/renderer/components/keybinding/` |
| Preload API | `src/preload/index.ts` |
| Windows | `src/main/windows/WindowManager.ts` |
| Voice orchestrator | `src/main/services/VoiceModeService.ts` |