// 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//D3RO-Voice--x64-portable.7z.001/.002/... // apps/desktop/release//portable.json (볼륨 인덱스: 이름/크기/sha256) // bucket/d3ro-voice.json (Scoop 매니페스트, 커밋 대상) // // 사용: // 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, rmSync, statSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' 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)보다 여유를 둔 볼륨 크기 */ const VOLUME_SIZE = '95m' const MAX_VOLUME_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) } 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) } // 이전 볼륨 정리 (같은 버전 재드 시 잔여 볼륨이 섞이지 않도록) 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', ) console.log( [ '[portable] 완료', ` 볼륨 : ${volumeEntries.length}개 / 합계 ${(totalBytes / 1048576).toFixed(1)}MiB`, ...volumeEntries.map( (entry) => ` ${entry.name} (${(entry.size / 1048576).toFixed(1)}MiB)`, ), ` 인덱스 : ${join(releaseDir, 'portable.json')}`, ` scoop : ${join(root, 'bucket', 'd3ro-voice.json')}`, ` 게시 : node scripts/ci/publish-portable-release.mjs`, ].join('\n'), )