플랫폼 분기 (paths.ts): - EXE_SUFFIX 상수로 sox/sidecar/ffmpeg 실행파일 확장자 통합 - Windows에선 .exe 자동 부착, Mac/Linux에선 빈 문자열 - 미사용 getProjectRoot 헬퍼 제거 런타임 서비스 Mac 분기: - SoundEffectService: darwin → /usr/bin/afplay, linux → aplay 분기 추가 (execFile로 안전하게) - ScreenContextService._getActiveWindowInfo: win32 → PowerShell + user32.dll (기존), darwin → osascript (System Events frontmost process + 윈도우 타이틀) Linux는 미지원 (null) electron-builder.yml: - mac 타겟 추가 (dmg + zip, arm64 + x64 매트릭스) - hardenedRuntime, gatekeeperAssess, entitlements 설정 - extendInfo로 NSMicrophoneUsage / NSCameraUsage / NSAppleEvents / NSSystemAdministration 권한 메시지 - dmg 레이아웃 (드래그 to /Applications) - linux AppImage placeholder - notarize: false 기본, NOTARIZE 환경변수로 활성화 build/entitlements.mac.plist: - allow-jit, allow-unsigned-executable-memory (Electron 필수) - audio-input, camera, network.client - automation.apple-events (활성 윈도우 조회용) - files.user-selected.read-write - allow-dyld-environment-variables (sox/ffmpeg 라이브러리 로드) scripts/build-sidecar.py: - IS_WINDOWS / IS_MACOS / EXE_SUFFIX 도입 - Windows에서만 --noconsole 플래그 - 빌드 결과 경로 + size 출력 플랫폼 통합 scripts/install-sox.sh (신규): - Mac/Linux용 SoX 번들 스크립트 - macOS는 otool로 dylib 의존성 식별 후 함께 복사, install_name_tool로 rpath를 @loader_path로 변경 - electron-builder의 extraResources 대상 디렉토리에 배치 resources/icons/ (신규): - README.md만 커밋, 실제 아이콘 파일은 분리 - sips/iconutil/imagemagick으로 .icns/.ico/.png 생성 가이드 .github/workflows/build-mac.yml (신규): - macos-14 runner (Apple Silicon), arm64/x64 matrix - brew sox, npm install, @electron/rebuild, install-sox.sh, build-sidecar.py, electron-builder dist - CSC/NOTARIZE 환경변수 자동 처리 - artifact 업로드 (dmg + zip, retention 7일) docs/v2/phase-V2-5-mac-guide.md (신규): - 사전 조건, 시스템 의존성, dev 실행, dist 빌드, Code signing + Notarization, CI 트리거, 트러블슈팅 검증 (Windows에서): - typecheck 통과 (Mac 분기 추가에도 회귀 없음) - build 통과 - dev 런타임 정상 Mac 검증은 사용자 본인 Mac에서 수행 (V2-5 사용자 액션).
405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
// 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<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 + 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<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
|
|
}
|