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 }) => {
// 렌더러의 이 호출은 사용자가 누른 "내려받기 / 다시 설치" 버튼 하나뿐이다. 이미 설치돼
// 있는데 누른 경우는 손상(백신 격리 등)을 고치려는 것이므로 기본값으로 새로 받아
// 교체한다 — 존재·버전 확인만으로는 손상을 알 수 없어, 예전에는 아무 일도 하지 않았다.
// 설치돼 있으면 건너뛰어야 하는 호출처는 `force: false` 를 넘긴다.
ipcMain.handle(
IPC_CHANNELS.RUNTIME.ENSURE,
async (_event, params: { component: RuntimeComponent; force?: boolean }) => {
try {
const component = params?.component
if (component !== 'sidecar' && component !== 'ffmpeg') {
if (!isRuntimeComponent(component)) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, `알 수 없는 런타임 구성 요소: ${String(component)}`)
}
const binaryPath = await getRuntimeProvisioner().ensure(component)
const force = params?.force !== false
const binaryPath = await getRuntimeProvisioner().ensure(component, { force })
return ipcSuccess({ component, binaryPath })
} catch (error) {
return ipcError(error, ErrorCode.STTSidecarSpawnFailed)
return toIpcError(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(this.componentDir(component), this._binaryName(component))
}
return join(dir, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')
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 tempDir = join(this._runtimeRoot(), `.download-${component}`)
try {
const { version, entry } = await this._fetchIndex(component)
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('..'),
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'

View file

@ -51,6 +51,7 @@ import type {
} from '@d3ro/core/types'
import { CodexOAuthGuideModal } from './CodexOAuthGuideModal'
import { CaptionModelSelect } from './CaptionModelSelect'
import { subscribeSttTabEvents } from './stt/sttTabSubscriptions'
interface STTTabProps {
config: Partial<AppConfig>
@ -98,7 +99,20 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
if (res.success && res.data) setLocalModels(res.data)
})
const unsubDownload = window.electronAPI.stt.onDownloadProgress((e) => {
const loadRuntime = () => {
window.electronAPI.runtime.getStatus().then((res) => {
if (res.success && res.data) setRuntimeStatus(res.data)
})
}
loadRuntime()
return subscribeSttTabEvents(
{
onDownloadProgress: window.electronAPI.stt.onDownloadProgress,
onRuntimeProgress: window.electronAPI.runtime.onProgress,
},
{
onDownloadProgress: (e) => {
setDownloadingModelId(e.modelId)
setDownloadPercent(e.percent)
if (e.percent >= 100) {
@ -107,16 +121,8 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
if (res.success && res.data) setLocalModels(res.data)
})
}
})
const loadRuntime = () => {
window.electronAPI.runtime.getStatus().then((res) => {
if (res.success && res.data) setRuntimeStatus(res.data)
})
}
loadRuntime()
const unsubRuntime = window.electronAPI.runtime.onProgress((e) => {
},
onRuntimeProgress: (e) => {
if (e.component !== 'sidecar' && e.component !== 'ffmpeg') return
if (e.phase === 'done') {
setRuntimeBusy(null)
@ -126,13 +132,9 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
}
setRuntimeBusy(e.component)
setRuntimePercent(e.percent)
})
return () => {
unsubDownload()
}
unsubDownload()
unsubRuntime()
},
},
)
}, [])
// Load specific provider config when activeProvider changes

View file

@ -0,0 +1,33 @@
// src/renderer/components/stt/sttTabSubscriptions.ts
// STT 탭이 구독하는 main → renderer 진행률 이벤트를 한 곳에서 묶고, 해제도 한 번에 한다.
//
// 왜: 예전 effect 는 cleanup 이 `unsubDownload()` 만 부르고 끝났고, `unsubRuntime()` 은
// return 뒤의 도달 불가 코드에 있었다. 설정 창의 TabPanel 은 비활성 탭을 언마운트하므로
// STT 탭을 열 때마다 runtime:progress 리스너가 하나씩 쌓였다. 구독과 해제를 같은 함수가
// 책임지면 새 구독을 추가해도 해제를 빠뜨릴 수 없다.
export type Unsubscribe = () => void
export interface SttTabEventSource<DownloadEvent, RuntimeEvent> {
onDownloadProgress: (cb: (event: DownloadEvent) => void) => Unsubscribe
onRuntimeProgress: (cb: (event: RuntimeEvent) => void) => Unsubscribe
}
export interface SttTabEventHandlers<DownloadEvent, RuntimeEvent> {
onDownloadProgress: (event: DownloadEvent) => void
onRuntimeProgress: (event: RuntimeEvent) => void
}
/** 모든 구독을 등록하고, 전부 해제하는 함수 하나를 돌려준다 (useEffect cleanup 용) */
export function subscribeSttTabEvents<DownloadEvent, RuntimeEvent>(
source: SttTabEventSource<DownloadEvent, RuntimeEvent>,
handlers: SttTabEventHandlers<DownloadEvent, RuntimeEvent>,
): Unsubscribe {
const unsubscribes: Unsubscribe[] = [
source.onDownloadProgress(handlers.onDownloadProgress),
source.onRuntimeProgress(handlers.onRuntimeProgress),
]
return () => {
for (const unsubscribe of unsubscribes.splice(0)) unsubscribe()
}
}

View file

@ -0,0 +1,390 @@
// tests/main/services/runtime-provisioner-redteam-r1-8.test.ts
// RuntimeProvisioner 분리(인덱스 검증 / 부품 다운로드 / 스테이징 설치)와 회귀 테스트.
//
// 회귀 대상:
// 1) tar 항목 쓰기 오류가 경고로 삼켜져 반쯤 풀린 엔진에 버전 마커가 붙던 문제
// 2) 부품 전체 전송에 120초 제한이 걸려 느린 링크에서 영영 설치할 수 없던 문제
// 3) "다시 설치" 가 설치된 런타임에서 아무 일도 하지 않던 문제
// 4) 최소 버전 미만 인덱스를 받아들여 매번 다시 받는 루프
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as tar from 'tar'
import {
isRuntimeVersionSatisfied,
parseRuntimeIndex,
type RuntimePart,
} from '../../../src/main/services/runtime/runtime-index'
import {
downloadPart,
type RuntimeFetch,
type RuntimeFetchResponse,
} from '../../../src/main/services/runtime/download-part'
import { installArchive } from '../../../src/main/services/runtime/install-archive'
import { RuntimeProvisioner } from '../../../src/main/services/RuntimeProvisioner'
// ── 헬퍼 ────────────────────────────────────────────────────────────
const sha = (data: Buffer): string => createHash('sha256').update(data).digest('hex')
type TarEntry = { path: string; body?: string; type?: 'File' | 'Directory' }
/** 손으로 만든 tar (파일 다음에 같은 이름의 하위 항목을 넣어 항목 오류를 재현할 수 있다) */
function buildTar(entries: TarEntry[]): Buffer {
const blocks: Buffer[] = []
for (const entry of entries) {
const type = entry.type ?? 'File'
const body = Buffer.from(entry.body ?? '')
const header = new tar.Header({
path: entry.path,
mode: type === 'File' ? 0o644 : 0o755,
size: type === 'File' ? body.length : 0,
type,
mtime: new Date(0),
uid: 0,
gid: 0,
})
const headerBlock = Buffer.alloc(512)
header.encode(headerBlock, 0)
blocks.push(headerBlock)
if (type === 'File') {
blocks.push(body, Buffer.alloc((512 - (body.length % 512)) % 512))
}
}
blocks.push(Buffer.alloc(1024))
return Buffer.concat(blocks)
}
const SIDECAR_BIN = process.platform === 'win32' ? 'sidecar.exe' : 'sidecar'
function sidecarTar(version: string): Buffer {
return buildTar([
{ path: SIDECAR_BIN, body: `engine ${version}` },
{ path: '_internal', type: 'Directory' },
{ path: '_internal/core.dll', body: 'dll' },
])
}
/** 항목 하나가 반드시 실패하는 tar: 파일 `_internal/x` 뒤에 `_internal/x/y` */
function brokenSidecarTar(): Buffer {
return buildTar([
{ path: SIDECAR_BIN, body: 'engine' },
{ path: '_internal', type: 'Directory' },
{ path: '_internal/x', body: 'file in the way' },
{ path: '_internal/x/y.dll', body: 'never written' },
])
}
function response(
body: BodyInit | null,
init: { status?: number; headers?: Record<string, string> } = {},
): RuntimeFetchResponse {
return new Response(body, { status: init.status ?? 200, headers: init.headers }) as RuntimeFetchResponse
}
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
/** chunkCount 개의 청크를 intervalMs 간격으로 보내는 스트림 */
function slowStream(data: Buffer, chunkCount: number, intervalMs: number): ReadableStream<Uint8Array> {
const chunkSize = Math.ceil(data.length / chunkCount)
let offset = 0
return new ReadableStream<Uint8Array>({
async pull(controller) {
if (offset >= data.length) {
controller.close()
return
}
await delay(intervalMs)
controller.enqueue(new Uint8Array(data.subarray(offset, offset + chunkSize)))
offset += chunkSize
},
})
}
/** 앞부분 bytes 만 보내고 영원히 멈추는 스트림 */
function stallingStream(data: Buffer, bytes: number): ReadableStream<Uint8Array> {
let sent = false
return new ReadableStream<Uint8Array>({
pull(controller) {
if (!sent) {
sent = true
controller.enqueue(new Uint8Array(data.subarray(0, bytes)))
return
}
return new Promise<void>(() => undefined)
},
})
}
let workDir: string
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'd3ro-runtime-r1-8-'))
})
afterEach(() => {
rmSync(workDir, { recursive: true, force: true })
})
// ── parseRuntimeIndex ───────────────────────────────────────────────
function validIndex(version = '1.9.0'): Record<string, unknown> {
const part = Buffer.from('abc')
return {
schemaVersion: 1,
version,
components: {
sidecar: {
archive: 'd3ro-runtime-sidecar.tar.gz',
sha256: sha(part),
totalSize: part.length,
parts: [{ name: 'd3ro-runtime-sidecar.tar.gz.001', size: part.length, sha256: sha(part), url: 'https://feed/x.001' }],
},
},
}
}
describe('parseRuntimeIndex', () => {
it('정상 인덱스에서 구성 요소 항목과 버전을 돌려준다', () => {
const resolved = parseRuntimeIndex(validIndex(), 'sidecar', '1.7.0')
expect(resolved.version).toBe('1.9.0')
expect(resolved.entry.parts).toHaveLength(1)
})
it('최소 버전보다 낮은 인덱스는 거부한다 (재다운로드 루프 방지)', () => {
expect(() => parseRuntimeIndex(validIndex('1.6.9'), 'sidecar', '1.7.0')).toThrow(/최소 요구 버전/)
})
it('semver 가 아닌 version 은 거부한다', () => {
expect(() => parseRuntimeIndex(validIndex('latest'), 'sidecar', '1.7.0')).toThrow(/semver/)
})
it('경로 구분자나 .. 가 든 부품 이름은 거부한다', () => {
const index = validIndex()
const components = index.components as Record<string, { parts: RuntimePart[] }>
components.sidecar.parts[0].name = '..\\..\\evil.dll'
expect(() => parseRuntimeIndex(index, 'sidecar', '1.7.0')).toThrow(/안전한 파일 이름/)
})
it('해시가 SHA-256 형식이 아니면 거부한다', () => {
const index = validIndex()
const components = index.components as Record<string, { sha256: string }>
components.sidecar.sha256 = 'nope'
expect(() => parseRuntimeIndex(index, 'sidecar', '1.7.0')).toThrow(/SHA-256/)
})
it('구성 요소가 없으면 그 이름으로 알린다', () => {
expect(() => parseRuntimeIndex(validIndex(), 'ffmpeg', null)).toThrow(/ffmpeg 구성 요소가 없습니다/)
})
it('isRuntimeVersionSatisfied 는 최소 버전 null 이면 항상 참, 읽을 수 없으면 거짓', () => {
expect(isRuntimeVersionSatisfied(undefined, null)).toBe(true)
expect(isRuntimeVersionSatisfied('1.7.0\n', '1.7.0')).toBe(true)
expect(isRuntimeVersionSatisfied('1.6.0', '1.7.0')).toBe(false)
expect(isRuntimeVersionSatisfied('garbage', '1.7.0')).toBe(false)
})
})
// ── downloadPart ────────────────────────────────────────────────────
describe('downloadPart', () => {
const data = Buffer.alloc(64 * 1024, 7)
const part: RuntimePart = { name: 'p.001', size: data.length, sha256: sha(data), url: 'https://feed/p.001' }
it('전체 전송이 정체 타임아웃보다 길어도 데이터가 계속 오면 끝까지 받는다', async () => {
// 10청크 × 40ms ≈ 400ms > stallTimeout 150ms — 예전(전송 전체 타임아웃)에는 끊겼다
const fetchImpl: RuntimeFetch = async () => response(slowStream(data, 10, 40))
const dest = join(workDir, part.name)
const size = await downloadPart(part, dest, { fetchImpl, stallTimeoutMs: 150, attempts: 1 })
expect(size).toBe(data.length)
expect(sha(readFileSync(dest))).toBe(part.sha256)
})
it('데이터가 멈추면 끊고, 재시도에서 Range 로 이어 받는다', async () => {
const requests: Array<Record<string, string> | undefined> = []
const fetchImpl: RuntimeFetch = async (_url, init) => {
requests.push(init.headers)
if (requests.length === 1) return response(stallingStream(data, 1000))
const range = init.headers?.Range ?? ''
const start = Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0)
return response(new Uint8Array(data.subarray(start)), {
status: 206,
headers: { 'content-range': `bytes ${start}-${data.length - 1}/${data.length}` },
})
}
const dest = join(workDir, part.name)
const size = await downloadPart(part, dest, { fetchImpl, stallTimeoutMs: 80, attempts: 2 })
expect(size).toBe(data.length)
expect(requests[0]).toBeUndefined()
expect(requests[1]).toEqual({ Range: 'bytes=1000-' })
expect(sha(readFileSync(dest))).toBe(part.sha256)
})
it('모든 시도가 정체되면 정체 오류로 실패하고 부분 파일을 남기지 않는다', async () => {
const fetchImpl: RuntimeFetch = async () => response(stallingStream(data, 10))
const dest = join(workDir, part.name)
await expect(downloadPart(part, dest, { fetchImpl, stallTimeoutMs: 50, attempts: 2 })).rejects.toThrow(/멈췄습니다/)
expect(existsSync(dest)).toBe(false)
})
it('해시가 틀리면 부분 파일을 버리고 처음부터 다시 받는다', async () => {
let calls = 0
const fetchImpl: RuntimeFetch = async (_url, init) => {
calls += 1
expect(init.headers).toBeUndefined()
return response(new Uint8Array(calls === 1 ? Buffer.alloc(data.length, 1) : data))
}
const dest = join(workDir, part.name)
await downloadPart(part, dest, { fetchImpl, stallTimeoutMs: 1000, attempts: 2 })
expect(calls).toBe(2)
})
})
// ── installArchive ──────────────────────────────────────────────────
describe('installArchive', () => {
const verify = (dir: string): boolean =>
existsSync(join(dir, SIDECAR_BIN)) && existsSync(join(dir, '_internal'))
function seedPrevious(targetDir: string): void {
mkdirSync(join(targetDir, '_internal'), { recursive: true })
writeFileSync(join(targetDir, SIDECAR_BIN), 'old engine')
writeFileSync(join(targetDir, '.runtime-version'), '1.7.0')
}
it('항목 쓰기 오류가 나면 실패하고 기존 설치와 마커를 그대로 둔다', async () => {
const archivePath = join(workDir, 'broken.tar')
writeFileSync(archivePath, brokenSidecarTar())
const targetDir = join(workDir, 'sidecar')
seedPrevious(targetDir)
await expect(
installArchive({ archivePath, targetDir, verify, marker: { fileName: '.runtime-version', content: '1.9.0' } }),
).rejects.toThrow()
expect(readFileSync(join(targetDir, SIDECAR_BIN), 'utf8')).toBe('old engine')
expect(readFileSync(join(targetDir, '.runtime-version'), 'utf8')).toBe('1.7.0')
expect(existsSync(`${targetDir}.staging`)).toBe(false)
})
it('성공하면 새 트리로 교체하고 마커를 새 버전으로 쓴다', async () => {
const archivePath = join(workDir, 'ok.tar')
writeFileSync(archivePath, sidecarTar('1.9.0'))
const targetDir = join(workDir, 'sidecar')
seedPrevious(targetDir)
await installArchive({ archivePath, targetDir, verify, marker: { fileName: '.runtime-version', content: '1.9.0' } })
expect(readFileSync(join(targetDir, SIDECAR_BIN), 'utf8')).toBe('engine 1.9.0')
expect(readFileSync(join(targetDir, '.runtime-version'), 'utf8')).toBe('1.9.0')
expect(existsSync(join(targetDir, '_internal', 'core.dll'))).toBe(true)
expect(existsSync(`${targetDir}.old`)).toBe(false)
})
it('필수 파일이 없으면 교체하지 않는다', async () => {
const archivePath = join(workDir, 'noexe.tar')
writeFileSync(archivePath, buildTar([{ path: 'readme.txt', body: 'x' }]))
const targetDir = join(workDir, 'sidecar')
seedPrevious(targetDir)
await expect(installArchive({ archivePath, targetDir, verify })).rejects.toThrow(/필수 파일/)
expect(readFileSync(join(targetDir, SIDECAR_BIN), 'utf8')).toBe('old engine')
})
})
// ── RuntimeProvisioner (주입된 IO로 전체 흐름) ─────────────────────────
describe('RuntimeProvisioner', () => {
const FEED = 'https://feed.test/runtime-latest'
function feedFor(archive: Buffer, version: string): { fetchImpl: RuntimeFetch; calls: string[] } {
const calls: string[] = []
const partName = 'd3ro-runtime-sidecar.tar.gz.001'
const index = {
schemaVersion: 1,
version,
components: {
sidecar: {
archive: 'd3ro-runtime-sidecar.tar.gz',
sha256: sha(archive),
totalSize: archive.length,
parts: [{ name: partName, size: archive.length, sha256: sha(archive), url: `${FEED}/${partName}` }],
},
},
}
const fetchImpl: RuntimeFetch = async (url) => {
calls.push(url)
if (url === `${FEED}/runtime.json`) return response(JSON.stringify(index))
if (url === `${FEED}/${partName}`) return response(new Uint8Array(archive))
return response(null, { status: 404 })
}
return { fetchImpl, calls }
}
function provisioner(fetchImpl: RuntimeFetch): RuntimeProvisioner {
return new RuntimeProvisioner({
userDataDir: () => workDir,
fetchImpl,
feedUrl: FEED,
platform: process.platform,
stallTimeoutMs: 1000,
partAttempts: 1,
})
}
const markerPath = (): string => join(workDir, 'runtime', 'sidecar', '.runtime-version')
it('설치 후 마커를 쓰고, 다음 ensure 는 내려받지 않는다', async () => {
const feed = feedFor(sidecarTar('1.9.0'), '1.9.0')
const p = provisioner(feed.fetchImpl)
const path = await p.ensure('sidecar')
expect(path).toBe(p.binaryPath('sidecar'))
expect(readFileSync(markerPath(), 'utf8')).toBe('1.9.0')
feed.calls.length = 0
await p.ensure('sidecar')
expect(feed.calls).toEqual([])
})
it('force 면 설치돼 있어도 다시 받아 손상된 트리를 복구한다', async () => {
const feed = feedFor(sidecarTar('1.9.0'), '1.9.0')
const p = provisioner(feed.fetchImpl)
await p.ensure('sidecar')
// 백신이 DLL 을 격리한 상황
rmSync(join(workDir, 'runtime', 'sidecar', '_internal', 'core.dll'))
feed.calls.length = 0
await p.ensure('sidecar', { force: true })
expect(feed.calls.length).toBeGreaterThan(0)
expect(existsSync(join(workDir, 'runtime', 'sidecar', '_internal', 'core.dll'))).toBe(true)
})
it('추출 중 항목 오류가 나면 실패하고 버전 마커를 남기지 않는다', async () => {
const feed = feedFor(brokenSidecarTar(), '1.9.0')
const p = provisioner(feed.fetchImpl)
await expect(p.ensure('sidecar')).rejects.toThrow()
expect(existsSync(markerPath())).toBe(false)
expect(p.isInstalled('sidecar')).toBe(false)
})
it('최소 버전 미만 인덱스는 부품을 받기 전에 거부한다', async () => {
const feed = feedFor(sidecarTar('1.6.0'), '1.6.0')
const p = provisioner(feed.fetchImpl)
await expect(p.ensure('sidecar')).rejects.toThrow(/최소 요구 버전/)
expect(feed.calls).toEqual([`${FEED}/runtime.json`])
})
it('다운로드가 끝나면 임시 폴더를 남기지 않는다', async () => {
const feed = feedFor(sidecarTar('1.9.0'), '1.9.0')
await provisioner(feed.fetchImpl).ensure('sidecar')
expect(existsSync(join(workDir, 'runtime', '.download-sidecar'))).toBe(false)
expect(existsSync(join(workDir, 'runtime', 'sidecar.staging'))).toBe(false)
})
})

View file

@ -0,0 +1,62 @@
// tests/unit/stt-tab-subscriptions-redteam-r1-8.test.ts
// STT 탭 언마운트 시 모든 진행률 구독이 해제되는지 (runtime:progress 리스너 누수 회귀).
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { subscribeSttTabEvents } from '../../src/renderer/components/stt/sttTabSubscriptions'
type Listener = (event: number) => void
function fakeChannel(): { subscribe: (cb: Listener) => () => void; listeners: Set<Listener> } {
const listeners = new Set<Listener>()
return {
listeners,
subscribe: (cb) => {
listeners.add(cb)
return () => {
listeners.delete(cb)
}
},
}
}
describe('subscribeSttTabEvents', () => {
it('마운트·언마운트를 반복해도 리스너가 쌓이지 않는다', () => {
const download = fakeChannel()
const runtime = fakeChannel()
for (let mount = 0; mount < 12; mount += 1) {
const cleanup = subscribeSttTabEvents(
{ onDownloadProgress: download.subscribe, onRuntimeProgress: runtime.subscribe },
{ onDownloadProgress: () => undefined, onRuntimeProgress: () => undefined },
)
expect(download.listeners.size).toBe(1)
expect(runtime.listeners.size).toBe(1)
cleanup()
}
expect(download.listeners.size).toBe(0)
expect(runtime.listeners.size).toBe(0)
})
it('cleanup 을 두 번 불러도 안전하다 (StrictMode 이중 호출)', () => {
const download = fakeChannel()
const runtime = fakeChannel()
const cleanup = subscribeSttTabEvents(
{ onDownloadProgress: download.subscribe, onRuntimeProgress: runtime.subscribe },
{ onDownloadProgress: () => undefined, onRuntimeProgress: () => undefined },
)
cleanup()
expect(() => cleanup()).not.toThrow()
})
it('STTTab 은 진행률 이벤트를 이 헬퍼로만 구독한다 (개별 구독 + 누락된 해제 재발 방지)', () => {
const source = readFileSync(
join(__dirname, '../../src/renderer/components/STTTab.tsx'),
'utf8',
)
expect(source).toContain('return subscribeSttTabEvents(')
expect(source).not.toMatch(/const\s+unsub\w*\s*=\s*window\.electronAPI\.(stt|runtime)\.on/)
})
})