// 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 매니페스트, 커밋 대상) // // 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'), )