Compare commits

..

18 commits
v1.3.0 ... main

Author SHA1 Message Date
Yun Chan
0ca9e242fa fix(site): serve the landing domain from the Pages deployment
Some checks failed
deploy-site / deploy (push) Failing after 1m26s
d3ro.chanpaca.net answered an empty Cloudflare 404 because the account had no
Pages project for the landing site, and attaching the custom domain still needs
a DNS CNAME that the local Cloudflare credentials cannot create. Add a small
Workers route bridge that serves the Pages deployment on that hostname so the
domain works now and keeps following Pages deploys, and record the cleanup
left to do.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-19 09:21:27 +09:00
Yun Chan
79ecdc89c4 docs(map): record why the landing domain answers 404
Some checks failed
deploy-site / deploy (push) Failing after 1m29s
The Cloudflare account had no Pages project for the landing site, so the custom
domain had nothing to bind to. The project and a production deployment now exist
and the domain is attached; only the DNS CNAME is missing because the local
Cloudflare credentials cannot edit DNS.
2026-09-19 09:12:22 +09:00
Yun Chan
2407f5a1c1 docs(map): record where the release pipelines actually stop
Some checks failed
deploy-site / deploy (push) Failing after 1m4s
The tag pipeline dies at the sidecar step before the signing guard because the
release runner cannot see Python 3.11, the site deploy now builds and fails only
for the missing Cloudflare token, and the portable channel published 1.3.7 with
only its evidence-upload step failing.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-19 08:53:11 +09:00
Yun Chan
4b40b25e53 fix(site): stop the landing build from depending on hoisted Node types
Some checks failed
deploy-site / deploy (push) Failing after 1m4s
The hero typed its timer ref with NodeJS.Timeout, which only resolves when
@types/node is visible from an ancestor node_modules. The deploy job installs
site dependencies alone, so tsc -b failed with TS2503 and the landing site has
not been redeployed since. Use ReturnType<typeof setTimeout> and record the
measured deploy blocker plus the runner's missing Python in the map.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-19 08:47:33 +09:00
Yun Chan
ee1deb64cf fix(release): point the download centers at the published installer
Some checks failed
deploy-site / deploy (push) Failing after 52s
The download contract on both the web console and the landing site still
advertised 1.2.0, so the install button pointed at
D3RO-Voice-Setup-1.2.0-x64.exe, which the feed answers with 404 while 1.3.7
is live. Wire both contract files into version:sync so the installer version
and release date follow the version SSOT, and set them to the published 1.3.7.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-19 08:41:37 +09:00
Yun Chan
f741999859 docs(map): record the 1.3.7 release
Some checks failed
deploy-site / deploy (push) Failing after 1m12s
The overlay fix shipped as 1.3.7 through the local updater publisher, so the
map now carries the published version, the per-channel evidence, and the
correction that 1.3.2-1.3.7 are live on the feed even though the CI signing
gate still blocks tag-driven publication.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-19 08:30:29 +09:00
Yun Chan
05f0aaa660 chore(release): 1.3.7
Some checks failed
release / release-windows (push) Failing after 6m51s
portable-unsigned / portable-windows (push) Failing after 16m51s
💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-19 08:24:13 +09:00
Yun Chan
ae7efb6acf fix(desktop): render popup overlays in packaged builds
Popup pages loaded their scripts as classic <script src> tags, which the
renderer build never bundles, so an installed app rendered only the static
markup: the recording tip stayed at 0:00 with no wave bars and live captions
showed nothing.

- declare popup scripts as modules so the build emits them, and fail
  packaging when a renderer page references an asset that was never produced
- hold popup IPC until the renderer has loaded and re-assert visibility on
  every show, so a popup hidden once still appears next time
- surface popup renderer console and load failures in the main log

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-19 08:24:03 +09:00
Yun Chan
74cbc8f6ae docs(map): record the 1.3.6 release
Some checks failed
deploy-site / deploy (push) Failing after 12m28s
2026-09-18 21:07:00 +09:00
Yun Chan
6766cb8c8e chore(release): 1.3.6
Some checks failed
deploy-site / deploy (push) Failing after 1m37s
release / release-windows (push) Failing after 6m6s
portable-unsigned / portable-windows (push) Failing after 18m52s
2026-09-18 20:58:52 +09:00
Yun Chan
27facb8569 fix(desktop): verify runtime parts on disk so engine install stops failing
Part checks counted bytes from the network stream while the joined archive
was hashed from disk, so a truncated write passed part verification and only
failed later as "런타임 아카이브 해시 불일치". Verify size and hash from the
written file, check the joined size before its hash, and retry a failed part
up to 3 times.
2026-09-18 20:26:26 +09:00
Yun Chan
57c17d0977 fix(desktop): recommend the model the app actually uses
Some checks failed
deploy-site / deploy (push) Failing after 13m48s
The Ollama setup guide still led with two retired models while the app already
defaults to the newer one, so a fresh setup would install a model the app does
not use. The guide now recommends the same model as the rest of the app and
lists current lightweight alternatives.
2026-09-18 17:15:37 +09:00
Yun Chan
f14341ace4 fix(release): keep the updater configuration in the installer
Some checks failed
deploy-site / deploy (push) Failing after 1m6s
Installing the previous build left an app that could not update itself: the
packaging path used to guarantee the native module build does not create the
updater configuration file, so the update client had nothing to read.

That file is now written from the single feed source and its presence in the
packaged app is checked before anything is published, so an installer that
cannot update can no longer be released.
2026-09-18 16:35:03 +09:00
Yun Chan
1af3cf75c7 fix(release): stop shipping native modules built for the wrong runtime
Some checks failed
deploy-site / deploy (push) Failing after 14m16s
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.
2026-09-18 15:45:03 +09:00
Yun Chan
0fbbbc1756 fix(release): restore automatic updates by shipping the speech engine on demand
Some checks failed
deploy-site / deploy (push) Failing after 1m15s
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.
2026-09-18 13:51:49 +09:00
Yun Chan
0411f389d9 feat(release): make the signing-free install work with nothing but Windows
Some checks failed
deploy-site / deploy (push) Failing after 3m26s
The manual install path still needed 7-Zip, which the target machine does not
have, so "installable without a certificate" was not yet true.

The channel now also publishes the app as byte-split zip parts, and the install
script joins them and extracts with the built-in Windows Expand-Archive after
verifying every part and the joined archive. Version 1.3.1 republishes the
channel from a single build, because a version's artifacts can only match one
build and published volumes are never overwritten.
2026-09-18 12:03:18 +09:00
Yun Chan
c35c6f3e95 feat(release): ship an install path that needs no code-signing certificate
Some checks failed
deploy-site / deploy (push) Failing after 4m9s
Installers could not be published at all: the signing certificate does not
exist yet, and the release pipelines stop at their signing guard. Users had no
way to install a fixed build, so the product was effectively stuck behind a
certificate that takes weeks to obtain.

There is also a second, independent blocker: the download feed sits behind
Cloudflare, which rejects any upload body over about 100 MiB, and the app with
its speech engine exceeds that even when signed.

A portable channel now publishes what can actually be delivered today: the app
compressed into 95 MiB 7z volumes (162 MiB total instead of 243 MiB), a Scoop
bucket for a normal install and uninstall experience, and a verifiable manual
installer script. It is deliberately separate from the auto-update feed, needs
no certificate, and refuses to overwrite an already published version.
2026-09-18 11:03:34 +09:00
Yun Chan
a85ab799a3 docs(release): record why tagged releases publish nothing
Some checks failed
deploy-site / deploy (push) Failing after 31s
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.
2026-09-18 04:37:05 +09:00
86 changed files with 3219 additions and 210 deletions

View file

@ -0,0 +1,72 @@
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: 데스크톱 렌더러 번들 검증
run: node scripts/ci/verify-desktop-renderer-bundles.mjs
- 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

View file

@ -62,8 +62,10 @@ jobs:
throw "로컬 개발 인증서는 production 서명 identity가 아닙니다." throw "로컬 개발 인증서는 production 서명 identity가 아닙니다."
} }
npm run build --workspace=@d3ro/desktop npm run build --workspace=@d3ro/desktop
node scripts/ci/verify-desktop-renderer-bundles.mjs
Push-Location apps/desktop Push-Location apps/desktop
npx electron-builder --win --x64 --config electron-builder.yml --publish never npx electron-builder --win --x64 --config electron-builder.yml --publish never
node scripts/ci/verify-native-abi.mjs
Pop-Location Pop-Location
- name: Windows 산출물 검증 - name: Windows 산출물 검증

View file

@ -169,6 +169,10 @@ jobs:
- name: Build Target Workspace - name: Build Target Workspace
run: ${{ matrix.cmd }} run: ${{ matrix.cmd }}
- name: Verify Desktop Renderer Bundles
if: matrix.target == 'desktop'
run: node scripts/ci/verify-desktop-renderer-bundles.mjs
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
# 4. Android x86_64 artifacts and native dependency gate # 4. Android x86_64 artifacts and native dependency gate
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────

View file

@ -109,6 +109,9 @@ jobs:
npm run typecheck npm run typecheck
npm run build --workspace=@d3ro/desktop npm run build --workspace=@d3ro/desktop
- name: Verify Desktop Renderer Bundles
run: node scripts/ci/verify-desktop-renderer-bundles.mjs
- name: Build STT Sidecar (local transcription engine) - name: Build STT Sidecar (local transcription engine)
run: | run: |
# Local transcription depends on the faster-whisper sidecar; a release # Local transcription depends on the faster-whisper sidecar; a release
@ -138,6 +141,7 @@ jobs:
Push-Location apps/desktop Push-Location apps/desktop
try { try {
npx electron-builder --win --x64 --config electron-builder.yml 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." } if ($LASTEXITCODE -ne 0) { throw "electron-builder failed with exit code $LASTEXITCODE." }
} }
finally { finally {
@ -188,6 +192,9 @@ jobs:
npm run typecheck npm run typecheck
npm run build --workspace=@d3ro/desktop npm run build --workspace=@d3ro/desktop
- name: Verify Desktop Renderer Bundles
run: node scripts/ci/verify-desktop-renderer-bundles.mjs
- name: Build STT Sidecar (local transcription engine) - name: Build STT Sidecar (local transcription engine)
run: | run: |
# Local transcription depends on the faster-whisper sidecar; a release # Local transcription depends on the faster-whisper sidecar; a release
@ -201,6 +208,7 @@ jobs:
run: | run: |
cd apps/desktop cd apps/desktop
npx electron-builder --mac --arm64 --config electron-builder.yml npx electron-builder --mac --arm64 --config electron-builder.yml
node scripts/ci/verify-native-abi.mjs
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CSC_LINK }} CSC_LINK: ${{ secrets.MAC_CSC_LINK }}

View file

@ -251,6 +251,7 @@ package-windows:
- npm run build --workspace=@d3ro/desktop - npm run build --workspace=@d3ro/desktop
- cd apps/desktop - cd apps/desktop
- npx electron-builder --win --x64 --config electron-builder.yml - npx electron-builder --win --x64 --config electron-builder.yml
- node scripts/ci/verify-native-abi.mjs
- cd ../.. - cd ../..
- $releaseVersion = node -p "require('./release/product-version.json').version" - $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"' - '& 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 - npm run build --workspace=@d3ro/desktop
- cd apps/desktop - cd apps/desktop
- npx electron-builder --mac --arm64 --config electron-builder.yml - npx electron-builder --mac --arm64 --config electron-builder.yml
- node scripts/ci/verify-native-abi.mjs
artifacts: artifacts:
name: "d3ro-voice-macos-$CI_COMMIT_TAG" name: "d3ro-voice-macos-$CI_COMMIT_TAG"
paths: paths:

View file

@ -13,6 +13,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cloud-optional backup (encrypted, opt-in) - Cloud-optional backup (encrypted, opt-in)
- Plugin system for custom pipelines - Plugin system for custom pipelines
## [1.3.7] - 2026-09-19
### Fixed
- **Recording and live caption overlays never worked in installed builds.** Popup
pages loaded their scripts as classic `<script src>` tags, which the renderer
build does not bundle, so a packaged app rendered only the static markup: the
recording tip froze at 0:00 with no wave bars and captions showed nothing.
Popup scripts are now module scripts, a packaging check fails when a renderer
page references an asset that was never emitted, and popups hold IPC until
their renderer is ready and re-assert visibility on every show.
## [1.3.6] - 2026-09-18
### Fixed
- **Installing the local speech engine always failed** with a hash mismatch, leaving local
transcription unusable. The downloaded parts were checked against bytes counted from the
network stream, while the joined archive was checked against what was actually written to
disk, so a truncated write passed part verification and only surfaced later as an archive
mismatch with no usable diagnostic. Every check now reads the file on disk, the joined
archive is size-checked before hashing, mismatch errors report the actual and expected
values, and a failed part is discarded and retried up to three times.
## [1.3.5] - 2026-09-18
### Fixed
- The Ollama guide still listed the retired models as its recommendation while the
app already defaults to . The guide now leads with the model the app actually
uses and lists current lightweight alternatives.
## [1.3.4] - 2026-09-18
### Fixed
- **Automatic updates could not start on 1.3.3**: its installer was missing the updater
configuration file, because the packaging path used to guarantee the native module build
does not create it. The file is now written from the single feed source and its presence
in the packaged app is verified before publishing, so an installer that cannot update is
never shipped.
## [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
- **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
- **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 ## [1.3.0] - 2026-09-18
> Published from an annotated tag through CI. Installer and update metadata are > Published from an annotated tag through CI. Installer and update metadata are
@ -33,6 +106,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 `sidecar:build`, plus a packaging-time bundle verifier that fails the build when the
engine or its VAD data is missing. 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 ### Changed
- Local engine connections now target the IPv4 loopback (`127.0.0.1`) instead of - 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 `localhost`. On machines where `localhost` resolves only to IPv6, every local request

View file

@ -3,7 +3,7 @@
"info": { "info": {
"title": "D3RO-VOICE Admin API", "title": "D3RO-VOICE Admin API",
"description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.", "description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.",
"version": "1.3.0" "version": "1.3.7"
}, },
"servers": [ "servers": [
{ {

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/admin", "name": "@d3ro/admin",
"version": "1.3.0", "version": "1.3.7",
"private": true, "private": true,
"description": "D3RO Voice Admin CRM — SaaS 관리 도구", "description": "D3RO Voice Admin CRM — SaaS 관리 도구",
"scripts": { "scripts": {

View file

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Version>1.3.0</Version> <Version>1.3.7</Version>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>

View file

@ -4,6 +4,10 @@ copyright: Copyright © 2026 D3RO
# monorepo(npm workspaces)에서 electron이 루트 node_modules로 호이스팅되어 # monorepo(npm workspaces)에서 electron이 루트 node_modules로 호이스팅되어
# 자동 감지가 실패하는 문제를 피하려고 명시적으로 버전 고정. # 자동 감지가 실패하는 문제를 피하려고 명시적으로 버전 고정.
electronVersion: "33.4.11" electronVersion: "33.4.11"
# 설치 크기 절감 — 지원 로케일만 포함한다
electronLanguages:
- ko
- en-US
directories: directories:
# buildResources를 build/로 지정 — resources/icons/가 비어있어 아이콘 자동 스캔이 실패하는 것을 우회. # buildResources를 build/로 지정 — resources/icons/가 비어있어 아이콘 자동 스캔이 실패하는 것을 우회.
@ -14,6 +18,10 @@ directories:
files: files:
- out/**/* - out/**/*
- "!out/**/*.map" - "!out/**/*.map"
# ffmpeg 정적 바이너리(61MB)는 설치본에 넣지 않는다 — 필요할 때 런타임으로 내려받는다
- "!node_modules/@ffmpeg-installer/**"
# ffmpeg 정적 바이너리(61MB)는 설치본에 넣지 않는다 — 필요할 때 런타임으로 내려받는다
- "!node_modules/@ffmpeg-installer/**"
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
# 자동 업데이트 feed — 이 설정이 있어야 electron-builder가 # 자동 업데이트 feed — 이 설정이 있어야 electron-builder가
@ -36,8 +44,6 @@ asarUnpack:
- "node_modules/better-sqlite3/**" - "node_modules/better-sqlite3/**"
- "node_modules/uiohook-napi/**" - "node_modules/uiohook-napi/**"
- "node_modules/@nut-tree-fork/**" - "node_modules/@nut-tree-fork/**"
# ffmpeg 정적 바이너리는 실행 파일이므로 asar 내부에서 spawn할 수 없다
- "node_modules/@ffmpeg-installer/**"
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
# Windows # Windows
@ -115,20 +121,11 @@ extraResources:
- from: resources/sox/ - from: resources/sox/
to: sox/ to: sox/
# faster-whisper STT 사이드카 (PyInstaller onedir: sidecar.exe + _internal/). # 주의: 로컬 AI 런타임(사이드카 엔진, ffmpeg)은 여기에 넣지 않는다.
# 반드시 존재해야 한다. 누락되면 로컬 전사가 전혀 동작하지 않는다. # 포함하면 설치본이 Cloudflare 업로드 한도(100MiB)를 넘어 자동 업데이트 메타데이터를
# 빌드: npm --prefix apps/desktop run sidecar:build # 게시할 수 없다(실측: 엔진 포함 189MB vs 엔진 제외 90.5MiB). 런타임은 처음 필요할 때
# electron-builder는 이 트리를 재귀로 복사한다(_internal 포함). # RuntimeProvisioner가 feed에서 내려받아 검증한 뒤 해제해 설치한다.
# 서명 검증에 실패하면 복사가 중간에 끊겨 _internal이 빠지므로,
# 서명 없이 로컬 검증할 때는 -c.win.forceCodeSigning=false 를 사용한다.
- from: sidecar-dist/sidecar/
to: sidecar/
filter:
- "**/*"
# ffmpeg (파일 전사/미디어 변환용). CI가 resources/ffmpeg/에 배치한다.
- from: resources/ffmpeg/
to: ffmpeg/
# Ollama 바이너리 (포터블 zip을 사전 배치) # Ollama 바이너리 (포터블 zip을 사전 배치)
- from: resources/ollama/ - from: resources/ollama/

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/desktop", "name": "@d3ro/desktop",
"version": "1.3.0", "version": "1.3.7",
"productName": "d3ro-voice", "productName": "d3ro-voice",
"description": "로컬 AI 음성 어시스턴트 (Electron)", "description": "로컬 AI 음성 어시스턴트 (Electron)",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@ -65,6 +65,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tar": "^7.5.13",
"uiohook-napi": "^1.5.5" "uiohook-napi": "^1.5.5"
}, },
"optionalDependencies": { "optionalDependencies": {

View file

@ -5,6 +5,8 @@ import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors' import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors'
import { getLocalSTTService } from '../services/LocalSTTService' import { getLocalSTTService } from '../services/LocalSTTService'
import { getRuntimeProvisioner } from '../services/RuntimeProvisioner'
import type { RuntimeComponent } from '../services/RuntimeProvisioner'
import { getSTTManager } from '../services/stt/STTManager' import { getSTTManager } from '../services/stt/STTManager'
import { configGet, configSet } from '../services/ConfigService' import { configGet, configSet } from '../services/ConfigService'
import { getMainWindow } from '../windows/WindowManager' import { getMainWindow } from '../windows/WindowManager'
@ -26,6 +28,31 @@ function safeSendToRenderer(channel: string, data: unknown): void {
} }
export function registerSTTHandlers(): 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) => { getLocalSTTService().on('download-progress', (payload) => {
safeSendToRenderer(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, payload) safeSendToRenderer(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, payload)
}) })

View file

@ -42,7 +42,7 @@ const CONFIG_DEFAULTS: AppConfig = {
ttsSpeed: 1.0, ttsSpeed: 1.0,
onlineApiUrl: 'http://127.0.0.1:5000', onlineApiUrl: 'http://127.0.0.1:5000',
localModelsDir: '', localModelsDir: '',
llmModelId: 'gemma-2-2b-it.Q4_K_M.gguf', llmModelId: 'gemma4:e4b',
ollamaServerUrl: 'http://127.0.0.1:11434', ollamaServerUrl: 'http://127.0.0.1:11434',
appUsageMode: null, appUsageMode: null,
authToken: null, authToken: null,

View file

@ -11,6 +11,7 @@ import { getSTTManager } from './stt/STTManager'
import { getHistoryService } from './HistoryService' import { getHistoryService } from './HistoryService'
import { configGet } from './ConfigService' import { configGet } from './ConfigService'
import { getFfmpegPath } from '../utils/paths' import { getFfmpegPath } from '../utils/paths'
import { getRuntimeProvisioner } from './RuntimeProvisioner'
import { getMainWindow } from '../windows/WindowManager' import { getMainWindow } from '../windows/WindowManager'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
@ -250,6 +251,18 @@ class FileTranscriptionService extends EventEmitter {
} }
} }
/**
* ffmpeg . ffmpeg을
* feed에서 ( / ).
*/
private async _ensureFfmpeg(): Promise<string> {
const resolved = getFfmpegPath()
if (resolved !== 'ffmpeg') return resolved
logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다')
return getRuntimeProvisioner().ensure('ffmpeg')
}
/** /**
* ffmpeg로 PCM16 16kHz mono WAV로 * ffmpeg로 PCM16 16kHz mono WAV로
*/ */

View file

@ -663,7 +663,7 @@ class LocalLLMService extends EventEmitter {
return { return {
connectionState, connectionState,
serverUrl: getOllamaServerUrl(), serverUrl: getOllamaServerUrl(),
activeModel: configGet('llmModelId') || 'gemma2:2b', activeModel: configGet('llmModelId') || 'gemma4:e4b',
serverVersion: this._serverVersion serverVersion: this._serverVersion
} }
} }
@ -686,7 +686,7 @@ class LocalLLMService extends EventEmitter {
} }
const serverUrl = getOllamaServerUrl() const serverUrl = getOllamaServerUrl()
const model = options?.model ?? configGet('llmModelId') ?? 'gemma2:2b' const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
this._abortController = new AbortController() this._abortController = new AbortController()
this._state = LLMState.Generating this._state = LLMState.Generating

View file

@ -11,6 +11,7 @@ import { join } from 'path'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { configGet } from './ConfigService' import { configGet } from './ConfigService'
import { getSidecarCommand, getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths' import { getSidecarCommand, getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths'
import { getRuntimeProvisioner } from './RuntimeProvisioner'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { import type {
STTModel, STTModel,
@ -101,6 +102,15 @@ export interface LocalSTTEvents {
'transcription-complete': { result: TranscriptionResult } 'transcription-complete': { result: TranscriptionResult }
'model-loaded': { model: STTModel; loadTimeMs: number } 'model-loaded': { model: STTModel; loadTimeMs: number }
'download-progress': DownloadProgressEvent 'download-progress': DownloadProgressEvent
/** 런타임(엔진/ffmpeg) 내려받기 진행률 — 필요할 때 자동 설치 */
'runtime-progress': {
component: string
phase: 'index' | 'downloading' | 'extracting' | 'done'
percent: number
downloadedBytes: number
totalBytes: number
bytesPerSecond: number
}
'error': { error: D3ROError } 'error': { error: D3ROError }
} }
@ -183,6 +193,10 @@ class LocalSTTService extends EventEmitter {
// (실측: sidecar crash 루프 중 ERR_UNHANDLED_ERROR). 기본 sink로 방지 — // (실측: sidecar crash 루프 중 ERR_UNHANDLED_ERROR). 기본 sink로 방지 —
// 실제 로깅은 _emitError에서 수행. // 실제 로깅은 _emitError에서 수행.
this.on('error', () => { /* default sink */ }) this.on('error', () => { /* default sink */ })
// 런타임 내려받기 진행률을 그대로 중계한다 (IPC가 renderer로 전달)
getRuntimeProvisioner().on('progress', (payload) => {
this.emit('runtime-progress', payload)
})
} }
private _state: STTState = STTState.Uninitialized private _state: STTState = STTState.Uninitialized
@ -638,8 +652,8 @@ class LocalSTTService extends EventEmitter {
// dev mode HMR/재시작으로 이전 sidecar가 orphan으로 남아있을 수 있음. // dev mode HMR/재시작으로 이전 sidecar가 orphan으로 남아있을 수 있음.
this._port = await this._findFreePort(SIDECAR_PORT, 20) this._port = await this._findFreePort(SIDECAR_PORT, 20)
// 번들/venv 경로가 깨졌으면 여기서 즉시 실패한다 (조용한 PATH 폴백 금지). // 설치본에는 엔진이 없다 — 없으면 여기서 feed에서 내려받고 산다. dev는 venv/번들 경로를 쓴다.
const launch = getSidecarCommand() const launch = await this._resolveSidecarLaunch()
const fullArgs = [ const fullArgs = [
...launch.args, ...launch.args,
'--port', '--port',
@ -726,6 +740,31 @@ class LocalSTTService extends EventEmitter {
consume(child.stderr, (message) => sidecarLogger.warn(message)) 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 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */ /** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */
private _spawnFailureError( private _spawnFailureError(
err: Error, err: Error,

View file

@ -0,0 +1,390 @@
// 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, stat } from 'node:fs/promises'
import { Readable, Writable } 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<string, RuntimeComponentIndex>
}
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
/** 부품 다운로드 재시도 횟수 — 전송 중 잘림/일시적 네트워크 오류 대비 */
const PART_DOWNLOAD_ATTEMPTS = 3
class RuntimeProvisioner extends EventEmitter {
constructor() {
super()
this.on('error', () => {
/* 기본 sink — EventEmitter 'error' 미처리 예외 방지 */
})
}
private _inFlight = new Map<RuntimeComponent, Promise<string>>()
/** 설치된 런타임 트 (%APPDATA%/d3ro-voice/runtime/<component>) */
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<string> {
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<string> {
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<RuntimeIndex> {
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<void> {
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 partSize = await this._downloadPart(part, partPath)
downloadedBytes += partSize
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),
)
}
// 부품을 순서대로 이어 붙인다 (스트리밍 — 메모리에 통째로 올리지 않는다)
const archiveStream = createWriteStream(archivePath)
for (const part of entry.parts) {
await pipeline(createReadStream(join(tempDir, part.name)), archiveStream, { end: false })
}
archiveStream.end()
await once(archiveStream, 'finish')
// 크기를 먼저 본다 — 불일치하면 "어디까지 받았는지"가 로그에 남아 진단이 가능하다.
const archiveSize = (await stat(archivePath)).size
const expectedSize = entry.parts.reduce((sum, part) => sum + part.size, 0)
if (archiveSize !== expectedSize) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 아카이브 크기 불일치 (${component}: ${archiveSize} != ${expectedSize})`,
)
}
const actualArchive = await sha256File(archivePath)
if (entry.sha256 && actualArchive !== entry.sha256) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 아카이브 해시 불일치 (${component}: ${actualArchive} != ${entry.sha256})`,
)
}
}
/**
* · .
* ( 1 ).
*/
private async _downloadPart(part: RuntimePart, partPath: string): Promise<number> {
let lastError: Error | null = null
for (let attempt = 1; attempt <= PART_DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
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}`,
)
}
// 스트림을 파일로 저장한 뒤 "디스크에 실제로 남은 파일"에서 크기와 해시를 계산한다.
// 메모리 스트림에서 센 값으로 검증하면, 디스크 쓰기가 잘려도 부품 검사를 통과해
// 결합 단계에 가서야 해시 불일치로 터진다 — 실측 사고.
await pipeline(Readable.fromWeb(response.body as never), createWriteStream(partPath))
const partSize = (await stat(partPath)).size
if (partSize !== part.size) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 부품 크기 불일치 (${part.name}: ${partSize} != ${part.size})`,
)
}
const actualPartHash = await sha256File(partPath)
if (part.sha256 && actualPartHash !== part.sha256) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 부품 해시 불일치 (${part.name})`,
)
}
return partSize
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err))
await rm(partPath, { force: true }).catch(() => undefined)
logger.warn(
`런타임 부품 다운로드 실패 (${part.name}, ${attempt}/${PART_DOWNLOAD_ATTEMPTS}): ${lastError.message}`,
)
}
}
throw lastError ?? new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 부품 다운로드 실패 (${part.name})`,
)
}
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)
}
}
/** 파일 SHA-256 (스트림 — 메모리에 통째로 올리지 않는다) */
async function sha256File(path: string): Promise<string> {
const hash = createHash('sha256')
await pipeline(createReadStream(path), new Writable({
write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
hash.update(chunk)
callback()
},
}))
return hash.digest('hex')
}
let _instance: RuntimeProvisioner | null = null
export function getRuntimeProvisioner(): RuntimeProvisioner {
if (!_instance) {
_instance = new RuntimeProvisioner()
}
return _instance
}
export function resetRuntimeProvisionerForTests(): void {
_instance = null
}

View file

@ -16,6 +16,17 @@ export const UPDATE_FEED_URL =
// GitLab Generic Registry legacy mirror. 2026-08 이전 설치본(0.2.1-alpha)은 // GitLab Generic Registry legacy mirror. 2026-08 이전 설치본(0.2.1-alpha)은
// 이 feed를 폴링하므로, 새 설치자가 Forgejo feed를 내장할 때까지 publisher가 // 이 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 = export const LEGACY_UPDATE_FEED_URL =
'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest' 'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest'

View file

@ -86,6 +86,31 @@ function packagedResourcePath(...segments: string[]): string {
return path.join(process.resourcesPath, ...segments) 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 let cachedSoxPath: string | undefined
/** /**
@ -138,7 +163,7 @@ export interface SidecarLaunch {
command: string command: string
args: string[] args: string[]
/** 어디에서 결정되었는지 (로그/진단용) */ /** 어디에서 결정되었는지 (로그/진단용) */
source: 'bundled' | 'venv' | 'python' source: 'bundled' | 'provisioned' | 'venv' | 'python'
} }
/** /**
@ -154,11 +179,16 @@ export function getSidecarCommand(): SidecarLaunch {
if (existsSync(exePath)) { if (existsSync(exePath)) {
return { command: exePath, args: [], source: 'bundled' } return { command: exePath, args: [], source: 'bundled' }
} }
// 설치본에는 엔진을 넣지 않는다(업데이트 게시 크기 한도). 필요할 때 내려받은 경로를 쓴다.
const provisioned = getProvisionedSidecarPath()
if (provisioned) {
return { command: provisioned, args: [], source: 'provisioned' }
}
throw new D3ROError( throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed, ErrorCode.STTEngineNotInstalled,
`번들된 STT 사이드카를 찾을 수 없습니다: ${exePath}. ` + '로컬 음성 엔진이 아직 설치되지 않았습니다. 설정 > STT에서 "엔진 다운로드"를 실행하세요.',
'설치 패키지에 sidecar 리소스가 누락되었습니다(로컬 전사 불가). ' +
'앱을 다시 설치하거나 개발 모드에서 `npm run sidecar:build`로 빌드하세요.',
) )
} }
@ -185,7 +215,6 @@ export function getSidecarCommand(): SidecarLaunch {
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3' const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
return { command: pythonCmd, args: [sidecarPath], source: 'python' } return { command: pythonCmd, args: [sidecarPath], source: 'python' }
} }
/** STT 사이드카 HTTP 기본 URL. sidecar는 IPv4 프백에만 바인딩한다. */ /** STT 사이드카 HTTP 기본 URL. sidecar는 IPv4 프백에만 바인딩한다. */
export function getSidecarBaseUrl(port: number): string { export function getSidecarBaseUrl(port: number): string {
return loopbackUrl(port) return loopbackUrl(port)
@ -232,6 +261,12 @@ export function getFfmpegPath(): string {
return bundled return bundled
} }
// 설치본에서는 ffmpeg도 필요할 때 내려받는다 (설치/업데이트 크기 절감)
const provisionedFfmpeg = getProvisionedFfmpegPath()
if (provisionedFfmpeg) {
return provisionedFfmpeg
}
try { try {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const installer = require('@ffmpeg-installer/ffmpeg') as { path?: string } const installer = require('@ffmpeg-installer/ffmpeg') as { path?: string }

View file

@ -62,6 +62,99 @@ function getPopupI18nStrings(): Record<string, string> {
} }
} }
// ── Popup window lifecycle ────────────────────────────
/** Popup windows whose renderer finished loading and can receive IPC */
const popupReady = new WeakSet<BrowserWindow>()
/** IPC messages held back until the popup renderer is ready */
const pendingPopupMessages = new WeakMap<BrowserWindow, Array<{ channel: string; data: unknown }>>()
/**
* Promote popup renderer console output and load failures into the main log.
* Popups have no renderer logging otherwise, so a missing asset or a script
* exception stays invisible and the popup just renders static markup.
*/
function hookPopupDiagnostics(win: BrowserWindow, name: string): void {
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
logger.info(`[${name}] [Renderer] [${level}] ${message} (${sourceId}:${line})`)
})
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
logger.error(`[${name}] renderer load failed [${errorCode}] ${errorDescription} (${validatedURL})`)
})
win.webContents.on('render-process-gone', (_event, details) => {
logger.error(`[${name}] renderer process gone: ${details.reason} (exitCode=${details.exitCode})`)
})
}
/**
* Deliver IPC to a popup even while its renderer is still loading.
* webContents.send before load is silently dropped, which leaves popups stuck
* on their initial markup (for example a frozen 0:00 timer).
*/
function sendToPopupWindow(win: BrowserWindow, channel: string, data: unknown): void {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
if (popupReady.has(win)) {
win.webContents.send(channel, data)
return
}
const queue = pendingPopupMessages.get(win) ?? []
queue.push({ channel, data })
if (queue.length > 32) {
queue.splice(0, queue.length - 32)
}
pendingPopupMessages.set(win, queue)
}
function flushPopupMessages(win: BrowserWindow): void {
popupReady.add(win)
const queue = pendingPopupMessages.get(win)
if (!queue || queue.length === 0) return
pendingPopupMessages.delete(win)
for (const message of queue) {
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
win.webContents.send(message.channel, message.data)
}
}
}
/** Shared popup lifecycle: diagnostics + load tracking + theme injection */
function attachPopupLifecycle(win: BrowserWindow, name: string): void {
hookPopupDiagnostics(win, name)
win.webContents.on('did-start-loading', () => {
popupReady.delete(win)
})
win.webContents.on('did-finish-load', () => {
injectPopupTheme(win)
flushPopupMessages(win)
})
}
/**
* Show a popup without stealing focus, reliably on every call.
* After hide(), showInactive() can lose z-order and repaint, so popups stopped
* appearing from the second show onward: re-assert topmost and force a repaint.
*/
function presentPopup(win: BrowserWindow, level?: 'floating' | 'screen-saver'): void {
if (!win.isVisible()) {
win.showInactive()
}
// Z-order/repaint adjustments must never break a capture session.
try {
win.setAlwaysOnTop(true, level)
win.moveTop()
win.webContents.invalidate()
} catch (err) {
logger.warn(`Popup present adjustment failed: ${err instanceof Error ? err.message : String(err)}`)
}
}
// ── 윈도우 참조 ─────────────────────────────────────── // ── 윈도우 참조 ───────────────────────────────────────
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
@ -199,9 +292,7 @@ function createRecordingTipWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/recording-tip/index.html')) win.loadFile(join(__dirname, '../renderer/popups/recording-tip/index.html'))
} }
win.webContents.on('did-finish-load', () => { attachPopupLifecycle(win, 'recording-tip')
injectPopupTheme(win)
})
win.on('closed', () => { win.on('closed', () => {
recordingTipWindow = null recordingTipWindow = null
@ -253,10 +344,9 @@ export function showRecordingTip(
win.setBounds({ x, y, width: TIP_WIDTH, height: TIP_HEIGHT }) win.setBounds({ x, y, width: TIP_WIDTH, height: TIP_HEIGHT })
// 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지) // 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지)
win.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params }) sendToPopupWindow(win, IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
if (!win.isVisible()) { presentPopup(win, 'screen-saver')
win.showInactive() logger.debug(`RecordingTip presented: state=${state} visible=${win.isVisible()} loading=${win.webContents.isLoading()}`)
}
} }
export function hideRecordingTip(): void { export function hideRecordingTip(): void {
@ -270,20 +360,20 @@ export function updateRecordingTipState(
params?: { text?: string; errorMessage?: string } params?: { text?: string; errorMessage?: string }
): void { ): void {
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) { if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
recordingTipWindow.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params }) sendToPopupWindow(recordingTipWindow, IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
} }
} }
export function sendAudioLevelToTip(level: number): void { export function sendAudioLevelToTip(level: number): void {
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) { if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
recordingTipWindow.webContents.send(IPC_CHANNELS.VOICE.AUDIO_LEVEL, { level }) sendToPopupWindow(recordingTipWindow, IPC_CHANNELS.VOICE.AUDIO_LEVEL, { level })
} }
} }
/** 실시간 부분 전사 텍스트를 RecordingTip에 전달 */ /** 실시간 부분 전사 텍스트를 RecordingTip에 전달 */
export function sendPartialTranscriptToTip(text: string): void { export function sendPartialTranscriptToTip(text: string): void {
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) { if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
recordingTipWindow.webContents.send(IPC_CHANNELS.VOICE_PARTIAL.PARTIAL_TRANSCRIPT, { text }) sendToPopupWindow(recordingTipWindow, IPC_CHANNELS.VOICE_PARTIAL.PARTIAL_TRANSCRIPT, { text })
} }
} }
@ -314,9 +404,7 @@ function createResultPopupWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/result-popup/index.html')) win.loadFile(join(__dirname, '../renderer/popups/result-popup/index.html'))
} }
win.webContents.on('did-finish-load', () => { attachPopupLifecycle(win, 'result-popup')
injectPopupTheme(win)
})
win.on('closed', () => { win.on('closed', () => {
resultPopupWindow = null resultPopupWindow = null
@ -340,7 +428,7 @@ export function showResultPopup(text: string, autoHideMs = 5000): void {
const win = getResultPopupWindow() const win = getResultPopupWindow()
// Phase 1: prepare // Phase 1: prepare
win.webContents.send(IPC_CHANNELS.POPUP_RESULT.PREPARE, { text, _i18n: getPopupI18nStrings() }) sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.PREPARE, { text, _i18n: getPopupI18nStrings() })
ipcMain.once(IPC_CHANNELS.POPUP_RESULT.MEASURED, (_event, data: { width: number; height: number }) => { ipcMain.once(IPC_CHANNELS.POPUP_RESULT.MEASURED, (_event, data: { width: number; height: number }) => {
const cursorPos = screen.getCursorScreenPoint() const cursorPos = screen.getCursorScreenPoint()
@ -359,12 +447,10 @@ export function showResultPopup(text: string, autoHideMs = 5000): void {
win.setBounds({ x, y, width: data.width, height: data.height }) win.setBounds({ x, y, width: data.width, height: data.height })
if (!win.isVisible()) { presentPopup(win)
win.showInactive()
}
// Phase 2: show // Phase 2: show
win.webContents.send(IPC_CHANNELS.POPUP_RESULT.SHOW, { autoHideMs }) sendToPopupWindow(win, IPC_CHANNELS.POPUP_RESULT.SHOW, { autoHideMs })
}) })
} }
@ -401,9 +487,7 @@ function createHistoryPopupWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html')) win.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html'))
} }
win.webContents.on('did-finish-load', () => { attachPopupLifecycle(win, 'history-popup')
injectPopupTheme(win)
})
win.on('closed', () => { win.on('closed', () => {
historyPopupWindow = null historyPopupWindow = null
@ -440,18 +524,16 @@ export function showHistoryPopup(entries: Array<Record<string, unknown>>): void
win.setBounds({ x, y, width: popupWidth, height: popupHeight }) win.setBounds({ x, y, width: popupWidth, height: popupHeight })
win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() }) sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() })
if (!win.isVisible()) { presentPopup(win)
win.showInactive()
}
win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW, {}) sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW, {})
} }
export function hideHistoryPopup(): void { export function hideHistoryPopup(): void {
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) { if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
historyPopupWindow.webContents.send(IPC_CHANNELS.POPUP_HISTORY.HIDE, {}) sendToPopupWindow(historyPopupWindow, IPC_CHANNELS.POPUP_HISTORY.HIDE, {})
setTimeout(() => { setTimeout(() => {
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) { if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
historyPopupWindow.hide() historyPopupWindow.hide()
@ -462,7 +544,7 @@ export function hideHistoryPopup(): void {
export function sendKeyToHistoryPopup(key: string): void { export function sendKeyToHistoryPopup(key: string): void {
if (historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible()) { if (historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible()) {
historyPopupWindow.webContents.send(IPC_CHANNELS.POPUP_HISTORY.KEY_EVENT, { key }) sendToPopupWindow(historyPopupWindow, IPC_CHANNELS.POPUP_HISTORY.KEY_EVENT, { key })
} }
} }
@ -497,9 +579,7 @@ function createCommandPopupWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html')) win.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html'))
} }
win.webContents.on('did-finish-load', () => { attachPopupLifecycle(win, 'command-popup')
injectPopupTheme(win)
})
win.on('closed', () => { commandPopupWindow = null }) win.on('closed', () => { commandPopupWindow = null })
return win return win
@ -527,15 +607,15 @@ export function showCommandPopup(commands: Array<Record<string, unknown>>, activ
if (y < display.workArea.y) { y = cursorPos.y + 20 } if (y < display.workArea.y) { y = cursorPos.y + 20 }
win.setBounds({ x, y, width: popupWidth, height: popupHeight }) win.setBounds({ x, y, width: popupWidth, height: popupHeight })
win.webContents.send(IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() }) sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() })
if (!win.isVisible()) { win.showInactive() } presentPopup(win)
win.webContents.send(IPC_CHANNELS.POPUP_COMMAND.SHOW, {}) sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW, {})
} }
export function hideCommandPopup(): void { export function hideCommandPopup(): void {
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) { if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
commandPopupWindow.webContents.send(IPC_CHANNELS.POPUP_COMMAND.HIDE, {}) sendToPopupWindow(commandPopupWindow, IPC_CHANNELS.POPUP_COMMAND.HIDE, {})
setTimeout(() => { setTimeout(() => {
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) { if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
commandPopupWindow.hide() commandPopupWindow.hide()
@ -546,7 +626,7 @@ export function hideCommandPopup(): void {
export function sendKeyToCommandPopup(key: string): void { export function sendKeyToCommandPopup(key: string): void {
if (commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible()) { if (commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible()) {
commandPopupWindow.webContents.send(IPC_CHANNELS.POPUP_COMMAND.KEY_EVENT, { key }) sendToPopupWindow(commandPopupWindow, IPC_CHANNELS.POPUP_COMMAND.KEY_EVENT, { key })
} }
} }
@ -591,9 +671,7 @@ function createCaptionOverlayWindow(): BrowserWindow {
win.loadFile(join(__dirname, '../renderer/popups/caption-overlay/index.html')) win.loadFile(join(__dirname, '../renderer/popups/caption-overlay/index.html'))
} }
win.webContents.on('did-finish-load', () => { attachPopupLifecycle(win, 'caption-overlay')
injectPopupTheme(win)
})
win.on('closed', () => { win.on('closed', () => {
captionOverlayWindow = null captionOverlayWindow = null
@ -612,21 +690,19 @@ export function getCaptionOverlayWindow(): BrowserWindow {
export function showCaptionOverlay(): void { export function showCaptionOverlay(): void {
const win = getCaptionOverlayWindow() const win = getCaptionOverlayWindow()
if (!win.isVisible()) { presentPopup(win)
win.showInactive()
}
} }
export function hideCaptionOverlay(): void { export function hideCaptionOverlay(): void {
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) { if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
captionOverlayWindow.webContents.send(IPC_CHANNELS.POPUP_CAPTION.HIDE, {}) sendToPopupWindow(captionOverlayWindow, IPC_CHANNELS.POPUP_CAPTION.HIDE, {})
captionOverlayWindow.hide() captionOverlayWindow.hide()
} }
} }
export function sendToCaptionOverlay(channel: string, data: unknown): void { export function sendToCaptionOverlay(channel: string, data: unknown): void {
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) { if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
captionOverlayWindow.webContents.send(channel, data) sendToPopupWindow(captionOverlayWindow, channel, data)
} }
} }

View file

@ -180,6 +180,25 @@ import type {
import { Feature } from '@d3ro/core/types' import { Feature } from '@d3ro/core/types'
import type { IPCResult } from '@d3ro/core/errors' 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 type Unsubscribe = () => void
function invoke<TResult>(channel: string, ...args: unknown[]): Promise<IPCResult<TResult>> { function invoke<TResult>(channel: string, ...args: unknown[]): Promise<IPCResult<TResult>> {
@ -284,6 +303,18 @@ const electronAPI = {
on(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, cb) on(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, cb)
}, },
// ── Local AI runtime (엔진/ffmpeg — 필요할 때 내려받음) ──
runtime: {
getStatus: () => invoke<RuntimeStatusPayload[]>(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 ─────────────────────────────────────────────
hotkey: { hotkey: {
getDictationShortcut: () => getDictationShortcut: () =>

View file

@ -59,29 +59,29 @@ interface RecommendedModel {
const RECOMMENDED_MODELS: RecommendedModel[] = [ const RECOMMENDED_MODELS: RecommendedModel[] = [
{ {
id: 'gemma2:2b', id: 'gemma4:e4b',
name: 'Gemma 2 (2B)', name: 'Gemma 4 (E4B)',
size: '1.6 GB', size: '약 4 GB',
description: '구글 경량 모델. 빠른 응답 속도와 저사양 PC에 최적화 (기본 추천)', description: '이 앱의 기본 로컬 모델. 추론 토큰을 쓰지 않아 받아쓰기 다듬기에 가장 빠름 (기본 추천)',
recommended: true, recommended: true,
}, },
{ {
id: 'llama3.2:3b', id: 'llama3.2:3b',
name: 'Llama 3.2 (3B)', name: 'Llama 3.2 (3B)',
size: '2.0 GB', size: '2.0 GB',
description: '메타의 최신 경량 모델. 텍스트 다듬기 및 문법 교정에 우수', description: '메타의 경량 모델. 문법 교정과 톤 조절에 균형이 좋음',
}, },
{ {
id: 'qwen2.5:3b', id: 'qwen2.5:3b',
name: 'Qwen 2.5 (3B)', name: 'Qwen 2.5 (3B)',
size: '1.9 GB', size: '1.9 GB',
description: '한국어 및 다국어 이해도가 매우 뛰어난 고성능 모델', description: '한국어·다국어 이해도가 뛰어남',
}, },
{ {
id: 'gemma2:9b', id: 'phi4',
name: 'Gemma 2 (9B)', name: 'Phi 4',
size: '5.4 GB', size: '9.1 GB',
description: '고성능 모델. 복잡한 요약 및 번역에 적합 (RAM 16GB+ 권장)', description: '요약·번역 품질이 높음 (RAM 16GB+ 권장)',
}, },
] ]
@ -146,7 +146,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
const { t } = useI18n() const { t } = useI18n()
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null) const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
const [installedModels, setInstalledModels] = useState<LLMModel[]>([]) const [installedModels, setInstalledModels] = useState<LLMModel[]>([])
const [activeModel, setActiveModel] = useState<string>('gemma2:2b') const [activeModel, setActiveModel] = useState<string>('gemma4:e4b')
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
const [starting, setStarting] = useState(false) const [starting, setStarting] = useState(false)
const [startMessage, setStartMessage] = useState<string | null>(null) const [startMessage, setStartMessage] = useState<string | null>(null)
@ -582,7 +582,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.dimLabel }}> <Typography sx={{ fontSize: '11px', color: d3roPalette.text.dimLabel }}>
: :
</Typography> </Typography>
<CodeBlock code="ollama pull gemma2:2b" /> <CodeBlock code="ollama pull gemma4:e4b" />
</Box> </Box>
</Box> </Box>

View file

@ -49,7 +49,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
// Local Ollama State // Local Ollama State
const [ollamaStatus, setOllamaStatus] = useState<LLMStatus | null>(null) const [ollamaStatus, setOllamaStatus] = useState<LLMStatus | null>(null)
const [installedModels, setInstalledModels] = useState<LLMModel[]>([]) const [installedModels, setInstalledModels] = useState<LLMModel[]>([])
const [activeModel, setActiveModel] = useState<string>('gemma2:2b') const [activeModel, setActiveModel] = useState<string>('gemma4:e4b')
const [checkingOllama, setCheckingOllama] = useState(false) const [checkingOllama, setCheckingOllama] = useState(false)
const [startingOllama, setStartingOllama] = useState(false) const [startingOllama, setStartingOllama] = useState(false)
const [ollamaMsg, setOllamaMsg] = useState<string | null>(null) const [ollamaMsg, setOllamaMsg] = useState<string | null>(null)
@ -498,7 +498,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<Button <Button
variant="contained" variant="contained"
startIcon={<Download size={14} />} startIcon={<Download size={14} />}
onClick={() => handlePullModel('gemma2:2b')} onClick={() => handlePullModel('gemma4:e4b')}
disabled={!isOllamaConnected} disabled={!isOllamaConnected}
sx={{ sx={{
fontWeight: 500, fontWeight: 500,

View file

@ -69,6 +69,18 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
const [downloadingModelId, setDownloadingModelId] = useState<string | null>(null) const [downloadingModelId, setDownloadingModelId] = useState<string | null>(null)
const [downloadPercent, setDownloadPercent] = useState<number>(0) const [downloadPercent, setDownloadPercent] = useState<number>(0)
// 로컬 AI 런타임(사이드카 엔진/ffmpeg) — 설치본에는 없고 필요할 때 내려받는다
type RuntimeComponentName = 'sidecar' | 'ffmpeg'
interface RuntimeStatusRow {
component: RuntimeComponentName
installed: boolean
path: string
sizeBytes: number
}
const [runtimeStatus, setRuntimeStatus] = useState<RuntimeStatusRow[]>([])
const [runtimeBusy, setRuntimeBusy] = useState<RuntimeComponentName | null>(null)
const [runtimePercent, setRuntimePercent] = useState(0)
// Test connection state // Test connection state
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; latencyMs: number; message: string } | null>(null) 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 () => { return () => {
unsubDownload() unsubDownload()
} }
unsubDownload()
unsubRuntime()
}, []) }, [])
// Load specific provider config when activeProvider changes // Load specific provider config when activeProvider changes
@ -133,6 +166,19 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
[activeProvider, providerConfig] [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 () => { const handleTestConnection = useCallback(async () => {
setTesting(true) setTesting(true)
setTestResult(null) setTestResult(null)
@ -364,6 +410,55 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
} }
return null return null
})()} })()}
{/* 로컬 AI 런타임(엔진/ffmpeg): 설치본에는 없고 처음 필요할 때 내려받는다 */}
{runtimeStatus.map((row) => {
const busy = runtimeBusy === row.component
return (
<Paper
key={row.component}
elevation={0}
sx={{
p: 1.5,
bgcolor: d3roPalette.bg.elevated,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.small,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{row.component === 'sidecar'
? '로컬 음성 엔진 (faster-whisper)'
: '미디어 변환기 (ffmpeg)'}
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
{row.installed
? `설치됨 · ${Math.round(row.sizeBytes / 1_000_000)} MB`
: busy
? `다운로드 중 (${runtimePercent}%)`
: '설치되지 않음 — 로컬 전사에 필요합니다'}
</Typography>
</Box>
{busy ? (
<CircularProgress size={16} />
) : (
<Button
size="small"
variant={row.installed ? 'outlined' : 'contained'}
startIcon={<HardDriveDownload size={14} />}
onClick={() => handleEnsureRuntime(row.component)}
sx={{ fontFamily: d3roFontMono, fontSize: '11px' }}
>
{row.installed ? '다시 설치' : '내려받기'}
</Button>
)}
</Paper>
)
})}
</Box> </Box>
) : ( ) : (
/* Cloud STT (OpenAI, Groq, Deepgram, AssemblyAI, Google, Custom) 설정 */ /* Cloud STT (OpenAI, Groq, Deepgram, AssemblyAI, Google, Custom) 설정 */

View file

@ -723,7 +723,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
: 'Ollama 오프라인 (서버 실행 필요)'} : 'Ollama 오프라인 (서버 실행 필요)'}
</Typography> </Typography>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.secondary }}> <Typography sx={{ fontSize: '11px', color: d3roPalette.text.secondary }}>
: {ollamaModels.length} | : {config.llmModelId ?? 'gemma2:2b'} : {ollamaModels.length} | : {config.llmModelId ?? 'gemma4:e4b'}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@ -800,7 +800,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
<InputLabel> Ollama </InputLabel> <InputLabel> Ollama </InputLabel>
<Select <Select
label="활성 Ollama 모델" label="활성 Ollama 모델"
value={config.llmModelId ?? 'gemma2:2b'} value={config.llmModelId ?? 'gemma4:e4b'}
onChange={(e) => { onChange={(e) => {
const val = e.target.value const val = e.target.value
updateConfig('llmModelId', val) updateConfig('llmModelId', val)
@ -815,7 +815,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
))} ))}
{/* 설치된 모델 목록이 없거나 기본 추천 추가 */} {/* 설치된 모델 목록이 없거나 기본 추천 추가 */}
{ollamaModels.length === 0 && ( {ollamaModels.length === 0 && (
<MenuItem value="gemma2:2b">gemma2:2b ()</MenuItem> <MenuItem value="gemma4:e4b">gemma4:e4b ()</MenuItem>
)} )}
{!ollamaModels.some((m) => m.name === 'llama3.2:3b') && ( {!ollamaModels.some((m) => m.name === 'llama3.2:3b') && (
<MenuItem value="llama3.2:3b">llama3.2:3b</MenuItem> <MenuItem value="llama3.2:3b">llama3.2:3b</MenuItem>

View file

@ -12,6 +12,7 @@
<div id="lines"></div> <div id="lines"></div>
</div> </div>
</div> </div>
<script src="./script.js"></script> <!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다(누락 시 자막이 렌더되지 않는다). -->
<script type="module" src="./script.js"></script>
</body> </body>
</html> </html>

View file

@ -19,6 +19,7 @@
</div> </div>
</div> </div>
</div> </div>
<script src="./script.js"></script> <!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
<script type="module" src="./script.js"></script>
</body> </body>
</html> </html>

View file

@ -17,6 +17,7 @@
</div> </div>
</div> </div>
</div> </div>
<script src="./script.js"></script> <!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
<script type="module" src="./script.js"></script>
</body> </body>
</html> </html>

View file

@ -34,6 +34,7 @@
</div> </div>
</div> </div>
</div> </div>
<script src="./script.js"></script> <!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
<script type="module" src="./script.js"></script>
</body> </body>
</html> </html>

View file

@ -23,6 +23,7 @@
</div> </div>
</div> </div>
</div> </div>
<script src="./script.js"></script> <!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
<script type="module" src="./script.js"></script>
</body> </body>
</html> </html>

View file

@ -151,8 +151,8 @@ def versionSettingsValid = configuredVersionName != null &&
configuredVersionName ==~ strictSemver && configuredVersionName ==~ strictSemver &&
configuredVersionCodeValue != null && configuredVersionCodeValue != null &&
configuredVersionCodeValue <= 2100000000L configuredVersionCodeValue <= 2100000000L
def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.0" def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.7"
def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1030001 def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031007
def requiredReleaseSettings = [ def requiredReleaseSettings = [
D3RO_RELEASE_STORE_FILE: releaseStoreFilePath, D3RO_RELEASE_STORE_FILE: releaseStoreFilePath,

View file

@ -257,7 +257,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1030001; CURRENT_PROJECT_VERSION = 1031007;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = D3ROVoice/Info.plist; INFOPLIST_FILE = D3ROVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@ -265,7 +265,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.3.7;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@ -287,14 +287,14 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1030001; CURRENT_PROJECT_VERSION = 1031007;
INFOPLIST_FILE = D3ROVoice/Info.plist; INFOPLIST_FILE = D3ROVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.3.7;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",

View file

@ -0,0 +1 @@
Added a signing-free install path: the download is served in verifiable parts and installed after checking them.

View file

@ -0,0 +1 @@
Reworked the install layout so updates flow again; required components are verified and fetched only when first needed.

View file

@ -0,0 +1 @@
Fixed an installer that could not start, and updated the default local model to the current one.

View file

@ -0,0 +1 @@
Fixed an installer that was missing its update configuration.

View file

@ -0,0 +1 @@
Updated the recommended local model list.

View file

@ -0,0 +1 @@
Stability improvements.

View file

@ -0,0 +1 @@
Overlay fixes: the recording waveform/timer and live captions now display correctly.

View file

@ -0,0 +1 @@
서명 인증서 없이도 설치할 수 있는 배포 경로를 추가했습니다. 설치 파일이 나뉘어 제공되고 검증 후에 설치됩니다.

View file

@ -0,0 +1 @@
업데이트가 정상 동작하도록 설치 구조를 정리했습니다. 필요한 기능은 처음 사용할 때 검증하여 내려받습니다.

View file

@ -0,0 +1 @@
실행이 안 되던 설치 문제를 고쳤습니다. 로컬 모델 기본값도 최신 모델로 정리했습니다.

View file

@ -0,0 +1 @@
자동 업데이트 설정이 누락되던 문제를 고쳤습니다.

View file

@ -0,0 +1 @@
로컬 모델 추천 목록을 최신 모델로 정리했습니다.

View file

@ -0,0 +1 @@
안정성을 개선했습니다.

View file

@ -0,0 +1 @@
오버레이 수정: 녹음 파형/타이머와 실시간 자막이 정상 표시됩니다.

View file

@ -1,12 +1,12 @@
{ {
"name": "@d3ro/mobile-rn", "name": "@d3ro/mobile-rn",
"version": "1.3.0", "version": "1.3.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@d3ro/mobile-rn", "name": "@d3ro/mobile-rn",
"version": "1.3.0", "version": "1.3.7",
"dependencies": { "dependencies": {
"@d3ro/api-client": "file:../../packages/api-client", "@d3ro/api-client": "file:../../packages/api-client",
"@d3ro/core": "file:../../packages/core", "@d3ro/core": "file:../../packages/core",
@ -62,7 +62,7 @@
}, },
"../..": { "../..": {
"name": "d3ro-voice-monorepo", "name": "d3ro-voice-monorepo",
"version": "1.3.0", "version": "1.3.7",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"apps/desktop", "apps/desktop",
@ -81,7 +81,7 @@
}, },
"../../packages/api-client": { "../../packages/api-client": {
"name": "@d3ro/api-client", "name": "@d3ro/api-client",
"version": "1.3.0", "version": "1.3.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@d3ro/core": "*", "@d3ro/core": "*",
@ -98,7 +98,7 @@
}, },
"../../packages/core": { "../../packages/core": {
"name": "@d3ro/core", "name": "@d3ro/core",
"version": "1.3.0", "version": "1.3.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"docx": "^9.6.1" "docx": "^9.6.1"
@ -109,7 +109,7 @@
}, },
"../../packages/i18n": { "../../packages/i18n": {
"name": "@d3ro/i18n", "name": "@d3ro/i18n",
"version": "1.3.0", "version": "1.3.7",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
@ -120,7 +120,7 @@
}, },
"../../packages/ui-native": { "../../packages/ui-native": {
"name": "@d3ro/ui-native", "name": "@d3ro/ui-native",
"version": "1.3.0", "version": "1.3.7",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "*" "@types/react": "*"

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/mobile-rn", "name": "@d3ro/mobile-rn",
"version": "1.3.0", "version": "1.3.7",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/web", "name": "@d3ro/web",
"version": "1.3.0", "version": "1.3.7",
"private": true, "private": true,
"description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스", "description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스",
"scripts": { "scripts": {

View file

@ -139,7 +139,7 @@ export function Sidebar(): React.ReactElement {
</PhosphorText> </PhosphorText>
</Box> </Box>
<Box sx={{ fontSize: '10px', fontFamily: 'ui-monospace, monospace', color: d3roPalette.text.muted }}> <Box sx={{ fontSize: '10px', fontFamily: 'ui-monospace, monospace', color: d3roPalette.text.muted }}>
v1.3.0 v1.3.7
</Box> </Box>
</Box> </Box>

View file

@ -4,10 +4,10 @@
// 설치 파일은 canonical Forgejo feed에서만 배포한다. 웹/사이트 빌드 산출물에는 // 설치 파일은 canonical Forgejo feed에서만 배포한다. 웹/사이트 빌드 산출물에는
// 설치 바이너리가 포함되지 않으므로 `/releases/...` 로컬 경로는 배포 환경에서 404다. // 설치 바이너리가 포함되지 않으므로 `/releases/...` 로컬 경로는 배포 환경에서 404다.
export const DESKTOP_VERSION = '1.2.0' export const DESKTOP_VERSION = '1.3.7'
/** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */ /** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */
export const DESKTOP_RELEASE_DATE = '2026-09-16' export const DESKTOP_RELEASE_DATE = '2026-09-19'
const FORGEJO_ORIGIN = 'https://git.chanpaca.net' const FORGEJO_ORIGIN = 'https://git.chanpaca.net'
const FORGEJO_OWNER = 'yunchan' const FORGEJO_OWNER = 'yunchan'

18
bucket/README.md Normal file
View file

@ -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`로 새 버전을 받을 수 있습니다.

35
bucket/d3ro-voice.json Normal file
View file

@ -0,0 +1,35 @@
{
"version": "1.3.5",
"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.5/D3RO-Voice-1.3.5-x64-portable.7z.001"
],
"hash": [
"45ecac82d2665cd64dd4a6edcfed1fa7874dce7116608664e0ba795a1c1509e7"
]
}
},
"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.5-x64-portable.7z.001"
]
}
}
}
}

View file

@ -1,41 +1,41 @@
# D3RO Voice 릴리스 가이드 # 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 identity
| 항목 | 정본 | 현재 판정 | | 항목 | 정본 | 현재 판정 |
|---|---|---| |---|---|---|
| 제품 버전 | `release/product-version.json`: `1.2.0` | source SSOT 확정 | | 제품 버전 | `release/product-version.json`: `1.3.0` | source SSOT 확정 |
| Android | versionCode `1020001` | production AAB 미생성 | | Android | versionCode `1030001` | production AAB 미생성 |
| iOS | build `1020001` | production archive 미검증 | | 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 대조 대기 | | 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·복구 백업 대기 | | 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 주입 대기 | | 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 생성 필요 | | Firebase | Console `u/0`, `u/1` 모두 D3RO project 없음 | 사용자 승인 후 project·Android app 생성 필요 |
| AdMob | app `ca-app-pub-1039714767792854~6427959892`; banner `/9840591290`; rewarded `/2255790918` | SSOT 확정. `검토 필요`·`광고 게재 제한`·store 미연결·결제 프로필 미완료 | | 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 | | 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]` | 태그 전 확정·검증 필수 | | release notes | `CHANGELOG.md` `## [1.3.0]` + Play changelog `1030001.txt` (ko/en) | 태그 전 확정·검증 필수 |
| 직전 게시본 | Forgejo Release `v1.1.0` (2026-09-15 게시, unsigned installer 포함) | 불변 태그. `1.2.0`은 이를 대체하는 forward-fix | | 직전 게시본 | 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 릴리스 파이프라인 ## desktop 릴리스 파이프라인
```text ```text
authoritative release commit authoritative release commit
→ version/check/test/build GREEN → version/check/test/build GREEN
→ annotated tag v1.2.0 → annotated tag v1.3.0
→ package-windows (build-win-x64) → package-windows (build-win-x64)
→ package-macos (build-mac-arm64) → package-macos (build-mac-arm64)
→ publish-release (build-linux-x64) → publish-release (build-linux-x64)
├─ publish-forgejo-release.mjs ← canonical ├─ 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 Generic Registry /d3ro-voice/latest/ (updater feed + update-policy.json)
│ └─ Forgejo Release + CHANGELOG notes + 자산 첨부 │ └─ Forgejo Release + CHANGELOG notes + 자산 첨부
└─ publish-gitlab-release.mjs ← legacy mirror (pre-Forgejo 설치본) └─ 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 Generic Registry /d3ro-voice/latest/
└─ GitLab Release └─ GitLab Release
``` ```
@ -50,7 +50,7 @@ authoritative release commit
포함되지 않는다. 사이트·웹 다운로드 센터는 로컬 경로가 아니라 feed URL을 포함되지 않는다. 사이트·웹 다운로드 센터는 로컬 경로가 아니라 feed URL을
링크한다. (역사적 `1.0.0` 자산만 추적 상태로 남아 있다.) 링크한다. (역사적 `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 계약을 검증한다. - `scripts/ci/verify-release-metadata.mjs`는 배포 메타데이터와 CI/publisher 계약을 검증한다.
- 같은 gate는 desktop license public key가 Ed25519이고 `release/product-version.json``desktopLicensePublicKeyId`와 일치하는지 검증한다. `electron.vite.config.ts`는 이 파일을 직접 읽으므로 누락·손상된 키로는 build가 시작되지 않는다. - 같은 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에도 올린다. - `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,83 @@ https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice
- **회수(rollback)**: `stagingPercentage`를 낮추거나 `killSwitch`를 켠다. 이미 배포된 버전은 되돌리지 않고 더 높은 patch로 forward-fix한다. - **회수(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/<versionCode>.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회 성공**을 확인한다.
## 자동 업데이트 게시 (런타임 분리 전제)
설치본은 **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에 기록).
- **네이티브 ABI 게이트 필수**: 패키징 후 `node scripts/ci/verify-native-abi.mjs`로 확인한다.
개발 PC에서 `npm install`을 돌리면 `better-sqlite3`가 Node ABI로 재빌드되어 설치본이
시작조차 못 한다(실측: 1.3.2). 로컬에서 실행 중인 Electron이 모듈을 잠그면
`node scripts/ci/fix-native-abi.mjs --dir <win-unpacked>`로 Electron ABI를 주입한 뒤
`--prepackaged`로 설치본을 만든다(검증된 트리에서만 패키징).
- 런타임은 `runtime-latest/runtime.json`이 정본이고 앱이 처음 필요할 때 내려받는다.
런타임을 바꾸면 반드시 `release:portable`로 먼저 게시한 뒤 설치본을 게시한다.
## 서명 없이 내놓기 (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`).
- 산출물은 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` 릴리스 절차 (기록)
1. `release/product-version.json`의 version/build 값과 모든 버전 면을 `npm run version:check`로 대조한다. 1. `release/product-version.json`의 version/build 값과 모든 버전 면을 `npm run version:check`로 대조한다.
2. `CHANGELOG.md` `## [1.2.0] - 2026-09-16` 섹션을 사용자 변경점 중심으로 확정한다. publisher는 이 섹션이 없으면 실패해야 한다. 2. `CHANGELOG.md` `## [1.2.0] - 2026-09-16` 섹션을 사용자 변경점 중심으로 확정한다. publisher는 이 섹션이 없으면 실패해야 한다.
@ -147,6 +223,9 @@ Desktop release를 게시해도 Android production 출시가 자동으로 완료
- `release/mobile-release-evidence-public.pem` — release evidence public key - `release/mobile-release-evidence-public.pem` — release evidence public key
- `apps/desktop/resources/license/production-public.pem` — desktop offline license public key SSOT - `apps/desktop/resources/license/production-public.pem` — desktop offline license public key SSOT
- `scripts/ci/sync-version.mjs` — 버전 면 동기화·검증 - `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/create-release-tag.mjs` — 릴리스 태그 게이트 (annotated/서명, 불변)
- `scripts/ci/verify-release-metadata.mjs` — release metadata 자가 검증 - `scripts/ci/verify-release-metadata.mjs` — release metadata 자가 검증
- `scripts/ci/verify-windows-release-artifact.ps1` — Windows version·updater metadata·Authenticode gate - `scripts/ci/verify-windows-release-artifact.ps1` — Windows version·updater metadata·Authenticode gate

View file

@ -0,0 +1,133 @@
# 서명 없이 배포하기 — 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/<v>/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 채널)
- 산출물 두 가지(같은 버라도 목적이 다름):
- **7z 분할 볼륨** `D3RO-Voice-<v>-x64-portable.7z.001/.002` — LZMA2로 688MB → 162MiB. Scoop 전용.
- **zip 분할 부품** `...zip.001/.002/.003` — 243MiB. 수동 설치 스크립트용. Windows 내장
`Expand-Archive`만으로 풀 수 있어 사용자가 7-Zip을 설치할 필요가 없다.
- 게시 위치: `.../generic/d3ro-voice/portable-<version>/``.../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` (피드에도 게시됨).
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
```
- 자동화: `.forgejo/workflows/portable.yml` (태그 + 수동 실행). `WIN_CSC_*`가 필요 없다.
- 커맨드: `npm run release:portable:build``npm run release:portable:check``npm run release:portable`
**안전 규칙(구현에 반영)**:
- 볼륨은 불변 — 같은 버전 경로에 다른 바이트가 있으면 게시 중단.
- 메타데이터(`portable.json`, 설치 스크립트)만 갱신 허용.
- 같은 버전을 다시 빌드하면 7z/zip 바이트가 달라져(내부 타임스탬프) 게시가 중단된다.
**한 버전의 portable 산출물은 한 번의 빌드에서만 나온다**. 태그 파이프라인(CI)이 최초
게시자가 되도록 하고, 로컬 재게시로 채널을 덮어쓰지 않는다. 이미 게시된 세트를 갱신해야 하면
버전을 올린다(이 문서 작성 시 `1.3.0` 부분 게시분을 `1.3.1`로 대체).
- 파일명에 `-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)
- 설치본에서 로컬 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-<version>` / `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`이 대체한다.
- 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`
존재 확인 → 시작 메뉴 바로가기 생성. (검증 후 테스트 설치/바로가기는 제거)
- 재게시 시도 시 동일 볼륨은 "이미 동일한 파일" 로 건너뛰는 것 확인(불변 가드 동작).
- 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`
확인 → 시작 메뉴 바로가기 생성. (검증 후 테스트 설치/바로가기 제거)

View file

@ -2,7 +2,8 @@
> Status: ACTIVE > Status: ACTIVE
> Last full audit: 2026-09-13 > Last full audit: 2026-09-13
> Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.3.0` > Last update: 2026-09-19 — GAP-INFRA-05 (desktop renderer popup bundle verification wired into CI); 1.3.7 published to the updater feed
> Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.3.7`
> Purpose: let any agent (or human) answer two questions in under a minute: > Purpose: let any agent (or human) answer two questions in under a minute:
> 1. **What infrastructure exists?** (build, CI, services, APIs, data, packages, deploy) > 1. **What infrastructure exists?** (build, CI, services, APIs, data, packages, deploy)
> 2. **How far is each feature developed?** (per surface, with file anchors and status) > 2. **How far is each feature developed?** (per surface, with file anchors and status)

View file

@ -78,6 +78,7 @@ npm run release:metadata[:test]
npm run release:forgejo[:check] # canonical Forgejo publisher/feed npm run release:forgejo[:check] # canonical Forgejo publisher/feed
npm run release:tag # annotated/signed immutable release tag npm run release:tag # annotated/signed immutable release tag
npm run security:secrets[:test] # hardcoded-secret scanner npm run security:secrets[:test] # hardcoded-secret scanner
npm run check:desktop-renderer[:test] # built renderer pages reference only assets on disk
npm run test:e2e:red # content-report red e2e npm run test:e2e:red # content-report red e2e
npm run release:mobile:boundary[:test] npm run release:mobile:boundary[:test]
npm run release:mobile:config[:test] npm run release:mobile:config[:test]
@ -136,11 +137,12 @@ See [`03-shared-packages.md`](./03-shared-packages.md). Summary:
### GitLab CI (`.gitlab-ci.yml`) ### GitLab CI (`.gitlab-ci.yml`)
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. 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. Every pipeline that runs `npm run build --workspace=@d3ro/desktop` (`.forgejo` release/portable, `.github` CI/release) then runs `scripts/ci/verify-desktop-renderer-bundles.mjs`, which fails packaging when a renderer page references an asset the build did not emit (GAP-INFRA-05).
### Forgejo Actions (`.forgejo/workflows/`) ### 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. `deploy-site.yml` / `deploy-site-windows.yml` — build `site`, write release identity, deploy to Cloudflare Pages `d3ro` (`d3ro.pages.dev`), verify live commit/version, app-links, legal URLs. 커스텀 도메인 `d3ro.chanpaca.net` 은 Pages 커스텀 도메인이 DNS CNAME을 요구하므로, DNS를 건드릴 수 없는 동안은 Workers 라우트 브리지 `server/cloudflare-site-bridge/`(`d3ro.chanpaca.net/*` → Pages 프록시, 수동 `npx wrangler deploy`)가 서빙한다. CNAME을 추가한 뒤 브리지를 삭제하면 Pages 커스텀 도메인으로 직접 서빙된다(GAP-REL-09b).
`release.yml` — tag-triggered Windows build (signed) + `publish-forgejo-release.mjs` to the canonical Forgejo feed/release hub. `release.yml` — tag-triggered Windows build (signed) + `publish-forgejo-release.mjs` to the canonical Forgejo feed/release hub.
--- ---
@ -157,7 +159,7 @@ Stages `validate → test → build → e2e → package → publish → deploy`.
Deploy scripts: `scripts/deploy-nas.ps1`, `scripts/deploy-nas.sh`, `scripts/deploy-site-to-nas.js`, `scripts/nas-control.sh` (start/stop/restart/status/logs/backup/update). Deploy scripts: `scripts/deploy-nas.ps1`, `scripts/deploy-nas.sh`, `scripts/deploy-site-to-nas.js`, `scripts/nas-control.sh` (start/stop/restart/status/logs/backup/update).
Public endpoints (production): `https://d3ro.chanpaca.net` (portal/API), `https://admin.chanpaca.net` (admin CRM). Edge: `server/cloudflare-worker` proxying to the NAS origin, plus a **Cron Trigger** (`* * * * *`) that drains the Supabase push outbox via `send-push?mode=drain` (`src/push-drain.ts`; needs `SUPABASE_URL` var + `SUPABASE_SERVICE_ROLE_KEY` secret). Tunnel: Cloudflare Tunnel `kd-nas`. Public endpoints (production): `https://d3ro.chanpaca.net` **랜딩/다운로드 센터**(2026-09-19부터 Pages `d3ro` 배포본을 Workers 라우트 브리지가 서빙; 그 이전에는 바인딩이 없어 빈 404였다), `https://admin.chanpaca.net` (admin CRM). Edge: `server/cloudflare-worker` proxying to the NAS origin, plus a **Cron Trigger** (`* * * * *`) that drains the Supabase push outbox via `send-push?mode=drain` (`src/push-drain.ts`; needs `SUPABASE_URL` var + `SUPABASE_SERVICE_ROLE_KEY` secret). Tunnel: Cloudflare Tunnel `kd-nas` (NAS 포털/API는 현재 이 호스트네임에 바인딩되어 있지 않다).
--- ---
@ -190,13 +192,14 @@ Full detail: [`09-supabase-backend.md`](./09-supabase-backend.md).
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| `release/product-version.json` | version `1.3.0`, `androidVersionCode`/`iosBuildNumber` `1030001`, releaseDate, desktop license keyId | | `release/product-version.json` | version `1.3.7`, `androidVersionCode`/`iosBuildNumber` `1031007`, releaseDate, desktop license keyId |
| `release/android-release-identity.json` | package `com.d3ro.voice`, Play app ID, app-signing SHA-256, upload cert SHA-256, evidence keyId, AdMob unit IDs | | `release/android-release-identity.json` | package `com.d3ro.voice`, Play app ID, app-signing SHA-256, upload cert SHA-256, evidence keyId, AdMob unit IDs |
| `release/desktop-license-public.pem` | Ed25519 public key for desktop offline licenses | | `release/desktop-license-public.pem` | Ed25519 public key for desktop offline licenses |
| `release/mobile-release-evidence-public.pem` | Ed25519 public key for mobile release evidence | | `release/mobile-release-evidence-public.pem` | Ed25519 public key for mobile release evidence |
| `apps/desktop/electron-builder.yml` | appId `com.d3ro.voice`, NSIS x64 (forced code signing), macOS DMG/ZIP arm64, generic Forgejo publish feed, asarUnpack native modules + `@ffmpeg-installer`, extraResources (icons, sounds, sox, **sidecar**, ffmpeg, ollama) | | `apps/desktop/electron-builder.yml` | appId `com.d3ro.voice`, NSIS x64 (forced code signing), macOS DMG/ZIP arm64, generic Forgejo publish feed, asarUnpack native modules + `@ffmpeg-installer`, extraResources (icons, sounds, sox, **sidecar**, ffmpeg, ollama) |
| `apps/desktop/src/main/update-feed.ts` | Auto-update feed SSOT (canonical Forgejo + legacy GitLab mirror, channels) | | `apps/desktop/src/main/update-feed.ts` | Auto-update feed SSOT (canonical Forgejo + legacy GitLab mirror, channels) |
| `release/update-policy.json` | Update policy SSOT (channels, minimum supported version, forced update, delta/full, staged rollout, kill switch) | | `release/update-policy.json` | Update policy SSOT (channels, minimum supported version, forced update, delta/full, staged rollout, kill switch) |
| `apps/web/src/lib/desktop-release.ts`, `site/src/release.ts` | Download-center desktop release contract (installer filename + release date); version and date are kept on the SSOT by `npm run version:sync` (drifted to 1.2.0 once — GAP-REL-08) |
| `apps/desktop/src/main/update-policy.ts` | Policy parsing/decision logic | | `apps/desktop/src/main/update-policy.ts` | Policy parsing/decision logic |
| `scripts/ci/publish-forgejo-release.mjs` | Canonical Forgejo registry + Release + feed publisher | | `scripts/ci/publish-forgejo-release.mjs` | Canonical Forgejo registry + Release + feed publisher |

View file

@ -146,6 +146,12 @@ Preload exposes **`window.electronAPI`** with 33 namespaces: `platform, audio, c
`windows/WindowManager.ts` creates 6 windows: main (borderless, custom TitleBar; macOS `hiddenInset`), recording-tip, result-popup, history-popup, command-popup, caption-overlay. Injects popup theme CSS + i18n strings; 2-phase resize. `windows/TrayManager.ts` — tray icon + menu + double-click show. `windows/WindowManager.ts` creates 6 windows: main (borderless, custom TitleBar; macOS `hiddenInset`), recording-tip, result-popup, history-popup, command-popup, caption-overlay. Injects popup theme CSS + i18n strings; 2-phase resize. `windows/TrayManager.ts` — tray icon + menu + double-click show.
**Popup invariants** (each shipped broken once — do not regress):
- 팝업 HTML의 스크립트는 반드시 `<script type="module">`로 선언한다. Vite는 모듈 스크립트만 번들에 포함하므로 classic `<script src="./script.js">`는 dev에서만 로드되고 패키징 산출물에서는 파일 자체가 사라진다(오버레이가 정적 HTML로 멈춘 원인). `scripts/ci/verify-desktop-renderer-bundles.mjs`가 빌드 HTML이 참조하는 모든 로컬 asset의 존재를 검사한다.
- 렌더러 로드 전의 `webContents.send`는 조용히 버려진다. 팝업 전송은 `sendToPopupWindow`를 쓰고, 이 함수가 `did-finish-load`까지 메시지를 보관했다가 전달한다. `attachPopupLifecycle`이 로드 상태 추적·테마 주입·팝업 렌더러 진단 로그를 한 곳에서 묶는다.
- 팝업 표시는 `presentPopup`으로 통일한다(`showInactive` + topmost 재선언 + `moveTop` + `webContents.invalidate`). 한 번 `hide()`된 팝업이 두 번째 표시에서 z-order/repaint를 잃어 보이지 않던 문제를 막는다.
Vanilla popups (`src/renderer/popups/`): Vanilla popups (`src/renderer/popups/`):
| Popup | Purpose | | Popup | Purpose |
|---|---| |---|---|

View file

@ -17,7 +17,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
| CAP-01 | Push-to-talk dictation (hold/release) | [x] | [-] | [x] | [-] | Desktop `VoiceModeService`; mobile RecordScreen via app CTA/notification action (no global hotkey) | | CAP-01 | Push-to-talk dictation (hold/release) | [x] | [-] | [x] | [-] | Desktop `VoiceModeService`; mobile RecordScreen via app CTA/notification action (no global hotkey) |
| CAP-02 | Hands-free toggle dictation | [x] | [-] | [x] | [-] | Desktop double-press; mobile toggle | | CAP-02 | Hands-free toggle dictation | [x] | [-] | [x] | [-] | Desktop double-press; mobile toggle |
| CAP-03 | Live partial transcript while recording | [x] | [ ] | [ ] | [-] | Desktop `voice:partialTranscript` + recording-tip; producer added in `1.3.0` (`VoiceModeService._runPartial``LocalSTTService.transcribePartial`, 1.5 s cadence / 7.5 s window, never inserted). The row was `[x]` before any producer existed. | | CAP-03 | Live partial transcript while recording | [x] | [ ] | [ ] | [-] | Desktop `voice:partialTranscript` + recording-tip; producer added in `1.3.0` (`VoiceModeService._runPartial``LocalSTTService.transcribePartial`, 1.5 s cadence / 7.5 s window, never inserted). The row was `[x]` before any producer existed. |
| CAP-04 | Recording waveform + level meter | [x] | [x] | [x] | [-] | Desktop 9-bar cos distribution; mobile audio level | | CAP-04 | Recording waveform + level meter | [x] | [x] | [x] | [-] | Desktop 9-bar cos distribution; mobile audio level; the recording-tip popup bundle and its on-disk assets are verified by `scripts/ci/verify-desktop-renderer-bundles.mjs` |
| CAP-05 | Device/mic selection | [x] | [ ] | [~] | [-] | Desktop config; mobile uses system default | | CAP-05 | Device/mic selection | [x] | [ ] | [~] | [-] | Desktop config; mobile uses system default |
| CAP-06 | System/loopback audio capture | [x] | [-] | [ ] | [-] | Desktop only (caption source); mobile policy-limited | | CAP-06 | System/loopback audio capture | [x] | [-] | [ ] | [-] | Desktop only (caption source); mobile policy-limited |
| CAP-07 | Local Whisper STT | [x] | [-] | [x] | [-] | Desktop ships the faster-whisper sidecar (`resources/sidecar`, built by `sidecar:build`, verified by `scripts/ci/verify-sidecar-bundle.mjs`), warms it up at app start, and connects over IPv4 loopback; mobile on-device Whisper (supported devices) | | CAP-07 | Local Whisper STT | [x] | [-] | [x] | [-] | Desktop ships the faster-whisper sidecar (`resources/sidecar`, built by `sidecar:build`, verified by `scripts/ci/verify-sidecar-bundle.mjs`), warms it up at app start, and connects over IPv4 loopback; mobile on-device Whisper (supported devices) |
@ -26,7 +26,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
| CAP-10 | STT model download/management UI | [x] | [-] | [~] | [-] | Desktop model manager + onboarding; mobile bundled model | | CAP-10 | STT model download/management UI | [x] | [-] | [~] | [-] | Desktop model manager + onboarding; mobile bundled model |
| CAP-11 | File transcription (audio/video) | [x] | [ ] | [x] | [~] | Desktop ffmpeg chunking; mobile import picker; web deferred | | CAP-11 | File transcription (audio/video) | [x] | [ ] | [x] | [~] | Desktop ffmpeg chunking; mobile import picker; web deferred |
| CAP-12 | Audio import from other apps (share intent) | [-] | [-] | [x] | [-] | Mobile Android `ACTION_SEND`/`ACTION_VIEW` (SSOT R-016 GREEN) | | CAP-12 | Audio import from other apps (share intent) | [-] | [-] | [x] | [-] | Mobile Android `ACTION_SEND`/`ACTION_VIEW` (SSOT R-016 GREEN) |
| CAP-13 | Live captions overlay | [x] | [-] | [-] | [-] | Desktop `CaptionService` + caption-overlay popup | | CAP-13 | Live captions overlay | [x] | [-] | [-] | [-] | Desktop `CaptionService` + caption-overlay popup; popup assets verified in the packaged build (`scripts/ci/verify-desktop-renderer-bundles.mjs`); GAP-INFRA-05 |
| CAP-14 | Recording persistence / crash recovery | [x] | [ ] | [x] | [-] | Desktop WAV persist; mobile durable queue + process-kill WAV recovery | | CAP-14 | Recording persistence / crash recovery | [x] | [ ] | [x] | [-] | Desktop WAV persist; mobile durable queue + process-kill WAV recovery |
| CAP-15 | Android foreground recording service | [-] | [-] | [x] | [-] | Mobile API 34 FGS + persistent notification (SSOT R-005 GREEN) | | CAP-15 | Android foreground recording service | [-] | [-] | [x] | [-] | Mobile API 34 FGS + persistent notification (SSOT R-005 GREEN) |
@ -193,6 +193,9 @@ 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-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-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-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, 업데이트 피드 게시 복구. 검증은 전부 디스크에 기록된 파일 기준이며(부품 크기·해시 → 결합본 크기·해시), 부품 다운로드는 최대 3회 재시도한다. 2026-09-18 실제 feed 통합 검증(엔진 94.4MiB/18초, ffmpeg 21.7MiB/5초). |
| INFRA-19 | 네이티브 ABI + updater 설정 게이트 | [x] | `scripts/ci/verify-native-abi.mjs`(패키징된 `better_sqlite3.node`가 Electron ABI인지 호스트 Node 로드 거부로 판별) + `scripts/ci/fix-native-abi.mjs`(로컬 잠금 우회용 주입). GitLab/Forgejo/GitHub 패키징 단계에 검증 삽입. 2026-09-18: Node ABI 모듈로 앱이 시작 즉시 죽은 사고 + 누락으로 자동 업데이트가 죽은 사고를 함께 방지(). |
--- ---

View file

@ -21,8 +21,15 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
| ID | Area | Gap | Evidence | Suggested next step | | ID | Area | Gap | Evidence | Suggested next step |
|---|---|---|---|---| |---|---|---|---|---|
| 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-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-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. 2026-09-19: `1.3.7` (overlay fix) is published to the canonical updater feed (`latest.yml` = 1.3.7, 90.6MiB); the CI signing gate that blocks tag-driven publication is still unresolved, so this went out through the local updater path (GAP-REL-06). |
| 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-08 | Release | 다운로드 센터가 **존재하지 않는 설치 파일**을 가리켰다. `apps/web/src/lib/desktop-release.ts``site/src/release.ts``DESKTOP_VERSION``1.2.0`에 멈춰 있어 설치 URL이 `D3RO-Voice-Setup-1.2.0-x64.exe`였고, 그 경로는 피드에서 404다(실측: 1.2.0=404, 1.3.7=206). `version:sync`가 이 두 표면을 덮지 않아 계속 어긋났다. | `scripts/ci/sync-version.mjs`, `apps/web/src/lib/desktop-release.ts`, `site/src/release.ts` | `[x]` 2026-09-19: 두 다운로드 계약 파일을 `sync-version.mjs` 대상에 추가해 버전·릴리스일이 SSOT에서 자동 반영되도록 하고, 현재 값(1.3.7 / 2026-09-19)으로 정정했다. `version:check`·typecheck·site 빌드 GREEN. |
| GAP-REL-09 | Release | 랜딩 사이트가 **재배포되지 않는다**. `deploy` 워크플로가 main push마다 실패한다. 실측 원인(run#66 로그): `site/src/sections/Hero.tsx`가 타이머 ref를 `NodeJS.Timeout`으로 타이핑해 `@types/node` 네임스페이스가 필요했고, 배포 잡은 `npm ci --prefix site`만 하므로 조상 `node_modules`의 hoisted 타입이 없어 `tsc -b``TS2503: Cannot find namespace 'NodeJS'`로 실패한다. 그래서 `https://d3ro.chanpaca.net/release-identity.json`이 404다(공개 버전 검증 불가). | `.forgejo/workflows/deploy-site.yml`, `site/src/sections/Hero.tsx` | `[x]` 2026-09-19: ref를 `ReturnType<typeof setTimeout>`으로 바꿔 hoisted 타입 의존을 제거했다(격리 `--typeRoots`로 CI 조건 재현 → 수정 전 TS2503, 수정 후 clean). 같은 수정을 push하자 `deploy` run#67이 사이트 빌드를 통과해 `dist/`를 만들었고, 실패는 다음 단계(Cloudflare)로 이동했다. |
| GAP-REL-09b | Release | `d3ro.chanpaca.net`이 404였던 직접 원인: 이 Cloudflare 계정에 Pages 프로젝트 `d3ro`/`d3ro-voice`**존재하지 않아** 커스텀 도메인 바인딩이 없었다(빈 본문 404, `cf-ray`만 반환). Pages 커스텀 도메인은 존 DNS CNAME(`d3ro → d3ro.pages.dev`)을 요구하는데 기존 `d3ro` 레코드가 남아 있어 `CNAME record not set`으로 pending에 머물렀고, 로컬 wrangler 자격증명에는 DNS 스코프가 없다(403 Authentication error). `deploy-site.yml``CF_API_TOKEN` 시크릿이 없어 마지막 게시 단계에서도 `exit 1`이다. | `server/cloudflare-site-bridge/`, `.forgejo/workflows/deploy-site.yml`, `docs/map/02-infrastructure.md` | `[x]` 2026-09-19: Pages 프로젝트 `d3ro` 생성 + `site/dist` production 배포(`d3ro.pages.dev` 200, `release-identity.json` = commit `2407f5a` / 1.3.7) + 커스텀 도메인 연결. DNS 없이 도메인을 살리기 위해 Workers 라우트 브리지(`server/cloudflare-site-bridge`, `d3ro.chanpaca.net/*` → Pages 프록시, `npx wrangler deploy`)를 배포 → 라이브 확인: `/`·`/privacy/`·`/terms/`·`/delete-account/` 200, 라이브 번들이 설치 파일명을 `1.3.7`로 계산, `/download.html``/#download`. 남은 정리 2건: (1) 대시보드에 CNAME을 추가한 뒤 브리지 워커 삭제, (2) CI 자동 게시를 위해 `CF_API_TOKEN`(Pages/Workers Edit) + `CF_ACCOUNT_ID`=`8e83cc130e7329c160cf2b88d6b4c20a`를 Forgejo 시크릿에 등록. |
| GAP-REL-10 | Release | `release-windows`(태그 파이프라인)는 서명 가드에 도달하기 **전에** sidecar 단계에서 죽는다. 이 러너 컨텍스트에서는 `sidecar:setup`이 Python 3.11+를 찾지 못한다(`Python 3.11+ 를 찾을 수 없습니다`) → `sidecar:build``verify-sidecar-bundle.mjs` 연쇄 실패(실측: run#65 `v1.3.7`, run#61 `v1.3.6`). 같은 러너의 portable 잡은 `py -3.11 → Python 3.11.9`를 찾아 사이드카 빌드에 성공하므로, 워크플로/컨테이너 간 PATH 차이다. | `.forgejo/workflows/release.yml`, `apps/desktop/scripts/setup-sidecar.mjs` | `[!]` 2026-09-19: 러너에 Python 3.11+(`py` 런처 포함)를 보장하거나 워크플로에 `actions/setup-python` 단계를 추가한다. 그 전까지 서명 게시는 불가능하다(GAP-REL-02와 별개 선행 차단). |
| GAP-REL-11 | Release | portable 워크플로의 마지막 `actions/upload-artifact@v4` 단계가 Forgejo 러너에서 `GHESNotSupportedError`로 실패한다(증거 보존만 실패, 게시는 성공). | `.forgejo/workflows/portable.yml` | `[x]` 2026-09-19: `v1.3.7` portable 게시는 run#64에서 성공(7z 단일 볼륨 83.7MB + zip 2부, `portable-latest/portable.json`이 1.3.7 보고). 남은 조치: upload-artifact 단계를 제거하거나 v3/다른 보존 방식으로 바꿔 워크플로를 GREEN으로 만든다. |
| 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). **2026-09-19 정정**: CI 서명 게이트는 여전히 막혀 있지만, updater feed에는 `1.3.2`~`1.3.7`이 로컬 `release:updater` 경로로 게시되어 있다(GAP-REL-06). |
| 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`(명시적 승인 플래그)로 게시. **2026-09-19: `1.3.7`도 같은 경로로 게시**(`npm run release:updater -- --ack-unsigned`, 설치본 90.6MiB, `latest.yml`=1.3.7, 설치본 sha512가 피드 메타데이터와 일치). 인증서 확보 시 더 높은 버전으로 서명 게시하여 대체하고, 이 예외를 제거한다. |
| GAP-REL-07 | Release | 패키징된 `better-sqlite3`가 호스트 Node ABI여서 `1.3.2` 설치본이 시작 즉시 죽었다(NODE_MODULE_VERSION 131 vs 130). 원인: 로컬 `npm install`이 네이티브 모듈을 Node용으로 재빌드했고 패키징이 재빌드를 건너었다. | `scripts/ci/verify-native-abi.mjs`, `scripts/ci/fix-native-abi.mjs`, `.gitlab-ci.yml`/`.forgejo`/`.github` 패키징 단계 | `[x]` 2026-09-18: 패키징 후 Electron ABI를 검증하고, 검증된 트리에서만 설치본을 생성(`--prepackaged`)한다. `1.3.3`은 설치본에서 추출한 바이너리로 재검증 GREEN. |
| 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-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-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. | | 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,7 +54,13 @@ 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-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-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-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는 건드리지 않음. **2026-09-19: `1.3.7`이 CI portable run#64에서 `portable-latest`에 게시됨**(7z 단일 볼륨 83.7MB + zip 2부, `portable.json`=1.3.7) — 즉 portable 경로는 CI만으로 동작하고, 실패한 것은 마지막 증거 업로드 단계다(GAP-REL-11). |
| 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). | | 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). |
| GAP-STT-08 | Local STT | 1.3.5 설치본에서 엔진 설치가 "런타임 아카이브 해시 불일치 (sidecar)"로 항상 실패했다. 부품 검증은 **메모리 스트림**에서 센 값으로, 결합 검증은 **디스크 파일**에서 계산해 기준이 서로 달랐다. 디스크 쓰기가 잘려도 부품 검사를 통과하고 결합 단계에서만 터지므로 원인 파악도 불가능했다. 재시도가 없어 전송이 한 번 끊기면 곧바로 설치 실패였다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts` | `[x]` 2026-09-18: 부품 크기·해시를 디스크 파일 기준으로 통일하고, 결합본은 크기를 먼저 검사한 뒤 해시를 본다(오류 메시지에 실제/기대값 포함). 부품 다운로드는 실패 시 해당 파일을 지우고 최대 3회 재시도한다. 서버 아티팩트는 무결함을 확인했고(부품 2개 해시 일치, 결합본 `e203aa53…` = 인덱스 기대값), 실제 feed로 설치를 재현해 18초 만에 성공. **1.3.6으로 게시 완료**`latest.yml`이 1.3.6/90.6MiB를 서빙하고 설치본 sha512가 피드 메타데이터와 일치. 설치본 asar에 수정 코드가 포함되고 구버전 `archiveHash` 경로는 제거됨을 확인. |
| GAP-INFRA-05 | Build | 패키징된 렌더러 팝업 스크립트가 번들에 없었다. 팝업 HTML이 classic `<script src="./script.js">`를 참조해 Vite가 처리하지 않았고, dev에서는 로드되지만 설치본에는 파일이 없었다. 그래서 녹음 오버레이가 0:00에서 멈추고 웨이브 바가 뜨지 않았으며 실시간 자막이 렌더되지 않았다. 로드 전 `webContents.send`가 조용히 버려지는 문제와 `hide()` 이후 재표시의 z-order/repaint 유실도 함께 있었다. | `apps/desktop/src/renderer/popups/*/index.html`, `apps/desktop/src/main/windows/WindowManager.ts`, `scripts/ci/verify-desktop-renderer-bundles.mjs` | `[x]` 2026-09-19: 팝업 5종을 `type="module"`로 전환해 Vite가 해시된 번들로 방출하도록 고쳤고, 빌드 HTML이 참조하는 모든 로컬 asset이 디스크에 있는지 검사하는 `verify-desktop-renderer-bundles.mjs`(+ self-test)를 `.forgejo`/`.github` 패키징 파이프라인에 연결했다. WindowManager는 렌더러 준비 전 IPC를 `did-finish-load`까지 보관하고, 팝업을 표시할 때마다 topmost 재선언 + 강제 repaint를 수행하며, 팝업 렌더러 콘솔/로드 실패를 main 로그로 승격한다. |
--- ---

View file

@ -1,5 +1,57 @@
# D3RO-VOICE 프로젝트 현황 # D3RO-VOICE 프로젝트 현황
## v1.3.3 — 배포본 시작 실패(네이티브 ABI) + gemma4 기본값 (2026-09-18) 🛠️
배포한 1.3.2 설치본이 **시작 즉시 죽었다**: `NODE_MODULE_VERSION 131 ... requires 130`.
- 원인: 로컬에서 `npm install`(ffmpeg/tar)을 돌리면서 `better-sqlite3`가 **Node ABI(131)**로 재빌드됐고,
패키징이 네이티브 재빌드를 건너뛰어(npmRebuild=false) 그 모듈이 설치본에 들어갔다. 또 그 설치는
예전 경로(`Programs\@d3rodesktop`)를 덮어써 1.0.0을 망가뜨렸다.
- 대책: `scripts/ci/verify-native-abi.mjs`(패키징된 모듈이 Electron ABI인지 호스트 Node 로드 거부로
판별) + `fix-native-abi.mjs`(로컬 잠금 우회 주입). 설치본은 **검증된 트리에서만** 생성(`--prepackaged`).
GitLab/Forgejo/GitHub 패키징 단계에 게이트 삽입. 검증: 1.3.3 설치본을 7za로 풀어 그 안의 바이너리로
재검증 → Electron ABI GREEN, 피드 sha512가 로컬 빌드와 일치.
- 함께 수정: 로컬 모델 기본값이 레거시 `*.gguf`였고 UI가 `gemma2:2b`를 추천하던 것을 **gemma4:e4b**로
통일(ConfigService 기본값, LocalLLMService 폴백/상태, 설정·온보딩·Ollama 가이드).
- `publish-*`의 "동일 파일 건너뛰기"가 **크기만 비교**해 버전만 바뀐 `latest.yml`을 놓쳤다(피드가 1.3.2로
남는 사고) → 1MiB 이하는 내용까지 비교하도록 수정.
- 배포: `1.3.3` 업데이터 피드 게시 완료(`latest.yml` = 1.3.3). 기존 설치본은 1.3.3을 1회 수동 설치하면 이후 자동.
---
## 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-<v>`/`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) 🎙️ ## v1.3.0 — 로컬 전사가 실제로 동작하게 (2026-09-18) 🎙️
설치본에서 로컬 전사가 **한 번도 성공한 적 없던** 원인을 끝까지 추적해 수정. 엔진 자체는 설치본에서 로컬 전사가 **한 번도 성공한 적 없던** 원인을 끝까지 추적해 수정. 엔진 자체는

109
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "d3ro-voice-monorepo", "name": "d3ro-voice-monorepo",
"version": "1.3.0", "version": "1.3.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "d3ro-voice-monorepo", "name": "d3ro-voice-monorepo",
"version": "1.3.0", "version": "1.3.7",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"apps/desktop", "apps/desktop",
@ -25,7 +25,7 @@
}, },
"apps/admin": { "apps/admin": {
"name": "@d3ro/admin", "name": "@d3ro/admin",
"version": "1.3.0", "version": "1.3.7",
"dependencies": { "dependencies": {
"@d3ro/api-client": "*", "@d3ro/api-client": "*",
"@d3ro/core": "*", "@d3ro/core": "*",
@ -109,7 +109,7 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "@d3ro/desktop", "name": "@d3ro/desktop",
"version": "1.3.0", "version": "1.3.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@d3ro/core": "*", "@d3ro/core": "*",
@ -135,6 +135,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tar": "^7.5.13",
"uiohook-napi": "^1.5.5" "uiohook-napi": "^1.5.5"
}, },
"devDependencies": { "devDependencies": {
@ -157,7 +158,7 @@
}, },
"apps/web": { "apps/web": {
"name": "@d3ro/web", "name": "@d3ro/web",
"version": "1.3.0", "version": "1.3.7",
"dependencies": { "dependencies": {
"@d3ro/api-client": "*", "@d3ro/api-client": "*",
"@d3ro/core": "*", "@d3ro/core": "*",
@ -360,7 +361,7 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -441,7 +442,7 @@
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", "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==", "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@ -457,7 +458,7 @@
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@ -2519,7 +2520,6 @@
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"minipass": "^7.0.4" "minipass": "^7.0.4"
@ -3537,7 +3537,7 @@
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.stat": "2.0.5", "@nodelib/fs.stat": "2.0.5",
@ -3551,7 +3551,7 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
@ -3561,7 +3561,7 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.scandir": "2.1.5", "@nodelib/fs.scandir": "2.1.5",
@ -6281,7 +6281,7 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@ -6426,7 +6426,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -6505,7 +6504,7 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
"integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"restore-cursor": "^3.1.0" "restore-cursor": "^3.1.0"
@ -6518,7 +6517,7 @@
"version": "2.9.2", "version": "2.9.2",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -6583,7 +6582,7 @@
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
"integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.8" "node": ">=0.8"
@ -7486,7 +7485,7 @@
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
"integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"clone": "^1.0.2" "clone": "^1.0.2"
@ -7996,7 +7995,7 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.1",
@ -8472,7 +8471,7 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@ -9065,7 +9064,7 @@
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"devOptional": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"reusify": "^1.0.4" "reusify": "^1.0.4"
@ -9255,7 +9254,7 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"locate-path": "^6.0.0", "locate-path": "^6.0.0",
@ -9460,7 +9459,7 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
@ -9485,7 +9484,7 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dunder-proto": "^1.0.1", "dunder-proto": "^1.0.1",
@ -9733,7 +9732,7 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -10198,7 +10197,7 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -10223,7 +10222,7 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@ -10246,7 +10245,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
"integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -10297,7 +10296,7 @@
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -10801,7 +10800,7 @@
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"p-locate": "^5.0.0" "p-locate": "^5.0.0"
@ -10856,7 +10855,7 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
"integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chalk": "^4.1.0", "chalk": "^4.1.0",
@ -11014,7 +11013,7 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -11946,7 +11945,7 @@
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"mime": "cli.js" "mime": "cli.js"
@ -11959,7 +11958,7 @@
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
@ -11969,7 +11968,7 @@
"version": "2.1.35", "version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mime-db": "1.52.0" "mime-db": "1.52.0"
@ -11982,7 +11981,7 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -12053,7 +12052,6 @@
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@ -12193,7 +12191,6 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"minipass": "^7.1.2" "minipass": "^7.1.2"
@ -12586,7 +12583,7 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mimic-fn": "^2.1.0" "mimic-fn": "^2.1.0"
@ -12620,7 +12617,7 @@
"version": "5.4.1", "version": "5.4.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
"integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bl": "^4.1.0", "bl": "^4.1.0",
@ -12678,7 +12675,7 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"yocto-queue": "^0.1.0" "yocto-queue": "^0.1.0"
@ -12694,7 +12691,7 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"p-limit": "^3.0.2" "p-limit": "^3.0.2"
@ -12841,7 +12838,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -13408,7 +13405,7 @@
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"devOptional": true, "dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -14616,7 +14613,7 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
"integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"onetime": "^5.1.0", "onetime": "^5.1.0",
@ -14640,7 +14637,7 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"iojs": ">=1.0.0", "iojs": ">=1.0.0",
@ -14749,7 +14746,7 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"devOptional": true, "dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -15580,7 +15577,6 @@
"version": "7.5.13", "version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/fs-minipass": "^4.0.0", "@isaacs/fs-minipass": "^4.0.0",
@ -15645,7 +15641,6 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -16047,7 +16042,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -16564,7 +16559,7 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
"integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"defaults": "^1.0.3" "defaults": "^1.0.3"
@ -16845,7 +16840,7 @@
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -16866,7 +16861,7 @@
}, },
"packages/api-client": { "packages/api-client": {
"name": "@d3ro/api-client", "name": "@d3ro/api-client",
"version": "1.3.0", "version": "1.3.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@d3ro/core": "*", "@d3ro/core": "*",
@ -16883,7 +16878,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@d3ro/core", "name": "@d3ro/core",
"version": "1.3.0", "version": "1.3.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"docx": "^9.6.1" "docx": "^9.6.1"
@ -16894,7 +16889,7 @@
}, },
"packages/i18n": { "packages/i18n": {
"name": "@d3ro/i18n", "name": "@d3ro/i18n",
"version": "1.3.0", "version": "1.3.7",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
@ -16905,7 +16900,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@d3ro/ui", "name": "@d3ro/ui",
"version": "1.3.0", "version": "1.3.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@d3ro/core": "*" "@d3ro/core": "*"
@ -16925,7 +16920,7 @@
}, },
"packages/ui-native": { "packages/ui-native": {
"name": "@d3ro/ui-native", "name": "@d3ro/ui-native",
"version": "1.3.0", "version": "1.3.7",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "*" "@types/react": "*"

View file

@ -1,6 +1,6 @@
{ {
"name": "d3ro-voice-monorepo", "name": "d3ro-voice-monorepo",
"version": "1.3.0", "version": "1.3.7",
"private": true, "private": true,
"description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)", "description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)",
"author": "D3RO", "author": "D3RO",
@ -26,10 +26,17 @@
"release:forgejo:local": "node --env-file-if-exists=.env scripts/ci/publish-forgejo-release.mjs", "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:forgejo:check": "node scripts/ci/publish-forgejo-release.mjs --check",
"release:tag": "node scripts/ci/create-release-tag.mjs", "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", "security:secrets": "node scripts/ci/check-no-hardcoded-secrets.mjs",
"security:secrets:test": "node scripts/ci/check-no-hardcoded-secrets.mjs --self-test", "security:secrets:test": "node scripts/ci/check-no-hardcoded-secrets.mjs --self-test",
"check:design": "node scripts/ci/check-design-tokens.mjs", "check:design": "node scripts/ci/check-design-tokens.mjs",
"check:design:test": "node scripts/ci/check-design-tokens.mjs --self-test", "check:design:test": "node scripts/ci/check-design-tokens.mjs --self-test",
"check:desktop-renderer": "node scripts/ci/verify-desktop-renderer-bundles.mjs",
"check:desktop-renderer:test": "node scripts/ci/verify-desktop-renderer-bundles.mjs --self-test",
"test:e2e:red": "node server/supabase/tests/content-report-red.e2e.mjs", "test:e2e:red": "node server/supabase/tests/content-report-red.e2e.mjs",
"release:mobile:boundary": "node scripts/ci/verify-mobile-release-boundary.mjs", "release:mobile:boundary": "node scripts/ci/verify-mobile-release-boundary.mjs",
"release:mobile:boundary:test": "node scripts/ci/verify-mobile-release-boundary.mjs --self-test", "release:mobile:boundary:test": "node scripts/ci/verify-mobile-release-boundary.mjs --self-test",
@ -45,7 +52,9 @@
"typecheck:mobile": "npm --prefix apps/mobile-rn run typecheck", "typecheck:mobile": "npm --prefix apps/mobile-rn run typecheck",
"lint:mobile": "npm --prefix apps/mobile-rn run lint", "lint:mobile": "npm --prefix apps/mobile-rn run lint",
"test:mobile": "npm --prefix apps/mobile-rn test", "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": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/api-client", "name": "@d3ro/api-client",
"version": "1.3.0", "version": "1.3.7",
"private": true, "private": true,
"description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)", "description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/core", "name": "@d3ro/core",
"version": "1.3.0", "version": "1.3.7",
"private": true, "private": true,
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸", "description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
"license": "MIT", "license": "MIT",

View file

@ -29,6 +29,14 @@ export const IPC_CHANNELS = {
TEST_LEVEL: 'audio:testLevel' TEST_LEVEL: 'audio:testLevel'
}, },
/** 로컬 AI 런타임(사이드카 엔진/ffmpeg) 설치 — 앱이 feed에서 내려받아 검증한다 */
RUNTIME: {
GET_STATUS: 'runtime:getStatus',
ENSURE: 'runtime:ensure',
// Main → Renderer events
PROGRESS: 'runtime:progress'
},
STT: { STT: {
GET_STATUS: 'stt:getStatus', GET_STATUS: 'stt:getStatus',
GET_MODELS: 'stt:getModels', GET_MODELS: 'stt:getModels',

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/i18n", "name": "@d3ro/i18n",
"version": "1.3.0", "version": "1.3.7",
"private": true, "private": true,
"description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸", "description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/ui-native", "name": "@d3ro/ui-native",
"version": "1.3.0", "version": "1.3.7",
"private": true, "private": true,
"description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.", "description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/ui", "name": "@d3ro/ui",
"version": "1.3.0", "version": "1.3.7",
"private": true, "private": true,
"description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵", "description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵",
"license": "MIT", "license": "MIT",

View file

@ -1,8 +1,8 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"version": "1.3.0", "version": "1.3.7",
"androidVersionCode": 1030001, "androidVersionCode": 1031007,
"iosBuildNumber": 1030001, "iosBuildNumber": 1031007,
"releaseDate": "2026-09-18", "releaseDate": "2026-09-19",
"desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f" "desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f"
} }

View file

@ -0,0 +1,436 @@
// scripts/ci/build-portable.mjs
// 서명 없이 배포할 수 있는 휴대용 Windows 배포본을 만든다(7z 분할 볼륨 + Scoop 매니페스트).
//
// 왜 분할인가: canonical feed(git.chanpaca.net)는 Cloudflare 뒤에 있고 업로드 본문이
// ~100MiB(104,857,600 bytes)를 넘으면 413으로 거부한다(실측: 60MiB 201 / 110MiB 413).
// 사이드카(faster-whisper)를 포함한 앱은 그 한도를 넘으므로, 95MiB 단위 7z 볼륨으로
// 나눠 올리고 Scoop이 볼륨을 이어서 해제하도록 한다(Scoop은 .7z.001 볼을 공식 지원).
//
// 산출물:
// apps/desktop/release/<version>/D3RO-Voice-<version>-x64-portable.7z.001/.002/...
// apps/desktop/release/<version>/portable.json (볼륨 인덱스: 이름/크기/sha256)
// bucket/d3ro-voice.json (Scoop 매니페스트, 커밋 대상)
//
// zip 분할 부품(수동 설치용)도 함께 만든다 — Windows 내장 Expand-Archive로 해제할 수 있어
// 사용자에게 7-Zip 설치를 요구하지 않는다. 7z 볼륨은 Scoop 전용으로 더 작다(162MiB vs 243MiB).
//
// 사용:
// npm run build --workspace=@d3ro/desktop
// node scripts/ci/build-portable.mjs # 7z 분할 볼륨
// node scripts/ci/build-portable.mjs --zip # + 로컬 배포용 단일 zip(게시용 아님)
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
existsSync,
readFileSync,
readdirSync,
mkdirSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { createRequire } from 'node:module'
import { createGzip } from 'node:zlib'
import { pipeline } from 'node:stream/promises'
import { createReadStream, createWriteStream } from 'node:fs'
import * as tar from 'tar'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const require = createRequire(import.meta.url)
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const desktopDir = join(root, 'apps', 'desktop')
const version = JSON.parse(
readFileSync(join(root, 'release', 'product-version.json'), 'utf8'),
).version
const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice'
const PORTABLE_VERSION_PATH = `${FEED}/portable-${version}`
const PORTABLE_LATEST_PATH = `${FEED}/portable-latest`
const ARCHIVE_BASE = `D3RO-Voice-${version}-x64-portable`
/** Cloudflare 본문 한도(100MiB)보다 여유를 둔 7z 볼륨 크기 (Scoop 경로) */
const VOLUME_SIZE = '95m'
const MAX_VOLUME_BYTES = 95 * 1024 * 1024
/** 수동 설치 스크립트용 zip 분할 부품 크기 */
const ZIP_PART_SIZE = '90m'
const MAX_ZIP_PART_BYTES = 95 * 1024 * 1024
const wantZip = process.argv.includes('--zip')
const releaseDir = join(desktopDir, 'release', version)
const appDir = join(releaseDir, 'win-unpacked')
if (!existsSync(join(desktopDir, 'out', 'main', 'index.js'))) {
console.error(
'out/main/index.js 가 없습니다. 먼저 데스크톱 번들을 빌드하세요:\n' +
' npm run build --workspace=@d3ro/desktop',
)
process.exit(1)
}
if (!existsSync(join(desktopDir, 'sidecar-dist', 'sidecar'))) {
console.error(
'STT 사이드카 번들이 없습니다. 서명 없이 배포해도 로컬 전사에는 사이드카가 필요합니다:\n' +
' npm --prefix apps/desktop run sidecar:setup\n' +
' npm --prefix apps/desktop run sidecar:build',
)
process.exit(1)
}
/** 현재 스크립트와 같은 Node로 CI 스크립트를 실행한다 */
function runNodeScript(relativePath, scriptArgs) {
console.log(`[portable] $ node ${relativePath} ${scriptArgs.join(' ')}`)
const result = spawnSync(
process.execPath,
[join(root, relativePath), ...scriptArgs],
{ cwd: root, stdio: 'inherit' },
)
if (result.status !== 0) {
console.error(`[portable] ${relativePath} 실패 (exit ${result.status ?? 'null'})`)
process.exit(result.status ?? 1)
}
}
function resolve7za() {
// electron-builder가 의존하는 7zip-bin이 플랫폼별 7za 실행 파일을 제공한다.
const platformDir =
process.platform === 'win32'
? join('win', process.arch === 'arm64' ? 'arm64' : 'x64')
: process.platform === 'darwin'
? join('mac', process.arch === 'arm64' ? 'arm64' : 'x64')
: join('linux', process.arch === 'arm64' ? 'arm64' : 'x64')
const binary = process.platform === 'win32' ? '7za.exe' : '7za'
const candidate = join(root, 'node_modules', '7zip-bin', platformDir, binary)
if (!existsSync(candidate)) {
console.error(`7za를 찾을 수 없습니다: ${candidate}\n npm ci 후 다시 실행하세요.`)
process.exit(1)
}
return candidate
}
const target = process.argv.includes('--zip') ? 'zip' : 'dir'
console.log(
`[portable] electron-builder 빌드 (version ${version}, target=${target}, 서명 없음)`,
)
const targetArgs = target === 'zip' ? ['--win', 'zip'] : ['--win', 'dir']
const build = spawnSync(
'npx',
[
'electron-builder',
...targetArgs,
'--x64',
'--config',
'electron-builder.yml',
'--publish',
'never',
// 서명이 없으므로 NSIS 경로의 fail-closed 게이트를 이 채널에서만 명시적으로 해제한다.
// (자동 업데이트 피드가 아니라 별도 portable 경로로만 게시한다 — publish 스크립트 참조)
'-c.win.forceCodeSigning=false',
'-c.npmRebuild=false',
],
{ cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' },
)
if (build.status !== 0) {
console.error(`[portable] electron-builder 실패 (exit ${build.status ?? 'null'})`)
process.exit(build.status ?? 1)
}
if (!existsSync(appDir)) {
console.error(`[portable] win-unpacked가 없습니다: ${appDir}`)
process.exit(1)
}
// 네이티브 모듈 ABI 사고 방지: 호스트 Node ABI로 빌드된 모듈이 섞이면 설치본이 시작조차 못 한다.
// (실측 사고: better_sqlite3.node가 NODE_MODULE_VERSION 131 → Electron 130 요구)
runNodeScript('scripts/ci/fix-native-abi.mjs', ['--dir', appDir])
runNodeScript('scripts/ci/verify-native-abi.mjs', ['--dir', appDir])
// electron-builder의 --dir/--prepackaged 경로는 app-update.yml을 만들지 않는다.
// 이 파일이 없으면 electron-updater가 설정을 읽지 못해 자동 업데이트가 죽는다(실측).
runNodeScript('scripts/ci/write-app-update-yml.mjs', ['--dir', appDir])
// 이전 볼륨 정리 (같은 버전 재드 시 잔여 볼륨이 섞이지 않도록)
for (const name of readdirSync(releaseDir)) {
if (name.startsWith(ARCHIVE_BASE)) {
rmSync(join(releaseDir, name), { force: true })
}
}
const sevenZip = resolve7za()
const archivePath = join(releaseDir, `${ARCHIVE_BASE}.7z`)
console.log(`[portable] 7z 분할 볼륨 생성 (볼륨 ${VOLUME_SIZE})`)
const compress = spawnSync(
sevenZip,
[
'a',
'-t7z',
'-m0=lzma2',
'-mx=9',
'-mmt=on',
'-ms=on',
`-v${VOLUME_SIZE}`,
'-bsp0',
'-bso0',
'-y',
archivePath,
join(appDir, '*'),
],
{ stdio: 'inherit' },
)
if (compress.status !== 0) {
console.error(`[portable] 7z 압축 실패 (exit ${compress.status ?? 'null'})`)
process.exit(compress.status ?? 1)
}
const volumes = readdirSync(releaseDir)
.filter((name) => name.startsWith(`${ARCHIVE_BASE}.7z.`))
.sort()
if (volumes.length === 0) {
console.error('[portable] 7z 볼륨을 찾을 수 없습니다.')
process.exit(1)
}
const volumeEntries = []
for (const name of volumes) {
const path = join(releaseDir, name)
const size = statSync(path).size
if (size > MAX_VOLUME_BYTES) {
console.error(
`[portable] 볼륨이 너무 큽니다(${name}: ${(size / 1048576).toFixed(1)}MiB). ` +
'VOLUME_SIZE를 줄이세요 — Cloudflare가 100MiB 초과 업로드를 413으로 거부합니다.',
)
process.exit(1)
}
const sha256 = createHash('sha256').update(readFileSync(path)).digest('hex')
volumeEntries.push({ name, size, sha256, url: `${PORTABLE_VERSION_PATH}/${name}` })
}
const totalBytes = volumeEntries.reduce((sum, entry) => sum + entry.size, 0)
const portableIndex = {
channel: 'portable-unsigned',
version,
archive: `${ARCHIVE_BASE}.7z`,
volumes: volumeEntries,
volumeCount: volumeEntries.length,
totalSize: totalBytes,
releasedAt: new Date().toISOString(),
latestIndexUrl: `${PORTABLE_LATEST_PATH}/portable.json`,
installScriptUrl: `${PORTABLE_LATEST_PATH}/install-d3ro-voice.ps1`,
notes: [
'서명 없는 휴대용 배포본입니다. 자동 업데이트 피드(latest.yml)는 갱신하지 않습니다.',
'Cloudflare 업로드 한도(100MiB) 때문에 7z 볼륨으로 나뉘어 있습니다. Scoop이 이어서 해제합니다.',
'수동 설치: install-d3ro-voice.ps1 (7-Zip 필요) 또는 Scoop 사용을 권장합니다.',
],
}
writeFileSync(
join(releaseDir, 'portable.json'),
`${JSON.stringify(portableIndex, null, 2)}\n`,
'utf8',
)
const scoopManifest = {
version,
description: '로컬 AI 음성 어시스턴트 (faster-whisper + Ollama, 100% 오프라인 지원)',
homepage: 'https://d3ro.chanpaca.net',
license: 'MIT',
architecture: {
'64bit': {
url: volumeEntries.map((entry) => entry.url),
hash: volumeEntries.map((entry) => entry.sha256),
},
},
shortcuts: [['D3RO Voice.exe', 'D3RO Voice']],
checkver: {
url: `${PORTABLE_LATEST_PATH}/portable.json`,
jsonpath: '$.version',
},
autoupdate: {
architecture: {
'64bit': {
url: volumeEntries.map((entry) =>
entry.url.replace(`portable-${version}`, 'portable-$version'),
),
},
},
},
}
writeFileSync(
join(root, 'bucket', 'd3ro-voice.json'),
`${JSON.stringify(scoopManifest, null, 2)}\n`,
'utf8',
)
// ── 수동 설치용 zip 분할 부품 ─────────────────────────────
const zipTarget = join(releaseDir, `${ARCHIVE_BASE}.zip`)
console.log(`[portable] 수동 설치용 zip 생성 (분할 ${ZIP_PART_SIZE})`)
const zipBuild = spawnSync(
'npx',
[
'electron-builder',
// 검증된 트리에서 바로 패키징한다(ne ABI가 확실한 디렉토리만 사용).
'--prepackaged',
appDir,
'--win',
'zip',
'--x64',
'--config',
'electron-builder.yml',
'--publish',
'never',
'-c.win.forceCodeSigning=false',
'-c.npmRebuild=false',
],
{ cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' },
)
if (zipBuild.status !== 0) {
console.error(`[portable] zip 빌드 실패 (exit ${zipBuild.status ?? 'null'})`)
process.exit(zipBuild.status ?? 1)
}
const producedZip = readdirSync(releaseDir).find((name) =>
name.endsWith('.zip') && name.includes(version) && !name.includes('.part'),
)
if (!producedZip) {
console.error('[portable] zip 산출물을 찾을 수 없습니다.')
process.exit(1)
}
if (join(releaseDir, producedZip) !== zipTarget) {
renameSync(join(releaseDir, producedZip), zipTarget)
}
// zip을 90MiB 단위로 바이트 분할한다 (사용자가 이어 붙여 Expand-Archive로 해제)
const zipBytes = readFileSync(zipTarget)
const partSize = 90 * 1024 * 1024
const zipParts = []
for (let offset = 0, index = 1; offset < zipBytes.length; offset += partSize, index += 1) {
const slice = zipBytes.subarray(offset, Math.min(offset + partSize, zipBytes.length))
const name = `${ARCHIVE_BASE}.zip.${String(index).padStart(3, '0')}`
if (slice.length > MAX_ZIP_PART_BYTES) {
console.error(`[portable] zip 부품이 너무 큽니다: ${name}`)
process.exit(1)
}
writeFileSync(join(releaseDir, name), slice)
zipParts.push({
name,
size: slice.length,
sha256: createHash('sha256').update(slice).digest('hex'),
url: `${PORTABLE_VERSION_PATH}/${name}`,
})
}
const zipSha256 = createHash('sha256').update(zipBytes).digest('hex')
// 인덱스에 zip 부품 정보를 추가한다 (설치 스크립트가 사용)
const indexJson = JSON.parse(readFileSync(join(releaseDir, 'portable.json'), 'utf8'))
indexJson.zipArchive = `${ARCHIVE_BASE}.zip`
indexJson.zipSize = zipBytes.length
indexJson.zipSha256 = zipSha256
indexJson.zipParts = zipParts
writeFileSync(
join(releaseDir, 'portable.json'),
`${JSON.stringify(indexJson, null, 2)}\n`,
'utf8',
)
// ── 로컬 AI 런타임 번들 (설치본에 넣지 않고 처음 필요할 때 내려받는다) ──────
const RUNTIME_DIR = join(releaseDir, 'runtime')
rmSync(RUNTIME_DIR, { recursive: true, force: true })
mkdirSync(RUNTIME_DIR, { recursive: true })
/** 디렉터리를 tar.gz으로 묶어 90MiB 부품으로 나누고 인덱스 항목을 돌려준다 */
async function packRuntime(component, sourceDir, archiveBase) {
const archivePath = join(RUNTIME_DIR, `${archiveBase}.tar.gz`)
await pipeline(
tar.c({ cwd: sourceDir, portable: true, gzip: false }, ['.']),
createGzip({ level: 6 }),
createWriteStream(archivePath),
)
const archiveBytes = readFileSync(archivePath)
const sha256 = createHash('sha256').update(archiveBytes).digest('hex')
const partSize = 90 * 1024 * 1024
const parts = []
for (let offset = 0, index = 1; offset < archiveBytes.length; offset += partSize, index += 1) {
const slice = archiveBytes.subarray(offset, Math.min(offset + partSize, archiveBytes.length))
const name = `${archiveBase}.tar.gz.${String(index).padStart(3, '0')}`
if (slice.length > MAX_ZIP_PART_BYTES) {
throw new Error(`런타임 부품이 너무 큽니다: ${name}`)
}
writeFileSync(join(RUNTIME_DIR, name), slice)
parts.push({
name,
size: slice.length,
sha256: createHash('sha256').update(slice).digest('hex'),
})
}
return {
component,
archive: `${archiveBase}.tar.gz`,
sha256,
totalSize: archiveBytes.length,
parts,
}
}
const runtimeComponents = {}
const sidecarSource = join(desktopDir, 'sidecar-dist', 'sidecar')
// @ffmpeg-installer가 플랫폼별로 제공하는 실행 파일 경로를 그대로 사용한다
const ffmpegInstaller = (() => {
try {
return require('@ffmpeg-installer/ffmpeg')
} catch {
return null
}
})()
const ffmpegSource = ffmpegInstaller?.path ? dirname(ffmpegInstaller.path) : null
if (existsSync(sidecarSource)) {
console.log('[portable] 런타임 번들 생성: sidecar (faster-whisper 진)')
runtimeComponents.sidecar = await packRuntime('sidecar', sidecarSource, 'd3ro-runtime-sidecar')
} else {
console.error('[portable] 경고: sidecar-dist가 없어 런타임 번들을 만들 수 없습니다')
}
if (ffmpegSource && existsSync(ffmpegSource)) {
console.log('[portable] 런타임 번들 생성: ffmpeg')
runtimeComponents.ffmpeg = await packRuntime('ffmpeg', ffmpegSource, 'd3ro-runtime-ffmpeg')
} else {
console.error('[portable] 경고: @ffmpeg-installer가 없어 ffmpeg 런타임을 만들 수 없습니다')
}
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] 완료',
` 볼륨 : ${volumeEntries.length}개 / 합계 ${(totalBytes / 1048576).toFixed(1)}MiB`,
...volumeEntries.map(
(entry) => ` ${entry.name} (${(entry.size / 1048576).toFixed(1)}MiB)`,
),
` zip : ${zipParts.length}개 부품 / 합계 ${(zipBytes.length / 1048576).toFixed(1)}MiB`,
` 인덱스 : ${join(releaseDir, 'portable.json')}`,
` scoop : ${join(root, 'bucket', 'd3ro-voice.json')}`,
` 런타임 : ${Object.keys(runtimeComponents).join(', ') || '(없음)'}${join(RUNTIME_DIR, 'runtime.json')}`,
` 게시 : node scripts/ci/publish-portable-release.mjs`,
].join('\n'),
)

View file

@ -0,0 +1,119 @@
// scripts/ci/fix-native-abi.mjs
// 패키징된 앱 트리에 Electron ABI 네이티브 모듈을 보장한다.
//
// 필요한가:
// - better-sqlite3는 V8 내부 API에 의존해 런타임별 ABI(NODE_MODULE_VERSION)가 다르다.
// 개발 PC에서 `npm install`을 돌리면 Node ABI로 재빌드되고, 그 상태로 패키징하면
// 설치본이 시작하자마자 "NODE_MODULE_VERSION 131 ... requires 130"으로 죽는다(실측 사고).
// - CI에서는 electron-builder의 npmRebuild가 이를 처리하지만, 로컬에서는 실행 중인 Electron이
// node_modules 파일을 잠그고 있어 재빌드가 EPERM으로 실패할 수 있다.
// 그래서 "패키징된 트리"에 정확한 ABI 바이너리를 직접 넣는다(원본 node_modules는 건드리지 않는다).
//
// 동작: 임시 사본에서 prebuild-install로 Electron용 프리빌드를 받아 패키징 트리에 복사한 뒤,
// 호스트 Node가 그 모듈을 거부하는지(= Electron ABI) 확인한다.
//
// 사용:
// node scripts/ci/fix-native-abi.mjs --dir <packagedDir>
import { spawnSync } from 'node:child_process'
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const dirFlagIndex = process.argv.indexOf('--dir')
const packagedDir = dirFlagIndex >= 0 ? process.argv[dirFlagIndex + 1] : null
if (!packagedDir || !existsSync(packagedDir)) {
console.error('사용: node scripts/ci/fix-native-abi.mjs --dir <packagedDir>')
process.exit(1)
}
const sourcePackageJson = JSON.parse(
readFileSync(join(root, 'apps', 'desktop', 'package.json'), 'utf8'),
)
const electronVersion =
sourcePackageJson.devDependencies?.electron?.replace(/[^0-9.]/g, '') ?? '33.4.11'
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
const relativeBinary = join(
'node_modules',
'better-sqlite3',
'build',
'Release',
'better_sqlite3.node',
)
const targetBinary = join(packagedDir, 'resources', 'app.asar.unpacked', relativeBinary)
if (!existsSync(targetBinary)) {
console.error(`[native-abi] 패키징 트리에 better-sqlite3 바이너리가 없습니다: ${targetBinary}`)
process.exit(1)
}
/** 호스트 Node로 로드되면 Electron ABI가 아니다 */
function hostNodeLoads(binaryPath) {
const probe = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(binaryPath)})`], {
encoding: 'utf8',
})
return probe.status === 0
}
if (!hostNodeLoads(targetBinary)) {
console.log('[native-abi] 이미 Electron ABI 바이너리입니다 — 수정 불필요')
process.exit(0)
}
console.log('[native-abi] 호스트 Node ABI로 빌드된 모듈을 찾았습니다 → Electron ABI로 교체합니다')
const scratchRoot = join(root, '.tmp', 'native-abi')
const scratchModule = join(scratchRoot, 'better-sqlite3')
// 임시 사본 준비 (원본 node_modules는 잠겨 있을 수 있으므로 복사해서 작업)
rmSync(scratchRoot, { recursive: true, force: true })
mkdirSync(scratchRoot, { recursive: true })
const copy = spawnSync(
process.platform === 'win32' ? 'cmd' : 'cp',
process.platform === 'win32'
? ['/c', 'xcopy', join(root, 'node_modules', 'better-sqlite3'), scratchModule, '/E', '/I', '/Q', '/Y']
: ['-r', join(root, 'node_modules', 'better-sqlite3'), scratchModule],
{ stdio: 'inherit' },
)
if (copy.status !== 0) {
console.error('[native-abi] better-sqlite3 사본 생성 실패')
process.exit(copy.status ?? 1)
}
const prebuild = spawnSync(
process.platform === 'win32' ? 'npx.cmd' : 'npx',
[
'--no-install',
'prebuild-install',
`--runtime=electron`,
`--target=${electronVersion}`,
`--arch=${arch}`,
'--force',
],
{ cwd: scratchModule, stdio: 'inherit', shell: process.platform === 'win32' },
)
const scratchBinary = join(scratchModule, 'build', 'Release', 'better_sqlite3.node')
if (prebuild.status !== 0 || !existsSync(scratchBinary)) {
console.error(
[
`[native-abi] Electron ${electronVersion} 프리빌드를 받지 못했습니다.`,
' 대안: CI에서 electron-builder의 npmRebuild=true로 빌드하세요(권장).',
' 로컬에서 계속하려면 실행 중인 Electron을 모두 종료한 뒤 다음을 실행하세요:',
` npx @electron/rebuild -v ${electronVersion} -m better-sqlite3`,
].join('\n'),
)
process.exit(1)
}
if (hostNodeLoads(scratchBinary)) {
console.error('[native-abi] 받은 프리빌드가 Electron ABI가 아닙니다(prebuild-install 결과를 확인).')
process.exit(1)
}
copyFileSync(scratchBinary, targetBinary)
console.log(`[native-abi] 교체 완료: ${targetBinary}`)
rmSync(scratchRoot, { recursive: true, force: true })

View file

@ -0,0 +1,231 @@
// scripts/ci/publish-portable-release.mjs
// 서명 없는 휴대용 배포본(7z 분할 볼륨) + Scoop 매니페스트 + 수동 설치 스크립트를
// Forgejo Generic Registry에 게시한다.
//
// 이 채널은 자동 업데이트 피드(latest.yml / update-policy.json)를 건드리지 않는다.
// 서명이 없어도 게시할 수 있으므로 인증서 발급 전에도 사용자가 설치할 수 있는 경로다.
//
// 경로:
// .../generic/d3ro-voice/portable-<version>/<륨>.7z.00N
// .../generic/d3ro-voice/portable-<version>/portable.json
// .../generic/d3ro-voice/portable-<version>/install-d3ro-voice.ps1
// .../generic/d3ro-voice/portable-latest/... (동일 파일 alias)
//
// 사용:
// node scripts/ci/build-portable.mjs
// node --env-file-if-exists=.env scripts/ci/publish-portable-release.mjs [--check]
import credentialHelpers from '../lib/credentials.cjs'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const { forgejoAuthorization } = credentialHelpers
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const check = process.argv.includes('--check') || process.env.PORTABLE_PUBLISH_DRY_RUN === '1'
const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice'
const version = JSON.parse(
readFileSync(join(root, 'release', 'product-version.json'), 'utf8'),
).version
const releaseDir = join(root, 'apps', 'desktop', 'release', version)
const index = JSON.parse(readFileSync(join(releaseDir, 'portable.json'), 'utf8'))
const installerPath = join(root, 'scripts', 'install', 'install-d3ro-voice.ps1')
if (index.version !== version) {
console.error(
`[portable] portable.json 버전(${index.version})이 product-version.json(${version})과 다릅니다. build-portable.mjs를 다시 실행하세요.`,
)
process.exit(1)
}
if (!existsSync(installerPath)) {
console.error(`[portable] 설치 스크립트가 없습니다: ${installerPath}`)
process.exit(1)
}
// 게시 전 해시 재검증 — 파일이 바뀌었는데 인덱스가 낡으면 불일치 배포가 된다.
const payloads = []
for (const volume of index.volumes) {
const path = join(releaseDir, volume.name)
if (!existsSync(path)) {
console.error(`[portable] 볼륨이 없습니다: ${path}`)
process.exit(1)
}
const bytes = await readFile(path)
const sha256 = createHash('sha256').update(bytes).digest('hex')
if (sha256 !== volume.sha256) {
console.error(
`[portable] sha256 불일치 (${volume.name}): index=${volume.sha256} actual=${sha256}`,
)
process.exit(1)
}
payloads.push({ name: volume.name, bytes, contentType: 'application/octet-stream' })
}
// 수동 설치용 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'),
contentType: 'application/json',
})
payloads.push({
name: 'install-d3ro-voice.ps1',
bytes: await readFile(installerPath),
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`]
async function forgejoFetch(url, init = {}) {
return fetch(url, {
...init,
headers: { Authorization: authorization, ...(init.headers ?? {}) },
})
}
/**
* 원격 파일이 로컬 바이트와 같은지 판단한다.
* Forgejo generic registry는 HEAD를 405 거부하고 해시도 주지 않으므로,
* Range GET으로 크기를 1MiB 이하는 실제 바이트까지 비교한다.
* (크기만 비교하면 버전 문자열만 바뀐 latest.yml 같은 메타데이터를 놓친다 실측 사고.)
*/
async function remoteIsIdentical(url, body, fetchImpl) {
const probe = await fetchImpl(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
if (!probe?.ok) return false
const contentRange = probe.headers.get('content-range')
const remoteSize = contentRange ? Number(contentRange.split('/')[1]) : NaN
if (!Number.isFinite(remoteSize) || remoteSize !== body.length) return false
if (body.length > 1024 * 1024) return true
const full = await fetchImpl(url).catch(() => null)
if (!full?.ok) return false
const remoteBytes = Buffer.from(await full.arrayBuffer())
return remoteBytes.length === body.length && remoteBytes.equals(body)
}
async function upload(url, body, contentType) {
if (check) {
console.log(`[portable] (check) PUT ${url} (${body.length} bytes)`)
return
}
// 메타데이터는 크기가 아니라 내용까지 비교해야 한다.
// 크기만 보면 버전 문자열만 바뀐 latest.yml/json을 "동일"로 오판한다 — 실측 사고.
if (await remoteIsIdentical(url, body, forgejoFetch)) {
console.log(`[portable] 이미 동일한 파일이 있습니다(건너): ${url}`)
return
}
const existing = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
if (existing?.ok) {
// 볼륨/부품은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다.
const isImmutableAsset =
url.includes(`/portable-${version}/`) && (url.includes('.7z.') || url.includes('.zip.'))
if (isImmutableAsset) {
console.error(
`[portable] ${version} 자산에 다른 바이트가 이미 있습니다: ${url}\n` +
' 이미 게시된 버전은 어쓰지 않습니다(불변). 새 버전으로 게시하세요.',
)
process.exit(1)
}
// 메타데이터와 latest 별칭은 최신을 반영해야 하므로 지우고 쓴다.
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}`)
}
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)
}
}
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`,
runtimePayloads.length
? ` 런타임 : ${FEED}/runtime-latest/runtime.json (${runtimePayloads.length}개 파일)`
: ' 런타임 : (없음)',
'',
' 설치(Scoop, 권장):',
' scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git',
' scoop install d3ro/d3ro-voice',
'',
' 수동 설치(추가 도구 불필요):',
` irm ${FEED}/portable-latest/install-d3ro-voice.ps1 | iex`,
'',
' 참고: 이 채널은 서명이 없어 자동 업데이트 피드를 갱신하지 않습니다.',
].join('\n'),
)

View file

@ -0,0 +1,284 @@
// 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) {
const appDir = join(releaseDir, 'win-unpacked')
// 1) 먼저 unpacked 트리빌드한다.
console.log('[updater] electron-builder --dir (앱 전용 — 런타임 제외)')
const result = spawnSync(
'npx',
[
'electron-builder',
'--win',
'dir',
'--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)
}
// 2) 네이티브 모듈 ABI 보장 + 검증 (호스트 Node ABI가 섞이면 이 시작조차 못 한다)
runNodeScript('scripts/ci/fix-native-abi.mjs', ['--dir', appDir])
runNodeScript('scripts/ci/verify-native-abi.mjs', ['--dir', appDir])
// electron-updater 설정 파일을 보장한다(없으면 자동 업데이트가 동작하지 않는다)
runNodeScript('scripts/ci/write-app-update-yml.mjs', ['--dir', appDir])
// 3) 검증된 트리에서 설치본 생성 (--prepackaged = 재빌드 없이 그대로 패키징)
console.log('[updater] electron-builder --prepackaged (NSIS x64)')
const packageResult = spawnSync(
'npx',
[
'electron-builder',
'--prepackaged',
appDir,
'--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 (packageResult.status !== 0) {
console.error(`[updater] 설치본 생성 실패 (exit ${packageResult.status ?? 'null'})`)
process.exit(packageResult.status ?? 1)
}
}
/** 이 스크립트와 같은 Node로 CI 스크립트를 실행한다 */
function runNodeScript(relativePath, scriptArgs) {
console.log(`[updater] $ node ${relativePath} ${scriptArgs.join(' ')}`)
const result = spawnSync(process.execPath, [join(root, relativePath), ...scriptArgs], {
cwd: root,
stdio: 'inherit',
})
if (result.status !== 0) {
console.error(`[updater] ${relativePath} 실패 (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)
}
// 설치본에 app-update.yml이 없으면 electron-updater가 설정을 읽지 못해 자동 업데이트가 죽는다.
const packagedUpdateConfig = join(releaseDir, 'win-unpacked', 'resources', 'app-update.yml')
if (!existsSync(packagedUpdateConfig)) {
console.error('[updater] app-update.yml 누락 — write-app-update-yml.mjs를 먼저 실행하세요.')
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()
/**
* 원격 파일이 로컬 바이트와 같은지 판단한다.
* Forgejo generic registry는 HEAD를 405 거부하고 해시도 주지 않으므로,
* Range GET으로 크기를 1MiB 이하는 실제 바이트까지 비교한다.
* (크기만 비교하면 버전 문자열만 바뀐 latest.yml 같은 메타데이터를 놓친다 실측 사고.)
*/
async function remoteIsIdentical(url, body, fetchImpl) {
const probe = await fetchImpl(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
if (!probe?.ok) return false
const contentRange = probe.headers.get('content-range')
const remoteSize = contentRange ? Number(contentRange.split('/')[1]) : NaN
if (!Number.isFinite(remoteSize) || remoteSize !== body.length) return false
if (body.length > 1024 * 1024) return true
const full = await fetchImpl(url).catch(() => null)
if (!full?.ok) return false
const remoteBytes = Buffer.from(await full.arrayBuffer())
return remoteBytes.length === body.length && remoteBytes.equals(body)
}
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는 HEAD를 405로 거부하고 해시도 주지 않는다.
// 크기만 비교하면 버전 문자열만 바뀐 latest.yml을 "동일"로 오판한다 — 내용까지 비교한다.
if (await remoteIsIdentical(url, body, forgejoFetch)) {
console.log(`[updater] 이미 동일한 파일이 있습니다(건너): ${url}`)
continue
}
// 메타데이터는 최신을 가리켜야 하므로 기존 파일을 지우고 쓴다(PUT은 409를 돌려준다).
const existing = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
if (existing?.ok) {
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'),
)

View file

@ -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)

View file

@ -180,6 +180,28 @@ updateText('apps/web/src/components/layout/sidebar.tsx', (text) =>
), ),
) )
// Download centers build the installer filename from these constants. They drifted
// to 1.2.0 while the feed served newer versions, so the download button pointed at
// an installer that does not exist. Keep both surfaces on the version SSOT.
for (const releaseContractPath of [
'apps/web/src/lib/desktop-release.ts',
'site/src/release.ts',
]) {
updateText(releaseContractPath, (text) =>
replaceExactlyOnce(
replaceExactlyOnce(
text,
/export const DESKTOP_VERSION = '[^']+'/,
`export const DESKTOP_VERSION = '${metadata.version}'`,
`${releaseContractPath} desktop version`,
),
/export const DESKTOP_RELEASE_DATE = '[^']+'/,
`export const DESKTOP_RELEASE_DATE = '${metadata.releaseDate}'`,
`${releaseContractPath} desktop release date`,
),
)
}
const changelog = readFileSync(join(root, 'CHANGELOG.md'), 'utf8') const changelog = readFileSync(join(root, 'CHANGELOG.md'), 'utf8')
if (!new RegExp(`^## \\[${escapeRegExp(metadata.version)}\\] - ${metadata.releaseDate}$`, 'm').test(changelog)) { if (!new RegExp(`^## \\[${escapeRegExp(metadata.version)}\\] - ${metadata.releaseDate}$`, 'm').test(changelog)) {
fail(`CHANGELOG.md is missing [${metadata.version}] - ${metadata.releaseDate}.`) fail(`CHANGELOG.md is missing [${metadata.version}] - ${metadata.releaseDate}.`)

View file

@ -0,0 +1,160 @@
// scripts/ci/verify-desktop-renderer-bundles.mjs
//
// Fails packaging when a renderer page ships without the assets it references.
//
// Why this exists: Vite only bundles <script type="module"> tags. A page that
// keeps a classic <script src="./script.js"> points at a file the build never
// emits, so the packaged window renders its static markup forever. That is how
// the recording overlay froze at 0:00 without wave bars and live captions never
// showed up. Comparing built HTML against disk catches the whole class of bug.
//
// Usage:
// node scripts/ci/verify-desktop-renderer-bundles.mjs
// node scripts/ci/verify-desktop-renderer-bundles.mjs --self-test
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(scriptDir, '..', '..')
const desktopDir = path.join(repoRoot, 'apps', 'desktop')
const sourcePopupDir = path.join(desktopDir, 'src', 'renderer', 'popups')
const builtRendererDir = path.join(desktopDir, 'out', 'renderer')
/**
* Pages the packaged renderer must contain, relative to the renderer root.
* Popup directories are discovered from source so a new popup cannot be added
* without also being built.
*/
function expectedPages() {
const pages = ['index.html']
if (!existsSync(sourcePopupDir)) return pages
for (const entry of readdirSync(sourcePopupDir).sort()) {
const entryPath = path.join(sourcePopupDir, entry)
if (statSync(entryPath).isDirectory() && existsSync(path.join(entryPath, 'index.html'))) {
pages.push(path.posix.join('popups', entry, 'index.html'))
}
}
return pages
}
/** Local script/link references inside a built HTML page. */
function localReferences(html) {
const refs = []
const pattern = /<(?:script|link)\b[^>]*?\b(?:src|href)="([^"]+)"/g
let match
while ((match = pattern.exec(html)) !== null) {
const ref = match[1]
if (/^[a-z]+:/i.test(ref) || ref.startsWith('//') || ref.startsWith('#')) continue
refs.push(ref.split('?')[0])
}
return refs
}
/**
* @param {string} rendererDir built renderer root
* @param {string[]} pages page paths relative to that root
* @returns {string[]} problems, empty when the build is complete
*/
function collectProblems(rendererDir, pages) {
const problems = []
for (const page of pages) {
const pagePath = path.join(rendererDir, page)
if (!existsSync(pagePath)) {
problems.push(`missing built page: ${pagePath}`)
continue
}
const html = readFileSync(pagePath, 'utf8')
for (const ref of localReferences(html)) {
const assetPath = ref.startsWith('/')
? path.join(rendererDir, ref.slice(1))
: path.resolve(path.dirname(pagePath), ref)
if (!existsSync(assetPath)) {
problems.push(`${page} references a missing asset: ${ref} -> ${assetPath}`)
}
}
// A classic script tag is never emitted by the renderer build.
if (/<script\b(?![^>]*\btype="module")[^>]*\bsrc=/.test(html)) {
problems.push(`${page} loads a classic script; add type="module" so Vite bundles it`)
}
}
return problems
}
function selfTest() {
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), 'd3ro-renderer-bundles-'))
const failures = []
try {
const bundlePage = (root, scriptTag) => {
mkdirSync(path.join(root, 'assets'), { recursive: true })
mkdirSync(path.join(root, 'popups', 'recording-tip'), { recursive: true })
writeFileSync(path.join(root, 'assets', 'app.js'), '')
writeFileSync(
path.join(root, 'popups', 'recording-tip', 'index.html'),
`<!DOCTYPE html>\n<html><body>${scriptTag}</body></html>\n`,
)
}
const pages = ['popups/recording-tip/index.html']
const goodRoot = path.join(tmpRoot, 'good')
bundlePage(goodRoot, '<script type="module" src="../../assets/app.js"></script>')
const goodProblems = collectProblems(goodRoot, pages)
if (goodProblems.length !== 0) {
failures.push(`complete build reported problems: ${goodProblems.join('; ')}`)
}
const brokenRoot = path.join(tmpRoot, 'broken')
bundlePage(brokenRoot, '<script src="./script.js"></script>')
const brokenProblems = collectProblems(brokenRoot, pages)
if (!brokenProblems.some((problem) => problem.includes('missing asset'))) {
failures.push('missing asset was not detected')
}
if (!brokenProblems.some((problem) => problem.includes('classic script'))) {
failures.push('classic script tag was not detected')
}
const missingPageProblems = collectProblems(goodRoot, ['popups/absent/index.html'])
if (!missingPageProblems.some((problem) => problem.startsWith('missing built page'))) {
failures.push('missing page was not detected')
}
} finally {
rmSync(tmpRoot, { recursive: true, force: true })
}
if (failures.length > 0) {
console.error('verify-desktop-renderer-bundles self-test failed:')
for (const failure of failures) console.error(`- ${failure}`)
process.exit(1)
}
console.log('verify-desktop-renderer-bundles self-test: OK')
}
if (process.argv.slice(2).includes('--self-test')) {
selfTest()
} else if (!existsSync(builtRendererDir)) {
console.error(`Renderer build not found: ${builtRendererDir}`)
console.error(' build: npm run build --workspace=@d3ro/desktop')
process.exit(1)
} else {
const pages = expectedPages()
const problems = collectProblems(builtRendererDir, pages)
if (problems.length > 0) {
console.error('Desktop renderer bundle verification failed:')
for (const problem of problems) console.error(`- ${problem}`)
console.error(' rebuild: npm run build --workspace=@d3ro/desktop')
process.exit(1)
}
console.log(`Desktop renderer bundle verification passed: ${pages.length} page(s) with all assets on disk`)
}

View file

@ -0,0 +1,117 @@
// scripts/ci/verify-native-abi.mjs
// 패키징된 Electron 앱의 네이티브 모이 "Electron ABI"로 빌드됐는지 검증한다.
//
// 배경(실측 사고): 설치본에 Node ABI(131)로 빌드된 better_sqlite3.node가 들어가
// 앱이 시작하자마자 "NODE_MODULE_VERSION 131 ... requires 130"으로 죽었다.
// 원인은 패키징에서 네이티브 재빌드를 건너뛴 것(npmRebuild=false)이었고, 조용히 지나갔다.
//
// 검증 방법: 호스트 Node로 모듈을 로드해 본다.
// - 로드 성공 → 호스트 Node ABI로 빌드된 것 = Electron용이 아님 → 실패
// - NODE_MODULE_VERSION 불일치로 거부 → 다른 런타임(Electron)용 = 통과
// - 파일 없음 → 실패
//
// 사용:
// node scripts/ci/verify-native-abi.mjs # release/<version>/win-unpacked 자동 탐색
// node scripts/ci/verify-native-abi.mjs --dir <packagedDir>
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const dirFlagIndex = process.argv.indexOf('--dir')
let packagedDir = dirFlagIndex >= 0 ? process.argv[dirFlagIndex + 1] : null
if (!packagedDir) {
const version = JSON.parse(
readFileSync(join(root, 'release', 'product-version.json'), 'utf8'),
).version
const releaseDir = join(root, 'apps', 'desktop', 'release', version)
if (existsSync(releaseDir)) {
const candidates = readdirSync(releaseDir).filter((name) =>
/unpacked$/.test(name) || name === 'win-unpacked' || name === 'mac-arm64',
)
if (candidates.length > 0) {
packagedDir = join(releaseDir, candidates[0])
}
}
}
if (!packagedDir || !existsSync(packagedDir)) {
console.error(
'패키징 산출물 디렉토리를 찾을 수 없습니다. --dir로 지정하세요 (예: apps/desktop/release/1.3.3/win-unpacked).',
)
process.exit(1)
}
const unpackedRoot = join(packagedDir, 'resources', 'app.asar.unpacked', 'node_modules')
/** Electron ABI(V8 내부 API)에 의존해 재빌드가 반드시 필요한 모듈 */
const REQUIRED_ELECTRON_ABI = [
{
name: 'better-sqlite3',
binary: join('better-sqlite3', 'build', 'Release', 'better_sqlite3.node'),
},
]
/** N-API 기반이라 타임 무관 — 존재만 확인 */
const NAPI_MODULES = [
{ name: 'uiohook-napi', binary: join('uiohook-napi', 'build', 'Release', 'uiohook_napi.node') },
]
const failures = []
const notes = []
for (const module of REQUIRED_ELECTRON_ABI) {
const binaryPath = join(unpackedRoot, module.binary)
if (!existsSync(binaryPath)) {
failures.push(`${module.name}: 패키징된 네이티브 바이너리가 없습니다 → ${binaryPath}`)
continue
}
// 호스트 Node로 로드 시도: 성공하면 Electron ABI가 아니다.
const probe = spawnSync(
process.execPath,
['-e', `require(${JSON.stringify(binaryPath.replace(/\\/g, '\\\\'))})`],
{ encoding: 'utf8' },
)
const output = `${probe.stdout ?? ''}${probe.stderr ?? ''}`
if (probe.status === 0) {
failures.push(
[
`${module.name}: 호스트 Node에서 로드됩니다 = Electron ABI가 아니다.`,
' 패키징 전에 Electron용으로 재빌드해야 합니다 (electron-builder npmRebuild=true,',
' 또는 `npx @electron/rebuild -v <electronVersion>`).',
].join('\n'),
)
continue
}
if (/NODE_MODULE_VERSION/.test(output)) {
notes.push(`${module.name}: Electron ABI 확인 (${output.split('\n')[0].slice(0, 80)})`)
} else {
failures.push(`${module.name}: 알 수 없는 오류로 로드 실패 → ${output.split('\n')[0]}`)
}
}
for (const module of NAPI_MODULES) {
const binaryPath = join(unpackedRoot, module.binary)
if (!existsSync(binaryPath)) {
notes.push(`${module.name}: 바이너리 없음(선택) — ${binaryPath}`)
} else {
notes.push(`${module.name}: 존재 확인 (N-API)`)
}
}
for (const note of notes) console.log(`[native-abi] ${note}`)
if (failures.length > 0) {
console.error('[native-abi] 검증 실패:')
for (const failure of failures) console.error(` - ${failure}`)
process.exit(1)
}
console.log('[native-abi] GREEN — 패키징된 네이티브 모듈이 Electron에서 실행 가능한 ABI입니다.')

View file

@ -0,0 +1,70 @@
// scripts/ci/write-app-update-yml.mjs
// 패키징된 앱 트리에 electron-updater 설정 파일(resources/app-update.yml)을 보장한다.
//
// 배경(실측 사고): electron-builder는 `--dir`/`--prepackaged` 경로에서 app-update.yml을
// 생성하지 않는다. 그래서 1.3.3 설치본에는 이 파일이 없었고, electron-updater가 설정을
// 읽지 못해 **자동 업데이트가 동작하지 않는다**.
// (일반 `electron-builder --win nsis` 경로에서는 생성되지만, 우리는 네이티브 ABI 검증을 위해
// --dir → 검증 → --prepackaged 순서를 쓰므로 직접 만들어 준다.)
//
// 값의 출처는 런타임 SSOT인 apps/desktop/src/main/update-feed.ts의 UPDATE_FEED_URL 하나뿐이다.
//
// 사용: node scripts/ci/write-app-update-yml.mjs --dir <packagedDir>
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const dirFlagIndex = process.argv.indexOf('--dir')
const packagedDir = dirFlagIndex >= 0 ? process.argv[dirFlagIndex + 1] : null
if (!packagedDir || !existsSync(packagedDir)) {
console.error('사용: node scripts/ci/write-app-update-yml.mjs --dir <packagedDir>')
process.exit(1)
}
// 피드 URL은 update-feed.ts가 정본이다 (electron-builder.yml의 publish.url과 동일해야 한다).
const feedSource = readFileSync(join(root, 'apps', 'desktop', 'src', 'main', 'update-feed.ts'), 'utf8')
const feedMatch = feedSource.match(/export const UPDATE_FEED_URL\s*=\s*'([^']+)'/)
if (!feedMatch) {
console.error('[app-update] update-feed.ts에서 UPDATE_FEED_URL을 찾을 수 없습니다.')
process.exit(1)
}
const feedUrl = feedMatch[1]
const electronBuilderConfig = readFileSync(
join(root, 'apps', 'desktop', 'electron-builder.yml'),
'utf8',
)
const publishMatch = electronBuilderConfig.match(/^publish:\s*$[\s\S]*?url:\s*"([^"]+)"/m)
if (publishMatch && publishMatch[1] !== feedUrl) {
console.error(
[
'[app-update] feed URL 불일치:',
` update-feed.ts : ${feedUrl}`,
` electron-builder.yml : ${publishMatch[1]}`,
' 두 값은 같아야 합니다(자동 업데이트 계약).',
].join('\n'),
)
process.exit(1)
}
const target = join(packagedDir, 'resources', 'app-update.yml')
const contents = [
'provider: generic',
`url: ${feedUrl}`,
// electron-builder가 일반 경로에서 생성하는 값과 동일한 규칙(제품명 기반)
"updaterCacheDirName: 'd3ro-voice-updater'",
'',
].join('\n')
const existing = existsSync(target) ? readFileSync(target, 'utf8') : null
if (existing === contents) {
console.log('[app-update] 이미 최신 상태입니다')
process.exit(0)
}
writeFileSync(target, contents, 'utf8')
console.log(`[app-update] 작성: ${target}`)
console.log(contents.trimEnd())

View file

@ -0,0 +1,131 @@
# scripts/install/install-d3ro-voice.ps1
# 서명 없이 D3RO Voice를 설치하는 수동 설치 스크립트.
#
# 왜 스크립트인가: canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 100MiB를 넘으면
# 거부된다. 사이드카(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
#
# 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'),
[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
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 ($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 $part.sha256) {
throw "해시가 일치하지 않습니다: $($part.name)`n 기대: $($part.sha256)`n 실제: $hash"
}
}
Write-Step '모든 부품의 SHA-256 검증 완료'
# 3. 부품 이어 붙이기
$archive = Join-Path $workDir $index.zipArchive
$stream = [System.IO.File]::Create($archive)
try {
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. 압축 해제 (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 를 붙이세요."
}
Write-Step "기존 설치를 교체합니다: $InstallDir"
Remove-Item -Recurse -Force $InstallDir
}
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
Copy-Item -Path (Join-Path $extractDir '*') -Destination $InstallDir -Recurse -Force
# 6. 시작 메뉴 바로가기
$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 ' - Scoop 사용자는 scoop update d3ro-voice 로 갱신할 수 있습니다(7z 162MiB로 더 작음).'
Write-Host ''
Write-Host "실행: `"$exe`"" -ForegroundColor Green

View file

@ -0,0 +1,29 @@
// server/cloudflare-site-bridge/src/index.ts
// d3ro.chanpaca.net → Cloudflare Pages(d3ro.pages.dev) 프록시.
//
// Pages 커스텀 도메인은 존 DNS에 CNAME을 요구하므로, DNS를 건드릴 수 없는 동안
// 이 워커가 도메인을 살린다. 콘텐츠 정본은 Pages 배포본 하나이므로 CI가 Pages에
// 배포하면 도메인에도 그대로 반영된다.
const PAGES_ORIGIN = 'https://d3ro.pages.dev'
export default {
async fetch(request: Request): Promise<Response> {
const target = new URL(request.url)
target.protocol = 'https:'
target.hostname = new URL(PAGES_ORIGIN).hostname
target.port = ''
const headers = new Headers(request.headers)
headers.delete('host')
const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
return fetch(target.toString(), {
method: request.method,
headers,
body: hasBody ? request.body : undefined,
redirect: 'manual',
})
},
}

View file

@ -0,0 +1,20 @@
# server/cloudflare-site-bridge/wrangler.toml
#
# d3ro.chanpaca.net 을 Cloudflare Pages 배포본(d3ro.pages.dev)에 연결하는 브리지.
#
# 왜 필요한가: Pages 커스텀 도메인은 존 DNS에 CNAME(d3ro → d3ro.pages.dev)을 요구한다.
# 기존 d3ro 레코드가 남아 있어 Pages가 레코드를 만들지 못하고("CNAME record not set")
# 도메인은 빈 404를 반환했다. DNS 편집 권한 없이 도메인을 살리기 위해, 이미 프록시된
# 호스트네임에 Workers 라우트를 걸어 Pages 배포본을 그대로 서빙한다.
#
# 정리(권장): 대시보드에서 CNAME d3ro → d3ro.pages.dev 를 추가한 뒤 이 라우트와
# 워커를 제거하면 트래픽이 Pages 커스텀 도메인으로 직접 흐른다.
# npx wrangler delete --name d3ro-site-bridge (라우트는 워커 삭제 시 함께 해제)
name = "d3ro-site-bridge"
main = "src/index.ts"
compatibility_date = "2024-04-01"
routes = [
{ pattern = "d3ro.chanpaca.net/*", zone_name = "chanpaca.net" }
]

View file

@ -1,12 +1,12 @@
{ {
"name": "d3ro-voice-site", "name": "d3ro-voice-site",
"version": "1.3.0", "version": "1.3.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "d3ro-voice-site", "name": "d3ro-voice-site",
"version": "1.3.0", "version": "1.3.7",
"dependencies": { "dependencies": {
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"

View file

@ -1,7 +1,7 @@
{ {
"name": "d3ro-voice-site", "name": "d3ro-voice-site",
"private": true, "private": true,
"version": "1.3.0", "version": "1.3.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port 5199 --host", "dev": "vite --port 5199 --host",

View file

@ -5,10 +5,10 @@
// NAS 배포는 바이너리를 포함하지 않으므로 `/releases/...` 같은 로컬 경로는 // NAS 배포는 바이너리를 포함하지 않으므로 `/releases/...` 같은 로컬 경로는
// 실제 배포 환경에서 404가 된다. // 실제 배포 환경에서 404가 된다.
export const DESKTOP_VERSION = '1.2.0' export const DESKTOP_VERSION = '1.3.7'
/** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */ /** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */
export const DESKTOP_RELEASE_DATE = '2026-09-16' export const DESKTOP_RELEASE_DATE = '2026-09-19'
const FORGEJO_ORIGIN = 'https://git.chanpaca.net' const FORGEJO_ORIGIN = 'https://git.chanpaca.net'
const FORGEJO_OWNER = 'yunchan' const FORGEJO_OWNER = 'yunchan'

View file

@ -15,7 +15,7 @@ export function Hero() {
const [typedRaw, setTypedRaw] = useState('') const [typedRaw, setTypedRaw] = useState('')
const [typedClean, setTypedClean] = useState('') const [typedClean, setTypedClean] = useState('')
const [waveLevels, setWaveLevels] = useState([0.3, 0.5, 0.8, 1, 0.9, 0.7, 0.4, 0.6, 0.3]) const [waveLevels, setWaveLevels] = useState([0.3, 0.5, 0.8, 1, 0.9, 0.7, 0.4, 0.6, 0.3])
const timerRef = useRef<NodeJS.Timeout | null>(null) const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sampleRawText = locale === 'ko' const sampleRawText = locale === 'ko'
? '어... 이번 프로젝트 배포는 다음 주 금요일까지로 잡으면 될 것 같아요.' ? '어... 이번 프로젝트 배포는 다음 주 금요일까지로 잡으면 될 것 같아요.'