d3ro-voice/apps/desktop/src/main/services/RuntimeProvisioner.ts
Yun Chan 4b0f685941 feat(caption): let live captions use their own speech model
The speech engine now keeps an auxiliary model next to the dictation model
and transcribes with whichever the request names, reloading it once if the
engine restarted. Settings > STT gains a live-caption model so captions can
run on large-v3-turbo while dictation keeps its own model. The runtime
minimum rises to 1.7.0 because older engines would silently ignore the
model choice.

Suggestion paging moves to Up/Down: the page follows the selection and the
last item waits while more candidates are being generated. The Left/Right
page shortcuts are removed; they did nothing until a page had filled and
clash with Intel's display-rotation hotkeys.
2026-09-24 21:46:54 +09:00

430 lines
15 KiB
TypeScript

// 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<string, RuntimeComponentIndex>
}
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<RuntimeComponent, string | null> = {
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<RuntimeComponent, Promise<string>>()
/** 설치된 런타임 트 (%APPDATA%/d3ro-voice/runtime/<component>) */
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<string> {
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<string> {
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<RuntimeIndex> {
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<void> {
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<number> {
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<string> {
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
}