fix(text-insert): drop nut-js 300ms key delay and send paste as one chord

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 9e5b94ced2
commit 96b24e279c
2 changed files with 312 additions and 21 deletions

View file

@ -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<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 {
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<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 {
private _nutKeyboard: NutKeyboard | null = null
private _nut: LoadedNut | 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> {
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<void> {
if (!text) return
const nut = await this._ensureNut()
await nut.type(text)
await nut.native.type(text)
}
private async _ensureNut(): Promise<NutKeyboard> {
if (this._nutKeyboard) return this._nutKeyboard
private async _ensureNut(): Promise<LoadedNut> {
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
}
}