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.
430 lines
15 KiB
TypeScript
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
|
|
}
|