fix(release): restore automatic updates by shipping the speech engine on demand
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:
Yun Chan 2026-09-18 13:51:49 +09:00
parent 0411f389d9
commit 0fbbbc1756
42 changed files with 1137 additions and 123 deletions

View file

@ -25,12 +25,17 @@ import {
existsSync,
readFileSync,
readdirSync,
mkdirSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { createRequire } from 'node:module'
import { createGzip } from 'node:zlib'
import { pipeline } from 'node:stream/promises'
import { createReadStream, createWriteStream } from 'node:fs'
import * as tar from 'tar'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -306,6 +311,90 @@ writeFileSync(
`${JSON.stringify(indexJson, null, 2)}\n`,
'utf8',
)
// ── 로컬 AI 런타임 번들 (설치본에 넣지 않고 처음 필요할 때 내려받는다) ──────
const RUNTIME_DIR = join(releaseDir, 'runtime')
rmSync(RUNTIME_DIR, { recursive: true, force: true })
mkdirSync(RUNTIME_DIR, { recursive: true })
/** 디렉터리를 tar.gz으로 묶어 90MiB 부품으로 나누고 인덱스 항목을 돌려준다 */
async function packRuntime(component, sourceDir, archiveBase) {
const archivePath = join(RUNTIME_DIR, `${archiveBase}.tar.gz`)
await pipeline(
tar.c({ cwd: sourceDir, portable: true, gzip: false }, ['.']),
createGzip({ level: 6 }),
createWriteStream(archivePath),
)
const archiveBytes = readFileSync(archivePath)
const sha256 = createHash('sha256').update(archiveBytes).digest('hex')
const partSize = 90 * 1024 * 1024
const parts = []
for (let offset = 0, index = 1; offset < archiveBytes.length; offset += partSize, index += 1) {
const slice = archiveBytes.subarray(offset, Math.min(offset + partSize, archiveBytes.length))
const name = `${archiveBase}.tar.gz.${String(index).padStart(3, '0')}`
if (slice.length > MAX_ZIP_PART_BYTES) {
throw new Error(`런타임 부품이 너무 큽니다: ${name}`)
}
writeFileSync(join(RUNTIME_DIR, name), slice)
parts.push({
name,
size: slice.length,
sha256: createHash('sha256').update(slice).digest('hex'),
})
}
return {
component,
archive: `${archiveBase}.tar.gz`,
sha256,
totalSize: archiveBytes.length,
parts,
}
}
const runtimeComponents = {}
const sidecarSource = join(desktopDir, 'sidecar-dist', 'sidecar')
// @ffmpeg-installer가 플랫폼별로 제공하는 실행 파일 경로를 그대로 사용한다
const ffmpegInstaller = (() => {
try {
return require('@ffmpeg-installer/ffmpeg')
} catch {
return null
}
})()
const ffmpegSource = ffmpegInstaller?.path ? dirname(ffmpegInstaller.path) : null
if (existsSync(sidecarSource)) {
console.log('[portable] 런타임 번들 생성: sidecar (faster-whisper 진)')
runtimeComponents.sidecar = await packRuntime('sidecar', sidecarSource, 'd3ro-runtime-sidecar')
} else {
console.error('[portable] 경고: sidecar-dist가 없어 런타임 번들을 만들 수 없습니다')
}
if (ffmpegSource && existsSync(ffmpegSource)) {
console.log('[portable] 런타임 번들 생성: ffmpeg')
runtimeComponents.ffmpeg = await packRuntime('ffmpeg', ffmpegSource, 'd3ro-runtime-ffmpeg')
} else {
console.error('[portable] 경고: @ffmpeg-installer가 없어 ffmpeg 런타임을 만들 수 없습니다')
}
const runtimeIndex = {
schemaVersion: 1,
version,
generatedAt: new Date().toISOString(),
components: Object.fromEntries(
Object.entries(runtimeComponents).map(([name, entry]) => [
name,
{
...entry,
parts: entry.parts.map((part) => ({
...part,
url: `${FEED}/runtime-${version}/${part.name}`,
})),
},
]),
),
}
writeFileSync(join(RUNTIME_DIR, 'runtime.json'), `${JSON.stringify(runtimeIndex, null, 2)}\n`, 'utf8')
console.log(
[
'[portable] 완료',
@ -316,6 +405,7 @@ console.log(
` zip : ${zipParts.length}개 부품 / 합계 ${(zipBytes.length / 1048576).toFixed(1)}MiB`,
` 인덱스 : ${join(releaseDir, 'portable.json')}`,
` scoop : ${join(root, 'bucket', 'd3ro-voice.json')}`,
` 런타임 : ${Object.keys(runtimeComponents).join(', ') || '(없음)'}${join(RUNTIME_DIR, 'runtime.json')}`,
` 게시 : node scripts/ci/publish-portable-release.mjs`,
].join('\n'),
)