fix(release): restore automatic updates by shipping the speech engine on demand
Some checks failed
deploy-site / deploy (push) Failing after 1m15s
Some checks failed
deploy-site / deploy (push) Failing after 1m15s
Auto-update could not work at all: the installer was 189 MB because it carried the local speech engine and ffmpeg, and the download feed rejects uploads over about 100 MiB, so update metadata could never be published. The installer now leaves those components out and the app fetches them the first time they are needed, verifying every part and the joined archive before installing. The installer is 90.6 MiB, the update feed is published again, and updates stay small because the engine is not re-sent on every release. The fetch is visible and recoverable: the download runs with progress, a failed install cleans up after itself, and Settings > STT shows the runtime status with a manual download action for when the automatic one cannot run.
This commit is contained in:
parent
0411f389d9
commit
0fbbbc1756
42 changed files with 1137 additions and 123 deletions
343
apps/desktop/src/main/services/RuntimeProvisioner.ts
Normal file
343
apps/desktop/src/main/services/RuntimeProvisioner.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
// src/main/services/RuntimeProvisioner.ts
|
||||
// 로컬 AI 타임(사이드카 엔진 / ffmpeg)을 설치 시점이 아니라 "필요할 때" 내려받는다.
|
||||
//
|
||||
// 왜: 사이드카(242MB)를 설치본에 넣으면 NSIS가 189MB가 되어 canonical feed의 업로드
|
||||
// 한도(Cloudflare 100MiB)를 넘고, 그 결과 자동 업데이트(latest.yml)를 갱신할 수 없다.
|
||||
// 엔진을 분리하면 설치본이 90MiB대로 내려가 updater가 정상 동작하고, 업데이트마다
|
||||
// 162MB를 다시 받지 않아도 된다.
|
||||
//
|
||||
// 안전:
|
||||
// - 부품별 SHA-256 + 결합본 SHA-256을 모두 검증한 뒤에만 설치한다.
|
||||
// - tar 경로 탈출(..) 항목은 건너뛴다.
|
||||
// - 실패하면 부분 다운로드를 지우고 기존 설치를 건드리지 않는다.
|
||||
|
||||
import { EventEmitter, once } from 'events'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, createWriteStream, existsSync, statSync } from 'node:fs'
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import { Readable } 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'
|
||||
|
||||
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'
|
||||
percent: number
|
||||
downloadedBytes: number
|
||||
totalBytes: number
|
||||
bytesPerSecond: number
|
||||
}
|
||||
|
||||
export interface RuntimeStatus {
|
||||
component: RuntimeComponent
|
||||
installed: boolean
|
||||
path: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
const RUNTIME_DIR_NAME = 'runtime'
|
||||
const DOWNLOAD_TIMEOUT_MS = 120_000
|
||||
|
||||
class RuntimeProvisioner extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
this.on('error', () => {
|
||||
/* 기본 sink — EventEmitter 'error' 미처리 예외 방지 */
|
||||
})
|
||||
}
|
||||
|
||||
private _inFlight = new Map<RuntimeComponent, Promise<string>>()
|
||||
|
||||
/** 설치된 런타임 트 (%APPDATA%/d3ro-voice/runtime/<component>) */
|
||||
componentDir(component: RuntimeComponent): string {
|
||||
return join(app.getPath('userData'), RUNTIME_DIR_NAME, 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')
|
||||
}
|
||||
|
||||
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 true
|
||||
}
|
||||
|
||||
getStatus(): RuntimeStatus[] {
|
||||
return RUNTIME_COMPONENTS.map((component) => {
|
||||
const binary = this.binaryPath(component)
|
||||
let sizeBytes = 0
|
||||
try {
|
||||
sizeBytes = existsSync(binary) ? statSync(binary).size : 0
|
||||
} catch {
|
||||
sizeBytes = 0
|
||||
}
|
||||
return {
|
||||
component,
|
||||
installed: this.isInstalled(component),
|
||||
path: binary,
|
||||
sizeBytes,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 구성 요소가 설치되어 있으면 경로를, 없으면 내려받아 설치한 뒤 경로를 돌려준다.
|
||||
* 동시 호출은 같은 작업을 공유한다.
|
||||
*/
|
||||
async ensure(component: RuntimeComponent): Promise<string> {
|
||||
if (this.isInstalled(component)) {
|
||||
return this.binaryPath(component)
|
||||
}
|
||||
|
||||
const existing = this._inFlight.get(component)
|
||||
if (existing) {
|
||||
logger.debug(`런타임 설치 진행 중 — 기존 작업에 합류: ${component}`)
|
||||
return existing
|
||||
}
|
||||
|
||||
const task = this._install(component).finally(() => {
|
||||
this._inFlight.delete(component)
|
||||
})
|
||||
this._inFlight.set(component, task)
|
||||
return task
|
||||
}
|
||||
|
||||
private async _install(component: RuntimeComponent): Promise<string> {
|
||||
const started = Date.now()
|
||||
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 })
|
||||
|
||||
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('..'),
|
||||
})
|
||||
|
||||
if (!this.isInstalled(component)) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`런타임 설치 후 실행 파일을 찾을 수 없습니다: ${this.binaryPath(component)}`,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`런타임 설치 실패(${component}): ${message}`,
|
||||
)
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchIndex(): Promise<RuntimeIndex> {
|
||||
if (!RUNTIME_FEED_URL) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.ConfigReadFailed,
|
||||
'런타임 feed가 설정되지 않았습니다 (자동 업데이트 비활성 상태)',
|
||||
)
|
||||
}
|
||||
const url = `${RUNTIME_FEED_URL}/runtime.json`
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) })
|
||||
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
|
||||
}
|
||||
|
||||
private async _downloadParts(
|
||||
component: RuntimeComponent,
|
||||
entry: RuntimeComponentIndex,
|
||||
tempDir: string,
|
||||
archivePath: string,
|
||||
): Promise<void> {
|
||||
const totalBytes = entry.totalSize > 0
|
||||
? entry.totalSize
|
||||
: entry.parts.reduce((sum, part) => sum + part.size, 0)
|
||||
|
||||
let downloadedBytes = 0
|
||||
const startedAt = Date.now()
|
||||
|
||||
for (const part of entry.parts) {
|
||||
const partPath = join(tempDir, part.name)
|
||||
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}`,
|
||||
)
|
||||
}
|
||||
|
||||
const hash = createHash('sha256')
|
||||
let partBytes = 0
|
||||
const source = Readable.fromWeb(response.body as never)
|
||||
source.on('data', (chunk: Buffer) => {
|
||||
hash.update(chunk)
|
||||
partBytes += chunk.length
|
||||
downloadedBytes += chunk.length
|
||||
const elapsed = Math.max(0.001, (Date.now() - startedAt) / 1000)
|
||||
this._emitProgress(
|
||||
component,
|
||||
'downloading',
|
||||
totalBytes > 0 ? Math.min(100, Math.round((downloadedBytes * 100) / totalBytes)) : 0,
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
Math.round(downloadedBytes / elapsed),
|
||||
)
|
||||
})
|
||||
|
||||
await pipeline(source, createWriteStream(partPath))
|
||||
|
||||
const actual = hash.digest('hex')
|
||||
if (part.sha256 && actual !== part.sha256) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`런타임 부품 해시 불일치 (${part.name})`,
|
||||
)
|
||||
}
|
||||
if (partBytes !== part.size) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`런타임 부품 크기 불일치 (${part.name}: ${partBytes} != ${part.size})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 부품을 순서대로 이어 인다 (스트리밍 — 메모리에 통째로 올리지 않는다)
|
||||
const archiveHash = createHash('sha256')
|
||||
const archiveStream = createWriteStream(archivePath)
|
||||
for (const part of entry.parts) {
|
||||
const source = createReadStream(join(tempDir, part.name))
|
||||
source.on('data', (chunk: string | Buffer) => archiveHash.update(chunk))
|
||||
await pipeline(source, archiveStream, { end: false })
|
||||
}
|
||||
archiveStream.end()
|
||||
await once(archiveStream, 'finish')
|
||||
const actualArchive = archiveHash.digest('hex')
|
||||
if (entry.sha256 && actualArchive !== entry.sha256) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarSpawnFailed,
|
||||
`런타임 아카이브 해시 불일치 (${component})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private _emitProgress(
|
||||
component: RuntimeComponent,
|
||||
phase: RuntimeProgressEvent['phase'],
|
||||
percent: number,
|
||||
downloadedBytes: number,
|
||||
totalBytes: number,
|
||||
bytesPerSecond: number,
|
||||
): void {
|
||||
this.emit('progress', {
|
||||
component,
|
||||
phase,
|
||||
percent,
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
bytesPerSecond,
|
||||
} satisfies RuntimeProgressEvent)
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: RuntimeProvisioner | null = null
|
||||
|
||||
export function getRuntimeProvisioner(): RuntimeProvisioner {
|
||||
if (!_instance) {
|
||||
_instance = new RuntimeProvisioner()
|
||||
}
|
||||
return _instance
|
||||
}
|
||||
|
||||
export function resetRuntimeProvisionerForTests(): void {
|
||||
_instance = null
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue