feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동

- npm workspaces 루트 (apps/*, packages/*) 세팅
- V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar,
  scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts,
  tsconfig.node.json, tsconfig.web.json)
- apps/desktop/package.json 신규 (name=@d3ro/desktop)
- productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로
  %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장)
- 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지
  (typescript, eslint, prettier)
- turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase)
- memory/project_status.md 생성 (규칙 13)

검증:
- npm run typecheck 통과
- npm run build 통과 (electron-vite main+preload+renderer)
- npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
yunchan8804 2026-04-08 14:04:41 +09:00
parent 3a160b9032
commit 45a580878a
178 changed files with 214 additions and 0 deletions

View file

@ -0,0 +1,577 @@
// src/main/services/HotkeyService.ts
// uiohook-napi 기반 글로벌 키보드 후킹 서비스.
// 설계서 01의 IHotkeyService 구현. Speakly HotkeyService + HotkeyConfig 패턴 참조.
import { EventEmitter } from 'events'
import { uIOhook, UiohookKey } from 'uiohook-napi'
import type { UiohookKeyboardEvent } from 'uiohook-napi'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { D3ROError, ErrorCode } from '@shared/errors'
import { TIMING } from '@shared/constants'
import type { HotkeyBinding } from '@shared/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
}
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<number, number> = 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']]
])
/**
* 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
}
}
// ============================================================
// HotkeyService 클래스
// ============================================================
class HotkeyService extends EventEmitter {
private _isRunning = false
private _registeredHotkeys: Map<string, HotkeyConfig> = new Map()
/** 더블프레스 감지용: 마지막 press 시각 */
private _lastPressTime: Map<string, number> = new Map()
/** hold duration 계산용: press 시작 시각 */
private _pressStartTime: Map<string, number> = new Map()
/** 키 반복(auto-repeat) 방지: 현재 눌려있는 키 */
private _isKeyDown: Map<string, boolean> = 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<string, HotkeyConfig> {
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()
logger.info('uiohook stopped')
} catch (error) {
logger.error(
`Failed to stop uiohook: ${error instanceof Error ? error.message : String(error)}`
)
}
}
/**
* . ID가 .
*/
registerHotkey(config: HotkeyConfig): void {
if (!config.enabled) {
logger.debug(`Hotkey "${config.id}" is disabled, skipping registration`)
return
}
this._registeredHotkeys.set(config.id, config)
logger.info(
`Hotkey registered: "${config.id}" ` +
`(keyCode=${config.keyCode}, modifiers=[${config.modifiers.join(',')}], ` +
`holdMode=${config.holdMode}, doublePress=${config.doublePressEnabled})`
)
}
/**
* .
*/
unregisterHotkey(id: string): void {
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<K extends keyof HotkeyServiceEvents>(
event: K,
listener: HotkeyServiceEvents[K]
): this {
return super.on(event, listener)
}
override off<K extends keyof HotkeyServiceEvents>(
event: K,
listener: HotkeyServiceEvents[K]
): this {
return super.off(event, listener)
}
override emit<K extends keyof HotkeyServiceEvents>(
event: K,
...args: Parameters<HotkeyServiceEvents[K]>
): boolean {
return super.emit(event, ...args)
}
}
// ============================================================
// 싱글톤
// ============================================================
let instance: HotkeyService | null = null
export function getHotkeyService(): HotkeyService {
if (!instance) {
instance = new HotkeyService()
}
return instance
}