fix(desktop): hold 핫키 macOS beep 차단 + bootstrap import 누락 픽스
문제 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 반환 없음
This commit is contained in:
parent
29c24a1520
commit
2409cf9bd8
2 changed files with 146 additions and 2 deletions
|
|
@ -28,6 +28,8 @@ import {
|
||||||
hideCommandPopup,
|
hideCommandPopup,
|
||||||
sendKeyToCommandPopup,
|
sendKeyToCommandPopup,
|
||||||
isCommandPopupVisible,
|
isCommandPopupVisible,
|
||||||
|
hideRecordingTip,
|
||||||
|
updateRecordingTipState,
|
||||||
} from './windows/WindowManager'
|
} from './windows/WindowManager'
|
||||||
import { createTray } from './windows/TrayManager'
|
import { createTray } from './windows/TrayManager'
|
||||||
import { registerAllIpcHandlers } from './ipc'
|
import { registerAllIpcHandlers } from './ipc'
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// 설계서 01의 IHotkeyService 구현. Speakly HotkeyService + HotkeyConfig 패턴 참조.
|
// 설계서 01의 IHotkeyService 구현. Speakly HotkeyService + HotkeyConfig 패턴 참조.
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
|
import { globalShortcut } from 'electron'
|
||||||
import { uIOhook, UiohookKey } from 'uiohook-napi'
|
import { uIOhook, UiohookKey } from 'uiohook-napi'
|
||||||
import type { UiohookKeyboardEvent } from 'uiohook-napi'
|
import type { UiohookKeyboardEvent } from 'uiohook-napi'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
|
|
@ -30,6 +31,13 @@ export interface HotkeyConfig {
|
||||||
doublePressEnabled: boolean
|
doublePressEnabled: boolean
|
||||||
/** 활성화 여부 */
|
/** 활성화 여부 */
|
||||||
enabled: 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'
|
export type HotkeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta'
|
||||||
|
|
@ -141,6 +149,78 @@ const VK_TO_UIOHOOK: ReadonlyMap<number, number> = new Map([
|
||||||
[0x39, UiohookKey['9']]
|
[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 키코드)로 변환한다.
|
* HotkeyBinding(Windows VK 코드)을 HotkeyConfig(uiohook 키코드)로 변환한다.
|
||||||
*/
|
*/
|
||||||
|
|
@ -171,7 +251,8 @@ function bindingToConfig(
|
||||||
modifiers,
|
modifiers,
|
||||||
holdMode,
|
holdMode,
|
||||||
doublePressEnabled,
|
doublePressEnabled,
|
||||||
enabled: true
|
enabled: true,
|
||||||
|
acceleratorString: bindingToAccelerator(binding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,6 +338,16 @@ class HotkeyService extends EventEmitter {
|
||||||
this._isRunning = false
|
this._isRunning = false
|
||||||
this._isKeyDown.clear()
|
this._isKeyDown.clear()
|
||||||
this._pressStartTime.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')
|
logger.info('uiohook stopped')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
@ -267,6 +358,11 @@ class HotkeyService extends EventEmitter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 핫키를 등록한다. 동일 ID가 이미 등록되어 있으면 갱신한다.
|
* 핫키를 등록한다. 동일 ID가 이미 등록되어 있으면 갱신한다.
|
||||||
|
*
|
||||||
|
* macOS에서 hold 모드 핫키는 uiohook이 처리하지만, OS가 키를 swallow하지 않아
|
||||||
|
* focused app/desktop으로 전달되어 시스템 beep이 나는 경우가 있다.
|
||||||
|
* Electron globalShortcut을 noop callback과 함께 등록하면 OS가 키를 swallow하므로
|
||||||
|
* uiohook은 그대로 keydown/keyup을 받으면서 beep만 차단된다.
|
||||||
*/
|
*/
|
||||||
registerHotkey(config: HotkeyConfig): void {
|
registerHotkey(config: HotkeyConfig): void {
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
|
|
@ -274,11 +370,45 @@ class HotkeyService extends EventEmitter {
|
||||||
return
|
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)
|
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(
|
logger.info(
|
||||||
`Hotkey registered: "${config.id}" ` +
|
`Hotkey registered: "${config.id}" ` +
|
||||||
`(keyCode=${config.keyCode}, modifiers=[${config.modifiers.join(',')}], ` +
|
`(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 {
|
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)) {
|
if (this._registeredHotkeys.delete(id)) {
|
||||||
this._lastPressTime.delete(id)
|
this._lastPressTime.delete(id)
|
||||||
this._pressStartTime.delete(id)
|
this._pressStartTime.delete(id)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue