// src/main/services/TextInsertService.ts // 전사된 텍스트를 현재 활성 앱에 삽입한다. // 설계서 01의 ITextInsertService 구현. Speakly ClipboardPaste 패턴. // @nut-tree-fork/nut-js + electron clipboard API 사용. import { EventEmitter } from 'events' import { clipboard } from 'electron' import { getLogger } from './LoggerService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' const logger = getLogger('TextInsertService') // ============================================================ // 타입 // ============================================================ type InsertMethod = 'clipboard' | 'keyboard' interface ClipboardSnapshot { text: string | null html: string | null image: Electron.NativeImage | null rtf: string | null hasContent: boolean } interface InsertResult { success: boolean method: InsertMethod textLength: number durationMs: number } interface TextInsertEvents { 'insert-started': (payload: { text: string; method: InsertMethod }) => void 'insert-completed': (payload: { result: InsertResult }) => void 'insert-failed': (payload: { error: D3ROError; method: InsertMethod }) => void 'clipboard-saved': (payload: Record) => void 'clipboard-restored': (payload: Record) => void } // ============================================================ // TextInsertService // ============================================================ class TextInsertService extends EventEmitter { private _nutKeyboard: NutKeyboard | null = null private _nutLoadPromise: Promise | null = null /** * nut-js는 ESM + 네이티브 모듈이므로 lazy dynamic import 한다. */ private async _ensureNut(): Promise { if (this._nutKeyboard) return this._nutKeyboard 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 } 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._nutKeyboard) { throw new D3ROError(ErrorCode.TextInsertKeySimulationFailed, 'nut-js not available') } return this._nutKeyboard } /** * 텍스트를 현재 활성 앱에 삽입한다. * 기본 전략: clipboard save → set → Ctrl+V → restore */ async insertText(text: string, method: InsertMethod = 'clipboard'): Promise { const start = performance.now() this.emit('insert-started', { text, method }) try { if (method === 'clipboard') { await this._insertViaClipboard(text) } else { await this._insertViaKeyboard(text) } const result: InsertResult = { success: true, method, textLength: text.length, durationMs: performance.now() - start } this.emit('insert-completed', { result }) logger.info(`Text inserted (${text.length} chars, ${Math.round(result.durationMs)}ms)`) return result } catch (error) { const d3roError = error instanceof D3ROError ? error : new D3ROError( ErrorCode.TextInsertFailed, `Text insert failed: ${error instanceof Error ? error.message : String(error)}` ) this.emit('insert-failed', { error: d3roError, method }) throw d3roError } } /** * 클립보드 방식: save → set → Cmd/Ctrl+V → restore (Speakly 패턴) * macOS는 ⌘+V, Windows/Linux는 Ctrl+V로 분기. */ private async _insertViaClipboard(text: string): Promise { // 1. 기존 클립보드 저장 const snapshot = this.saveClipboard() this.emit('clipboard-saved', {}) try { // 2. 클립보드에 텍스트 설정 clipboard.writeText(text) // 짧은 지연: 일부 앱이 클립보드 변경을 받아들일 시간 필요 await this._sleep(20) // 3. Paste 단축키 시뮬레이션 — 플랫폼별 modifier const nut = await this._ensureNut() const pasteModKey = process.platform === 'darwin' ? nut.Key.LeftSuper : nut.Key.LeftControl await nut.pressKey(pasteModKey, nut.Key.V) await nut.releaseKey(nut.Key.V, pasteModKey) // 4. 붙여넣기 완료 대기 — 앱이 paste 이벤트를 처리할 시간 // macOS는 비동기 처리가 좀 더 느린 경우가 있어 250ms로 잡음 await this._sleep(process.platform === 'darwin' ? 250 : 150) // 5. 클립보드 복원 (원래 내용으로 되돌림) this.restoreClipboard(snapshot) this.emit('clipboard-restored', {}) } catch (error) { // 실패 시에도 클립보드 복원 시도 try { this.restoreClipboard(snapshot) } catch { logger.warn('Failed to restore clipboard after insert error') } throw error } } /** * 키보드 방식: 한 글자씩 타이핑 (느리지만 클립보드 비파괴) */ private async _insertViaKeyboard(text: string): Promise { const nut = await this._ensureNut() await nut.type(text) } /** * 현재 클립보드 상태를 캡처한다. */ saveClipboard(): ClipboardSnapshot { const text = clipboard.readText() || null const html = clipboard.readHTML() || null const rtf = clipboard.readRTF() || null const image = clipboard.readImage() const hasImage = image && !image.isEmpty() return { text, html, image: hasImage ? image : null, rtf, hasContent: !!(text || html || rtf || hasImage) } } /** * 저장된 클립보드 상태를 복원한다. */ restoreClipboard(snapshot: ClipboardSnapshot): void { if (!snapshot.hasContent) { clipboard.clear() return } // 텍스트가 있으면 텍스트 우선 복원 if (snapshot.text) { clipboard.writeText(snapshot.text) } else if (snapshot.html) { clipboard.writeHTML(snapshot.html) } else if (snapshot.rtf) { clipboard.writeRTF(snapshot.rtf) } else if (snapshot.image) { clipboard.writeImage(snapshot.image) } } dispose(): void { this.removeAllListeners() logger.info('TextInsertService disposed') } private _sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } // ── EventEmitter 타입 오버라이드 ─────────────────────── override on( event: K, listener: TextInsertEvents[K] ): this { return super.on(event, listener) } override off( event: K, listener: TextInsertEvents[K] ): this { return super.off(event, listener) } override emit( event: K, ...args: Parameters ): boolean { return super.emit(event, ...args) } } // ── nut-js 타입 (lazy import용) ────────────────────────── type NutModule = typeof import('@nut-tree-fork/nut-js') interface NutKeyboard { pressKey: NutModule['keyboard']['pressKey'] releaseKey: NutModule['keyboard']['releaseKey'] type: NutModule['keyboard']['type'] Key: NutModule['Key'] } // ── 싱글톤 ───────────────────────────────────────────── let instance: TextInsertService | null = null export function getTextInsertService(): TextInsertService { if (!instance) { instance = new TextInsertService() } return instance }