fix(keybinding): stop stealing chords on Windows, release mouse holds, keep cleared actions
This commit is contained in:
parent
115e8488ac
commit
1a4c39cb94
8 changed files with 927 additions and 148 deletions
|
|
@ -0,0 +1,338 @@
|
|||
// tests/main/services/keybinding-redteam-r2-16.test.ts
|
||||
// 레드팀 r2-16 회귀 테스트 (데스크톱 어댑터).
|
||||
// 1) globalShortcut 은 macOS 에서만, 켜진 기능의 바인딩만 예약한다 — Windows 에서 키를 빼앗지 않는다.
|
||||
// 2) 마우스 hold 바인딩은 누름 중 수정자를 더 눌러도 mouseup 에서 release 된다.
|
||||
// 3) 사용자가 비운 액션([])은 다른 액션 저장 · 재시작 뒤에도 비어 있다.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { globalShortcut, ipcMain } from 'electron'
|
||||
import { uIOhook } from 'uiohook-napi'
|
||||
import type { UiohookMouseEvent } from 'uiohook-napi'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { MouseButton, createDefaultBindingMap } from '@d3ro/core/keybinding'
|
||||
import type { KeyBinding, KeyBindingMap } from '@d3ro/core/types'
|
||||
import type { IPCResult } from '@d3ro/core/errors'
|
||||
import {
|
||||
acceleratorsToReserve,
|
||||
bindingToAccelerator,
|
||||
isKeyBindingActionFeatureEnabled
|
||||
} from '../../../src/main/services/keybinding-accelerator-policy'
|
||||
import type { ReservableBinding } from '../../../src/main/services/keybinding-accelerator-policy'
|
||||
import {
|
||||
configGet,
|
||||
configSet,
|
||||
initInMemoryConfig,
|
||||
loadKeyBindings,
|
||||
resetInMemoryConfig
|
||||
} from '../../../src/main/services/ConfigService'
|
||||
import { getKeyBindingService } from '../../../src/main/services/KeyBindingService'
|
||||
import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService'
|
||||
import { registerKeyBindingHandlers } from '../../../src/main/ipc/keybinding-handlers'
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
|
||||
}
|
||||
|
||||
function restorePlatform(): void {
|
||||
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
|
||||
function registeredAccelerators(): string[] {
|
||||
return vi.mocked(globalShortcut.register).mock.calls.map((call) => String(call[0]))
|
||||
}
|
||||
|
||||
function defaultReservable(): ReservableBinding[] {
|
||||
const map = createDefaultBindingMap()
|
||||
return (Object.keys(map) as (keyof KeyBindingMap)[]).flatMap((actionId) =>
|
||||
map[actionId].map((binding) => ({ actionId, binding }))
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1) accelerator 예약 정책 (순수)
|
||||
// ============================================================
|
||||
|
||||
describe('acceleratorsToReserve', () => {
|
||||
it('Windows 에서는 기본 바인딩을 하나도 예약하지 않는다', () => {
|
||||
expect(
|
||||
acceleratorsToReserve(defaultReservable(), { platform: 'win32', flags: { suggestionEnabled: true } })
|
||||
).toEqual([])
|
||||
expect(
|
||||
acceleratorsToReserve(defaultReservable(), { platform: 'linux', flags: { suggestionEnabled: true } })
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('macOS 에서 제안 기능이 꺼져 있으면 suggestion-* 바인딩은 예약하지 않는다', () => {
|
||||
expect(
|
||||
acceleratorsToReserve(defaultReservable(), { platform: 'darwin', flags: { suggestionEnabled: false } })
|
||||
).toEqual(['Control+Shift+V', 'Control+Shift+C'])
|
||||
})
|
||||
|
||||
it('macOS 에서 제안 기능이 켜져 있으면 suggestion-* 바인딩도 예약한다', () => {
|
||||
expect(
|
||||
acceleratorsToReserve(defaultReservable(), { platform: 'darwin', flags: { suggestionEnabled: true } })
|
||||
).toEqual([
|
||||
'Control+Shift+V',
|
||||
'Control+Shift+C',
|
||||
'Control+Alt+Return',
|
||||
'Control+Alt+Down',
|
||||
'Control+Alt+Up',
|
||||
'Control+Alt+Backspace'
|
||||
])
|
||||
})
|
||||
|
||||
it('한 바인딩을 공유하는 액션 중 하나라도 켜져 있으면 예약하고, 중복은 한 번만 낸다', () => {
|
||||
const shared: KeyBinding = { device: 'keyboard', code: 0x78, ctrl: true, alt: false, shift: true, meta: false }
|
||||
expect(
|
||||
acceleratorsToReserve(
|
||||
[
|
||||
{ actionId: 'suggestion-next', binding: shared },
|
||||
{ actionId: 'command-popup', binding: shared }
|
||||
],
|
||||
{ platform: 'darwin', flags: { suggestionEnabled: false } }
|
||||
)
|
||||
).toEqual(['Control+Shift+F9'])
|
||||
})
|
||||
|
||||
it('마우스 · 수정자 단독 바인딩은 accelerator 로 표현하지 않는다', () => {
|
||||
const x1: KeyBinding = { device: 'mouse', code: MouseButton.Back, ctrl: false, alt: false, shift: false, meta: false }
|
||||
expect(bindingToAccelerator(x1, 'darwin')).toBeNull()
|
||||
expect(bindingToAccelerator(createDefaultBindingMap().dictation[0]!, 'darwin')).toBeNull()
|
||||
})
|
||||
|
||||
it('meta 는 macOS 에서 Cmd, 그 외에서는 Super 다', () => {
|
||||
const b: KeyBinding = { device: 'keyboard', code: 0x4b, ctrl: false, alt: false, shift: false, meta: true }
|
||||
expect(bindingToAccelerator(b, 'darwin')).toBe('Cmd+K')
|
||||
expect(bindingToAccelerator(b, 'win32')).toBe('Super+K')
|
||||
})
|
||||
|
||||
it('기능 스위치가 없는 액션은 항상 켜져 있다', () => {
|
||||
expect(isKeyBindingActionFeatureEnabled('history-popup', { suggestionEnabled: false })).toBe(true)
|
||||
expect(isKeyBindingActionFeatureEnabled('suggestion-accept', { suggestionEnabled: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 1) + 2) KeyBindingService 어댑터
|
||||
// ============================================================
|
||||
|
||||
describe('KeyBindingService — globalShortcut 예약 · 마우스 release', () => {
|
||||
beforeEach(() => {
|
||||
initInMemoryConfig()
|
||||
vi.mocked(globalShortcut.register).mockClear()
|
||||
vi.mocked(globalShortcut.unregister).mockClear()
|
||||
vi.mocked(uIOhook.on).mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
getKeyBindingService().stop()
|
||||
restorePlatform()
|
||||
resetInMemoryConfig()
|
||||
})
|
||||
|
||||
it('Windows 에서는 기본 설정으로 시작해도 globalShortcut 을 하나도 등록하지 않는다', () => {
|
||||
setPlatform('win32')
|
||||
const service = getKeyBindingService()
|
||||
service.loadFromConfig()
|
||||
service.start()
|
||||
expect(service.registeredBindingCount).toBeGreaterThan(0)
|
||||
expect(globalShortcut.register).not.toHaveBeenCalled()
|
||||
|
||||
configSet('suggestionEnabled', true)
|
||||
expect(globalShortcut.register).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('macOS 에서는 켜진 기능만 예약하고, 제안 기능을 켜고 끄면 다시 맞춘다', () => {
|
||||
setPlatform('darwin')
|
||||
const service = getKeyBindingService()
|
||||
service.loadFromConfig()
|
||||
service.start()
|
||||
expect(registeredAccelerators()).toEqual(['Control+Shift+V', 'Control+Shift+C'])
|
||||
|
||||
vi.mocked(globalShortcut.register).mockClear()
|
||||
configSet('suggestionEnabled', true)
|
||||
expect(registeredAccelerators()).toContain('Control+Alt+Down')
|
||||
|
||||
vi.mocked(globalShortcut.register).mockClear()
|
||||
configSet('suggestionEnabled', false)
|
||||
expect(registeredAccelerators()).toEqual(['Control+Shift+V', 'Control+Shift+C'])
|
||||
})
|
||||
|
||||
it('X1 hold 중 Alt 를 더 누른 채 버튼을 놓아도 dictation 이 released 된다', () => {
|
||||
setPlatform('win32')
|
||||
const map = createDefaultBindingMap()
|
||||
map.dictation = [{ device: 'mouse', code: MouseButton.Back, ctrl: false, alt: false, shift: false, meta: false }]
|
||||
configSet('keyBindings', map)
|
||||
|
||||
const service = getKeyBindingService()
|
||||
service.loadFromConfig()
|
||||
service.start()
|
||||
|
||||
const handlerOf = (event: string): ((e: UiohookMouseEvent) => void) => {
|
||||
const call = vi.mocked(uIOhook.on).mock.calls.find((c) => c[0] === event)
|
||||
if (!call) throw new Error(`no ${event} handler`)
|
||||
return call[1] as (e: UiohookMouseEvent) => void
|
||||
}
|
||||
const events: string[] = []
|
||||
const listener = (p: KeyBindingTriggerPayload): void => {
|
||||
events.push(`${p.actionId}:${p.type}`)
|
||||
}
|
||||
service.on('triggered', listener)
|
||||
|
||||
const mouseEvent = (alt: boolean): UiohookMouseEvent =>
|
||||
({ button: MouseButton.Back, altKey: alt, ctrlKey: false, shiftKey: false, metaKey: false }) as unknown as UiohookMouseEvent
|
||||
|
||||
handlerOf('mousedown')(mouseEvent(false))
|
||||
handlerOf('mouseup')(mouseEvent(true))
|
||||
service.off('triggered', listener)
|
||||
|
||||
expect(events).toEqual(['dictation:pressed', 'dictation:released'])
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 3) 비운 액션의 영속
|
||||
// ============================================================
|
||||
|
||||
type Handler = (event: unknown, ...args: unknown[]) => Promise<IPCResult<unknown>>
|
||||
|
||||
function captureHandlers(): Map<string, Handler> {
|
||||
const handlers = new Map<string, Handler>()
|
||||
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Handler) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
registerKeyBindingHandlers()
|
||||
return handlers
|
||||
}
|
||||
|
||||
async function invoke(handlers: Map<string, Handler>, channel: string, params?: unknown): Promise<IPCResult<unknown>> {
|
||||
const handler = handlers.get(channel)
|
||||
if (!handler) throw new Error(`IPC handler not registered: ${channel}`)
|
||||
return handler({}, params)
|
||||
}
|
||||
|
||||
const FREE_BINDING: KeyBinding = { device: 'keyboard', code: 0x78, ctrl: true, alt: false, shift: true, meta: false }
|
||||
|
||||
describe('keybinding IPC — 비운 액션 보존', () => {
|
||||
beforeEach(() => {
|
||||
initInMemoryConfig()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
getKeyBindingService().stop()
|
||||
resetInMemoryConfig()
|
||||
})
|
||||
|
||||
it('history-popup 을 비운 뒤 다른 액션을 저장해도 history-popup 은 비어 있다 (GET_MAP 포함)', async () => {
|
||||
const handlers = captureHandlers()
|
||||
const cleared = await invoke(handlers, IPC_CHANNELS.KEYBINDING.SET_BINDINGS, {
|
||||
actionId: 'history-popup',
|
||||
bindings: []
|
||||
})
|
||||
expect(cleared.success).toBe(true)
|
||||
|
||||
const other = await invoke(handlers, IPC_CHANNELS.KEYBINDING.SET_BINDINGS, {
|
||||
actionId: 'dictation',
|
||||
bindings: [FREE_BINDING]
|
||||
})
|
||||
expect(other.success).toBe(true)
|
||||
|
||||
expect(configGet('keyBindings')['history-popup']).toEqual([])
|
||||
const map = await invoke(handlers, IPC_CHANNELS.KEYBINDING.GET_MAP)
|
||||
expect(map.success && (map.data as KeyBindingMap)['history-popup']).toEqual([])
|
||||
expect(loadKeyBindings()['history-popup']).toEqual([])
|
||||
})
|
||||
|
||||
it('비운 액션은 런타임에도 등록되지 않는다', async () => {
|
||||
const handlers = captureHandlers()
|
||||
const before = (() => {
|
||||
getKeyBindingService().loadFromConfig()
|
||||
return getKeyBindingService().registeredBindingCount
|
||||
})()
|
||||
await invoke(handlers, IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { actionId: 'history-popup', bindings: [] })
|
||||
expect(getKeyBindingService().registeredBindingCount).toBe(before - 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigService 재시작 — 비운 액션 보존', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('electron-store')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('저장소에 []로 남은 액션은 initConfigService 마이그레이션 뒤에도 [] 다', async () => {
|
||||
const persisted: Record<string, unknown> = {
|
||||
suggestionTuningRevision: 6,
|
||||
keyBindings: { ...createDefaultBindingMap(), 'history-popup': [] }
|
||||
}
|
||||
const setCalls: string[] = []
|
||||
|
||||
class TestStore<T extends Record<string, unknown>> {
|
||||
store: T
|
||||
|
||||
constructor(options: { defaults: T }) {
|
||||
this.store = { ...options.defaults, ...persisted } as T
|
||||
}
|
||||
|
||||
get<K extends keyof T>(key: K): T[K] {
|
||||
return this.store[key]
|
||||
}
|
||||
|
||||
set<K extends keyof T>(key: K, value: T[K]): void {
|
||||
setCalls.push(String(key))
|
||||
this.store[key] = value
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
delete this.store[key as keyof T]
|
||||
}
|
||||
}
|
||||
|
||||
vi.doMock('electron-store', () => ({ default: TestStore }))
|
||||
const fresh = await import('../../../src/main/services/ConfigService')
|
||||
await fresh.initConfigService()
|
||||
|
||||
expect(fresh.configGet('keyBindings')['history-popup']).toEqual([])
|
||||
expect(fresh.loadKeyBindings()['history-popup']).toEqual([])
|
||||
// 이미 정본 모양이면 다시 쓰지 않는다.
|
||||
expect(setCalls).not.toContain('keyBindings')
|
||||
fresh.resetInMemoryConfig()
|
||||
})
|
||||
|
||||
it('새 액션이 빠진 저장값은 그 액션만 기본값으로 채워 다시 쓴다', async () => {
|
||||
const stored: Partial<KeyBindingMap> = { ...createDefaultBindingMap(), 'history-popup': [] }
|
||||
delete stored['suggestion-dismiss']
|
||||
const persisted: Record<string, unknown> = { suggestionTuningRevision: 6, keyBindings: stored }
|
||||
|
||||
class TestStore<T extends Record<string, unknown>> {
|
||||
store: T
|
||||
|
||||
constructor(options: { defaults: T }) {
|
||||
this.store = { ...options.defaults, ...persisted } as T
|
||||
}
|
||||
|
||||
get<K extends keyof T>(key: K): T[K] {
|
||||
return this.store[key]
|
||||
}
|
||||
|
||||
set<K extends keyof T>(key: K, value: T[K]): void {
|
||||
this.store[key] = value
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
delete this.store[key as keyof T]
|
||||
}
|
||||
}
|
||||
|
||||
vi.doMock('electron-store', () => ({ default: TestStore }))
|
||||
const fresh = await import('../../../src/main/services/ConfigService')
|
||||
await fresh.initConfigService()
|
||||
|
||||
const bindings = fresh.configGet('keyBindings')
|
||||
expect(bindings['suggestion-dismiss']).toEqual(createDefaultBindingMap()['suggestion-dismiss'])
|
||||
expect(bindings['history-popup']).toEqual([])
|
||||
fresh.resetInMemoryConfig()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue