From 2409cf9bd8f582f1408af0cc949f7080f5e81120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sat, 11 Apr 2026 09:17:01 +0900 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20hold=20=ED=95=AB=ED=82=A4=20mac?= =?UTF-8?q?OS=20beep=20=EC=B0=A8=EB=8B=A8=20+=20bootstrap=20import=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=ED=94=BD=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제 1: hideRecordingTip is not defined (Action processing error) - VoiceModeService는 import OK - 그러나 bootstrap.ts에서 hideRecordingTip()를 호출하지만 import 목록에 없음 - voiceMode.on('session-cancelled') / voiceMode.on('error')에서 ReferenceError - session-cancelled 후 RecordingTip 팝업이 안 닫혀서 누적 문제 발생 가능 수정: - bootstrap.ts WindowManager import에 hideRecordingTip + updateRecordingTipState 추가 문제 2: 핫키 누르면 macOS 시스템 beep - uiohook-napi는 키 이벤트를 모니터링만 하고 swallow 안 함 - macOS에서 ⌘+⇧+1 같은 hold 핫키가 OS로 그대로 전달되어 받는 곳이 없으면 beep - 사용자가 hold-to-talk 동안 계속 beep 발생 → UX 파괴 수정: - HotkeyService에 Electron globalShortcut 통합 - bindingToAccelerator: Windows VK + modifier 플래그 → Electron Accelerator string - macOS: meta=true → 'Cmd', Windows: meta=true → 'Super' - vkToAcceleratorKey: 0~9, A~Z, F1~F24, Esc, Space, Enter, Tab, Insert, Delete, Home, End, PageUp/Down, Arrow, ;,=,/,. 등 - HotkeyConfig에 acceleratorString?: string 필드 추가 - registerHotkey: bindingToConfig 결과의 accelerator를 globalShortcut.register(accel, noop)으로 등록 → OS swallow 실제 hold/release 처리는 그대로 uiohook이 담당 - unregisterHotkey: globalShortcut.unregister(기존 accel) - stop(): globalShortcut.unregisterAll() - 단일 modifier 핫키 (예: Right Alt만 누름)는 accelerator 변환 불가 — null 반환 → globalShortcut 등록 건너뛰고 uiohook만 사용 (단일키는 OS가 swallow 안 해도 beep 없음) 검증: - voice-dictation: accelerator=Shift+Cmd+1로 등록 (로그 확인) - globalShortcut.register false 반환 없음 --- apps/desktop/src/main/bootstrap.ts | 2 + .../src/main/services/HotkeyService.ts | 146 +++++++++++++++++- 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index 9b491af..aabce21 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -28,6 +28,8 @@ import { hideCommandPopup, sendKeyToCommandPopup, isCommandPopupVisible, + hideRecordingTip, + updateRecordingTipState, } from './windows/WindowManager' import { createTray } from './windows/TrayManager' import { registerAllIpcHandlers } from './ipc' diff --git a/apps/desktop/src/main/services/HotkeyService.ts b/apps/desktop/src/main/services/HotkeyService.ts index 26d1fde..6d9fe49 100644 --- a/apps/desktop/src/main/services/HotkeyService.ts +++ b/apps/desktop/src/main/services/HotkeyService.ts @@ -3,6 +3,7 @@ // 설계서 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' @@ -30,6 +31,13 @@ export interface HotkeyConfig { 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' @@ -141,6 +149,78 @@ const VK_TO_UIOHOOK: ReadonlyMap = new Map([ [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 키코드)로 변환한다. */ @@ -171,7 +251,8 @@ function bindingToConfig( modifiers, holdMode, doublePressEnabled, - enabled: true + enabled: true, + acceleratorString: bindingToAccelerator(binding) } } @@ -257,6 +338,16 @@ class HotkeyService extends EventEmitter { 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( @@ -267,6 +358,11 @@ class HotkeyService extends EventEmitter { /** * 핫키를 등록한다. 동일 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) { @@ -274,11 +370,45 @@ class HotkeyService extends EventEmitter { 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})` + `holdMode=${config.holdMode}, doublePress=${config.doublePressEnabled}, ` + + `accelerator=${config.acceleratorString ?? 'N/A'})` ) } @@ -286,6 +416,18 @@ class HotkeyService extends EventEmitter { * 핫키 등록을 해제한다. */ 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)