feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동
- npm workspaces 루트 (apps/*, packages/*) 세팅 - V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar, scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts, tsconfig.node.json, tsconfig.web.json) - apps/desktop/package.json 신규 (name=@d3ro/desktop) - productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로 %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장) - 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지 (typescript, eslint, prettier) - turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase) - memory/project_status.md 생성 (규칙 13) 검증: - npm run typecheck 통과 - npm run build 통과 (electron-vite main+preload+renderer) - npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
parent
3a160b9032
commit
45a580878a
178 changed files with 214 additions and 0 deletions
267
apps/desktop/src/main/services/TextInsertService.ts
Normal file
267
apps/desktop/src/main/services/TextInsertService.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
// 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 '@shared/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 _nutLoaded = false
|
||||
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
|
||||
}
|
||||
this._nutLoaded = true
|
||||
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 → Ctrl+V → restore (Speakly 패턴)
|
||||
*/
|
||||
private async _insertViaClipboard(text: string): Promise<void> {
|
||||
// 1. 기존 클립보드 저장
|
||||
const snapshot = this.saveClipboard()
|
||||
this.emit('clipboard-saved', {})
|
||||
|
||||
try {
|
||||
// 2. 클립보드에 텍스트 설정
|
||||
clipboard.writeText(text)
|
||||
|
||||
// 3. Ctrl+V 시뮬레이션
|
||||
const nut = await this._ensureNut()
|
||||
await nut.pressKey(nut.Key.LeftControl, nut.Key.V)
|
||||
await nut.releaseKey(nut.Key.LeftControl, nut.Key.V)
|
||||
|
||||
// 4. 붙여넣기 완료 대기
|
||||
await this._sleep(150)
|
||||
|
||||
// 4.5 간이 삽입 검증 (EditMonitor 경량 버전)
|
||||
// 클립보드에 우리가 설정한 텍스트가 남아있으면 삽입 실패 가능성
|
||||
// (앱이 Ctrl+V를 처리했다면 클립보드 내용은 변하지 않음)
|
||||
const afterInsert = clipboard.readText()
|
||||
if (afterInsert === text) {
|
||||
// 클립보드가 그대로 → 정상 (앱이 붙여넣기함)
|
||||
logger.debug('Insert verification: clipboard unchanged (normal)')
|
||||
}
|
||||
|
||||
// 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용) ──────────────────────────
|
||||
|
||||
interface NutKeyboard {
|
||||
pressKey: (...keys: number[]) => Promise<void>
|
||||
releaseKey: (...keys: number[]) => Promise<void>
|
||||
type: (text: string) => Promise<void>
|
||||
Key: Record<string, number>
|
||||
}
|
||||
|
||||
// ── 싱글톤 ─────────────────────────────────────────────
|
||||
|
||||
let instance: TextInsertService | null = null
|
||||
|
||||
export function getTextInsertService(): TextInsertService {
|
||||
if (!instance) {
|
||||
instance = new TextInsertService()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue