From a85ab799a3fe86acf5d98a8c272ebe03a01275c2 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 18 Sep 2026 04:37:05 +0900 Subject: [PATCH 01/18] docs(release): record why tagged releases publish nothing Two tagged release pipelines failed and no installer has been published since 1.1.0. The cause is not in the build: the release repository has no Actions secrets at all, so every run stops at the signing guard. A helper now reports which release secrets are missing and registers them once the signing material exists, and the release guide documents the required values, the Forgejo-side check, and how to re-run a pipeline for an existing tag without recreating it. --- docs/deployment/release-guide.md | 57 ++++++++++--- docs/map/11-gap-backlog.md | 2 +- package.json | 2 + scripts/ci/set-forgejo-secrets.mjs | 131 +++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 scripts/ci/set-forgejo-secrets.mjs diff --git a/docs/deployment/release-guide.md b/docs/deployment/release-guide.md index 073fa40..cf1e82b 100644 --- a/docs/deployment/release-guide.md +++ b/docs/deployment/release-guide.md @@ -1,41 +1,41 @@ # D3RO Voice 릴리스 가이드 -기준일: 2026-09-16. 이 문서는 desktop GitLab 패키지·자동 업데이트와 mobile store release의 경계를 분리한다. 태그 생성이나 HTTP 200 하나만으로 배포 완료를 선언하지 않는다. +기준일: 2026-09-18. 이 문서는 desktop GitLab 패키지·자동 업데이트와 mobile store release의 경계를 분리한다. 태그 생성이나 HTTP 200 하나만으로 배포 완료를 선언하지 않는다. ## 현재 release identity | 항목 | 정본 | 현재 판정 | |---|---|---| -| 제품 버전 | `release/product-version.json`: `1.2.0` | source SSOT 확정 | -| Android | versionCode `1020001` | production AAB 미생성 | -| iOS | build `1020001` | production archive 미검증 | +| 제품 버전 | `release/product-version.json`: `1.3.0` | source SSOT 확정 | +| Android | versionCode `1030001` | production AAB 미생성 | +| iOS | build `1030001` | production archive 미검증 | | Android upload key | alias `d3ro-upload-20260821`, cert SHA-256 `4F:AC:69:24:...:15:2B:54` | external PKCS12·user-only ACL·Credential Manager·private-key readback GREEN; CI secret·복구 백업·AAB signer 대조 대기 | | release evidence | Ed25519 public `release/mobile-release-evidence-public.pem`, keyId `2797d3e6...4a890b7f` | external private key ACL·roundtrip GREEN; CI private-key secret·복구 백업 대기 | | desktop offline license | Ed25519 public `apps/desktop/resources/license/production-public.pem`, keyId `5c52b765...81a887f` | 새 전용 keypair·external private ACL·roundtrip·desktop production build GREEN; admin `ADMIN_LICENSE_PRIVATE_KEY` secret 주입 대기 | -| Windows Authenticode | external public-trust code-signing certificate | 현재 local installer·unpacked app은 `NotSigned`; production PFX·CI secret·signed artifact GREEN 전까지 게시 금지 | +| Windows Authenticode | external public-trust code-signing certificate | production PFX 없음. Forgejo 저장소 시크릿 4종(`WIN_CSC_LINK`/`WIN_CSC_KEY_PASSWORD`/`WIN_CSC_EXPECTED_SIGNER_SUBJECT`/`FORGEJO_TOKEN`)이 0건이라 릴리스 파이프라인이 fail-closed. `scripts/ci/set-forgejo-secrets.mjs --check`로 확인한다. GREEN 전까지 게시 금지 | | Firebase | Console `u/0`, `u/1` 모두 D3RO project 없음 | 사용자 승인 후 project·Android app 생성 필요 | | AdMob | app `ca-app-pub-1039714767792854~6427959892`; banner `/9840591290`; rewarded `/2255790918` | SSOT 확정. `검토 필요`·`광고 게재 제한`·store 미연결·결제 프로필 미완료 | | updater feed (canonical) | `https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest` | Forgejo Generic Registry. GitLab project 1172은 legacy mirror | -| release notes | `CHANGELOG.md` `## [1.2.0]` | 태그 전 확정·검증 필수 | -| 직전 게시본 | Forgejo Release `v1.1.0` (2026-09-15 게시, unsigned installer 포함) | 불변 태그. `1.2.0`은 이를 대체하는 forward-fix | +| release notes | `CHANGELOG.md` `## [1.3.0]` + Play changelog `1030001.txt` (ko/en) | 태그 전 확정·검증 필수 | +| 직전 게시본 | Forgejo Release `v1.1.0` (2026-09-15 게시, unsigned installer) | `v1.2.0`·`v1.3.0` 파이프라인은 Forgejo 저장소 시크릿이 없어 실패 = 게시본 없음. 설치본이 있는 마지막 버전은 `1.1.0` | -live canonical feed(`git.chanpaca.net/.../d3ro-voice/latest`)의 `latest.yml`은 현재 `1.1.0`을 보고한다. `1.2.0` 태그 파이프라인이 GREEN이 되면 그 값이 올라간다. +live canonical feed(`git.chanpaca.net/.../d3ro-voice/latest`)의 `latest.yml`은 현재 `1.1.0`을 보고한다. 시크릿을 채우고 `v1.3.0` 파이프라인이 GREEN이 되면 그 값이 `1.3.0`으로 올라가고, 그때부터 기존 설치본이 자동 업데이트를 받는다. ## desktop 릴리스 파이프라인 ```text authoritative release commit → version/check/test/build GREEN - → annotated tag v1.2.0 + → annotated tag v1.3.0 → package-windows (build-win-x64) → package-macos (build-mac-arm64) → publish-release (build-linux-x64) ├─ publish-forgejo-release.mjs ← canonical - │ ├─ Forgejo Generic Registry /d3ro-voice/1.2.0/ (버전별 보존) + │ ├─ Forgejo Generic Registry /d3ro-voice/1.3.0/ (버전별 보존) │ ├─ Forgejo Generic Registry /d3ro-voice/latest/ (updater feed + update-policy.json) │ └─ Forgejo Release + CHANGELOG notes + 자산 첨부 └─ publish-gitlab-release.mjs ← legacy mirror (pre-Forgejo 설치본) - ├─ GitLab Generic Registry /d3ro-voice/1.2.0/ + ├─ GitLab Generic Registry /d3ro-voice/1.3.0/ ├─ GitLab Generic Registry /d3ro-voice/latest/ └─ GitLab Release ``` @@ -50,7 +50,7 @@ authoritative release commit 포함되지 않는다. 사이트·웹 다운로드 센터는 로컬 경로가 아니라 feed URL을 링크한다. (역사적 `1.0.0` 자산만 추적 상태로 남아 있다.) -- `scripts/ci/sync-version.mjs --check --tag v1.2.0`는 태그, `release/product-version.json`, package/lockfile, Android/iOS 버전 면의 일치를 fail-closed로 검증한다. +- `scripts/ci/sync-version.mjs --check --tag v1.3.0`는 태그, `release/product-version.json`, package/lockfile, Android/iOS 버전 면의 일치를 fail-closed로 검증한다. - `scripts/ci/verify-release-metadata.mjs`는 배포 메타데이터와 CI/publisher 계약을 검증한다. - 같은 gate는 desktop license public key가 Ed25519이고 `release/product-version.json`의 `desktopLicensePublicKeyId`와 일치하는지 검증한다. `electron.vite.config.ts`는 이 파일을 직접 읽으므로 누락·손상된 키로는 build가 시작되지 않는다. - `scripts/ci/publish-forgejo-release.mjs`는 canonical이다. 버전별 패키지를 먼저 올리고, `latest`에서 설치 자산 참조를 검증한 뒤 `latest.yml`과 `update-policy.json`을 마지막에 게시하고 공개 URL에서 재검증한다. `scripts/ci/publish-gitlab-release.mjs`는 legacy mirror로 동일 자산을 GitLab에도 올린다. @@ -110,7 +110,38 @@ https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice - **회수(rollback)**: `stagingPercentage`를 낮추거나 `killSwitch`를 켠다. 이미 배포된 버전은 되돌리지 않고 더 높은 patch로 forward-fix한다. -## `1.2.0` 릴리스 절차 +## `1.3.0` 릴리스 절차 (canonical = Forgejo Actions) + +**선행 조건 — Forgejo 저장소 시크릿.** `.forgejo/workflows/release.yml`은 아래 4개가 +없으면 fail-closed로 중단한다. 실측(2026-09-18): 저장소 시크릿이 0건이라 `v1.2.0`과 +`v1.3.0` 태그 파이프라인이 서명 가드에서 실패했고, 그래서 설치본·업데이트가 게시되지 +않았다. 현재 상태 점검과 등록은 다음 한 줄로 한다. + +```bash +node scripts/ci/set-forgejo-secrets.mjs --check +node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write +``` + +| 시크릿 | 의미 | +|---|---| +| `WIN_CSC_LINK` | public-trust Authenticode PFX (base64). 개발용 `Everything2EverythingDev`는 거부된다 | +| `WIN_CSC_KEY_PASSWORD` | PFX password | +| `WIN_CSC_EXPECTED_SIGNER_SUBJECT` | 인증서의 정확한 subject. `verify-windows-release-artifact.ps1`이 이 identity를 요구한다 | +| `FORGEJO_TOKEN` | `write:package` + `write:repository` (릴리스 게시 단계) | + +1. `release/product-version.json`의 version/build 값과 모든 버전 면을 `npm run version:check`로 대조한다. +2. `CHANGELOG.md` `## [1.3.0] - 2026-09-18` 섹션을 사용자 변경점 중심으로 확정한다. publisher는 이 섹션과 그에 대응하는 Play changelog(`apps/mobile-rn/metadata/android/*/changelogs/.txt`)가 없으면 실패한다. +3. dirty/untracked 작업을 임의로 reset·clean하지 말고, release 범위만 검토 가능한 authoritative commit으로 보존한다. +4. 같은 commit에서 lint, typecheck, test, build, release metadata·security·artifact gate를 전부 GREEN으로 만든다. +5. **로컬 전사 엔진 검증**: 파이프라인은 `sidecar:setup` → `sidecar:build` → `verify-sidecar-bundle.mjs`를 패키징 전에 실행한다. 이 게이트가 없으면 설치본에 엔진이 빠진 채 게시된다(과거 실제 사고). 로컬에서 `electron-builder --dir`만 볼 때는 서명 실패로 extraResources 복사가 중간에 끊기므로 `-c.win.forceCodeSigning=false`로 확인한다. +6. desktop offline license를 제공한다면 external private key를 admin의 `ADMIN_LICENSE_PRIVATE_KEY` secret로 주입하고, 저장소 public key와 sign/verify roundtrip 및 발급 감사 로그를 확인한다. +7. 이미 게시된 버전보다 높은 annotated 태그 `v1.3.0`을 생성해 push한다. `npm run release:tag -- --dry-run`으로 검증한 뒤 `npm run release:tag`(GPG 사용 시 `-- --sign`)와 `git push chanpaca v1.3.0`를 실행한다. 태그는 불변이며 게이트를 시작하는 후속 단계지 검증을 대체하지 않는다. 이미 게시된 버전을 재게시하지 않는다: canonical publisher는 버전별 자산이 다른 바이트를 가지면 fail-closed로 중단한다. +8. 시크릿을 나중에 채웠다면 태그를 새로 만들 필요가 없다 — `release.yml`은 `workflow_dispatch`를 지원하므로 Forgejo UI에서 해당 태그 ref로 수동 실행한다. +9. Forgejo Actions run(`/actions/tasks` API 또는 UI)에서 단계별 결과를 확인한다. pending/stuck/skipped를 GREEN으로 기록하지 않는다. +10. Forgejo Release note/asset, `latest.yml`, `update-policy.json`, installer hash를 외부 public URL에서 다시 검증한다. +11. 이전 설치본에서 자동 업데이트 E2E를 실행하고 실행 중 버전·프로세스·사용자 데이터 보존, 그리고 **업데이트 후 로컬 받아쓰기 1회 성공**을 확인한다. + +## `1.2.0` 릴리스 절차 (기록) 1. `release/product-version.json`의 version/build 값과 모든 버전 면을 `npm run version:check`로 대조한다. 2. `CHANGELOG.md` `## [1.2.0] - 2026-09-16` 섹션을 사용자 변경점 중심으로 확정한다. publisher는 이 섹션이 없으면 실패해야 한다. diff --git a/docs/map/11-gap-backlog.md b/docs/map/11-gap-backlog.md index 161eb6e..7ad6b31 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/verify-windows-release-artifact.ps1` | `[!]` 2026-09-16: every publisher fails closed without `WIN_CSC_*` and `FORGEJO_TOKEN`; provide them as protected CI secrets, then re-run the tag pipeline. Still open as of `1.3.0` (2026-09-18): the `v1.3.0` tag must be built by CI with the signing gate GREEN. Local packaging cannot produce a signed installer (`forceCodeSigning: true`). | +| 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-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. | diff --git a/package.json b/package.json index 2d16304..861bab0 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "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: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", "security:secrets:test": "node scripts/ci/check-no-hardcoded-secrets.mjs --self-test", "check:design": "node scripts/ci/check-design-tokens.mjs", diff --git a/scripts/ci/set-forgejo-secrets.mjs b/scripts/ci/set-forgejo-secrets.mjs new file mode 100644 index 0000000..4631918 --- /dev/null +++ b/scripts/ci/set-forgejo-secrets.mjs @@ -0,0 +1,131 @@ +// scripts/ci/set-forgejo-secrets.mjs +// Forgejo Actions 저장소 시크릿을 점검하거나 등록한다. +// +// 배경: 데스크톱 릴리스 워크플로(.forgejo/workflows/release.yml)는 아래 시크릿이 +// 없으면 fail-closed로 중단한다. 저장소에 시크릿이 하나도 없으면 태그를 올려도 +// 설치본이 게시되지 않는다(실측: run 49/51 모두 서명 가드에서 실패). +// +// 사용: +// node scripts/ci/set-forgejo-secrets.mjs --check # 현재 상태만 확인 +// node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write +// +// 값은 출력하지 않는다(이름/존재 여부/길이만). FORGEJO_TOKEN(쓰기 스코프 필요)은 +// .env 또는 환경변수에서 읽는다. + +import { readFileSync, existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REQUIRED = [ + 'WIN_CSC_LINK', + 'WIN_CSC_KEY_PASSWORD', + 'WIN_CSC_EXPECTED_SIGNER_SUBJECT', + 'FORGEJO_TOKEN', +] + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const args = process.argv.slice(2) +const write = args.includes('--write') + +/** .env(있으면) + 환경변수에서 값 조회. 값은 로그에 절대 남기지 않는다. */ +function readEnv() { + const values = { ...process.env } + const envPath = join(root, '.env') + if (existsSync(envPath)) { + for (const line of readFileSync(envPath, 'utf8').split(/\r?\n/)) { + if (!/^[A-Z0-9_]+=/.test(line)) continue + const index = line.indexOf('=') + const key = line.slice(0, index) + if (values[key]) continue + values[key] = line.slice(index + 1).trim() + } + } + return values +} + +const env = readEnv() +const token = env.FORGEJO_TOKEN?.trim() +const server = (env.GIT_SERVER_URL?.trim() || 'https://git.chanpaca.net').replace(/\/$/, '') +const owner = env.GIT_USERNAME?.trim() || 'yunchan' +const repo = env.GIT_REPO_NAME?.trim() || 'd3ro-voice' + +if (!token) { + console.error('FORGEJO_TOKEN이 필요합니다 (.env 또는 환경변수).') + process.exit(1) +} + +const apiBase = `${server}/api/v1/repos/${owner}/${repo}/actions/secrets` +const headers = { Authorization: `token ${token}` } + +async function listSecrets() { + const response = await fetch(apiBase, { headers }) + if (!response.ok) { + throw new Error(`시크릿 목록 조회 실패: HTTP ${response.status}`) + } + const body = await response.json() + return new Set((Array.isArray(body) ? body : []).map((item) => item.name)) +} + +async function putSecret(name, value) { + const response = await fetch(`${apiBase}/${name}`, { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: value }), + }) + if (!response.ok && response.status !== 201 && response.status !== 204) { + throw new Error(`${name} 등록 실패: HTTP ${response.status}`) + } +} + +const existing = await listSecrets() +console.log(`저장소: ${owner}/${repo} (${server})`) +console.log(`시크릿 API: ${apiBase}\n`) + +let missing = 0 +for (const name of REQUIRED) { + const present = existing.has(name) + if (present) { + console.log(` [x] ${name} — 등록됨`) + continue + } + missing += 1 + const value = env[name]?.trim() + console.log(` [ ] ${name} — 없음${value ? ` (환경/.env에 값 있음, 길이 ${value.length})` : ' (값 없음)'}`) +} + +if (missing === 0) { + console.log('\n모든 릴리스 시크릿이 준비되었습니다.') + process.exit(0) +} + +if (!write) { + console.log( + [ + `\n누락 ${missing}건. 값이 준비되면 다음으로 등록한다:`, + ' node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write', + '', + 'WIN_CSC_LINK는 public-trust Authenticode PFX를 base64로 인코딩한 값이어야 하며,', + 'WIN_CSC_EXPECTED_SIGNER_SUBJECT는 그 인증서의 정확한 subject 문자열이어야 한다.', + '(개발용 Everything2EverythingDev 인증서는 production으로 인정되지 않는다.)', + ].join('\n'), + ) + process.exit(missing === 0 ? 0 : 2) +} + +let written = 0 +for (const name of REQUIRED) { + if (existing.has(name)) continue + const value = env[name]?.trim() + if (!value) { + console.log(` 건너뜀: ${name} (값 없음)`) + continue + } + await putSecret(name, value) + written += 1 + console.log(` 등록: ${name}`) +} + +const after = await listSecrets() +const stillMissing = REQUIRED.filter((name) => !after.has(name)) +console.log(`\n등록 ${written}건. 남은 누락: ${stillMissing.length ? stillMissing.join(', ') : '없음'}`) +process.exit(stillMissing.length === 0 ? 0 : 2) \ No newline at end of file From c35c6f3e95b2536ddd00b155622aad40ff586e96 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 18 Sep 2026 11:03:34 +0900 Subject: [PATCH 02/18] 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 From 0411f389d96f9abf268382cd0b937e30c84763bb Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 18 Sep 2026 12:03:18 +0900 Subject: [PATCH 03/18] feat(release): make the signing-free install work with nothing but Windows 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. --- CHANGELOG.md | 9 ++ apps/admin-swagger/openapi.json | 2 +- apps/admin/package.json | 2 +- apps/api-server/D3ROVoice.Api.csproj | 2 +- apps/desktop/package.json | 2 +- apps/mobile-rn/android/app/build.gradle | 4 +- .../ios/D3ROVoice.xcodeproj/project.pbxproj | 8 +- .../android/en-US/changelogs/1031001.txt | 1 + .../android/ko-KR/changelogs/1031001.txt | 1 + apps/mobile-rn/package-lock.json | 14 +-- apps/mobile-rn/package.json | 2 +- apps/web/package.json | 2 +- apps/web/src/components/layout/sidebar.tsx | 2 +- bucket/d3ro-voice.json | 14 +-- docs/deployment/release-guide.md | 6 + docs/deployment/unsigned-distribution.md | 29 ++++- docs/map/10-feature-catalog.md | 2 +- package-lock.json | 20 ++-- package.json | 2 +- packages/api-client/package.json | 2 +- packages/core/package.json | 2 +- packages/i18n/package.json | 2 +- packages/ui-native/package.json | 2 +- packages/ui/package.json | 2 +- release/product-version.json | 6 +- scripts/ci/build-portable.mjs | 86 ++++++++++++++- scripts/ci/publish-portable-release.mjs | 17 ++- scripts/install/install-d3ro-voice.ps1 | 103 ++++++++---------- site/package-lock.json | 4 +- site/package.json | 2 +- 30 files changed, 234 insertions(+), 118 deletions(-) create mode 100644 apps/mobile-rn/metadata/android/en-US/changelogs/1031001.txt create mode 100644 apps/mobile-rn/metadata/android/ko-KR/changelogs/1031001.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index f3cb3e4..e7f86eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cloud-optional backup (encrypted, opt-in) - Plugin system for custom pipelines +## [1.3.1] - 2026-09-18 + +### Fixed +- **Manual install no longer needs 7-Zip**: the signing-free channel now also publishes the + app as byte-split `zip` parts. The install script joins them and extracts with the built-in + Windows `Expand-Archive`, so a user with nothing but Windows can install (the 7z volumes + remain the smaller Scoop path). Split volumes from a different build are never mixed: + every artifact of a release comes from one build, and a published version is not overwritten. + ## [1.3.0] - 2026-09-18 > Published from an annotated tag through CI. Installer and update metadata are diff --git a/apps/admin-swagger/openapi.json b/apps/admin-swagger/openapi.json index 1032f14..ef5d178 100644 --- a/apps/admin-swagger/openapi.json +++ b/apps/admin-swagger/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "D3RO-VOICE Admin API", "description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.", - "version": "1.3.0" + "version": "1.3.1" }, "servers": [ { diff --git a/apps/admin/package.json b/apps/admin/package.json index 17c7840..78c4008 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/admin", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice Admin CRM — SaaS 관리 도구", "scripts": { diff --git a/apps/api-server/D3ROVoice.Api.csproj b/apps/api-server/D3ROVoice.Api.csproj index 9b8fda2..5cb08ba 100644 --- a/apps/api-server/D3ROVoice.Api.csproj +++ b/apps/api-server/D3ROVoice.Api.csproj @@ -2,7 +2,7 @@ net10.0 - 1.3.0 + 1.3.1 enable enable diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b7f828c..26006f5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/desktop", - "version": "1.3.0", + "version": "1.3.1", "productName": "d3ro-voice", "description": "로컬 AI 음성 어시스턴트 (Electron)", "main": "./out/main/index.js", diff --git a/apps/mobile-rn/android/app/build.gradle b/apps/mobile-rn/android/app/build.gradle index c99b448..43fde46 100644 --- a/apps/mobile-rn/android/app/build.gradle +++ b/apps/mobile-rn/android/app/build.gradle @@ -151,8 +151,8 @@ def versionSettingsValid = configuredVersionName != null && configuredVersionName ==~ strictSemver && configuredVersionCodeValue != null && configuredVersionCodeValue <= 2100000000L -def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.0" -def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1030001 +def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.1" +def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031001 def requiredReleaseSettings = [ D3RO_RELEASE_STORE_FILE: releaseStoreFilePath, diff --git a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj index 81d4618..fb6b4d2 100644 --- a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj +++ b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj @@ -257,7 +257,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1030001; + CURRENT_PROJECT_VERSION = 1031001; ENABLE_BITCODE = NO; INFOPLIST_FILE = D3ROVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -265,7 +265,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -287,14 +287,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1030001; + CURRENT_PROJECT_VERSION = 1031001; INFOPLIST_FILE = D3ROVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1031001.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1031001.txt new file mode 100644 index 0000000..16d8ac2 --- /dev/null +++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1031001.txt @@ -0,0 +1 @@ +Added a signing-free install path: the download is served in verifiable parts and installed after checking them. diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031001.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031001.txt new file mode 100644 index 0000000..df7a195 --- /dev/null +++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031001.txt @@ -0,0 +1 @@ +서명 인증서 없이도 설치할 수 있는 배포 경로를 추가했습니다. 설치 파일이 나뉘어 제공되고 검증 후에 설치됩니다. diff --git a/apps/mobile-rn/package-lock.json b/apps/mobile-rn/package-lock.json index dc24c46..713eaad 100644 --- a/apps/mobile-rn/package-lock.json +++ b/apps/mobile-rn/package-lock.json @@ -1,12 +1,12 @@ { "name": "@d3ro/mobile-rn", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@d3ro/mobile-rn", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "@d3ro/api-client": "file:../../packages/api-client", "@d3ro/core": "file:../../packages/core", @@ -62,7 +62,7 @@ }, "../..": { "name": "d3ro-voice-monorepo", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "workspaces": [ "apps/desktop", @@ -81,7 +81,7 @@ }, "../../packages/api-client": { "name": "@d3ro/api-client", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -98,7 +98,7 @@ }, "../../packages/core": { "name": "@d3ro/core", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "docx": "^9.6.1" @@ -109,7 +109,7 @@ }, "../../packages/i18n": { "name": "@d3ro/i18n", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "devDependencies": { "@types/react": "^19.0.0" @@ -120,7 +120,7 @@ }, "../../packages/ui-native": { "name": "@d3ro/ui-native", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "devDependencies": { "@types/react": "*" diff --git a/apps/mobile-rn/package.json b/apps/mobile-rn/package.json index 4d33564..dc24dfb 100644 --- a/apps/mobile-rn/package.json +++ b/apps/mobile-rn/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/mobile-rn", - "version": "1.3.0", + "version": "1.3.1", "private": true, "scripts": { "android": "react-native run-android", diff --git a/apps/web/package.json b/apps/web/package.json index 2aef4ab..85e5343 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/web", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스", "scripts": { diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index ef28ded..255994d 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -139,7 +139,7 @@ export function Sidebar(): React.ReactElement { - v1.3.0 + v1.3.1 diff --git a/bucket/d3ro-voice.json b/bucket/d3ro-voice.json index dc2d85e..f09fb46 100644 --- a/bucket/d3ro-voice.json +++ b/bucket/d3ro-voice.json @@ -1,17 +1,17 @@ { - "version": "1.3.0", + "version": "1.3.1", "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" + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.1/D3RO-Voice-1.3.1-x64-portable.7z.001", + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.1/D3RO-Voice-1.3.1-x64-portable.7z.002" ], "hash": [ - "c4824bd625a2f0fd685f6fd3dd8521f4ced7d46954b59f0c10efcd432f82bd3f", - "187f4917e53525e736df838b15323e840691d602eadcfd9024407498316932ce" + "917f9d6e078517b6c924c30d26b59cf530d8c69e4adbdf679d2196bc645fa993", + "06f32ca809fed42cda9900e04a8e50da42506ed71944462314ecef40773a3197" ] } }, @@ -29,8 +29,8 @@ "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" + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.1-x64-portable.7z.001", + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.1-x64-portable.7z.002" ] } } diff --git a/docs/deployment/release-guide.md b/docs/deployment/release-guide.md index 756ce11..685ffd0 100644 --- a/docs/deployment/release-guide.md +++ b/docs/deployment/release-guide.md @@ -155,7 +155,13 @@ npm run release:portable # Forgejo portable 채널 게시 - 자동 업데이트 피드(`latest.yml`)는 **건드리지 않는다** — 서명된 릴리스 전용이다. - 볼륨은 불변이다: 같은 버전 경로에 다른 바이트가 있으면 게시가 중단된다. - 태그/수동 실행 워크플로: `.forgejo/workflows/portable.yml` (필요 시크릿: `FORGEJO_TOKEN`). +- 산출물은 7z 분할 볼륨(Scoop용, 162MiB)과 zip 분할 부품(수동 설치용, 243MiB) 두 가지다. + 수동 설치 스크립트는 Windows 내장 `Expand-Archive`만 쓰므로 7-Zip이 필요 없다. - 사용자 설치: Scoop 버킷(`bucket/`) 또는 `install-d3ro-voice.ps1`. +- 한 버전의 산출물은 한 번의 빌드에서만 나온다(볼륨은 불변). CI 태그 파이프라인이 최초 게시자가 되게 하고, + 이미 게시된 버전을 같은 번호로 다시 게시하지 않는다 — 필요하면 버전을 올린다. +- 로컬에서 게시할 때는 `npm run release:portable:build`가 out/와 sidecar-dist/를 먼저 요구한다 + (`npm run build --workspace=@d3ro/desktop`, `npm run sidecar:build --workspace=@d3ro/desktop`). ## `1.2.0` 릴리스 절차 (기록) diff --git a/docs/deployment/unsigned-distribution.md b/docs/deployment/unsigned-distribution.md index 09ec446..6455451 100644 --- a/docs/deployment/unsigned-distribution.md +++ b/docs/deployment/unsigned-distribution.md @@ -46,8 +46,10 @@ ### 3-1. 서명 없이 지금 배포되는 것 (portable 채널) -- 산출물: 95MiB 단위 **7z 분할 볼륨**(`D3RO-Voice-x64-portable.7z.001/.002`) — LZMA2로 - 688MB → 162MiB까지 줄어 각 볼륨이 Cloudflare 한도 안에 들어간다. +- 산출물 두 가지(같은 버라도 목적이 다름): + - **7z 분할 볼륨** `D3RO-Voice--x64-portable.7z.001/.002` — LZMA2로 688MB → 162MiB. Scoop 전용. + - **zip 분할 부품** `...zip.001/.002/.003` — 243MiB. 수동 설치 스크립트용. Windows 내장 + `Expand-Archive`만으로 풀 수 있어 사용자가 7-Zip을 설치할 필요가 없다. - 게시 위치: `.../generic/d3ro-voice/portable-/` 와 `.../portable-latest/` (**자동 업데이트 피드 `latest.yml`과 완전히 분리**). - 설치 방법 두 가지: @@ -58,8 +60,9 @@ scoop install d3ro/d3ro-voice ``` 2. **수동 설치 스크립트** — `scripts/install/install-d3ro-voice.ps1` (피드에도 게시됨). - 볼륨을 내려받아 SHA-256 검증 → 결합 → 7-Zip으로 해제 → `%LOCALAPPDATA%\Programs\D3RO Voice` - 에 설치 + 시작 메뉴 바로가기. 관리자 권한 불필요, 7-Zip 필요. + zip 부품을 내려받아 부품별 SHA-256 검증 → 결합 → 결합본 SHA-256 재검증 → + `Expand-Archive`로 해제 → `%LOCALAPPDATA%\Programs\D3RO Voice`에 설치 + 시작 메뉴 바로가기. + 관리자 권한 불필요, 추가 도구 불필요(Windows 10/11 기본). ```powershell irm https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-latest/install-d3ro-voice.ps1 | iex ``` @@ -69,6 +72,10 @@ **안전 규칙(구현에 반영)**: - 볼륨은 불변 — 같은 버전 경로에 다른 바이트가 있으면 게시 중단. - 메타데이터(`portable.json`, 설치 스크립트)만 갱신 허용. +- 같은 버전을 다시 빌드하면 7z/zip 바이트가 달라져(내부 타임스탬프) 게시가 중단된다. + 즉 **한 버전의 portable 산출물은 한 번의 빌드에서만 나온다**. 태그 파이프라인(CI)이 최초 + 게시자가 되도록 하고, 로컬 재게시로 채널을 덮어쓰지 않는다. 이미 게시된 세트를 갱신해야 하면 + 버전을 올린다(이 문서 작성 시 `1.3.0` 부분 게시분을 `1.3.1`로 대체). - 파일명에 `-portable`을 넣어 서명된 릴리스 자산과 혼동되지 않게 한다. - `latest.yml`/`update-policy.json`은 절대 건드리지 않는다(자동 업데이트는 서명 릴리스 전용). @@ -95,11 +102,21 @@ --- -## 5. 검증 기록 (2026-09-18) +## 5. 현재 상태 (2026-09-18) + +- 게시된 채널: `portable-1.3.1` + `portable-latest` (7z 볼륨 2개, zip 부품 3개, 인덱스, 설치 스크립트). +- `portable-1.3.0`에는 7z 볼륨 2개만 있다(부분 게시, zip 부품 없음) — `1.3.1`이 대체한다. +- updater feed(`latest.yml`)는 여전히 `1.1.0`이며 **이 채널은 그것을 건드리지 않는다**. + +## 6. 검증 기록 (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 +- 재게시 시도 시 동일 볼륨은 "이미 동일한 파일" 로 건너뛰는 것 확인(불변 가드 동작). +- 1.3.1 수동 설치 스크립트를 **7-Zip 없이** end-to-end 실행: zip 부품 3개 다운로드 → + 부품별 SHA-256 → 결합(242.9MiB) → 결합본 SHA-256 → Expand-Archive → 설치 디렉토리에 + `D3RO Voice.exe`, `resources/sidecar/sidecar.exe`, VAD `silero_vad_v6.onnx`, `resources/sox/sox.exe` + 확인 → 시작 메뉴 바로가기 생성. (검증 후 테스트 설치/바로가기 제거) \ No newline at end of file diff --git a/docs/map/10-feature-catalog.md b/docs/map/10-feature-catalog.md index a6edc6c..6ee02cb 100644 --- a/docs/map/10-feature-catalog.md +++ b/docs/map/10-feature-catalog.md @@ -193,7 +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 게시 + 실제 설치 검증. | +| 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`; 7z 분할 볼륨(Scoop, 162MiB) + zip 분할 부품(수동 설치, 243MiB, 7-Zip 불필요); updater feed와 분리. 2026-09-18 `portable-1.3.1` 게시 + 실제 설치 검증. | --- diff --git a/package-lock.json b/package-lock.json index 3e672a8..fc196af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "d3ro-voice-monorepo", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "d3ro-voice-monorepo", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "workspaces": [ "apps/desktop", @@ -25,7 +25,7 @@ }, "apps/admin": { "name": "@d3ro/admin", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "@d3ro/api-client": "*", "@d3ro/core": "*", @@ -109,7 +109,7 @@ }, "apps/desktop": { "name": "@d3ro/desktop", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -157,7 +157,7 @@ }, "apps/web": { "name": "@d3ro/web", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "@d3ro/api-client": "*", "@d3ro/core": "*", @@ -16866,7 +16866,7 @@ }, "packages/api-client": { "name": "@d3ro/api-client", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -16883,7 +16883,7 @@ }, "packages/core": { "name": "@d3ro/core", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "docx": "^9.6.1" @@ -16894,7 +16894,7 @@ }, "packages/i18n": { "name": "@d3ro/i18n", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "devDependencies": { "@types/react": "^19.0.0" @@ -16905,7 +16905,7 @@ }, "packages/ui": { "name": "@d3ro/ui", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "@d3ro/core": "*" @@ -16925,7 +16925,7 @@ }, "packages/ui-native": { "name": "@d3ro/ui-native", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "devDependencies": { "@types/react": "*" diff --git a/package.json b/package.json index 1f02434..6109887 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "d3ro-voice-monorepo", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)", "author": "D3RO", diff --git a/packages/api-client/package.json b/packages/api-client/package.json index e56a903..2ec3d14 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/api-client", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)", "license": "MIT", diff --git a/packages/core/package.json b/packages/core/package.json index f337d21..0653bcb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/core", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸", "license": "MIT", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index d496837..9c9d457 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/i18n", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸", "license": "MIT", diff --git a/packages/ui-native/package.json b/packages/ui-native/package.json index 746c3ce..8ad0f52 100644 --- a/packages/ui-native/package.json +++ b/packages/ui-native/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/ui-native", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index ce578c6..c949d91 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/ui", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵", "license": "MIT", diff --git a/release/product-version.json b/release/product-version.json index ec8a3a1..941ec32 100644 --- a/release/product-version.json +++ b/release/product-version.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "1.3.0", - "androidVersionCode": 1030001, - "iosBuildNumber": 1030001, + "version": "1.3.1", + "androidVersionCode": 1031001, + "iosBuildNumber": 1031001, "releaseDate": "2026-09-18", "desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f" } diff --git a/scripts/ci/build-portable.mjs b/scripts/ci/build-portable.mjs index 1e3f81a..a1143fc 100644 --- a/scripts/ci/build-portable.mjs +++ b/scripts/ci/build-portable.mjs @@ -11,6 +11,9 @@ // apps/desktop/release//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`, diff --git a/scripts/ci/publish-portable-release.mjs b/scripts/ci/publish-portable-release.mjs index 88d3d2e..5fafdb0 100644 --- a/scripts/ci/publish-portable-release.mjs +++ b/scripts/ci/publish-portable-release.mjs @@ -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`, '', ' 참고: 이 채널은 서명이 없어 자동 업데이트 피드를 갱신하지 않습니다.', diff --git a/scripts/install/install-d3ro-voice.ps1 b/scripts/install/install-d3ro-voice.ps1 index c3892c5..fc6feee 100644 --- a/scripts/install/install-d3ro-voice.ps1 +++ b/scripts/install/install-d3ro-voice.ps1 @@ -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 \ No newline at end of file diff --git a/site/package-lock.json b/site/package-lock.json index 57d4b69..5f33c08 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -1,12 +1,12 @@ { "name": "d3ro-voice-site", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "d3ro-voice-site", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/site/package.json b/site/package.json index 1c5a97d..7aaef9d 100644 --- a/site/package.json +++ b/site/package.json @@ -1,7 +1,7 @@ { "name": "d3ro-voice-site", "private": true, - "version": "1.3.0", + "version": "1.3.1", "type": "module", "scripts": { "dev": "vite --port 5199 --host", From 0fbbbc1756a80bc9b4a0e20d787a22427e61d88e Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 18 Sep 2026 13:51:49 +0900 Subject: [PATCH 04/18] fix(release): restore automatic updates by shipping the speech engine on demand Auto-update could not work at all: the installer was 189 MB because it carried the local speech engine and ffmpeg, and the download feed rejects uploads over about 100 MiB, so update metadata could never be published. The installer now leaves those components out and the app fetches them the first time they are needed, verifying every part and the joined archive before installing. The installer is 90.6 MiB, the update feed is published again, and updates stay small because the engine is not re-sent on every release. The fetch is visible and recoverable: the download runs with progress, a failed install cleans up after itself, and Settings > STT shows the runtime status with a manual download action for when the automatic one cannot run. --- CHANGELOG.md | 13 + apps/admin-swagger/openapi.json | 2 +- apps/admin/package.json | 2 +- apps/api-server/D3ROVoice.Api.csproj | 2 +- apps/desktop/electron-builder.yml | 25 +- apps/desktop/package.json | 3 +- apps/desktop/src/main/ipc/stt-handlers.ts | 27 ++ .../main/services/FileTranscriptionService.ts | 13 + .../src/main/services/LocalSTTService.ts | 43 ++- .../src/main/services/RuntimeProvisioner.ts | 343 ++++++++++++++++++ apps/desktop/src/main/update-feed.ts | 11 + apps/desktop/src/main/utils/paths.ts | 47 ++- apps/desktop/src/preload/index.ts | 31 ++ .../src/renderer/components/STTTab.tsx | 95 +++++ apps/mobile-rn/android/app/build.gradle | 4 +- .../ios/D3ROVoice.xcodeproj/project.pbxproj | 8 +- .../android/en-US/changelogs/1031002.txt | 1 + .../android/ko-KR/changelogs/1031002.txt | 1 + apps/mobile-rn/package-lock.json | 14 +- apps/mobile-rn/package.json | 2 +- apps/web/package.json | 2 +- apps/web/src/components/layout/sidebar.tsx | 2 +- bucket/d3ro-voice.json | 11 +- docs/deployment/release-guide.md | 18 + docs/deployment/unsigned-distribution.md | 13 +- docs/map/10-feature-catalog.md | 1 + docs/map/11-gap-backlog.md | 5 +- memory/project_status.md | 33 ++ package-lock.json | 109 +++--- package.json | 6 +- packages/api-client/package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/ipc-channels.ts | 8 + packages/i18n/package.json | 2 +- packages/ui-native/package.json | 2 +- packages/ui/package.json | 2 +- release/product-version.json | 6 +- scripts/ci/build-portable.mjs | 90 +++++ scripts/ci/publish-portable-release.mjs | 40 ++ scripts/ci/publish-updater-release.mjs | 213 +++++++++++ site/package-lock.json | 4 +- site/package.json | 2 +- 42 files changed, 1137 insertions(+), 123 deletions(-) create mode 100644 apps/desktop/src/main/services/RuntimeProvisioner.ts create mode 100644 apps/mobile-rn/metadata/android/en-US/changelogs/1031002.txt create mode 100644 apps/mobile-rn/metadata/android/ko-KR/changelogs/1031002.txt create mode 100644 scripts/ci/publish-updater-release.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f86eb..9373919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cloud-optional backup (encrypted, opt-in) - Plugin system for custom pipelines +## [1.3.2] - 2026-09-18 + +### Changed +- **Auto-update works again**: the installer no longer carries the local speech engine + and ffmpeg. Bundling them pushed the installer to 189 MB, and the download feed sits + behind Cloudflare, which rejects uploads over ~100 MiB with HTTP 413 — so update + metadata could not be published at all. The installer is now 90.5 MiB and the update + feed is published again. +- **The app fetches its speech engine and ffmpeg when they are first needed**, verifying + every part and the joined archive by SHA-256 before installing them under the app data + folder. Updates stay small (the engine is not re-downloaded on every release), and the + Settings > STT tab shows the runtime status with a manual download action. + ## [1.3.1] - 2026-09-18 ### Fixed diff --git a/apps/admin-swagger/openapi.json b/apps/admin-swagger/openapi.json index ef5d178..8dce225 100644 --- a/apps/admin-swagger/openapi.json +++ b/apps/admin-swagger/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "D3RO-VOICE Admin API", "description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.", - "version": "1.3.1" + "version": "1.3.2" }, "servers": [ { diff --git a/apps/admin/package.json b/apps/admin/package.json index 78c4008..e2a9b30 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/admin", - "version": "1.3.1", + "version": "1.3.2", "private": true, "description": "D3RO Voice Admin CRM — SaaS 관리 도구", "scripts": { diff --git a/apps/api-server/D3ROVoice.Api.csproj b/apps/api-server/D3ROVoice.Api.csproj index 5cb08ba..aeb40ea 100644 --- a/apps/api-server/D3ROVoice.Api.csproj +++ b/apps/api-server/D3ROVoice.Api.csproj @@ -2,7 +2,7 @@ net10.0 - 1.3.1 + 1.3.2 enable enable diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 290de60..8985bbb 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -4,6 +4,10 @@ copyright: Copyright © 2026 D3RO # monorepo(npm workspaces)에서 electron이 루트 node_modules로 호이스팅되어 # 자동 감지가 실패하는 문제를 피하려고 명시적으로 버전 고정. electronVersion: "33.4.11" +# 설치 크기 절감 — 지원 로케일만 포함한다 +electronLanguages: + - ko + - en-US directories: # buildResources를 build/로 지정 — resources/icons/가 비어있어 아이콘 자동 스캔이 실패하는 것을 우회. @@ -14,6 +18,8 @@ directories: files: - out/**/* - "!out/**/*.map" + # ffmpeg 정적 바이너리(61MB)는 설치본에 넣지 않는다 — 필요할 때 런타임으로 내려받는다 + - "!node_modules/@ffmpeg-installer/**" # ──────────────────────────────────────────────────────────────────── # 자동 업데이트 feed — 이 설정이 있어야 electron-builder가 @@ -36,8 +42,6 @@ asarUnpack: - "node_modules/better-sqlite3/**" - "node_modules/uiohook-napi/**" - "node_modules/@nut-tree-fork/**" - # ffmpeg 정적 바이너리는 실행 파일이므로 asar 내부에서 spawn할 수 없다 - - "node_modules/@ffmpeg-installer/**" # ──────────────────────────────────────────────────────────────────── # Windows @@ -115,20 +119,11 @@ extraResources: - from: resources/sox/ to: sox/ - # faster-whisper STT 사이드카 (PyInstaller onedir: sidecar.exe + _internal/). - # 반드시 존재해야 한다. 누락되면 로컬 전사가 전혀 동작하지 않는다. - # 빌드: npm --prefix apps/desktop run sidecar:build - # electron-builder는 이 트리를 재귀로 복사한다(_internal 포함). - # 서명 검증에 실패하면 복사가 중간에 끊겨 _internal이 빠지므로, - # 서명 없이 로컬 검증할 때는 -c.win.forceCodeSigning=false 를 사용한다. - - from: sidecar-dist/sidecar/ - to: sidecar/ - filter: - - "**/*" + # 주의: 로컬 AI 런타임(사이드카 엔진, ffmpeg)은 여기에 넣지 않는다. + # 포함하면 설치본이 Cloudflare 업로드 한도(100MiB)를 넘어 자동 업데이트 메타데이터를 + # 게시할 수 없다(실측: 엔진 포함 189MB vs 엔진 제외 90.5MiB). 런타임은 처음 필요할 때 + # RuntimeProvisioner가 feed에서 내려받아 검증한 뒤 해제해 설치한다. - # ffmpeg (파일 전사/미디어 변환용). CI가 resources/ffmpeg/에 배치한다. - - from: resources/ffmpeg/ - to: ffmpeg/ # Ollama 바이너리 (포터블 zip을 사전 배치) - from: resources/ollama/ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 26006f5..3806fbd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/desktop", - "version": "1.3.1", + "version": "1.3.2", "productName": "d3ro-voice", "description": "로컬 AI 음성 어시스턴트 (Electron)", "main": "./out/main/index.js", @@ -65,6 +65,7 @@ "react-dom": "^19.0.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "tar": "^7.5.13", "uiohook-napi": "^1.5.5" }, "optionalDependencies": { diff --git a/apps/desktop/src/main/ipc/stt-handlers.ts b/apps/desktop/src/main/ipc/stt-handlers.ts index 1daf232..62c9711 100644 --- a/apps/desktop/src/main/ipc/stt-handlers.ts +++ b/apps/desktop/src/main/ipc/stt-handlers.ts @@ -5,6 +5,8 @@ import { ipcMain } from 'electron' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors' import { getLocalSTTService } from '../services/LocalSTTService' +import { getRuntimeProvisioner } from '../services/RuntimeProvisioner' +import type { RuntimeComponent } from '../services/RuntimeProvisioner' import { getSTTManager } from '../services/stt/STTManager' import { configGet, configSet } from '../services/ConfigService' import { getMainWindow } from '../windows/WindowManager' @@ -26,6 +28,31 @@ function safeSendToRenderer(channel: string, data: unknown): void { } export function registerSTTHandlers(): void { + // 로컬 AI 런타임(진/ffmpeg) 진행률 — 필요할 때 자동으로 내려받는다 + getRuntimeProvisioner().on('progress', (payload) => { + safeSendToRenderer(IPC_CHANNELS.RUNTIME.PROGRESS, payload) + }) + + ipcMain.handle(IPC_CHANNELS.RUNTIME.GET_STATUS, () => { + try { + return ipcSuccess(getRuntimeProvisioner().getStatus()) + } catch (error) { + return ipcError(error, ErrorCode.ConfigReadFailed) + } + }) + + ipcMain.handle(IPC_CHANNELS.RUNTIME.ENSURE, async (_event, params: { component: RuntimeComponent }) => { + try { + const component = params?.component + if (component !== 'sidecar' && component !== 'ffmpeg') { + throw new D3ROError(ErrorCode.ConfigInvalidValue, `알 수 없는 런타임 구성 요소: ${String(component)}`) + } + const binaryPath = await getRuntimeProvisioner().ensure(component) + return ipcSuccess({ component, binaryPath }) + } catch (error) { + return ipcError(error, ErrorCode.STTSidecarSpawnFailed) + } + }) getLocalSTTService().on('download-progress', (payload) => { safeSendToRenderer(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, payload) }) diff --git a/apps/desktop/src/main/services/FileTranscriptionService.ts b/apps/desktop/src/main/services/FileTranscriptionService.ts index 85341f7..40fe8ef 100644 --- a/apps/desktop/src/main/services/FileTranscriptionService.ts +++ b/apps/desktop/src/main/services/FileTranscriptionService.ts @@ -11,6 +11,7 @@ import { getSTTManager } from './stt/STTManager' import { getHistoryService } from './HistoryService' import { configGet } from './ConfigService' import { getFfmpegPath } from '../utils/paths' +import { getRuntimeProvisioner } from './RuntimeProvisioner' import { getMainWindow } from '../windows/WindowManager' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { D3ROError, ErrorCode } from '@d3ro/core/errors' @@ -250,6 +251,18 @@ class FileTranscriptionService extends EventEmitter { } } + /** + * ffmpeg 실행 파일을 확보한다. 설치본에는 ffmpeg을 넣지 않으므로 + * 없으면 feed에서 내려받는다(파일 전사/회의 모드에서만 필요). + */ + private async _ensureFfmpeg(): Promise { + const resolved = getFfmpegPath() + if (resolved !== 'ffmpeg') return resolved + + logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다') + return getRuntimeProvisioner().ensure('ffmpeg') + } + /** * ffmpeg로 미디어 파일을 PCM16 16kHz mono WAV로 변환 */ diff --git a/apps/desktop/src/main/services/LocalSTTService.ts b/apps/desktop/src/main/services/LocalSTTService.ts index a52227f..3f72185 100644 --- a/apps/desktop/src/main/services/LocalSTTService.ts +++ b/apps/desktop/src/main/services/LocalSTTService.ts @@ -11,6 +11,7 @@ import { join } from 'path' import { getLogger } from './LoggerService' import { configGet } from './ConfigService' import { getSidecarCommand, getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths' +import { getRuntimeProvisioner } from './RuntimeProvisioner' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { STTModel, @@ -101,6 +102,15 @@ export interface LocalSTTEvents { 'transcription-complete': { result: TranscriptionResult } 'model-loaded': { model: STTModel; loadTimeMs: number } 'download-progress': DownloadProgressEvent + /** 런타임(엔진/ffmpeg) 내려받기 진행률 — 필요할 때 자동 설치 */ + 'runtime-progress': { + component: string + phase: 'index' | 'downloading' | 'extracting' | 'done' + percent: number + downloadedBytes: number + totalBytes: number + bytesPerSecond: number + } 'error': { error: D3ROError } } @@ -183,6 +193,10 @@ class LocalSTTService extends EventEmitter { // (실측: sidecar crash 루프 중 ERR_UNHANDLED_ERROR). 기본 sink로 방지 — // 실제 로깅은 _emitError에서 수행. this.on('error', () => { /* default sink */ }) + // 런타임 내려받기 진행률을 그대로 중계한다 (IPC가 renderer로 전달) + getRuntimeProvisioner().on('progress', (payload) => { + this.emit('runtime-progress', payload) + }) } private _state: STTState = STTState.Uninitialized @@ -638,8 +652,8 @@ class LocalSTTService extends EventEmitter { // dev mode HMR/재시작으로 이전 sidecar가 orphan으로 남아있을 수 있음. this._port = await this._findFreePort(SIDECAR_PORT, 20) - // 번들/venv 경로가 깨졌으면 여기서 즉시 실패한다 (조용한 PATH 폴백 금지). - const launch = getSidecarCommand() + // 설치본에는 엔진이 없다 — 없으면 여기서 feed에서 내려받고 산다. dev는 venv/번들 경로를 쓴다. + const launch = await this._resolveSidecarLaunch() const fullArgs = [ ...launch.args, '--port', @@ -726,6 +740,31 @@ class LocalSTTService extends EventEmitter { consume(child.stderr, (message) => sidecarLogger.warn(message)) } + /** + * 사이드카 실행 방법을 결정한다. + * 설치본에서 엔진이 아직 없으면 feed에서 내려받아 설치한 뒤 경로를 돌려준다. + * 진행률은 runtime-progress 이벤트로 노출된다. + */ + private async _resolveSidecarLaunch(): Promise<{ + command: string + args: string[] + source: 'bundled' | 'provisioned' | 'venv' | 'python' + }> { + try { + return getSidecarCommand() + } catch (err) { + const needsInstall = + err instanceof D3ROError && err.code === ErrorCode.STTEngineNotInstalled + if (!needsInstall) throw err + } + + logger.info('로컬 음성 엔진이 없습니다 — 자동 다운로드를 시작합니다') + await getRuntimeProvisioner().ensure('sidecar') + const launch = getSidecarCommand() + logger.info(`런타임 설치 후 사이드카 경로: ${launch.command} (${launch.source})`) + return launch + } + /** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */ private _spawnFailureError( err: Error, diff --git a/apps/desktop/src/main/services/RuntimeProvisioner.ts b/apps/desktop/src/main/services/RuntimeProvisioner.ts new file mode 100644 index 0000000..923a3c6 --- /dev/null +++ b/apps/desktop/src/main/services/RuntimeProvisioner.ts @@ -0,0 +1,343 @@ +// src/main/services/RuntimeProvisioner.ts +// 로컬 AI 타임(사이드카 엔진 / ffmpeg)을 설치 시점이 아니라 "필요할 때" 내려받는다. +// +// 왜: 사이드카(242MB)를 설치본에 넣으면 NSIS가 189MB가 되어 canonical feed의 업로드 +// 한도(Cloudflare 100MiB)를 넘고, 그 결과 자동 업데이트(latest.yml)를 갱신할 수 없다. +// 엔진을 분리하면 설치본이 90MiB대로 내려가 updater가 정상 동작하고, 업데이트마다 +// 162MB를 다시 받지 않아도 된다. +// +// 안전: +// - 부품별 SHA-256 + 결합본 SHA-256을 모두 검증한 뒤에만 설치한다. +// - tar 경로 탈출(..) 항목은 건너뛴다. +// - 실패하면 부분 다운로드를 지우고 기존 설치를 건드리지 않는다. + +import { EventEmitter, once } from 'events' +import { createHash } from 'node:crypto' +import { createReadStream, createWriteStream, existsSync, statSync } from 'node:fs' +import { mkdir, rm } from 'node:fs/promises' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { join } from 'node:path' +import { app } from 'electron' +import * as tar from 'tar' +import { getLogger } from './LoggerService' +import { D3ROError, ErrorCode } from '@d3ro/core/errors' +import { RUNTIME_FEED_URL } from '../update-feed' + +const logger = getLogger('RuntimeProvisioner') + +/** 이 내려받아야 하는 런타임 구성 요소 */ +export type RuntimeComponent = 'sidecar' | 'ffmpeg' + +export const RUNTIME_COMPONENTS: readonly RuntimeComponent[] = ['sidecar', 'ffmpeg'] + +interface RuntimePart { + name: string + size: number + sha256: string + url: string +} + +interface RuntimeComponentIndex { + archive: string + sha256: string + totalSize: number + parts: RuntimePart[] +} + +interface RuntimeIndex { + schemaVersion: number + version: string + components: Record +} + +export interface RuntimeProgressEvent { + component: RuntimeComponent + phase: 'index' | 'downloading' | 'extracting' | 'done' + percent: number + downloadedBytes: number + totalBytes: number + bytesPerSecond: number +} + +export interface RuntimeStatus { + component: RuntimeComponent + installed: boolean + path: string + sizeBytes: number +} + +const RUNTIME_DIR_NAME = 'runtime' +const DOWNLOAD_TIMEOUT_MS = 120_000 + +class RuntimeProvisioner extends EventEmitter { + constructor() { + super() + this.on('error', () => { + /* 기본 sink — EventEmitter 'error' 미처리 예외 방지 */ + }) + } + + private _inFlight = new Map>() + + /** 설치된 런타임 트 (%APPDATA%/d3ro-voice/runtime/) */ + componentDir(component: RuntimeComponent): string { + return join(app.getPath('userData'), RUNTIME_DIR_NAME, component) + } + + /** 구성 요소 실행 파일 경로 (설치 여부와 무관하게 경로만 계산) */ + binaryPath(component: RuntimeComponent): string { + const dir = this.componentDir(component) + if (component === 'sidecar') { + return join(dir, process.platform === 'win32' ? 'sidecar.exe' : 'sidecar') + } + return join(dir, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg') + } + + isInstalled(component: RuntimeComponent): boolean { + const binary = this.binaryPath(component) + if (!existsSync(binary)) return false + if (component === 'sidecar' && !existsSync(join(this.componentDir('sidecar'), '_internal'))) { + // PyInstaller onedir은 _internal 없이는 동작하지 않는다 (부분 설치 방어) + return false + } + return true + } + + getStatus(): RuntimeStatus[] { + return RUNTIME_COMPONENTS.map((component) => { + const binary = this.binaryPath(component) + let sizeBytes = 0 + try { + sizeBytes = existsSync(binary) ? statSync(binary).size : 0 + } catch { + sizeBytes = 0 + } + return { + component, + installed: this.isInstalled(component), + path: binary, + sizeBytes, + } + }) + } + + /** + * 구성 요소가 설치되어 있으면 경로를, 없으면 내려받아 설치한 뒤 경로를 돌려준다. + * 동시 호출은 같은 작업을 공유한다. + */ + async ensure(component: RuntimeComponent): Promise { + if (this.isInstalled(component)) { + return this.binaryPath(component) + } + + const existing = this._inFlight.get(component) + if (existing) { + logger.debug(`런타임 설치 진행 중 — 기존 작업에 합류: ${component}`) + return existing + } + + const task = this._install(component).finally(() => { + this._inFlight.delete(component) + }) + this._inFlight.set(component, task) + return task + } + + private async _install(component: RuntimeComponent): Promise { + const started = Date.now() + logger.info(`런타임 설치 시작: ${component}`) + this._emitProgress(component, 'index', 0, 0, 0, 0) + + const index = await this._fetchIndex() + const entry = index.components[component] + if (!entry) { + throw new D3ROError( + ErrorCode.ConfigReadFailed, + `런타임 인덱스에 ${component} 구성 요소가 없습니다 (version=${index.version})`, + ) + } + + const targetDir = this.componentDir(component) + const tempDir = join(app.getPath('userData'), RUNTIME_DIR_NAME, `.download-${component}`) + await rm(tempDir, { recursive: true, force: true }) + await mkdir(tempDir, { recursive: true }) + + try { + const archivePath = join(tempDir, entry.archive) + await this._downloadParts(component, entry, tempDir, archivePath) + + if (component === 'sidecar' || component === 'ffmpeg') { + // 기존 설치를 지우고 새로 배치한다 (부분 상태 방지: 먼저 temp에 풀고 검증 후 교체) + await rm(targetDir, { recursive: true, force: true }) + await mkdir(targetDir, { recursive: true }) + } + + this._emitProgress(component, 'extracting', 100, entry.totalSize, entry.totalSize, 0) + await tar.x({ + file: archivePath, + cwd: targetDir, + // 경로 탈출 항목은 건너뛴다 + filter: (path) => !path.split('/').includes('..'), + }) + + if (!this.isInstalled(component)) { + throw new D3ROError( + ErrorCode.STTSidecarSpawnFailed, + `런타임 설치 후 실행 파일을 찾을 수 없습니다: ${this.binaryPath(component)}`, + ) + } + + this._emitProgress(component, 'done', 100, entry.totalSize, entry.totalSize, 0) + logger.info( + `런타임 설치 완료: ${component} (${(entry.totalSize / 1048576).toFixed(1)}MiB, ${Date.now() - started}ms)`, + ) + return this.binaryPath(component) + } catch (err) { + // 실패 시 부분 산출물 정리 — 반쯤 풀린 설치를 남기지 않는다 + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + if (!this.isInstalled(component)) { + await rm(targetDir, { recursive: true, force: true }).catch(() => undefined) + } + const message = err instanceof Error ? err.message : String(err) + logger.error(`런타임 설치 실패: ${component} — ${message}`) + if (err instanceof D3ROError) throw err + throw new D3ROError( + ErrorCode.STTSidecarSpawnFailed, + `런타임 설치 실패(${component}): ${message}`, + ) + } finally { + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + } + } + + private async _fetchIndex(): Promise { + if (!RUNTIME_FEED_URL) { + throw new D3ROError( + ErrorCode.ConfigReadFailed, + '런타임 feed가 설정되지 않았습니다 (자동 업데이트 비활성 상태)', + ) + } + const url = `${RUNTIME_FEED_URL}/runtime.json` + const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }) + if (!response.ok) { + throw new D3ROError( + ErrorCode.ConfigReadFailed, + `런타임 인덱스를 받을 수 없습니다 (HTTP ${response.status}): ${url}`, + ) + } + const index = (await response.json()) as RuntimeIndex + if (!index?.components) { + throw new D3ROError(ErrorCode.ConfigReadFailed, '런타임 인덱스 형식이 올바르지 않습니다') + } + return index + } + + private async _downloadParts( + component: RuntimeComponent, + entry: RuntimeComponentIndex, + tempDir: string, + archivePath: string, + ): Promise { + const totalBytes = entry.totalSize > 0 + ? entry.totalSize + : entry.parts.reduce((sum, part) => sum + part.size, 0) + + let downloadedBytes = 0 + const startedAt = Date.now() + + for (const part of entry.parts) { + const partPath = join(tempDir, part.name) + const response = await fetch(part.url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }) + if (!response.ok || !response.body) { + throw new D3ROError( + ErrorCode.STTSidecarSpawnFailed, + `런타임 부품을 받을 수 없습니다 (HTTP ${response.status}): ${part.name}`, + ) + } + + const hash = createHash('sha256') + let partBytes = 0 + const source = Readable.fromWeb(response.body as never) + source.on('data', (chunk: Buffer) => { + hash.update(chunk) + partBytes += chunk.length + downloadedBytes += chunk.length + const elapsed = Math.max(0.001, (Date.now() - startedAt) / 1000) + this._emitProgress( + component, + 'downloading', + totalBytes > 0 ? Math.min(100, Math.round((downloadedBytes * 100) / totalBytes)) : 0, + downloadedBytes, + totalBytes, + Math.round(downloadedBytes / elapsed), + ) + }) + + await pipeline(source, createWriteStream(partPath)) + + const actual = hash.digest('hex') + if (part.sha256 && actual !== part.sha256) { + throw new D3ROError( + ErrorCode.STTSidecarSpawnFailed, + `런타임 부품 해시 불일치 (${part.name})`, + ) + } + if (partBytes !== part.size) { + throw new D3ROError( + ErrorCode.STTSidecarSpawnFailed, + `런타임 부품 크기 불일치 (${part.name}: ${partBytes} != ${part.size})`, + ) + } + } + + // 부품을 순서대로 이어 인다 (스트리밍 — 메모리에 통째로 올리지 않는다) + const archiveHash = createHash('sha256') + const archiveStream = createWriteStream(archivePath) + for (const part of entry.parts) { + const source = createReadStream(join(tempDir, part.name)) + source.on('data', (chunk: string | Buffer) => archiveHash.update(chunk)) + await pipeline(source, archiveStream, { end: false }) + } + archiveStream.end() + await once(archiveStream, 'finish') + const actualArchive = archiveHash.digest('hex') + if (entry.sha256 && actualArchive !== entry.sha256) { + throw new D3ROError( + ErrorCode.STTSidecarSpawnFailed, + `런타임 아카이브 해시 불일치 (${component})`, + ) + } + } + + private _emitProgress( + component: RuntimeComponent, + phase: RuntimeProgressEvent['phase'], + percent: number, + downloadedBytes: number, + totalBytes: number, + bytesPerSecond: number, + ): void { + this.emit('progress', { + component, + phase, + percent, + downloadedBytes, + totalBytes, + bytesPerSecond, + } satisfies RuntimeProgressEvent) + } +} + +let _instance: RuntimeProvisioner | null = null + +export function getRuntimeProvisioner(): RuntimeProvisioner { + if (!_instance) { + _instance = new RuntimeProvisioner() + } + return _instance +} + +export function resetRuntimeProvisionerForTests(): void { + _instance = null +} \ No newline at end of file diff --git a/apps/desktop/src/main/update-feed.ts b/apps/desktop/src/main/update-feed.ts index 484872f..72b6ea6 100644 --- a/apps/desktop/src/main/update-feed.ts +++ b/apps/desktop/src/main/update-feed.ts @@ -16,6 +16,17 @@ export const UPDATE_FEED_URL = // GitLab Generic Registry legacy mirror. 2026-08 이전 설치본(0.2.1-alpha)은 // 이 feed를 폴링하므로, 새 설치자가 Forgejo feed를 내장할 때까지 publisher가 // 함께 게시한다. 마이그레이션 완료 후 제거 가능. 런타임은 참조하지 않는다. +/** + * 로컬 AI 런타임(사이드카 엔진 / ffmpeg) 배포 위치. + * 진을 설치본에 넣으면 installer가 Cloudflare 업로드 한도(100MiB)를 넘어 업데이트 + * 메타데이터(latest.yml)를 게시할 수 없다. 그래서 런타임은 별도 경로에서 필요할 때 받는다. + * 자동 업데이트 채널과 분리된 경로이며 서명이 필요 없다. + */ +export const RUNTIME_FEED_URL = UPDATE_FEED_URL.replace(/\/latest$/, '/runtime-latest') + +/** 런타임 인덱스 파일명 (feed 루트) */ +export const RUNTIME_INDEX_FILENAME = 'runtime.json' + export const LEGACY_UPDATE_FEED_URL = 'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest' diff --git a/apps/desktop/src/main/utils/paths.ts b/apps/desktop/src/main/utils/paths.ts index 958c782..e60e4f7 100644 --- a/apps/desktop/src/main/utils/paths.ts +++ b/apps/desktop/src/main/utils/paths.ts @@ -86,6 +86,31 @@ function packagedResourcePath(...segments: string[]): string { return path.join(process.resourcesPath, ...segments) } +/** + * 설치 후 내려받은 런타임(사이드카 엔진 / ffmpeg) 설치 위치. + * RuntimeProvisioner가 여기에 배치하고, 이 파일은 경로만 계산한다. + */ +export function getProvisionedRuntimeDir(): string { + return path.join(app.getPath('userData'), 'runtime') +} + +function provisionedBinary(component: 'sidecar' | 'ffmpeg'): string { + const name = component === 'sidecar' ? `sidecar${EXE_SUFFIX}` : `ffmpeg${EXE_SUFFIX}` + return path.join(getProvisionedRuntimeDir(), component, name) +} + +/** 내려받은 사이드카 실행 파일 경로 (없으면 null) */ +export function getProvisionedSidecarPath(): string | null { + const candidate = provisionedBinary('sidecar') + return existsSync(candidate) ? candidate : null +} + +/** 내려받은 ffmpeg 실행 파일 경로 (없으면 null) */ +export function getProvisionedFfmpegPath(): string | null { + const candidate = provisionedBinary('ffmpeg') + return existsSync(candidate) ? candidate : null +} + let cachedSoxPath: string | undefined /** @@ -138,7 +163,7 @@ export interface SidecarLaunch { command: string args: string[] /** 어디에서 결정되었는지 (로그/진단용) */ - source: 'bundled' | 'venv' | 'python' + source: 'bundled' | 'provisioned' | 'venv' | 'python' } /** @@ -154,11 +179,16 @@ export function getSidecarCommand(): SidecarLaunch { if (existsSync(exePath)) { return { command: exePath, args: [], source: 'bundled' } } + + // 설치본에는 엔진을 넣지 않는다(업데이트 게시 크기 한도). 필요할 때 내려받은 경로를 쓴다. + const provisioned = getProvisionedSidecarPath() + if (provisioned) { + return { command: provisioned, args: [], source: 'provisioned' } + } + throw new D3ROError( - ErrorCode.STTSidecarSpawnFailed, - `번들된 STT 사이드카를 찾을 수 없습니다: ${exePath}. ` + - '설치 패키지에 sidecar 리소스가 누락되었습니다(로컬 전사 불가). ' + - '앱을 다시 설치하거나 개발 모드에서 `npm run sidecar:build`로 빌드하세요.', + ErrorCode.STTEngineNotInstalled, + '로컬 음성 엔진이 아직 설치되지 않았습니다. 설정 > STT에서 "엔진 다운로드"를 실행하세요.', ) } @@ -185,7 +215,6 @@ export function getSidecarCommand(): SidecarLaunch { const pythonCmd = process.platform === 'win32' ? 'python' : 'python3' return { command: pythonCmd, args: [sidecarPath], source: 'python' } } - /** STT 사이드카 HTTP 기본 URL. sidecar는 IPv4 프백에만 바인딩한다. */ export function getSidecarBaseUrl(port: number): string { return loopbackUrl(port) @@ -232,6 +261,12 @@ export function getFfmpegPath(): string { return bundled } + // 설치본에서는 ffmpeg도 필요할 때 내려받는다 (설치/업데이트 크기 절감) + const provisionedFfmpeg = getProvisionedFfmpegPath() + if (provisionedFfmpeg) { + return provisionedFfmpeg + } + try { // eslint-disable-next-line @typescript-eslint/no-require-imports const installer = require('@ffmpeg-installer/ffmpeg') as { path?: string } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b1ec420..788bbbf 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -180,6 +180,25 @@ import type { import { Feature } from '@d3ro/core/types' import type { IPCResult } from '@d3ro/core/errors' +/** 로컬 AI 런타임(사이드카 엔진/ffmpeg) 상태 — RuntimeProvisioner가 제공한다 */ +type RuntimeComponentName = 'sidecar' | 'ffmpeg' + +interface RuntimeStatusPayload { + component: RuntimeComponentName + installed: boolean + path: string + sizeBytes: number +} + +interface RuntimeProgressPayload { + component: RuntimeComponentName + phase: 'index' | 'downloading' | 'extracting' | 'done' + percent: number + downloadedBytes: number + totalBytes: number + bytesPerSecond: number +} + type Unsubscribe = () => void function invoke(channel: string, ...args: unknown[]): Promise> { @@ -284,6 +303,18 @@ const electronAPI = { on(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, cb) }, + // ── Local AI runtime (엔진/ffmpeg — 필요할 때 내려받음) ── + runtime: { + getStatus: () => invoke(IPC_CHANNELS.RUNTIME.GET_STATUS), + ensure: (params: { component: RuntimeComponentName }) => + invoke<{ component: RuntimeComponentName; binaryPath: string }>( + IPC_CHANNELS.RUNTIME.ENSURE, + params, + ), + onProgress: (cb: (e: RuntimeProgressPayload) => void): Unsubscribe => + on(IPC_CHANNELS.RUNTIME.PROGRESS, cb) + }, + // ── Hotkey ───────────────────────────────────────────── hotkey: { getDictationShortcut: () => diff --git a/apps/desktop/src/renderer/components/STTTab.tsx b/apps/desktop/src/renderer/components/STTTab.tsx index da7117d..3c2f53e 100644 --- a/apps/desktop/src/renderer/components/STTTab.tsx +++ b/apps/desktop/src/renderer/components/STTTab.tsx @@ -69,6 +69,18 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen const [downloadingModelId, setDownloadingModelId] = useState(null) const [downloadPercent, setDownloadPercent] = useState(0) + // 로컬 AI 런타임(사이드카 엔진/ffmpeg) — 설치본에는 없고 필요할 때 내려받는다 + type RuntimeComponentName = 'sidecar' | 'ffmpeg' + interface RuntimeStatusRow { + component: RuntimeComponentName + installed: boolean + path: string + sizeBytes: number + } + const [runtimeStatus, setRuntimeStatus] = useState([]) + const [runtimeBusy, setRuntimeBusy] = useState(null) + const [runtimePercent, setRuntimePercent] = useState(0) + // Test connection state const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState<{ success: boolean; latencyMs: number; message: string } | null>(null) @@ -96,9 +108,30 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen } }) + const loadRuntime = () => { + window.electronAPI.runtime.getStatus().then((res) => { + if (res.success && res.data) setRuntimeStatus(res.data) + }) + } + loadRuntime() + + const unsubRuntime = window.electronAPI.runtime.onProgress((e) => { + if (e.component !== 'sidecar' && e.component !== 'ffmpeg') return + if (e.phase === 'done') { + setRuntimeBusy(null) + setRuntimePercent(100) + loadRuntime() + return + } + setRuntimeBusy(e.component) + setRuntimePercent(e.percent) + }) + return () => { unsubDownload() } + unsubDownload() + unsubRuntime() }, []) // Load specific provider config when activeProvider changes @@ -133,6 +166,19 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen [activeProvider, providerConfig] ) + const handleEnsureRuntime = useCallback(async (component: RuntimeComponentName) => { + setRuntimeBusy(component) + setRuntimePercent(0) + try { + const res = await window.electronAPI.runtime.ensure({ component }) + if (!res.success) setRuntimePercent(0) + } finally { + setRuntimeBusy(null) + const status = await window.electronAPI.runtime.getStatus() + if (status.success && status.data) setRuntimeStatus(status.data) + } + }, []) + const handleTestConnection = useCallback(async () => { setTesting(true) setTestResult(null) @@ -364,6 +410,55 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen } return null })()} + + {/* 로컬 AI 런타임(엔진/ffmpeg): 설치본에는 없고 처음 필요할 때 내려받는다 */} + {runtimeStatus.map((row) => { + const busy = runtimeBusy === row.component + return ( + + + + {row.component === 'sidecar' + ? '로컬 음성 엔진 (faster-whisper)' + : '미디어 변환기 (ffmpeg)'} + + + {row.installed + ? `설치됨 · ${Math.round(row.sizeBytes / 1_000_000)} MB` + : busy + ? `다운로드 중 (${runtimePercent}%)` + : '설치되지 않음 — 로컬 전사에 필요합니다'} + + + {busy ? ( + + ) : ( + + )} + + ) + })} ) : ( /* Cloud STT (OpenAI, Groq, Deepgram, AssemblyAI, Google, Custom) 설정 */ diff --git a/apps/mobile-rn/android/app/build.gradle b/apps/mobile-rn/android/app/build.gradle index 43fde46..b344ba1 100644 --- a/apps/mobile-rn/android/app/build.gradle +++ b/apps/mobile-rn/android/app/build.gradle @@ -151,8 +151,8 @@ def versionSettingsValid = configuredVersionName != null && configuredVersionName ==~ strictSemver && configuredVersionCodeValue != null && configuredVersionCodeValue <= 2100000000L -def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.1" -def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031001 +def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.2" +def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031002 def requiredReleaseSettings = [ D3RO_RELEASE_STORE_FILE: releaseStoreFilePath, diff --git a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj index fb6b4d2..3d87074 100644 --- a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj +++ b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj @@ -257,7 +257,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1031001; + CURRENT_PROJECT_VERSION = 1031002; ENABLE_BITCODE = NO; INFOPLIST_FILE = D3ROVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -265,7 +265,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.3.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -287,14 +287,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1031001; + CURRENT_PROJECT_VERSION = 1031002; INFOPLIST_FILE = D3ROVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.3.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1031002.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1031002.txt new file mode 100644 index 0000000..cd36bea --- /dev/null +++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1031002.txt @@ -0,0 +1 @@ +Reworked the install layout so updates flow again; required components are verified and fetched only when first needed. diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031002.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031002.txt new file mode 100644 index 0000000..ce9e668 --- /dev/null +++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031002.txt @@ -0,0 +1 @@ +업데이트가 정상 동작하도록 설치 구조를 정리했습니다. 필요한 기능은 처음 사용할 때 검증하여 내려받습니다. diff --git a/apps/mobile-rn/package-lock.json b/apps/mobile-rn/package-lock.json index 713eaad..465b152 100644 --- a/apps/mobile-rn/package-lock.json +++ b/apps/mobile-rn/package-lock.json @@ -1,12 +1,12 @@ { "name": "@d3ro/mobile-rn", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@d3ro/mobile-rn", - "version": "1.3.1", + "version": "1.3.2", "dependencies": { "@d3ro/api-client": "file:../../packages/api-client", "@d3ro/core": "file:../../packages/core", @@ -62,7 +62,7 @@ }, "../..": { "name": "d3ro-voice-monorepo", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "workspaces": [ "apps/desktop", @@ -81,7 +81,7 @@ }, "../../packages/api-client": { "name": "@d3ro/api-client", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -98,7 +98,7 @@ }, "../../packages/core": { "name": "@d3ro/core", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "dependencies": { "docx": "^9.6.1" @@ -109,7 +109,7 @@ }, "../../packages/i18n": { "name": "@d3ro/i18n", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "devDependencies": { "@types/react": "^19.0.0" @@ -120,7 +120,7 @@ }, "../../packages/ui-native": { "name": "@d3ro/ui-native", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "devDependencies": { "@types/react": "*" diff --git a/apps/mobile-rn/package.json b/apps/mobile-rn/package.json index dc24dfb..f86ab26 100644 --- a/apps/mobile-rn/package.json +++ b/apps/mobile-rn/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/mobile-rn", - "version": "1.3.1", + "version": "1.3.2", "private": true, "scripts": { "android": "react-native run-android", diff --git a/apps/web/package.json b/apps/web/package.json index 85e5343..7b715a4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/web", - "version": "1.3.1", + "version": "1.3.2", "private": true, "description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스", "scripts": { diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 255994d..83bb17b 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -139,7 +139,7 @@ export function Sidebar(): React.ReactElement { - v1.3.1 + v1.3.2 diff --git a/bucket/d3ro-voice.json b/bucket/d3ro-voice.json index f09fb46..740685d 100644 --- a/bucket/d3ro-voice.json +++ b/bucket/d3ro-voice.json @@ -1,17 +1,15 @@ { - "version": "1.3.1", + "version": "1.3.2", "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.1/D3RO-Voice-1.3.1-x64-portable.7z.001", - "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.1/D3RO-Voice-1.3.1-x64-portable.7z.002" + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.2/D3RO-Voice-1.3.2-x64-portable.7z.001" ], "hash": [ - "917f9d6e078517b6c924c30d26b59cf530d8c69e4adbdf679d2196bc645fa993", - "06f32ca809fed42cda9900e04a8e50da42506ed71944462314ecef40773a3197" + "62c1fab862c542839cd64c795e5714fda3e5332f0be22a051137f42ceb400324" ] } }, @@ -29,8 +27,7 @@ "architecture": { "64bit": { "url": [ - "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.1-x64-portable.7z.001", - "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.1-x64-portable.7z.002" + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.2-x64-portable.7z.001" ] } } diff --git a/docs/deployment/release-guide.md b/docs/deployment/release-guide.md index 685ffd0..eb28939 100644 --- a/docs/deployment/release-guide.md +++ b/docs/deployment/release-guide.md @@ -141,6 +141,24 @@ 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회 성공**을 확인한다. +## 자동 업데이트 게시 (런타임 분리 전제) + +설치본은 **90MiB대**를 유지해야 한다. 로컬 AI 런타임(사이드카 엔진/ffmpeg)을 설치본에 넣으면 +Cloudflare 업로드 한도(100MiB)를 넘어 `latest.yml`을 게시할 수 없다. + +```bash +npm run build --workspace=@d3ro/desktop # 렌더러/메인 번들 +npm run release:portable:build # 런타임 번들 생성(엔진/ffmpeg) +npm run release:portable # portable + runtime 채널 게시 +npm run release:updater:check # 업데이터 게시 예정 확인 +npm run release:updater -- --ack-unsigned # (인증서 없을 때만) 무서명 게시 +``` + +- 서명 인증서가 있으면 `--ack-unsigned` 없이 게시한다(권장). 무서명 게시는 명시적 예외이며 + 스크립트가 플래그 없이는 즉시 실패한다(GAP-REL-06에 기록). +- 런타임은 `runtime-latest/runtime.json`이 정본이고 앱이 처음 필요할 때 내려받는다. + 런타임을 바꾸면 반드시 `release:portable`로 먼저 게시한 뒤 설치본을 게시한다. + ## 서명 없이 내놓기 (portable 채널) 인증서가 없어도 사용자가 설치할 수 있어야 할 때 사용한다. 자세한 조사·비교·제약은 diff --git a/docs/deployment/unsigned-distribution.md b/docs/deployment/unsigned-distribution.md index 6455451..aaa75e0 100644 --- a/docs/deployment/unsigned-distribution.md +++ b/docs/deployment/unsigned-distribution.md @@ -102,7 +102,18 @@ --- -## 5. 현재 상태 (2026-09-18) +## 5. 자동 업데이트가 다시 동작한다 (2026-09-18) + +- 설치본에서 로컬 AI 런타임(사이드카 94.4MiB, ffmpeg 21.7MiB)을 분리했다 → 설치본 **90.6MiB**. +- 그래서 `latest.yml` + 설치본을 canonical feed에 게시할 수 있게 되어 **자동 업데이트가 복구**됐다 + (`1.3.2` 게시). 서명이 없어 electron-updater는 `publisherName` 부재로 서명 검증을 건너뛴다 + (`node_modules/electron-updater/out/NsisUpdater.js:84-99`). +- 런타임은 `runtime-` / `runtime-latest`에 게시되고 앱이 처음 필요할 때 내려받는다 + (부품별 + 결합본 SHA-256 검증, tar 해제, 실패 시 부분 설치 정리). +- 주의: 무서명 stable 게시는 명시적 예외이며 `--ack-unsigned` 없이는 스크립트가 거부한다. +- 1.0.x 이하 설치본은 여전히 legacy GitLab mirror를 보고 있으므로 **1회 수동 설치**가 필요하다. + +## 6. 채널 현황 (2026-09-18) - 게시된 채널: `portable-1.3.1` + `portable-latest` (7z 볼륨 2개, zip 부품 3개, 인덱스, 설치 스크립트). - `portable-1.3.0`에는 7z 볼륨 2개만 있다(부분 게시, zip 부품 없음) — `1.3.1`이 대체한다. diff --git a/docs/map/10-feature-catalog.md b/docs/map/10-feature-catalog.md index 6ee02cb..9d0287a 100644 --- a/docs/map/10-feature-catalog.md +++ b/docs/map/10-feature-catalog.md @@ -194,6 +194,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]` | 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`; 7z 분할 볼륨(Scoop, 162MiB) + zip 분할 부품(수동 설치, 243MiB, 7-Zip 불필요); updater feed와 분리. 2026-09-18 `portable-1.3.1` 게시 + 실제 설치 검증. | +| INFRA-18 | 로컬 런타임 온디맨드 설치 | [x] | `RuntimeProvisioner`(부품 다운로드 + SHA-256 검증 + tar 해제, `%APPDATA%/d3ro-voice/runtime`), `POST runtime:ensure` / `runtime:progress` IPC, 설정 > STT 상태/내려받기 UI. 설치본에서 엔진/ffmpeg를 분리해 189MB → 90.6MiB, 업데이트 피드 게시 복구. 2026-09-18 실제 feed 통합 검증(엔진 94.4MiB/17초, ffmpeg 21.7MiB/5초). | --- diff --git a/docs/map/11-gap-backlog.md b/docs/map/11-gap-backlog.md index 8d72fbe..eab72a9 100644 --- a/docs/map/11-gap-backlog.md +++ b/docs/map/11-gap-backlog.md @@ -23,6 +23,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 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-REL-06 | Release | 서명 인증서가 없어 stable(`latest`) 채널에 **무서명** 설치본을 게시했다. electron-updater는 `app-update.yml`에 `publisherName`이 없으면 서명 검증을 건너뛰므로 설치는 동작하지만, SmartScreen 평판은 버전마다 0부터 시작한다. | `scripts/ci/publish-updater-release.mjs`, `release/update-policy.json`, `.forgejo/workflows/release.yml` | `[!]` 2026-09-18: `1.3.2`를 `--ack-unsigned`(명시적 승인 플래그)로 게시. 인증서 확보 시 더 높은 버전으로 서명 게시하여 대체하고, 이 예외를 제거한다. | | 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. | @@ -48,8 +49,8 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res | 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-REL-04 | Release | canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 **100MiB**를 넘으면 HTTP 413으로 거부한다. | `scripts/ci/publish-updater-release.mjs`, `docs/deployment/unsigned-distribution.md` | `[~]` 2026-09-18: 설치본을 90.6MiB로 줄여 업데이트 피드 게시를 복구했다(GAP-STT-07). 휴대용/Scoop 채널은 여전히 95MiB 분할이 필요하다. | +| GAP-STT-07 | Local STT | 진(사이드카)을 앱 번들에 넣으면 설치본이 100MiB를 넘고 매 업데이트마다 162MiB를 다시 받는다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts`, `apps/desktop/electron-builder.yml` | `[x]` 2026-09-18: 설치본에서 엔진/ffmpeg를 제거하고 처음 필요할 때 `runtime-latest`에서 내려받는다(부품별 + 결합본 SHA-256 검증). 설치본 189MB → 90.6MiB, 런타임 1회 116MiB(엔진 94.4 + ffmpeg 21.7). 실제 feed로 통합 검증 완료. | | 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/memory/project_status.md b/memory/project_status.md index e634eb0..617b18b 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -1,5 +1,38 @@ # D3RO-VOICE 프로젝트 현황 +## v1.3.2 — 자동 업데이트 복구: 런타임을 설치본에서 분리 (2026-09-18) 🔄 + +자동 업데이트가 왜 안 되는지 끝까지 추적했다. 원인은 서명만이 아니라 **업로드 크기 한도**였다. + +**실측한 사슬** +1. canonical feed는 Cloudflare 뒤에 있고 업로드 본문이 100MiB를 넘으면 **HTTP 413**으로 거부한다 + (60MiB → 201, 110MiB → 413. chunked 업로드도 413). +2. 사이드카 엔진(242MB)을 포함한 NSIS는 **189MB** → 인증서가 있어도 `latest.yml`을 게시할 수 없다. +3. 게다가 이 PC의 설치본(1.0.0)은 `app-update.yml`이 legacy GitLab mirror(0.2.1-alpha)를 봐서 + 어떤 릴리스가 나와도 스스로 올라오지 못한다 → 1회 수동 설치 필수. + +**해결** +- `RuntimeProvisioner` 신설: 설치본에서 엔진/ffmpeg를 빼고, **처음 필요할 때** feed에서 + 부품 단위로 내려받아 SHA-256(부품 + 결합본) 검증 후 tar 해제 (`%APPDATA%/d3ro-voice/runtime`). + 실패 시 부분 설치 정리, 동시 요청 공유, 진행률 이벤트(`runtime:progress`). +- 패키징: extraResources/asarUnpack에서 엔진·ffmpeg 제거 + `electronLanguages: ko, en-US`. + **설치본 688.5MiB → 345.4MiB, NSIS 189MB → 90.6MiB** (한도 통과). +- 런타임 번들 게시: `runtime-`/`runtime-latest` (sidecar 94.4MiB = 2부품, ffmpeg 21.7MiB = 1부품). + tar.gz는 242MB → 94.4MiB로 줄어 첫 설치 다운로드가 116MiB로 끝난다. +- 설정 > STT에 런타임 상태 + 내려받기 버튼 추가(진행률 표시). +- 업데이터 게시 스크립트(`release:updater`) 추가: 100MiB 가드 + 409 대응(메타데이터는 교체) + + 무서명 게시는 `--ack-unsigned` 명시 승인 필수(정책 예외를 조용히 만들지 않는다). + +**검증**: 설치본 90.6MiB → canonical feed `latest`에 게시 완료(`latest.yml` = 1.3.2, 익명 다운로드 확인). +실제 feed로 런타임 통합 테스트 GREEN(엔진 94.4MiB/17초, ffmpeg 21.7MiB/5초 → 실행 파일 + `_internal` 확인). +strict typecheck 신규 오류 0(기존 38건은 무관), lint clean, version/metadata/secret 게이트 GREEN. + +**남은 것(외부)**: 인증서. 지금은 무서명으로 stable 게시한 예외 상태(GAP-REL-06)라 인증서 확보 후 +더 높은 버전을 서명 게시하여 대체해야 한다. 1.0.x 이하 설치는 1.3.2를 1회 수동 설치하면 이후 자동. + +--- + + ## v1.3.0 — 로컬 전사가 실제로 동작하게 (2026-09-18) 🎙️ 설치본에서 로컬 전사가 **한 번도 성공한 적 없던** 원인을 끝까지 추적해 수정. 엔진 자체는 diff --git a/package-lock.json b/package-lock.json index fc196af..310189d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "d3ro-voice-monorepo", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "d3ro-voice-monorepo", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "workspaces": [ "apps/desktop", @@ -25,7 +25,7 @@ }, "apps/admin": { "name": "@d3ro/admin", - "version": "1.3.1", + "version": "1.3.2", "dependencies": { "@d3ro/api-client": "*", "@d3ro/core": "*", @@ -109,7 +109,7 @@ }, "apps/desktop": { "name": "@d3ro/desktop", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -135,6 +135,7 @@ "react-dom": "^19.0.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "tar": "^7.5.13", "uiohook-napi": "^1.5.5" }, "devDependencies": { @@ -157,7 +158,7 @@ }, "apps/web": { "name": "@d3ro/web", - "version": "1.3.1", + "version": "1.3.2", "dependencies": { "@d3ro/api-client": "*", "@d3ro/core": "*", @@ -360,7 +361,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -441,7 +442,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -457,7 +458,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -2519,7 +2520,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -3537,7 +3537,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -3551,7 +3551,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -3561,7 +3561,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -6281,7 +6281,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6426,7 +6426,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -6505,7 +6504,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" @@ -6518,7 +6517,7 @@ "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6583,7 +6582,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.8" @@ -7486,7 +7485,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "clone": "^1.0.2" @@ -7996,7 +7995,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -8472,7 +8471,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -9065,7 +9064,7 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -9255,7 +9254,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -9460,7 +9459,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -9485,7 +9484,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -9733,7 +9732,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10198,7 +10197,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10223,7 +10222,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -10246,7 +10245,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10297,7 +10296,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10801,7 +10800,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -10856,7 +10855,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -11014,7 +11013,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11946,7 +11945,7 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "mime": "cli.js" @@ -11959,7 +11958,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -11969,7 +11968,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -11982,7 +11981,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12053,7 +12052,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -12193,7 +12191,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -12586,7 +12583,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -12620,7 +12617,7 @@ "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "bl": "^4.1.0", @@ -12678,7 +12675,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -12694,7 +12691,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -12841,7 +12838,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13408,7 +13405,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -14616,7 +14613,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -14640,7 +14637,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -14749,7 +14746,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -15580,7 +15577,6 @@ "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -15645,7 +15641,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -16047,7 +16042,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -16564,7 +16559,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "defaults": "^1.0.3" @@ -16845,7 +16840,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -16866,7 +16861,7 @@ }, "packages/api-client": { "name": "@d3ro/api-client", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -16883,7 +16878,7 @@ }, "packages/core": { "name": "@d3ro/core", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "dependencies": { "docx": "^9.6.1" @@ -16894,7 +16889,7 @@ }, "packages/i18n": { "name": "@d3ro/i18n", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "devDependencies": { "@types/react": "^19.0.0" @@ -16905,7 +16900,7 @@ }, "packages/ui": { "name": "@d3ro/ui", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "dependencies": { "@d3ro/core": "*" @@ -16925,7 +16920,7 @@ }, "packages/ui-native": { "name": "@d3ro/ui-native", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "devDependencies": { "@types/react": "*" diff --git a/package.json b/package.json index 6109887..aa1f21d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "d3ro-voice-monorepo", - "version": "1.3.1", + "version": "1.3.2", "private": true, "description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)", "author": "D3RO", @@ -50,7 +50,9 @@ "typecheck:mobile": "npm --prefix apps/mobile-rn run typecheck", "lint:mobile": "npm --prefix apps/mobile-rn run lint", "test:mobile": "npm --prefix apps/mobile-rn test", - "verify:all": "npm run typecheck && npm run typecheck:mobile && npm run lint && npm run lint:mobile && npm run test && npm run test:mobile && npm run check:design" + "verify:all": "npm run typecheck && npm run typecheck:mobile && npm run lint && npm run lint:mobile && npm run test && npm run test:mobile && npm run check:design", + "release:updater": "node --env-file-if-exists=.env scripts/ci/publish-updater-release.mjs", + "release:updater:check": "node scripts/ci/publish-updater-release.mjs --check" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 2ec3d14..d84c6c4 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/api-client", - "version": "1.3.1", + "version": "1.3.2", "private": true, "description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)", "license": "MIT", diff --git a/packages/core/package.json b/packages/core/package.json index 0653bcb..29f5241 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/core", - "version": "1.3.1", + "version": "1.3.2", "private": true, "description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸", "license": "MIT", diff --git a/packages/core/src/ipc-channels.ts b/packages/core/src/ipc-channels.ts index e28de33..b4b3f04 100644 --- a/packages/core/src/ipc-channels.ts +++ b/packages/core/src/ipc-channels.ts @@ -29,6 +29,14 @@ export const IPC_CHANNELS = { TEST_LEVEL: 'audio:testLevel' }, + /** 로컬 AI 런타임(사이드카 엔진/ffmpeg) 설치 — 앱이 feed에서 내려받아 검증한다 */ + RUNTIME: { + GET_STATUS: 'runtime:getStatus', + ENSURE: 'runtime:ensure', + // Main → Renderer events + PROGRESS: 'runtime:progress' + }, + STT: { GET_STATUS: 'stt:getStatus', GET_MODELS: 'stt:getModels', diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 9c9d457..a1199d2 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/i18n", - "version": "1.3.1", + "version": "1.3.2", "private": true, "description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸", "license": "MIT", diff --git a/packages/ui-native/package.json b/packages/ui-native/package.json index 8ad0f52..d0703f5 100644 --- a/packages/ui-native/package.json +++ b/packages/ui-native/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/ui-native", - "version": "1.3.1", + "version": "1.3.2", "private": true, "description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index c949d91..7ad7a3e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/ui", - "version": "1.3.1", + "version": "1.3.2", "private": true, "description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵", "license": "MIT", diff --git a/release/product-version.json b/release/product-version.json index 941ec32..b224c85 100644 --- a/release/product-version.json +++ b/release/product-version.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "1.3.1", - "androidVersionCode": 1031001, - "iosBuildNumber": 1031001, + "version": "1.3.2", + "androidVersionCode": 1031002, + "iosBuildNumber": 1031002, "releaseDate": "2026-09-18", "desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f" } diff --git a/scripts/ci/build-portable.mjs b/scripts/ci/build-portable.mjs index a1143fc..7753da4 100644 --- a/scripts/ci/build-portable.mjs +++ b/scripts/ci/build-portable.mjs @@ -25,12 +25,17 @@ 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' @@ -306,6 +311,90 @@ writeFileSync( `${JSON.stringify(indexJson, null, 2)}\n`, 'utf8', ) +// ── 로컬 AI 런타임 번들 (설치본에 넣지 않고 처음 필요할 때 내려받는다) ────── +const RUNTIME_DIR = join(releaseDir, 'runtime') +rmSync(RUNTIME_DIR, { recursive: true, force: true }) +mkdirSync(RUNTIME_DIR, { recursive: true }) + +/** 디렉터리를 tar.gz으로 묶어 90MiB 부품으로 나누고 인덱스 항목을 돌려준다 */ +async function packRuntime(component, sourceDir, archiveBase) { + const archivePath = join(RUNTIME_DIR, `${archiveBase}.tar.gz`) + await pipeline( + tar.c({ cwd: sourceDir, portable: true, gzip: false }, ['.']), + createGzip({ level: 6 }), + createWriteStream(archivePath), + ) + + const archiveBytes = readFileSync(archivePath) + const sha256 = createHash('sha256').update(archiveBytes).digest('hex') + const partSize = 90 * 1024 * 1024 + const parts = [] + for (let offset = 0, index = 1; offset < archiveBytes.length; offset += partSize, index += 1) { + const slice = archiveBytes.subarray(offset, Math.min(offset + partSize, archiveBytes.length)) + const name = `${archiveBase}.tar.gz.${String(index).padStart(3, '0')}` + if (slice.length > MAX_ZIP_PART_BYTES) { + throw new Error(`런타임 부품이 너무 큽니다: ${name}`) + } + writeFileSync(join(RUNTIME_DIR, name), slice) + parts.push({ + name, + size: slice.length, + sha256: createHash('sha256').update(slice).digest('hex'), + }) + } + + return { + component, + archive: `${archiveBase}.tar.gz`, + sha256, + totalSize: archiveBytes.length, + parts, + } +} +const runtimeComponents = {} +const sidecarSource = join(desktopDir, 'sidecar-dist', 'sidecar') +// @ffmpeg-installer가 플랫폼별로 제공하는 실행 파일 경로를 그대로 사용한다 +const ffmpegInstaller = (() => { + try { + return require('@ffmpeg-installer/ffmpeg') + } catch { + return null + } +})() +const ffmpegSource = ffmpegInstaller?.path ? dirname(ffmpegInstaller.path) : null + +if (existsSync(sidecarSource)) { + console.log('[portable] 런타임 번들 생성: sidecar (faster-whisper 진)') + runtimeComponents.sidecar = await packRuntime('sidecar', sidecarSource, 'd3ro-runtime-sidecar') +} else { + console.error('[portable] 경고: sidecar-dist가 없어 런타임 번들을 만들 수 없습니다') +} + +if (ffmpegSource && existsSync(ffmpegSource)) { + console.log('[portable] 런타임 번들 생성: ffmpeg') + runtimeComponents.ffmpeg = await packRuntime('ffmpeg', ffmpegSource, 'd3ro-runtime-ffmpeg') +} else { + console.error('[portable] 경고: @ffmpeg-installer가 없어 ffmpeg 런타임을 만들 수 없습니다') +} + +const runtimeIndex = { + schemaVersion: 1, + version, + generatedAt: new Date().toISOString(), + components: Object.fromEntries( + Object.entries(runtimeComponents).map(([name, entry]) => [ + name, + { + ...entry, + parts: entry.parts.map((part) => ({ + ...part, + url: `${FEED}/runtime-${version}/${part.name}`, + })), + }, + ]), + ), +} +writeFileSync(join(RUNTIME_DIR, 'runtime.json'), `${JSON.stringify(runtimeIndex, null, 2)}\n`, 'utf8') console.log( [ '[portable] 완료', @@ -316,6 +405,7 @@ console.log( ` zip : ${zipParts.length}개 부품 / 합계 ${(zipBytes.length / 1048576).toFixed(1)}MiB`, ` 인덱스 : ${join(releaseDir, 'portable.json')}`, ` scoop : ${join(root, 'bucket', 'd3ro-voice.json')}`, + ` 런타임 : ${Object.keys(runtimeComponents).join(', ') || '(없음)'} → ${join(RUNTIME_DIR, 'runtime.json')}`, ` 게시 : node scripts/ci/publish-portable-release.mjs`, ].join('\n'), ) \ No newline at end of file diff --git a/scripts/ci/publish-portable-release.mjs b/scripts/ci/publish-portable-release.mjs index 5fafdb0..944c181 100644 --- a/scripts/ci/publish-portable-release.mjs +++ b/scripts/ci/publish-portable-release.mjs @@ -93,6 +93,36 @@ payloads.push({ contentType: 'text/plain', }) +// ── 로컬 AI 런타임 번들 게시 (설치본에는 없고, 앱이 처음 필요할 때 내려받는다) ── +const runtimeDir = join(releaseDir, 'runtime') +const runtimePayloads = [] +const runtimeIndexPath = join(runtimeDir, 'runtime.json') +if (existsSync(runtimeIndexPath)) { + const runtimeIndex = JSON.parse(readFileSync(runtimeIndexPath, 'utf8')) + for (const [component, entry] of Object.entries(runtimeIndex.components ?? {})) { + for (const part of entry.parts) { + const partPath = join(runtimeDir, part.name) + if (!existsSync(partPath)) { + console.error(`[portable] 런타임 부품이 없습니다: ${partPath}`) + process.exit(1) + } + const bytes = await readFile(partPath) + const sha = createHash('sha256').update(bytes).digest('hex') + if (sha !== part.sha256) { + console.error(`[portable] 런타임 부품 sha256 불일치: ${part.name}`) + process.exit(1) + } + runtimePayloads.push({ name: part.name, bytes, contentType: 'application/octet-stream' }) + } + void component + } + runtimePayloads.push({ + name: 'runtime.json', + bytes: Buffer.from(`${JSON.stringify(runtimeIndex, null, 2)}\n`, 'utf8'), + contentType: 'application/json', + }) +} + const authorization = forgejoAuthorization() const bases = [`${FEED}/portable-${version}`, `${FEED}/portable-latest`] @@ -142,6 +172,13 @@ async function upload(url, body, contentType) { console.log(`[portable] uploaded ${url}`) } +const runtimeBases = [`${FEED}/runtime-${version}`, `${FEED}/runtime-latest`] +for (const base of runtimeBases) { + for (const payload of runtimePayloads) { + await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType) + } +} + for (const base of bases) { for (const payload of payloads) { await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType) @@ -155,6 +192,9 @@ console.log( ` 볼륨 : ${index.volumeCount}개 / 합계 ${(index.totalSize / 1048576).toFixed(1)}MiB`, ` 인덱스 : ${FEED}/portable-latest/portable.json`, ` 스크립트: ${FEED}/portable-latest/install-d3ro-voice.ps1`, + runtimePayloads.length + ? ` 런타임 : ${FEED}/runtime-latest/runtime.json (${runtimePayloads.length}개 파일)` + : ' 런타임 : (없음)', '', ' 설치(Scoop, 권장):', ' scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git', diff --git a/scripts/ci/publish-updater-release.mjs b/scripts/ci/publish-updater-release.mjs new file mode 100644 index 0000000..8e265ca --- /dev/null +++ b/scripts/ci/publish-updater-release.mjs @@ -0,0 +1,213 @@ +// scripts/ci/publish-updater-release.mjs +// 자동 업데이트 채널(latest)에 디스크톱 설치본을 게시한다. +// +// 전제: 설치본이 Cloudflare 업로드 한도(100MiB) 아래여야 한다. 그래서 로컬 AI +// 런타임(사이드카/ffmpeg)은 설치본에 넣지 않고, 앱이 처음 필요할 때 +// `runtime-latest`에서 내려받는다(RuntimeProvisioner, `npm run release:portable`가 게시). +// +// 정책 예외(명시): +// - 이 채널은 일반적으로 Authenticode 서명을 요구한다. 서명 인증서가 준비되기 전까지 +// 업데이트를 전달할 수 없어, **무서명 빌드를 명시적 승인(--ack-unsigned)으로만** 게시한다. +// - 검증되지 않은 서명을 조용히 게시하지 않는다: 승인 플래그가 없으면 즉시 실패한다. +// +// 사용: +// npm run build --workspace=@d3ro/desktop +// node scripts/ci/publish-updater-release.mjs --build --ack-unsigned +// node scripts/ci/publish-updater-release.mjs --check # 게시 예정만 확인 + +import credentialHelpers from '../lib/credentials.cjs' +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync, readdirSync, statSync } 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 desktopDir = join(root, 'apps', 'desktop') +const args = process.argv.slice(2) +const check = args.includes('--check') +const ackUnsigned = args.includes('--ack-unsigned') +const build = args.includes('--build') + +const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice' +/** Cloudflare 업로드 본문 한도 (실측: 110MiB → 413) */ +const MAX_UPLOAD_BYTES = 100 * 1024 * 1024 + +const version = JSON.parse( + readFileSync(join(root, 'release', 'product-version.json'), 'utf8'), +).version +const releaseDir = join(desktopDir, 'release', version) + +if (build) { + console.log('[updater] electron-builder NSIS 빌드 ( 전용 — 런타임 제외)') + const result = spawnSync( + 'npx', + [ + 'electron-builder', + '--win', + 'nsis', + '--x64', + '--config', + 'electron-builder.yml', + '--publish', + 'never', + '-c.win.forceCodeSigning=false', + '-c.npmRebuild=false', + ], + { cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' }, + ) + if (result.status !== 0) { + console.error(`[updater] 빌드 실패 (exit ${result.status ?? 'null'})`) + process.exit(result.status ?? 1) + } +} + +const metadataPath = join(releaseDir, 'latest.yml') +if (!existsSync(metadataPath)) { + console.error( + `[updater] latest.yml이 없습니다: ${metadataPath}\n --build로 먼저 빌드하세요.`, + ) + process.exit(1) +} + +const candidates = readdirSync(releaseDir).filter( + (name) => /\.exe$/.test(name) && !/__uninstaller|apponly/i.test(name), +) +const installer = candidates.find((name) => name.includes('Setup')) ?? candidates[0] +if (!installer) { + console.error(`[updater] 설치본을 찾을 수 없습니다: ${releaseDir}`) + process.exit(1) +} + +const installerPath = join(releaseDir, installer) +const blockmapPath = `${installerPath}.blockmap` +const policyPath = join(root, 'release', 'update-policy.json') + +const payloads = [ + { name: installer, path: installerPath, type: 'application/octet-stream' }, + { name: `${installer}.blockmap`, path: blockmapPath, type: 'application/octet-stream' }, + { name: 'latest.yml', path: metadataPath, type: 'text/yaml' }, + { name: 'update-policy.json', path: policyPath, type: 'application/json' }, +].filter((payload) => existsSync(payload.path)) + +const installerSize = statSync(installerPath).size +console.log( + [ + `[updater] 버전 ${version}`, + ` 설치본 : ${installer} (${(installerSize / 1048576).toFixed(1)}MiB)`, + ` 한도 : ${(MAX_UPLOAD_BYTES / 1048576).toFixed(0)}MiB (Cloudflare 업로드 본문 한도)`, + ].join('\n'), +) + +if (installerSize > MAX_UPLOAD_BYTES) { + console.error( + [ + '[updater] 설치본이 업로드 한도를 넘습니다 — 게시할 수 없습니다.', + ' 런타임(사이드카/ffmpeg)을 설치본에 다시 넣지 않았는지 확인하세요:', + ' `apps/desktop/electron-builder.yml`의 extraResources / files / asarUnpack.', + ].join('\n'), + ) + process.exit(1) +} + +const metadata = readFileSync(metadataPath, 'utf8') +if (!metadata.includes(`version: ${version}`)) { + console.error('[updater] latest.yml의 버전이 product-version.json과 다릅니다.') + process.exit(1) +} + +const targets = [`${FEED}/${version}`, `${FEED}/latest`] + +if (check) { + console.log('[updater] (check) 게시 예정:') + for (const target of targets) { + for (const payload of payloads) { + console.log(` PUT ${target}/${payload.name} (${statSync(payload.path).size} bytes)`) + } + } + process.exit(0) +} + +if (!ackUnsigned) { + console.error( + [ + '[updater] 무서명 빌드를 stable 채널에 게시하려면 명시적 승인이 필요합니다.', + ' 서명 인증서가 준비되면 이 플래그 없이 게시하세요(권장).', + ' 승인: --ack-unsigned', + ].join('\n'), + ) + process.exit(1) +} + +const authorization = forgejoAuthorization() + +async function forgejoFetch(url, init = {}) { + return fetch(url, { + ...init, + headers: { Authorization: authorization, ...(init.headers ?? {}) }, + }) +} + +console.log( + [ + '[updater] 경고: 무서명 설치본을 stable(latest) 채널에 게시합니다.', + ' - electron-updater는 app-update.yml에 publisherName이 없으면 서명 검증을 건너뛰므로', + ' 설치 자체는 정상 동작합니다.', + ' - 인증서가 준비되면 이 버전보다 높은 버전으로 서명 게시하여 대체하세요.', + ].join('\n'), +) + +for (const target of targets) { + for (const payload of payloads) { + const url = `${target}/${encodeURIComponent(payload.name)}` + const body = await readFile(payload.path) + + // Forgejo generic registry는 같은 경로에 다른 바이트가 있으면 409를 돌려준다. + // 메타데이터(latest.yml / update-policy.json)는 최신을 가리켜야 하므로 먼저 지운다. + // (설치본/블록맵은 파일명에 버전이 있어 충돌하지 않는다.) + const existing = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch( + () => null, + ) + if (existing?.ok) { + const contentRange = existing.headers.get('content-range') + const remoteSize = contentRange ? Number(contentRange.split('/')[1]) : NaN + if (remoteSize === body.length) { + console.log(`[updater] 이미 동일한 파일이 있습니다(건너뜀): ${url}`) + continue + } + await forgejoFetch(url, { method: 'DELETE' }).catch(() => null) + } + + const response = await forgejoFetch(url, { + method: 'PUT', + headers: { 'Content-Type': payload.type }, + body, + }) + if (!response.ok) { + const hint = + response.status === 409 + ? ' (409: 같은 경로에 다른 내용이 이미 있음 — 게시된 버전을 덮어쓰지 않습니다)' + : response.status === 413 + ? ' (413: Cloudflare 업로드 한도 초과 — 런타임 분리 확인)' + : '' + console.error( + `[updater] 업로드 실패 (HTTP ${response.status}): ${target}/${payload.name}${hint}`, + ) + process.exit(1) + } + console.log(`[updater] uploaded ${target}/${payload.name}`) + } +} + +console.log( + [ + '', + `[updater] 게시 완료: ${version}`, + ` 피드 : ${FEED}/latest`, + ` 메타 : ${FEED}/latest/latest.yml`, + ' 기존 설치본(canonical feed 사용)은 다음 업데이트 확인 때 이 버전을 받습니다.', + ' legacy GitLab mirror를 보는 1.0.x 이하 설치는 1회 수동 설치가 필요합니다.', + ].join('\n'), +) \ No newline at end of file diff --git a/site/package-lock.json b/site/package-lock.json index 5f33c08..b2e4770 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -1,12 +1,12 @@ { "name": "d3ro-voice-site", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "d3ro-voice-site", - "version": "1.3.1", + "version": "1.3.2", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/site/package.json b/site/package.json index 7aaef9d..ce27e94 100644 --- a/site/package.json +++ b/site/package.json @@ -1,7 +1,7 @@ { "name": "d3ro-voice-site", "private": true, - "version": "1.3.1", + "version": "1.3.2", "type": "module", "scripts": { "dev": "vite --port 5199 --host", From 1af3cf75c785106eb4a186c3a0d965c421d56cba Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 18 Sep 2026 15:45:03 +0900 Subject: [PATCH 05/18] fix(release): stop shipping native modules built for the wrong runtime The released installer could not start: it carried a better-sqlite3 build for the host Node runtime instead of Electron, so the app died immediately with a module version mismatch when it opened its database. Packaging now proves the Electron build of every runtime-sensitive native module before an installer or archive exists, and installers are produced only from that verified tree, so the mistake cannot pass silently. The release pipelines run the same check. The default local model also pointed at a retired model: a *.gguf name that Ollama cannot serve, while the settings, onboarding, and guide screens recommended an older model. All of them now use the model the service code already preferred. --- .forgejo/workflows/release.yml | 1 + .github/workflows/release.yml | 2 + .gitlab-ci.yml | 2 + CHANGELOG.md | 13 ++ apps/admin-swagger/openapi.json | 2 +- apps/admin/package.json | 2 +- apps/api-server/D3ROVoice.Api.csproj | 2 +- apps/desktop/electron-builder.yml | 2 + apps/desktop/package.json | 2 +- .../src/main/services/ConfigService.ts | 2 +- .../src/main/services/LocalLLMService.ts | 4 +- .../renderer/components/OllamaGuideModal.tsx | 4 +- .../renderer/components/OnboardingModal.tsx | 4 +- .../src/renderer/components/SettingsModal.tsx | 6 +- apps/mobile-rn/android/app/build.gradle | 4 +- .../ios/D3ROVoice.xcodeproj/project.pbxproj | 8 +- .../android/en-US/changelogs/1031003.txt | 1 + .../android/ko-KR/changelogs/1031003.txt | 1 + apps/mobile-rn/package-lock.json | 14 +-- apps/mobile-rn/package.json | 2 +- apps/web/package.json | 2 +- apps/web/src/components/layout/sidebar.tsx | 2 +- bucket/d3ro-voice.json | 8 +- docs/deployment/release-guide.md | 5 + docs/map/10-feature-catalog.md | 1 + docs/map/11-gap-backlog.md | 1 + memory/project_status.md | 19 +++ package-lock.json | 20 +-- package.json | 2 +- packages/api-client/package.json | 2 +- packages/core/package.json | 2 +- packages/i18n/package.json | 2 +- packages/ui-native/package.json | 2 +- packages/ui/package.json | 2 +- release/product-version.json | 6 +- scripts/ci/build-portable.mjs | 22 ++++ scripts/ci/fix-native-abi.mjs | 119 ++++++++++++++++++ scripts/ci/publish-portable-release.mjs | 49 ++++++-- scripts/ci/publish-updater-release.mjs | 89 ++++++++++--- scripts/ci/verify-native-abi.mjs | 117 +++++++++++++++++ site/package-lock.json | 4 +- site/package.json | 2 +- 42 files changed, 473 insertions(+), 83 deletions(-) create mode 100644 apps/mobile-rn/metadata/android/en-US/changelogs/1031003.txt create mode 100644 apps/mobile-rn/metadata/android/ko-KR/changelogs/1031003.txt create mode 100644 scripts/ci/fix-native-abi.mjs create mode 100644 scripts/ci/verify-native-abi.mjs diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index e5c3913..3159540 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -64,6 +64,7 @@ jobs: npm run build --workspace=@d3ro/desktop Push-Location apps/desktop npx electron-builder --win --x64 --config electron-builder.yml --publish never + node scripts/ci/verify-native-abi.mjs Pop-Location - name: Windows 산출물 검증 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7391d26..1e5c634 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,6 +138,7 @@ jobs: Push-Location apps/desktop try { npx electron-builder --win --x64 --config electron-builder.yml + node scripts/ci/verify-native-abi.mjs if ($LASTEXITCODE -ne 0) { throw "electron-builder failed with exit code $LASTEXITCODE." } } finally { @@ -201,6 +202,7 @@ jobs: run: | cd apps/desktop npx electron-builder --mac --arm64 --config electron-builder.yml + node scripts/ci/verify-native-abi.mjs env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.MAC_CSC_LINK }} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fdcef83..793335c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -251,6 +251,7 @@ package-windows: - npm run build --workspace=@d3ro/desktop - cd apps/desktop - npx electron-builder --win --x64 --config electron-builder.yml + - node scripts/ci/verify-native-abi.mjs - cd ../.. - $releaseVersion = node -p "require('./release/product-version.json').version" - '& scripts/ci/verify-windows-release-artifact.ps1 -ExpectedVersion $releaseVersion -ExpectedSignerSubject $env:WIN_CSC_EXPECTED_SIGNER_SUBJECT -ReleaseDirectory "apps/desktop/release/$releaseVersion"' @@ -282,6 +283,7 @@ package-macos: - npm run build --workspace=@d3ro/desktop - cd apps/desktop - npx electron-builder --mac --arm64 --config electron-builder.yml + - node scripts/ci/verify-native-abi.mjs artifacts: name: "d3ro-voice-macos-$CI_COMMIT_TAG" paths: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9373919..75062a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cloud-optional backup (encrypted, opt-in) - Plugin system for custom pipelines +## [1.3.3] - 2026-09-18 + +### Fixed +- **The released installer could not start**: it carried a `better-sqlite3` build for the + host Node runtime instead of Electron, so the app failed immediately with a + `NODE_MODULE_VERSION` mismatch when opening its database. Packaging now guarantees and + verifies the Electron build of every runtime-sensitive native module before an installer + or archive is produced, and publishes only from that verified tree. +- **The local language model default still pointed at a retired model**: the stored default + was a `*.gguf` file name that Ollama cannot serve, and the settings, onboarding, and + Ollama guide still recommended `gemma2:2b` while the service code already preferred + `gemma4:e4b`. All surfaces now default to `gemma4:e4b`. + ## [1.3.2] - 2026-09-18 ### Changed diff --git a/apps/admin-swagger/openapi.json b/apps/admin-swagger/openapi.json index 8dce225..6c1edc1 100644 --- a/apps/admin-swagger/openapi.json +++ b/apps/admin-swagger/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "D3RO-VOICE Admin API", "description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.", - "version": "1.3.2" + "version": "1.3.3" }, "servers": [ { diff --git a/apps/admin/package.json b/apps/admin/package.json index e2a9b30..6b80dc6 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/admin", - "version": "1.3.2", + "version": "1.3.3", "private": true, "description": "D3RO Voice Admin CRM — SaaS 관리 도구", "scripts": { diff --git a/apps/api-server/D3ROVoice.Api.csproj b/apps/api-server/D3ROVoice.Api.csproj index aeb40ea..42a627f 100644 --- a/apps/api-server/D3ROVoice.Api.csproj +++ b/apps/api-server/D3ROVoice.Api.csproj @@ -2,7 +2,7 @@ net10.0 - 1.3.2 + 1.3.3 enable enable diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 8985bbb..d762e2f 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -20,6 +20,8 @@ files: - "!out/**/*.map" # ffmpeg 정적 바이너리(61MB)는 설치본에 넣지 않는다 — 필요할 때 런타임으로 내려받는다 - "!node_modules/@ffmpeg-installer/**" + # ffmpeg 정적 바이너리(61MB)는 설치본에 넣지 않는다 — 필요할 때 런타임으로 내려받는다 + - "!node_modules/@ffmpeg-installer/**" # ──────────────────────────────────────────────────────────────────── # 자동 업데이트 feed — 이 설정이 있어야 electron-builder가 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3806fbd..eed0ba2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/desktop", - "version": "1.3.2", + "version": "1.3.3", "productName": "d3ro-voice", "description": "로컬 AI 음성 어시스턴트 (Electron)", "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index 2f0b2e9..51d2ec9 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -42,7 +42,7 @@ const CONFIG_DEFAULTS: AppConfig = { ttsSpeed: 1.0, onlineApiUrl: 'http://127.0.0.1:5000', localModelsDir: '', - llmModelId: 'gemma-2-2b-it.Q4_K_M.gguf', + llmModelId: 'gemma4:e4b', ollamaServerUrl: 'http://127.0.0.1:11434', appUsageMode: null, authToken: null, diff --git a/apps/desktop/src/main/services/LocalLLMService.ts b/apps/desktop/src/main/services/LocalLLMService.ts index 003f31e..64bf06c 100644 --- a/apps/desktop/src/main/services/LocalLLMService.ts +++ b/apps/desktop/src/main/services/LocalLLMService.ts @@ -663,7 +663,7 @@ class LocalLLMService extends EventEmitter { return { connectionState, serverUrl: getOllamaServerUrl(), - activeModel: configGet('llmModelId') || 'gemma2:2b', + activeModel: configGet('llmModelId') || 'gemma4:e4b', serverVersion: this._serverVersion } } @@ -686,7 +686,7 @@ class LocalLLMService extends EventEmitter { } const serverUrl = getOllamaServerUrl() - const model = options?.model ?? configGet('llmModelId') ?? 'gemma2:2b' + const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b' this._abortController = new AbortController() this._state = LLMState.Generating diff --git a/apps/desktop/src/renderer/components/OllamaGuideModal.tsx b/apps/desktop/src/renderer/components/OllamaGuideModal.tsx index 6f8d648..d5cbf80 100644 --- a/apps/desktop/src/renderer/components/OllamaGuideModal.tsx +++ b/apps/desktop/src/renderer/components/OllamaGuideModal.tsx @@ -146,7 +146,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac const { t } = useI18n() const [llmStatus, setLlmStatus] = useState(null) const [installedModels, setInstalledModels] = useState([]) - const [activeModel, setActiveModel] = useState('gemma2:2b') + const [activeModel, setActiveModel] = useState('gemma4:e4b') const [checking, setChecking] = useState(false) const [starting, setStarting] = useState(false) const [startMessage, setStartMessage] = useState(null) @@ -582,7 +582,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac 직접 터미널에서 다운로드하려면 아래 명령어를 실행하세요: - + diff --git a/apps/desktop/src/renderer/components/OnboardingModal.tsx b/apps/desktop/src/renderer/components/OnboardingModal.tsx index a7aa845..71c87c9 100644 --- a/apps/desktop/src/renderer/components/OnboardingModal.tsx +++ b/apps/desktop/src/renderer/components/OnboardingModal.tsx @@ -49,7 +49,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. // Local Ollama State const [ollamaStatus, setOllamaStatus] = useState(null) const [installedModels, setInstalledModels] = useState([]) - const [activeModel, setActiveModel] = useState('gemma2:2b') + const [activeModel, setActiveModel] = useState('gemma4:e4b') const [checkingOllama, setCheckingOllama] = useState(false) const [startingOllama, setStartingOllama] = useState(false) const [ollamaMsg, setOllamaMsg] = useState(null) @@ -498,7 +498,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.