fix(runtime): stage runtime installs strictly, time out only on stalls, and make reinstall actually reinstall

This commit is contained in:
Yun Chan 2026-09-28 00:53:44 +09:00
parent ba0dbbd813
commit 524d390bc2
10 changed files with 1160 additions and 238 deletions

View file

@ -3,10 +3,11 @@
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors'
import { ipcSuccess, ipcError, ErrorCode, D3ROError, type IPCResult } from '@d3ro/core/errors'
import { getLocalSTTService } from '../services/LocalSTTService'
import { getRuntimeProvisioner } from '../services/RuntimeProvisioner'
import type { RuntimeComponent } from '../services/RuntimeProvisioner'
import { isRuntimeComponent } from '../services/runtime/runtime-index'
import { getSTTManager } from '../services/stt/STTManager'
import { configGet, configSet } from '../services/ConfigService'
import { getMainWindow } from '../windows/WindowManager'
@ -27,8 +28,18 @@ function safeSendToRenderer(channel: string, data: unknown): void {
}
}
/**
* 던져진 오류를 IPC 오류로 바꾼다. D3ROError 면 그 코드를 보존하고, 아니면 fallback 코드를 쓴다.
* (예전 런타임 핸들러는 `ipcError(error, code)` 로 인자 순서가 뒤바뀌어 code 자리에 Error 객체가,
* message 자리에 숫자 코드가 들어갔다.)
*/
function toIpcError<T>(error: unknown, fallback: ErrorCode): IPCResult<T> {
if (error instanceof D3ROError) return ipcError(error.code, error.message)
return ipcError(fallback, error instanceof Error ? error.message : String(error))
}
export function registerSTTHandlers(): void {
// 로컬 AI 런타임(진/ffmpeg) 진행률 — 필요할 때 자동으로 내려받는다
// 로컬 AI 런타임(엔진/ffmpeg) 진행률 — 필요할 때 자동으로 내려받는다
getRuntimeProvisioner().on('progress', (payload) => {
safeSendToRenderer(IPC_CHANNELS.RUNTIME.PROGRESS, payload)
})
@ -37,22 +48,30 @@ export function registerSTTHandlers(): void {
try {
return ipcSuccess(getRuntimeProvisioner().getStatus())
} catch (error) {
return ipcError(error, ErrorCode.ConfigReadFailed)
return toIpcError(error, ErrorCode.ConfigReadFailed)
}
})
ipcMain.handle(IPC_CHANNELS.RUNTIME.ENSURE, async (_event, params: { component: RuntimeComponent }) => {
try {
const component = params?.component
if (component !== 'sidecar' && component !== 'ffmpeg') {
throw new D3ROError(ErrorCode.ConfigInvalidValue, `알 수 없는 런타임 구성 요소: ${String(component)}`)
// 렌더러의 이 호출은 사용자가 누른 "내려받기 / 다시 설치" 버튼 하나뿐이다. 이미 설치돼
// 있는데 누른 경우는 손상(백신 격리 등)을 고치려는 것이므로 기본값으로 새로 받아
// 교체한다 — 존재·버전 확인만으로는 손상을 알 수 없어, 예전에는 아무 일도 하지 않았다.
// 설치돼 있으면 건너뛰어야 하는 호출처는 `force: false` 를 넘긴다.
ipcMain.handle(
IPC_CHANNELS.RUNTIME.ENSURE,
async (_event, params: { component: RuntimeComponent; force?: boolean }) => {
try {
const component = params?.component
if (!isRuntimeComponent(component)) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, `알 수 없는 런타임 구성 요소: ${String(component)}`)
}
const force = params?.force !== false
const binaryPath = await getRuntimeProvisioner().ensure(component, { force })
return ipcSuccess({ component, binaryPath })
} catch (error) {
return toIpcError(error, ErrorCode.STTSidecarSpawnFailed)
}
const binaryPath = await getRuntimeProvisioner().ensure(component)
return ipcSuccess({ component, binaryPath })
} catch (error) {
return ipcError(error, ErrorCode.STTSidecarSpawnFailed)
}
})
},
)
getLocalSTTService().on('download-progress', (payload) => {
safeSendToRenderer(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, payload)
})

View file

@ -1,57 +1,52 @@
// src/main/services/RuntimeProvisioner.ts
// 로컬 AI 타임(사이드카 엔진 / ffmpeg)을 설치 시점이 아니라 "필요할 때" 내려받는다.
// 로컬 AI 런타임(사이드카 엔진 / ffmpeg)을 설치 시점이 아니라 "필요할 때" 내려받는다.
//
// 왜: 사이드카(242MB)를 설치본에 넣으면 NSIS가 189MB가 되어 canonical feed의 업로드
// 한도(Cloudflare 100MiB)를 넘고, 그 결과 자동 업데이트(latest.yml)를 갱신할 수 없다.
// 엔진을 분리하면 설치본이 90MiB대로 내려가 updater가 정상 동작하고, 업데이트마다
// 162MB를 다시 받지 않아도 된다.
//
// 이 클래스는 조율만 한다. 책임은 runtime/ 아래로 나뉘어 있다.
// - runtime-index.ts : 인덱스 스키마 검증 + 최소 버전 정책 (순수)
// - download-part.ts : 부품 다운로드 (정체 타임아웃, 이어 받기, 크기·해시 검증)
// - install-archive.ts : 스테이징 추출(strict) → 검증 → rename 교체 → 마커
// IO(사용자 데이터 경로, fetch, feed URL, 플랫폼)는 생성자로 주입받는다.
//
// 안전:
// - 부품별 SHA-256 + 결합본 SHA-256을 모두 검증한 뒤에만 설치한다.
// - tar 경로 탈출(..) 항목은 건너뛴다.
// - tar 경로 탈출(..) 항목은 건너뛰고, 항목 쓰기 오류는 설치 실패로 본다.
// - 실패하면 부분 다운로드를 지우고 기존 설치를 건드리지 않는다.
import { EventEmitter, once } from 'events'
import { createHash } from 'node:crypto'
import { createReadStream, createWriteStream, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { createReadStream, createWriteStream, existsSync, readFileSync, statSync } 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'
import { RUNTIME_FEED_URL, RUNTIME_INDEX_FILENAME } from '../update-feed'
import {
RUNTIME_COMPONENTS,
RUNTIME_MIN_VERSION,
isRuntimeVersionSatisfied,
parseRuntimeIndex,
type RuntimeComponent,
type RuntimeComponentIndex,
} from './runtime/runtime-index'
import {
DEFAULT_PART_DOWNLOAD_ATTEMPTS,
DEFAULT_STALL_TIMEOUT_MS,
downloadPart,
sha256File,
type RuntimeFetch,
} from './runtime/download-part'
import { installArchive } from './runtime/install-archive'
export { RUNTIME_COMPONENTS, type RuntimeComponent } from './runtime/runtime-index'
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'
@ -68,54 +63,82 @@ export interface RuntimeStatus {
sizeBytes: number
}
export interface EnsureRuntimeOptions {
/**
* true 면 이미 설치돼 있고 버전이 맞아도 새로 내려받아 교체한다 (사용자의 "다시 설치").
* 파일이 손상됐는지(백신 격리 등)는 존재 확인만으로 알 수 없어서 필요하다.
*/
force?: boolean
}
/** 주입 가능한 IO — 기본값은 electron app 경로·전역 fetch·canonical feed */
export interface RuntimeProvisionerDeps {
userDataDir: () => string
fetchImpl: RuntimeFetch
feedUrl: string
platform: NodeJS.Platform
stallTimeoutMs: number
partAttempts: number
}
const RUNTIME_DIR_NAME = 'runtime'
/** 설치된 런타임의 버전(runtime.json version)을 남기는 마커 */
const RUNTIME_VERSION_FILE = '.runtime-version'
const INDEX_TIMEOUT_MS = 30_000
/**
* 이 앱이 요구하는 런타임 최소 버전. 사이드카 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 defaultFetch: RuntimeFetch = (url, init) => fetch(url, init)
function defaultDeps(): RuntimeProvisionerDeps {
return {
userDataDir: () => app.getPath('userData'),
fetchImpl: defaultFetch,
feedUrl: RUNTIME_FEED_URL,
platform: process.platform,
stallTimeoutMs: DEFAULT_STALL_TIMEOUT_MS,
partAttempts: DEFAULT_PART_DOWNLOAD_ATTEMPTS,
}
}
const DOWNLOAD_TIMEOUT_MS = 120_000
/** 부품 다운로드 재시도 횟수 — 전송 중 잘림/일시적 네트워크 오류 대비 */
const PART_DOWNLOAD_ATTEMPTS = 3
class RuntimeProvisioner extends EventEmitter {
constructor() {
export class RuntimeProvisioner extends EventEmitter {
private readonly _deps: RuntimeProvisionerDeps
private _inFlight = new Map<RuntimeComponent, Promise<string>>()
constructor(deps: Partial<RuntimeProvisionerDeps> = {}) {
super()
this._deps = { ...defaultDeps(), ...deps }
this.on('error', () => {
/* 기본 sink — EventEmitter 'error' 미처리 예외 방지 */
})
}
private _inFlight = new Map<RuntimeComponent, Promise<string>>()
private _runtimeRoot(): string {
return join(this._deps.userDataDir(), RUNTIME_DIR_NAME)
}
/** 설치된 런타임 트 (%APPDATA%/d3ro-voice/runtime/<component>) */
/** 설치된 런타임 트리 (%APPDATA%/d3ro-voice/runtime/<component>) */
componentDir(component: RuntimeComponent): string {
return join(app.getPath('userData'), RUNTIME_DIR_NAME, component)
return join(this._runtimeRoot(), 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')
return join(this.componentDir(component), this._binaryName(component))
}
private _binaryName(component: RuntimeComponent): string {
const suffix = this._deps.platform === 'win32' ? '.exe' : ''
return component === 'sidecar' ? `sidecar${suffix}` : `ffmpeg${suffix}`
}
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 this._hasRequiredFiles(component, this.componentDir(component))
}
/** dir 에 구성 요소 실행에 필요한 파일이 있는지 (설치본·스테이징 공용) */
private _hasRequiredFiles(component: RuntimeComponent, dir: string): boolean {
if (!existsSync(join(dir, this._binaryName(component)))) return false
// PyInstaller onedir은 _internal 없이는 동작하지 않는다 (부분 설치 방어)
if (component === 'sidecar' && !existsSync(join(dir, '_internal'))) return false
return true
}
@ -125,16 +148,13 @@ class RuntimeProvisioner extends EventEmitter {
* 사이드카는 설치본에 넣지 않고 별도로 내려받는다(위 주석) — 그래서 앱이 업데이트돼
* 새 엔드포인트를 요구해도(예: 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
return isRuntimeVersionSatisfied(readFileSync(this._versionMarkerPath(component), 'utf8'), minimum)
} catch {
return false
}
@ -164,10 +184,10 @@ class RuntimeProvisioner extends EventEmitter {
/**
* 구성 요소가 설치되어 있으면 경로를, 없으면 내려받아 설치한 뒤 경로를 돌려준다.
* 동시 호출은 같은 작업을 공유한다.
* `force` 면 설치돼 있어도 새로 받아 교체한다. 동시 호출은 같은 작업을 공유한다.
*/
async ensure(component: RuntimeComponent): Promise<string> {
if (this.isInstalled(component) && this._isCurrentVersion(component)) {
async ensure(component: RuntimeComponent, options: EnsureRuntimeOptions = {}): Promise<string> {
if (!options.force && this.isInstalled(component) && this._isCurrentVersion(component)) {
return this.binaryPath(component)
}
@ -189,58 +209,30 @@ class RuntimeProvisioner extends EventEmitter {
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 })
const tempDir = join(this._runtimeRoot(), `.download-${component}`)
try {
const { version, entry } = await this._fetchIndex(component)
await rm(tempDir, { recursive: true, force: true })
await mkdir(tempDir, { recursive: true })
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('..'),
await installArchive({
archivePath,
targetDir: this.componentDir(component),
verify: (dir) => this._hasRequiredFiles(component, dir),
marker: { fileName: RUNTIME_VERSION_FILE, content: version },
})
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
@ -249,30 +241,27 @@ class RuntimeProvisioner extends EventEmitter {
`런타임 설치 실패(${component}): ${message}`,
)
} finally {
// 부분 산출물 정리 — 기존 설치(targetDir)는 installArchive 가 교체할 때만 바뀐다
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
}
}
private async _fetchIndex(): Promise<RuntimeIndex> {
if (!RUNTIME_FEED_URL) {
private async _fetchIndex(component: RuntimeComponent): Promise<ReturnType<typeof parseRuntimeIndex>> {
if (!this._deps.feedUrl) {
throw new D3ROError(
ErrorCode.ConfigReadFailed,
'런타임 feed가 설정되지 않았습니다 (자동 업데이트 비활성 상태)',
)
}
const url = `${RUNTIME_FEED_URL}/runtime.json`
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) })
const url = `${this._deps.feedUrl}/${RUNTIME_INDEX_FILENAME}`
const response = await this._deps.fetchImpl(url, { signal: AbortSignal.timeout(INDEX_TIMEOUT_MS) })
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
return parseRuntimeIndex(await response.json(), component)
}
private async _downloadParts(
@ -281,16 +270,21 @@ class RuntimeProvisioner extends EventEmitter {
tempDir: string,
archivePath: string,
): Promise<void> {
const totalBytes = entry.totalSize > 0
? entry.totalSize
: entry.parts.reduce((sum, part) => sum + part.size, 0)
const expectedSize = entry.parts.reduce((sum, part) => sum + part.size, 0)
const totalBytes = entry.totalSize > 0 ? entry.totalSize : expectedSize
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)
const partSize = await downloadPart(part, join(tempDir, part.name), {
fetchImpl: this._deps.fetchImpl,
stallTimeoutMs: this._deps.stallTimeoutMs,
attempts: this._deps.partAttempts,
onAttemptFailed: (message, attempt, attempts) => {
logger.warn(`런타임 부품 다운로드 실패 (${part.name}, ${attempt}/${attempts}): ${message}`)
},
})
downloadedBytes += partSize
const elapsed = Math.max(0.001, (Date.now() - startedAt) / 1000)
@ -304,17 +298,19 @@ class RuntimeProvisioner extends EventEmitter {
)
}
// 부품을 순서대로 이어 붙인다 (스트리밍 — 메모리에 통째로 올리지 않는다)
// 부품을 순서대로 이어 붙인다 (스트리밍 — 메모리에 통째로 올리지 않는다).
// 이어 붙인 부품은 바로 지운다 — 추출 중 디스크 최고 사용량(부품+결합본+트리)을 줄인다.
const archiveStream = createWriteStream(archivePath)
for (const part of entry.parts) {
await pipeline(createReadStream(join(tempDir, part.name)), archiveStream, { end: false })
const partPath = join(tempDir, part.name)
await pipeline(createReadStream(partPath), archiveStream, { end: false })
await rm(partPath, { force: true })
}
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,
@ -323,7 +319,7 @@ class RuntimeProvisioner extends EventEmitter {
}
const actualArchive = await sha256File(archivePath)
if (entry.sha256 && actualArchive !== entry.sha256) {
if (actualArchive !== entry.sha256) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 아카이브 해시 불일치 (${component}: ${actualArchive} != ${entry.sha256})`,
@ -331,60 +327,6 @@ class RuntimeProvisioner extends EventEmitter {
}
}
/**
* 부품 하나를 디스크로 내려받고 디스크 기준으로 크기·해시를 검증한다.
* 전송이 도중에 끊기면 같은 부품을 다시 받는다 (기존에는 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'],
@ -404,18 +346,6 @@ class RuntimeProvisioner extends EventEmitter {
}
}
/** 파일 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 {

View file

@ -0,0 +1,194 @@
// src/main/services/runtime/download-part.ts
// 런타임 부품 하나를 디스크로 내려받고 디스크 기준으로 크기·해시를 검증한다.
//
// 타임아웃은 "전송 전체" 가 아니라 "데이터가 멈춘 시간" 을 잰다. 예전에는
// AbortSignal.timeout(120s) 이 본문 전송 전체를 덮어서, 90MiB 부품을 120초 안에 받을 수
// 없는 링크(약 6Mbps 미만 — 테더링, 붐비는 Wi-Fi)에서는 세 번 모두 같은 자리에서 끊겨
// 로컬 엔진을 영영 설치할 수 없었다. 이제는 청크가 도착할 때마다 타이머가 다시 시작된다.
//
// 재시도 시 이미 받은 바이트가 있으면 Range 로 이어 받는다. 서버가 206 을 주지 않으면
// 처음부터 다시 받는다. 크기·해시가 틀리면 부분 파일을 지우고 처음부터 받는다.
import { createReadStream, createWriteStream } from 'node:fs'
import { rm, stat } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { Readable, Transform, Writable } from 'node:stream'
import type { ReadableStream as NodeReadableStream } from 'node:stream/web'
import { pipeline } from 'node:stream/promises'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { RuntimePart } from './runtime-index'
/** fetch 응답 중 이 모듈이 쓰는 부분만 — 테스트에서 가짜 fetch 를 주입할 수 있게 한다 */
export interface RuntimeFetchResponse {
ok: boolean
status: number
headers: { get(name: string): string | null }
body: ReadableStream<Uint8Array> | null
json(): Promise<unknown>
}
export type RuntimeFetch = (
url: string,
init: { signal: AbortSignal; headers?: Record<string, string> },
) => Promise<RuntimeFetchResponse>
export const DEFAULT_STALL_TIMEOUT_MS = 60_000
/** 부품 다운로드 시도 횟수 — 전송 중 잘림/일시적 네트워크 오류 대비 */
export const DEFAULT_PART_DOWNLOAD_ATTEMPTS = 3
export interface DownloadPartOptions {
fetchImpl: RuntimeFetch
/** 이 시간 동안 새 데이터가 한 바이트도 오지 않으면 전송을 끊는다 (응답 헤더 대기 포함) */
stallTimeoutMs?: number
attempts?: number
/** 시도 하나가 실패했을 때 (로그용) */
onAttemptFailed?: (message: string, attempt: number, attempts: number) => void
}
/** 파일 SHA-256 (스트림 — 메모리에 통째로 올리지 않는다) */
export 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')
}
async function fileSize(path: string): Promise<number> {
try {
return (await stat(path)).size
} catch {
return 0
}
}
/** `Content-Range: bytes <start>-<end>/<total>` 의 시작 오프셋 */
function contentRangeStart(header: string | null): number | null {
if (!header) return null
const match = /^bytes\s+(\d+)-\d+\/(?:\d+|\*)$/i.exec(header.trim())
return match ? Number(match[1]) : null
}
function failure(message: string): D3ROError {
return new D3ROError(ErrorCode.STTSidecarSpawnFailed, message)
}
/**
* 시도 한 번: 응답을 받아 파일에 쓴다 (이어 받기 가능하면 이어 쓴다).
* 데이터가 stallTimeoutMs 동안 멈추면 요청과 스트림을 모두 끊는다.
*/
async function transferOnce(
part: RuntimePart,
partPath: string,
fetchImpl: RuntimeFetch,
stallTimeoutMs: number,
): Promise<void> {
const offset = await fileSize(partPath)
const resumeFrom = offset > 0 && offset < part.size ? offset : 0
if (offset > 0 && resumeFrom === 0) {
await rm(partPath, { force: true })
}
const controller = new AbortController()
let source: Readable | null = null
let timer: NodeJS.Timeout | undefined
const stallError = failure(
`런타임 부품 전송이 ${Math.round(stallTimeoutMs / 1000)}초 동안 멈췄습니다 (${part.name})`,
)
const arm = (): void => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
controller.abort(stallError)
source?.destroy(stallError)
}, stallTimeoutMs)
}
arm()
try {
const headers: Record<string, string> | undefined =
resumeFrom > 0 ? { Range: `bytes=${resumeFrom}-` } : undefined
const response = await fetchImpl(part.url, { signal: controller.signal, headers })
if (!response.ok || !response.body) {
if (resumeFrom > 0) await rm(partPath, { force: true })
throw failure(`런타임 부품을 받을 수 없습니다 (HTTP ${response.status}): ${part.name}`)
}
let append = false
if (resumeFrom > 0 && response.status === 206) {
if (contentRangeStart(response.headers.get('content-range')) !== resumeFrom) {
await rm(partPath, { force: true })
throw failure(`런타임 부품 이어 받기 범위가 맞지 않습니다 (${part.name})`)
}
append = true
}
source = Readable.fromWeb(response.body as NodeReadableStream<Uint8Array>)
const watchdog = new Transform({
transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null, data?: Buffer) => void) {
arm()
callback(null, chunk)
},
})
// 스트림을 파일로 저장한 뒤 "디스크에 실제로 남은 파일"에서 크기와 해시를 계산한다.
// 메모리 스트림에서 센 값으로 검증하면, 디스크 쓰기가 잘려도 부품 검사를 통과해
// 결합 단계에 가서야 해시 불일치로 터진다 — 실측 사고.
await pipeline(source, watchdog, createWriteStream(partPath, { flags: append ? 'a' : 'w' }))
} catch (err) {
if (controller.signal.aborted) throw stallError
throw err
} finally {
if (timer) clearTimeout(timer)
}
}
/**
* 부품 하나를 partPath 로 내려받고 크기·해시를 확인한다. 받은 바이트 수를 돌려준다.
* 모든 시도가 실패하면 부분 파일을 지우고 마지막 오류를 던진다.
*/
export async function downloadPart(
part: RuntimePart,
partPath: string,
options: DownloadPartOptions,
): Promise<number> {
const stallTimeoutMs = options.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS
const attempts = Math.max(1, options.attempts ?? DEFAULT_PART_DOWNLOAD_ATTEMPTS)
let lastError: Error | null = null
await rm(partPath, { force: true })
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await transferOnce(part, partPath, options.fetchImpl, stallTimeoutMs)
const partSize = await fileSize(partPath)
if (partSize < part.size) {
// 전송이 오류 없이 짧게 끝났다 — 다음 시도에서 이어 받는다
throw failure(`런타임 부품 크기 불일치 (${part.name}: ${partSize} != ${part.size})`)
}
if (partSize !== part.size) {
await rm(partPath, { force: true })
throw failure(`런타임 부품 크기 불일치 (${part.name}: ${partSize} != ${part.size})`)
}
const actualHash = await sha256File(partPath)
if (actualHash !== part.sha256) {
await rm(partPath, { force: true })
throw failure(`런타임 부품 해시 불일치 (${part.name})`)
}
return partSize
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err))
options.onAttemptFailed?.(lastError.message, attempt, attempts)
}
}
await rm(partPath, { force: true }).catch(() => undefined)
throw lastError ?? failure(`런타임 부품 다운로드 실패 (${part.name})`)
}

View file

@ -0,0 +1,105 @@
// src/main/services/runtime/install-archive.ts
// 검증된 런타임 아카이브를 "스테이징 → 검증 → 교체" 순서로 설치한다.
//
// 예전에는 기존 설치를 먼저 지우고 그 자리에 비엄격(tar strict=false) 모드로 풀었다.
// node-tar 는 비엄격 모드에서 항목별 쓰기 오류(ENOSPC/EPERM 등)를 'warn' 으로만 알리고
// 추출을 끝까지 "성공" 시킨다. 그래서 디스크가 모자라거나 백신이 DLL 을 잠그면
// _internal 이 반쯤 빈 엔진에 버전 마커가 붙어, 이후 모든 실행에서 깨진 엔진을 쓰게 됐다.
//
// 지금은:
// - strict: true 로 풀어 항목 하나라도 실패하면 설치 전체를 실패시킨다.
// - `${target}.staging` 에 풀고 verify() 를 통과해야만 교체한다.
// - 버전 마커는 검증이 끝난 스테이징 안에 마지막으로 쓴다 (교체와 함께 나타난다).
// - Windows 순서: target → `.old` rename → staging → target rename → `.old` 삭제.
// 어느 rename 이든 실패하면 기존 설치를 그대로 되돌려 둔다 (실행 중 엔진은 EBUSY 로 실패).
import { existsSync } from 'node:fs'
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import * as tar from 'tar'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
export interface InstallArchiveOptions {
archivePath: string
targetDir: string
/** 스테이징 디렉터리가 실행 가능한 설치인지 (필수 파일 확인) */
verify: (dir: string) => boolean
/** 검증 뒤 스테이징 안에 마지막으로 쓰는 버전 마커 */
marker?: { fileName: string; content: string }
}
export function stagingDirOf(targetDir: string): string {
return `${targetDir}.staging`
}
export function backupDirOf(targetDir: string): string {
return `${targetDir}.old`
}
/** tar 항목 경로가 추출 폴더 밖으로 나가지 않는지 */
export function isSafeArchiveEntry(path: string): boolean {
return !path.replace(/\\/g, '/').split('/').includes('..')
}
function installFailed(message: string): D3ROError {
return new D3ROError(ErrorCode.STTSidecarSpawnFailed, message)
}
async function swapIntoPlace(stagingDir: string, targetDir: string): Promise<void> {
const backupDir = backupDirOf(targetDir)
await rm(backupDir, { recursive: true, force: true })
const hadPrevious = existsSync(targetDir)
if (hadPrevious) {
try {
await rename(targetDir, backupDir)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw installFailed(`기존 런타임을 교체할 수 없습니다 (사용 중일 수 있습니다): ${message}`)
}
}
try {
await rename(stagingDir, targetDir)
} catch (err) {
if (hadPrevious) {
await rename(backupDir, targetDir).catch(() => undefined)
}
const message = err instanceof Error ? err.message : String(err)
throw installFailed(`새 런타임을 제자리에 둘 수 없습니다: ${message}`)
}
// 옛 설치 삭제는 실패해도 설치 결과에 영향이 없다 — 다음 설치 때 다시 지운다
await rm(backupDir, { recursive: true, force: true }).catch(() => undefined)
}
export async function installArchive(options: InstallArchiveOptions): Promise<void> {
const { archivePath, targetDir, verify, marker } = options
const stagingDir = stagingDirOf(targetDir)
await rm(stagingDir, { recursive: true, force: true })
await mkdir(stagingDir, { recursive: true })
try {
await tar.x({
file: archivePath,
cwd: stagingDir,
// 항목 하나라도 쓰지 못하면(ENOSPC/EPERM…) 경고가 아니라 오류로 끝낸다
strict: true,
// 경로 탈출 항목은 건너뛴다
filter: (path) => isSafeArchiveEntry(path),
})
if (!verify(stagingDir)) {
throw installFailed(`런타임 아카이브를 풀었지만 필수 파일이 없습니다: ${archivePath}`)
}
if (marker) {
await writeFile(join(stagingDir, marker.fileName), marker.content, 'utf8')
}
await swapIntoPlace(stagingDir, targetDir)
} finally {
await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined)
}
}

View file

@ -0,0 +1,187 @@
// src/main/services/runtime/runtime-index.ts
// 로컬 AI 런타임 인덱스(runtime.json)의 스키마와 버전 정책 — 순수 로직만 둔다.
//
// 왜 분리하나: 예전에는 인덱스를 `components` 필드만 보고 그대로 받아들였다. 그래서
// feed의 version 이 이 앱의 최소 버전보다 낮거나 형식이 깨져 있으면, 설치는 성공하고
// 마커에 낮은 버전이 적혀 다음 ensure() 가 다시 ~160MB를 받는 루프가 생겼다.
// 부품 이름도 검증하지 않아 `..\` 같은 이름이 임시 폴더 밖 경로로 이어질 수 있었다.
// 여기서 한 번에 검증하고, 검증을 통과한 값만 설치 단계로 넘긴다.
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { compareVersions } from '../../update-policy'
/** 앱이 내려받아야 하는 런타임 구성 요소 */
export type RuntimeComponent = 'sidecar' | 'ffmpeg'
export const RUNTIME_COMPONENTS: readonly RuntimeComponent[] = ['sidecar', 'ffmpeg']
export function isRuntimeComponent(value: unknown): value is RuntimeComponent {
return typeof value === 'string' && (RUNTIME_COMPONENTS as readonly string[]).includes(value)
}
/**
* 이 앱이 요구하는 런타임 최소 버전. 사이드카 API 가 바뀔 때만 올린다.
* 1.5.0 — UIA 브리지(`/uia/focus`). 1.7.0 — 보조 모델 자리(`/load` slot, `/transcribe` model_id).
* ffmpeg 는 CLI 가 안정적이라 확인하지 않는다.
*/
export const RUNTIME_MIN_VERSION: Readonly<Record<RuntimeComponent, string | null>> = {
sidecar: '1.7.0',
ffmpeg: null,
}
/**
* 설치된(또는 설치하려는) 런타임 버전이 최소 버전 이상인지.
* 앱 버전과 "같음" 으로 비교하지 않는다 — 그러면 엔진이 그대로인 릴리스마다 모든
* 사용자가 100MB를 다시 받는다. 버전을 읽을 수 없으면 만족하지 않는 것으로 본다.
*/
export function isRuntimeVersionSatisfied(
version: string | null | undefined,
minimum: string | null,
): boolean {
if (minimum === null) return true
if (typeof version !== 'string') return false
const order = compareVersions(version.trim(), minimum)
return order !== null && order >= 0
}
export interface RuntimePart {
name: string
size: number
sha256: string
url: string
}
export interface RuntimeComponentIndex {
archive: string
sha256: string
totalSize: number
parts: RuntimePart[]
}
/** parseRuntimeIndex 의 결과 — 검증된 구성 요소 항목과 인덱스 버전 */
export interface ResolvedRuntimeEntry {
version: string
entry: RuntimeComponentIndex
}
const SHA256_PATTERN = /^[0-9a-f]{64}$/
function invalid(detail: string): D3ROError {
return new D3ROError(ErrorCode.ConfigReadFailed, `런타임 인덱스 형식이 올바르지 않습니다: ${detail}`)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** 임시 폴더 안에만 놓일 수 있는 단순 파일 이름인지 (경로 구분자·`..` 금지) */
function isSafeFileName(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= 255 &&
value !== '.' &&
value !== '..' &&
!/[\\/:\0]/.test(value)
)
}
function isNonNegativeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
function parseSha256(value: unknown, field: string): string {
if (typeof value !== 'string' || !SHA256_PATTERN.test(value.toLowerCase())) {
throw invalid(`${field} 해시가 SHA-256 형식이 아닙니다`)
}
return value.toLowerCase()
}
function parseHttpUrl(value: unknown, field: string): string {
if (typeof value !== 'string') throw invalid(`${field} URL이 없습니다`)
let parsed: URL
try {
parsed = new URL(value)
} catch {
throw invalid(`${field} URL을 해석할 수 없습니다`)
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw invalid(`${field} URL 프로토콜이 허용되지 않습니다 (${parsed.protocol})`)
}
return value
}
function parsePart(raw: unknown, index: number): RuntimePart {
const field = `parts[${index}]`
if (!isRecord(raw)) throw invalid(`${field} 항목이 객체가 아닙니다`)
if (!isSafeFileName(raw.name)) throw invalid(`${field}.name 이 안전한 파일 이름이 아닙니다`)
if (!isNonNegativeInteger(raw.size)) throw invalid(`${field}.size 가 올바르지 않습니다`)
return {
name: raw.name,
size: raw.size,
sha256: parseSha256(raw.sha256, `${field}.sha256`),
url: parseHttpUrl(raw.url, `${field}.url`),
}
}
/**
* feed 에서 받은 runtime.json 을 검증해 해당 구성 요소 항목을 돌려준다.
*
* - 스키마(버전·archive·parts·해시·URL)를 검증한다.
* - 부품·아카이브 이름은 경로 구분자 없는 단순 파일 이름이어야 한다.
* - 인덱스 version 이 이 앱의 최소 버전보다 낮으면 거부한다 — 설치해 봐야 곧바로
* "낡았다" 고 판단돼 매번 다시 받는 루프가 되기 때문이다.
*/
export function parseRuntimeIndex(
raw: unknown,
component: RuntimeComponent,
minVersion: string | null = RUNTIME_MIN_VERSION[component],
): ResolvedRuntimeEntry {
if (!isRecord(raw)) throw invalid('최상위 값이 객체가 아닙니다')
const version = raw.version
if (typeof version !== 'string' || compareVersions(version, '0.0.0') === null) {
throw invalid('version 이 semver 문자열이 아닙니다')
}
const components = raw.components
if (!isRecord(components)) throw invalid('components 가 없습니다')
const rawEntry = components[component]
if (rawEntry === undefined) {
throw new D3ROError(
ErrorCode.ConfigReadFailed,
`런타임 인덱스에 ${component} 구성 요소가 없습니다 (version=${version})`,
)
}
if (!isRecord(rawEntry)) throw invalid(`${component} 항목이 객체가 아닙니다`)
if (!isSafeFileName(rawEntry.archive)) throw invalid(`${component}.archive 가 안전한 파일 이름이 아닙니다`)
if (!isNonNegativeInteger(rawEntry.totalSize)) throw invalid(`${component}.totalSize 가 올바르지 않습니다`)
if (!Array.isArray(rawEntry.parts) || rawEntry.parts.length === 0) {
throw invalid(`${component}.parts 가 비어 있습니다`)
}
const parts = rawEntry.parts.map((part, index) => parsePart(part, index))
const names = new Set(parts.map((part) => part.name))
if (names.size !== parts.length || names.has(rawEntry.archive)) {
throw invalid(`${component} 부품/아카이브 이름이 중복됩니다`)
}
if (!isRuntimeVersionSatisfied(version, minVersion)) {
throw new D3ROError(
ErrorCode.ConfigReadFailed,
`런타임 feed의 ${component} 버전(${version})이 이 앱의 최소 요구 버전(${String(minVersion)})보다 낮습니다`,
)
}
return {
version,
entry: {
archive: rawEntry.archive,
sha256: parseSha256(rawEntry.sha256, `${component}.sha256`),
totalSize: rawEntry.totalSize,
parts,
},
}
}

View file

@ -13,20 +13,20 @@
export const UPDATE_FEED_URL =
'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest'
// GitLab Generic Registry legacy mirror. 2026-08 이전 설치본(0.2.1-alpha)은
// 이 feed를 폴링하므로, 새 설치자가 Forgejo feed를 내장할 때까지 publisher가
// 함께 게시한다. 마이그레이션 완료 후 제거 가능. 런타임은 참조하지 않는다.
/**
* 로컬 AI 런타임(사이드카 엔진 / ffmpeg) 배포 위치.
* 진을 설치본에 넣으면 installer가 Cloudflare 업로드 한도(100MiB)를 넘어 업데이트
* 엔진을 설치본에 넣으면 installer가 Cloudflare 업로드 한도(100MiB)를 넘어 업데이트
* 메타데이터(latest.yml)를 게시할 수 없다. 그래서 런타임은 별도 경로에서 필요할 때 받는다.
* 자동 업데이트 채널과 분리된 경로이며 서명이 필요 없다.
*/
export const RUNTIME_FEED_URL = UPDATE_FEED_URL.replace(/\/latest$/, '/runtime-latest')
/** 런타임 인덱스 파일명 (feed 루트) */
/** 런타임 인덱스 파일명 (feed 루트) — RuntimeProvisioner 가 `${RUNTIME_FEED_URL}/${RUNTIME_INDEX_FILENAME}` 으로 읽는다 */
export const RUNTIME_INDEX_FILENAME = 'runtime.json'
// GitLab Generic Registry legacy mirror. 2026-08 이전 설치본(0.2.1-alpha)은
// 이 feed를 폴링하므로, 새 설치자가 Forgejo feed를 내장할 때까지 publisher가
// 함께 게시한다. 마이그레이션 완료 후 제거 가능. 런타임은 참조하지 않는다.
export const LEGACY_UPDATE_FEED_URL =
'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest'