feat(release): make the signing-free install work with nothing but Windows
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.
This commit is contained in:
Yun Chan 2026-09-18 12:03:18 +09:00
parent c35c6f3e95
commit 0411f389d9
30 changed files with 234 additions and 118 deletions

View file

@ -11,6 +11,9 @@
// 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 분할 볼륨
@ -18,7 +21,15 @@
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
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'
@ -34,9 +45,12 @@ 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)보다 여유를 둔 볼륨 크기 */
/** 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)
@ -225,6 +239,73 @@ writeFileSync(
'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] 완료',
@ -232,6 +313,7 @@ console.log(
...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`,

View file

@ -67,6 +67,21 @@ for (const volume of index.volumes) {
payloads.push({ name: volume.name, bytes, contentType: 'application/octet-stream' })
}
// 수동 설치용 zip 분할 부품 (Windows 내장 Expand-Archive로 해제 — 7-Zip 불필요)
for (const part of index.zipParts ?? []) {
const partPath = join(releaseDir, part.name)
if (!existsSync(partPath)) {
console.error(`[portable] zip 부품이 없습니다: ${partPath}`)
process.exit(1)
}
const partBytes = await readFile(partPath)
const partSha = createHash('sha256').update(partBytes).digest('hex')
if (partSha !== part.sha256) {
console.error(`[portable] zip 부품 sha256 불일치 (${part.name})`)
process.exit(1)
}
payloads.push({ name: part.name, bytes: partBytes, contentType: 'application/octet-stream' })
}
payloads.push({
name: 'portable.json',
bytes: Buffer.from(`${JSON.stringify(index, null, 2)}\n`, 'utf8'),
@ -145,7 +160,7 @@ console.log(
' scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git',
' scoop install d3ro/d3ro-voice',
'',
' 수동 설치(7-Zip 필요):',
' 수동 설치(추가 도구 불필요):',
` irm ${FEED}/portable-latest/install-d3ro-voice.ps1 | iex`,
'',
' 참고: 이 채널은 서명이 없어 자동 업데이트 피드를 갱신하지 않습니다.',

View file

@ -1,23 +1,24 @@
# scripts/local/install-d3ro-voice.ps1
# scripts/install/install-d3ro-voice.ps1
# 서명 없이 D3RO Voice를 설치하는 수동 설치 스크립트.
#
# 왜 스크립트인가: canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 100MiB를 넘으면
# 거부된다. 사이드카(faster-whisper)를 포함한 앱은 95MiB 단위 7z 볼으로 나뉘어 있고,
# 이 스크립트가 볼륨을 이어 붙여 해제한다. Scoop을 쓰면 Scoop이 같은 일을 자동으로 한다.
# 거부된다. 사이드카(faster-whisper)를 포함한 앱은 그보다 크므로 zip을 90MiB 단위로
# 나누어 게시하고, 이 스크립트가 부품을 이어 붙여 설치한다. Windows 내장
# Expand-Archive만 사용하므로 7-Zip 같은 추가 도구가 필요 없고 관리자 권한도 필요 없다.
#
# 사용:
# irm https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-latest/install-d3ro-voice.ps1 | iex
# 또는 저장 후:
# powershell -ExecutionPolicy Bypass -File install-d3ro-voice.ps1
#
# 요구 사항: Windows 10/11 x64, 7-Zip(없으면 Scoop 사용을 권장).
# 관리자 권한 불필요 — %LOCALAPPDATA%\Programs 아래에 설치한다.
# Scoop을 쓸 수 있으면 그쪽이 더 작고(7z 162MiB) 업데이트도 자동이다:
# scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git
# scoop install d3ro/d3ro-voice
[CmdletBinding()]
param(
[string]$FeedBase = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-latest',
[string]$InstallDir = (Join-Path $env:LOCALAPPDATA 'Programs\D3RO Voice'),
[string]$SevenZipPath = '',
[switch]$Force
)
@ -30,98 +31,81 @@ function Get-Sha256($path) {
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$stream = [System.IO.File]::OpenRead($path)
try {
$bytes = $sha.ComputeHash($stream)
} finally { $stream.Dispose() }
try { $bytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() }
} finally { $sha.Dispose() }
return ($bytes | ForEach-Object { $_.ToString("x2") }) -join ''
return ($bytes | ForEach-Object { $_.ToString('x2') }) -join ''
}
Write-Step 'D3RO Voice 휴대용 배포본 설치를 시작합니다 (서명되지 않은 빌드).'
Write-Step 'D3RO Voice 설치를 시작합니다 (서명되지 않은 빌드).'
# 1. 인덱스 내려받기
$indexUrl = "$FeedBase/portable.json"
Write-Step "인덱스: $indexUrl"
$index = Invoke-RestMethod -Uri $indexUrl -UseBasicParsing
$version = $index.version
Write-Step "버전 $version, 볼륨 $($index.volumeCount)개 (합계 $([math]::Round($index.totalSize / 1MB, 1)) MB)"
# 2. 임시 디렉터리에 볼 내려받기 + 해시 검증
if (-not $index.zipParts -or $index.zipParts.Count -eq 0) {
throw '인덱스에 zip 부품 정보가 없습니다. 이 스크립트는 zipParts가 있는 버전(1.3.0+)을 지원합니다.'
}
Write-Step "버전 $version, 부품 $($index.zipParts.Count)개 (합계 $([math]::Round($index.zipSize / 1MB, 1)) MB)"
# 2. 임시 디렉터리에 부품 내려받기 + 해시 검증
$tempRoot = [System.IO.Path]::GetTempPath()
if ($env:TEMP) { $tempRoot = $env:TEMP }
elseif ($env:TMP) { $tempRoot = $env:TMP }
$workDir = Join-Path $tempRoot "d3ro-voice-$version-portable"
if (Test-Path $workDir) { Remove-Item -Recurse -Force $workDir }
New-Item -ItemType Directory -Path $workDir | Out-Null
foreach ($volume in $index.volumes) {
$dest = Join-Path $workDir $volume.name
$url = "$FeedBase/$($volume.name)"
Write-Step "내려받기: $($volume.name) ($([math]::Round($volume.size / 1MB, 1)) MB)"
Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing
foreach ($part in $index.zipParts) {
$dest = Join-Path $workDir $part.name
Write-Step "내려받기: $($part.name) ($([math]::Round($part.size / 1MB, 1)) MB)"
Invoke-WebRequest -Uri "$FeedBase/$($part.name)" -OutFile $dest -UseBasicParsing
$hash = Get-Sha256 $dest
if ($hash -ne $volume.sha256) {
throw "해시가 일치하지 않습니다: $($volume.name)`n 기대: $($volume.sha256)`n 실제: $hash"
if ($hash -ne $part.sha256) {
throw "해시가 일치하지 않습니다: $($part.name)`n 기대: $($part.sha256)`n 실제: $hash"
}
}
Write-Step '모든 볼륨의 SHA-256 검증 완료'
Write-Step '모든 부품의 SHA-256 검증 완료'
# 3. 볼륨 이어 붙이기
$archive = Join-Path $workDir "$($index.archive)"
# 3. 부품 이어 붙이기
$archive = Join-Path $workDir $index.zipArchive
$stream = [System.IO.File]::Create($archive)
try {
foreach ($volume in $index.volumes) {
$part = [System.IO.File]::OpenRead((Join-Path $workDir $volume.name))
try { $part.CopyTo($stream) } finally { $part.Dispose() }
foreach ($part in $index.zipParts) {
$piece = [System.IO.File]::OpenRead((Join-Path $workDir $part.name))
try { $piece.CopyTo($stream) } finally { $piece.Dispose() }
}
} finally {
$stream.Dispose()
}
$archiveHash = Get-Sha256 $archive
if ($index.zipSha256 -and $archiveHash -ne $index.zipSha256) {
throw "결합한 아카이브의 해시가 인덱스와 다릅니다.`n 기대: $($index.zipSha256)`n 실제: $archiveHash"
}
Write-Step "아카이브 결합 완료: $([math]::Round((Get-Item $archive).Length / 1MB, 1)) MB"
# 4. 해제 (7-Zip 필요; 없으면 안내)
$sevenZipCandidates = @()
foreach ($base in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) {
if ($base) { $sevenZipCandidates += (Join-Path $base '7-Zip\7z.exe') }
}
$sevenZip = $sevenZipCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($SevenZipPath) { $sevenZip = $SevenZipPath }
if (-not $sevenZip) {
# Windows PowerShell 5.1 호환 (?. 연산자는 PowerShell 7 전용)
$sevenZipCommand = Get-Command 7z -ErrorAction SilentlyContinue
if ($sevenZipCommand) { $sevenZip = $sevenZipCommand.Source }
}
if (-not $sevenZip) {
throw @'
7-Zip을 찾을 없습니다. 가지 방법이 있습니다.
1) Scoop 사용(권장, 7-Zip 자동 준비):
scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git
scoop install d3ro/d3ro-voice
2) 7-Zip 설치 스크립트를 다시 실행: https://www.7-zip.org/
'@
}
# 4. 압축 해제 (Windows 내장 Expand-Archive — 추가 도구 불필요)
$extractDir = Join-Path $workDir 'extract'
Write-Step '압축 해제 중 (수백 MB, 시간이 걸릴 수 있습니다)'
Expand-Archive -LiteralPath $archive -DestinationPath $extractDir -Force
# 5. 설치 디렉터리로 배치
if (Test-Path $InstallDir) {
if (-not $Force) {
throw "설치 경로가 이미 있습니다: $InstallDir`n 다시 설치하려면 -Force 붙이세요."
throw "설치 경로가 이미 있습니다: $InstallDir`n 다시 설치하려면 -Force 를 붙이세요."
}
Write-Step "기존 설치를 교체합니다: $InstallDir"
Remove-Item -Recurse -Force $InstallDir
}
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
Write-Step '압축 해제 중 (수백 MB, 시간이 걸릴 수 있습니다)'
& $sevenZip x $archive "-o$extractDir" -y | Out-Null
if ($LASTEXITCODE -ne 0) { throw "압축 해제 실패 (7-Zip exit $LASTEXITCODE)" }
Copy-Item -Path (Join-Path $extractDir '*') -Destination $InstallDir -Recurse -Force
# 5. 시작 메뉴 바로가기
# 6. 시작 메뉴 바로가기
$exe = Join-Path $InstallDir 'D3RO Voice.exe'
if (-not (Test-Path $exe)) { throw "실행 파일을 찾을 수 없습니다: $exe" }
@ -139,8 +123,9 @@ Write-Step "설치 완료: $InstallDir"
Write-Step "시작 메뉴 바로가기: $shortcutPath"
Write-Host ''
Write-Host '참고:' -ForegroundColor Yellow
Write-Host ' - 이 빌드는 Authenticode 서명이 없어 첫 실행 시 SmartScreen 경고가 뜰 수 있습니다.'
Write-Host ' - 이 빌드는 Authenticode 서명이 없어 SmartScreen 경고가 뜰 수 있습니다("추가 정보 -> 실행").'
Write-Host ' - 자동 업데이트는 서명된 릴리스가 게시된 뒤부터 동작합니다(현재 설치본은 그 피드를 봅니다).'
Write-Host ' - 설정/모델/기록은 %APPDATA%\d3ro-voice 를 공유하므로 기존 설치와 동일하게 유지됩니다.'
Write-Host ' - Scoop 사용자는 scoop update d3ro-voice 로 갱신할 수 있습니다(7z 162MiB로 더 작음).'
Write-Host ''
Write-Host "실행: `"$exe`"" -ForegroundColor Green