diff --git a/apps/desktop/src/main/services/text-insert/nut-keyboard-port.ts b/apps/desktop/src/main/services/text-insert/nut-keyboard-port.ts index 659a1c5..66e6f69 100644 --- a/apps/desktop/src/main/services/text-insert/nut-keyboard-port.ts +++ b/apps/desktop/src/main/services/text-insert/nut-keyboard-port.ts @@ -2,6 +2,7 @@ // 키 입력 시뮬레이션 포트와 nut-js 어댑터. // nut-js 는 ESM + 네이티브 모듈이므로 첫 사용 시 lazy dynamic import 한다. +import type { Key as NutKey } from '@nut-tree-fork/nut-js' import { getLogger } from '../LoggerService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' @@ -15,46 +16,96 @@ export interface KeyboardPort { type(text: string): Promise } -type NutModule = typeof import('@nut-tree-fork/nut-js') +/** + * nut-js 가 키 이벤트마다 넣는 지연(ms). + * nut-js 기본값은 300ms 로, pressKey/releaseKey/글자마다 그만큼 잠든다 — Ctrl 이 300ms 동안 + * 눌린 채 남아 사용자의 다음 키가 단축키가 되고, 60자 타이핑에 18초가 걸린다. + * 앱이 붙여넣기를 처리할 시간은 TextInsertService 가 자기 sleep 으로 따로 준다. + */ +export const NUT_KEY_DELAY_MS = 0 -interface NutKeyboard { - pressKey: NutModule['keyboard']['pressKey'] - releaseKey: NutModule['keyboard']['releaseKey'] - type: NutModule['keyboard']['type'] - Key: NutModule['Key'] +/** nut-js 네이티브 키보드 provider 중 이 어댑터가 쓰는 부분. */ +export interface NutNativeKeyboard { + setKeyboardDelay(delay: number): void + /** 문자열 전체를 네이티브 호출 한 번으로 타이핑 */ + type(input: string): Promise + /** modifier down → 키 down/up → modifier up 을 네이티브 호출 한 번(keyTap)으로 */ + click(...keys: NutKey[]): Promise } +/** + * 이 어댑터가 nut-js 모듈에서 쓰는 부분만 적은 구조 타입. + * 실제 모듈이 그대로 맞고, 테스트는 가짜 provider 로 만든 모듈을 넣을 수 있다. + */ +export interface NutKeyboardModule { + keyboard: { config: { autoDelayMs: number } } + providerRegistry: { + hasKeyboard(): boolean + getKeyboard(): NutNativeKeyboard + } + Key: Pick +} + +export type NutModuleLoader = () => Promise + +const loadNutModule: NutModuleLoader = () => import('@nut-tree-fork/nut-js') + +/** + * nut-js 의 JS 쪽 autoDelay 와 네이티브 keyboardDelay 를 모두 NUT_KEY_DELAY_MS 로 맞춘다. + * 네이티브 지연은 키 토글 뒤 메인 스레드를 동기로 막으므로 JS 설정만으로는 부족하다. + */ +export function configureNutKeyDelay(nut: NutKeyboardModule, delayMs: number = NUT_KEY_DELAY_MS): void { + nut.keyboard.config.autoDelayMs = delayMs + if (nut.providerRegistry.hasKeyboard()) { + nut.providerRegistry.getKeyboard().setKeyboardDelay(delayMs) + } +} + +interface LoadedNut { + native: NutNativeKeyboard + Key: NutKeyboardModule['Key'] +} + +/** + * nut-js 네이티브 provider 로 키를 보내는 KeyboardPort 어댑터. + * KeyboardClass(pressKey/releaseKey/type)를 거치지 않는다 — 그 경로는 키 이벤트·글자마다 + * JS setTimeout 을 끼워 넣어, 지연 0 이어도 Windows 타이머 해상도(~15ms)만큼 글자가 늘어진다. + */ export class NutKeyboardPort implements KeyboardPort { - private _nutKeyboard: NutKeyboard | null = null + private _nut: LoadedNut | null = null private _nutLoadPromise: Promise | null = null - constructor(private readonly _platform: NodeJS.Platform = process.platform) {} + constructor( + private readonly _platform: NodeJS.Platform = process.platform, + private readonly _load: NutModuleLoader = loadNutModule + ) {} + /** 붙여넣기 단축키를 한 번의 코드(chord)로 보내 await 사이에 modifier 가 눌린 채 남지 않게 한다. */ async paste(): Promise { const nut = await this._ensureNut() const pasteModKey = this._platform === 'darwin' ? nut.Key.LeftSuper : nut.Key.LeftControl - await nut.pressKey(pasteModKey, nut.Key.V) - await nut.releaseKey(nut.Key.V, pasteModKey) + await nut.native.click(pasteModKey, nut.Key.V) } + /** 텍스트 전체를 네이티브 호출 한 번으로 타이핑한다 (글자 사이 JS 대기 없음). */ async type(text: string): Promise { + if (!text) return const nut = await this._ensureNut() - await nut.type(text) + await nut.native.type(text) } - private async _ensureNut(): Promise { - if (this._nutKeyboard) return this._nutKeyboard + private async _ensureNut(): Promise { + if (this._nut) return this._nut if (!this._nutLoadPromise) { this._nutLoadPromise = (async () => { try { - const nut = await import('@nut-tree-fork/nut-js') - this._nutKeyboard = { - pressKey: nut.keyboard.pressKey.bind(nut.keyboard), - releaseKey: nut.keyboard.releaseKey.bind(nut.keyboard), - type: nut.keyboard.type.bind(nut.keyboard), - Key: nut.Key + const nut = await this._load() + if (!nut.providerRegistry.hasKeyboard()) { + throw new Error('no native keyboard provider registered') } + configureNutKeyDelay(nut) + this._nut = { native: nut.providerRegistry.getKeyboard(), Key: nut.Key } logger.info('nut-js loaded successfully') } catch (error) { logger.error(`Failed to load nut-js: ${error instanceof Error ? error.message : String(error)}`) @@ -67,9 +118,9 @@ export class NutKeyboardPort implements KeyboardPort { } await this._nutLoadPromise - if (!this._nutKeyboard) { + if (!this._nut) { throw new D3ROError(ErrorCode.TextInsertKeySimulationFailed, 'nut-js not available') } - return this._nutKeyboard + return this._nut } } diff --git a/apps/desktop/tests/main/services/textinsert-redteam-r2-4.test.ts b/apps/desktop/tests/main/services/textinsert-redteam-r2-4.test.ts new file mode 100644 index 0000000..101de97 --- /dev/null +++ b/apps/desktop/tests/main/services/textinsert-redteam-r2-4.test.ts @@ -0,0 +1,240 @@ +// tests/main/services/textinsert-redteam-r2-4.test.ts +// 회귀: nut-js 기본 autoDelay(300ms)가 그대로 남아 붙여넣기마다 Ctrl 이 ~300ms 눌린 채 +// 유지되고(사용자의 다음 키가 Ctrl 단축키가 됨), 키보드 삽입이 글자당 300ms 걸리던 버그. +// 실제 nut-js KeyboardClass 를 가짜 네이티브 provider 위에 올려 검증한다 (키는 실제로 눌리지 않는다). + +import { describe, it, expect, vi } from 'vitest' +import { KeyboardClass } from '@nut-tree-fork/nut-js/dist/lib/keyboard.class' +import { Key } from '@nut-tree-fork/shared' +import { + NutKeyboardPort, + NUT_KEY_DELAY_MS, + configureNutKeyDelay, + type NutKeyboardModule +} from '../../../src/main/services/text-insert/nut-keyboard-port' +import { TextInsertService } from '../../../src/main/services/TextInsertService' +import type { ClipboardImage, ClipboardPort } from '../../../src/main/services/text-insert/clipboard-snapshot' +import { ErrorCode } from '@d3ro/core/errors' + +type ProviderRegistryArg = ConstructorParameters[0] + +type NativeEvent = + | { kind: 'type'; char: string } + | { kind: 'click'; keys: Key[] } + | { kind: 'press'; keys: Key[] } + | { kind: 'release'; keys: Key[] } + +/** libnut 대신 이벤트만 기록하는 가짜 provider registry. */ +class FakeRegistry { + events: NativeEvent[] = [] + nativeDelays: number[] = [] + private readonly _log = { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn(), trace: vi.fn() } + private readonly _keyboard = { + setKeyboardDelay: (delay: number): void => { + this.nativeDelays.push(delay) + }, + type: async (char: string): Promise => { + this.events.push({ kind: 'type', char }) + }, + click: async (...keys: Key[]): Promise => { + this.events.push({ kind: 'click', keys }) + }, + pressKey: async (...keys: Key[]): Promise => { + this.events.push({ kind: 'press', keys }) + }, + releaseKey: async (...keys: Key[]): Promise => { + this.events.push({ kind: 'release', keys }) + } + } + + hasKeyboard(): boolean { + return true + } + getKeyboard(): typeof this._keyboard { + return this._keyboard + } + getLogProvider(): typeof this._log { + return this._log + } +} + +function makeNut(): { nut: NutKeyboardModule; registry: FakeRegistry; keyboard: KeyboardClass } { + const registry = new FakeRegistry() + const keyboard = new KeyboardClass(registry as unknown as ProviderRegistryArg) + return { nut: { keyboard, providerRegistry: registry, Key }, registry, keyboard } +} + +async function timed(fn: () => Promise): Promise { + const start = performance.now() + await fn() + return performance.now() - start +} + +describe('NutKeyboardPort key delay (redteam r2-4)', () => { + it('real nut-js KeyboardClass starts with a 300ms autoDelay (the hazard being fixed)', () => { + const { keyboard, registry } = makeNut() + expect(keyboard.config.autoDelayMs).toBe(300) + expect(registry.nativeDelays).toEqual([300]) + }) + + it('zeroes both the JS autoDelay and the native keyboard delay on load', async () => { + const { nut, registry, keyboard } = makeNut() + const port = new NutKeyboardPort('win32', async () => nut) + + await port.paste() + + expect(NUT_KEY_DELAY_MS).toBe(0) + expect(keyboard.config.autoDelayMs).toBe(0) + expect(registry.nativeDelays.at(-1)).toBe(0) + }) + + it('sends Ctrl+V as a single native chord without holding the modifier across awaits', async () => { + const { nut, registry } = makeNut() + const port = new NutKeyboardPort('win32', async () => nut) + + const elapsed = await timed(() => port.paste()) + + expect(registry.events).toEqual([{ kind: 'click', keys: [Key.LeftControl, Key.V] }]) + expect(registry.events.some((e) => e.kind === 'press' || e.kind === 'release')).toBe(false) + // 수정 전: pressKey·releaseKey 앞에서 각각 300ms → 600ms 이상 + expect(elapsed).toBeLessThan(150) + }) + + it('uses the Super (⌘) modifier on macOS', async () => { + const { nut, registry } = makeNut() + const port = new NutKeyboardPort('darwin', async () => nut) + + await port.paste() + + expect(registry.events).toEqual([{ kind: 'click', keys: [Key.LeftSuper, Key.V] }]) + }) + + it('types a 60-character text without per-character 300ms sleeps', async () => { + const { nut, registry } = makeNut() + const port = new NutKeyboardPort('win32', async () => nut) + const text = '가나다라마바사아자차카타파하 hello world, this is a suggestion!!'.padEnd(60, '.') + + const elapsed = await timed(() => port.type(text)) + + // 글자마다 JS sleep 을 끼우는 KeyboardClass.type 대신 네이티브 호출 한 번 + expect(registry.events).toEqual([{ kind: 'type', char: text }]) + // 수정 전: 60 × 300ms = 18s + expect(elapsed).toBeLessThan(150) + }) + + it('loads nut-js once and reuses it', async () => { + const { nut } = makeNut() + const load = vi.fn(async () => nut) + const port = new NutKeyboardPort('win32', load) + + await Promise.all([port.paste(), port.type('a'), port.paste()]) + + expect(load).toHaveBeenCalledTimes(1) + }) + + it('does not touch the keyboard for empty text', async () => { + const { nut, registry } = makeNut() + const load = vi.fn(async () => nut) + const port = new NutKeyboardPort('win32', load) + + await port.type('') + + expect(load).not.toHaveBeenCalled() + expect(registry.events).toEqual([]) + }) + + it('fails with TextInsertKeySimulationFailed when no native keyboard provider is registered', async () => { + const { nut, registry } = makeNut() + const port = new NutKeyboardPort('win32', async () => ({ + ...nut, + providerRegistry: { hasKeyboard: () => false, getKeyboard: () => registry.getKeyboard() } + })) + + await expect(port.paste()).rejects.toMatchObject({ code: ErrorCode.TextInsertKeySimulationFailed }) + }) + + it('maps a load failure to TextInsertKeySimulationFailed', async () => { + const port = new NutKeyboardPort('win32', async () => { + throw new Error('native module missing') + }) + + await expect(port.paste()).rejects.toMatchObject({ code: ErrorCode.TextInsertKeySimulationFailed }) + }) + + it('configureNutKeyDelay skips the native delay when no keyboard provider is registered', () => { + const { nut, keyboard } = makeNut() + const setKeyboardDelay = vi.fn() + const noProvider: NutKeyboardModule = { + ...nut, + providerRegistry: { + hasKeyboard: () => false, + getKeyboard: () => ({ setKeyboardDelay, type: vi.fn(), click: vi.fn() }) + } + } + + configureNutKeyDelay(noProvider, 5) + + expect(keyboard.config.autoDelayMs).toBe(5) + expect(setKeyboardDelay).not.toHaveBeenCalled() + }) +}) + +describe('TextInsertService with the nut-js adapter (redteam r2-4)', () => { + function memoryClipboard(): ClipboardPort { + let text = 'original' + const empty: ClipboardImage = { isEmpty: () => true } + return { + availableFormats: () => (text ? ['text/plain'] : []), + readText: () => text, + readHTML: () => '', + readRTF: () => '', + readImage: () => empty, + readBuffer: () => Buffer.alloc(0), + writeText: (value: string) => { + text = value + }, + write: (data) => { + text = data.text ?? '' + }, + writeBuffer: () => undefined, + clear: () => { + text = '' + } + } + } + + it('clipboard insert sends one paste chord and restores the clipboard', async () => { + const { nut, registry } = makeNut() + const clipboard = memoryClipboard() + const service = new TextInsertService({ + clipboard, + keyboard: new NutKeyboardPort('win32', async () => nut), + sleep: async () => undefined, + platform: 'win32' + }) + + const result = await service.insertText('dictated text') + + expect(result.method).toBe('clipboard') + expect(registry.events).toEqual([{ kind: 'click', keys: [Key.LeftControl, Key.V] }]) + expect(clipboard.readText()).toBe('original') + expect(result.durationMs).toBeLessThan(150) + }) + + it('keyboard insert finishes promptly instead of 300ms per character', async () => { + const { nut, registry } = makeNut() + const service = new TextInsertService({ + clipboard: memoryClipboard(), + keyboard: new NutKeyboardPort('win32', async () => nut), + sleep: async () => undefined, + platform: 'win32' + }) + const text = 'x'.repeat(60) + + const result = await service.insertText(text, 'keyboard') + + expect(result.method).toBe('keyboard') + expect(registry.events).toEqual([{ kind: 'type', char: text }]) + expect(result.durationMs).toBeLessThan(150) + }) +})