diff --git a/apps/desktop/src/main/ipc/keybinding-handlers.ts b/apps/desktop/src/main/ipc/keybinding-handlers.ts index 0b1db8f..1830726 100644 --- a/apps/desktop/src/main/ipc/keybinding-handlers.ts +++ b/apps/desktop/src/main/ipc/keybinding-handlers.ts @@ -14,12 +14,16 @@ import { 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 { + configGet, + configSet, + loadKeyBindings, + saveKeyBindings +} from '../services/ConfigService' import type { KeyBinding, KeyBindingChangedEvent, @@ -44,16 +48,19 @@ function broadcastChanged(map: KeyBindingMap): void { broadcast(IPC_CHANNELS.KEYBINDING.CHANGED, event) } -/** 저장된 맵을 읽는다. 손상·누락 항목은 기본값으로 복원한다. */ +/** + * 저장된 맵을 읽는다. 손상·누락 항목은 기본값으로 복원하고, 사용자가 비운 액션(`[]`)은 + * 비운 그대로 둔다 — 다른 액션을 고칠 때 지운 단축키가 되살아나지 않도록. + */ function readMap(): KeyBindingMap { - return parseBindingMap(configGet('keyBindings')) + return loadKeyBindings() } /** 맵을 저장하고 후킹 서비스에 다시 태운 뒤 창에 알린다. */ function applyMap(map: KeyBindingMap): void { - configSet('keyBindings', map) + const saved = saveKeyBindings(map) getKeyBindingService().loadFromConfig() - broadcastChanged(map) + broadcastChanged(saved) } /** uiohook 트리거를 렌더러로 중계한다. 재등록 시 리스너가 중복되지 않게 이전 것을 떼어낸다. */ diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index d1b0538..a6029c2 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -6,10 +6,10 @@ import type { AppConfig, ConfigChangedEvent, KeyBinding, KeyBindingActionId, Key import { bindingsEqual, createDefaultBindingMap, + decodeKeyBindingMap, findActionSpec, kb, normalizeBinding, - parseBindingMap, VK } from '@d3ro/core/keybinding' import { D3ROError, ErrorCode } from '@d3ro/core/errors' @@ -40,16 +40,13 @@ function migrateSuggestionTuning(activeStore: ElectronStore): void { } if (current < 2) { - const rawBindings = raw.keyBindings as unknown - const bindings = parseBindingMap(rawBindings) - const defaults = createDefaultBindingMap() - const suggestionActions = ['suggestion-accept', 'suggestion-next', 'suggestion-prev', 'suggestion-dismiss'] as const - for (const action of suggestionActions) { - const spec = findActionSpec(action) - if (!spec) continue - bindings[action] = defaults[action] ?? spec.defaultBindings.map((binding) => ({ ...binding })) - } - activeStore.set('keyBindings', bindings) + // 의도된 강제 초기화 — 이 개정판은 제안 액션 4개를 기본값으로 되돌린다. + updateStoredKeyBindings(activeStore, (bindings) => { + const defaults = createDefaultBindingMap() + const suggestionActions = ['suggestion-accept', 'suggestion-next', 'suggestion-prev', 'suggestion-dismiss'] as const + for (const action of suggestionActions) bindings[action] = defaults[action] + return true + }) } if (current < 3) activeStore.set('suggestionRequestTimeoutMs', 8000) @@ -88,20 +85,41 @@ function bindingListEquals(a: readonly KeyBinding[], b: readonly KeyBinding[]): * 절대 건드리지 않는다. */ function migrateSuggestionOverlayBindings(activeStore: ElectronStore): void { - const raw = activeStore.store as unknown as Record - const bindings = parseBindingMap(raw.keyBindings) + updateStoredKeyBindings(activeStore, (bindings) => { + const migrateIfOldDefault = (actionId: KeyBindingActionId, oldDefault: readonly KeyBinding[]): boolean => { + if (!bindingListEquals(bindings[actionId], oldDefault)) return false + const spec = findActionSpec(actionId) + if (!spec) return false + bindings[actionId] = spec.defaultBindings.map((binding) => ({ ...binding })) + return true + } - const migrateIfOldDefault = (actionId: KeyBindingActionId, oldDefault: readonly KeyBinding[]): void => { - const existing = bindings[actionId] ?? [] - if (!bindingListEquals(existing, oldDefault)) return - const spec = findActionSpec(actionId) - if (spec) bindings[actionId] = spec.defaultBindings.map((binding) => ({ ...binding })) + const acceptMoved = migrateIfOldDefault('suggestion-accept', OLD_SUGGESTION_ACCEPT_DEFAULT) + const dismissMoved = migrateIfOldDefault('suggestion-dismiss', OLD_SUGGESTION_DISMISS_DEFAULT) + return acceptMoved || dismissMoved + }) +} + +// ── 키바인딩 저장 코덱 경계 ── +// +// 저장된 keyBindings 는 전부 core 의 decodeKeyBindingMap 으로만 해석한다. 명시적 빈 목록(사용자가 +// 단축키를 모두 지운 액션)은 보존하고, 누락·손상 항목만 기본값으로 복구한다. 설정 로딩 · IPC · +// 후킹 서비스 · 마이그레이션이 같은 해석을 공유해야 화면과 런타임과 저장소가 어긋나지 않는다. + +/** + * 저장된 키바인딩을 해석하고 migrate 로 고친 뒤, 실제로 바뀐 것이 있을 때만 다시 쓴다. + * migrate 는 맵을 제자리에서 고치고, 무언가 바꿨으면 true 를 돌려준다. + */ +function updateStoredKeyBindings( + activeStore: ElectronStore, + migrate: (bindings: KeyBindingMap) => boolean, +): void { + const decoded = decodeKeyBindingMap(activeStore.get('keyBindings') as unknown) + const changed = migrate(decoded.map) + if (decoded.repairedActions.length > 0) { + logger.info(`Restored default key bindings for: ${decoded.repairedActions.join(', ')}`) } - - migrateIfOldDefault('suggestion-accept', OLD_SUGGESTION_ACCEPT_DEFAULT) - migrateIfOldDefault('suggestion-dismiss', OLD_SUGGESTION_DISMISS_DEFAULT) - - activeStore.set('keyBindings', bindings) + if (changed || decoded.needsRewrite) activeStore.set('keyBindings', decoded.map) } // electron-store v10은 ESM 전용이므로 동적 import 필요 @@ -261,33 +279,30 @@ function isLegacyShortcut(value: unknown): value is LegacyShortcut { */ 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, - }), - ] - } + updateStoredKeyBindings(activeStore, (restored) => { + 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, + }), + ] + } + return legacyKeys.length > 0 + }) - activeStore.set('keyBindings', restored) + if (legacyKeys.length === 0) return for (const key of legacyKeys) { activeStore.delete(key) } @@ -347,6 +362,21 @@ export function configSet(key: K, value: AppConfig[K] logger.debug(`Config changed: ${key}`) } +/** + * 저장된 키바인딩 맵을 읽는다 — 모든 액션 키를 갖고, 명시적 빈 목록은 빈 목록 그대로다. + * keyBindings 를 읽는 곳은 configGet('keyBindings') 대신 이것을 쓴다. + */ +export function loadKeyBindings(): KeyBindingMap { + return decodeKeyBindingMap(configGet('keyBindings') as unknown).map +} + +/** 키바인딩 맵을 정규화해 저장하고, 실제로 저장한 맵을 돌려준다. */ +export function saveKeyBindings(map: KeyBindingMap): KeyBindingMap { + const normalized = decodeKeyBindingMap(map).map + configSet('keyBindings', normalized) + return normalized +} + export function configGetAll(): AppConfig { if (!store) return { ...CONFIG_DEFAULTS } return store.store diff --git a/apps/desktop/src/main/services/KeyBindingService.ts b/apps/desktop/src/main/services/KeyBindingService.ts index 92a9ff5..7a0b298 100644 --- a/apps/desktop/src/main/services/KeyBindingService.ts +++ b/apps/desktop/src/main/services/KeyBindingService.ts @@ -5,8 +5,9 @@ // press / release · 더블프레스 · auto-repeat · AltGr 보류 상태 머신의 정본은 // `packages/core/src/keybinding-runtime.ts` 다. 이 파일은 입출력 어댑터만 책임진다: // 1) 정본 좌표계(Windows VK / MouseButton) ↔ uiohook 이벤트 좌표계 변환 -// 2) 설정 로딩 → 상태 머신 바인딩 등록 +// 2) 설정 로딩 → 상태 머신 바인딩 등록 (저장값 해석은 ConfigService.loadKeyBindings 정본) // 3) macOS beep 차단용 globalShortcut accelerator 관리 +// (예약 정책 — 플랫폼 · 기능 게이트 — 은 keybinding-accelerator-policy.ts 의 순수 함수) import { EventEmitter } from 'events' import { globalShortcut } from 'electron' @@ -14,7 +15,9 @@ import { uIOhook, UiohookKey } from 'uiohook-napi' import type { UiohookKeyboardEvent, UiohookMouseEvent } from 'uiohook-napi' import { getLogger } from './LoggerService' import { acquireGlobalInputHook } from './global-input-hook' -import { configGet } from './ConfigService' +import { configGet, loadKeyBindings, onConfigChanged } from './ConfigService' +import { acceleratorsToReserve } from './keybinding-accelerator-policy' +import type { ReservableBinding } from './keybinding-accelerator-policy' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { KEYBINDING_ACTIONS, @@ -239,70 +242,6 @@ function readMouseButton(event: UiohookMouseEvent): MouseButtonCode | null { 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('+') -} - // ============================================================ // 내부 타입 // ============================================================ @@ -330,6 +269,12 @@ class KeyBindingService extends EventEmitter { /** 이 서비스가 직접 등록한 accelerator만 추적한다 (다른 곳의 등록을 해제하지 않기 위해) */ private _ownedAccelerators: Set = new Set() + /** 상태 머신에 등록한 바인딩과 액션 — accelerator 예약 정책의 입력 */ + private _reservable: ReservableBinding[] = [] + + /** 기능 스위치(제안 on/off) 변경 시 accelerator 를 다시 맞추는 구독 해제자 */ + private _offConfigChanged: (() => void) | null = null + private _onKeyDown: ((e: UiohookKeyboardEvent) => void) | null = null private _onKeyUp: ((e: UiohookKeyboardEvent) => void) | null = null private _onMouseDown: ((e: UiohookMouseEvent) => void) | null = null @@ -378,6 +323,9 @@ class KeyBindingService extends EventEmitter { this._releaseHook = acquireGlobalInputHook() this._isRunning = true + this._offConfigChanged = onConfigChanged((event) => { + if (event.key === 'suggestionEnabled') this._syncAccelerators() + }) this._syncAccelerators() logger.info('Key binding listeners attached to global input hook') } catch (error) { @@ -416,6 +364,8 @@ class KeyBindingService extends EventEmitter { } this._isRunning = false + this._offConfigChanged?.() + this._offConfigChanged = null this._clearRuntimeState() this._unregisterOwnedAccelerators() @@ -431,6 +381,7 @@ class KeyBindingService extends EventEmitter { this.stop() // setBindings 는 런타임 상태(보류 타이머 포함)도 함께 비운다. this._machine.setBindings([]) + this._reservable = [] this._unregisterOwnedAccelerators() this.removeAllListeners() logger.info('KeyBindingService disposed') @@ -438,20 +389,25 @@ class KeyBindingService extends EventEmitter { // ── 등록 ─────────────────────────────────────────────── - /** ConfigService 의 keyBindings 를 읽어 상태 머신 바인딩을 다시 만든다. */ + /** + * ConfigService 의 keyBindings 를 읽어 상태 머신 바인딩을 다시 만든다. + * 저장값 해석은 IPC · 마이그레이션과 같은 코덱(loadKeyBindings)을 쓴다 — 화면과 런타임이 + * 서로 다른 바인딩을 보지 않도록. + */ loadFromConfig(): void { if (!configGet('hotkeyEnabled')) { // setBindings 는 런타임 상태(보류 타이머 포함)도 함께 비운다. this._machine.setBindings([]) + this._reservable = [] this._unregisterOwnedAccelerators() logger.info('Key bindings disabled in config') return } - const map = configGet('keyBindings') + const map = loadKeyBindings() const entries: ChordBindingEntry[] = [] for (const spec of KEYBINDING_ACTIONS) { - for (const raw of map[spec.id] ?? []) { + for (const raw of map[spec.id]) { const registered = this._toObservable(spec, raw) if (registered === null) continue entries.push({ @@ -463,6 +419,8 @@ class KeyBindingService extends EventEmitter { } } this._machine.setBindings(entries) + // _toObservable 이 이미 정규화했다. + this._reservable = entries.map((entry) => ({ actionId: entry.actionId, binding: entry.binding })) this._syncAccelerators() logger.info( @@ -486,16 +444,20 @@ class KeyBindingService extends EventEmitter { // ── accelerator (macOS beep 차단) ────────────────────── + /** + * 예약 정책(acceleratorsToReserve)이 고른 accelerator 만 등록한다. + * macOS 가 아니거나 기능이 꺼진 액션이면 아무것도 등록하지 않는다 — Windows 의 + * globalShortcut(RegisterHotKey)은 키를 포커스된 앱에서 빼앗기 때문이다. + */ private _syncAccelerators(): void { this._unregisterOwnedAccelerators() if (!this._isRunning) return - for (const binding of this._machine.registeredBindings()) { - if (binding.device !== 'keyboard') continue - const accelerator = bindingToAccelerator(binding) - if (accelerator === null) continue - if (this._ownedAccelerators.has(accelerator)) continue - + const accelerators = acceleratorsToReserve(this._reservable, { + platform: process.platform, + flags: { suggestionEnabled: configGet('suggestionEnabled') === true } + }) + for (const accelerator of accelerators) { try { const ok = globalShortcut.register(accelerator, () => { // noop — press/release 는 uiohook 이 책임진다. 등록 목적은 OS 의 키 swallow 다. @@ -584,11 +546,15 @@ class KeyBindingService extends EventEmitter { this._machine.keyUp(this._keyboardEventKey(e, vk), vk) } - /** 마우스 업은 정확 매칭만 한다. */ + /** + * 마우스 업. 버튼을 누른 채 수정자를 더 누르면(X1 누름 → Alt → X1 놓음) mouseup 의 + * bindingKey 가 누를 때와 달라 정확 매칭이 실패한다. 놓인 버튼을 구성 요소로 함께 넘겨 + * 같은 버튼의 눌림 바인딩을 놓는다 — hold-to-talk 가 계속 녹음되지 않도록. + */ private _handleMouseUp(e: UiohookMouseEvent): void { - const key = this._mouseEventKey(e) - if (key === null) return - this._machine.keyUp(key, null) + const button = readMouseButton(e) + if (button === null) return + this._machine.keyUp(this._mouseEventKey(e), { device: 'mouse', code: button }) } // ── EventEmitter 타입 오버라이드 ─────────────────────── diff --git a/apps/desktop/src/main/services/keybinding-accelerator-policy.ts b/apps/desktop/src/main/services/keybinding-accelerator-policy.ts new file mode 100644 index 0000000..d21068b --- /dev/null +++ b/apps/desktop/src/main/services/keybinding-accelerator-policy.ts @@ -0,0 +1,131 @@ +// src/main/services/keybinding-accelerator-policy.ts +// +// globalShortcut 예약 정책 (순수 함수). +// +// KeyBindingService 는 실제 press/release 를 uiohook(저수준 훅)으로 받는다. globalShortcut 은 +// **macOS 에서 시스템 beep 을 막는 용도로만** 쓴다 — 등록하면 OS 가 키를 삼켜 포커스된 앱으로 +// 전달하지 않기 때문이다. Windows 의 globalShortcut 은 RegisterHotKey 라 같은 이유로 키를 +// 시스템 전역에서 빼앗는다(Windows Terminal 의 Ctrl+Shift+C/V, VS Code 의 Ctrl+Alt+↑/↓ 가 +// 죽는다). 그래서 예약 여부는 플랫폼과 기능 활성 상태로 판정하며, 이 판정을 Electron 없이 +// 테스트할 수 있게 여기 둔다. + +import { VK } from '@d3ro/core/keybinding' +import type { KeyBinding, KeyBindingActionId } from '@d3ro/core/types' + +/** accelerator 예약이 필요한 플랫폼 — beep 은 macOS 에만 있다. */ +const RESERVING_PLATFORMS: ReadonlySet = new Set(['darwin']) + +/** 제안 오버레이 전용 액션. 제안 기능이 꺼져 있으면 예약하지 않는다. */ +const SUGGESTION_ACTIONS: ReadonlySet = new Set([ + 'suggestion-accept', + 'suggestion-next', + 'suggestion-prev', + 'suggestion-dismiss' +]) + +/** 액션 활성 판정에 필요한 기능 스위치 */ +export interface KeyBindingFeatureFlags { + suggestionEnabled: boolean +} + +/** 액션이 쓰이는 기능이 켜져 있는가. 기능 스위치가 없는 액션은 항상 켜져 있다. */ +export function isKeyBindingActionFeatureEnabled( + actionId: KeyBindingActionId, + flags: KeyBindingFeatureFlags +): boolean { + if (SUGGESTION_ACTIONS.has(actionId)) return flags.suggestionEnabled + return true +} + +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 accelerator. 표현할 수 없으면 null. + * 수정자 키 자체를 주 키로 쓰는 바인딩(예: Right Alt 단독)과 마우스 바인딩은 표현할 수 없다. + */ +export function bindingToAccelerator(binding: KeyBinding, platform: string): string | null { + if (binding.device !== 'keyboard') return 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(platform === 'darwin' ? 'Cmd' : 'Super') + parts.push(key) + return parts.join('+') +} + +/** 예약 후보 — 등록된 바인딩과 그 바인딩을 쓰는 액션 */ +export interface ReservableBinding { + actionId: KeyBindingActionId + binding: KeyBinding +} + +export interface AcceleratorReserveContext { + platform: string + flags: KeyBindingFeatureFlags +} + +/** + * globalShortcut 으로 예약할 accelerator 목록 (중복 없음, 입력 순서 유지). + * + * - macOS 가 아니면 비어 있다 — 다른 플랫폼에서 예약은 beep 차단 효과 없이 키만 빼앗는다. + * - 기능이 꺼진 액션의 바인딩은 예약하지 않는다. 한 바인딩을 여러 액션이 공유하면 + * 그중 하나라도 켜져 있을 때 예약한다. + */ +export function acceleratorsToReserve( + bindings: readonly ReservableBinding[], + context: AcceleratorReserveContext +): string[] { + if (!RESERVING_PLATFORMS.has(context.platform)) return [] + + const out: string[] = [] + const seen = new Set() + for (const { actionId, binding } of bindings) { + if (!isKeyBindingActionFeatureEnabled(actionId, context.flags)) continue + const accelerator = bindingToAccelerator(binding, context.platform) + if (accelerator === null || seen.has(accelerator)) continue + seen.add(accelerator) + out.push(accelerator) + } + return out +} diff --git a/apps/desktop/tests/main/services/keybinding-redteam-r2-16.test.ts b/apps/desktop/tests/main/services/keybinding-redteam-r2-16.test.ts new file mode 100644 index 0000000..b48e849 --- /dev/null +++ b/apps/desktop/tests/main/services/keybinding-redteam-r2-16.test.ts @@ -0,0 +1,338 @@ +// tests/main/services/keybinding-redteam-r2-16.test.ts +// 레드팀 r2-16 회귀 테스트 (데스크톱 어댑터). +// 1) globalShortcut 은 macOS 에서만, 켜진 기능의 바인딩만 예약한다 — Windows 에서 키를 빼앗지 않는다. +// 2) 마우스 hold 바인딩은 누름 중 수정자를 더 눌러도 mouseup 에서 release 된다. +// 3) 사용자가 비운 액션([])은 다른 액션 저장 · 재시작 뒤에도 비어 있다. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { globalShortcut, ipcMain } from 'electron' +import { uIOhook } from 'uiohook-napi' +import type { UiohookMouseEvent } from 'uiohook-napi' +import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' +import { MouseButton, createDefaultBindingMap } from '@d3ro/core/keybinding' +import type { KeyBinding, KeyBindingMap } from '@d3ro/core/types' +import type { IPCResult } from '@d3ro/core/errors' +import { + acceleratorsToReserve, + bindingToAccelerator, + isKeyBindingActionFeatureEnabled +} from '../../../src/main/services/keybinding-accelerator-policy' +import type { ReservableBinding } from '../../../src/main/services/keybinding-accelerator-policy' +import { + configGet, + configSet, + initInMemoryConfig, + loadKeyBindings, + resetInMemoryConfig +} from '../../../src/main/services/ConfigService' +import { getKeyBindingService } from '../../../src/main/services/KeyBindingService' +import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService' +import { registerKeyBindingHandlers } from '../../../src/main/ipc/keybinding-handlers' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +function restorePlatform(): void { + if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform) +} + +function registeredAccelerators(): string[] { + return vi.mocked(globalShortcut.register).mock.calls.map((call) => String(call[0])) +} + +function defaultReservable(): ReservableBinding[] { + const map = createDefaultBindingMap() + return (Object.keys(map) as (keyof KeyBindingMap)[]).flatMap((actionId) => + map[actionId].map((binding) => ({ actionId, binding })) + ) +} + +// ============================================================ +// 1) accelerator 예약 정책 (순수) +// ============================================================ + +describe('acceleratorsToReserve', () => { + it('Windows 에서는 기본 바인딩을 하나도 예약하지 않는다', () => { + expect( + acceleratorsToReserve(defaultReservable(), { platform: 'win32', flags: { suggestionEnabled: true } }) + ).toEqual([]) + expect( + acceleratorsToReserve(defaultReservable(), { platform: 'linux', flags: { suggestionEnabled: true } }) + ).toEqual([]) + }) + + it('macOS 에서 제안 기능이 꺼져 있으면 suggestion-* 바인딩은 예약하지 않는다', () => { + expect( + acceleratorsToReserve(defaultReservable(), { platform: 'darwin', flags: { suggestionEnabled: false } }) + ).toEqual(['Control+Shift+V', 'Control+Shift+C']) + }) + + it('macOS 에서 제안 기능이 켜져 있으면 suggestion-* 바인딩도 예약한다', () => { + expect( + acceleratorsToReserve(defaultReservable(), { platform: 'darwin', flags: { suggestionEnabled: true } }) + ).toEqual([ + 'Control+Shift+V', + 'Control+Shift+C', + 'Control+Alt+Return', + 'Control+Alt+Down', + 'Control+Alt+Up', + 'Control+Alt+Backspace' + ]) + }) + + it('한 바인딩을 공유하는 액션 중 하나라도 켜져 있으면 예약하고, 중복은 한 번만 낸다', () => { + const shared: KeyBinding = { device: 'keyboard', code: 0x78, ctrl: true, alt: false, shift: true, meta: false } + expect( + acceleratorsToReserve( + [ + { actionId: 'suggestion-next', binding: shared }, + { actionId: 'command-popup', binding: shared } + ], + { platform: 'darwin', flags: { suggestionEnabled: false } } + ) + ).toEqual(['Control+Shift+F9']) + }) + + it('마우스 · 수정자 단독 바인딩은 accelerator 로 표현하지 않는다', () => { + const x1: KeyBinding = { device: 'mouse', code: MouseButton.Back, ctrl: false, alt: false, shift: false, meta: false } + expect(bindingToAccelerator(x1, 'darwin')).toBeNull() + expect(bindingToAccelerator(createDefaultBindingMap().dictation[0]!, 'darwin')).toBeNull() + }) + + it('meta 는 macOS 에서 Cmd, 그 외에서는 Super 다', () => { + const b: KeyBinding = { device: 'keyboard', code: 0x4b, ctrl: false, alt: false, shift: false, meta: true } + expect(bindingToAccelerator(b, 'darwin')).toBe('Cmd+K') + expect(bindingToAccelerator(b, 'win32')).toBe('Super+K') + }) + + it('기능 스위치가 없는 액션은 항상 켜져 있다', () => { + expect(isKeyBindingActionFeatureEnabled('history-popup', { suggestionEnabled: false })).toBe(true) + expect(isKeyBindingActionFeatureEnabled('suggestion-accept', { suggestionEnabled: false })).toBe(false) + }) +}) + +// ============================================================ +// 1) + 2) KeyBindingService 어댑터 +// ============================================================ + +describe('KeyBindingService — globalShortcut 예약 · 마우스 release', () => { + beforeEach(() => { + initInMemoryConfig() + vi.mocked(globalShortcut.register).mockClear() + vi.mocked(globalShortcut.unregister).mockClear() + vi.mocked(uIOhook.on).mockClear() + }) + + afterEach(() => { + getKeyBindingService().stop() + restorePlatform() + resetInMemoryConfig() + }) + + it('Windows 에서는 기본 설정으로 시작해도 globalShortcut 을 하나도 등록하지 않는다', () => { + setPlatform('win32') + const service = getKeyBindingService() + service.loadFromConfig() + service.start() + expect(service.registeredBindingCount).toBeGreaterThan(0) + expect(globalShortcut.register).not.toHaveBeenCalled() + + configSet('suggestionEnabled', true) + expect(globalShortcut.register).not.toHaveBeenCalled() + }) + + it('macOS 에서는 켜진 기능만 예약하고, 제안 기능을 켜고 끄면 다시 맞춘다', () => { + setPlatform('darwin') + const service = getKeyBindingService() + service.loadFromConfig() + service.start() + expect(registeredAccelerators()).toEqual(['Control+Shift+V', 'Control+Shift+C']) + + vi.mocked(globalShortcut.register).mockClear() + configSet('suggestionEnabled', true) + expect(registeredAccelerators()).toContain('Control+Alt+Down') + + vi.mocked(globalShortcut.register).mockClear() + configSet('suggestionEnabled', false) + expect(registeredAccelerators()).toEqual(['Control+Shift+V', 'Control+Shift+C']) + }) + + it('X1 hold 중 Alt 를 더 누른 채 버튼을 놓아도 dictation 이 released 된다', () => { + setPlatform('win32') + const map = createDefaultBindingMap() + map.dictation = [{ device: 'mouse', code: MouseButton.Back, ctrl: false, alt: false, shift: false, meta: false }] + configSet('keyBindings', map) + + const service = getKeyBindingService() + service.loadFromConfig() + service.start() + + const handlerOf = (event: string): ((e: UiohookMouseEvent) => void) => { + const call = vi.mocked(uIOhook.on).mock.calls.find((c) => c[0] === event) + if (!call) throw new Error(`no ${event} handler`) + return call[1] as (e: UiohookMouseEvent) => void + } + const events: string[] = [] + const listener = (p: KeyBindingTriggerPayload): void => { + events.push(`${p.actionId}:${p.type}`) + } + service.on('triggered', listener) + + const mouseEvent = (alt: boolean): UiohookMouseEvent => + ({ button: MouseButton.Back, altKey: alt, ctrlKey: false, shiftKey: false, metaKey: false }) as unknown as UiohookMouseEvent + + handlerOf('mousedown')(mouseEvent(false)) + handlerOf('mouseup')(mouseEvent(true)) + service.off('triggered', listener) + + expect(events).toEqual(['dictation:pressed', 'dictation:released']) + }) +}) + +// ============================================================ +// 3) 비운 액션의 영속 +// ============================================================ + +type Handler = (event: unknown, ...args: unknown[]) => Promise> + +function captureHandlers(): Map { + const handlers = new Map() + vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Handler) => { + handlers.set(channel, handler) + }) + registerKeyBindingHandlers() + return handlers +} + +async function invoke(handlers: Map, channel: string, params?: unknown): Promise> { + const handler = handlers.get(channel) + if (!handler) throw new Error(`IPC handler not registered: ${channel}`) + return handler({}, params) +} + +const FREE_BINDING: KeyBinding = { device: 'keyboard', code: 0x78, ctrl: true, alt: false, shift: true, meta: false } + +describe('keybinding IPC — 비운 액션 보존', () => { + beforeEach(() => { + initInMemoryConfig() + }) + + afterEach(() => { + getKeyBindingService().stop() + resetInMemoryConfig() + }) + + it('history-popup 을 비운 뒤 다른 액션을 저장해도 history-popup 은 비어 있다 (GET_MAP 포함)', async () => { + const handlers = captureHandlers() + const cleared = await invoke(handlers, IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { + actionId: 'history-popup', + bindings: [] + }) + expect(cleared.success).toBe(true) + + const other = await invoke(handlers, IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { + actionId: 'dictation', + bindings: [FREE_BINDING] + }) + expect(other.success).toBe(true) + + expect(configGet('keyBindings')['history-popup']).toEqual([]) + const map = await invoke(handlers, IPC_CHANNELS.KEYBINDING.GET_MAP) + expect(map.success && (map.data as KeyBindingMap)['history-popup']).toEqual([]) + expect(loadKeyBindings()['history-popup']).toEqual([]) + }) + + it('비운 액션은 런타임에도 등록되지 않는다', async () => { + const handlers = captureHandlers() + const before = (() => { + getKeyBindingService().loadFromConfig() + return getKeyBindingService().registeredBindingCount + })() + await invoke(handlers, IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { actionId: 'history-popup', bindings: [] }) + expect(getKeyBindingService().registeredBindingCount).toBe(before - 1) + }) +}) + +describe('ConfigService 재시작 — 비운 액션 보존', () => { + afterEach(() => { + vi.doUnmock('electron-store') + vi.resetModules() + }) + + it('저장소에 []로 남은 액션은 initConfigService 마이그레이션 뒤에도 [] 다', async () => { + const persisted: Record = { + suggestionTuningRevision: 6, + keyBindings: { ...createDefaultBindingMap(), 'history-popup': [] } + } + const setCalls: string[] = [] + + class TestStore> { + store: T + + constructor(options: { defaults: T }) { + this.store = { ...options.defaults, ...persisted } as T + } + + get(key: K): T[K] { + return this.store[key] + } + + set(key: K, value: T[K]): void { + setCalls.push(String(key)) + this.store[key] = value + } + + delete(key: string): void { + delete this.store[key as keyof T] + } + } + + vi.doMock('electron-store', () => ({ default: TestStore })) + const fresh = await import('../../../src/main/services/ConfigService') + await fresh.initConfigService() + + expect(fresh.configGet('keyBindings')['history-popup']).toEqual([]) + expect(fresh.loadKeyBindings()['history-popup']).toEqual([]) + // 이미 정본 모양이면 다시 쓰지 않는다. + expect(setCalls).not.toContain('keyBindings') + fresh.resetInMemoryConfig() + }) + + it('새 액션이 빠진 저장값은 그 액션만 기본값으로 채워 다시 쓴다', async () => { + const stored: Partial = { ...createDefaultBindingMap(), 'history-popup': [] } + delete stored['suggestion-dismiss'] + const persisted: Record = { suggestionTuningRevision: 6, keyBindings: stored } + + class TestStore> { + store: T + + constructor(options: { defaults: T }) { + this.store = { ...options.defaults, ...persisted } as T + } + + get(key: K): T[K] { + return this.store[key] + } + + set(key: K, value: T[K]): void { + this.store[key] = value + } + + delete(key: string): void { + delete this.store[key as keyof T] + } + } + + vi.doMock('electron-store', () => ({ default: TestStore })) + const fresh = await import('../../../src/main/services/ConfigService') + await fresh.initConfigService() + + const bindings = fresh.configGet('keyBindings') + expect(bindings['suggestion-dismiss']).toEqual(createDefaultBindingMap()['suggestion-dismiss']) + expect(bindings['history-popup']).toEqual([]) + fresh.resetInMemoryConfig() + }) +}) diff --git a/packages/core/__tests__/keybinding-redteam-r2-16.test.ts b/packages/core/__tests__/keybinding-redteam-r2-16.test.ts new file mode 100644 index 0000000..ea79cb9 --- /dev/null +++ b/packages/core/__tests__/keybinding-redteam-r2-16.test.ts @@ -0,0 +1,185 @@ +// packages/core/__tests__/keybinding-redteam-r2-16.test.ts +// 레드팀 r2-16 회귀 테스트. +// - 저장 코덱(decodeKeyBindingMap): 사용자가 비운 액션([])을 기본값으로 되살리지 않는다. +// - 런타임(ChordStateMachine): 마우스 버튼 누름 중 수정자를 더 눌러도 mouseup 이 release 한다. + +import { describe, it, expect } from 'vitest' +import { ChordStateMachine, isChordComponentOf } from '../src/keybinding-runtime' +import type { ChordClock, ChordTriggerEvent } from '../src/keybinding-runtime' +import { + KEYBINDING_ACTIONS, + MouseButton, + VK, + bindingKey, + createDefaultBindingMap, + decodeKeyBindingMap, + kb +} from '../src/keybinding' +import type { KeyBinding } from '../src/keybinding' + +const F8 = 0x77 + +function key(code: number, mods: Partial> = {}): KeyBinding { + return kb(code, mods) +} + +function mouse(code: number, mods: Partial> = {}): KeyBinding { + return { ...kb(code, mods), device: 'mouse' } +} + +// ============================================================ +// decodeKeyBindingMap +// ============================================================ + +describe('decodeKeyBindingMap', () => { + it('명시적 빈 목록은 빈 목록 그대로 보존한다 (기본값으로 되살리지 않는다)', () => { + const decoded = decodeKeyBindingMap({ ...createDefaultBindingMap(), 'history-popup': [] }) + expect(decoded.map['history-popup']).toEqual([]) + expect(decoded.repairedActions).toEqual([]) + expect(decoded.needsRewrite).toBe(false) + }) + + it('빈 목록은 decode → 저장 → decode 왕복에서도 빈 목록이다', () => { + const first = decodeKeyBindingMap({ ...createDefaultBindingMap(), caption: [] }) + const roundTrip = decodeKeyBindingMap(JSON.parse(JSON.stringify(first.map))) + expect(roundTrip.map.caption).toEqual([]) + expect(roundTrip.map).toEqual(first.map) + }) + + it.each([ + ['누락', undefined], + ['배열 아님', 'garbage'], + ['객체', { device: 'keyboard', code: 65 }], + ['비어 있지 않지만 전부 손상', [{ device: 'keyboard', code: 65 }, null, 7]] + ])('%s 인 액션만 기본값으로 복구하고 repairedActions 에 넣는다', (_label, stored) => { + const defaults = createDefaultBindingMap() + const decoded = decodeKeyBindingMap({ ...defaults, command: stored }) + expect(decoded.map.command).toEqual(defaults.command) + expect(decoded.repairedActions).toEqual(['command']) + expect(decoded.needsRewrite).toBe(true) + }) + + it('레코드가 아니면 모든 액션을 복구한다', () => { + for (const value of [null, undefined, 'x', 7, [key(F8)]]) { + const decoded = decodeKeyBindingMap(value) + expect(decoded.map).toEqual(createDefaultBindingMap()) + expect(decoded.repairedActions.sort()).toEqual(KEYBINDING_ACTIONS.map((a) => a.id).sort()) + expect(decoded.needsRewrite).toBe(true) + } + }) + + it('일부 손상 항목은 버리고 유효 항목을 유지한다 — 복구가 아니라 정규화다', () => { + const decoded = decodeKeyBindingMap({ ...createDefaultBindingMap(), dictation: [key(F8), 'junk'] }) + expect(decoded.map.dictation).toEqual([key(F8)]) + expect(decoded.repairedActions).toEqual([]) + expect(decoded.needsRewrite).toBe(true) + }) + + it('정규화·중복 제거가 필요하면 needsRewrite, 이미 정본 모양이면 다시 쓰지 않는다', () => { + const unnormalized = decodeKeyBindingMap({ + ...createDefaultBindingMap(), + dictation: [key(VK.AltRight, { alt: true, ctrl: true })] + }) + expect(unnormalized.map.dictation).toEqual([key(VK.AltRight, { ctrl: true })]) + expect(unnormalized.needsRewrite).toBe(true) + + const duplicated = decodeKeyBindingMap({ ...createDefaultBindingMap(), dictation: [key(F8), key(F8)] }) + expect(duplicated.map.dictation).toEqual([key(F8)]) + expect(duplicated.needsRewrite).toBe(true) + + expect(decodeKeyBindingMap(createDefaultBindingMap()).needsRewrite).toBe(false) + }) + + it('알 수 없는 액션 키는 버리고 모든 액션 키를 갖는다', () => { + const decoded = decodeKeyBindingMap({ ...createDefaultBindingMap(), 'not-an-action': [key(F8)] }) + expect(Object.keys(decoded.map).sort()).toEqual(KEYBINDING_ACTIONS.map((a) => a.id).sort()) + expect(Object.keys(decoded.map)).not.toContain('not-an-action') + }) + + it('반환 맵은 기본값 정의와 참조를 공유하지 않는다', () => { + const decoded = decodeKeyBindingMap(null) + decoded.map.dictation[0]!.ctrl = true + expect(createDefaultBindingMap().dictation[0]!.ctrl).toBe(false) + }) +}) + +// ============================================================ +// 마우스 hold 바인딩 release +// ============================================================ + +const clock: ChordClock = { + now: () => 1_000, + schedule: () => () => undefined +} + +function labels(events: ChordTriggerEvent[]): string[] { + return events.map((e) => `${e.actionId}:${e.type}`) +} + +describe('ChordStateMachine — 마우스 버튼 누름 중 수정자 추가', () => { + it('X1 누름 → Alt 누름 → X1 놓음(mouseup 은 X1+Alt) 이면 release 한다', () => { + const events: ChordTriggerEvent[] = [] + const machine = new ChordStateMachine({ clock, onTrigger: (e) => events.push(e) }) + const x1 = mouse(MouseButton.Back) + machine.setBindings([{ actionId: 'dictation', holdMode: true, doublePress: false, binding: x1 }]) + + machine.keyDown(bindingKey(x1)) + // Alt 키 누름/놓음 자체는 X1 바인딩의 구성 요소가 아니다. + machine.keyUp(bindingKey(key(VK.AltLeft)), VK.AltLeft) + expect(labels(events)).toEqual(['dictation:pressed']) + + machine.keyUp(bindingKey(mouse(MouseButton.Back, { alt: true })), { + device: 'mouse', + code: MouseButton.Back + }) + expect(labels(events)).toEqual(['dictation:pressed', 'dictation:released']) + + // 눌림 상태가 남지 않았으므로 다음 누름이 auto-repeat 로 삼켜지지 않는다. + machine.keyDown(bindingKey(x1)) + expect(labels(events)).toEqual(['dictation:pressed', 'dictation:released', 'dictation:pressed']) + }) + + it('다른 버튼의 mouseup 은 release 하지 않는다', () => { + const events: ChordTriggerEvent[] = [] + const machine = new ChordStateMachine({ clock, onTrigger: (e) => events.push(e) }) + const x1 = mouse(MouseButton.Back) + machine.setBindings([{ actionId: 'dictation', holdMode: true, doublePress: false, binding: x1 }]) + + machine.keyDown(bindingKey(x1)) + machine.keyUp(bindingKey(mouse(MouseButton.Forward, { alt: true })), { + device: 'mouse', + code: MouseButton.Forward + }) + expect(labels(events)).toEqual(['dictation:pressed']) + }) + + it('숫자 component 는 키보드 VK 로 해석한다 (옛 호출 형태 호환)', () => { + const events: ChordTriggerEvent[] = [] + const machine = new ChordStateMachine({ clock, onTrigger: (e) => events.push(e) }) + const ctrlShiftV = key(0x56, { ctrl: true, shift: true }) + machine.setBindings([{ actionId: 'history-popup', holdMode: false, doublePress: false, binding: ctrlShiftV }]) + machine.keyDown(bindingKey(ctrlShiftV)) + machine.keyUp(bindingKey(key(VK.ShiftLeft, { ctrl: true })), VK.ShiftLeft) + expect(labels(events)).toEqual(['history-popup:pressed', 'history-popup:released']) + }) +}) + +describe('isChordComponentOf', () => { + it('같은 장치·같은 코드면 수정자와 무관하게 구성 요소다', () => { + expect(isChordComponentOf({ device: 'mouse', code: MouseButton.Back }, mouse(MouseButton.Back))).toBe(true) + expect( + isChordComponentOf({ device: 'mouse', code: MouseButton.Back }, mouse(MouseButton.Back, { ctrl: true })) + ).toBe(true) + }) + + it('마우스 버튼 코드는 같은 숫자의 키보드 키와 섞이지 않는다', () => { + // MouseButton.Back 과 같은 숫자의 VK 키보드 바인딩 + expect(isChordComponentOf({ device: 'mouse', code: MouseButton.Back }, key(MouseButton.Back))).toBe(false) + expect(isChordComponentOf({ device: 'keyboard', code: MouseButton.Back }, mouse(MouseButton.Back))).toBe(false) + }) + + it('키보드 수정자 키는 그 수정자를 켠 바인딩(마우스 포함)의 구성 요소다', () => { + expect(isChordComponentOf({ device: 'keyboard', code: VK.CtrlLeft }, mouse(MouseButton.Back, { ctrl: true }))).toBe(true) + expect(isChordComponentOf({ device: 'keyboard', code: VK.AltLeft }, mouse(MouseButton.Back))).toBe(false) + }) +}) diff --git a/packages/core/src/keybinding-runtime.ts b/packages/core/src/keybinding-runtime.ts index db32453..a613f35 100644 --- a/packages/core/src/keybinding-runtime.ts +++ b/packages/core/src/keybinding-runtime.ts @@ -10,14 +10,14 @@ // 어댑터(데스크톱 KeyBindingService)의 책임: // - 장치 이벤트 → bindingKey / VK 변환 // - 관측 불가능한 키 걸러내기(등록 전에) -// - macOS beep 차단용 accelerator, 설정 로딩 +// - macOS beep 차단용 accelerator(플랫폼 · 기능 게이트 포함), 설정 로딩 // // 런타임 상태는 액션 id 가 아니라 bindingKey() 로 키잉한다 — // 한 액션에 여러 바인딩이 붙고, 여러 액션이 한 바인딩을 공유하기 때문이다. import { TIMING } from './constants' import { bindingKey, isModifierKeyCode, normalizeBinding } from './keybinding' -import type { KeyBinding, KeyBindingActionId } from './keybinding' +import type { BindingDevice, KeyBinding, KeyBindingActionId } from './keybinding' // ============================================================ // 공개 타입 · 포트 @@ -46,6 +46,15 @@ export interface ChordBindingEntry { binding: KeyBinding } +/** + * 놓인 물리 키/버튼. keyUp 이 정확 매칭에 실패했을 때 "눌림 상태 바인딩의 구성 요소가 + * 놓였는가" 를 판정하는 데 쓴다. 키보드는 Windows VK, 마우스는 MouseButton 코드다. + */ +export interface ChordReleasedComponent { + device: BindingDevice + code: number +} + /** * 시간 포트. 실제 구현은 Date.now / setTimeout, 테스트는 가짜 타이머를 넣는다. * schedule 은 취소 함수를 돌려준다 — 타이머 핸들 타입을 core 가 알 필요가 없다. @@ -118,16 +127,33 @@ export function isAltGrShapedBinding(binding: KeyBinding): boolean { } /** - * 놓인 키(VK)가 바인딩의 주 키이거나 켜진 수정자의 구성 키인가. - * 마우스 바인딩(Ctrl+뒤로 등)도 수정자를 먼저 놓으면 release 되어야 한다 — - * 그 뒤의 mouseup 은 수정자 없이 들어와 정확 매칭이 실패하기 때문이다. + * 놓인 키/버튼이 바인딩의 주 키이거나 켜진 수정자의 구성 키인가. + * + * - 주 키: 장치와 코드가 같으면 수정자 플래그와 무관하게 구성 요소다. 마우스 X1 을 누른 채 + * Alt 를 더 누르고 버튼을 놓으면 mouseup 이 `X1+Alt` 로 들어와 정확 매칭이 실패하는데, + * 이때도 X1 누름은 놓여야 한다(아니면 hold-to-talk 가 계속 녹음한다). + * - 수정자: 키보드 수정자 키만 해당한다. 마우스 바인딩(Ctrl+뒤로 등)도 수정자를 먼저 놓으면 + * release 되어야 한다 — 그 뒤의 mouseup 은 수정자 없이 들어와 정확 매칭이 실패하기 때문이다. */ -function isComponentOf(vk: number, binding: KeyBinding): boolean { - if (binding.device === 'keyboard' && binding.code === vk) return true - const flag = modifierFlagOfKeyCode(vk) +export function isChordComponentOf( + component: ChordReleasedComponent, + binding: KeyBinding +): boolean { + if (binding.device === component.device && binding.code === component.code) return true + if (component.device !== 'keyboard') return false + const flag = modifierFlagOfKeyCode(component.code) return flag !== null && binding[flag] } +/** keyUp 의 두 번째 인자 — 숫자는 키보드 VK(옛 호출 형태)다. */ +function toReleasedComponent( + component: number | ChordReleasedComponent | null +): ChordReleasedComponent | null { + if (component === null) return null + if (typeof component === 'number') return { device: 'keyboard', code: component } + return component +} + // ============================================================ // 상태 머신 // ============================================================ @@ -297,27 +323,35 @@ export class ChordStateMachine { /** * 키/버튼 놓임. * - * eventKey 가 눌림 상태와 정확히 맞으면 그 바인딩을 놓는다. 아니면 componentVk(놓인 - * 키의 VK)가 눌림 상태 바인딩의 주 키/수정자인지 본다 — Alt+1 에서 수정자를 먼저 - * 놓아도 release 를 놓치지 않기 위해서다. mouseup 은 componentVk 를 null 로 넘겨 - * 정확 매칭만 한다(마우스 버튼은 키보드 바인딩의 구성 키가 아니다). + * eventKey 가 눌림 상태와 정확히 맞으면 그 바인딩을 놓는다. 아니면 component(놓인 + * 키/버튼)가 눌림 상태 바인딩의 주 키/수정자인지 본다 — Alt+1 에서 수정자를 먼저 + * 놓거나, 마우스 버튼을 누른 채 수정자를 더 눌러도 release 를 놓치지 않기 위해서다. + * + * component 가 숫자면 키보드 VK 다(옛 호출 형태). null 이면 정확 매칭만 한다. + * 마우스 버튼은 물리적으로 하나이므로 그 버튼을 쓰는 눌림 바인딩을 모두 놓는다. */ - keyUp(eventKey: string | null, componentVk: number | null): void { + keyUp( + eventKey: string | null, + component: number | ChordReleasedComponent | null + ): void { if (eventKey !== null && this._isDown.has(eventKey)) { this._release(eventKey) return } - if (componentVk === null) return + const released = toReleasedComponent(component) + if (released === null) return + const matches: string[] = [] for (const [key, entries] of this._byKey) { if (!this._isDown.has(key)) continue const first = entries[0] if (first === undefined) continue - if (isComponentOf(componentVk, first.binding)) { - this._release(key) - return + if (isChordComponentOf(released, first.binding)) { + matches.push(key) + if (released.device === 'keyboard') break } } + for (const key of matches) this._release(key) } // ── 내부 ─────────────────────────────────────────────── diff --git a/packages/core/src/keybinding.ts b/packages/core/src/keybinding.ts index 94f5fbe..c869e38 100644 --- a/packages/core/src/keybinding.ts +++ b/packages/core/src/keybinding.ts @@ -1248,7 +1248,13 @@ export function parseBindingList(value: unknown): KeyBinding[] { return out } -/** 저장된 전체 맵을 복원한다. 누락·손상 액션은 기본값으로 채운다. */ +/** + * 저장된 전체 맵을 복원한다 (옛 의미). 누락·손상 액션 **과 명시적 빈 목록** 을 기본값으로 채운다. + * + * @deprecated 저장소를 읽는 경로는 `decodeKeyBindingMap` 을 쓴다. 이 함수는 사용자가 지운 + * 액션(`[]`)을 기본값으로 되살리므로, 저장값을 읽고 다시 쓰는 곳에서 쓰면 지운 단축키가 + * 되돌아온다. 옛 호출부 호환을 위해서만 남긴다. + */ export function parseBindingMap(value: unknown): KeyBindingMap { const defaults = createDefaultBindingMap() if (!isRecord(value)) return defaults @@ -1260,3 +1266,85 @@ export function parseBindingMap(value: unknown): KeyBindingMap { } return out } + +// ============================================================ +// 저장 코덱 — 저장된 keyBindings 를 읽는 단일 정본 +// ============================================================ + +/** 액션 하나의 저장값을 해석한 결과 */ +type StoredListState = + /** 저장값 그대로 쓸 수 있다 */ + | 'intact' + /** 유효 항목은 있지만 손상·중복 항목을 버렸거나 정규화로 모양이 바뀌었다 */ + | 'normalized' + /** 누락·배열 아님·전부 손상 — 기본값으로 복구했다 */ + | 'repaired' + +function sameStoredShape(stored: KeyBinding, decoded: KeyBinding): boolean { + return ( + stored.device === decoded.device && + stored.code === decoded.code && + stored.ctrl === decoded.ctrl && + stored.alt === decoded.alt && + stored.shift === decoded.shift && + stored.meta === decoded.meta + ) +} + +function decodeBindingList( + value: unknown, + fallback: readonly KeyBinding[] +): { bindings: KeyBinding[]; state: StoredListState } { + const repaired = (): { bindings: KeyBinding[]; state: StoredListState } => ({ + bindings: fallback.map((b) => ({ ...b })), + state: 'repaired' + }) + if (!Array.isArray(value)) return repaired() + // 명시적 빈 목록 = 사용자가 이 액션의 단축키를 모두 지웠다. 기본값으로 되살리지 않는다. + if (value.length === 0) return { bindings: [], state: 'intact' } + + const bindings = parseBindingList(value) + // 비어 있지 않았는데 쓸 만한 항목이 하나도 없다 = 손상. 기본값으로 복구한다. + if (bindings.length === 0) return repaired() + + const intact = + bindings.length === value.length && + bindings.every((binding, index) => { + const stored: unknown = value[index] + return isKeyBinding(stored) && sameStoredShape(stored, binding) + }) + return { bindings, state: intact ? 'intact' : 'normalized' } +} + +/** `decodeKeyBindingMap` 결과 */ +export interface DecodedKeyBindingMap { + /** 모든 액션 키를 가진 정규화된 맵. 명시적 빈 목록은 빈 목록 그대로다. */ + map: KeyBindingMap + /** 저장값이 없거나(새 액션 포함) 손상되어 기본값으로 채운 액션 */ + repairedActions: KeyBindingActionId[] + /** 결과가 저장된 모양과 달라 다시 써야 하는지 (복구 · 손상 항목 제거 · 정규화) */ + needsRewrite: boolean +} + +/** + * 저장된 keyBindings 를 엄격하게 해석한다 — 저장소를 읽는 모든 경로(설정 로딩 · IPC · + * 마이그레이션 · 후킹 서비스)의 단일 정본. + * + * - 명시적 빈 목록(`[]`)은 "이 액션은 단축키 없음" 으로 보존한다. + * - 누락 · 배열 아님 · 비어 있지 않은데 유효 항목이 없는 액션만 기본값으로 복구한다. + * - 유효 항목은 정규화하고 중복을 없앤다. 알 수 없는 액션 키는 버린다. + */ +export function decodeKeyBindingMap(value: unknown): DecodedKeyBindingMap { + const record = isRecord(value) && !Array.isArray(value) ? value : null + const map = {} as KeyBindingMap + const repairedActions: KeyBindingActionId[] = [] + let needsRewrite = record === null + + for (const action of KEYBINDING_ACTIONS) { + const decoded = decodeBindingList(record?.[action.id], action.defaultBindings) + map[action.id] = decoded.bindings + if (decoded.state === 'repaired') repairedActions.push(action.id) + if (decoded.state !== 'intact') needsRewrite = true + } + return { map, repairedActions, needsRewrite } +}