// 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 '@d3ro/core/errors' import type { ScreenContext, CaptureContextResult } from '@d3ro/core/types' const logger = getLogger('screen-context') // ============================================================ // nut-js 타입 (lazy import) // ============================================================ interface NutKeyboard { pressKey: (...keys: number[]) => Promise releaseKey: (...keys: number[]) => Promise Key: Record } // ============================================================ // 클립보드 스냅샷 (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 | null = null /** * nut-js lazy dynamic import (TextInsertService 패턴). */ 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), 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 { 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 + user32.dll * - macOS: osascript (System Events) * - Linux: 미지원 (null 반환) * * 참고: macOS는 Accessibility 권한이 필요하다. 첫 호출 시 시스템이 * 권한 요청 다이얼로그를 띄운다. 사용자가 거부하면 { null, null } 반환. */ private async _getActiveWindowInfo(): Promise<{ appName: string | null windowTitle: string | null }> { if (process.platform === 'win32') { return this._getActiveWindowInfoWin32() } if (process.platform === 'darwin') { return this._getActiveWindowInfoDarwin() } return { appName: null, windowTitle: null } } /** * Windows: PowerShell + user32.dll로 활성 윈도우 조회. */ private async _getActiveWindowInfoWin32(): Promise<{ appName: string | null windowTitle: string | null }> { const { execFile } = await import('child_process') const { promisify } = await import('util') const execFileAsync = promisify(execFile) 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 } } } /** * macOS: osascript (AppleScript)로 frontmost process와 윈도우 타이틀 조회. * Accessibility 권한 필요. */ private async _getActiveWindowInfoDarwin(): Promise<{ appName: string | null windowTitle: string | null }> { const { execFile } = await import('child_process') const { promisify } = await import('util') const execFileAsync = promisify(execFile) // AppleScript: 프로세스명과 앞 윈도우 타이틀을 2줄로 반환. // 윈도우가 없는 앱도 있으므로 try-fallback. const script = ` try tell application "System Events" set frontApp to first process whose frontmost is true set appName to name of frontApp try set winTitle to name of front window of frontApp on error set winTitle to "" end try return appName & linefeed & winTitle end tell on error errMsg return "" & linefeed & "" end try `.trim() try { const { stdout } = await execFileAsync('/usr/bin/osascript', ['-e', script], { timeout: 3000 }) const lines = stdout.split('\n') const appName = lines[0]?.trim() || null const windowTitle = lines[1]?.trim() || null return { appName, windowTitle } } catch (error) { logger.warn( `osascript 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 { 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 { return new Promise((resolve) => setTimeout(resolve, ms)) } } // ── 싱글톤 ──────────────────────────────────────────── let instance: ScreenContextService | null = null export function getScreenContextService(): ScreenContextService { if (!instance) { instance = new ScreenContextService() } return instance }