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:
yunchan8804 2026-04-08 14:04:41 +09:00
parent 3a160b9032
commit 45a580878a
178 changed files with 214 additions and 0 deletions

View file

@ -1,342 +0,0 @@
// src/main/services/ScreenContextService.ts
// 활성 윈도우 정보 + 선택된 텍스트를 캡처하여 LLM 프롬프트에 컨텍스트로 제공한다.
// Phase 10.2 스크린 컨텍스트. Speakly ContextService 참조.
import { clipboard } from 'electron'
import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService'
import { D3ROError, ErrorCode } from '@shared/errors'
import type { ScreenContext, CaptureContextResult } from '@shared/types'
const logger = getLogger('screen-context')
// ============================================================
// nut-js 타입 (lazy import)
// ============================================================
interface NutKeyboard {
pressKey: (...keys: number[]) => Promise<void>
releaseKey: (...keys: number[]) => Promise<void>
Key: Record<string, number>
}
// ============================================================
// 클립보드 스냅샷 (TextInsertService와 동일 패턴)
// ============================================================
interface ClipboardSnapshot {
text: string | null
html: string | null
image: Electron.NativeImage | null
rtf: string | null
hasContent: boolean
}
// ============================================================
// ScreenContextService
// ============================================================
class ScreenContextService {
private _nutKeyboard: NutKeyboard | null = null
private _nutLoadPromise: Promise<void> | null = null
/**
* nut-js lazy dynamic import (TextInsertService ).
*/
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),
Key: nut.Key,
}
logger.info('nut-js loaded for screen context')
} catch (error) {
logger.error(
`Failed to load nut-js: ${error instanceof Error ? error.message : String(error)}`
)
throw new D3ROError(
ErrorCode.ContextCaptureFailed,
'nut-js 로드 실패. 스크린 컨텍스트를 사용할 수 없습니다.'
)
}
})()
}
await this._nutLoadPromise
if (!this._nutKeyboard) {
throw new D3ROError(ErrorCode.ContextCaptureFailed, 'nut-js not available')
}
return this._nutKeyboard
}
// ── 메인 API ──────────────────────────────────────────
/**
* + .
*
* @param captureSelectedText true이면 Ctrl+C로
*/
async captureContext(captureSelectedText = true): Promise<CaptureContextResult> {
const capturedAt = Date.now()
let appName: string | null = null
let windowTitle: string | null = null
let selectedText: string | null = null
let selectedTextAttempted = false
// 1. 활성 윈도우 정보 (PowerShell)
try {
const appInfo = await this._getActiveWindowInfo()
appName = appInfo.appName
windowTitle = appInfo.windowTitle
} catch (error) {
logger.warn(
`Failed to get active window info: ${error instanceof Error ? error.message : String(error)}`
)
// 활성 윈도우 감지 실패는 치명적이지 않으므로 계속 진행
}
// 2. 선택된 텍스트 캡처 (클립보드 방식)
if (captureSelectedText) {
selectedTextAttempted = true
try {
selectedText = await this._captureSelectedText()
} catch (error) {
logger.warn(
`Failed to capture selected text: ${error instanceof Error ? error.message : String(error)}`
)
// 선택 텍스트 캡처 실패도 치명적이지 않음
}
}
const context: ScreenContext = {
appName,
windowTitle,
selectedText,
capturedAt,
}
logger.info(
`Context captured: app=${appName ?? 'unknown'}, title=${windowTitle ? windowTitle.substring(0, 40) : 'unknown'}, selectedText=${selectedText ? `${selectedText.length} chars` : 'none'}`
)
return { context, selectedTextAttempted }
}
/**
* ScreenContext를 LLM .
* .
*/
buildContextPrompt(ctx: ScreenContext): string {
const parts: string[] = []
if (ctx.appName || ctx.windowTitle || ctx.selectedText) {
parts.push('[컨텍스트]')
if (ctx.appName) {
parts.push(`활성 앱: ${ctx.appName}`)
}
if (ctx.windowTitle) {
parts.push(`윈도우: ${ctx.windowTitle}`)
}
if (ctx.selectedText) {
parts.push(`선택된 텍스트:`)
parts.push(ctx.selectedText)
}
parts.push('') // 빈 줄로 구분
}
return parts.join('\n')
}
/**
* .
*/
isEnabled(): boolean {
return configGet('screenContextEnabled')
}
/**
* /.
*/
setEnabled(enabled: boolean): void {
configSet('screenContextEnabled', enabled)
logger.info(`Screen context ${enabled ? 'enabled' : 'disabled'}`)
}
dispose(): void {
this._nutKeyboard = null
this._nutLoadPromise = null
logger.info('ScreenContextService disposed')
}
// ── 내부 구현 ─────────────────────────────────────────
/**
* Windows에서 .
* PowerShell을 GetForegroundWindow .
*/
private async _getActiveWindowInfo(): Promise<{
appName: string | null
windowTitle: string | null
}> {
if (process.platform !== 'win32') {
return { appName: null, windowTitle: null }
}
const { execFile } = await import('child_process')
const { promisify } = await import('util')
const execFileAsync = promisify(execFile)
// PowerShell 스크립트: GetForegroundWindow의 프로세스명과 윈도우 타이틀
const psScript = `
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class Win32 {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
}
"@
$hwnd = [Win32]::GetForegroundWindow()
$pid = 0
[void][Win32]::GetWindowThreadProcessId($hwnd, [ref]$pid)
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
$sb = New-Object System.Text.StringBuilder 512
[void][Win32]::GetWindowText($hwnd, $sb, 512)
$procName = if ($proc) { $proc.ProcessName } else { '' }
$title = $sb.ToString()
"$procName$([char]10)$title"
`.trim()
try {
const { stdout } = await execFileAsync('powershell.exe', [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy', 'Bypass',
'-Command', psScript,
], { timeout: 3000 })
const lines = stdout.trim().split('\n')
const appName = lines[0]?.trim() || null
const windowTitle = lines[1]?.trim() || null
return { appName, windowTitle }
} catch (error) {
logger.warn(
`PowerShell active window query failed: ${error instanceof Error ? error.message : String(error)}`
)
return { appName: null, windowTitle: null }
}
}
/**
* .
* TextInsertService의 역방향: clipboard save Ctrl+C simulate clipboard read clipboard restore
*/
private async _captureSelectedText(): Promise<string | null> {
const nut = await this._ensureNut()
// 1. 기존 클립보드 저장
const snapshot = this._saveClipboard()
try {
// 2. 클립보드 비우기 (이전 내용이 남아있으면 "선택 없음"을 감지할 수 없으므로)
clipboard.clear()
// 3. Ctrl+C 시뮬레이션
await nut.pressKey(nut.Key.LeftControl, nut.Key.C)
await nut.releaseKey(nut.Key.LeftControl, nut.Key.C)
// 4. 클립보드에 텍스트가 복사될 때까지 대기
await this._sleep(150)
// 5. 클립보드에서 텍스트 읽기
const text = clipboard.readText()
// 6. 클립보드 복원
this._restoreClipboard(snapshot)
// 빈 문자열이면 선택된 텍스트 없음
if (!text || text.trim().length === 0) {
return null
}
return text
} catch (error) {
// 실패 시에도 클립보드 복원
try {
this._restoreClipboard(snapshot)
} catch {
logger.warn('Failed to restore clipboard after selected text capture error')
}
throw new D3ROError(
ErrorCode.ContextSelectedTextFailed,
`선택 텍스트 캡처 실패: ${error instanceof Error ? error.message : String(error)}`
)
}
}
/**
* (TextInsertService와 ).
*/
private _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),
}
}
/**
* (TextInsertService와 ).
*/
private _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)
}
}
private _sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
// ── 싱글톤 ────────────────────────────────────────────
let instance: ScreenContextService | null = null
export function getScreenContextService(): ScreenContextService {
if (!instance) {
instance = new ScreenContextService()
}
return instance
}