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.
266 lines
8 KiB
TypeScript
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
|
|
}
|