d3ro-voice/scripts/ci/build-portable.mjs
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

452 lines
No EOL
17 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'
import { buildRuntimeIndex } from './lib/runtime-index-builder.mjs'
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])
// 휴대용 표식: 이 트리(Scoop 7z · 수동 설치 zip)는 NSIS 설치자가 소유하지 않는다.
// 런타임(src/main/install-kind.ts)은 NSIS 제거 프로그램이 없는 설치본에서 내장 자동 업데이트(NsisUpdater)를
// 끈다 — 켜 두면 NSIS 설치자가 다른 경로에 두 번째 사본을 깔고, 실행 중인 사본은 옛 버전으로 남는다.
// 표식은 그 판단을 명시하고 로그 사유를 남긴다. 파일 이름은 install-kind.ts 가 정본이다.
const installKindSource = readFileSync(join(desktopDir, 'src', 'main', 'install-kind.ts'), 'utf8')
const markerMatch = installKindSource.match(/export const PORTABLE_INSTALL_MARKER\s*=\s*'([^']+)'/)
if (!markerMatch) {
console.error('[portable] install-kind.ts에서 PORTABLE_INSTALL_MARKER를 찾을 수 없습니다.')
process.exit(1)
}
const portableMarkerPath = join(appDir, 'resources', markerMatch[1])
writeFileSync(
portableMarkerPath,
`${JSON.stringify({ kind: 'portable', version, updater: 'scoop-or-install-script' }, null, 2)}\n`,
'utf8',
)
// 같은 win-unpacked 트리로 나중에 NSIS 설치자(--prepackaged)를 만들 수 있으므로, 아카이브를 만든 뒤에는
// 표식을 지운다. (런타임은 NSIS 제거 프로그램을 먼저 보므로 표식이 섞여도 설치본 업데이트는 꺼지지 않는다.)
process.on('exit', () => {
rmSync(portableMarkerPath, { force: true })
})
// 이전 볼륨 정리 (같은 버전 재드 시 잔여 볼륨이 섞이지 않도록)
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 런타임을 만들 수 없습니다')
}
// 사이드카(PyInstaller)와 @ffmpeg-installer 바이너리는 이 호스트의 네이티브 실행 파일이다.
// platform/arch 를 인덱스에 밝혀야 다른 플랫폼 앱이 부품(~160MB)을 받기 전에 거부할 수 있다.
const runtimeIndex = buildRuntimeIndex({
version,
generatedAt: new Date().toISOString(),
platform: process.platform,
arch: process.arch,
partBaseUrl: `${FEED}/runtime-${version}`,
components: runtimeComponents,
})
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(', ') || '(없음)'} (${runtimeIndex.platform}-${runtimeIndex.arch}) → ${join(RUNTIME_DIR, 'runtime.json')}`,
` 게시 : node scripts/ci/publish-portable-release.mjs`,
].join('\n'),
)