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.
411 lines
No EOL
14 KiB
JavaScript
411 lines
No EOL
14 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)
|
|
}
|
|
|
|
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',
|
|
)
|
|
// ── 로컬 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'),
|
|
) |