126 lines
4.9 KiB
TypeScript
126 lines
4.9 KiB
TypeScript
// src/main/services/text-insert/nut-keyboard-port.ts
|
|
// 키 입력 시뮬레이션 포트와 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'
|
|
|
|
const logger = getLogger('TextInsertService')
|
|
|
|
/** 텍스트 삽입에 필요한 키 입력만 노출하는 포트. */
|
|
export interface KeyboardPort {
|
|
/** 플랫폼 붙여넣기 단축키 (macOS ⌘+V, 그 외 Ctrl+V) */
|
|
paste(): Promise<void>
|
|
/** 글자 단위 타이핑 (클립보드 비파괴) */
|
|
type(text: string): Promise<void>
|
|
}
|
|
|
|
/**
|
|
* nut-js 가 키 이벤트마다 넣는 지연(ms).
|
|
* nut-js 기본값은 300ms 로, pressKey/releaseKey/글자마다 그만큼 잠든다 — Ctrl 이 300ms 동안
|
|
* 눌린 채 남아 사용자의 다음 키가 단축키가 되고, 60자 타이핑에 18초가 걸린다.
|
|
* 앱이 붙여넣기를 처리할 시간은 TextInsertService 가 자기 sleep 으로 따로 준다.
|
|
*/
|
|
export const NUT_KEY_DELAY_MS = 0
|
|
|
|
/** 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 _nut: LoadedNut | null = null
|
|
private _nutLoadPromise: Promise<void> | null = null
|
|
|
|
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.native.click(pasteModKey, nut.Key.V)
|
|
}
|
|
|
|
/** 텍스트 전체를 네이티브 호출 한 번으로 타이핑한다 (글자 사이 JS 대기 없음). */
|
|
async type(text: string): Promise<void> {
|
|
if (!text) return
|
|
const nut = await this._ensureNut()
|
|
await nut.native.type(text)
|
|
}
|
|
|
|
private async _ensureNut(): Promise<LoadedNut> {
|
|
if (this._nut) return this._nut
|
|
|
|
if (!this._nutLoadPromise) {
|
|
this._nutLoadPromise = (async () => {
|
|
try {
|
|
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)}`)
|
|
throw new D3ROError(
|
|
ErrorCode.TextInsertKeySimulationFailed,
|
|
'nut-js 로드 실패. 키보드 시뮬레이션을 사용할 수 없습니다.'
|
|
)
|
|
}
|
|
})()
|
|
}
|
|
|
|
await this._nutLoadPromise
|
|
if (!this._nut) {
|
|
throw new D3ROError(ErrorCode.TextInsertKeySimulationFailed, 'nut-js not available')
|
|
}
|
|
return this._nut
|
|
}
|
|
}
|