From c35c6f3e95b2536ddd00b155622aad40ff586e96 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 18 Sep 2026 11:03:34 +0900 Subject: [PATCH] feat(release): ship an install path that needs no code-signing certificate 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. --- .forgejo/workflows/portable.yml | 69 +++++++ CHANGELOG.md | 9 + bucket/README.md | 18 ++ bucket/d3ro-voice.json | 38 ++++ docs/deployment/release-guide.md | 19 ++ docs/deployment/unsigned-distribution.md | 105 ++++++++++ docs/map/02-infrastructure.md | 1 + docs/map/10-feature-catalog.md | 1 + docs/map/11-gap-backlog.md | 5 +- package.json | 3 + scripts/ci/build-portable.mjs | 239 +++++++++++++++++++++++ scripts/ci/publish-portable-release.mjs | 153 +++++++++++++++ scripts/install/install-d3ro-voice.ps1 | 146 ++++++++++++++ 13 files changed, 805 insertions(+), 1 deletion(-) create mode 100644 .forgejo/workflows/portable.yml create mode 100644 bucket/README.md create mode 100644 bucket/d3ro-voice.json create mode 100644 docs/deployment/unsigned-distribution.md create mode 100644 scripts/ci/build-portable.mjs create mode 100644 scripts/ci/publish-portable-release.mjs create mode 100644 scripts/install/install-d3ro-voice.ps1 diff --git a/.forgejo/workflows/portable.yml b/.forgejo/workflows/portable.yml new file mode 100644 index 0000000..4472b63 --- /dev/null +++ b/.forgejo/workflows/portable.yml @@ -0,0 +1,69 @@ +name: portable-unsigned + +# 서명 없는 휴대용 배포 채널. +# +# 배경: NSIS/MSIX 설치본은 public-trust Authenticode 서명이 필수라 인증서가 없는 동안 +# 게시할 수 없다(실측: 릴리스 파이프라인 2회 모두 서명 가드에서 실패). 이 워크플로는 +# 인증서 없이 동작하는 7z 분할 볼륨 + Scoop 채널을 게시한다. +# +# 안전 규칙: +# - 자동 업데이트 피드(latest.yml / update-policy.json)를 절대 건드리지 않는다. +# - 파일명에 -portable 을 두어 서명된 릴리스 자산과 혼동되지 않게 한다. +# - Cloudflare 업로드 한도(100MiB)를 넘지 않게 95MiB 볼륨으로 나누어 게시한다. +# +# 필요한 시크릿: FORGEJO_TOKEN (write:package) + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +jobs: + portable-windows: + runs-on: windows + defaults: { run: { shell: pwsh } } + steps: + - name: checkout + env: { CI_TOKEN: "${{ github.token }}" } + run: | + $u = [Uri]$env:GITHUB_SERVER_URL + $url = "$($u.Scheme)://actions:$($env:CI_TOKEN)@$($u.Authority)/$($env:GITHUB_REPOSITORY).git" + if (-not (Test-Path .git)) { git init -q . } + if (git remote | Select-String -Quiet '^origin$') { git remote set-url origin $url } else { git remote add origin $url } + git fetch -q --depth 1 origin $env:GITHUB_REF + git checkout -q -f FETCH_HEAD + git clean -qfdx + + - name: 버전 정본 대조 + run: | + node scripts/ci/sync-version.mjs --check --tag "$env:GITHUB_REF_NAME" + + - name: 의존성 설치 + run: npm ci + + - name: STT 사이드카 빌드 + run: | + npm run sidecar:setup --workspace=@d3ro/desktop + npm run sidecar:build --workspace=@d3ro/desktop + node scripts/ci/verify-sidecar-bundle.mjs + + - name: 데스크톱 번들 빌드 + run: npm run build --workspace=@d3ro/desktop + + - name: 휴대용 ZIP + Scoop 매니페스트 생성 + run: node scripts/ci/build-portable.mjs + + - name: Forgejo portable 채널 게시 + env: + FORGEJO_TOKEN: "${{ secrets.FORGEJO_TOKEN }}" + run: node scripts/ci/publish-portable-release.mjs + + - name: 아티팩트 업로드 + uses: actions/upload-artifact@v4 + with: + name: d3ro-voice-portable-${{ github.ref_name }} + path: | + apps/desktop/release/*/*-portable.7z.00* + apps/desktop/release/*/portable.json + bucket/d3ro-voice.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index afe66c4..f3cb3e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `sidecar:build`, plus a packaging-time bundle verifier that fails the build when the engine or its VAD data is missing. +### Added +- **Signing-free install path (`portable` channel)**: the canonical feed sits behind + Cloudflare, which rejects any upload body over ~100 MiB with HTTP 413 (measured: + 60 MiB accepted, 110 MiB rejected). The sidecar-carrying app exceeds that, so releases + are now also published as 95 MiB 7z split volumes (688 MB app → 162 MiB) with a Scoop + bucket manifest and a verifiable manual installer script. This channel never touches the + auto-update feed and needs no Authenticode certificate, so users can install while the + signing certificate is still being procured. + ### Changed - Local engine connections now target the IPv4 loopback (`127.0.0.1`) instead of `localhost`. On machines where `localhost` resolves only to IPv6, every local request diff --git a/bucket/README.md b/bucket/README.md new file mode 100644 index 0000000..76fed4e --- /dev/null +++ b/bucket/README.md @@ -0,0 +1,18 @@ +# Scoop bucket — D3RO Voice + +서명 인증서 없이도 설치할 수 있는 배포 경로입니다. Scoop은 파일을 직접 내려받아 +MOTW(Mark-of-the-Web)를 남기지 않으므로 SmartScreen 경고가 뜨지 않고, 관리자 권한도 +필요하지 않습니다. + +```powershell +scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git +scoop install d3ro/d3ro-voice +``` + +매니페스트(`bucket/d3ro-voice.json`)는 `scripts/ci/build-portable.mjs`가 버전/URL/해시를 +자동으로 채워 생성합니다. 손으로 수정하지 말고 그 스크립트를 다시 실행하세요. + +- 이 채널은 **서명되지 않은** 휴대용 ZIP을 배포합니다(파일명에 `-portable-unsigned`). +- 자동 업데이트 피드(`latest.yml`)와는 분리되어 있습니다. 서명된 NSIS/MSIX 설치본은 + 인증서가 준비되면 기존 릴리스 파이프라인으로 게시합니다. +- `scoop update d3ro-voice`로 새 버전을 받을 수 있습니다. \ No newline at end of file diff --git a/bucket/d3ro-voice.json b/bucket/d3ro-voice.json new file mode 100644 index 0000000..dc2d85e --- /dev/null +++ b/bucket/d3ro-voice.json @@ -0,0 +1,38 @@ +{ + "version": "1.3.0", + "description": "로컬 AI 음성 어시스턴트 (faster-whisper + Ollama, 100% 오프라인 지원)", + "homepage": "https://d3ro.chanpaca.net", + "license": "MIT", + "architecture": { + "64bit": { + "url": [ + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.0/D3RO-Voice-1.3.0-x64-portable.7z.001", + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.0/D3RO-Voice-1.3.0-x64-portable.7z.002" + ], + "hash": [ + "c4824bd625a2f0fd685f6fd3dd8521f4ced7d46954b59f0c10efcd432f82bd3f", + "187f4917e53525e736df838b15323e840691d602eadcfd9024407498316932ce" + ] + } + }, + "shortcuts": [ + [ + "D3RO Voice.exe", + "D3RO Voice" + ] + ], + "checkver": { + "url": "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-latest/portable.json", + "jsonpath": "$.version" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": [ + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.0-x64-portable.7z.001", + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.0-x64-portable.7z.002" + ] + } + } + } +} diff --git a/docs/deployment/release-guide.md b/docs/deployment/release-guide.md index cf1e82b..756ce11 100644 --- a/docs/deployment/release-guide.md +++ b/docs/deployment/release-guide.md @@ -141,6 +141,22 @@ node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write 10. Forgejo Release note/asset, `latest.yml`, `update-policy.json`, installer hash를 외부 public URL에서 다시 검증한다. 11. 이전 설치본에서 자동 업데이트 E2E를 실행하고 실행 중 버전·프로세스·사용자 데이터 보존, 그리고 **업데이트 후 로컬 받아쓰기 1회 성공**을 확인한다. +## 서명 없이 내놓기 (portable 채널) + +인증서가 없어도 사용자가 설치할 수 있어야 할 때 사용한다. 자세한 조사·비교·제약은 +[`unsigned-distribution.md`](./unsigned-distribution.md)에 있다. + +```bash +npm run release:portable:build # 7z 분할 볼륨 + Scoop 매니페스트 생성 +npm run release:portable:check # 게시 예정 목록 확인(실제 업로드 없음) +npm run release:portable # Forgejo portable 채널 게시 +``` + +- 자동 업데이트 피드(`latest.yml`)는 **건드리지 않는다** — 서명된 릴리스 전용이다. +- 볼륨은 불변이다: 같은 버전 경로에 다른 바이트가 있으면 게시가 중단된다. +- 태그/수동 실행 워크플로: `.forgejo/workflows/portable.yml` (필요 시크릿: `FORGEJO_TOKEN`). +- 사용자 설치: Scoop 버킷(`bucket/`) 또는 `install-d3ro-voice.ps1`. + ## `1.2.0` 릴리스 절차 (기록) 1. `release/product-version.json`의 version/build 값과 모든 버전 면을 `npm run version:check`로 대조한다. @@ -178,6 +194,9 @@ Desktop release를 게시해도 Android production 출시가 자동으로 완료 - `release/mobile-release-evidence-public.pem` — release evidence public key - `apps/desktop/resources/license/production-public.pem` — desktop offline license public key SSOT - `scripts/ci/sync-version.mjs` — 버전 면 동기화·검증 +- `scripts/ci/build-portable.mjs` — 서명 없는 portable 산출물(7z 분할 볼륨 + Scoop 매니페스트) +- `scripts/ci/publish-portable-release.mjs` — portable 채널 게시(불변 가드) +- `scripts/install/install-d3ro-voice.ps1` — 수동 설치 스크립트(해시 검증 + 결합 + 해제) - `scripts/ci/create-release-tag.mjs` — 릴리스 태그 게이트 (annotated/서명, 불변) - `scripts/ci/verify-release-metadata.mjs` — release metadata 자가 검증 - `scripts/ci/verify-windows-release-artifact.ps1` — Windows version·updater metadata·Authenticode gate diff --git a/docs/deployment/unsigned-distribution.md b/docs/deployment/unsigned-distribution.md new file mode 100644 index 0000000..09ec446 --- /dev/null +++ b/docs/deployment/unsigned-distribution.md @@ -0,0 +1,105 @@ +# 서명 없이 배포하기 — D3RO Voice 배포 경로 정리 (2026-09 기준) + +> 왜 이 문서가 있나: MSIX/NSIS 설치본은 public-trust Authenticode 서명이 필수다. 인증서가 +> 없으면 릴리스 파이프라인이 fail-closed로 멈춘다(실측: `v1.2.0`·`v1.3.0` 태그 모두 서명 +> 가드에서 실패). 그동안 사용자가 설치할 수 있는 경로가 필요해 조사하고 구현한 결과를 남긴다. +> MSIX 계획을 버리는 문서가 **아니다** — 서명이 준비되면 기존 계획을 그대로 간다. + +--- + +## 1. 제약 (실측) + +| 제약 | 값 | 근거 | +|---|---|---| +| canonical feed 업로드 본문 한도 | **100MiB (104,857,600 bytes)** — 초과 시 HTTP 413 | Cloudflare 뒤에 있음. 실측: 60MiB → 201, 110MiB → 413 (274ms, CF 오류 페이지) | +| 1.1.0 설치본이 통과한 이유 | 102,172,129 bytes = **97.4MiB** (한도 미만) | 같은 feed의 `latest.yml` | +| 사이드카 포함 앱 크기 | unpacked 688MB → zip 243MiB / 7z 162MiB | `apps/desktop/release//win-unpacked` 실측 | +| 이 PC의 Smart App Control | **꺼짐** (`VerifiedAndReputablePolicyState = 0`) | SAC가 켜져 있으면 서명 없는 바이너리는 채널과 무관하게 실행 차단 | +| Forgejo generic registry | `HEAD` 미지원(405), `Range: bytes=0-0` 지원(206 + content-range) | 크기/불변 검증은 Range GET으로 한다 | + +**결론**: 서명 여부와 별개로, 100MiB를 넘는 산출물은 이 feed로 게시할 수 없다. 즉 +사이드카를 포함한 NSIS 설치본(189MB)은 **인증서가 있어도 지금 게시할 수 없다**. 이건 +서명과 무관한 별도 결함이며, 앱 크기를 줄이거나(엔진 분리 다운로드) 게시 경로를 바꾸는 +작업이 필요하다(§4 백로그). + +--- + +## 2. 서명 없이 설치되는 방법 비교 (2025-2026) + +| 방법 | SmartScreen | Smart App Control | 관리자 | 자동 업데이트 | +|---|---|---|---|---| +| 대용 ZIP | 최초 실행 경고(다운로드 MOTW 전파) | **차단** | 불필요 | 없음 | +| **Scoop** | 패키지 매니저가 직접 내려받아 MOTW 없음 → 경고 없음 | **차단** | 불필요(per-user) | `scoop update *` + manifest `autoupdate` | +| winget | 대개 경고 없음 | **차단** | 설치 방식에 따름 | `winget upgrade` (manifest 등록 필요) | +| 서명 없는 NSIS/Inno | "Windows protected your PC" → Run anyway | **차단** | 사실상 필요 | 없음 | +| MSIX self-signed 사이드로드 | 서명 없는 것과 같음 | **차단** | 필요(인증서를 TrustedPeople에) | `.appinstaller` (ms-appinstaller는 기본 비활성) | +| Velopack(per-user Setup.exe) | 서명 없으면 자주 경고 | **차단** | 불필요 | 내장 UpdateManager | + +핵심: **Smart App Control이 켜진 PC에서는 어떤 무서명 경로도 통하지 않는다.** SAC는 +클린 설치 기본값이 켜져 있고, 끄면 다시 켤 수 없다(재설치 필요). 그래서 공개 배포의 +정답은 여전히 "서명"이고, Scoop/휴대용은 SAC가 꺼진 환경(그리고 개발/테스트)에서 +쓸 수 있는 보완 경로다. + +--- + +## 3. 이 저장소가 택한 경로 + +### 3-1. 서명 없이 지금 배포되는 것 (portable 채널) + +- 산출물: 95MiB 단위 **7z 분할 볼륨**(`D3RO-Voice-x64-portable.7z.001/.002`) — LZMA2로 + 688MB → 162MiB까지 줄어 각 볼륨이 Cloudflare 한도 안에 들어간다. +- 게시 위치: `.../generic/d3ro-voice/portable-/` 와 `.../portable-latest/` + (**자동 업데이트 피드 `latest.yml`과 완전히 분리**). +- 설치 방법 두 가지: + 1. **Scoop** — 저장소의 `bucket/` 디렉토리를 버킷으로 쓴다. Scoop은 `.7z.001` 볼륨을 + 이어서 해제하는 기능을 공식 지원한다(7-Zip 볼륨). + ```powershell + scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git + scoop install d3ro/d3ro-voice + ``` + 2. **수동 설치 스크립트** — `scripts/install/install-d3ro-voice.ps1` (피드에도 게시됨). + 볼륨을 내려받아 SHA-256 검증 → 결합 → 7-Zip으로 해제 → `%LOCALAPPDATA%\Programs\D3RO Voice` + 에 설치 + 시작 메뉴 바로가기. 관리자 권한 불필요, 7-Zip 필요. + ```powershell + irm https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-latest/install-d3ro-voice.ps1 | iex + ``` +- 자동화: `.forgejo/workflows/portable.yml` (태그 + 수동 실행). `WIN_CSC_*`가 필요 없다. +- 커맨드: `npm run release:portable:build` → `npm run release:portable:check` → `npm run release:portable` + +**안전 규칙(구현에 반영)**: +- 볼륨은 불변 — 같은 버전 경로에 다른 바이트가 있으면 게시 중단. +- 메타데이터(`portable.json`, 설치 스크립트)만 갱신 허용. +- 파일명에 `-portable`을 넣어 서명된 릴리스 자산과 혼동되지 않게 한다. +- `latest.yml`/`update-policy.json`은 절대 건드리지 않는다(자동 업데이트는 서명 릴리스 전용). + +### 3-2. 서명이 준비되면 (원래 계획 유지) + +- MSIX/NSIS는 그대로 간다. 필요한 것은 public-trust 인증서 하나다. +- 2026년 기준 가장 싼 현실적 선택: + - **Azure Artifact Signing(구 Trusted Signing)** — Basic **$9.99/월**, 하드웨어 토큰 불필요, + EXE/MSI/**MSIX** 서명 가능, SmartScreen/SAC 대응. 단 **개인은 미국/캐나다 거주자만**, + 조직은 한국 포함 특정 국가에서 가능(사업자 검증 필요). CI는 `signtool` + dlib 또는 + `azure/artifact-signing-action`(Windows 러너)로 연동. + - **SSL.com OV 코드 서명** — 약 $129/년(+클라우드 HSM/eSigner 별도), 사업자 필요. + - 참고: EV가 SmartScreen을 즉시 통과시키는 경로는 2024년에 폐지됐다. 평판은 누적된다. + +--- + +## 4. 남은 백로그 + +| ID | 항목 | 왜 | +|---|---|---| +| GAP-REL-04 | 사이드카 포함 설치본이 Cloudflare 100MiB 한도를 넘는다(NSIS 189MB) | 인증서가 있어도 게시 불가. 크기를 줄이거나 게시 경로를 바꿔야 한다 | +| GAP-STT-07 | 엔진(사이드카) 첫 실행 다운로드 방식으로 분리 | 앱 번들을 100MiB 이하로 만들고, 엔진은 분할 다운로드 + SHA-256 검증으로 받는다. 설치 경험과 업데이트 크기가 모두 좋아진다 | +| GAP-REL-05 | winget 매니페스트 등록 | winget-pkgs 커뮤니티 저장소 제출 필요(100MiB 한도와 무관한 별도 경로) | + +--- + +## 5. 검증 기록 (2026-09-18) + +- 7z 볼륨 게시 후 **무인증 공개 GET**으로 인덱스/볼륨/스크립트 제공 확인. +- 설치 스크립트 end-to-end 실행: 볼륨 2개 다운로드 → SHA-256 검증 → 결합(162.1MiB) → + 7-Zip 해제 → 설치 디렉토리에 `D3RO Voice.exe`, `resources/sidecar/sidecar.exe`, + `resources/sidecar/_internal/faster_whisper/assets/silero_vad_v6.onnx`, `resources/sox/sox.exe` + 존재 확인 → 시작 메뉴 바로가기 생성. (검증 후 테스트 설치/바로가기는 제거) +- 재게시 시도 시 동일 볼륨은 "이미 동일한 파일" 로 건너뛰는 것 확인(불변 가드 동작). \ No newline at end of file diff --git a/docs/map/02-infrastructure.md b/docs/map/02-infrastructure.md index 11803d4..6411555 100644 --- a/docs/map/02-infrastructure.md +++ b/docs/map/02-infrastructure.md @@ -139,6 +139,7 @@ See [`03-shared-packages.md`](./03-shared-packages.md). Summary: Stages `validate → test → build → e2e → package → publish → deploy`. Primary pipeline for desktop Windows/macOS releases (Forgejo Generic Registry is the canonical updater feed; GitLab project 1172 is a legacy mirror) and production mobile releases (`mobile-production-release`, manual/protected). Admin NAS deploy job is intentionally **disabled**. `package-windows`/`package-macos` build the faster-whisper sidecar (`sidecar:setup` → `sidecar:build`) and run `scripts/ci/verify-sidecar-bundle.mjs` before electron-builder, so a release can never ship without the local STT engine. ### Forgejo Actions (`.forgejo/workflows/`) +`portable.yml` — 태그/수동 실행으로 **서명 없이** portable 채널(95MiB 7z 분할 볼륨 + Scoop 매니페스트 + 설치 스크립트)을 게시한다. `WIN_CSC_*` 불필요, updater feed는 건드리지 않는다. `deploy-site.yml` / `deploy-site-windows.yml` — build `site`, write release identity, deploy to Cloudflare Pages `d3ro` (`d3ro.chanpaca.net`), verify live commit/version, app-links, legal URLs. `release.yml` — tag-triggered Windows build (signed) + `publish-forgejo-release.mjs` to the canonical Forgejo feed/release hub. diff --git a/docs/map/10-feature-catalog.md b/docs/map/10-feature-catalog.md index b2c35af..a6edc6c 100644 --- a/docs/map/10-feature-catalog.md +++ b/docs/map/10-feature-catalog.md @@ -193,6 +193,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]` | INFRA-13 | Site deploy (Cloudflare Pages + GitHub Pages) | [x] | `.forgejo/workflows/deploy-site.yml`, `.github/workflows/deploy-site.yml` | | INFRA-15 | Update & release system | [x] | Canonical Forgejo feed + channels/policy (`release/update-policy.json`, `src/main/update-policy.ts`), canonical publisher `scripts/ci/publish-forgejo-release.mjs`, legacy GitLab mirror; `npm run release:metadata:test`. v1.1.0 was published to Forgejo on 2026-09-15; product version moved to `1.2.0` as a forward-fix with CI-only publication, a same-version re-release guard, and download centers that link the feed instead of repository paths. `1.3.0` (2026-09-18) carries the local-STT fixes; Windows publication still needs the CI signing secrets (`11` GAP-REL-02). | | INFRA-16 | Desktop STT engine packaging | [x] | `apps/desktop/scripts/setup-sidecar.mjs` + `build-sidecar.mjs`, `electron-builder.yml` `extraResources` (`sidecar-dist/sidecar` → `resources/sidecar`, `resources/ffmpeg`), and `scripts/ci/verify-sidecar-bundle.mjs` run in `package-windows`/`package-macos` before electron-builder. Verified on the real bundle: `sidecar.exe` + `_internal` including `faster_whisper/assets/silero_vad_v6.onnx`, plus a packaged-engine transcription round-trip on GPU. | +| INFRA-17 | 서명 없는 배포 채널 (portable + Scoop) | [x] | `scripts/ci/build-portable.mjs` (95MiB 7z 분할 볼륨 + Scoop 매니페스트), `scripts/ci/publish-portable-release.mjs`, `scripts/local/install-d3ro-voice.ps1`, `bucket/` 버킷, `.forgejo/workflows/portable.yml`; updater feed와 분리. 2026-09-18 게시 + 실제 설치 검증. | --- diff --git a/docs/map/11-gap-backlog.md b/docs/map/11-gap-backlog.md index 7ad6b31..8d72fbe 100644 --- a/docs/map/11-gap-backlog.md +++ b/docs/map/11-gap-backlog.md @@ -22,7 +22,7 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res |---|---|---|---|---| | GAP-QA-01 | Quality | Extreme Red Team: headful end-to-end bug hunting across real desktop Electron, Web Next.js, and CI pipelines. | `red_team_log.md`, `tests/e2e/red_team_cycle*.spec.ts`, `apps/web/e2e/red_team_cycle4_web.spec.ts` | `[x]` 2026-09-15: 18 scenarios executed, 14 defects caught and 100% resolved (infinite chunking loop DEF-008, IPC signature mismatch DEF-004, markdown editor typing rollback DEF-006, Web RSC Link serialization DEF-012, secret scanner lookahead DEF-013, etc.). All 18 scenarios GREEN with zero regressions. | | GAP-REL-01 | Release | Official release publication to Forgejo and active public download center deployment. | `scripts/ci/publish-forgejo-release.mjs`, `apps/web/src/app/download/page.tsx`, `site/src/sections/Download.tsx`, `apps/web/e2e/red_team_cycle4_web.spec.ts` | `[~]` 2026-09-15: v1.1.0 release assets (`D3RO-Voice-Setup-1.1.0-x64.exe`, `.blockmap`, `latest.yml`, `update-policy.json`) published to canonical Forgejo registry and release hub. 2026-09-16: the published 1.1.0 installer carries no Authenticode signature, so it does not satisfy the release policy; product version moved to `1.2.0` and publication must come from CI with the signing gate GREEN. Download centers in `apps/web` (`/download`) and `site` (`#download`) link the canonical Forgejo feed. | -| GAP-REL-02 | Release | Windows stable publication needs an external public-trust Authenticode PFX, its password, the exact signer subject, and a Forgejo token, none of which live in the repository. | `.forgejo/workflows/release.yml`, `.gitlab-ci.yml`, `scripts/ci/set-forgejo-secrets.mjs`, `scripts/ci/verify-windows-release-artifact.ps1` | `[!]` 2026-09-18 measured: the Forgejo repo has **zero** Actions secrets (`GET /repos/yunchan/d3ro-voice/actions/secrets` → `[]`), so `v1.2.0` (run 49) and `v1.3.0` (run 51) both failed at the signing guard and **no installer or update has been published since `1.1.0`**. Inject the four secrets (`WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`, `FORGEJO_TOKEN`) with `npm run release:secrets` (check: `npm run release:secrets:check`), then re-run `release.yml` for the `v1.3.0` tag via `workflow_dispatch` (tags are immutable). | +| GAP-REL-02 | Release | Windows stable publication needs an external public-trust Authenticode PFX, its password, the exact signer subject, and a Forgejo token, none of which live in the repository. | `.forgejo/workflows/release.yml`, `.gitlab-ci.yml`, `scripts/ci/set-forgejo-secrets.mjs`, `scripts/ci/verify-windows-release-artifact.ps1` | `[!]` 2026-09-18 measured: the Forgejo repo had **zero** Actions secrets; `FORGEJO_TOKEN` is registered now (2026-09-18) but `WIN_CSC_*` still have no values, so `v1.2.0` (run 49) and `v1.3.0` (run 51) both failed at the signing guard and **no updater-feed release has been published since `1.1.0`**. Inject the four secrets (`WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`, `FORGEJO_TOKEN`) with `npm run release:secrets` (check: `npm run release:secrets:check`), then re-run `release.yml` for the `v1.3.0` tag via `workflow_dispatch` (tags are immutable). | | GAP-ADS-01 | Ads | 9 of 10 desktop ad adapters still extend `UnavailableAdAdapter` (`provider_not_integrated`). | `apps/desktop/src/main/services/ads/*` | `[~]` 2026-09-13: `DirectHouseSponsorAdapter` is now a real configurable REST adapter (bid/impression/click/reward via `endpointUrl`; fail-closed when unconfigured; 22 unit tests GREEN). Remaining 9 need official SDKs/authenticated endpoints. | | GAP-ADS-02 | Ads | Desktop mediation reward accounting is not wired to license quota (`claimReward` still returns no tokens). | `AdMediationEngine.ts`, `AppLayout.tsx` | Wire verified `reportRewardCompletion` to `LicenseService` quota after the direct sponsor endpoint exists. | | GAP-ID-01 | Identity | Supabase, .NET JWT/SQLite, and the desktop offline license each had their own tier/role shape. | `@d3ro/core/entitlement`, `LicenseService`, `entitlement-context` | `[~]` 2026-09-13: canonical `EntitlementSnapshot` + `resolveEntitlement` added with tests; desktop tier normalization + `isPro` fixed. Full adoption tracked as GAP-ID-02. | @@ -47,6 +47,9 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res | GAP-STT-03 | Local engines | On hosts where `localhost` resolves only to IPv6, every local engine call (STT sidecar and Ollama) was refused. Audio capture and local LLM appeared dead. | `apps/desktop/src/main/utils/loopback.ts`, `LocalSTTService`, `LocalLLMService`, `RAGService`, `OnlineLLMService`, `STTManager` | `[x]` 2026-09-18: loopback normalization to `127.0.0.1` for all local engine URLs; defaults updated; 9 unit tests. Verified against the live sidecar and Ollama on a host with an IPv6-only `localhost`. | | GAP-STT-04 | Local STT | Live partial transcript (`CAP-03`, `voice:partialTranscript`) was marked done but had **no producer**: the channel, popup UI, and preload existed, nothing ever emitted. | `apps/desktop/src/main/services/VoiceModeService.ts`, `LocalSTTService.transcribePartial`, `STTManager.transcribePartial` | `[x]` 2026-09-18: 1.5 s cadence over a 7.5 s trailing window, greedy decode, drained before the final transcription; never inserted. | | GAP-STT-05 | Local STT | The bundled sidecar lacked faster-whisper's Silero VAD data, so `vad_filter=true` transcription would have failed at runtime even with the engine bundled. | `apps/desktop/scripts/build-sidecar.mjs`, `scripts/ci/verify-sidecar-bundle.mjs` | `[x]` 2026-09-18: `--collect-all faster_whisper` plus a packaging-time presence check for `assets/silero_vad_v6.onnx`. | +| GAP-REL-03 | Release | 서명이 없어 설치할 수 있는 경로가 없다(인증서 발급 전 공백). | `.forgejo/workflows/portable.yml`, `scripts/ci/build-portable.mjs`, `scripts/local/install-d3ro-voice.ps1`, `bucket/d3ro-voice.json` | `[x]` 2026-09-18: 서명 없는 portable 채널 구현 — 95MiB 7z 분할 볼륨(688MB → 162MiB) + Scoop 버킷 + 수동 설치 스크립트를 Forgejo에 게시. 실제 설치 스크립트 end-to-end 검증(볼륨 다운로드 → SHA-256 → 결합 → 해제 → 엔진 포함 확인). updater feed는 건드리지 않음. | +| GAP-REL-04 | Release | canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 **100MiB**를 넘으면 HTTP 413으로 거부한다. 사이드카를 포함한 NSIS 설치본은 189MB라 **인증서가 있어도 게시할 수 없다**. | `scripts/ci/publish-portable-release.mjs`, `.forgejo/workflows/release.yml`, `docs/deployment/unsigned-distribution.md` | `[ ]` 2026-09-18 실측(60MiB 201 · 110MiB 413). 해결: 앱 번들을 100MiB 이하로 만들고 엔진은 분할 다운로드로 받거나(GAP-STT-07), 게시 경로를 바꾼다. | +| GAP-STT-07 | Local STT | 엔진(사이드카)를 앱 번들에 넣으면 설치본이 100MiB를 넘고 매 업데이트마다 162MiB를 다시 받는다. | `apps/desktop/electron-builder.yml`, `LocalSTTService` | `[ ]` 앱은 엔진을 첫 실행 시 분할 다운로드 + SHA-256 검증으로 받도록 분리한다. 설치 크기/업데이트 크기/패키징 실패 지점이 모두 줄어든다. | | GAP-STT-06 | Local STT | Decode settings were untuned: previous-text conditioning let repeated hallucinations compound, and no VAD parameters meant slow, uneven segments. | `apps/desktop/sidecar/main.py` | `[x]` 2026-09-18: `condition_on_previous_text=false`, bounded low-temperature fallback, `no_speech`/`compression_ratio`/`log_prob` thresholds, 300 ms silence trimming. Same transcript, ~5x faster on the reference machine (7.7 s audio: 1609 ms → 303 ms). | --- diff --git a/package.json b/package.json index 861bab0..1f02434 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "release:forgejo:local": "node --env-file-if-exists=.env scripts/ci/publish-forgejo-release.mjs", "release:forgejo:check": "node scripts/ci/publish-forgejo-release.mjs --check", "release:tag": "node scripts/ci/create-release-tag.mjs", + "release:portable:build": "node scripts/ci/build-portable.mjs", + "release:portable:check": "node scripts/ci/publish-portable-release.mjs --check", + "release:portable": "node --env-file-if-exists=.env scripts/ci/publish-portable-release.mjs", "release:secrets": "node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write", "release:secrets:check": "node scripts/ci/set-forgejo-secrets.mjs --check", "security:secrets": "node scripts/ci/check-no-hardcoded-secrets.mjs", diff --git a/scripts/ci/build-portable.mjs b/scripts/ci/build-portable.mjs new file mode 100644 index 0000000..1e3f81a --- /dev/null +++ b/scripts/ci/build-portable.mjs @@ -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//D3RO-Voice--x64-portable.7z.001/.002/... +// apps/desktop/release//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'), +) \ No newline at end of file diff --git a/scripts/ci/publish-portable-release.mjs b/scripts/ci/publish-portable-release.mjs new file mode 100644 index 0000000..88d3d2e --- /dev/null +++ b/scripts/ci/publish-portable-release.mjs @@ -0,0 +1,153 @@ +// scripts/ci/publish-portable-release.mjs +// 서명 없는 휴대용 배포본(7z 분할 볼륨) + Scoop 매니페스트 + 수동 설치 스크립트를 +// Forgejo Generic Registry에 게시한다. +// +// 이 채널은 자동 업데이트 피드(latest.yml / update-policy.json)를 건드리지 않는다. +// 서명이 없어도 게시할 수 있으므로 인증서 발급 전에도 사용자가 설치할 수 있는 경로다. +// +// 경로: +// .../generic/d3ro-voice/portable-/<륨>.7z.00N +// .../generic/d3ro-voice/portable-/portable.json +// .../generic/d3ro-voice/portable-/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'), +) \ No newline at end of file diff --git a/scripts/install/install-d3ro-voice.ps1 b/scripts/install/install-d3ro-voice.ps1 new file mode 100644 index 0000000..c3892c5 --- /dev/null +++ b/scripts/install/install-d3ro-voice.ps1 @@ -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 \ No newline at end of file