diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index 58956d2..c406db6 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -3,7 +3,7 @@ import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron' import { initLoggerService, getLogger } from './services/LoggerService' import { initConfigService, configGet, configSet } from './services/ConfigService' -import { getHotkeyService } from './services/HotkeyService' +import { getKeyBindingService } from './services/KeyBindingService' import { getVoiceModeService } from './services/VoiceModeService' import { startLocalLLMAvailability } from './services/LocalLLMService' import { getHistoryService } from './services/HistoryService' @@ -61,7 +61,7 @@ export async function bootstrap(): Promise { { name: 'sound-effects', critical: false, fn: initSoundEffects }, { name: 'auto-launch', critical: false, fn: initAutoLaunch }, { name: 'popup-preload', critical: false, fn: initPopupWindows }, - { name: 'hotkey', critical: false, fn: initHotkey }, + { name: 'key-bindings', critical: false, fn: initKeyBindings }, { name: 'voice-mode', critical: false, fn: initVoiceMode }, { name: 'stt-warmup', critical: false, fn: initSTTWarmup }, { name: 'llm-polling', critical: false, fn: initLLMPolling }, @@ -119,10 +119,10 @@ async function initIpcHandlers(): Promise { registerAllIpcHandlers() } -async function initHotkey(): Promise { - const hotkey = getHotkeyService() - hotkey.loadFromConfig() - hotkey.start() +async function initKeyBindings(): Promise { + const keyBindings = getKeyBindingService() + keyBindings.loadFromConfig() + keyBindings.start() } async function initCustomInstructions(): Promise { @@ -155,34 +155,42 @@ async function initPopupWindows(): Promise { // 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환) getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ }) - // Ctrl+Shift+V → 히스토리 팝업 토글 - globalShortcut.register('Ctrl+Shift+V', () => { - if (isHistoryPopupVisible()) { - hideHistoryPopup() - unregisterPopupNavKeys() - } else { - const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries - showHistoryPopup(entries as unknown as Array>) - registerPopupNavKeys() - } - }) - - // Ctrl+Shift+C → 커맨드 선택 팝업 토글 - globalShortcut.register('Ctrl+Shift+C', () => { - if (isCommandPopupVisible()) { - hideCommandPopup() - unregisterPopupNavKeys() - } else { - const instructions = getCustomInstructionService().getAll() - const activeId = configGet('activeInstructionId') as string | null - showCommandPopup(instructions as unknown as Array>, activeId || null) - registerPopupNavKeys('command') + // 팝업 토글도 재바인딩 가능한 액션이다 — 하드코딩 accelerator 대신 KeyBindingService를 구독한다. + getKeyBindingService().on('triggered', (payload) => { + if (payload.type !== 'pressed') return + if (payload.actionId === 'history-popup') { + toggleHistoryPopup() + } else if (payload.actionId === 'command-popup') { + toggleCommandPopup() } }) setupCommandPopupIPC() } +function toggleHistoryPopup(): void { + if (isHistoryPopupVisible()) { + hideHistoryPopup() + unregisterPopupNavKeys() + return + } + const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries + showHistoryPopup(entries as unknown as Array>) + registerPopupNavKeys() +} + +function toggleCommandPopup(): void { + if (isCommandPopupVisible()) { + hideCommandPopup() + unregisterPopupNavKeys() + return + } + const instructions = getCustomInstructionService().getAll() + const activeId = configGet('activeInstructionId') as string | null + showCommandPopup(instructions as unknown as Array>, activeId || null) + registerPopupNavKeys('command') +} + // 히스토리 팝업 키 네비게이션 등록/해제 const POPUP_NAV_KEYS: Array<{ accel: string; key: string }> = [ { accel: 'Up', key: 'ArrowUp' }, @@ -222,7 +230,7 @@ function unregisterPopupNavKeys(): void { async function initVoiceMode(): Promise { const voiceMode = getVoiceModeService() - voiceMode.connectHotkey() + voiceMode.connectKeyBindings() const soundEffect = getSoundEffectService() diff --git a/apps/desktop/src/main/ipc/hotkey-handlers.ts b/apps/desktop/src/main/ipc/hotkey-handlers.ts deleted file mode 100644 index cf2c051..0000000 --- a/apps/desktop/src/main/ipc/hotkey-handlers.ts +++ /dev/null @@ -1,85 +0,0 @@ -// src/main/ipc/hotkey-handlers.ts - -import { ipcMain } from 'electron' -import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' -import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors' -import { getHotkeyService } from '../services/HotkeyService' -import { configGet, configSet } from '../services/ConfigService' -import type { SetHotkeyParams, SetEnabledParams } from '@d3ro/core/types' - -export function registerHotkeyHandlers(): void { - ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT, async () => { - return ipcSuccess(configGet('dictationShortcut')) - }) - - ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, async (_event, params: SetHotkeyParams) => { - try { - configSet('dictationShortcut', params.binding) - getHotkeyService().loadFromConfig() - return ipcSuccess(undefined) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return ipcError(ErrorCode.HotkeyRegistrationFailed, `Failed to set dictation shortcut: ${message}`) - } - }) - - ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT, async () => { - return ipcSuccess(configGet('handsFreeShortcut')) - }) - - ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, async (_event, params: SetHotkeyParams) => { - try { - configSet('handsFreeShortcut', params.binding) - getHotkeyService().loadFromConfig() - return ipcSuccess(undefined) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return ipcError(ErrorCode.HotkeyRegistrationFailed, `Failed to set hands-free shortcut: ${message}`) - } - }) - - ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT, async () => { - return ipcSuccess(configGet('commandShortcut')) - }) - - ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, async (_event, params: SetHotkeyParams) => { - try { - configSet('commandShortcut', params.binding) - getHotkeyService().loadFromConfig() - return ipcSuccess(undefined) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return ipcError(ErrorCode.HotkeyRegistrationFailed, `Failed to set command shortcut: ${message}`) - } - }) - - ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_CAPTION_SHORTCUT, async () => { - return ipcSuccess(configGet('captionShortcut')) - }) - - ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_CAPTION_SHORTCUT, async (_event, params: SetHotkeyParams) => { - try { - configSet('captionShortcut', params.binding) - getHotkeyService().loadFromConfig() - return ipcSuccess(undefined) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return ipcError(ErrorCode.HotkeyRegistrationFailed, `Failed to set caption shortcut: ${message}`) - } - }) - - ipcMain.handle(IPC_CHANNELS.HOTKEY.IS_ENABLED, async () => { - return ipcSuccess(configGet('hotkeyEnabled')) - }) - - ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_ENABLED, async (_event, params: SetEnabledParams) => { - configSet('hotkeyEnabled', params.enabled) - const hotkey = getHotkeyService() - if (params.enabled) { - hotkey.start() - } else { - hotkey.stop() - } - return ipcSuccess(undefined) - }) -} diff --git a/apps/desktop/src/main/ipc/index.ts b/apps/desktop/src/main/ipc/index.ts index a64a219..33cb23d 100644 --- a/apps/desktop/src/main/ipc/index.ts +++ b/apps/desktop/src/main/ipc/index.ts @@ -6,7 +6,7 @@ import { registerWindowHandlers } from './window-handlers' import { registerSystemHandlers } from './system-handlers' import { registerVoiceHandlers } from './voice-handlers' import { registerSTTHandlers } from './stt-handlers' -import { registerHotkeyHandlers } from './hotkey-handlers' +import { registerKeyBindingHandlers } from './keybinding-handlers' import { registerLLMHandlers } from './llm-handlers' import { registerHistoryHandlers } from './history-handlers' import { registerDictionaryHandlers } from './dictionary-handlers' @@ -40,7 +40,7 @@ export function registerAllIpcHandlers(): void { registerSystemHandlers() registerVoiceHandlers() registerSTTHandlers() - registerHotkeyHandlers() + registerKeyBindingHandlers() registerLLMHandlers() registerHistoryHandlers() registerDictionaryHandlers() diff --git a/apps/desktop/src/main/ipc/keybinding-handlers.ts b/apps/desktop/src/main/ipc/keybinding-handlers.ts new file mode 100644 index 0000000..0b1db8f --- /dev/null +++ b/apps/desktop/src/main/ipc/keybinding-handlers.ts @@ -0,0 +1,238 @@ +// src/main/ipc/keybinding-handlers.ts +// +// 키바인딩 IPC. 액션을 파라미터로 받는 단일 채널 집합이며, +// 검증·충돌 판정은 전부 `packages/core/src/keybinding.ts` 의 함수를 쓴다. + +import { ipcMain, BrowserWindow } from 'electron' +import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' +import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors' +import { + bindingKey, + createDefaultBindingMap, + detectBindingConflicts, + findActionSpec, + isKeyBinding, + isKeyBindingActionId, + normalizeBinding, + parseBindingMap, + validateBinding +} from '@d3ro/core/keybinding' +import { getKeyBindingService } from '../services/KeyBindingService' +import type { KeyBindingTriggerPayload } from '../services/KeyBindingService' +import { configGet, configSet } from '../services/ConfigService' +import type { + KeyBinding, + KeyBindingChangedEvent, + KeyBindingMap, + KeyBindingTriggeredEvent, + KeyBindingValidationResult, + ResetKeyBindingParams, + SetEnabledParams, + SetKeyBindingsParams, + ValidateKeyBindingParams +} from '@d3ro/core/types' + +function broadcast(channel: string, payload: unknown): void { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(channel, payload) + } +} + +/** 바인딩 맵 변경을 모든 창에 알린다 (창 간 동기화). */ +function broadcastChanged(map: KeyBindingMap): void { + const event: KeyBindingChangedEvent = { map } + broadcast(IPC_CHANNELS.KEYBINDING.CHANGED, event) +} + +/** 저장된 맵을 읽는다. 손상·누락 항목은 기본값으로 복원한다. */ +function readMap(): KeyBindingMap { + return parseBindingMap(configGet('keyBindings')) +} + +/** 맵을 저장하고 후킹 서비스에 다시 태운 뒤 창에 알린다. */ +function applyMap(map: KeyBindingMap): void { + configSet('keyBindings', map) + getKeyBindingService().loadFromConfig() + broadcastChanged(map) +} + +/** uiohook 트리거를 렌더러로 중계한다. 재등록 시 리스너가 중복되지 않게 이전 것을 떼어낸다. */ +let triggerRelay: ((payload: KeyBindingTriggerPayload) => void) | null = null + +function connectTriggerRelay(): void { + const service = getKeyBindingService() + if (triggerRelay !== null) { + service.off('triggered', triggerRelay) + } + triggerRelay = (payload: KeyBindingTriggerPayload) => { + const event: KeyBindingTriggeredEvent = { + actionId: payload.actionId, + type: payload.type, + isDoublePress: payload.isDoublePress + } + broadcast(IPC_CHANNELS.KEYBINDING.TRIGGERED, event) + } + service.on('triggered', triggerRelay) +} + +export function registerKeyBindingHandlers(): void { + connectTriggerRelay() + + ipcMain.handle(IPC_CHANNELS.KEYBINDING.GET_MAP, async () => { + return ipcSuccess(readMap()) + }) + + ipcMain.handle( + IPC_CHANNELS.KEYBINDING.SET_BINDINGS, + async (_event, params: SetKeyBindingsParams) => { + if (!isKeyBindingActionId(params.actionId)) { + return ipcError( + ErrorCode.HotkeyRegistrationFailed, + `Unknown key binding action: ${String(params.actionId)}` + ) + } + if (!Array.isArray(params.bindings)) { + return ipcError( + ErrorCode.HotkeyRegistrationFailed, + `Bindings for "${params.actionId}" must be an array` + ) + } + + const map = readMap() + const next: KeyBinding[] = [] + const seen = new Set() + + for (const candidate of params.bindings) { + if (!isKeyBinding(candidate)) { + return ipcError( + ErrorCode.HotkeyRegistrationFailed, + `Malformed binding for "${params.actionId}"` + ) + } + + const binding = normalizeBinding(candidate) + const validation = validateBinding(binding) + if (!validation.valid) { + return ipcError( + ErrorCode.HotkeySystemReserved, + `Binding rejected for "${params.actionId}": ${validation.reason}`, + { reason: validation.reason, reasonKey: validation.reasonKey } + ) + } + + const conflicts = detectBindingConflicts(params.actionId, binding, map) + if (conflicts.length > 0) { + return ipcError( + ErrorCode.HotkeyConflict, + `Binding for "${params.actionId}" conflicts with ` + + conflicts.map((c) => c.actionId).join(', '), + { conflicts: conflicts.map((c) => c.actionId) } + ) + } + + const key = bindingKey(binding) + if (seen.has(key)) continue + seen.add(key) + next.push(binding) + } + + map[params.actionId] = next + try { + applyMap(map) + return ipcSuccess(undefined) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return ipcError( + ErrorCode.HotkeyRegistrationFailed, + `Failed to set bindings for "${params.actionId}": ${message}` + ) + } + } + ) + + ipcMain.handle( + IPC_CHANNELS.KEYBINDING.RESET_ACTION, + async (_event, params: ResetKeyBindingParams) => { + const spec = isKeyBindingActionId(params.actionId) + ? findActionSpec(params.actionId) + : null + if (spec === null) { + return ipcError( + ErrorCode.HotkeyRegistrationFailed, + `Unknown key binding action: ${String(params.actionId)}` + ) + } + + try { + const map = readMap() + map[spec.id] = spec.defaultBindings.map((binding) => ({ ...binding })) + applyMap(map) + return ipcSuccess(undefined) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return ipcError( + ErrorCode.HotkeyRegistrationFailed, + `Failed to reset "${spec.id}": ${message}` + ) + } + } + ) + + ipcMain.handle(IPC_CHANNELS.KEYBINDING.RESET_ALL, async () => { + try { + applyMap(createDefaultBindingMap()) + return ipcSuccess(undefined) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return ipcError( + ErrorCode.HotkeyRegistrationFailed, + `Failed to reset key bindings: ${message}` + ) + } + }) + + ipcMain.handle( + IPC_CHANNELS.KEYBINDING.VALIDATE, + async (_event, params: ValidateKeyBindingParams) => { + if (!isKeyBindingActionId(params.actionId)) { + return ipcError( + ErrorCode.HotkeyRegistrationFailed, + `Unknown key binding action: ${String(params.actionId)}` + ) + } + if (!isKeyBinding(params.binding)) { + return ipcError( + ErrorCode.HotkeyRegistrationFailed, + `Malformed binding for "${params.actionId}"` + ) + } + + const binding = normalizeBinding(params.binding) + const result: KeyBindingValidationResult = { + validation: validateBinding(binding), + conflicts: detectBindingConflicts(params.actionId, binding, readMap()) + } + return ipcSuccess(result) + } + ) + + ipcMain.handle(IPC_CHANNELS.KEYBINDING.IS_ENABLED, async () => { + return ipcSuccess(configGet('hotkeyEnabled')) + }) + + ipcMain.handle( + IPC_CHANNELS.KEYBINDING.SET_ENABLED, + async (_event, params: SetEnabledParams) => { + configSet('hotkeyEnabled', params.enabled) + const service = getKeyBindingService() + if (params.enabled) { + // 비활성 상태로 부팅한 뒤 켜는 경로 — 등록이 비어 있으므로 먼저 다시 읽는다. + service.loadFromConfig() + service.start() + } else { + service.stop() + } + return ipcSuccess(undefined) + } + ) +} diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index 51d2ec9..ba2f88f 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -2,7 +2,8 @@ // electron-store 기반 설정 관리. 설계서 02의 AppConfig 타입 사용. import { EventEmitter } from 'events' -import type { AppConfig, ConfigChangedEvent } from '@d3ro/core/types' +import type { AppConfig, ConfigChangedEvent, KeyBindingActionId, KeyBindingMap } from '@d3ro/core/types' +import { createDefaultBindingMap, normalizeBinding, parseBindingMap } from '@d3ro/core/keybinding' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { getLogger } from './LoggerService' @@ -12,6 +13,7 @@ const logger = getLogger('ConfigService') interface ElectronStore { get(key: K): T[K] set(key: K, value: T[K]): void + delete(key: string): void store: T } @@ -51,38 +53,7 @@ const CONFIG_DEFAULTS: AppConfig = { // 라이브 음성 대화 백엔드 — 'realtime'은 로그인+구독 필요 (OpenAI Realtime WebRTC) conversationBackend: 'local' as const, defaultLLMAction: 'refine', - dictationShortcut: { - keyCode: 0xa5, // Right Alt - ctrl: false, - alt: false, - shift: false, - meta: false, - displayLabel: 'Right Alt' - }, - handsFreeShortcut: { - keyCode: 0xa5, - ctrl: false, - alt: false, - shift: false, - meta: false, - displayLabel: 'Right Alt (double)' - }, - commandShortcut: { - keyCode: 0xa5, - ctrl: true, - alt: false, - shift: false, - meta: false, - displayLabel: 'Ctrl + Right Alt' - }, - captionShortcut: { - keyCode: 0xa5, - ctrl: true, - alt: false, - shift: true, - meta: false, - displayLabel: 'Ctrl + Shift + Right Alt' - }, + keyBindings: createDefaultBindingMap(), hotkeyEnabled: true, insertMethod: 'clipboard', autoInsert: true, @@ -125,6 +96,9 @@ function createMemoryStore(initial: AppConfig): ElectronStore { set(key: K, value: AppConfig[K]): void { data[key] = value }, + delete(key: string): void { + delete (data as unknown as Record)[key] + }, get store(): AppConfig { return data }, @@ -134,6 +108,77 @@ function createMemoryStore(initial: AppConfig): ElectronStore { } } +// ── 키바인딩 마이그레이션 (구 *Shortcut 4개 → keyBindings) ── + +/** 0.x 저장 형태. 구조·라벨 정본이 keybinding.ts 로 옮겨지기 전의 값이다. */ +interface LegacyShortcut { + keyCode: number + ctrl: boolean + alt: boolean + shift: boolean + meta: boolean +} + +const LEGACY_SHORTCUT_ACTIONS: Readonly> = { + dictationShortcut: 'dictation', + handsFreeShortcut: 'hands-free', + commandShortcut: 'command', + captionShortcut: 'caption', +} + +function isLegacyShortcut(value: unknown): value is LegacyShortcut { + if (typeof value !== 'object' || value === null) return false + const v = value as Record + return ( + typeof v.keyCode === 'number' && + typeof v.ctrl === 'boolean' && + typeof v.alt === 'boolean' && + typeof v.shift === 'boolean' && + typeof v.meta === 'boolean' + ) +} + +/** + * 저장된 keyBindings 를 복원하고, 남아 있는 구 `*Shortcut` 값을 1회만 이관한다. + * + * 구 필드는 이관 직후 삭제하므로 다시 읽히지 않는다. + * `displayLabel` 은 폐기한다 — 라벨은 formatBindingSegments() 로 파생시킨다. + */ +function migrateKeyBindings(activeStore: ElectronStore): void { + const raw = activeStore.store as unknown as Record + const restored: KeyBindingMap = parseBindingMap(raw.keyBindings) + + const legacyKeys = Object.keys(LEGACY_SHORTCUT_ACTIONS).filter((key) => + isLegacyShortcut(raw[key]), + ) + if (legacyKeys.length === 0) { + activeStore.set('keyBindings', restored) + return + } + + for (const key of legacyKeys) { + const legacy = raw[key] as LegacyShortcut + const actionId = LEGACY_SHORTCUT_ACTIONS[key] + if (actionId === undefined) continue + restored[actionId] = [ + normalizeBinding({ + device: 'keyboard', + code: legacy.keyCode, + ctrl: legacy.ctrl, + alt: legacy.alt, + shift: legacy.shift, + meta: legacy.meta, + }), + ] + } + + activeStore.set('keyBindings', restored) + for (const key of legacyKeys) { + activeStore.delete(key) + } + logger.info(`Migrated ${legacyKeys.length} legacy shortcut(s) to keyBindings`) +} + /** electron-store 없이 설정 CRUD를 가능하게 한다 (테스트 + 초기화 전 안전망). */ export function initInMemoryConfig(overrides?: Partial): void { store = createMemoryStore({ ...CONFIG_DEFAULTS, ...overrides }) @@ -149,6 +194,7 @@ export async function initConfigService(): Promise { name: 'd3ro-voice-config', defaults: CONFIG_DEFAULTS }) + migrateKeyBindings(store) logger.info('ConfigService initialized') } diff --git a/apps/desktop/src/main/services/HotkeyService.ts b/apps/desktop/src/main/services/HotkeyService.ts deleted file mode 100644 index 52bcbcb..0000000 --- a/apps/desktop/src/main/services/HotkeyService.ts +++ /dev/null @@ -1,709 +0,0 @@ -// src/main/services/HotkeyService.ts -// uiohook-napi 기반 글로벌 키보드 후킹 서비스. -// 설계서 01의 IHotkeyService 구현. Speakly HotkeyService + HotkeyConfig 패턴 참조. - -import { EventEmitter } from 'events' -import { globalShortcut } from 'electron' -import { uIOhook, UiohookKey } from 'uiohook-napi' -import type { UiohookKeyboardEvent } from 'uiohook-napi' -import { getLogger } from './LoggerService' -import { configGet } from './ConfigService' -import { D3ROError, ErrorCode } from '@d3ro/core/errors' -import { TIMING } from '@d3ro/core/constants' -import type { HotkeyBinding } from '@d3ro/core/types' - -const logger = getLogger('HotkeyService') - -// ============================================================ -// 내부 타입 -// ============================================================ - -export interface HotkeyConfig { - /** 핫키 식별자 (예: 'voice-dictation', 'voice-handsfree') */ - id: string - /** uiohook 키코드 */ - keyCode: number - /** 수정자 키 목록 */ - modifiers: HotkeyModifier[] - /** true=hold-to-talk (누르고 있는 동안 활성), false=toggle */ - holdMode: boolean - /** 더블프레스 감지 활성화 여부 */ - doublePressEnabled: boolean - /** 활성화 여부 */ - enabled: boolean - /** - * Electron globalShortcut accelerator (예: 'Shift+Cmd+1'). - * macOS에서 OS 시스템 beep을 막기 위해 함께 register하고 - * callback은 noop으로 둔다 (실제 hold/release는 uiohook이 처리). - * 변환 실패 시 null. - */ - acceleratorString: string | null -} - -export type HotkeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta' - -interface HotkeyServiceEvents { - 'hotkey-pressed': (payload: { config: HotkeyConfig; timestamp: number }) => void - 'hotkey-released': (payload: { - config: HotkeyConfig - durationMs: number - timestamp: number - }) => void - 'double-press': (payload: { - config: HotkeyConfig - intervalMs: number - timestamp: number - }) => void - error: (payload: { error: D3ROError }) => void -} - -// ============================================================ -// Windows VK 코드 → uiohook 키코드 매핑 -// ============================================================ - -/** - * Windows Virtual-Key 코드를 uiohook-napi 키코드로 변환한다. - * ConfigService에 저장된 HotkeyBinding.keyCode는 Windows VK 코드이므로 - * uiohook 이벤트와 비교하려면 변환이 필요하다. - */ -const VK_TO_UIOHOOK: ReadonlyMap = new Map([ - // 수정자 키 - [0xa0, UiohookKey.Shift], // VK_LSHIFT - [0xa1, UiohookKey.ShiftRight], // VK_RSHIFT - [0xa2, UiohookKey.Ctrl], // VK_LCONTROL - [0xa3, UiohookKey.CtrlRight], // VK_RCONTROL - [0xa4, UiohookKey.Alt], // VK_LMENU - [0xa5, UiohookKey.AltRight], // VK_RMENU (Right Alt) - [0x5b, UiohookKey.Meta], // VK_LWIN - [0x5c, UiohookKey.MetaRight], // VK_RWIN - - // 기능 키 - [0x70, UiohookKey.F1], - [0x71, UiohookKey.F2], - [0x72, UiohookKey.F3], - [0x73, UiohookKey.F4], - [0x74, UiohookKey.F5], - [0x75, UiohookKey.F6], - [0x76, UiohookKey.F7], - [0x77, UiohookKey.F8], - [0x78, UiohookKey.F9], - [0x79, UiohookKey.F10], - [0x7a, UiohookKey.F11], - [0x7b, UiohookKey.F12], - - // 일반 키 - [0x20, UiohookKey.Space], - [0x0d, UiohookKey.Enter], - [0x1b, UiohookKey.Escape], - [0x08, UiohookKey.Backspace], - [0x09, UiohookKey.Tab], - [0x2d, UiohookKey.Insert], - [0x2e, UiohookKey.Delete], - [0x24, UiohookKey.Home], - [0x23, UiohookKey.End], - [0x21, UiohookKey.PageUp], - [0x22, UiohookKey.PageDown], - [0x25, UiohookKey.ArrowLeft], - [0x26, UiohookKey.ArrowUp], - [0x27, UiohookKey.ArrowRight], - [0x28, UiohookKey.ArrowDown], - - // 알파벳 (VK_A=0x41 ~ VK_Z=0x5A) - [0x41, UiohookKey.A], - [0x42, UiohookKey.B], - [0x43, UiohookKey.C], - [0x44, UiohookKey.D], - [0x45, UiohookKey.E], - [0x46, UiohookKey.F], - [0x47, UiohookKey.G], - [0x48, UiohookKey.H], - [0x49, UiohookKey.I], - [0x4a, UiohookKey.J], - [0x4b, UiohookKey.K], - [0x4c, UiohookKey.L], - [0x4d, UiohookKey.M], - [0x4e, UiohookKey.N], - [0x4f, UiohookKey.O], - [0x50, UiohookKey.P], - [0x51, UiohookKey.Q], - [0x52, UiohookKey.R], - [0x53, UiohookKey.S], - [0x54, UiohookKey.T], - [0x55, UiohookKey.U], - [0x56, UiohookKey.V], - [0x57, UiohookKey.W], - [0x58, UiohookKey.X], - [0x59, UiohookKey.Y], - [0x5a, UiohookKey.Z], - - // 숫자 (VK_0=0x30 ~ VK_9=0x39) - [0x30, UiohookKey['0']], - [0x31, UiohookKey['1']], - [0x32, UiohookKey['2']], - [0x33, UiohookKey['3']], - [0x34, UiohookKey['4']], - [0x35, UiohookKey['5']], - [0x36, UiohookKey['6']], - [0x37, UiohookKey['7']], - [0x38, UiohookKey['8']], - [0x39, UiohookKey['9']] -]) - -// Windows VK → Electron Accelerator key string -function vkToAcceleratorKey(vk: number): string | null { - // 0~9 - if (vk >= 0x30 && vk <= 0x39) return String.fromCharCode(vk) - // A~Z - if (vk >= 0x41 && vk <= 0x5a) return String.fromCharCode(vk) - // F1~F24 - if (vk >= 0x70 && vk <= 0x87) return `F${vk - 0x6f}` - - switch (vk) { - case 0x20: return 'Space' - case 0x0d: return 'Return' - case 0x1b: return 'Esc' - case 0x08: return 'Backspace' - case 0x09: return 'Tab' - case 0x2d: return 'Insert' - case 0x2e: return 'Delete' - case 0x24: return 'Home' - case 0x23: return 'End' - case 0x21: return 'PageUp' - case 0x22: return 'PageDown' - case 0x25: return 'Left' - case 0x26: return 'Up' - case 0x27: return 'Right' - case 0x28: return 'Down' - case 0xba: return ';' - case 0xbb: return '=' - case 0xbc: return ',' - case 0xbd: return '-' - case 0xbe: return '.' - case 0xbf: return '/' - case 0xc0: return '`' - case 0xdb: return '[' - case 0xdc: return '\\' - case 0xdd: return ']' - case 0xde: return "'" - default: return null - } -} - -/** - * HotkeyBinding을 Electron globalShortcut accelerator로 변환. - * macOS Cmd → 'Cmd', Windows Win key → 'Super'. - */ -function bindingToAccelerator(binding: HotkeyBinding): string | null { - const key = vkToAcceleratorKey(binding.keyCode) - if (!key) return null - - // 단일 modifier 키 자체를 단축키로 쓰는 경우 (예: Right Alt 단독) — accelerator 등록 불가. - // 일반 키와 modifier가 함께일 때만 등록. - const hasModifier = binding.ctrl || binding.alt || binding.shift || binding.meta - if (!hasModifier) { - // F1~F24, Esc 등 단일키는 등록 가능. 단 modifier 키 자체는 제외. - const isModifierKey = - binding.keyCode === 0x10 || - binding.keyCode === 0x11 || - binding.keyCode === 0x12 || - binding.keyCode === 0x5b || - binding.keyCode === 0x5c || - (binding.keyCode >= 0xa0 && binding.keyCode <= 0xa5) - if (isModifierKey) return null - } - - const parts: string[] = [] - if (binding.ctrl) parts.push('Control') - if (binding.alt) parts.push('Alt') - if (binding.shift) parts.push('Shift') - if (binding.meta) parts.push(process.platform === 'darwin' ? 'Cmd' : 'Super') - parts.push(key) - return parts.join('+') -} - -/** - * HotkeyBinding(Windows VK 코드)을 HotkeyConfig(uiohook 키코드)로 변환한다. - */ -function bindingToConfig( - id: string, - binding: HotkeyBinding, - holdMode: boolean, - doublePressEnabled: boolean -): HotkeyConfig { - const uiohookKeyCode = VK_TO_UIOHOOK.get(binding.keyCode) - - if (uiohookKeyCode === undefined) { - logger.warn( - `Unknown VK code 0x${binding.keyCode.toString(16)} for hotkey "${id}", ` + - `using raw value ${binding.keyCode}` - ) - } - - const modifiers: HotkeyModifier[] = [] - if (binding.ctrl) modifiers.push('ctrl') - if (binding.alt) modifiers.push('alt') - if (binding.shift) modifiers.push('shift') - if (binding.meta) modifiers.push('meta') - - return { - id, - keyCode: uiohookKeyCode ?? binding.keyCode, - modifiers, - holdMode, - doublePressEnabled, - enabled: true, - acceleratorString: bindingToAccelerator(binding) - } -} - -// ============================================================ -// HotkeyService 클래스 -// ============================================================ - -class HotkeyService extends EventEmitter { - private _isRunning = false - private _registeredHotkeys: Map = new Map() - - /** 더블프레스 감지용: 마지막 press 시각 */ - private _lastPressTime: Map = new Map() - - /** hold duration 계산용: press 시작 시각 */ - private _pressStartTime: Map = new Map() - - /** 키 반복(auto-repeat) 방지: 현재 눌려있는 키 */ - private _isKeyDown: Map = new Map() - - /** uiohook 이벤트 핸들러 (바인딩 해제용) */ - private _onKeyDown: ((e: UiohookKeyboardEvent) => void) | null = null - private _onKeyUp: ((e: UiohookKeyboardEvent) => void) | null = null - - get isRunning(): boolean { - return this._isRunning - } - - get registeredHotkeys(): ReadonlyMap { - return this._registeredHotkeys - } - - /** - * uiohook 글로벌 키보드 후킹을 시작한다. - * 이미 실행 중이면 무시. - */ - start(): void { - if (this._isRunning) { - logger.warn('HotkeyService already running') - return - } - - try { - this._onKeyDown = (e: UiohookKeyboardEvent) => this._handleKeyDown(e) - this._onKeyUp = (e: UiohookKeyboardEvent) => this._handleKeyUp(e) - - uIOhook.on('keydown', this._onKeyDown) - uIOhook.on('keyup', this._onKeyUp) - uIOhook.start() - - this._isRunning = true - logger.info('uiohook started, global keyboard hook active') - } catch (error) { - const d3roError = new D3ROError( - ErrorCode.HotkeyHookInitFailed, - `Failed to start uiohook: ${error instanceof Error ? error.message : String(error)}` - ) - this.emit('error', { error: d3roError }) - logger.error(d3roError.message) - } - } - - /** - * uiohook 글로벌 키보드 후킹을 중지한다. - */ - stop(): void { - if (!this._isRunning) { - return - } - - try { - uIOhook.stop() - - if (this._onKeyDown) { - uIOhook.removeListener('keydown', this._onKeyDown) - this._onKeyDown = null - } - if (this._onKeyUp) { - uIOhook.removeListener('keyup', this._onKeyUp) - this._onKeyUp = null - } - - this._isRunning = false - this._isKeyDown.clear() - this._pressStartTime.clear() - - // 등록된 모든 globalShortcut 해제 - try { - globalShortcut.unregisterAll() - } catch (err) { - logger.warn( - `globalShortcut.unregisterAll failed: ${err instanceof Error ? err.message : String(err)}` - ) - } - - logger.info('uiohook stopped') - } catch (error) { - logger.error( - `Failed to stop uiohook: ${error instanceof Error ? error.message : String(error)}` - ) - } - } - - /** - * 핫키를 등록한다. 동일 ID가 이미 등록되어 있으면 갱신한다. - * - * macOS에서 hold 모드 핫키는 uiohook이 처리하지만, OS가 키를 swallow하지 않아 - * focused app/desktop으로 전달되어 시스템 beep이 나는 경우가 있다. - * Electron globalShortcut을 noop callback과 함께 등록하면 OS가 키를 swallow하므로 - * uiohook은 그대로 keydown/keyup을 받으면서 beep만 차단된다. - */ - registerHotkey(config: HotkeyConfig): void { - if (!config.enabled) { - logger.debug(`Hotkey "${config.id}" is disabled, skipping registration`) - return - } - - // 기존 등록이 있으면 globalShortcut도 한 번 해제 (accelerator 변경 가능성) - const existing = this._registeredHotkeys.get(config.id) - if (existing?.acceleratorString) { - try { - if (globalShortcut.isRegistered(existing.acceleratorString)) { - globalShortcut.unregister(existing.acceleratorString) - } - } catch (err) { - logger.warn( - `globalShortcut unregister (existing) failed for "${config.id}": ${err instanceof Error ? err.message : String(err)}` - ) - } - } - - this._registeredHotkeys.set(config.id, config) - - // macOS beep 차단용 globalShortcut 등록 (callback은 noop, 실제 처리는 uiohook) - if (config.acceleratorString) { - try { - const ok = globalShortcut.register(config.acceleratorString, () => { - // noop — uiohook이 hold/release를 책임진다. - }) - if (!ok) { - logger.warn( - `globalShortcut.register returned false for "${config.id}" (${config.acceleratorString}) — beep 차단 실패` - ) - } - } catch (err) { - logger.warn( - `globalShortcut.register failed for "${config.id}": ${err instanceof Error ? err.message : String(err)}` - ) - } - } - - logger.info( - `Hotkey registered: "${config.id}" ` + - `(keyCode=${config.keyCode}, modifiers=[${config.modifiers.join(',')}], ` + - `holdMode=${config.holdMode}, doublePress=${config.doublePressEnabled}, ` + - `accelerator=${config.acceleratorString ?? 'N/A'})` - ) - } - - /** - * 핫키 등록을 해제한다. - */ - unregisterHotkey(id: string): void { - const existing = this._registeredHotkeys.get(id) - if (existing?.acceleratorString) { - try { - if (globalShortcut.isRegistered(existing.acceleratorString)) { - globalShortcut.unregister(existing.acceleratorString) - } - } catch (err) { - logger.warn( - `globalShortcut unregister failed for "${id}": ${err instanceof Error ? err.message : String(err)}` - ) - } - } - if (this._registeredHotkeys.delete(id)) { - this._lastPressTime.delete(id) - this._pressStartTime.delete(id) - this._isKeyDown.delete(id) - logger.info(`Hotkey unregistered: "${id}"`) - } - } - - /** - * ConfigService에서 핫키 설정을 로드하여 등록한다. - */ - loadFromConfig(): void { - const hotkeyEnabled = configGet('hotkeyEnabled') - if (!hotkeyEnabled) { - logger.info('Hotkeys disabled in config') - return - } - - const dictationBinding = configGet('dictationShortcut') - const handsFreeBinding = configGet('handsFreeShortcut') - const commandBinding = configGet('commandShortcut') - const captionBinding = configGet('captionShortcut') - - // 기존 핫키 초기화 - this._registeredHotkeys.clear() - - // Dictation: hold-to-talk, 더블프레스 비활성 - this.registerHotkey( - bindingToConfig('voice-dictation', dictationBinding, true, false) - ) - - // Hands-free: toggle, 더블프레스 활성 - this.registerHotkey( - bindingToConfig('voice-handsfree', handsFreeBinding, false, true) - ) - - // Command: toggle, 더블프레스 비활성 - this.registerHotkey( - bindingToConfig('voice-command', commandBinding, false, false) - ) - - // Caption: toggle (자막 모드 시작/정지), 더블프레스 비활성 - this.registerHotkey( - bindingToConfig('voice-caption', captionBinding, false, false) - ) - - logger.info( - `Loaded ${this._registeredHotkeys.size} hotkeys from config` - ) - } - - /** - * 서비스 리소스를 정리한다. - */ - dispose(): void { - this.stop() - this._registeredHotkeys.clear() - this._lastPressTime.clear() - this._pressStartTime.clear() - this._isKeyDown.clear() - this.removeAllListeners() - logger.info('HotkeyService disposed') - } - - // ============================================================ - // 내부: 키 이벤트 처리 - // ============================================================ - - /** - * 키 다운 이벤트를 처리한다. - * 등록된 핫키 중 매칭되는 것을 찾아 적절한 이벤트를 발행한다. - */ - private _handleKeyDown(e: UiohookKeyboardEvent): void { - const matched = this._findMatchingHotkey(e) - if (!matched) return - - const { id } = matched - - // 키 반복(auto-repeat) 무시: 이미 눌려있으면 건너뜀 - if (this._isKeyDown.get(id)) { - return - } - this._isKeyDown.set(id, true) - - const now = Date.now() - - // press 시작 시각 기록 (hold duration 계산용) - this._pressStartTime.set(id, now) - - // 더블프레스 감지 - if (matched.doublePressEnabled) { - const lastPress = this._lastPressTime.get(id) - - if (lastPress !== undefined && now - lastPress < TIMING.DOUBLE_PRESS_DURATION) { - // 300ms 이내 연속 두 번 press = 더블프레스 - this._lastPressTime.delete(id) - - logger.debug(`Double-press detected: "${id}" (interval=${now - lastPress}ms)`) - this.emit('double-press', { - config: matched, - intervalMs: now - lastPress, - timestamp: now - }) - return - } - - this._lastPressTime.set(id, now) - } - - logger.debug(`Hotkey pressed: "${id}"`) - this.emit('hotkey-pressed', { - config: matched, - timestamp: now - }) - } - - /** - * 키 업 이벤트를 처리한다. - * Alt+1 같은 조합에서 Alt를 먼저 놓아도 release 감지해야 한다. - * 전략: pressed 상태인 핫키 중 구성 키(main key 또는 modifier)가 놓아지면 release. - */ - private _handleKeyUp(e: UiohookKeyboardEvent): void { - // 방법 1: 정확한 매칭 시도 - const matched = this._findMatchingHotkey(e) - if (matched && this._isKeyDown.get(matched.id)) { - this._fireRelease(matched) - return - } - - // 방법 2: 현재 pressed인 핫키 중 구성 키가 놓아진 경우 (modifier 먼저 놓기 대응) - for (const config of this._registeredHotkeys.values()) { - if (!this._isKeyDown.get(config.id)) continue - - // 놓아진 키가 이 핫키의 main key인가? - const isMainKey = e.keycode === config.keyCode - // 놓아진 키가 이 핫키의 modifier 중 하나인가? - const isModifier = this._isEventModifierOf(e, config) - - if (isMainKey || isModifier) { - this._fireRelease(config) - return - } - } - } - - /** pressed 핫키의 release 이벤트 발행 */ - private _fireRelease(config: HotkeyConfig): void { - const { id } = config - this._isKeyDown.set(id, false) - - const now = Date.now() - const pressStart = this._pressStartTime.get(id) - const durationMs = pressStart !== undefined ? now - pressStart : 0 - this._pressStartTime.delete(id) - - logger.debug(`Hotkey released: "${id}" (duration=${durationMs}ms)`) - this.emit('hotkey-released', { - config, - durationMs, - timestamp: now - }) - } - - /** 키 이벤트가 핫키의 modifier 구성요소인지 확인 */ - private _isEventModifierOf(e: UiohookKeyboardEvent, config: HotkeyConfig): boolean { - const modifiers = config.modifiers ?? [] - for (const mod of modifiers) { - if (mod === 'ctrl' && (this._isCtrlKeyCode(e.keycode))) return true - if (mod === 'alt' && (this._isAltKeyCode(e.keycode))) return true - if (mod === 'shift' && (this._isShiftKeyCode(e.keycode))) return true - if (mod === 'meta' && (this._isMetaKeyCode(e.keycode))) return true - } - return false - } - - /** - * uiohook 키 이벤트와 등록된 핫키를 매칭한다. - * 키코드와 수정자 키가 모두 일치해야 매칭 성공. - */ - private _findMatchingHotkey(e: UiohookKeyboardEvent): HotkeyConfig | null { - for (const config of this._registeredHotkeys.values()) { - if (!config.enabled) continue - - // 키코드 일치 확인 - if (e.keycode !== config.keyCode) continue - - // 수정자 키 일치 확인 - // 핫키에 지정된 수정자가 모두 눌려 있어야 하고, - // 지정되지 않은 수정자는 눌려 있으면 안 된다. - // - // 단, 핫키의 주 키 자체가 수정자 키인 경우(예: Right Alt 단독): - // - 해당 수정자의 altKey 등이 true로 올 수 있으므로, - // modifiers에 해당 modifier가 **없을 때만** 이벤트의 해당 플래그를 무시한다. - // - modifiers에 해당 modifier가 **있으면** (예: Alt+Right Alt은 불가하므로) - // 해당 플래그가 true여야 매칭 성공. - // - // 예: Right Alt 단독 (keyCode=AltRight, modifiers=[]) - // → e.altKey가 true여도 wantsAlt=false이므로 bypass → match - // 예: Ctrl+Right Alt (keyCode=AltRight, modifiers=['ctrl']) - // → e.altKey bypass, e.ctrlKey===true 체크 → match only with Ctrl held - - const wantsCtrl = config.modifiers.includes('ctrl') - const wantsAlt = config.modifiers.includes('alt') - const wantsShift = config.modifiers.includes('shift') - const wantsMeta = config.modifiers.includes('meta') - - // 주 키 자체가 해당 modifier인 경우, 그 modifier의 이벤트 플래그는 무시 - const skipCtrl = this._isCtrlKeyCode(config.keyCode) && !wantsCtrl - const skipAlt = this._isAltKeyCode(config.keyCode) && !wantsAlt - const skipShift = this._isShiftKeyCode(config.keyCode) && !wantsShift - const skipMeta = this._isMetaKeyCode(config.keyCode) && !wantsMeta - - const ctrlMatch = skipCtrl || (e.ctrlKey === wantsCtrl) - const altMatch = skipAlt || (e.altKey === wantsAlt) - const shiftMatch = skipShift || (e.shiftKey === wantsShift) - const metaMatch = skipMeta || (e.metaKey === wantsMeta) - - if (ctrlMatch && altMatch && shiftMatch && metaMatch) { - return config - } - } - - return null - } - - private _isCtrlKeyCode(keyCode: number): boolean { - return keyCode === UiohookKey.Ctrl || keyCode === UiohookKey.CtrlRight - } - - private _isAltKeyCode(keyCode: number): boolean { - return keyCode === UiohookKey.Alt || keyCode === UiohookKey.AltRight - } - - private _isShiftKeyCode(keyCode: number): boolean { - return keyCode === UiohookKey.Shift || keyCode === UiohookKey.ShiftRight - } - - private _isMetaKeyCode(keyCode: number): boolean { - return keyCode === UiohookKey.Meta || keyCode === UiohookKey.MetaRight - } - - // ============================================================ - // EventEmitter 타입 오버라이드 - // ============================================================ - - override on( - event: K, - listener: HotkeyServiceEvents[K] - ): this { - return super.on(event, listener) - } - - override off( - event: K, - listener: HotkeyServiceEvents[K] - ): this { - return super.off(event, listener) - } - - override emit( - event: K, - ...args: Parameters - ): boolean { - return super.emit(event, ...args) - } -} - -// ============================================================ -// 싱글톤 -// ============================================================ - -let instance: HotkeyService | null = null - -export function getHotkeyService(): HotkeyService { - if (!instance) { - instance = new HotkeyService() - } - return instance -} diff --git a/apps/desktop/src/main/services/KeyBindingService.ts b/apps/desktop/src/main/services/KeyBindingService.ts new file mode 100644 index 0000000..727ab50 --- /dev/null +++ b/apps/desktop/src/main/services/KeyBindingService.ts @@ -0,0 +1,766 @@ +// src/main/services/KeyBindingService.ts +// uiohook-napi 기반 글로벌 키바인딩 후킹 서비스. +// +// 바인딩 계약(키 목록·기본값·정규화·검증·충돌 판정)의 정본은 `packages/core/src/keybinding.ts` 다. +// 이 파일은 두 가지만 책임진다: +// 1) 정본 좌표계(Windows VK / MouseButton) ↔ uiohook 이벤트 좌표계 변환 +// 2) press / release · 더블프레스 · auto-repeat 런타임 상태 머신 +// +// 런타임 상태는 액션 id 가 아니라 bindingKey() 로 키잉한다 — +// 한 액션에 여러 바인딩이 붙고, 여러 액션이 한 바인딩을 공유하기 때문이다. + +import { EventEmitter } from 'events' +import { globalShortcut } from 'electron' +import { uIOhook, UiohookKey } from 'uiohook-napi' +import type { UiohookKeyboardEvent, UiohookMouseEvent } from 'uiohook-napi' +import { getLogger } from './LoggerService' +import { configGet } from './ConfigService' +import { D3ROError, ErrorCode } from '@d3ro/core/errors' +import { TIMING } from '@d3ro/core/constants' +import { + KEYBINDING_ACTIONS, + MouseButton, + VK, + bindingKey, + normalizeBinding +} from '@d3ro/core/keybinding' +import type { + KeyBinding, + KeyBindingActionId, + KeyBindingActionSpec, + MouseButtonCode +} from '@d3ro/core/types' + +const logger = getLogger('KeyBindingService') + +// ============================================================ +// 이벤트 페이로드 +// ============================================================ + +export interface KeyBindingTriggerPayload { + actionId: KeyBindingActionId + type: 'pressed' | 'released' + /** 더블프레스로 트리거된 누름인지 */ + isDoublePress: boolean + /** KEYBINDING_ACTIONS 의 holdMode — 소비자가 hold-to-talk / 토글을 분기하는 데 쓴다 */ + holdMode: boolean + timestamp: number + /** type === 'released' 일 때의 누름 유지 시간 (pressed 는 0) */ + durationMs: number +} + +interface KeyBindingServiceEvents { + triggered: (payload: KeyBindingTriggerPayload) => void + error: (payload: { error: D3ROError }) => void +} + +// ============================================================ +// Windows VK ↔ uiohook 키코드 +// ============================================================ + +/** + * Windows Virtual-Key 코드 → uiohook-napi 키코드. + * 저장·IPC 의 KeyBinding.code 는 VK 이므로 uiohook 이벤트와 비교하려면 변환이 필요하다. + * 여기에 없는 키는 uiohook 이벤트로 관측할 수 없으므로 등록 시점에 건너뛴다. + */ +const VK_TO_UIOHOOK: ReadonlyMap = new Map([ + // 수정자 키 + [VK.ShiftLeft, UiohookKey.Shift], + [VK.ShiftRight, UiohookKey.ShiftRight], + [VK.CtrlLeft, UiohookKey.Ctrl], + [VK.CtrlRight, UiohookKey.CtrlRight], + [VK.AltLeft, UiohookKey.Alt], + [VK.AltRight, UiohookKey.AltRight], + [VK.MetaLeft, UiohookKey.Meta], + [VK.MetaRight, UiohookKey.MetaRight], + + // 기능 키 F1~F24 + [0x70, UiohookKey.F1], + [0x71, UiohookKey.F2], + [0x72, UiohookKey.F3], + [0x73, UiohookKey.F4], + [0x74, UiohookKey.F5], + [0x75, UiohookKey.F6], + [0x76, UiohookKey.F7], + [0x77, UiohookKey.F8], + [0x78, UiohookKey.F9], + [0x79, UiohookKey.F10], + [0x7a, UiohookKey.F11], + [0x7b, UiohookKey.F12], + [0x7c, UiohookKey.F13], + [0x7d, UiohookKey.F14], + [0x7e, UiohookKey.F15], + [0x7f, UiohookKey.F16], + [0x80, UiohookKey.F17], + [0x81, UiohookKey.F18], + [0x82, UiohookKey.F19], + [0x83, UiohookKey.F20], + [0x84, UiohookKey.F21], + [0x85, UiohookKey.F22], + [0x86, UiohookKey.F23], + [0x87, UiohookKey.F24], + + // 편집 키 + [VK.Space, UiohookKey.Space], + [VK.Enter, UiohookKey.Enter], + [VK.Escape, UiohookKey.Escape], + [VK.Backspace, UiohookKey.Backspace], + [VK.Tab, UiohookKey.Tab], + [VK.CapsLock, UiohookKey.CapsLock], + + // 내비게이션 키 + [VK.Insert, UiohookKey.Insert], + [VK.Delete, UiohookKey.Delete], + [VK.Home, UiohookKey.Home], + [VK.End, UiohookKey.End], + [VK.PageUp, UiohookKey.PageUp], + [VK.PageDown, UiohookKey.PageDown], + [VK.ArrowLeft, UiohookKey.ArrowLeft], + [VK.ArrowUp, UiohookKey.ArrowUp], + [VK.ArrowRight, UiohookKey.ArrowRight], + [VK.ArrowDown, UiohookKey.ArrowDown], + + // 알파벳 (VK_A=0x41 ~ VK_Z=0x5A) + [0x41, UiohookKey.A], + [0x42, UiohookKey.B], + [0x43, UiohookKey.C], + [0x44, UiohookKey.D], + [0x45, UiohookKey.E], + [0x46, UiohookKey.F], + [0x47, UiohookKey.G], + [0x48, UiohookKey.H], + [0x49, UiohookKey.I], + [0x4a, UiohookKey.J], + [0x4b, UiohookKey.K], + [0x4c, UiohookKey.L], + [0x4d, UiohookKey.M], + [0x4e, UiohookKey.N], + [0x4f, UiohookKey.O], + [0x50, UiohookKey.P], + [0x51, UiohookKey.Q], + [0x52, UiohookKey.R], + [0x53, UiohookKey.S], + [0x54, UiohookKey.T], + [0x55, UiohookKey.U], + [0x56, UiohookKey.V], + [0x57, UiohookKey.W], + [0x58, UiohookKey.X], + [0x59, UiohookKey.Y], + [0x5a, UiohookKey.Z], + + // 숫자 (VK_0=0x30 ~ VK_9=0x39) + [0x30, UiohookKey['0']], + [0x31, UiohookKey['1']], + [0x32, UiohookKey['2']], + [0x33, UiohookKey['3']], + [0x34, UiohookKey['4']], + [0x35, UiohookKey['5']], + [0x36, UiohookKey['6']], + [0x37, UiohookKey['7']], + [0x38, UiohookKey['8']], + [0x39, UiohookKey['9']], + + // 넘패드 + [VK.Numpad0, UiohookKey.Numpad0], + [0x61, UiohookKey.Numpad1], + [0x62, UiohookKey.Numpad2], + [0x63, UiohookKey.Numpad3], + [0x64, UiohookKey.Numpad4], + [0x65, UiohookKey.Numpad5], + [0x66, UiohookKey.Numpad6], + [0x67, UiohookKey.Numpad7], + [0x68, UiohookKey.Numpad8], + [VK.Numpad9, UiohookKey.Numpad9], + [VK.NumpadMultiply, UiohookKey.NumpadMultiply], + [VK.NumpadAdd, UiohookKey.NumpadAdd], + [VK.NumpadSubtract, UiohookKey.NumpadSubtract], + [VK.NumpadDecimal, UiohookKey.NumpadDecimal], + [VK.NumpadDivide, UiohookKey.NumpadDivide], + + // 문장부호 + [VK.Semicolon, UiohookKey.Semicolon], + [VK.Equal, UiohookKey.Equal], + [VK.Comma, UiohookKey.Comma], + [VK.Minus, UiohookKey.Minus], + [VK.Period, UiohookKey.Period], + [VK.Slash, UiohookKey.Slash], + [VK.Backquote, UiohookKey.Backquote], + [VK.BracketLeft, UiohookKey.BracketLeft], + [VK.Backslash, UiohookKey.Backslash], + [VK.BracketRight, UiohookKey.BracketRight], + [VK.Quote, UiohookKey.Quote], + + // 시스템 키 + [VK.PrintScreen, UiohookKey.PrintScreen], + [VK.ScrollLock, UiohookKey.ScrollLock], + [VK.NumLock, UiohookKey.NumLock] +]) + +const UIOHOOK_TO_VK: ReadonlyMap = new Map( + Array.from(VK_TO_UIOHOOK, ([vk, uiohookCode]) => [uiohookCode, vk] as const) +) + +function isCtrlCode(code: number): boolean { + return code === UiohookKey.Ctrl || code === UiohookKey.CtrlRight +} + +function isAltCode(code: number): boolean { + return code === UiohookKey.Alt || code === UiohookKey.AltRight +} + +function isShiftCode(code: number): boolean { + return code === UiohookKey.Shift || code === UiohookKey.ShiftRight +} + +function isMetaCode(code: number): boolean { + return code === UiohookKey.Meta || code === UiohookKey.MetaRight +} + +// ============================================================ +// 마우스 버튼 좁히기 +// ============================================================ + +const MOUSE_BUTTON_CODES: readonly MouseButtonCode[] = [ + MouseButton.Left, + MouseButton.Right, + MouseButton.Middle, + MouseButton.Back, + MouseButton.Forward +] + +/** + * uiohook-napi 의 `UiohookMouseEvent.button` 은 타입이 `unknown` 이다. + * libuiohook 은 MOUSE_BUTTON1..5 를 숫자로 싣고 보내므로 여기서 좁힌다. + */ +function readMouseButton(event: UiohookMouseEvent): MouseButtonCode | null { + const raw: unknown = event.button + if (typeof raw !== 'number' || !Number.isInteger(raw)) return null + const found = MOUSE_BUTTON_CODES.find((code) => code === raw) + return found ?? null +} + +// ============================================================ +// Electron accelerator 변환 (macOS beep 차단 전용) +// ============================================================ + +function vkToAcceleratorKey(vk: number): string | null { + if (vk >= VK.Digit0 && vk <= VK.Digit9) return String.fromCharCode(vk) + if (vk >= VK.A && vk <= VK.Z) return String.fromCharCode(vk) + if (vk >= VK.F1 && vk <= VK.F24) return `F${vk - VK.F1 + 1}` + + switch (vk) { + case VK.Space: return 'Space' + case VK.Enter: return 'Return' + case VK.Escape: return 'Esc' + case VK.Backspace: return 'Backspace' + case VK.Tab: return 'Tab' + case VK.Insert: return 'Insert' + case VK.Delete: return 'Delete' + case VK.Home: return 'Home' + case VK.End: return 'End' + case VK.PageUp: return 'PageUp' + case VK.PageDown: return 'PageDown' + case VK.ArrowLeft: return 'Left' + case VK.ArrowUp: return 'Up' + case VK.ArrowRight: return 'Right' + case VK.ArrowDown: return 'Down' + case VK.PrintScreen: return 'PrintScreen' + case VK.NumLock: return 'Numlock' + case VK.ScrollLock: return 'Scrolllock' + case VK.Semicolon: return ';' + case VK.Equal: return '=' + case VK.Comma: return ',' + case VK.Minus: return '-' + case VK.Period: return '.' + case VK.Slash: return '/' + case VK.Backquote: return '`' + case VK.BracketLeft: return '[' + case VK.Backslash: return '\\' + case VK.BracketRight: return ']' + case VK.Quote: return "'" + default: return null + } +} + +/** + * 키보드 바인딩 → Electron globalShortcut accelerator. + * + * globalShortcut 은 **macOS 시스템 beep 차단 용도로만** 쓴다. + * OS 가 키를 swallow 하게 만들어 focused app 으로 전달되지 않게 하고, + * 실제 press/release 처리는 uiohook 이 그대로 담당한다. + * 수정자 키 자체를 주 키로 쓰는 바인딩(예: Right Alt 단독)은 accelerator 로 표현할 수 없다. + */ +function bindingToAccelerator(binding: KeyBinding): string | null { + const key = vkToAcceleratorKey(binding.code) + if (key === null) return null + + const parts: string[] = [] + if (binding.ctrl) parts.push('Control') + if (binding.alt) parts.push('Alt') + if (binding.shift) parts.push('Shift') + if (binding.meta) parts.push(process.platform === 'darwin' ? 'Cmd' : 'Super') + parts.push(key) + return parts.join('+') +} + +// ============================================================ +// 내부 타입 +// ============================================================ + +interface RegisteredBinding { + actionId: KeyBindingActionId + spec: KeyBindingActionSpec + /** 정본 좌표계 바인딩 (정규화 완료) */ + binding: KeyBinding + /** keyboard → uiohook 키코드 / mouse → MouseButton 코드 */ + eventCode: number + /** macOS beep 차단용 accelerator. 마우스 바인딩과 변환 불가 키는 null */ + accelerator: string | null +} + +interface ActiveTrigger { + actionId: KeyBindingActionId + isDoublePress: boolean + holdMode: boolean +} + +// ============================================================ +// KeyBindingService +// ============================================================ + +class KeyBindingService extends EventEmitter { + private _isRunning = false + + /** bindingKey → 그 바인딩을 쓰는 액션들. 매칭은 이 역인덱스 조회로 끝난다. */ + private _byBindingKey: Map = new Map() + + /** 이 서비스가 직접 등록한 accelerator만 추적한다 (다른 곳의 등록을 해제하지 않기 위해) */ + private _ownedAccelerators: Set = new Set() + + /** 키 반복(auto-repeat) 방지: 현재 눌려있는 바인딩 */ + private _isKeyDown: Map = new Map() + + /** hold duration 계산용: press 시작 시각 */ + private _pressStartTime: Map = new Map() + + /** 더블프레스 감지용: 마지막 press 시각 */ + private _lastPressTime: Map = new Map() + + /** 현재 누름에서 실제로 트리거된 액션 — release 를 같은 대상에게만 보낸다 */ + private _activeTriggers: Map = new Map() + + private _onKeyDown: ((e: UiohookKeyboardEvent) => void) | null = null + private _onKeyUp: ((e: UiohookKeyboardEvent) => void) | null = null + private _onMouseDown: ((e: UiohookMouseEvent) => void) | null = null + private _onMouseUp: ((e: UiohookMouseEvent) => void) | null = null + + get isRunning(): boolean { + return this._isRunning + } + + /** 등록된 바인딩 수 (bindingKey 기준) */ + get registeredBindingCount(): number { + return this._byBindingKey.size + } + + // ── 수명 주기 ────────────────────────────────────────── + + /** + * uiohook 글로벌 후킹을 시작한다. 이미 실행 중이면 무시. + * + * mousemove / wheel / click 은 구독하지 않는다 — 고빈도이고 바인딩 대상이 아니다. + */ + start(): void { + if (this._isRunning) { + logger.warn('KeyBindingService already running') + return + } + + try { + this._onKeyDown = (e: UiohookKeyboardEvent) => this._handleKeyDown(e) + this._onKeyUp = (e: UiohookKeyboardEvent) => this._handleKeyUp(e) + this._onMouseDown = (e: UiohookMouseEvent) => this._handleMouseDown(e) + this._onMouseUp = (e: UiohookMouseEvent) => this._handleMouseUp(e) + + uIOhook.on('keydown', this._onKeyDown) + uIOhook.on('keyup', this._onKeyUp) + uIOhook.on('mousedown', this._onMouseDown) + uIOhook.on('mouseup', this._onMouseUp) + uIOhook.start() + + this._isRunning = true + this._syncAccelerators() + logger.info('uiohook started, global keyboard/mouse hook active') + } catch (error) { + const d3roError = new D3ROError( + ErrorCode.HotkeyHookInitFailed, + `Failed to start uiohook: ${error instanceof Error ? error.message : String(error)}` + ) + this.emit('error', { error: d3roError }) + logger.error(d3roError.message) + } + } + + /** uiohook 글로벌 후킹을 중지한다. 이 서비스가 등록한 accelerator만 해제한다. */ + stop(): void { + if (!this._isRunning) return + + try { + uIOhook.stop() + + if (this._onKeyDown) { + uIOhook.removeListener('keydown', this._onKeyDown) + this._onKeyDown = null + } + if (this._onKeyUp) { + uIOhook.removeListener('keyup', this._onKeyUp) + this._onKeyUp = null + } + if (this._onMouseDown) { + uIOhook.removeListener('mousedown', this._onMouseDown) + this._onMouseDown = null + } + if (this._onMouseUp) { + uIOhook.removeListener('mouseup', this._onMouseUp) + this._onMouseUp = null + } + + this._isRunning = false + this._clearRuntimeState() + this._unregisterOwnedAccelerators() + + logger.info('uiohook stopped') + } catch (error) { + logger.error( + `Failed to stop uiohook: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + dispose(): void { + this.stop() + this._byBindingKey.clear() + this._unregisterOwnedAccelerators() + this._clearRuntimeState() + this.removeAllListeners() + logger.info('KeyBindingService disposed') + } + + // ── 등록 ─────────────────────────────────────────────── + + /** ConfigService 의 keyBindings 를 읽어 역인덱스를 다시 만든다. */ + loadFromConfig(): void { + this._byBindingKey.clear() + this._clearRuntimeState() + + if (!configGet('hotkeyEnabled')) { + this._unregisterOwnedAccelerators() + logger.info('Key bindings disabled in config') + return + } + + const map = configGet('keyBindings') + for (const spec of KEYBINDING_ACTIONS) { + for (const raw of map[spec.id] ?? []) { + this._register(spec, raw) + } + } + + this._syncAccelerators() + logger.info( + `Loaded ${this._byBindingKey.size} key binding(s) from config ` + + `for ${KEYBINDING_ACTIONS.length} action(s)` + ) + } + + private _register(spec: KeyBindingActionSpec, raw: KeyBinding): void { + const binding = normalizeBinding(raw) + + let eventCode: number | null + if (binding.device === 'mouse') { + eventCode = binding.code + } else { + eventCode = VK_TO_UIOHOOK.get(binding.code) ?? null + } + + if (eventCode === null) { + logger.warn( + `Key code 0x${binding.code.toString(16)} has no uiohook equivalent ` + + `— binding for action "${spec.id}" is not registered` + ) + return + } + + const key = bindingKey(binding) + const entry: RegisteredBinding = { + actionId: spec.id, + spec, + binding, + eventCode, + accelerator: binding.device === 'keyboard' ? bindingToAccelerator(binding) : null + } + + const existing = this._byBindingKey.get(key) + if (existing === undefined) { + this._byBindingKey.set(key, [entry]) + return + } + // 같은 액션이 같은 바인딩을 두 번 들고 있으면 한 번 누를 때 두 번 트리거된다. + if (existing.some((registered) => registered.actionId === spec.id)) return + existing.push(entry) + } + + // ── accelerator (macOS beep 차단) ────────────────────── + + private _syncAccelerators(): void { + this._unregisterOwnedAccelerators() + if (!this._isRunning) return + + for (const entries of this._byBindingKey.values()) { + // 같은 bindingKey 를 공유하는 엔트리는 바인딩이 동일하므로 accelerator 도 같다. + const accelerator = entries[0]?.accelerator + if (accelerator === undefined || accelerator === null) continue + if (this._ownedAccelerators.has(accelerator)) continue + + try { + const ok = globalShortcut.register(accelerator, () => { + // noop — press/release 는 uiohook 이 책임진다. 등록 목적은 OS 의 키 swallow 다. + }) + if (ok) { + this._ownedAccelerators.add(accelerator) + } else { + logger.warn(`globalShortcut.register returned false for "${accelerator}"`) + } + } catch (err) { + logger.warn( + `globalShortcut.register failed for "${accelerator}": ${err instanceof Error ? err.message : String(err)}` + ) + } + } + } + + private _unregisterOwnedAccelerators(): void { + for (const accelerator of this._ownedAccelerators) { + try { + globalShortcut.unregister(accelerator) + } catch (err) { + logger.warn( + `globalShortcut.unregister failed for "${accelerator}": ${err instanceof Error ? err.message : String(err)}` + ) + } + } + this._ownedAccelerators.clear() + } + + private _clearRuntimeState(): void { + this._isKeyDown.clear() + this._pressStartTime.clear() + this._lastPressTime.clear() + this._activeTriggers.clear() + } + + // ── 이벤트 → 바인딩 매칭 ─────────────────────────────── + + /** + * uiohook 이벤트를 정본 좌표계 bindingKey 로 옮긴다. + * normalizeBinding 이 "주 키가 수정자 자신"인 경우를 정리하므로 + * Right Alt 단독 바인딩도 그대로 매칭된다. + */ + private _keyboardEventKey(e: UiohookKeyboardEvent): string | null { + const vk = UIOHOOK_TO_VK.get(e.keycode) + if (vk === undefined) return null + return bindingKey({ + device: 'keyboard', + code: vk, + ctrl: e.ctrlKey, + alt: e.altKey, + shift: e.shiftKey, + meta: e.metaKey + }) + } + + private _mouseEventKey(e: UiohookMouseEvent): string | null { + const button = readMouseButton(e) + if (button === null) return null + return bindingKey({ + device: 'mouse', + code: button, + ctrl: e.ctrlKey, + alt: e.altKey, + shift: e.shiftKey, + meta: e.metaKey + }) + } + + private _handleKeyDown(e: UiohookKeyboardEvent): void { + const key = this._keyboardEventKey(e) + if (key === null) return + this._handlePress(key) + } + + private _handleMouseDown(e: UiohookMouseEvent): void { + const key = this._mouseEventKey(e) + if (key === null) return + this._handlePress(key) + } + + /** + * 키 업. + * Alt+1 같은 조합에서 수정자를 먼저 놓아도 release 를 놓치지 않아야 하므로, + * 정확 매칭이 실패하면 눌림 상태인 바인딩의 구성 키가 놓였는지도 확인한다. + */ + private _handleKeyUp(e: UiohookKeyboardEvent): void { + const key = this._keyboardEventKey(e) + if (key !== null && this._isKeyDown.get(key) === true) { + this._fireRelease(key) + return + } + this._releaseByComponentKey(e.keycode) + } + + private _handleMouseUp(e: UiohookMouseEvent): void { + const key = this._mouseEventKey(e) + if (key !== null && this._isKeyDown.get(key) === true) { + this._fireRelease(key) + } + } + + /** 눌림 상태인 바인딩 중 놓인 키가 주 키/수정자인 것을 release 시킨다. */ + private _releaseByComponentKey(uiohookCode: number): void { + for (const [key, entries] of this._byBindingKey) { + if (this._isKeyDown.get(key) !== true) continue + const entry = entries[0] + if (entry === undefined) continue + + const isMainKey = + entry.binding.device === 'keyboard' && entry.eventCode === uiohookCode + if (isMainKey || this._isModifierComponent(uiohookCode, entry.binding)) { + this._fireRelease(key) + return + } + } + } + + private _isModifierComponent(uiohookCode: number, binding: KeyBinding): boolean { + if (binding.ctrl && isCtrlCode(uiohookCode)) return true + if (binding.alt && isAltCode(uiohookCode)) return true + if (binding.shift && isShiftCode(uiohookCode)) return true + if (binding.meta && isMetaCode(uiohookCode)) return true + return false + } + + // ── press / release ──────────────────────────────────── + + private _handlePress(key: string): void { + const entries = this._byBindingKey.get(key) + if (entries === undefined || entries.length === 0) return + + // 키 반복(auto-repeat) 무시 + if (this._isKeyDown.get(key) === true) return + this._isKeyDown.set(key, true) + + const now = Date.now() + this._pressStartTime.set(key, now) + + // 더블프레스 감지는 이 바인딩을 쓰는 액션 중 하나라도 doublePress 일 때만 한다. + // 그렇지 않으면 단일프레스 액션을 빠르게 두 번 누를 때 두 번째가 삼켜진다. + let isDoublePress = false + if (entries.some((entry) => entry.spec.doublePress)) { + const lastPress = this._lastPressTime.get(key) + isDoublePress = + lastPress !== undefined && now - lastPress < TIMING.DOUBLE_PRESS_DURATION + if (isDoublePress) { + this._lastPressTime.delete(key) + } else { + this._lastPressTime.set(key, now) + } + } + + // dictation(단일) 과 hands-free(더블) 는 같은 바인딩을 공유한다 — + // 첫 매치만 반환하지 않고 doublePress 여부로 갈라 해당하는 액션을 모두 트리거한다. + const triggers: ActiveTrigger[] = [] + for (const entry of entries) { + if (entry.spec.doublePress !== isDoublePress) continue + triggers.push({ + actionId: entry.actionId, + isDoublePress, + holdMode: entry.spec.holdMode + }) + } + this._activeTriggers.set(key, triggers) + + for (const trigger of triggers) { + logger.debug(`Key binding pressed: "${trigger.actionId}" (double=${isDoublePress})`) + this.emit('triggered', { + actionId: trigger.actionId, + type: 'pressed', + isDoublePress: trigger.isDoublePress, + holdMode: trigger.holdMode, + timestamp: now, + durationMs: 0 + }) + } + } + + private _fireRelease(key: string): void { + this._isKeyDown.set(key, false) + + const now = Date.now() + const pressStart = this._pressStartTime.get(key) + const durationMs = pressStart !== undefined ? now - pressStart : 0 + this._pressStartTime.delete(key) + + const triggers = this._activeTriggers.get(key) ?? [] + this._activeTriggers.delete(key) + + for (const trigger of triggers) { + logger.debug( + `Key binding released: "${trigger.actionId}" (duration=${durationMs}ms)` + ) + this.emit('triggered', { + actionId: trigger.actionId, + type: 'released', + isDoublePress: trigger.isDoublePress, + holdMode: trigger.holdMode, + timestamp: now, + durationMs + }) + } + } + + // ── EventEmitter 타입 오버라이드 ─────────────────────── + + override on( + event: K, + listener: KeyBindingServiceEvents[K] + ): this { + return super.on(event, listener) + } + + override off( + event: K, + listener: KeyBindingServiceEvents[K] + ): this { + return super.off(event, listener) + } + + override emit( + event: K, + ...args: Parameters + ): boolean { + return super.emit(event, ...args) + } +} + +// ============================================================ +// 싱글톤 +// ============================================================ + +let instance: KeyBindingService | null = null + +export function getKeyBindingService(): KeyBindingService { + if (!instance) { + instance = new KeyBindingService() + } + return instance +} diff --git a/apps/desktop/src/main/services/VoiceModeService.ts b/apps/desktop/src/main/services/VoiceModeService.ts index c8e8b72..f2e18fc 100644 --- a/apps/desktop/src/main/services/VoiceModeService.ts +++ b/apps/desktop/src/main/services/VoiceModeService.ts @@ -13,15 +13,15 @@ import { getAudioCaptureService } from './AudioCaptureService' import { getLocalSTTService } from './LocalSTTService' import type { TranscriptionResult } from './LocalSTTService' import { getSTTManager } from './stt/STTManager' -import { getHotkeyService } from './HotkeyService' -import type { HotkeyConfig } from './HotkeyService' +import { getKeyBindingService } from './KeyBindingService' +import type { KeyBindingTriggerPayload } from './KeyBindingService' import { configGet } from './ConfigService' import { getTextInsertService } from './TextInsertService' import { getLocalLLMService } from './LocalLLMService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { TIMING } from '@d3ro/core/constants' import { RecognitionState, AudioState } from '@d3ro/core/types' -import type { VoiceMode, VoiceState, LLMAction } from '@d3ro/core/types' +import type { KeyBindingActionId, VoiceMode, VoiceState, LLMAction } from '@d3ro/core/types' import { showRecordingTip, hideRecordingTip, @@ -55,7 +55,9 @@ interface VoiceAction { type: 'press' | 'release' | 'escape' timestamp: number mode: VoiceMode - hotkeyId: string + actionId: KeyBindingActionId + /** hold-to-talk 여부 — release 에서 세션을 끊을지 결정한다 */ + holdMode: boolean } interface VoiceModeEvents { @@ -147,9 +149,7 @@ class VoiceModeService extends EventEmitter { // 리스너 해제용 참조 private _audioDataHandler: ((payload: { buffer: Buffer }) => void) | null = null private _audioLevelHandler: ((payload: { level: number }) => void) | null = null - private _hotkeyPressHandler: ((payload: { config: HotkeyConfig; timestamp: number }) => void) | null = null - private _hotkeyReleaseHandler: ((payload: { config: HotkeyConfig; durationMs: number; timestamp: number }) => void) | null = null - private _doublePressHandler: ((payload: { config: HotkeyConfig }) => void) | null = null + private _keyBindingHandler: ((payload: KeyBindingTriggerPayload) => void) | null = null private _disposed = false @@ -174,39 +174,39 @@ class VoiceModeService extends EventEmitter { // ── 초기화 ────────────────────────────────────────────── /** - * HotkeyService 이벤트를 구독하여 핫키 → 세션 제어를 연결한다. + * KeyBindingService 이벤트를 구독하여 키바인딩 → 세션 제어를 연결한다. * bootstrap에서 호출한다. */ - connectHotkey(): void { - const hotkey = getHotkeyService() - - this._hotkeyPressHandler = (payload) => { - // Phase 10.1: caption 핫키는 VoiceModeService가 아닌 CaptionService로 라우팅 - if (payload.config.id === 'voice-caption') { - this._toggleCaption() - return + connectKeyBindings(): void { + this._keyBindingHandler = (payload) => { + switch (payload.actionId) { + case 'caption': + // Phase 10.1: caption은 VoiceModeService가 아닌 CaptionService로 라우팅. + // 토글이므로 press만 처리하고 release는 버린다. + if (payload.type === 'pressed') this._toggleCaption() + return + case 'history-popup': + case 'command-popup': + // 팝업 액션은 bootstrap이 직접 구독한다. + return + default: + break } - const mode = this._resolveMode(payload.config) - this._enqueueAction({ type: 'press', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id }) + + const holdMode = this._resolveHoldMode(payload.actionId, payload.holdMode) + const action: VoiceAction = { + type: payload.type === 'pressed' ? 'press' : 'release', + timestamp: payload.timestamp, + mode: this._resolveMode(payload.actionId, payload.isDoublePress), + actionId: payload.actionId, + holdMode + } + this._enqueueAction(action) } - this._hotkeyReleaseHandler = (payload) => { - // caption 핫키의 release는 무시 (토글 방식) - if (payload.config.id === 'voice-caption') return - const mode = this._resolveMode(payload.config) - this._enqueueAction({ type: 'release', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id }) - } + getKeyBindingService().on('triggered', this._keyBindingHandler) - this._doublePressHandler = (payload) => { - // 더블프레스 → hands-free 모드 토글 - this._enqueueAction({ type: 'press', timestamp: Date.now(), mode: 'hands-free', hotkeyId: payload.config.id }) - } - - hotkey.on('hotkey-pressed', this._hotkeyPressHandler) - hotkey.on('hotkey-released', this._hotkeyReleaseHandler) - hotkey.on('double-press', this._doublePressHandler) - - logger.info('Hotkey events connected') + logger.info('Key binding events connected') } // ── 세션 제어 ────────────────────────────────────────── @@ -1050,24 +1050,34 @@ class VoiceModeService extends EventEmitter { } } - private async _handleRelease(_action: VoiceAction): Promise { - if (_action.mode === 'dictation' && this.isActive) { + private async _handleRelease(action: VoiceAction): Promise { + if (action.holdMode && action.mode === 'dictation' && this.isActive) { // Dictation: hold-to-talk — release로 즉시 종료 // Speakly 패턴: 딜레이 없이 즉시 stop (딜레이가 race condition 유발) await this.stopSession() } - // HandsFree: release 무시 + // HandsFree(토글): release 무시 } // ── 유틸리티 ─────────────────────────────────────────── - private _resolveMode(config: HotkeyConfig): VoiceMode { - if (config.id === 'voice-handsfree' || config.doublePressEnabled) { + private _resolveMode(actionId: KeyBindingActionId, isDoublePress: boolean): VoiceMode { + if (actionId === 'hands-free' || isDoublePress) { return 'hands-free' } return 'dictation' } + /** + * 'command' 액션에는 아직 전용 핸들러가 없다. + * 현행 동작대로 dictation 파이프라인으로 fallback 하며, 그 경로는 hold-to-talk 이므로 + * KEYBINDING_ACTIONS 의 holdMode(false)가 아니라 dictation 과 같은 값을 쓴다. + */ + private _resolveHoldMode(actionId: KeyBindingActionId, specHoldMode: boolean): boolean { + if (actionId === 'command') return true + return specHoldMode + } + // ── 자막 모드 토글 (Phase 10.1) ───────────────────────── private async _toggleCaption(): Promise { @@ -1111,16 +1121,10 @@ class VoiceModeService extends EventEmitter { this._cancelSession('user') } - // 핫키 리스너 해제 - const hotkey = getHotkeyService() - if (this._hotkeyPressHandler) { - hotkey.off('hotkey-pressed', this._hotkeyPressHandler) - } - if (this._hotkeyReleaseHandler) { - hotkey.off('hotkey-released', this._hotkeyReleaseHandler) - } - if (this._doublePressHandler) { - hotkey.off('double-press', this._doublePressHandler) + // 키바인딩 리스너 해제 + if (this._keyBindingHandler) { + getKeyBindingService().off('triggered', this._keyBindingHandler) + this._keyBindingHandler = null } // 오디오 리스너 해제 diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 788bbbf..c269e97 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -44,10 +44,14 @@ import type { SetSTTProviderConfigParams, TestSTTConnectionParams, TestSTTConnectionResult, - HotkeyBinding, - SetHotkeyParams, + KeyBindingMap, + SetKeyBindingsParams, + ResetKeyBindingParams, + ValidateKeyBindingParams, + KeyBindingValidationResult, + KeyBindingTriggeredEvent, + KeyBindingChangedEvent, SetEnabledParams, - HotkeyTriggeredEvent, LLMStatus, LLMModel, LLMProcessParams, @@ -315,29 +319,23 @@ const electronAPI = { on(IPC_CHANNELS.RUNTIME.PROGRESS, cb) }, - // ── Hotkey ───────────────────────────────────────────── - hotkey: { - getDictationShortcut: () => - invoke(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT), - setDictationShortcut: (params: SetHotkeyParams) => - invoke(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, params), - getHandsFreeShortcut: () => - invoke(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT), - setHandsFreeShortcut: (params: SetHotkeyParams) => - invoke(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, params), - getCommandShortcut: () => - invoke(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT), - setCommandShortcut: (params: SetHotkeyParams) => - invoke(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, params), - getCaptionShortcut: () => - invoke(IPC_CHANNELS.HOTKEY.GET_CAPTION_SHORTCUT), - setCaptionShortcut: (params: SetHotkeyParams) => - invoke(IPC_CHANNELS.HOTKEY.SET_CAPTION_SHORTCUT, params), - isEnabled: () => invoke(IPC_CHANNELS.HOTKEY.IS_ENABLED), + // ── Key bindings ─────────────────────────────────────── + keybinding: { + getMap: () => invoke(IPC_CHANNELS.KEYBINDING.GET_MAP), + setBindings: (params: SetKeyBindingsParams) => + invoke(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, params), + resetAction: (params: ResetKeyBindingParams) => + invoke(IPC_CHANNELS.KEYBINDING.RESET_ACTION, params), + resetAll: () => invoke(IPC_CHANNELS.KEYBINDING.RESET_ALL), + validate: (params: ValidateKeyBindingParams) => + invoke(IPC_CHANNELS.KEYBINDING.VALIDATE, params), + isEnabled: () => invoke(IPC_CHANNELS.KEYBINDING.IS_ENABLED), setEnabled: (params: SetEnabledParams) => - invoke(IPC_CHANNELS.HOTKEY.SET_ENABLED, params), - onTriggered: (cb: (e: HotkeyTriggeredEvent) => void): Unsubscribe => - on(IPC_CHANNELS.HOTKEY.TRIGGERED, cb) + invoke(IPC_CHANNELS.KEYBINDING.SET_ENABLED, params), + onTriggered: (cb: (e: KeyBindingTriggeredEvent) => void): Unsubscribe => + on(IPC_CHANNELS.KEYBINDING.TRIGGERED, cb), + onChanged: (cb: (e: KeyBindingChangedEvent) => void): Unsubscribe => + on(IPC_CHANNELS.KEYBINDING.CHANGED, cb) }, // ── LLM ──────────────────────────────────────────────── diff --git a/apps/desktop/src/renderer/components/HotkeyRecordModal.tsx b/apps/desktop/src/renderer/components/HotkeyRecordModal.tsx deleted file mode 100644 index 41854f7..0000000 --- a/apps/desktop/src/renderer/components/HotkeyRecordModal.tsx +++ /dev/null @@ -1,315 +0,0 @@ -// src/renderer/components/HotkeyRecordModal.tsx -// Speakly HotkeyRecordModal 패턴: Modal → 키 입력 대기 → Chip 표시 → 저장/취소 -// 조합키: modifier(Ctrl/Alt/Shift) 누른 상태에서 일반키 입력 → 조합 완성 -// 단일키: modifier만 눌렀다 놓으면 해당 modifier가 단독 핫키로 등록 - -import { useState, useEffect, useCallback, useRef } from 'react' -import { - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Box, - Button, - Chip, - Stack, - Typography, -} from '@mui/material' -import { d3roPalette, d3roRadius, d3roTypo } from '@d3ro/ui/theme' -import { useI18n } from '@d3ro/i18n' -import type { HotkeyBinding } from '@d3ro/core/types' -import { formatHotkeyLabel, getPlatform, keyCodeToName } from '../utils/format-hotkey' - -// 시스템 예약 조합 -const RESERVED_COMBOS = [ - 'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S', 'Ctrl+W', - 'Alt+F4', 'Alt+Tab', 'Ctrl+Alt+Delete', -] - -const MODIFIER_KEYCODES = new Set([16, 17, 18, 91, 92, 160, 161, 162, 163, 164, 165]) - -function getKeyName(keyCode: number, key: string): string { - // 플랫폼별 라벨 (macOS는 ⌘/⇧/⌃/⌥). format-hotkey.ts의 단일 매핑을 사용. - const platform = getPlatform() - const mapped = keyCodeToName(keyCode, platform) - if (mapped && !mapped.startsWith('Key')) return mapped - if (key.length === 1) return key.toUpperCase() - return key -} - -function isModifier(keyCode: number): boolean { - return MODIFIER_KEYCODES.has(keyCode) -} - -interface HotkeyRecordModalProps { - open: boolean - onClose: () => void - onSave: (binding: HotkeyBinding) => void - currentBinding?: HotkeyBinding | null - title?: string -} - -export function HotkeyRecordModal({ - open, - onClose, - onSave, - currentBinding, - title, -}: HotkeyRecordModalProps): React.ReactElement { - const { t } = useI18n() - // 현재 눌려있는 키들을 실시간 추적 - const [pressedKeys, setPressedKeys] = useState>([]) - // 확정된 조합 (녹화 완료 후) - const [captured, setCaptured] = useState | null>(null) - const [error, setError] = useState(null) - const pressedRef = useRef>(new Map()) - // modifier-only 확정을 위한 타이머 (Alt만 눌렀을 때 바로 확정하지 않고 잠시 대기) - const modifierTimerRef = useRef | null>(null) - // 가장 최근 pressedKeys 스냅샷 (타이머 콜백에서 stale closure 방지) - const lastPressedRef = useRef>([]) - - // Modal 열릴 때 리셋 - useEffect(() => { - if (open) { - setPressedKeys([]) - setCaptured(null) - setError(null) - pressedRef.current.clear() - lastPressedRef.current = [] - if (modifierTimerRef.current) { - clearTimeout(modifierTimerRef.current) - modifierTimerRef.current = null - } - } - }, [open]) - - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (captured) return // 이미 확정됨 - e.preventDefault() - e.stopPropagation() - - // 새 키가 눌렸으므로 modifier-only 타이머 취소 - if (modifierTimerRef.current) { - clearTimeout(modifierTimerRef.current) - modifierTimerRef.current = null - } - - const keyCode = e.keyCode || e.which - if (pressedRef.current.has(keyCode)) return - - const name = getKeyName(keyCode, e.key) - pressedRef.current.set(keyCode, name) - - const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n })) - setPressedKeys(keys) - lastPressedRef.current = keys - - // modifier가 아닌 키가 눌리면 → 조합 즉시 확정 (Alt+1, Ctrl+F5 등) - if (!isModifier(keyCode)) { - setCaptured(keys) - } - }, [captured]) - - const handleKeyUp = useCallback((e: KeyboardEvent) => { - if (captured) return // 이미 확정됨 - - const keyCode = e.keyCode || e.which - pressedRef.current.delete(keyCode) - - // 모든 키를 놓았고 modifier만 눌렀었다면 → 500ms 대기 후 확정 - // 이 대기 시간 동안 추가 키를 누르면 타이머가 취소되어 조합키로 확장 가능 - if (pressedRef.current.size === 0 && lastPressedRef.current.length > 0 && lastPressedRef.current.every(k => isModifier(k.keyCode))) { - if (modifierTimerRef.current) clearTimeout(modifierTimerRef.current) - modifierTimerRef.current = setTimeout(() => { - // 타이머 만료: 여전히 confirmed 안됐고 추가 키 입력 없으면 단일 modifier로 확정 - setCaptured(lastPressedRef.current) - modifierTimerRef.current = null - }, 500) - } - - const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n })) - setPressedKeys(keys) - }, [captured]) - - useEffect(() => { - if (!open) return - window.addEventListener('keydown', handleKeyDown, true) - window.addEventListener('keyup', handleKeyUp, true) - return () => { - window.removeEventListener('keydown', handleKeyDown, true) - window.removeEventListener('keyup', handleKeyUp, true) - } - }, [open, handleKeyDown, handleKeyUp]) - - const displayKeys = captured ?? pressedKeys - const isReady = captured !== null && captured.length > 0 - - const handleSave = () => { - if (!captured || captured.length === 0) { - setError(t('hotkey.noKey')) - return - } - - // 예약 단축키 체크 - const label = captured.map(k => k.name).join('+') - if (RESERVED_COMBOS.includes(label)) { - setError(t('hotkey.reserved', { keys: label })) - handleReset() - return - } - - // HotkeyBinding 생성 - // 주 키(main key) = modifier가 아닌 키가 있으면 그것, 없으면 첫 번째 modifier - const mainKey = captured.find(k => !isModifier(k.keyCode)) - const primaryKeyCode = mainKey?.keyCode ?? captured[0].keyCode - - // modifier 플래그: 주 키 자체가 해당 modifier인 경우 false 유지 - // 예: Right Alt 단독 → keyCode=165, alt=false (주 키가 Alt 자체이므로) - // 예: Ctrl+A → keyCode=A, ctrl=true (Ctrl은 modifier로 사용) - const isCtrlKey = (kc: number) => kc === 17 || kc === 162 || kc === 163 - const isAltKey = (kc: number) => kc === 18 || kc === 164 || kc === 165 - const isShiftKey = (kc: number) => kc === 16 || kc === 160 || kc === 161 - const isMetaKey = (kc: number) => kc === 91 || kc === 92 - - // 주 키를 제외한 나머지 키들만 modifier로 취급 - const modifierKeys = captured.filter(k => k.keyCode !== primaryKeyCode) - - const binding: HotkeyBinding = { - keyCode: primaryKeyCode, - ctrl: modifierKeys.some(k => isCtrlKey(k.keyCode)), - alt: modifierKeys.some(k => isAltKey(k.keyCode)), - shift: modifierKeys.some(k => isShiftKey(k.keyCode)), - meta: modifierKeys.some(k => isMetaKey(k.keyCode)), - displayLabel: label, - } - - onSave(binding) - onClose() - } - - const handleReset = () => { - setPressedKeys([]) - setCaptured(null) - pressedRef.current.clear() - } - - const handleCancel = () => { - handleReset() - onClose() - } - - return ( - - {title ?? t('hotkey.title')} - - {/* 녹화 영역 */} - 0 - ? d3roPalette.accent.main - : d3roPalette.border.strong - }`, - borderRadius: d3roRadius.inner, - p: 3, - minHeight: 80, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - bgcolor: d3roPalette.bg.inset, - transition: 'border-color 0.2s ease', - }} - > - {displayKeys.length > 0 ? ( - - {displayKeys.map((key, i) => ( - - ))} - - ) : ( - - {t('hotkey.prompt')} - - )} - - - {/* 상태 표시 */} - {isReady && !error && ( - - {t('hotkey.ready', { - keys: - captured - ?.map((k) => k.name) - .join(getPlatform() === 'darwin' ? '' : ' + ') ?? '' - })} - - )} - - {error && ( - - {error} - - )} - - {currentBinding && ( - - {t('hotkey.current', { keys: formatHotkeyLabel(currentBinding) })} - - )} - - - {t('hotkey.hint')} - - - - - {isReady && ( - - )} - - - - ) -} diff --git a/apps/desktop/src/renderer/components/SettingsModal.tsx b/apps/desktop/src/renderer/components/SettingsModal.tsx index 679265d..bd7472e 100644 --- a/apps/desktop/src/renderer/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/components/SettingsModal.tsx @@ -20,21 +20,21 @@ import { InputLabel, FormControl, Button, - Chip, - Stack, Paper, } from '@mui/material' -import { X, Keyboard, Pencil, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle } from 'lucide-react' +import { X, Keyboard, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle } from 'lucide-react' import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme' import { Led } from '@d3ro/ui/components/ds' -import { HotkeyRecordModal } from './HotkeyRecordModal' -import { formatHotkeyLabel, formatHotkeySegments } from '../utils/format-hotkey' import { LicenseTab } from './LicenseTab' import { CloudSyncSection } from './CloudSyncSection' import { STTTab } from './STTTab' +import { KeyBindingField } from './keybinding/KeyBindingField' +import { asTranslationKey } from './keybinding/translation-key' import { useI18n, LOCALE_META } from '@d3ro/i18n' import type { Locale } from '@d3ro/i18n' -import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types' +import type { KeyBindingActionGroup } from '@d3ro/core/keybinding' +import { KEYBINDING_ACTIONS } from '@d3ro/core/keybinding' +import type { ThemeMode, AppConfig, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types' interface SettingsModalProps { open: boolean @@ -53,135 +53,13 @@ function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement return {children} } -// ── 핫키 표시 컴포넌트 ────────────────────────────────── -function HotkeyDisplay({ - binding, - onEdit, - label, - notSetLabel, -}: { - binding: HotkeyBinding | null - onEdit: () => void - label: string - notSetLabel: string -}): React.ReactElement { - return ( - - - {label} - - {binding ? ( - - {formatHotkeySegments(binding).map((key, idx) => ( - - ))} - - ) : ( - - {notSetLabel} - - )} - - - - - ) +// ── 액션 그룹 섹션 ───────────────────────────────────── +const ACTION_GROUP_LABEL_KEYS: Readonly> = { + voice: 'keybinding.ui.sectionVoice', + window: 'keybinding.ui.sectionWindow', } -// ── 음성 모드 카드 ────────────────────────────────────── -function VoiceModeCard({ - title, - description, - enabled, - onToggle, - disabled, - enabledLabel, - disabledLabel, - children, -}: { - title: string - description: string - enabled: boolean - onToggle: (enabled: boolean) => void - disabled?: boolean - enabledLabel: string - disabledLabel: string - children?: React.ReactNode -}): React.ReactElement { - return ( - - - - - - {title} - - onToggle(e.target.checked)} - disabled={disabled} - size="small" - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.green }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { - backgroundColor: d3roPalette.tag.green, - }, - }} - /> - } - label={ - - } - sx={{ ml: 0, mr: 0 }} - /> - - - {description} - - - - {children && {children}} - - ) -} +const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window'] export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalProps): React.ReactElement { const { t, locale, setLocale } = useI18n() @@ -213,18 +91,8 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr } }, [open]) - // 음성 모드 상태 - const [dictationEnabled, setDictationEnabled] = useState(true) - const [dictationBinding, setDictationBinding] = useState(null) - const [handsFreeEnabled, setHandsFreeEnabled] = useState(false) - const [handsFreeBinding, setHandsFreeBinding] = useState(null) - const [captionBinding, setCaptionBinding] = useState(null) - const [, setHotkeyGlobalEnabled] = useState(true) - - // 핫키 녹화 모달 - const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false) - const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree' | 'caption'>('dictation') - + // 전역 단축키 on/off (개별 바인딩은 KeyBindingField 가 담당한다) + const [keybindingEnabled, setKeybindingEnabled] = useState(true) // 오디오 디바이스 const [audioDevices, setAudioDevices] = useState([]) @@ -261,20 +129,11 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr Promise.all([ window.electronAPI.config.getAll(), - window.electronAPI.hotkey.getDictationShortcut(), - window.electronAPI.hotkey.getHandsFreeShortcut(), - window.electronAPI.hotkey.getCaptionShortcut(), - window.electronAPI.hotkey.isEnabled(), + window.electronAPI.keybinding.isEnabled(), ]) - .then(([configResult, dictResult, hfResult, capResult, enabledResult]) => { + .then(([configResult, enabledResult]) => { if (configResult.success) setConfig(configResult.data) - if (dictResult.success && dictResult.data) setDictationBinding(dictResult.data) - if (hfResult.success && hfResult.data) setHandsFreeBinding(hfResult.data) - if (capResult.success && capResult.data) setCaptionBinding(capResult.data) - if (enabledResult.success) { - setHotkeyGlobalEnabled(enabledResult.data) - setDictationEnabled(enabledResult.data) - } + if (enabledResult.success) setKeybindingEnabled(enabledResult.data) }) .finally(() => setLoading(false)) @@ -293,48 +152,9 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr window.electronAPI.config.set({ key, value }) }, []) - const handleDictationToggle = useCallback( - (enabled: boolean) => { - setDictationEnabled(enabled) - window.electronAPI.hotkey.setEnabled({ enabled }) - if (!enabled && handsFreeEnabled) { - setHandsFreeEnabled(false) - } - }, - [handsFreeEnabled] - ) - - const handleHandsFreeToggle = useCallback((enabled: boolean) => { - if (enabled && !handsFreeBinding) { - setHotkeyModalTarget('handsFree') - setHotkeyModalOpen(true) - return - } - setHandsFreeEnabled(enabled) - }, [handsFreeBinding]) - - const handleHotkeySave = useCallback( - (binding: HotkeyBinding) => { - if (hotkeyModalTarget === 'dictation') { - setDictationBinding(binding) - window.electronAPI.hotkey.setDictationShortcut({ binding }) - setDictationEnabled(true) - window.electronAPI.hotkey.setEnabled({ enabled: true }) - } else if (hotkeyModalTarget === 'handsFree') { - setHandsFreeBinding(binding) - window.electronAPI.hotkey.setHandsFreeShortcut({ binding }) - setHandsFreeEnabled(true) - } else { - setCaptionBinding(binding) - window.electronAPI.hotkey.setCaptionShortcut({ binding }) - } - }, - [hotkeyModalTarget] - ) - - const openHotkeyModal = useCallback((target: 'dictation' | 'handsFree' | 'caption') => { - setHotkeyModalTarget(target) - setHotkeyModalOpen(true) + const handleKeybindingToggle = useCallback((enabled: boolean) => { + setKeybindingEnabled(enabled) + void window.electronAPI.keybinding.setEnabled({ enabled }) }, []) const handleLanguageChange = useCallback((newLocale: string) => { @@ -418,73 +238,48 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr {/* ── 일반 탭 ─────────────────────────────── */} - - {t('settings.shortcuts')} - - - - - openHotkeyModal('dictation')} - label={t('settings.key')} - notSetLabel={t('settings.notSet')} - /> - - - + + {t('settings.shortcuts')} + + handleKeybindingToggle(e.target.checked)} + /> + } + label={ + + {t('keybinding.ui.globalEnabled')} + } - enabled={dictationEnabled} - onToggle={handleDictationToggle} - disabled={!dictationEnabled} - enabledLabel={t('settings.enabled')} - disabledLabel={t('settings.disabled')} /> + - - openHotkeyModal('handsFree')} - label={t('settings.key')} - notSetLabel={t('settings.notSet')} - /> - - - - openHotkeyModal('caption')} - label={t('settings.key')} - notSetLabel={t('settings.notSet')} - /> - - + {ACTION_GROUP_ORDER.map((group) => { + const actions = KEYBINDING_ACTIONS.filter((action) => action.group === group) + if (actions.length === 0) return null + return ( + + + {t(asTranslationKey(ACTION_GROUP_LABEL_KEYS[group]))} + + {actions.map((action) => ( + + ))} + + ) + })} @@ -981,14 +776,6 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr - - setHotkeyModalOpen(false)} - onSave={handleHotkeySave} - currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : hotkeyModalTarget === 'handsFree' ? handsFreeBinding : captionBinding} - title={hotkeyModalTarget === 'dictation' ? t('hotkey.dictationTitle') : hotkeyModalTarget === 'handsFree' ? t('hotkey.oneTouchTitle') : t('hotkey.captionTitle')} - /> ) } diff --git a/apps/desktop/src/renderer/components/keybinding/KeyBindingField.tsx b/apps/desktop/src/renderer/components/keybinding/KeyBindingField.tsx new file mode 100644 index 0000000..7a47769 --- /dev/null +++ b/apps/desktop/src/renderer/components/keybinding/KeyBindingField.tsx @@ -0,0 +1,171 @@ +// apps/desktop/src/renderer/components/keybinding/KeyBindingField.tsx +// 액션 하나의 바인딩 목록 전체를 담당한다. 앱에서 키바인딩을 다루는 유일한 진입점이다. +// +// 라벨 · 설명 · 기본값은 KEYBINDING_ACTIONS 에서 파생하므로 호출부는 actionId 만 넘긴다. + +import { useCallback, useState } from 'react' +import { Box, IconButton, Tooltip, Typography } from '@mui/material' +import { Plus, RotateCcw, X } from 'lucide-react' +import { d3roPalette, d3roRadius, d3roShadow, d3roTypo } from '@d3ro/ui/theme' +import { TactileBadge } from '@d3ro/ui/components/ds' +import { useI18n } from '@d3ro/i18n' +import type { KeyBinding, KeyBindingActionId } from '@d3ro/core/keybinding' +import { bindingKey, bindingsEqual, findActionSpec } from '@d3ro/core/keybinding' +import { useKeyBindingMap } from '../../hooks/useKeyBindingMap' +import { BindingKeycaps, useBindingLabel } from './Keycap' +import { KeyBindingPicker } from './KeyBindingPicker' +import { asTranslationKey } from './translation-key' + +interface KeyBindingFieldProps { + actionId: KeyBindingActionId +} + +export function KeyBindingField({ actionId }: KeyBindingFieldProps): React.ReactElement | null { + const { t } = useI18n() + const map = useKeyBindingMap() + const bindingLabel = useBindingLabel() + const [pickerOpen, setPickerOpen] = useState(false) + const [failed, setFailed] = useState(false) + + const spec = findActionSpec(actionId) + const bindings = map === null ? [] : (map[actionId] ?? []) + + const commit = useCallback( + (next: KeyBinding[]) => { + void window.electronAPI.keybinding + .setBindings({ actionId, bindings: next }) + .then((result) => setFailed(!result.success)) + }, + [actionId] + ) + + const handleAdd = useCallback( + (binding: KeyBinding) => { + commit([...bindings, binding]) + }, + [bindings, commit] + ) + + const handleRemove = useCallback( + (binding: KeyBinding) => { + commit(bindings.filter((existing) => !bindingsEqual(existing, binding))) + }, + [bindings, commit] + ) + + const handleReset = useCallback(() => { + void window.electronAPI.keybinding + .resetAction({ actionId }) + .then((result) => setFailed(!result.success)) + }, [actionId]) + + if (spec === null) return null + + return ( + + + + {t(asTranslationKey(spec.labelKey))} + + {spec.holdMode && {t('keybinding.ui.holdMode')}} + {spec.doublePress && {t('keybinding.ui.doublePress')}} + + + + + + + + + {t(asTranslationKey(spec.descriptionKey))} + + + + {bindings.length === 0 && ( + + {t('keybinding.ui.noBindings')} + + )} + {bindings.map((binding) => ( + + + handleRemove(binding)} + aria-label={t('keybinding.ui.remove', { keys: bindingLabel(binding) })} + sx={{ color: d3roPalette.text.inactive, p: 0.25 }} + > + + + + ))} + + setPickerOpen(true)} + aria-label={t('keybinding.ui.add')} + sx={{ + color: d3roPalette.accent.main, + border: `1px dashed ${d3roPalette.border.strong}`, + borderRadius: d3roRadius.xs, + px: 1, + height: 22, + }} + > + + + {t('keybinding.ui.add')} + + + + + {failed && ( + + {t('keybinding.ui.saveFailed')} + + )} + + setPickerOpen(false)} + onConfirm={handleAdd} + /> + + ) +} diff --git a/apps/desktop/src/renderer/components/keybinding/KeyBindingPicker.tsx b/apps/desktop/src/renderer/components/keybinding/KeyBindingPicker.tsx new file mode 100644 index 0000000..aa28fc9 --- /dev/null +++ b/apps/desktop/src/renderer/components/keybinding/KeyBindingPicker.tsx @@ -0,0 +1,696 @@ +// apps/desktop/src/renderer/components/keybinding/KeyBindingPicker.tsx +// 바인딩 하나를 고르는 모달. 입력 방식이 두 가지다. +// +// 녹화 탭 — 실제로 키(또는 마우스 버튼)를 눌러 캡처한다. +// 목록 탭 — 검색 인풋이 달린 드롭다운에서 고르고 수정자를 토글한다. +// +// 키 목록 · 라벨 · 검증 · 충돌 판정은 전부 @d3ro/core/keybinding 이 정본이다. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + Autocomplete, + Box, + ButtonBase, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + Typography, +} from '@mui/material' +import type { AutocompleteRenderGroupParams } from '@mui/material' +import { Keyboard, ListFilter, MousePointerClick } from 'lucide-react' +import { d3roFontMono, d3roPalette, d3roRadius, d3roShadow, d3roTypo } from '@d3ro/ui/theme' +import { PhysicalButton, SegmentControl } from '@d3ro/ui/components/ds' +import { useI18n } from '@d3ro/i18n' +import type { + BindingPlatform, + KeyBinding, + KeyBindingActionId, + KeyBindingValidationResult, + KeyCatalogEntry, +} from '@d3ro/core/keybinding' +import { + KEY_CATALOG, + KEY_CATALOG_GROUP_LABEL_KEYS, + KEY_CATALOG_GROUP_ORDER, + MouseButton, + VK, + bindingsEqual, + findActionSpec, + formatBindingSegments, + isModifierKeyCode, + normalizeBinding, + searchKeyCatalog, + validateBinding, +} from '@d3ro/core/keybinding' +import { getPlatform } from '../../utils/format-hotkey' +import { BindingKeycaps, Keycap } from './Keycap' +import { asTranslationKey } from './translation-key' + +type PickerMode = 'record' | 'list' + +interface ModifierState { + ctrl: boolean + alt: boolean + shift: boolean + meta: boolean +} + +const NO_MODIFIERS: ModifierState = { ctrl: false, alt: false, shift: false, meta: false } + +const MODIFIER_SLOTS = ['ctrl', 'alt', 'shift', 'meta'] as const +type ModifierSlot = (typeof MODIFIER_SLOTS)[number] + +/** 단일 modifier 를 눌렀다 뗐을 때 조합으로 확장할 시간을 준다. */ +const MODIFIER_COMMIT_DELAY_MS = 500 + +/** + * 브라우저 MouseEvent.button → libuiohook MOUSE_BUTTON 코드. + * 두 체계의 가운데/오른쪽 순서가 서로 다르므로 그대로 쓰면 안 된다. + */ +const BROWSER_BUTTON_TO_MOUSE: Readonly> = { + 0: MouseButton.Left, + 1: MouseButton.Middle, + 2: MouseButton.Right, + 3: MouseButton.Back, + 4: MouseButton.Forward, +} + +const GROUP_RANK: ReadonlyMap = new Map( + KEY_CATALOG_GROUP_ORDER.map((group, index) => [group, index]) +) + +function byGroupOrder(a: KeyCatalogEntry, b: KeyCatalogEntry): number { + return (GROUP_RANK.get(a.group) ?? 0) - (GROUP_RANK.get(b.group) ?? 0) +} + +const CATALOG_OPTIONS: readonly KeyCatalogEntry[] = [...KEY_CATALOG].sort(byGroupOrder) + +/** + * 브라우저 KeyboardEvent 의 keyCode 를 Windows VK 로 맞춘다. + * + * keyCode 는 수정자를 좌/우 구분 없이 16/17/18 로 주는데 카탈로그는 좌/우 코드만 담고 있다. + * location 으로 보정하지 않으면 수정자 단독 바인딩이 전부 unknown-key 로 거부된다. + */ +function resolveKeyCode(event: KeyboardEvent): number { + const code = event.keyCode || event.which + const right = event.location === 2 + if (code === 16) return right ? VK.ShiftRight : VK.ShiftLeft + if (code === 17) return right ? VK.CtrlRight : VK.CtrlLeft + if (code === 18) return right ? VK.AltRight : VK.AltLeft + // macOS 오른쪽 Command 는 93 으로 온다 (Windows 의 93 은 ContextMenu 라 구분이 필요하다). + if (code === 93 && right) return VK.MetaRight + return code +} + +/** + * 눌린 키들 → 바인딩. + * + * 주 키는 수정자가 아닌 키가 있으면 그것, 없으면 **가장 나중에 누른** 수정자다. + * "Ctrl 누른 채 오른쪽 Alt" 가 `Ctrl + Right Alt` 로 잡히려면 마지막 것이어야 한다. + */ +function buildBinding(codes: readonly number[], mods: ModifierState): KeyBinding | null { + if (codes.length === 0) return null + const main = codes.find((code) => !isModifierKeyCode(code)) ?? codes[codes.length - 1] + return normalizeBinding({ device: 'keyboard', code: main, ...mods }) +} + +function probeBinding(slot: ModifierSlot): KeyBinding { + return { + device: 'keyboard', + code: VK.F1, + ctrl: slot === 'ctrl', + alt: slot === 'alt', + shift: slot === 'shift', + meta: slot === 'meta', + } +} + +/** 수정자 토글에 붙일 표기 — 플랫폼 표기 규칙도 코어가 정본이다. */ +function modifierLabel(slot: ModifierSlot, platform: BindingPlatform): string { + return formatBindingSegments(probeBinding(slot), platform)[0]?.label ?? '' +} + +interface KeyBindingPickerProps { + open: boolean + actionId: KeyBindingActionId + /** 이 액션에 이미 걸려 있는 바인딩 — 같은 것을 두 번 추가하지 못하게 한다. */ + existingBindings: readonly KeyBinding[] + onClose: () => void + onConfirm: (binding: KeyBinding) => void +} + +export function KeyBindingPicker({ + open, + actionId, + existingBindings, + onClose, + onConfirm, +}: KeyBindingPickerProps): React.ReactElement { + const { t } = useI18n() + const platform = getPlatform() + + const [mode, setMode] = useState('record') + const [recorded, setRecorded] = useState(null) + const [preview, setPreview] = useState(null) + const [listEntry, setListEntry] = useState(null) + const [listMods, setListMods] = useState(NO_MODIFIERS) + const [remote, setRemote] = useState(null) + const [remotePending, setRemotePending] = useState(false) + + const pressedRef = useRef>(new Set()) + const modsRef = useRef(NO_MODIFIERS) + const pendingRef = useRef<{ codes: number[]; binding: KeyBinding } | null>(null) + const commitTimerRef = useRef | null>(null) + + const clearCommitTimer = useCallback(() => { + if (commitTimerRef.current !== null) { + clearTimeout(commitTimerRef.current) + commitTimerRef.current = null + } + }, []) + + const resetRecording = useCallback(() => { + clearCommitTimer() + pressedRef.current.clear() + modsRef.current = NO_MODIFIERS + pendingRef.current = null + setRecorded(null) + setPreview(null) + }, [clearCommitTimer]) + + useEffect(() => { + if (!open) return + setMode('record') + setListEntry(null) + setListMods(NO_MODIFIERS) + setRemote(null) + setRemotePending(false) + resetRecording() + }, [open, actionId, resetRecording]) + + useEffect(() => clearCommitTimer, [clearCommitTimer]) + + // ── 키보드 녹화 ──────────────────────────────────────── + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (recorded !== null) return + event.preventDefault() + event.stopPropagation() + clearCommitTimer() + + const code = resolveKeyCode(event) + if (pressedRef.current.has(code)) return + pressedRef.current.add(code) + modsRef.current = { + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + meta: event.metaKey, + } + + const codes = [...pressedRef.current] + const binding = buildBinding(codes, modsRef.current) + if (binding === null) return + pendingRef.current = { codes, binding } + setPreview(binding) + + // 수정자가 아닌 키가 들어오면 조합이 완성된 것이므로 즉시 확정한다. + if (!isModifierKeyCode(code)) setRecorded(binding) + }, + [recorded, clearCommitTimer] + ) + + const handleKeyUp = useCallback( + (event: KeyboardEvent) => { + if (recorded !== null) return + pressedRef.current.delete(resolveKeyCode(event)) + if (pressedRef.current.size > 0) return + + const pending = pendingRef.current + if (pending === null) return + // 수정자만 눌렸다 놓인 경우에만 지연 확정한다. 그 사이 다른 키를 누르면 타이머가 취소되고 + // 조합키로 확장된다. + if (!pending.codes.every(isModifierKeyCode)) return + + clearCommitTimer() + commitTimerRef.current = setTimeout(() => { + setRecorded(pending.binding) + commitTimerRef.current = null + }, MODIFIER_COMMIT_DELAY_MS) + }, + [recorded, clearCommitTimer] + ) + + useEffect(() => { + // 목록 탭에서는 검색 인풋에 타이핑해야 하므로 캡처를 걸지 않는다. + if (!open || mode !== 'record') return + window.addEventListener('keydown', handleKeyDown, true) + window.addEventListener('keyup', handleKeyUp, true) + return () => { + window.removeEventListener('keydown', handleKeyDown, true) + window.removeEventListener('keyup', handleKeyUp, true) + } + }, [open, mode, handleKeyDown, handleKeyUp]) + + // ── 마우스 녹화 ──────────────────────────────────────── + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + if (recorded !== null) return + const code = BROWSER_BUTTON_TO_MOUSE[event.button] + if (code === undefined) return + event.preventDefault() + event.stopPropagation() + clearCommitTimer() + pressedRef.current.clear() + pendingRef.current = null + setPreview(null) + setRecorded( + normalizeBinding({ + device: 'mouse', + code, + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + meta: event.metaKey, + }) + ) + }, + [recorded, clearCommitTimer] + ) + + // ── 후보 바인딩과 검증 ───────────────────────────────── + const candidate = useMemo((): KeyBinding | null => { + if (mode === 'record') return recorded + if (listEntry === null) return null + return normalizeBinding({ device: listEntry.device, code: listEntry.code, ...listMods }) + }, [mode, recorded, listEntry, listMods]) + + const localValidation = useMemo( + () => (candidate === null ? null : validateBinding(candidate)), + [candidate] + ) + + useEffect(() => { + if (candidate === null || localValidation === null || !localValidation.valid) { + setRemote(null) + setRemotePending(false) + return + } + let cancelled = false + setRemotePending(true) + void window.electronAPI.keybinding + .validate({ actionId, binding: candidate }) + .then((result) => { + if (cancelled) return + setRemote(result.success ? result.data : null) + setRemotePending(false) + }) + return () => { + cancelled = true + } + }, [candidate, localValidation, actionId]) + + const conflicts = remote?.conflicts ?? [] + const remoteValidation = remote?.validation ?? null + const isDuplicate = + candidate !== null && existingBindings.some((existing) => bindingsEqual(existing, candidate)) + const rejectionKey = localValidation?.reasonKey ?? remoteValidation?.reasonKey ?? null + const warningKey = remoteValidation?.warningKey ?? localValidation?.warningKey ?? null + const canConfirm = + candidate !== null && + !isDuplicate && + localValidation?.valid === true && + remoteValidation?.valid === true && + conflicts.length === 0 && + !remotePending + + // ── 목록 탭 ──────────────────────────────────────────── + const localizedLabels = useMemo(() => { + const out: Record = {} + for (const entry of KEY_CATALOG) { + if (entry.labelKey === undefined) continue + out[`${entry.device}:${entry.code}`] = t(asTranslationKey(entry.labelKey)) + } + return out + }, [t]) + + const entryText = useCallback( + (entry: KeyCatalogEntry): string => + entry.labelKey === undefined ? entry.label : t(asTranslationKey(entry.labelKey)), + [t] + ) + + /** 키캡에 찍힐 표기 — formatBindingSegments() 가 확정 후에 쓰는 규칙과 같아야 한다. */ + const entryKeycapText = useCallback( + (entry: KeyCatalogEntry): string => { + if (entry.labelKey !== undefined) return t(asTranslationKey(entry.labelKey)) + return platform === 'darwin' ? (entry.macLabel ?? entry.label) : entry.label + }, + [t, platform] + ) + + const actionSpec = findActionSpec(actionId) + const actionName = actionSpec === null ? '' : t(asTranslationKey(actionSpec.labelKey)) + + const handleConfirm = (): void => { + if (candidate === null || !canConfirm) return + onConfirm(candidate) + onClose() + } + + const renderGroupSection = (params: AutocompleteRenderGroupParams): React.ReactElement => ( +
  • + + {t( + asTranslationKey( + KEY_CATALOG_GROUP_LABEL_KEYS[params.group as keyof typeof KEY_CATALOG_GROUP_LABEL_KEYS] + ) + )} + + + {params.children} + +
  • + ) + + return ( + + + {t('keybinding.ui.pickerTitle', { action: actionName })} + + + + + + value={mode} + onChange={setMode} + size="small" + options={[ + { value: 'record', label: t('keybinding.ui.tabRecord'), icon: }, + { value: 'list', label: t('keybinding.ui.tabList'), icon: }, + ]} + /> + + + {mode === 'record' ? ( + event.preventDefault()} + sx={{ + border: `2px solid ${ + rejectionKey !== null + ? d3roPalette.tag.red + : recorded !== null + ? d3roPalette.tag.green + : preview !== null + ? d3roPalette.accent.main + : d3roPalette.border.strong + }`, + borderRadius: d3roRadius.inner, + bgcolor: d3roPalette.bg.inset, + p: 3, + minHeight: 104, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 1.25, + cursor: 'pointer', + userSelect: 'none', + transition: 'border-color 0.2s ease', + }} + > + {candidate !== null ? ( + + ) : preview !== null ? ( + + ) : ( + <> + + + {t('keybinding.ui.pressKeys')} + + + )} + + ) : ( + + + + {t('keybinding.ui.modifiers')} + + + {MODIFIER_SLOTS.map((slot) => { + const active = listMods[slot] + return ( + setListMods((prev) => ({ ...prev, [slot]: !prev[slot] }))} + sx={{ + borderRadius: d3roRadius.xs, + px: 1.25, + height: 30, + fontFamily: d3roFontMono, + fontSize: d3roTypo.small.size, + fontWeight: 500, + letterSpacing: '0.04em', + color: active ? d3roPalette.text.primary : d3roPalette.text.inactive, + bgcolor: active ? d3roPalette.accent.dim : d3roPalette.bg.chassis, + border: `1px solid ${ + active ? d3roPalette.accent.main : d3roPalette.border.default + }`, + boxShadow: active ? d3roShadow.inset : 'none', + transition: 'all 0.15s ease', + }} + > + {modifierLabel(slot, platform)} + + ) + })} + + + + {t('keybinding.ui.selectedKey')} + + {candidate !== null && } + + + + + open + options={CATALOG_OPTIONS as KeyCatalogEntry[]} + value={listEntry} + onChange={(_, value) => setListEntry(value)} + groupBy={(option) => option.group} + getOptionLabel={entryText} + getOptionDisabled={(option) => option.disabledReasonKey !== undefined} + isOptionEqualToValue={(a, b) => a.device === b.device && a.code === b.code} + filterOptions={(_, state) => + [...searchKeyCatalog(state.inputValue, localizedLabels)].sort(byGroupOrder) + } + noOptionsText={t('keybinding.ui.searchEmpty')} + renderGroup={renderGroupSection} + renderOption={(props, option) => { + const { key, ...liProps } = props + const keycapText = entryKeycapText(option) + // 키캡에 찍히는 표기와 다를 때만 로케일 무관 원표기를 보조로 보여준다. + const secondary = keycapText === option.label ? null : option.label + return ( + + + {keycapText} + + + {secondary} + + {option.disabledReasonKey !== undefined && ( + + {t(asTranslationKey(option.disabledReasonKey))} + + )} + + ) + }} + renderInput={(params) => ( + + )} + slotProps={{ + popper: { placement: 'bottom-start', modifiers: [{ name: 'flip', enabled: false }] }, + paper: { + sx: { + bgcolor: d3roPalette.bg.chassis, + backgroundImage: 'none', + border: `1px solid ${d3roPalette.border.default}`, + borderRadius: d3roRadius.inner, + boxShadow: d3roShadow.card, + }, + }, + listbox: { sx: { maxHeight: 260, py: 0 } }, + }} + /> + + )} + + + {rejectionKey !== null && ( + + {t(asTranslationKey(rejectionKey))} + + )} + {isDuplicate && ( + + {t('keybinding.ui.duplicate')} + + )} + {conflicts.map((conflict) => { + const spec = findActionSpec(conflict.actionId) + return ( + + {t('keybinding.ui.conflictWith', { + action: spec === null ? conflict.actionId : t(asTranslationKey(spec.labelKey)), + })} + + ) + })} + {rejectionKey === null && warningKey !== null && ( + + {t(asTranslationKey(warningKey))} + + )} + {mode === 'record' && candidate === null && ( + + {t('keybinding.ui.recordHint')} + + )} + + + + + + {t('common.cancel')} + + {mode === 'record' && recorded !== null && ( + + {t('keybinding.ui.recordAgain')} + + )} + + {t('common.save')} + + + + ) +} diff --git a/apps/desktop/src/renderer/components/keybinding/Keycap.tsx b/apps/desktop/src/renderer/components/keybinding/Keycap.tsx new file mode 100644 index 0000000..fc7ef76 --- /dev/null +++ b/apps/desktop/src/renderer/components/keybinding/Keycap.tsx @@ -0,0 +1,116 @@ +// apps/desktop/src/renderer/components/keybinding/Keycap.tsx +// 키캡 표기 SSOT. 대시보드 · 설정 · 피커가 전부 이 컴포넌트로 키를 그린다. +// +// 표기할 문자열은 @d3ro/core/keybinding 의 formatBindingSegments() 가 정본이다 — +// 여기서는 그리기만 한다. + +import { Box } from '@mui/material' +import { d3roFontMono, d3roPalette, d3roRadius, d3roShadow, d3roTypo } from '@d3ro/ui/theme' +import { useI18n } from '@d3ro/i18n' +import type { BindingSegment, KeyBinding } from '@d3ro/core/keybinding' +import { formatBindingSegments, joinBindingSegments } from '@d3ro/core/keybinding' +import { getPlatform } from '../../utils/format-hotkey' +import { asTranslationKey } from './translation-key' + +export type KeycapSize = 'sm' | 'md' | 'lg' + +const SIZE_SPEC = { + sm: { height: 22, fontSize: d3roTypo.label.size, px: 0.85, radius: d3roRadius.xs }, + md: { height: 28, fontSize: d3roTypo.small.size, px: 1.1, radius: d3roRadius.xs }, + lg: { height: 38, fontSize: d3roTypo.body.size, px: 1.6, radius: d3roRadius.small }, +} as const + +interface KeycapProps { + children: React.ReactNode + size?: KeycapSize + /** 선택 불가 항목을 흐리게 표시 */ + muted?: boolean +} + +export function Keycap({ children, size = 'md', muted = false }: KeycapProps): React.ReactElement { + const spec = SIZE_SPEC[size] + return ( + + {children} + + ) +} + +/** 세그먼트 하나를 표시 문자열로 — i18nKey 가 있으면 번역이 우선이다. */ +function useSegmentText(): (segment: BindingSegment) => string { + const { t } = useI18n() + return (segment) => + segment.i18nKey === undefined ? segment.label : t(asTranslationKey(segment.i18nKey)) +} + +/** 바인딩 하나 → 결합된 한 줄 문자열. 접근성 라벨 · 본문 문구용. */ +export function useBindingLabel(): (binding: KeyBinding | null) => string { + const segmentText = useSegmentText() + const platform = getPlatform() + return (binding) => + joinBindingSegments(formatBindingSegments(binding, platform).map(segmentText), platform) +} + +interface BindingKeycapsProps { + binding: KeyBinding + size?: KeycapSize + muted?: boolean +} + +/** 바인딩 하나 → 키캡 나열. Windows/Linux 는 키캡 사이에 `+` 를 넣는다. */ +export function BindingKeycaps({ + binding, + size = 'md', + muted = false, +}: BindingKeycapsProps): React.ReactElement { + const segmentText = useSegmentText() + const platform = getPlatform() + const segments = formatBindingSegments(binding, platform) + const showSeparator = platform !== 'darwin' + + return ( + + {segments.map((segment, index) => ( + + {index > 0 && showSeparator && ( + + + + + )} + + {segmentText(segment)} + + + ))} + + ) +} diff --git a/apps/desktop/src/renderer/components/keybinding/translation-key.ts b/apps/desktop/src/renderer/components/keybinding/translation-key.ts new file mode 100644 index 0000000..0566903 --- /dev/null +++ b/apps/desktop/src/renderer/components/keybinding/translation-key.ts @@ -0,0 +1,9 @@ +// apps/desktop/src/renderer/components/keybinding/translation-key.ts +// @d3ro/core/keybinding 은 i18n 키를 평범한 문자열로 노출한다(코어가 로케일 패키지에 의존하지 +// 않게 하려는 의도). t() 에 넘기는 경계에서만 TranslationKey 로 좁힌다. + +import type { TranslationKey } from '@d3ro/i18n' + +export function asTranslationKey(key: string): TranslationKey { + return key as TranslationKey +} diff --git a/apps/desktop/src/renderer/hooks/useKeyBindingMap.ts b/apps/desktop/src/renderer/hooks/useKeyBindingMap.ts new file mode 100644 index 0000000..74ecb68 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useKeyBindingMap.ts @@ -0,0 +1,44 @@ +// apps/desktop/src/renderer/hooks/useKeyBindingMap.ts +// 렌더러 쪽 바인딩 맵 캐시. +// +// 액션마다 KeyBindingField 가 하나씩 붙으므로 컴포넌트별로 getMap() 을 부르면 같은 맵을 +// 액션 수만큼 가져오게 된다. 구독을 모듈 스코프에 한 벌만 두고 changed 이벤트로 갱신한다. + +import { useEffect, useState } from 'react' +import type { KeyBindingMap } from '@d3ro/core/keybinding' + +type Listener = (map: KeyBindingMap) => void + +let cached: KeyBindingMap | null = null +let subscribed = false +const listeners = new Set() + +function publish(map: KeyBindingMap): void { + cached = map + for (const listener of listeners) listener(map) +} + +function ensureSubscribed(): void { + if (subscribed) return + subscribed = true + window.electronAPI.keybinding.onChanged((event) => publish(event.map)) + void window.electronAPI.keybinding.getMap().then((result) => { + if (result.success) publish(result.data) + }) +} + +/** 맵이 아직 도착하지 않았으면 null. */ +export function useKeyBindingMap(): KeyBindingMap | null { + const [map, setMap] = useState(cached) + + useEffect(() => { + listeners.add(setMap) + ensureSubscribed() + if (cached !== null) setMap(cached) + return () => { + listeners.delete(setMap) + } + }, []) + + return map +} diff --git a/apps/desktop/src/renderer/pages/DashboardPage.tsx b/apps/desktop/src/renderer/pages/DashboardPage.tsx index 0ba41db..46d454f 100644 --- a/apps/desktop/src/renderer/pages/DashboardPage.tsx +++ b/apps/desktop/src/renderer/pages/DashboardPage.tsx @@ -33,12 +33,13 @@ import { AudioVisualizerBar, } from '@d3ro/ui/components/ds' import { EmptyStateCard, HistoryEntryCard } from '../components/shared' -import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow, d3roTypo } from '@d3ro/ui/theme' +import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters' -import { formatHotkeyLabel } from '../utils/format-hotkey' +import { BindingKeycaps } from '../components/keybinding/Keycap' +import { useKeyBindingMap } from '../hooks/useKeyBindingMap' import { FileDropZone } from '../components/FileDropZone' -import type { StatsSummary, HistoryEntry, HotkeyBinding, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types' +import type { StatsSummary, HistoryEntry, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types' /** 카드 섹션 헤더 (제목 + 우측 액세서리) — 헤어라인 하단 구분 */ function CardHeader({ @@ -100,7 +101,8 @@ export function DashboardPage(): React.ReactElement { const [history, setHistory] = useState([]) const [ollamaConnected, setOllamaConnected] = useState(false) const [llmModel, setLlmModel] = useState(null) - const [dictationBinding, setDictationBinding] = useState(null) + const bindingMap = useKeyBindingMap() + const dictationBinding = bindingMap?.dictation[0] ?? null const [captionState, setCaptionState] = useState('inactive') const [audioLevel, setAudioLevel] = useState(0) const audioDecayRef = useRef | null>(null) @@ -122,9 +124,6 @@ export function DashboardPage(): React.ReactElement { window.electronAPI.history.getAll({ page: 0, pageSize: 10, sortOrder: 'desc' }).then((r) => { if (r.success) setHistory(r.data.entries) }) - window.electronAPI.hotkey.getDictationShortcut().then((r) => { - if (r.success && r.data) setDictationBinding(r.data) - }) window.electronAPI.caption.getState().then((r) => { if (r.success) setCaptionState(r.data) }) @@ -145,8 +144,8 @@ export function DashboardPage(): React.ReactElement { const unsub = window.electronAPI.app.onDataChanged(() => { loadData() }) - const unsubCaption = window.electronAPI.caption.onStateChanged((state) => { - setCaptionState(state) + const unsubCaption = window.electronAPI.caption.onStateChanged((e) => { + setCaptionState(e.state) }) const unsubAudio = window.electronAPI.voice.onAudioLevel((e) => { setAudioLevel(e.level) @@ -272,27 +271,9 @@ export function DashboardPage(): React.ReactElement { - {dictationBinding ? t('dashboard.pressToRecord', { key: '' }) : t('dashboard.hotkeyNotSet')} + {dictationBinding ? t('keybinding.ui.pressToRecord') : t('dashboard.hotkeyNotSet')} - {dictationBinding && ( - - {formatHotkeyLabel(dictationBinding).toUpperCase()} - - )} + {dictationBinding && } diff --git a/apps/desktop/src/renderer/utils/format-hotkey.ts b/apps/desktop/src/renderer/utils/format-hotkey.ts index 0210ebc..debeaec 100644 --- a/apps/desktop/src/renderer/utils/format-hotkey.ts +++ b/apps/desktop/src/renderer/utils/format-hotkey.ts @@ -1,13 +1,12 @@ // apps/desktop/src/renderer/utils/format-hotkey.ts -// HotkeyBinding → 플랫폼별 표시 라벨 (segments + 결합 문자열). -// macOS: ⌘/⌥/⌃/⇧ + key, Windows/Linux: Ctrl/Alt/Win/Shift + key. +// 렌더러가 실행 중인 플랫폼을 알아내는 얇은 어댑터. // -// 기존 binding.displayLabel은 키 녹화 시 만들어진 정적 문자열이라 -// 플랫폼이 다르면 잘못 보임 — 이 헬퍼가 단일 진입점. +// 키 이름 · 수정자 표기 · 결합 규칙의 정본은 @d3ro/core/keybinding 이다 +// (formatBindingSegments / joinBindingSegments). 매핑 테이블을 여기에 두지 않는다. -import type { HotkeyBinding } from '@d3ro/core/types' +import type { BindingPlatform } from '@d3ro/core/keybinding' -export type Platform = 'darwin' | 'win32' | 'linux' +export type Platform = BindingPlatform export function getPlatform(): Platform { const p = (window as { electronAPI?: { platform?: string } }).electronAPI?.platform @@ -16,104 +15,3 @@ export function getPlatform(): Platform { if (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform)) return 'darwin' return 'win32' } - -// keyCode → 키명. Windows VK_* 기준 + 일부 macOS 분기. -export function keyCodeToName(keyCode: number, platform: Platform = getPlatform()): string { - // Modifier 표기 — 플랫폼별 - if (platform === 'darwin') { - if (keyCode === 16 || keyCode === 160 || keyCode === 161) return '⇧' - if (keyCode === 17 || keyCode === 162 || keyCode === 163) return '⌃' - if (keyCode === 18 || keyCode === 164 || keyCode === 165) return '⌥' - if (keyCode === 91 || keyCode === 92) return '⌘' - } else { - if (keyCode === 16 || keyCode === 160 || keyCode === 161) return 'Shift' - if (keyCode === 17 || keyCode === 162 || keyCode === 163) return 'Ctrl' - if (keyCode === 18 || keyCode === 164 || keyCode === 165) return 'Alt' - if (keyCode === 91 || keyCode === 92) return 'Win' - } - - // 공통 키 - switch (keyCode) { - case 8: return 'Backspace' - case 9: return 'Tab' - case 13: return platform === 'darwin' ? '↩' : 'Enter' - case 19: return 'Pause' - case 20: return 'CapsLock' - case 27: return 'Esc' - case 32: return 'Space' - case 33: return 'PgUp' - case 34: return 'PgDn' - case 35: return 'End' - case 36: return 'Home' - case 37: return '←' - case 38: return '↑' - case 39: return '→' - case 40: return '↓' - case 45: return 'Insert' - case 46: return platform === 'darwin' ? '⌫' : 'Delete' - case 186: return ';' - case 187: return '=' - case 188: return ',' - case 189: return '-' - case 190: return '.' - case 191: return '/' - case 192: return '`' - case 219: return '[' - case 220: return '\\' - case 221: return ']' - case 222: return "'" - default: break - } - - // F1~F24 - if (keyCode >= 112 && keyCode <= 135) return `F${keyCode - 111}` - // Numpad - if (keyCode >= 96 && keyCode <= 105) return `Num${keyCode - 96}` - // 알파벳/숫자 - if ((keyCode >= 48 && keyCode <= 57) || (keyCode >= 65 && keyCode <= 90)) { - return String.fromCharCode(keyCode) - } - - return `Key${keyCode}` -} - -/** - * binding을 플랫폼별 segment 배열로 변환. - * 예) Mac: ['⌘', '⇧', '1'], Win: ['Win', 'Shift', '1'] - * - * 순서: - * - macOS: ⌃ ⌥ ⇧ ⌘ + key (Apple 표준) - * - Win/Linux: Ctrl Win Alt Shift + key - */ -export function formatHotkeySegments(binding: HotkeyBinding | null): string[] { - if (!binding) return [] - const platform = getPlatform() - const segments: string[] = [] - - if (platform === 'darwin') { - if (binding.ctrl) segments.push('⌃') - if (binding.alt) segments.push('⌥') - if (binding.shift) segments.push('⇧') - if (binding.meta) segments.push('⌘') - } else { - if (binding.ctrl) segments.push('Ctrl') - if (binding.meta) segments.push('Win') - if (binding.alt) segments.push('Alt') - if (binding.shift) segments.push('Shift') - } - - segments.push(keyCodeToName(binding.keyCode, platform)) - return segments -} - -/** - * 사람이 읽기 위한 단일 문자열 형태. - * macOS: '⌘⇧1' (구분자 없음 — Apple 표준) - * Win/Linux: 'Ctrl + Shift + 1' - */ -export function formatHotkeyLabel(binding: HotkeyBinding | null): string { - const segments = formatHotkeySegments(binding) - if (segments.length === 0) return '' - const platform = getPlatform() - return platform === 'darwin' ? segments.join('') : segments.join(' + ') -} diff --git a/apps/desktop/tests/e2e/red_team_cycle3.spec.ts b/apps/desktop/tests/e2e/red_team_cycle3.spec.ts index 54af8c6..81f6a2a 100644 --- a/apps/desktop/tests/e2e/red_team_cycle3.spec.ts +++ b/apps/desktop/tests/e2e/red_team_cycle3.spec.ts @@ -200,7 +200,7 @@ test.describe.serial('Extreme Red Team - Cycle 3: Modals, Deep Configuration & S expect(uncaughtExceptions).toEqual([]); }); - test('RT-11: HotkeyRecordModal - Interactive Hotkey Recording & Conflict Protection', async () => { + test('RT-11: KeyBindingPicker - Key Recording, Reserved-Combo Protection & Cancel Safety', async () => { // 1. Re-open SettingsModal to General Tab const settingsTrigger = window.getByText('설정', { exact: true }); await settingsTrigger.click(); @@ -211,34 +211,114 @@ test.describe.serial('Extreme Red Team - Cycle 3: Modals, Deep Configuration & S await generalTab.click(); await window.waitForTimeout(400); - // 2. Click pencil icon on Dictation shortcut to open HotkeyRecordModal - const editHotkeyBtn = window.locator('button:has(svg.lucide-pencil)').first(); - await expect(editHotkeyBtn).toBeVisible(); - await editHotkeyBtn.click(); - await window.waitForTimeout(500); + // 2. The dictation row is rendered by KeyBindingField off KEYBINDING_ACTIONS + const dictationField = window.getByTestId('keybinding-field-dictation'); + await expect(dictationField).toBeVisible({ timeout: 5000 }); - // Verify HotkeyRecordModal is open - await expect(window.getByText(/단축키 설정|단축키 녹화|키 조합/i).first()).toBeVisible({ timeout: 5000 }); + // Wait for the binding map to arrive, then snapshot how many bindings this action + // has so we can prove afterwards that cancelling did not corrupt them. + const bindingChips = dictationField.getByTestId('keybinding-binding'); + await expect(bindingChips.first()).toBeVisible({ timeout: 5000 }); + const bindingsBefore = await bindingChips.count(); - // 3. Test pressing a key (F9) + // 3. Open the picker with the "+ Add" button (replaces the old pencil icon) + await dictationField.getByTestId('keybinding-add-dictation').click(); + const picker = window.getByTestId('keybinding-picker'); + await expect(picker).toBeVisible({ timeout: 5000 }); + + // 4. Record tab: pressing F9 captures it onto a keycap + const recordArea = picker.getByTestId('keybinding-record-area'); + await expect(recordArea).toBeVisible(); await window.keyboard.press('F9'); await window.waitForTimeout(400); + await expect(recordArea.getByText('F9', { exact: true })).toBeVisible(); - // Verify chip shows F9 - await expect(window.getByText('F9', { exact: true })).toBeVisible(); - - // Take screenshot while modal is open with captured key + // Take screenshot while the picker holds the captured key await window.screenshot({ - path: path.join(SCREENSHOT_DIR, 'rt11_hotkey_modal_verified.png'), + path: path.join(SCREENSHOT_DIR, 'rt11_keybinding_picker_recorded.png'), }); - // 4. Click Cancel button to close HotkeyRecordModal without corrupting bindings - const cancelBtn = window.getByRole('button', { name: /취소|Cancel/i }); - await expect(cancelBtn).toBeVisible(); - await cancelBtn.click(); - await window.waitForTimeout(500); + // 5. Reserved-combo protection: Ctrl+C must be rejected and Save must stay disabled + await picker.getByTestId('keybinding-record-again').click(); + await window.waitForTimeout(300); + await window.keyboard.press('Control+c'); + await window.waitForTimeout(400); + await expect(picker.getByTestId('keybinding-rejection').first()).toBeVisible(); + await expect(picker.getByTestId('keybinding-confirm')).toBeDisabled(); + + // 6. Cancel closes the picker without touching the stored bindings + await picker.getByTestId('keybinding-cancel').click(); + await window.waitForTimeout(500); + await expect(picker).toBeHidden(); + await expect(bindingChips).toHaveCount(bindingsBefore); + + // Close SettingsModal (its close button sits in DialogTitle, ahead of any field markup) + const closeSettingsBtn = window.locator('div[role="dialog"] button:has(svg.lucide-x)').first(); + if (await closeSettingsBtn.isVisible()) { + await closeSettingsBtn.click(); + await window.waitForTimeout(500); + } + + expect(uncaughtExceptions).toEqual([]); + }); + + test('RT-11b: KeyBindingPicker - Searchable Key Dropdown & Multi-Binding Add/Remove', async () => { + // 1. Re-open SettingsModal to General Tab + const settingsTrigger = window.getByText('설정', { exact: true }); + await settingsTrigger.click(); + await window.waitForTimeout(600); + + const generalTab = window.getByRole('tab', { name: /일반|General/i }); + await generalTab.click(); + await window.waitForTimeout(400); + + const dictationField = window.getByTestId('keybinding-field-dictation'); + const bindingChips = dictationField.getByTestId('keybinding-binding'); + await expect(bindingChips.first()).toBeVisible({ timeout: 5000 }); + const bindingsBefore = await bindingChips.count(); + + // 2. Open the picker and switch to the list tab + await dictationField.getByTestId('keybinding-add-dictation').click(); + const picker = window.getByTestId('keybinding-picker'); + await expect(picker).toBeVisible({ timeout: 5000 }); + await picker + .getByTestId('keybinding-picker-tabs') + .getByText(/목록에서 선택|Choose from List/i) + .click(); + await window.waitForTimeout(300); + + // 3. The dropdown carries its own search field and narrows the key catalog as you type. + // Its listbox is portalled to , so options are queried from the page root. + const searchInput = picker.getByTestId('keybinding-search').locator('input'); + await expect(searchInput).toBeVisible(); + + const keyOptions = window.getByTestId('keybinding-key-option'); + expect(await keyOptions.count()).toBeGreaterThan(1); + + await searchInput.fill('f8'); + await window.waitForTimeout(400); + await expect(keyOptions).toHaveCount(1); + await keyOptions.first().click(); + await window.waitForTimeout(300); + + // Picked key is reflected back into the picker's selection preview + await expect(picker.getByText('F8', { exact: true }).first()).toBeVisible(); + + // 4. Saving adds a SECOND binding to the same action (multi-binding) + const confirmBtn = picker.getByTestId('keybinding-confirm'); + await expect(confirmBtn).toBeEnabled({ timeout: 5000 }); + await confirmBtn.click(); + await expect(picker).toBeHidden(); + await expect(bindingChips).toHaveCount(bindingsBefore + 1); + + await window.screenshot({ + path: path.join(SCREENSHOT_DIR, 'rt11b_keybinding_multi_binding.png'), + }); + + // 5. Remove it again so the stored config is left exactly as we found it + await bindingChips.last().getByTestId('keybinding-remove').click(); + await expect(bindingChips).toHaveCount(bindingsBefore); - // Close SettingsModal const closeSettingsBtn = window.locator('div[role="dialog"] button:has(svg.lucide-x)').first(); if (await closeSettingsBtn.isVisible()) { await closeSettingsBtn.click(); diff --git a/apps/desktop/tests/main/services/VoiceModeService.test.ts b/apps/desktop/tests/main/services/VoiceModeService.test.ts index b4d3f3e..334c8cf 100644 --- a/apps/desktop/tests/main/services/VoiceModeService.test.ts +++ b/apps/desktop/tests/main/services/VoiceModeService.test.ts @@ -51,13 +51,13 @@ vi.mock('../../../src/main/services/AudioCaptureService', () => ({ getAudioCaptureService: () => mockAudio })) -const mockHotkey = { +const mockKeyBinding = { on: vi.fn(), off: vi.fn() } -vi.mock('../../../src/main/services/HotkeyService', () => ({ - getHotkeyService: () => mockHotkey +vi.mock('../../../src/main/services/KeyBindingService', () => ({ + getKeyBindingService: () => mockKeyBinding })) vi.mock('../../../src/main/services/ConfigService', () => ({ diff --git a/apps/desktop/tests/red/ipc-surfaces.usecase.test.ts b/apps/desktop/tests/red/ipc-surfaces.usecase.test.ts index dd26cdf..fdce2d6 100644 --- a/apps/desktop/tests/red/ipc-surfaces.usecase.test.ts +++ b/apps/desktop/tests/red/ipc-surfaces.usecase.test.ts @@ -1,8 +1,18 @@ import { describe, it, expect, vi } from 'vitest' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ErrorCode } from '@d3ro/core/errors' +import { + KEYBINDING_ACTIONS, + createDefaultBindingMap, + findActionSpec, +} from '@d3ro/core/keybinding' +import type { + KeyBinding, + KeyBindingMap, + KeyBindingValidationResult, +} from '@d3ro/core/types' import { configGet } from '../../src/main/services/ConfigService' -import { registerHotkeyHandlers } from '../../src/main/ipc/hotkey-handlers' +import { registerKeyBindingHandlers } from '../../src/main/ipc/keybinding-handlers' import { registerCaptionHandlers } from '../../src/main/ipc/caption-handlers' import { registerRAGHandlers } from '../../src/main/ipc/rag-handlers' import { registerVoiceConversationHandlers } from '../../src/main/ipc/voice-conversation-handlers' @@ -13,73 +23,171 @@ import { registerTemplateHandlers } from '../../src/main/ipc/template-handlers' import { registerMeetingDocTemplateHandlers } from '../../src/main/ipc/meeting-doc-template-handlers' import { invokeIpc, useRedHarness } from './harness' -vi.mock('../../src/main/services/HotkeyService', () => ({ - getHotkeyService: () => ({ +const { keyBindingService } = vi.hoisted(() => ({ + keyBindingService: { loadFromConfig: vi.fn(), start: vi.fn(), stop: vi.fn(), on: vi.fn(), off: vi.fn(), - }), + }, +})) + +vi.mock('../../src/main/services/KeyBindingService', () => ({ + getKeyBindingService: () => keyBindingService, })) useRedHarness() -const SAMPLE_BINDING = { - keyCode: 65, +/** Ctrl + A — 시스템 예약 조합이라 검증에서 거부되어야 한다 */ +const RESERVED_BINDING: KeyBinding = { + device: 'keyboard', + code: 0x41, ctrl: true, alt: false, shift: false, meta: false, - displayLabel: 'Ctrl+A', } -describe('유스케이스: 핫키 / 캡션 / RAG / 대화 / 액션 / 파일전사 / 회의 IPC', () => { - it('핫키 받아쓰기 단축키를 읽고 쓴다', async () => { - registerHotkeyHandlers() - const set = await invokeIpc(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, { binding: SAMPLE_BINDING }) +/** Ctrl + Shift + F9 — 예약되지 않은 유효한 조합 */ +const FREE_BINDING: KeyBinding = { + device: 'keyboard', + code: 0x78, + ctrl: true, + alt: false, + shift: true, + meta: false, +} + +describe('유스케이스: 키바인딩 / 캡션 / RAG / 대화 / 액션 / 파일전사 / 회의 IPC', () => { + it('키바인딩 맵을 읽으면 모든 액션이 들어 있다', async () => { + registerKeyBindingHandlers() + const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.GET_MAP) + expect(get.success).toBe(true) + if (get.success) { + for (const action of KEYBINDING_ACTIONS) { + expect(get.data[action.id]).toEqual(action.defaultBindings) + } + } + }) + + it('액션 바인딩을 교체하면 맵에 반영되고 서비스가 다시 읽는다', async () => { + registerKeyBindingHandlers() + const set = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { + actionId: 'dictation', + bindings: [FREE_BINDING], + }) expect(set.success).toBe(true) - const get = await invokeIpc(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT) - expect(get.success).toBe(true) - if (get.success) expect(get.data.displayLabel).toBe('Ctrl+A') + expect(keyBindingService.loadFromConfig).toHaveBeenCalled() + + const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.GET_MAP) + if (get.success) expect(get.data.dictation).toEqual([FREE_BINDING]) }) - it('핫키 핸즈프리 단축키를 읽고 쓴다', async () => { - registerHotkeyHandlers() - await invokeIpc(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, { binding: SAMPLE_BINDING }) - const get = await invokeIpc(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT) - expect(get.success).toBe(true) + it('시스템 예약 조합은 HotkeySystemReserved로 거부된다', async () => { + registerKeyBindingHandlers() + const set = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { + actionId: 'dictation', + bindings: [RESERVED_BINDING], + }) + expect(set.success).toBe(false) + if (!set.success) expect(set.error.code).toBe(ErrorCode.HotkeySystemReserved) }) - it('핫키 명령 단축키를 읽고 쓴다', async () => { - registerHotkeyHandlers() - await invokeIpc(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, { binding: SAMPLE_BINDING }) - const get = await invokeIpc(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT) - expect(get.success).toBe(true) + it('다른 액션이 쓰는 바인딩은 HotkeyConflict로 거부된다', async () => { + registerKeyBindingHandlers() + const ok = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { + actionId: 'caption', + bindings: [FREE_BINDING], + }) + expect(ok.success).toBe(true) + + const conflict = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { + actionId: 'command', + bindings: [FREE_BINDING], + }) + expect(conflict.success).toBe(false) + if (!conflict.success) expect(conflict.error.code).toBe(ErrorCode.HotkeyConflict) }) - it('핫키 자막 단축키를 읽고 쓴다', async () => { - registerHotkeyHandlers() - await invokeIpc(IPC_CHANNELS.HOTKEY.SET_CAPTION_SHORTCUT, { binding: SAMPLE_BINDING }) - const get = await invokeIpc(IPC_CHANNELS.HOTKEY.GET_CAPTION_SHORTCUT) - expect(get.success).toBe(true) + it('바인딩 사전 검사는 유효성과 충돌을 함께 돌려준다', async () => { + registerKeyBindingHandlers() + const reserved = await invokeIpc( + IPC_CHANNELS.KEYBINDING.VALIDATE, + { actionId: 'dictation', binding: RESERVED_BINDING }, + ) + expect(reserved.success).toBe(true) + if (reserved.success) { + expect(reserved.data.validation.valid).toBe(false) + expect(reserved.data.validation.reason).toBe('system-reserved') + } + + const free = await invokeIpc( + IPC_CHANNELS.KEYBINDING.VALIDATE, + { actionId: 'dictation', binding: FREE_BINDING }, + ) + if (free.success) { + expect(free.data.validation.valid).toBe(true) + expect(free.data.conflicts).toEqual([]) + } }) - it('핫키 활성 토글을 끈다', async () => { - registerHotkeyHandlers() - const set = await invokeIpc(IPC_CHANNELS.HOTKEY.SET_ENABLED, { enabled: false }) + it('액션 하나를 기본값으로 되돌린다', async () => { + registerKeyBindingHandlers() + await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { + actionId: 'dictation', + bindings: [FREE_BINDING], + }) + const reset = await invokeIpc(IPC_CHANNELS.KEYBINDING.RESET_ACTION, { actionId: 'dictation' }) + expect(reset.success).toBe(true) + + const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.GET_MAP) + if (get.success) { + expect(get.data.dictation).toEqual(findActionSpec('dictation')?.defaultBindings) + } + }) + + it('전체를 기본값으로 되돌린다', async () => { + registerKeyBindingHandlers() + await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { + actionId: 'caption', + bindings: [FREE_BINDING], + }) + const reset = await invokeIpc(IPC_CHANNELS.KEYBINDING.RESET_ALL) + expect(reset.success).toBe(true) + + const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.GET_MAP) + if (get.success) expect(get.data).toEqual(createDefaultBindingMap()) + }) + + it('알 수 없는 액션 id는 거부된다', async () => { + registerKeyBindingHandlers() + const res = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { + actionId: 'nope', + bindings: [FREE_BINDING], + }) + expect(res.success).toBe(false) + }) + + it('키바인딩 활성 토글을 끈다', async () => { + registerKeyBindingHandlers() + const set = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_ENABLED, { enabled: false }) expect(set.success).toBe(true) - const get = await invokeIpc(IPC_CHANNELS.HOTKEY.IS_ENABLED) + const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.IS_ENABLED) expect(get.success).toBe(true) if (get.success) expect(get.data).toBe(false) expect(configGet('hotkeyEnabled')).toBe(false) + expect(keyBindingService.stop).toHaveBeenCalled() }) - it('핫키 활성 토글을 켠다', async () => { - registerHotkeyHandlers() - await invokeIpc(IPC_CHANNELS.HOTKEY.SET_ENABLED, { enabled: true }) - const get = await invokeIpc(IPC_CHANNELS.HOTKEY.IS_ENABLED) + it('키바인딩을 다시 켜면 등록을 다시 읽고 후킹을 시작한다', async () => { + registerKeyBindingHandlers() + keyBindingService.loadFromConfig.mockClear() + await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_ENABLED, { enabled: true }) + const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.IS_ENABLED) if (get.success) expect(get.data).toBe(true) + expect(keyBindingService.loadFromConfig).toHaveBeenCalled() + expect(keyBindingService.start).toHaveBeenCalled() }) it('캡션 초기 상태를 읽는다', async () => { diff --git a/apps/desktop/tests/red/voice.usecase.test.ts b/apps/desktop/tests/red/voice.usecase.test.ts index 4ef434d..ee3282b 100644 --- a/apps/desktop/tests/red/voice.usecase.test.ts +++ b/apps/desktop/tests/red/voice.usecase.test.ts @@ -104,8 +104,8 @@ vi.mock('../../src/main/services/CaptionService', () => ({ resetCaptionServiceForTests: () => undefined, })) -vi.mock('../../src/main/services/HotkeyService', () => ({ - getHotkeyService: () => ({ +vi.mock('../../src/main/services/KeyBindingService', () => ({ + getKeyBindingService: () => ({ on: vi.fn(), off: vi.fn(), }), diff --git a/docs/map/00-index.md b/docs/map/00-index.md index d3413ac..a59f238 100644 --- a/docs/map/00-index.md +++ b/docs/map/00-index.md @@ -2,7 +2,7 @@ > Status: ACTIVE > Last full audit: 2026-09-13 -> Last update: 2026-09-19 — GAP-INFRA-05 (desktop renderer popup bundle verification wired into CI); 1.3.7 published to the updater feed +> Last update: 2026-09-21 — CAP-16 (desktop key bindings rebuilt on one `@d3ro/core/keybinding` SSOT: multiple bindings per action, mouse buttons, `HOTKEY` → `KEYBINDING` IPC group); verified on Windows by a manual run, so CAP-16 and CAP-02 are `[x]` and GAP-KEY-01 is closed; GAP-KEY-02/03, GAP-QA-02, GAP-I18N-01/02, GAP-INFRA-06 remain open; `11` gained §7 for accepted design constraints (things deliberately kept, not gaps) > Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.3.7` > Purpose: let any agent (or human) answer two questions in under a minute: > 1. **What infrastructure exists?** (build, CI, services, APIs, data, packages, deploy) diff --git a/docs/map/01-system-overview.md b/docs/map/01-system-overview.md index d135aa8..a76fd38 100644 --- a/docs/map/01-system-overview.md +++ b/docs/map/01-system-overview.md @@ -15,7 +15,7 @@ A multi-platform AI voice assistant: press/hold or tap to speak, get a transcrip | Surface | Path | Stack | Runtime model | Role | |---|---|---|---|---| -| Desktop | `apps/desktop` | Electron 33 + React 19 + MUI 7 + Vite | **Local-first** (SoX, faster-whisper sidecar, Ollama, SQLite) with optional cloud sync | The flagship: global hotkey dictation, text insertion into other apps, meetings, captions, RAG, voice conversation, OS actions | +| Desktop | `apps/desktop` | Electron 33 + React 19 + MUI 7 + Vite | **Local-first** (SoX, faster-whisper sidecar, Ollama, SQLite) with optional cloud sync | The flagship: global key-binding dictation (keyboard or mouse, rebindable — CAP-16), text insertion into other apps, meetings, captions, RAG, voice conversation, OS actions | | Web | `apps/web` | Next.js 15 App Router + Supabase | **Cloud** | Browser console: record/STT, history, commands, meetings, knowledge, teams, chat, billing | | Mobile | `apps/mobile-rn` | React Native 0.85 + React 19 (CLI, not Expo) | **Cloud-first** (Supabase + Edge Functions), on-device Whisper fallback | Product mobile app: recording/import, history, meetings, memos, templates, teams, Talk, admin, data portability, IAP + ads | | API server | `apps/api-server` | ASP.NET Core 10 + EF Core + SQLite | Cloud (self-hosted/NAS) | LLM/STT proxy and admin back-office backend for the .NET identity side | diff --git a/docs/map/02-infrastructure.md b/docs/map/02-infrastructure.md index 836a6db..f0cdee0 100644 --- a/docs/map/02-infrastructure.md +++ b/docs/map/02-infrastructure.md @@ -114,7 +114,7 @@ See [`03-shared-packages.md`](./03-shared-packages.md). Summary: | Package | Provides | |---|---| -| `@d3ro/core` | Domain types, `D3ROError`/`ErrorCode`, IPC channel SSOT, constants, `crypto-license`, `pii-redactor`, `secure-memory`, `supabase-config`, `meeting-markdown`, `markdown-to-docx` | +| `@d3ro/core` | Domain types, `D3ROError`/`ErrorCode`, IPC channel SSOT, key-binding SSOT (`./keybinding`), constants, `crypto-license`, `pii-redactor`, `secure-memory`, `supabase-config`, `meeting-markdown`, `markdown-to-docx`; has vitest tests (`packages/core/vitest.config.ts`, `npm run test --workspace=@d3ro/core`) | | `@d3ro/ui` | Theme tokens, CSS vars, MUI DS components (web/desktop) | | `@d3ro/ui-native` | RN design system (MetalCard, PhosphorText, Led, PhysicalButton, WaveBars, …) | | `@d3ro/i18n` | 12 locales, `I18nProvider`, `t()`, date/number/relative formatters | diff --git a/docs/map/03-shared-packages.md b/docs/map/03-shared-packages.md index ef41ccd..531132d 100644 --- a/docs/map/03-shared-packages.md +++ b/docs/map/03-shared-packages.md @@ -15,7 +15,8 @@ The canonical place for types and cross-surface logic. Both desktop and web/mobi |---|---|---| | Types | `./types` | Domain types shared across surfaces | | Errors | `./errors` | `D3ROError`, `ErrorCode` | -| IPC channels | `./ipc-channels` | `IPC_CHANNELS` object + `IPCChannel` union. **SSOT** for every desktop IPC channel (VOICE, AUDIO, STT, TTS, LLM, HOTKEY, CONFIG, HISTORY, DICTIONARY, WINDOW, SYSTEM, STATS, MEMO, VOICE_COMMAND, CONTEXT, CHAIN, CAPTION, FILE_TRANSCRIPTION, MEETING_SUMMARY, DICTATION_TEMPLATE, VOICE_CONVERSATION, RAG, VOICE_ACTION, MEETING_MODE, MEETING_DOC_TEMPLATE, MEETING_CHAT, LICENSE, CLOUD_SYNC, INSTRUCTION, SYSTEM_AUDIO, POPUP_RESULT, POPUP_HISTORY, POPUP_COMMAND, POPUP_CAPTION, VOICE_PARTIAL, CLIPBOARD, APP, ONLINE_AUTH, ADS, SUPPORT, PAYMENT) | +| IPC channels | `./ipc-channels` | `IPC_CHANNELS` object + `IPCChannel` union. **SSOT** for every desktop IPC channel (VOICE, AUDIO, STT, TTS, LLM, KEYBINDING, CONFIG, HISTORY, DICTIONARY, WINDOW, SYSTEM, STATS, MEMO, VOICE_COMMAND, CONTEXT, CHAIN, CAPTION, FILE_TRANSCRIPTION, MEETING_SUMMARY, DICTATION_TEMPLATE, VOICE_CONVERSATION, RAG, VOICE_ACTION, MEETING_MODE, MEETING_DOC_TEMPLATE, MEETING_CHAT, LICENSE, CLOUD_SYNC, INSTRUCTION, SYSTEM_AUDIO, POPUP_RESULT, POPUP_HISTORY, POPUP_COMMAND, POPUP_CAPTION, VOICE_PARTIAL, CLIPBOARD, APP, ONLINE_AUTH, ADS, SUPPORT, PAYMENT) | +| Key bindings | `./keybinding` | **SSOT** for every global shortcut in the app: `KeyBinding` (`device`/`code`/`ctrl`/`alt`/`shift`/`meta`), `KEY_CATALOG` (10 selectable groups incl. mouse), `KEYBINDING_ACTIONS` (6 rebindable actions), `bindingKey`/`normalizeBinding`/`validateBinding`/`detectBindingConflicts`/`formatBindingSegments`/`searchKeyCatalog`/`parseBindingMap`. Persisted as `AppConfig.keyBindings`. i18n keys are exposed as plain `string` so core stays independent of `@d3ro/i18n`; consumers narrow at the boundary (`asTranslationKey`) and a contract test guards the keys — accepted constraint, `11` §7 CONSTRAINT-I18N-01. Tests: `__tests__/keybinding*.test.ts` via `vitest.config.ts` (`npm run test --workspace=@d3ro/core`), 117 cases as of 2026-09-21 | | Constants | `./constants` | Shared constants | | Crypto license | `./utils/crypto-license` | Ed25519 license sign/verify (used by admin issuer + desktop verifier) | | PII | `pii-redactor`, `secure-memory` | Redaction + secure memory helpers | diff --git a/docs/map/04-desktop-app.md b/docs/map/04-desktop-app.md index 317ae42..5152121 100644 --- a/docs/map/04-desktop-app.md +++ b/docs/map/04-desktop-app.md @@ -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` (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` (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` | diff --git a/docs/map/10-feature-catalog.md b/docs/map/10-feature-catalog.md index 500a2f9..34e6c72 100644 --- a/docs/map/10-feature-catalog.md +++ b/docs/map/10-feature-catalog.md @@ -14,8 +14,8 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]` | ID | Feature | D | W | M | B | Anchors / notes | |---|---|---|---|---|---|---| -| CAP-01 | Push-to-talk dictation (hold/release) | [x] | [-] | [x] | [-] | Desktop `VoiceModeService`; mobile RecordScreen via app CTA/notification action (no global hotkey) | -| CAP-02 | Hands-free toggle dictation | [x] | [-] | [x] | [-] | Desktop double-press; mobile toggle | +| CAP-01 | Push-to-talk dictation (hold/release) | [x] | [-] | [x] | [-] | Desktop `VoiceModeService`; the trigger is now the rebindable `dictation` action of CAP-16 (several bindings per action, keyboard or mouse) rather than a single stored shortcut. The pipeline itself is unchanged and tested; the rewritten entry layer was confirmed in the 2026-09-21 manual run (CAP-16). Mobile RecordScreen via app CTA/notification action (no global hotkey) | +| CAP-02 | Hands-free toggle dictation | [x] | [-] | [x] | [-] | Desktop double-press shares the `dictation` binding and is split by the action's `doublePress` flag (`KeyBindingService.ts:680`). This path was **dead in shipped builds**: the previous lookup returned only the first matching action, so with both actions on the same binding double-press never reached hands-free. Fixed and confirmed in the 2026-09-21 manual run (CAP-16); core tests cover the contract side (same binding is not a conflict, `keybinding.test.ts:499`/`:734`). `KeyBindingService` still has no unit test of its own (GAP-KEY-01 evidence). Mobile toggle | | CAP-03 | Live partial transcript while recording | [x] | [ ] | [ ] | [-] | Desktop `voice:partialTranscript` + recording-tip; producer added in `1.3.0` (`VoiceModeService._runPartial` → `LocalSTTService.transcribePartial`, 1.5 s cadence / 7.5 s window, never inserted). The row was `[x]` before any producer existed. | | CAP-04 | Recording waveform + level meter | [x] | [x] | [x] | [-] | Desktop 9-bar cos distribution; mobile audio level; the recording-tip popup bundle and its on-disk assets are verified by `scripts/ci/verify-desktop-renderer-bundles.mjs` | | CAP-05 | Device/mic selection | [x] | [ ] | [~] | [-] | Desktop config; mobile uses system default | @@ -29,6 +29,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]` | CAP-13 | Live captions overlay | [x] | [-] | [-] | [-] | Desktop `CaptionService` + caption-overlay popup; popup assets verified in the packaged build (`scripts/ci/verify-desktop-renderer-bundles.mjs`); GAP-INFRA-05 | | CAP-14 | Recording persistence / crash recovery | [x] | [ ] | [x] | [-] | Desktop WAV persist; mobile durable queue + process-kill WAV recovery | | CAP-15 | Android foreground recording service | [-] | [-] | [x] | [-] | Mobile API 34 FGS + persistent notification (SSOT R-005 GREEN) | +| CAP-16 | Rebindable global key bindings (keyboard + mouse) | [x] | [-] | [-] | [-] | Contract SSOT `packages/core/src/keybinding.ts`: `KEY_CATALOG` (10 groups, `:615`), `KEYBINDING_ACTIONS` (6 actions, `:719`), `validateBinding` (`:953`), `detectBindingConflicts` (`:1016`). Multiple bindings per action persist as one `AppConfig.keyBindings` map (`packages/core/src/types.ts:459`), replacing the four singular `*Shortcut` fields; `ConfigService` migrates legacy values once (`ConfigService.ts:142`). `KeyBindingService` hooks keyboard **and** mouse via uiohook (`KeyBindingService.ts:387`) — MB1 is not bindable, MB2/MB3 need a modifier, MB4/MB5 are free, and no mouse button can be suppressed, so the original click still fires (warning surfaced in the UI). Selection is either key-recording or a searchable grouped dropdown (`KeyBindingPicker.tsx:536`). `history-popup`/`command-popup` were hardcoded in `bootstrap.ts` and are now rebindable actions (`bootstrap.ts:159`). **Verified 2026-09-21 on Windows by a manual run** (`%APPDATA%/d3ro-voice/logs/main.log`, 12:53–13:06): `ConfigService` migrated the four legacy shortcuts with the user's non-default values preserved exactly, `KeyBindingService` loaded 6 bindings for 6 actions and started the uiohook keyboard **and** mouse hook with zero boot errors, and keyboard plus mouse (MB4/MB5) bindings were exercised through the UI. A `Loaded 7 key binding(s) … for 6 action(s)` line later in the same session shows multi-binding working end to end. The migrated map was read back from `d3ro-voice-config.json`: legacy `*Shortcut` fields gone, no `displayLabel` left. Contract evidence: `packages/core` 117 tests GREEN, no renderer type errors in the key-binding files. **Still open:** `KeyBindingService` has no unit test of its own, macOS/Linux mouse behavior is unconfirmed (`11` GAP-KEY-02), and `command` still falls back to the dictation pipeline (GAP-KEY-03). W/M `[-]`: no OS-level global binding surface exists there (browser sandbox; mobile has no global hotkey, see CAP-01). B `[-]`: device-local setting, nothing server-side. See `11` GAP-KEY-02/03 (open), GAP-KEY-01 (`[x]`), and `11` §7 CONSTRAINT-I18N-01. | --- @@ -158,9 +159,9 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]` | ID | Feature | D | W | M | B | Anchors / notes | |---|---|---|---|---|---|---| -| SHELL-01 | Settings / preferences | [x] | [~] | [x] | [x] | Desktop tabbed modal; web theme/i18n; mobile `SettingsScreen` | +| SHELL-01 | Settings / preferences | [x] | [~] | [x] | [x] | Desktop tabbed modal; the General tab hosts the whole key-binding editor (CAP-16: global on/off switch + one `KeyBindingField` per action, grouped voice/window — `SettingsModal.tsx:239`), which is also the first settings entry point the `command` action ever had; web theme/i18n; mobile `SettingsScreen` | | SHELL-02 | Theme system (6 themes) | [x] | [x] | [x] | [-] | `theme.ts` SSOT | -| SHELL-03 | i18n (12 locales) | [x] | [x] | [x] | [-] | `@d3ro/i18n`; ko/en fully translated, others partial | +| SHELL-03 | i18n (12 locales) | [x] | [x] | [x] | [-] | `@d3ro/i18n`; ko/en fully translated, others partial. Measured 2026-09-21: `ko` 1716 keys / `en` 1709 / the other ten 327 each, so ~1,380 keys fall back for non-English locales — tracked as `11` GAP-I18N-01 | | SHELL-04 | Onboarding / first-run | [x] | [ ] | [x] | [-] | Desktop model bootstrap; mobile audience/theme/locale | | SHELL-05 | Accessibility / reduced motion | [~] | [~] | [~] | [-] | Desktop reduced-motion honored; mobile a11y rows pending | | SHELL-06 | System tray / background | [x] | [-] | [-] | [-] | Desktop tray | @@ -203,7 +204,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]` | Surface | `[x]` | `[~]` | `[ ]` | Notable strength | Notable weakness | |---|---|---|---|---|---| -| Desktop | ~40 | 3 | ~8 | Local AI pipeline, meetings, RAG, conversation, hotkeys | Ads stubs, no team admin, no email account | +| Desktop | ~40 | 3 | ~8 | Local AI pipeline, meetings, RAG, conversation, key bindings | Ads stubs, no team admin, no email account | | Web | ~22 | 6 | ~14 | Server-shared data UX, billing, meetings, teams | No local AI, limited knowledge upload/search | | Mobile | ~40 | 12 | ~18 | Cloud + native recording, portability, admin, IAP/ads | External store/console gates, a11y, deep E2E pending | | Backend | ~45 | 6 | ~4 | RLS, Edge functions, billing, fail-closed AI | Payple webhook signature, some external provider keys | diff --git a/docs/map/11-gap-backlog.md b/docs/map/11-gap-backlog.md index 984365d..cf6e39a 100644 --- a/docs/map/11-gap-backlog.md +++ b/docs/map/11-gap-backlog.md @@ -13,6 +13,7 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res - An item here is **not** a failure. It is a known state with an owner and a next step. - When you close an item, flip it to `[x]`, add the date + evidence path, and also update `10-feature-catalog.md`. - Grandfathered detail lives in `docs/v3/MOBILE_APP_COMPLETION_SSOT.md`; this file is the cross-surface roll-up. When the two disagree, the SSOT wins for mobile and must be reconciled here. +- **Not everything imperfect is a gap.** Trade-offs that were reviewed and deliberately kept live in §7 as constraints, not in §1. Check §7 before opening a row for one. --- @@ -58,6 +59,13 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res | GAP-REL-04 | Release | canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 **100MiB**를 넘으면 HTTP 413으로 거부한다. | `scripts/ci/publish-updater-release.mjs`, `docs/deployment/unsigned-distribution.md` | `[~]` 2026-09-18: 설치본을 90.6MiB로 줄여 업데이트 피드 게시를 복구했다(GAP-STT-07). 휴대용/Scoop 채널은 여전히 95MiB 분할이 필요하다. | | GAP-STT-07 | Local STT | 진(사이드카)을 앱 번들에 넣으면 설치본이 100MiB를 넘고 매 업데이트마다 162MiB를 다시 받는다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts`, `apps/desktop/electron-builder.yml` | `[x]` 2026-09-18: 설치본에서 엔진/ffmpeg를 제거하고 처음 필요할 때 `runtime-latest`에서 내려받는다(부품별 + 결합본 SHA-256 검증). 설치본 189MB → 90.6MiB, 런타임 1회 116MiB(엔진 94.4 + ffmpeg 21.7). 실제 feed로 통합 검증 완료. | | GAP-STT-06 | Local STT | Decode settings were untuned: previous-text conditioning let repeated hallucinations compound, and no VAD parameters meant slow, uneven segments. | `apps/desktop/sidecar/main.py` | `[x]` 2026-09-18: `condition_on_previous_text=false`, bounded low-temperature fallback, `no_speech`/`compression_ratio`/`log_prob` thresholds, 300 ms silence trimming. Same transcript, ~5x faster on the reference machine (7.7 s audio: 1609 ms → 303 ms). | +| GAP-KEY-01 | Key bindings | 키바인딩 전면 개편(CAP-16)이 실앱 구동으로 검증되지 않은 상태였다. uiohook 전역 후킹, 더블프레스 타이밍, 마우스 버튼 수신을 실행 중인 Electron 에서 확인한 적이 없었고, 증거가 계약 수준(`packages/core` 117개 통과, 키바인딩 파일 타입 에러 0건)뿐이었다. 에이전트는 데스크톱 GUI 를 띄울 수 없다(`AGENTS.md` §3). | `apps/desktop/src/main/services/KeyBindingService.ts`, `packages/core/__tests__/keybinding.test.ts`, `%APPDATA%/d3ro-voice/logs/main.log`, `%APPDATA%/d3ro-voice/d3ro-voice-config.json` | `[x]` 2026-09-21: 사용자가 Windows 에서 앱을 띄워 검증 완료. 로그(12:53–13:06)에 `Migrated 4 legacy shortcut(s) to keyBindings` → `Loaded 6 key binding(s) from config for 6 action(s)` → `uiohook started, global keyboard/mouse hook active` → `key-bindings initialized` → `Key binding events connected` 가 순서대로 남았고 부팅 에러 0건, STT 모델 로딩까지 정상. 키보드·마우스(MB4/MB5) 바인딩을 UI 로 실제 조작해 동작을 확인했고, 같은 세션의 `Loaded 7 key binding(s) … for 6 action(s)` 가 다중 바인딩이 실동작함을 보인다. 설정 파일 되읽기로 마이그레이션 결과를 확인 — 사용자 커스텀 값이 그대로 보존됐고(dictation `code:49,alt` / hands-free `code:49,alt+shift` / command `code:165,ctrl` / caption `code:49,ctrl+alt+shift`, 팝업 2종은 신규 기본값), 구 `*Shortcut` 4개와 `displayLabel` 은 모두 사라졌다. **남은 것**: `KeyBindingService` 자체의 유닛 테스트는 여전히 없다(실행 검증이 유닛 테스트를 대체하지 않는다). macOS/Linux 는 GAP-KEY-02 로 계속 열려 있다. | +| GAP-KEY-02 | Key bindings | 마우스 버튼 지원이 **Windows 기준으로만** 설계·확인됐다. `KeyBindingService` 에는 마우스 관련 플랫폼 분기가 없고(`process.platform` 은 meta 수정자 라벨 표기에만 쓰인다), macOS/Linux 에서 uiohook 이 보고하는 X1/X2 버튼 번호와 OS 기본 "뒤로/앞으로" 동작과의 간섭은 확인하지 않았다. 마우스 이벤트는 suppress 가 불가능하므로 원래 동작이 항상 함께 실행된다. | `KeyBindingService.ts:235`(`readMouseButton`), `:301`(meta 라벨 분기), `packages/core/src/keybinding.ts:561-612`(마우스 카탈로그 5종). 2026-09-21 실앱 검증(GAP-KEY-01)은 **Windows 에서만** 이뤄졌고 거기서는 MB4/MB5 가 정상 동작했다. | macOS/Linux 에서 MB2~MB5 수신 여부와 버튼 번호 매핑을 확인하고, 다르면 카탈로그를 플랫폼별로 분기한다. | +| GAP-KEY-03 | Key bindings | `command` 액션에 전용 핸들러가 없다. 이번에 처음으로 설정 UI 에 노출됐지만, 트리거되면 dictation 파이프라인으로 fallback 하며 `KEYBINDING_ACTIONS` 의 `holdMode:false` 대신 dictation 과 같은 hold-to-talk 로 강제된다. 개편 이전부터 같은 동작이었고 이번 작업은 그 사실을 코드에 명시화만 했다(기능 변화 없음). | `apps/desktop/src/main/services/VoiceModeService.ts:1071`(`_resolveHoldMode`), `packages/core/src/keybinding.ts:740`(액션 정의) | `command` 전용 동작을 정의하고 `_resolveHoldMode` 의 예외를 제거하거나, 액션을 카탈로그에서 뺀다. | +| GAP-QA-02 | Quality | 캡션 테스트 2건이 **개발 머신에 사이드카 venv 가 있는지에 따라 결과가 갈린다**. `LocalSTTService.initialize()`(`:239`) → `_ensureSidecarRunning()`(`:583`) → `_spawnSidecar()`(`:650`) → `_waitForHealth()`(`:794`) 경로에서 venv 가 존재하면 실제 Python 프로세스를 띄우고 health 폴링이 vitest 기본 타임아웃 10초를 넘긴다. venv 가 없으면 `getSidecarCommand()`(`apps/desktop/src/main/utils/paths.ts:174`)가 즉시 throw 해서 같은 테스트가 빠르게 통과한다. 테스트가 로컬 환경을 격리하지 못한 것이 결함이다. | `tests/red/ipc-surfaces.usecase.test.ts`(`캡션 시작 실패는 success:false 로 나온다`), `tests/red/silent-errors.usecase.test.ts:48`. **키바인딩 개편의 회귀가 아니다** — 2026-09-21 에 HEAD(`0ca9e24`) 무수정 코드를 같은 환경(venv 연결)에서 돌려 동일하게 재현했다. 같은 날 같은 머신에서도 실행 방식에 따라 결과가 갈렸다: 전체 실행은 `3 failed / 1311 passed (1314)`(`rag.usecase` + `silent-errors` 캡션 + `paths.test`)이고 `ipc-surfaces` 캡션 케이스는 통과했는데, 그 파일만 단독 실행하면 같은 케이스가 10초 타임아웃으로 실패한다. 테스트 총수 1314 는 어느 실행에서나 같고, 새로 깨진 테스트는 0건이다. | 사이드카 기동을 테스트 경계에서 주입·모킹해 환경 의존을 끊는다. 함께 실패하는 `rag.usecase`(임베딩 서버 부재)도 같은 성격이다. `tests/main/utils/paths.test.ts:78` 은 성격이 다르다 — 기대 정규식이 `사이드카를 찾을 수 없습니다` 인데 실제 메시지는 `로컬 음성 엔진이 아직 설치되지 않았습니다…` 로 바뀌어 테스트가 문구를 따라가지 못한 것이다. | +| GAP-I18N-01 | i18n | 로케일별 키 수가 크게 어긋난다. 2026-09-21 실측: `ko` 1716 / `en` 1709 / 나머지 10개 로케일 각 327. `keybinding.*` 55개는 12개 로케일 전부에 동일하게 들어갔지만, 그 밖 약 1,380개 키가 비영어 로케일에 없어 폴백 체인(locale → `en` → `ko`)으로 표시된다. 키바인딩 작업 이전부터 있던 부채이며 그 작업 범위 밖이었다. | `packages/i18n/src/locales/*.json`, 카탈로그 SHELL-03 | 로케일 간 키 diff 를 내는 커버리지 게이트를 만들어 회귀를 막고, 누락 키를 채운다. | +| GAP-I18N-02 | i18n | 렌더러가 `ko.json` 에 없는 `license.*` 키를 쓴다. `TranslationKey` 가 `ko.json` 에서 파생되므로 누락은 타입 에러로 드러난다. 타입 에러로만 끝나지 않는다 — 폴백 체인이 `locale → en → ko → 키 문자열` 이므로 마스터 로케일에도 없으면 **`license.team` 같은 키가 화면에 그대로 노출된다**. 2026-09-21 실측: `license.feature.premium_llm`·`license.team`·`license.enterprise` 가 없고 이로 인한 TS2345 가 4건이다. HEAD 에서도 없던 키이므로 선재 결함이며 키바인딩 작업과 무관하다. | `apps/desktop/src/renderer/components/UpgradePromptModal.tsx:47`·`:192`, `apps/desktop/src/renderer/pages/DashboardPage.tsx:481`·`:529`, `packages/i18n/src/locales/ko.json` | 세 키를 `ko.json` 에 추가하고 12개 로케일에 반영한다. 같은 타입체크에 잡히는 `LicenseTab.tsx`(6건)·`LicenseModal.tsx`(2건)는 원인이 다르다 — `TFunction` 을 `(k: string) => string` 에 넘기는 TS2322 4건과 `currentTier` 미정의 TS2304 2건으로, 후자는 컴파일이 깨지는 별개 결함이다(GAP-INFRA-04 범위). | +| GAP-INFRA-06 | Dev env | `better-sqlite3` 네이티브 ABI 가 **앱 실행과 로컬 테스트에서 서로 다른 값을 요구**한다. Electron 33 은 ABI 130, 호스트 Node 23 은 ABI 131 이라 한쪽에 맞추면 다른 쪽이 깨진다. 2026-09-21 실측: `electron-rebuild -f -w better-sqlite3` 직후 vitest 가 `366 failed / 948 passed` 로 무너졌고, 리빌드 전에는 `1311 passed` 였다. 같은 날 확인한 현재 워크스페이스는 Node ABI 쪽(호스트 `node -e "require('better-sqlite3')"` 성공)이라 테스트는 돌고 앱 실행에는 재리빌드가 필요하다. **배포 차단 이슈가 아니다** — `node_modules/` 는 gitignore(`.gitignore:1`)이고 패키징 경로는 `scripts/ci/verify-native-abi.mjs` 가 이미 막는다(GAP-REL-07 `[x]`). 순수하게 로컬 개발 환경 전환 비용 문제다. | `scripts/ci/verify-native-abi.mjs`, `scripts/ci/fix-native-abi.mjs`, `package.json`(현재 리빌드용 스크립트 없음) | 두 ABI 를 오가는 npm 스크립트를 둔다(예: `rebuild:app` = Electron ABI, `rebuild:test` = Node ABI). 지금은 전환 방법이 문서화도 스크립트화도 되어 있지 않아 매번 수동으로 알아내야 한다. | | GAP-STT-08 | Local STT | 1.3.5 설치본에서 엔진 설치가 "런타임 아카이브 해시 불일치 (sidecar)"로 항상 실패했다. 부품 검증은 **메모리 스트림**에서 센 값으로, 결합 검증은 **디스크 파일**에서 계산해 기준이 서로 달랐다. 디스크 쓰기가 잘려도 부품 검사를 통과하고 결합 단계에서만 터지므로 원인 파악도 불가능했다. 재시도가 없어 전송이 한 번 끊기면 곧바로 설치 실패였다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts` | `[x]` 2026-09-18: 부품 크기·해시를 디스크 파일 기준으로 통일하고, 결합본은 크기를 먼저 검사한 뒤 해시를 본다(오류 메시지에 실제/기대값 포함). 부품 다운로드는 실패 시 해당 파일을 지우고 최대 3회 재시도한다. 서버 아티팩트는 무결함을 확인했고(부품 2개 해시 일치, 결합본 `e203aa53…` = 인덱스 기대값), 실제 feed로 설치를 재현해 18초 만에 성공. **1.3.6으로 게시 완료** — `latest.yml`이 1.3.6/90.6MiB를 서빙하고 설치본 sha512가 피드 메타데이터와 일치. 설치본 asar에 수정 코드가 포함되고 구버전 `archiveHash` 경로는 제거됨을 확인. | | GAP-INFRA-05 | Build | 패키징된 렌더러 팝업 스크립트가 번들에 없었다. 팝업 HTML이 classic `