fix(text-insert): drop nut-js 300ms key delay and send paste as one chord
This commit is contained in:
parent
9e5b94ced2
commit
96b24e279c
2 changed files with 312 additions and 21 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
// 키 입력 시뮬레이션 포트와 nut-js 어댑터.
|
// 키 입력 시뮬레이션 포트와 nut-js 어댑터.
|
||||||
// nut-js 는 ESM + 네이티브 모듈이므로 첫 사용 시 lazy dynamic import 한다.
|
// nut-js 는 ESM + 네이티브 모듈이므로 첫 사용 시 lazy dynamic import 한다.
|
||||||
|
|
||||||
|
import type { Key as NutKey } from '@nut-tree-fork/nut-js'
|
||||||
import { getLogger } from '../LoggerService'
|
import { getLogger } from '../LoggerService'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
|
|
||||||
|
|
@ -15,46 +16,96 @@ export interface KeyboardPort {
|
||||||
type(text: string): Promise<void>
|
type(text: string): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
/** nut-js 네이티브 키보드 provider 중 이 어댑터가 쓰는 부분. */
|
||||||
pressKey: NutModule['keyboard']['pressKey']
|
export interface NutNativeKeyboard {
|
||||||
releaseKey: NutModule['keyboard']['releaseKey']
|
setKeyboardDelay(delay: number): void
|
||||||
type: NutModule['keyboard']['type']
|
/** 문자열 전체를 네이티브 호출 한 번으로 타이핑 */
|
||||||
Key: NutModule['Key']
|
type(input: string): Promise<void>
|
||||||
|
/** modifier down → 키 down/up → modifier up 을 네이티브 호출 한 번(keyTap)으로 */
|
||||||
|
click(...keys: NutKey[]): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이 어댑터가 nut-js 모듈에서 쓰는 부분만 적은 구조 타입.
|
||||||
|
* 실제 모듈이 그대로 맞고, 테스트는 가짜 provider 로 만든 모듈을 넣을 수 있다.
|
||||||
|
*/
|
||||||
|
export interface NutKeyboardModule {
|
||||||
|
keyboard: { config: { autoDelayMs: number } }
|
||||||
|
providerRegistry: {
|
||||||
|
hasKeyboard(): boolean
|
||||||
|
getKeyboard(): NutNativeKeyboard
|
||||||
|
}
|
||||||
|
Key: Pick<typeof NutKey, 'LeftControl' | 'LeftSuper' | 'V'>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NutModuleLoader = () => Promise<NutKeyboardModule>
|
||||||
|
|
||||||
|
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 {
|
export class NutKeyboardPort implements KeyboardPort {
|
||||||
private _nutKeyboard: NutKeyboard | null = null
|
private _nut: LoadedNut | null = null
|
||||||
private _nutLoadPromise: Promise<void> | null = null
|
private _nutLoadPromise: Promise<void> | 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<void> {
|
async paste(): Promise<void> {
|
||||||
const nut = await this._ensureNut()
|
const nut = await this._ensureNut()
|
||||||
const pasteModKey = this._platform === 'darwin' ? nut.Key.LeftSuper : nut.Key.LeftControl
|
const pasteModKey = this._platform === 'darwin' ? nut.Key.LeftSuper : nut.Key.LeftControl
|
||||||
await nut.pressKey(pasteModKey, nut.Key.V)
|
await nut.native.click(pasteModKey, nut.Key.V)
|
||||||
await nut.releaseKey(nut.Key.V, pasteModKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 텍스트 전체를 네이티브 호출 한 번으로 타이핑한다 (글자 사이 JS 대기 없음). */
|
||||||
async type(text: string): Promise<void> {
|
async type(text: string): Promise<void> {
|
||||||
|
if (!text) return
|
||||||
const nut = await this._ensureNut()
|
const nut = await this._ensureNut()
|
||||||
await nut.type(text)
|
await nut.native.type(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _ensureNut(): Promise<NutKeyboard> {
|
private async _ensureNut(): Promise<LoadedNut> {
|
||||||
if (this._nutKeyboard) return this._nutKeyboard
|
if (this._nut) return this._nut
|
||||||
|
|
||||||
if (!this._nutLoadPromise) {
|
if (!this._nutLoadPromise) {
|
||||||
this._nutLoadPromise = (async () => {
|
this._nutLoadPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const nut = await import('@nut-tree-fork/nut-js')
|
const nut = await this._load()
|
||||||
this._nutKeyboard = {
|
if (!nut.providerRegistry.hasKeyboard()) {
|
||||||
pressKey: nut.keyboard.pressKey.bind(nut.keyboard),
|
throw new Error('no native keyboard provider registered')
|
||||||
releaseKey: nut.keyboard.releaseKey.bind(nut.keyboard),
|
|
||||||
type: nut.keyboard.type.bind(nut.keyboard),
|
|
||||||
Key: nut.Key
|
|
||||||
}
|
}
|
||||||
|
configureNutKeyDelay(nut)
|
||||||
|
this._nut = { native: nut.providerRegistry.getKeyboard(), Key: nut.Key }
|
||||||
logger.info('nut-js loaded successfully')
|
logger.info('nut-js loaded successfully')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to load nut-js: ${error instanceof Error ? error.message : String(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
|
await this._nutLoadPromise
|
||||||
if (!this._nutKeyboard) {
|
if (!this._nut) {
|
||||||
throw new D3ROError(ErrorCode.TextInsertKeySimulationFailed, 'nut-js not available')
|
throw new D3ROError(ErrorCode.TextInsertKeySimulationFailed, 'nut-js not available')
|
||||||
}
|
}
|
||||||
return this._nutKeyboard
|
return this._nut
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
240
apps/desktop/tests/main/services/textinsert-redteam-r2-4.test.ts
Normal file
240
apps/desktop/tests/main/services/textinsert-redteam-r2-4.test.ts
Normal file
|
|
@ -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<typeof KeyboardClass>[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<void> => {
|
||||||
|
this.events.push({ kind: 'type', char })
|
||||||
|
},
|
||||||
|
click: async (...keys: Key[]): Promise<void> => {
|
||||||
|
this.events.push({ kind: 'click', keys })
|
||||||
|
},
|
||||||
|
pressKey: async (...keys: Key[]): Promise<void> => {
|
||||||
|
this.events.push({ kind: 'press', keys })
|
||||||
|
},
|
||||||
|
releaseKey: async (...keys: Key[]): Promise<void> => {
|
||||||
|
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<unknown>): Promise<number> {
|
||||||
|
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<ClipboardImage> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue