Compare commits
18 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ca9e242fa | ||
|
|
79ecdc89c4 | ||
|
|
2407f5a1c1 | ||
|
|
4b40b25e53 | ||
|
|
ee1deb64cf | ||
|
|
f741999859 | ||
|
|
05f0aaa660 | ||
|
|
ae7efb6acf | ||
|
|
74cbc8f6ae | ||
|
|
6766cb8c8e | ||
|
|
27facb8569 | ||
|
|
57c17d0977 | ||
|
|
f14341ace4 | ||
|
|
1af3cf75c7 | ||
|
|
0fbbbc1756 | ||
|
|
0411f389d9 | ||
|
|
c35c6f3e95 | ||
|
|
a85ab799a3 |
86 changed files with 3219 additions and 210 deletions
72
.forgejo/workflows/portable.yml
Normal file
72
.forgejo/workflows/portable.yml
Normal 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
|
||||||
|
|
@ -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 산출물 검증
|
||||||
|
|
|
||||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
82
CHANGELOG.md
82
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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로 변환
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
390
apps/desktop/src/main/services/RuntimeProvisioner.ts
Normal file
390
apps/desktop/src/main/services/RuntimeProvisioner.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: () =>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) 설정 */
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Added a signing-free install path: the download is served in verifiable parts and installed after checking them.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Reworked the install layout so updates flow again; required components are verified and fetched only when first needed.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Fixed an installer that could not start, and updated the default local model to the current one.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Fixed an installer that was missing its update configuration.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Updated the recommended local model list.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Stability improvements.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Overlay fixes: the recording waveform/timer and live captions now display correctly.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
서명 인증서 없이도 설치할 수 있는 배포 경로를 추가했습니다. 설치 파일이 나뉘어 제공되고 검증 후에 설치됩니다.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
업데이트가 정상 동작하도록 설치 구조를 정리했습니다. 필요한 기능은 처음 사용할 때 검증하여 내려받습니다.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
실행이 안 되던 설치 문제를 고쳤습니다. 로컬 모델 기본값도 최신 모델로 정리했습니다.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
자동 업데이트 설정이 누락되던 문제를 고쳤습니다.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
로컬 모델 추천 목록을 최신 모델로 정리했습니다.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
안정성을 개선했습니다.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
오버레이 수정: 녹음 파형/타이머와 실시간 자막이 정상 표시됩니다.
|
||||||
14
apps/mobile-rn/package-lock.json
generated
14
apps/mobile-rn/package-lock.json
generated
|
|
@ -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": "*"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
18
bucket/README.md
Normal 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
35
bucket/d3ro-voice.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
133
docs/deployment/unsigned-distribution.md
Normal file
133
docs/deployment/unsigned-distribution.md
Normal 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`
|
||||||
|
확인 → 시작 메뉴 바로가기 생성. (검증 후 테스트 설치/바로가기 제거)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
|
||||||
|
|
@ -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 모듈로 앱이 시작 즉시 죽은 사고 + 누락으로 자동 업데이트가 죽은 사고를 함께 방지(). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 로그로 승격한다. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
109
package-lock.json
generated
|
|
@ -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": "*"
|
||||||
|
|
|
||||||
13
package.json
13
package.json
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
436
scripts/ci/build-portable.mjs
Normal file
436
scripts/ci/build-portable.mjs
Normal 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'),
|
||||||
|
)
|
||||||
119
scripts/ci/fix-native-abi.mjs
Normal file
119
scripts/ci/fix-native-abi.mjs
Normal 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 })
|
||||||
231
scripts/ci/publish-portable-release.mjs
Normal file
231
scripts/ci/publish-portable-release.mjs
Normal 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'),
|
||||||
|
)
|
||||||
284
scripts/ci/publish-updater-release.mjs
Normal file
284
scripts/ci/publish-updater-release.mjs
Normal 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'),
|
||||||
|
)
|
||||||
131
scripts/ci/set-forgejo-secrets.mjs
Normal file
131
scripts/ci/set-forgejo-secrets.mjs
Normal 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)
|
||||||
|
|
@ -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}.`)
|
||||||
|
|
|
||||||
160
scripts/ci/verify-desktop-renderer-bundles.mjs
Normal file
160
scripts/ci/verify-desktop-renderer-bundles.mjs
Normal 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`)
|
||||||
|
}
|
||||||
117
scripts/ci/verify-native-abi.mjs
Normal file
117
scripts/ci/verify-native-abi.mjs
Normal 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입니다.')
|
||||||
70
scripts/ci/write-app-update-yml.mjs
Normal file
70
scripts/ci/write-app-update-yml.mjs
Normal 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())
|
||||||
131
scripts/install/install-d3ro-voice.ps1
Normal file
131
scripts/install/install-d3ro-voice.ps1
Normal 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
|
||||||
29
server/cloudflare-site-bridge/src/index.ts
Normal file
29
server/cloudflare-site-bridge/src/index.ts
Normal 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',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
20
server/cloudflare-site-bridge/wrangler.toml
Normal file
20
server/cloudflare-site-bridge/wrangler.toml
Normal 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" }
|
||||||
|
]
|
||||||
4
site/package-lock.json
generated
4
site/package-lock.json
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
? '어... 이번 프로젝트 배포는 다음 주 금요일까지로 잡으면 될 것 같아요.'
|
? '어... 이번 프로젝트 배포는 다음 주 금요일까지로 잡으면 될 것 같아요.'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue