diff --git a/apps/desktop/src/main/ipc/stt-handlers.ts b/apps/desktop/src/main/ipc/stt-handlers.ts index 62c9711..b2d6738 100644 --- a/apps/desktop/src/main/ipc/stt-handlers.ts +++ b/apps/desktop/src/main/ipc/stt-handlers.ts @@ -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(error: unknown, fallback: ErrorCode): IPCResult { + 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) }) diff --git a/apps/desktop/src/main/services/RuntimeProvisioner.ts b/apps/desktop/src/main/services/RuntimeProvisioner.ts index f63912d..0ca65f7 100644 --- a/apps/desktop/src/main/services/RuntimeProvisioner.ts +++ b/apps/desktop/src/main/services/RuntimeProvisioner.ts @@ -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 -} - 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 = { - 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>() + + constructor(deps: Partial = {}) { super() + this._deps = { ...defaultDeps(), ...deps } this.on('error', () => { /* 기본 sink — EventEmitter 'error' 미처리 예외 방지 */ }) } - private _inFlight = new Map>() + private _runtimeRoot(): string { + return join(this._deps.userDataDir(), RUNTIME_DIR_NAME) + } - /** 설치된 런타임 트 (%APPDATA%/d3ro-voice/runtime/) */ + /** 설치된 런타임 트리 (%APPDATA%/d3ro-voice/runtime/) */ 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 { - if (this.isInstalled(component) && this._isCurrentVersion(component)) { + async ensure(component: RuntimeComponent, options: EnsureRuntimeOptions = {}): Promise { + 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 { - if (!RUNTIME_FEED_URL) { + private async _fetchIndex(component: RuntimeComponent): Promise> { + 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 { - 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 { - 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 { - 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 { diff --git a/apps/desktop/src/main/services/runtime/download-part.ts b/apps/desktop/src/main/services/runtime/download-part.ts new file mode 100644 index 0000000..9f048aa --- /dev/null +++ b/apps/desktop/src/main/services/runtime/download-part.ts @@ -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 | null + json(): Promise +} + +export type RuntimeFetch = ( + url: string, + init: { signal: AbortSignal; headers?: Record }, +) => Promise + +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 { + 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 { + try { + return (await stat(path)).size + } catch { + return 0 + } +} + +/** `Content-Range: bytes -/` 의 시작 오프셋 */ +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 { + 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 | 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) + 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 { + 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})`) +} diff --git a/apps/desktop/src/main/services/runtime/install-archive.ts b/apps/desktop/src/main/services/runtime/install-archive.ts new file mode 100644 index 0000000..7ea7b35 --- /dev/null +++ b/apps/desktop/src/main/services/runtime/install-archive.ts @@ -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 { + 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 { + 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) + } +} diff --git a/apps/desktop/src/main/services/runtime/runtime-index.ts b/apps/desktop/src/main/services/runtime/runtime-index.ts new file mode 100644 index 0000000..2d767de --- /dev/null +++ b/apps/desktop/src/main/services/runtime/runtime-index.ts @@ -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> = { + 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 { + 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, + }, + } +} diff --git a/apps/desktop/src/main/update-feed.ts b/apps/desktop/src/main/update-feed.ts index 72b6ea6..d26885a 100644 --- a/apps/desktop/src/main/update-feed.ts +++ b/apps/desktop/src/main/update-feed.ts @@ -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' diff --git a/apps/desktop/src/renderer/components/STTTab.tsx b/apps/desktop/src/renderer/components/STTTab.tsx index a6e2415..9425df9 100644 --- a/apps/desktop/src/renderer/components/STTTab.tsx +++ b/apps/desktop/src/renderer/components/STTTab.tsx @@ -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 @@ -98,17 +99,6 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen if (res.success && res.data) setLocalModels(res.data) }) - const unsubDownload = window.electronAPI.stt.onDownloadProgress((e) => { - setDownloadingModelId(e.modelId) - setDownloadPercent(e.percent) - if (e.percent >= 100) { - setDownloadingModelId(null) - window.electronAPI.stt.getModels().then((res) => { - if (res.success && res.data) setLocalModels(res.data) - }) - } - }) - const loadRuntime = () => { window.electronAPI.runtime.getStatus().then((res) => { if (res.success && res.data) setRuntimeStatus(res.data) @@ -116,23 +106,35 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen } loadRuntime() - const unsubRuntime = window.electronAPI.runtime.onProgress((e) => { - if (e.component !== 'sidecar' && e.component !== 'ffmpeg') return - if (e.phase === 'done') { - setRuntimeBusy(null) - setRuntimePercent(100) - loadRuntime() - return - } - setRuntimeBusy(e.component) - setRuntimePercent(e.percent) - }) - - return () => { - unsubDownload() - } - unsubDownload() - unsubRuntime() + return subscribeSttTabEvents( + { + onDownloadProgress: window.electronAPI.stt.onDownloadProgress, + onRuntimeProgress: window.electronAPI.runtime.onProgress, + }, + { + onDownloadProgress: (e) => { + setDownloadingModelId(e.modelId) + setDownloadPercent(e.percent) + if (e.percent >= 100) { + setDownloadingModelId(null) + window.electronAPI.stt.getModels().then((res) => { + if (res.success && res.data) setLocalModels(res.data) + }) + } + }, + onRuntimeProgress: (e) => { + if (e.component !== 'sidecar' && e.component !== 'ffmpeg') return + if (e.phase === 'done') { + setRuntimeBusy(null) + setRuntimePercent(100) + loadRuntime() + return + } + setRuntimeBusy(e.component) + setRuntimePercent(e.percent) + }, + }, + ) }, []) // Load specific provider config when activeProvider changes diff --git a/apps/desktop/src/renderer/components/stt/sttTabSubscriptions.ts b/apps/desktop/src/renderer/components/stt/sttTabSubscriptions.ts new file mode 100644 index 0000000..3f9f0ac --- /dev/null +++ b/apps/desktop/src/renderer/components/stt/sttTabSubscriptions.ts @@ -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 { + onDownloadProgress: (cb: (event: DownloadEvent) => void) => Unsubscribe + onRuntimeProgress: (cb: (event: RuntimeEvent) => void) => Unsubscribe +} + +export interface SttTabEventHandlers { + onDownloadProgress: (event: DownloadEvent) => void + onRuntimeProgress: (event: RuntimeEvent) => void +} + +/** 모든 구독을 등록하고, 전부 해제하는 함수 하나를 돌려준다 (useEffect cleanup 용) */ +export function subscribeSttTabEvents( + source: SttTabEventSource, + handlers: SttTabEventHandlers, +): Unsubscribe { + const unsubscribes: Unsubscribe[] = [ + source.onDownloadProgress(handlers.onDownloadProgress), + source.onRuntimeProgress(handlers.onRuntimeProgress), + ] + return () => { + for (const unsubscribe of unsubscribes.splice(0)) unsubscribe() + } +} diff --git a/apps/desktop/tests/main/services/runtime-provisioner-redteam-r1-8.test.ts b/apps/desktop/tests/main/services/runtime-provisioner-redteam-r1-8.test.ts new file mode 100644 index 0000000..7136c11 --- /dev/null +++ b/apps/desktop/tests/main/services/runtime-provisioner-redteam-r1-8.test.ts @@ -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 } = {}, +): RuntimeFetchResponse { + return new Response(body, { status: init.status ?? 200, headers: init.headers }) as RuntimeFetchResponse +} + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +/** chunkCount 개의 청크를 intervalMs 간격으로 보내는 스트림 */ +function slowStream(data: Buffer, chunkCount: number, intervalMs: number): ReadableStream { + const chunkSize = Math.ceil(data.length / chunkCount) + let offset = 0 + return new ReadableStream({ + 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 { + let sent = false + return new ReadableStream({ + pull(controller) { + if (!sent) { + sent = true + controller.enqueue(new Uint8Array(data.subarray(0, bytes))) + return + } + return new Promise(() => 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 { + 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 + 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 + 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 | 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) + }) +}) diff --git a/apps/desktop/tests/unit/stt-tab-subscriptions-redteam-r1-8.test.ts b/apps/desktop/tests/unit/stt-tab-subscriptions-redteam-r1-8.test.ts new file mode 100644 index 0000000..f9c3b8b --- /dev/null +++ b/apps/desktop/tests/unit/stt-tab-subscriptions-redteam-r1-8.test.ts @@ -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 } { + const listeners = new Set() + 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/) + }) +})