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 }) => {
|
||||
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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,57 +1,52 @@
|
|||
// src/main/services/RuntimeProvisioner.ts
|
||||
// 로컬 AI 타임(사이드카 엔진 / ffmpeg)을 설치 시점이 아니라 "필요할 때" 내려받는다.
|
||||
// 로컬 AI 런타임(사이드카 엔진 / ffmpeg)을 설치 시점이 아니라 "필요할 때" 내려받는다.
|
||||
//
|
||||
// 왜: 사이드카(242MB)를 설치본에 넣으면 NSIS가 189MB가 되어 canonical feed의 업로드
|
||||
// 한도(Cloudflare 100MiB)를 넘고, 그 결과 자동 업데이트(latest.yml)를 갱신할 수 없다.
|
||||
// 엔진을 분리하면 설치본이 90MiB대로 내려가 updater가 정상 동작하고, 업데이트마다
|
||||
// 162MB를 다시 받지 않아도 된다.
|
||||
//
|
||||
// 이 클래스는 조율만 한다. 책임은 runtime/ 아래로 나뉘어 있다.
|
||||
// - runtime-index.ts : 인덱스 스키마 검증 + 최소 버전 정책 (순수)
|
||||
// - download-part.ts : 부품 다운로드 (정체 타임아웃, 이어 받기, 크기·해시 검증)
|
||||
// - install-archive.ts : 스테이징 추출(strict) → 검증 → rename 교체 → 마커
|
||||
// IO(사용자 데이터 경로, fetch, feed URL, 플랫폼)는 생성자로 주입받는다.
|
||||
//
|
||||
// 안전:
|
||||
// - 부품별 SHA-256 + 결합본 SHA-256을 모두 검증한 뒤에만 설치한다.
|
||||
// - tar 경로 탈출(..) 항목은 건너뛴다.
|
||||
// - tar 경로 탈출(..) 항목은 건너뛰고, 항목 쓰기 오류는 설치 실패로 본다.
|
||||
// - 실패하면 부분 다운로드를 지우고 기존 설치를 건드리지 않는다.
|
||||
|
||||
import { EventEmitter, once } from 'events'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, createWriteStream, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { createReadStream, createWriteStream, existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { mkdir, rm, stat } from 'node:fs/promises'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import * as tar from 'tar'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { RUNTIME_FEED_URL } from '../update-feed'
|
||||
import { compareVersions } from '../update-policy'
|
||||
import { RUNTIME_FEED_URL, RUNTIME_INDEX_FILENAME } from '../update-feed'
|
||||
import {
|
||||
RUNTIME_COMPONENTS,
|
||||
RUNTIME_MIN_VERSION,
|
||||
isRuntimeVersionSatisfied,
|
||||
parseRuntimeIndex,
|
||||
type RuntimeComponent,
|
||||
type RuntimeComponentIndex,
|
||||
} from './runtime/runtime-index'
|
||||
import {
|
||||
DEFAULT_PART_DOWNLOAD_ATTEMPTS,
|
||||
DEFAULT_STALL_TIMEOUT_MS,
|
||||
downloadPart,
|
||||
sha256File,
|
||||
type RuntimeFetch,
|
||||
} from './runtime/download-part'
|
||||
import { installArchive } from './runtime/install-archive'
|
||||
|
||||
export { RUNTIME_COMPONENTS, type RuntimeComponent } from './runtime/runtime-index'
|
||||
|
||||
const logger = getLogger('RuntimeProvisioner')
|
||||
|
||||
/** 이 내려받아야 하는 런타임 구성 요소 */
|
||||
export type RuntimeComponent = 'sidecar' | 'ffmpeg'
|
||||
|
||||
export const RUNTIME_COMPONENTS: readonly RuntimeComponent[] = ['sidecar', 'ffmpeg']
|
||||
|
||||
interface RuntimePart {
|
||||
name: string
|
||||
size: number
|
||||
sha256: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface RuntimeComponentIndex {
|
||||
archive: string
|
||||
sha256: string
|
||||
totalSize: number
|
||||
parts: RuntimePart[]
|
||||
}
|
||||
|
||||
interface RuntimeIndex {
|
||||
schemaVersion: number
|
||||
version: string
|
||||
components: Record<string, RuntimeComponentIndex>
|
||||
}
|
||||
|
||||
export interface RuntimeProgressEvent {
|
||||
component: RuntimeComponent
|
||||
phase: 'index' | 'downloading' | 'extracting' | 'done'
|
||||
|
|
@ -68,54 +63,82 @@ export interface RuntimeStatus {
|
|||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface EnsureRuntimeOptions {
|
||||
/**
|
||||
* true 면 이미 설치돼 있고 버전이 맞아도 새로 내려받아 교체한다 (사용자의 "다시 설치").
|
||||
* 파일이 손상됐는지(백신 격리 등)는 존재 확인만으로 알 수 없어서 필요하다.
|
||||
*/
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
/** 주입 가능한 IO — 기본값은 electron app 경로·전역 fetch·canonical feed */
|
||||
export interface RuntimeProvisionerDeps {
|
||||
userDataDir: () => string
|
||||
fetchImpl: RuntimeFetch
|
||||
feedUrl: string
|
||||
platform: NodeJS.Platform
|
||||
stallTimeoutMs: number
|
||||
partAttempts: number
|
||||
}
|
||||
|
||||
const RUNTIME_DIR_NAME = 'runtime'
|
||||
/** 설치된 런타임의 버전(runtime.json version)을 남기는 마커 */
|
||||
const RUNTIME_VERSION_FILE = '.runtime-version'
|
||||
const INDEX_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* 이 앱이 요구하는 런타임 최소 버전. 사이드카 API 가 바뀔 때만 올린다.
|
||||
* 1.5.0 — UIA 브리지(`/uia/focus`). 1.7.0 — 보조 모델 자리(`/load` slot, `/transcribe` model_id).
|
||||
* ffmpeg 는 CLI 가 안정적이라 확인하지 않는다.
|
||||
*/
|
||||
const RUNTIME_MIN_VERSION: Record<RuntimeComponent, string | null> = {
|
||||
sidecar: '1.7.0',
|
||||
ffmpeg: null
|
||||
const defaultFetch: RuntimeFetch = (url, init) => fetch(url, init)
|
||||
|
||||
function defaultDeps(): RuntimeProvisionerDeps {
|
||||
return {
|
||||
userDataDir: () => app.getPath('userData'),
|
||||
fetchImpl: defaultFetch,
|
||||
feedUrl: RUNTIME_FEED_URL,
|
||||
platform: process.platform,
|
||||
stallTimeoutMs: DEFAULT_STALL_TIMEOUT_MS,
|
||||
partAttempts: DEFAULT_PART_DOWNLOAD_ATTEMPTS,
|
||||
}
|
||||
}
|
||||
const DOWNLOAD_TIMEOUT_MS = 120_000
|
||||
/** 부품 다운로드 재시도 횟수 — 전송 중 잘림/일시적 네트워크 오류 대비 */
|
||||
const PART_DOWNLOAD_ATTEMPTS = 3
|
||||
|
||||
class RuntimeProvisioner extends EventEmitter {
|
||||
constructor() {
|
||||
export class RuntimeProvisioner extends EventEmitter {
|
||||
private readonly _deps: RuntimeProvisionerDeps
|
||||
private _inFlight = new Map<RuntimeComponent, Promise<string>>()
|
||||
|
||||
constructor(deps: Partial<RuntimeProvisionerDeps> = {}) {
|
||||
super()
|
||||
this._deps = { ...defaultDeps(), ...deps }
|
||||
this.on('error', () => {
|
||||
/* 기본 sink — EventEmitter 'error' 미처리 예외 방지 */
|
||||
})
|
||||
}
|
||||
|
||||
private _inFlight = new Map<RuntimeComponent, Promise<string>>()
|
||||
private _runtimeRoot(): string {
|
||||
return join(this._deps.userDataDir(), RUNTIME_DIR_NAME)
|
||||
}
|
||||
|
||||
/** 설치된 런타임 트 (%APPDATA%/d3ro-voice/runtime/<component>) */
|
||||
/** 설치된 런타임 트리 (%APPDATA%/d3ro-voice/runtime/<component>) */
|
||||
componentDir(component: RuntimeComponent): string {
|
||||
return join(app.getPath('userData'), RUNTIME_DIR_NAME, component)
|
||||
return join(this._runtimeRoot(), component)
|
||||
}
|
||||
|
||||
/** 구성 요소 실행 파일 경로 (설치 여부와 무관하게 경로만 계산) */
|
||||
binaryPath(component: RuntimeComponent): string {
|
||||
const dir = this.componentDir(component)
|
||||
if (component === 'sidecar') {
|
||||
return join(dir, process.platform === 'win32' ? 'sidecar.exe' : 'sidecar')
|
||||
}
|
||||
return join(dir, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')
|
||||
return join(this.componentDir(component), this._binaryName(component))
|
||||
}
|
||||
|
||||
private _binaryName(component: RuntimeComponent): string {
|
||||
const suffix = this._deps.platform === 'win32' ? '.exe' : ''
|
||||
return component === 'sidecar' ? `sidecar${suffix}` : `ffmpeg${suffix}`
|
||||
}
|
||||
|
||||
isInstalled(component: RuntimeComponent): boolean {
|
||||
const binary = this.binaryPath(component)
|
||||
if (!existsSync(binary)) return false
|
||||
if (component === 'sidecar' && !existsSync(join(this.componentDir('sidecar'), '_internal'))) {
|
||||
// PyInstaller onedir은 _internal 없이는 동작하지 않는다 (부분 설치 방어)
|
||||
return false
|
||||
}
|
||||
return this._hasRequiredFiles(component, this.componentDir(component))
|
||||
}
|
||||
|
||||
/** dir 에 구성 요소 실행에 필요한 파일이 있는지 (설치본·스테이징 공용) */
|
||||
private _hasRequiredFiles(component: RuntimeComponent, dir: string): boolean {
|
||||
if (!existsSync(join(dir, this._binaryName(component)))) return false
|
||||
// PyInstaller onedir은 _internal 없이는 동작하지 않는다 (부분 설치 방어)
|
||||
if (component === 'sidecar' && !existsSync(join(dir, '_internal'))) return false
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -125,16 +148,13 @@ class RuntimeProvisioner extends EventEmitter {
|
|||
* 사이드카는 설치본에 넣지 않고 별도로 내려받는다(위 주석) — 그래서 앱이 업데이트돼
|
||||
* 새 엔드포인트를 요구해도(예: v1.5.0의 `/uia/focus`), 예전에 내려받은 사이드카가
|
||||
* 남아 있으면 새 앱은 그 구버전 엔진과 계속 통신한다. 마커가 없거나 최소 버전보다
|
||||
* 낮으면 다시 받는다. 앱 버전과 "같음" 으로 비교하지 않는다 — 그러면 엔진이 그대로인
|
||||
* 릴리스마다 모든 사용자가 100MB를 다시 받는다.
|
||||
* 낮으면 다시 받는다.
|
||||
*/
|
||||
private _isCurrentVersion(component: RuntimeComponent): boolean {
|
||||
const minimum = RUNTIME_MIN_VERSION[component]
|
||||
if (minimum === null) return true
|
||||
try {
|
||||
const installed = readFileSync(this._versionMarkerPath(component), 'utf8').trim()
|
||||
const order = compareVersions(installed, minimum)
|
||||
return order !== null && order >= 0
|
||||
return isRuntimeVersionSatisfied(readFileSync(this._versionMarkerPath(component), 'utf8'), minimum)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
|
@ -164,10 +184,10 @@ class RuntimeProvisioner extends EventEmitter {
|
|||
|
||||
/**
|
||||
* 구성 요소가 설치되어 있으면 경로를, 없으면 내려받아 설치한 뒤 경로를 돌려준다.
|
||||
* 동시 호출은 같은 작업을 공유한다.
|
||||
* `force` 면 설치돼 있어도 새로 받아 교체한다. 동시 호출은 같은 작업을 공유한다.
|
||||
*/
|
||||
async ensure(component: RuntimeComponent): Promise<string> {
|
||||
if (this.isInstalled(component) && this._isCurrentVersion(component)) {
|
||||
async ensure(component: RuntimeComponent, options: EnsureRuntimeOptions = {}): Promise<string> {
|
||||
if (!options.force && this.isInstalled(component) && this._isCurrentVersion(component)) {
|
||||
return this.binaryPath(component)
|
||||
}
|
||||
|
||||
|
|
@ -189,58 +209,30 @@ class RuntimeProvisioner extends EventEmitter {
|
|||
logger.info(`런타임 설치 시작: ${component}`)
|
||||
this._emitProgress(component, 'index', 0, 0, 0, 0)
|
||||
|
||||
const index = await this._fetchIndex()
|
||||
const entry = index.components[component]
|
||||
if (!entry) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.ConfigReadFailed,
|
||||
`런타임 인덱스에 ${component} 구성 요소가 없습니다 (version=${index.version})`,
|
||||
)
|
||||
}
|
||||
|
||||
const targetDir = this.componentDir(component)
|
||||
const tempDir = join(app.getPath('userData'), RUNTIME_DIR_NAME, `.download-${component}`)
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
await mkdir(tempDir, { recursive: true })
|
||||
|
||||
const tempDir = join(this._runtimeRoot(), `.download-${component}`)
|
||||
try {
|
||||
const { version, entry } = await this._fetchIndex(component)
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
await mkdir(tempDir, { recursive: true })
|
||||
|
||||
const archivePath = join(tempDir, entry.archive)
|
||||
await this._downloadParts(component, entry, tempDir, archivePath)
|
||||
|
||||
if (component === 'sidecar' || component === 'ffmpeg') {
|
||||
// 기존 설치를 지우고 새로 배치한다 (부분 상태 방지: 먼저 temp에 풀고 검증 후 교체)
|
||||
await rm(targetDir, { recursive: true, force: true })
|
||||
await mkdir(targetDir, { recursive: true })
|
||||
}
|
||||
|
||||
this._emitProgress(component, 'extracting', 100, entry.totalSize, entry.totalSize, 0)
|
||||
await tar.x({
|
||||
file: archivePath,
|
||||
cwd: targetDir,
|
||||
// 경로 탈출 항목은 건너뛴다
|
||||
filter: (path) => !path.split('/').includes('..'),
|
||||
await installArchive({
|
||||
archivePath,
|
||||
targetDir: this.componentDir(component),
|
||||
verify: (dir) => this._hasRequiredFiles(component, dir),
|
||||
marker: { fileName: RUNTIME_VERSION_FILE, content: version },
|
||||
})
|
||||
|
||||
if (!this.isInstalled(component)) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`런타임 설치 후 실행 파일을 찾을 수 없습니다: ${this.binaryPath(component)}`,
|
||||
)
|
||||
}
|
||||
|
||||
writeFileSync(this._versionMarkerPath(component), index.version, 'utf8')
|
||||
|
||||
this._emitProgress(component, 'done', 100, entry.totalSize, entry.totalSize, 0)
|
||||
logger.info(
|
||||
`런타임 설치 완료: ${component} (${(entry.totalSize / 1048576).toFixed(1)}MiB, ${Date.now() - started}ms)`,
|
||||
)
|
||||
return this.binaryPath(component)
|
||||
} catch (err) {
|
||||
// 실패 시 부분 산출물 정리 — 반쯤 풀린 설치를 남기지 않는다
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
if (!this.isInstalled(component)) {
|
||||
await rm(targetDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.error(`런타임 설치 실패: ${component} — ${message}`)
|
||||
if (err instanceof D3ROError) throw err
|
||||
|
|
@ -249,30 +241,27 @@ class RuntimeProvisioner extends EventEmitter {
|
|||
`런타임 설치 실패(${component}): ${message}`,
|
||||
)
|
||||
} finally {
|
||||
// 부분 산출물 정리 — 기존 설치(targetDir)는 installArchive 가 교체할 때만 바뀐다
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchIndex(): Promise<RuntimeIndex> {
|
||||
if (!RUNTIME_FEED_URL) {
|
||||
private async _fetchIndex(component: RuntimeComponent): Promise<ReturnType<typeof parseRuntimeIndex>> {
|
||||
if (!this._deps.feedUrl) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.ConfigReadFailed,
|
||||
'런타임 feed가 설정되지 않았습니다 (자동 업데이트 비활성 상태)',
|
||||
)
|
||||
}
|
||||
const url = `${RUNTIME_FEED_URL}/runtime.json`
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) })
|
||||
const url = `${this._deps.feedUrl}/${RUNTIME_INDEX_FILENAME}`
|
||||
const response = await this._deps.fetchImpl(url, { signal: AbortSignal.timeout(INDEX_TIMEOUT_MS) })
|
||||
if (!response.ok) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.ConfigReadFailed,
|
||||
`런타임 인덱스를 받을 수 없습니다 (HTTP ${response.status}): ${url}`,
|
||||
)
|
||||
}
|
||||
const index = (await response.json()) as RuntimeIndex
|
||||
if (!index?.components) {
|
||||
throw new D3ROError(ErrorCode.ConfigReadFailed, '런타임 인덱스 형식이 올바르지 않습니다')
|
||||
}
|
||||
return index
|
||||
return parseRuntimeIndex(await response.json(), component)
|
||||
}
|
||||
|
||||
private async _downloadParts(
|
||||
|
|
@ -281,16 +270,21 @@ class RuntimeProvisioner extends EventEmitter {
|
|||
tempDir: string,
|
||||
archivePath: string,
|
||||
): Promise<void> {
|
||||
const totalBytes = entry.totalSize > 0
|
||||
? entry.totalSize
|
||||
: entry.parts.reduce((sum, part) => sum + part.size, 0)
|
||||
const expectedSize = entry.parts.reduce((sum, part) => sum + part.size, 0)
|
||||
const totalBytes = entry.totalSize > 0 ? entry.totalSize : expectedSize
|
||||
|
||||
let downloadedBytes = 0
|
||||
const startedAt = Date.now()
|
||||
|
||||
for (const part of entry.parts) {
|
||||
const partPath = join(tempDir, part.name)
|
||||
const partSize = await this._downloadPart(part, partPath)
|
||||
const partSize = await downloadPart(part, join(tempDir, part.name), {
|
||||
fetchImpl: this._deps.fetchImpl,
|
||||
stallTimeoutMs: this._deps.stallTimeoutMs,
|
||||
attempts: this._deps.partAttempts,
|
||||
onAttemptFailed: (message, attempt, attempts) => {
|
||||
logger.warn(`런타임 부품 다운로드 실패 (${part.name}, ${attempt}/${attempts}): ${message}`)
|
||||
},
|
||||
})
|
||||
|
||||
downloadedBytes += partSize
|
||||
const elapsed = Math.max(0.001, (Date.now() - startedAt) / 1000)
|
||||
|
|
@ -304,17 +298,19 @@ class RuntimeProvisioner extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
// 부품을 순서대로 이어 붙인다 (스트리밍 — 메모리에 통째로 올리지 않는다)
|
||||
// 부품을 순서대로 이어 붙인다 (스트리밍 — 메모리에 통째로 올리지 않는다).
|
||||
// 이어 붙인 부품은 바로 지운다 — 추출 중 디스크 최고 사용량(부품+결합본+트리)을 줄인다.
|
||||
const archiveStream = createWriteStream(archivePath)
|
||||
for (const part of entry.parts) {
|
||||
await pipeline(createReadStream(join(tempDir, part.name)), archiveStream, { end: false })
|
||||
const partPath = join(tempDir, part.name)
|
||||
await pipeline(createReadStream(partPath), archiveStream, { end: false })
|
||||
await rm(partPath, { force: true })
|
||||
}
|
||||
archiveStream.end()
|
||||
await once(archiveStream, 'finish')
|
||||
|
||||
// 크기를 먼저 본다 — 불일치하면 "어디까지 받았는지"가 로그에 남아 진단이 가능하다.
|
||||
const archiveSize = (await stat(archivePath)).size
|
||||
const expectedSize = entry.parts.reduce((sum, part) => sum + part.size, 0)
|
||||
if (archiveSize !== expectedSize) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
|
|
@ -323,7 +319,7 @@ class RuntimeProvisioner extends EventEmitter {
|
|||
}
|
||||
|
||||
const actualArchive = await sha256File(archivePath)
|
||||
if (entry.sha256 && actualArchive !== entry.sha256) {
|
||||
if (actualArchive !== entry.sha256) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`런타임 아카이브 해시 불일치 (${component}: ${actualArchive} != ${entry.sha256})`,
|
||||
|
|
@ -331,60 +327,6 @@ class RuntimeProvisioner extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부품 하나를 디스크로 내려받고 디스크 기준으로 크기·해시를 검증한다.
|
||||
* 전송이 도중에 끊기면 같은 부품을 다시 받는다 (기존에는 1회 실패가 곧 설치 실패였다).
|
||||
*/
|
||||
private async _downloadPart(part: RuntimePart, partPath: string): Promise<number> {
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 1; attempt <= PART_DOWNLOAD_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(part.url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) })
|
||||
if (!response.ok || !response.body) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`런타임 부품을 받을 수 없습니다 (HTTP ${response.status}): ${part.name}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 스트림을 파일로 저장한 뒤 "디스크에 실제로 남은 파일"에서 크기와 해시를 계산한다.
|
||||
// 메모리 스트림에서 센 값으로 검증하면, 디스크 쓰기가 잘려도 부품 검사를 통과해
|
||||
// 결합 단계에 가서야 해시 불일치로 터진다 — 실측 사고.
|
||||
await pipeline(Readable.fromWeb(response.body as never), createWriteStream(partPath))
|
||||
|
||||
const partSize = (await stat(partPath)).size
|
||||
if (partSize !== part.size) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`런타임 부품 크기 불일치 (${part.name}: ${partSize} != ${part.size})`,
|
||||
)
|
||||
}
|
||||
|
||||
const actualPartHash = await sha256File(partPath)
|
||||
if (part.sha256 && actualPartHash !== part.sha256) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`런타임 부품 해시 불일치 (${part.name})`,
|
||||
)
|
||||
}
|
||||
|
||||
return partSize
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err))
|
||||
await rm(partPath, { force: true }).catch(() => undefined)
|
||||
logger.warn(
|
||||
`런타임 부품 다운로드 실패 (${part.name}, ${attempt}/${PART_DOWNLOAD_ATTEMPTS}): ${lastError.message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`런타임 부품 다운로드 실패 (${part.name})`,
|
||||
)
|
||||
}
|
||||
|
||||
private _emitProgress(
|
||||
component: RuntimeComponent,
|
||||
phase: RuntimeProgressEvent['phase'],
|
||||
|
|
@ -404,18 +346,6 @@ class RuntimeProvisioner extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/** 파일 SHA-256 (스트림 — 메모리에 통째로 올리지 않는다) */
|
||||
async function sha256File(path: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
await pipeline(createReadStream(path), new Writable({
|
||||
write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
|
||||
hash.update(chunk)
|
||||
callback()
|
||||
},
|
||||
}))
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
let _instance: RuntimeProvisioner | null = null
|
||||
|
||||
export function getRuntimeProvisioner(): RuntimeProvisioner {
|
||||
|
|
|
|||
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'
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue