feat(keybinding): several shortcuts per action, mouse buttons, searchable picker

Shortcuts were defined in four places that drifted apart: per-action IPC channel
pairs, a hand-written VK table in the service, a second one in the renderer, and
three copies of the keycap styling. Adding an action meant editing all of them,
so two shortcuts stayed hardcoded in bootstrap and one had no settings entry at
all.

packages/core/src/keybinding.ts is now the single source for the binding type,
the selectable key catalog, the action catalog, normalization, validation,
conflict detection, display labels, search and deserialization. Main, preload
and renderer all read from it; nothing redefines keys or rules locally.

- Each action holds a list of bindings instead of one. AppConfig's four
  *Shortcut fields collapse into a single keyBindings map, migrated on launch.
- Mouse buttons can be bound. Left click is refused, right/middle need a
  modifier, side buttons are free. uiohook cannot swallow events, so the
  original click still fires and the UI says so.
- Keys can be picked from a grouped dropdown with a search box, not only by
  recording a keypress.
- HOTKEY's 14 channels become KEYBINDING's 9, taking the action as a parameter,
  so actions no longer multiply channels. The history and command popups moved
  out of bootstrap into ordinary actions.
- displayLabel is gone; labels derive from the binding and follow the app
  language and platform.

Fixes found on the way:
- Double-press hands-free was unreachable: lookup returned only the first
  matching action, and dictation shares its default binding.
- Reserved-combination checks compared joined key names, so a different modifier
  order let Ctrl+C through.
- Disabling shortcuts released every global registration in the process,
  including the popup ones, and never restored them.
- Enabling shortcuts after starting disabled left nothing registered.
- The dashboard stored the caption event payload instead of the state in it.
This commit is contained in:
Yun Chan 2026-09-21 13:41:47 +09:00
parent 0ca9e242fa
commit 4ad1ae6ed4
49 changed files with 5901 additions and 1792 deletions

View file

@ -3,7 +3,7 @@
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
import { initLoggerService, getLogger } from './services/LoggerService'
import { initConfigService, configGet, configSet } from './services/ConfigService'
import { getHotkeyService } from './services/HotkeyService'
import { getKeyBindingService } from './services/KeyBindingService'
import { getVoiceModeService } from './services/VoiceModeService'
import { startLocalLLMAvailability } from './services/LocalLLMService'
import { getHistoryService } from './services/HistoryService'
@ -61,7 +61,7 @@ export async function bootstrap(): Promise<void> {
{ name: 'sound-effects', critical: false, fn: initSoundEffects },
{ name: 'auto-launch', critical: false, fn: initAutoLaunch },
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
{ name: 'hotkey', critical: false, fn: initHotkey },
{ name: 'key-bindings', critical: false, fn: initKeyBindings },
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
{ name: 'stt-warmup', critical: false, fn: initSTTWarmup },
{ name: 'llm-polling', critical: false, fn: initLLMPolling },
@ -119,10 +119,10 @@ async function initIpcHandlers(): Promise<void> {
registerAllIpcHandlers()
}
async function initHotkey(): Promise<void> {
const hotkey = getHotkeyService()
hotkey.loadFromConfig()
hotkey.start()
async function initKeyBindings(): Promise<void> {
const keyBindings = getKeyBindingService()
keyBindings.loadFromConfig()
keyBindings.start()
}
async function initCustomInstructions(): Promise<void> {
@ -155,34 +155,42 @@ async function initPopupWindows(): Promise<void> {
// 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환)
getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ })
// Ctrl+Shift+V → 히스토리 팝업 토글
globalShortcut.register('Ctrl+Shift+V', () => {
if (isHistoryPopupVisible()) {
hideHistoryPopup()
unregisterPopupNavKeys()
} else {
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
registerPopupNavKeys()
}
})
// Ctrl+Shift+C → 커맨드 선택 팝업 토글
globalShortcut.register('Ctrl+Shift+C', () => {
if (isCommandPopupVisible()) {
hideCommandPopup()
unregisterPopupNavKeys()
} else {
const instructions = getCustomInstructionService().getAll()
const activeId = configGet('activeInstructionId') as string | null
showCommandPopup(instructions as unknown as Array<Record<string, unknown>>, activeId || null)
registerPopupNavKeys('command')
// 팝업 토글도 재바인딩 가능한 액션이다 — 하드코딩 accelerator 대신 KeyBindingService를 구독한다.
getKeyBindingService().on('triggered', (payload) => {
if (payload.type !== 'pressed') return
if (payload.actionId === 'history-popup') {
toggleHistoryPopup()
} else if (payload.actionId === 'command-popup') {
toggleCommandPopup()
}
})
setupCommandPopupIPC()
}
function toggleHistoryPopup(): void {
if (isHistoryPopupVisible()) {
hideHistoryPopup()
unregisterPopupNavKeys()
return
}
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
registerPopupNavKeys()
}
function toggleCommandPopup(): void {
if (isCommandPopupVisible()) {
hideCommandPopup()
unregisterPopupNavKeys()
return
}
const instructions = getCustomInstructionService().getAll()
const activeId = configGet('activeInstructionId') as string | null
showCommandPopup(instructions as unknown as Array<Record<string, unknown>>, activeId || null)
registerPopupNavKeys('command')
}
// 히스토리 팝업 키 네비게이션 등록/해제
const POPUP_NAV_KEYS: Array<{ accel: string; key: string }> = [
{ accel: 'Up', key: 'ArrowUp' },
@ -222,7 +230,7 @@ function unregisterPopupNavKeys(): void {
async function initVoiceMode(): Promise<void> {
const voiceMode = getVoiceModeService()
voiceMode.connectHotkey()
voiceMode.connectKeyBindings()
const soundEffect = getSoundEffectService()

View file

@ -1,85 +0,0 @@
// src/main/ipc/hotkey-handlers.ts
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
import { getHotkeyService } from '../services/HotkeyService'
import { configGet, configSet } from '../services/ConfigService'
import type { SetHotkeyParams, SetEnabledParams } from '@d3ro/core/types'
export function registerHotkeyHandlers(): void {
ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT, async () => {
return ipcSuccess(configGet('dictationShortcut'))
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, async (_event, params: SetHotkeyParams) => {
try {
configSet('dictationShortcut', params.binding)
getHotkeyService().loadFromConfig()
return ipcSuccess(undefined)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return ipcError(ErrorCode.HotkeyRegistrationFailed, `Failed to set dictation shortcut: ${message}`)
}
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT, async () => {
return ipcSuccess(configGet('handsFreeShortcut'))
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, async (_event, params: SetHotkeyParams) => {
try {
configSet('handsFreeShortcut', params.binding)
getHotkeyService().loadFromConfig()
return ipcSuccess(undefined)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return ipcError(ErrorCode.HotkeyRegistrationFailed, `Failed to set hands-free shortcut: ${message}`)
}
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT, async () => {
return ipcSuccess(configGet('commandShortcut'))
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, async (_event, params: SetHotkeyParams) => {
try {
configSet('commandShortcut', params.binding)
getHotkeyService().loadFromConfig()
return ipcSuccess(undefined)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return ipcError(ErrorCode.HotkeyRegistrationFailed, `Failed to set command shortcut: ${message}`)
}
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_CAPTION_SHORTCUT, async () => {
return ipcSuccess(configGet('captionShortcut'))
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_CAPTION_SHORTCUT, async (_event, params: SetHotkeyParams) => {
try {
configSet('captionShortcut', params.binding)
getHotkeyService().loadFromConfig()
return ipcSuccess(undefined)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return ipcError(ErrorCode.HotkeyRegistrationFailed, `Failed to set caption shortcut: ${message}`)
}
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.IS_ENABLED, async () => {
return ipcSuccess(configGet('hotkeyEnabled'))
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_ENABLED, async (_event, params: SetEnabledParams) => {
configSet('hotkeyEnabled', params.enabled)
const hotkey = getHotkeyService()
if (params.enabled) {
hotkey.start()
} else {
hotkey.stop()
}
return ipcSuccess(undefined)
})
}

View file

@ -6,7 +6,7 @@ import { registerWindowHandlers } from './window-handlers'
import { registerSystemHandlers } from './system-handlers'
import { registerVoiceHandlers } from './voice-handlers'
import { registerSTTHandlers } from './stt-handlers'
import { registerHotkeyHandlers } from './hotkey-handlers'
import { registerKeyBindingHandlers } from './keybinding-handlers'
import { registerLLMHandlers } from './llm-handlers'
import { registerHistoryHandlers } from './history-handlers'
import { registerDictionaryHandlers } from './dictionary-handlers'
@ -40,7 +40,7 @@ export function registerAllIpcHandlers(): void {
registerSystemHandlers()
registerVoiceHandlers()
registerSTTHandlers()
registerHotkeyHandlers()
registerKeyBindingHandlers()
registerLLMHandlers()
registerHistoryHandlers()
registerDictionaryHandlers()

View file

@ -0,0 +1,238 @@
// src/main/ipc/keybinding-handlers.ts
//
// 키바인딩 IPC. 액션을 파라미터로 받는 단일 채널 집합이며,
// 검증·충돌 판정은 전부 `packages/core/src/keybinding.ts` 의 함수를 쓴다.
import { ipcMain, BrowserWindow } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
import {
bindingKey,
createDefaultBindingMap,
detectBindingConflicts,
findActionSpec,
isKeyBinding,
isKeyBindingActionId,
normalizeBinding,
parseBindingMap,
validateBinding
} from '@d3ro/core/keybinding'
import { getKeyBindingService } from '../services/KeyBindingService'
import type { KeyBindingTriggerPayload } from '../services/KeyBindingService'
import { configGet, configSet } from '../services/ConfigService'
import type {
KeyBinding,
KeyBindingChangedEvent,
KeyBindingMap,
KeyBindingTriggeredEvent,
KeyBindingValidationResult,
ResetKeyBindingParams,
SetEnabledParams,
SetKeyBindingsParams,
ValidateKeyBindingParams
} from '@d3ro/core/types'
function broadcast(channel: string, payload: unknown): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(channel, payload)
}
}
/** 바인딩 맵 변경을 모든 창에 알린다 (창 간 동기화). */
function broadcastChanged(map: KeyBindingMap): void {
const event: KeyBindingChangedEvent = { map }
broadcast(IPC_CHANNELS.KEYBINDING.CHANGED, event)
}
/** 저장된 맵을 읽는다. 손상·누락 항목은 기본값으로 복원한다. */
function readMap(): KeyBindingMap {
return parseBindingMap(configGet('keyBindings'))
}
/** 맵을 저장하고 후킹 서비스에 다시 태운 뒤 창에 알린다. */
function applyMap(map: KeyBindingMap): void {
configSet('keyBindings', map)
getKeyBindingService().loadFromConfig()
broadcastChanged(map)
}
/** uiohook 트리거를 렌더러로 중계한다. 재등록 시 리스너가 중복되지 않게 이전 것을 떼어낸다. */
let triggerRelay: ((payload: KeyBindingTriggerPayload) => void) | null = null
function connectTriggerRelay(): void {
const service = getKeyBindingService()
if (triggerRelay !== null) {
service.off('triggered', triggerRelay)
}
triggerRelay = (payload: KeyBindingTriggerPayload) => {
const event: KeyBindingTriggeredEvent = {
actionId: payload.actionId,
type: payload.type,
isDoublePress: payload.isDoublePress
}
broadcast(IPC_CHANNELS.KEYBINDING.TRIGGERED, event)
}
service.on('triggered', triggerRelay)
}
export function registerKeyBindingHandlers(): void {
connectTriggerRelay()
ipcMain.handle(IPC_CHANNELS.KEYBINDING.GET_MAP, async () => {
return ipcSuccess(readMap())
})
ipcMain.handle(
IPC_CHANNELS.KEYBINDING.SET_BINDINGS,
async (_event, params: SetKeyBindingsParams) => {
if (!isKeyBindingActionId(params.actionId)) {
return ipcError(
ErrorCode.HotkeyRegistrationFailed,
`Unknown key binding action: ${String(params.actionId)}`
)
}
if (!Array.isArray(params.bindings)) {
return ipcError(
ErrorCode.HotkeyRegistrationFailed,
`Bindings for "${params.actionId}" must be an array`
)
}
const map = readMap()
const next: KeyBinding[] = []
const seen = new Set<string>()
for (const candidate of params.bindings) {
if (!isKeyBinding(candidate)) {
return ipcError(
ErrorCode.HotkeyRegistrationFailed,
`Malformed binding for "${params.actionId}"`
)
}
const binding = normalizeBinding(candidate)
const validation = validateBinding(binding)
if (!validation.valid) {
return ipcError(
ErrorCode.HotkeySystemReserved,
`Binding rejected for "${params.actionId}": ${validation.reason}`,
{ reason: validation.reason, reasonKey: validation.reasonKey }
)
}
const conflicts = detectBindingConflicts(params.actionId, binding, map)
if (conflicts.length > 0) {
return ipcError(
ErrorCode.HotkeyConflict,
`Binding for "${params.actionId}" conflicts with ` +
conflicts.map((c) => c.actionId).join(', '),
{ conflicts: conflicts.map((c) => c.actionId) }
)
}
const key = bindingKey(binding)
if (seen.has(key)) continue
seen.add(key)
next.push(binding)
}
map[params.actionId] = next
try {
applyMap(map)
return ipcSuccess(undefined)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return ipcError(
ErrorCode.HotkeyRegistrationFailed,
`Failed to set bindings for "${params.actionId}": ${message}`
)
}
}
)
ipcMain.handle(
IPC_CHANNELS.KEYBINDING.RESET_ACTION,
async (_event, params: ResetKeyBindingParams) => {
const spec = isKeyBindingActionId(params.actionId)
? findActionSpec(params.actionId)
: null
if (spec === null) {
return ipcError(
ErrorCode.HotkeyRegistrationFailed,
`Unknown key binding action: ${String(params.actionId)}`
)
}
try {
const map = readMap()
map[spec.id] = spec.defaultBindings.map((binding) => ({ ...binding }))
applyMap(map)
return ipcSuccess(undefined)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return ipcError(
ErrorCode.HotkeyRegistrationFailed,
`Failed to reset "${spec.id}": ${message}`
)
}
}
)
ipcMain.handle(IPC_CHANNELS.KEYBINDING.RESET_ALL, async () => {
try {
applyMap(createDefaultBindingMap())
return ipcSuccess(undefined)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return ipcError(
ErrorCode.HotkeyRegistrationFailed,
`Failed to reset key bindings: ${message}`
)
}
})
ipcMain.handle(
IPC_CHANNELS.KEYBINDING.VALIDATE,
async (_event, params: ValidateKeyBindingParams) => {
if (!isKeyBindingActionId(params.actionId)) {
return ipcError(
ErrorCode.HotkeyRegistrationFailed,
`Unknown key binding action: ${String(params.actionId)}`
)
}
if (!isKeyBinding(params.binding)) {
return ipcError(
ErrorCode.HotkeyRegistrationFailed,
`Malformed binding for "${params.actionId}"`
)
}
const binding = normalizeBinding(params.binding)
const result: KeyBindingValidationResult = {
validation: validateBinding(binding),
conflicts: detectBindingConflicts(params.actionId, binding, readMap())
}
return ipcSuccess(result)
}
)
ipcMain.handle(IPC_CHANNELS.KEYBINDING.IS_ENABLED, async () => {
return ipcSuccess(configGet('hotkeyEnabled'))
})
ipcMain.handle(
IPC_CHANNELS.KEYBINDING.SET_ENABLED,
async (_event, params: SetEnabledParams) => {
configSet('hotkeyEnabled', params.enabled)
const service = getKeyBindingService()
if (params.enabled) {
// 비활성 상태로 부팅한 뒤 켜는 경로 — 등록이 비어 있으므로 먼저 다시 읽는다.
service.loadFromConfig()
service.start()
} else {
service.stop()
}
return ipcSuccess(undefined)
}
)
}

View file

@ -2,7 +2,8 @@
// electron-store 기반 설정 관리. 설계서 02의 AppConfig 타입 사용.
import { EventEmitter } from 'events'
import type { AppConfig, ConfigChangedEvent } from '@d3ro/core/types'
import type { AppConfig, ConfigChangedEvent, KeyBindingActionId, KeyBindingMap } from '@d3ro/core/types'
import { createDefaultBindingMap, normalizeBinding, parseBindingMap } from '@d3ro/core/keybinding'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getLogger } from './LoggerService'
@ -12,6 +13,7 @@ const logger = getLogger('ConfigService')
interface ElectronStore<T> {
get<K extends keyof T>(key: K): T[K]
set<K extends keyof T>(key: K, value: T[K]): void
delete(key: string): void
store: T
}
@ -51,38 +53,7 @@ const CONFIG_DEFAULTS: AppConfig = {
// 라이브 음성 대화 백엔드 — 'realtime'은 로그인+구독 필요 (OpenAI Realtime WebRTC)
conversationBackend: 'local' as const,
defaultLLMAction: 'refine',
dictationShortcut: {
keyCode: 0xa5, // Right Alt
ctrl: false,
alt: false,
shift: false,
meta: false,
displayLabel: 'Right Alt'
},
handsFreeShortcut: {
keyCode: 0xa5,
ctrl: false,
alt: false,
shift: false,
meta: false,
displayLabel: 'Right Alt (double)'
},
commandShortcut: {
keyCode: 0xa5,
ctrl: true,
alt: false,
shift: false,
meta: false,
displayLabel: 'Ctrl + Right Alt'
},
captionShortcut: {
keyCode: 0xa5,
ctrl: true,
alt: false,
shift: true,
meta: false,
displayLabel: 'Ctrl + Shift + Right Alt'
},
keyBindings: createDefaultBindingMap(),
hotkeyEnabled: true,
insertMethod: 'clipboard',
autoInsert: true,
@ -125,6 +96,9 @@ function createMemoryStore(initial: AppConfig): ElectronStore<AppConfig> {
set<K extends keyof AppConfig>(key: K, value: AppConfig[K]): void {
data[key] = value
},
delete(key: string): void {
delete (data as unknown as Record<string, unknown>)[key]
},
get store(): AppConfig {
return data
},
@ -134,6 +108,77 @@ function createMemoryStore(initial: AppConfig): ElectronStore<AppConfig> {
}
}
// ── 키바인딩 마이그레이션 (구 *Shortcut 4개 → keyBindings) ──
/** 0.x 저장 형태. 구조·라벨 정본이 keybinding.ts 로 옮겨지기 전의 값이다. */
interface LegacyShortcut {
keyCode: number
ctrl: boolean
alt: boolean
shift: boolean
meta: boolean
}
const LEGACY_SHORTCUT_ACTIONS: Readonly<Record<string, KeyBindingActionId>> = {
dictationShortcut: 'dictation',
handsFreeShortcut: 'hands-free',
commandShortcut: 'command',
captionShortcut: 'caption',
}
function isLegacyShortcut(value: unknown): value is LegacyShortcut {
if (typeof value !== 'object' || value === null) return false
const v = value as Record<string, unknown>
return (
typeof v.keyCode === 'number' &&
typeof v.ctrl === 'boolean' &&
typeof v.alt === 'boolean' &&
typeof v.shift === 'boolean' &&
typeof v.meta === 'boolean'
)
}
/**
* 저장된 keyBindings 를 복원하고, 남아 있는 구 `*Shortcut` 값을 1회만 이관한다.
*
* 구 필드는 이관 직후 삭제하므로 다시 읽히지 않는다.
* `displayLabel` 은 폐기한다 — 라벨은 formatBindingSegments() 로 파생시킨다.
*/
function migrateKeyBindings(activeStore: ElectronStore<AppConfig>): void {
const raw = activeStore.store as unknown as Record<string, unknown>
const restored: KeyBindingMap = parseBindingMap(raw.keyBindings)
const legacyKeys = Object.keys(LEGACY_SHORTCUT_ACTIONS).filter((key) =>
isLegacyShortcut(raw[key]),
)
if (legacyKeys.length === 0) {
activeStore.set('keyBindings', restored)
return
}
for (const key of legacyKeys) {
const legacy = raw[key] as LegacyShortcut
const actionId = LEGACY_SHORTCUT_ACTIONS[key]
if (actionId === undefined) continue
restored[actionId] = [
normalizeBinding({
device: 'keyboard',
code: legacy.keyCode,
ctrl: legacy.ctrl,
alt: legacy.alt,
shift: legacy.shift,
meta: legacy.meta,
}),
]
}
activeStore.set('keyBindings', restored)
for (const key of legacyKeys) {
activeStore.delete(key)
}
logger.info(`Migrated ${legacyKeys.length} legacy shortcut(s) to keyBindings`)
}
/** electron-store 없이 설정 CRUD를 가능하게 한다 (테스트 + 초기화 전 안전망). */
export function initInMemoryConfig(overrides?: Partial<AppConfig>): void {
store = createMemoryStore({ ...CONFIG_DEFAULTS, ...overrides })
@ -149,6 +194,7 @@ export async function initConfigService(): Promise<void> {
name: 'd3ro-voice-config',
defaults: CONFIG_DEFAULTS
})
migrateKeyBindings(store)
logger.info('ConfigService initialized')
}

View file

@ -1,709 +0,0 @@
// 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<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']]
])
// 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<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()
// 등록된 모든 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
}
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
}

View file

@ -0,0 +1,766 @@
// src/main/services/KeyBindingService.ts
// uiohook-napi 기반 글로벌 키바인딩 후킹 서비스.
//
// 바인딩 계약(키 목록·기본값·정규화·검증·충돌 판정)의 정본은 `packages/core/src/keybinding.ts` 다.
// 이 파일은 두 가지만 책임진다:
// 1) 정본 좌표계(Windows VK / MouseButton) ↔ uiohook 이벤트 좌표계 변환
// 2) press / release · 더블프레스 · auto-repeat 런타임 상태 머신
//
// 런타임 상태는 액션 id 가 아니라 bindingKey() 로 키잉한다 —
// 한 액션에 여러 바인딩이 붙고, 여러 액션이 한 바인딩을 공유하기 때문이다.
import { EventEmitter } from 'events'
import { globalShortcut } from 'electron'
import { uIOhook, UiohookKey } from 'uiohook-napi'
import type { UiohookKeyboardEvent, UiohookMouseEvent } 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 {
KEYBINDING_ACTIONS,
MouseButton,
VK,
bindingKey,
normalizeBinding
} from '@d3ro/core/keybinding'
import type {
KeyBinding,
KeyBindingActionId,
KeyBindingActionSpec,
MouseButtonCode
} from '@d3ro/core/types'
const logger = getLogger('KeyBindingService')
// ============================================================
// 이벤트 페이로드
// ============================================================
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
}
interface KeyBindingServiceEvents {
triggered: (payload: KeyBindingTriggerPayload) => void
error: (payload: { error: D3ROError }) => void
}
// ============================================================
// Windows VK ↔ uiohook 키코드
// ============================================================
/**
* Windows Virtual-Key 코드 → uiohook-napi 키코드.
* 저장·IPC 의 KeyBinding.code 는 VK 이므로 uiohook 이벤트와 비교하려면 변환이 필요하다.
* 여기에 없는 키는 uiohook 이벤트로 관측할 수 없으므로 등록 시점에 건너뛴다.
*/
const VK_TO_UIOHOOK: ReadonlyMap<number, number> = new Map([
// 수정자 키
[VK.ShiftLeft, UiohookKey.Shift],
[VK.ShiftRight, UiohookKey.ShiftRight],
[VK.CtrlLeft, UiohookKey.Ctrl],
[VK.CtrlRight, UiohookKey.CtrlRight],
[VK.AltLeft, UiohookKey.Alt],
[VK.AltRight, UiohookKey.AltRight],
[VK.MetaLeft, UiohookKey.Meta],
[VK.MetaRight, UiohookKey.MetaRight],
// 기능 키 F1~F24
[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],
[0x7c, UiohookKey.F13],
[0x7d, UiohookKey.F14],
[0x7e, UiohookKey.F15],
[0x7f, UiohookKey.F16],
[0x80, UiohookKey.F17],
[0x81, UiohookKey.F18],
[0x82, UiohookKey.F19],
[0x83, UiohookKey.F20],
[0x84, UiohookKey.F21],
[0x85, UiohookKey.F22],
[0x86, UiohookKey.F23],
[0x87, UiohookKey.F24],
// 편집 키
[VK.Space, UiohookKey.Space],
[VK.Enter, UiohookKey.Enter],
[VK.Escape, UiohookKey.Escape],
[VK.Backspace, UiohookKey.Backspace],
[VK.Tab, UiohookKey.Tab],
[VK.CapsLock, UiohookKey.CapsLock],
// 내비게이션 키
[VK.Insert, UiohookKey.Insert],
[VK.Delete, UiohookKey.Delete],
[VK.Home, UiohookKey.Home],
[VK.End, UiohookKey.End],
[VK.PageUp, UiohookKey.PageUp],
[VK.PageDown, UiohookKey.PageDown],
[VK.ArrowLeft, UiohookKey.ArrowLeft],
[VK.ArrowUp, UiohookKey.ArrowUp],
[VK.ArrowRight, UiohookKey.ArrowRight],
[VK.ArrowDown, 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']],
// 넘패드
[VK.Numpad0, UiohookKey.Numpad0],
[0x61, UiohookKey.Numpad1],
[0x62, UiohookKey.Numpad2],
[0x63, UiohookKey.Numpad3],
[0x64, UiohookKey.Numpad4],
[0x65, UiohookKey.Numpad5],
[0x66, UiohookKey.Numpad6],
[0x67, UiohookKey.Numpad7],
[0x68, UiohookKey.Numpad8],
[VK.Numpad9, UiohookKey.Numpad9],
[VK.NumpadMultiply, UiohookKey.NumpadMultiply],
[VK.NumpadAdd, UiohookKey.NumpadAdd],
[VK.NumpadSubtract, UiohookKey.NumpadSubtract],
[VK.NumpadDecimal, UiohookKey.NumpadDecimal],
[VK.NumpadDivide, UiohookKey.NumpadDivide],
// 문장부호
[VK.Semicolon, UiohookKey.Semicolon],
[VK.Equal, UiohookKey.Equal],
[VK.Comma, UiohookKey.Comma],
[VK.Minus, UiohookKey.Minus],
[VK.Period, UiohookKey.Period],
[VK.Slash, UiohookKey.Slash],
[VK.Backquote, UiohookKey.Backquote],
[VK.BracketLeft, UiohookKey.BracketLeft],
[VK.Backslash, UiohookKey.Backslash],
[VK.BracketRight, UiohookKey.BracketRight],
[VK.Quote, UiohookKey.Quote],
// 시스템 키
[VK.PrintScreen, UiohookKey.PrintScreen],
[VK.ScrollLock, UiohookKey.ScrollLock],
[VK.NumLock, UiohookKey.NumLock]
])
const UIOHOOK_TO_VK: ReadonlyMap<number, number> = new Map(
Array.from(VK_TO_UIOHOOK, ([vk, uiohookCode]) => [uiohookCode, vk] as const)
)
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
}
// ============================================================
// 마우스 버튼 좁히기
// ============================================================
const MOUSE_BUTTON_CODES: readonly MouseButtonCode[] = [
MouseButton.Left,
MouseButton.Right,
MouseButton.Middle,
MouseButton.Back,
MouseButton.Forward
]
/**
* uiohook-napi 의 `UiohookMouseEvent.button` 은 타입이 `unknown` 이다.
* libuiohook 은 MOUSE_BUTTON1..5 를 숫자로 싣고 보내므로 여기서 좁힌다.
*/
function readMouseButton(event: UiohookMouseEvent): MouseButtonCode | null {
const raw: unknown = event.button
if (typeof raw !== 'number' || !Number.isInteger(raw)) return null
const found = MOUSE_BUTTON_CODES.find((code) => code === raw)
return found ?? null
}
// ============================================================
// Electron accelerator 변환 (macOS beep 차단 전용)
// ============================================================
function vkToAcceleratorKey(vk: number): string | null {
if (vk >= VK.Digit0 && vk <= VK.Digit9) return String.fromCharCode(vk)
if (vk >= VK.A && vk <= VK.Z) return String.fromCharCode(vk)
if (vk >= VK.F1 && vk <= VK.F24) return `F${vk - VK.F1 + 1}`
switch (vk) {
case VK.Space: return 'Space'
case VK.Enter: return 'Return'
case VK.Escape: return 'Esc'
case VK.Backspace: return 'Backspace'
case VK.Tab: return 'Tab'
case VK.Insert: return 'Insert'
case VK.Delete: return 'Delete'
case VK.Home: return 'Home'
case VK.End: return 'End'
case VK.PageUp: return 'PageUp'
case VK.PageDown: return 'PageDown'
case VK.ArrowLeft: return 'Left'
case VK.ArrowUp: return 'Up'
case VK.ArrowRight: return 'Right'
case VK.ArrowDown: return 'Down'
case VK.PrintScreen: return 'PrintScreen'
case VK.NumLock: return 'Numlock'
case VK.ScrollLock: return 'Scrolllock'
case VK.Semicolon: return ';'
case VK.Equal: return '='
case VK.Comma: return ','
case VK.Minus: return '-'
case VK.Period: return '.'
case VK.Slash: return '/'
case VK.Backquote: return '`'
case VK.BracketLeft: return '['
case VK.Backslash: return '\\'
case VK.BracketRight: return ']'
case VK.Quote: return "'"
default: return null
}
}
/**
* 키보드 바인딩 → Electron globalShortcut accelerator.
*
* globalShortcut 은 **macOS 시스템 beep 차단 용도로만** 쓴다.
* OS 가 키를 swallow 하게 만들어 focused app 으로 전달되지 않게 하고,
* 실제 press/release 처리는 uiohook 이 그대로 담당한다.
* 수정자 키 자체를 주 키로 쓰는 바인딩(예: Right Alt 단독)은 accelerator 로 표현할 수 없다.
*/
function bindingToAccelerator(binding: KeyBinding): string | null {
const key = vkToAcceleratorKey(binding.code)
if (key === null) 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('+')
}
// ============================================================
// 내부 타입
// ============================================================
interface RegisteredBinding {
actionId: KeyBindingActionId
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
}
// ============================================================
// KeyBindingService
// ============================================================
class KeyBindingService extends EventEmitter {
private _isRunning = false
/** bindingKey → 그 바인딩을 쓰는 액션들. 매칭은 이 역인덱스 조회로 끝난다. */
private _byBindingKey: Map<string, RegisteredBinding[]> = new Map()
/** 이 서비스가 직접 등록한 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
get isRunning(): boolean {
return this._isRunning
}
/** 등록된 바인딩 수 (bindingKey 기준) */
get registeredBindingCount(): number {
return this._byBindingKey.size
}
// ── 수명 주기 ──────────────────────────────────────────
/**
* uiohook 글로벌 후킹을 시작한다. 이미 실행 중이면 무시.
*
* mousemove / wheel / click 은 구독하지 않는다 — 고빈도이고 바인딩 대상이 아니다.
*/
start(): void {
if (this._isRunning) {
logger.warn('KeyBindingService already running')
return
}
try {
this._onKeyDown = (e: UiohookKeyboardEvent) => this._handleKeyDown(e)
this._onKeyUp = (e: UiohookKeyboardEvent) => this._handleKeyUp(e)
this._onMouseDown = (e: UiohookMouseEvent) => this._handleMouseDown(e)
this._onMouseUp = (e: UiohookMouseEvent) => this._handleMouseUp(e)
uIOhook.on('keydown', this._onKeyDown)
uIOhook.on('keyup', this._onKeyUp)
uIOhook.on('mousedown', this._onMouseDown)
uIOhook.on('mouseup', this._onMouseUp)
uIOhook.start()
this._isRunning = true
this._syncAccelerators()
logger.info('uiohook started, global keyboard/mouse 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 글로벌 후킹을 중지한다. 이 서비스가 등록한 accelerator만 해제한다. */
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
}
if (this._onMouseDown) {
uIOhook.removeListener('mousedown', this._onMouseDown)
this._onMouseDown = null
}
if (this._onMouseUp) {
uIOhook.removeListener('mouseup', this._onMouseUp)
this._onMouseUp = null
}
this._isRunning = false
this._clearRuntimeState()
this._unregisterOwnedAccelerators()
logger.info('uiohook stopped')
} catch (error) {
logger.error(
`Failed to stop uiohook: ${error instanceof Error ? error.message : String(error)}`
)
}
}
dispose(): void {
this.stop()
this._byBindingKey.clear()
this._unregisterOwnedAccelerators()
this._clearRuntimeState()
this.removeAllListeners()
logger.info('KeyBindingService disposed')
}
// ── 등록 ───────────────────────────────────────────────
/** ConfigService 의 keyBindings 를 읽어 역인덱스를 다시 만든다. */
loadFromConfig(): void {
this._byBindingKey.clear()
this._clearRuntimeState()
if (!configGet('hotkeyEnabled')) {
this._unregisterOwnedAccelerators()
logger.info('Key bindings disabled in config')
return
}
const map = configGet('keyBindings')
for (const spec of KEYBINDING_ACTIONS) {
for (const raw of map[spec.id] ?? []) {
this._register(spec, raw)
}
}
this._syncAccelerators()
logger.info(
`Loaded ${this._byBindingKey.size} key binding(s) from config ` +
`for ${KEYBINDING_ACTIONS.length} action(s)`
)
}
private _register(spec: KeyBindingActionSpec, raw: KeyBinding): void {
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) {
logger.warn(
`Key code 0x${binding.code.toString(16)} has no uiohook equivalent ` +
`— binding for action "${spec.id}" is not registered`
)
return
}
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)
}
// ── accelerator (macOS beep 차단) ──────────────────────
private _syncAccelerators(): void {
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
if (this._ownedAccelerators.has(accelerator)) continue
try {
const ok = globalShortcut.register(accelerator, () => {
// noop — press/release 는 uiohook 이 책임진다. 등록 목적은 OS 의 키 swallow 다.
})
if (ok) {
this._ownedAccelerators.add(accelerator)
} else {
logger.warn(`globalShortcut.register returned false for "${accelerator}"`)
}
} catch (err) {
logger.warn(
`globalShortcut.register failed for "${accelerator}": ${err instanceof Error ? err.message : String(err)}`
)
}
}
}
private _unregisterOwnedAccelerators(): void {
for (const accelerator of this._ownedAccelerators) {
try {
globalShortcut.unregister(accelerator)
} catch (err) {
logger.warn(
`globalShortcut.unregister failed for "${accelerator}": ${err instanceof Error ? err.message : String(err)}`
)
}
}
this._ownedAccelerators.clear()
}
private _clearRuntimeState(): void {
this._isKeyDown.clear()
this._pressStartTime.clear()
this._lastPressTime.clear()
this._activeTriggers.clear()
}
// ── 이벤트 → 바인딩 매칭 ───────────────────────────────
/**
* uiohook 이벤트를 정본 좌표계 bindingKey 로 옮긴다.
* normalizeBinding 이 "주 키가 수정자 자신"인 경우를 정리하므로
* Right Alt 단독 바인딩도 그대로 매칭된다.
*/
private _keyboardEventKey(e: UiohookKeyboardEvent): string | null {
const vk = UIOHOOK_TO_VK.get(e.keycode)
if (vk === undefined) return null
return bindingKey({
device: 'keyboard',
code: vk,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
meta: e.metaKey
})
}
private _mouseEventKey(e: UiohookMouseEvent): string | null {
const button = readMouseButton(e)
if (button === null) return null
return bindingKey({
device: 'mouse',
code: button,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
meta: e.metaKey
})
}
private _handleKeyDown(e: UiohookKeyboardEvent): void {
const key = this._keyboardEventKey(e)
if (key === null) return
this._handlePress(key)
}
private _handleMouseDown(e: UiohookMouseEvent): void {
const key = this._mouseEventKey(e)
if (key === null) return
this._handlePress(key)
}
/**
* 키 업.
* Alt+1 같은 조합에서 수정자를 먼저 놓아도 release 를 놓치지 않아야 하므로,
* 정확 매칭이 실패하면 눌림 상태인 바인딩의 구성 키가 놓였는지도 확인한다.
*/
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)
}
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
})
}
this._activeTriggers.set(key, triggers)
for (const trigger of triggers) {
logger.debug(`Key binding pressed: "${trigger.actionId}" (double=${isDoublePress})`)
this.emit('triggered', {
actionId: trigger.actionId,
type: 'pressed',
isDoublePress: trigger.isDoublePress,
holdMode: trigger.holdMode,
timestamp: now,
durationMs: 0
})
}
}
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)`
)
this.emit('triggered', {
actionId: trigger.actionId,
type: 'released',
isDoublePress: trigger.isDoublePress,
holdMode: trigger.holdMode,
timestamp: now,
durationMs
})
}
}
// ── EventEmitter 타입 오버라이드 ───────────────────────
override on<K extends keyof KeyBindingServiceEvents>(
event: K,
listener: KeyBindingServiceEvents[K]
): this {
return super.on(event, listener)
}
override off<K extends keyof KeyBindingServiceEvents>(
event: K,
listener: KeyBindingServiceEvents[K]
): this {
return super.off(event, listener)
}
override emit<K extends keyof KeyBindingServiceEvents>(
event: K,
...args: Parameters<KeyBindingServiceEvents[K]>
): boolean {
return super.emit(event, ...args)
}
}
// ============================================================
// 싱글톤
// ============================================================
let instance: KeyBindingService | null = null
export function getKeyBindingService(): KeyBindingService {
if (!instance) {
instance = new KeyBindingService()
}
return instance
}

View file

@ -13,15 +13,15 @@ import { getAudioCaptureService } from './AudioCaptureService'
import { getLocalSTTService } from './LocalSTTService'
import type { TranscriptionResult } from './LocalSTTService'
import { getSTTManager } from './stt/STTManager'
import { getHotkeyService } from './HotkeyService'
import type { HotkeyConfig } from './HotkeyService'
import { getKeyBindingService } from './KeyBindingService'
import type { KeyBindingTriggerPayload } from './KeyBindingService'
import { configGet } from './ConfigService'
import { getTextInsertService } from './TextInsertService'
import { getLocalLLMService } from './LocalLLMService'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { TIMING } from '@d3ro/core/constants'
import { RecognitionState, AudioState } from '@d3ro/core/types'
import type { VoiceMode, VoiceState, LLMAction } from '@d3ro/core/types'
import type { KeyBindingActionId, VoiceMode, VoiceState, LLMAction } from '@d3ro/core/types'
import {
showRecordingTip,
hideRecordingTip,
@ -55,7 +55,9 @@ interface VoiceAction {
type: 'press' | 'release' | 'escape'
timestamp: number
mode: VoiceMode
hotkeyId: string
actionId: KeyBindingActionId
/** hold-to-talk 여부 — release 에서 세션을 끊을지 결정한다 */
holdMode: boolean
}
interface VoiceModeEvents {
@ -147,9 +149,7 @@ class VoiceModeService extends EventEmitter {
// 리스너 해제용 참조
private _audioDataHandler: ((payload: { buffer: Buffer }) => void) | null = null
private _audioLevelHandler: ((payload: { level: number }) => void) | null = null
private _hotkeyPressHandler: ((payload: { config: HotkeyConfig; timestamp: number }) => void) | null = null
private _hotkeyReleaseHandler: ((payload: { config: HotkeyConfig; durationMs: number; timestamp: number }) => void) | null = null
private _doublePressHandler: ((payload: { config: HotkeyConfig }) => void) | null = null
private _keyBindingHandler: ((payload: KeyBindingTriggerPayload) => void) | null = null
private _disposed = false
@ -174,39 +174,39 @@ class VoiceModeService extends EventEmitter {
// ── 초기화 ──────────────────────────────────────────────
/**
* HotkeyService 이벤트를 구독하여 핫키 → 세션 제어를 연결한다.
* KeyBindingService 이벤트를 구독하여 키바인딩 → 세션 제어를 연결한다.
* bootstrap에서 호출한다.
*/
connectHotkey(): void {
const hotkey = getHotkeyService()
this._hotkeyPressHandler = (payload) => {
// Phase 10.1: caption 핫키는 VoiceModeService가 아닌 CaptionService로 라우팅
if (payload.config.id === 'voice-caption') {
this._toggleCaption()
return
connectKeyBindings(): void {
this._keyBindingHandler = (payload) => {
switch (payload.actionId) {
case 'caption':
// Phase 10.1: caption은 VoiceModeService가 아닌 CaptionService로 라우팅.
// 토글이므로 press만 처리하고 release는 버린다.
if (payload.type === 'pressed') this._toggleCaption()
return
case 'history-popup':
case 'command-popup':
// 팝업 액션은 bootstrap이 직접 구독한다.
return
default:
break
}
const mode = this._resolveMode(payload.config)
this._enqueueAction({ type: 'press', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id })
const holdMode = this._resolveHoldMode(payload.actionId, payload.holdMode)
const action: VoiceAction = {
type: payload.type === 'pressed' ? 'press' : 'release',
timestamp: payload.timestamp,
mode: this._resolveMode(payload.actionId, payload.isDoublePress),
actionId: payload.actionId,
holdMode
}
this._enqueueAction(action)
}
this._hotkeyReleaseHandler = (payload) => {
// caption 핫키의 release는 무시 (토글 방식)
if (payload.config.id === 'voice-caption') return
const mode = this._resolveMode(payload.config)
this._enqueueAction({ type: 'release', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id })
}
getKeyBindingService().on('triggered', this._keyBindingHandler)
this._doublePressHandler = (payload) => {
// 더블프레스 → hands-free 모드 토글
this._enqueueAction({ type: 'press', timestamp: Date.now(), mode: 'hands-free', hotkeyId: payload.config.id })
}
hotkey.on('hotkey-pressed', this._hotkeyPressHandler)
hotkey.on('hotkey-released', this._hotkeyReleaseHandler)
hotkey.on('double-press', this._doublePressHandler)
logger.info('Hotkey events connected')
logger.info('Key binding events connected')
}
// ── 세션 제어 ──────────────────────────────────────────
@ -1050,24 +1050,34 @@ class VoiceModeService extends EventEmitter {
}
}
private async _handleRelease(_action: VoiceAction): Promise<void> {
if (_action.mode === 'dictation' && this.isActive) {
private async _handleRelease(action: VoiceAction): Promise<void> {
if (action.holdMode && action.mode === 'dictation' && this.isActive) {
// Dictation: hold-to-talk — release로 즉시 종료
// Speakly 패턴: 딜레이 없이 즉시 stop (딜레이가 race condition 유발)
await this.stopSession()
}
// HandsFree: release 무시
// HandsFree(토글): release 무시
}
// ── 유틸리티 ───────────────────────────────────────────
private _resolveMode(config: HotkeyConfig): VoiceMode {
if (config.id === 'voice-handsfree' || config.doublePressEnabled) {
private _resolveMode(actionId: KeyBindingActionId, isDoublePress: boolean): VoiceMode {
if (actionId === 'hands-free' || isDoublePress) {
return 'hands-free'
}
return 'dictation'
}
/**
* 'command' 액션에는 아직 전용 핸들러가 없다.
* 현행 동작대로 dictation 파이프라인으로 fallback 하며, 그 경로는 hold-to-talk 이므로
* KEYBINDING_ACTIONS 의 holdMode(false)가 아니라 dictation 과 같은 값을 쓴다.
*/
private _resolveHoldMode(actionId: KeyBindingActionId, specHoldMode: boolean): boolean {
if (actionId === 'command') return true
return specHoldMode
}
// ── 자막 모드 토글 (Phase 10.1) ─────────────────────────
private async _toggleCaption(): Promise<void> {
@ -1111,16 +1121,10 @@ class VoiceModeService extends EventEmitter {
this._cancelSession('user')
}
// 핫키 리스너 해제
const hotkey = getHotkeyService()
if (this._hotkeyPressHandler) {
hotkey.off('hotkey-pressed', this._hotkeyPressHandler)
}
if (this._hotkeyReleaseHandler) {
hotkey.off('hotkey-released', this._hotkeyReleaseHandler)
}
if (this._doublePressHandler) {
hotkey.off('double-press', this._doublePressHandler)
// 키바인딩 리스너 해제
if (this._keyBindingHandler) {
getKeyBindingService().off('triggered', this._keyBindingHandler)
this._keyBindingHandler = null
}
// 오디오 리스너 해제

View file

@ -44,10 +44,14 @@ import type {
SetSTTProviderConfigParams,
TestSTTConnectionParams,
TestSTTConnectionResult,
HotkeyBinding,
SetHotkeyParams,
KeyBindingMap,
SetKeyBindingsParams,
ResetKeyBindingParams,
ValidateKeyBindingParams,
KeyBindingValidationResult,
KeyBindingTriggeredEvent,
KeyBindingChangedEvent,
SetEnabledParams,
HotkeyTriggeredEvent,
LLMStatus,
LLMModel,
LLMProcessParams,
@ -315,29 +319,23 @@ const electronAPI = {
on(IPC_CHANNELS.RUNTIME.PROGRESS, cb)
},
// ── Hotkey ─────────────────────────────────────────────
hotkey: {
getDictationShortcut: () =>
invoke<HotkeyBinding>(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT),
setDictationShortcut: (params: SetHotkeyParams) =>
invoke<void>(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, params),
getHandsFreeShortcut: () =>
invoke<HotkeyBinding>(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT),
setHandsFreeShortcut: (params: SetHotkeyParams) =>
invoke<void>(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, params),
getCommandShortcut: () =>
invoke<HotkeyBinding>(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT),
setCommandShortcut: (params: SetHotkeyParams) =>
invoke<void>(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, params),
getCaptionShortcut: () =>
invoke<HotkeyBinding>(IPC_CHANNELS.HOTKEY.GET_CAPTION_SHORTCUT),
setCaptionShortcut: (params: SetHotkeyParams) =>
invoke<void>(IPC_CHANNELS.HOTKEY.SET_CAPTION_SHORTCUT, params),
isEnabled: () => invoke<boolean>(IPC_CHANNELS.HOTKEY.IS_ENABLED),
// ── Key bindings ───────────────────────────────────────
keybinding: {
getMap: () => invoke<KeyBindingMap>(IPC_CHANNELS.KEYBINDING.GET_MAP),
setBindings: (params: SetKeyBindingsParams) =>
invoke<void>(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, params),
resetAction: (params: ResetKeyBindingParams) =>
invoke<void>(IPC_CHANNELS.KEYBINDING.RESET_ACTION, params),
resetAll: () => invoke<void>(IPC_CHANNELS.KEYBINDING.RESET_ALL),
validate: (params: ValidateKeyBindingParams) =>
invoke<KeyBindingValidationResult>(IPC_CHANNELS.KEYBINDING.VALIDATE, params),
isEnabled: () => invoke<boolean>(IPC_CHANNELS.KEYBINDING.IS_ENABLED),
setEnabled: (params: SetEnabledParams) =>
invoke<void>(IPC_CHANNELS.HOTKEY.SET_ENABLED, params),
onTriggered: (cb: (e: HotkeyTriggeredEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.HOTKEY.TRIGGERED, cb)
invoke<void>(IPC_CHANNELS.KEYBINDING.SET_ENABLED, params),
onTriggered: (cb: (e: KeyBindingTriggeredEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.KEYBINDING.TRIGGERED, cb),
onChanged: (cb: (e: KeyBindingChangedEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.KEYBINDING.CHANGED, cb)
},
// ── LLM ────────────────────────────────────────────────

View file

@ -1,315 +0,0 @@
// src/renderer/components/HotkeyRecordModal.tsx
// Speakly HotkeyRecordModal 패턴: Modal → 키 입력 대기 → Chip 표시 → 저장/취소
// 조합키: modifier(Ctrl/Alt/Shift) 누른 상태에서 일반키 입력 → 조합 완성
// 단일키: modifier만 눌렀다 놓으면 해당 modifier가 단독 핫키로 등록
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
Button,
Chip,
Stack,
Typography,
} from '@mui/material'
import { d3roPalette, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { HotkeyBinding } from '@d3ro/core/types'
import { formatHotkeyLabel, getPlatform, keyCodeToName } from '../utils/format-hotkey'
// 시스템 예약 조합
const RESERVED_COMBOS = [
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S', 'Ctrl+W',
'Alt+F4', 'Alt+Tab', 'Ctrl+Alt+Delete',
]
const MODIFIER_KEYCODES = new Set([16, 17, 18, 91, 92, 160, 161, 162, 163, 164, 165])
function getKeyName(keyCode: number, key: string): string {
// 플랫폼별 라벨 (macOS는 ⌘/⇧/⌃/⌥). format-hotkey.ts의 단일 매핑을 사용.
const platform = getPlatform()
const mapped = keyCodeToName(keyCode, platform)
if (mapped && !mapped.startsWith('Key')) return mapped
if (key.length === 1) return key.toUpperCase()
return key
}
function isModifier(keyCode: number): boolean {
return MODIFIER_KEYCODES.has(keyCode)
}
interface HotkeyRecordModalProps {
open: boolean
onClose: () => void
onSave: (binding: HotkeyBinding) => void
currentBinding?: HotkeyBinding | null
title?: string
}
export function HotkeyRecordModal({
open,
onClose,
onSave,
currentBinding,
title,
}: HotkeyRecordModalProps): React.ReactElement {
const { t } = useI18n()
// 현재 눌려있는 키들을 실시간 추적
const [pressedKeys, setPressedKeys] = useState<Array<{ keyCode: number; name: string }>>([])
// 확정된 조합 (녹화 완료 후)
const [captured, setCaptured] = useState<Array<{ keyCode: number; name: string }> | null>(null)
const [error, setError] = useState<string | null>(null)
const pressedRef = useRef<Map<number, string>>(new Map())
// modifier-only 확정을 위한 타이머 (Alt만 눌렀을 때 바로 확정하지 않고 잠시 대기)
const modifierTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// 가장 최근 pressedKeys 스냅샷 (타이머 콜백에서 stale closure 방지)
const lastPressedRef = useRef<Array<{ keyCode: number; name: string }>>([])
// Modal 열릴 때 리셋
useEffect(() => {
if (open) {
setPressedKeys([])
setCaptured(null)
setError(null)
pressedRef.current.clear()
lastPressedRef.current = []
if (modifierTimerRef.current) {
clearTimeout(modifierTimerRef.current)
modifierTimerRef.current = null
}
}
}, [open])
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (captured) return // 이미 확정됨
e.preventDefault()
e.stopPropagation()
// 새 키가 눌렸으므로 modifier-only 타이머 취소
if (modifierTimerRef.current) {
clearTimeout(modifierTimerRef.current)
modifierTimerRef.current = null
}
const keyCode = e.keyCode || e.which
if (pressedRef.current.has(keyCode)) return
const name = getKeyName(keyCode, e.key)
pressedRef.current.set(keyCode, name)
const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n }))
setPressedKeys(keys)
lastPressedRef.current = keys
// modifier가 아닌 키가 눌리면 → 조합 즉시 확정 (Alt+1, Ctrl+F5 등)
if (!isModifier(keyCode)) {
setCaptured(keys)
}
}, [captured])
const handleKeyUp = useCallback((e: KeyboardEvent) => {
if (captured) return // 이미 확정됨
const keyCode = e.keyCode || e.which
pressedRef.current.delete(keyCode)
// 모든 키를 놓았고 modifier만 눌렀었다면 → 500ms 대기 후 확정
// 이 대기 시간 동안 추가 키를 누르면 타이머가 취소되어 조합키로 확장 가능
if (pressedRef.current.size === 0 && lastPressedRef.current.length > 0 && lastPressedRef.current.every(k => isModifier(k.keyCode))) {
if (modifierTimerRef.current) clearTimeout(modifierTimerRef.current)
modifierTimerRef.current = setTimeout(() => {
// 타이머 만료: 여전히 confirmed 안됐고 추가 키 입력 없으면 단일 modifier로 확정
setCaptured(lastPressedRef.current)
modifierTimerRef.current = null
}, 500)
}
const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n }))
setPressedKeys(keys)
}, [captured])
useEffect(() => {
if (!open) return
window.addEventListener('keydown', handleKeyDown, true)
window.addEventListener('keyup', handleKeyUp, true)
return () => {
window.removeEventListener('keydown', handleKeyDown, true)
window.removeEventListener('keyup', handleKeyUp, true)
}
}, [open, handleKeyDown, handleKeyUp])
const displayKeys = captured ?? pressedKeys
const isReady = captured !== null && captured.length > 0
const handleSave = () => {
if (!captured || captured.length === 0) {
setError(t('hotkey.noKey'))
return
}
// 예약 단축키 체크
const label = captured.map(k => k.name).join('+')
if (RESERVED_COMBOS.includes(label)) {
setError(t('hotkey.reserved', { keys: label }))
handleReset()
return
}
// HotkeyBinding 생성
// 주 키(main key) = modifier가 아닌 키가 있으면 그것, 없으면 첫 번째 modifier
const mainKey = captured.find(k => !isModifier(k.keyCode))
const primaryKeyCode = mainKey?.keyCode ?? captured[0].keyCode
// modifier 플래그: 주 키 자체가 해당 modifier인 경우 false 유지
// 예: Right Alt 단독 → keyCode=165, alt=false (주 키가 Alt 자체이므로)
// 예: Ctrl+A → keyCode=A, ctrl=true (Ctrl은 modifier로 사용)
const isCtrlKey = (kc: number) => kc === 17 || kc === 162 || kc === 163
const isAltKey = (kc: number) => kc === 18 || kc === 164 || kc === 165
const isShiftKey = (kc: number) => kc === 16 || kc === 160 || kc === 161
const isMetaKey = (kc: number) => kc === 91 || kc === 92
// 주 키를 제외한 나머지 키들만 modifier로 취급
const modifierKeys = captured.filter(k => k.keyCode !== primaryKeyCode)
const binding: HotkeyBinding = {
keyCode: primaryKeyCode,
ctrl: modifierKeys.some(k => isCtrlKey(k.keyCode)),
alt: modifierKeys.some(k => isAltKey(k.keyCode)),
shift: modifierKeys.some(k => isShiftKey(k.keyCode)),
meta: modifierKeys.some(k => isMetaKey(k.keyCode)),
displayLabel: label,
}
onSave(binding)
onClose()
}
const handleReset = () => {
setPressedKeys([])
setCaptured(null)
pressedRef.current.clear()
}
const handleCancel = () => {
handleReset()
onClose()
}
return (
<Dialog
open={open}
onClose={handleCancel}
maxWidth="xs"
fullWidth
// aria-hidden 에러 방지: disableEnforceFocus + disableAutoFocus
disableEnforceFocus
disableAutoFocus
disableRestoreFocus
>
<DialogTitle sx={{ fontWeight: 500, fontSize: d3roTypo.heading.size }}>{title ?? t('hotkey.title')}</DialogTitle>
<DialogContent>
{/* 녹화 영역 */}
<Box
sx={{
border: `2px solid ${
error
? d3roPalette.tag.red
: isReady
? d3roPalette.tag.green
: displayKeys.length > 0
? d3roPalette.accent.main
: d3roPalette.border.strong
}`,
borderRadius: d3roRadius.inner,
p: 3,
minHeight: 80,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: d3roPalette.bg.inset,
transition: 'border-color 0.2s ease',
}}
>
{displayKeys.length > 0 ? (
<Stack direction="row" spacing={1} flexWrap="wrap" justifyContent="center">
{displayKeys.map((key, i) => (
<Chip
key={`${key.keyCode}-${i}`}
label={key.name}
sx={{
fontWeight: 500,
fontSize: '14px',
bgcolor: d3roPalette.bg.chassis,
color: d3roPalette.text.primary,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: '8px',
height: 40,
px: 1,
}}
/>
))}
</Stack>
) : (
<Typography
sx={{
color: d3roPalette.accent.main,
fontSize: '13px',
textAlign: 'center',
}}
>
{t('hotkey.prompt')}
</Typography>
)}
</Box>
{/* 상태 표시 */}
{isReady && !error && (
<Typography sx={{ color: d3roPalette.tag.green, fontSize: '12px', mt: 1, fontWeight: 600 }}>
{t('hotkey.ready', {
keys:
captured
?.map((k) => k.name)
.join(getPlatform() === 'darwin' ? '' : ' + ') ?? ''
})}
</Typography>
)}
{error && (
<Typography sx={{ color: d3roPalette.tag.red, fontSize: '12px', mt: 1 }}>
{error}
</Typography>
)}
{currentBinding && (
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '12px', mt: 2 }}>
{t('hotkey.current', { keys: formatHotkeyLabel(currentBinding) })}
</Typography>
)}
<Typography sx={{ color: d3roPalette.text.muted, fontSize: '11px', mt: 1 }}>
{t('hotkey.hint')}
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleCancel} sx={{ color: d3roPalette.text.inactive }}>
{t('common.cancel')}
</Button>
{isReady && (
<Button onClick={handleReset} sx={{ color: d3roPalette.text.inactive }}>
{t('hotkey.reset')}
</Button>
)}
<Button
variant="contained"
onClick={handleSave}
disabled={!isReady}
>
{t('common.save')}
</Button>
</DialogActions>
</Dialog>
)
}

View file

@ -20,21 +20,21 @@ import {
InputLabel,
FormControl,
Button,
Chip,
Stack,
Paper,
} from '@mui/material'
import { X, Keyboard, Pencil, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle } from 'lucide-react'
import { X, Keyboard, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import { HotkeyRecordModal } from './HotkeyRecordModal'
import { formatHotkeyLabel, formatHotkeySegments } from '../utils/format-hotkey'
import { LicenseTab } from './LicenseTab'
import { CloudSyncSection } from './CloudSyncSection'
import { STTTab } from './STTTab'
import { KeyBindingField } from './keybinding/KeyBindingField'
import { asTranslationKey } from './keybinding/translation-key'
import { useI18n, LOCALE_META } from '@d3ro/i18n'
import type { Locale } from '@d3ro/i18n'
import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types'
import type { KeyBindingActionGroup } from '@d3ro/core/keybinding'
import { KEYBINDING_ACTIONS } from '@d3ro/core/keybinding'
import type { ThemeMode, AppConfig, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types'
interface SettingsModalProps {
open: boolean
@ -53,135 +53,13 @@ function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement
return <Box sx={{ pt: 2 }}>{children}</Box>
}
// ── 핫키 표시 컴포넌트 ──────────────────────────────────
function HotkeyDisplay({
binding,
onEdit,
label,
notSetLabel,
}: {
binding: HotkeyBinding | null
onEdit: () => void
label: string
notSetLabel: string
}): React.ReactElement {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, minWidth: 40 }}>
{label}
</Typography>
{binding ? (
<Stack direction="row" spacing={0.5} alignItems="center">
{formatHotkeySegments(binding).map((key, idx) => (
<Chip
key={`${key}-${idx}`}
label={key}
size="small"
sx={{
fontWeight: 500,
fontSize: d3roTypo.label.size,
bgcolor: d3roPalette.bg.elevated,
color: d3roPalette.text.primary,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.xs,
height: 28,
}}
/>
))}
</Stack>
) : (
<Typography variant="body2" sx={{ color: d3roPalette.text.disabled }}>
{notSetLabel}
</Typography>
)}
<IconButton size="small" onClick={onEdit} sx={{ color: d3roPalette.text.inactive, ml: 'auto' }}>
<Pencil size={16} />
</IconButton>
</Box>
)
// ── 액션 그룹 섹션 ─────────────────────────────────────
const ACTION_GROUP_LABEL_KEYS: Readonly<Record<KeyBindingActionGroup, string>> = {
voice: 'keybinding.ui.sectionVoice',
window: 'keybinding.ui.sectionWindow',
}
// ── 음성 모드 카드 ──────────────────────────────────────
function VoiceModeCard({
title,
description,
enabled,
onToggle,
disabled,
enabledLabel,
disabledLabel,
children,
}: {
title: string
description: string
enabled: boolean
onToggle: (enabled: boolean) => void
disabled?: boolean
enabledLabel: string
disabledLabel: string
children?: React.ReactNode
}): React.ReactElement {
return (
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
borderRadius: '10px',
border: 'none',
boxShadow: d3roShadow.inset,
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 500, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, letterSpacing: '0.5px' }}
>
{title}
</Typography>
<FormControlLabel
control={
<Switch
checked={enabled}
onChange={(e) => onToggle(e.target.checked)}
disabled={disabled}
size="small"
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.green },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
backgroundColor: d3roPalette.tag.green,
},
}}
/>
}
label={
<Chip
label={enabled ? enabledLabel : disabledLabel}
size="small"
sx={{
fontSize: '10px', // P4: 토큰에 없는 10px 보존
fontWeight: 500,
height: 20,
bgcolor: enabled ? d3roPalette.tag.greenBg : 'transparent',
color: enabled ? d3roPalette.tag.green : d3roPalette.text.disabled,
border: enabled ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
}}
/>
}
sx={{ ml: 0, mr: 0 }}
/>
</Box>
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: d3roTypo.label.size }}>
{description}
</Typography>
</Box>
</Box>
{children && <Box sx={{ mt: 1.5 }}>{children}</Box>}
</Paper>
)
}
const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window']
export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalProps): React.ReactElement {
const { t, locale, setLocale } = useI18n()
@ -213,18 +91,8 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
}
}, [open])
// 음성 모드 상태
const [dictationEnabled, setDictationEnabled] = useState(true)
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
const [handsFreeEnabled, setHandsFreeEnabled] = useState(false)
const [handsFreeBinding, setHandsFreeBinding] = useState<HotkeyBinding | null>(null)
const [captionBinding, setCaptionBinding] = useState<HotkeyBinding | null>(null)
const [, setHotkeyGlobalEnabled] = useState(true)
// 핫키 녹화 모달
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree' | 'caption'>('dictation')
// 전역 단축키 on/off (개별 바인딩은 KeyBindingField 가 담당한다)
const [keybindingEnabled, setKeybindingEnabled] = useState(true)
// 오디오 디바이스
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([])
@ -261,20 +129,11 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
Promise.all([
window.electronAPI.config.getAll(),
window.electronAPI.hotkey.getDictationShortcut(),
window.electronAPI.hotkey.getHandsFreeShortcut(),
window.electronAPI.hotkey.getCaptionShortcut(),
window.electronAPI.hotkey.isEnabled(),
window.electronAPI.keybinding.isEnabled(),
])
.then(([configResult, dictResult, hfResult, capResult, enabledResult]) => {
.then(([configResult, enabledResult]) => {
if (configResult.success) setConfig(configResult.data)
if (dictResult.success && dictResult.data) setDictationBinding(dictResult.data)
if (hfResult.success && hfResult.data) setHandsFreeBinding(hfResult.data)
if (capResult.success && capResult.data) setCaptionBinding(capResult.data)
if (enabledResult.success) {
setHotkeyGlobalEnabled(enabledResult.data)
setDictationEnabled(enabledResult.data)
}
if (enabledResult.success) setKeybindingEnabled(enabledResult.data)
})
.finally(() => setLoading(false))
@ -293,48 +152,9 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
window.electronAPI.config.set({ key, value })
}, [])
const handleDictationToggle = useCallback(
(enabled: boolean) => {
setDictationEnabled(enabled)
window.electronAPI.hotkey.setEnabled({ enabled })
if (!enabled && handsFreeEnabled) {
setHandsFreeEnabled(false)
}
},
[handsFreeEnabled]
)
const handleHandsFreeToggle = useCallback((enabled: boolean) => {
if (enabled && !handsFreeBinding) {
setHotkeyModalTarget('handsFree')
setHotkeyModalOpen(true)
return
}
setHandsFreeEnabled(enabled)
}, [handsFreeBinding])
const handleHotkeySave = useCallback(
(binding: HotkeyBinding) => {
if (hotkeyModalTarget === 'dictation') {
setDictationBinding(binding)
window.electronAPI.hotkey.setDictationShortcut({ binding })
setDictationEnabled(true)
window.electronAPI.hotkey.setEnabled({ enabled: true })
} else if (hotkeyModalTarget === 'handsFree') {
setHandsFreeBinding(binding)
window.electronAPI.hotkey.setHandsFreeShortcut({ binding })
setHandsFreeEnabled(true)
} else {
setCaptionBinding(binding)
window.electronAPI.hotkey.setCaptionShortcut({ binding })
}
},
[hotkeyModalTarget]
)
const openHotkeyModal = useCallback((target: 'dictation' | 'handsFree' | 'caption') => {
setHotkeyModalTarget(target)
setHotkeyModalOpen(true)
const handleKeybindingToggle = useCallback((enabled: boolean) => {
setKeybindingEnabled(enabled)
void window.electronAPI.keybinding.setEnabled({ enabled })
}, [])
const handleLanguageChange = useCallback((newLocale: string) => {
@ -418,73 +238,48 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
{/* ── 일반 탭 ─────────────────────────────── */}
<TabPanel value={activeTab} index={0}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.shortcuts')}
</Typography>
<Stack spacing={1.5}>
<VoiceModeCard
title={t('settings.dictation')}
description={t('settings.dictation.desc')}
enabled={dictationEnabled}
onToggle={handleDictationToggle}
enabledLabel={t('settings.enabled')}
disabledLabel={t('settings.disabled')}
>
<HotkeyDisplay
binding={dictationBinding}
onEdit={() => openHotkeyModal('dictation')}
label={t('settings.key')}
notSetLabel={t('settings.notSet')}
/>
</VoiceModeCard>
<VoiceModeCard
title={t('settings.agent')}
description={
dictationBinding
? t('settings.agent.descWithKey', { key: formatHotkeyLabel(dictationBinding) })
: t('settings.agent.descNoKey')
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.shortcuts')}
</Typography>
<FormControlLabel
sx={{ ml: 'auto', mr: 0 }}
control={
<Switch
size="small"
checked={keybindingEnabled}
onChange={(e) => handleKeybindingToggle(e.target.checked)}
/>
}
label={
<Typography sx={{ fontSize: d3roTypo.label.size, color: d3roPalette.text.secondary }}>
{t('keybinding.ui.globalEnabled')}
</Typography>
}
enabled={dictationEnabled}
onToggle={handleDictationToggle}
disabled={!dictationEnabled}
enabledLabel={t('settings.enabled')}
disabledLabel={t('settings.disabled')}
/>
</Box>
<VoiceModeCard
title={t('settings.oneTouch')}
description={t('settings.oneTouch.desc')}
enabled={handsFreeEnabled}
onToggle={handleHandsFreeToggle}
disabled={!dictationEnabled}
enabledLabel={t('settings.enabled')}
disabledLabel={t('settings.disabled')}
>
<HotkeyDisplay
binding={handsFreeBinding}
onEdit={() => openHotkeyModal('handsFree')}
label={t('settings.key')}
notSetLabel={t('settings.notSet')}
/>
</VoiceModeCard>
<VoiceModeCard
title={t('settings.caption')}
description={t('settings.caption.desc')}
enabled={true}
enabledLabel={t('settings.enabled')}
disabledLabel={t('settings.disabled')}
>
<HotkeyDisplay
binding={captionBinding}
onEdit={() => openHotkeyModal('caption')}
label={t('settings.key')}
notSetLabel={t('settings.notSet')}
/>
</VoiceModeCard>
</Stack>
{ACTION_GROUP_ORDER.map((group) => {
const actions = KEYBINDING_ACTIONS.filter((action) => action.group === group)
if (actions.length === 0) return null
return (
<Box key={group} sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<Typography
sx={{
color: d3roPalette.text.dimLabel,
fontSize: d3roTypo.engrave.size,
letterSpacing: '0.1em',
textTransform: 'uppercase',
}}
>
{t(asTranslationKey(ACTION_GROUP_LABEL_KEYS[group]))}
</Typography>
{actions.map((action) => (
<KeyBindingField key={action.id} actionId={action.id} />
))}
</Box>
)
})}
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
@ -981,14 +776,6 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
</Box>
</DialogContent>
</Dialog>
<HotkeyRecordModal
open={hotkeyModalOpen}
onClose={() => setHotkeyModalOpen(false)}
onSave={handleHotkeySave}
currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : hotkeyModalTarget === 'handsFree' ? handsFreeBinding : captionBinding}
title={hotkeyModalTarget === 'dictation' ? t('hotkey.dictationTitle') : hotkeyModalTarget === 'handsFree' ? t('hotkey.oneTouchTitle') : t('hotkey.captionTitle')}
/>
</>
)
}

View file

@ -0,0 +1,171 @@
// apps/desktop/src/renderer/components/keybinding/KeyBindingField.tsx
// 액션 하나의 바인딩 목록 전체를 담당한다. 앱에서 키바인딩을 다루는 유일한 진입점이다.
//
// 라벨 · 설명 · 기본값은 KEYBINDING_ACTIONS 에서 파생하므로 호출부는 actionId 만 넘긴다.
import { useCallback, useState } from 'react'
import { Box, IconButton, Tooltip, Typography } from '@mui/material'
import { Plus, RotateCcw, X } from 'lucide-react'
import { d3roPalette, d3roRadius, d3roShadow, d3roTypo } from '@d3ro/ui/theme'
import { TactileBadge } from '@d3ro/ui/components/ds'
import { useI18n } from '@d3ro/i18n'
import type { KeyBinding, KeyBindingActionId } from '@d3ro/core/keybinding'
import { bindingKey, bindingsEqual, findActionSpec } from '@d3ro/core/keybinding'
import { useKeyBindingMap } from '../../hooks/useKeyBindingMap'
import { BindingKeycaps, useBindingLabel } from './Keycap'
import { KeyBindingPicker } from './KeyBindingPicker'
import { asTranslationKey } from './translation-key'
interface KeyBindingFieldProps {
actionId: KeyBindingActionId
}
export function KeyBindingField({ actionId }: KeyBindingFieldProps): React.ReactElement | null {
const { t } = useI18n()
const map = useKeyBindingMap()
const bindingLabel = useBindingLabel()
const [pickerOpen, setPickerOpen] = useState(false)
const [failed, setFailed] = useState(false)
const spec = findActionSpec(actionId)
const bindings = map === null ? [] : (map[actionId] ?? [])
const commit = useCallback(
(next: KeyBinding[]) => {
void window.electronAPI.keybinding
.setBindings({ actionId, bindings: next })
.then((result) => setFailed(!result.success))
},
[actionId]
)
const handleAdd = useCallback(
(binding: KeyBinding) => {
commit([...bindings, binding])
},
[bindings, commit]
)
const handleRemove = useCallback(
(binding: KeyBinding) => {
commit(bindings.filter((existing) => !bindingsEqual(existing, binding)))
},
[bindings, commit]
)
const handleReset = useCallback(() => {
void window.electronAPI.keybinding
.resetAction({ actionId })
.then((result) => setFailed(!result.success))
}, [actionId])
if (spec === null) return null
return (
<Box
data-testid={`keybinding-field-${actionId}`}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.inner,
boxShadow: d3roShadow.inset,
display: 'flex',
flexDirection: 'column',
gap: 1.25,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
sx={{
fontSize: d3roTypo.compact.size,
fontWeight: 500,
color: d3roPalette.text.primary,
}}
>
{t(asTranslationKey(spec.labelKey))}
</Typography>
{spec.holdMode && <TactileBadge mono>{t('keybinding.ui.holdMode')}</TactileBadge>}
{spec.doublePress && <TactileBadge mono>{t('keybinding.ui.doublePress')}</TactileBadge>}
<Tooltip title={t('keybinding.ui.reset')}>
<IconButton
size="small"
onClick={handleReset}
sx={{ color: d3roPalette.text.inactive, ml: 'auto' }}
>
<RotateCcw size={14} />
</IconButton>
</Tooltip>
</Box>
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: d3roTypo.label.size }}>
{t(asTranslationKey(spec.descriptionKey))}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{bindings.length === 0 && (
<Typography sx={{ color: d3roPalette.text.disabled, fontSize: d3roTypo.small.size }}>
{t('keybinding.ui.noBindings')}
</Typography>
)}
{bindings.map((binding) => (
<Box
key={bindingKey(binding)}
data-testid="keybinding-binding"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
pr: 0.25,
borderRadius: d3roRadius.xs,
border: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<BindingKeycaps binding={binding} size="sm" />
<IconButton
size="small"
data-testid="keybinding-remove"
onClick={() => handleRemove(binding)}
aria-label={t('keybinding.ui.remove', { keys: bindingLabel(binding) })}
sx={{ color: d3roPalette.text.inactive, p: 0.25 }}
>
<X size={12} />
</IconButton>
</Box>
))}
<IconButton
size="small"
data-testid={`keybinding-add-${actionId}`}
onClick={() => setPickerOpen(true)}
aria-label={t('keybinding.ui.add')}
sx={{
color: d3roPalette.accent.main,
border: `1px dashed ${d3roPalette.border.strong}`,
borderRadius: d3roRadius.xs,
px: 1,
height: 22,
}}
>
<Plus size={13} />
<Typography component="span" sx={{ ml: 0.5, fontSize: d3roTypo.label.size }}>
{t('keybinding.ui.add')}
</Typography>
</IconButton>
</Box>
{failed && (
<Typography sx={{ color: d3roPalette.tag.red, fontSize: d3roTypo.label.size }}>
{t('keybinding.ui.saveFailed')}
</Typography>
)}
<KeyBindingPicker
open={pickerOpen}
actionId={actionId}
existingBindings={bindings}
onClose={() => setPickerOpen(false)}
onConfirm={handleAdd}
/>
</Box>
)
}

View file

@ -0,0 +1,696 @@
// apps/desktop/src/renderer/components/keybinding/KeyBindingPicker.tsx
// 바인딩 하나를 고르는 모달. 입력 방식이 두 가지다.
//
// 녹화 탭 — 실제로 키(또는 마우스 버튼)를 눌러 캡처한다.
// 목록 탭 — 검색 인풋이 달린 드롭다운에서 고르고 수정자를 토글한다.
//
// 키 목록 · 라벨 · 검증 · 충돌 판정은 전부 @d3ro/core/keybinding 이 정본이다.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Autocomplete,
Box,
ButtonBase,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
Typography,
} from '@mui/material'
import type { AutocompleteRenderGroupParams } from '@mui/material'
import { Keyboard, ListFilter, MousePointerClick } from 'lucide-react'
import { d3roFontMono, d3roPalette, d3roRadius, d3roShadow, d3roTypo } from '@d3ro/ui/theme'
import { PhysicalButton, SegmentControl } from '@d3ro/ui/components/ds'
import { useI18n } from '@d3ro/i18n'
import type {
BindingPlatform,
KeyBinding,
KeyBindingActionId,
KeyBindingValidationResult,
KeyCatalogEntry,
} from '@d3ro/core/keybinding'
import {
KEY_CATALOG,
KEY_CATALOG_GROUP_LABEL_KEYS,
KEY_CATALOG_GROUP_ORDER,
MouseButton,
VK,
bindingsEqual,
findActionSpec,
formatBindingSegments,
isModifierKeyCode,
normalizeBinding,
searchKeyCatalog,
validateBinding,
} from '@d3ro/core/keybinding'
import { getPlatform } from '../../utils/format-hotkey'
import { BindingKeycaps, Keycap } from './Keycap'
import { asTranslationKey } from './translation-key'
type PickerMode = 'record' | 'list'
interface ModifierState {
ctrl: boolean
alt: boolean
shift: boolean
meta: boolean
}
const NO_MODIFIERS: ModifierState = { ctrl: false, alt: false, shift: false, meta: false }
const MODIFIER_SLOTS = ['ctrl', 'alt', 'shift', 'meta'] as const
type ModifierSlot = (typeof MODIFIER_SLOTS)[number]
/** 단일 modifier 를 눌렀다 뗐을 때 조합으로 확장할 시간을 준다. */
const MODIFIER_COMMIT_DELAY_MS = 500
/**
* 브라우저 MouseEvent.button → libuiohook MOUSE_BUTTON 코드.
* 두 체계의 가운데/오른쪽 순서가 서로 다르므로 그대로 쓰면 안 된다.
*/
const BROWSER_BUTTON_TO_MOUSE: Readonly<Record<number, number>> = {
0: MouseButton.Left,
1: MouseButton.Middle,
2: MouseButton.Right,
3: MouseButton.Back,
4: MouseButton.Forward,
}
const GROUP_RANK: ReadonlyMap<string, number> = new Map(
KEY_CATALOG_GROUP_ORDER.map((group, index) => [group, index])
)
function byGroupOrder(a: KeyCatalogEntry, b: KeyCatalogEntry): number {
return (GROUP_RANK.get(a.group) ?? 0) - (GROUP_RANK.get(b.group) ?? 0)
}
const CATALOG_OPTIONS: readonly KeyCatalogEntry[] = [...KEY_CATALOG].sort(byGroupOrder)
/**
* 브라우저 KeyboardEvent 의 keyCode 를 Windows VK 로 맞춘다.
*
* keyCode 는 수정자를 좌/우 구분 없이 16/17/18 로 주는데 카탈로그는 좌/우 코드만 담고 있다.
* location 으로 보정하지 않으면 수정자 단독 바인딩이 전부 unknown-key 로 거부된다.
*/
function resolveKeyCode(event: KeyboardEvent): number {
const code = event.keyCode || event.which
const right = event.location === 2
if (code === 16) return right ? VK.ShiftRight : VK.ShiftLeft
if (code === 17) return right ? VK.CtrlRight : VK.CtrlLeft
if (code === 18) return right ? VK.AltRight : VK.AltLeft
// macOS 오른쪽 Command 는 93 으로 온다 (Windows 의 93 은 ContextMenu 라 구분이 필요하다).
if (code === 93 && right) return VK.MetaRight
return code
}
/**
* 눌린 키들 → 바인딩.
*
* 주 키는 수정자가 아닌 키가 있으면 그것, 없으면 **가장 나중에 누른** 수정자다.
* "Ctrl 누른 채 오른쪽 Alt" 가 `Ctrl + Right Alt` 로 잡히려면 마지막 것이어야 한다.
*/
function buildBinding(codes: readonly number[], mods: ModifierState): KeyBinding | null {
if (codes.length === 0) return null
const main = codes.find((code) => !isModifierKeyCode(code)) ?? codes[codes.length - 1]
return normalizeBinding({ device: 'keyboard', code: main, ...mods })
}
function probeBinding(slot: ModifierSlot): KeyBinding {
return {
device: 'keyboard',
code: VK.F1,
ctrl: slot === 'ctrl',
alt: slot === 'alt',
shift: slot === 'shift',
meta: slot === 'meta',
}
}
/** 수정자 토글에 붙일 표기 — 플랫폼 표기 규칙도 코어가 정본이다. */
function modifierLabel(slot: ModifierSlot, platform: BindingPlatform): string {
return formatBindingSegments(probeBinding(slot), platform)[0]?.label ?? ''
}
interface KeyBindingPickerProps {
open: boolean
actionId: KeyBindingActionId
/** 이 액션에 이미 걸려 있는 바인딩 — 같은 것을 두 번 추가하지 못하게 한다. */
existingBindings: readonly KeyBinding[]
onClose: () => void
onConfirm: (binding: KeyBinding) => void
}
export function KeyBindingPicker({
open,
actionId,
existingBindings,
onClose,
onConfirm,
}: KeyBindingPickerProps): React.ReactElement {
const { t } = useI18n()
const platform = getPlatform()
const [mode, setMode] = useState<PickerMode>('record')
const [recorded, setRecorded] = useState<KeyBinding | null>(null)
const [preview, setPreview] = useState<KeyBinding | null>(null)
const [listEntry, setListEntry] = useState<KeyCatalogEntry | null>(null)
const [listMods, setListMods] = useState<ModifierState>(NO_MODIFIERS)
const [remote, setRemote] = useState<KeyBindingValidationResult | null>(null)
const [remotePending, setRemotePending] = useState(false)
const pressedRef = useRef<Set<number>>(new Set())
const modsRef = useRef<ModifierState>(NO_MODIFIERS)
const pendingRef = useRef<{ codes: number[]; binding: KeyBinding } | null>(null)
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearCommitTimer = useCallback(() => {
if (commitTimerRef.current !== null) {
clearTimeout(commitTimerRef.current)
commitTimerRef.current = null
}
}, [])
const resetRecording = useCallback(() => {
clearCommitTimer()
pressedRef.current.clear()
modsRef.current = NO_MODIFIERS
pendingRef.current = null
setRecorded(null)
setPreview(null)
}, [clearCommitTimer])
useEffect(() => {
if (!open) return
setMode('record')
setListEntry(null)
setListMods(NO_MODIFIERS)
setRemote(null)
setRemotePending(false)
resetRecording()
}, [open, actionId, resetRecording])
useEffect(() => clearCommitTimer, [clearCommitTimer])
// ── 키보드 녹화 ────────────────────────────────────────
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (recorded !== null) return
event.preventDefault()
event.stopPropagation()
clearCommitTimer()
const code = resolveKeyCode(event)
if (pressedRef.current.has(code)) return
pressedRef.current.add(code)
modsRef.current = {
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
meta: event.metaKey,
}
const codes = [...pressedRef.current]
const binding = buildBinding(codes, modsRef.current)
if (binding === null) return
pendingRef.current = { codes, binding }
setPreview(binding)
// 수정자가 아닌 키가 들어오면 조합이 완성된 것이므로 즉시 확정한다.
if (!isModifierKeyCode(code)) setRecorded(binding)
},
[recorded, clearCommitTimer]
)
const handleKeyUp = useCallback(
(event: KeyboardEvent) => {
if (recorded !== null) return
pressedRef.current.delete(resolveKeyCode(event))
if (pressedRef.current.size > 0) return
const pending = pendingRef.current
if (pending === null) return
// 수정자만 눌렸다 놓인 경우에만 지연 확정한다. 그 사이 다른 키를 누르면 타이머가 취소되고
// 조합키로 확장된다.
if (!pending.codes.every(isModifierKeyCode)) return
clearCommitTimer()
commitTimerRef.current = setTimeout(() => {
setRecorded(pending.binding)
commitTimerRef.current = null
}, MODIFIER_COMMIT_DELAY_MS)
},
[recorded, clearCommitTimer]
)
useEffect(() => {
// 목록 탭에서는 검색 인풋에 타이핑해야 하므로 캡처를 걸지 않는다.
if (!open || mode !== 'record') return
window.addEventListener('keydown', handleKeyDown, true)
window.addEventListener('keyup', handleKeyUp, true)
return () => {
window.removeEventListener('keydown', handleKeyDown, true)
window.removeEventListener('keyup', handleKeyUp, true)
}
}, [open, mode, handleKeyDown, handleKeyUp])
// ── 마우스 녹화 ────────────────────────────────────────
const handleMouseDown = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (recorded !== null) return
const code = BROWSER_BUTTON_TO_MOUSE[event.button]
if (code === undefined) return
event.preventDefault()
event.stopPropagation()
clearCommitTimer()
pressedRef.current.clear()
pendingRef.current = null
setPreview(null)
setRecorded(
normalizeBinding({
device: 'mouse',
code,
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
meta: event.metaKey,
})
)
},
[recorded, clearCommitTimer]
)
// ── 후보 바인딩과 검증 ─────────────────────────────────
const candidate = useMemo((): KeyBinding | null => {
if (mode === 'record') return recorded
if (listEntry === null) return null
return normalizeBinding({ device: listEntry.device, code: listEntry.code, ...listMods })
}, [mode, recorded, listEntry, listMods])
const localValidation = useMemo(
() => (candidate === null ? null : validateBinding(candidate)),
[candidate]
)
useEffect(() => {
if (candidate === null || localValidation === null || !localValidation.valid) {
setRemote(null)
setRemotePending(false)
return
}
let cancelled = false
setRemotePending(true)
void window.electronAPI.keybinding
.validate({ actionId, binding: candidate })
.then((result) => {
if (cancelled) return
setRemote(result.success ? result.data : null)
setRemotePending(false)
})
return () => {
cancelled = true
}
}, [candidate, localValidation, actionId])
const conflicts = remote?.conflicts ?? []
const remoteValidation = remote?.validation ?? null
const isDuplicate =
candidate !== null && existingBindings.some((existing) => bindingsEqual(existing, candidate))
const rejectionKey = localValidation?.reasonKey ?? remoteValidation?.reasonKey ?? null
const warningKey = remoteValidation?.warningKey ?? localValidation?.warningKey ?? null
const canConfirm =
candidate !== null &&
!isDuplicate &&
localValidation?.valid === true &&
remoteValidation?.valid === true &&
conflicts.length === 0 &&
!remotePending
// ── 목록 탭 ────────────────────────────────────────────
const localizedLabels = useMemo(() => {
const out: Record<string, string> = {}
for (const entry of KEY_CATALOG) {
if (entry.labelKey === undefined) continue
out[`${entry.device}:${entry.code}`] = t(asTranslationKey(entry.labelKey))
}
return out
}, [t])
const entryText = useCallback(
(entry: KeyCatalogEntry): string =>
entry.labelKey === undefined ? entry.label : t(asTranslationKey(entry.labelKey)),
[t]
)
/** 키캡에 찍힐 표기 — formatBindingSegments() 가 확정 후에 쓰는 규칙과 같아야 한다. */
const entryKeycapText = useCallback(
(entry: KeyCatalogEntry): string => {
if (entry.labelKey !== undefined) return t(asTranslationKey(entry.labelKey))
return platform === 'darwin' ? (entry.macLabel ?? entry.label) : entry.label
},
[t, platform]
)
const actionSpec = findActionSpec(actionId)
const actionName = actionSpec === null ? '' : t(asTranslationKey(actionSpec.labelKey))
const handleConfirm = (): void => {
if (candidate === null || !canConfirm) return
onConfirm(candidate)
onClose()
}
const renderGroupSection = (params: AutocompleteRenderGroupParams): React.ReactElement => (
<li key={params.key}>
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1,
px: 1.5,
py: 0.75,
bgcolor: d3roPalette.bg.inset,
color: d3roPalette.text.label,
fontSize: d3roTypo.engrave.size,
fontWeight: 500,
letterSpacing: '0.1em',
textTransform: 'uppercase',
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
}}
>
{t(
asTranslationKey(
KEY_CATALOG_GROUP_LABEL_KEYS[params.group as keyof typeof KEY_CATALOG_GROUP_LABEL_KEYS]
)
)}
</Box>
<Box component="ul" sx={{ p: 0, m: 0, listStyle: 'none' }}>
{params.children}
</Box>
</li>
)
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="xs"
fullWidth
data-testid="keybinding-picker"
disableEnforceFocus
disableRestoreFocus
PaperProps={{
sx: {
bgcolor: d3roPalette.bg.chassis,
backgroundImage: 'none',
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: d3roRadius.card,
},
}}
>
<DialogTitle sx={{ fontWeight: 500, fontSize: d3roTypo.heading.size, pb: 1.5 }}>
{t('keybinding.ui.pickerTitle', { action: actionName })}
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pb: 1 }}>
<Box
data-testid="keybinding-picker-tabs"
sx={{ display: 'flex', justifyContent: 'center' }}
>
<SegmentControl<PickerMode>
value={mode}
onChange={setMode}
size="small"
options={[
{ value: 'record', label: t('keybinding.ui.tabRecord'), icon: <Keyboard size={14} /> },
{ value: 'list', label: t('keybinding.ui.tabList'), icon: <ListFilter size={14} /> },
]}
/>
</Box>
{mode === 'record' ? (
<Box
data-testid="keybinding-record-area"
onMouseDown={handleMouseDown}
onContextMenu={(event) => event.preventDefault()}
sx={{
border: `2px solid ${
rejectionKey !== null
? d3roPalette.tag.red
: recorded !== null
? d3roPalette.tag.green
: preview !== null
? d3roPalette.accent.main
: d3roPalette.border.strong
}`,
borderRadius: d3roRadius.inner,
bgcolor: d3roPalette.bg.inset,
p: 3,
minHeight: 104,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1.25,
cursor: 'pointer',
userSelect: 'none',
transition: 'border-color 0.2s ease',
}}
>
{candidate !== null ? (
<BindingKeycaps binding={candidate} size="lg" />
) : preview !== null ? (
<BindingKeycaps binding={preview} size="lg" />
) : (
<>
<MousePointerClick size={18} color={d3roPalette.accent.main} />
<Typography
sx={{
color: d3roPalette.accent.main,
fontSize: d3roTypo.compact.size,
textAlign: 'center',
}}
>
{t('keybinding.ui.pressKeys')}
</Typography>
</>
)}
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.75 }}>
<Box
sx={{
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.inner,
bgcolor: d3roPalette.bg.inset,
px: 2,
py: 1.75,
display: 'flex',
flexDirection: 'column',
gap: 1.25,
}}
>
<Typography
sx={{
color: d3roPalette.text.label,
fontSize: d3roTypo.engrave.size,
letterSpacing: '0.1em',
textTransform: 'uppercase',
}}
>
{t('keybinding.ui.modifiers')}
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{MODIFIER_SLOTS.map((slot) => {
const active = listMods[slot]
return (
<ButtonBase
key={slot}
onClick={() => setListMods((prev) => ({ ...prev, [slot]: !prev[slot] }))}
sx={{
borderRadius: d3roRadius.xs,
px: 1.25,
height: 30,
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
fontWeight: 500,
letterSpacing: '0.04em',
color: active ? d3roPalette.text.primary : d3roPalette.text.inactive,
bgcolor: active ? d3roPalette.accent.dim : d3roPalette.bg.chassis,
border: `1px solid ${
active ? d3roPalette.accent.main : d3roPalette.border.default
}`,
boxShadow: active ? d3roShadow.inset : 'none',
transition: 'all 0.15s ease',
}}
>
{modifierLabel(slot, platform)}
</ButtonBase>
)
})}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minHeight: 30 }}>
<Typography
sx={{ color: d3roPalette.text.inactive, fontSize: d3roTypo.small.size }}
>
{t('keybinding.ui.selectedKey')}
</Typography>
{candidate !== null && <BindingKeycaps binding={candidate} size="md" />}
</Box>
</Box>
<Autocomplete<KeyCatalogEntry, false, false, false>
open
options={CATALOG_OPTIONS as KeyCatalogEntry[]}
value={listEntry}
onChange={(_, value) => setListEntry(value)}
groupBy={(option) => option.group}
getOptionLabel={entryText}
getOptionDisabled={(option) => option.disabledReasonKey !== undefined}
isOptionEqualToValue={(a, b) => a.device === b.device && a.code === b.code}
filterOptions={(_, state) =>
[...searchKeyCatalog(state.inputValue, localizedLabels)].sort(byGroupOrder)
}
noOptionsText={t('keybinding.ui.searchEmpty')}
renderGroup={renderGroupSection}
renderOption={(props, option) => {
const { key, ...liProps } = props
const keycapText = entryKeycapText(option)
// 키캡에 찍히는 표기와 다를 때만 로케일 무관 원표기를 보조로 보여준다.
const secondary = keycapText === option.label ? null : option.label
return (
<Box
component="li"
key={key}
{...liProps}
data-testid="keybinding-key-option"
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.75 }}
>
<Keycap size="sm" muted={option.disabledReasonKey !== undefined}>
{keycapText}
</Keycap>
<Typography
sx={{
flex: 1,
minWidth: 0,
fontSize: d3roTypo.small.size,
color: d3roPalette.text.secondary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{secondary}
</Typography>
{option.disabledReasonKey !== undefined && (
<Typography
sx={{ fontSize: d3roTypo.nano.size, color: d3roPalette.text.disabled }}
>
{t(asTranslationKey(option.disabledReasonKey))}
</Typography>
)}
</Box>
)
}}
renderInput={(params) => (
<TextField
{...params}
size="small"
autoFocus
data-testid="keybinding-search"
placeholder={t('keybinding.ui.search')}
/>
)}
slotProps={{
popper: { placement: 'bottom-start', modifiers: [{ name: 'flip', enabled: false }] },
paper: {
sx: {
bgcolor: d3roPalette.bg.chassis,
backgroundImage: 'none',
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.inner,
boxShadow: d3roShadow.card,
},
},
listbox: { sx: { maxHeight: 260, py: 0 } },
}}
/>
</Box>
)}
<Box sx={{ minHeight: 34, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{rejectionKey !== null && (
<Typography
data-testid="keybinding-rejection"
sx={{ color: d3roPalette.tag.red, fontSize: d3roTypo.label.size }}
>
{t(asTranslationKey(rejectionKey))}
</Typography>
)}
{isDuplicate && (
<Typography
data-testid="keybinding-rejection"
sx={{ color: d3roPalette.tag.red, fontSize: d3roTypo.label.size }}
>
{t('keybinding.ui.duplicate')}
</Typography>
)}
{conflicts.map((conflict) => {
const spec = findActionSpec(conflict.actionId)
return (
<Typography
key={conflict.actionId}
data-testid="keybinding-rejection"
sx={{ color: d3roPalette.tag.red, fontSize: d3roTypo.label.size }}
>
{t('keybinding.ui.conflictWith', {
action: spec === null ? conflict.actionId : t(asTranslationKey(spec.labelKey)),
})}
</Typography>
)
})}
{rejectionKey === null && warningKey !== null && (
<Typography sx={{ color: d3roPalette.tag.orange, fontSize: d3roTypo.label.size }}>
{t(asTranslationKey(warningKey))}
</Typography>
)}
{mode === 'record' && candidate === null && (
<Typography sx={{ color: d3roPalette.text.muted, fontSize: d3roTypo.meta.size }}>
{t('keybinding.ui.recordHint')}
</Typography>
)}
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
<PhysicalButton
tone="ghost"
data-testid="keybinding-cancel"
onClick={onClose}
sx={{ height: 34 }}
>
{t('common.cancel')}
</PhysicalButton>
{mode === 'record' && recorded !== null && (
<PhysicalButton
tone="glass"
data-testid="keybinding-record-again"
onClick={resetRecording}
sx={{ height: 34 }}
>
{t('keybinding.ui.recordAgain')}
</PhysicalButton>
)}
<PhysicalButton
tone="accent"
data-testid="keybinding-confirm"
onClick={handleConfirm}
disabled={!canConfirm}
sx={{ height: 34 }}
>
{t('common.save')}
</PhysicalButton>
</DialogActions>
</Dialog>
)
}

View file

@ -0,0 +1,116 @@
// apps/desktop/src/renderer/components/keybinding/Keycap.tsx
// 키캡 표기 SSOT. 대시보드 · 설정 · 피커가 전부 이 컴포넌트로 키를 그린다.
//
// 표기할 문자열은 @d3ro/core/keybinding 의 formatBindingSegments() 가 정본이다 —
// 여기서는 그리기만 한다.
import { Box } from '@mui/material'
import { d3roFontMono, d3roPalette, d3roRadius, d3roShadow, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { BindingSegment, KeyBinding } from '@d3ro/core/keybinding'
import { formatBindingSegments, joinBindingSegments } from '@d3ro/core/keybinding'
import { getPlatform } from '../../utils/format-hotkey'
import { asTranslationKey } from './translation-key'
export type KeycapSize = 'sm' | 'md' | 'lg'
const SIZE_SPEC = {
sm: { height: 22, fontSize: d3roTypo.label.size, px: 0.85, radius: d3roRadius.xs },
md: { height: 28, fontSize: d3roTypo.small.size, px: 1.1, radius: d3roRadius.xs },
lg: { height: 38, fontSize: d3roTypo.body.size, px: 1.6, radius: d3roRadius.small },
} as const
interface KeycapProps {
children: React.ReactNode
size?: KeycapSize
/** 선택 불가 항목을 흐리게 표시 */
muted?: boolean
}
export function Keycap({ children, size = 'md', muted = false }: KeycapProps): React.ReactElement {
const spec = SIZE_SPEC[size]
return (
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
height: spec.height,
px: spec.px,
borderRadius: spec.radius,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${
muted ? d3roPalette.border.subtle : d3roPalette.glass.hairlineStrong
}`,
boxShadow: muted ? 'none' : d3roShadow.inset,
fontFamily: d3roFontMono,
fontSize: spec.fontSize,
fontWeight: 500,
letterSpacing: '0.04em',
lineHeight: 1,
whiteSpace: 'nowrap',
color: muted ? d3roPalette.text.disabled : d3roPalette.text.primary,
}}
>
{children}
</Box>
)
}
/** 세그먼트 하나를 표시 문자열로 — i18nKey 가 있으면 번역이 우선이다. */
function useSegmentText(): (segment: BindingSegment) => string {
const { t } = useI18n()
return (segment) =>
segment.i18nKey === undefined ? segment.label : t(asTranslationKey(segment.i18nKey))
}
/** 바인딩 하나 → 결합된 한 줄 문자열. 접근성 라벨 · 본문 문구용. */
export function useBindingLabel(): (binding: KeyBinding | null) => string {
const segmentText = useSegmentText()
const platform = getPlatform()
return (binding) =>
joinBindingSegments(formatBindingSegments(binding, platform).map(segmentText), platform)
}
interface BindingKeycapsProps {
binding: KeyBinding
size?: KeycapSize
muted?: boolean
}
/** 바인딩 하나 → 키캡 나열. Windows/Linux 는 키캡 사이에 `+` 를 넣는다. */
export function BindingKeycaps({
binding,
size = 'md',
muted = false,
}: BindingKeycapsProps): React.ReactElement {
const segmentText = useSegmentText()
const platform = getPlatform()
const segments = formatBindingSegments(binding, platform)
const showSeparator = platform !== 'darwin'
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: showSeparator ? 0.5 : 0.35 }}>
{segments.map((segment, index) => (
<Box
key={`${segment.i18nKey ?? segment.label}-${index}`}
sx={{ display: 'inline-flex', alignItems: 'center', gap: showSeparator ? 0.5 : 0.35 }}
>
{index > 0 && showSeparator && (
<Box
component="span"
sx={{ color: d3roPalette.text.dimLabel, fontSize: d3roTypo.label.size }}
>
+
</Box>
)}
<Keycap size={size} muted={muted}>
{segmentText(segment)}
</Keycap>
</Box>
))}
</Box>
)
}

View file

@ -0,0 +1,9 @@
// apps/desktop/src/renderer/components/keybinding/translation-key.ts
// @d3ro/core/keybinding 은 i18n 키를 평범한 문자열로 노출한다(코어가 로케일 패키지에 의존하지
// 않게 하려는 의도). t() 에 넘기는 경계에서만 TranslationKey 로 좁힌다.
import type { TranslationKey } from '@d3ro/i18n'
export function asTranslationKey(key: string): TranslationKey {
return key as TranslationKey
}

View file

@ -0,0 +1,44 @@
// apps/desktop/src/renderer/hooks/useKeyBindingMap.ts
// 렌더러 쪽 바인딩 맵 캐시.
//
// 액션마다 KeyBindingField 가 하나씩 붙으므로 컴포넌트별로 getMap() 을 부르면 같은 맵을
// 액션 수만큼 가져오게 된다. 구독을 모듈 스코프에 한 벌만 두고 changed 이벤트로 갱신한다.
import { useEffect, useState } from 'react'
import type { KeyBindingMap } from '@d3ro/core/keybinding'
type Listener = (map: KeyBindingMap) => void
let cached: KeyBindingMap | null = null
let subscribed = false
const listeners = new Set<Listener>()
function publish(map: KeyBindingMap): void {
cached = map
for (const listener of listeners) listener(map)
}
function ensureSubscribed(): void {
if (subscribed) return
subscribed = true
window.electronAPI.keybinding.onChanged((event) => publish(event.map))
void window.electronAPI.keybinding.getMap().then((result) => {
if (result.success) publish(result.data)
})
}
/** 맵이 아직 도착하지 않았으면 null. */
export function useKeyBindingMap(): KeyBindingMap | null {
const [map, setMap] = useState<KeyBindingMap | null>(cached)
useEffect(() => {
listeners.add(setMap)
ensureSubscribed()
if (cached !== null) setMap(cached)
return () => {
listeners.delete(setMap)
}
}, [])
return map
}

View file

@ -33,12 +33,13 @@ import {
AudioVisualizerBar,
} from '@d3ro/ui/components/ds'
import { EmptyStateCard, HistoryEntryCard } from '../components/shared'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow, d3roTypo } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters'
import { formatHotkeyLabel } from '../utils/format-hotkey'
import { BindingKeycaps } from '../components/keybinding/Keycap'
import { useKeyBindingMap } from '../hooks/useKeyBindingMap'
import { FileDropZone } from '../components/FileDropZone'
import type { StatsSummary, HistoryEntry, HotkeyBinding, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types'
import type { StatsSummary, HistoryEntry, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types'
/** 카드 섹션 헤더 (제목 + 우측 액세서리) — 헤어라인 하단 구분 */
function CardHeader({
@ -100,7 +101,8 @@ export function DashboardPage(): React.ReactElement {
const [history, setHistory] = useState<HistoryEntry[]>([])
const [ollamaConnected, setOllamaConnected] = useState(false)
const [llmModel, setLlmModel] = useState<string | null>(null)
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
const bindingMap = useKeyBindingMap()
const dictationBinding = bindingMap?.dictation[0] ?? null
const [captionState, setCaptionState] = useState<CaptionState>('inactive')
const [audioLevel, setAudioLevel] = useState(0)
const audioDecayRef = useRef<ReturnType<typeof setInterval> | null>(null)
@ -122,9 +124,6 @@ export function DashboardPage(): React.ReactElement {
window.electronAPI.history.getAll({ page: 0, pageSize: 10, sortOrder: 'desc' }).then((r) => {
if (r.success) setHistory(r.data.entries)
})
window.electronAPI.hotkey.getDictationShortcut().then((r) => {
if (r.success && r.data) setDictationBinding(r.data)
})
window.electronAPI.caption.getState().then((r) => {
if (r.success) setCaptionState(r.data)
})
@ -145,8 +144,8 @@ export function DashboardPage(): React.ReactElement {
const unsub = window.electronAPI.app.onDataChanged(() => {
loadData()
})
const unsubCaption = window.electronAPI.caption.onStateChanged((state) => {
setCaptionState(state)
const unsubCaption = window.electronAPI.caption.onStateChanged((e) => {
setCaptionState(e.state)
})
const unsubAudio = window.electronAPI.voice.onAudioLevel((e) => {
setAudioLevel(e.level)
@ -272,27 +271,9 @@ export function DashboardPage(): React.ReactElement {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mb: 2.5, flexWrap: 'wrap' }}>
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary }}>
{dictationBinding ? t('dashboard.pressToRecord', { key: '' }) : t('dashboard.hotkeyNotSet')}
{dictationBinding ? t('keybinding.ui.pressToRecord') : t('dashboard.hotkeyNotSet')}
</PhosphorText>
{dictationBinding && (
<Box
sx={{
px: 1.25,
py: 0.35,
borderRadius: d3roRadius.xs,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
boxShadow: d3roShadow.inset,
fontFamily: d3roFontMono,
fontSize: '12px',
fontWeight: 500,
color: d3roPalette.accent.light,
letterSpacing: '0.04em',
}}
>
{formatHotkeyLabel(dictationBinding).toUpperCase()}
</Box>
)}
{dictationBinding && <BindingKeycaps binding={dictationBinding} size="sm" />}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>

View file

@ -1,13 +1,12 @@
// apps/desktop/src/renderer/utils/format-hotkey.ts
// HotkeyBinding → 플랫폼별 표시 라벨 (segments + 결합 문자열).
// macOS: ⌘/⌥/⌃/⇧ + key, Windows/Linux: Ctrl/Alt/Win/Shift + key.
// 렌더러가 실행 중인 플랫폼을 알아내는 얇은 어댑터.
//
// 기존 binding.displayLabel은 키 녹화 시 만들어진 정적 문자열이라
// 플랫폼이 다르면 잘못 보임 — 이 헬퍼가 단일 진입점.
// 키 이름 · 수정자 표기 · 결합 규칙의 정본은 @d3ro/core/keybinding 이다
// (formatBindingSegments / joinBindingSegments). 매핑 테이블을 여기에 두지 않는다.
import type { HotkeyBinding } from '@d3ro/core/types'
import type { BindingPlatform } from '@d3ro/core/keybinding'
export type Platform = 'darwin' | 'win32' | 'linux'
export type Platform = BindingPlatform
export function getPlatform(): Platform {
const p = (window as { electronAPI?: { platform?: string } }).electronAPI?.platform
@ -16,104 +15,3 @@ export function getPlatform(): Platform {
if (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform)) return 'darwin'
return 'win32'
}
// keyCode → 키명. Windows VK_* 기준 + 일부 macOS 분기.
export function keyCodeToName(keyCode: number, platform: Platform = getPlatform()): string {
// Modifier 표기 — 플랫폼별
if (platform === 'darwin') {
if (keyCode === 16 || keyCode === 160 || keyCode === 161) return '⇧'
if (keyCode === 17 || keyCode === 162 || keyCode === 163) return '⌃'
if (keyCode === 18 || keyCode === 164 || keyCode === 165) return '⌥'
if (keyCode === 91 || keyCode === 92) return '⌘'
} else {
if (keyCode === 16 || keyCode === 160 || keyCode === 161) return 'Shift'
if (keyCode === 17 || keyCode === 162 || keyCode === 163) return 'Ctrl'
if (keyCode === 18 || keyCode === 164 || keyCode === 165) return 'Alt'
if (keyCode === 91 || keyCode === 92) return 'Win'
}
// 공통 키
switch (keyCode) {
case 8: return 'Backspace'
case 9: return 'Tab'
case 13: return platform === 'darwin' ? '↩' : 'Enter'
case 19: return 'Pause'
case 20: return 'CapsLock'
case 27: return 'Esc'
case 32: return 'Space'
case 33: return 'PgUp'
case 34: return 'PgDn'
case 35: return 'End'
case 36: return 'Home'
case 37: return '←'
case 38: return '↑'
case 39: return '→'
case 40: return '↓'
case 45: return 'Insert'
case 46: return platform === 'darwin' ? '⌫' : 'Delete'
case 186: return ';'
case 187: return '='
case 188: return ','
case 189: return '-'
case 190: return '.'
case 191: return '/'
case 192: return '`'
case 219: return '['
case 220: return '\\'
case 221: return ']'
case 222: return "'"
default: break
}
// F1~F24
if (keyCode >= 112 && keyCode <= 135) return `F${keyCode - 111}`
// Numpad
if (keyCode >= 96 && keyCode <= 105) return `Num${keyCode - 96}`
// 알파벳/숫자
if ((keyCode >= 48 && keyCode <= 57) || (keyCode >= 65 && keyCode <= 90)) {
return String.fromCharCode(keyCode)
}
return `Key${keyCode}`
}
/**
* binding을 플랫폼별 segment 배열로 변환.
* 예) Mac: ['⌘', '⇧', '1'], Win: ['Win', 'Shift', '1']
*
* 순서:
* - macOS: ⌃ ⌥ ⇧ ⌘ + key (Apple 표준)
* - Win/Linux: Ctrl Win Alt Shift + key
*/
export function formatHotkeySegments(binding: HotkeyBinding | null): string[] {
if (!binding) return []
const platform = getPlatform()
const segments: string[] = []
if (platform === 'darwin') {
if (binding.ctrl) segments.push('⌃')
if (binding.alt) segments.push('⌥')
if (binding.shift) segments.push('⇧')
if (binding.meta) segments.push('⌘')
} else {
if (binding.ctrl) segments.push('Ctrl')
if (binding.meta) segments.push('Win')
if (binding.alt) segments.push('Alt')
if (binding.shift) segments.push('Shift')
}
segments.push(keyCodeToName(binding.keyCode, platform))
return segments
}
/**
* 사람이 읽기 위한 단일 문자열 형태.
* macOS: '⌘⇧1' (구분자 없음 — Apple 표준)
* Win/Linux: 'Ctrl + Shift + 1'
*/
export function formatHotkeyLabel(binding: HotkeyBinding | null): string {
const segments = formatHotkeySegments(binding)
if (segments.length === 0) return ''
const platform = getPlatform()
return platform === 'darwin' ? segments.join('') : segments.join(' + ')
}

View file

@ -200,7 +200,7 @@ test.describe.serial('Extreme Red Team - Cycle 3: Modals, Deep Configuration & S
expect(uncaughtExceptions).toEqual([]);
});
test('RT-11: HotkeyRecordModal - Interactive Hotkey Recording & Conflict Protection', async () => {
test('RT-11: KeyBindingPicker - Key Recording, Reserved-Combo Protection & Cancel Safety', async () => {
// 1. Re-open SettingsModal to General Tab
const settingsTrigger = window.getByText('설정', { exact: true });
await settingsTrigger.click();
@ -211,34 +211,114 @@ test.describe.serial('Extreme Red Team - Cycle 3: Modals, Deep Configuration & S
await generalTab.click();
await window.waitForTimeout(400);
// 2. Click pencil icon on Dictation shortcut to open HotkeyRecordModal
const editHotkeyBtn = window.locator('button:has(svg.lucide-pencil)').first();
await expect(editHotkeyBtn).toBeVisible();
await editHotkeyBtn.click();
await window.waitForTimeout(500);
// 2. The dictation row is rendered by KeyBindingField off KEYBINDING_ACTIONS
const dictationField = window.getByTestId('keybinding-field-dictation');
await expect(dictationField).toBeVisible({ timeout: 5000 });
// Verify HotkeyRecordModal is open
await expect(window.getByText(/단축키 설정|단축키 녹화|키 조합/i).first()).toBeVisible({ timeout: 5000 });
// Wait for the binding map to arrive, then snapshot how many bindings this action
// has so we can prove afterwards that cancelling did not corrupt them.
const bindingChips = dictationField.getByTestId('keybinding-binding');
await expect(bindingChips.first()).toBeVisible({ timeout: 5000 });
const bindingsBefore = await bindingChips.count();
// 3. Test pressing a key (F9)
// 3. Open the picker with the "+ Add" button (replaces the old pencil icon)
await dictationField.getByTestId('keybinding-add-dictation').click();
const picker = window.getByTestId('keybinding-picker');
await expect(picker).toBeVisible({ timeout: 5000 });
// 4. Record tab: pressing F9 captures it onto a keycap
const recordArea = picker.getByTestId('keybinding-record-area');
await expect(recordArea).toBeVisible();
await window.keyboard.press('F9');
await window.waitForTimeout(400);
await expect(recordArea.getByText('F9', { exact: true })).toBeVisible();
// Verify chip shows F9
await expect(window.getByText('F9', { exact: true })).toBeVisible();
// Take screenshot while modal is open with captured key
// Take screenshot while the picker holds the captured key
await window.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt11_hotkey_modal_verified.png'),
path: path.join(SCREENSHOT_DIR, 'rt11_keybinding_picker_recorded.png'),
});
// 4. Click Cancel button to close HotkeyRecordModal without corrupting bindings
const cancelBtn = window.getByRole('button', { name: /취소|Cancel/i });
await expect(cancelBtn).toBeVisible();
await cancelBtn.click();
await window.waitForTimeout(500);
// 5. Reserved-combo protection: Ctrl+C must be rejected and Save must stay disabled
await picker.getByTestId('keybinding-record-again').click();
await window.waitForTimeout(300);
await window.keyboard.press('Control+c');
await window.waitForTimeout(400);
await expect(picker.getByTestId('keybinding-rejection').first()).toBeVisible();
await expect(picker.getByTestId('keybinding-confirm')).toBeDisabled();
// 6. Cancel closes the picker without touching the stored bindings
await picker.getByTestId('keybinding-cancel').click();
await window.waitForTimeout(500);
await expect(picker).toBeHidden();
await expect(bindingChips).toHaveCount(bindingsBefore);
// Close SettingsModal (its close button sits in DialogTitle, ahead of any field markup)
const closeSettingsBtn = window.locator('div[role="dialog"] button:has(svg.lucide-x)').first();
if (await closeSettingsBtn.isVisible()) {
await closeSettingsBtn.click();
await window.waitForTimeout(500);
}
expect(uncaughtExceptions).toEqual([]);
});
test('RT-11b: KeyBindingPicker - Searchable Key Dropdown & Multi-Binding Add/Remove', async () => {
// 1. Re-open SettingsModal to General Tab
const settingsTrigger = window.getByText('설정', { exact: true });
await settingsTrigger.click();
await window.waitForTimeout(600);
const generalTab = window.getByRole('tab', { name: /일반|General/i });
await generalTab.click();
await window.waitForTimeout(400);
const dictationField = window.getByTestId('keybinding-field-dictation');
const bindingChips = dictationField.getByTestId('keybinding-binding');
await expect(bindingChips.first()).toBeVisible({ timeout: 5000 });
const bindingsBefore = await bindingChips.count();
// 2. Open the picker and switch to the list tab
await dictationField.getByTestId('keybinding-add-dictation').click();
const picker = window.getByTestId('keybinding-picker');
await expect(picker).toBeVisible({ timeout: 5000 });
await picker
.getByTestId('keybinding-picker-tabs')
.getByText(/목록에서 선택|Choose from List/i)
.click();
await window.waitForTimeout(300);
// 3. The dropdown carries its own search field and narrows the key catalog as you type.
// Its listbox is portalled to <body>, so options are queried from the page root.
const searchInput = picker.getByTestId('keybinding-search').locator('input');
await expect(searchInput).toBeVisible();
const keyOptions = window.getByTestId('keybinding-key-option');
expect(await keyOptions.count()).toBeGreaterThan(1);
await searchInput.fill('f8');
await window.waitForTimeout(400);
await expect(keyOptions).toHaveCount(1);
await keyOptions.first().click();
await window.waitForTimeout(300);
// Picked key is reflected back into the picker's selection preview
await expect(picker.getByText('F8', { exact: true }).first()).toBeVisible();
// 4. Saving adds a SECOND binding to the same action (multi-binding)
const confirmBtn = picker.getByTestId('keybinding-confirm');
await expect(confirmBtn).toBeEnabled({ timeout: 5000 });
await confirmBtn.click();
await expect(picker).toBeHidden();
await expect(bindingChips).toHaveCount(bindingsBefore + 1);
await window.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt11b_keybinding_multi_binding.png'),
});
// 5. Remove it again so the stored config is left exactly as we found it
await bindingChips.last().getByTestId('keybinding-remove').click();
await expect(bindingChips).toHaveCount(bindingsBefore);
// Close SettingsModal
const closeSettingsBtn = window.locator('div[role="dialog"] button:has(svg.lucide-x)').first();
if (await closeSettingsBtn.isVisible()) {
await closeSettingsBtn.click();

View file

@ -51,13 +51,13 @@ vi.mock('../../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => mockAudio
}))
const mockHotkey = {
const mockKeyBinding = {
on: vi.fn(),
off: vi.fn()
}
vi.mock('../../../src/main/services/HotkeyService', () => ({
getHotkeyService: () => mockHotkey
vi.mock('../../../src/main/services/KeyBindingService', () => ({
getKeyBindingService: () => mockKeyBinding
}))
vi.mock('../../../src/main/services/ConfigService', () => ({

View file

@ -1,8 +1,18 @@
import { describe, it, expect, vi } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode } from '@d3ro/core/errors'
import {
KEYBINDING_ACTIONS,
createDefaultBindingMap,
findActionSpec,
} from '@d3ro/core/keybinding'
import type {
KeyBinding,
KeyBindingMap,
KeyBindingValidationResult,
} from '@d3ro/core/types'
import { configGet } from '../../src/main/services/ConfigService'
import { registerHotkeyHandlers } from '../../src/main/ipc/hotkey-handlers'
import { registerKeyBindingHandlers } from '../../src/main/ipc/keybinding-handlers'
import { registerCaptionHandlers } from '../../src/main/ipc/caption-handlers'
import { registerRAGHandlers } from '../../src/main/ipc/rag-handlers'
import { registerVoiceConversationHandlers } from '../../src/main/ipc/voice-conversation-handlers'
@ -13,73 +23,171 @@ import { registerTemplateHandlers } from '../../src/main/ipc/template-handlers'
import { registerMeetingDocTemplateHandlers } from '../../src/main/ipc/meeting-doc-template-handlers'
import { invokeIpc, useRedHarness } from './harness'
vi.mock('../../src/main/services/HotkeyService', () => ({
getHotkeyService: () => ({
const { keyBindingService } = vi.hoisted(() => ({
keyBindingService: {
loadFromConfig: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
on: vi.fn(),
off: vi.fn(),
}),
},
}))
vi.mock('../../src/main/services/KeyBindingService', () => ({
getKeyBindingService: () => keyBindingService,
}))
useRedHarness()
const SAMPLE_BINDING = {
keyCode: 65,
/** Ctrl + A — 시스템 예약 조합이라 검증에서 거부되어야 한다 */
const RESERVED_BINDING: KeyBinding = {
device: 'keyboard',
code: 0x41,
ctrl: true,
alt: false,
shift: false,
meta: false,
displayLabel: 'Ctrl+A',
}
describe('유스케이스: 핫키 / 캡션 / RAG / 대화 / 액션 / 파일전사 / 회의 IPC', () => {
it('핫키 받아쓰기 단축키를 읽고 쓴다', async () => {
registerHotkeyHandlers()
const set = await invokeIpc(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, { binding: SAMPLE_BINDING })
/** Ctrl + Shift + F9 — 예약되지 않은 유효한 조합 */
const FREE_BINDING: KeyBinding = {
device: 'keyboard',
code: 0x78,
ctrl: true,
alt: false,
shift: true,
meta: false,
}
describe('유스케이스: 키바인딩 / 캡션 / RAG / 대화 / 액션 / 파일전사 / 회의 IPC', () => {
it('키바인딩 맵을 읽으면 모든 액션이 들어 있다', async () => {
registerKeyBindingHandlers()
const get = await invokeIpc<KeyBindingMap>(IPC_CHANNELS.KEYBINDING.GET_MAP)
expect(get.success).toBe(true)
if (get.success) {
for (const action of KEYBINDING_ACTIONS) {
expect(get.data[action.id]).toEqual(action.defaultBindings)
}
}
})
it('액션 바인딩을 교체하면 맵에 반영되고 서비스가 다시 읽는다', async () => {
registerKeyBindingHandlers()
const set = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, {
actionId: 'dictation',
bindings: [FREE_BINDING],
})
expect(set.success).toBe(true)
const get = await invokeIpc(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT)
expect(get.success).toBe(true)
if (get.success) expect(get.data.displayLabel).toBe('Ctrl+A')
expect(keyBindingService.loadFromConfig).toHaveBeenCalled()
const get = await invokeIpc<KeyBindingMap>(IPC_CHANNELS.KEYBINDING.GET_MAP)
if (get.success) expect(get.data.dictation).toEqual([FREE_BINDING])
})
it('핫키 핸즈프리 단축키를 읽고 쓴다', async () => {
registerHotkeyHandlers()
await invokeIpc(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, { binding: SAMPLE_BINDING })
const get = await invokeIpc(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT)
expect(get.success).toBe(true)
it('시스템 예약 조합은 HotkeySystemReserved로 거부된다', async () => {
registerKeyBindingHandlers()
const set = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, {
actionId: 'dictation',
bindings: [RESERVED_BINDING],
})
expect(set.success).toBe(false)
if (!set.success) expect(set.error.code).toBe(ErrorCode.HotkeySystemReserved)
})
it('핫키 명령 단축키를 읽고 쓴다', async () => {
registerHotkeyHandlers()
await invokeIpc(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, { binding: SAMPLE_BINDING })
const get = await invokeIpc(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT)
expect(get.success).toBe(true)
it('다른 액션이 쓰는 바인딩은 HotkeyConflict로 거부된다', async () => {
registerKeyBindingHandlers()
const ok = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, {
actionId: 'caption',
bindings: [FREE_BINDING],
})
expect(ok.success).toBe(true)
const conflict = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, {
actionId: 'command',
bindings: [FREE_BINDING],
})
expect(conflict.success).toBe(false)
if (!conflict.success) expect(conflict.error.code).toBe(ErrorCode.HotkeyConflict)
})
it('핫키 자막 단축키를 읽고 쓴다', async () => {
registerHotkeyHandlers()
await invokeIpc(IPC_CHANNELS.HOTKEY.SET_CAPTION_SHORTCUT, { binding: SAMPLE_BINDING })
const get = await invokeIpc(IPC_CHANNELS.HOTKEY.GET_CAPTION_SHORTCUT)
expect(get.success).toBe(true)
it('바인딩 사전 검사는 유효성과 충돌을 함께 돌려준다', async () => {
registerKeyBindingHandlers()
const reserved = await invokeIpc<KeyBindingValidationResult>(
IPC_CHANNELS.KEYBINDING.VALIDATE,
{ actionId: 'dictation', binding: RESERVED_BINDING },
)
expect(reserved.success).toBe(true)
if (reserved.success) {
expect(reserved.data.validation.valid).toBe(false)
expect(reserved.data.validation.reason).toBe('system-reserved')
}
const free = await invokeIpc<KeyBindingValidationResult>(
IPC_CHANNELS.KEYBINDING.VALIDATE,
{ actionId: 'dictation', binding: FREE_BINDING },
)
if (free.success) {
expect(free.data.validation.valid).toBe(true)
expect(free.data.conflicts).toEqual([])
}
})
it('핫키 활성 토글을 끈다', async () => {
registerHotkeyHandlers()
const set = await invokeIpc(IPC_CHANNELS.HOTKEY.SET_ENABLED, { enabled: false })
it('액션 하나를 기본값으로 되돌린다', async () => {
registerKeyBindingHandlers()
await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, {
actionId: 'dictation',
bindings: [FREE_BINDING],
})
const reset = await invokeIpc(IPC_CHANNELS.KEYBINDING.RESET_ACTION, { actionId: 'dictation' })
expect(reset.success).toBe(true)
const get = await invokeIpc<KeyBindingMap>(IPC_CHANNELS.KEYBINDING.GET_MAP)
if (get.success) {
expect(get.data.dictation).toEqual(findActionSpec('dictation')?.defaultBindings)
}
})
it('전체를 기본값으로 되돌린다', async () => {
registerKeyBindingHandlers()
await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, {
actionId: 'caption',
bindings: [FREE_BINDING],
})
const reset = await invokeIpc(IPC_CHANNELS.KEYBINDING.RESET_ALL)
expect(reset.success).toBe(true)
const get = await invokeIpc<KeyBindingMap>(IPC_CHANNELS.KEYBINDING.GET_MAP)
if (get.success) expect(get.data).toEqual(createDefaultBindingMap())
})
it('알 수 없는 액션 id는 거부된다', async () => {
registerKeyBindingHandlers()
const res = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, {
actionId: 'nope',
bindings: [FREE_BINDING],
})
expect(res.success).toBe(false)
})
it('키바인딩 활성 토글을 끈다', async () => {
registerKeyBindingHandlers()
const set = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_ENABLED, { enabled: false })
expect(set.success).toBe(true)
const get = await invokeIpc(IPC_CHANNELS.HOTKEY.IS_ENABLED)
const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.IS_ENABLED)
expect(get.success).toBe(true)
if (get.success) expect(get.data).toBe(false)
expect(configGet('hotkeyEnabled')).toBe(false)
expect(keyBindingService.stop).toHaveBeenCalled()
})
it('핫키 활성 토글을 켠다', async () => {
registerHotkeyHandlers()
await invokeIpc(IPC_CHANNELS.HOTKEY.SET_ENABLED, { enabled: true })
const get = await invokeIpc(IPC_CHANNELS.HOTKEY.IS_ENABLED)
it('키바인딩을 다시 켜면 등록을 다시 읽고 후킹을 시작한다', async () => {
registerKeyBindingHandlers()
keyBindingService.loadFromConfig.mockClear()
await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_ENABLED, { enabled: true })
const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.IS_ENABLED)
if (get.success) expect(get.data).toBe(true)
expect(keyBindingService.loadFromConfig).toHaveBeenCalled()
expect(keyBindingService.start).toHaveBeenCalled()
})
it('캡션 초기 상태를 읽는다', async () => {

View file

@ -104,8 +104,8 @@ vi.mock('../../src/main/services/CaptionService', () => ({
resetCaptionServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/HotkeyService', () => ({
getHotkeyService: () => ({
vi.mock('../../src/main/services/KeyBindingService', () => ({
getKeyBindingService: () => ({
on: vi.fn(),
off: vi.fn(),
}),

View file

@ -2,7 +2,7 @@
> Status: ACTIVE
> Last full audit: 2026-09-13
> Last update: 2026-09-19 — GAP-INFRA-05 (desktop renderer popup bundle verification wired into CI); 1.3.7 published to the updater feed
> Last update: 2026-09-21 — CAP-16 (desktop key bindings rebuilt on one `@d3ro/core/keybinding` SSOT: multiple bindings per action, mouse buttons, `HOTKEY` → `KEYBINDING` IPC group); verified on Windows by a manual run, so CAP-16 and CAP-02 are `[x]` and GAP-KEY-01 is closed; GAP-KEY-02/03, GAP-QA-02, GAP-I18N-01/02, GAP-INFRA-06 remain open; `11` gained §7 for accepted design constraints (things deliberately kept, not gaps)
> Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.3.7`
> Purpose: let any agent (or human) answer two questions in under a minute:
> 1. **What infrastructure exists?** (build, CI, services, APIs, data, packages, deploy)

View file

@ -15,7 +15,7 @@ A multi-platform AI voice assistant: press/hold or tap to speak, get a transcrip
| Surface | Path | Stack | Runtime model | Role |
|---|---|---|---|---|
| Desktop | `apps/desktop` | Electron 33 + React 19 + MUI 7 + Vite | **Local-first** (SoX, faster-whisper sidecar, Ollama, SQLite) with optional cloud sync | The flagship: global hotkey dictation, text insertion into other apps, meetings, captions, RAG, voice conversation, OS actions |
| Desktop | `apps/desktop` | Electron 33 + React 19 + MUI 7 + Vite | **Local-first** (SoX, faster-whisper sidecar, Ollama, SQLite) with optional cloud sync | The flagship: global key-binding dictation (keyboard or mouse, rebindable — CAP-16), text insertion into other apps, meetings, captions, RAG, voice conversation, OS actions |
| Web | `apps/web` | Next.js 15 App Router + Supabase | **Cloud** | Browser console: record/STT, history, commands, meetings, knowledge, teams, chat, billing |
| Mobile | `apps/mobile-rn` | React Native 0.85 + React 19 (CLI, not Expo) | **Cloud-first** (Supabase + Edge Functions), on-device Whisper fallback | Product mobile app: recording/import, history, meetings, memos, templates, teams, Talk, admin, data portability, IAP + ads |
| API server | `apps/api-server` | ASP.NET Core 10 + EF Core + SQLite | Cloud (self-hosted/NAS) | LLM/STT proxy and admin back-office backend for the .NET identity side |

View file

@ -114,7 +114,7 @@ See [`03-shared-packages.md`](./03-shared-packages.md). Summary:
| Package | Provides |
|---|---|
| `@d3ro/core` | Domain types, `D3ROError`/`ErrorCode`, IPC channel SSOT, constants, `crypto-license`, `pii-redactor`, `secure-memory`, `supabase-config`, `meeting-markdown`, `markdown-to-docx` |
| `@d3ro/core` | Domain types, `D3ROError`/`ErrorCode`, IPC channel SSOT, key-binding SSOT (`./keybinding`), constants, `crypto-license`, `pii-redactor`, `secure-memory`, `supabase-config`, `meeting-markdown`, `markdown-to-docx`; has vitest tests (`packages/core/vitest.config.ts`, `npm run test --workspace=@d3ro/core`) |
| `@d3ro/ui` | Theme tokens, CSS vars, MUI DS components (web/desktop) |
| `@d3ro/ui-native` | RN design system (MetalCard, PhosphorText, Led, PhysicalButton, WaveBars, …) |
| `@d3ro/i18n` | 12 locales, `I18nProvider`, `t()`, date/number/relative formatters |

View file

@ -15,7 +15,8 @@ The canonical place for types and cross-surface logic. Both desktop and web/mobi
|---|---|---|
| Types | `./types` | Domain types shared across surfaces |
| Errors | `./errors` | `D3ROError`, `ErrorCode` |
| IPC channels | `./ipc-channels` | `IPC_CHANNELS` object + `IPCChannel` union. **SSOT** for every desktop IPC channel (VOICE, AUDIO, STT, TTS, LLM, HOTKEY, CONFIG, HISTORY, DICTIONARY, WINDOW, SYSTEM, STATS, MEMO, VOICE_COMMAND, CONTEXT, CHAIN, CAPTION, FILE_TRANSCRIPTION, MEETING_SUMMARY, DICTATION_TEMPLATE, VOICE_CONVERSATION, RAG, VOICE_ACTION, MEETING_MODE, MEETING_DOC_TEMPLATE, MEETING_CHAT, LICENSE, CLOUD_SYNC, INSTRUCTION, SYSTEM_AUDIO, POPUP_RESULT, POPUP_HISTORY, POPUP_COMMAND, POPUP_CAPTION, VOICE_PARTIAL, CLIPBOARD, APP, ONLINE_AUTH, ADS, SUPPORT, PAYMENT) |
| IPC channels | `./ipc-channels` | `IPC_CHANNELS` object + `IPCChannel` union. **SSOT** for every desktop IPC channel (VOICE, AUDIO, STT, TTS, LLM, KEYBINDING, CONFIG, HISTORY, DICTIONARY, WINDOW, SYSTEM, STATS, MEMO, VOICE_COMMAND, CONTEXT, CHAIN, CAPTION, FILE_TRANSCRIPTION, MEETING_SUMMARY, DICTATION_TEMPLATE, VOICE_CONVERSATION, RAG, VOICE_ACTION, MEETING_MODE, MEETING_DOC_TEMPLATE, MEETING_CHAT, LICENSE, CLOUD_SYNC, INSTRUCTION, SYSTEM_AUDIO, POPUP_RESULT, POPUP_HISTORY, POPUP_COMMAND, POPUP_CAPTION, VOICE_PARTIAL, CLIPBOARD, APP, ONLINE_AUTH, ADS, SUPPORT, PAYMENT) |
| Key bindings | `./keybinding` | **SSOT** for every global shortcut in the app: `KeyBinding` (`device`/`code`/`ctrl`/`alt`/`shift`/`meta`), `KEY_CATALOG` (10 selectable groups incl. mouse), `KEYBINDING_ACTIONS` (6 rebindable actions), `bindingKey`/`normalizeBinding`/`validateBinding`/`detectBindingConflicts`/`formatBindingSegments`/`searchKeyCatalog`/`parseBindingMap`. Persisted as `AppConfig.keyBindings`. i18n keys are exposed as plain `string` so core stays independent of `@d3ro/i18n`; consumers narrow at the boundary (`asTranslationKey`) and a contract test guards the keys — accepted constraint, `11` §7 CONSTRAINT-I18N-01. Tests: `__tests__/keybinding*.test.ts` via `vitest.config.ts` (`npm run test --workspace=@d3ro/core`), 117 cases as of 2026-09-21 |
| Constants | `./constants` | Shared constants |
| Crypto license | `./utils/crypto-license` | Ed25519 license sign/verify (used by admin issuer + desktop verifier) |
| PII | `pii-redactor`, `secure-memory` | Redaction + secure memory helpers |

View file

@ -16,7 +16,7 @@
**Main entry** `src/main/index.ts`: sets app name/AppUserModelId, disables GPU acceleration, EPIPE/uncaught handlers, registers `d3ro-voice://` deep-link protocol (Supabase OAuth implicit + PKCE), single-instance lock, then `bootstrap()` + `setupLifecycle()`.
**Bootstrap** `src/main/bootstrap.ts`: ordered `BootstrapStep[]` — logger, config, **database (critical)**, license, create-windows (critical), tray, **ipc-handlers (critical)**, custom-instructions, voice-commands, sound-effects, auto-launch, popup-preload, hotkey, voice-mode, llm-polling, meeting-summary-wiring, meeting-mode, cloud-sync, auto-update. Wires VoiceMode events to sound + history persistence.
**Bootstrap** `src/main/bootstrap.ts`: ordered `BootstrapStep[]` — logger, config, **database (critical)**, license, create-windows (critical), tray, **ipc-handlers (critical)**, custom-instructions, voice-commands, sound-effects, auto-launch, popup-preload, key-bindings, voice-mode, stt-warmup, llm-polling, meeting-summary-wiring, meeting-mode, cloud-sync, auto-update. Wires VoiceMode events to sound + history persistence, and subscribes to `KeyBindingService` `triggered` for the `history-popup` / `command-popup` actions (`bootstrap.ts:159`) — those two were hardcoded accelerators before and are now rebindable like everything else.
---
@ -30,7 +30,7 @@ Singleton + `EventEmitter` pattern (`getXService()` accessors).
| `VoiceModeService` | Orchestrator: 9-state `RecognitionState` + 4-state `AudioState`, dual-condition flush, action queue. Events: session-started/completed/cancelled, transcription-update, audio-level, recognition/audio-state-changed, premium-llm-fallback, error |
| `AudioCaptureService` | Mic PCM16 16kHz mono (bundled SoX on Windows, node-record-lpcm16 elsewhere). Spawns hidden (`windowsHide`); a missing SoX fails with the exact fix command |
| `LocalSTTService` | faster-whisper Python sidecar manager (state machine, dual-flush, model download/cancel, background warm-up, live partial transcription). Connects over IPv4 loopback (`getSidecarBaseUrl`) and fails fast with an actionable message when the bundled engine or virtualenv is missing |
| `HotkeyService` | uiohook-napi global hooking (dictation/hands-free/command/caption). Events: hotkey-pressed/released, double-press, error |
| `KeyBindingService` | uiohook-napi global hooking for **keyboard and mouse**, driven by the `@d3ro/core/keybinding` contract: 6 rebindable actions (dictation, hands-free, command, caption, history-popup, command-popup), several bindings per action, structural reserved-combo checks. Events: `triggered` (in-process payload carries `actionId`, `type` (`pressed`/`released`), `isDoublePress`, `holdMode`, `timestamp`; the renderer-facing `keybinding:triggered` event is the narrower `KeyBindingTriggeredEvent`, `keybinding.ts:1092`), `changed`, `error`. `globalShortcut` is used only to mute the macOS system beep, and only for accelerators it registered itself. Mouse events cannot be suppressed by uiohook, so a bound button also performs its native action |
| `TextInsertService` | Clipboard save→set→Ctrl+V→restore via nut-js |
| `SoundEffectService` | Preloaded WAV feedback (start/stop/error/cancel/chime) |
@ -118,8 +118,8 @@ Registry: `src/main/ipc/index.ts` calls 29 `registerXHandlers()` in fixed order.
| `dictionary-handlers` | DICTIONARY |
| `file-transcription-handlers` | FILE_TRANSCRIPTION |
| `history-handlers` | HISTORY + `stats:getSummary` |
| `hotkey-handlers` | HOTKEY |
| `instruction-handlers` | INSTRUCTION |
| `keybinding-handlers` | KEYBINDING |
| `license-handlers` | LICENSE |
| `llm-handlers` | LLM + `llm:premium:*` + ONLINE_AUTH |
| `meeting-doc-template-handlers` | MEETING_DOC_TEMPLATE |
@ -138,7 +138,9 @@ Registry: `src/main/ipc/index.ts` calls 29 `registerXHandlers()` in fixed order.
| `voice-handlers` | VOICE |
| `window-handlers` | WINDOW + `SYSTEM.OPEN_EXTERNAL` |
Preload exposes **`window.electronAPI`** with 33 namespaces: `platform, audio, config, voice, stt, hotkey, llm (incl. premium), history, dictionary, stats, window, system, instruction, app, memo, voiceCommand, context, chain, caption, license, fileTranscription, meetingSummary, dictationTemplate, rag, voiceAction, voiceConversation, meetingMode, meetingChat, meetingDocTemplate, cloudSync, onlineAuth, ads, support, payment`. Envelope: `IPCResult<T>` (success/error); `app.onDataChanged` is the global refresh channel.
The **`KEYBINDING`** group replaced the old per-action `HOTKEY` group. `HOTKEY` had 14 channels — a get/set pair per action plus three that were never implemented — so every new action meant new channels. `KEYBINDING` is 9 channels that take the action **as a parameter**: `getMap`, `setBindings`, `resetAction`, `resetAll`, `validate`, `isEnabled`, `setEnabled`, plus the `triggered` / `changed` events (`packages/core/src/ipc-channels.ts:104`). Adding an action now costs zero channels.
Preload exposes **`window.electronAPI`** with 33 namespaces: `platform, audio, config, voice, stt, keybinding, llm (incl. premium), history, dictionary, stats, window, system, instruction, app, memo, voiceCommand, context, chain, caption, license, fileTranscription, meetingSummary, dictationTemplate, rag, voiceAction, voiceConversation, meetingMode, meetingChat, meetingDocTemplate, cloudSync, onlineAuth, ads, support, payment`. The `keybinding` bridge is 9 methods mirroring the channels above (`src/preload/index.ts:323`), replacing the 11-method `hotkey` bridge. Envelope: `IPCResult<T>` (success/error); `app.onDataChanged` is the global refresh channel.
---
@ -157,8 +159,8 @@ Vanilla popups (`src/renderer/popups/`):
|---|---|
| `recording-tip` | 9-bar waveform indicator, partial transcript |
| `result-popup` | Transcription result + copy, auto-close with hover pause |
| `history-popup` | Recent transcriptions; ↑↓/Enter/1-9/ESC |
| `command-popup` | Command selection (Ctrl+Shift+C) |
| `history-popup` | Recent transcriptions; ↑↓/Enter/1-9/ESC. Opened by the `history-popup` action (default `Ctrl+Shift+V`, rebindable) |
| `command-popup` | Command selection. Opened by the `command-popup` action (default `Ctrl+Shift+C`, rebindable) |
| `caption-overlay` | Live caption overlay (font/opacity/maxLines) |
---
@ -177,9 +179,11 @@ Routing is state-based in `AppLayout.tsx` (`Route` union + `NAV_ITEMS`), no reac
| `KnowledgeBasePage` | knowledge | Local RAG: add/index docs, semantic query, reindex/remove |
| `MeetingModePage` | meeting | Meeting studio: live transcript, memos, doc generation/export, diarization |
Modals/components: `SettingsModal` (tabs General/Audio/STT/LLM/License/Cloud/About), `LicenseModal`, `LicenseTab`, `CloudSyncSection`, `OnboardingModal`, `UpgradePromptModal`, `ProBadge`, `TemplateSection`, `FileDropZone`, `HotkeyRecordModal`, `OllamaGuideModal`, `CodexOAuthGuideModal`, `TitleBar`, `StatusBar`, meeting components (9), voice-conversation, payment (`CheckoutModal`, `checkout-flow.ts`), support (`SupportModal`), ads (`AdBanner`, `RewardedQuotaModal`), shared cards.
Modals/components: `SettingsModal` (tabs General/Audio/STT/LLM/License/Cloud/About), `LicenseModal`, `LicenseTab`, `CloudSyncSection`, `OnboardingModal`, `UpgradePromptModal`, `ProBadge`, `TemplateSection`, `FileDropZone`, `OllamaGuideModal`, `CodexOAuthGuideModal`, `TitleBar`, `StatusBar`, meeting components (9), voice-conversation, payment (`CheckoutModal`, `checkout-flow.ts`), support (`SupportModal`), ads (`AdBanner`, `RewardedQuotaModal`), shared cards.
Hooks: `useRealtimeConversation` (OpenAI Realtime WebRTC), `useLicenseState`, `useProFeature`.
Key-binding UI lives in `components/keybinding/` (`Keycap`, `KeyBindingPicker`, `KeyBindingField`, `translation-key`), embedded in the Settings **General** tab (`SettingsModal.tsx:239`) — one field per action plus a global on/off switch. The picker offers both key recording and a searchable grouped dropdown (MUI `Autocomplete` over `KEY_CATALOG`, `KeyBindingPicker.tsx:536`). It replaced `HotkeyRecordModal`. `renderer/utils/format-hotkey.ts` is now a 17-line platform adapter only; key names, modifier glyphs, and join rules come from `@d3ro/core/keybinding`.
Hooks: `useRealtimeConversation` (OpenAI Realtime WebRTC), `useLicenseState`, `useProFeature`, `useKeyBindingMap` (subscribes to `keybinding:changed`; the dashboard renders the live `dictation` binding through `BindingKeycaps`).
DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `stats`, `memo_tags`, `daily_usage`, `rag_documents`, `rag_chunks`, `meeting_sessions`, `meeting_memos`, `meeting_documents`.
@ -187,12 +191,14 @@ DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `s
## 6. Desktop status summary
- Core dictation/LLM/history pipeline: **implemented + tested** (~590 desktop tests; vitest + playwright).
- Core dictation/LLM/history pipeline: **implemented + tested**. Measured 2026-09-21: 1314 vitest cases in `apps/desktop`, 1311 passing; playwright e2e is separate. The failures are environment-dependent rather than regressions — two need a local sidecar venv or embedding server, one pins an error message that has since changed (`11` GAP-QA-02). These numbers hold with `better-sqlite3` built for the host Node ABI; rebuilding it for Electron to run the app invalidates them until you rebuild back (`11` GAP-INFRA-06).
- Cross-platform packaging: Windows NSIS (signed, `forceCodeSigning`), macOS DMG/ZIP arm64 (ad-hoc signing); auto-update via canonical Forgejo feed with update policy (`release/update-policy.json`).
- Local-first AI (SoX + faster-whisper sidecar + bundled Ollama) and cloud paths both present.
- **Local STT is packaged** (`1.3.0`): `electron-builder.yml` `extraResources` copies `sidecar-dist/sidecar` → `resources/sidecar` and `resources/ffmpeg` → `resources/ffmpeg`; `scripts/ci/verify-sidecar-bundle.mjs` gates packaging. Build locally with `npm --prefix apps/desktop run sidecar:setup && npm --prefix apps/desktop run sidecar:build`. The sidecar stays in console mode so `stdout`/`stderr` reach the app log (UTF-8, line-buffered); a packaged sidecar **must** exist or startup fails loudly instead of silently falling back to a system Python.
- All local engine URLs (`LocalSTTService`, `LocalLLMService`, `RAGService`, `OnlineLLMService`, `STTManager`) pass through `src/main/utils/loopback.ts`, which rewrites `localhost` to `127.0.0.1`, because some Windows hosts resolve `localhost` to IPv6 only and local engines bind IPv4.
- Meeting intelligence, RAG, voice conversation (local + Realtime), captions, file transcription: implemented.
- **Key bindings: implemented and verified on Windows.** Every global shortcut now comes from one contract (`@d3ro/core/keybinding`) with multiple bindings per action, mouse-button support, and no hardcoded accelerators left in `bootstrap.ts`. A manual run on 2026-09-21 confirmed legacy migration (custom values preserved), 6 actions loaded, the uiohook keyboard **and** mouse hook active with zero boot errors, and multi-binding working; contract side is `packages/core` 117 tests GREEN with no type errors in the key-binding files (`11` GAP-KEY-01 `[x]`). Two things remain open: `KeyBindingService` has no unit test of its own, and macOS/Linux mouse behavior is unconfirmed (`11` GAP-KEY-02). The rewrite also fixed a dead hands-free double-press path, an order-dependent reserved-combo check, a `globalShortcut.unregisterAll()` that wiped the popup accelerators, and a `setEnabled(true)` that re-enabled hooking with an empty binding set.
- The same pass fixed an unrelated pre-existing dashboard bug: `caption.onStateChanged` delivers `{ state }`, but `DashboardPage` passed the whole object into `setCaptionState`, so the caption status readout never showed the right value (`DashboardPage.tsx:148`).
- **Ad mediation**: `DirectHouseSponsorAdapter` performs real configurable REST bids; the other 9 adapters remain fail-closed stubs pending official SDKs (see `11-gap-backlog.md` GAP-ADS-01/02).
- Tier resolution now routes through `@d3ro/core/entitlement` (`resolveEntitlement`, `normalizeEntitlementTier`); `useLicenseState.isPro` includes `pro_plus`.
- No `TODO`/`FIXME` markers found in `src` (grep clean). `src/main/types/` is an empty directory.
@ -207,6 +213,8 @@ DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `s
| Bootstrap order | `src/main/bootstrap.ts` |
| IPC registry | `src/main/ipc/index.ts` |
| IPC channel SSOT | `packages/core/src/ipc-channels.ts` |
| Key-binding contract SSOT | `packages/core/src/keybinding.ts` (catalog, actions, validation, conflicts, formatting, parsing) |
| Key-binding service / IPC / UI | `src/main/services/KeyBindingService.ts`, `src/main/ipc/keybinding-handlers.ts`, `src/renderer/components/keybinding/` |
| Preload API | `src/preload/index.ts` |
| Windows | `src/main/windows/WindowManager.ts` |
| Voice orchestrator | `src/main/services/VoiceModeService.ts` |

View file

@ -14,8 +14,8 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
| ID | Feature | D | W | M | B | Anchors / notes |
|---|---|---|---|---|---|---|
| CAP-01 | Push-to-talk dictation (hold/release) | [x] | [-] | [x] | [-] | Desktop `VoiceModeService`; mobile RecordScreen via app CTA/notification action (no global hotkey) |
| CAP-02 | Hands-free toggle dictation | [x] | [-] | [x] | [-] | Desktop double-press; mobile toggle |
| CAP-01 | Push-to-talk dictation (hold/release) | [x] | [-] | [x] | [-] | Desktop `VoiceModeService`; the trigger is now the rebindable `dictation` action of CAP-16 (several bindings per action, keyboard or mouse) rather than a single stored shortcut. The pipeline itself is unchanged and tested; the rewritten entry layer was confirmed in the 2026-09-21 manual run (CAP-16). Mobile RecordScreen via app CTA/notification action (no global hotkey) |
| CAP-02 | Hands-free toggle dictation | [x] | [-] | [x] | [-] | Desktop double-press shares the `dictation` binding and is split by the action's `doublePress` flag (`KeyBindingService.ts:680`). This path was **dead in shipped builds**: the previous lookup returned only the first matching action, so with both actions on the same binding double-press never reached hands-free. Fixed and confirmed in the 2026-09-21 manual run (CAP-16); core tests cover the contract side (same binding is not a conflict, `keybinding.test.ts:499`/`:734`). `KeyBindingService` still has no unit test of its own (GAP-KEY-01 evidence). Mobile toggle |
| CAP-03 | Live partial transcript while recording | [x] | [ ] | [ ] | [-] | Desktop `voice:partialTranscript` + recording-tip; producer added in `1.3.0` (`VoiceModeService._runPartial` → `LocalSTTService.transcribePartial`, 1.5 s cadence / 7.5 s window, never inserted). The row was `[x]` before any producer existed. |
| CAP-04 | Recording waveform + level meter | [x] | [x] | [x] | [-] | Desktop 9-bar cos distribution; mobile audio level; the recording-tip popup bundle and its on-disk assets are verified by `scripts/ci/verify-desktop-renderer-bundles.mjs` |
| CAP-05 | Device/mic selection | [x] | [ ] | [~] | [-] | Desktop config; mobile uses system default |
@ -29,6 +29,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
| CAP-13 | Live captions overlay | [x] | [-] | [-] | [-] | Desktop `CaptionService` + caption-overlay popup; popup assets verified in the packaged build (`scripts/ci/verify-desktop-renderer-bundles.mjs`); GAP-INFRA-05 |
| CAP-14 | Recording persistence / crash recovery | [x] | [ ] | [x] | [-] | Desktop WAV persist; mobile durable queue + process-kill WAV recovery |
| CAP-15 | Android foreground recording service | [-] | [-] | [x] | [-] | Mobile API 34 FGS + persistent notification (SSOT R-005 GREEN) |
| CAP-16 | Rebindable global key bindings (keyboard + mouse) | [x] | [-] | [-] | [-] | Contract SSOT `packages/core/src/keybinding.ts`: `KEY_CATALOG` (10 groups, `:615`), `KEYBINDING_ACTIONS` (6 actions, `:719`), `validateBinding` (`:953`), `detectBindingConflicts` (`:1016`). Multiple bindings per action persist as one `AppConfig.keyBindings` map (`packages/core/src/types.ts:459`), replacing the four singular `*Shortcut` fields; `ConfigService` migrates legacy values once (`ConfigService.ts:142`). `KeyBindingService` hooks keyboard **and** mouse via uiohook (`KeyBindingService.ts:387`) — MB1 is not bindable, MB2/MB3 need a modifier, MB4/MB5 are free, and no mouse button can be suppressed, so the original click still fires (warning surfaced in the UI). Selection is either key-recording or a searchable grouped dropdown (`KeyBindingPicker.tsx:536`). `history-popup`/`command-popup` were hardcoded in `bootstrap.ts` and are now rebindable actions (`bootstrap.ts:159`). **Verified 2026-09-21 on Windows by a manual run** (`%APPDATA%/d3ro-voice/logs/main.log`, 12:53–13:06): `ConfigService` migrated the four legacy shortcuts with the user's non-default values preserved exactly, `KeyBindingService` loaded 6 bindings for 6 actions and started the uiohook keyboard **and** mouse hook with zero boot errors, and keyboard plus mouse (MB4/MB5) bindings were exercised through the UI. A `Loaded 7 key binding(s) … for 6 action(s)` line later in the same session shows multi-binding working end to end. The migrated map was read back from `d3ro-voice-config.json`: legacy `*Shortcut` fields gone, no `displayLabel` left. Contract evidence: `packages/core` 117 tests GREEN, no renderer type errors in the key-binding files. **Still open:** `KeyBindingService` has no unit test of its own, macOS/Linux mouse behavior is unconfirmed (`11` GAP-KEY-02), and `command` still falls back to the dictation pipeline (GAP-KEY-03). W/M `[-]`: no OS-level global binding surface exists there (browser sandbox; mobile has no global hotkey, see CAP-01). B `[-]`: device-local setting, nothing server-side. See `11` GAP-KEY-02/03 (open), GAP-KEY-01 (`[x]`), and `11` §7 CONSTRAINT-I18N-01. |
---
@ -158,9 +159,9 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
| ID | Feature | D | W | M | B | Anchors / notes |
|---|---|---|---|---|---|---|
| SHELL-01 | Settings / preferences | [x] | [~] | [x] | [x] | Desktop tabbed modal; web theme/i18n; mobile `SettingsScreen` |
| SHELL-01 | Settings / preferences | [x] | [~] | [x] | [x] | Desktop tabbed modal; the General tab hosts the whole key-binding editor (CAP-16: global on/off switch + one `KeyBindingField` per action, grouped voice/window — `SettingsModal.tsx:239`), which is also the first settings entry point the `command` action ever had; web theme/i18n; mobile `SettingsScreen` |
| SHELL-02 | Theme system (6 themes) | [x] | [x] | [x] | [-] | `theme.ts` SSOT |
| SHELL-03 | i18n (12 locales) | [x] | [x] | [x] | [-] | `@d3ro/i18n`; ko/en fully translated, others partial |
| SHELL-03 | i18n (12 locales) | [x] | [x] | [x] | [-] | `@d3ro/i18n`; ko/en fully translated, others partial. Measured 2026-09-21: `ko` 1716 keys / `en` 1709 / the other ten 327 each, so ~1,380 keys fall back for non-English locales — tracked as `11` GAP-I18N-01 |
| SHELL-04 | Onboarding / first-run | [x] | [ ] | [x] | [-] | Desktop model bootstrap; mobile audience/theme/locale |
| SHELL-05 | Accessibility / reduced motion | [~] | [~] | [~] | [-] | Desktop reduced-motion honored; mobile a11y rows pending |
| SHELL-06 | System tray / background | [x] | [-] | [-] | [-] | Desktop tray |
@ -203,7 +204,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
| Surface | `[x]` | `[~]` | `[ ]` | Notable strength | Notable weakness |
|---|---|---|---|---|---|
| Desktop | ~40 | 3 | ~8 | Local AI pipeline, meetings, RAG, conversation, hotkeys | Ads stubs, no team admin, no email account |
| Desktop | ~40 | 3 | ~8 | Local AI pipeline, meetings, RAG, conversation, key bindings | Ads stubs, no team admin, no email account |
| Web | ~22 | 6 | ~14 | Server-shared data UX, billing, meetings, teams | No local AI, limited knowledge upload/search |
| Mobile | ~40 | 12 | ~18 | Cloud + native recording, portability, admin, IAP/ads | External store/console gates, a11y, deep E2E pending |
| Backend | ~45 | 6 | ~4 | RLS, Edge functions, billing, fail-closed AI | Payple webhook signature, some external provider keys |

View file

@ -13,6 +13,7 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
- An item here is **not** a failure. It is a known state with an owner and a next step.
- When you close an item, flip it to `[x]`, add the date + evidence path, and also update `10-feature-catalog.md`.
- Grandfathered detail lives in `docs/v3/MOBILE_APP_COMPLETION_SSOT.md`; this file is the cross-surface roll-up. When the two disagree, the SSOT wins for mobile and must be reconciled here.
- **Not everything imperfect is a gap.** Trade-offs that were reviewed and deliberately kept live in §7 as constraints, not in §1. Check §7 before opening a row for one.
---
@ -58,6 +59,13 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
| GAP-REL-04 | Release | canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 **100MiB**를 넘으면 HTTP 413으로 거부한다. | `scripts/ci/publish-updater-release.mjs`, `docs/deployment/unsigned-distribution.md` | `[~]` 2026-09-18: 설치본을 90.6MiB로 줄여 업데이트 피드 게시를 복구했다(GAP-STT-07). 휴대용/Scoop 채널은 여전히 95MiB 분할이 필요하다. |
| GAP-STT-07 | Local STT | 진(사이드카)을 앱 번들에 넣으면 설치본이 100MiB를 넘고 매 업데이트마다 162MiB를 다시 받는다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts`, `apps/desktop/electron-builder.yml` | `[x]` 2026-09-18: 설치본에서 엔진/ffmpeg를 제거하고 처음 필요할 때 `runtime-latest`에서 내려받는다(부품별 + 결합본 SHA-256 검증). 설치본 189MB → 90.6MiB, 런타임 1회 116MiB(엔진 94.4 + ffmpeg 21.7). 실제 feed로 통합 검증 완료. |
| GAP-STT-06 | Local STT | Decode settings were untuned: previous-text conditioning let repeated hallucinations compound, and no VAD parameters meant slow, uneven segments. | `apps/desktop/sidecar/main.py` | `[x]` 2026-09-18: `condition_on_previous_text=false`, bounded low-temperature fallback, `no_speech`/`compression_ratio`/`log_prob` thresholds, 300 ms silence trimming. Same transcript, ~5x faster on the reference machine (7.7 s audio: 1609 ms → 303 ms). |
| GAP-KEY-01 | Key bindings | 키바인딩 전면 개편(CAP-16)이 실앱 구동으로 검증되지 않은 상태였다. uiohook 전역 후킹, 더블프레스 타이밍, 마우스 버튼 수신을 실행 중인 Electron 에서 확인한 적이 없었고, 증거가 계약 수준(`packages/core` 117개 통과, 키바인딩 파일 타입 에러 0건)뿐이었다. 에이전트는 데스크톱 GUI 를 띄울 수 없다(`AGENTS.md` §3). | `apps/desktop/src/main/services/KeyBindingService.ts`, `packages/core/__tests__/keybinding.test.ts`, `%APPDATA%/d3ro-voice/logs/main.log`, `%APPDATA%/d3ro-voice/d3ro-voice-config.json` | `[x]` 2026-09-21: 사용자가 Windows 에서 앱을 띄워 검증 완료. 로그(12:53–13:06)에 `Migrated 4 legacy shortcut(s) to keyBindings` → `Loaded 6 key binding(s) from config for 6 action(s)` → `uiohook started, global keyboard/mouse hook active` → `key-bindings initialized` → `Key binding events connected` 가 순서대로 남았고 부팅 에러 0건, STT 모델 로딩까지 정상. 키보드·마우스(MB4/MB5) 바인딩을 UI 로 실제 조작해 동작을 확인했고, 같은 세션의 `Loaded 7 key binding(s) … for 6 action(s)` 가 다중 바인딩이 실동작함을 보인다. 설정 파일 되읽기로 마이그레이션 결과를 확인 — 사용자 커스텀 값이 그대로 보존됐고(dictation `code:49,alt` / hands-free `code:49,alt+shift` / command `code:165,ctrl` / caption `code:49,ctrl+alt+shift`, 팝업 2종은 신규 기본값), 구 `*Shortcut` 4개와 `displayLabel` 은 모두 사라졌다. **남은 것**: `KeyBindingService` 자체의 유닛 테스트는 여전히 없다(실행 검증이 유닛 테스트를 대체하지 않는다). macOS/Linux 는 GAP-KEY-02 로 계속 열려 있다. |
| GAP-KEY-02 | Key bindings | 마우스 버튼 지원이 **Windows 기준으로만** 설계·확인됐다. `KeyBindingService` 에는 마우스 관련 플랫폼 분기가 없고(`process.platform` 은 meta 수정자 라벨 표기에만 쓰인다), macOS/Linux 에서 uiohook 이 보고하는 X1/X2 버튼 번호와 OS 기본 "뒤로/앞으로" 동작과의 간섭은 확인하지 않았다. 마우스 이벤트는 suppress 가 불가능하므로 원래 동작이 항상 함께 실행된다. | `KeyBindingService.ts:235`(`readMouseButton`), `:301`(meta 라벨 분기), `packages/core/src/keybinding.ts:561-612`(마우스 카탈로그 5종). 2026-09-21 실앱 검증(GAP-KEY-01)은 **Windows 에서만** 이뤄졌고 거기서는 MB4/MB5 가 정상 동작했다. | macOS/Linux 에서 MB2~MB5 수신 여부와 버튼 번호 매핑을 확인하고, 다르면 카탈로그를 플랫폼별로 분기한다. |
| GAP-KEY-03 | Key bindings | `command` 액션에 전용 핸들러가 없다. 이번에 처음으로 설정 UI 에 노출됐지만, 트리거되면 dictation 파이프라인으로 fallback 하며 `KEYBINDING_ACTIONS` 의 `holdMode:false` 대신 dictation 과 같은 hold-to-talk 로 강제된다. 개편 이전부터 같은 동작이었고 이번 작업은 그 사실을 코드에 명시화만 했다(기능 변화 없음). | `apps/desktop/src/main/services/VoiceModeService.ts:1071`(`_resolveHoldMode`), `packages/core/src/keybinding.ts:740`(액션 정의) | `command` 전용 동작을 정의하고 `_resolveHoldMode` 의 예외를 제거하거나, 액션을 카탈로그에서 뺀다. |
| GAP-QA-02 | Quality | 캡션 테스트 2건이 **개발 머신에 사이드카 venv 가 있는지에 따라 결과가 갈린다**. `LocalSTTService.initialize()`(`:239`) → `_ensureSidecarRunning()`(`:583`) → `_spawnSidecar()`(`:650`) → `_waitForHealth()`(`:794`) 경로에서 venv 가 존재하면 실제 Python 프로세스를 띄우고 health 폴링이 vitest 기본 타임아웃 10초를 넘긴다. venv 가 없으면 `getSidecarCommand()`(`apps/desktop/src/main/utils/paths.ts:174`)가 즉시 throw 해서 같은 테스트가 빠르게 통과한다. 테스트가 로컬 환경을 격리하지 못한 것이 결함이다. | `tests/red/ipc-surfaces.usecase.test.ts`(`캡션 시작 실패는 success:false 로 나온다`), `tests/red/silent-errors.usecase.test.ts:48`. **키바인딩 개편의 회귀가 아니다** — 2026-09-21 에 HEAD(`0ca9e24`) 무수정 코드를 같은 환경(venv 연결)에서 돌려 동일하게 재현했다. 같은 날 같은 머신에서도 실행 방식에 따라 결과가 갈렸다: 전체 실행은 `3 failed / 1311 passed (1314)`(`rag.usecase` + `silent-errors` 캡션 + `paths.test`)이고 `ipc-surfaces` 캡션 케이스는 통과했는데, 그 파일만 단독 실행하면 같은 케이스가 10초 타임아웃으로 실패한다. 테스트 총수 1314 는 어느 실행에서나 같고, 새로 깨진 테스트는 0건이다. | 사이드카 기동을 테스트 경계에서 주입·모킹해 환경 의존을 끊는다. 함께 실패하는 `rag.usecase`(임베딩 서버 부재)도 같은 성격이다. `tests/main/utils/paths.test.ts:78` 은 성격이 다르다 — 기대 정규식이 `사이드카를 찾을 수 없습니다` 인데 실제 메시지는 `로컬 음성 엔진이 아직 설치되지 않았습니다…` 로 바뀌어 테스트가 문구를 따라가지 못한 것이다. |
| GAP-I18N-01 | i18n | 로케일별 키 수가 크게 어긋난다. 2026-09-21 실측: `ko` 1716 / `en` 1709 / 나머지 10개 로케일 각 327. `keybinding.*` 55개는 12개 로케일 전부에 동일하게 들어갔지만, 그 밖 약 1,380개 키가 비영어 로케일에 없어 폴백 체인(locale → `en` → `ko`)으로 표시된다. 키바인딩 작업 이전부터 있던 부채이며 그 작업 범위 밖이었다. | `packages/i18n/src/locales/*.json`, 카탈로그 SHELL-03 | 로케일 간 키 diff 를 내는 커버리지 게이트를 만들어 회귀를 막고, 누락 키를 채운다. |
| GAP-I18N-02 | i18n | 렌더러가 `ko.json` 에 없는 `license.*` 키를 쓴다. `TranslationKey` 가 `ko.json` 에서 파생되므로 누락은 타입 에러로 드러난다. 타입 에러로만 끝나지 않는다 — 폴백 체인이 `locale → en → ko → 키 문자열` 이므로 마스터 로케일에도 없으면 **`license.team` 같은 키가 화면에 그대로 노출된다**. 2026-09-21 실측: `license.feature.premium_llm`·`license.team`·`license.enterprise` 가 없고 이로 인한 TS2345 가 4건이다. HEAD 에서도 없던 키이므로 선재 결함이며 키바인딩 작업과 무관하다. | `apps/desktop/src/renderer/components/UpgradePromptModal.tsx:47`·`:192`, `apps/desktop/src/renderer/pages/DashboardPage.tsx:481`·`:529`, `packages/i18n/src/locales/ko.json` | 세 키를 `ko.json` 에 추가하고 12개 로케일에 반영한다. 같은 타입체크에 잡히는 `LicenseTab.tsx`(6건)·`LicenseModal.tsx`(2건)는 원인이 다르다 — `TFunction` 을 `(k: string) => string` 에 넘기는 TS2322 4건과 `currentTier` 미정의 TS2304 2건으로, 후자는 컴파일이 깨지는 별개 결함이다(GAP-INFRA-04 범위). |
| GAP-INFRA-06 | Dev env | `better-sqlite3` 네이티브 ABI 가 **앱 실행과 로컬 테스트에서 서로 다른 값을 요구**한다. Electron 33 은 ABI 130, 호스트 Node 23 은 ABI 131 이라 한쪽에 맞추면 다른 쪽이 깨진다. 2026-09-21 실측: `electron-rebuild -f -w better-sqlite3` 직후 vitest 가 `366 failed / 948 passed` 로 무너졌고, 리빌드 전에는 `1311 passed` 였다. 같은 날 확인한 현재 워크스페이스는 Node ABI 쪽(호스트 `node -e "require('better-sqlite3')"` 성공)이라 테스트는 돌고 앱 실행에는 재리빌드가 필요하다. **배포 차단 이슈가 아니다** — `node_modules/` 는 gitignore(`.gitignore:1`)이고 패키징 경로는 `scripts/ci/verify-native-abi.mjs` 가 이미 막는다(GAP-REL-07 `[x]`). 순수하게 로컬 개발 환경 전환 비용 문제다. | `scripts/ci/verify-native-abi.mjs`, `scripts/ci/fix-native-abi.mjs`, `package.json`(현재 리빌드용 스크립트 없음) | 두 ABI 를 오가는 npm 스크립트를 둔다(예: `rebuild:app` = Electron ABI, `rebuild:test` = Node ABI). 지금은 전환 방법이 문서화도 스크립트화도 되어 있지 않아 매번 수동으로 알아내야 한다. |
| GAP-STT-08 | Local STT | 1.3.5 설치본에서 엔진 설치가 "런타임 아카이브 해시 불일치 (sidecar)"로 항상 실패했다. 부품 검증은 **메모리 스트림**에서 센 값으로, 결합 검증은 **디스크 파일**에서 계산해 기준이 서로 달랐다. 디스크 쓰기가 잘려도 부품 검사를 통과하고 결합 단계에서만 터지므로 원인 파악도 불가능했다. 재시도가 없어 전송이 한 번 끊기면 곧바로 설치 실패였다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts` | `[x]` 2026-09-18: 부품 크기·해시를 디스크 파일 기준으로 통일하고, 결합본은 크기를 먼저 검사한 뒤 해시를 본다(오류 메시지에 실제/기대값 포함). 부품 다운로드는 실패 시 해당 파일을 지우고 최대 3회 재시도한다. 서버 아티팩트는 무결함을 확인했고(부품 2개 해시 일치, 결합본 `e203aa53…` = 인덱스 기대값), 실제 feed로 설치를 재현해 18초 만에 성공. **1.3.6으로 게시 완료** — `latest.yml`이 1.3.6/90.6MiB를 서빙하고 설치본 sha512가 피드 메타데이터와 일치. 설치본 asar에 수정 코드가 포함되고 구버전 `archiveHash` 경로는 제거됨을 확인. |
| GAP-INFRA-05 | Build | 패키징된 렌더러 팝업 스크립트가 번들에 없었다. 팝업 HTML이 classic `<script src="./script.js">`를 참조해 Vite가 처리하지 않았고, dev에서는 로드되지만 설치본에는 파일이 없었다. 그래서 녹음 오버레이가 0:00에서 멈추고 웨이브 바가 뜨지 않았으며 실시간 자막이 렌더되지 않았다. 로드 전 `webContents.send`가 조용히 버려지는 문제와 `hide()` 이후 재표시의 z-order/repaint 유실도 함께 있었다. | `apps/desktop/src/renderer/popups/*/index.html`, `apps/desktop/src/main/windows/WindowManager.ts`, `scripts/ci/verify-desktop-renderer-bundles.mjs` | `[x]` 2026-09-19: 팝업 5종을 `type="module"`로 전환해 Vite가 해시된 번들로 방출하도록 고쳤고, 빌드 HTML이 참조하는 모든 로컬 asset이 디스크에 있는지 검사하는 `verify-desktop-renderer-bundles.mjs`(+ self-test)를 `.forgejo`/`.github` 패키징 파이프라인에 연결했다. WindowManager는 렌더러 준비 전 IPC를 `did-finish-load`까지 보관하고, 팝업을 표시할 때마다 topmost 재선언 + 강제 repaint를 수행하며, 팝업 렌더러 콘솔/로드 실패를 main 로그로 승격한다. |
@ -119,6 +127,11 @@ These are the mobile SSOT rows still `[ ]` / `[~]`. Do not duplicate the full te
- **Desktop local dictation / LLM / history / meetings / RAG / conversation:** local
dictation works in dev **and** in packaged builds as of `1.3.0` (engine bundled, paths
fixed, IPv4 loopback). Ads: one real adapter, rest stubs.
- **Desktop key bindings (CAP-16):** rewritten onto one SSOT with multiple bindings per
action and mouse-button support; **verified on Windows** by a manual run on 2026-09-21
(legacy migration, 6 actions loaded, keyboard + mouse hook live, multi-binding exercised
— GAP-KEY-01 `[x]`). macOS/Linux mouse behavior is still unconfirmed (GAP-KEY-02) and
the service has no unit test of its own.
- **Web console:** yes, feature-complete for server-shared data; knowledge upload/search and team feed implemented.
- **Mobile:** code complete for most flows and tested locally; blocked mainly by external store/console gates, plus a11y and some E2E depth.
- **Backend:** fail-closed AI proxies, RLS, billing, ads SSV implemented; push transports (FCM + webpush + APNs) and cron drain implemented.
@ -155,3 +168,14 @@ Actionable checklist for the work started this session. Fields to fill are blank
**Docs**
- [ ] Keep `docs/deployment/push-transport-without-firebase.md` and this file in sync when transports or clients change.
---
## 7. 알려진 설계 제약 (수용됨 — 결함 아님)
여기 있는 항목은 고쳐야 할 갭이 아니라 **대안을 검토한 뒤 의도적으로 유지하기로 한 절충**이다.
§1 에 갭으로 재등록하지 마라.
| ID | 제약 | 왜 이대로 두는가 | 완화 장치 |
|---|---|---|---|
| CONSTRAINT-I18N-01 | `packages/core/src/keybinding.ts` 는 i18n 키를 평범한 `string` 으로 노출한다. 렌더러가 `asTranslationKey()`(`apps/desktop/src/renderer/components/keybinding/translation-key.ts:7`)로 경계에서 캐스팅하므로, 존재하지 않는 키를 넘겨도 컴파일러가 잡지 못한다. | core 가 로케일 패키지에 의존하지 않게 하려는 의도적 설계다. 검토한 대안 둘 다 성립하지 않는다 — (A) 키 필드를 리터럴 유니온으로 좁히는 방식은 `KEY_CATALOG` 가 `letterEntries()` 같은 함수 생성부를 포함해 불가능하고, (B) core 가 `@d3ro/i18n` 의 타입 가드를 쓰는 방식은 의존 방향을 core → i18n 으로 역전시켜 `03-shared-packages.md` §6 의 전제를 깬다. 2026-09-21 결정: 현행 유지. | `packages/core/__tests__/keybinding-i18n.test.ts` (14 케이스). core 가 참조하는 키가 12개 로케일 전부에 있는지, 값이 빈 문자열이 아닌지, core 가 렌더러 전용 `keybinding.ui.*` 를 참조하지 않는지 검사한다. 거부 사유 키는 하드코딩 목록이 아니라 실제 `validateBinding` 경로를 태워 수집하므로 새 사유가 생기면 자동으로 커버된다. |

View file

@ -0,0 +1,310 @@
// packages/core/__tests__/keybinding-i18n.test.ts
// 키바인딩 SSOT 가 참조하는 i18n 키가 실제 로케일 JSON 에 존재하는지 잠근다.
//
// keybinding.ts 는 i18n 키를 평범한 string 으로 노출한다 — 코어가 로케일 패키지에 의존하지
// 않게 하려는 의도다. 그래서 렌더러는 `as TranslationKey` 로 좁혀 쓰고, 캐스팅이라
// 존재하지 않는 키를 넘겨도 컴파일이 잡지 못한다. 런타임에 키 문자열이 그대로 화면에 노출된다.
//
// 그 구멍을 여기서 막는다. 소스에 i18n 의존을 들이지 않기 위해 JSON 을 직접 읽으며,
// 이 파일에서만 그렇게 한다.
//
// 검증 대상은 **core 가 참조하는 키만**이다. 렌더러 전용 `keybinding.ui.*` 는 대상이 아니다.
import { readFileSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, it, expect } from 'vitest'
import {
KEYBINDING_ACTIONS,
KEY_CATALOG,
KEY_CATALOG_GROUP_LABEL_KEYS,
validateBinding
} from '../src/keybinding'
import type { BindingDevice, BindingRejectReason, KeyBinding } from '../src/keybinding'
// ------------------------------------------------------------
// 로케일 JSON 접근
// ------------------------------------------------------------
const I18N_SRC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'i18n', 'src')
const LOCALES_DIR = join(I18N_SRC_DIR, 'locales')
/** 마스터 로케일. `TranslationKey = keyof typeof ko` 이므로 ko 가 키 집합의 정본이다. */
const MASTER_LOCALE = 'ko'
/** 렌더러 전용 키 접두사 — core 는 이걸 참조하지 않아야 한다. */
const RENDERER_ONLY_PREFIX = 'keybinding.ui.'
function localeNames(): string[] {
return readdirSync(LOCALES_DIR)
.filter((file) => file.endsWith('.json'))
.map((file) => file.slice(0, -'.json'.length))
.sort()
}
function readLocale(name: string): Record<string, string> {
const parsed: unknown = JSON.parse(readFileSync(join(LOCALES_DIR, `${name}.json`), 'utf8'))
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`${name}.json 의 최상위가 평면 객체가 아니다`)
}
const table: Record<string, string> = {}
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== 'string') {
throw new Error(`${name}.json 의 "${key}" 값이 문자열이 아니다`)
}
table[key] = value
}
return table
}
// ------------------------------------------------------------
// core 가 참조하는 i18n 키 수집
// ------------------------------------------------------------
/** 카탈로그 · 그룹 · 액션 상수가 선언적으로 들고 있는 키. */
function declaredI18nKeys(): Set<string> {
const keys = new Set<string>()
for (const entry of KEY_CATALOG) {
if (entry.labelKey !== undefined) keys.add(entry.labelKey)
if (entry.disabledReasonKey !== undefined) keys.add(entry.disabledReasonKey)
if (entry.passthroughWarningKey !== undefined) keys.add(entry.passthroughWarningKey)
}
for (const groupLabelKey of Object.values(KEY_CATALOG_GROUP_LABEL_KEYS)) {
keys.add(groupLabelKey)
}
for (const action of KEYBINDING_ACTIONS) {
keys.add(action.labelKey)
keys.add(action.descriptionKey)
}
return keys
}
type ModFlags = Partial<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>>
/** 거부·경고 경로를 폭넓게 태우기 위한 수정자 조합. */
const MOD_COMBOS: readonly ModFlags[] = [
{},
{ ctrl: true },
{ alt: true },
{ shift: true },
{ meta: true },
{ ctrl: true, alt: true },
{ ctrl: true, shift: true },
{ ctrl: true, alt: true, shift: true, meta: true }
]
/** 어느 카탈로그 그룹에도 없는 코드 — unknown-key 경로용 */
const UNKNOWN_VK = 0x99
const UNKNOWN_MOUSE = 9
function probe(device: BindingDevice, code: number, mods: ModFlags): KeyBinding {
return {
device,
code,
ctrl: mods.ctrl ?? false,
alt: mods.alt ?? false,
shift: mods.shift ?? false,
meta: mods.meta ?? false
}
}
interface ValidationSweep {
reasonKeys: Set<string>
warningKeys: Set<string>
reasons: Set<BindingRejectReason>
}
/**
* 카탈로그 전 엔트리 × 수정자 조합 + 카탈로그 밖 코드를 실제로 validateBinding 에 태워
* 반환된 i18n 키를 수집한다.
*
* 하드코딩 목록을 쓰지 않는 이유: 거부 사유나 카탈로그 항목이 늘어나면 그 키가 자동으로
* 수집 대상에 들어와야 한다.
*/
function sweepValidation(): ValidationSweep {
const sweep: ValidationSweep = {
reasonKeys: new Set<string>(),
warningKeys: new Set<string>(),
reasons: new Set<BindingRejectReason>()
}
const probes: KeyBinding[] = []
for (const entry of KEY_CATALOG) {
for (const mods of MOD_COMBOS) {
probes.push(probe(entry.device, entry.code, mods))
}
}
for (const mods of MOD_COMBOS) {
probes.push(probe('keyboard', UNKNOWN_VK, mods))
probes.push(probe('mouse', UNKNOWN_MOUSE, mods))
}
for (const binding of probes) {
const result = validateBinding(binding)
if (result.reasonKey !== null) sweep.reasonKeys.add(result.reasonKey)
if (result.warningKey !== null) sweep.warningKeys.add(result.warningKey)
if (result.reason !== null) sweep.reasons.add(result.reason)
}
return sweep
}
/** core 가 참조하는 i18n 키 전부 (선언 + 런타임 반환값). */
function allCoreI18nKeys(): string[] {
const sweep = sweepValidation()
const keys = new Set<string>([
...declaredI18nKeys(),
...sweep.reasonKeys,
...sweep.warningKeys
])
return [...keys].sort()
}
// ------------------------------------------------------------
// 수집기 자체가 비어있지 않은지 (이 아래 검증이 공회전하지 않게)
// ------------------------------------------------------------
describe('i18n 키 수집기', () => {
it('선언된 키를 실제로 모은다', () => {
const declared = declaredI18nKeys()
expect(declared.size).toBeGreaterThan(0)
for (const action of KEYBINDING_ACTIONS) {
expect(declared.has(action.labelKey), action.id).toBe(true)
expect(declared.has(action.descriptionKey), action.id).toBe(true)
}
for (const groupLabelKey of Object.values(KEY_CATALOG_GROUP_LABEL_KEYS)) {
expect(declared.has(groupLabelKey), groupLabelKey).toBe(true)
}
})
it('검증 스윕이 모든 거부 사유 경로를 실제로 태운다', () => {
const sweep = sweepValidation()
const reasons: BindingRejectReason[] = [
'device-disabled',
'modifier-required',
'system-reserved',
'unknown-key'
]
expect([...sweep.reasons].sort()).toEqual(reasons)
expect(sweep.reasonKeys.size).toBe(reasons.length)
expect(sweep.warningKeys.size).toBeGreaterThan(0)
})
it('수집한 키가 전부 keybinding 네임스페이스에 있다', () => {
const keys = allCoreI18nKeys()
expect(keys.length).toBeGreaterThan(0)
for (const key of keys) {
expect(key.startsWith('keybinding.'), key).toBe(true)
}
})
it('수집한 키 수가 계약 상수 규모에 못 미치지 않는다', () => {
// 액션당 라벨·설명 2개 + 그룹 라벨 전부는 최소한 들어와야 한다.
const floor =
KEYBINDING_ACTIONS.length * 2 + Object.keys(KEY_CATALOG_GROUP_LABEL_KEYS).length
expect(allCoreI18nKeys().length).toBeGreaterThanOrEqual(floor)
})
it('core 는 렌더러 전용 keybinding.ui.* 를 참조하지 않는다', () => {
const rendererKeys = allCoreI18nKeys().filter((key) =>
key.startsWith(RENDERER_ONLY_PREFIX)
)
expect(rendererKeys).toEqual([])
})
})
// ------------------------------------------------------------
// 마스터 로케일 (ko)
// ------------------------------------------------------------
describe('키바인딩 i18n 키 — 마스터 로케일 ko', () => {
it('카탈로그·그룹·액션이 선언한 키가 전부 ko.json 에 있다', () => {
const table = readLocale(MASTER_LOCALE)
const missing = [...declaredI18nKeys()].sort().filter((key) => !(key in table))
expect(missing).toEqual([])
})
it('validateBinding 이 실제로 반환하는 reasonKey 가 전부 ko.json 에 있다', () => {
const table = readLocale(MASTER_LOCALE)
const missing = [...sweepValidation().reasonKeys].sort().filter((key) => !(key in table))
expect(missing).toEqual([])
})
it('validateBinding 이 실제로 반환하는 warningKey 가 전부 ko.json 에 있다', () => {
const table = readLocale(MASTER_LOCALE)
const missing = [...sweepValidation().warningKeys].sort().filter((key) => !(key in table))
expect(missing).toEqual([])
})
it('ko.json 의 해당 값이 빈 문자열이 아니다', () => {
const table = readLocale(MASTER_LOCALE)
const blank = allCoreI18nKeys().filter((key) => {
const value = table[key]
return value !== undefined && value.trim() === ''
})
expect(blank).toEqual([])
})
})
// ------------------------------------------------------------
// 전체 로케일
// ------------------------------------------------------------
describe('키바인딩 i18n 키 — 전체 로케일', () => {
it('마스터 로케일 ko 를 포함해 로케일이 복수로 존재한다', () => {
const names = localeNames()
expect(names).toContain(MASTER_LOCALE)
expect(names.length).toBeGreaterThan(1)
})
it('로케일 디렉터리의 파일이 전부 index.tsx 에 등록되어 있다', () => {
const source = readFileSync(join(I18N_SRC_DIR, 'index.tsx'), 'utf8')
const unregistered = localeNames().filter(
(name) => !source.includes(`./locales/${name}.json`)
)
expect(unregistered).toEqual([])
})
it('모든 로케일이 core 참조 키를 빠짐없이 갖는다', () => {
const keys = allCoreI18nKeys()
const missing: string[] = []
for (const name of localeNames()) {
const table = readLocale(name)
for (const key of keys) {
if (!(key in table)) missing.push(`${name}: ${key}`)
}
}
expect(missing).toEqual([])
})
it('모든 로케일이 ko 와 동일한 core 참조 키 집합을 갖는다', () => {
const keys = allCoreI18nKeys()
const master = readLocale(MASTER_LOCALE)
const masterSubset = keys.filter((key) => key in master)
const mismatched: string[] = []
for (const name of localeNames()) {
const table = readLocale(name)
const subset = keys.filter((key) => key in table)
if (subset.join('\n') !== masterSubset.join('\n')) {
const missing = masterSubset.filter((key) => !subset.includes(key))
const extra = subset.filter((key) => !masterSubset.includes(key))
mismatched.push(
`${name}: missing=[${missing.join(', ')}] extra=[${extra.join(', ')}]`
)
}
}
expect(mismatched).toEqual([])
})
it('모든 로케일에서 core 참조 키의 값이 빈 문자열이 아니다', () => {
const keys = allCoreI18nKeys()
const blank: string[] = []
for (const name of localeNames()) {
const table = readLocale(name)
for (const key of keys) {
const value = table[key]
if (value !== undefined && value.trim() === '') blank.push(`${name}: ${key}`)
}
}
expect(blank).toEqual([])
})
})

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,9 @@
"private": true,
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
"license": "MIT",
"scripts": {
"test": "vitest run"
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
@ -15,6 +18,10 @@
"types": "./src/types.ts",
"default": "./src/types.ts"
},
"./keybinding": {
"types": "./src/keybinding.ts",
"default": "./src/keybinding.ts"
},
"./errors": {
"types": "./src/errors.ts",
"default": "./src/errors.ts"

View file

@ -3,6 +3,7 @@
// 본 파일은 편의를 위한 통합 re-export
export * from './types'
export * from './keybinding'
export * from './errors'
export * from './ipc-channels'
export * from './constants'

View file

@ -97,22 +97,30 @@ export const IPC_CHANNELS = {
PREMIUM_UPGRADE_REQUIRED: 'llm:premiumUpgradeRequired'
},
HOTKEY: {
GET_DICTATION_SHORTCUT: 'hotkey:getDictationShortcut',
SET_DICTATION_SHORTCUT: 'hotkey:setDictationShortcut',
GET_HANDS_FREE_SHORTCUT: 'hotkey:getHandsFreeShortcut',
SET_HANDS_FREE_SHORTCUT: 'hotkey:setHandsFreeShortcut',
GET_COMMAND_SHORTCUT: 'hotkey:getCommandShortcut',
SET_COMMAND_SHORTCUT: 'hotkey:setCommandShortcut',
GET_CAPTION_SHORTCUT: 'hotkey:getCaptionShortcut',
SET_CAPTION_SHORTCUT: 'hotkey:setCaptionShortcut',
IS_ENABLED: 'hotkey:isEnabled',
SET_ENABLED: 'hotkey:setEnabled',
START_RECORDING: 'hotkey:startRecording',
STOP_RECORDING: 'hotkey:stopRecording',
/**
* 키바인딩. 액션을 파라미터로 받는 단일 채널 집합이다 —
* 액션이 늘어도 채널을 늘리지 않는다. 계약 정본은 `packages/core/src/keybinding.ts`.
*/
KEYBINDING: {
/** 전체 액션의 바인딩 맵 조회 */
GET_MAP: 'keybinding:getMap',
/** 한 액션의 바인딩 목록 교체 */
SET_BINDINGS: 'keybinding:setBindings',
/** 한 액션을 기본값으로 되돌림 */
RESET_ACTION: 'keybinding:resetAction',
/** 전체를 기본값으로 되돌림 */
RESET_ALL: 'keybinding:resetAll',
/** 바인딩 유효성 + 액션 간 충돌 사전 검사 */
VALIDATE: 'keybinding:validate',
/** 전역 키바인딩 on/off 조회 */
IS_ENABLED: 'keybinding:isEnabled',
/** 전역 키바인딩 on/off 설정 */
SET_ENABLED: 'keybinding:setEnabled',
// Main → Renderer events
TRIGGERED: 'hotkey:triggered',
RECORDING_RESULT: 'hotkey:recordingResult'
/** 바인딩 입력 감지 알림 */
TRIGGERED: 'keybinding:triggered',
/** 바인딩 맵 변경 알림 (창 간 동기화) */
CHANGED: 'keybinding:changed'
},
CONFIG: {

File diff suppressed because it is too large Load diff

View file

@ -376,39 +376,40 @@ export interface LLMPullProgressEvent {
}
// ============================================================
// Hotkey (핫키)
// Keybinding (키바인딩)
// ============================================================
//
// 계약 정본은 `packages/core/src/keybinding.ts` 다.
// 키 목록 · 라벨 · 검증 · 충돌 판정을 여기 다시 정의하지 않는다.
export interface HotkeyBinding {
keyCode: number
ctrl: boolean
alt: boolean
shift: boolean
meta: boolean
displayLabel: string
}
export interface SetHotkeyParams {
binding: HotkeyBinding
}
export type {
BindingConflict,
BindingDevice,
BindingPlatform,
BindingRejectReason,
BindingSegment,
BindingValidation,
KeyBinding,
KeyBindingActionGroup,
KeyBindingActionId,
KeyBindingActionSpec,
KeyBindingChangedEvent,
KeyBindingList,
KeyBindingMap,
KeyBindingTriggeredEvent,
KeyBindingValidationResult,
KeyCatalogEntry,
KeyCatalogGroup,
MouseButtonCode,
ResetKeyBindingParams,
SetKeyBindingsParams,
ValidateKeyBindingParams
} from './keybinding'
export interface SetEnabledParams {
enabled: boolean
}
export type HotkeyAction = 'dictation' | 'hands-free' | 'command' | 'caption'
export interface HotkeyTriggeredEvent {
action: HotkeyAction
type: 'pressed' | 'released'
isDoublePress: boolean
}
export interface HotkeyRecordingResultEvent {
binding: HotkeyBinding | null
conflictReason: string | null
}
// ============================================================
// Config (설정)
// ============================================================
@ -451,11 +452,11 @@ export interface AppConfig {
*/
conversationBackend: 'local' | 'realtime'
defaultLLMAction: LLMActionSelection
dictationShortcut: HotkeyBinding
handsFreeShortcut: HotkeyBinding
commandShortcut: HotkeyBinding
/** 실시간 자막 토글 핫키 (Phase 10.1) */
captionShortcut: HotkeyBinding
/**
* 전체 키바인딩. 액션 id → 바인딩 목록(다중 바인딩).
* 구조·기본값·검증은 `packages/core/src/keybinding.ts` 가 정본이다.
*/
keyBindings: import('./keybinding').KeyBindingMap
hotkeyEnabled: boolean
insertMethod: 'clipboard' | 'keyboard'
autoInsert: boolean

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: false,
environment: 'node',
include: ['__tests__/**/*.test.ts'],
testTimeout: 10000
}
})

View file

@ -270,5 +270,60 @@
"mobile.works.historyDescription": "Transkripte von Aufnahmen und Importen suchen und verwalten.",
"mobile.meetings.templateEmpty": "Keine Meeting-Dokumentvorlagen verfügbar. Sie können das Meeting ohne Vorlage starten.",
"mobile.meetings.templateNone": "Ohne Vorlage starten",
"mobile.meetings.templateNoneDesc": "Vorlage später bei der Dokumenterzeugung wählen"
"mobile.meetings.templateNoneDesc": "Vorlage später bei der Dokumenterzeugung wählen",
"keybinding.mouse.left": "Linke Maustaste",
"keybinding.mouse.right": "Rechte Maustaste",
"keybinding.mouse.middle": "Mittlere Maustaste",
"keybinding.mouse.back": "Maustaste Zurück",
"keybinding.mouse.forward": "Maustaste Vorwärts",
"keybinding.group.mouse": "Maus",
"keybinding.group.modifier": "Modifikatortasten",
"keybinding.group.function": "Funktionstasten",
"keybinding.group.letter": "Buchstaben",
"keybinding.group.digit": "Ziffern",
"keybinding.group.numpad": "Ziffernblock",
"keybinding.group.navigation": "Navigation",
"keybinding.group.editing": "Bearbeiten",
"keybinding.group.punctuation": "Sonderzeichen",
"keybinding.group.system": "System",
"keybinding.action.dictation": "Diktat",
"keybinding.action.dictation.desc": "Nimmt auf, solange die Taste gedrückt bleibt.",
"keybinding.action.handsFree": "Ein-Tasten-Modus",
"keybinding.action.handsFree.desc": "Zweimal drücken zum Umschalten.",
"keybinding.action.command": "Sprachbefehle",
"keybinding.action.command.desc": "Führt einen Befehl per Sprache aus.",
"keybinding.action.caption": "Live-Untertitel",
"keybinding.action.caption.desc": "Schaltet Live-Untertitel ein oder aus.",
"keybinding.action.historyPopup": "Verlaufs-Popup",
"keybinding.action.historyPopup.desc": "Öffnet das Verlaufs-Popup.",
"keybinding.action.commandPopup": "Befehls-Popup",
"keybinding.action.commandPopup.desc": "Öffnet das Befehls-Popup.",
"keybinding.reject.unknownKey": "Diese Taste wird nicht unterstützt",
"keybinding.reject.systemReserved": "Dies ist ein vom System reserviertes Tastenkürzel",
"keybinding.reject.modifierRequired": "Muss mit einer Modifikatortaste (Strg/Alt/Umschalt) kombiniert werden",
"keybinding.disabled.mouseLeft": "Der Linksklick wird für jede Bildschirmbedienung gebraucht und lässt sich nicht belegen",
"keybinding.warning.mousePassthrough": "Maustasten führen zusätzlich weiterhin ihre ursprüngliche Aktion aus",
"keybinding.ui.add": "Hinzufügen",
"keybinding.ui.remove": "Löschen",
"keybinding.ui.reset": "Auf Standard zurücksetzen",
"keybinding.ui.tabRecord": "Taste drücken",
"keybinding.ui.tabList": "Aus Liste wählen",
"keybinding.ui.search": "Tasten suchen",
"keybinding.ui.searchEmpty": "Keine Suchergebnisse",
"keybinding.ui.pressKeys": "Drücken Sie die gewünschte Taste oder Maustaste",
"keybinding.ui.noBindings": "Kein Tastenkürzel festgelegt",
"keybinding.ui.conflictWith": "Überschneidet sich mit {{action}}",
"keybinding.ui.modifiers": "Modifikatortasten",
"keybinding.ui.selectedKey": "Ausgewählte Taste",
"keybinding.ui.sectionVoice": "Sprache",
"keybinding.ui.sectionWindow": "Fenster / Popups",
"keybinding.ui.duplicate": "Dieses Tastenkürzel wurde bereits hinzugefügt",
"keybinding.ui.holdMode": "Halten",
"keybinding.ui.doublePress": "Doppeldruck",
"keybinding.ui.pickerTitle": "Tastenkürzel einrichten",
"keybinding.ui.pressToRecord": "Drücken, um die Aufnahme zu starten",
"keybinding.ui.recordHint": "Gib eine Kombination (z. B. Ctrl+Shift+Q) oder eine einzelne Taste (z. B. F5) ein",
"keybinding.ui.recordAgain": "Erneut eingeben",
"keybinding.ui.saveFailed": "Das Tastenkürzel konnte nicht gespeichert werden",
"keybinding.ui.globalEnabled": "Globale Tastenkürzel aktivieren"
}

View file

@ -1652,5 +1652,60 @@
"mobile.works.section.data": "Data & alerts",
"mobile.works.historyDescription": "Search and manage transcripts from recordings and imports.",
"mobile.meetings.templateNone": "Start without template",
"mobile.meetings.templateNoneDesc": "Pick a template later when generating documents"
"mobile.meetings.templateNoneDesc": "Pick a template later when generating documents",
"keybinding.mouse.left": "Left Mouse Button",
"keybinding.mouse.right": "Right Mouse Button",
"keybinding.mouse.middle": "Middle Mouse Button",
"keybinding.mouse.back": "Mouse Back Button",
"keybinding.mouse.forward": "Mouse Forward Button",
"keybinding.group.mouse": "Mouse",
"keybinding.group.modifier": "Modifiers",
"keybinding.group.function": "Function Keys",
"keybinding.group.letter": "Letters",
"keybinding.group.digit": "Digits",
"keybinding.group.numpad": "Numpad",
"keybinding.group.navigation": "Navigation",
"keybinding.group.editing": "Editing",
"keybinding.group.punctuation": "Punctuation",
"keybinding.group.system": "System",
"keybinding.action.dictation": "Dictation",
"keybinding.action.dictation.desc": "Records while the key is held down.",
"keybinding.action.handsFree": "One-Touch Mode",
"keybinding.action.handsFree.desc": "Press twice to toggle.",
"keybinding.action.command": "Voice Commands",
"keybinding.action.command.desc": "Runs a command by voice.",
"keybinding.action.caption": "Live Caption",
"keybinding.action.caption.desc": "Turns live captions on and off.",
"keybinding.action.historyPopup": "History Popup",
"keybinding.action.historyPopup.desc": "Opens the history popup.",
"keybinding.action.commandPopup": "Command Popup",
"keybinding.action.commandPopup.desc": "Opens the command popup.",
"keybinding.reject.unknownKey": "This key is not supported",
"keybinding.reject.systemReserved": "This is a reserved system shortcut",
"keybinding.reject.modifierRequired": "Must be combined with a modifier (Ctrl/Alt/Shift)",
"keybinding.disabled.mouseLeft": "Left click is used for every on-screen interaction, so it cannot be bound",
"keybinding.warning.mousePassthrough": "Mouse buttons also keep performing their original action",
"keybinding.ui.add": "Add",
"keybinding.ui.remove": "Remove",
"keybinding.ui.reset": "Reset to Default",
"keybinding.ui.tabRecord": "Press a Key",
"keybinding.ui.tabList": "Choose from List",
"keybinding.ui.search": "Search keys",
"keybinding.ui.searchEmpty": "No matching keys",
"keybinding.ui.pressKeys": "Press the key or mouse button you want",
"keybinding.ui.noBindings": "No shortcut set",
"keybinding.ui.conflictWith": "Conflicts with {{action}}",
"keybinding.ui.modifiers": "Modifiers",
"keybinding.ui.selectedKey": "Selected key",
"keybinding.ui.sectionVoice": "Voice",
"keybinding.ui.sectionWindow": "Windows / Popups",
"keybinding.ui.duplicate": "This shortcut has already been added",
"keybinding.ui.holdMode": "Hold",
"keybinding.ui.doublePress": "Double-press",
"keybinding.ui.pickerTitle": "Set Shortcut",
"keybinding.ui.pressToRecord": "Press to start recording",
"keybinding.ui.recordHint": "Enter a combination (e.g. Ctrl+Shift+Q) or a single key (e.g. F5)",
"keybinding.ui.recordAgain": "Clear",
"keybinding.ui.saveFailed": "The shortcut could not be saved",
"keybinding.ui.globalEnabled": "Enable global shortcuts"
}

View file

@ -270,5 +270,60 @@
"mobile.works.historyDescription": "Busca y gestiona transcripciones de grabaciones e importaciones.",
"mobile.meetings.templateEmpty": "No hay plantillas de documento de reunión disponibles. Puedes iniciar la reunión sin una.",
"mobile.meetings.templateNone": "Empezar sin plantilla",
"mobile.meetings.templateNoneDesc": "Elige una plantilla al generar documentos"
"mobile.meetings.templateNoneDesc": "Elige una plantilla al generar documentos",
"keybinding.mouse.left": "Botón izquierdo del ratón",
"keybinding.mouse.right": "Botón derecho del ratón",
"keybinding.mouse.middle": "Botón central del ratón",
"keybinding.mouse.back": "Botón Atrás del ratón",
"keybinding.mouse.forward": "Botón Adelante del ratón",
"keybinding.group.mouse": "Ratón",
"keybinding.group.modifier": "Modificadores",
"keybinding.group.function": "Teclas de función",
"keybinding.group.letter": "Letras",
"keybinding.group.digit": "Números",
"keybinding.group.numpad": "Teclado numérico",
"keybinding.group.navigation": "Navegación",
"keybinding.group.editing": "Edición",
"keybinding.group.punctuation": "Símbolos",
"keybinding.group.system": "Sistema",
"keybinding.action.dictation": "Dictado",
"keybinding.action.dictation.desc": "Graba mientras se mantiene pulsada la tecla.",
"keybinding.action.handsFree": "Modo un toque",
"keybinding.action.handsFree.desc": "Pulsa dos veces para activarlo o desactivarlo.",
"keybinding.action.command": "Comandos de voz",
"keybinding.action.command.desc": "Ejecuta un comando por voz.",
"keybinding.action.caption": "Subtítulos en directo",
"keybinding.action.caption.desc": "Activa o desactiva los subtítulos en directo.",
"keybinding.action.historyPopup": "Ventana de historial",
"keybinding.action.historyPopup.desc": "Abre la ventana emergente del historial.",
"keybinding.action.commandPopup": "Ventana de comandos",
"keybinding.action.commandPopup.desc": "Abre la ventana emergente de comandos.",
"keybinding.reject.unknownKey": "Esta tecla no es compatible",
"keybinding.reject.systemReserved": "Es un atajo reservado por el sistema",
"keybinding.reject.modifierRequired": "Debe combinarse con un modificador (Ctrl/Alt/Mayús)",
"keybinding.disabled.mouseLeft": "El clic izquierdo se usa en toda la interfaz, así que no se puede asignar",
"keybinding.warning.mousePassthrough": "Los botones del ratón siguen ejecutando también su acción original",
"keybinding.ui.add": "Añadir",
"keybinding.ui.remove": "Eliminar",
"keybinding.ui.reset": "Restablecer valores",
"keybinding.ui.tabRecord": "Pulsar tecla",
"keybinding.ui.tabList": "Elegir de la lista",
"keybinding.ui.search": "Buscar teclas",
"keybinding.ui.searchEmpty": "No hay resultados",
"keybinding.ui.pressKeys": "Pulsa la tecla o el botón del ratón que quieras",
"keybinding.ui.noBindings": "Sin atajo configurado",
"keybinding.ui.conflictWith": "Entra en conflicto con {{action}}",
"keybinding.ui.modifiers": "Modificadores",
"keybinding.ui.selectedKey": "Tecla seleccionada",
"keybinding.ui.sectionVoice": "Voz",
"keybinding.ui.sectionWindow": "Ventanas / Emergentes",
"keybinding.ui.duplicate": "Este atajo ya está añadido",
"keybinding.ui.holdMode": "Mantener",
"keybinding.ui.doublePress": "Doble pulsación",
"keybinding.ui.pickerTitle": "Configurar atajo",
"keybinding.ui.pressToRecord": "Pulsa para empezar a grabar",
"keybinding.ui.recordHint": "Introduce una combinación (ej: Ctrl+Shift+Q) o una tecla sola (ej: F5)",
"keybinding.ui.recordAgain": "Volver a introducir",
"keybinding.ui.saveFailed": "No se ha podido guardar el atajo",
"keybinding.ui.globalEnabled": "Usar atajos globales"
}

View file

@ -270,5 +270,60 @@
"mobile.works.historyDescription": "Recherchez et gérez les transcriptions des enregistrements et importations.",
"mobile.meetings.templateEmpty": "Aucun modèle de document de réunion disponible. Vous pouvez démarrer la réunion sans modèle.",
"mobile.meetings.templateNone": "Démarrer sans modèle",
"mobile.meetings.templateNoneDesc": "Choisissez un modèle lors de la génération de documents"
"mobile.meetings.templateNoneDesc": "Choisissez un modèle lors de la génération de documents",
"keybinding.mouse.left": "Bouton gauche de la souris",
"keybinding.mouse.right": "Bouton droit de la souris",
"keybinding.mouse.middle": "Bouton du milieu de la souris",
"keybinding.mouse.back": "Bouton Précédent de la souris",
"keybinding.mouse.forward": "Bouton Suivant de la souris",
"keybinding.group.mouse": "Souris",
"keybinding.group.modifier": "Touches de modification",
"keybinding.group.function": "Touches de fonction",
"keybinding.group.letter": "Lettres",
"keybinding.group.digit": "Chiffres",
"keybinding.group.numpad": "Pavé numérique",
"keybinding.group.navigation": "Navigation",
"keybinding.group.editing": "Édition",
"keybinding.group.punctuation": "Symboles",
"keybinding.group.system": "Système",
"keybinding.action.dictation": "Dictée",
"keybinding.action.dictation.desc": "Enregistre tant que la touche est maintenue.",
"keybinding.action.handsFree": "Mode une touche",
"keybinding.action.handsFree.desc": "Appuyez deux fois pour activer ou désactiver.",
"keybinding.action.command": "Commandes vocales",
"keybinding.action.command.desc": "Exécute une commande à la voix.",
"keybinding.action.caption": "Sous-titres en direct",
"keybinding.action.caption.desc": "Active ou désactive les sous-titres en direct.",
"keybinding.action.historyPopup": "Fenêtre d'historique",
"keybinding.action.historyPopup.desc": "Ouvre la fenêtre contextuelle de l'historique.",
"keybinding.action.commandPopup": "Fenêtre de commandes",
"keybinding.action.commandPopup.desc": "Ouvre la fenêtre contextuelle des commandes.",
"keybinding.reject.unknownKey": "Cette touche n'est pas prise en charge",
"keybinding.reject.systemReserved": "Il s'agit d'un raccourci réservé au système",
"keybinding.reject.modifierRequired": "Doit être combinée avec une touche de modification (Ctrl/Alt/Maj)",
"keybinding.disabled.mouseLeft": "Le clic gauche sert à toutes les interactions à l'écran, il ne peut pas être affecté",
"keybinding.warning.mousePassthrough": "Les boutons de la souris conservent aussi leur action d'origine",
"keybinding.ui.add": "Ajouter",
"keybinding.ui.remove": "Supprimer",
"keybinding.ui.reset": "Valeurs par défaut",
"keybinding.ui.tabRecord": "Appuyer sur une touche",
"keybinding.ui.tabList": "Choisir dans la liste",
"keybinding.ui.search": "Rechercher une touche",
"keybinding.ui.searchEmpty": "Aucun résultat",
"keybinding.ui.pressKeys": "Appuyez sur la touche ou le bouton de souris souhaité",
"keybinding.ui.noBindings": "Aucun raccourci défini",
"keybinding.ui.conflictWith": "En conflit avec {{action}}",
"keybinding.ui.modifiers": "Touches de modification",
"keybinding.ui.selectedKey": "Touche sélectionnée",
"keybinding.ui.sectionVoice": "Voix",
"keybinding.ui.sectionWindow": "Fenêtres / Pop-ups",
"keybinding.ui.duplicate": "Ce raccourci est déjà ajouté",
"keybinding.ui.holdMode": "Maintien",
"keybinding.ui.doublePress": "Double appui",
"keybinding.ui.pickerTitle": "Configurer le raccourci",
"keybinding.ui.pressToRecord": "Appuyez pour commencer l'enregistrement",
"keybinding.ui.recordHint": "Saisissez une combinaison (ex : Ctrl+Shift+Q) ou une touche seule (ex : F5)",
"keybinding.ui.recordAgain": "Ressaisir",
"keybinding.ui.saveFailed": "Le raccourci n'a pas pu être enregistré",
"keybinding.ui.globalEnabled": "Activer les raccourcis globaux"
}

View file

@ -270,5 +270,60 @@
"mobile.works.historyDescription": "録音とインポートの文字起こしを検索・管理します。",
"mobile.meetings.templateEmpty": "利用可能な会議ドキュメントテンプレートがありません。テンプレートなしで会議を開始できます。",
"mobile.meetings.templateNone": "テンプレートなしで開始",
"mobile.meetings.templateNoneDesc": "ドキュメント生成時にテンプレートを選択します"
"mobile.meetings.templateNoneDesc": "ドキュメント生成時にテンプレートを選択します",
"keybinding.mouse.left": "マウス左ボタン",
"keybinding.mouse.right": "マウス右ボタン",
"keybinding.mouse.middle": "マウス中ボタン",
"keybinding.mouse.back": "マウス戻るボタン",
"keybinding.mouse.forward": "マウス進むボタン",
"keybinding.group.mouse": "マウス",
"keybinding.group.modifier": "修飾キー",
"keybinding.group.function": "ファンクションキー",
"keybinding.group.letter": "文字",
"keybinding.group.digit": "数字",
"keybinding.group.numpad": "テンキー",
"keybinding.group.navigation": "ナビゲーション",
"keybinding.group.editing": "編集",
"keybinding.group.punctuation": "記号",
"keybinding.group.system": "システム",
"keybinding.action.dictation": "ディクテーション",
"keybinding.action.dictation.desc": "押している間、録音します。",
"keybinding.action.handsFree": "ワンタッチモード",
"keybinding.action.handsFree.desc": "2回押して切り替えます。",
"keybinding.action.command": "音声コマンド",
"keybinding.action.command.desc": "音声でコマンドを実行します。",
"keybinding.action.caption": "リアルタイム字幕",
"keybinding.action.caption.desc": "リアルタイム字幕をオン・オフします。",
"keybinding.action.historyPopup": "履歴ポップアップ",
"keybinding.action.historyPopup.desc": "履歴ポップアップを開きます。",
"keybinding.action.commandPopup": "コマンドポップアップ",
"keybinding.action.commandPopup.desc": "コマンドポップアップを開きます。",
"keybinding.reject.unknownKey": "サポートされていないキーです",
"keybinding.reject.systemReserved": "システム予約のショートカットです",
"keybinding.reject.modifierRequired": "修飾キー(Ctrl/Alt/Shift)と組み合わせて使用してください",
"keybinding.disabled.mouseLeft": "左クリックはすべての画面操作に使われるため、割り当てできません",
"keybinding.warning.mousePassthrough": "マウスボタンは本来の動作も同時に実行されます",
"keybinding.ui.add": "追加",
"keybinding.ui.remove": "削除",
"keybinding.ui.reset": "既定値に戻す",
"keybinding.ui.tabRecord": "直接入力",
"keybinding.ui.tabList": "一覧から選択",
"keybinding.ui.search": "キーを検索",
"keybinding.ui.searchEmpty": "検索結果がありません",
"keybinding.ui.pressKeys": "使用するキーまたはマウスボタンを押してください",
"keybinding.ui.noBindings": "ショートカット未設定",
"keybinding.ui.conflictWith": "{{action}}と重複しています",
"keybinding.ui.modifiers": "修飾キー",
"keybinding.ui.selectedKey": "選択したキー",
"keybinding.ui.sectionVoice": "音声",
"keybinding.ui.sectionWindow": "ウィンドウ / ポップアップ",
"keybinding.ui.duplicate": "すでに追加されているショートカットです",
"keybinding.ui.holdMode": "長押し",
"keybinding.ui.doublePress": "2回押し",
"keybinding.ui.pickerTitle": "ショートカット設定",
"keybinding.ui.pressToRecord": "押して録音開始",
"keybinding.ui.recordHint": "組み合わせキー(例: Ctrl+Shift+Q)または単一キー(例: F5)を入力してください",
"keybinding.ui.recordAgain": "再入力",
"keybinding.ui.saveFailed": "ショートカットを保存できませんでした",
"keybinding.ui.globalEnabled": "グローバルショートカットを使用"
}

View file

@ -1659,5 +1659,60 @@
"mobile.works.section.data": "데이터 · 알림",
"mobile.works.historyDescription": "녹음과 가져오기의 전사 기록을 검색하고 관리합니다.",
"mobile.meetings.templateNone": "템플릿 없이 시작",
"mobile.meetings.templateNoneDesc": "문서 생성 시점에 템플릿을 선택합니다"
"mobile.meetings.templateNoneDesc": "문서 생성 시점에 템플릿을 선택합니다",
"keybinding.mouse.left": "마우스 왼쪽 버튼",
"keybinding.mouse.right": "마우스 오른쪽 버튼",
"keybinding.mouse.middle": "마우스 가운데 버튼",
"keybinding.mouse.back": "마우스 뒤로 버튼",
"keybinding.mouse.forward": "마우스 앞으로 버튼",
"keybinding.group.mouse": "마우스",
"keybinding.group.modifier": "조합키",
"keybinding.group.function": "기능키",
"keybinding.group.letter": "문자",
"keybinding.group.digit": "숫자",
"keybinding.group.numpad": "숫자패드",
"keybinding.group.navigation": "탐색",
"keybinding.group.editing": "편집",
"keybinding.group.punctuation": "기호",
"keybinding.group.system": "시스템",
"keybinding.action.dictation": "받아쓰기",
"keybinding.action.dictation.desc": "누르고 있는 동안 녹음합니다.",
"keybinding.action.handsFree": "원터치 모드",
"keybinding.action.handsFree.desc": "두 번 눌러 토글합니다.",
"keybinding.action.command": "음성 명령어",
"keybinding.action.command.desc": "음성으로 명령을 실행합니다.",
"keybinding.action.caption": "실시간 자막",
"keybinding.action.caption.desc": "실시간 자막을 켜고 끕니다.",
"keybinding.action.historyPopup": "기록 팝업",
"keybinding.action.historyPopup.desc": "기록 팝업을 엽니다.",
"keybinding.action.commandPopup": "명령어 팝업",
"keybinding.action.commandPopup.desc": "명령어 팝업을 엽니다.",
"keybinding.reject.unknownKey": "지원하지 않는 키입니다",
"keybinding.reject.systemReserved": "시스템 예약 단축키입니다",
"keybinding.reject.modifierRequired": "조합키(Ctrl/Alt/Shift)와 함께 사용해야 합니다",
"keybinding.disabled.mouseLeft": "왼쪽 클릭은 모든 화면 조작에 쓰이므로 바인딩할 수 없습니다",
"keybinding.warning.mousePassthrough": "마우스 버튼은 원래 동작도 함께 실행됩니다",
"keybinding.ui.add": "추가",
"keybinding.ui.remove": "삭제",
"keybinding.ui.reset": "기본값으로",
"keybinding.ui.tabRecord": "직접 입력",
"keybinding.ui.tabList": "목록에서 선택",
"keybinding.ui.search": "키 검색",
"keybinding.ui.searchEmpty": "검색 결과가 없습니다",
"keybinding.ui.pressKeys": "원하는 키 또는 마우스 버튼을 누르세요",
"keybinding.ui.noBindings": "설정된 단축키 없음",
"keybinding.ui.conflictWith": "{{action}}와(과) 중복됩니다",
"keybinding.ui.modifiers": "조합키",
"keybinding.ui.selectedKey": "선택한 키",
"keybinding.ui.sectionVoice": "음성",
"keybinding.ui.sectionWindow": "창 / 팝업",
"keybinding.ui.duplicate": "이미 추가된 단축키입니다",
"keybinding.ui.holdMode": "누름 유지",
"keybinding.ui.doublePress": "두 번 누름",
"keybinding.ui.pickerTitle": "단축키 설정",
"keybinding.ui.pressToRecord": "눌러 녹음 시작",
"keybinding.ui.recordHint": "조합키(예: Ctrl+Shift+Q) 또는 단일키(예: F5)를 입력하세요",
"keybinding.ui.recordAgain": "다시 입력",
"keybinding.ui.saveFailed": "단축키를 저장하지 못했습니다",
"keybinding.ui.globalEnabled": "전역 단축키 사용"
}

View file

@ -270,5 +270,60 @@
"mobile.works.historyDescription": "Pesquise e gerencie transcrições de gravações e importações.",
"mobile.meetings.templateEmpty": "Nenhuma template de documento de reunião disponível. Você pode iniciar a reunião sem uma.",
"mobile.meetings.templateNone": "Começar sem template",
"mobile.meetings.templateNoneDesc": "Escolha uma template ao gerar documentos"
"mobile.meetings.templateNoneDesc": "Escolha uma template ao gerar documentos",
"keybinding.mouse.left": "Botão esquerdo do mouse",
"keybinding.mouse.right": "Botão direito do mouse",
"keybinding.mouse.middle": "Botão do meio do mouse",
"keybinding.mouse.back": "Botão Voltar do mouse",
"keybinding.mouse.forward": "Botão Avançar do mouse",
"keybinding.group.mouse": "Mouse",
"keybinding.group.modifier": "Modificadores",
"keybinding.group.function": "Teclas de função",
"keybinding.group.letter": "Letras",
"keybinding.group.digit": "Números",
"keybinding.group.numpad": "Teclado numérico",
"keybinding.group.navigation": "Navegação",
"keybinding.group.editing": "Edição",
"keybinding.group.punctuation": "Símbolos",
"keybinding.group.system": "Sistema",
"keybinding.action.dictation": "Ditado",
"keybinding.action.dictation.desc": "Grava enquanto a tecla estiver pressionada.",
"keybinding.action.handsFree": "Modo de um toque",
"keybinding.action.handsFree.desc": "Pressione duas vezes para alternar.",
"keybinding.action.command": "Comandos de voz",
"keybinding.action.command.desc": "Executa um comando por voz.",
"keybinding.action.caption": "Legendas ao vivo",
"keybinding.action.caption.desc": "Ativa ou desativa as legendas ao vivo.",
"keybinding.action.historyPopup": "Pop-up de histórico",
"keybinding.action.historyPopup.desc": "Abre o pop-up de histórico.",
"keybinding.action.commandPopup": "Pop-up de comandos",
"keybinding.action.commandPopup.desc": "Abre o pop-up de comandos.",
"keybinding.reject.unknownKey": "Esta tecla não é compatível",
"keybinding.reject.systemReserved": "É um atalho reservado pelo sistema",
"keybinding.reject.modifierRequired": "Precisa ser combinada com um modificador (Ctrl/Alt/Shift)",
"keybinding.disabled.mouseLeft": "O clique esquerdo é usado em toda a interface, por isso não pode ser vinculado",
"keybinding.warning.mousePassthrough": "Os botões do mouse continuam executando também a ação original",
"keybinding.ui.add": "Adicionar",
"keybinding.ui.remove": "Excluir",
"keybinding.ui.reset": "Restaurar padrão",
"keybinding.ui.tabRecord": "Pressionar tecla",
"keybinding.ui.tabList": "Escolher da lista",
"keybinding.ui.search": "Pesquisar teclas",
"keybinding.ui.searchEmpty": "Nenhum resultado encontrado",
"keybinding.ui.pressKeys": "Pressione a tecla ou o botão do mouse desejado",
"keybinding.ui.noBindings": "Nenhum atalho definido",
"keybinding.ui.conflictWith": "Conflita com {{action}}",
"keybinding.ui.modifiers": "Modificadores",
"keybinding.ui.selectedKey": "Tecla selecionada",
"keybinding.ui.sectionVoice": "Voz",
"keybinding.ui.sectionWindow": "Janelas / Pop-ups",
"keybinding.ui.duplicate": "Este atalho já foi adicionado",
"keybinding.ui.holdMode": "Manter",
"keybinding.ui.doublePress": "Pressão dupla",
"keybinding.ui.pickerTitle": "Configurar atalho",
"keybinding.ui.pressToRecord": "Pressione para iniciar a gravação",
"keybinding.ui.recordHint": "Insira uma combinação (ex: Ctrl+Shift+Q) ou uma tecla única (ex: F5)",
"keybinding.ui.recordAgain": "Inserir novamente",
"keybinding.ui.saveFailed": "Não foi possível salvar o atalho",
"keybinding.ui.globalEnabled": "Usar atalhos globais"
}

View file

@ -270,5 +270,60 @@
"mobile.works.historyDescription": "Поиск и управление расшифровками записей и импорта.",
"mobile.meetings.templateEmpty": "Нет доступных шаблонов документов встреч. Можно начать встречу без шаблона.",
"mobile.meetings.templateNone": "Начать без шаблона",
"mobile.meetings.templateNoneDesc": "Шаблон можно выбрать при создании документов"
"mobile.meetings.templateNoneDesc": "Шаблон можно выбрать при создании документов",
"keybinding.mouse.left": "Левая кнопка мыши",
"keybinding.mouse.right": "Правая кнопка мыши",
"keybinding.mouse.middle": "Средняя кнопка мыши",
"keybinding.mouse.back": "Кнопка мыши «Назад»",
"keybinding.mouse.forward": "Кнопка мыши «Вперёд»",
"keybinding.group.mouse": "Мышь",
"keybinding.group.modifier": "Модификаторы",
"keybinding.group.function": "Функциональные клавиши",
"keybinding.group.letter": "Буквы",
"keybinding.group.digit": "Цифры",
"keybinding.group.numpad": "Цифровой блок",
"keybinding.group.navigation": "Навигация",
"keybinding.group.editing": "Редактирование",
"keybinding.group.punctuation": "Символы",
"keybinding.group.system": "Система",
"keybinding.action.dictation": "Диктовка",
"keybinding.action.dictation.desc": "Записывает, пока клавиша удерживается.",
"keybinding.action.handsFree": "Режим одного нажатия",
"keybinding.action.handsFree.desc": "Нажмите дважды для переключения.",
"keybinding.action.command": "Голосовые команды",
"keybinding.action.command.desc": "Выполняет команду голосом.",
"keybinding.action.caption": "Живые субтитры",
"keybinding.action.caption.desc": "Включает или выключает живые субтитры.",
"keybinding.action.historyPopup": "Окно истории",
"keybinding.action.historyPopup.desc": "Открывает всплывающее окно истории.",
"keybinding.action.commandPopup": "Окно команд",
"keybinding.action.commandPopup.desc": "Открывает всплывающее окно команд.",
"keybinding.reject.unknownKey": "Эта клавиша не поддерживается",
"keybinding.reject.systemReserved": "Это системное сочетание клавиш",
"keybinding.reject.modifierRequired": "Нужно сочетать с модификатором (Ctrl/Alt/Shift)",
"keybinding.disabled.mouseLeft": "Левый клик используется для всех действий на экране, поэтому его нельзя назначить",
"keybinding.warning.mousePassthrough": "Кнопки мыши при этом продолжают выполнять и своё обычное действие",
"keybinding.ui.add": "Добавить",
"keybinding.ui.remove": "Удалить",
"keybinding.ui.reset": "Сбросить по умолчанию",
"keybinding.ui.tabRecord": "Нажать клавишу",
"keybinding.ui.tabList": "Выбрать из списка",
"keybinding.ui.search": "Поиск клавиш",
"keybinding.ui.searchEmpty": "Ничего не найдено",
"keybinding.ui.pressKeys": "Нажмите нужную клавишу или кнопку мыши",
"keybinding.ui.noBindings": "Сочетание не задано",
"keybinding.ui.conflictWith": "Конфликтует с «{{action}}»",
"keybinding.ui.modifiers": "Модификаторы",
"keybinding.ui.selectedKey": "Выбранная клавиша",
"keybinding.ui.sectionVoice": "Голос",
"keybinding.ui.sectionWindow": "Окна / всплывающие окна",
"keybinding.ui.duplicate": "Это сочетание уже добавлено",
"keybinding.ui.holdMode": "Удержание",
"keybinding.ui.doublePress": "Двойное нажатие",
"keybinding.ui.pickerTitle": "Настройка горячей клавиши",
"keybinding.ui.pressToRecord": "Нажмите для начала записи",
"keybinding.ui.recordHint": "Введите сочетание клавиш (например: Ctrl+Shift+Q) или одну клавишу (например: F5)",
"keybinding.ui.recordAgain": "Ввести снова",
"keybinding.ui.saveFailed": "Не удалось сохранить сочетание клавиш",
"keybinding.ui.globalEnabled": "Использовать глобальные горячие клавиши"
}

View file

@ -270,5 +270,60 @@
"mobile.works.historyDescription": "ค้นหาและจัดการบทถอดความจากการบันทึกเสียงและการนำเข้า",
"mobile.meetings.templateEmpty": "ไม่มีเทมเพลตเอกสารประชุมที่ใช้ได้ เริ่มประชุมโดยไม่ใช้เทมเพลตได้",
"mobile.meetings.templateNone": "เริ่มโดยไม่ใช้เทมเพลต",
"mobile.meetings.templateNoneDesc": "เลือกเทมเพลตภายหลังเมื่อสร้างเอกสาร"
"mobile.meetings.templateNoneDesc": "เลือกเทมเพลตภายหลังเมื่อสร้างเอกสาร",
"keybinding.mouse.left": "ปุ่มซ้ายของเมาส์",
"keybinding.mouse.right": "ปุ่มขวาของเมาส์",
"keybinding.mouse.middle": "ปุ่มกลางของเมาส์",
"keybinding.mouse.back": "ปุ่มย้อนกลับของเมาส์",
"keybinding.mouse.forward": "ปุ่มไปข้างหน้าของเมาส์",
"keybinding.group.mouse": "เมาส์",
"keybinding.group.modifier": "ปุ่มปรับแต่ง",
"keybinding.group.function": "ปุ่มฟังก์ชัน",
"keybinding.group.letter": "ตัวอักษร",
"keybinding.group.digit": "ตัวเลข",
"keybinding.group.numpad": "แป้นตัวเลข",
"keybinding.group.navigation": "การนำทาง",
"keybinding.group.editing": "การแก้ไข",
"keybinding.group.punctuation": "สัญลักษณ์",
"keybinding.group.system": "ระบบ",
"keybinding.action.dictation": "การบอกเล่า",
"keybinding.action.dictation.desc": "บันทึกเสียงขณะที่กดปุ่มค้างไว้",
"keybinding.action.handsFree": "โหมดสัมผัสเดียว",
"keybinding.action.handsFree.desc": "กดสองครั้งเพื่อเปิดหรือปิด",
"keybinding.action.command": "คำสั่งเสียง",
"keybinding.action.command.desc": "สั่งงานด้วยเสียง",
"keybinding.action.caption": "คำบรรยายสด",
"keybinding.action.caption.desc": "เปิดหรือปิดคำบรรยายสด",
"keybinding.action.historyPopup": "ป๊อปอัปประวัติ",
"keybinding.action.historyPopup.desc": "เปิดป๊อปอัปประวัติ",
"keybinding.action.commandPopup": "ป๊อปอัปคำสั่ง",
"keybinding.action.commandPopup.desc": "เปิดป๊อปอัปคำสั่ง",
"keybinding.reject.unknownKey": "ไม่รองรับปุ่มนี้",
"keybinding.reject.systemReserved": "เป็นปุ่มลัดที่ระบบสงวนไว้",
"keybinding.reject.modifierRequired": "ต้องใช้ร่วมกับปุ่มปรับแต่ง (Ctrl/Alt/Shift)",
"keybinding.disabled.mouseLeft": "คลิกซ้ายถูกใช้กับการควบคุมหน้าจอทั้งหมด จึงกำหนดเป็นปุ่มลัดไม่ได้",
"keybinding.warning.mousePassthrough": "ปุ่มเมาส์จะยังทำงานตามการทำงานเดิมไปพร้อมกันด้วย",
"keybinding.ui.add": "เพิ่ม",
"keybinding.ui.remove": "ลบ",
"keybinding.ui.reset": "คืนค่าเริ่มต้น",
"keybinding.ui.tabRecord": "กดปุ่มเอง",
"keybinding.ui.tabList": "เลือกจากรายการ",
"keybinding.ui.search": "ค้นหาปุ่ม",
"keybinding.ui.searchEmpty": "ไม่พบผลการค้นหา",
"keybinding.ui.pressKeys": "กดปุ่มหรือปุ่มเมาส์ที่ต้องการ",
"keybinding.ui.noBindings": "ยังไม่ได้ตั้งปุ่มลัด",
"keybinding.ui.conflictWith": "ซ้ำกับ {{action}}",
"keybinding.ui.modifiers": "ปุ่มปรับแต่ง",
"keybinding.ui.selectedKey": "ปุ่มที่เลือก",
"keybinding.ui.sectionVoice": "เสียง",
"keybinding.ui.sectionWindow": "หน้าต่าง / ป๊อปอัป",
"keybinding.ui.duplicate": "เพิ่มปุ่มลัดนี้ไว้แล้ว",
"keybinding.ui.holdMode": "กดค้าง",
"keybinding.ui.doublePress": "กดสองครั้ง",
"keybinding.ui.pickerTitle": "การตั้งค่าปุ่มลัด",
"keybinding.ui.pressToRecord": "กดเพื่อเริ่มบันทึก",
"keybinding.ui.recordHint": "กรอกปุ่มลัด (เช่น: Ctrl+Shift+Q) หรือปุ่มเดี่ยว (เช่น: F5)",
"keybinding.ui.recordAgain": "กรอกใหม่",
"keybinding.ui.saveFailed": "บันทึกปุ่มลัดไม่สำเร็จ",
"keybinding.ui.globalEnabled": "ใช้ปุ่มลัดส่วนกลาง"
}

View file

@ -270,5 +270,60 @@
"mobile.works.historyDescription": "Tìm kiếm và quản lý bản chép lời từ bản ghi và tệp nhập.",
"mobile.meetings.templateEmpty": "Không có mẫu tài liệu họp nào. Bạn có thể bắt đầu cuộc họp mà không cần mẫu.",
"mobile.meetings.templateNone": "Bắt đầu không cần mẫu",
"mobile.meetings.templateNoneDesc": "Chọn mẫu sau khi khi tạo tài liệu"
"mobile.meetings.templateNoneDesc": "Chọn mẫu sau khi khi tạo tài liệu",
"keybinding.mouse.left": "Chuột trái",
"keybinding.mouse.right": "Chuột phải",
"keybinding.mouse.middle": "Chuột giữa",
"keybinding.mouse.back": "Nút Lùi của chuột",
"keybinding.mouse.forward": "Nút Tiến của chuột",
"keybinding.group.mouse": "Chuột",
"keybinding.group.modifier": "Phím bổ trợ",
"keybinding.group.function": "Phím chức năng",
"keybinding.group.letter": "Chữ cái",
"keybinding.group.digit": "Chữ số",
"keybinding.group.numpad": "Bàn phím số",
"keybinding.group.navigation": "Điều hướng",
"keybinding.group.editing": "Chỉnh sửa",
"keybinding.group.punctuation": "Ký hiệu",
"keybinding.group.system": "Hệ thống",
"keybinding.action.dictation": "Chính tả",
"keybinding.action.dictation.desc": "Ghi âm trong khi giữ phím.",
"keybinding.action.handsFree": "Chế độ một chạm",
"keybinding.action.handsFree.desc": "Nhấn hai lần để bật hoặc tắt.",
"keybinding.action.command": "Lệnh thoại",
"keybinding.action.command.desc": "Thực hiện lệnh bằng giọng nói.",
"keybinding.action.caption": "Phụ đề trực tiếp",
"keybinding.action.caption.desc": "Bật hoặc tắt phụ đề trực tiếp.",
"keybinding.action.historyPopup": "Cửa sổ lịch sử",
"keybinding.action.historyPopup.desc": "Mở cửa sổ bật lên lịch sử.",
"keybinding.action.commandPopup": "Cửa sổ lệnh",
"keybinding.action.commandPopup.desc": "Mở cửa sổ bật lên lệnh.",
"keybinding.reject.unknownKey": "Phím này không được hỗ trợ",
"keybinding.reject.systemReserved": "Đây là phím tắt dành riêng cho hệ thống",
"keybinding.reject.modifierRequired": "Phải kết hợp với phím bổ trợ (Ctrl/Alt/Shift)",
"keybinding.disabled.mouseLeft": "Nhấp chuột trái được dùng cho mọi thao tác trên màn hình nên không thể gán",
"keybinding.warning.mousePassthrough": "Các nút chuột vẫn đồng thời thực hiện hành động gốc của chúng",
"keybinding.ui.add": "Thêm",
"keybinding.ui.remove": "Xóa",
"keybinding.ui.reset": "Khôi phục mặc định",
"keybinding.ui.tabRecord": "Nhấn phím",
"keybinding.ui.tabList": "Chọn từ danh sách",
"keybinding.ui.search": "Tìm phím",
"keybinding.ui.searchEmpty": "Không có kết quả tìm kiếm",
"keybinding.ui.pressKeys": "Hãy nhấn phím hoặc nút chuột bạn muốn",
"keybinding.ui.noBindings": "Chưa đặt phím tắt",
"keybinding.ui.conflictWith": "Trùng với {{action}}",
"keybinding.ui.modifiers": "Phím bổ trợ",
"keybinding.ui.selectedKey": "Phím đã chọn",
"keybinding.ui.sectionVoice": "Giọng nói",
"keybinding.ui.sectionWindow": "Cửa sổ / Cửa sổ bật lên",
"keybinding.ui.duplicate": "Phím tắt này đã được thêm",
"keybinding.ui.holdMode": "Giữ phím",
"keybinding.ui.doublePress": "Nhấn hai lần",
"keybinding.ui.pickerTitle": "Cài đặt phím tắt",
"keybinding.ui.pressToRecord": "Nhấn để bắt đầu ghi âm",
"keybinding.ui.recordHint": "Nhập tổ hợp phím (ví dụ: Ctrl+Shift+Q) hoặc phím đơn (ví dụ: F5)",
"keybinding.ui.recordAgain": "Nhập lại",
"keybinding.ui.saveFailed": "Không thể lưu phím tắt",
"keybinding.ui.globalEnabled": "Bật phím tắt toàn cục"
}

View file

@ -270,5 +270,60 @@
"mobile.works.historyDescription": "搜尋和管理錄音與匯入的逐字稿。",
"mobile.meetings.templateEmpty": "沒有可用的會議文件範本。可以不使用範本開始會議。",
"mobile.meetings.templateNone": "不使用範本開始",
"mobile.meetings.templateNoneDesc": "稍後產生文件時再選擇範本"
"mobile.meetings.templateNoneDesc": "稍後產生文件時再選擇範本",
"keybinding.mouse.left": "滑鼠左鍵",
"keybinding.mouse.right": "滑鼠右鍵",
"keybinding.mouse.middle": "滑鼠中鍵",
"keybinding.mouse.back": "滑鼠上一頁鍵",
"keybinding.mouse.forward": "滑鼠下一頁鍵",
"keybinding.group.mouse": "滑鼠",
"keybinding.group.modifier": "輔助鍵",
"keybinding.group.function": "功能鍵",
"keybinding.group.letter": "字母",
"keybinding.group.digit": "數字",
"keybinding.group.numpad": "數字鍵台",
"keybinding.group.navigation": "導覽",
"keybinding.group.editing": "編輯",
"keybinding.group.punctuation": "符號",
"keybinding.group.system": "系統",
"keybinding.action.dictation": "聽寫",
"keybinding.action.dictation.desc": "按住期間進行錄音。",
"keybinding.action.handsFree": "一鍵模式",
"keybinding.action.handsFree.desc": "按兩下切換開關。",
"keybinding.action.command": "語音指令",
"keybinding.action.command.desc": "以語音執行指令。",
"keybinding.action.caption": "即時字幕",
"keybinding.action.caption.desc": "開啟或關閉即時字幕。",
"keybinding.action.historyPopup": "歷史記錄快顯視窗",
"keybinding.action.historyPopup.desc": "開啟歷史記錄快顯視窗。",
"keybinding.action.commandPopup": "指令快顯視窗",
"keybinding.action.commandPopup.desc": "開啟指令快顯視窗。",
"keybinding.reject.unknownKey": "不支援這個按鍵",
"keybinding.reject.systemReserved": "這是系統保留的快速鍵",
"keybinding.reject.modifierRequired": "必須與輔助鍵(Ctrl/Alt/Shift)搭配使用",
"keybinding.disabled.mouseLeft": "左鍵用於所有畫面操作,無法綁定",
"keybinding.warning.mousePassthrough": "滑鼠按鍵仍會一併執行原本的動作",
"keybinding.ui.add": "新增",
"keybinding.ui.remove": "刪除",
"keybinding.ui.reset": "回復預設值",
"keybinding.ui.tabRecord": "直接輸入",
"keybinding.ui.tabList": "從清單選擇",
"keybinding.ui.search": "搜尋按鍵",
"keybinding.ui.searchEmpty": "沒有搜尋結果",
"keybinding.ui.pressKeys": "請按下想使用的按鍵或滑鼠按鍵",
"keybinding.ui.noBindings": "未設定快速鍵",
"keybinding.ui.conflictWith": "與{{action}}重複",
"keybinding.ui.modifiers": "輔助鍵",
"keybinding.ui.selectedKey": "已選按鍵",
"keybinding.ui.sectionVoice": "語音",
"keybinding.ui.sectionWindow": "視窗 / 快顯視窗",
"keybinding.ui.duplicate": "這個快速鍵已經新增過了",
"keybinding.ui.holdMode": "長按",
"keybinding.ui.doublePress": "按兩下",
"keybinding.ui.pickerTitle": "快速鍵設定",
"keybinding.ui.pressToRecord": "按下開始錄音",
"keybinding.ui.recordHint": "請輸入組合鍵(例如:Ctrl+Shift+Q)或單一鍵(例如:F5)",
"keybinding.ui.recordAgain": "重新輸入",
"keybinding.ui.saveFailed": "無法儲存快速鍵",
"keybinding.ui.globalEnabled": "啟用全域快速鍵"
}

View file

@ -270,5 +270,60 @@
"mobile.works.historyDescription": "搜索和管理录音与导入的转写记录。",
"mobile.meetings.templateEmpty": "没有可用的会议文档模板。可以不使用模板开始会议。",
"mobile.meetings.templateNone": "不使用模板开始",
"mobile.meetings.templateNoneDesc": "稍后生成文档时再选择模板"
"mobile.meetings.templateNoneDesc": "稍后生成文档时再选择模板",
"keybinding.mouse.left": "鼠标左键",
"keybinding.mouse.right": "鼠标右键",
"keybinding.mouse.middle": "鼠标中键",
"keybinding.mouse.back": "鼠标后退键",
"keybinding.mouse.forward": "鼠标前进键",
"keybinding.group.mouse": "鼠标",
"keybinding.group.modifier": "修饰键",
"keybinding.group.function": "功能键",
"keybinding.group.letter": "字母",
"keybinding.group.digit": "数字",
"keybinding.group.numpad": "小键盘",
"keybinding.group.navigation": "导航",
"keybinding.group.editing": "编辑",
"keybinding.group.punctuation": "符号",
"keybinding.group.system": "系统",
"keybinding.action.dictation": "听写",
"keybinding.action.dictation.desc": "按住期间进行录音。",
"keybinding.action.handsFree": "一触即发模式",
"keybinding.action.handsFree.desc": "按两下切换开关。",
"keybinding.action.command": "语音命令",
"keybinding.action.command.desc": "用语音执行命令。",
"keybinding.action.caption": "实时字幕",
"keybinding.action.caption.desc": "开启或关闭实时字幕。",
"keybinding.action.historyPopup": "历史记录弹窗",
"keybinding.action.historyPopup.desc": "打开历史记录弹窗。",
"keybinding.action.commandPopup": "命令弹窗",
"keybinding.action.commandPopup.desc": "打开命令弹窗。",
"keybinding.reject.unknownKey": "不支持该按键",
"keybinding.reject.systemReserved": "这是系统保留的快捷键",
"keybinding.reject.modifierRequired": "必须与修饰键(Ctrl/Alt/Shift)组合使用",
"keybinding.disabled.mouseLeft": "左键用于所有界面操作,无法绑定",
"keybinding.warning.mousePassthrough": "鼠标按键仍会同时执行其原本的操作",
"keybinding.ui.add": "添加",
"keybinding.ui.remove": "删除",
"keybinding.ui.reset": "恢复默认",
"keybinding.ui.tabRecord": "直接输入",
"keybinding.ui.tabList": "从列表选择",
"keybinding.ui.search": "搜索按键",
"keybinding.ui.searchEmpty": "没有搜索结果",
"keybinding.ui.pressKeys": "请按下想要使用的按键或鼠标按键",
"keybinding.ui.noBindings": "未设置快捷键",
"keybinding.ui.conflictWith": "与{{action}}冲突",
"keybinding.ui.modifiers": "修饰键",
"keybinding.ui.selectedKey": "已选按键",
"keybinding.ui.sectionVoice": "语音",
"keybinding.ui.sectionWindow": "窗口 / 弹窗",
"keybinding.ui.duplicate": "该快捷键已添加",
"keybinding.ui.holdMode": "长按",
"keybinding.ui.doublePress": "按两下",
"keybinding.ui.pickerTitle": "快捷键设置",
"keybinding.ui.pressToRecord": "按下开始录音",
"keybinding.ui.recordHint": "请输入组合键(例如:Ctrl+Shift+Q)或单个键(例如:F5)",
"keybinding.ui.recordAgain": "重新输入",
"keybinding.ui.saveFailed": "无法保存快捷键",
"keybinding.ui.globalEnabled": "启用全局快捷键"
}