fix(runtime): stage runtime installs strictly, time out only on stalls, and make reinstall actually reinstall
This commit is contained in:
parent
ba0dbbd813
commit
524d390bc2
10 changed files with 1160 additions and 238 deletions
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
194
apps/desktop/src/main/services/runtime/download-part.ts
Normal file
194
apps/desktop/src/main/services/runtime/download-part.ts
Normal 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})`)
|
||||
}
|
||||
105
apps/desktop/src/main/services/runtime/install-archive.ts
Normal file
105
apps/desktop/src/main/services/runtime/install-archive.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
187
apps/desktop/src/main/services/runtime/runtime-index.ts
Normal file
187
apps/desktop/src/main/services/runtime/runtime-index.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue