refactor(keybinding): extract chord state machine into core and fix AltGr guard

This commit is contained in:
Yun Chan 2026-09-28 00:53:46 +09:00
parent de1e8a82a4
commit 9cd81b48c1
3 changed files with 960 additions and 355 deletions

View file

@ -1,13 +1,12 @@
// src/main/services/KeyBindingService.ts
// uiohook-napi 기반 글로벌 키바인딩 후킹 서비스.
// uiohook-napi 기반 글로벌 키바인딩 후킹 어댑터.
//
// 바인딩 계약(키 목록·기본값·정규화·검증·충돌 판정)의 정본은 `packages/core/src/keybinding.ts` 다.
// 이 파일은 두 가지만 책임진다:
// 바인딩 계약(키 목록·기본값·정규화·검증·충돌 판정)의 정본은 `packages/core/src/keybinding.ts`,
// press / release · 더블프레스 · auto-repeat · AltGr 보류 상태 머신의 정본은
// `packages/core/src/keybinding-runtime.ts` 다. 이 파일은 입출력 어댑터만 책임진다:
// 1) 정본 좌표계(Windows VK / MouseButton) ↔ uiohook 이벤트 좌표계 변환
// 2) press / release · 더블프레스 · auto-repeat 런타임 상태 머신
//
// 런타임 상태는 액션 id 가 아니라 bindingKey() 로 키잉한다 —
// 한 액션에 여러 바인딩이 붙고, 여러 액션이 한 바인딩을 공유하기 때문이다.
// 2) 설정 로딩 → 상태 머신 바인딩 등록
// 3) macOS beep 차단용 globalShortcut accelerator 관리
import { EventEmitter } from 'events'
import { globalShortcut } from 'electron'
@ -17,7 +16,6 @@ import { getLogger } from './LoggerService'
import { acquireGlobalInputHook } from './global-input-hook'
import { configGet } from './ConfigService'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { TIMING } from '@d3ro/core/constants'
import {
KEYBINDING_ACTIONS,
MouseButton,
@ -25,82 +23,42 @@ import {
bindingKey,
normalizeBinding
} from '@d3ro/core/keybinding'
import { ChordStateMachine } from '@d3ro/core/keybinding-runtime'
import type {
KeyBinding,
KeyBindingActionId,
KeyBindingActionSpec,
MouseButtonCode
} from '@d3ro/core/types'
ChordBindingEntry,
ChordClock,
ChordTriggerEvent
} from '@d3ro/core/keybinding-runtime'
import type { KeyBinding, KeyBindingActionSpec, MouseButtonCode } from '@d3ro/core/types'
const logger = getLogger('KeyBindingService')
/**
* AltGr(=Ctrl+Alt) 함정 가드.
*
* 오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표 같은 조합을 누르면
* Alt 를 누르는 순간 Ctrl+AltRight 바인딩이 먼저 발동한다(실측 신고: 제안 탐색 대신
* 음성 파이프라인이 돌았다). 수정자만으로 끝나면서 Ctrl+Alt 가 함께 눌린 트리거는
* 잠깐 보류하고, 그 사이에 다른 키가 눌리면(=조합을 만들려던 것) 취소한다.
*
* 받아쓰기(수정자 단독, Ctrl 없음)와 Alt+Shift 계열은 모양이 달라 영향받지 않는다.
*/
const ALTGR_CHORD_GRACE_MS = 280
/** 수정자 키의 VK 코드 (수정자만으로 끝나는 조합 판별). */
const MODIFIER_VK_CODES: ReadonlySet<number> = new Set([
0x10, // Shift
0x11, // Ctrl
0x12, // Alt
0x5b, // LWin
0x5c, // RWin
0xa0,
0xa1, // L/R Shift
0xa2,
0xa3, // L/R Ctrl
0xa4,
0xa5 // L/R Alt
])
let pendingAltGrEmit: NodeJS.Timeout | null = null
/** 보류가 취소된 바인딩 — 대응하는 released 를 내보내지 않기 위해 기억한다. */
const cancelledAltGrBindings = new Set<string>()
// ============================================================
// 이벤트 페이로드
// ============================================================
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
/**
* 매칭된 바인딩의 주 키 코드와 수정자 상태.
*
* 소비자가 "수정자만으로 끝나는 조합인지" 를 판단해 AltGr 함정을 피하는 데 쓴다.
* (오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표를 누르면
* Alt 를 누르는 순간 Ctrl+AltRight 바인딩이 먼저 발동한다)
*/
binding: {
device: 'keyboard' | 'mouse'
code: number
ctrl: boolean
alt: boolean
shift: boolean
meta: boolean
}
}
/**
* `triggered` 이벤트 페이로드. 정본은 core 의 ChordTriggerEvent 다.
*
* binding 은 매칭된 바인딩(정규화 완료)의 주 키 코드와 수정자 상태다.
*/
export type KeyBindingTriggerPayload = ChordTriggerEvent
interface KeyBindingServiceEvents {
triggered: (payload: KeyBindingTriggerPayload) => void
error: (payload: { error: D3ROError }) => void
}
/** 실제 시계. 보류 타이머가 프로세스 종료를 붙잡지 않도록 unref 한다. */
const systemClock: ChordClock = {
now: () => Date.now(),
schedule: (callback, delayMs) => {
const handle = setTimeout(callback, delayMs)
handle.unref?.()
return () => clearTimeout(handle)
}
}
// ============================================================
// Windows VK ↔ uiohook 키코드
// ============================================================
@ -258,22 +216,6 @@ export function uiohookCodeToVk(code: number): number | null {
return UIOHOOK_TO_VK.get(code) ?? null
}
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
}
// ============================================================
// 마우스 버튼 좁히기
// ============================================================
@ -365,66 +307,38 @@ function bindingToAccelerator(binding: KeyBinding): string | null {
// 내부 타입
// ============================================================
interface RegisteredBinding {
actionId: KeyBindingActionId
interface RegisteredAction {
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
/** 눌림을 만든 바인딩 (AltGr 형태 판정에 필요) */
entry: RegisteredBinding
}
// ============================================================
// KeyBindingService
// ============================================================
/** AltGr(=Ctrl+Alt) 형태의 수정자 전용 조합인가. */
function isAltGrShaped(binding: KeyBinding): boolean {
if (binding.device !== 'keyboard') return false
if (!binding.ctrl || !binding.alt) return false
return MODIFIER_VK_CODES.has(binding.code)
}
class KeyBindingService extends EventEmitter {
private _isRunning = false
/** bindingKey → 그 바인딩을 쓰는 액션들. 매칭은 이 역인덱스 조회로 끝난다. */
private _byBindingKey: Map<string, RegisteredBinding[]> = new Map()
/** press / release · 더블프레스 · AltGr 보류 판정 (순수 정책, core) */
private readonly _machine = new ChordStateMachine({
clock: systemClock,
onTrigger: (event) => this.emit('triggered', event),
debug: (message) => logger.debug(message)
})
/** 이 서비스가 직접 등록한 accelerator만 추적한다 (다른 곳의 등록을 해제하지 않기 위해) */
private _ownedAccelerators: Set<string> = new Set()
/** 키 반복(auto-repeat) 방지: 현재 눌려있는 바인딩 */
private _isKeyDown: Map<string, boolean> = new Map()
/** hold duration 계산용: press 시작 시각 */
private _pressStartTime: Map<string, number> = new Map()
/** 더블프레스 감지용: 마지막 press 시각 */
private _lastPressTime: Map<string, number> = new Map()
/** 현재 누름에서 실제로 트리거된 액션 — release 를 같은 대상에게만 보낸다 */
private _activeTriggers: Map<string, ActiveTrigger[]> = 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
/**
* 글로벌 후 해제 함수.
* 글로벌 후킹 해제 함수.
*
* 후킹은 프로세스 전역 글턴이고 InputTelemetryService 도 같은 후킹을 쓴다.
* 후킹은 프로세스 전역 싱글턴이고 InputTelemetryService 도 같은 후킹을 쓴다.
* 직접 stop 하면 상대방 수신을 죽이므로 참조 카운트 해제자를 보관한다.
*/
private _releaseHook: (() => void) | null = null
@ -435,7 +349,7 @@ class KeyBindingService extends EventEmitter {
/** 등록된 바인딩 수 (bindingKey 기준) */
get registeredBindingCount(): number {
return this._byBindingKey.size
return this._machine.registeredBindingCount
}
// ── 수명 주기 ──────────────────────────────────────────
@ -515,75 +429,59 @@ class KeyBindingService extends EventEmitter {
dispose(): void {
this.stop()
this._byBindingKey.clear()
// setBindings 는 런타임 상태(보류 타이머 포함)도 함께 비운다.
this._machine.setBindings([])
this._unregisterOwnedAccelerators()
this._clearRuntimeState()
this.removeAllListeners()
logger.info('KeyBindingService disposed')
}
// ── 등록 ───────────────────────────────────────────────
/** ConfigService 의 keyBindings 를 읽어 역인덱스를 다시 만든다. */
/** ConfigService 의 keyBindings 를 읽어 상태 머신 바인딩을 다시 만든다. */
loadFromConfig(): void {
this._byBindingKey.clear()
this._clearRuntimeState()
if (!configGet('hotkeyEnabled')) {
// setBindings 는 런타임 상태(보류 타이머 포함)도 함께 비운다.
this._machine.setBindings([])
this._unregisterOwnedAccelerators()
logger.info('Key bindings disabled in config')
return
}
const map = configGet('keyBindings')
const entries: ChordBindingEntry[] = []
for (const spec of KEYBINDING_ACTIONS) {
for (const raw of map[spec.id] ?? []) {
this._register(spec, raw)
const registered = this._toObservable(spec, raw)
if (registered === null) continue
entries.push({
actionId: registered.spec.id,
holdMode: registered.spec.holdMode,
doublePress: registered.spec.doublePress,
binding: registered.binding
})
}
}
this._machine.setBindings(entries)
this._syncAccelerators()
logger.info(
`Loaded ${this._byBindingKey.size} key binding(s) from config ` +
`Loaded ${this._machine.registeredBindingCount} key binding(s) from config ` +
`for ${KEYBINDING_ACTIONS.length} action(s)`
)
}
private _register(spec: KeyBindingActionSpec, raw: KeyBinding): void {
/** uiohook 이벤트로 관측할 수 있는 바인딩만 통과시킨다. */
private _toObservable(spec: KeyBindingActionSpec, raw: KeyBinding): RegisteredAction | null {
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) {
if (binding.device === 'keyboard' && !VK_TO_UIOHOOK.has(binding.code)) {
logger.warn(
`Key code 0x${binding.code.toString(16)} has no uiohook equivalent ` +
`— binding for action "${spec.id}" is not registered`
)
return
return null
}
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)
return { spec, binding }
}
// ── accelerator (macOS beep 차단) ──────────────────────
@ -592,10 +490,10 @@ class KeyBindingService extends EventEmitter {
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
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
try {
@ -628,23 +526,20 @@ class KeyBindingService extends EventEmitter {
this._ownedAccelerators.clear()
}
/** 눌림 · 더블프레스 · AltGr 보류 타이머를 모두 비운다. */
private _clearRuntimeState(): void {
this._isKeyDown.clear()
this._pressStartTime.clear()
this._lastPressTime.clear()
this._activeTriggers.clear()
this._machine.reset()
}
// ── 이벤트 → 바인딩 매칭 ───────────────────────────────
// ── uiohook 이벤트 → 정본 좌표계 ──────────────────────
/**
* uiohook 이벤트를 정본 좌표계 bindingKey 로 옮긴다.
* uiohook 키보드 이벤트를 정본 좌표계 bindingKey 로 옮긴다.
* normalizeBinding 이 "주 키가 수정자 자신"인 경우를 정리하므로
* Right Alt 단독 바인딩도 그대로 매칭된다.
*/
private _keyboardEventKey(e: UiohookKeyboardEvent): string | null {
const vk = UIOHOOK_TO_VK.get(e.keycode)
if (vk === undefined) return null
private _keyboardEventKey(e: UiohookKeyboardEvent, vk: number | null): string | null {
if (vk === null) return null
return bindingKey({
device: 'keyboard',
code: vk,
@ -669,202 +564,31 @@ class KeyBindingService extends EventEmitter {
}
private _handleKeyDown(e: UiohookKeyboardEvent): void {
const key = this._keyboardEventKey(e)
const key = this._keyboardEventKey(e, uiohookCodeToVk(e.keycode))
if (key === null) return
this._handlePress(key)
this._machine.keyDown(key)
}
private _handleMouseDown(e: UiohookMouseEvent): void {
const key = this._mouseEventKey(e)
if (key === null) return
this._handlePress(key)
this._machine.keyDown(key)
}
/**
* 키 업.
* Alt+1 같은 조합에서 수정자를 먼저 놓아도 release 를 놓치지 않아야 하므로,
* 정확 매칭이 실패하면 눌림 상태인 바인딩의 구성 키가 놓였는지도 확인한다.
* 키 업. Alt+1 같은 조합에서 수정자를 먼저 놓아도 release 를 놓치지 않도록
* 놓인 키의 VK 를 함께 넘긴다 — 정확 매칭이 실패하면 상태 머신이 구성 키 여부를 본다.
*/
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)
const vk = uiohookCodeToVk(e.keycode)
this._machine.keyUp(this._keyboardEventKey(e, vk), vk)
}
/** 마우스 업은 정확 매칭만 한다. */
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,
entry
})
}
this._activeTriggers.set(key, triggers)
for (const trigger of triggers) {
logger.debug(`Key binding pressed: "${trigger.actionId}" (double=${isDoublePress})`)
this._emitPressed(trigger.entry, trigger, now, key)
}
}
/**
* 눌림 이벤트를 내보낸다.
*
* AltGr 형태(수정자 전용 + Ctrl+Alt)는 곧 다른 키가 이어질 수 있으므로 잠깐
* 보류한다. 그 사이 다른 바인딩의 눌림이 오면 취소한다 — 오른쪽 Alt 는 Windows 가
* Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표를 누르면 Alt 순간에 Ctrl+AltRight(음성 명령)
* 가 먼저 발동하던 문제를 이 지점에서 막는다.
*/
private _emitPressed(
entry: RegisteredBinding,
trigger: ActiveTrigger,
now: number,
bindingKeyValue: string
): void {
const payload: KeyBindingTriggerPayload = {
actionId: trigger.actionId,
type: 'pressed',
isDoublePress: trigger.isDoublePress,
holdMode: trigger.holdMode,
timestamp: now,
durationMs: 0,
binding: {
device: entry.binding.device,
code: entry.binding.code,
ctrl: entry.binding.ctrl,
alt: entry.binding.alt,
shift: entry.binding.shift,
meta: entry.binding.meta
}
}
if (isAltGrShaped(entry.binding)) {
if (pendingAltGrEmit) clearTimeout(pendingAltGrEmit)
const actionId = trigger.actionId
// 취소 표시는 _fireRelease 가 쓰는 것과 같은 키여야 한다 (device:code 조합이 아니다).
cancelledAltGrBindings.add(actionId + ':' + bindingKeyValue)
pendingAltGrEmit = setTimeout(() => {
pendingAltGrEmit = null
logger.debug(`AltGr 형태 트리거 유예 후 발동: "${actionId}"`)
this.emit('triggered', payload)
}, ALTGR_CHORD_GRACE_MS)
pendingAltGrEmit.unref?.()
return
}
// 다른 키가 이어졌다 = 조합을 만들려던 것 → 보류 중인 AltGr 트리거는 취소.
if (pendingAltGrEmit) {
clearTimeout(pendingAltGrEmit)
pendingAltGrEmit = null
logger.debug('AltGr 형태 보류 트리거 취소 (다른 키가 이어짐)')
}
this.emit('triggered', payload)
}
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)`
)
if (cancelledAltGrBindings.delete(trigger.actionId + ':' + key)) {
logger.debug(`AltGr 보류가 취소된 바인딩의 released 는 내보내지 않는다: "${trigger.actionId}"`)
continue
}
this.emit('triggered', {
actionId: trigger.actionId,
type: 'released',
isDoublePress: trigger.isDoublePress,
holdMode: trigger.holdMode,
timestamp: now,
durationMs,
binding: {
device: 'keyboard',
code: 0,
ctrl: false,
alt: false,
shift: false,
meta: false
}
})
}
if (key === null) return
this._machine.keyUp(key, null)
}
// ── EventEmitter 타입 오버라이드 ───────────────────────