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:
parent
0ca9e242fa
commit
4ad1ae6ed4
49 changed files with 5901 additions and 1792 deletions
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
238
apps/desktop/src/main/ipc/keybinding-handlers.ts
Normal file
238
apps/desktop/src/main/ipc/keybinding-handlers.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue