feat(release): ship an install path that needs no code-signing certificate
Some checks failed
deploy-site / deploy (push) Failing after 4m9s
Some checks failed
deploy-site / deploy (push) Failing after 4m9s
Installers could not be published at all: the signing certificate does not exist yet, and the release pipelines stop at their signing guard. Users had no way to install a fixed build, so the product was effectively stuck behind a certificate that takes weeks to obtain. There is also a second, independent blocker: the download feed sits behind Cloudflare, which rejects any upload body over about 100 MiB, and the app with its speech engine exceeds that even when signed. A portable channel now publishes what can actually be delivered today: the app compressed into 95 MiB 7z volumes (162 MiB total instead of 243 MiB), a Scoop bucket for a normal install and uninstall experience, and a verifiable manual installer script. It is deliberately separate from the auto-update feed, needs no certificate, and refuses to overwrite an already published version.
This commit is contained in:
parent
a85ab799a3
commit
c35c6f3e95
13 changed files with 805 additions and 1 deletions
239
scripts/ci/build-portable.mjs
Normal file
239
scripts/ci/build-portable.mjs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// 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 매니페스트, 커밋 대상)
|
||||
//
|
||||
// 사용:
|
||||
// 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, 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)보다 여유를 둔 볼륨 크기 */
|
||||
const VOLUME_SIZE = '95m'
|
||||
const MAX_VOLUME_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',
|
||||
)
|
||||
|
||||
console.log(
|
||||
[
|
||||
'[portable] 완료',
|
||||
` 볼륨 : ${volumeEntries.length}개 / 합계 ${(totalBytes / 1048576).toFixed(1)}MiB`,
|
||||
...volumeEntries.map(
|
||||
(entry) => ` ${entry.name} (${(entry.size / 1048576).toFixed(1)}MiB)`,
|
||||
),
|
||||
` 인덱스 : ${join(releaseDir, 'portable.json')}`,
|
||||
` scoop : ${join(root, 'bucket', 'd3ro-voice.json')}`,
|
||||
` 게시 : node scripts/ci/publish-portable-release.mjs`,
|
||||
].join('\n'),
|
||||
)
|
||||
153
scripts/ci/publish-portable-release.mjs
Normal file
153
scripts/ci/publish-portable-release.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// scripts/ci/publish-portable-release.mjs
|
||||
// 서명 없는 휴대용 배포본(7z 분할 볼륨) + Scoop 매니페스트 + 수동 설치 스크립트를
|
||||
// Forgejo Generic Registry에 게시한다.
|
||||
//
|
||||
// 이 채널은 자동 업데이트 피드(latest.yml / update-policy.json)를 건드리지 않는다.
|
||||
// 서명이 없어도 게시할 수 있으므로 인증서 발급 전에도 사용자가 설치할 수 있는 경로다.
|
||||
//
|
||||
// 경로:
|
||||
// .../generic/d3ro-voice/portable-<version>/<륨>.7z.00N
|
||||
// .../generic/d3ro-voice/portable-<version>/portable.json
|
||||
// .../generic/d3ro-voice/portable-<version>/install-d3ro-voice.ps1
|
||||
// .../generic/d3ro-voice/portable-latest/... (동일 파일 alias)
|
||||
//
|
||||
// 사용:
|
||||
// node scripts/ci/build-portable.mjs
|
||||
// node --env-file-if-exists=.env scripts/ci/publish-portable-release.mjs [--check]
|
||||
|
||||
import credentialHelpers from '../lib/credentials.cjs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const { forgejoAuthorization } = credentialHelpers
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
const check = process.argv.includes('--check') || process.env.PORTABLE_PUBLISH_DRY_RUN === '1'
|
||||
|
||||
const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice'
|
||||
const version = JSON.parse(
|
||||
readFileSync(join(root, 'release', 'product-version.json'), 'utf8'),
|
||||
).version
|
||||
|
||||
const releaseDir = join(root, 'apps', 'desktop', 'release', version)
|
||||
const index = JSON.parse(readFileSync(join(releaseDir, 'portable.json'), 'utf8'))
|
||||
const installerPath = join(root, 'scripts', 'install', 'install-d3ro-voice.ps1')
|
||||
|
||||
if (index.version !== version) {
|
||||
console.error(
|
||||
`[portable] portable.json 버전(${index.version})이 product-version.json(${version})과 다릅니다. build-portable.mjs를 다시 실행하세요.`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!existsSync(installerPath)) {
|
||||
console.error(`[portable] 설치 스크립트가 없습니다: ${installerPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 게시 전 해시 재검증 — 파일이 바뀌었는데 인덱스가 낡으면 불일치 배포가 된다.
|
||||
const payloads = []
|
||||
for (const volume of index.volumes) {
|
||||
const path = join(releaseDir, volume.name)
|
||||
if (!existsSync(path)) {
|
||||
console.error(`[portable] 볼륨이 없습니다: ${path}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const bytes = await readFile(path)
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
||||
if (sha256 !== volume.sha256) {
|
||||
console.error(
|
||||
`[portable] sha256 불일치 (${volume.name}): index=${volume.sha256} actual=${sha256}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
payloads.push({ name: volume.name, bytes, contentType: 'application/octet-stream' })
|
||||
}
|
||||
|
||||
payloads.push({
|
||||
name: 'portable.json',
|
||||
bytes: Buffer.from(`${JSON.stringify(index, null, 2)}\n`, 'utf8'),
|
||||
contentType: 'application/json',
|
||||
})
|
||||
payloads.push({
|
||||
name: 'install-d3ro-voice.ps1',
|
||||
bytes: await readFile(installerPath),
|
||||
contentType: 'text/plain',
|
||||
})
|
||||
|
||||
const authorization = forgejoAuthorization()
|
||||
const bases = [`${FEED}/portable-${version}`, `${FEED}/portable-latest`]
|
||||
|
||||
async function forgejoFetch(url, init = {}) {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: { Authorization: authorization, ...(init.headers ?? {}) },
|
||||
})
|
||||
}
|
||||
|
||||
async function upload(url, body, contentType) {
|
||||
if (check) {
|
||||
console.log(`[portable] (check) PUT ${url} (${body.length} bytes)`)
|
||||
return
|
||||
}
|
||||
// Forgejo의 generic registry는 HEAD를 405로 거부한다(실측) → Range GET으로 크기만 읽는다.
|
||||
const probe = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
|
||||
const contentRange = probe?.headers.get('content-range')
|
||||
const remoteLength = contentRange ? Number(contentRange.split('/')[1]) : NaN
|
||||
if (probe?.ok && Number.isFinite(remoteLength)) {
|
||||
if (remoteLength === body.length) {
|
||||
console.log(`[portable] 이미 동일한 파일이 있습니다(건너뜀): ${url}`)
|
||||
return
|
||||
}
|
||||
// 볼륨은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다.
|
||||
if (url.includes(`/portable-${version}/`) && url.includes('.7z.')) {
|
||||
console.error(
|
||||
`[portable] ${version} 볼륨에 다른 바이트가 이미 있습니다: ${url}\n` +
|
||||
' 이미 게시된 버전은 덮어쓰지 않습니다(불변). 새 버전으로 게시하세요.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
await forgejoFetch(url, { method: 'DELETE' }).catch(() => null)
|
||||
}
|
||||
const response = await forgejoFetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': contentType },
|
||||
body,
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`[portable] 업로드 실패 (HTTP ${response.status}): ${url}\n` +
|
||||
' HTTP 413이면 Cloudflare 본문 한도(100MiB) 초과입니다. 볼륨 크기를 줄이세요.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`[portable] uploaded ${url}`)
|
||||
}
|
||||
|
||||
for (const base of bases) {
|
||||
for (const payload of payloads) {
|
||||
await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
[
|
||||
'',
|
||||
`[portable] 게시 ${check ? '(check 모드 — 실제 업로드 없음)' : '완료'}: ${version}`,
|
||||
` 볼륨 : ${index.volumeCount}개 / 합계 ${(index.totalSize / 1048576).toFixed(1)}MiB`,
|
||||
` 인덱스 : ${FEED}/portable-latest/portable.json`,
|
||||
` 스크립트: ${FEED}/portable-latest/install-d3ro-voice.ps1`,
|
||||
'',
|
||||
' 설치(Scoop, 권장):',
|
||||
' 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`,
|
||||
'',
|
||||
' 참고: 이 채널은 서명이 없어 자동 업데이트 피드를 갱신하지 않습니다.',
|
||||
].join('\n'),
|
||||
)
|
||||
146
scripts/install/install-d3ro-voice.ps1
Normal file
146
scripts/install/install-d3ro-voice.ps1
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# scripts/local/install-d3ro-voice.ps1
|
||||
# 서명 없이 D3RO Voice를 설치하는 수동 설치 스크립트.
|
||||
#
|
||||
# 왜 스크립트인가: canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 100MiB를 넘으면
|
||||
# 거부된다. 사이드카(faster-whisper)를 포함한 앱은 95MiB 단위 7z 볼으로 나뉘어 있고,
|
||||
# 이 스크립트가 볼륨을 이어 붙여 해제한다. Scoop을 쓰면 Scoop이 같은 일을 자동으로 한다.
|
||||
#
|
||||
# 사용:
|
||||
# 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 아래에 설치한다.
|
||||
|
||||
[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
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
function Write-Step($message) { Write-Host "[d3ro] $message" -ForegroundColor Cyan }
|
||||
|
||||
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() }
|
||||
} finally { $sha.Dispose() }
|
||||
return ($bytes | ForEach-Object { $_.ToString("x2") }) -join ''
|
||||
}
|
||||
|
||||
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. 임시 디렉터리에 볼 내려받기 + 해시 검증
|
||||
$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
|
||||
|
||||
$hash = Get-Sha256 $dest
|
||||
if ($hash -ne $volume.sha256) {
|
||||
throw "해시가 일치하지 않습니다: $($volume.name)`n 기대: $($volume.sha256)`n 실제: $hash"
|
||||
}
|
||||
}
|
||||
Write-Step '모든 볼륨의 SHA-256 검증 완료'
|
||||
|
||||
# 3. 볼륨 이어 붙이기
|
||||
$archive = Join-Path $workDir "$($index.archive)"
|
||||
$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() }
|
||||
}
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
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/
|
||||
'@
|
||||
}
|
||||
|
||||
$extractDir = Join-Path $workDir 'extract'
|
||||
if (Test-Path $InstallDir) {
|
||||
if (-not $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. 시작 메뉴 바로가기
|
||||
$exe = Join-Path $InstallDir 'D3RO Voice.exe'
|
||||
if (-not (Test-Path $exe)) { throw "실행 파일을 찾을 수 없습니다: $exe" }
|
||||
|
||||
$startMenu = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs'
|
||||
$shortcutPath = Join-Path $startMenu 'D3RO Voice.lnk'
|
||||
$shell = New-Object -ComObject WScript.Shell
|
||||
$shortcut = $shell.CreateShortcut($shortcutPath)
|
||||
$shortcut.TargetPath = $exe
|
||||
$shortcut.WorkingDirectory = $InstallDir
|
||||
$shortcut.Save()
|
||||
|
||||
Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Step "설치 완료: $InstallDir"
|
||||
Write-Step "시작 메뉴 바로가기: $shortcutPath"
|
||||
Write-Host ''
|
||||
Write-Host '참고:' -ForegroundColor Yellow
|
||||
Write-Host ' - 이 빌드는 Authenticode 서명이 없어 첫 실행 시 SmartScreen 경고가 뜰 수 있습니다.'
|
||||
Write-Host ' - 자동 업데이트는 서명된 릴리스가 게시된 뒤부터 동작합니다(현재 설치본은 그 피드를 봅니다).'
|
||||
Write-Host ' - 설정/모델/기록은 %APPDATA%\d3ro-voice 를 공유하므로 기존 설치와 동일하게 유지됩니다.'
|
||||
Write-Host ''
|
||||
Write-Host "실행: `"$exe`"" -ForegroundColor Green
|
||||
Loading…
Add table
Add a link
Reference in a new issue