d3ro-voice/scripts/ci/build-portable.mjs
Yun Chan f14341ace4
Some checks failed
deploy-site / deploy (push) Failing after 1m6s
fix(release): keep the updater configuration in the installer
Installing the previous build left an app that could not update itself: the
packaging path used to guarantee the native module build does not create the
updater configuration file, so the update client had nothing to read.

That file is now written from the single feed source and its presence in the
packaged app is checked before anything is published, so an installer that
cannot update can no longer be released.
2026-09-18 16:35:03 +09:00

436 lines
No EOL
16 KiB
JavaScript

// scripts/ci/build-portable.mjs
// 서명 없이 배포할 수 있는 휴대용 Windows 배포본을 만든다(7z 분할 볼륨 + Scoop 매니페스트).
//
// 왜 분할인가: canonical feed(git.chanpaca.net)는 Cloudflare 뒤에 있고 업로드 본문이
// ~100MiB(104,857,600 bytes)를 넘으면 413으로 거부한다(실측: 60MiB 201 / 110MiB 413).
// 사이드카(faster-whisper)를 포함한 앱은 그 한도를 넘으므로, 95MiB 단위 7z 볼륨으로
// 나눠 올리고 Scoop이 볼륨을 이어서 해제하도록 한다(Scoop은 .7z.001 볼을 공식 지원).
//
// 산출물:
// apps/desktop/release/<version>/D3RO-Voice-<version>-x64-portable.7z.001/.002/...
// apps/desktop/release/<version>/portable.json (볼륨 인덱스: 이름/크기/sha256)
// bucket/d3ro-voice.json (Scoop 매니페스트, 커밋 대상)
//
// zip 분할 부품(수동 설치용)도 함께 만든다 — Windows 내장 Expand-Archive로 해제할 수 있어
// 사용자에게 7-Zip 설치를 요구하지 않는다. 7z 볼륨은 Scoop 전용으로 더 작다(162MiB vs 243MiB).
//
// 사용:
// npm run build --workspace=@d3ro/desktop
// node scripts/ci/build-portable.mjs # 7z 분할 볼륨
// node scripts/ci/build-portable.mjs --zip # + 로컬 배포용 단일 zip(게시용 아님)
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
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'
const require = createRequire(import.meta.url)
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const desktopDir = join(root, 'apps', 'desktop')
const version = JSON.parse(
readFileSync(join(root, 'release', 'product-version.json'), 'utf8'),
).version
const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice'
const PORTABLE_VERSION_PATH = `${FEED}/portable-${version}`
const PORTABLE_LATEST_PATH = `${FEED}/portable-latest`
const ARCHIVE_BASE = `D3RO-Voice-${version}-x64-portable`
/** Cloudflare 본문 한도(100MiB)보다 여유를 둔 7z 볼륨 크기 (Scoop 경로) */
const VOLUME_SIZE = '95m'
const MAX_VOLUME_BYTES = 95 * 1024 * 1024
/** 수동 설치 스크립트용 zip 분할 부품 크기 */
const ZIP_PART_SIZE = '90m'
const MAX_ZIP_PART_BYTES = 95 * 1024 * 1024
const wantZip = process.argv.includes('--zip')
const releaseDir = join(desktopDir, 'release', version)
const appDir = join(releaseDir, 'win-unpacked')
if (!existsSync(join(desktopDir, 'out', 'main', 'index.js'))) {
console.error(
'out/main/index.js 가 없습니다. 먼저 데스크톱 번들을 빌드하세요:\n' +
' npm run build --workspace=@d3ro/desktop',
)
process.exit(1)
}
if (!existsSync(join(desktopDir, 'sidecar-dist', 'sidecar'))) {
console.error(
'STT 사이드카 번들이 없습니다. 서명 없이 배포해도 로컬 전사에는 사이드카가 필요합니다:\n' +
' npm --prefix apps/desktop run sidecar:setup\n' +
' npm --prefix apps/desktop run sidecar:build',
)
process.exit(1)
}
/** 현재 스크립트와 같은 Node로 CI 스크립트를 실행한다 */
function runNodeScript(relativePath, scriptArgs) {
console.log(`[portable] $ node ${relativePath} ${scriptArgs.join(' ')}`)
const result = spawnSync(
process.execPath,
[join(root, relativePath), ...scriptArgs],
{ cwd: root, stdio: 'inherit' },
)
if (result.status !== 0) {
console.error(`[portable] ${relativePath} 실패 (exit ${result.status ?? 'null'})`)
process.exit(result.status ?? 1)
}
}
function resolve7za() {
// electron-builder가 의존하는 7zip-bin이 플랫폼별 7za 실행 파일을 제공한다.
const platformDir =
process.platform === 'win32'
? join('win', process.arch === 'arm64' ? 'arm64' : 'x64')
: process.platform === 'darwin'
? join('mac', process.arch === 'arm64' ? 'arm64' : 'x64')
: join('linux', process.arch === 'arm64' ? 'arm64' : 'x64')
const binary = process.platform === 'win32' ? '7za.exe' : '7za'
const candidate = join(root, 'node_modules', '7zip-bin', platformDir, binary)
if (!existsSync(candidate)) {
console.error(`7za를 찾을 수 없습니다: ${candidate}\n npm ci 후 다시 실행하세요.`)
process.exit(1)
}
return candidate
}
const target = process.argv.includes('--zip') ? 'zip' : 'dir'
console.log(
`[portable] electron-builder 빌드 (version ${version}, target=${target}, 서명 없음)`,
)
const targetArgs = target === 'zip' ? ['--win', 'zip'] : ['--win', 'dir']
const build = spawnSync(
'npx',
[
'electron-builder',
...targetArgs,
'--x64',
'--config',
'electron-builder.yml',
'--publish',
'never',
// 서명이 없으므로 NSIS 경로의 fail-closed 게이트를 이 채널에서만 명시적으로 해제한다.
// (자동 업데이트 피드가 아니라 별도 portable 경로로만 게시한다 — publish 스크립트 참조)
'-c.win.forceCodeSigning=false',
'-c.npmRebuild=false',
],
{ cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' },
)
if (build.status !== 0) {
console.error(`[portable] electron-builder 실패 (exit ${build.status ?? 'null'})`)
process.exit(build.status ?? 1)
}
if (!existsSync(appDir)) {
console.error(`[portable] win-unpacked가 없습니다: ${appDir}`)
process.exit(1)
}
// 네이티브 모듈 ABI 사고 방지: 호스트 Node ABI로 빌드된 모듈이 섞이면 설치본이 시작조차 못 한다.
// (실측 사고: better_sqlite3.node가 NODE_MODULE_VERSION 131 → Electron 130 요구)
runNodeScript('scripts/ci/fix-native-abi.mjs', ['--dir', appDir])
runNodeScript('scripts/ci/verify-native-abi.mjs', ['--dir', appDir])
// electron-builder의 --dir/--prepackaged 경로는 app-update.yml을 만들지 않는다.
// 이 파일이 없으면 electron-updater가 설정을 읽지 못해 자동 업데이트가 죽는다(실측).
runNodeScript('scripts/ci/write-app-update-yml.mjs', ['--dir', appDir])
// 이전 볼륨 정리 (같은 버전 재드 시 잔여 볼륨이 섞이지 않도록)
for (const name of readdirSync(releaseDir)) {
if (name.startsWith(ARCHIVE_BASE)) {
rmSync(join(releaseDir, name), { force: true })
}
}
const sevenZip = resolve7za()
const archivePath = join(releaseDir, `${ARCHIVE_BASE}.7z`)
console.log(`[portable] 7z 분할 볼륨 생성 (볼륨 ${VOLUME_SIZE})`)
const compress = spawnSync(
sevenZip,
[
'a',
'-t7z',
'-m0=lzma2',
'-mx=9',
'-mmt=on',
'-ms=on',
`-v${VOLUME_SIZE}`,
'-bsp0',
'-bso0',
'-y',
archivePath,
join(appDir, '*'),
],
{ stdio: 'inherit' },
)
if (compress.status !== 0) {
console.error(`[portable] 7z 압축 실패 (exit ${compress.status ?? 'null'})`)
process.exit(compress.status ?? 1)
}
const volumes = readdirSync(releaseDir)
.filter((name) => name.startsWith(`${ARCHIVE_BASE}.7z.`))
.sort()
if (volumes.length === 0) {
console.error('[portable] 7z 볼륨을 찾을 수 없습니다.')
process.exit(1)
}
const volumeEntries = []
for (const name of volumes) {
const path = join(releaseDir, name)
const size = statSync(path).size
if (size > MAX_VOLUME_BYTES) {
console.error(
`[portable] 볼륨이 너무 큽니다(${name}: ${(size / 1048576).toFixed(1)}MiB). ` +
'VOLUME_SIZE를 줄이세요 — Cloudflare가 100MiB 초과 업로드를 413으로 거부합니다.',
)
process.exit(1)
}
const sha256 = createHash('sha256').update(readFileSync(path)).digest('hex')
volumeEntries.push({ name, size, sha256, url: `${PORTABLE_VERSION_PATH}/${name}` })
}
const totalBytes = volumeEntries.reduce((sum, entry) => sum + entry.size, 0)
const portableIndex = {
channel: 'portable-unsigned',
version,
archive: `${ARCHIVE_BASE}.7z`,
volumes: volumeEntries,
volumeCount: volumeEntries.length,
totalSize: totalBytes,
releasedAt: new Date().toISOString(),
latestIndexUrl: `${PORTABLE_LATEST_PATH}/portable.json`,
installScriptUrl: `${PORTABLE_LATEST_PATH}/install-d3ro-voice.ps1`,
notes: [
'서명 없는 휴대용 배포본입니다. 자동 업데이트 피드(latest.yml)는 갱신하지 않습니다.',
'Cloudflare 업로드 한도(100MiB) 때문에 7z 볼륨으로 나뉘어 있습니다. Scoop이 이어서 해제합니다.',
'수동 설치: install-d3ro-voice.ps1 (7-Zip 필요) 또는 Scoop 사용을 권장합니다.',
],
}
writeFileSync(
join(releaseDir, 'portable.json'),
`${JSON.stringify(portableIndex, null, 2)}\n`,
'utf8',
)
const scoopManifest = {
version,
description: '로컬 AI 음성 어시스턴트 (faster-whisper + Ollama, 100% 오프라인 지원)',
homepage: 'https://d3ro.chanpaca.net',
license: 'MIT',
architecture: {
'64bit': {
url: volumeEntries.map((entry) => entry.url),
hash: volumeEntries.map((entry) => entry.sha256),
},
},
shortcuts: [['D3RO Voice.exe', 'D3RO Voice']],
checkver: {
url: `${PORTABLE_LATEST_PATH}/portable.json`,
jsonpath: '$.version',
},
autoupdate: {
architecture: {
'64bit': {
url: volumeEntries.map((entry) =>
entry.url.replace(`portable-${version}`, 'portable-$version'),
),
},
},
},
}
writeFileSync(
join(root, 'bucket', 'd3ro-voice.json'),
`${JSON.stringify(scoopManifest, null, 2)}\n`,
'utf8',
)
// ── 수동 설치용 zip 분할 부품 ─────────────────────────────
const zipTarget = join(releaseDir, `${ARCHIVE_BASE}.zip`)
console.log(`[portable] 수동 설치용 zip 생성 (분할 ${ZIP_PART_SIZE})`)
const zipBuild = spawnSync(
'npx',
[
'electron-builder',
// 검증된 트리에서 바로 패키징한다(ne ABI가 확실한 디렉토리만 사용).
'--prepackaged',
appDir,
'--win',
'zip',
'--x64',
'--config',
'electron-builder.yml',
'--publish',
'never',
'-c.win.forceCodeSigning=false',
'-c.npmRebuild=false',
],
{ cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' },
)
if (zipBuild.status !== 0) {
console.error(`[portable] zip 빌드 실패 (exit ${zipBuild.status ?? 'null'})`)
process.exit(zipBuild.status ?? 1)
}
const producedZip = readdirSync(releaseDir).find((name) =>
name.endsWith('.zip') && name.includes(version) && !name.includes('.part'),
)
if (!producedZip) {
console.error('[portable] zip 산출물을 찾을 수 없습니다.')
process.exit(1)
}
if (join(releaseDir, producedZip) !== zipTarget) {
renameSync(join(releaseDir, producedZip), zipTarget)
}
// zip을 90MiB 단위로 바이트 분할한다 (사용자가 이어 붙여 Expand-Archive로 해제)
const zipBytes = readFileSync(zipTarget)
const partSize = 90 * 1024 * 1024
const zipParts = []
for (let offset = 0, index = 1; offset < zipBytes.length; offset += partSize, index += 1) {
const slice = zipBytes.subarray(offset, Math.min(offset + partSize, zipBytes.length))
const name = `${ARCHIVE_BASE}.zip.${String(index).padStart(3, '0')}`
if (slice.length > MAX_ZIP_PART_BYTES) {
console.error(`[portable] zip 부품이 너무 큽니다: ${name}`)
process.exit(1)
}
writeFileSync(join(releaseDir, name), slice)
zipParts.push({
name,
size: slice.length,
sha256: createHash('sha256').update(slice).digest('hex'),
url: `${PORTABLE_VERSION_PATH}/${name}`,
})
}
const zipSha256 = createHash('sha256').update(zipBytes).digest('hex')
// 인덱스에 zip 부품 정보를 추가한다 (설치 스크립트가 사용)
const indexJson = JSON.parse(readFileSync(join(releaseDir, 'portable.json'), 'utf8'))
indexJson.zipArchive = `${ARCHIVE_BASE}.zip`
indexJson.zipSize = zipBytes.length
indexJson.zipSha256 = zipSha256
indexJson.zipParts = zipParts
writeFileSync(
join(releaseDir, 'portable.json'),
`${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] 완료',
` 볼륨 : ${volumeEntries.length}개 / 합계 ${(totalBytes / 1048576).toFixed(1)}MiB`,
...volumeEntries.map(
(entry) => ` ${entry.name} (${(entry.size / 1048576).toFixed(1)}MiB)`,
),
` 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'),
)