// src/main/services/HotkeyService.ts // uiohook-napi 기반 글로벌 키보드 후킹 서비스. // 설계서 01의 IHotkeyService 구현. Speakly HotkeyService + HotkeyConfig 패턴 참조. import { EventEmitter } from 'events' import { globalShortcut } from 'electron' import { uIOhook, UiohookKey } from 'uiohook-napi' import type { UiohookKeyboardEvent } from 'uiohook-napi' import { getLogger } from './LoggerService' import { configGet } from './ConfigService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { TIMING } from '@d3ro/core/constants' import type { HotkeyBinding } from '@d3ro/core/types' const logger = getLogger('HotkeyService') // ============================================================ // 내부 타입 // ============================================================ export interface HotkeyConfig { /** 핫키 식별자 (예: 'voice-dictation', 'voice-handsfree') */ id: string /** uiohook 키코드 */ keyCode: number /** 수정자 키 목록 */ modifiers: HotkeyModifier[] /** true=hold-to-talk (누르고 있는 동안 활성), false=toggle */ holdMode: boolean /** 더블프레스 감지 활성화 여부 */ doublePressEnabled: boolean /** 활성화 여부 */ enabled: boolean /** * Electron globalShortcut accelerator (예: 'Shift+Cmd+1'). * macOS에서 OS 시스템 beep을 막기 위해 함께 register하고 * callback은 noop으로 둔다 (실제 hold/release는 uiohook이 처리). * 변환 실패 시 null. */ acceleratorString: string | null } export type HotkeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta' interface HotkeyServiceEvents { 'hotkey-pressed': (payload: { config: HotkeyConfig; timestamp: number }) => void 'hotkey-released': (payload: { config: HotkeyConfig durationMs: number timestamp: number }) => void 'double-press': (payload: { config: HotkeyConfig intervalMs: number timestamp: number }) => void error: (payload: { error: D3ROError }) => void } // ============================================================ // Windows VK 코드 → uiohook 키코드 매핑 // ============================================================ /** * Windows Virtual-Key 코드를 uiohook-napi 키코드로 변환한다. * ConfigService에 저장된 HotkeyBinding.keyCode는 Windows VK 코드이므로 * uiohook 이벤트와 비교하려면 변환이 필요하다. */ const VK_TO_UIOHOOK: ReadonlyMap = new Map([ // 수정자 키 [0xa0, UiohookKey.Shift], // VK_LSHIFT [0xa1, UiohookKey.ShiftRight], // VK_RSHIFT [0xa2, UiohookKey.Ctrl], // VK_LCONTROL [0xa3, UiohookKey.CtrlRight], // VK_RCONTROL [0xa4, UiohookKey.Alt], // VK_LMENU [0xa5, UiohookKey.AltRight], // VK_RMENU (Right Alt) [0x5b, UiohookKey.Meta], // VK_LWIN [0x5c, UiohookKey.MetaRight], // VK_RWIN // 기능 키 [0x70, UiohookKey.F1], [0x71, UiohookKey.F2], [0x72, UiohookKey.F3], [0x73, UiohookKey.F4], [0x74, UiohookKey.F5], [0x75, UiohookKey.F6], [0x76, UiohookKey.F7], [0x77, UiohookKey.F8], [0x78, UiohookKey.F9], [0x79, UiohookKey.F10], [0x7a, UiohookKey.F11], [0x7b, UiohookKey.F12], // 일반 키 [0x20, UiohookKey.Space], [0x0d, UiohookKey.Enter], [0x1b, UiohookKey.Escape], [0x08, UiohookKey.Backspace], [0x09, UiohookKey.Tab], [0x2d, UiohookKey.Insert], [0x2e, UiohookKey.Delete], [0x24, UiohookKey.Home], [0x23, UiohookKey.End], [0x21, UiohookKey.PageUp], [0x22, UiohookKey.PageDown], [0x25, UiohookKey.ArrowLeft], [0x26, UiohookKey.ArrowUp], [0x27, UiohookKey.ArrowRight], [0x28, UiohookKey.ArrowDown], // 알파벳 (VK_A=0x41 ~ VK_Z=0x5A) [0x41, UiohookKey.A], [0x42, UiohookKey.B], [0x43, UiohookKey.C], [0x44, UiohookKey.D], [0x45, UiohookKey.E], [0x46, UiohookKey.F], [0x47, UiohookKey.G], [0x48, UiohookKey.H], [0x49, UiohookKey.I], [0x4a, UiohookKey.J], [0x4b, UiohookKey.K], [0x4c, UiohookKey.L], [0x4d, UiohookKey.M], [0x4e, UiohookKey.N], [0x4f, UiohookKey.O], [0x50, UiohookKey.P], [0x51, UiohookKey.Q], [0x52, UiohookKey.R], [0x53, UiohookKey.S], [0x54, UiohookKey.T], [0x55, UiohookKey.U], [0x56, UiohookKey.V], [0x57, UiohookKey.W], [0x58, UiohookKey.X], [0x59, UiohookKey.Y], [0x5a, UiohookKey.Z], // 숫자 (VK_0=0x30 ~ VK_9=0x39) [0x30, UiohookKey['0']], [0x31, UiohookKey['1']], [0x32, UiohookKey['2']], [0x33, UiohookKey['3']], [0x34, UiohookKey['4']], [0x35, UiohookKey['5']], [0x36, UiohookKey['6']], [0x37, UiohookKey['7']], [0x38, UiohookKey['8']], [0x39, UiohookKey['9']] ]) // Windows VK → Electron Accelerator key string function vkToAcceleratorKey(vk: number): string | null { // 0~9 if (vk >= 0x30 && vk <= 0x39) return String.fromCharCode(vk) // A~Z if (vk >= 0x41 && vk <= 0x5a) return String.fromCharCode(vk) // F1~F24 if (vk >= 0x70 && vk <= 0x87) return `F${vk - 0x6f}` switch (vk) { case 0x20: return 'Space' case 0x0d: return 'Return' case 0x1b: return 'Esc' case 0x08: return 'Backspace' case 0x09: return 'Tab' case 0x2d: return 'Insert' case 0x2e: return 'Delete' case 0x24: return 'Home' case 0x23: return 'End' case 0x21: return 'PageUp' case 0x22: return 'PageDown' case 0x25: return 'Left' case 0x26: return 'Up' case 0x27: return 'Right' case 0x28: return 'Down' case 0xba: return ';' case 0xbb: return '=' case 0xbc: return ',' case 0xbd: return '-' case 0xbe: return '.' case 0xbf: return '/' case 0xc0: return '`' case 0xdb: return '[' case 0xdc: return '\\' case 0xdd: return ']' case 0xde: return "'" default: return null } } /** * HotkeyBinding을 Electron globalShortcut accelerator로 변환. * macOS Cmd → 'Cmd', Windows Win key → 'Super'. */ function bindingToAccelerator(binding: HotkeyBinding): string | null { const key = vkToAcceleratorKey(binding.keyCode) if (!key) return null // 단일 modifier 키 자체를 단축키로 쓰는 경우 (예: Right Alt 단독) — accelerator 등록 불가. // 일반 키와 modifier가 함께일 때만 등록. const hasModifier = binding.ctrl || binding.alt || binding.shift || binding.meta if (!hasModifier) { // F1~F24, Esc 등 단일키는 등록 가능. 단 modifier 키 자체는 제외. const isModifierKey = binding.keyCode === 0x10 || binding.keyCode === 0x11 || binding.keyCode === 0x12 || binding.keyCode === 0x5b || binding.keyCode === 0x5c || (binding.keyCode >= 0xa0 && binding.keyCode <= 0xa5) if (isModifierKey) return null } const parts: string[] = [] if (binding.ctrl) parts.push('Control') if (binding.alt) parts.push('Alt') if (binding.shift) parts.push('Shift') if (binding.meta) parts.push(process.platform === 'darwin' ? 'Cmd' : 'Super') parts.push(key) return parts.join('+') } /** * HotkeyBinding(Windows VK 코드)을 HotkeyConfig(uiohook 키코드)로 변환한다. */ function bindingToConfig( id: string, binding: HotkeyBinding, holdMode: boolean, doublePressEnabled: boolean ): HotkeyConfig { const uiohookKeyCode = VK_TO_UIOHOOK.get(binding.keyCode) if (uiohookKeyCode === undefined) { logger.warn( `Unknown VK code 0x${binding.keyCode.toString(16)} for hotkey "${id}", ` + `using raw value ${binding.keyCode}` ) } const modifiers: HotkeyModifier[] = [] if (binding.ctrl) modifiers.push('ctrl') if (binding.alt) modifiers.push('alt') if (binding.shift) modifiers.push('shift') if (binding.meta) modifiers.push('meta') return { id, keyCode: uiohookKeyCode ?? binding.keyCode, modifiers, holdMode, doublePressEnabled, enabled: true, acceleratorString: bindingToAccelerator(binding) } } // ============================================================ // HotkeyService 클래스 // ============================================================ class HotkeyService extends EventEmitter { private _isRunning = false private _registeredHotkeys: Map = new Map() /** 더블프레스 감지용: 마지막 press 시각 */ private _lastPressTime: Map = new Map() /** hold duration 계산용: press 시작 시각 */ private _pressStartTime: Map = new Map() /** 키 반복(auto-repeat) 방지: 현재 눌려있는 키 */ private _isKeyDown: Map = new Map() /** uiohook 이벤트 핸들러 (바인딩 해제용) */ private _onKeyDown: ((e: UiohookKeyboardEvent) => void) | null = null private _onKeyUp: ((e: UiohookKeyboardEvent) => void) | null = null get isRunning(): boolean { return this._isRunning } get registeredHotkeys(): ReadonlyMap { return this._registeredHotkeys } /** * uiohook 글로벌 키보드 후킹을 시작한다. * 이미 실행 중이면 무시. */ start(): void { if (this._isRunning) { logger.warn('HotkeyService already running') return } try { this._onKeyDown = (e: UiohookKeyboardEvent) => this._handleKeyDown(e) this._onKeyUp = (e: UiohookKeyboardEvent) => this._handleKeyUp(e) uIOhook.on('keydown', this._onKeyDown) uIOhook.on('keyup', this._onKeyUp) uIOhook.start() this._isRunning = true logger.info('uiohook started, global keyboard hook active') } catch (error) { const d3roError = new D3ROError( ErrorCode.HotkeyHookInitFailed, `Failed to start uiohook: ${error instanceof Error ? error.message : String(error)}` ) this.emit('error', { error: d3roError }) logger.error(d3roError.message) } } /** * uiohook 글로벌 키보드 후킹을 중지한다. */ stop(): void { if (!this._isRunning) { return } try { uIOhook.stop() if (this._onKeyDown) { uIOhook.removeListener('keydown', this._onKeyDown) this._onKeyDown = null } if (this._onKeyUp) { uIOhook.removeListener('keyup', this._onKeyUp) this._onKeyUp = null } this._isRunning = false this._isKeyDown.clear() this._pressStartTime.clear() // 등록된 모든 globalShortcut 해제 try { globalShortcut.unregisterAll() } catch (err) { logger.warn( `globalShortcut.unregisterAll failed: ${err instanceof Error ? err.message : String(err)}` ) } logger.info('uiohook stopped') } catch (error) { logger.error( `Failed to stop uiohook: ${error instanceof Error ? error.message : String(error)}` ) } } /** * 핫키를 등록한다. 동일 ID가 이미 등록되어 있으면 갱신한다. * * macOS에서 hold 모드 핫키는 uiohook이 처리하지만, OS가 키를 swallow하지 않아 * focused app/desktop으로 전달되어 시스템 beep이 나는 경우가 있다. * Electron globalShortcut을 noop callback과 함께 등록하면 OS가 키를 swallow하므로 * uiohook은 그대로 keydown/keyup을 받으면서 beep만 차단된다. */ registerHotkey(config: HotkeyConfig): void { if (!config.enabled) { logger.debug(`Hotkey "${config.id}" is disabled, skipping registration`) return } // 기존 등록이 있으면 globalShortcut도 한 번 해제 (accelerator 변경 가능성) const existing = this._registeredHotkeys.get(config.id) if (existing?.acceleratorString) { try { if (globalShortcut.isRegistered(existing.acceleratorString)) { globalShortcut.unregister(existing.acceleratorString) } } catch (err) { logger.warn( `globalShortcut unregister (existing) failed for "${config.id}": ${err instanceof Error ? err.message : String(err)}` ) } } this._registeredHotkeys.set(config.id, config) // macOS beep 차단용 globalShortcut 등록 (callback은 noop, 실제 처리는 uiohook) if (config.acceleratorString) { try { const ok = globalShortcut.register(config.acceleratorString, () => { // noop — uiohook이 hold/release를 책임진다. }) if (!ok) { logger.warn( `globalShortcut.register returned false for "${config.id}" (${config.acceleratorString}) — beep 차단 실패` ) } } catch (err) { logger.warn( `globalShortcut.register failed for "${config.id}": ${err instanceof Error ? err.message : String(err)}` ) } } logger.info( `Hotkey registered: "${config.id}" ` + `(keyCode=${config.keyCode}, modifiers=[${config.modifiers.join(',')}], ` + `holdMode=${config.holdMode}, doublePress=${config.doublePressEnabled}, ` + `accelerator=${config.acceleratorString ?? 'N/A'})` ) } /** * 핫키 등록을 해제한다. */ unregisterHotkey(id: string): void { const existing = this._registeredHotkeys.get(id) if (existing?.acceleratorString) { try { if (globalShortcut.isRegistered(existing.acceleratorString)) { globalShortcut.unregister(existing.acceleratorString) } } catch (err) { logger.warn( `globalShortcut unregister failed for "${id}": ${err instanceof Error ? err.message : String(err)}` ) } } if (this._registeredHotkeys.delete(id)) { this._lastPressTime.delete(id) this._pressStartTime.delete(id) this._isKeyDown.delete(id) logger.info(`Hotkey unregistered: "${id}"`) } } /** * ConfigService에서 핫키 설정을 로드하여 등록한다. */ loadFromConfig(): void { const hotkeyEnabled = configGet('hotkeyEnabled') if (!hotkeyEnabled) { logger.info('Hotkeys disabled in config') return } const dictationBinding = configGet('dictationShortcut') const handsFreeBinding = configGet('handsFreeShortcut') const commandBinding = configGet('commandShortcut') const captionBinding = configGet('captionShortcut') // 기존 핫키 초기화 this._registeredHotkeys.clear() // Dictation: hold-to-talk, 더블프레스 비활성 this.registerHotkey( bindingToConfig('voice-dictation', dictationBinding, true, false) ) // Hands-free: toggle, 더블프레스 활성 this.registerHotkey( bindingToConfig('voice-handsfree', handsFreeBinding, false, true) ) // Command: toggle, 더블프레스 비활성 this.registerHotkey( bindingToConfig('voice-command', commandBinding, false, false) ) // Caption: toggle (자막 모드 시작/정지), 더블프레스 비활성 this.registerHotkey( bindingToConfig('voice-caption', captionBinding, false, false) ) logger.info( `Loaded ${this._registeredHotkeys.size} hotkeys from config` ) } /** * 서비스 리소스를 정리한다. */ dispose(): void { this.stop() this._registeredHotkeys.clear() this._lastPressTime.clear() this._pressStartTime.clear() this._isKeyDown.clear() this.removeAllListeners() logger.info('HotkeyService disposed') } // ============================================================ // 내부: 키 이벤트 처리 // ============================================================ /** * 키 다운 이벤트를 처리한다. * 등록된 핫키 중 매칭되는 것을 찾아 적절한 이벤트를 발행한다. */ private _handleKeyDown(e: UiohookKeyboardEvent): void { const matched = this._findMatchingHotkey(e) if (!matched) return const { id } = matched // 키 반복(auto-repeat) 무시: 이미 눌려있으면 건너뜀 if (this._isKeyDown.get(id)) { return } this._isKeyDown.set(id, true) const now = Date.now() // press 시작 시각 기록 (hold duration 계산용) this._pressStartTime.set(id, now) // 더블프레스 감지 if (matched.doublePressEnabled) { const lastPress = this._lastPressTime.get(id) if (lastPress !== undefined && now - lastPress < TIMING.DOUBLE_PRESS_DURATION) { // 300ms 이내 연속 두 번 press = 더블프레스 this._lastPressTime.delete(id) logger.debug(`Double-press detected: "${id}" (interval=${now - lastPress}ms)`) this.emit('double-press', { config: matched, intervalMs: now - lastPress, timestamp: now }) return } this._lastPressTime.set(id, now) } logger.debug(`Hotkey pressed: "${id}"`) this.emit('hotkey-pressed', { config: matched, timestamp: now }) } /** * 키 업 이벤트를 처리한다. * Alt+1 같은 조합에서 Alt를 먼저 놓아도 release 감지해야 한다. * 전략: pressed 상태인 핫키 중 구성 키(main key 또는 modifier)가 놓아지면 release. */ private _handleKeyUp(e: UiohookKeyboardEvent): void { // 방법 1: 정확한 매칭 시도 const matched = this._findMatchingHotkey(e) if (matched && this._isKeyDown.get(matched.id)) { this._fireRelease(matched) return } // 방법 2: 현재 pressed인 핫키 중 구성 키가 놓아진 경우 (modifier 먼저 놓기 대응) for (const config of this._registeredHotkeys.values()) { if (!this._isKeyDown.get(config.id)) continue // 놓아진 키가 이 핫키의 main key인가? const isMainKey = e.keycode === config.keyCode // 놓아진 키가 이 핫키의 modifier 중 하나인가? const isModifier = this._isEventModifierOf(e, config) if (isMainKey || isModifier) { this._fireRelease(config) return } } } /** pressed 핫키의 release 이벤트 발행 */ private _fireRelease(config: HotkeyConfig): void { const { id } = config this._isKeyDown.set(id, false) const now = Date.now() const pressStart = this._pressStartTime.get(id) const durationMs = pressStart !== undefined ? now - pressStart : 0 this._pressStartTime.delete(id) logger.debug(`Hotkey released: "${id}" (duration=${durationMs}ms)`) this.emit('hotkey-released', { config, durationMs, timestamp: now }) } /** 키 이벤트가 핫키의 modifier 구성요소인지 확인 */ private _isEventModifierOf(e: UiohookKeyboardEvent, config: HotkeyConfig): boolean { const modifiers = config.modifiers ?? [] for (const mod of modifiers) { if (mod === 'ctrl' && (this._isCtrlKeyCode(e.keycode))) return true if (mod === 'alt' && (this._isAltKeyCode(e.keycode))) return true if (mod === 'shift' && (this._isShiftKeyCode(e.keycode))) return true if (mod === 'meta' && (this._isMetaKeyCode(e.keycode))) return true } return false } /** * uiohook 키 이벤트와 등록된 핫키를 매칭한다. * 키코드와 수정자 키가 모두 일치해야 매칭 성공. */ private _findMatchingHotkey(e: UiohookKeyboardEvent): HotkeyConfig | null { for (const config of this._registeredHotkeys.values()) { if (!config.enabled) continue // 키코드 일치 확인 if (e.keycode !== config.keyCode) continue // 수정자 키 일치 확인 // 핫키에 지정된 수정자가 모두 눌려 있어야 하고, // 지정되지 않은 수정자는 눌려 있으면 안 된다. // // 단, 핫키의 주 키 자체가 수정자 키인 경우(예: Right Alt 단독): // - 해당 수정자의 altKey 등이 true로 올 수 있으므로, // modifiers에 해당 modifier가 **없을 때만** 이벤트의 해당 플래그를 무시한다. // - modifiers에 해당 modifier가 **있으면** (예: Alt+Right Alt은 불가하므로) // 해당 플래그가 true여야 매칭 성공. // // 예: Right Alt 단독 (keyCode=AltRight, modifiers=[]) // → e.altKey가 true여도 wantsAlt=false이므로 bypass → match // 예: Ctrl+Right Alt (keyCode=AltRight, modifiers=['ctrl']) // → e.altKey bypass, e.ctrlKey===true 체크 → match only with Ctrl held const wantsCtrl = config.modifiers.includes('ctrl') const wantsAlt = config.modifiers.includes('alt') const wantsShift = config.modifiers.includes('shift') const wantsMeta = config.modifiers.includes('meta') // 주 키 자체가 해당 modifier인 경우, 그 modifier의 이벤트 플래그는 무시 const skipCtrl = this._isCtrlKeyCode(config.keyCode) && !wantsCtrl const skipAlt = this._isAltKeyCode(config.keyCode) && !wantsAlt const skipShift = this._isShiftKeyCode(config.keyCode) && !wantsShift const skipMeta = this._isMetaKeyCode(config.keyCode) && !wantsMeta const ctrlMatch = skipCtrl || (e.ctrlKey === wantsCtrl) const altMatch = skipAlt || (e.altKey === wantsAlt) const shiftMatch = skipShift || (e.shiftKey === wantsShift) const metaMatch = skipMeta || (e.metaKey === wantsMeta) if (ctrlMatch && altMatch && shiftMatch && metaMatch) { return config } } return null } /** 주어진 uiohook 키코드가 수정자 키(Ctrl/Alt/Shift/Meta)인지 확인 */ private _isModifierKeyCode(keyCode: number): boolean { return ( this._isCtrlKeyCode(keyCode) || this._isAltKeyCode(keyCode) || this._isShiftKeyCode(keyCode) || this._isMetaKeyCode(keyCode) ) } private _isCtrlKeyCode(keyCode: number): boolean { return keyCode === UiohookKey.Ctrl || keyCode === UiohookKey.CtrlRight } private _isAltKeyCode(keyCode: number): boolean { return keyCode === UiohookKey.Alt || keyCode === UiohookKey.AltRight } private _isShiftKeyCode(keyCode: number): boolean { return keyCode === UiohookKey.Shift || keyCode === UiohookKey.ShiftRight } private _isMetaKeyCode(keyCode: number): boolean { return keyCode === UiohookKey.Meta || keyCode === UiohookKey.MetaRight } // ============================================================ // EventEmitter 타입 오버라이드 // ============================================================ override on( event: K, listener: HotkeyServiceEvents[K] ): this { return super.on(event, listener) } override off( event: K, listener: HotkeyServiceEvents[K] ): this { return super.off(event, listener) } override emit( event: K, ...args: Parameters ): boolean { return super.emit(event, ...args) } } // ============================================================ // 싱글톤 // ============================================================ let instance: HotkeyService | null = null export function getHotkeyService(): HotkeyService { if (!instance) { instance = new HotkeyService() } return instance }