diff --git a/.forgejo/workflows/portable.yml b/.forgejo/workflows/portable.yml new file mode 100644 index 0000000..205659b --- /dev/null +++ b/.forgejo/workflows/portable.yml @@ -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 \ No newline at end of file diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index e5c3913..d42bffd 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -62,8 +62,10 @@ jobs: throw "로컬 개발 인증서는 production 서명 identity가 아닙니다." } npm run build --workspace=@d3ro/desktop + node scripts/ci/verify-desktop-renderer-bundles.mjs Push-Location apps/desktop npx electron-builder --win --x64 --config electron-builder.yml --publish never + node scripts/ci/verify-native-abi.mjs Pop-Location - name: Windows 산출물 검증 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0694180..0db2b41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,6 +169,10 @@ jobs: - name: Build Target Workspace 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 # ────────────────────────────────────────────────────────────────── diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7391d26..75e3d44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,6 +109,9 @@ jobs: npm run typecheck 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) run: | # Local transcription depends on the faster-whisper sidecar; a release @@ -138,6 +141,7 @@ jobs: Push-Location apps/desktop try { npx electron-builder --win --x64 --config electron-builder.yml + node scripts/ci/verify-native-abi.mjs if ($LASTEXITCODE -ne 0) { throw "electron-builder failed with exit code $LASTEXITCODE." } } finally { @@ -188,6 +192,9 @@ jobs: npm run typecheck 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) run: | # Local transcription depends on the faster-whisper sidecar; a release @@ -201,6 +208,7 @@ jobs: run: | cd apps/desktop npx electron-builder --mac --arm64 --config electron-builder.yml + node scripts/ci/verify-native-abi.mjs env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.MAC_CSC_LINK }} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fdcef83..793335c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -251,6 +251,7 @@ package-windows: - npm run build --workspace=@d3ro/desktop - cd apps/desktop - npx electron-builder --win --x64 --config electron-builder.yml + - node scripts/ci/verify-native-abi.mjs - cd ../.. - $releaseVersion = node -p "require('./release/product-version.json').version" - '& scripts/ci/verify-windows-release-artifact.ps1 -ExpectedVersion $releaseVersion -ExpectedSignerSubject $env:WIN_CSC_EXPECTED_SIGNER_SUBJECT -ReleaseDirectory "apps/desktop/release/$releaseVersion"' @@ -282,6 +283,7 @@ package-macos: - npm run build --workspace=@d3ro/desktop - cd apps/desktop - npx electron-builder --mac --arm64 --config electron-builder.yml + - node scripts/ci/verify-native-abi.mjs artifacts: name: "d3ro-voice-macos-$CI_COMMIT_TAG" paths: diff --git a/CHANGELOG.md b/CHANGELOG.md index afe66c4..f3aaa8c 100644 --- a/CHANGELOG.md +++ b/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) - 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 ` + + diff --git a/apps/desktop/src/renderer/popups/command-popup/index.html b/apps/desktop/src/renderer/popups/command-popup/index.html index 664087f..996a62f 100644 --- a/apps/desktop/src/renderer/popups/command-popup/index.html +++ b/apps/desktop/src/renderer/popups/command-popup/index.html @@ -19,6 +19,7 @@ - + + diff --git a/apps/desktop/src/renderer/popups/history-popup/index.html b/apps/desktop/src/renderer/popups/history-popup/index.html index 780a1fb..0a461e5 100644 --- a/apps/desktop/src/renderer/popups/history-popup/index.html +++ b/apps/desktop/src/renderer/popups/history-popup/index.html @@ -17,6 +17,7 @@ - + + diff --git a/apps/desktop/src/renderer/popups/recording-tip/index.html b/apps/desktop/src/renderer/popups/recording-tip/index.html index 58ea4ba..623064c 100644 --- a/apps/desktop/src/renderer/popups/recording-tip/index.html +++ b/apps/desktop/src/renderer/popups/recording-tip/index.html @@ -34,6 +34,7 @@ - + + diff --git a/apps/desktop/src/renderer/popups/result-popup/index.html b/apps/desktop/src/renderer/popups/result-popup/index.html index 552fe47..6457d33 100644 --- a/apps/desktop/src/renderer/popups/result-popup/index.html +++ b/apps/desktop/src/renderer/popups/result-popup/index.html @@ -23,6 +23,7 @@ - + + diff --git a/apps/mobile-rn/android/app/build.gradle b/apps/mobile-rn/android/app/build.gradle index c99b448..b71db41 100644 --- a/apps/mobile-rn/android/app/build.gradle +++ b/apps/mobile-rn/android/app/build.gradle @@ -151,8 +151,8 @@ def versionSettingsValid = configuredVersionName != null && configuredVersionName ==~ strictSemver && configuredVersionCodeValue != null && configuredVersionCodeValue <= 2100000000L -def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.0" -def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1030001 +def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.7" +def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031007 def requiredReleaseSettings = [ D3RO_RELEASE_STORE_FILE: releaseStoreFilePath, diff --git a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj index 81d4618..24b59d2 100644 --- a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj +++ b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj @@ -257,7 +257,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1030001; + CURRENT_PROJECT_VERSION = 1031007; ENABLE_BITCODE = NO; INFOPLIST_FILE = D3ROVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -265,7 +265,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.7; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -287,14 +287,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1030001; + CURRENT_PROJECT_VERSION = 1031007; INFOPLIST_FILE = D3ROVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.7; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1031001.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1031001.txt new file mode 100644 index 0000000..16d8ac2 --- /dev/null +++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1031001.txt @@ -0,0 +1 @@ +Added a signing-free install path: the download is served in verifiable parts and installed after checking them. diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1031002.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1031002.txt new file mode 100644 index 0000000..cd36bea --- /dev/null +++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1031002.txt @@ -0,0 +1 @@ +Reworked the install layout so updates flow again; required components are verified and fetched only when first needed. diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1031003.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1031003.txt new file mode 100644 index 0000000..e03707e --- /dev/null +++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1031003.txt @@ -0,0 +1 @@ +Fixed an installer that could not start, and updated the default local model to the current one. diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1031004.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1031004.txt new file mode 100644 index 0000000..27365b8 --- /dev/null +++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1031004.txt @@ -0,0 +1 @@ +Fixed an installer that was missing its update configuration. diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1031005.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1031005.txt new file mode 100644 index 0000000..ffd1821 --- /dev/null +++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1031005.txt @@ -0,0 +1 @@ +Updated the recommended local model list. diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1031006.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1031006.txt new file mode 100644 index 0000000..9435f6d --- /dev/null +++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1031006.txt @@ -0,0 +1 @@ +Stability improvements. diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1031007.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1031007.txt new file mode 100644 index 0000000..2ea3629 --- /dev/null +++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1031007.txt @@ -0,0 +1 @@ +Overlay fixes: the recording waveform/timer and live captions now display correctly. diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031001.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031001.txt new file mode 100644 index 0000000..df7a195 --- /dev/null +++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031001.txt @@ -0,0 +1 @@ +서명 인증서 없이도 설치할 수 있는 배포 경로를 추가했습니다. 설치 파일이 나뉘어 제공되고 검증 후에 설치됩니다. diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031002.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031002.txt new file mode 100644 index 0000000..ce9e668 --- /dev/null +++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031002.txt @@ -0,0 +1 @@ +업데이트가 정상 동작하도록 설치 구조를 정리했습니다. 필요한 기능은 처음 사용할 때 검증하여 내려받습니다. diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031003.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031003.txt new file mode 100644 index 0000000..95a5ca6 --- /dev/null +++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031003.txt @@ -0,0 +1 @@ +실행이 안 되던 설치 문제를 고쳤습니다. 로컬 모델 기본값도 최신 모델로 정리했습니다. diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031004.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031004.txt new file mode 100644 index 0000000..0d0d3e4 --- /dev/null +++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031004.txt @@ -0,0 +1 @@ +자동 업데이트 설정이 누락되던 문제를 고쳤습니다. diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031005.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031005.txt new file mode 100644 index 0000000..5ffd625 --- /dev/null +++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031005.txt @@ -0,0 +1 @@ +로컬 모델 추천 목록을 최신 모델로 정리했습니다. diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031006.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031006.txt new file mode 100644 index 0000000..e80179f --- /dev/null +++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031006.txt @@ -0,0 +1 @@ +안정성을 개선했습니다. diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031007.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031007.txt new file mode 100644 index 0000000..031c828 --- /dev/null +++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031007.txt @@ -0,0 +1 @@ +오버레이 수정: 녹음 파형/타이머와 실시간 자막이 정상 표시됩니다. diff --git a/apps/mobile-rn/package-lock.json b/apps/mobile-rn/package-lock.json index dc24c46..e3cbe30 100644 --- a/apps/mobile-rn/package-lock.json +++ b/apps/mobile-rn/package-lock.json @@ -1,12 +1,12 @@ { "name": "@d3ro/mobile-rn", - "version": "1.3.0", + "version": "1.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@d3ro/mobile-rn", - "version": "1.3.0", + "version": "1.3.7", "dependencies": { "@d3ro/api-client": "file:../../packages/api-client", "@d3ro/core": "file:../../packages/core", @@ -62,7 +62,7 @@ }, "../..": { "name": "d3ro-voice-monorepo", - "version": "1.3.0", + "version": "1.3.7", "license": "MIT", "workspaces": [ "apps/desktop", @@ -81,7 +81,7 @@ }, "../../packages/api-client": { "name": "@d3ro/api-client", - "version": "1.3.0", + "version": "1.3.7", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -98,7 +98,7 @@ }, "../../packages/core": { "name": "@d3ro/core", - "version": "1.3.0", + "version": "1.3.7", "license": "MIT", "dependencies": { "docx": "^9.6.1" @@ -109,7 +109,7 @@ }, "../../packages/i18n": { "name": "@d3ro/i18n", - "version": "1.3.0", + "version": "1.3.7", "license": "MIT", "devDependencies": { "@types/react": "^19.0.0" @@ -120,7 +120,7 @@ }, "../../packages/ui-native": { "name": "@d3ro/ui-native", - "version": "1.3.0", + "version": "1.3.7", "license": "MIT", "devDependencies": { "@types/react": "*" diff --git a/apps/mobile-rn/package.json b/apps/mobile-rn/package.json index 4d33564..81ef219 100644 --- a/apps/mobile-rn/package.json +++ b/apps/mobile-rn/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/mobile-rn", - "version": "1.3.0", + "version": "1.3.7", "private": true, "scripts": { "android": "react-native run-android", diff --git a/apps/web/package.json b/apps/web/package.json index 2aef4ab..8fbf2a1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/web", - "version": "1.3.0", + "version": "1.3.7", "private": true, "description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스", "scripts": { diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index ef28ded..e4a0264 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -139,7 +139,7 @@ export function Sidebar(): React.ReactElement { - v1.3.0 + v1.3.7 diff --git a/apps/web/src/lib/desktop-release.ts b/apps/web/src/lib/desktop-release.ts index 27c051b..6a81fb6 100644 --- a/apps/web/src/lib/desktop-release.ts +++ b/apps/web/src/lib/desktop-release.ts @@ -4,10 +4,10 @@ // 설치 파일은 canonical Forgejo feed에서만 배포한다. 웹/사이트 빌드 산출물에는 // 설치 바이너리가 포함되지 않으므로 `/releases/...` 로컬 경로는 배포 환경에서 404다. -export const DESKTOP_VERSION = '1.2.0' +export const DESKTOP_VERSION = '1.3.7' /** 릴리스 게시일. `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_OWNER = 'yunchan' diff --git a/bucket/README.md b/bucket/README.md new file mode 100644 index 0000000..76fed4e --- /dev/null +++ b/bucket/README.md @@ -0,0 +1,18 @@ +# Scoop bucket — D3RO Voice + +서명 인증서 없이도 설치할 수 있는 배포 경로입니다. Scoop은 파일을 직접 내려받아 +MOTW(Mark-of-the-Web)를 남기지 않으므로 SmartScreen 경고가 뜨지 않고, 관리자 권한도 +필요하지 않습니다. + +```powershell +scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git +scoop install d3ro/d3ro-voice +``` + +매니페스트(`bucket/d3ro-voice.json`)는 `scripts/ci/build-portable.mjs`가 버전/URL/해시를 +자동으로 채워 생성합니다. 손으로 수정하지 말고 그 스크립트를 다시 실행하세요. + +- 이 채널은 **서명되지 않은** 휴대용 ZIP을 배포합니다(파일명에 `-portable-unsigned`). +- 자동 업데이트 피드(`latest.yml`)와는 분리되어 있습니다. 서명된 NSIS/MSIX 설치본은 + 인증서가 준비되면 기존 릴리스 파이프라인으로 게시합니다. +- `scoop update d3ro-voice`로 새 버전을 받을 수 있습니다. \ No newline at end of file diff --git a/bucket/d3ro-voice.json b/bucket/d3ro-voice.json new file mode 100644 index 0000000..6a68196 --- /dev/null +++ b/bucket/d3ro-voice.json @@ -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" + ] + } + } + } +} diff --git a/docs/deployment/release-guide.md b/docs/deployment/release-guide.md index 073fa40..103a320 100644 --- a/docs/deployment/release-guide.md +++ b/docs/deployment/release-guide.md @@ -1,41 +1,41 @@ # D3RO Voice 릴리스 가이드 -기준일: 2026-09-16. 이 문서는 desktop GitLab 패키지·자동 업데이트와 mobile store release의 경계를 분리한다. 태그 생성이나 HTTP 200 하나만으로 배포 완료를 선언하지 않는다. +기준일: 2026-09-18. 이 문서는 desktop GitLab 패키지·자동 업데이트와 mobile store release의 경계를 분리한다. 태그 생성이나 HTTP 200 하나만으로 배포 완료를 선언하지 않는다. ## 현재 release identity | 항목 | 정본 | 현재 판정 | |---|---|---| -| 제품 버전 | `release/product-version.json`: `1.2.0` | source SSOT 확정 | -| Android | versionCode `1020001` | production AAB 미생성 | -| iOS | build `1020001` | production archive 미검증 | +| 제품 버전 | `release/product-version.json`: `1.3.0` | source SSOT 확정 | +| Android | versionCode `1030001` | production AAB 미생성 | +| iOS | build `1030001` | production archive 미검증 | | Android upload key | alias `d3ro-upload-20260821`, cert SHA-256 `4F:AC:69:24:...:15:2B:54` | external PKCS12·user-only ACL·Credential Manager·private-key readback GREEN; CI secret·복구 백업·AAB signer 대조 대기 | | release evidence | Ed25519 public `release/mobile-release-evidence-public.pem`, keyId `2797d3e6...4a890b7f` | external private key ACL·roundtrip GREEN; CI private-key secret·복구 백업 대기 | | desktop offline license | Ed25519 public `apps/desktop/resources/license/production-public.pem`, keyId `5c52b765...81a887f` | 새 전용 keypair·external private ACL·roundtrip·desktop production build GREEN; admin `ADMIN_LICENSE_PRIVATE_KEY` secret 주입 대기 | -| Windows Authenticode | external public-trust code-signing certificate | 현재 local installer·unpacked app은 `NotSigned`; production PFX·CI secret·signed artifact GREEN 전까지 게시 금지 | +| Windows Authenticode | external public-trust code-signing certificate | production PFX 없음. Forgejo 저장소 시크릿 4종(`WIN_CSC_LINK`/`WIN_CSC_KEY_PASSWORD`/`WIN_CSC_EXPECTED_SIGNER_SUBJECT`/`FORGEJO_TOKEN`)이 0건이라 릴리스 파이프라인이 fail-closed. `scripts/ci/set-forgejo-secrets.mjs --check`로 확인한다. GREEN 전까지 게시 금지 | | Firebase | Console `u/0`, `u/1` 모두 D3RO project 없음 | 사용자 승인 후 project·Android app 생성 필요 | | AdMob | app `ca-app-pub-1039714767792854~6427959892`; banner `/9840591290`; rewarded `/2255790918` | SSOT 확정. `검토 필요`·`광고 게재 제한`·store 미연결·결제 프로필 미완료 | | updater feed (canonical) | `https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest` | Forgejo Generic Registry. GitLab project 1172은 legacy mirror | -| release notes | `CHANGELOG.md` `## [1.2.0]` | 태그 전 확정·검증 필수 | -| 직전 게시본 | Forgejo Release `v1.1.0` (2026-09-15 게시, unsigned installer 포함) | 불변 태그. `1.2.0`은 이를 대체하는 forward-fix | +| release notes | `CHANGELOG.md` `## [1.3.0]` + Play changelog `1030001.txt` (ko/en) | 태그 전 확정·검증 필수 | +| 직전 게시본 | Forgejo Release `v1.1.0` (2026-09-15 게시, unsigned installer) | `v1.2.0`·`v1.3.0` 파이프라인은 Forgejo 저장소 시크릿이 없어 실패 = 게시본 없음. 설치본이 있는 마지막 버전은 `1.1.0` | -live canonical feed(`git.chanpaca.net/.../d3ro-voice/latest`)의 `latest.yml`은 현재 `1.1.0`을 보고한다. `1.2.0` 태그 파이프라인이 GREEN이 되면 그 값이 올라간다. +live canonical feed(`git.chanpaca.net/.../d3ro-voice/latest`)의 `latest.yml`은 현재 `1.1.0`을 보고한다. 시크릿을 채우고 `v1.3.0` 파이프라인이 GREEN이 되면 그 값이 `1.3.0`으로 올라가고, 그때부터 기존 설치본이 자동 업데이트를 받는다. ## desktop 릴리스 파이프라인 ```text authoritative release commit → version/check/test/build GREEN - → annotated tag v1.2.0 + → annotated tag v1.3.0 → package-windows (build-win-x64) → package-macos (build-mac-arm64) → publish-release (build-linux-x64) ├─ publish-forgejo-release.mjs ← canonical - │ ├─ Forgejo Generic Registry /d3ro-voice/1.2.0/ (버전별 보존) + │ ├─ Forgejo Generic Registry /d3ro-voice/1.3.0/ (버전별 보존) │ ├─ Forgejo Generic Registry /d3ro-voice/latest/ (updater feed + update-policy.json) │ └─ Forgejo Release + CHANGELOG notes + 자산 첨부 └─ publish-gitlab-release.mjs ← legacy mirror (pre-Forgejo 설치본) - ├─ GitLab Generic Registry /d3ro-voice/1.2.0/ + ├─ GitLab Generic Registry /d3ro-voice/1.3.0/ ├─ GitLab Generic Registry /d3ro-voice/latest/ └─ GitLab Release ``` @@ -50,7 +50,7 @@ authoritative release commit 포함되지 않는다. 사이트·웹 다운로드 센터는 로컬 경로가 아니라 feed URL을 링크한다. (역사적 `1.0.0` 자산만 추적 상태로 남아 있다.) -- `scripts/ci/sync-version.mjs --check --tag v1.2.0`는 태그, `release/product-version.json`, package/lockfile, Android/iOS 버전 면의 일치를 fail-closed로 검증한다. +- `scripts/ci/sync-version.mjs --check --tag v1.3.0`는 태그, `release/product-version.json`, package/lockfile, Android/iOS 버전 면의 일치를 fail-closed로 검증한다. - `scripts/ci/verify-release-metadata.mjs`는 배포 메타데이터와 CI/publisher 계약을 검증한다. - 같은 gate는 desktop license public key가 Ed25519이고 `release/product-version.json`의 `desktopLicensePublicKeyId`와 일치하는지 검증한다. `electron.vite.config.ts`는 이 파일을 직접 읽으므로 누락·손상된 키로는 build가 시작되지 않는다. - `scripts/ci/publish-forgejo-release.mjs`는 canonical이다. 버전별 패키지를 먼저 올리고, `latest`에서 설치 자산 참조를 검증한 뒤 `latest.yml`과 `update-policy.json`을 마지막에 게시하고 공개 URL에서 재검증한다. `scripts/ci/publish-gitlab-release.mjs`는 legacy mirror로 동일 자산을 GitLab에도 올린다. @@ -110,7 +110,83 @@ https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice - **회수(rollback)**: `stagingPercentage`를 낮추거나 `killSwitch`를 켠다. 이미 배포된 버전은 되돌리지 않고 더 높은 patch로 forward-fix한다. -## `1.2.0` 릴리스 절차 +## `1.3.0` 릴리스 절차 (canonical = Forgejo Actions) + +**선행 조건 — Forgejo 저장소 시크릿.** `.forgejo/workflows/release.yml`은 아래 4개가 +없으면 fail-closed로 중단한다. 실측(2026-09-18): 저장소 시크릿이 0건이라 `v1.2.0`과 +`v1.3.0` 태그 파이프라인이 서명 가드에서 실패했고, 그래서 설치본·업데이트가 게시되지 +않았다. 현재 상태 점검과 등록은 다음 한 줄로 한다. + +```bash +node scripts/ci/set-forgejo-secrets.mjs --check +node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write +``` + +| 시크릿 | 의미 | +|---|---| +| `WIN_CSC_LINK` | public-trust Authenticode PFX (base64). 개발용 `Everything2EverythingDev`는 거부된다 | +| `WIN_CSC_KEY_PASSWORD` | PFX password | +| `WIN_CSC_EXPECTED_SIGNER_SUBJECT` | 인증서의 정확한 subject. `verify-windows-release-artifact.ps1`이 이 identity를 요구한다 | +| `FORGEJO_TOKEN` | `write:package` + `write:repository` (릴리스 게시 단계) | + +1. `release/product-version.json`의 version/build 값과 모든 버전 면을 `npm run version:check`로 대조한다. +2. `CHANGELOG.md` `## [1.3.0] - 2026-09-18` 섹션을 사용자 변경점 중심으로 확정한다. publisher는 이 섹션과 그에 대응하는 Play changelog(`apps/mobile-rn/metadata/android/*/changelogs/.txt`)가 없으면 실패한다. +3. dirty/untracked 작업을 임의로 reset·clean하지 말고, release 범위만 검토 가능한 authoritative commit으로 보존한다. +4. 같은 commit에서 lint, typecheck, test, build, release metadata·security·artifact gate를 전부 GREEN으로 만든다. +5. **로컬 전사 엔진 검증**: 파이프라인은 `sidecar:setup` → `sidecar:build` → `verify-sidecar-bundle.mjs`를 패키징 전에 실행한다. 이 게이트가 없으면 설치본에 엔진이 빠진 채 게시된다(과거 실제 사고). 로컬에서 `electron-builder --dir`만 볼 때는 서명 실패로 extraResources 복사가 중간에 끊기므로 `-c.win.forceCodeSigning=false`로 확인한다. +6. desktop offline license를 제공한다면 external private key를 admin의 `ADMIN_LICENSE_PRIVATE_KEY` secret로 주입하고, 저장소 public key와 sign/verify roundtrip 및 발급 감사 로그를 확인한다. +7. 이미 게시된 버전보다 높은 annotated 태그 `v1.3.0`을 생성해 push한다. `npm run release:tag -- --dry-run`으로 검증한 뒤 `npm run release:tag`(GPG 사용 시 `-- --sign`)와 `git push chanpaca v1.3.0`를 실행한다. 태그는 불변이며 게이트를 시작하는 후속 단계지 검증을 대체하지 않는다. 이미 게시된 버전을 재게시하지 않는다: canonical publisher는 버전별 자산이 다른 바이트를 가지면 fail-closed로 중단한다. +8. 시크릿을 나중에 채웠다면 태그를 새로 만들 필요가 없다 — `release.yml`은 `workflow_dispatch`를 지원하므로 Forgejo UI에서 해당 태그 ref로 수동 실행한다. +9. Forgejo Actions run(`/actions/tasks` API 또는 UI)에서 단계별 결과를 확인한다. pending/stuck/skipped를 GREEN으로 기록하지 않는다. +10. Forgejo Release note/asset, `latest.yml`, `update-policy.json`, installer hash를 외부 public URL에서 다시 검증한다. +11. 이전 설치본에서 자동 업데이트 E2E를 실행하고 실행 중 버전·프로세스·사용자 데이터 보존, 그리고 **업데이트 후 로컬 받아쓰기 1회 성공**을 확인한다. + +## 자동 업데이트 게시 (런타임 분리 전제) + +설치본은 **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 `로 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`로 대조한다. 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 - `apps/desktop/resources/license/production-public.pem` — desktop offline license public key SSOT - `scripts/ci/sync-version.mjs` — 버전 면 동기화·검증 +- `scripts/ci/build-portable.mjs` — 서명 없는 portable 산출물(7z 분할 볼륨 + Scoop 매니페스트) +- `scripts/ci/publish-portable-release.mjs` — portable 채널 게시(불변 가드) +- `scripts/install/install-d3ro-voice.ps1` — 수동 설치 스크립트(해시 검증 + 결합 + 해제) - `scripts/ci/create-release-tag.mjs` — 릴리스 태그 게이트 (annotated/서명, 불변) - `scripts/ci/verify-release-metadata.mjs` — release metadata 자가 검증 - `scripts/ci/verify-windows-release-artifact.ps1` — Windows version·updater metadata·Authenticode gate diff --git a/docs/deployment/unsigned-distribution.md b/docs/deployment/unsigned-distribution.md new file mode 100644 index 0000000..aaa75e0 --- /dev/null +++ b/docs/deployment/unsigned-distribution.md @@ -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//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--x64-portable.7z.001/.002` — LZMA2로 688MB → 162MiB. Scoop 전용. + - **zip 분할 부품** `...zip.001/.002/.003` — 243MiB. 수동 설치 스크립트용. Windows 내장 + `Expand-Archive`만으로 풀 수 있어 사용자가 7-Zip을 설치할 필요가 없다. +- 게시 위치: `.../generic/d3ro-voice/portable-/` 와 `.../portable-latest/` + (**자동 업데이트 피드 `latest.yml`과 완전히 분리**). +- 설치 방법 두 가지: + 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-` / `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` + 확인 → 시작 메뉴 바로가기 생성. (검증 후 테스트 설치/바로가기 제거) \ No newline at end of file diff --git a/docs/map/00-index.md b/docs/map/00-index.md index bda0005..d3413ac 100644 --- a/docs/map/00-index.md +++ b/docs/map/00-index.md @@ -2,7 +2,8 @@ > Status: ACTIVE > 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: > 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) diff --git a/docs/map/02-infrastructure.md b/docs/map/02-infrastructure.md index 11803d4..836a6db 100644 --- a/docs/map/02-infrastructure.md +++ b/docs/map/02-infrastructure.md @@ -78,6 +78,7 @@ npm run release:metadata[:test] npm run release:forgejo[:check] # canonical Forgejo publisher/feed npm run release:tag # annotated/signed immutable release tag 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 release:mobile:boundary[: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`) -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/`) +`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. --- @@ -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). -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 | |---|---| -| `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/desktop-license-public.pem` | Ed25519 public key for desktop offline licenses | | `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/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) | +| `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 | | `scripts/ci/publish-forgejo-release.mjs` | Canonical Forgejo registry + Release + feed publisher | diff --git a/docs/map/04-desktop-app.md b/docs/map/04-desktop-app.md index dd6e5df..317ae42 100644 --- a/docs/map/04-desktop-app.md +++ b/docs/map/04-desktop-app.md @@ -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. +**Popup invariants** (each shipped broken once — do not regress): + +- 팝업 HTML의 스크립트는 반드시 `') + 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, '') + 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`) +} \ No newline at end of file diff --git a/scripts/ci/verify-native-abi.mjs b/scripts/ci/verify-native-abi.mjs new file mode 100644 index 0000000..574c3b1 --- /dev/null +++ b/scripts/ci/verify-native-abi.mjs @@ -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//win-unpacked 자동 탐색 +// node scripts/ci/verify-native-abi.mjs --dir + +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 `).', + ].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입니다.') \ No newline at end of file diff --git a/scripts/ci/write-app-update-yml.mjs b/scripts/ci/write-app-update-yml.mjs new file mode 100644 index 0000000..0a8fe03 --- /dev/null +++ b/scripts/ci/write-app-update-yml.mjs @@ -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 + +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 ') + 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()) \ No newline at end of file diff --git a/scripts/install/install-d3ro-voice.ps1 b/scripts/install/install-d3ro-voice.ps1 new file mode 100644 index 0000000..fc6feee --- /dev/null +++ b/scripts/install/install-d3ro-voice.ps1 @@ -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 \ No newline at end of file diff --git a/server/cloudflare-site-bridge/src/index.ts b/server/cloudflare-site-bridge/src/index.ts new file mode 100644 index 0000000..a1b34e8 --- /dev/null +++ b/server/cloudflare-site-bridge/src/index.ts @@ -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 { + 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', + }) + }, +} diff --git a/server/cloudflare-site-bridge/wrangler.toml b/server/cloudflare-site-bridge/wrangler.toml new file mode 100644 index 0000000..9b9df39 --- /dev/null +++ b/server/cloudflare-site-bridge/wrangler.toml @@ -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" } +] diff --git a/site/package-lock.json b/site/package-lock.json index 57d4b69..4e3c001 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -1,12 +1,12 @@ { "name": "d3ro-voice-site", - "version": "1.3.0", + "version": "1.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "d3ro-voice-site", - "version": "1.3.0", + "version": "1.3.7", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/site/package.json b/site/package.json index 1c5a97d..87c082a 100644 --- a/site/package.json +++ b/site/package.json @@ -1,7 +1,7 @@ { "name": "d3ro-voice-site", "private": true, - "version": "1.3.0", + "version": "1.3.7", "type": "module", "scripts": { "dev": "vite --port 5199 --host", diff --git a/site/src/release.ts b/site/src/release.ts index 93e0264..910c795 100644 --- a/site/src/release.ts +++ b/site/src/release.ts @@ -5,10 +5,10 @@ // NAS 배포는 바이너리를 포함하지 않으므로 `/releases/...` 같은 로컬 경로는 // 실제 배포 환경에서 404가 된다. -export const DESKTOP_VERSION = '1.2.0' +export const DESKTOP_VERSION = '1.3.7' /** 릴리스 게시일. `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_OWNER = 'yunchan' diff --git a/site/src/sections/Hero.tsx b/site/src/sections/Hero.tsx index 3e0117a..550d619 100644 --- a/site/src/sections/Hero.tsx +++ b/site/src/sections/Hero.tsx @@ -15,7 +15,7 @@ export function Hero() { const [typedRaw, setTypedRaw] = 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 timerRef = useRef(null) + const timerRef = useRef | null>(null) const sampleRawText = locale === 'ko' ? '어... 이번 프로젝트 배포는 다음 주 금요일까지로 잡으면 될 것 같아요.'