d3ro-voice/apps/desktop/src/main/services/TextInsertService.ts
Yun Chan 6ba25f53b7 fix(desktop): surface configuration and provider failures instead of hiding them
Several desktop paths quietly substituted defaults or partial results: a
config write could fall back to a throwaway in-memory store, speech provider
errors were absorbed into empty transcriptions, and meeting exports built
file names from raw titles.

Writes now fail explicitly when the store is unavailable, provider and model
failures reach the UI as errors, and export names pass through one
sanitizer. Settings, license, ad, and support surfaces use the shared theme
tokens, unused hotkey helpers are gone, and the package gains strict
node/renderer typecheck configs plus red-team e2e scenarios for these flows.
2026-09-16 23:23:58 +09:00

266 lines
8 KiB
TypeScript

// 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<string, never>) => void
'clipboard-restored': (payload: Record<string, never>) => void
}
// ============================================================
// TextInsertService
// ============================================================
class TextInsertService extends EventEmitter {
private _nutKeyboard: NutKeyboard | null = null
private _nutLoadPromise: Promise<void> | null = null
/**
* nut-js는 ESM + 네이티브 모듈이므로 lazy dynamic import 한다.
*/
private async _ensureNut(): Promise<NutKeyboard> {
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<InsertResult> {
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<void> {
// 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<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// ── EventEmitter 타입 오버라이드 ───────────────────────
override on<K extends keyof TextInsertEvents>(
event: K,
listener: TextInsertEvents[K]
): this {
return super.on(event, listener)
}
override off<K extends keyof TextInsertEvents>(
event: K,
listener: TextInsertEvents[K]
): this {
return super.off(event, listener)
}
override emit<K extends keyof TextInsertEvents>(
event: K,
...args: Parameters<TextInsertEvents[K]>
): 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
}