fix(keybinding): stop stealing chords on Windows, release mouse holds, keep cleared actions
This commit is contained in:
parent
115e8488ac
commit
1a4c39cb94
8 changed files with 927 additions and 148 deletions
|
|
@ -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 트리거를 렌더러로 중계한다. 재등록 시 리스너가 중복되지 않게 이전 것을 떼어낸다. */
|
||||
|
|
|
|||
|
|
@ -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<AppConfig>): 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<AppConfig>): void {
|
||||
const raw = activeStore.store as unknown as Record<string, unknown>
|
||||
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<AppConfig>,
|
||||
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<AppConfig>): void {
|
||||
const raw = activeStore.store as unknown as Record<string, unknown>
|
||||
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<K extends keyof AppConfig>(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
|
||||
|
|
|
|||
|
|
@ -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<string> = 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 타입 오버라이드 ───────────────────────
|
||||
|
|
|
|||
131
apps/desktop/src/main/services/keybinding-accelerator-policy.ts
Normal file
131
apps/desktop/src/main/services/keybinding-accelerator-policy.ts
Normal file
|
|
@ -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<string> = new Set(['darwin'])
|
||||
|
||||
/** 제안 오버레이 전용 액션. 제안 기능이 꺼져 있으면 예약하지 않는다. */
|
||||
const SUGGESTION_ACTIONS: ReadonlySet<KeyBindingActionId> = new Set<KeyBindingActionId>([
|
||||
'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<string>()
|
||||
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
|
||||
}
|
||||
|
|
@ -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<IPCResult<unknown>>
|
||||
|
||||
function captureHandlers(): Map<string, Handler> {
|
||||
const handlers = new Map<string, Handler>()
|
||||
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Handler) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
registerKeyBindingHandlers()
|
||||
return handlers
|
||||
}
|
||||
|
||||
async function invoke(handlers: Map<string, Handler>, channel: string, params?: unknown): Promise<IPCResult<unknown>> {
|
||||
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<string, unknown> = {
|
||||
suggestionTuningRevision: 6,
|
||||
keyBindings: { ...createDefaultBindingMap(), 'history-popup': [] }
|
||||
}
|
||||
const setCalls: string[] = []
|
||||
|
||||
class TestStore<T extends Record<string, unknown>> {
|
||||
store: T
|
||||
|
||||
constructor(options: { defaults: T }) {
|
||||
this.store = { ...options.defaults, ...persisted } as T
|
||||
}
|
||||
|
||||
get<K extends keyof T>(key: K): T[K] {
|
||||
return this.store[key]
|
||||
}
|
||||
|
||||
set<K extends keyof T>(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<KeyBindingMap> = { ...createDefaultBindingMap(), 'history-popup': [] }
|
||||
delete stored['suggestion-dismiss']
|
||||
const persisted: Record<string, unknown> = { suggestionTuningRevision: 6, keyBindings: stored }
|
||||
|
||||
class TestStore<T extends Record<string, unknown>> {
|
||||
store: T
|
||||
|
||||
constructor(options: { defaults: T }) {
|
||||
this.store = { ...options.defaults, ...persisted } as T
|
||||
}
|
||||
|
||||
get<K extends keyof T>(key: K): T[K] {
|
||||
return this.store[key]
|
||||
}
|
||||
|
||||
set<K extends keyof T>(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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue