// src/main/services/RuntimeProvisioner.ts // 로컬 AI 타임(사이드카 엔진 / ffmpeg)을 설치 시점이 아니라 "필요할 때" 내려받는다. // // 왜: 사이드카(242MB)를 설치본에 넣으면 NSIS가 189MB가 되어 canonical feed의 업로드 // 한도(Cloudflare 100MiB)를 넘고, 그 결과 자동 업데이트(latest.yml)를 갱신할 수 없다. // 엔진을 분리하면 설치본이 90MiB대로 내려가 updater가 정상 동작하고, 업데이트마다 // 162MB를 다시 받지 않아도 된다. // // 안전: // - 부품별 SHA-256 + 결합본 SHA-256을 모두 검증한 뒤에만 설치한다. // - tar 경로 탈출(..) 항목은 건너뛴다. // - 실패하면 부분 다운로드를 지우고 기존 설치를 건드리지 않는다. import { EventEmitter, once } from 'events' import { createHash } from 'node:crypto' import { createReadStream, createWriteStream, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { mkdir, rm, stat } from 'node:fs/promises' import { Readable, Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { join } from 'node:path' import { app } from 'electron' import * as tar from 'tar' import { getLogger } from './LoggerService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { RUNTIME_FEED_URL } from '../update-feed' import { compareVersions } from '../update-policy' const logger = getLogger('RuntimeProvisioner') /** 이 내려받아야 하는 런타임 구성 요소 */ export type RuntimeComponent = 'sidecar' | 'ffmpeg' export const RUNTIME_COMPONENTS: readonly RuntimeComponent[] = ['sidecar', 'ffmpeg'] interface RuntimePart { name: string size: number sha256: string url: string } interface RuntimeComponentIndex { archive: string sha256: string totalSize: number parts: RuntimePart[] } interface RuntimeIndex { schemaVersion: number version: string components: Record } export interface RuntimeProgressEvent { component: RuntimeComponent phase: 'index' | 'downloading' | 'extracting' | 'done' percent: number downloadedBytes: number totalBytes: number bytesPerSecond: number } export interface RuntimeStatus { component: RuntimeComponent installed: boolean path: string sizeBytes: number } const RUNTIME_DIR_NAME = 'runtime' /** 설치된 런타임의 버전(runtime.json version)을 남기는 마커 */ const RUNTIME_VERSION_FILE = '.runtime-version' /** * 이 앱이 요구하는 런타임 최소 버전. 사이드카 API 가 바뀔 때만 올린다. * 1.5.0 — UIA 브리지(`/uia/focus`). 1.7.0 — 보조 모델 자리(`/load` slot, `/transcribe` model_id). * ffmpeg 는 CLI 가 안정적이라 확인하지 않는다. */ const RUNTIME_MIN_VERSION: Record = { sidecar: '1.7.0', ffmpeg: null } const DOWNLOAD_TIMEOUT_MS = 120_000 /** 부품 다운로드 재시도 횟수 — 전송 중 잘림/일시적 네트워크 오류 대비 */ const PART_DOWNLOAD_ATTEMPTS = 3 class RuntimeProvisioner extends EventEmitter { constructor() { super() this.on('error', () => { /* 기본 sink — EventEmitter 'error' 미처리 예외 방지 */ }) } private _inFlight = new Map>() /** 설치된 런타임 트 (%APPDATA%/d3ro-voice/runtime/) */ componentDir(component: RuntimeComponent): string { return join(app.getPath('userData'), RUNTIME_DIR_NAME, component) } /** 구성 요소 실행 파일 경로 (설치 여부와 무관하게 경로만 계산) */ binaryPath(component: RuntimeComponent): string { const dir = this.componentDir(component) if (component === 'sidecar') { return join(dir, process.platform === 'win32' ? 'sidecar.exe' : 'sidecar') } return join(dir, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg') } isInstalled(component: RuntimeComponent): boolean { const binary = this.binaryPath(component) if (!existsSync(binary)) return false if (component === 'sidecar' && !existsSync(join(this.componentDir('sidecar'), '_internal'))) { // PyInstaller onedir은 _internal 없이는 동작하지 않는다 (부분 설치 방어) return false } return true } /** * 설치된 런타임이 이 앱이 요구하는 최소 버전 이상인지. * * 사이드카는 설치본에 넣지 않고 별도로 내려받는다(위 주석) — 그래서 앱이 업데이트돼 * 새 엔드포인트를 요구해도(예: v1.5.0의 `/uia/focus`), 예전에 내려받은 사이드카가 * 남아 있으면 새 앱은 그 구버전 엔진과 계속 통신한다. 마커가 없거나 최소 버전보다 * 낮으면 다시 받는다. 앱 버전과 "같음" 으로 비교하지 않는다 — 그러면 엔진이 그대로인 * 릴리스마다 모든 사용자가 100MB를 다시 받는다. */ private _isCurrentVersion(component: RuntimeComponent): boolean { const minimum = RUNTIME_MIN_VERSION[component] if (minimum === null) return true try { const installed = readFileSync(this._versionMarkerPath(component), 'utf8').trim() const order = compareVersions(installed, minimum) return order !== null && order >= 0 } catch { return false } } private _versionMarkerPath(component: RuntimeComponent): string { return join(this.componentDir(component), RUNTIME_VERSION_FILE) } getStatus(): RuntimeStatus[] { return RUNTIME_COMPONENTS.map((component) => { const binary = this.binaryPath(component) let sizeBytes = 0 try { sizeBytes = existsSync(binary) ? statSync(binary).size : 0 } catch { sizeBytes = 0 } return { component, installed: this.isInstalled(component), path: binary, sizeBytes, } }) } /** * 구성 요소가 설치되어 있으면 경로를, 없으면 내려받아 설치한 뒤 경로를 돌려준다. * 동시 호출은 같은 작업을 공유한다. */ async ensure(component: RuntimeComponent): Promise { if (this.isInstalled(component) && this._isCurrentVersion(component)) { return this.binaryPath(component) } const existing = this._inFlight.get(component) if (existing) { logger.debug(`런타임 설치 진행 중 — 기존 작업에 합류: ${component}`) return existing } const task = this._install(component).finally(() => { this._inFlight.delete(component) }) this._inFlight.set(component, task) return task } private async _install(component: RuntimeComponent): Promise { const started = Date.now() logger.info(`런타임 설치 시작: ${component}`) this._emitProgress(component, 'index', 0, 0, 0, 0) const index = await this._fetchIndex() const entry = index.components[component] if (!entry) { throw new D3ROError( ErrorCode.ConfigReadFailed, `런타임 인덱스에 ${component} 구성 요소가 없습니다 (version=${index.version})`, ) } const targetDir = this.componentDir(component) const tempDir = join(app.getPath('userData'), RUNTIME_DIR_NAME, `.download-${component}`) await rm(tempDir, { recursive: true, force: true }) await mkdir(tempDir, { recursive: true }) try { const archivePath = join(tempDir, entry.archive) await this._downloadParts(component, entry, tempDir, archivePath) if (component === 'sidecar' || component === 'ffmpeg') { // 기존 설치를 지우고 새로 배치한다 (부분 상태 방지: 먼저 temp에 풀고 검증 후 교체) await rm(targetDir, { recursive: true, force: true }) await mkdir(targetDir, { recursive: true }) } this._emitProgress(component, 'extracting', 100, entry.totalSize, entry.totalSize, 0) await tar.x({ file: archivePath, cwd: targetDir, // 경로 탈출 항목은 건너뛴다 filter: (path) => !path.split('/').includes('..'), }) if (!this.isInstalled(component)) { throw new D3ROError( ErrorCode.STTSidecarSpawnFailed, `런타임 설치 후 실행 파일을 찾을 수 없습니다: ${this.binaryPath(component)}`, ) } writeFileSync(this._versionMarkerPath(component), index.version, 'utf8') this._emitProgress(component, 'done', 100, entry.totalSize, entry.totalSize, 0) logger.info( `런타임 설치 완료: ${component} (${(entry.totalSize / 1048576).toFixed(1)}MiB, ${Date.now() - started}ms)`, ) return this.binaryPath(component) } catch (err) { // 실패 시 부분 산출물 정리 — 반쯤 풀린 설치를 남기지 않는다 await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) if (!this.isInstalled(component)) { await rm(targetDir, { recursive: true, force: true }).catch(() => undefined) } const message = err instanceof Error ? err.message : String(err) logger.error(`런타임 설치 실패: ${component} — ${message}`) if (err instanceof D3ROError) throw err throw new D3ROError( ErrorCode.STTSidecarSpawnFailed, `런타임 설치 실패(${component}): ${message}`, ) } finally { await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) } } private async _fetchIndex(): Promise { if (!RUNTIME_FEED_URL) { throw new D3ROError( ErrorCode.ConfigReadFailed, '런타임 feed가 설정되지 않았습니다 (자동 업데이트 비활성 상태)', ) } const url = `${RUNTIME_FEED_URL}/runtime.json` const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }) if (!response.ok) { throw new D3ROError( ErrorCode.ConfigReadFailed, `런타임 인덱스를 받을 수 없습니다 (HTTP ${response.status}): ${url}`, ) } const index = (await response.json()) as RuntimeIndex if (!index?.components) { throw new D3ROError(ErrorCode.ConfigReadFailed, '런타임 인덱스 형식이 올바르지 않습니다') } return index } private async _downloadParts( component: RuntimeComponent, entry: RuntimeComponentIndex, tempDir: string, archivePath: string, ): Promise { const totalBytes = entry.totalSize > 0 ? entry.totalSize : entry.parts.reduce((sum, part) => sum + part.size, 0) let downloadedBytes = 0 const startedAt = Date.now() for (const part of entry.parts) { const partPath = join(tempDir, part.name) const partSize = await this._downloadPart(part, partPath) downloadedBytes += partSize const elapsed = Math.max(0.001, (Date.now() - startedAt) / 1000) this._emitProgress( component, 'downloading', totalBytes > 0 ? Math.min(100, Math.round((downloadedBytes * 100) / totalBytes)) : 0, downloadedBytes, totalBytes, Math.round(downloadedBytes / elapsed), ) } // 부품을 순서대로 이어 붙인다 (스트리밍 — 메모리에 통째로 올리지 않는다) const archiveStream = createWriteStream(archivePath) for (const part of entry.parts) { await pipeline(createReadStream(join(tempDir, part.name)), archiveStream, { end: false }) } archiveStream.end() await once(archiveStream, 'finish') // 크기를 먼저 본다 — 불일치하면 "어디까지 받았는지"가 로그에 남아 진단이 가능하다. const archiveSize = (await stat(archivePath)).size const expectedSize = entry.parts.reduce((sum, part) => sum + part.size, 0) if (archiveSize !== expectedSize) { throw new D3ROError( ErrorCode.STTSidecarSpawnFailed, `런타임 아카이브 크기 불일치 (${component}: ${archiveSize} != ${expectedSize})`, ) } const actualArchive = await sha256File(archivePath) if (entry.sha256 && actualArchive !== entry.sha256) { throw new D3ROError( ErrorCode.STTSidecarSpawnFailed, `런타임 아카이브 해시 불일치 (${component}: ${actualArchive} != ${entry.sha256})`, ) } } /** * 부품 하나를 디스크로 내려받고 디스크 기준으로 크기·해시를 검증한다. * 전송이 도중에 끊기면 같은 부품을 다시 받는다 (기존에는 1회 실패가 곧 설치 실패였다). */ private async _downloadPart(part: RuntimePart, partPath: string): Promise { let lastError: Error | null = null for (let attempt = 1; attempt <= PART_DOWNLOAD_ATTEMPTS; attempt += 1) { try { const response = await fetch(part.url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }) if (!response.ok || !response.body) { throw new D3ROError( ErrorCode.STTSidecarSpawnFailed, `런타임 부품을 받을 수 없습니다 (HTTP ${response.status}): ${part.name}`, ) } // 스트림을 파일로 저장한 뒤 "디스크에 실제로 남은 파일"에서 크기와 해시를 계산한다. // 메모리 스트림에서 센 값으로 검증하면, 디스크 쓰기가 잘려도 부품 검사를 통과해 // 결합 단계에 가서야 해시 불일치로 터진다 — 실측 사고. await pipeline(Readable.fromWeb(response.body as never), createWriteStream(partPath)) const partSize = (await stat(partPath)).size if (partSize !== part.size) { throw new D3ROError( ErrorCode.STTSidecarSpawnFailed, `런타임 부품 크기 불일치 (${part.name}: ${partSize} != ${part.size})`, ) } const actualPartHash = await sha256File(partPath) if (part.sha256 && actualPartHash !== part.sha256) { throw new D3ROError( ErrorCode.STTSidecarSpawnFailed, `런타임 부품 해시 불일치 (${part.name})`, ) } return partSize } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)) await rm(partPath, { force: true }).catch(() => undefined) logger.warn( `런타임 부품 다운로드 실패 (${part.name}, ${attempt}/${PART_DOWNLOAD_ATTEMPTS}): ${lastError.message}`, ) } } throw lastError ?? new D3ROError( ErrorCode.STTSidecarSpawnFailed, `런타임 부품 다운로드 실패 (${part.name})`, ) } private _emitProgress( component: RuntimeComponent, phase: RuntimeProgressEvent['phase'], percent: number, downloadedBytes: number, totalBytes: number, bytesPerSecond: number, ): void { this.emit('progress', { component, phase, percent, downloadedBytes, totalBytes, bytesPerSecond, } satisfies RuntimeProgressEvent) } } /** 파일 SHA-256 (스트림 — 메모리에 통째로 올리지 않는다) */ async function sha256File(path: string): Promise { const hash = createHash('sha256') await pipeline(createReadStream(path), new Writable({ write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void) { hash.update(chunk) callback() }, })) return hash.digest('hex') } let _instance: RuntimeProvisioner | null = null export function getRuntimeProvisioner(): RuntimeProvisioner { if (!_instance) { _instance = new RuntimeProvisioner() } return _instance } export function resetRuntimeProvisionerForTests(): void { _instance = null }