Some checks failed
deploy-site / deploy (push) Failing after 3m26s
The manual install path still needed 7-Zip, which the target machine does not have, so "installable without a certificate" was not yet true. The channel now also publishes the app as byte-split zip parts, and the install script joins them and extracts with the built-in Windows Expand-Archive after verifying every part and the joined archive. Version 1.3.1 republishes the channel from a single build, because a version's artifacts can only match one build and published volumes are never overwritten.
321 lines
No EOL
11 KiB
JavaScript
321 lines
No EOL
11 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,
|
|
renameSync,
|
|
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)보다 여유를 둔 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)
|
|
}
|
|
|
|
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',
|
|
)
|
|
|
|
// ── 수동 설치용 zip 분할 부품 ─────────────────────────────
|
|
const zipTarget = join(releaseDir, `${ARCHIVE_BASE}.zip`)
|
|
console.log(`[portable] 수동 설치용 zip 생성 (분할 ${ZIP_PART_SIZE})`)
|
|
const zipBuild = spawnSync(
|
|
'npx',
|
|
[
|
|
'electron-builder',
|
|
'--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',
|
|
)
|
|
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')}`,
|
|
` 게시 : node scripts/ci/publish-portable-release.mjs`,
|
|
].join('\n'),
|
|
) |