d3ro-voice/apps/desktop/src/main/services/ScreenContextService.ts
yunchan8804 77d514222e feat(V2-5): macOS 빌드 지원 — 플랫폼 분기 + electron-builder mac 타겟 + CI
플랫폼 분기 (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 사용자 액션).
2026-04-08 15:57:16 +09:00

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
}