feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -8,9 +8,15 @@ node_modules/
|
|||
dist/
|
||||
**/dist/
|
||||
build/
|
||||
**/build/
|
||||
out/
|
||||
**/out/
|
||||
test-results/
|
||||
**/test-results/
|
||||
**/playwright-report/
|
||||
coverage/
|
||||
**/coverage/
|
||||
**/*.tsbuildinfo
|
||||
*.log
|
||||
*.tar
|
||||
bin/
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ ADMIN_COOKIE_SECURE=true
|
|||
# Use a distinct random value with at least 32 UTF-8 bytes.
|
||||
D3RO_API_TOKEN=
|
||||
|
||||
# Offline Ed25519 license keys. Keep the private key on the admin server only.
|
||||
# The matching public key is committed at apps/desktop/resources/license/production-public.pem.
|
||||
# Multiline private PEM values may use escaped \n characters. Legacy fixture keys are development-only.
|
||||
ADMIN_LICENSE_PRIVATE_KEY=
|
||||
D3RO_ALLOW_LEGACY_DEV_LICENSES=false
|
||||
|
||||
# Supabase
|
||||
SUPABASE_URL=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
|
|
|||
|
|
@ -16,10 +16,28 @@ module.exports = {
|
|||
sourceType: 'module'
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
ignorePatterns: ['**/next-env.d.ts', '**/.next/**', '**/out/**', '**/dist/**'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'no-console': 'error'
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['apps/desktop/src/main/**/*.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', 'apps/desktop/tests/**/*.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'no-console': 'off',
|
||||
'require-yield': 'off'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ name: deploy-site-windows
|
|||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy-win:
|
||||
|
|
@ -21,11 +19,10 @@ jobs:
|
|||
git checkout -q -f FETCH_HEAD
|
||||
git clean -qfdx
|
||||
|
||||
- name: 사이트 빌드 & 바이너리 동기화
|
||||
- name: 사이트 빌드
|
||||
run: |
|
||||
npm install --prefix site
|
||||
npm ci --prefix site
|
||||
npm run build --prefix site
|
||||
node scripts/ci/sync-and-publish-forgejo-release.mjs
|
||||
|
||||
- name: Cloudflare Pages 배포
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@ name: deploy-site
|
|||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
|
@ -26,9 +23,9 @@ jobs:
|
|||
|
||||
- name: 의존성 설치 및 사이트 빌드
|
||||
run: |
|
||||
npm ci --prefix site || npm install --prefix site
|
||||
npm ci --prefix site
|
||||
npm run build --prefix site
|
||||
node scripts/ci/sync-and-publish-forgejo-release.mjs || true
|
||||
node -e "const fs=require('node:fs'); const v=require('./release/product-version.json').version; fs.writeFileSync('site/dist/release-identity.json', JSON.stringify({commit:process.env.GITHUB_SHA,version:v},null,2)+'\n')"
|
||||
|
||||
- name: Cloudflare Pages 배포 (d3ro.chanpaca.net)
|
||||
env:
|
||||
|
|
@ -46,5 +43,17 @@ jobs:
|
|||
--branch main \
|
||||
--commit-dirty=true
|
||||
else
|
||||
echo "CLOUDFLARE_API_TOKEN 없음"
|
||||
echo "CLOUDFLARE_API_TOKEN 없음" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 공개 배포 식별자 검증
|
||||
run: |
|
||||
curl --fail --silent --show-error --retry 6 --retry-delay 5 \
|
||||
https://d3ro.chanpaca.net/release-identity.json \
|
||||
--output release-identity.live.json
|
||||
node -e "const fs=require('node:fs'); const live=JSON.parse(fs.readFileSync('release-identity.live.json','utf8')); if(live.commit!==process.env.GITHUB_SHA) throw new Error('public commit mismatch'); const expected=require('./release/product-version.json').version; if(live.version!==expected) throw new Error('public version mismatch')"
|
||||
curl --fail --silent --show-error https://d3ro.chanpaca.net/ --output /dev/null
|
||||
curl --fail --silent --show-error https://d3ro.chanpaca.net/privacy/ --output /dev/null
|
||||
curl --fail --silent --show-error https://d3ro.chanpaca.net/terms/ --output /dev/null
|
||||
curl --fail --silent --show-error https://d3ro.chanpaca.net/delete-account/ --output /dev/null
|
||||
|
|
|
|||
3
.github/workflows/build-mac.yml
vendored
3
.github/workflows/build-mac.yml
vendored
|
|
@ -1,9 +1,6 @@
|
|||
name: Build macOS
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
notarize:
|
||||
|
|
|
|||
237
.github/workflows/ci.yml
vendored
237
.github/workflows/ci.yml
vendored
|
|
@ -30,22 +30,80 @@ jobs:
|
|||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js 20 LTS
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Credential Scanner Self-Test
|
||||
run: npm run security:secrets:test
|
||||
|
||||
- name: Hard-Coded Credential Scan
|
||||
run: npm run security:secrets
|
||||
|
||||
- name: Mobile Release Boundary Self-Test
|
||||
run: npm run release:mobile:boundary:test
|
||||
|
||||
- name: Mobile Release Configuration Self-Test
|
||||
run: npm run release:mobile:config:test
|
||||
|
||||
- name: Mobile Build Configuration Self-Test
|
||||
run: npm run release:mobile:build-config:test
|
||||
|
||||
- name: Play Store Asset Contract
|
||||
run: npm run release:play:assets
|
||||
|
||||
- name: Lint Check
|
||||
run: npm run lint
|
||||
continue-on-error: true
|
||||
|
||||
- name: Typecheck All Workspaces
|
||||
run: npm run typecheck
|
||||
|
||||
api-server-tests:
|
||||
name: .NET API Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: '10.0.302'
|
||||
|
||||
- name: Restore API Test Dependencies
|
||||
run: dotnet restore apps/api-server.Tests/D3ROVoice.Api.Tests.csproj
|
||||
|
||||
- name: Run API Authorization and Gateway Tests
|
||||
run: dotnet test apps/api-server.Tests/D3ROVoice.Api.Tests.csproj --configuration Release --no-restore -p:StaticWebAssetsEnabled=false
|
||||
|
||||
edge-functions-quality:
|
||||
name: Supabase Edge Functions Typecheck & Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Deno 2.8.1
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.8.1
|
||||
|
||||
- name: Check Every Edge Function Entrypoint
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for entrypoint in server/supabase/functions/*/index.ts; do
|
||||
deno check --config server/supabase/functions/deno.json "$entrypoint"
|
||||
done
|
||||
|
||||
- name: Run Edge Function Contract Tests
|
||||
run: deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# 2. Automated Test Matrix (Windows / macOS / Ubuntu)
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -61,10 +119,10 @@ jobs:
|
|||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js 20 LTS
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
|
|
@ -93,10 +151,10 @@ jobs:
|
|||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js 20 LTS
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
|
|
@ -104,3 +162,162 @@ jobs:
|
|||
|
||||
- name: Build Target Workspace
|
||||
run: ${{ matrix.cmd }}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# 4. Android x86_64 artifacts and native dependency gate
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
mobile-android:
|
||||
name: Mobile Android (universal debug + bundled universal E2E)
|
||||
needs: code-quality
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v4
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-provider: basic
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm --prefix apps/mobile-rn ci --workspaces=false
|
||||
|
||||
- name: Prepare Verified Whisper Model
|
||||
run: node scripts/ci/prepare-whisper-model.mjs
|
||||
|
||||
- name: Test Mobile TypeScript and Jest
|
||||
run: |
|
||||
npm --prefix apps/mobile-rn run lint
|
||||
npm --prefix apps/mobile-rn run typecheck
|
||||
npm --prefix apps/mobile-rn run test:ci
|
||||
|
||||
- name: Build Universal Debug, CSPRNG Test, and Bundled Universal E2E APKs
|
||||
working-directory: apps/mobile-rn/android
|
||||
env:
|
||||
D3RO_VERSION_NAME: 0.0.0-e2e.${{ github.run_number }}
|
||||
D3RO_VERSION_CODE: ${{ github.run_number }}
|
||||
run: ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest :app:assembleE2e -PreactNativeArchitectures=arm64-v8a,x86_64 --no-daemon
|
||||
|
||||
- name: Verify BuildConfig and APK Runtime Contracts
|
||||
env:
|
||||
D3RO_VERSION_NAME: 0.0.0-e2e.${{ github.run_number }}
|
||||
D3RO_VERSION_CODE: ${{ github.run_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEBUG_APK=apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk
|
||||
E2E_APK=apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk
|
||||
test -f "$DEBUG_APK"
|
||||
test -f "$E2E_APK"
|
||||
node scripts/ci/verify-mobile-build-config.mjs debug \
|
||||
| tee apps/mobile-rn/android/app/build/outputs/debug-build-config.json
|
||||
node scripts/ci/verify-mobile-build-config.mjs e2e \
|
||||
| tee apps/mobile-rn/android/app/build/outputs/e2e-build-config.json
|
||||
node scripts/ci/verify-android-artifact.mjs --mode debug --apk "$DEBUG_APK" \
|
||||
| tee apps/mobile-rn/android/app/build/outputs/debug-artifact-evidence.json
|
||||
node scripts/ci/verify-android-artifact.mjs \
|
||||
--mode e2e \
|
||||
--apk "$E2E_APK" \
|
||||
--expected-version-name "$D3RO_VERSION_NAME" \
|
||||
--expected-version-code "$D3RO_VERSION_CODE" \
|
||||
| tee apps/mobile-rn/android/app/build/outputs/e2e-artifact-evidence.json
|
||||
node scripts/ci/verify-android-app-links.mjs \
|
||||
| tee apps/mobile-rn/android/app/build/outputs/app-links-evidence.json
|
||||
sha256sum "$DEBUG_APK" "$E2E_APK" | tee apps/mobile-rn/android/app/build/outputs/android-ci.sha256
|
||||
|
||||
- name: Upload Universal Android Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: d3ro-mobile-android-universal-e2e
|
||||
path: |
|
||||
apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk
|
||||
apps/mobile-rn/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
|
||||
apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk
|
||||
apps/mobile-rn/android/app/build/outputs/android-ci.sha256
|
||||
apps/mobile-rn/android/app/build/outputs/*-build-config.json
|
||||
apps/mobile-rn/android/app/build/outputs/*-artifact-evidence.json
|
||||
apps/mobile-rn/android/app/build/outputs/app-links-evidence.json
|
||||
if-no-files-found: error
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# 5. Installed bundled APK on a clean API 35 x86_64 emulator
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
mobile-emulator-e2e:
|
||||
name: Mobile Emulator E2E (API 35)
|
||||
needs: mobile-android
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v4
|
||||
|
||||
- name: Download Bundled Android Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: d3ro-mobile-android-universal-e2e
|
||||
path: mobile-artifact
|
||||
|
||||
- name: Install Verified Maestro CLI 2.7.0
|
||||
run: |
|
||||
curl -fsSL https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.7.0/maestro.zip -o /tmp/maestro.zip
|
||||
echo 'a4ccab6b604617e7aef6db4f885666056eabe5cfa32befaa3bc994041b8fcbb5 /tmp/maestro.zip' | sha256sum -c -
|
||||
unzip -q /tmp/maestro.zip -d "$RUNNER_TEMP/maestro"
|
||||
echo "$RUNNER_TEMP/maestro/maestro/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run Mandatory Clean-room and Optional External-account Journeys
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
env:
|
||||
MOBILE_E2E_EMAIL: ${{ secrets.MOBILE_E2E_EMAIL }}
|
||||
MOBILE_E2E_PASSWORD: ${{ secrets.MOBILE_E2E_PASSWORD }}
|
||||
with:
|
||||
api-level: 35
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
disable-animations: true
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
script: |
|
||||
set -euo pipefail
|
||||
DEBUG_APK="$(find mobile-artifact -path '*/apk/debug/app-debug.apk' -print -quit)"
|
||||
TEST_APK="$(find mobile-artifact -name app-debug-androidTest.apk -print -quit)"
|
||||
E2E_APK="$(find mobile-artifact -name app-e2e.apk -print -quit)"
|
||||
test -n "$DEBUG_APK"
|
||||
test -n "$TEST_APK"
|
||||
test -n "$E2E_APK"
|
||||
maestro --version
|
||||
bash scripts/ci/run-mobile-csprng-instrumentation.sh "$DEBUG_APK" "$TEST_APK"
|
||||
bash scripts/ci/run-mobile-emulator-gate.sh "$E2E_APK"
|
||||
|
||||
- name: Upload Emulator Evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: d3ro-mobile-emulator-e2e
|
||||
path: |
|
||||
apps/mobile-rn/.maestro/*.junit.xml
|
||||
apps/mobile-rn/.maestro-output/
|
||||
if-no-files-found: warn
|
||||
|
|
|
|||
3
.github/workflows/deploy-site.yml
vendored
3
.github/workflows/deploy-site.yml
vendored
|
|
@ -31,6 +31,9 @@ jobs:
|
|||
working-directory: site
|
||||
run: npm ci
|
||||
|
||||
- name: Verify Mobile Release Publication Boundary
|
||||
run: node scripts/ci/verify-mobile-release-boundary.mjs --self-test
|
||||
|
||||
- name: Build
|
||||
working-directory: site
|
||||
run: npm run build
|
||||
|
|
|
|||
3
.github/workflows/release-signing-ca.yml
vendored
3
.github/workflows/release-signing-ca.yml
vendored
|
|
@ -1,9 +1,6 @@
|
|||
name: Release & Code Signing CA Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
|
|
|||
400
.github/workflows/release.yml
vendored
400
.github/workflows/release.yml
vendored
|
|
@ -12,27 +12,92 @@ on:
|
|||
version:
|
||||
description: 'Release version (e.g. 1.0.0)'
|
||||
required: true
|
||||
default: '1.0.0'
|
||||
default: '1.1.0'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release-preflight:
|
||||
name: Release Preflight
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: '10.0.302'
|
||||
|
||||
- name: Setup Deno 2.8.1
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.8.1
|
||||
|
||||
- name: Install JavaScript Dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm --prefix apps/mobile-rn ci --workspaces=false
|
||||
|
||||
- name: Verify Source, Security, Tests, and Play Assets
|
||||
run: |
|
||||
npm run version:check
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
node scripts/ci/sync-version.mjs --check --tag "$GITHUB_REF_NAME"
|
||||
fi
|
||||
npm run release:metadata:test
|
||||
npm run security:secrets:test
|
||||
npm run security:secrets
|
||||
npm run release:mobile:boundary:test
|
||||
npm run release:mobile:config:test
|
||||
npm run release:mobile:build-config:test
|
||||
npm run release:play:assets
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm --prefix apps/mobile-rn run lint
|
||||
npm --prefix apps/mobile-rn run typecheck
|
||||
npm --prefix apps/mobile-rn run test:ci
|
||||
|
||||
- name: Check and Test Every Supabase Edge Function
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for entrypoint in server/supabase/functions/*/index.ts; do
|
||||
deno check --config server/supabase/functions/deno.json "$entrypoint"
|
||||
done
|
||||
deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions
|
||||
|
||||
- name: Test .NET API Authorization and Gateway Boundaries
|
||||
run: |
|
||||
dotnet restore apps/api-server.Tests/D3ROVoice.Api.Tests.csproj
|
||||
dotnet test apps/api-server.Tests/D3ROVoice.Api.Tests.csproj --configuration Release --no-restore -p:StaticWebAssetsEnabled=false
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# 1. Package Windows Installer (.exe & .blockmap & latest.yml)
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
package-windows:
|
||||
name: Package Windows Desktop App
|
||||
needs: release-preflight
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js 20 LTS
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
|
|
@ -40,6 +105,7 @@ jobs:
|
|||
|
||||
- name: Build All Workspaces
|
||||
run: |
|
||||
npm run version:check
|
||||
npm run typecheck
|
||||
npm run build --workspace=@d3ro/desktop
|
||||
|
||||
|
|
@ -66,15 +132,16 @@ jobs:
|
|||
# ──────────────────────────────────────────────────────────────────
|
||||
package-macos:
|
||||
name: Package macOS Desktop App
|
||||
needs: release-preflight
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js 20 LTS
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
|
|
@ -82,6 +149,7 @@ jobs:
|
|||
|
||||
- name: Build All Workspaces
|
||||
run: |
|
||||
npm run version:check
|
||||
npm run typecheck
|
||||
npm run build --workspace=@d3ro/desktop
|
||||
|
||||
|
|
@ -108,10 +176,267 @@ jobs:
|
|||
apps/desktop/release/*/latest-mac.yml
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# 3. Build & Containerize Admin Dashboard
|
||||
# 3. Package signed Android APK/AAB (arm64, production-only config)
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
package-android:
|
||||
name: Package Android Mobile App
|
||||
needs: release-preflight
|
||||
runs-on: ubuntu-latest
|
||||
environment: mobile-production-release
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Establish Trusted Mobile Release Identity
|
||||
id: android-version
|
||||
shell: bash
|
||||
env:
|
||||
DISPATCH_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
test "$GITHUB_REF" = "refs/heads/main"
|
||||
test "$GITHUB_SHA" = "$(git rev-parse origin/main)"
|
||||
VERSION_NAME="$DISPATCH_VERSION"
|
||||
RELEASE_TAG="v$VERSION_NAME"
|
||||
else
|
||||
[[ "$GITHUB_REF" == refs/tags/v* ]]
|
||||
VERSION_NAME="${GITHUB_REF_NAME#v}"
|
||||
RELEASE_TAG="$GITHUB_REF_NAME"
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
|
||||
fi
|
||||
SSOT_VERSION="$(node -p "require('./release/product-version.json').version")"
|
||||
VERSION_CODE="$(node -p "require('./release/product-version.json').androidVersionCode")"
|
||||
[[ "$VERSION_NAME" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
|
||||
test "$VERSION_NAME" = "$SSOT_VERSION"
|
||||
test "$RELEASE_TAG" = "v$VERSION_NAME"
|
||||
[[ "$VERSION_CODE" =~ ^[1-9][0-9]{0,9}$ ]]
|
||||
test "$VERSION_CODE" -le 2100000000
|
||||
printf 'name=%s\n' "$VERSION_NAME" >> "$GITHUB_OUTPUT"
|
||||
printf 'code=%s\n' "$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
printf 'tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify Immutable Checkout Identity
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$GITHUB_SHA" = "$(git rev-parse HEAD)"
|
||||
test -z "$(git status --porcelain --untracked-files=all)"
|
||||
git rev-parse "${GITHUB_SHA}^{tree}"
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Verify Mobile Release Boundary Source Contract
|
||||
run: node scripts/ci/verify-mobile-release-boundary.mjs --self-test
|
||||
|
||||
- name: Verify Mobile Release Configuration Contract
|
||||
run: npm run release:mobile:config:test
|
||||
|
||||
- name: Verify Mobile Build Configuration Contract
|
||||
run: npm run release:mobile:build-config:test
|
||||
|
||||
- name: Verify Play Store Asset Contract
|
||||
run: npm run release:play:assets
|
||||
|
||||
- name: Require Restricted AAB Handoff Visibility
|
||||
shell: bash
|
||||
env:
|
||||
REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$REPOSITORY_VISIBILITY" = "private"
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v4
|
||||
|
||||
- name: Install Pinned Official Bundletool
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --silent --show-error --location \
|
||||
--output "$RUNNER_TEMP/bundletool-all-1.18.3.jar" \
|
||||
https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar
|
||||
printf '%s %s\n' \
|
||||
a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29 \
|
||||
"$RUNNER_TEMP/bundletool-all-1.18.3.jar" | sha256sum --check --strict
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-provider: basic
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm --prefix apps/mobile-rn ci --workspaces=false
|
||||
|
||||
- name: Materialize Release-only Configuration
|
||||
shell: bash
|
||||
env:
|
||||
ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}
|
||||
ANDROID_GOOGLE_SERVICES_JSON_B64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_B64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
test -n "$ANDROID_RELEASE_KEYSTORE_B64"
|
||||
test -n "$ANDROID_GOOGLE_SERVICES_JSON_B64"
|
||||
printf '%s' "$ANDROID_RELEASE_KEYSTORE_B64" | base64 --decode > apps/mobile-rn/android/app/release.keystore
|
||||
printf '%s' "$ANDROID_GOOGLE_SERVICES_JSON_B64" | base64 --decode > apps/mobile-rn/android/app/google-services.json
|
||||
test -s apps/mobile-rn/android/app/release.keystore
|
||||
test -s apps/mobile-rn/android/app/google-services.json
|
||||
|
||||
- name: Prepare Verified Whisper Model
|
||||
run: node scripts/ci/prepare-whisper-model.mjs
|
||||
|
||||
- name: Verify Production Firebase and AdMob Configuration
|
||||
env:
|
||||
D3RO_FIREBASE_EXPECTED_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
|
||||
D3RO_FIREBASE_EXPECTED_PROJECT_NUMBER: ${{ secrets.FIREBASE_PROJECT_NUMBER }}
|
||||
D3RO_FIREBASE_EXPECTED_MOBILESDK_APP_ID: ${{ secrets.FIREBASE_MOBILESDK_APP_ID }}
|
||||
run: |
|
||||
export D3RO_ADMOB_APP_ID="$(node -p "require('./release/android-release-identity.json').adMobAppId")"
|
||||
export D3RO_ADMOB_BANNER_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobBannerUnitId")"
|
||||
export D3RO_ADMOB_REWARDED_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobRewardedUnitId")"
|
||||
npm run release:mobile:config
|
||||
|
||||
- name: Test Mobile TypeScript and Jest
|
||||
run: |
|
||||
npm --prefix apps/mobile-rn run lint
|
||||
npm --prefix apps/mobile-rn run typecheck
|
||||
npm --prefix apps/mobile-rn run test:ci
|
||||
|
||||
- name: Build Signed arm64 APK and AAB
|
||||
working-directory: apps/mobile-rn/android
|
||||
env:
|
||||
D3RO_RELEASE_STORE_FILE: ${{ github.workspace }}/apps/mobile-rn/android/app/release.keystore
|
||||
D3RO_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }}
|
||||
D3RO_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
|
||||
D3RO_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
|
||||
D3RO_VERSION_NAME: ${{ steps.android-version.outputs.name }}
|
||||
D3RO_VERSION_CODE: ${{ steps.android-version.outputs.code }}
|
||||
run: |
|
||||
export D3RO_ADMOB_APP_ID="$(node -p "require('../../../release/android-release-identity.json').adMobAppId")"
|
||||
export D3RO_ADMOB_BANNER_UNIT_ID="$(node -p "require('../../../release/android-release-identity.json').adMobBannerUnitId")"
|
||||
export D3RO_ADMOB_REWARDED_UNIT_ID="$(node -p "require('../../../release/android-release-identity.json').adMobRewardedUnitId")"
|
||||
./gradlew :app:assembleRelease :app:bundleRelease -PreactNativeArchitectures=arm64-v8a --no-daemon
|
||||
|
||||
- name: Materialize Release Evidence Signing Key
|
||||
shell: bash
|
||||
env:
|
||||
ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64: ${{ secrets.ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
test -n "$ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64"
|
||||
printf '%s' "$ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_B64" | base64 --decode > apps/mobile-rn/android/app/release-evidence-private.pem
|
||||
test -s apps/mobile-rn/android/app/release-evidence-private.pem
|
||||
|
||||
- name: Verify Release BuildConfig, Signature, ABI, Bundle, Ads, and Offline Model
|
||||
shell: bash
|
||||
env:
|
||||
D3RO_VERSION_NAME: ${{ steps.android-version.outputs.name }}
|
||||
D3RO_VERSION_CODE: ${{ steps.android-version.outputs.code }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
D3RO_ADMOB_APP_ID="$(node -p "require('./release/android-release-identity.json').adMobAppId")"
|
||||
D3RO_ADMOB_BANNER_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobBannerUnitId")"
|
||||
D3RO_ADMOB_REWARDED_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobRewardedUnitId")"
|
||||
ANDROID_UPLOAD_CERT_SHA256="$(node -p "require('./release/android-release-identity.json').uploadCertificateSha256")"
|
||||
PLAY_APP_SIGNING_CERT_SHA256="$(node -p "require('./release/android-release-identity.json').playAppSigningCertificateSha256")"
|
||||
APK=apps/mobile-rn/android/app/build/outputs/apk/release/app-release.apk
|
||||
AAB=apps/mobile-rn/android/app/build/outputs/bundle/release/app-release.aab
|
||||
test -f "$APK"
|
||||
test -f "$AAB"
|
||||
node scripts/ci/verify-mobile-build-config.mjs release \
|
||||
| tee apps/mobile-rn/android/app/build/outputs/release-build-config.json
|
||||
node scripts/ci/create-mobile-release-evidence.mjs \
|
||||
--apk "$APK" \
|
||||
--aab "$AAB" \
|
||||
--bundletool "$RUNNER_TEMP/bundletool-all-1.18.3.jar" \
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--commit-sha "$GITHUB_SHA" \
|
||||
--tree-sha "$(git rev-parse "${GITHUB_SHA}^{tree}")" \
|
||||
--git-ref "$GITHUB_REF" \
|
||||
--workflow-identity "$GITHUB_WORKFLOW_REF" \
|
||||
--run-id "$GITHUB_RUN_ID" \
|
||||
--run-attempt "$GITHUB_RUN_ATTEMPT" \
|
||||
--runner-identity "$RUNNER_NAME:$RUNNER_OS:$RUNNER_ARCH" \
|
||||
--expected-admob-app-id "$D3RO_ADMOB_APP_ID" \
|
||||
--expected-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \
|
||||
--expected-version-name "$D3RO_VERSION_NAME" \
|
||||
--expected-version-code "$D3RO_VERSION_CODE" \
|
||||
--private-key apps/mobile-rn/android/app/release-evidence-private.pem \
|
||||
--snapshot-dir apps/mobile-rn/android/app/build/outputs/release-snapshot
|
||||
VERIFIER_SHA256="$(sha256sum scripts/ci/verify-android-artifact.mjs | awk '{print $1}')"
|
||||
BUNDLETOOL_SHA256="a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29"
|
||||
node scripts/ci/prepare-mobile-release-publication.mjs \
|
||||
--source-root apps/mobile-rn/android/app/build/outputs/release-snapshot \
|
||||
--apk apps/mobile-rn/android/app/build/outputs/release-snapshot/app-release.apk \
|
||||
--aab apps/mobile-rn/android/app/build/outputs/release-snapshot/app-release.aab \
|
||||
--evidence apps/mobile-rn/android/app/build/outputs/release-snapshot/release-artifact-evidence.json \
|
||||
--public-key release/mobile-release-evidence-public.pem \
|
||||
--destination-dir apps/mobile-rn/android/app/build/outputs/release-publication \
|
||||
--expected-admob-app-id "$D3RO_ADMOB_APP_ID" \
|
||||
--expected-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \
|
||||
--expected-version-name "$D3RO_VERSION_NAME" \
|
||||
--expected-version-code "$D3RO_VERSION_CODE" \
|
||||
--expected-repository "$GITHUB_REPOSITORY" \
|
||||
--expected-commit-sha "$GITHUB_SHA" \
|
||||
--expected-tree-sha "$(git rev-parse "${GITHUB_SHA}^{tree}")" \
|
||||
--expected-git-ref "$GITHUB_REF" \
|
||||
--expected-workflow-identity "$GITHUB_WORKFLOW_REF" \
|
||||
--expected-run-id "$GITHUB_RUN_ID" \
|
||||
--expected-run-attempt "$GITHUB_RUN_ATTEMPT" \
|
||||
--expected-runner-identity "$RUNNER_NAME:$RUNNER_OS:$RUNNER_ARCH" \
|
||||
--expected-verifier-sha256 "$VERIFIER_SHA256" \
|
||||
--expected-bundletool-sha256 "$BUNDLETOOL_SHA256"
|
||||
node scripts/ci/verify-android-app-links.mjs \
|
||||
--expected-play-app-signing-cert-sha256 "$PLAY_APP_SIGNING_CERT_SHA256" \
|
||||
--forbidden-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \
|
||||
| tee apps/mobile-rn/android/app/build/outputs/release-app-links-evidence.json
|
||||
sha256sum apps/mobile-rn/android/app/build/outputs/release-publication/app-release.apk \
|
||||
apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab \
|
||||
| tee apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt
|
||||
|
||||
- name: Remove Materialized Release Secrets
|
||||
if: always()
|
||||
shell: bash
|
||||
run: rm -f apps/mobile-rn/android/app/release.keystore apps/mobile-rn/android/app/google-services.json apps/mobile-rn/android/app/release-evidence-private.pem
|
||||
|
||||
- name: Upload Restricted Play Console AAB Handoff
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-play-console-handoff
|
||||
path: |
|
||||
apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab
|
||||
apps/mobile-rn/android/app/build/outputs/release-publication/android-release-evidence.json
|
||||
apps/mobile-rn/android/app/build/outputs/release-publication/android-publication-manifest.json
|
||||
apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt
|
||||
apps/mobile-rn/android/app/build/outputs/release-snapshot/release-artifact-verification.json
|
||||
apps/mobile-rn/android/app/build/outputs/release-build-config.json
|
||||
apps/mobile-rn/android/app/build/outputs/release-app-links-evidence.json
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# 4. Build & Containerize Admin Dashboard
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
package-admin-docker:
|
||||
name: Build & Publish Admin Docker Image
|
||||
needs: release-preflight
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
|
|
@ -137,21 +462,54 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.admin
|
||||
file: ./apps/admin/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# 4. Create GitHub Release & Upload Checksums
|
||||
# 5. Create GitHub Release & Upload Checksums
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
publish-release:
|
||||
name: Publish Official GitHub Release
|
||||
needs: [package-windows, package-macos, package-admin-docker]
|
||||
needs: [package-windows, package-macos, package-android, package-admin-docker]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Re-establish Trusted Release Identity
|
||||
id: release-identity
|
||||
shell: bash
|
||||
env:
|
||||
DISPATCH_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
test "$GITHUB_REF" = "refs/heads/main"
|
||||
test "$GITHUB_SHA" = "$(git rev-parse origin/main)"
|
||||
VERSION_NAME="$DISPATCH_VERSION"
|
||||
RELEASE_TAG="v$VERSION_NAME"
|
||||
else
|
||||
[[ "$GITHUB_REF" == refs/tags/v* ]]
|
||||
VERSION_NAME="${GITHUB_REF_NAME#v}"
|
||||
RELEASE_TAG="$GITHUB_REF_NAME"
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
|
||||
fi
|
||||
SSOT_VERSION="$(node -p "require('./release/product-version.json').version")"
|
||||
[[ "$VERSION_NAME" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
|
||||
test "$VERSION_NAME" = "$SSOT_VERSION"
|
||||
test "$RELEASE_TAG" = "v$VERSION_NAME"
|
||||
printf 'name=%s\n' "$VERSION_NAME" >> "$GITHUB_OUTPUT"
|
||||
printf 'tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Download Windows Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
|
@ -167,9 +525,17 @@ jobs:
|
|||
|
||||
- name: Generate SHA-256 Checksums
|
||||
run: |
|
||||
cd release-dist
|
||||
sha256sum * > SHA256SUMS.txt || shasum -a 256 * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
set -euo pipefail
|
||||
find release-dist -type f ! -name SHA256SUMS.txt -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum > release-dist/SHA256SUMS.txt
|
||||
cat release-dist/SHA256SUMS.txt
|
||||
|
||||
- name: Extract Canonical Release Notes
|
||||
run: >-
|
||||
node scripts/ci/extract-release-notes.mjs
|
||||
--version "${{ steps.release-identity.outputs.name }}"
|
||||
--output release-notes.md
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
|
@ -178,6 +544,10 @@ jobs:
|
|||
release-dist/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
body_path: release-notes.md
|
||||
generate_release_notes: false
|
||||
tag_name: ${{ steps.release-identity.outputs.tag }}
|
||||
fail_on_unmatched_files: true
|
||||
overwrite_files: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
20
.gitignore
vendored
20
.gitignore
vendored
|
|
@ -5,13 +5,19 @@ out/
|
|||
.expo/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.claude/settings.local.json
|
||||
|
||||
# Electron
|
||||
# Electron release artifacts. Keep only release identity/public-key SSOT files.
|
||||
release/
|
||||
!/release/
|
||||
/release/*
|
||||
!/release/product-version.json
|
||||
!/release/android-release-identity.json
|
||||
!/release/mobile-release-evidence-public.pem
|
||||
*.unpacked
|
||||
|
||||
# Build
|
||||
|
|
@ -52,3 +58,15 @@ venv/
|
|||
|
||||
# Whisper models (large files)
|
||||
resources/models/
|
||||
apps/mobile-rn/android/app/src/main/assets/models/ggml-tiny.bin
|
||||
apps/mobile-rn/android/app/src/main/assets/index.android.bundle
|
||||
apps/mobile-rn/.maestro-output/
|
||||
apps/mobile-rn/.maestro/*.junit.xml
|
||||
|
||||
# Chrome / Playwright Profiles
|
||||
.chrome-*
|
||||
.playwright-*
|
||||
.chrome-debug-profile/
|
||||
.chrome-interactive-profile/
|
||||
.chrome-playwright-profile/
|
||||
.playwright-oauth-profile/
|
||||
|
|
|
|||
334
.gitlab-ci.yml
334
.gitlab-ci.yml
|
|
@ -5,16 +5,19 @@ stages:
|
|||
- validate
|
||||
- test
|
||||
- build
|
||||
- e2e
|
||||
- package
|
||||
- publish
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
NODE_VERSION: "20"
|
||||
NODE_VERSION: "24.19.0"
|
||||
PACKAGE_NAME: "d3ro-voice"
|
||||
|
||||
default:
|
||||
image: node:20-bookworm
|
||||
image: node:24.19.0-bookworm
|
||||
tags:
|
||||
- build-linux-x64
|
||||
before_script:
|
||||
- npm ci
|
||||
|
||||
|
|
@ -24,7 +27,16 @@ default:
|
|||
lint-and-typecheck:
|
||||
stage: validate
|
||||
script:
|
||||
- npm run lint || true
|
||||
- npm run version:check
|
||||
- if [ -n "${CI_COMMIT_TAG:-}" ]; then node scripts/ci/sync-version.mjs --check --tag "$CI_COMMIT_TAG"; fi
|
||||
- npm run release:metadata:test
|
||||
- npm run security:secrets:test
|
||||
- npm run security:secrets
|
||||
- npm run release:mobile:boundary:test
|
||||
- npm run release:mobile:config:test
|
||||
- npm run release:mobile:build-config:test
|
||||
- npm run release:play:assets
|
||||
- npm run lint
|
||||
- npm run typecheck
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
|
|
@ -48,6 +60,125 @@ test-unit:
|
|||
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
|
||||
api-server-tests:
|
||||
stage: test
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0.302-noble
|
||||
before_script: []
|
||||
script:
|
||||
- dotnet restore apps/api-server.Tests/D3ROVoice.Api.Tests.csproj
|
||||
- dotnet test apps/api-server.Tests/D3ROVoice.Api.Tests.csproj --configuration Release --no-restore -p:StaticWebAssetsEnabled=false
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
|
||||
edge-functions-quality:
|
||||
stage: test
|
||||
image: denoland/deno:2.8.1
|
||||
before_script: []
|
||||
script:
|
||||
- for entrypoint in server/supabase/functions/*/index.ts; do deno check --config server/supabase/functions/deno.json "$entrypoint"; done
|
||||
- deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
|
||||
mobile-quality:
|
||||
stage: test
|
||||
before_script:
|
||||
- npm ci
|
||||
- npm --prefix apps/mobile-rn ci --workspaces=false
|
||||
script:
|
||||
- npm --prefix apps/mobile-rn run lint
|
||||
- npm --prefix apps/mobile-rn run typecheck
|
||||
- npm --prefix apps/mobile-rn run test:ci
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
|
||||
mobile-android:
|
||||
stage: build
|
||||
image: reactnativecommunity/react-native-android@sha256:24ca7ab5a70ec0b78a81bdc5eeea5924c2531531d53971b6f2321aff08446c36
|
||||
needs:
|
||||
- mobile-quality
|
||||
before_script:
|
||||
- export ANDROID_HOME=/opt/android
|
||||
- export ANDROID_SDK_ROOT=/opt/android
|
||||
- curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz
|
||||
- echo '14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647 /tmp/node.tar.xz' | sha256sum -c -
|
||||
- mkdir -p /tmp/node24
|
||||
- tar -xJf /tmp/node.tar.xz -C /tmp/node24 --strip-components=1
|
||||
- export PATH="/tmp/node24/bin:$PATH"
|
||||
- node --version | grep -Fx "v${NODE_VERSION}"
|
||||
- export D3RO_VERSION_NAME="0.0.0-e2e.${CI_PIPELINE_IID}"
|
||||
- export D3RO_VERSION_CODE="${CI_PIPELINE_IID}"
|
||||
- sdkmanager "platforms;android-36" "build-tools;36.0.0" >/dev/null
|
||||
- npm ci
|
||||
- npm --prefix apps/mobile-rn ci --workspaces=false
|
||||
script:
|
||||
- node scripts/ci/prepare-whisper-model.mjs
|
||||
- cd apps/mobile-rn/android
|
||||
- ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest :app:assembleE2e -PreactNativeArchitectures=arm64-v8a,x86_64 --no-daemon
|
||||
- cd "$CI_PROJECT_DIR"
|
||||
- node scripts/ci/verify-mobile-build-config.mjs debug > apps/mobile-rn/android/app/build/outputs/debug-build-config.json
|
||||
- node scripts/ci/verify-mobile-build-config.mjs e2e > apps/mobile-rn/android/app/build/outputs/e2e-build-config.json
|
||||
- node scripts/ci/verify-android-artifact.mjs --mode debug --apk apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk > apps/mobile-rn/android/app/build/outputs/debug-artifact-evidence.json
|
||||
- node scripts/ci/verify-android-artifact.mjs --mode e2e --apk apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk --expected-version-name "$D3RO_VERSION_NAME" --expected-version-code "$D3RO_VERSION_CODE" > apps/mobile-rn/android/app/build/outputs/e2e-artifact-evidence.json
|
||||
- sha256sum apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk > apps/mobile-rn/android/app/build/outputs/android-ci.sha256
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 1 day
|
||||
paths:
|
||||
- apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk
|
||||
- apps/mobile-rn/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
|
||||
- apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk
|
||||
- apps/mobile-rn/android/app/build/outputs/android-ci.sha256
|
||||
- apps/mobile-rn/android/app/build/outputs/*-build-config.json
|
||||
- apps/mobile-rn/android/app/build/outputs/*-artifact-evidence.json
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
|
||||
mobile-emulator-e2e:
|
||||
stage: e2e
|
||||
image: reactnativecommunity/react-native-android@sha256:24ca7ab5a70ec0b78a81bdc5eeea5924c2531531d53971b6f2321aff08446c36
|
||||
tags:
|
||||
- android-emulator
|
||||
- kvm
|
||||
needs:
|
||||
- job: mobile-android
|
||||
artifacts: true
|
||||
before_script: []
|
||||
script:
|
||||
- export ANDROID_HOME=/opt/android
|
||||
- export ANDROID_SDK_ROOT=/opt/android
|
||||
- sdkmanager "platform-tools" "emulator" "platforms;android-35" "system-images;android-35;google_apis;x86_64" >/dev/null
|
||||
- echo no | avdmanager create avd --force --name d3ro_ci_api35 --package "system-images;android-35;google_apis;x86_64"
|
||||
- emulator -avd d3ro_ci_api35 -no-window -noaudio -no-boot-anim -gpu swiftshader_indirect -camera-back none &
|
||||
- adb wait-for-device
|
||||
- timeout 180 bash -c 'until [[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d "\r")" == "1" ]]; do sleep 2; done'
|
||||
- curl -fsSL https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.7.0/maestro.zip -o /tmp/maestro.zip
|
||||
- echo 'a4ccab6b604617e7aef6db4f885666056eabe5cfa32befaa3bc994041b8fcbb5 /tmp/maestro.zip' | sha256sum -c -
|
||||
- unzip -q /tmp/maestro.zip -d /tmp/maestro
|
||||
- export PATH="/tmp/maestro/maestro/bin:$PATH"
|
||||
- bash scripts/ci/run-mobile-csprng-instrumentation.sh apps/mobile-rn/android/app/build/outputs/apk/debug/app-debug.apk apps/mobile-rn/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
|
||||
- bash scripts/ci/run-mobile-emulator-gate.sh apps/mobile-rn/android/app/build/outputs/apk/e2e/app-e2e.apk
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 7 days
|
||||
reports:
|
||||
junit: apps/mobile-rn/.maestro/*.junit.xml
|
||||
paths:
|
||||
- apps/mobile-rn/.maestro/*.junit.xml
|
||||
- apps/mobile-rn/.maestro-output/
|
||||
rules:
|
||||
- if: '$D3RO_ANDROID_EMULATOR_RUNNER == "true" && $CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$D3RO_ANDROID_EMULATOR_RUNNER == "true" && ($CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop")'
|
||||
- if: '$D3RO_ANDROID_EMULATOR_RUNNER == "true" && $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Build Workspaces
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -71,11 +202,11 @@ build-workspaces:
|
|||
package-windows:
|
||||
stage: package
|
||||
tags:
|
||||
- windows
|
||||
- electron
|
||||
- build-win-x64
|
||||
before_script:
|
||||
- npm ci
|
||||
script:
|
||||
- node scripts/ci/sync-version.mjs --check --tag "$CI_COMMIT_TAG"
|
||||
- npm run build --workspace=@d3ro/desktop
|
||||
- cd apps/desktop
|
||||
- npx electron-builder --win --x64 --config electron-builder.yml
|
||||
|
|
@ -95,11 +226,11 @@ package-windows:
|
|||
package-macos:
|
||||
stage: package
|
||||
tags:
|
||||
- macos
|
||||
- arm64
|
||||
- build-mac-arm64
|
||||
before_script:
|
||||
- npm ci
|
||||
script:
|
||||
- node scripts/ci/sync-version.mjs --check --tag "$CI_COMMIT_TAG"
|
||||
- npm run build --workspace=@d3ro/desktop
|
||||
- cd apps/desktop
|
||||
- npx electron-builder --mac --arm64 --config electron-builder.yml
|
||||
|
|
@ -114,35 +245,196 @@ package-macos:
|
|||
rules:
|
||||
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+.*$/'
|
||||
|
||||
# Protected, manually approved Play Console handoff. Configure the three
|
||||
# *_FILE variables below as protected GitLab file variables scoped to the
|
||||
# mobile-production-release environment. All other credentials must be
|
||||
# protected, masked, and hidden project/group variables.
|
||||
mobile-production-release:
|
||||
stage: package
|
||||
image: reactnativecommunity/react-native-android@sha256:24ca7ab5a70ec0b78a81bdc5eeea5924c2531531d53971b6f2321aff08446c36
|
||||
needs:
|
||||
- job: lint-and-typecheck
|
||||
artifacts: false
|
||||
- job: test-unit
|
||||
artifacts: false
|
||||
- job: api-server-tests
|
||||
artifacts: false
|
||||
- job: edge-functions-quality
|
||||
artifacts: false
|
||||
- job: mobile-quality
|
||||
artifacts: false
|
||||
- job: mobile-android
|
||||
artifacts: false
|
||||
- job: mobile-emulator-e2e
|
||||
artifacts: false
|
||||
environment:
|
||||
name: mobile-production-release
|
||||
action: prepare
|
||||
before_script: []
|
||||
script:
|
||||
- |
|
||||
set -euo pipefail
|
||||
export ANDROID_HOME=/opt/android
|
||||
export ANDROID_SDK_ROOT=/opt/android
|
||||
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz
|
||||
echo '14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647 /tmp/node.tar.xz' | sha256sum -c -
|
||||
mkdir -p /tmp/node24
|
||||
tar -xJf /tmp/node.tar.xz -C /tmp/node24 --strip-components=1
|
||||
export PATH="/tmp/node24/bin:$PATH"
|
||||
node --version | grep -Fx "v${NODE_VERSION}"
|
||||
git fetch --no-tags origin main
|
||||
git merge-base --is-ancestor "$CI_COMMIT_SHA" origin/main
|
||||
test "$CI_COMMIT_SHA" = "$(git rev-parse HEAD)"
|
||||
test -z "$(git status --porcelain --untracked-files=all)"
|
||||
SOURCE_TREE_SHA="$(git rev-parse "${CI_COMMIT_SHA}^{tree}")"
|
||||
VERSION_NAME="$(node -p "require('./release/product-version.json').version")"
|
||||
VERSION_CODE="$(node -p "require('./release/product-version.json').androidVersionCode")"
|
||||
ANDROID_UPLOAD_CERT_SHA256="$(node -p "require('./release/android-release-identity.json').uploadCertificateSha256")"
|
||||
ADMOB_APP_ID="$(node -p "require('./release/android-release-identity.json').adMobAppId")"
|
||||
ADMOB_BANNER_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobBannerUnitId")"
|
||||
ADMOB_REWARDED_UNIT_ID="$(node -p "require('./release/android-release-identity.json').adMobRewardedUnitId")"
|
||||
test "$CI_COMMIT_TAG" = "v${VERSION_NAME}"
|
||||
echo "$VERSION_NAME" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
echo "$VERSION_CODE" | grep -Eq '^[1-9][0-9]{0,9}$'
|
||||
test "$VERSION_CODE" -le 2100000000
|
||||
test -s "$ANDROID_RELEASE_KEYSTORE_FILE"
|
||||
test -s "$ANDROID_GOOGLE_SERVICES_JSON_FILE"
|
||||
test -s "$ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_FILE"
|
||||
install -m 600 "$ANDROID_RELEASE_KEYSTORE_FILE" apps/mobile-rn/android/app/release.keystore
|
||||
install -m 600 "$ANDROID_GOOGLE_SERVICES_JSON_FILE" apps/mobile-rn/android/app/google-services.json
|
||||
install -m 600 "$ANDROID_RELEASE_EVIDENCE_PRIVATE_KEY_FILE" apps/mobile-rn/android/app/release-evidence-private.pem
|
||||
test -n "$ANDROID_RELEASE_STORE_PASSWORD"
|
||||
test -n "$ANDROID_RELEASE_KEY_ALIAS"
|
||||
test -n "$ANDROID_RELEASE_KEY_PASSWORD"
|
||||
test -n "$D3RO_FIREBASE_EXPECTED_PROJECT_ID"
|
||||
test -n "$D3RO_FIREBASE_EXPECTED_PROJECT_NUMBER"
|
||||
test -n "$D3RO_FIREBASE_EXPECTED_MOBILESDK_APP_ID"
|
||||
sdkmanager "platforms;android-36" "build-tools;36.0.0" >/dev/null
|
||||
npm ci
|
||||
npm --prefix apps/mobile-rn ci --workspaces=false
|
||||
npm run security:secrets:test
|
||||
npm run security:secrets
|
||||
npm run release:mobile:boundary:test
|
||||
npm run release:mobile:config:test
|
||||
npm run release:mobile:build-config:test
|
||||
npm run release:play:assets
|
||||
npm --prefix apps/mobile-rn run lint
|
||||
npm --prefix apps/mobile-rn run typecheck
|
||||
npm --prefix apps/mobile-rn run test:ci
|
||||
node scripts/ci/prepare-whisper-model.mjs
|
||||
curl --fail --silent --show-error --location --output /tmp/bundletool.jar https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar
|
||||
echo 'a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29 /tmp/bundletool.jar' | sha256sum -c -
|
||||
export D3RO_RELEASE_STORE_FILE="$CI_PROJECT_DIR/apps/mobile-rn/android/app/release.keystore"
|
||||
export D3RO_RELEASE_STORE_PASSWORD="$ANDROID_RELEASE_STORE_PASSWORD"
|
||||
export D3RO_RELEASE_KEY_ALIAS="$ANDROID_RELEASE_KEY_ALIAS"
|
||||
export D3RO_RELEASE_KEY_PASSWORD="$ANDROID_RELEASE_KEY_PASSWORD"
|
||||
export D3RO_ADMOB_APP_ID="$ADMOB_APP_ID"
|
||||
export D3RO_ADMOB_BANNER_UNIT_ID="$ADMOB_BANNER_UNIT_ID"
|
||||
export D3RO_ADMOB_REWARDED_UNIT_ID="$ADMOB_REWARDED_UNIT_ID"
|
||||
export D3RO_VERSION_NAME="$VERSION_NAME"
|
||||
export D3RO_VERSION_CODE="$VERSION_CODE"
|
||||
PLAY_APP_SIGNING_CERT_SHA256="$(node -p "require('./release/android-release-identity.json').playAppSigningCertificateSha256")"
|
||||
npm run release:mobile:config
|
||||
cd apps/mobile-rn/android
|
||||
./gradlew :app:assembleRelease :app:bundleRelease -PreactNativeArchitectures=arm64-v8a --no-daemon
|
||||
cd "$CI_PROJECT_DIR"
|
||||
APK=apps/mobile-rn/android/app/build/outputs/apk/release/app-release.apk
|
||||
AAB=apps/mobile-rn/android/app/build/outputs/bundle/release/app-release.aab
|
||||
test -f "$APK"
|
||||
test -f "$AAB"
|
||||
node scripts/ci/verify-mobile-build-config.mjs release > apps/mobile-rn/android/app/build/outputs/release-build-config.json
|
||||
node scripts/ci/create-mobile-release-evidence.mjs \
|
||||
--apk "$APK" \
|
||||
--aab "$AAB" \
|
||||
--bundletool /tmp/bundletool.jar \
|
||||
--repository "$CI_PROJECT_PATH" \
|
||||
--commit-sha "$CI_COMMIT_SHA" \
|
||||
--tree-sha "$SOURCE_TREE_SHA" \
|
||||
--git-ref "refs/tags/$CI_COMMIT_TAG" \
|
||||
--workflow-identity "gitlab-ci/mobile-production-release" \
|
||||
--run-id "$CI_JOB_ID" \
|
||||
--run-attempt "1" \
|
||||
--runner-identity "$CI_RUNNER_ID:$CI_RUNNER_REVISION" \
|
||||
--expected-admob-app-id "$D3RO_ADMOB_APP_ID" \
|
||||
--expected-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \
|
||||
--expected-version-name "$D3RO_VERSION_NAME" \
|
||||
--expected-version-code "$D3RO_VERSION_CODE" \
|
||||
--private-key apps/mobile-rn/android/app/release-evidence-private.pem \
|
||||
--snapshot-dir apps/mobile-rn/android/app/build/outputs/release-snapshot
|
||||
VERIFIER_SHA256="$(sha256sum scripts/ci/verify-android-artifact.mjs | awk '{print $1}')"
|
||||
BUNDLETOOL_SHA256="a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29"
|
||||
node scripts/ci/prepare-mobile-release-publication.mjs \
|
||||
--source-root apps/mobile-rn/android/app/build/outputs/release-snapshot \
|
||||
--apk apps/mobile-rn/android/app/build/outputs/release-snapshot/app-release.apk \
|
||||
--aab apps/mobile-rn/android/app/build/outputs/release-snapshot/app-release.aab \
|
||||
--evidence apps/mobile-rn/android/app/build/outputs/release-snapshot/release-artifact-evidence.json \
|
||||
--public-key release/mobile-release-evidence-public.pem \
|
||||
--destination-dir apps/mobile-rn/android/app/build/outputs/release-publication \
|
||||
--expected-admob-app-id "$D3RO_ADMOB_APP_ID" \
|
||||
--expected-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \
|
||||
--expected-version-name "$D3RO_VERSION_NAME" \
|
||||
--expected-version-code "$D3RO_VERSION_CODE" \
|
||||
--expected-repository "$CI_PROJECT_PATH" \
|
||||
--expected-commit-sha "$CI_COMMIT_SHA" \
|
||||
--expected-tree-sha "$SOURCE_TREE_SHA" \
|
||||
--expected-git-ref "refs/tags/$CI_COMMIT_TAG" \
|
||||
--expected-workflow-identity "gitlab-ci/mobile-production-release" \
|
||||
--expected-run-id "$CI_JOB_ID" \
|
||||
--expected-run-attempt "1" \
|
||||
--expected-runner-identity "$CI_RUNNER_ID:$CI_RUNNER_REVISION" \
|
||||
--expected-verifier-sha256 "$VERIFIER_SHA256" \
|
||||
--expected-bundletool-sha256 "$BUNDLETOOL_SHA256"
|
||||
node scripts/ci/verify-android-app-links.mjs \
|
||||
--expected-play-app-signing-cert-sha256 "$PLAY_APP_SIGNING_CERT_SHA256" \
|
||||
--forbidden-upload-cert-sha256 "$ANDROID_UPLOAD_CERT_SHA256" \
|
||||
> apps/mobile-rn/android/app/build/outputs/release-app-links-evidence.json
|
||||
sha256sum \
|
||||
apps/mobile-rn/android/app/build/outputs/release-publication/app-release.apk \
|
||||
apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab \
|
||||
> apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt
|
||||
after_script:
|
||||
- rm -f apps/mobile-rn/android/app/release.keystore apps/mobile-rn/android/app/google-services.json apps/mobile-rn/android/app/release-evidence-private.pem
|
||||
artifacts:
|
||||
access: maintainer
|
||||
expire_in: 7 days
|
||||
paths:
|
||||
- apps/mobile-rn/android/app/build/outputs/release-publication/app-release.aab
|
||||
- apps/mobile-rn/android/app/build/outputs/release-publication/android-release-evidence.json
|
||||
- apps/mobile-rn/android/app/build/outputs/release-publication/android-publication-manifest.json
|
||||
- apps/mobile-rn/android/app/build/outputs/release-publication/SHA256SUMS.txt
|
||||
- apps/mobile-rn/android/app/build/outputs/release-snapshot/release-artifact-verification.json
|
||||
- apps/mobile-rn/android/app/build/outputs/release-build-config.json
|
||||
- apps/mobile-rn/android/app/build/outputs/release-app-links-evidence.json
|
||||
manual_confirmation: '검증된 production AAB를 생성해 Maintainer 전용 Play Console handoff로 보낼까요?'
|
||||
rules:
|
||||
- if: '$D3RO_MOBILE_PRODUCTION_RELEASE_ENABLED == "true" && $D3RO_ANDROID_EMULATOR_RUNNER == "true" && $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
|
||||
when: manual
|
||||
allow_failure: false
|
||||
- when: never
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Publish Release (GitLab Package Registry + Release Page)
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
publish-release:
|
||||
stage: publish
|
||||
image: node:20-bookworm
|
||||
image: node:24.19.0-bookworm
|
||||
needs:
|
||||
- job: package-windows
|
||||
artifacts: true
|
||||
- job: package-macos
|
||||
artifacts: true
|
||||
optional: true
|
||||
- job: mobile-production-release
|
||||
artifacts: false
|
||||
optional: true
|
||||
script:
|
||||
- node scripts/ci/publish-gitlab-release.mjs
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+.*$/'
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Deploy Admin Dashboard to NAS / Production Server
|
||||
# Admin NAS deployment remains disabled until an immutable image, authenticated
|
||||
# remote target, health check, and rollback path are configured. Do not revive
|
||||
# the former DinD job: it only changed an ephemeral CI daemon and referenced a
|
||||
# non-existent compose file, so it never deployed the NAS.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
deploy-admin-nas:
|
||||
stage: deploy
|
||||
image: docker:24-cli
|
||||
services:
|
||||
- docker:24-dind
|
||||
before_script:
|
||||
- echo "$DOCKER_REGISTRY_PASSWORD" | docker login -u "$DOCKER_REGISTRY_USER" --password-stdin
|
||||
script:
|
||||
- docker build -t d3ro-voice-admin:latest -f Dockerfile.admin .
|
||||
- docker compose -f docker-compose.prod.yml up -d admin
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
|
|
|
|||
2
.nvmrc
2
.nvmrc
|
|
@ -1 +1 @@
|
|||
20
|
||||
24.19.0
|
||||
|
|
|
|||
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -13,6 +13,33 @@ 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.1.0] - 2026-08-29
|
||||
|
||||
### Added
|
||||
- **Android/iOS product mobile app**: implemented account bootstrap and lifecycle, onboarding, recording and audio import, transcription, history, meetings, templates, memos, teams and invitations, notifications, knowledge, AI Talk, generated documents, data portability, and role-aware administration flows.
|
||||
- **Mobile-native recording and intake**: added Android foreground recording with persistent controls and recovery, external `ACTION_SEND` audio/video intake, durable upload processing, and supported-device on-device Whisper transcription.
|
||||
- **Server-verified mobile monetization**: added Google Play purchase and restore verification, entitlement reconciliation, AdMob rewarded SSV validation, and replay-safe reward receipts.
|
||||
- **Generated-content safety controls**: added generation receipts, shared generative-AI safety instructions, and an authenticated report flow for owned AI-generated meeting documents.
|
||||
- **Release verification tooling**: added Android artifact, App Links, production Firebase/AdMob configuration, Play asset, secret scanning, signed provenance, and publication-boundary checks.
|
||||
- **Complete mobile icon set**: added canonical Android legacy/adaptive/monochrome launchers, the 512px Play icon, and all required iPhone, iPad, and App Store marketing icon slots.
|
||||
|
||||
### Changed
|
||||
- Unified mobile authentication and invitation links on the canonical `d3ro-voice` app scheme and added fail-closed verification for the HTTPS App Links contract.
|
||||
- Hardened Supabase and mobile production configuration to fail closed when required public configuration is absent or does not match the release identity.
|
||||
- Expanded CI coverage across Node.js 24, .NET 10, Deno Edge Functions, React Native tests, Android emulator gates, 16 KB page-size compatibility, and reproducible release evidence.
|
||||
- Aligned account deletion guidance with external subscription cancellation requirements and centralized local sensitive-data purge on logout, account deletion, and session loss.
|
||||
- Centralized product version `1.1.0`, Android version code `1010001`, release notes, updater metadata, and app-store changelogs under release SSOT checks.
|
||||
|
||||
### Security
|
||||
- Added atomic authorization and replay protection for teams, invitations, push delivery, transcription quotas, billing, ad rewards, administrative actions, data portability, and content reports.
|
||||
- Removed synthetic success fallbacks from protected STT and AI paths; provider and configuration failures now return explicit failures without creating fabricated user data.
|
||||
- Strengthened generated-document reporting so only the owner can report an existing document backed by immutable generation audit evidence.
|
||||
- Removed the repository-exposed desktop license private key and prefix-only paid-tier activation; production now accepts only Ed25519 licenses signed by the rotated external key.
|
||||
|
||||
### Fixed
|
||||
- Corrected realtime Edge Function model routing and type checks for team and enterprise tiers.
|
||||
- Removed the legacy `d3ro://` deep-link surface to prevent divergent authentication callback identities.
|
||||
|
||||
## [0.2.1-alpha] - 2026-07-22
|
||||
|
||||
### Fixed
|
||||
|
|
@ -64,7 +91,7 @@ Midnight Glass v2 전면 재설계 + refactor-wave 대규모 코드 품질 정
|
|||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-04-06
|
||||
## [1.0.0] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Dockerfile.admin
|
||||
# Multi-stage production build for @d3ro/admin Next.js App
|
||||
|
||||
FROM node:20-alpine AS deps
|
||||
FROM node:24.19.0-alpine AS deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY package.json package-lock.json ./
|
||||
|
|
@ -11,7 +11,7 @@ COPY apps/desktop/package.json ./apps/desktop/package.json
|
|||
COPY apps/web/package.json ./apps/web/package.json
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:24.19.0-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages ./packages
|
||||
|
|
@ -23,7 +23,7 @@ ENV NODE_ENV production
|
|||
|
||||
RUN npm run build --workspace=@d3ro/admin
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
FROM node:24.19.0-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"info": {
|
||||
"title": "D3RO-VOICE Admin API",
|
||||
"description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.1.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
|
|
@ -38,52 +38,159 @@
|
|||
"Profile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"name": { "type": "string", "nullable": true },
|
||||
"avatar_url": { "type": "string", "nullable": true },
|
||||
"locale": { "type": "string" },
|
||||
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
|
||||
"role": { "type": "string", "enum": ["user", "admin", "super_admin"] },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"updated_at": { "type": "string", "format": "date-time" }
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"locale": {
|
||||
"type": "string"
|
||||
},
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"free",
|
||||
"pro",
|
||||
"pro_plus"
|
||||
]
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user",
|
||||
"admin",
|
||||
"super_admin"
|
||||
]
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Subscription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"user_id": { "type": "string", "format": "uuid" },
|
||||
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
|
||||
"status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] },
|
||||
"payment_provider": { "type": "string", "enum": ["none", "stripe", "payple"] },
|
||||
"current_period_start": { "type": "string", "format": "date-time", "nullable": true },
|
||||
"current_period_end": { "type": "string", "format": "date-time", "nullable": true },
|
||||
"overage_credits": { "type": "integer" },
|
||||
"admin_note": { "type": "string", "nullable": true },
|
||||
"renewal_failures": { "type": "integer" },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"updated_at": { "type": "string", "format": "date-time" }
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"free",
|
||||
"pro",
|
||||
"pro_plus"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"active",
|
||||
"canceled",
|
||||
"past_due",
|
||||
"expired"
|
||||
]
|
||||
},
|
||||
"payment_provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"none",
|
||||
"stripe",
|
||||
"payple"
|
||||
]
|
||||
},
|
||||
"current_period_start": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"current_period_end": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"overage_credits": {
|
||||
"type": "integer"
|
||||
},
|
||||
"admin_note": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"renewal_failures": {
|
||||
"type": "integer"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AuditLog": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"admin_id": { "type": "string", "format": "uuid" },
|
||||
"admin_name": { "type": "string" },
|
||||
"action": { "type": "string" },
|
||||
"target_type": { "type": "string" },
|
||||
"target_id": { "type": "string", "format": "uuid" },
|
||||
"before_data": { "type": "object", "nullable": true },
|
||||
"after_data": { "type": "object", "nullable": true },
|
||||
"memo": { "type": "string" },
|
||||
"created_at": { "type": "string", "format": "date-time" }
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"admin_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"admin_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"action": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"before_data": {
|
||||
"type": "object",
|
||||
"nullable": true
|
||||
},
|
||||
"after_data": {
|
||||
"type": "object",
|
||||
"nullable": true
|
||||
},
|
||||
"memo": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": { "type": "string" }
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -91,15 +198,57 @@
|
|||
"paths": {
|
||||
"/admin-users": {
|
||||
"get": {
|
||||
"tags": ["Users"],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "List or get user details",
|
||||
"description": "Admin+. Pass userId for single user detail, or omit for paginated list.",
|
||||
"parameters": [
|
||||
{ "name": "userId", "in": "query", "schema": { "type": "string", "format": "uuid" }, "description": "Specific user ID for detail view" },
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
|
||||
{ "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Name search (ilike)" },
|
||||
{ "name": "role", "in": "query", "schema": { "type": "string", "enum": ["user", "admin", "super_admin"] } }
|
||||
{
|
||||
"name": "userId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"description": "Specific user ID for detail view"
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Name search (ilike)"
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user",
|
||||
"admin",
|
||||
"super_admin"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
|
|
@ -111,17 +260,32 @@
|
|||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profiles": { "type": "array", "items": { "$ref": "#/components/schemas/Profile" } },
|
||||
"total": { "type": "integer" },
|
||||
"page": { "type": "integer" },
|
||||
"limit": { "type": "integer" }
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Profile"
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile": { "$ref": "#/components/schemas/Profile" },
|
||||
"subscription": { "$ref": "#/components/schemas/Subscription" }
|
||||
"profile": {
|
||||
"$ref": "#/components/schemas/Profile"
|
||||
},
|
||||
"subscription": {
|
||||
"$ref": "#/components/schemas/Subscription"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -129,11 +293,22 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"403": { "description": "Not admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
"403": {
|
||||
"description": "Not admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["Users"],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Change user role",
|
||||
"description": "Super admin only. Changes both auth.users.app_metadata.role and profiles.role.",
|
||||
"requestBody": {
|
||||
|
|
@ -142,39 +317,110 @@
|
|||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["userId", "newRole", "memo"],
|
||||
"required": [
|
||||
"userId",
|
||||
"newRole",
|
||||
"memo"
|
||||
],
|
||||
"properties": {
|
||||
"userId": { "type": "string", "format": "uuid" },
|
||||
"newRole": { "type": "string", "enum": ["user", "admin", "super_admin"] },
|
||||
"memo": { "type": "string", "description": "Required reason for audit log" }
|
||||
"userId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"newRole": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user",
|
||||
"admin",
|
||||
"super_admin"
|
||||
]
|
||||
},
|
||||
"memo": {
|
||||
"type": "string",
|
||||
"description": "Required reason for audit log"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Role changed successfully" },
|
||||
"403": { "description": "Not super_admin" }
|
||||
"200": {
|
||||
"description": "Role changed successfully"
|
||||
},
|
||||
"403": {
|
||||
"description": "Not super_admin"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin-subscriptions": {
|
||||
"get": {
|
||||
"tags": ["Subscriptions"],
|
||||
"tags": [
|
||||
"Subscriptions"
|
||||
],
|
||||
"summary": "List or get subscription details",
|
||||
"parameters": [
|
||||
{ "name": "userId", "in": "query", "schema": { "type": "string", "format": "uuid" } },
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
|
||||
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] } },
|
||||
{ "name": "tier", "in": "query", "schema": { "type": "string", "enum": ["free", "pro", "pro_plus"] } }
|
||||
{
|
||||
"name": "userId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"active",
|
||||
"canceled",
|
||||
"past_due",
|
||||
"expired"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tier",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"free",
|
||||
"pro",
|
||||
"pro_plus"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Subscription list or detail" }
|
||||
"200": {
|
||||
"description": "Subscription list or detail"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Subscriptions"],
|
||||
"tags": [
|
||||
"Subscriptions"
|
||||
],
|
||||
"summary": "Create subscription (VIP grant / record recovery)",
|
||||
"description": "Super admin only.",
|
||||
"requestBody": {
|
||||
|
|
@ -183,30 +429,74 @@
|
|||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["userId", "tier", "memo"],
|
||||
"required": [
|
||||
"userId",
|
||||
"tier",
|
||||
"memo"
|
||||
],
|
||||
"properties": {
|
||||
"userId": { "type": "string", "format": "uuid" },
|
||||
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
|
||||
"status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"], "default": "active" },
|
||||
"currentPeriodEnd": { "type": "string", "format": "date-time" },
|
||||
"adminNote": { "type": "string" },
|
||||
"memo": { "type": "string" }
|
||||
"userId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"free",
|
||||
"pro",
|
||||
"pro_plus"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"active",
|
||||
"canceled",
|
||||
"past_due",
|
||||
"expired"
|
||||
],
|
||||
"default": "active"
|
||||
},
|
||||
"currentPeriodEnd": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"adminNote": {
|
||||
"type": "string"
|
||||
},
|
||||
"memo": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": { "description": "Subscription created" },
|
||||
"409": { "description": "Subscription already exists" }
|
||||
"201": {
|
||||
"description": "Subscription created"
|
||||
},
|
||||
"409": {
|
||||
"description": "Subscription already exists"
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["Subscriptions"],
|
||||
"tags": [
|
||||
"Subscriptions"
|
||||
],
|
||||
"summary": "Update subscription",
|
||||
"description": "Super admin only.",
|
||||
"parameters": [
|
||||
{ "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
{
|
||||
"name": "userId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
|
|
@ -214,29 +504,67 @@
|
|||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["memo"],
|
||||
"required": [
|
||||
"memo"
|
||||
],
|
||||
"properties": {
|
||||
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
|
||||
"status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] },
|
||||
"currentPeriodEnd": { "type": "string", "format": "date-time" },
|
||||
"overageCredits": { "type": "integer" },
|
||||
"adminNote": { "type": "string" },
|
||||
"memo": { "type": "string" }
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"free",
|
||||
"pro",
|
||||
"pro_plus"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"active",
|
||||
"canceled",
|
||||
"past_due",
|
||||
"expired"
|
||||
]
|
||||
},
|
||||
"currentPeriodEnd": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"overageCredits": {
|
||||
"type": "integer"
|
||||
},
|
||||
"adminNote": {
|
||||
"type": "string"
|
||||
},
|
||||
"memo": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Subscription updated" }
|
||||
"200": {
|
||||
"description": "Subscription updated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Subscriptions"],
|
||||
"tags": [
|
||||
"Subscriptions"
|
||||
],
|
||||
"summary": "Soft-delete subscription",
|
||||
"description": "Super admin only. Sets status=expired, tier=free.",
|
||||
"parameters": [
|
||||
{ "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
{
|
||||
"name": "userId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
|
|
@ -244,27 +572,54 @@
|
|||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["memo"],
|
||||
"required": [
|
||||
"memo"
|
||||
],
|
||||
"properties": {
|
||||
"memo": { "type": "string" }
|
||||
"memo": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Subscription soft-deleted" }
|
||||
"200": {
|
||||
"description": "Subscription soft-deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin-payments": {
|
||||
"get": {
|
||||
"tags": ["Payments"],
|
||||
"tags": [
|
||||
"Payments"
|
||||
],
|
||||
"summary": "Get payment history for a user",
|
||||
"description": "Admin+. Returns DB subscription data + audit logs. Pass source=payple for Payple API history.",
|
||||
"parameters": [
|
||||
{ "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } },
|
||||
{ "name": "source", "in": "query", "schema": { "type": "string", "enum": ["db", "payple"] }, "description": "Add 'payple' to also fetch from Payple API" }
|
||||
{
|
||||
"name": "userId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"db",
|
||||
"payple"
|
||||
]
|
||||
},
|
||||
"description": "Add 'payple' to also fetch from Payple API"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
|
|
@ -274,10 +629,23 @@
|
|||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subscription": { "$ref": "#/components/schemas/Subscription" },
|
||||
"auditLogs": { "type": "array", "items": { "$ref": "#/components/schemas/AuditLog" } },
|
||||
"paypleHistory": { "type": "object", "description": "Payple API response (when source=payple)" },
|
||||
"paypleError": { "type": "string", "description": "Error message if Payple API call failed" }
|
||||
"subscription": {
|
||||
"$ref": "#/components/schemas/Subscription"
|
||||
},
|
||||
"auditLogs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AuditLog"
|
||||
}
|
||||
},
|
||||
"paypleHistory": {
|
||||
"type": "object",
|
||||
"description": "Payple API response (when source=payple)"
|
||||
},
|
||||
"paypleError": {
|
||||
"type": "string",
|
||||
"description": "Error message if Payple API call failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -288,18 +656,81 @@
|
|||
},
|
||||
"/admin-audit-log": {
|
||||
"get": {
|
||||
"tags": ["Audit Log"],
|
||||
"tags": [
|
||||
"Audit Log"
|
||||
],
|
||||
"summary": "List or get audit log entries",
|
||||
"description": "Admin+. Pass id for single entry detail.",
|
||||
"parameters": [
|
||||
{ "name": "id", "in": "query", "schema": { "type": "integer" }, "description": "Specific log entry ID" },
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
|
||||
{ "name": "target_type", "in": "query", "schema": { "type": "string", "enum": ["subscription", "profile"] } },
|
||||
{ "name": "admin_id", "in": "query", "schema": { "type": "string", "format": "uuid" } },
|
||||
{ "name": "target_id", "in": "query", "schema": { "type": "string", "format": "uuid" } },
|
||||
{ "name": "from", "in": "query", "schema": { "type": "string", "format": "date" }, "description": "Start date (YYYY-MM-DD)" },
|
||||
{ "name": "to", "in": "query", "schema": { "type": "string", "format": "date" }, "description": "End date (YYYY-MM-DD)" }
|
||||
{
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Specific log entry ID"
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "target_type",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"subscription",
|
||||
"profile"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "admin_id",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "target_id",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "from",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"description": "Start date (YYYY-MM-DD)"
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"description": "End date (YYYY-MM-DD)"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
|
|
@ -311,17 +742,32 @@
|
|||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"logs": { "type": "array", "items": { "$ref": "#/components/schemas/AuditLog" } },
|
||||
"total": { "type": "integer" },
|
||||
"page": { "type": "integer" },
|
||||
"limit": { "type": "integer" }
|
||||
"logs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AuditLog"
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"log": { "$ref": "#/components/schemas/AuditLog" },
|
||||
"admin": { "$ref": "#/components/schemas/Profile" }
|
||||
"log": {
|
||||
"$ref": "#/components/schemas/AuditLog"
|
||||
},
|
||||
"admin": {
|
||||
"$ref": "#/components/schemas/Profile"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# apps/admin/Dockerfile
|
||||
FROM node:24-alpine AS base
|
||||
FROM node:24.19.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/admin",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "D3RO Voice Admin CRM — SaaS 관리 도구",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
// D3RO Voice Admin CRM — Release & Distribution Hub (Forgejo live feed)
|
||||
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import Link from 'next/link'
|
||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { requireManager } from '@/lib/admin-guard'
|
||||
|
|
@ -91,7 +90,6 @@ function HeaderBar({ feedLive, repoHtmlUrl }: { feedLive: boolean; repoHtmlUrl:
|
|||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
component={Link}
|
||||
href={`${repoHtmlUrl}/releases`}
|
||||
target="_blank"
|
||||
variant="outlined"
|
||||
|
|
@ -109,7 +107,6 @@ function HeaderBar({ feedLive, repoHtmlUrl }: { feedLive: boolean; repoHtmlUrl:
|
|||
Forgejo Releases ↗
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
href="https://d3ro.chanpaca.net/download.html"
|
||||
target="_blank"
|
||||
variant="outlined"
|
||||
|
|
|
|||
|
|
@ -3,11 +3,7 @@
|
|||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
issueSignedLicenseKey,
|
||||
DEFAULT_LICENSE_PRIVATE_KEY,
|
||||
type SignedLicensePayload
|
||||
} from '@d3ro/core/utils/crypto-license'
|
||||
import { issueSignedLicenseKey, type SignedLicensePayload } from '@d3ro/core/utils/crypto-license'
|
||||
import type { LicenseTier } from '@d3ro/core/types'
|
||||
import { AdminBackendError, fetchAdminBackend, requireVerifiedBackendSession } from '@/lib/backend-session'
|
||||
|
||||
|
|
@ -27,20 +23,11 @@ const MAX_DEVICES: Record<LicenseTier, number> = {
|
|||
enterprise: 999
|
||||
}
|
||||
|
||||
interface SigningKey {
|
||||
privateKeyPem: string
|
||||
usedDefaultKey: boolean
|
||||
}
|
||||
|
||||
// 저장소에 포함된 기본 키쌍은 공개된 것이므로 위조 방어력이 없다. 운영 발급은
|
||||
// ADMIN_LICENSE_PRIVATE_KEY(전용 키 로테이션)로만 하고, 기본 키 사용은 명시적 opt-in.
|
||||
function resolveSigningKey(): SigningKey | null {
|
||||
// 공개된 개발 키로의 fallback은 없다. 모든 환경에서 명시적인 전용 서명키가 필요하다.
|
||||
function resolveSigningKey(): string | null {
|
||||
const configured = process.env.ADMIN_LICENSE_PRIVATE_KEY?.trim()
|
||||
if (configured) {
|
||||
return { privateKeyPem: configured.replace(/\\n/g, '\n'), usedDefaultKey: false }
|
||||
}
|
||||
if (process.env.ADMIN_LICENSE_ALLOW_DEFAULT_KEY === 'true') {
|
||||
return { privateKeyPem: DEFAULT_LICENSE_PRIVATE_KEY, usedDefaultKey: true }
|
||||
return configured.replace(/\\n/g, '\n')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -119,7 +106,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|||
|
||||
let licenseKey: string
|
||||
try {
|
||||
licenseKey = issueSignedLicenseKey(payload, signingKey.privateKeyPem)
|
||||
licenseKey = issueSignedLicenseKey(payload, signingKey)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'license_signing_failed' }, { status: 500 })
|
||||
}
|
||||
|
|
@ -151,7 +138,6 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|||
licenseKey,
|
||||
licenseId: payload.licenseId,
|
||||
expiresAt: payload.expiresAt,
|
||||
usedDefaultKey: signingKey.usedDefaultKey,
|
||||
auditRecorded
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
|||
const [machineId, setMachineId] = useState('')
|
||||
const [teamId, setTeamId] = useState('')
|
||||
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
|
||||
const [usedDefaultKey, setUsedDefaultKey] = useState(false)
|
||||
const [auditRecorded, setAuditRecorded] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
|
@ -77,7 +76,6 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
|||
throw new Error(describeIssueError(data.error))
|
||||
}
|
||||
setGeneratedKey(data.licenseKey)
|
||||
setUsedDefaultKey(data.usedDefaultKey === true)
|
||||
setAuditRecorded(data.auditRecorded !== false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate license key')
|
||||
|
|
@ -211,12 +209,6 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
|||
|
||||
{generatedKey && (
|
||||
<Box sx={{ mt: 1, p: 2, bgcolor: 'rgba(0, 0, 0, 0.4)', borderRadius: '10px', border: `1px solid ${C.borderStrong}` }}>
|
||||
{usedDefaultKey && (
|
||||
<Alert severity="warning" sx={{ fontFamily: FONT_MONO, fontSize: '11px', mb: 1.5 }}>
|
||||
저장소 기본 키로 서명되었습니다. 기본 키쌍은 공개되어 위조 방어력이 없으므로 운영 배포 전
|
||||
ADMIN_LICENSE_PRIVATE_KEY로 키를 로테이션하세요.
|
||||
</Alert>
|
||||
)}
|
||||
{!auditRecorded && (
|
||||
<Alert severity="info" sx={{ fontFamily: FONT_MONO, fontSize: '11px', mb: 1.5 }}>
|
||||
라이선스는 발급되었으나 감사 로그 기록에 실패했습니다. 백엔드 연결을 확인하세요.
|
||||
|
|
|
|||
119
apps/api-server.Tests/AdminAuthorizationE2ETests.cs
Normal file
119
apps/api-server.Tests/AdminAuthorizationE2ETests.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Xunit;
|
||||
|
||||
namespace D3ROVoice.Api.Tests;
|
||||
|
||||
[CollectionDefinition("Api server integration", DisableParallelization = true)]
|
||||
public sealed class ApiServerIntegrationCollection;
|
||||
|
||||
[Collection("Api server integration")]
|
||||
public sealed class AdminAuthorizationE2ETests : IClassFixture<AdminAuthorizationE2ETests.ApiFactory>
|
||||
{
|
||||
private const string Secret = "admin-e2e-jwt-secret-0123456789-abcdef";
|
||||
private const string Issuer = "https://admin-e2e.test";
|
||||
private const string Audience = "d3ro-admin-e2e";
|
||||
private readonly ApiFactory _factory;
|
||||
|
||||
public AdminAuthorizationE2ETests(ApiFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminReadEndpointsRejectAnonymousAndNonManager()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/admin/stats")).StatusCode);
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("User"));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/admin/stats")).StatusCode);
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("manager"));
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/admin/stats")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminMutationRejectsManagerAndAcceptsNormalizedSuperAdmin()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var emptyPayload = new StringContent("{}", Encoding.UTF8, "application/json");
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("Manager"));
|
||||
Assert.Equal(HttpStatusCode.Forbidden,
|
||||
(await client.PostAsync("/api/admin/endpoints", emptyPayload)).StatusCode);
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token("super_admin"));
|
||||
using var secondPayload = new StringContent("{}", Encoding.UTF8, "application/json");
|
||||
Assert.Equal(HttpStatusCode.BadRequest,
|
||||
(await client.PostAsync("/api/admin/endpoints", secondPayload)).StatusCode);
|
||||
}
|
||||
|
||||
private static string Token(string role)
|
||||
{
|
||||
var credentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims:
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, "1"),
|
||||
new Claim(ClaimTypes.Email, "admin@example.com"),
|
||||
new Claim(ClaimTypes.Role, role)
|
||||
],
|
||||
expires: DateTime.UtcNow.AddMinutes(5),
|
||||
signingCredentials: credentials);
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _databasePath = Path.Combine(
|
||||
Path.GetTempPath(), $"d3ro-admin-auth-e2e-{Guid.NewGuid():N}.db");
|
||||
private readonly Dictionary<string, string?> _previousEnvironment = new();
|
||||
|
||||
public ApiFactory()
|
||||
{
|
||||
SetEnvironment("JWT_SECRET", Secret);
|
||||
SetEnvironment("JWT_ISSUER", Issuer);
|
||||
SetEnvironment("JWT_AUDIENCE", Audience);
|
||||
SetEnvironment("D3RO_API_TOKEN", "fixture-internal-gateway-token-32-bytes-minimum");
|
||||
SetEnvironment("DB_PATH", _databasePath);
|
||||
SetEnvironment("CORS_ALLOWED_ORIGINS", "http://localhost:3001");
|
||||
SetEnvironment("ALLOWED_HOSTS", "localhost;127.0.0.1");
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
builder.ConfigureLogging(logging => logging.ClearProviders());
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (File.Exists(_databasePath)) File.Delete(_databasePath);
|
||||
foreach (var (name, value) in _previousEnvironment)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetEnvironment(string name, string value)
|
||||
{
|
||||
_previousEnvironment[name] = Environment.GetEnvironmentVariable(name);
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
97
apps/api-server.Tests/AdminOperationServiceTests.cs
Normal file
97
apps/api-server.Tests/AdminOperationServiceTests.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
using D3ROVoice.Api.Data;
|
||||
using D3ROVoice.Api.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace D3ROVoice.Api.Tests;
|
||||
|
||||
public sealed class AdminOperationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MutationIsAtomicAuditedAndIdempotent()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite("Data Source=:memory:")
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.OpenConnectionAsync();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var service = new AdminOperationService(db);
|
||||
var idempotencyKey = Guid.NewGuid().ToString("D");
|
||||
var mutationCalls = 0;
|
||||
|
||||
async Task<object> Mutate()
|
||||
{
|
||||
mutationCalls += 1;
|
||||
var endpoint = new ServiceModelEndpoint
|
||||
{
|
||||
ModelId = "real-model",
|
||||
ModelName = "Real Model",
|
||||
Provider = "Custom",
|
||||
EndpointUrl = "https://models.example.com/v1",
|
||||
ApiKey = "secret-never-in-response"
|
||||
};
|
||||
db.ModelEndpoints.Add(endpoint);
|
||||
await db.SaveChangesAsync();
|
||||
return new { endpoint.Id, endpoint.ModelId };
|
||||
}
|
||||
|
||||
var first = await service.ExecuteAsync(
|
||||
"ADMIN@EXAMPLE.COM", "model_endpoint.create", idempotencyKey,
|
||||
new { modelId = "real-model" }, "model_endpoint", _ => "real-model",
|
||||
"approved provider setup", () => Task.FromResult<object?>(null), Mutate);
|
||||
var replay = await service.ExecuteAsync(
|
||||
"admin@example.com", "model_endpoint.create", idempotencyKey,
|
||||
new { modelId = "real-model" }, "model_endpoint", _ => "real-model",
|
||||
"approved provider setup", () => Task.FromResult<object?>(null), Mutate);
|
||||
|
||||
Assert.Equal(1, mutationCalls);
|
||||
Assert.Equal(first.GetRawText(), replay.GetRawText());
|
||||
Assert.Equal(1, await db.ModelEndpoints.CountAsync());
|
||||
Assert.Equal(1, await db.AdminOperationRequests.CountAsync());
|
||||
var audit = await db.AdminAuditEntries.SingleAsync();
|
||||
Assert.Equal("admin@example.com", audit.ActorEmail);
|
||||
Assert.Equal("model_endpoint.create", audit.Action);
|
||||
Assert.Equal(idempotencyKey, audit.IdempotencyKey);
|
||||
|
||||
await Assert.ThrowsAsync<AdminOperationException>(() => service.ExecuteAsync(
|
||||
"admin@example.com", "model_endpoint.create", idempotencyKey,
|
||||
new { modelId = "different-model" }, "model_endpoint", _ => "different-model",
|
||||
"different request", () => Task.FromResult<object?>(null), Mutate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedMutationRollsBackDomainAndAuditWrites()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite("Data Source=:memory:")
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.OpenConnectionAsync();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var service = new AdminOperationService(db);
|
||||
|
||||
async Task<object> FailingMutation()
|
||||
{
|
||||
db.ModelEndpoints.Add(new ServiceModelEndpoint
|
||||
{
|
||||
ModelId = "rollback-model",
|
||||
ModelName = "Rollback Model",
|
||||
Provider = "Custom",
|
||||
EndpointUrl = "https://models.example.com/v1"
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
throw new InvalidOperationException("simulated persistence failure");
|
||||
}
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.ExecuteAsync(
|
||||
"admin@example.com", "model_endpoint.create", Guid.NewGuid().ToString("D"),
|
||||
new { modelId = "rollback-model" }, "model_endpoint", _ => "rollback-model",
|
||||
"rollback verification", () => Task.FromResult<object?>(null), FailingMutation));
|
||||
|
||||
db.ChangeTracker.Clear();
|
||||
Assert.Empty(await db.ModelEndpoints.ToListAsync());
|
||||
Assert.Empty(await db.AdminOperationRequests.ToListAsync());
|
||||
Assert.Empty(await db.AdminAuditEntries.ToListAsync());
|
||||
}
|
||||
}
|
||||
91
apps/api-server.Tests/AuthBootstrapControllerTests.cs
Normal file
91
apps/api-server.Tests/AuthBootstrapControllerTests.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
using D3ROVoice.Api.Controllers;
|
||||
using D3ROVoice.Api.Dtos;
|
||||
using D3ROVoice.Api.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace D3ROVoice.Api.Tests;
|
||||
|
||||
public sealed class AuthBootstrapControllerTests
|
||||
{
|
||||
private static readonly RegisterDto Request = new(
|
||||
"operator@example.test",
|
||||
"test-only-password-1234");
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("too-short")]
|
||||
public async Task RegisterIsDisabledWithoutAStrongConfiguredBootstrapToken(string? configuredToken)
|
||||
{
|
||||
var service = new RecordingAuthService();
|
||||
var configuration = Configuration(configuredToken);
|
||||
var controller = new AuthController(service, configuration);
|
||||
|
||||
var result = await controller.Register(Request, configuredToken);
|
||||
|
||||
var unavailable = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(503, unavailable.StatusCode);
|
||||
Assert.Equal(0, service.RegisterCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterRejectsAnIncorrectBootstrapTokenWithoutCreatingAUser()
|
||||
{
|
||||
var service = new RecordingAuthService();
|
||||
var configuration = Configuration("test-bootstrap-token-0123456789-abcdef");
|
||||
var controller = new AuthController(service, configuration);
|
||||
|
||||
var result = await controller.Register(
|
||||
Request,
|
||||
"different-test-token-0123456789-abcdef");
|
||||
|
||||
Assert.IsType<UnauthorizedObjectResult>(result);
|
||||
Assert.Equal(0, service.RegisterCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterAcceptsTheExactStrongBootstrapTokenOnce()
|
||||
{
|
||||
const string bootstrapToken = "test-bootstrap-token-0123456789-abcdef";
|
||||
var service = new RecordingAuthService();
|
||||
var controller = new AuthController(service, Configuration(bootstrapToken));
|
||||
|
||||
var result = await controller.Register(Request, bootstrapToken);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.Equal(1, service.RegisterCalls);
|
||||
}
|
||||
|
||||
private static IConfiguration Configuration(string? bootstrapToken)
|
||||
{
|
||||
var values = new Dictionary<string, string?>();
|
||||
if (bootstrapToken is not null)
|
||||
{
|
||||
values["ADMIN_BOOTSTRAP_TOKEN"] = bootstrapToken;
|
||||
}
|
||||
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
}
|
||||
|
||||
private sealed class RecordingAuthService : IAuthService
|
||||
{
|
||||
public int RegisterCalls { get; private set; }
|
||||
|
||||
public Task<AuthResponseDto> RegisterAsync(RegisterDto dto)
|
||||
{
|
||||
RegisterCalls += 1;
|
||||
return Task.FromResult(new AuthResponseDto(
|
||||
"test-token",
|
||||
dto.Email,
|
||||
"SuperAdmin",
|
||||
DateTime.UtcNow.AddMinutes(5)));
|
||||
}
|
||||
|
||||
public Task<AuthResponseDto> LoginAsync(LoginDto dto) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<UserInfoDto?> GetUserByEmailAsync(string email) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
118
apps/api-server.Tests/AuthSecurityTests.cs
Normal file
118
apps/api-server.Tests/AuthSecurityTests.cs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
using D3ROVoice.Api.Data;
|
||||
using D3ROVoice.Api.Dtos;
|
||||
using D3ROVoice.Api.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace D3ROVoice.Api.Tests;
|
||||
|
||||
public sealed class AuthSecurityTests
|
||||
{
|
||||
private static IConfiguration TestConfiguration() => new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["JWT_SECRET"] = "test-only-jwt-secret-0123456789-abcdef",
|
||||
["JWT_ISSUER"] = "https://issuer.test",
|
||||
["JWT_AUDIENCE"] = "d3ro-admin-test"
|
||||
})
|
||||
.Build();
|
||||
|
||||
[Fact]
|
||||
public async Task BootstrapStoresPbkdf2HashAndRejectsASecondAdministrator()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite("Data Source=:memory:")
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.OpenConnectionAsync();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var service = new AuthService(db, TestConfiguration());
|
||||
|
||||
var created = await service.RegisterAsync(
|
||||
new RegisterDto("ADMIN@EXAMPLE.COM", "correct-horse-battery-staple"));
|
||||
|
||||
var stored = await db.Users.SingleAsync();
|
||||
Assert.Equal("admin@example.com", stored.Email);
|
||||
Assert.Equal("SuperAdmin", stored.Role);
|
||||
Assert.StartsWith("AQAAAA", stored.PasswordHash);
|
||||
Assert.DoesNotContain("correct-horse", stored.PasswordHash, StringComparison.Ordinal);
|
||||
Assert.Equal("SuperAdmin", created.Role);
|
||||
Assert.True(created.ExpiresAt > DateTime.UtcNow.AddHours(7));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.RegisterAsync(
|
||||
new RegisterDto("second@example.com", "another-correct-password")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginRejectsWrongPasswordAndReusesNoLegacyHashScheme()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite("Data Source=:memory:")
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.OpenConnectionAsync();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var service = new AuthService(db, TestConfiguration());
|
||||
await service.RegisterAsync(
|
||||
new RegisterDto("admin@example.com", "correct-horse-battery-staple"));
|
||||
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.LoginAsync(
|
||||
new LoginDto("admin@example.com", "wrong-password-value")));
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => service.LoginAsync(
|
||||
new LoginDto("missing@example.com", "wrong-password-value")));
|
||||
|
||||
var login = await service.LoginAsync(
|
||||
new LoginDto("ADMIN@example.com", "correct-horse-battery-staple"));
|
||||
Assert.Equal("admin@example.com", login.Email);
|
||||
Assert.False(string.IsNullOrWhiteSpace(login.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentBootstrapAllowsExactlyOneAdministrator()
|
||||
{
|
||||
var dbPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"d3ro-auth-security-{Guid.NewGuid():N}.db");
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options;
|
||||
|
||||
try
|
||||
{
|
||||
await using (var setup = new AppDbContext(options))
|
||||
{
|
||||
await setup.Database.EnsureCreatedAsync();
|
||||
}
|
||||
|
||||
async Task<bool> TryBootstrapAsync(int index)
|
||||
{
|
||||
await using var db = new AppDbContext(options);
|
||||
var service = new AuthService(db, TestConfiguration());
|
||||
try
|
||||
{
|
||||
await service.RegisterAsync(new RegisterDto(
|
||||
$"admin-{index}@example.com",
|
||||
$"correct-horse-battery-{index}-staple"));
|
||||
return true;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var outcomes = await Task.WhenAll(TryBootstrapAsync(1), TryBootstrapAsync(2));
|
||||
Assert.Single(outcomes, result => result);
|
||||
|
||||
await using (var verification = new AppDbContext(options))
|
||||
{
|
||||
Assert.Equal(1, await verification.Users.CountAsync(user => user.IsActive));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(dbPath)) File.Delete(dbPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
apps/api-server.Tests/D3ROVoice.Api.Tests.csproj
Normal file
25
apps/api-server.Tests/D3ROVoice.Api.Tests.csproj
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\api-server\D3ROVoice.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
191
apps/api-server.Tests/SttControllerSecurityTests.cs
Normal file
191
apps/api-server.Tests/SttControllerSecurityTests.cs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using D3ROVoice.Api.Controllers;
|
||||
using D3ROVoice.Api.Dtos;
|
||||
using D3ROVoice.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace D3ROVoice.Api.Tests;
|
||||
|
||||
public sealed class SttControllerSecurityTests
|
||||
{
|
||||
private const string GatewayToken = "fixture-internal-gateway-token-32-bytes-minimum";
|
||||
|
||||
[Fact]
|
||||
public void Controller_RequiresJwtAuthorization()
|
||||
{
|
||||
Assert.NotNull(Attribute.GetCustomAttribute(typeof(SttController), typeof(AuthorizeAttribute)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("42", true)]
|
||||
[InlineData("not-an-integer", true)]
|
||||
public async Task Transcribe_AlwaysRequiresQuotaOwningEdgeGatewayAndNeverCallsProvider(
|
||||
string? userId,
|
||||
bool authenticated)
|
||||
{
|
||||
var service = new RecordingSttService();
|
||||
var controller = CreateController(service, userId, isAuthenticated: authenticated);
|
||||
|
||||
var result = Assert.IsType<ObjectResult>(await controller.Transcribe());
|
||||
|
||||
Assert.Equal(StatusCodes.Status410Gone, result.StatusCode);
|
||||
Assert.Contains("stt_edge_gateway_required", JsonSerializer.Serialize(result.Value), StringComparison.Ordinal);
|
||||
Assert.Equal(0, service.TranscribeCalls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("wrong-token")]
|
||||
public async Task InternalGateway_RejectsMissingOrWrongTokenWithoutProviderCall(string? suppliedToken)
|
||||
{
|
||||
var service = new RecordingSttService();
|
||||
var controller = CreateController(service, null, isAuthenticated: false, suppliedGatewayToken: suppliedToken);
|
||||
|
||||
var result = Assert.IsType<UnauthorizedObjectResult>(await controller.TranscribeFromQuotaGateway());
|
||||
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
|
||||
Assert.Equal(0, service.TranscribeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InternalGateway_FailsClosedWhenServerSecretIsTooShort()
|
||||
{
|
||||
var service = new RecordingSttService();
|
||||
var controller = CreateController(
|
||||
service,
|
||||
null,
|
||||
isAuthenticated: false,
|
||||
configuredGatewayToken: "short",
|
||||
suppliedGatewayToken: "short");
|
||||
|
||||
var result = Assert.IsType<ObjectResult>(await controller.TranscribeFromQuotaGateway());
|
||||
|
||||
Assert.Equal(StatusCodes.Status503ServiceUnavailable, result.StatusCode);
|
||||
Assert.Equal(0, service.TranscribeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InternalGateway_ValidTokenInvokesProviderWithoutLegacyUsageWrite()
|
||||
{
|
||||
var service = new RecordingSttService();
|
||||
var controller = CreateController(
|
||||
service,
|
||||
null,
|
||||
isAuthenticated: false,
|
||||
suppliedGatewayToken: GatewayToken,
|
||||
includeAudioForm: true);
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(await controller.TranscribeFromQuotaGateway());
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, result.StatusCode);
|
||||
Assert.Equal(1, service.TranscribeCalls);
|
||||
Assert.False(service.LastRecordUsage);
|
||||
Assert.Equal(0, service.LastUserId);
|
||||
Assert.Equal("edge-internal", service.LastUserEmail);
|
||||
}
|
||||
|
||||
private static SttController CreateController(
|
||||
RecordingSttService service,
|
||||
string? userId,
|
||||
bool isAuthenticated,
|
||||
string? email = null,
|
||||
string configuredGatewayToken = GatewayToken,
|
||||
string? suppliedGatewayToken = null,
|
||||
bool includeAudioForm = false)
|
||||
{
|
||||
var claims = new List<Claim>();
|
||||
if (userId != null) claims.Add(new Claim(ClaimTypes.NameIdentifier, userId));
|
||||
if (email != null) claims.Add(new Claim(ClaimTypes.Email, email));
|
||||
var identity = new ClaimsIdentity(claims, isAuthenticated ? "test-auth" : null);
|
||||
var context = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(identity)
|
||||
};
|
||||
if (includeAudioForm)
|
||||
{
|
||||
context.Request.ContentType = "multipart/form-data; boundary=fixture";
|
||||
var audioStream = new MemoryStream(new byte[] { 1, 2, 3, 4 });
|
||||
var files = new FormFileCollection
|
||||
{
|
||||
new FormFile(audioStream, 0, audioStream.Length, "file", "fixture.wav")
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = "audio/wav",
|
||||
}
|
||||
};
|
||||
context.Request.Form = new FormCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>(),
|
||||
files);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Request.ContentType = "application/json";
|
||||
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{\"audioBase64\":\"AQID\"}"));
|
||||
}
|
||||
if (suppliedGatewayToken != null)
|
||||
context.Request.Headers["X-D3RO-STT-Gateway-Token"] = suppliedGatewayToken;
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["D3RO_API_TOKEN"] = configuredGatewayToken,
|
||||
})
|
||||
.Build();
|
||||
|
||||
return new SttController(service, configuration)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = context }
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class RecordingSttService : ISttProxyService
|
||||
{
|
||||
public SttProviderUnavailableException? Exception { get; init; }
|
||||
public int TranscribeCalls { get; private set; }
|
||||
public int LastUserId { get; private set; }
|
||||
public string? LastUserEmail { get; private set; }
|
||||
public bool LastRecordUsage { get; private set; } = true;
|
||||
|
||||
public Task<SttTranscribeResponse> TranscribeAsync(
|
||||
int userId,
|
||||
string userEmail,
|
||||
SttTranscribeRequest request,
|
||||
byte[]? audioBytes = null,
|
||||
string? contentType = null,
|
||||
string? fileName = null,
|
||||
bool recordUsage = true)
|
||||
{
|
||||
TranscribeCalls++;
|
||||
LastUserId = userId;
|
||||
LastUserEmail = userEmail;
|
||||
LastRecordUsage = recordUsage;
|
||||
if (Exception != null) throw Exception;
|
||||
|
||||
return Task.FromResult(new SttTranscribeResponse(
|
||||
"real transcript", 0.99, "ko", 1, "test", "test-model", 1, 0));
|
||||
}
|
||||
|
||||
public Task<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<SttProviderEndpointDto>> GetAllEndpointsAsync() =>
|
||||
Task.FromResult(new List<SttProviderEndpointDto>());
|
||||
|
||||
public Task<SttProviderEndpointDto> CreateEndpointAsync(CreateSttEndpointDto dto) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<SttProviderEndpointDto> UpdateEndpointAsync(int id, UpdateSttEndpointDto dto) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<bool> DeleteEndpointAsync(int id) => throw new NotSupportedException();
|
||||
public Task<bool> SetDefaultEndpointAsync(int id) => throw new NotSupportedException();
|
||||
public Task<SttUsageReportDto> GetUsageReportAsync() => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
223
apps/api-server.Tests/SttFailClosedTests.cs
Normal file
223
apps/api-server.Tests/SttFailClosedTests.cs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using D3ROVoice.Api.Data;
|
||||
using D3ROVoice.Api.Dtos;
|
||||
using D3ROVoice.Api.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace D3ROVoice.Api.Tests;
|
||||
|
||||
public sealed class SttFailClosedTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MissingProviderCredentials_FailsUnavailableWithoutUsage()
|
||||
{
|
||||
await using var fixture = await SttFixture.CreateAsync(
|
||||
apiKey: string.Empty,
|
||||
_ => throw new InvalidOperationException("HTTP should not be called"));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<SttProviderUnavailableException>(
|
||||
() => fixture.Service.TranscribeAsync(
|
||||
7,
|
||||
"user@example.test",
|
||||
new SttTranscribeRequest(Language: "ko"),
|
||||
new byte[] { 1, 2, 3 },
|
||||
"audio/webm",
|
||||
"recording.webm"));
|
||||
|
||||
Assert.False(exception.ProviderAttempted);
|
||||
Assert.Equal(0, fixture.Handler.CallCount);
|
||||
Assert.Empty(await fixture.Db.SttUsageLogs.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpstreamFailure_FailsBadGatewayWithoutLeakingOrRecordingUsage()
|
||||
{
|
||||
const string sensitiveBody = "secret-key-value original transcript text";
|
||||
await using var fixture = await SttFixture.CreateAsync(
|
||||
apiKey: "configured-key",
|
||||
_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent(sensitiveBody)
|
||||
});
|
||||
|
||||
var exception = await Assert.ThrowsAsync<SttProviderUnavailableException>(
|
||||
() => fixture.Service.TranscribeAsync(
|
||||
9,
|
||||
"user@example.test",
|
||||
new SttTranscribeRequest(Language: "ko"),
|
||||
new byte[] { 1, 2, 3 },
|
||||
"audio/webm",
|
||||
"recording.webm"));
|
||||
|
||||
Assert.True(exception.ProviderAttempted);
|
||||
Assert.DoesNotContain(sensitiveBody, exception.Message, StringComparison.Ordinal);
|
||||
Assert.Empty(await fixture.Db.SttUsageLogs.ToListAsync());
|
||||
|
||||
var error = Assert.Single(await fixture.Db.ErrorLogs.ToListAsync());
|
||||
Assert.DoesNotContain(sensitiveBody, error.Message, StringComparison.Ordinal);
|
||||
Assert.Null(error.Endpoint);
|
||||
Assert.Null(error.StackTrace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RealProviderSuccess_RecordsUsageAndReturnsTranscript()
|
||||
{
|
||||
await using var fixture = await SttFixture.CreateAsync(
|
||||
apiKey: "configured-key",
|
||||
_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"{\"text\":\"real transcript\",\"duration\":1.25,\"language\":\"ko\"}",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
});
|
||||
|
||||
var result = await fixture.Service.TranscribeAsync(
|
||||
11,
|
||||
"user@example.test",
|
||||
new SttTranscribeRequest(Language: "ko"),
|
||||
new byte[] { 1, 2, 3 },
|
||||
"audio/webm",
|
||||
"recording.webm");
|
||||
|
||||
Assert.Equal("real transcript", result.Text);
|
||||
Assert.Equal("groq", result.Provider);
|
||||
var usage = Assert.Single(await fixture.Db.SttUsageLogs.ToListAsync());
|
||||
Assert.Equal(11, usage.UserId);
|
||||
Assert.Equal(200, usage.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GoogleApiKey_IsSentInHeaderAndNeverInRequestUrl()
|
||||
{
|
||||
const string apiKey = "sensitive-google-key";
|
||||
await using var fixture = await SttFixture.CreateAsync(
|
||||
apiKey,
|
||||
_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"real transcript\"}]}}]}",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
},
|
||||
providerType: "google",
|
||||
endpointUrl: "https://generativelanguage.googleapis.com/v1beta/models/gemini:generateContent",
|
||||
modelId: "gemini-test");
|
||||
|
||||
var result = await fixture.Service.TranscribeAsync(
|
||||
12,
|
||||
"user@example.test",
|
||||
new SttTranscribeRequest(Language: "ko"),
|
||||
new byte[] { 1, 2, 3 },
|
||||
"audio/webm",
|
||||
"recording.webm");
|
||||
|
||||
Assert.Equal("real transcript", result.Text);
|
||||
Assert.Equal(apiKey, fixture.Handler.LastGoogleApiKey);
|
||||
Assert.DoesNotContain(apiKey, fixture.Handler.LastRequestUri, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class SttFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
private SttFixture(
|
||||
SqliteConnection connection,
|
||||
AppDbContext db,
|
||||
RecordingHandler handler,
|
||||
SttProxyService service)
|
||||
{
|
||||
_connection = connection;
|
||||
Db = db;
|
||||
Handler = handler;
|
||||
Service = service;
|
||||
}
|
||||
|
||||
public AppDbContext Db { get; }
|
||||
public RecordingHandler Handler { get; }
|
||||
public SttProxyService Service { get; }
|
||||
|
||||
public static async Task<SttFixture> CreateAsync(
|
||||
string apiKey,
|
||||
Func<HttpRequestMessage, HttpResponseMessage> responseFactory,
|
||||
string providerType = "groq",
|
||||
string endpointUrl = "https://provider.example.test/transcriptions",
|
||||
string modelId = "whisper-test")
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
db.SttProviderEndpoints.Add(new SttProviderEndpoint
|
||||
{
|
||||
Name = "Test provider",
|
||||
ProviderType = providerType,
|
||||
EndpointUrl = endpointUrl,
|
||||
ApiKey = apiKey,
|
||||
ModelId = modelId,
|
||||
Method = "multipart",
|
||||
Language = "ko",
|
||||
IsDefault = true,
|
||||
IsActive = true,
|
||||
FallbackPriority = 1
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = new RecordingHandler(responseFactory);
|
||||
var factory = new StubHttpClientFactory(handler);
|
||||
var service = new SttProxyService(db, factory, NullLogger<SttProxyService>.Instance);
|
||||
return new SttFixture(connection, db, handler, service);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Db.DisposeAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly RecordingHandler _handler;
|
||||
|
||||
public StubHttpClientFactory(RecordingHandler handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
public sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
|
||||
|
||||
public RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
_responseFactory = responseFactory;
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
public string LastRequestUri { get; private set; } = string.Empty;
|
||||
public string? LastGoogleApiKey { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
LastRequestUri = request.RequestUri?.ToString() ?? string.Empty;
|
||||
LastGoogleApiKey = request.Headers.TryGetValues("X-Goog-Api-Key", out var values)
|
||||
? values.SingleOrDefault()
|
||||
: null;
|
||||
return Task.FromResult(_responseFactory(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
100
apps/api-server.Tests/SttGatewayAuthorizationE2ETests.cs
Normal file
100
apps/api-server.Tests/SttGatewayAuthorizationE2ETests.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Xunit;
|
||||
|
||||
namespace D3ROVoice.Api.Tests;
|
||||
|
||||
[Collection("Api server integration")]
|
||||
public sealed class SttGatewayAuthorizationE2ETests : IClassFixture<AdminAuthorizationE2ETests.ApiFactory>
|
||||
{
|
||||
private const string Secret = "admin-e2e-jwt-secret-0123456789-abcdef";
|
||||
private const string Issuer = "https://admin-e2e.test";
|
||||
private const string Audience = "d3ro-admin-e2e";
|
||||
private const string GatewayToken = "fixture-internal-gateway-token-32-bytes-minimum";
|
||||
private readonly AdminAuthorizationE2ETests.ApiFactory _factory;
|
||||
|
||||
public SttGatewayAuthorizationE2ETests(AdminAuthorizationE2ETests.ApiFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserEndpointRequiresJwtThenStillRefusesQuotaBypass()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var anonymousBody = JsonBody();
|
||||
Assert.Equal(HttpStatusCode.Unauthorized,
|
||||
(await client.PostAsync("/api/stt/transcribe", anonymousBody)).StatusCode);
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", UserToken());
|
||||
using var authenticatedBody = JsonBody();
|
||||
var response = await client.PostAsync("/api/stt/transcribe", authenticatedBody);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Gone, response.StatusCode);
|
||||
Assert.Contains("stt_edge_gateway_required", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("one-character-off")]
|
||||
public async Task InternalEndpointRejectsMissingOrWrongGatewaySecret(string? suppliedToken)
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
if (suppliedToken != null)
|
||||
client.DefaultRequestHeaders.Add("X-D3RO-STT-Gateway-Token", suppliedToken);
|
||||
using var body = AudioBody();
|
||||
|
||||
var response = await client.PostAsync("/api/stt/internal/transcribe", body);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Contains("stt_gateway_unauthorized", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InternalEndpointAcceptsExactSecretButFailsClosedWithoutProvider()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-D3RO-STT-Gateway-Token", GatewayToken);
|
||||
using var body = AudioBody();
|
||||
|
||||
var response = await client.PostAsync("/api/stt/internal/transcribe", body);
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Contains("stt_provider_unavailable", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static StringContent JsonBody() =>
|
||||
new("{\"audioBase64\":\"AQID\"}", Encoding.UTF8, "application/json");
|
||||
|
||||
private static MultipartFormDataContent AudioBody()
|
||||
{
|
||||
var body = new MultipartFormDataContent();
|
||||
var audio = new ByteArrayContent([1, 2, 3, 4]);
|
||||
audio.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
|
||||
body.Add(audio, "file", "fixture.wav");
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string UserToken()
|
||||
{
|
||||
var credentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims:
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, "1"),
|
||||
new Claim(ClaimTypes.Email, "user@example.com"),
|
||||
new Claim(ClaimTypes.Role, "user")
|
||||
],
|
||||
expires: DateTime.UtcNow.AddMinutes(5),
|
||||
signingCredentials: credentials);
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Net.Mail;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using D3ROVoice.Api.Dtos;
|
||||
using D3ROVoice.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace D3ROVoice.Api.Controllers;
|
||||
|
||||
|
|
@ -13,18 +17,45 @@ namespace D3ROVoice.Api.Controllers;
|
|||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _authService;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public AuthController(IAuthService authService)
|
||||
public AuthController(IAuthService authService, IConfiguration configuration)
|
||||
{
|
||||
_authService = authService;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterDto dto)
|
||||
[EnableRateLimiting("auth")]
|
||||
[RequestSizeLimit(16 * 1024)]
|
||||
public async Task<IActionResult> Register(
|
||||
[FromBody] RegisterDto dto,
|
||||
[FromHeader(Name = "X-D3RO-Bootstrap-Token")] string? bootstrapToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Email) || string.IsNullOrWhiteSpace(dto.Password))
|
||||
var normalizedEmail = dto.Email?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
if (
|
||||
normalizedEmail.Length is < 3 or > 150
|
||||
|| !MailAddress.TryCreate(normalizedEmail, out var parsedEmail)
|
||||
|| !string.Equals(parsedEmail.Address, normalizedEmail, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.IsNullOrWhiteSpace(dto.Password)
|
||||
)
|
||||
{
|
||||
return BadRequest(new { message = "이메일과 비밀번호를 입력해주세요." });
|
||||
return BadRequest(new { message = "유효한 이메일과 비밀번호를 입력해주세요." });
|
||||
}
|
||||
|
||||
var configuredToken = _configuration["ADMIN_BOOTSTRAP_TOKEN"];
|
||||
if (
|
||||
string.IsNullOrWhiteSpace(configuredToken)
|
||||
|| Encoding.UTF8.GetByteCount(configuredToken) < 32
|
||||
)
|
||||
{
|
||||
return StatusCode(503, new { message = "관리자 초기 등록이 비활성화되어 있습니다." });
|
||||
}
|
||||
var configuredDigest = SHA256.HashData(Encoding.UTF8.GetBytes(configuredToken));
|
||||
var providedDigest = SHA256.HashData(Encoding.UTF8.GetBytes(bootstrapToken ?? string.Empty));
|
||||
if (!CryptographicOperations.FixedTimeEquals(configuredDigest, providedDigest))
|
||||
{
|
||||
return Unauthorized(new { message = "관리자 초기 등록 토큰이 올바르지 않습니다." });
|
||||
}
|
||||
|
||||
try
|
||||
|
|
@ -39,9 +70,17 @@ public class AuthController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[EnableRateLimiting("auth")]
|
||||
[RequestSizeLimit(16 * 1024)]
|
||||
public async Task<IActionResult> Login([FromBody] LoginDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Email) || string.IsNullOrWhiteSpace(dto.Password))
|
||||
var normalizedEmail = dto.Email?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
if (
|
||||
normalizedEmail.Length is < 3 or > 150
|
||||
|| !MailAddress.TryCreate(normalizedEmail, out _)
|
||||
|| string.IsNullOrWhiteSpace(dto.Password)
|
||||
|| dto.Password.Length > 256
|
||||
)
|
||||
{
|
||||
return BadRequest(new { message = "이메일과 비밀번호를 입력해주세요." });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,99 +1,120 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using D3ROVoice.Api.Data;
|
||||
using D3ROVoice.Api.Dtos;
|
||||
using D3ROVoice.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace D3ROVoice.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/[controller]")]
|
||||
public class SttController : ControllerBase
|
||||
{
|
||||
private const string InternalGatewayHeader = "X-D3RO-STT-Gateway-Token";
|
||||
private readonly ISttProxyService _sttService;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public SttController(ISttProxyService sttService)
|
||||
public SttController(ISttProxyService sttService, IConfiguration configuration)
|
||||
{
|
||||
_sttService = sttService;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
[HttpPost("transcribe")]
|
||||
[Consumes("application/json", "multipart/form-data")]
|
||||
public async Task<IActionResult> Transcribe()
|
||||
public Task<IActionResult> Transcribe()
|
||||
{
|
||||
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var userEmail = User.FindFirstValue(ClaimTypes.Email) ?? "user@d3ro.voice";
|
||||
int userId = int.TryParse(userIdStr, out var id) ? id : 1;
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var file = form.Files.GetFile("file") ?? form.Files.GetFile("audio");
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { message = "전송할 오디오 파일(file 또는 audio)이 필요합니다." });
|
||||
// User-facing transcription is exclusively handled by the Supabase
|
||||
// stt-proxy, which owns authenticated identity, quota reservation and
|
||||
// usage persistence. This legacy provider path must not bypass it.
|
||||
return Task.FromResult<IActionResult>(StatusCode(
|
||||
StatusCodes.Status410Gone,
|
||||
new { error = "stt_edge_gateway_required" }));
|
||||
}
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
await file.CopyToAsync(memoryStream);
|
||||
var audioBytes = memoryStream.ToArray();
|
||||
[HttpPost("internal/transcribe")]
|
||||
[AllowAnonymous]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestSizeLimit(26 * 1024 * 1024)]
|
||||
public async Task<IActionResult> TranscribeFromQuotaGateway()
|
||||
{
|
||||
var configuredToken = _configuration["D3RO_API_TOKEN"]?.Trim() ?? string.Empty;
|
||||
if (Encoding.UTF8.GetByteCount(configuredToken) < 32)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "stt_gateway_not_configured" });
|
||||
}
|
||||
|
||||
var suppliedToken = Request.Headers[InternalGatewayHeader].ToString();
|
||||
if (!FixedTimeTokenEquals(configuredToken, suppliedToken))
|
||||
{
|
||||
return Unauthorized(new { error = "stt_gateway_unauthorized" });
|
||||
}
|
||||
|
||||
if (!Request.HasFormContentType)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status415UnsupportedMediaType, new { error = "unsupported_media_type" });
|
||||
}
|
||||
|
||||
var form = await Request.ReadFormAsync();
|
||||
var file = form.Files.GetFile("file") ?? form.Files.GetFile("audio");
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { error = "missing_audio" });
|
||||
}
|
||||
|
||||
await using var memoryStream = new MemoryStream();
|
||||
await file.CopyToAsync(memoryStream);
|
||||
var language = form["language"].ToString();
|
||||
var prompt = form["prompt"].ToString();
|
||||
var model = form["model"].ToString();
|
||||
var provider = form["provider"].ToString();
|
||||
|
||||
var request = new SttTranscribeRequest(
|
||||
AudioBase64: null,
|
||||
Language: string.IsNullOrWhiteSpace(language) ? "ko" : language,
|
||||
InitialPrompt: string.IsNullOrWhiteSpace(prompt) ? null : prompt,
|
||||
ModelId: string.IsNullOrWhiteSpace(model) ? null : model,
|
||||
Provider: string.IsNullOrWhiteSpace(provider) ? null : provider
|
||||
);
|
||||
ModelId: null,
|
||||
Provider: null);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _sttService.TranscribeAsync(
|
||||
userId,
|
||||
userEmail,
|
||||
userId: 0,
|
||||
userEmail: "edge-internal",
|
||||
request,
|
||||
audioBytes,
|
||||
file.ContentType ?? "audio/webm",
|
||||
file.FileName ?? "recording.webm"
|
||||
);
|
||||
|
||||
memoryStream.ToArray(),
|
||||
file.ContentType ?? "application/octet-stream",
|
||||
file.FileName ?? "recording.bin",
|
||||
recordUsage: false);
|
||||
return Ok(result);
|
||||
}
|
||||
else
|
||||
catch (SttProviderUnavailableException)
|
||||
{
|
||||
// Read JSON body
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var json = await reader.ReadToEndAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "stt_provider_unavailable" });
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return BadRequest(new { message = "요청 본문이 비어있습니다." });
|
||||
return BadRequest(new { error = "invalid_audio" });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "stt_upstream_failed" });
|
||||
}
|
||||
}
|
||||
|
||||
var request = System.Text.Json.JsonSerializer.Deserialize<SttTranscribeRequest>(
|
||||
json,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }
|
||||
);
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.AudioBase64))
|
||||
private static bool FixedTimeTokenEquals(string configured, string supplied)
|
||||
{
|
||||
return BadRequest(new { message = "AudioBase64 데이터가 필요합니다." });
|
||||
}
|
||||
|
||||
var result = await _sttService.TranscribeAsync(userId, userEmail, request);
|
||||
return Ok(result);
|
||||
}
|
||||
var configuredDigest = SHA256.HashData(Encoding.UTF8.GetBytes(configured));
|
||||
var suppliedDigest = SHA256.HashData(Encoding.UTF8.GetBytes(supplied));
|
||||
return CryptographicOperations.FixedTimeEquals(configuredDigest, suppliedDigest);
|
||||
}
|
||||
|
||||
[HttpGet("providers")]
|
||||
[Authorize(Policy = "ManagerOrAbove")]
|
||||
public async Task<IActionResult> GetActiveProviders()
|
||||
{
|
||||
var endpoints = await _sttService.GetAllEndpointsAsync();
|
||||
|
|
@ -101,6 +122,7 @@ public class SttController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpPost("test")]
|
||||
[Authorize(Policy = "ManagerOrAbove")]
|
||||
public async Task<IActionResult> TestConnection([FromQuery] int endpointId = 0)
|
||||
{
|
||||
var result = await _sttService.TestEndpointAsync(endpointId);
|
||||
|
|
|
|||
|
|
@ -2,14 +2,24 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>1.1.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Retained legacy binaries are not static assets and must never publish. -->
|
||||
<Content Remove="wwwroot\releases\**\*" />
|
||||
<Content Remove="wwwroot\index.html" />
|
||||
<Content Remove="wwwroot\assets\index-D7M5UQvT.js" />
|
||||
<Content Remove="wwwroot\assets\index-JlYFxlAJ.js" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.13" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.22.0" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -203,6 +203,58 @@ public class SttUsageLog
|
|||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class AdminOperationRequest
|
||||
{
|
||||
[Key]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required, MaxLength(150)]
|
||||
public string ActorEmail { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(36)]
|
||||
public string IdempotencyKey { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string Operation { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(64)]
|
||||
public string RequestHash { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string ResponseJson { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class AdminAuditEntry
|
||||
{
|
||||
[Key]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required, MaxLength(150)]
|
||||
public string ActorEmail { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(80)]
|
||||
public string TargetType { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string TargetId { get; set; } = string.Empty;
|
||||
|
||||
public string? BeforeJson { get; set; }
|
||||
public string? AfterJson { get; set; }
|
||||
|
||||
[Required, MaxLength(1000)]
|
||||
public string Memo { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(36)]
|
||||
public string IdempotencyKey { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
|
@ -213,6 +265,8 @@ public class AppDbContext : DbContext
|
|||
public DbSet<ServerErrorLog> ErrorLogs => Set<ServerErrorLog>();
|
||||
public DbSet<SttProviderEndpoint> SttProviderEndpoints => Set<SttProviderEndpoint>();
|
||||
public DbSet<SttUsageLog> SttUsageLogs => Set<SttUsageLog>();
|
||||
public DbSet<AdminOperationRequest> AdminOperationRequests => Set<AdminOperationRequest>();
|
||||
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
|
@ -231,5 +285,12 @@ public class AppDbContext : DbContext
|
|||
|
||||
modelBuilder.Entity<SttProviderEndpoint>()
|
||||
.HasIndex(s => s.IsDefault);
|
||||
|
||||
modelBuilder.Entity<AdminOperationRequest>()
|
||||
.HasIndex(operation => new { operation.ActorEmail, operation.IdempotencyKey })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<AdminAuditEntry>()
|
||||
.HasIndex(entry => entry.CreatedAt);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Multi-stage Docker build for D3RO Voice C# .NET API Backend & BackOffice
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0.302-noble AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY D3ROVoice.Api.csproj ./
|
||||
|
|
@ -8,7 +8,7 @@ RUN dotnet restore
|
|||
COPY . ./
|
||||
RUN dotnet publish -c Release -o /app/out
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0.10-noble AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Create persistent storage directory for SQLite database
|
||||
|
|
@ -24,4 +24,3 @@ ENV DATA_DIR=/app/data
|
|||
VOLUME ["/app/data"]
|
||||
|
||||
ENTRYPOINT ["dotnet", "D3ROVoice.Api.dll"]
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ using System.Threading.RateLimiting;
|
|||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var serverStartTime = DateTime.UtcNow;
|
||||
var releaseVersion = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString(3)
|
||||
?? "unknown";
|
||||
|
||||
// Add Services to Container
|
||||
builder.Services.AddControllers();
|
||||
|
|
@ -34,7 +36,7 @@ builder.Services.AddSwaggerGen(c =>
|
|||
c.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Title = "D3RO Voice Cloud API & BackOffice",
|
||||
Version = "v1",
|
||||
Version = releaseVersion,
|
||||
Description = "D3RO Voice Self-Hosted Cloud Backend for NAS & Docker"
|
||||
});
|
||||
});
|
||||
|
|
@ -460,7 +462,7 @@ app.MapGet("/health", () => Results.Ok(new
|
|||
{
|
||||
status = "Healthy",
|
||||
service = "D3RO Voice Cloud API",
|
||||
version = "1.0.0",
|
||||
version = releaseVersion,
|
||||
uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds,
|
||||
database = File.Exists(dbPath) ? "Connected" : "Initializing",
|
||||
timestamp = DateTime.UtcNow
|
||||
|
|
@ -470,7 +472,7 @@ app.MapGet("/api/health", () => Results.Ok(new
|
|||
{
|
||||
status = "Healthy",
|
||||
service = "D3RO Voice Cloud API",
|
||||
version = "1.0.0",
|
||||
version = releaseVersion,
|
||||
uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds,
|
||||
database = File.Exists(dbPath) ? "Connected" : "Initializing",
|
||||
timestamp = DateTime.UtcNow
|
||||
|
|
|
|||
102
apps/api-server/Services/AdminOperationService.cs
Normal file
102
apps/api-server/Services/AdminOperationService.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using D3ROVoice.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace D3ROVoice.Api.Services;
|
||||
|
||||
public sealed class AdminOperationException : Exception
|
||||
{
|
||||
public AdminOperationException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public interface IAdminOperationService
|
||||
{
|
||||
Task<JsonElement> ExecuteAsync(
|
||||
string actorEmail,
|
||||
string operation,
|
||||
string idempotencyKey,
|
||||
object request,
|
||||
string targetType,
|
||||
Func<object, string> targetId,
|
||||
string memo,
|
||||
Func<Task<object?>> readBefore,
|
||||
Func<Task<object>> mutate);
|
||||
}
|
||||
|
||||
public sealed class AdminOperationService : IAdminOperationService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdminOperationService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<JsonElement> ExecuteAsync(
|
||||
string actorEmail,
|
||||
string operation,
|
||||
string idempotencyKey,
|
||||
object request,
|
||||
string targetType,
|
||||
Func<object, string> targetId,
|
||||
string memo,
|
||||
Func<Task<object?>> readBefore,
|
||||
Func<Task<object>> mutate)
|
||||
{
|
||||
actorEmail = actorEmail.Trim().ToLowerInvariant();
|
||||
memo = memo.Trim();
|
||||
if (actorEmail.Length is < 3 or > 150) throw new AdminOperationException("invalid_actor");
|
||||
if (!Guid.TryParseExact(idempotencyKey, "D", out _)) throw new AdminOperationException("invalid_idempotency_key");
|
||||
if (memo.Length is < 3 or > 1000) throw new AdminOperationException("invalid_audit_memo");
|
||||
|
||||
var requestJson = JsonSerializer.Serialize(request);
|
||||
var requestHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{operation}:{requestJson}")));
|
||||
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
|
||||
var existing = await _db.AdminOperationRequests.AsNoTracking().SingleOrDefaultAsync(entry =>
|
||||
entry.ActorEmail == actorEmail && entry.IdempotencyKey == idempotencyKey);
|
||||
if (existing != null)
|
||||
{
|
||||
if (existing.Operation != operation || existing.RequestHash != requestHash)
|
||||
throw new AdminOperationException("idempotency_key_reused_with_different_request");
|
||||
using var replay = JsonDocument.Parse(existing.ResponseJson);
|
||||
return replay.RootElement.Clone();
|
||||
}
|
||||
|
||||
var before = await readBefore();
|
||||
var result = await mutate();
|
||||
var responseJson = JsonSerializer.Serialize(result);
|
||||
var resolvedTargetId = targetId(result);
|
||||
if (string.IsNullOrWhiteSpace(resolvedTargetId) || resolvedTargetId.Length > 200)
|
||||
throw new AdminOperationException("invalid_audit_target");
|
||||
|
||||
_db.AdminOperationRequests.Add(new AdminOperationRequest
|
||||
{
|
||||
ActorEmail = actorEmail,
|
||||
IdempotencyKey = idempotencyKey,
|
||||
Operation = operation,
|
||||
RequestHash = requestHash,
|
||||
ResponseJson = responseJson,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
_db.AdminAuditEntries.Add(new AdminAuditEntry
|
||||
{
|
||||
ActorEmail = actorEmail,
|
||||
Action = operation,
|
||||
TargetType = targetType,
|
||||
TargetId = resolvedTargetId,
|
||||
BeforeJson = before == null ? null : JsonSerializer.Serialize(before),
|
||||
AfterJson = responseJson,
|
||||
Memo = memo,
|
||||
IdempotencyKey = idempotencyKey,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
using var response = JsonDocument.Parse(responseJson);
|
||||
return response.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
using System;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using D3ROVoice.Api.Data;
|
||||
using D3ROVoice.Api.Dtos;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace D3ROVoice.Api.Services;
|
||||
|
|
@ -21,43 +21,81 @@ public interface IAuthService
|
|||
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private static readonly SemaphoreSlim BootstrapLock = new(1, 1);
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly PasswordHasher<User> _passwordHasher = new();
|
||||
private readonly string _dummyPasswordHash;
|
||||
|
||||
public AuthService(AppDbContext db, IConfiguration config)
|
||||
{
|
||||
_db = db;
|
||||
_config = config;
|
||||
_dummyPasswordHash = _passwordHasher.HashPassword(new User(), "dummy-password-never-used");
|
||||
}
|
||||
|
||||
public async Task<AuthResponseDto> RegisterAsync(RegisterDto dto)
|
||||
{
|
||||
var existing = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == dto.Email.ToLower());
|
||||
if (dto.Password.Length < 12 || dto.Password.Length > 256)
|
||||
{
|
||||
throw new InvalidOperationException("비밀번호는 12자 이상 256자 이하여야 합니다.");
|
||||
}
|
||||
|
||||
await BootstrapLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var normalizedEmail = dto.Email.Trim().ToLowerInvariant();
|
||||
var existing = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail);
|
||||
if (existing != null)
|
||||
{
|
||||
throw new InvalidOperationException("이미 등록된 이메일 주소입니다.");
|
||||
}
|
||||
|
||||
var isFirstUser = !await _db.Users.AnyAsync();
|
||||
if (await _db.Users.AnyAsync(u => u.IsActive))
|
||||
{
|
||||
throw new InvalidOperationException("관리자 초기 등록이 이미 완료되었습니다.");
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Email = dto.Email.Trim().ToLower(),
|
||||
PasswordHash = HashPassword(dto.Password),
|
||||
Role = isFirstUser ? "Admin" : "User",
|
||||
Email = normalizedEmail,
|
||||
Role = "SuperAdmin",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
|
||||
|
||||
_db.Users.Add(user);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return GenerateToken(user);
|
||||
}
|
||||
finally
|
||||
{
|
||||
BootstrapLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AuthResponseDto> LoginAsync(LoginDto dto)
|
||||
{
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == dto.Email.ToLower());
|
||||
if (user == null || !VerifyPassword(dto.Password, user.PasswordHash))
|
||||
var normalizedEmail = dto.Email.Trim().ToLowerInvariant();
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail);
|
||||
if (user == null)
|
||||
{
|
||||
_passwordHasher.VerifyHashedPassword(new User(), _dummyPasswordHash, dto.Password);
|
||||
throw new UnauthorizedAccessException("이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
PasswordVerificationResult verification;
|
||||
try
|
||||
{
|
||||
verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, dto.Password);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
verification = PasswordVerificationResult.Failed;
|
||||
}
|
||||
if (verification == PasswordVerificationResult.Failed)
|
||||
{
|
||||
throw new UnauthorizedAccessException("이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||
}
|
||||
|
|
@ -68,6 +106,10 @@ public class AuthService : IAuthService
|
|||
}
|
||||
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
|
||||
{
|
||||
user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return GenerateToken(user);
|
||||
|
|
@ -82,7 +124,21 @@ public class AuthService : IAuthService
|
|||
|
||||
private AuthResponseDto GenerateToken(User user)
|
||||
{
|
||||
var secretKey = _config["Jwt:SecretKey"] ?? "D3ROVoice_Super_Secure_Secret_Key_2026_Key!";
|
||||
var secretKey = _config["JWT_SECRET"];
|
||||
if (string.IsNullOrWhiteSpace(secretKey) || Encoding.UTF8.GetByteCount(secretKey) < 32)
|
||||
{
|
||||
throw new InvalidOperationException("JWT_SECRET must contain at least 32 non-whitespace bytes.");
|
||||
}
|
||||
var issuer = _config["JWT_ISSUER"];
|
||||
if (string.IsNullOrWhiteSpace(issuer))
|
||||
{
|
||||
throw new InvalidOperationException("JWT_ISSUER is required.");
|
||||
}
|
||||
var audience = _config["JWT_AUDIENCE"];
|
||||
if (string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
throw new InvalidOperationException("JWT_AUDIENCE is required.");
|
||||
}
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
|
|
@ -93,11 +149,11 @@ public class AuthService : IAuthService
|
|||
new Claim(ClaimTypes.Role, user.Role)
|
||||
};
|
||||
|
||||
var expiresAt = DateTime.UtcNow.AddDays(30);
|
||||
var expiresAt = DateTime.UtcNow.AddHours(8);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _config["Jwt:Issuer"] ?? "D3ROVoiceApi",
|
||||
audience: _config["Jwt:Audience"] ?? "D3ROVoiceClient",
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
claims: claims,
|
||||
expires: expiresAt,
|
||||
signingCredentials: creds
|
||||
|
|
@ -107,15 +163,4 @@ public class AuthService : IAuthService
|
|||
return new AuthResponseDto(tokenHandler.WriteToken(token), user.Email, user.Role, expiresAt);
|
||||
}
|
||||
|
||||
public static string HashPassword(string password)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(password + "D3RO_SALT_2026"));
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private static bool VerifyPassword(string password, string hash)
|
||||
{
|
||||
return HashPassword(password) == hash;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ public interface ISttProxyService
|
|||
SttTranscribeRequest request,
|
||||
byte[]? audioBytes = null,
|
||||
string? contentType = null,
|
||||
string? fileName = null
|
||||
string? fileName = null,
|
||||
bool recordUsage = true
|
||||
);
|
||||
|
||||
Task<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null);
|
||||
|
|
@ -35,6 +36,17 @@ public interface ISttProxyService
|
|||
Task<SttUsageReportDto> GetUsageReportAsync();
|
||||
}
|
||||
|
||||
public sealed class SttProviderUnavailableException : Exception
|
||||
{
|
||||
public bool ProviderAttempted { get; }
|
||||
|
||||
public SttProviderUnavailableException(bool providerAttempted)
|
||||
: base("STT service is temporarily unavailable.")
|
||||
{
|
||||
ProviderAttempted = providerAttempted;
|
||||
}
|
||||
}
|
||||
|
||||
public class SttProxyService : ISttProxyService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
|
@ -54,7 +66,8 @@ public class SttProxyService : ISttProxyService
|
|||
SttTranscribeRequest request,
|
||||
byte[]? audioBytes = null,
|
||||
string? contentType = null,
|
||||
string? fileName = null
|
||||
string? fileName = null,
|
||||
bool recordUsage = true
|
||||
)
|
||||
{
|
||||
// 1. Resolve Audio Bytes
|
||||
|
|
@ -94,23 +107,12 @@ public class SttProxyService : ISttProxyService
|
|||
var candidates = await GetCandidateEndpointsAsync(request.Provider, request.ModelId);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Seed a dynamic fallback endpoint in memory
|
||||
candidates.Add(new SttProviderEndpoint
|
||||
{
|
||||
Id = 0,
|
||||
Name = "Default Groq Whisper Fallback",
|
||||
ProviderType = "groq",
|
||||
EndpointUrl = "https://api.groq.com/openai/v1/audio/transcriptions",
|
||||
ModelId = "whisper-large-v3-turbo",
|
||||
Method = "multipart",
|
||||
Language = request.Language ?? "ko",
|
||||
CostPerMinute = 0.000500m,
|
||||
IsActive = true
|
||||
});
|
||||
throw new SttProviderUnavailableException(providerAttempted: false);
|
||||
}
|
||||
|
||||
var totalSw = Stopwatch.StartNew();
|
||||
Exception? lastException = null;
|
||||
var providerAttempted = false;
|
||||
|
||||
// 3. Failover Execution Chain
|
||||
foreach (var endpoint in candidates)
|
||||
|
|
@ -118,6 +120,14 @@ public class SttProxyService : ISttProxyService
|
|||
var epSw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
if (!HasRequiredProviderConfiguration(endpoint))
|
||||
{
|
||||
_logger.LogWarning("Skipping STT provider {Provider}: required credentials are not configured",
|
||||
endpoint.ProviderType);
|
||||
continue;
|
||||
}
|
||||
|
||||
providerAttempted = true;
|
||||
_logger.LogInformation("Attempting STT transcription via provider {Provider} ({Name}, Model: {Model})",
|
||||
endpoint.ProviderType, endpoint.Name, endpoint.ModelId);
|
||||
|
||||
|
|
@ -136,7 +146,10 @@ public class SttProxyService : ISttProxyService
|
|||
var durationMinutes = (decimal)(finalDuration / 60.0);
|
||||
var cost = Math.Max(0.000001m, durationMinutes * endpoint.CostPerMinute);
|
||||
|
||||
// Record Usage Log
|
||||
// Supabase stt-proxy owns user quota and usage for internal
|
||||
// provider calls. Direct legacy callers may still record here.
|
||||
if (recordUsage)
|
||||
{
|
||||
try
|
||||
{
|
||||
var usageLog = new SttUsageLog
|
||||
|
|
@ -160,6 +173,7 @@ public class SttProxyService : ISttProxyService
|
|||
{
|
||||
_logger.LogWarning(dbEx, "Failed to save STT usage log to database");
|
||||
}
|
||||
}
|
||||
|
||||
return new SttTranscribeResponse(
|
||||
Text: transcript,
|
||||
|
|
@ -176,18 +190,19 @@ public class SttProxyService : ISttProxyService
|
|||
{
|
||||
epSw.Stop();
|
||||
lastException = ex;
|
||||
_logger.LogWarning(ex, "Provider {Provider} ({Name}) transcription failed after {Ms}ms. Trying fallback...",
|
||||
endpoint.ProviderType, endpoint.Name, epSw.ElapsedMilliseconds);
|
||||
var failureType = GetSafeFailureType(ex);
|
||||
_logger.LogWarning("Provider {Provider} transcription failed after {Ms}ms ({FailureType}). Trying fallback...",
|
||||
endpoint.ProviderType, epSw.ElapsedMilliseconds, failureType);
|
||||
|
||||
// Record error to ErrorLogs
|
||||
try
|
||||
{
|
||||
_db.ErrorLogs.Add(new ServerErrorLog
|
||||
{
|
||||
ErrorType = $"SttProviderError:{endpoint.ProviderType}",
|
||||
Message = $"STT failed on endpoint {endpoint.Name} ({endpoint.EndpointUrl}): {ex.Message}",
|
||||
StackTrace = ex.StackTrace,
|
||||
Endpoint = endpoint.EndpointUrl,
|
||||
ErrorType = "SttProviderError",
|
||||
Message = $"STT provider request failed ({failureType}).",
|
||||
StackTrace = null,
|
||||
Endpoint = null,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
|
@ -200,19 +215,9 @@ public class SttProxyService : ISttProxyService
|
|||
}
|
||||
|
||||
totalSw.Stop();
|
||||
_logger.LogError(lastException, "All STT candidate endpoints failed. Total duration: {Ms}ms", totalSw.ElapsedMilliseconds);
|
||||
|
||||
// Standalone graceful mock echo response if no cloud API keys configured or network is isolated
|
||||
return new SttTranscribeResponse(
|
||||
Text: $"[D3RO Cloud STT — Voice Transcribed] 음성 전사 완료 ({effectiveFileName}, {Math.Round(durationSeconds, 1)}초)",
|
||||
Confidence: 0.95,
|
||||
Language: request.Language ?? "ko",
|
||||
DurationSeconds: Math.Round(durationSeconds, 2),
|
||||
Provider: "fallback-local",
|
||||
ModelId: "whisper-local",
|
||||
LatencyMs: totalSw.ElapsedMilliseconds,
|
||||
Cost: 0m
|
||||
);
|
||||
_logger.LogError("All STT candidate endpoints failed. Total duration: {Ms}ms; last failure type: {FailureType}",
|
||||
totalSw.ElapsedMilliseconds, GetSafeFailureType(lastException));
|
||||
throw new SttProviderUnavailableException(providerAttempted);
|
||||
}
|
||||
|
||||
private async Task<(string Transcript, double Confidence, string? Language, double Duration)> ExecuteProviderTranscriptionAsync(
|
||||
|
|
@ -229,15 +234,9 @@ public class SttProxyService : ISttProxyService
|
|||
var providerType = endpoint.ProviderType.ToLowerInvariant();
|
||||
var apiKey = endpoint.ApiKey?.Trim() ?? "";
|
||||
|
||||
// If no API key configured or local mock
|
||||
if (string.IsNullOrWhiteSpace(apiKey) && providerType != "local-sidecar" && providerType != "custom")
|
||||
if (RequiresApiKey(providerType) && string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return (
|
||||
$"[D3RO Online Cloud STT - {endpoint.Name}] 음성 인식이 성공적으로 처리되었습니다.",
|
||||
0.99,
|
||||
request.Language ?? endpoint.Language ?? "ko",
|
||||
EstimateAudioDuration(audioBytes, contentType)
|
||||
);
|
||||
throw new InvalidOperationException("STT provider credentials are not configured.");
|
||||
}
|
||||
|
||||
switch (providerType)
|
||||
|
|
@ -325,17 +324,17 @@ public class SttProxyService : ISttProxyService
|
|||
|
||||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"STT Upstream {endpoint.ProviderType} returned HTTP {httpResponse.StatusCode}: {responseBody}");
|
||||
throw new HttpRequestException($"STT upstream returned HTTP {httpResponse.StatusCode}.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var text = "";
|
||||
if (root.TryGetProperty("text", out var textProp))
|
||||
if (!root.TryGetProperty("text", out var textProp) || textProp.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
text = textProp.GetString() ?? "";
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
var text = textProp.GetString() ?? "";
|
||||
|
||||
var duration = 0.0;
|
||||
if (root.TryGetProperty("duration", out var durProp))
|
||||
|
|
@ -385,28 +384,29 @@ public class SttProxyService : ISttProxyService
|
|||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Deepgram returned HTTP {response.StatusCode}: {responseBody}");
|
||||
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var transcript = "";
|
||||
string transcript;
|
||||
var confidence = 0.95;
|
||||
var duration = 0.0;
|
||||
|
||||
if (root.TryGetProperty("results", out var results) &&
|
||||
results.TryGetProperty("channels", out var channels) &&
|
||||
channels.GetArrayLength() > 0)
|
||||
if (!root.TryGetProperty("results", out var results) ||
|
||||
!results.TryGetProperty("channels", out var channels) ||
|
||||
channels.GetArrayLength() == 0 ||
|
||||
!channels[0].TryGetProperty("alternatives", out var alternatives) ||
|
||||
alternatives.GetArrayLength() == 0 ||
|
||||
!alternatives[0].TryGetProperty("transcript", out var transcriptProperty) ||
|
||||
transcriptProperty.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
var ch0 = channels[0];
|
||||
if (ch0.TryGetProperty("alternatives", out var alts) && alts.GetArrayLength() > 0)
|
||||
{
|
||||
var alt0 = alts[0];
|
||||
if (alt0.TryGetProperty("transcript", out var tProp)) transcript = tProp.GetString() ?? "";
|
||||
if (alt0.TryGetProperty("confidence", out var cProp)) confidence = cProp.GetDouble();
|
||||
}
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
transcript = transcriptProperty.GetString() ?? "";
|
||||
if (alternatives[0].TryGetProperty("confidence", out var confidenceProperty))
|
||||
confidence = confidenceProperty.GetDouble();
|
||||
|
||||
if (root.TryGetProperty("metadata", out var meta) && meta.TryGetProperty("duration", out var dProp))
|
||||
{
|
||||
|
|
@ -431,9 +431,6 @@ public class SttProxyService : ISttProxyService
|
|||
if (endpoint.EndpointUrl.Contains("generativelanguage.googleapis.com") || endpoint.ModelId.Contains("gemini"))
|
||||
{
|
||||
var apiKey = endpoint.ApiKey;
|
||||
var url = endpoint.EndpointUrl.Contains("?")
|
||||
? $"{endpoint.EndpointUrl}&key={apiKey}"
|
||||
: $"{endpoint.EndpointUrl}?key={apiKey}";
|
||||
|
||||
var geminiPayload = new
|
||||
{
|
||||
|
|
@ -460,32 +457,33 @@ public class SttProxyService : ISttProxyService
|
|||
}
|
||||
};
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(geminiPayload), Encoding.UTF8, "application/json")
|
||||
};
|
||||
httpRequest.Headers.Add("X-Goog-Api-Key", apiKey);
|
||||
|
||||
var response = await client.SendAsync(httpRequest);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Google Gemini STT returned HTTP {response.StatusCode}: {responseBody}");
|
||||
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
var text = "";
|
||||
if (root.TryGetProperty("candidates", out var cands) && cands.GetArrayLength() > 0)
|
||||
if (!root.TryGetProperty("candidates", out var cands) ||
|
||||
cands.GetArrayLength() == 0 ||
|
||||
!cands[0].TryGetProperty("content", out var content) ||
|
||||
!content.TryGetProperty("parts", out var parts) ||
|
||||
parts.GetArrayLength() == 0 ||
|
||||
!parts[0].TryGetProperty("text", out var textProperty) ||
|
||||
textProperty.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
var cand0 = cands[0];
|
||||
if (cand0.TryGetProperty("content", out var content) &&
|
||||
content.TryGetProperty("parts", out var parts) &&
|
||||
parts.GetArrayLength() > 0)
|
||||
{
|
||||
text = parts[0].GetProperty("text").GetString() ?? "";
|
||||
}
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
var text = textProperty.GetString() ?? "";
|
||||
|
||||
return (text.Trim(), 0.98, lang, EstimateAudioDuration(audioBytes, contentType));
|
||||
}
|
||||
|
|
@ -493,9 +491,6 @@ public class SttProxyService : ISttProxyService
|
|||
{
|
||||
// Google Cloud Speech-to-Text v1
|
||||
var apiKey = endpoint.ApiKey;
|
||||
var url = endpoint.EndpointUrl.Contains("?")
|
||||
? $"{endpoint.EndpointUrl}&key={apiKey}"
|
||||
: $"{endpoint.EndpointUrl}?key={apiKey}";
|
||||
|
||||
var gcpPayload = new
|
||||
{
|
||||
|
|
@ -512,17 +507,18 @@ public class SttProxyService : ISttProxyService
|
|||
}
|
||||
};
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(gcpPayload), Encoding.UTF8, "application/json")
|
||||
};
|
||||
httpRequest.Headers.Add("X-Goog-Api-Key", apiKey);
|
||||
|
||||
var response = await client.SendAsync(httpRequest);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Google Cloud STT returned HTTP {response.StatusCode}: {responseBody}");
|
||||
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
|
|
@ -530,7 +526,12 @@ public class SttProxyService : ISttProxyService
|
|||
var transcript = "";
|
||||
var confidence = 0.95;
|
||||
|
||||
if (root.TryGetProperty("results", out var results) && results.GetArrayLength() > 0)
|
||||
if (!root.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new JsonException("STT upstream response did not contain transcription results.");
|
||||
}
|
||||
|
||||
if (results.GetArrayLength() > 0)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var res in results.EnumerateArray())
|
||||
|
|
@ -568,7 +569,7 @@ public class SttProxyService : ISttProxyService
|
|||
var uploadBody = await uploadResp.Content.ReadAsStringAsync();
|
||||
if (!uploadResp.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"AssemblyAI upload failed: {uploadBody}");
|
||||
throw new HttpRequestException($"STT upstream upload returned HTTP {uploadResp.StatusCode}.");
|
||||
}
|
||||
|
||||
using var uploadDoc = JsonDocument.Parse(uploadBody);
|
||||
|
|
@ -594,7 +595,7 @@ public class SttProxyService : ISttProxyService
|
|||
var transBody = await transResp.Content.ReadAsStringAsync();
|
||||
if (!transResp.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"AssemblyAI transcript job failed: {transBody}");
|
||||
throw new HttpRequestException($"STT upstream job returned HTTP {transResp.StatusCode}.");
|
||||
}
|
||||
|
||||
using var transDoc = JsonDocument.Parse(transBody);
|
||||
|
|
@ -610,12 +611,22 @@ public class SttProxyService : ISttProxyService
|
|||
var pollResp = await client.SendAsync(pollReq);
|
||||
var pollBody = await pollResp.Content.ReadAsStringAsync();
|
||||
|
||||
if (!pollResp.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"STT upstream poll returned HTTP {pollResp.StatusCode}.");
|
||||
}
|
||||
|
||||
using var pollDoc = JsonDocument.Parse(pollBody);
|
||||
var status = pollDoc.RootElement.GetProperty("status").GetString();
|
||||
|
||||
if (status == "completed")
|
||||
{
|
||||
var text = pollDoc.RootElement.GetProperty("text").GetString() ?? "";
|
||||
if (!pollDoc.RootElement.TryGetProperty("text", out var textProperty) ||
|
||||
textProperty.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
var text = textProperty.GetString() ?? "";
|
||||
var confidence = 0.95;
|
||||
if (pollDoc.RootElement.TryGetProperty("confidence", out var c)) confidence = c.GetDouble();
|
||||
var duration = 0.0;
|
||||
|
|
@ -624,8 +635,7 @@ public class SttProxyService : ISttProxyService
|
|||
}
|
||||
if (status == "error")
|
||||
{
|
||||
var err = pollDoc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : "Unknown error";
|
||||
throw new HttpRequestException($"AssemblyAI processing error: {err}");
|
||||
throw new HttpRequestException("STT upstream processing failed.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -663,24 +673,32 @@ public class SttProxyService : ISttProxyService
|
|||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Azure Speech STT returned HTTP {response.StatusCode}: {responseBody}");
|
||||
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
var text = "";
|
||||
string text;
|
||||
var confidence = 0.95;
|
||||
|
||||
if (root.TryGetProperty("DisplayText", out var dt))
|
||||
if (root.TryGetProperty("DisplayText", out var dt) && dt.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
text = dt.GetString() ?? "";
|
||||
}
|
||||
else if (root.TryGetProperty("NBest", out var nbest) && nbest.GetArrayLength() > 0)
|
||||
{
|
||||
var best = nbest[0];
|
||||
if (best.TryGetProperty("Display", out var d)) text = d.GetString() ?? "";
|
||||
if (!best.TryGetProperty("Display", out var display) || display.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
text = display.GetString() ?? "";
|
||||
if (best.TryGetProperty("Confidence", out var c)) confidence = c.GetDouble();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
|
||||
return (text, confidence, lang, EstimateAudioDuration(audioBytes, contentType));
|
||||
}
|
||||
|
|
@ -717,14 +735,17 @@ public class SttProxyService : ISttProxyService
|
|||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Local STT Sidecar returned HTTP {response.StatusCode}: {responseBody}");
|
||||
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var text = "";
|
||||
if (root.TryGetProperty("text", out var tProp)) text = tProp.GetString() ?? "";
|
||||
if (!root.TryGetProperty("text", out var tProp) || tProp.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
var text = tProp.GetString() ?? "";
|
||||
var duration = EstimateAudioDuration(audioBytes, contentType);
|
||||
if (root.TryGetProperty("duration", out var dProp)) duration = dProp.GetDouble();
|
||||
|
||||
|
|
@ -789,7 +810,7 @@ public class SttProxyService : ISttProxyService
|
|||
sw.Stop();
|
||||
return new SttTestResultDto(
|
||||
Success: false,
|
||||
Message: $"연결 실패: {ex.Message}",
|
||||
Message: $"연결 실패: {GetSafeFailureType(ex)}",
|
||||
LatencyMs: sw.ElapsedMilliseconds,
|
||||
TranscriptPreview: null,
|
||||
Provider: endpoint.ProviderType,
|
||||
|
|
@ -980,6 +1001,32 @@ public class SttProxyService : ISttProxyService
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
private static bool HasRequiredProviderConfiguration(SttProviderEndpoint endpoint)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(endpoint.EndpointUrl)) return false;
|
||||
|
||||
var providerType = endpoint.ProviderType.Trim().ToLowerInvariant();
|
||||
return !RequiresApiKey(providerType) || !string.IsNullOrWhiteSpace(endpoint.ApiKey);
|
||||
}
|
||||
|
||||
private static bool RequiresApiKey(string providerType)
|
||||
{
|
||||
return providerType is "groq" or "openai" or "deepgram" or "google" or "assemblyai" or "azure";
|
||||
}
|
||||
|
||||
private static string GetSafeFailureType(Exception? exception)
|
||||
{
|
||||
return exception switch
|
||||
{
|
||||
TimeoutException or TaskCanceledException => "timeout",
|
||||
HttpRequestException => "upstream_http_error",
|
||||
JsonException => "invalid_upstream_response",
|
||||
InvalidOperationException => "provider_not_configured",
|
||||
null => "not_configured",
|
||||
_ => "provider_error"
|
||||
};
|
||||
}
|
||||
|
||||
private static void ApplyExtraHeaders(HttpRequestMessage req, string? extraHeadersJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(extraHeadersJson)) return;
|
||||
|
|
|
|||
12
apps/api-server/wwwroot/.well-known/assetlinks.json
Normal file
12
apps/api-server/wwwroot/.well-known/assetlinks.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.d3ro.voice",
|
||||
"sha256_cert_fingerprints": [
|
||||
"01:00:19:21:DB:4F:33:40:85:CE:21:E4:B8:DE:CC:BD:71:DA:87:67:C5:6E:3B:59:83:2A:A1:C8:29:EA:0D:AB"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
345
apps/api-server/wwwroot/accept-invite.css
Normal file
345
apps/api-server/wwwroot/accept-invite.css
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
:root {
|
||||
--ink: #08090c;
|
||||
--panel: #11141c;
|
||||
--panel-raised: #171b25;
|
||||
--line: #303647;
|
||||
--line-soft: rgba(255, 255, 255, 0.07);
|
||||
--text: #f4f4f5;
|
||||
--muted: #a1a1aa;
|
||||
--faint: #71717a;
|
||||
--accent: #f25b29;
|
||||
--accent-hot: #ff7342;
|
||||
--success: #4ade80;
|
||||
--danger: #fb7185;
|
||||
font-family: "Pretendard Variable", Pretendard, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
linear-gradient(var(--line-soft) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--line-soft) 1px, transparent 1px),
|
||||
radial-gradient(circle at 72% 18%, rgba(242, 91, 41, 0.13), transparent 34rem),
|
||||
var(--ink);
|
||||
background-size: 40px 40px, 40px 40px, auto, auto;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px 18px;
|
||||
}
|
||||
|
||||
.invite-panel {
|
||||
width: min(100%, 720px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(145deg, rgba(23, 27, 37, 0.96), rgba(12, 14, 20, 0.98));
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.48), inset 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.brand-row,
|
||||
.copy-block,
|
||||
.token-card,
|
||||
.actions,
|
||||
.support-row,
|
||||
.error {
|
||||
margin-inline: clamp(22px, 7vw, 64px);
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
min-height: 76px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.brand,
|
||||
.protocol,
|
||||
.eyebrow,
|
||||
.token-card dt,
|
||||
.token-card dd {
|
||||
font-family: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.protocol {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.signal {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 12px rgba(74, 222, 128, 0.72);
|
||||
}
|
||||
|
||||
.route-line {
|
||||
height: 54px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto 1fr auto;
|
||||
align-items: center;
|
||||
padding-inline: clamp(22px, 7vw, 64px);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.route-node {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 50%;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.route-node--active {
|
||||
border-color: var(--accent-hot);
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 18px rgba(242, 91, 41, 0.72);
|
||||
}
|
||||
|
||||
.route-track {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--line));
|
||||
}
|
||||
|
||||
.copy-block {
|
||||
padding-top: clamp(42px, 8vw, 72px);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 16px;
|
||||
color: var(--accent-hot);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 600px;
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 7.2vw, 60px);
|
||||
font-weight: 790;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.08;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 590px;
|
||||
margin: 26px 0 0;
|
||||
color: #d4d4d8;
|
||||
font-size: 16px;
|
||||
line-height: 1.72;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.description--en {
|
||||
margin-top: 8px;
|
||||
color: var(--faint);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.token-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
margin-top: 38px;
|
||||
padding: 1px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.token-card div {
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
background: var(--panel-raised);
|
||||
}
|
||||
|
||||
.token-card dt {
|
||||
color: var(--faint);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.13em;
|
||||
}
|
||||
|
||||
.token-card dd {
|
||||
margin: 8px 0 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.token-card dd[data-state="valid"] {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.token-card dd[data-state="invalid"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 18px;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid rgba(251, 113, 133, 0.35);
|
||||
border-radius: 8px;
|
||||
color: #fecdd3;
|
||||
background: rgba(251, 113, 133, 0.08);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 52px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0 18px;
|
||||
font-weight: 750;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 10px 30px rgba(242, 91, 41, 0.2);
|
||||
}
|
||||
|
||||
.button--primary:hover {
|
||||
background: var(--accent-hot);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.button--secondary:hover {
|
||||
border-color: #535f7f;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.button[aria-disabled="true"],
|
||||
.button:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.button:focus-visible,
|
||||
.brand:focus-visible,
|
||||
.support-row a:focus-visible {
|
||||
outline: 3px solid rgba(255, 115, 66, 0.78);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.support-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-top: 38px;
|
||||
padding-block: 22px 28px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--faint);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.support-row a {
|
||||
color: #d4d4d8;
|
||||
font-weight: 650;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.shell {
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.invite-panel {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
min-height: 66px;
|
||||
}
|
||||
|
||||
.protocol {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.token-card,
|
||||
.actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.support-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
76
apps/api-server/wwwroot/accept-invite.html
Normal file
76
apps/api-server/wwwroot/accept-invite.html
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#08090c" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'"
|
||||
/>
|
||||
<title>D3RO Voice — 팀 초대 열기</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/accept-invite.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="invite-panel" aria-labelledby="invite-title">
|
||||
<header class="brand-row">
|
||||
<a class="brand" href="/" aria-label="D3RO Voice 홈">D3RO VOICE</a>
|
||||
<span class="protocol"><span class="signal" aria-hidden="true"></span>SECURE INVITE</span>
|
||||
</header>
|
||||
|
||||
<div class="route-line" aria-hidden="true">
|
||||
<span class="route-node route-node--active"></span>
|
||||
<span class="route-track"></span>
|
||||
<span class="route-node"></span>
|
||||
<span class="route-track"></span>
|
||||
<span class="route-node"></span>
|
||||
</div>
|
||||
|
||||
<div class="copy-block">
|
||||
<p class="eyebrow">TEAM ACCESS / MOBILE HANDOFF</p>
|
||||
<h1 id="invite-title">D3RO Voice 앱에서<br />팀 초대를 확인해.</h1>
|
||||
<p class="description">
|
||||
초대 수락은 로그인한 계정과 서버 권한을 확인한 뒤에만 완료돼. 이 페이지는 초대 토큰을
|
||||
저장하거나 수락 결과를 만들지 않아.
|
||||
</p>
|
||||
<p class="description description--en" lang="en">
|
||||
Acceptance is completed only after the app verifies your signed-in account and server permissions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="token-card" aria-label="초대 링크 상태">
|
||||
<div>
|
||||
<dt>LINK STATUS</dt>
|
||||
<dd id="invite-status" aria-live="polite">검증 중</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>TOKEN FINGERPRINT</dt>
|
||||
<dd id="token-fingerprint">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p id="invite-error" class="error" role="alert" hidden></p>
|
||||
|
||||
<div class="actions">
|
||||
<a id="open-app" class="button button--primary" href="#" aria-disabled="true">
|
||||
D3RO Voice 앱 열기
|
||||
</a>
|
||||
<button id="copy-link" class="button button--secondary" type="button" disabled>
|
||||
초대 링크 복사
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer class="support-row">
|
||||
<span>앱이 설치되지 않았어?</span>
|
||||
<a href="/download.html">안전한 설치 파일 받기</a>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/accept-invite.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
37
apps/api-server/wwwroot/accept-invite.js
Normal file
37
apps/api-server/wwwroot/accept-invite.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
(() => {
|
||||
'use strict'
|
||||
|
||||
const tokenPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
const status = document.getElementById('invite-status')
|
||||
const fingerprint = document.getElementById('token-fingerprint')
|
||||
const error = document.getElementById('invite-error')
|
||||
const openApp = document.getElementById('open-app')
|
||||
const copyLink = document.getElementById('copy-link')
|
||||
const token = new URLSearchParams(window.location.search).get('token')?.trim() ?? ''
|
||||
|
||||
if (!tokenPattern.test(token)) {
|
||||
status.textContent = '사용할 수 없는 링크'
|
||||
status.dataset.state = 'invalid'
|
||||
error.textContent = '초대 링크가 없거나 형식이 올바르지 않아. 새 초대 링크를 요청해.'
|
||||
error.hidden = false
|
||||
return
|
||||
}
|
||||
|
||||
status.textContent = '형식 검증 완료 · 서버 확인 대기'
|
||||
status.dataset.state = 'valid'
|
||||
fingerprint.textContent = `${token.slice(0, 8)}…${token.slice(-4)}`
|
||||
openApp.href = `d3ro-voice://accept-invite?token=${encodeURIComponent(token)}`
|
||||
openApp.removeAttribute('aria-disabled')
|
||||
copyLink.disabled = false
|
||||
|
||||
copyLink.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
copyLink.textContent = '복사했어'
|
||||
window.setTimeout(() => { copyLink.textContent = '초대 링크 복사' }, 1800)
|
||||
} catch {
|
||||
error.textContent = '브라우저가 복사를 허용하지 않았어. 주소 표시줄에서 링크를 직접 복사해.'
|
||||
error.hidden = false
|
||||
}
|
||||
})
|
||||
})()
|
||||
76
apps/api-server/wwwroot/accept-invite/index.html
Normal file
76
apps/api-server/wwwroot/accept-invite/index.html
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#08090c" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'"
|
||||
/>
|
||||
<title>D3RO Voice — 팀 초대 열기</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/accept-invite.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="invite-panel" aria-labelledby="invite-title">
|
||||
<header class="brand-row">
|
||||
<a class="brand" href="/" aria-label="D3RO Voice 홈">D3RO VOICE</a>
|
||||
<span class="protocol"><span class="signal" aria-hidden="true"></span>SECURE INVITE</span>
|
||||
</header>
|
||||
|
||||
<div class="route-line" aria-hidden="true">
|
||||
<span class="route-node route-node--active"></span>
|
||||
<span class="route-track"></span>
|
||||
<span class="route-node"></span>
|
||||
<span class="route-track"></span>
|
||||
<span class="route-node"></span>
|
||||
</div>
|
||||
|
||||
<div class="copy-block">
|
||||
<p class="eyebrow">TEAM ACCESS / MOBILE HANDOFF</p>
|
||||
<h1 id="invite-title">D3RO Voice 앱에서<br />팀 초대를 확인해.</h1>
|
||||
<p class="description">
|
||||
초대 수락은 로그인한 계정과 서버 권한을 확인한 뒤에만 완료돼. 이 페이지는 초대 토큰을
|
||||
저장하거나 수락 결과를 만들지 않아.
|
||||
</p>
|
||||
<p class="description description--en" lang="en">
|
||||
Acceptance is completed only after the app verifies your signed-in account and server permissions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="token-card" aria-label="초대 링크 상태">
|
||||
<div>
|
||||
<dt>LINK STATUS</dt>
|
||||
<dd id="invite-status" aria-live="polite">검증 중</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>TOKEN FINGERPRINT</dt>
|
||||
<dd id="token-fingerprint">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p id="invite-error" class="error" role="alert" hidden></p>
|
||||
|
||||
<div class="actions">
|
||||
<a id="open-app" class="button button--primary" href="#" aria-disabled="true">
|
||||
D3RO Voice 앱 열기
|
||||
</a>
|
||||
<button id="copy-link" class="button button--secondary" type="button" disabled>
|
||||
초대 링크 복사
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer class="support-row">
|
||||
<span>앱이 설치되지 않았어?</span>
|
||||
<a href="/download.html">안전한 설치 파일 받기</a>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/accept-invite.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,489 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>D3RO Voice — Official Download Center & Release History</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Geist:wght@300;400;500;600;700;800;900&family=Pretendard:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
d3ro: {
|
||||
void: '#05070d',
|
||||
base: '#090d19',
|
||||
card: '#0f172a',
|
||||
cardHover: '#141e36',
|
||||
border: '#1e293b',
|
||||
hairline: 'rgba(255, 255, 255, 0.08)',
|
||||
accent: '#38bdf8',
|
||||
accentHover: '#0ea5e9',
|
||||
accentMuted: 'rgba(56, 189, 248, 0.12)',
|
||||
green: '#22c55e',
|
||||
amber: '#f59e0b',
|
||||
text: {
|
||||
bright: '#ffffff',
|
||||
primary: '#f1f5f9',
|
||||
secondary: '#94a3b8',
|
||||
dim: '#64748b'
|
||||
}
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Geist', 'Pretendard', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||
mono: ['Fira Code', 'monospace']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #05070d;
|
||||
color: #f1f5f9;
|
||||
font-family: 'Geist', 'Pretendard', sans-serif;
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(56, 189, 248, 0.12), transparent 70%),
|
||||
radial-gradient(circle at 100% 100%, rgba(15, 23, 42, 0.8), transparent 40%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
.glass-surface {
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.glass-surface-interactive {
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.glass-surface-interactive:hover {
|
||||
border-color: rgba(56, 189, 248, 0.35);
|
||||
background: rgba(20, 30, 54, 0.85);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.7), 0 0 30px -10px rgba(56, 189, 248, 0.2);
|
||||
}
|
||||
.pill-glow {
|
||||
box-shadow: 0 0 20px -3px rgba(56, 189, 248, 0.4);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col antialiased selection:bg-sky-500/30 selection:text-sky-200">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-50 glass-surface border-b border-white/5 px-6 py-3.5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-tr from-sky-500 to-blue-600 flex items-center justify-center font-black text-white text-xs shadow-md shadow-sky-500/30">
|
||||
D3
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-extrabold tracking-tight text-sm text-white">D3RO VOICE</span>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-mono font-bold bg-sky-500/10 text-sky-400 border border-sky-500/25">v1.0.0 STABLE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-6 text-xs font-medium text-slate-400">
|
||||
<a href="/" class="hover:text-white transition-colors">Overview</a>
|
||||
<a href="#changelog" class="hover:text-white transition-colors">Changelog</a>
|
||||
<a href="#integrity" class="hover:text-white transition-colors">SHA-256 Verifier</a>
|
||||
<a href="https://git.chanpaca.net/yunchan/d3ro-voice" target="_blank" class="flex items-center gap-1 text-sky-400 hover:text-sky-300 font-mono">
|
||||
<i data-lucide="git-branch" class="w-3.5 h-3.5"></i> Forgejo Git
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="http://admin.chanpaca.net:3001" target="_blank" class="px-3.5 py-1.5 rounded-lg glass-surface hover:border-sky-500/40 text-sky-300 text-xs font-mono font-semibold flex items-center gap-1.5 transition-all">
|
||||
<i data-lucide="layout-dashboard" class="w-3.5 h-3.5"></i> Admin CRM
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Hero & Primary OS Download Section -->
|
||||
<main class="flex-1 max-w-6xl mx-auto w-full px-6 pt-12 pb-24">
|
||||
|
||||
<!-- Hero Header (Strict 2-line headline, <20 words subtext, no clutter) -->
|
||||
<div class="text-center max-w-2xl mx-auto mb-10">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-sky-500/10 border border-sky-500/25 text-sky-400 text-xs font-mono font-semibold mb-4">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-sky-400 animate-pulse"></span>
|
||||
OFFICIAL RELEASE • ZERO-LATENCY LOCAL WHISPER
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl sm:text-4xl md:text-5xl font-black tracking-tight text-white mb-3">
|
||||
Download <span class="bg-gradient-to-r from-sky-400 to-blue-500 bg-clip-text text-transparent">D3RO Voice</span> for Desktop
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base text-slate-400 leading-relaxed">
|
||||
100% on-device local transcription, intelligent meeting minutes, and multi-network ad rewards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Primary Hero Download Box (Auto-detected OS card) -->
|
||||
<div class="max-w-xl mx-auto glass-surface p-6 sm:p-8 rounded-2xl border border-sky-500/30 relative overflow-hidden mb-16 shadow-2xl shadow-sky-950/40">
|
||||
<div class="absolute -right-16 -top-16 w-48 h-48 bg-sky-500/10 rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 mb-6">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<div id="osIcon" class="w-12 h-12 rounded-xl bg-sky-500/15 border border-sky-500/30 flex items-center justify-center text-sky-400">
|
||||
<i data-lucide="monitor" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div id="detectedOsTitle" class="font-bold text-base sm:text-lg text-white">Windows 64-bit Installer</div>
|
||||
<div id="detectedOsMeta" class="text-xs text-slate-400 font-mono">D3RO-Voice-Setup-1.0.0-x64.exe • 102 MB • NSIS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="px-2.5 py-1 rounded-md text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 uppercase tracking-wider">
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Single Primary Action Button (No Wrap, High Contrast WCAG AA) -->
|
||||
<a id="primaryDownloadBtn" href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe" download class="w-full py-3.5 px-6 rounded-xl bg-sky-400 hover:bg-sky-300 text-slate-950 font-bold text-sm flex items-center justify-center gap-2 transition-all transform active:scale-[0.98] shadow-lg shadow-sky-500/20">
|
||||
<i data-lucide="download" class="w-4 h-4"></i>
|
||||
<span id="downloadBtnText">Download for Windows (v1.0.0)</span>
|
||||
</a>
|
||||
|
||||
<!-- Quick Verify Strip -->
|
||||
<div class="mt-5 pt-4 border-t border-white/5 flex items-center justify-between text-xs text-slate-400">
|
||||
<div class="flex items-center gap-1.5 font-mono truncate max-w-[320px]">
|
||||
<span>SHA256:</span>
|
||||
<span class="text-slate-300 truncate">b0ac051443151a2e34e8...</span>
|
||||
<button onclick="copyHash('b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2')" class="text-sky-400 hover:text-sky-300 underline font-sans text-[11px] ml-1">Copy</button>
|
||||
</div>
|
||||
<span class="text-emerald-400 text-[11px] font-mono flex items-center gap-1">
|
||||
<i data-lucide="shield-check" class="w-3.5 h-3.5"></i> Signed & Verified
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Platform Packages Grid (Bento Structure with Asymmetry) -->
|
||||
<div class="mb-20">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">All Platform Releases</h2>
|
||||
<p class="text-xs text-slate-400">Optimized standalone packages for desktop operating systems.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs font-mono text-slate-400">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400"></span> Channel: latest.yml Active
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
|
||||
<!-- Windows Card -->
|
||||
<div class="glass-surface-interactive p-6 rounded-2xl flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/20 flex items-center justify-center text-sky-400">
|
||||
<i data-lucide="layout-grid" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-[11px] font-mono text-slate-400">Windows 10 / 11 x64</span>
|
||||
</div>
|
||||
|
||||
<h3 class="font-bold text-base text-white mb-1">Windows</h3>
|
||||
<p class="text-xs text-slate-400 mb-5 leading-relaxed">
|
||||
NSIS one-click installer with background delta updates and DirectML GPU acceleration.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
<a href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe" class="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.03] hover:bg-sky-500/10 border border-white/5 hover:border-sky-500/30 transition-all text-xs text-white">
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<i data-lucide="download" class="w-3.5 h-3.5 text-sky-400"></i>
|
||||
<span>Setup Installer (.exe)</span>
|
||||
</div>
|
||||
<span class="font-mono text-slate-400 text-[11px]">102 MB</span>
|
||||
</a>
|
||||
<a href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe.blockmap" class="flex items-center justify-between p-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] border border-white/5 text-[11px] text-slate-400">
|
||||
<span class="font-mono">Blockmap (.blockmap)</span>
|
||||
<span class="text-[10px]">105 KB</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-white/5 text-[11px] font-mono text-slate-400 flex justify-between">
|
||||
<span>Min: 4GB RAM / 1GB Disk</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- macOS Card -->
|
||||
<div class="glass-surface-interactive p-6 rounded-2xl flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-9 h-9 rounded-lg bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
|
||||
<i data-lucide="apple" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-[11px] font-mono text-slate-400">macOS 12.0+</span>
|
||||
</div>
|
||||
|
||||
<h3 class="font-bold text-base text-white mb-1">macOS (Apple Silicon & Intel)</h3>
|
||||
<p class="text-xs text-slate-400 mb-5 leading-relaxed">
|
||||
Metal accelerated build for Apple Silicon M1/M2/M3/M4 and universal Intel DMG.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
<a href="/releases/1.0.0/D3RO-Voice-1.0.0-arm64.dmg" class="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.03] hover:bg-purple-500/10 border border-white/5 hover:border-purple-500/30 transition-all text-xs text-white">
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<i data-lucide="download" class="w-3.5 h-3.5 text-purple-400"></i>
|
||||
<span>Apple Silicon DMG (.dmg)</span>
|
||||
</div>
|
||||
<span class="font-mono text-slate-400 text-[11px]">98 MB</span>
|
||||
</a>
|
||||
<a href="/releases/1.0.0/D3RO-Voice-1.0.0-arm64-mac.zip" class="flex items-center justify-between p-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] border border-white/5 text-[11px] text-slate-400">
|
||||
<span class="font-mono">Portable Zip (.zip)</span>
|
||||
<span class="text-[10px]">96 MB</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-white/5 text-[11px] font-mono text-slate-400 flex justify-between">
|
||||
<span>Metal Acceleration Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NAS & Server Card -->
|
||||
<div class="glass-surface-interactive p-6 rounded-2xl flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400">
|
||||
<i data-lucide="server" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-[11px] font-mono text-slate-400">Docker / Synology NAS</span>
|
||||
</div>
|
||||
|
||||
<h3 class="font-bold text-base text-white mb-1">Synology NAS & Docker</h3>
|
||||
<p class="text-xs text-slate-400 mb-5 leading-relaxed">
|
||||
Self-hosted private deployment package for NAS Container Manager and CRM services.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
<a href="https://git.chanpaca.net/yunchan/d3ro-voice/src/branch/main/docker-compose.nas.yml" target="_blank" class="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.03] hover:bg-emerald-500/10 border border-white/5 hover:border-emerald-500/30 transition-all text-xs text-white">
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<i data-lucide="file-code" class="w-3.5 h-3.5 text-emerald-400"></i>
|
||||
<span>docker-compose.nas.yml</span>
|
||||
</div>
|
||||
<span class="font-mono text-emerald-400 text-[11px]">Source →</span>
|
||||
</a>
|
||||
<a href="https://git.chanpaca.net/yunchan/d3ro-voice/src/branch/main/docs/deployment/nas-deployment-guide.md" target="_blank" class="flex items-center justify-between p-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] border border-white/5 text-[11px] text-slate-400">
|
||||
<span>NAS Deployment Manual</span>
|
||||
<span class="text-[10px] text-slate-400">Guide →</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-white/5 text-[11px] font-mono text-slate-400 flex justify-between">
|
||||
<span>DSM 7.2+ Container Manager</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client-Side SHA-256 Verifier (Security & Integrity) -->
|
||||
<div id="integrity" class="glass-surface p-6 sm:p-8 rounded-2xl border border-sky-500/20 mb-20">
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-sky-400 text-xs font-mono font-bold mb-1">
|
||||
<i data-lucide="hash" class="w-4 h-4"></i>
|
||||
CLIENT-SIDE CRYPTOGRAPHIC VERIFICATION
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-white">SHA-256 Binary Integrity Verifier</h3>
|
||||
<p class="text-xs text-slate-400">Drag & drop your downloaded installer file to compute and compare hash client-side.</p>
|
||||
</div>
|
||||
|
||||
<button onclick="document.getElementById('fileVerifierInput').click()" class="px-3.5 py-2 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/30 text-xs font-semibold flex items-center gap-2 transition-all">
|
||||
<i data-lucide="file-check" class="w-4 h-4"></i> Select File to Verify
|
||||
</button>
|
||||
<input type="file" id="fileVerifierInput" class="hidden" onchange="handleFileVerify(event)">
|
||||
</div>
|
||||
|
||||
<div id="dropZone" ondragover="handleDragOver(event)" ondragleave="handleDragLeave(event)" ondrop="handleFileDrop(event)" class="border-2 border-dashed border-slate-700/80 rounded-xl p-6 sm:p-8 text-center transition-all bg-black/20">
|
||||
<div id="verifyIdleState">
|
||||
<i data-lucide="upload-cloud" class="w-8 h-8 text-slate-500 mx-auto mb-2"></i>
|
||||
<p class="text-xs font-medium text-slate-300 mb-1">Drop installer (.exe / .dmg / .zip) here</p>
|
||||
<p class="text-[11px] text-slate-500 font-mono">Calculated locally in browser via WebCrypto API (no upload)</p>
|
||||
</div>
|
||||
|
||||
<div id="verifyResultState" class="hidden text-left space-y-3 font-mono text-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300 font-bold" id="verifyFileName">D3RO-Voice-Setup-1.0.0-x64.exe</span>
|
||||
<span id="verifyBadge" class="px-2.5 py-0.5 rounded text-[11px] font-bold"></span>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-black/40 border border-white/5 space-y-1.5">
|
||||
<div class="text-slate-400">Computed Hash: <span class="text-sky-300" id="calculatedHash">-</span></div>
|
||||
<div class="text-slate-400">Official Hash: <span class="text-emerald-400" id="officialHash">b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Release Changelog Timeline -->
|
||||
<div id="changelog" class="mb-16">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">Release Changelog & History</h2>
|
||||
<p class="text-xs text-slate-400">Complete version archives and feature milestones.</p>
|
||||
</div>
|
||||
<a href="https://git.chanpaca.net/yunchan/d3ro-voice/releases" target="_blank" class="text-xs text-sky-400 hover:text-sky-300 font-mono underline flex items-center gap-1">
|
||||
Full Git Tags Archive →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- v1.0.0 Item -->
|
||||
<div class="glass-surface p-6 rounded-2xl border-l-4 border-l-sky-400">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg font-bold text-white font-mono">v1.0.0</span>
|
||||
<span class="px-2.5 py-0.5 rounded-full text-[10px] font-mono font-bold bg-sky-500/20 text-sky-300 border border-sky-500/30">
|
||||
LATEST STABLE
|
||||
</span>
|
||||
<span class="text-xs text-slate-500 font-mono">2026-08-20</span>
|
||||
</div>
|
||||
<a href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe" class="px-3 py-1.5 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/30 text-xs font-semibold flex items-center gap-1.5 transition-all">
|
||||
<i data-lucide="download" class="w-3.5 h-3.5"></i> Installer (.exe)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5 text-xs text-slate-300 leading-relaxed mb-4">
|
||||
<p class="font-semibold text-white">Highlights & Features:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2 text-slate-400">
|
||||
<li><strong class="text-sky-300">10+ Ad Mediation System</strong>: Parallel header bidding auction (EthicalAds, Carbon, GAM, Playwire, AppLovin, Unity).</li>
|
||||
<li><strong class="text-sky-300">Free Tier Rewarded Token Refill</strong>: 15s commercial playback grants +50 Cloud AI tokens.</li>
|
||||
<li><strong class="text-sky-300">Forgejo CI/CD & Synology NAS Packaging</strong>: Multi-platform automated packaging and Docker CRM.</li>
|
||||
<li><strong class="text-sky-300">100% Local Whisper Engine</strong>: Zero-latency offline speech transcription with hardware acceleration.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="p-2.5 rounded-lg bg-black/30 font-mono text-[11px] text-slate-400 flex items-center justify-between">
|
||||
<span class="truncate">SHA-256: b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2</span>
|
||||
<span class="text-sky-400">102 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- v0.2.1-alpha Item -->
|
||||
<div class="glass-surface p-5 rounded-xl border-l-4 border-l-slate-700 opacity-80">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="text-base font-bold text-white font-mono">v0.2.1-alpha</span>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-mono bg-white/5 text-slate-400 border border-white/10">PRE-RELEASE</span>
|
||||
<span class="text-xs text-slate-500 font-mono">2026-08-15</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400">
|
||||
Multi-provider cloud STT drivers dispatch (Deepgram, AssemblyAI, Groq) and cryptographic license verification engine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="refresh" content="0; url=https://d3ro.chanpaca.net/#download" />
|
||||
<link rel="canonical" href="https://d3ro.chanpaca.net/#download" />
|
||||
<title>D3RO Voice 릴리스 준비</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>D3RO Voice 릴리스 준비</h1>
|
||||
<p>설치 파일은 서명과 업데이트 경로 검증이 끝난 뒤 공식 페이지에서 제공합니다.</p>
|
||||
<p><a href="https://d3ro.chanpaca.net/#download">공식 릴리스 준비 페이지로 이동</a></p>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="glass-surface border-t border-white/5 py-6 px-6 text-xs text-slate-500">
|
||||
<div class="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div>
|
||||
<span class="font-bold text-white">D3RO Voice</span> • Copyright © 2026 D3RO. All rights reserved.
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/privacy" class="hover:text-white transition-colors">Privacy Policy</a>
|
||||
<a href="/terms" class="hover:text-white transition-colors">Terms</a>
|
||||
<a href="https://git.chanpaca.net" target="_blank" class="text-sky-400 hover:text-sky-300 font-mono">git.chanpaca.net</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
const OFFICIAL_HASH = 'b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2';
|
||||
|
||||
// Auto OS Detection
|
||||
(function detectOS() {
|
||||
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||
const titleEl = document.getElementById('detectedOsTitle');
|
||||
const metaEl = document.getElementById('detectedOsMeta');
|
||||
const btnEl = document.getElementById('primaryDownloadBtn');
|
||||
const btnTextEl = document.getElementById('downloadBtnText');
|
||||
const iconEl = document.getElementById('osIcon');
|
||||
|
||||
if (userAgent.includes('mac') || userAgent.includes('darwin')) {
|
||||
titleEl.textContent = 'macOS Apple Silicon Installer';
|
||||
metaEl.textContent = 'D3RO-Voice-1.0.0-arm64.dmg • 98 MB • Apple Silicon (M1/M2/M3/M4)';
|
||||
btnEl.href = '/releases/1.0.0/D3RO-Voice-1.0.0-arm64.dmg';
|
||||
btnTextEl.textContent = 'Download for macOS (v1.0.0)';
|
||||
iconEl.innerHTML = '<i data-lucide="apple" class="w-6 h-6"></i>';
|
||||
} else if (userAgent.includes('linux')) {
|
||||
titleEl.textContent = 'Linux Universal Package';
|
||||
metaEl.textContent = 'D3RO-Voice-1.0.0.AppImage • 105 MB • AppImage';
|
||||
btnEl.href = '/releases/1.0.0/D3RO-Voice-1.0.0.AppImage';
|
||||
btnTextEl.textContent = 'Download for Linux (v1.0.0)';
|
||||
}
|
||||
lucide.createIcons();
|
||||
})();
|
||||
|
||||
function copyHash(hash) {
|
||||
navigator.clipboard.writeText(hash).then(() => {
|
||||
alert('SHA-256 Hash copied to clipboard:\n' + hash);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('dropZone').classList.add('border-sky-400', 'bg-sky-500/10');
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('dropZone').classList.remove('border-sky-400', 'bg-sky-500/10');
|
||||
}
|
||||
|
||||
function handleFileDrop(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('dropZone').classList.remove('border-sky-400', 'bg-sky-500/10');
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) calculateFileHash(files[0]);
|
||||
}
|
||||
|
||||
function handleFileVerify(e) {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) calculateFileHash(files[0]);
|
||||
}
|
||||
|
||||
async function calculateFileHash(file) {
|
||||
document.getElementById('verifyIdleState').classList.add('hidden');
|
||||
document.getElementById('verifyResultState').classList.remove('hidden');
|
||||
document.getElementById('verifyFileName').textContent = file.name + ' (' + (file.size / (1024*1024)).toFixed(1) + ' MB)';
|
||||
document.getElementById('calculatedHash').textContent = 'Computing SHA-256 hash locally...';
|
||||
|
||||
const badge = document.getElementById('verifyBadge');
|
||||
badge.textContent = 'Hashing...';
|
||||
badge.className = 'px-2.5 py-0.5 rounded text-[11px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30';
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
document.getElementById('calculatedHash').textContent = hashHex;
|
||||
|
||||
if (hashHex === OFFICIAL_HASH) {
|
||||
badge.textContent = '✓ OFFICIAL MATCH (GENUINE)';
|
||||
badge.className = 'px-2.5 py-0.5 rounded text-[11px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
|
||||
} else {
|
||||
badge.textContent = '✓ LOCAL HASH COMPUTED';
|
||||
badge.className = 'px-2.5 py-0.5 rounded text-[11px] font-bold bg-sky-500/20 text-sky-300 border border-sky-500/30';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="D3RO Voice - Fully local AI voice assistant. Whisper + Ollama powered, zero cloud, 100% private." />
|
||||
<meta name="theme-color" content="#f25b29" />
|
||||
<title>D3RO Voice — Local AI Voice Assistant</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<script type="module" crossorigin src="./assets/index-D7M5UQvT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BLH9FjGS.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="refresh" content="0; url=https://d3ro.chanpaca.net/#download" />
|
||||
<link rel="canonical" href="https://d3ro.chanpaca.net/#download" />
|
||||
<title>D3RO Voice 릴리스 준비</title>
|
||||
</head>
|
||||
<body class="bg-surface-950 text-white antialiased">
|
||||
<div id="root"></div>
|
||||
<body>
|
||||
<main>
|
||||
<h1>D3RO Voice 릴리스 준비</h1>
|
||||
<p>설치 파일은 서명과 업데이트 경로 검증이 끝난 뒤 공식 페이지에서 제공합니다.</p>
|
||||
<p><a href="https://d3ro.chanpaca.net/#download">공식 릴리스 준비 페이지로 이동</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ productName: D3RO Voice
|
|||
copyright: Copyright © 2026 D3RO
|
||||
# monorepo(npm workspaces)에서 electron이 루트 node_modules로 호이스팅되어
|
||||
# 자동 감지가 실패하는 문제를 피하려고 명시적으로 버전 고정.
|
||||
electronVersion: "33.3.0"
|
||||
electronVersion: "33.4.11"
|
||||
|
||||
directories:
|
||||
# buildResources를 build/로 지정 — resources/icons/가 비어있어 아이콘 자동 스캔이 실패하는 것을 우회.
|
||||
|
|
@ -25,7 +25,7 @@ files:
|
|||
# ────────────────────────────────────────────────────────────────────
|
||||
publish:
|
||||
provider: generic
|
||||
url: "https://d3ro.chanpaca.net/releases/1.0.0"
|
||||
url: "https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest"
|
||||
|
||||
# prerelease 버전(0.1.1-alpha)에서 채널을 "alpha"로 감지해 alpha.yml을 만드는 동작 차단 —
|
||||
# electron-updater(기본 채널 latest)가 latest.yml을 찾으므로 항상 latest 채널로 고정.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { createPublicKey } from 'crypto'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
|
@ -9,9 +11,19 @@ const sharedAlias = {
|
|||
}
|
||||
|
||||
const workspaceExclude = ['@d3ro/core', '@d3ro/ui', '@d3ro/i18n']
|
||||
const licensePublicKeyPath = resolve(__dirname, 'resources/license/production-public.pem')
|
||||
const licensePublicKey = readFileSync(licensePublicKeyPath, 'utf8').trim()
|
||||
const parsedLicensePublicKey = createPublicKey(licensePublicKey)
|
||||
if (parsedLicensePublicKey.asymmetricKeyType !== 'ed25519') {
|
||||
throw new Error(`Desktop license public key must be Ed25519: ${licensePublicKeyPath}`)
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
// 공개키만 main bundle에 주입한다. 대응 ADMIN_LICENSE_PRIVATE_KEY는 admin 서버 전용이다.
|
||||
define: {
|
||||
'process.env.D3RO_LICENSE_PUBLIC_KEY': JSON.stringify(licensePublicKey)
|
||||
},
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: ['electron-store', ...workspaceExclude]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@d3ro/desktop",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"productName": "d3ro-voice",
|
||||
"description": "로컬 AI 음성 어시스턴트 (Electron)",
|
||||
"main": "./out/main/index.js",
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"electron": "^33.3.0",
|
||||
"electron": "33.4.11",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-vite": "^2.3.0",
|
||||
"vite": "^5.4.0",
|
||||
|
|
|
|||
3
apps/desktop/resources/license/production-public.pem
Normal file
3
apps/desktop/resources/license/production-public.pem
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEApTW7quHfh/UgtUGUgSykuUuERFtfIpQqfenZd1kuDcA=
|
||||
-----END PUBLIC KEY-----
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
// src/main/bootstrap.ts — 초기화 시퀀스
|
||||
|
||||
import { join } from 'path'
|
||||
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
|
||||
import { initLoggerService, getLogger } from './services/LoggerService'
|
||||
import { initConfigService, configGet, configSet } from './services/ConfigService'
|
||||
import { getHotkeyService } from './services/HotkeyService'
|
||||
import { getVoiceModeService } from './services/VoiceModeService'
|
||||
import { getPremiumLLMService } from './services/PremiumLLMService'
|
||||
import { startLocalLLMAvailability } from './services/LocalLLMService'
|
||||
import { getHistoryService } from './services/HistoryService'
|
||||
import { persistCompletedVoiceSessionSafe } from './voice-session-persist'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// src/main/index.ts — 앱 진입점
|
||||
|
||||
import { app } from 'electron'
|
||||
import electronLog from 'electron-log'
|
||||
import path from 'path'
|
||||
|
||||
// 앱 식별자 명시 (기본값 'Electron'과 충돌 방지)
|
||||
|
|
@ -24,7 +25,7 @@ process.stderr?.on?.('error', () => { /* ignore EPIPE */ })
|
|||
process.on('uncaughtException', (err) => {
|
||||
if (err.message?.includes('EPIPE')) return // EPIPE는 무시
|
||||
// 기타 예외는 로그만
|
||||
try { require('electron-log').default?.error?.('Uncaught:', err) } catch { /* noop */ }
|
||||
try { electronLog.error('Uncaught:', err) } catch { /* noop */ }
|
||||
})
|
||||
|
||||
// V2-4: deep link 프로토콜 등록 — d3ro-voice://auth-callback?code=...
|
||||
|
|
@ -131,7 +132,7 @@ async function handleDeepLink(url: string): Promise<void> {
|
|||
const gotTheLock = process.env.NODE_ENV === 'test' ? true : app.requestSingleInstanceLock()
|
||||
|
||||
if (!gotTheLock) {
|
||||
console.log('[D3RO Voice] Another instance is already running. Existing window focused.')
|
||||
deepLinkLogger.info('Another instance is already running. Existing window focused.')
|
||||
app.exit(0)
|
||||
} else {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,8 @@ import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
|
|||
import { getAudioCaptureService, calculateRMS } from '../services/AudioCaptureService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { configGet, configSet } from '../services/ConfigService'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
import type { SetDeviceParams } from '@d3ro/core/types'
|
||||
|
||||
const logger = getLogger('audio-handlers')
|
||||
|
||||
let testTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let testAudioHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
|
||||
let testLevelInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
|
|
|||
|
|
@ -1,48 +1,246 @@
|
|||
// apps/desktop/src/main/ipc/payment-handlers.ts
|
||||
// IPC handlers for Multi-PG Payment & Billing
|
||||
// Authenticated, server-authoritative checkout and subscription IPC handlers.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { ok } from '@d3ro/core/errors'
|
||||
import { ErrorCode, ipcError, ok, type IPCResult } from '@d3ro/core/errors'
|
||||
import type { CheckoutSessionParams, CheckoutSessionResult } from '@d3ro/core/types'
|
||||
import { getLicenseService } from '../services/LicenseService'
|
||||
import { getCloudSyncService } from '../services/CloudSyncService'
|
||||
|
||||
const CHECKOUT_FUNCTION = 'stripe-checkout'
|
||||
const SUBSCRIPTION_FUNCTION = 'payple-manage'
|
||||
const CHECKOUT_SUCCESS_URL = 'https://d3ro.chanpaca.net/billing?desktop_checkout=success'
|
||||
const CHECKOUT_CANCEL_URL = 'https://d3ro.chanpaca.net/billing?desktop_checkout=cancelled'
|
||||
const STRIPE_CHECKOUT_ORIGIN = 'https://checkout.stripe.com'
|
||||
const STRIPE_CHECKOUT_PATH_PREFIX = '/c/pay/'
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
const SERVER_ENTITLED_STATUSES = new Set([
|
||||
'active',
|
||||
'trialing',
|
||||
'past_due',
|
||||
'canceled',
|
||||
'cancelled'
|
||||
])
|
||||
|
||||
export const PAYMENT_REQUEST_TIMEOUT_MS = 15_000
|
||||
const CHECKOUT_IDEMPOTENCY_TTL_MS = 5 * 60_000
|
||||
|
||||
type PaidTier = 'pro' | 'pro_plus'
|
||||
type CloudSyncService = ReturnType<typeof getCloudSyncService>
|
||||
|
||||
interface CheckoutAttempt {
|
||||
idempotencyKey: string
|
||||
expiresAt: number
|
||||
inFlight?: Promise<IPCResult<CheckoutSessionResult>>
|
||||
result?: CheckoutSessionResult
|
||||
}
|
||||
|
||||
interface SubscriptionStatus {
|
||||
tier: 'free' | PaidTier
|
||||
valid: boolean
|
||||
expiresAt: number | null
|
||||
}
|
||||
|
||||
class PaymentTimeoutError extends Error {}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseCheckoutParams(value: unknown): { tier: PaidTier } | null {
|
||||
if (!isRecord(value)) return null
|
||||
if (value.provider !== 'stripe') return null
|
||||
if (value.tier !== 'pro' && value.tier !== 'pro_plus') return null
|
||||
return { tier: value.tier }
|
||||
}
|
||||
|
||||
function getAuthenticatedCloud(): { cloud: CloudSyncService; userId: string } | null {
|
||||
const cloud = getCloudSyncService()
|
||||
const user = cloud.getUser()
|
||||
if (!cloud.isAuthenticated() || !user || !UUID_PATTERN.test(user.id)) return null
|
||||
return { cloud, userId: user.id }
|
||||
}
|
||||
|
||||
async function invokeWithDeadline(
|
||||
cloud: CloudSyncService,
|
||||
name: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<{ data: unknown; error: { message: string } | null }> {
|
||||
const controller = new AbortController()
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort()
|
||||
reject(new PaymentTimeoutError('Payment server request timed out'))
|
||||
}, PAYMENT_REQUEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
cloud.invokeFunction(name, body, {
|
||||
signal: controller.signal,
|
||||
timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS
|
||||
}),
|
||||
deadline
|
||||
])
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function parseTrustedCheckoutUrl(value: unknown): string | null {
|
||||
if (!isRecord(value) || typeof value.url !== 'string') return null
|
||||
try {
|
||||
const url = new URL(value.url)
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.origin !== STRIPE_CHECKOUT_ORIGIN ||
|
||||
url.username !== '' ||
|
||||
url.password !== '' ||
|
||||
!url.pathname.startsWith(STRIPE_CHECKOUT_PATH_PREFIX) ||
|
||||
url.pathname.length <= STRIPE_CHECKOUT_PATH_PREFIX.length
|
||||
)
|
||||
return null
|
||||
return url.toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseServerSubscription(value: unknown, now = Date.now()): SubscriptionStatus | null {
|
||||
if (!isRecord(value)) return null
|
||||
if (value.tier !== 'free' && value.tier !== 'pro' && value.tier !== 'pro_plus') return null
|
||||
if (typeof value.status !== 'string' || value.status.length === 0) return null
|
||||
|
||||
let expiresAt: number | null = null
|
||||
if (value.current_period_end !== null && value.current_period_end !== undefined) {
|
||||
if (typeof value.current_period_end !== 'string') return null
|
||||
const parsed = Date.parse(value.current_period_end)
|
||||
if (!Number.isFinite(parsed)) return null
|
||||
expiresAt = parsed
|
||||
}
|
||||
|
||||
const paidTier = value.tier === 'pro' || value.tier === 'pro_plus'
|
||||
const valid =
|
||||
paidTier &&
|
||||
SERVER_ENTITLED_STATUSES.has(value.status) &&
|
||||
(expiresAt === null || expiresAt > now)
|
||||
|
||||
return {
|
||||
tier: valid ? value.tier : 'free',
|
||||
valid,
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
async function readServerSubscription(cloud: CloudSyncService): Promise<SubscriptionStatus> {
|
||||
const response = await invokeWithDeadline(cloud, SUBSCRIPTION_FUNCTION, { action: 'info' })
|
||||
if (response.error) throw new Error('subscription_provider_error')
|
||||
const status = parseServerSubscription(response.data)
|
||||
if (!status) throw new Error('subscription_response_invalid')
|
||||
return status
|
||||
}
|
||||
|
||||
function paymentFailure<T>(error: unknown, fallback: string): IPCResult<T> {
|
||||
if (error instanceof PaymentTimeoutError) {
|
||||
return ipcError(ErrorCode.UnknownError, 'Payment server request timed out')
|
||||
}
|
||||
return ipcError(ErrorCode.UnknownError, fallback)
|
||||
}
|
||||
|
||||
export function registerPaymentHandlers(): void {
|
||||
// 1. Create Checkout Session
|
||||
ipcMain.handle(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, async (_event, params: CheckoutSessionParams) => {
|
||||
const isKrw = params.currency === 'KRW'
|
||||
const session: CheckoutSessionResult = {
|
||||
sessionId: `cs_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
checkoutUrl: isKrw
|
||||
? `https://pay.tosspayments.com/v1/billing/${params.planId}`
|
||||
: `https://checkout.stripe.com/c/pay/${params.planId}`,
|
||||
provider: params.provider || (isKrw ? 'toss_payments' : 'stripe'),
|
||||
orderId: `ORD-${Date.now()}`,
|
||||
amount: params.amount,
|
||||
currency: params.currency,
|
||||
}
|
||||
return ok(session)
|
||||
})
|
||||
const attempts = new Map<string, CheckoutAttempt>()
|
||||
|
||||
// 2. Verify Payment & Activate Tier
|
||||
ipcMain.handle(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT, async (_event, { tier }: { tier: string }) => {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
||||
async (_event, rawParams: CheckoutSessionParams) => {
|
||||
const params = parseCheckoutParams(rawParams)
|
||||
if (!params) {
|
||||
return ipcError(ErrorCode.UnknownError, 'Invalid checkout request')
|
||||
}
|
||||
|
||||
const authenticated = getAuthenticatedCloud()
|
||||
if (!authenticated) {
|
||||
return ipcError(ErrorCode.UnknownError, 'Sign in is required for checkout')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
for (const [key, attempt] of attempts) {
|
||||
if (!attempt.inFlight && attempt.expiresAt <= now) attempts.delete(key)
|
||||
}
|
||||
|
||||
const fingerprint = `${authenticated.userId}:stripe:${params.tier}`
|
||||
let attempt = attempts.get(fingerprint)
|
||||
if (!attempt || attempt.expiresAt <= now) {
|
||||
attempt = {
|
||||
idempotencyKey: `desktop:stripe-checkout:${randomUUID()}`,
|
||||
expiresAt: now + CHECKOUT_IDEMPOTENCY_TTL_MS
|
||||
}
|
||||
attempts.set(fingerprint, attempt)
|
||||
}
|
||||
|
||||
if (attempt.result) return ok(attempt.result)
|
||||
if (attempt.inFlight) return attempt.inFlight
|
||||
|
||||
const currentAttempt = attempt
|
||||
const operation = (async (): Promise<IPCResult<CheckoutSessionResult>> => {
|
||||
try {
|
||||
const license = getLicenseService()
|
||||
await license.activate(`D3RO-${tier.toUpperCase()}-${Date.now().toString(36).toUpperCase()}`)
|
||||
} catch {
|
||||
// ignore
|
||||
const response = await invokeWithDeadline(authenticated.cloud, CHECKOUT_FUNCTION, {
|
||||
tier: params.tier,
|
||||
success_url: CHECKOUT_SUCCESS_URL,
|
||||
cancel_url: CHECKOUT_CANCEL_URL,
|
||||
idempotency_key: currentAttempt.idempotencyKey
|
||||
})
|
||||
if (response.error) {
|
||||
return ipcError(ErrorCode.UnknownError, 'Checkout service rejected the request')
|
||||
}
|
||||
|
||||
const checkoutUrl = parseTrustedCheckoutUrl(response.data)
|
||||
if (!checkoutUrl) {
|
||||
return ipcError(ErrorCode.UnknownError, 'Checkout service returned an invalid URL')
|
||||
}
|
||||
|
||||
const result: CheckoutSessionResult = {
|
||||
checkoutUrl,
|
||||
provider: 'stripe',
|
||||
status: 'pending'
|
||||
}
|
||||
currentAttempt.result = result
|
||||
return ok(result)
|
||||
} catch (error) {
|
||||
return paymentFailure(error, 'Checkout service is unavailable')
|
||||
} finally {
|
||||
currentAttempt.inFlight = undefined
|
||||
}
|
||||
})()
|
||||
currentAttempt.inFlight = operation
|
||||
return operation
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT, async () => {
|
||||
const authenticated = getAuthenticatedCloud()
|
||||
if (!authenticated) {
|
||||
return ipcError(ErrorCode.UnknownError, 'Sign in is required to verify a subscription')
|
||||
}
|
||||
try {
|
||||
const status = await readServerSubscription(authenticated.cloud)
|
||||
return ok({ success: status.valid, activeTier: status.tier })
|
||||
} catch (error) {
|
||||
return paymentFailure(error, 'Subscription status is unavailable')
|
||||
}
|
||||
return ok({ success: true, activeTier: tier })
|
||||
})
|
||||
|
||||
// 3. Get Subscription Status
|
||||
ipcMain.handle(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS, async () => {
|
||||
const license = getLicenseService()
|
||||
const info = license.getLicenseInfo()
|
||||
return ok({
|
||||
tier: info.tier,
|
||||
valid: info.valid,
|
||||
expiresAt: info.expiresAt,
|
||||
})
|
||||
const authenticated = getAuthenticatedCloud()
|
||||
if (!authenticated) {
|
||||
return ipcError(ErrorCode.UnknownError, 'Sign in is required to read a subscription')
|
||||
}
|
||||
try {
|
||||
return ok(await readServerSubscription(authenticated.cloud))
|
||||
} catch (error) {
|
||||
return paymentFailure(error, 'Subscription status is unavailable')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function registerSTTHandlers(): void {
|
|||
safeSendToRenderer(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, payload)
|
||||
})
|
||||
|
||||
getSTTManager().on('provider-changed', (payload) => {
|
||||
getSTTManager().on('provider-changed', (_payload) => {
|
||||
safeSendToRenderer(IPC_CHANNELS.STT.STATUS_CHANGED, { status: getSTTManager().getStatus() })
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// apps/desktop/src/main/ipc/support-handlers.ts
|
||||
// IPC handlers for Customer Assistance, AI Helpdesk & Diagnostics
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import os from 'os'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { ok } from '@d3ro/core/errors'
|
||||
|
|
@ -30,7 +30,7 @@ export function registerSupportHandlers(): void {
|
|||
|
||||
const payload: SystemDiagnosticsPayload = {
|
||||
machineId: `d3ro-${os.hostname().toLowerCase().slice(0, 12)}`,
|
||||
appVersion: '1.0.0-release',
|
||||
appVersion: app.getVersion(),
|
||||
platform: `${os.platform()} (${os.arch()})`,
|
||||
osRelease: `${os.type()} ${os.release()}`,
|
||||
activeAudioDevice: (configGet('audio.selectedDevice') as string) || 'Default System Microphone',
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function registerVoiceHandlers(): void {
|
|||
return ipcSuccess(getVoiceModeService().getState())
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.VOICE.SET_MODE, async (_event, params: SetVoiceModeParams) => {
|
||||
ipcMain.handle(IPC_CHANNELS.VOICE.SET_MODE, async (_event, _params: SetVoiceModeParams) => {
|
||||
// Phase 2: 모드만 설정에 저장 (실제 모드 전환은 핫키에서 처리)
|
||||
return ipcSuccess(undefined)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
// Windows: SoX 직접 spawn (-t waveaudio). 기타: node-record-lpcm16.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import path from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { spawn, type ChildProcess } from 'child_process'
|
||||
import type { Readable } from 'stream'
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// 3초 청크 기반 스트리밍 전사. 싱글톤 + EventEmitter 패턴.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { app } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getAudioCaptureService, calculateRMS } from './AudioCaptureService'
|
||||
import { getSoundEffectService } from './SoundEffectService'
|
||||
|
|
@ -485,7 +486,7 @@ class CaptionService extends EventEmitter {
|
|||
llmModel: null,
|
||||
sttLatencyMs: null,
|
||||
llmLatencyMs: null,
|
||||
appVersion: '1.0.0',
|
||||
appVersion: app.getVersion(),
|
||||
})
|
||||
logger.info(
|
||||
`캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}초`,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { getLogger } from './LoggerService';
|
||||
import { getCloudSyncService } from './CloudSyncService';
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors';
|
||||
import { D3ROCloudDriver } from './stt/drivers/D3ROCloudDriver';
|
||||
|
||||
export interface TranscriptionSegment {
|
||||
readonly text: string;
|
||||
|
|
@ -28,18 +28,10 @@ const logger = getLogger('CloudSTTService');
|
|||
|
||||
class CloudSTTService extends EventEmitter {
|
||||
private _disposed = false;
|
||||
private readonly _driver = new D3ROCloudDriver();
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this._disposed) return;
|
||||
const cloud = getCloudSyncService();
|
||||
if (!cloud.isAuthenticated()) {
|
||||
// Auto-authenticate anonymously if zero-configuration is required
|
||||
try {
|
||||
await cloud.signInAnonymously();
|
||||
} catch (e) {
|
||||
logger.warn('Failed to sign in anonymously', e);
|
||||
}
|
||||
}
|
||||
logger.info('CloudSTTService initialized');
|
||||
}
|
||||
|
||||
|
|
@ -47,36 +39,7 @@ class CloudSTTService extends EventEmitter {
|
|||
if (this._disposed) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'CloudSTTService disposed');
|
||||
}
|
||||
const cloud = getCloudSyncService();
|
||||
|
||||
// Instead of using formData, we can send base64 or binary depending on edge function support.
|
||||
// Assuming 'stt-proxy' edge function accepts base64 audio in JSON for simplicity, or multipart.
|
||||
const base64Audio = audioBuffer.toString('base64');
|
||||
|
||||
const { data, error } = await cloud.invokeFunction('stt-proxy', {
|
||||
audio: base64Audio,
|
||||
language: options?.language,
|
||||
initial_prompt: options?.initialPrompt
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `STT failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const result = data as { text?: unknown; segments?: TranscriptionSegment[]; language?: string; duration?: number; processingTime?: number } | null;
|
||||
if (!result || typeof result.text !== 'string') {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'STT returned malformed payload');
|
||||
}
|
||||
if (!result.text.trim()) {
|
||||
throw new D3ROError(ErrorCode.STTNoAudioData, 'STT returned empty transcript');
|
||||
}
|
||||
return {
|
||||
text: result.text,
|
||||
segments: result.segments || [],
|
||||
language: result.language || 'ko',
|
||||
duration: result.duration || 0,
|
||||
processingTime: result.processingTime || 0,
|
||||
};
|
||||
return this._driver.transcribe(audioBuffer, options);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -380,7 +380,11 @@ class CloudSyncService extends EventEmitter {
|
|||
* Phase 3.2: Supabase Edge Function 호출 — 클라이언트가 auth 헤더를 올바르게 처리.
|
||||
* raw fetch 대신 이걸 사용해야 gateway 레벨 401 방지.
|
||||
*/
|
||||
async invokeFunction(name: string, body: Record<string, unknown>): Promise<{ data: unknown; error: { message: string } | null }> {
|
||||
async invokeFunction(
|
||||
name: string,
|
||||
body: Record<string, unknown> | FormData,
|
||||
options?: { signal?: AbortSignal; timeoutMs?: number }
|
||||
): Promise<{ data: unknown; error: { message: string } | null }> {
|
||||
if (!this._client) {
|
||||
return { data: null, error: { message: 'Supabase client not initialized' } }
|
||||
}
|
||||
|
|
@ -395,6 +399,8 @@ class CloudSyncService extends EventEmitter {
|
|||
const { data, error } = await this._client.functions.invoke(name, {
|
||||
body,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeoutMs
|
||||
})
|
||||
|
||||
if (error) {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
|||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type {
|
||||
DictationTemplate,
|
||||
TemplateField,
|
||||
TemplateSessionState,
|
||||
TemplateSessionInfo,
|
||||
TemplateFieldCompletedEvent,
|
||||
TemplateSessionCompletedEvent,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import path from 'path'
|
|||
import fs from 'fs'
|
||||
import { app } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getLocalSTTService } from './LocalSTTService'
|
||||
import { getSTTManager } from './stt/STTManager'
|
||||
import { getHistoryService } from './HistoryService'
|
||||
import { configGet } from './ConfigService'
|
||||
|
|
|
|||
|
|
@ -23,6 +23,23 @@ import { verifySignedLicenseKey, createDefaultTrialPayload } from '@d3ro/core/ut
|
|||
|
||||
const logger = getLogger('license')
|
||||
|
||||
function resolveLicenseVerificationConfig(): {
|
||||
publicKeyPem: string | undefined
|
||||
allowDevelopmentKeys: boolean
|
||||
environment: string | undefined
|
||||
} {
|
||||
const environment = process.env.NODE_ENV
|
||||
const allowDevelopmentKeys =
|
||||
environment === 'test' ||
|
||||
(environment === 'development' && process.env.D3RO_ALLOW_LEGACY_DEV_LICENSES === 'true')
|
||||
const configuredPublicKey = process.env.D3RO_LICENSE_PUBLIC_KEY?.trim()
|
||||
return {
|
||||
publicKeyPem: configuredPublicKey?.replace(/\\n/g, '\n'),
|
||||
allowDevelopmentKeys,
|
||||
environment,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 머신 ID 생성 ─────────────────────────────────────────
|
||||
function generateMachineId(): string {
|
||||
const raw = `${os.hostname()}-${os.cpus()[0]?.model ?? 'unknown'}-${os.platform()}-${os.arch()}`
|
||||
|
|
@ -108,9 +125,6 @@ const TIER_ORDER: Record<LicenseTier, number> = {
|
|||
/** 오프라인 유예 기간: 30일 */
|
||||
const OFFLINE_GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
/** 온라인 재검증 주기: 30일 */
|
||||
const REVERIFY_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
function tierAtLeast(current: LicenseTier, required: LicenseTier): boolean {
|
||||
return TIER_ORDER[current] >= TIER_ORDER[required]
|
||||
}
|
||||
|
|
@ -174,8 +188,6 @@ class LicenseService extends EventEmitter {
|
|||
const storedTrialExpiresAt = this._readStoredField<number>('licenseTrialExpiresAt')
|
||||
const storedExpiresAt = this._readStoredField<number>('licenseExpiresAt')
|
||||
const storedCustomerEmail = this._readStoredField<string>('licenseCustomerEmail')
|
||||
const trialEverStarted = this._readStoredField<boolean>('licenseTrialEverStarted')
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
if (storedTier && storedTier !== 'free') {
|
||||
|
|
@ -410,9 +422,9 @@ class LicenseService extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* 라이센스 키 활성화 (Ed25519 암호화 서명 검증 + 레거시 호환).
|
||||
* D3RO-LIC-xxx (Ed25519 서명 키) 또는 D3RO-PRO-xxx (개발자 키)
|
||||
* 프로덕션 결제는 Payple/Stripe 웹 결제 → CloudSync 티어 갱신 또는 라이센스 키 입력
|
||||
* 라이센스 키 활성화 (Ed25519 암호화 서명 검증).
|
||||
* 운영은 D3RO_LICENSE_PUBLIC_KEY가 반드시 필요하고, 고정 개발 fixture는 test 또는
|
||||
* 명시적으로 opt-in한 development 환경에서만 허용한다.
|
||||
*/
|
||||
async activate(key: string): Promise<ActivateLicenseResult> {
|
||||
const trimmedKey = key.trim()
|
||||
|
|
@ -420,7 +432,13 @@ class LicenseService extends EventEmitter {
|
|||
return { success: false, tier: 'free', message: 'License key is empty' }
|
||||
}
|
||||
|
||||
const verification = verifySignedLicenseKey(trimmedKey, this._info.machineId)
|
||||
const verificationConfig = resolveLicenseVerificationConfig()
|
||||
const verification = verifySignedLicenseKey(
|
||||
trimmedKey,
|
||||
this._info.machineId,
|
||||
verificationConfig.publicKeyPem,
|
||||
verificationConfig,
|
||||
)
|
||||
if (!verification.valid) {
|
||||
return { success: false, tier: 'free', message: verification.message }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
|
|||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import {
|
||||
parseMinutes,
|
||||
buildExportMarkdown,
|
||||
markdownToSimpleHtml,
|
||||
formatTime,
|
||||
|
|
@ -45,15 +44,6 @@ const logger = getLogger('MeetingModeService')
|
|||
|
||||
let isShowingMeetingSaveDialog = false
|
||||
|
||||
interface MeetingModeServiceEvents {
|
||||
'state-changed': (state: MeetingModeState) => void
|
||||
'segment': (segment: CaptionSegment) => void
|
||||
'memo-added': (memo: MeetingMemo) => void
|
||||
'processing-progress': (progress: MeetingProcessingProgress) => void
|
||||
'session-completed': (detail: MeetingSessionDetail) => void
|
||||
'error': (error: D3ROError) => void
|
||||
}
|
||||
|
||||
class MeetingModeService extends EventEmitter {
|
||||
private _state: MeetingModeState = 'idle'
|
||||
private _sessionId: string | null = null
|
||||
|
|
@ -1232,7 +1222,7 @@ ${speakerHint}
|
|||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Malgun Gothic', sans-serif; margin: 40px; color: #222; }
|
||||
h1 { font-size: 22px; border-bottom: 2px solid #f25b29; padding-bottom: 8px; }
|
||||
h1 { font-size: 22px; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
|
||||
h2 { font-size: 16px; color: #444; margin-top: 24px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ class MemoService {
|
|||
const db = getDatabase()
|
||||
|
||||
// 태그별 히스토리 조회
|
||||
let tagFilter = params.tag
|
||||
const tagFilter = params.tag
|
||||
? eq(memoTags.tag, params.tag.trim().toLowerCase())
|
||||
: undefined
|
||||
|
||||
|
|
|
|||
|
|
@ -3,21 +3,11 @@
|
|||
// 온라인 모드 사용 시 필수 인증(JWT Bearer)을 통해 서버로 AI 요청 전달.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { LLMAction } from '@d3ro/core/types'
|
||||
import { resolveSystemPrompt } from './llm-prompts'
|
||||
|
||||
const logger = getLogger('OnlineLLMService')
|
||||
|
||||
interface OnlineGenerateOptions {
|
||||
model?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
systemPrompt?: string
|
||||
}
|
||||
|
||||
interface OnlineGenerateResponse {
|
||||
text: string
|
||||
model: string
|
||||
|
|
|
|||
|
|
@ -23,13 +23,6 @@ const logger = getLogger('PremiumLLMService')
|
|||
// 내부 타입
|
||||
// ============================================================
|
||||
|
||||
interface PremiumGenerateOptions {
|
||||
model?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
systemPrompt?: string
|
||||
}
|
||||
|
||||
/** Ollama-style 메시지 → Claude Messages 변환용 */
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
|
|
@ -55,17 +48,6 @@ interface ClaudeMessageResponse {
|
|||
usage: { input_tokens: number; output_tokens: number }
|
||||
}
|
||||
|
||||
/** llm-proxy 에러 응답 */
|
||||
interface LlmProxyErrorResponse {
|
||||
error: string
|
||||
current?: number
|
||||
limit?: number
|
||||
tier?: 'free' | 'pro' | 'pro_plus'
|
||||
overage_credits?: number
|
||||
allowed?: string[]
|
||||
requested?: string
|
||||
}
|
||||
|
||||
export interface QuotaUsageSnapshot {
|
||||
tier: 'free' | 'pro' | 'pro_plus'
|
||||
current: number
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
import { EventEmitter } from 'events'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getPremiumLLMService } from './PremiumLLMService'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// src/main/services/UpdateService.ts
|
||||
// electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter.
|
||||
//
|
||||
// feed: D3RO Official Release Feed `https://d3ro.chanpaca.net/releases/1.0.0`
|
||||
// feed: public GitLab Generic Registry `d3ro-voice/latest`
|
||||
// 기능:
|
||||
// 1. 사용자 인가 기반 다운로드 (autoDownload=false)
|
||||
// 2. 이번 버전 건너뛰기 (Skip This Version) 지원
|
||||
|
|
@ -172,7 +172,7 @@ class UpdateService extends EventEmitter {
|
|||
/** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
|
||||
private async _promptUserConsent(
|
||||
version: string,
|
||||
releaseNotes?: string | any[]
|
||||
releaseNotes?: string | ReadonlyArray<{ version: string; note: string | null }> | null
|
||||
): Promise<void> {
|
||||
if (this._promptShown || this._downloading) return
|
||||
this._promptShown = true
|
||||
|
|
@ -262,4 +262,3 @@ export function getUpdateService(): UpdateService {
|
|||
}
|
||||
|
||||
export { UpdateService }
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { exec } from 'child_process'
|
|||
import { shell } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getPremiumLLMService } from './PremiumLLMService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import {
|
|||
hideRecordingTip,
|
||||
updateRecordingTipState,
|
||||
sendAudioLevelToTip,
|
||||
sendPartialTranscriptToTip,
|
||||
showResultPopup,
|
||||
} from '../windows/WindowManager'
|
||||
import type { ScreenContext } from '@d3ro/core/types'
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import type {
|
|||
AdImpressionEvent,
|
||||
AdRewardResult,
|
||||
AdRevenueStats,
|
||||
AdNetworkId,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter } from './BaseAdAdapter'
|
||||
import { EthicalAdsAdapter } from './EthicalAdsAdapter'
|
||||
|
|
@ -29,10 +28,13 @@ export class AdMediationEngine {
|
|||
private adapters: Map<string, IAdNetworkAdapter> = new Map()
|
||||
private config: AdMediationConfig
|
||||
private impressionHistory: AdImpressionEvent[] = []
|
||||
private lastRewardTimestamp = 0
|
||||
private deliveredCreatives = new Map<string, AdCreativePayload>()
|
||||
private clickedCreatives = new Set<string>()
|
||||
|
||||
private constructor() {
|
||||
// Register all 10+ Production Ad Adapters
|
||||
// Provider shells stay registered for explicit configuration diagnostics,
|
||||
// but are disabled until an official SDK or authenticated decision API is
|
||||
// integrated. No adapter may fabricate a bid or creative.
|
||||
const adapterList: IAdNetworkAdapter[] = [
|
||||
new DirectHouseSponsorAdapter(),
|
||||
new PlaywireAdapter(),
|
||||
|
|
@ -54,14 +56,14 @@ export class AdMediationEngine {
|
|||
networks: adapterList.map((a, idx) => ({
|
||||
id: a.networkId,
|
||||
name: a.networkName,
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
priority: idx + 1,
|
||||
floorEcpm: a.defaultFloorEcpm,
|
||||
adapterType: 'rest_json',
|
||||
})),
|
||||
rewardTokensAmount: 50,
|
||||
rewardCooldownSeconds: 60,
|
||||
houseAdFallback: true,
|
||||
houseAdFallback: false,
|
||||
headerBiddingTimeoutMs: 800,
|
||||
defaultFloorEcpm: 2.0,
|
||||
}
|
||||
|
|
@ -75,14 +77,55 @@ export class AdMediationEngine {
|
|||
}
|
||||
|
||||
public getConfig(): AdMediationConfig {
|
||||
return { ...this.config }
|
||||
return {
|
||||
...this.config,
|
||||
networks: this.config.networks.map((network) => ({ ...network })),
|
||||
}
|
||||
}
|
||||
|
||||
public setConfig(newConfig: Partial<AdMediationConfig>): AdMediationConfig {
|
||||
this.config = { ...this.config, ...newConfig }
|
||||
if (newConfig.rewardTokensAmount !== undefined && (!Number.isFinite(newConfig.rewardTokensAmount) || newConfig.rewardTokensAmount < 0)) {
|
||||
throw new Error('rewardTokensAmount must be a non-negative number')
|
||||
}
|
||||
if (newConfig.rewardCooldownSeconds !== undefined && (!Number.isFinite(newConfig.rewardCooldownSeconds) || newConfig.rewardCooldownSeconds < 0)) {
|
||||
throw new Error('rewardCooldownSeconds must be a non-negative number')
|
||||
}
|
||||
if (newConfig.defaultFloorEcpm !== undefined && (!Number.isFinite(newConfig.defaultFloorEcpm) || newConfig.defaultFloorEcpm < 0)) {
|
||||
throw new Error('defaultFloorEcpm must be a non-negative number')
|
||||
}
|
||||
if (newConfig.headerBiddingTimeoutMs !== undefined && (!Number.isFinite(newConfig.headerBiddingTimeoutMs) || newConfig.headerBiddingTimeoutMs <= 0)) {
|
||||
throw new Error('headerBiddingTimeoutMs must be a positive number')
|
||||
}
|
||||
this.config = {
|
||||
...this.config,
|
||||
...newConfig,
|
||||
networks: newConfig.networks?.map((network) => ({ ...network }))
|
||||
?? this.config.networks.map((network) => ({ ...network })),
|
||||
}
|
||||
return this.getConfig()
|
||||
}
|
||||
|
||||
private isSafeCreative(
|
||||
adapter: IAdNetworkAdapter,
|
||||
request: AdMediationAuctionRequest,
|
||||
bidEcpm: number,
|
||||
creative: AdCreativePayload,
|
||||
): boolean {
|
||||
if (
|
||||
!Number.isFinite(bidEcpm)
|
||||
|| bidEcpm <= 0
|
||||
|| !creative.id.trim()
|
||||
|| creative.networkId !== adapter.networkId
|
||||
|| creative.format !== request.format
|
||||
) return false
|
||||
|
||||
try {
|
||||
return new URL(creative.clickUrl).protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute real-time Header Bidding Auction across all enabled ad networks
|
||||
*/
|
||||
|
|
@ -93,7 +136,7 @@ export class AdMediationEngine {
|
|||
|
||||
const enabledAdapters = Array.from(this.adapters.values()).filter((adapter) => {
|
||||
const netConfig = this.config.networks.find((n) => n.id === adapter.networkId)
|
||||
return (netConfig ? netConfig.enabled : true) && adapter.supportedFormats.includes(request.format)
|
||||
return netConfig?.enabled === true && adapter.supportedFormats.includes(request.format)
|
||||
})
|
||||
|
||||
// Query all participating demand sources in parallel with timeout
|
||||
|
|
@ -107,11 +150,36 @@ export class AdMediationEngine {
|
|||
),
|
||||
])
|
||||
|
||||
if (
|
||||
bidResult.hasBid
|
||||
&& (
|
||||
!bidResult.creative
|
||||
|| !this.isSafeCreative(adapter, request, bidResult.bidEcpm, bidResult.creative)
|
||||
)
|
||||
) {
|
||||
return {
|
||||
networkId: adapter.networkId,
|
||||
networkName: adapter.networkName,
|
||||
bidEcpm: 0,
|
||||
creative: undefined,
|
||||
latencyMs: Date.now() - adapterStart,
|
||||
status: 'error' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const creative = bidResult.hasBid && bidResult.creative
|
||||
? {
|
||||
...bidResult.creative,
|
||||
networkName: adapter.networkName,
|
||||
bidEcpm: bidResult.bidEcpm,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
networkId: adapter.networkId,
|
||||
networkName: adapter.networkName,
|
||||
bidEcpm: bidResult.hasBid ? bidResult.bidEcpm : 0,
|
||||
creative: bidResult.creative,
|
||||
creative,
|
||||
latencyMs: Date.now() - adapterStart,
|
||||
status: (bidResult.hasBid ? 'bid' : 'no_bid') as 'bid' | 'no_bid',
|
||||
}
|
||||
|
|
@ -134,22 +202,23 @@ export class AdMediationEngine {
|
|||
.filter((b) => b.status === 'bid' && b.creative && b.bidEcpm >= floorEcpm)
|
||||
.sort((a, b) => b.bidEcpm - a.bidEcpm)
|
||||
|
||||
let winningCreative: AdCreativePayload
|
||||
|
||||
if (validBids.length > 0 && validBids[0].creative) {
|
||||
winningCreative = validBids[0].creative
|
||||
} else {
|
||||
// Fallback to Direct House Sponsor
|
||||
const houseAdapter = this.adapters.get('direct_sponsor') || new DirectHouseSponsorAdapter()
|
||||
const fallbackBid = await houseAdapter.requestBid(request)
|
||||
winningCreative = fallbackBid.creative!
|
||||
const winningCreative = validBids[0]?.creative ?? null
|
||||
if (winningCreative !== null) {
|
||||
this.deliveredCreatives.set(winningCreative.id, winningCreative)
|
||||
if (this.deliveredCreatives.size > 256) {
|
||||
const oldest = this.deliveredCreatives.keys().next().value as string | undefined
|
||||
if (oldest !== undefined) {
|
||||
this.deliveredCreatives.delete(oldest)
|
||||
this.clickedCreatives.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalLatency = Date.now() - auctionStart
|
||||
|
||||
return {
|
||||
winner: winningCreative,
|
||||
winningBidEcpm: winningCreative.bidEcpm,
|
||||
winningBidEcpm: winningCreative?.bidEcpm ?? 0,
|
||||
participatingBids: bidResults.map((b) => ({
|
||||
networkId: b.networkId,
|
||||
networkName: b.networkName,
|
||||
|
|
@ -163,8 +232,18 @@ export class AdMediationEngine {
|
|||
}
|
||||
|
||||
public recordImpression(event: Omit<AdImpressionEvent, 'timestamp'>): void {
|
||||
const delivered = this.deliveredCreatives.get(event.adId)
|
||||
if (
|
||||
delivered === undefined
|
||||
|| delivered.networkId !== event.network
|
||||
|| delivered.format !== event.format
|
||||
|| this.impressionHistory.some((candidate) => candidate.adId === event.adId)
|
||||
) return
|
||||
|
||||
const fullEvent: AdImpressionEvent = {
|
||||
...event,
|
||||
networkName: delivered.networkName,
|
||||
earnedEcpm: delivered.bidEcpm,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
this.impressionHistory.push(fullEvent)
|
||||
|
|
@ -175,10 +254,19 @@ export class AdMediationEngine {
|
|||
}
|
||||
|
||||
// Register into Settlement Ledger
|
||||
getAdSettlementService().recordImpression(event.network, event.earnedEcpm || 3.5)
|
||||
getAdSettlementService().recordImpression(event.network, delivered.bidEcpm)
|
||||
}
|
||||
|
||||
public recordClick(adId: string, networkId: string): void {
|
||||
const delivered = this.deliveredCreatives.get(adId)
|
||||
const impressed = this.impressionHistory.some((candidate) => candidate.adId === adId)
|
||||
if (
|
||||
delivered === undefined
|
||||
|| delivered.networkId !== networkId
|
||||
|| !impressed
|
||||
|| this.clickedCreatives.has(adId)
|
||||
) return
|
||||
this.clickedCreatives.add(adId)
|
||||
const adapter = this.adapters.get(networkId)
|
||||
if (adapter) {
|
||||
adapter.reportClick(adId).catch(() => {})
|
||||
|
|
@ -187,39 +275,19 @@ export class AdMediationEngine {
|
|||
}
|
||||
|
||||
public async claimReward(adId: string, networkId: string): Promise<AdRewardResult> {
|
||||
const now = Date.now()
|
||||
const cooldownMs = this.config.rewardCooldownSeconds * 1000
|
||||
|
||||
if (now - this.lastRewardTimestamp < cooldownMs) {
|
||||
const waitSeconds = Math.ceil((cooldownMs - (now - this.lastRewardTimestamp)) / 1000)
|
||||
// Desktop mediation has no server-verified completion/nonce ledger. A
|
||||
// timer, renderer-supplied ID, or adapter callback is not entitlement
|
||||
// proof, so rewards remain unavailable until that boundary exists.
|
||||
void adId
|
||||
void networkId
|
||||
return {
|
||||
success: false,
|
||||
tokensAdded: 0,
|
||||
newTotalQuota: 0,
|
||||
nextAvailableAt: now + waitSeconds * 1000,
|
||||
}
|
||||
}
|
||||
|
||||
const adapter = this.adapters.get(networkId)
|
||||
let tokenAmount = this.config.rewardTokensAmount
|
||||
|
||||
if (adapter && adapter.reportRewardCompletion) {
|
||||
const res = await adapter.reportRewardCompletion(adId)
|
||||
if (res.success && res.tokenReward) tokenAmount = res.tokenReward
|
||||
}
|
||||
|
||||
this.lastRewardTimestamp = now
|
||||
getAdSettlementService().recordCompletion(networkId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tokensAdded: tokenAmount,
|
||||
newTotalQuota: 100 + tokenAmount, // Demo / actual license service quota boost
|
||||
rewardId: `rew_${Date.now()}`,
|
||||
}
|
||||
}
|
||||
|
||||
public getRevenueStats(period = '2026-08'): AdRevenueStats {
|
||||
public getRevenueStats(period = new Date().toISOString().slice(0, 7)): AdRevenueStats {
|
||||
return getAdSettlementService().getRevenueStats(period)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,23 +2,22 @@
|
|||
// Ad Revenue Settlement, Tax Withholding & Payout Ledger Service
|
||||
|
||||
import type {
|
||||
AdSettlementRecord,
|
||||
AdRevenueStats,
|
||||
AdSettlementRecord,
|
||||
PublisherAccountConfig,
|
||||
AdNetworkId,
|
||||
} from '@d3ro/core/types'
|
||||
|
||||
export class AdSettlementService {
|
||||
private static instance: AdSettlementService | null = null
|
||||
|
||||
private publisherAccount: PublisherAccountConfig = {
|
||||
accountEmail: 'yunchanpaca@gmail.com',
|
||||
beneficiaryName: 'D3RO Voice AI',
|
||||
payoutBank: 'KB국민은행 (Kookmin Bank)',
|
||||
payoutAccountNumber: '928702-00-184920',
|
||||
taxRegistrationNumber: '120-88-01923',
|
||||
paypalEmail: 'yunchanpaca@gmail.com',
|
||||
networksConfigured: 10,
|
||||
accountEmail: '',
|
||||
beneficiaryName: '',
|
||||
payoutBank: '',
|
||||
payoutAccountNumber: '',
|
||||
taxRegistrationNumber: '',
|
||||
paypalEmail: '',
|
||||
networksConfigured: 0,
|
||||
}
|
||||
|
||||
// Network counters for current cycle
|
||||
|
|
@ -27,11 +26,7 @@ export class AdSettlementService {
|
|||
{ impressions: number; clicks: number; completions: number; grossUsd: number }
|
||||
> = new Map()
|
||||
|
||||
private settlements: AdSettlementRecord[] = []
|
||||
|
||||
private constructor() {
|
||||
this.seedInitialSettlementHistory()
|
||||
}
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): AdSettlementService {
|
||||
if (!AdSettlementService.instance) {
|
||||
|
|
@ -40,51 +35,8 @@ export class AdSettlementService {
|
|||
return AdSettlementService.instance
|
||||
}
|
||||
|
||||
private seedInitialSettlementHistory(): void {
|
||||
const networks: Array<{ id: AdNetworkId; name: string; imp: number; ecpm: number }> = [
|
||||
{ id: 'direct_sponsor', name: 'Direct House Sponsor (Cursor/Notion)', imp: 84000, ecpm: 15.2 },
|
||||
{ id: 'playwire', name: 'Playwire RAMP Desktop Header Bidding', imp: 62000, ecpm: 8.4 },
|
||||
{ id: 'applovin_max', name: 'AppLovin MAX In-App Bidding', imp: 48000, ecpm: 7.8 },
|
||||
{ id: 'unity_ads', name: 'Unity LevelPlay Rewarded Video', imp: 45000, ecpm: 9.1 },
|
||||
{ id: 'ethical_ads', name: 'EthicalAds Privacy-First Dev Network', imp: 38000, ecpm: 3.8 },
|
||||
{ id: 'carbon_ads', name: 'Carbon Ads (BuySellAds)', imp: 31000, ecpm: 4.2 },
|
||||
{ id: 'google_ad_manager', name: 'Google Ad Manager 360', imp: 29000, ecpm: 3.5 },
|
||||
{ id: 'mintegral', name: 'Mintegral Global Video Network', imp: 22000, ecpm: 6.2 },
|
||||
{ id: 'inmobi', name: 'InMobi Exchange', imp: 19000, ecpm: 3.4 },
|
||||
{ id: 'pubmatic', name: 'PubMatic OpenWrap SSP', imp: 15000, ecpm: 3.6 },
|
||||
]
|
||||
|
||||
for (const net of networks) {
|
||||
const grossUsd = (net.imp / 1000) * net.ecpm
|
||||
const withholdingRate = 0.033 // 3.3% Korean Business Tax Withholding
|
||||
const netUsd = parseFloat((grossUsd * (1 - withholdingRate)).toFixed(2))
|
||||
const exchangeRate = 1350
|
||||
const netKrw = Math.round(netUsd * exchangeRate)
|
||||
|
||||
this.settlements.push({
|
||||
id: `stl_202607_${net.id}`,
|
||||
cycleMonth: '2026-07',
|
||||
networkId: net.id,
|
||||
networkName: net.name,
|
||||
impressions: net.imp,
|
||||
clicks: Math.round(net.imp * 0.032),
|
||||
completions: Math.round(net.imp * 0.15),
|
||||
avgEcpm: net.ecpm,
|
||||
grossRevenueUsd: parseFloat(grossUsd.toFixed(2)),
|
||||
withholdingTaxRate: withholdingRate,
|
||||
netRevenueUsd: netUsd,
|
||||
exchangeRateKrw: exchangeRate,
|
||||
netPayoutKrw: netKrw,
|
||||
payoutStatus: 'settled',
|
||||
paymentMethod: 'bank_wire_krw',
|
||||
beneficiaryAccount: this.publisherAccount.payoutAccountNumber,
|
||||
settledAt: Date.now() - 1000 * 60 * 60 * 24 * 10,
|
||||
invoiceNumber: `INV-202607-${net.id.toUpperCase().slice(0, 4)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public recordImpression(networkId: string, earnedEcpm: number): void {
|
||||
if (!networkId || !Number.isFinite(earnedEcpm) || earnedEcpm <= 0) return
|
||||
const cur = this.networkCounters.get(networkId) || { impressions: 0, clicks: 0, completions: 0, grossUsd: 0 }
|
||||
cur.impressions += 1
|
||||
cur.grossUsd += earnedEcpm / 1000
|
||||
|
|
@ -92,13 +44,15 @@ export class AdSettlementService {
|
|||
}
|
||||
|
||||
public recordClick(networkId: string): void {
|
||||
const cur = this.networkCounters.get(networkId) || { impressions: 0, clicks: 0, completions: 0, grossUsd: 0 }
|
||||
const cur = this.networkCounters.get(networkId)
|
||||
if (!cur || cur.clicks >= cur.impressions) return
|
||||
cur.clicks += 1
|
||||
this.networkCounters.set(networkId, cur)
|
||||
}
|
||||
|
||||
public recordCompletion(networkId: string): void {
|
||||
const cur = this.networkCounters.get(networkId) || { impressions: 0, clicks: 0, completions: 0, grossUsd: 0 }
|
||||
const cur = this.networkCounters.get(networkId)
|
||||
if (!cur || cur.completions >= cur.impressions) return
|
||||
cur.completions += 1
|
||||
this.networkCounters.set(networkId, cur)
|
||||
}
|
||||
|
|
@ -112,28 +66,32 @@ export class AdSettlementService {
|
|||
return this.getPublisherAccount()
|
||||
}
|
||||
|
||||
public getRevenueStats(period = '2026-08'): AdRevenueStats {
|
||||
public getRevenueStats(period = new Date().toISOString().slice(0, 7)): AdRevenueStats {
|
||||
let totalImp = 0
|
||||
let totalClicks = 0
|
||||
let totalCompletions = 0
|
||||
let totalGrossUsd = 0
|
||||
|
||||
for (const record of this.settlements) {
|
||||
totalImp += record.impressions
|
||||
totalClicks += record.clicks
|
||||
totalCompletions += record.completions
|
||||
totalGrossUsd += record.grossRevenueUsd
|
||||
for (const counters of this.networkCounters.values()) {
|
||||
totalImp += counters.impressions
|
||||
totalClicks += counters.clicks
|
||||
totalCompletions += counters.completions
|
||||
totalGrossUsd += counters.grossUsd
|
||||
}
|
||||
|
||||
const avgEcpm = totalImp > 0 ? (totalGrossUsd / totalImp) * 1000 : 5.84
|
||||
|
||||
const networkBreakdown = this.settlements.map((s) => ({
|
||||
network: s.networkName,
|
||||
impressions: s.impressions,
|
||||
revenueUsd: s.grossRevenueUsd,
|
||||
ecpm: s.avgEcpm,
|
||||
fillRate: 98.4,
|
||||
}))
|
||||
const avgEcpm = totalImp > 0 ? (totalGrossUsd / totalImp) * 1000 : 0
|
||||
const networkBreakdown = Array.from(this.networkCounters.entries()).map(
|
||||
([network, counters]) => ({
|
||||
network,
|
||||
impressions: counters.impressions,
|
||||
revenueUsd: parseFloat(counters.grossUsd.toFixed(6)),
|
||||
ecpm: counters.impressions > 0
|
||||
? parseFloat(((counters.grossUsd / counters.impressions) * 1000).toFixed(2))
|
||||
: 0,
|
||||
// The desktop shell has no authoritative request/no-fill ledger yet.
|
||||
fillRate: 0,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
period,
|
||||
|
|
@ -142,24 +100,15 @@ export class AdSettlementService {
|
|||
totalCompletions: totalCompletions,
|
||||
totalRevenueUsd: parseFloat(totalGrossUsd.toFixed(2)),
|
||||
avgEcpm: parseFloat(avgEcpm.toFixed(2)),
|
||||
fillRatePercent: 98.6,
|
||||
fillRatePercent: 0,
|
||||
networkBreakdown,
|
||||
settlements: [...this.settlements],
|
||||
settlements: [],
|
||||
}
|
||||
}
|
||||
|
||||
public requestPayout(settlementId: string): { success: boolean; message: string; settlement?: AdSettlementRecord } {
|
||||
const found = this.settlements.find((s) => s.id === settlementId)
|
||||
if (!found) {
|
||||
return { success: false, message: 'Settlement record not found.' }
|
||||
}
|
||||
found.payoutStatus = 'paid'
|
||||
found.settledAt = Date.now()
|
||||
return {
|
||||
success: true,
|
||||
message: `정산금 ₩${found.netPayoutKrw.toLocaleString()}이 ${this.publisherAccount.payoutBank} (${this.publisherAccount.payoutAccountNumber})으로 성공적으로 입금 신청되었습니다. (원천징수 영수증 발급 완료)`,
|
||||
settlement: found,
|
||||
}
|
||||
void settlementId
|
||||
return { success: false, message: 'external_settlement_not_configured' }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +1,12 @@
|
|||
// apps/desktop/src/main/services/ads/AppLovinAdapter.ts
|
||||
// AppLovin MAX Programmatic Bidding Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class AppLovinAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'applovin_max'
|
||||
readonly networkName = 'AppLovin MAX (Real-Time In-App Bidding)'
|
||||
readonly supportedFormats: AdFormat[] = ['rewarded_video', 'banner_dock', 'export_sponsor']
|
||||
readonly defaultFloorEcpm = 5.5
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
const baseEcpm = request.format === 'rewarded_video' ? 11.5 : 4.5
|
||||
const ecpm = baseEcpm + Math.random() * 5.0 // Competitive bid
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `max_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'Grammarly AI — Write with Confidence Across All Apps',
|
||||
description: 'Real-time AI suggestions, tone adjustments, and grammar correction.',
|
||||
ctaText: 'Get Grammarly Free',
|
||||
clickUrl: 'https://grammarly.com?utm_source=applovin',
|
||||
sponsorTag: 'AppLovin MAX',
|
||||
advertiserName: 'Grammarly',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
rewardTokens: request.format === 'rewarded_video' ? 50 : undefined,
|
||||
durationSeconds: 15,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 60,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class AppLovinAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super(
|
||||
'applovin_max',
|
||||
'AppLovin MAX',
|
||||
['rewarded_video', 'banner_dock', 'export_sponsor'],
|
||||
5.5,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,7 @@
|
|||
// apps/desktop/src/main/services/ads/CarbonAdsAdapter.ts
|
||||
// BuySellAds / Carbon Ads Curated Tech Single-Unit Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class CarbonAdsAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'carbon_ads'
|
||||
readonly networkName = 'Carbon Ads (BuySellAds Tech Network)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'sidebar_sponsor_card' as any]
|
||||
readonly defaultFloorEcpm = 3.5
|
||||
|
||||
private placement = 'd3rovoice'
|
||||
|
||||
async init(config?: { placement?: string }): Promise<void> {
|
||||
if (config?.placement) this.placement = config.placement
|
||||
}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
const ecpm = 3.8 + Math.random() * 2.2 // $3.80 - $6.00 eCPM
|
||||
const creative: AdCreativePayload = {
|
||||
id: `carbon_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'Linear — The issue tracking tool you will actually love',
|
||||
description: 'Streamline software projects, sprints, tasks, and bug tracking at high speed.',
|
||||
ctaText: 'Try Linear',
|
||||
iconUrl: 'https://cdn.carbonads.com/carbon_linear_logo.png',
|
||||
clickUrl: 'https://linear.app?ref=carbon',
|
||||
sponsorTag: 'Carbon Ads',
|
||||
advertiserName: 'Linear',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 52,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {
|
||||
// Carbon impression beacon
|
||||
}
|
||||
|
||||
async reportClick(adId: string): Promise<void> {
|
||||
// Carbon click beacon
|
||||
export class CarbonAdsAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super('carbon_ads', 'Carbon Ads', ['banner_dock'], 3.5)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,113 +1,12 @@
|
|||
// apps/desktop/src/main/services/ads/DirectHouseSponsorAdapter.ts
|
||||
// Direct House Sponsor Engine (Highest margin, premium AI/developer partnerships)
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
interface HouseSponsorCreative {
|
||||
title: string
|
||||
description: string
|
||||
ctaText: string
|
||||
clickUrl: string
|
||||
sponsorTag: string
|
||||
advertiserName: string
|
||||
bidEcpm: number
|
||||
format: AdFormat
|
||||
iconUrl?: string
|
||||
}
|
||||
|
||||
export class DirectHouseSponsorAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'direct_sponsor'
|
||||
readonly networkName = 'Direct House Sponsor Engine (100% Margin)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video', 'export_sponsor']
|
||||
readonly defaultFloorEcpm = 12.0
|
||||
|
||||
private sponsors: HouseSponsorCreative[] = [
|
||||
{
|
||||
title: 'Cursor AI — Next-Gen AI Code Editor',
|
||||
description: 'Build software with intelligent voice agents & lightning-speed code search.',
|
||||
ctaText: 'Learn More',
|
||||
clickUrl: 'https://cursor.com',
|
||||
sponsorTag: 'Direct Partner',
|
||||
advertiserName: 'Cursor AI',
|
||||
bidEcpm: 15.5,
|
||||
format: 'banner_dock',
|
||||
},
|
||||
{
|
||||
title: 'ElevenLabs — Human-like Voice AI & Speech Synthesis',
|
||||
description: 'Industry-leading emotional AI voices for creators, developers, and games.',
|
||||
ctaText: 'Try Voice AI',
|
||||
clickUrl: 'https://elevenlabs.io',
|
||||
sponsorTag: 'Direct Partner',
|
||||
advertiserName: 'ElevenLabs',
|
||||
bidEcpm: 18.0,
|
||||
format: 'rewarded_video',
|
||||
},
|
||||
{
|
||||
title: 'Perplexity Pro — Where Knowledge Begins',
|
||||
description: 'Instant answers with citations, source tracking, and multi-model research.',
|
||||
ctaText: 'Try Perplexity',
|
||||
clickUrl: 'https://perplexity.ai',
|
||||
sponsorTag: 'Direct Partner',
|
||||
advertiserName: 'Perplexity AI',
|
||||
bidEcpm: 14.2,
|
||||
format: 'banner_dock',
|
||||
},
|
||||
{
|
||||
title: 'Notion AI — Connected Workspace for Documents & Notes',
|
||||
description: 'Summarize meeting audio, manage tasks, and organize thoughts in one canvas.',
|
||||
ctaText: 'Get Notion Free',
|
||||
clickUrl: 'https://notion.so',
|
||||
sponsorTag: 'Direct Partner',
|
||||
advertiserName: 'Notion Labs',
|
||||
bidEcpm: 13.5,
|
||||
format: 'export_sponsor',
|
||||
},
|
||||
]
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
const matching = this.sponsors.filter((s) => s.format === request.format)
|
||||
if (matching.length === 0) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 2 }
|
||||
}
|
||||
|
||||
// Pick rotating sponsor
|
||||
const picked = matching[Math.floor(Math.random() * matching.length)]
|
||||
const creative: AdCreativePayload = {
|
||||
id: `house_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: picked.title,
|
||||
description: picked.description,
|
||||
ctaText: picked.ctaText,
|
||||
clickUrl: picked.clickUrl,
|
||||
sponsorTag: picked.sponsorTag,
|
||||
advertiserName: picked.advertiserName,
|
||||
bidEcpm: picked.bidEcpm,
|
||||
format: picked.format,
|
||||
rewardTokens: picked.format === 'rewarded_video' ? 50 : undefined,
|
||||
durationSeconds: picked.format === 'rewarded_video' ? 15 : undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 8, // Near zero latency
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class DirectHouseSponsorAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super(
|
||||
'direct_sponsor',
|
||||
'Direct House Sponsor',
|
||||
['banner_dock', 'rewarded_video', 'export_sponsor'],
|
||||
12,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,7 @@
|
|||
// apps/desktop/src/main/services/ads/EthicalAdsAdapter.ts
|
||||
// Privacy-First Developer Native Ad Network Adapter (REST Decision API /api/v1/decision/)
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class EthicalAdsAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'ethical_ads'
|
||||
readonly networkName = 'EthicalAds (Privacy-First Dev Network)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'export_sponsor']
|
||||
readonly defaultFloorEcpm = 3.2
|
||||
|
||||
private publisherId = 'd3ro-voice'
|
||||
|
||||
async init(config?: { publisherId?: string }): Promise<void> {
|
||||
if (config?.publisherId) this.publisherId = config.publisherId
|
||||
}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
try {
|
||||
// EthicalAds developer ads simulation & real JSON endpoint fallback
|
||||
const ecpm = 3.2 + Math.random() * 1.5 // $3.20 - $4.70 eCPM
|
||||
const creative: AdCreativePayload = {
|
||||
id: `ea_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'MongoDB Atlas — The Multi-Cloud Developer Data Platform',
|
||||
description: 'Build fast with automated scaling, vector search, and global clusters.',
|
||||
ctaText: 'Deploy Free',
|
||||
iconUrl: 'https://media.ethicalads.io/media/images/2024/02/mongodb_icon.png',
|
||||
clickUrl: 'https://www.mongodb.com/cloud/atlas/register?utm_source=ethicalads',
|
||||
sponsorTag: 'EthicalAd • Privacy Verified',
|
||||
advertiserName: 'MongoDB',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 45,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
hasBid: false,
|
||||
bidEcpm: 0,
|
||||
latencyMs: Date.now() - startTime,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {
|
||||
// console.log(`[EthicalAds] Impression recorded for ${adId}`)
|
||||
}
|
||||
|
||||
async reportClick(adId: string): Promise<void> {
|
||||
// console.log(`[EthicalAds] Click recorded for ${adId}`)
|
||||
export class EthicalAdsAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super('ethical_ads', 'EthicalAds', ['banner_dock', 'export_sponsor'], 3.2)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,12 @@
|
|||
// apps/desktop/src/main/services/ads/GoogleAdManagerAdapter.ts
|
||||
// Google Ad Manager 360 / AdMob Universal Global Demand Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class GoogleAdManagerAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'google_ad_manager'
|
||||
readonly networkName = 'Google Ad Manager 360 (Global Demand)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video', 'export_sponsor']
|
||||
readonly defaultFloorEcpm = 2.0
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
// High 99%+ fill rate, stable eCPM
|
||||
const baseEcpm = request.format === 'rewarded_video' ? 7.2 : 3.0
|
||||
const ecpm = baseEcpm + Math.random() * 2.0
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `gam_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'Google Cloud Vertex AI — Build & Scale Generative AI Apps',
|
||||
description: 'Access Gemini 1.5 Pro, customized embeddings, and enterprise search.',
|
||||
ctaText: 'Explore Cloud',
|
||||
clickUrl: 'https://cloud.google.com/vertex-ai',
|
||||
sponsorTag: 'Google Ad Manager',
|
||||
advertiserName: 'Google Cloud',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
rewardTokens: request.format === 'rewarded_video' ? 50 : undefined,
|
||||
durationSeconds: 15,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 40,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class GoogleAdManagerAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super(
|
||||
'google_ad_manager',
|
||||
'Google Ad Manager',
|
||||
['banner_dock', 'rewarded_video', 'export_sponsor'],
|
||||
2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,58 +1,7 @@
|
|||
// apps/desktop/src/main/services/ads/InMobiAdapter.ts
|
||||
// InMobi Programmatic Demand & Mobile/Hybrid Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class InMobiAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'inmobi'
|
||||
readonly networkName = 'InMobi (Programmatic Exchange)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video']
|
||||
readonly defaultFloorEcpm = 2.8
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
const baseEcpm = request.format === 'rewarded_video' ? 6.8 : 3.4
|
||||
const ecpm = baseEcpm + Math.random() * 2.2
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `inmobi_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'NordVPN — Secure Your Data with Next-Gen Encryption',
|
||||
description: 'Ultra-fast VPN protection across all your desktop and mobile devices.',
|
||||
ctaText: 'Get 70% Off',
|
||||
clickUrl: 'https://nordvpn.com?utm_source=inmobi',
|
||||
sponsorTag: 'InMobi Exchange',
|
||||
advertiserName: 'Nord Security',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
rewardTokens: 50,
|
||||
durationSeconds: 15,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 50,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class InMobiAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super('inmobi', 'InMobi', ['banner_dock', 'rewarded_video'], 2.8)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,58 +1,7 @@
|
|||
// apps/desktop/src/main/services/ads/MintegralAdapter.ts
|
||||
// Mintegral Global / APAC Rewarded Video & Interstitial Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class MintegralAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'mintegral'
|
||||
readonly networkName = 'Mintegral (APAC & Global Video Network)'
|
||||
readonly supportedFormats: AdFormat[] = ['rewarded_video', 'banner_dock']
|
||||
readonly defaultFloorEcpm = 4.0
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
const baseEcpm = request.format === 'rewarded_video' ? 8.8 : 3.8
|
||||
const ecpm = baseEcpm + Math.random() * 3.2
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `mintegral_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'Canva Pro — Design Anything with Team Collaboration',
|
||||
description: 'Create presentations, graphics, and video with easy AI magic tools.',
|
||||
ctaText: 'Try Canva Free',
|
||||
clickUrl: 'https://canva.com?ref=mintegral',
|
||||
sponsorTag: 'Mintegral Video',
|
||||
advertiserName: 'Canva',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
rewardTokens: 50,
|
||||
durationSeconds: 15,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 52,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class MintegralAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super('mintegral', 'Mintegral', ['rewarded_video', 'banner_dock'], 4)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,12 @@
|
|||
// apps/desktop/src/main/services/ads/PlaywireAdapter.ts
|
||||
// Playwire Desktop Application Programmatic Header Bidding Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class PlaywireAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'playwire'
|
||||
readonly networkName = 'Playwire RAMP (Desktop Header Bidding)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video', 'export_sponsor']
|
||||
readonly defaultFloorEcpm = 4.5
|
||||
|
||||
async init(): Promise<void> {
|
||||
// Initialize Playwire RAMP desktop runtime
|
||||
}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
// Playwire high-tier programmatic bidding: $4.50 - $11.00 eCPM
|
||||
const baseEcpm = request.format === 'rewarded_video' ? 8.5 : 4.8
|
||||
const ecpm = baseEcpm + Math.random() * 3.5
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `playwire_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'AWS Cloud — Scalable AI & Machine Learning Infrastructure',
|
||||
description: 'Train models and deploy high-performance applications on AWS Bedrock.',
|
||||
ctaText: 'Start Free Trial',
|
||||
clickUrl: 'https://aws.amazon.com/free/?utm_source=playwire',
|
||||
sponsorTag: 'Playwire Programmatic',
|
||||
advertiserName: 'Amazon Web Services',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
rewardTokens: request.format === 'rewarded_video' ? 50 : undefined,
|
||||
durationSeconds: request.format === 'rewarded_video' ? 15 : undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 68,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class PlaywireAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super(
|
||||
'playwire',
|
||||
'Playwire RAMP',
|
||||
['banner_dock', 'rewarded_video', 'export_sponsor'],
|
||||
4.5,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,7 @@
|
|||
// apps/desktop/src/main/services/ads/PubMaticAdapter.ts
|
||||
// PubMatic OpenWrap Header Bidding SSP Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class PubMaticAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'pubmatic'
|
||||
readonly networkName = 'PubMatic OpenWrap (Enterprise SSP)'
|
||||
readonly supportedFormats: AdFormat[] = ['banner_dock', 'export_sponsor']
|
||||
readonly defaultFloorEcpm = 3.0
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
export class PubMaticAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super('pubmatic', 'PubMatic OpenWrap', ['banner_dock', 'export_sponsor'], 3)
|
||||
}
|
||||
|
||||
const ecpm = 3.6 + Math.random() * 2.5
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `pubmatic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'Datadog — Cloud Monitoring, APM & Security in One Platform',
|
||||
description: 'See metrics, traces, and logs from your entire technology stack.',
|
||||
ctaText: 'Start Monitoring',
|
||||
clickUrl: 'https://datadoghq.com?utm_source=pubmatic',
|
||||
sponsorTag: 'PubMatic OpenWrap',
|
||||
advertiserName: 'Datadog',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 58,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
}
|
||||
|
|
|
|||
37
apps/desktop/src/main/services/ads/UnavailableAdAdapter.ts
Normal file
37
apps/desktop/src/main/services/ads/UnavailableAdAdapter.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { AdFormat, AdMediationAuctionRequest, AdNetworkId } from '@d3ro/core/types'
|
||||
import type { AdBidResponse, IAdNetworkAdapter } from './BaseAdAdapter'
|
||||
|
||||
/**
|
||||
* Fail-closed boundary for providers whose official desktop SDK or
|
||||
* authenticated decision endpoint is not integrated. Demo creatives are not
|
||||
* ads, so this adapter deliberately returns no bid and never grants rewards.
|
||||
*/
|
||||
export class UnavailableAdAdapter implements IAdNetworkAdapter {
|
||||
constructor(
|
||||
readonly networkId: AdNetworkId,
|
||||
readonly networkName: string,
|
||||
readonly supportedFormats: AdFormat[],
|
||||
readonly defaultFloorEcpm: number,
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(_request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
return {
|
||||
hasBid: false,
|
||||
bidEcpm: 0,
|
||||
latencyMs: 0,
|
||||
error: 'provider_not_integrated',
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(_adId: string): Promise<void> {}
|
||||
|
||||
async reportClick(_adId: string): Promise<void> {}
|
||||
|
||||
async reportRewardCompletion(
|
||||
_adId: string,
|
||||
): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: false, tokenReward: 0 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +1,7 @@
|
|||
// apps/desktop/src/main/services/ads/UnityAdsAdapter.ts
|
||||
// Unity Ads / Unity LevelPlay Rewarded Video Adapter
|
||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdMediationAuctionRequest,
|
||||
AdCreativePayload,
|
||||
} from '@d3ro/core/types'
|
||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
||||
|
||||
export class UnityAdsAdapter implements IAdNetworkAdapter {
|
||||
readonly networkId: AdNetworkId = 'unity_ads'
|
||||
readonly networkName = 'Unity LevelPlay (Rewarded Video & Bidding)'
|
||||
readonly supportedFormats: AdFormat[] = ['rewarded_video', 'banner_dock']
|
||||
readonly defaultFloorEcpm = 6.0
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||
const startTime = Date.now()
|
||||
if (!this.supportedFormats.includes(request.format)) {
|
||||
return { hasBid: false, bidEcpm: 0, latencyMs: 5 }
|
||||
}
|
||||
|
||||
const baseEcpm = request.format === 'rewarded_video' ? 10.2 : 4.0
|
||||
const ecpm = baseEcpm + Math.random() * 4.0 // High yield rewarded video
|
||||
|
||||
const creative: AdCreativePayload = {
|
||||
id: `unity_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
networkId: this.networkId,
|
||||
networkName: this.networkName,
|
||||
title: 'Unity Engine — Create & Grow Real-Time 3D Experiences',
|
||||
description: 'The industry-standard game engine for multi-platform interactive applications.',
|
||||
ctaText: 'Download Unity',
|
||||
clickUrl: 'https://unity.com/download',
|
||||
sponsorTag: 'Unity Ads',
|
||||
advertiserName: 'Unity Technologies',
|
||||
bidEcpm: parseFloat(ecpm.toFixed(2)),
|
||||
format: request.format,
|
||||
rewardTokens: 50,
|
||||
durationSeconds: 15,
|
||||
}
|
||||
|
||||
return {
|
||||
hasBid: true,
|
||||
bidEcpm: creative.bidEcpm,
|
||||
creative,
|
||||
latencyMs: Date.now() - startTime + 55,
|
||||
}
|
||||
}
|
||||
|
||||
async reportImpression(adId: string): Promise<void> {}
|
||||
async reportClick(adId: string): Promise<void> {}
|
||||
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||
return { success: true, tokenReward: 50 }
|
||||
export class UnityAdsAdapter extends UnavailableAdAdapter {
|
||||
constructor() {
|
||||
super('unity_ads', 'Unity LevelPlay', ['rewarded_video', 'banner_dock'], 6)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import type {
|
|||
TestSTTConnectionResult,
|
||||
STTStatus,
|
||||
} from '@d3ro/core/types'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { ISTTDriver } from './types'
|
||||
import { OpenAIDriver } from './drivers/OpenAIDriver'
|
||||
import { GroqDriver } from './drivers/GroqDriver'
|
||||
|
|
|
|||
|
|
@ -1,147 +1,173 @@
|
|||
// apps/desktop/src/main/services/stt/drivers/D3ROCloudDriver.ts
|
||||
// D3RO Voice Cloud STT Gateway 드라이버
|
||||
// 사용자는 별도 API 키 설정 없이 D3RO 클라우드 서비스를 통해 관리자 설정 프로바이더로 전사 처리
|
||||
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { ISTTDriver } from '../types'
|
||||
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
|
||||
import type { STTProviderConfig } from '@d3ro/core/types'
|
||||
import { pcmToWav, createProbeWav } from '../audio-utils'
|
||||
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
|
||||
import { getCloudSyncService } from '../../CloudSyncService'
|
||||
import { getLogger } from '../../LoggerService'
|
||||
import { configGet } from '../../ConfigService'
|
||||
import { pcmToWav } from '../audio-utils'
|
||||
import type { ISTTDriver } from '../types'
|
||||
|
||||
const logger = getLogger('D3ROCloudDriver')
|
||||
|
||||
export interface CloudSttGateway {
|
||||
getAccessToken(): Promise<string | null>
|
||||
getSupabaseUrl(): string | null
|
||||
getAnonKey(): string | null
|
||||
}
|
||||
|
||||
interface CloudSttPayload {
|
||||
transcript?: unknown
|
||||
confidence?: unknown
|
||||
language_code?: unknown
|
||||
duration_seconds?: unknown
|
||||
provider?: unknown
|
||||
}
|
||||
|
||||
function parseCloudSttPayload(value: unknown): {
|
||||
transcript: string
|
||||
confidence: number
|
||||
language: string
|
||||
duration: number
|
||||
} {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 응답이 올바르지 않습니다.')
|
||||
}
|
||||
const payload = value as CloudSttPayload
|
||||
if (typeof payload.transcript === 'string' && !payload.transcript.trim()) {
|
||||
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
|
||||
}
|
||||
if (
|
||||
typeof payload.transcript !== 'string'
|
||||
|| !payload.transcript.trim()
|
||||
|| payload.transcript.length > 1_000_000
|
||||
|| typeof payload.confidence !== 'number'
|
||||
|| !Number.isFinite(payload.confidence)
|
||||
|| payload.confidence < 0
|
||||
|| payload.confidence > 1
|
||||
|| typeof payload.language_code !== 'string'
|
||||
|| !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/.test(payload.language_code)
|
||||
|| typeof payload.duration_seconds !== 'number'
|
||||
|| !Number.isFinite(payload.duration_seconds)
|
||||
|| payload.duration_seconds < 0
|
||||
|| payload.duration_seconds > 24 * 60 * 60
|
||||
|| typeof payload.provider !== 'string'
|
||||
|| !/^[a-z0-9._-]{1,64}$/.test(payload.provider)
|
||||
) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 응답이 올바르지 않습니다.')
|
||||
}
|
||||
return {
|
||||
transcript: payload.transcript.trim(),
|
||||
confidence: payload.confidence,
|
||||
language: payload.language_code,
|
||||
duration: payload.duration_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
function edgeEndpoint(supabaseUrl: string): string {
|
||||
try {
|
||||
const endpoint = new URL('/functions/v1/stt-proxy', supabaseUrl)
|
||||
const localHttp = endpoint.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(endpoint.hostname)
|
||||
if ((endpoint.protocol !== 'https:' && !localHttp) || endpoint.username || endpoint.password) {
|
||||
throw new Error('invalid endpoint')
|
||||
}
|
||||
return endpoint.toString()
|
||||
} catch {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 서버 설정이 올바르지 않습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
export class D3ROCloudDriver implements ISTTDriver {
|
||||
readonly id = 'd3ro-cloud' as const
|
||||
readonly name = 'D3RO Cloud STT (Managed)'
|
||||
|
||||
constructor(private readonly cloud: CloudSttGateway = getCloudSyncService()) {}
|
||||
|
||||
async transcribe(
|
||||
audioBuffer: Buffer,
|
||||
options?: TranscribeOptions,
|
||||
config?: STTProviderConfig
|
||||
config?: STTProviderConfig,
|
||||
): Promise<TranscriptionResult> {
|
||||
const apiBase = (config?.baseUrl || configGet('cloudApiUrl') || process.env.D3RO_API_URL || 'http://localhost:5000').replace(/\/+$/, '')
|
||||
const endpoint = `${apiBase}/api/stt/transcribe`
|
||||
const startTime = Date.now()
|
||||
void config
|
||||
if (!Buffer.isBuffer(audioBuffer) || audioBuffer.length < 1) {
|
||||
throw new D3ROError(ErrorCode.STTNoAudioData, '전사할 오디오가 없습니다.')
|
||||
}
|
||||
const token = await this.cloud.getAccessToken()
|
||||
const supabaseUrl = this.cloud.getSupabaseUrl()
|
||||
const anonKey = this.cloud.getAnonKey()
|
||||
if (!token || !supabaseUrl || !anonKey) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT는 로그인과 Supabase 설정이 필요합니다.')
|
||||
}
|
||||
|
||||
const wavBuffer = pcmToWav(audioBuffer, 16000, 1, 16)
|
||||
const arrayBuf = wavBuffer.buffer.slice(
|
||||
const arrayBuffer = wavBuffer.buffer.slice(
|
||||
wavBuffer.byteOffset,
|
||||
wavBuffer.byteOffset + wavBuffer.byteLength
|
||||
wavBuffer.byteOffset + wavBuffer.byteLength,
|
||||
) as ArrayBuffer
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'recording.wav')
|
||||
|
||||
formData.append('audio', new Blob([arrayBuffer], { type: 'audio/wav' }), 'recording.wav')
|
||||
if (options?.language && options.language !== 'auto') {
|
||||
formData.append('language', options.language)
|
||||
}
|
||||
if (options?.initialPrompt) {
|
||||
formData.append('prompt', options.initialPrompt)
|
||||
}
|
||||
if (config?.modelId && config.modelId !== 'default') {
|
||||
formData.append('model', config.modelId)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
const token = config?.apiKey || (configGet('cloudAuthToken') as string | undefined)
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
formData.append('language_code', options.language)
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
logger.info(`Sending audio to D3RO Cloud STT Gateway: ${endpoint}`)
|
||||
const response = await fetch(endpoint, {
|
||||
logger.info('Sending audio to authenticated D3RO Cloud STT Edge gateway')
|
||||
const response = await fetch(edgeEndpoint(supabaseUrl), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text()
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 오류 (${response.status}): ${errorBody}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
text?: string
|
||||
language?: string
|
||||
durationSeconds?: number
|
||||
provider?: string
|
||||
latencyMs?: number
|
||||
}
|
||||
|
||||
const rawText = data.text?.trim() ?? ''
|
||||
if (!rawText) {
|
||||
throw new D3ROError(ErrorCode.STTNoAudioData, '인식된 음성이 없습니다.')
|
||||
}
|
||||
|
||||
const processingTime = Date.now() - startTime
|
||||
const duration = data.durationSeconds || Math.round(audioBuffer.length / 2 / 16000)
|
||||
|
||||
return {
|
||||
text: rawText,
|
||||
language: data.language || options?.language || 'ko',
|
||||
duration,
|
||||
processingTime,
|
||||
segments: [
|
||||
{
|
||||
text: rawText,
|
||||
start: 0,
|
||||
end: duration,
|
||||
confidence: 0.98,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
apikey: anonKey,
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof D3ROError) throw err
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
logger.error('D3RO Cloud driver transcribe error:', msg)
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 전사 실패: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
|
||||
const apiBase = (config.baseUrl || configGet('cloudApiUrl') || 'http://localhost:5000').replace(/\/+$/, '')
|
||||
const endpoint = `${apiBase}/api/stt/test`
|
||||
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
if (config.apiKey) {
|
||||
headers.Authorization = `Bearer ${config.apiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
})
|
||||
|
||||
const latencyMs = Date.now() - startTime
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 요청 실패 (${response.status})`)
|
||||
}
|
||||
const result = parseCloudSttPayload(await response.json().catch(() => null))
|
||||
return {
|
||||
success: true,
|
||||
latencyMs: data.latencyMs || latencyMs,
|
||||
message: data.message || `D3RO Cloud STT 연결 성공 (${latencyMs}ms)`,
|
||||
text: result.transcript,
|
||||
language: result.language,
|
||||
duration: result.duration,
|
||||
processingTime: Date.now() - startedAt,
|
||||
segments: [{
|
||||
text: result.transcript,
|
||||
start: 0,
|
||||
end: result.duration,
|
||||
confidence: result.confidence,
|
||||
}],
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof D3ROError) throw error
|
||||
logger.error('D3RO Cloud driver transcribe error')
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 전사에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
async testConnection(_config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
const token = await this.cloud.getAccessToken()
|
||||
const supabaseUrl = this.cloud.getSupabaseUrl()
|
||||
const anonKey = this.cloud.getAnonKey()
|
||||
if (!token || !supabaseUrl || !anonKey) throw new Error('로그인 또는 Supabase 설정이 없습니다.')
|
||||
const response = await fetch(edgeEndpoint(supabaseUrl), {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, apikey: anonKey },
|
||||
body: new FormData(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
const latencyMs = Date.now() - startedAt
|
||||
// The empty authenticated probe must be rejected before provider work,
|
||||
// proving reachability without consuming quota or incurring provider cost.
|
||||
if ([400, 413, 415].includes(response.status)) {
|
||||
return { success: true, latencyMs, message: `D3RO Cloud STT 인증 경로 준비됨 (${latencyMs}ms)` }
|
||||
}
|
||||
return { success: false, latencyMs, message: `D3RO Cloud 연결 실패 (HTTP ${response.status})` }
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs,
|
||||
message: `D3RO Cloud 연결 실패 (HTTP ${response.status}): ${text.slice(0, 100)}`,
|
||||
}
|
||||
} catch (err) {
|
||||
const latencyMs = Date.now() - startTime
|
||||
return {
|
||||
success: false,
|
||||
latencyMs,
|
||||
message: `D3RO Cloud 연결 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
message: 'D3RO Cloud 연결 또는 인증에 실패했습니다.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@
|
|||
//
|
||||
// 빈 문자열이면 UpdateService가 비활성 상태로 동작한다.
|
||||
// d3r0/voice 프로젝트 ID = 1172. electron-builder.yml publish.url과 동일 값 유지.
|
||||
// 버전 없는 `latest` 패키지를 가리켜야 기존 설치본이 새 릴리스를 계속 찾을 수 있다.
|
||||
export const UPDATE_FEED_URL =
|
||||
'https://d3ro.chanpaca.net/releases/1.0.0'
|
||||
'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest'
|
||||
|
|
|
|||
|
|
@ -94,7 +94,13 @@ export function createMainWindow(): BrowserWindow {
|
|||
minHeight: 600,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
frame: true,
|
||||
// 보더리스: 렌더러의 커스텀 TitleBar(AppLayout 최상단)가 크롬을 대체한다.
|
||||
// macOS는 hiddenInset으로 네이티브 교통신호(traffic lights)를 콘텐츠 위에
|
||||
// 인셋 유지 — 이 플랫폼에서는 커스텀 최대/최소/닫기 버튼을 렌더러에서 숨긴다.
|
||||
...(process.platform === 'darwin'
|
||||
? { titleBarStyle: 'hiddenInset' as const, trafficLightPosition: { x: 16, y: 13 } }
|
||||
: { frame: false }),
|
||||
roundedCorners: true,
|
||||
backgroundColor: '#0a0e1c',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ import type {
|
|||
SetVoiceCommandKeywordsParams,
|
||||
SetVoiceCommandEnabledParams,
|
||||
CaptureContextResult,
|
||||
ScreenContext,
|
||||
LLMChain,
|
||||
CreateChainParams,
|
||||
UpdateChainParams,
|
||||
|
|
@ -95,7 +94,6 @@ import type {
|
|||
CaptionState,
|
||||
CaptionSegment,
|
||||
CaptionConfig,
|
||||
CaptionSessionSummary,
|
||||
// Phase 11
|
||||
LicenseInfo,
|
||||
ActivateLicenseParams,
|
||||
|
|
@ -162,7 +160,6 @@ import type {
|
|||
MeetingDocument,
|
||||
MeetingDocTemplate,
|
||||
MeetingGenerateDocParams,
|
||||
MeetingGenerateDocResult,
|
||||
MeetingUpdateDocParams,
|
||||
MeetingDeleteDocParams,
|
||||
MeetingGetDocsParams,
|
||||
|
|
@ -845,10 +842,10 @@ const electronAPI = {
|
|||
payment: {
|
||||
createCheckoutSession: (params: import('@d3ro/core/types').CheckoutSessionParams) =>
|
||||
invoke<import('@d3ro/core/types').CheckoutSessionResult>(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, params),
|
||||
verifyPayment: (params: { tier: string }) =>
|
||||
invoke<{ success: boolean; activeTier: string }>(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT, params),
|
||||
verifyPayment: () =>
|
||||
invoke<import('@d3ro/core/types').VerifyPaymentResult>(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT),
|
||||
getSubscriptionStatus: () =>
|
||||
invoke<{ tier: string; valid: boolean; expiresAt: number | null }>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS),
|
||||
invoke<import('@d3ro/core/types').SubscriptionStatusResult>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS),
|
||||
},
|
||||
} as const
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ import {
|
|||
Library,
|
||||
Users,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Headphones,
|
||||
} from 'lucide-react'
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
||||
|
|
@ -182,7 +180,7 @@ export function AppLayout(): React.ReactElement {
|
|||
sx={{
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '20px',
|
||||
fontWeight: 800,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.02em',
|
||||
backgroundImage: d3roPalette.gradient.logo,
|
||||
backgroundClip: 'text',
|
||||
|
|
@ -198,7 +196,7 @@ export function AppLayout(): React.ReactElement {
|
|||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.08em',
|
||||
color: d3roPalette.text.dimLabel,
|
||||
textTransform: 'uppercase',
|
||||
|
|
@ -401,7 +399,6 @@ export function AppLayout(): React.ReactElement {
|
|||
onClose={() => setCheckoutModalOpen(false)}
|
||||
onSuccess={(newTier) => {
|
||||
setCurrentTier(newTier)
|
||||
setCheckoutModalOpen(false)
|
||||
}}
|
||||
/>
|
||||
<RewardedQuotaModal
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useEffect, useState } from 'react'
|
|||
import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material'
|
||||
import { Cloud, CloudCheck, Globe, GitBranch } from 'lucide-react'
|
||||
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
|
||||
interface CloudSyncState {
|
||||
authenticated: boolean
|
||||
|
|
@ -22,7 +21,6 @@ interface SyncProgress {
|
|||
}
|
||||
|
||||
export function CloudSyncSection(): React.ReactElement {
|
||||
const { t: _t } = useI18n()
|
||||
const [state, setState] = useState<CloudSyncState>({
|
||||
authenticated: false,
|
||||
userEmail: null,
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontFamily: d3roFontMono,
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
fontSize: d3roTypo.body.size,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
textTransform: 'uppercase',
|
||||
|
|
@ -91,7 +91,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
'& .MuiTab-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.label.size,
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.5px',
|
||||
color: d3roPalette.text.inactive,
|
||||
minHeight: 44,
|
||||
|
|
@ -119,7 +119,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
'& .MuiAlert-icon': { color: d3roPalette.accent.main },
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 500, mb: 0.5 }}>
|
||||
가장 빠르고 안정적인 연동 방법 (30초 소요)
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
|
||||
|
|
@ -128,7 +128,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
</Alert>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontFamily: d3roFontMono, mb: 1.5, color: d3roPalette.accent.main }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontFamily: d3roFontMono, mb: 1.5, color: d3roPalette.accent.main }}>
|
||||
[STEP-BY-STEP 발급 절차]
|
||||
</Typography>
|
||||
|
||||
|
|
@ -143,7 +143,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
1단계: OpenAI Platform 대시보드 열기
|
||||
</Typography>
|
||||
<Button
|
||||
|
|
@ -154,7 +154,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
|
|
@ -177,7 +177,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
borderRadius: d3roRadius.sm,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.5 }}>
|
||||
2단계: 새 비밀 키(Secret Key) 생성 및 복사
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive, display: 'block', mb: 1 }}>
|
||||
|
|
@ -204,7 +204,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
borderRadius: d3roRadius.sm,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.5 }}>
|
||||
3단계: D3RO-Voice STT 설정에 붙여넣기 및 연결 테스트
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
||||
|
|
@ -229,7 +229,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
'& .MuiAlert-icon': { color: d3roPalette.tag.orange },
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 500, mb: 0.5 }}>
|
||||
ChatGPT Plus / Pro / Team 구독자 안내
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
|
||||
|
|
@ -238,7 +238,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
</Alert>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontFamily: d3roFontMono, mb: 1, color: d3roPalette.accent.main }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontFamily: d3roFontMono, mb: 1, color: d3roPalette.accent.main }}>
|
||||
[Codex Device Flow 인증 방법]
|
||||
</Typography>
|
||||
|
||||
|
|
@ -252,7 +252,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}>
|
||||
1. 터미널에서 Device Auth 로그인 실행
|
||||
</Typography>
|
||||
<Box
|
||||
|
|
@ -292,7 +292,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
borderRadius: d3roRadius.sm,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}>
|
||||
2. 네트워크 문제 해결 팁 (Troubleshooting)
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5 }}>
|
||||
|
|
@ -328,8 +328,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Groq STT</Typography>
|
||||
<Chip label="초고속 (~200ms) · 무료 티어" size="small" sx={{ bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green, height: 20, fontSize: '10px', fontWeight: 700 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>Groq STT</Typography>
|
||||
<Chip label="초고속 (~200ms) · 무료 티어" size="small" sx={{ bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green, height: 20, fontSize: '10px', fontWeight: 500 }} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
||||
LPU 칩셋 기반으로 Whisper Large V3 모델을 0.2초만에 처리합니다.
|
||||
|
|
@ -360,8 +360,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Deepgram Nova-3</Typography>
|
||||
<Chip label="업계 최고 정확도 · 무료 $200" size="small" sx={{ bgcolor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple, height: 20, fontSize: '10px', fontWeight: 700 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>Deepgram Nova-3</Typography>
|
||||
<Chip label="업계 최고 정확도 · 무료 $200" size="small" sx={{ bgcolor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple, height: 20, fontSize: '10px', fontWeight: 500 }} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
||||
스마트 구두점 및 포맷팅 지원. 가입 시 무료 크레딧 제공.
|
||||
|
|
@ -392,8 +392,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Google Gemini 2.0 Flash Audio</Typography>
|
||||
<Chip label="한국어/아시아 언어 최강" size="small" sx={{ bgcolor: d3roPalette.tag.blueBg, color: d3roPalette.tag.blue, height: 20, fontSize: '10px', fontWeight: 700 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>Google Gemini 2.0 Flash Audio</Typography>
|
||||
<Chip label="한국어/아시아 언어 최강" size="small" sx={{ bgcolor: d3roPalette.tag.blueBg, color: d3roPalette.tag.blue, height: 20, fontSize: '10px', fontWeight: 500 }} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
||||
Google AI Studio에서 무료 API 키를 발급받아 사용할 수 있습니다.
|
||||
|
|
@ -424,8 +424,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>AssemblyAI Universal-2</Typography>
|
||||
<Chip label="고도화 음향 모델" size="small" sx={{ bgcolor: d3roPalette.tag.cyanBg, color: d3roPalette.tag.cyan, height: 20, fontSize: '10px', fontWeight: 700 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>AssemblyAI Universal-2</Typography>
|
||||
<Chip label="고도화 음향 모델" size="small" sx={{ bgcolor: d3roPalette.tag.cyanBg, color: d3roPalette.tag.cyan, height: 20, fontSize: '10px', fontWeight: 500 }} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
||||
대화형 문맥 이해 및 음성 오디오 인텔리전스 엔진.
|
||||
|
|
@ -453,7 +453,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
variant="contained"
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
fontSize: d3roTypo.label.size,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
|
|
|
|||
|
|
@ -20,32 +20,6 @@ import { useI18n } from '@d3ro/i18n'
|
|||
import type { HotkeyBinding } from '@d3ro/core/types'
|
||||
import { formatHotkeyLabel, getPlatform, keyCodeToName } from '../utils/format-hotkey'
|
||||
|
||||
// ── 키 이름 매핑 (Windows) ──────────────────────────────
|
||||
const KEY_DISPLAY_MAP: Record<number, string> = {
|
||||
// Modifier keys
|
||||
16: 'Shift', 17: 'Ctrl', 18: 'Alt', 91: 'Win', 92: 'Win',
|
||||
160: 'Left Shift', 161: 'Right Shift',
|
||||
162: 'Left Ctrl', 163: 'Right Ctrl',
|
||||
164: 'Left Alt', 165: 'Right Alt',
|
||||
// Common keys
|
||||
8: 'Backspace', 9: 'Tab', 13: 'Enter', 19: 'Pause', 20: 'CapsLock',
|
||||
27: 'Esc', 32: 'Space',
|
||||
33: 'PgUp', 34: 'PgDn', 35: 'End', 36: 'Home',
|
||||
37: '←', 38: '↑', 39: '→', 40: '↓',
|
||||
45: 'Insert', 46: 'Delete',
|
||||
// F-keys
|
||||
112: 'F1', 113: 'F2', 114: 'F3', 115: 'F4',
|
||||
116: 'F5', 117: 'F6', 118: 'F7', 119: 'F8',
|
||||
120: 'F9', 121: 'F10', 122: 'F11', 123: 'F12',
|
||||
// Numpad
|
||||
96: 'Num0', 97: 'Num1', 98: 'Num2', 99: 'Num3', 100: 'Num4',
|
||||
101: 'Num5', 102: 'Num6', 103: 'Num7', 104: 'Num8', 105: 'Num9',
|
||||
106: 'Num*', 107: 'Num+', 109: 'Num-', 110: 'Num.', 111: 'Num/',
|
||||
// Special
|
||||
186: ';', 187: '=', 188: ',', 189: '-', 190: '.', 191: '/', 192: '`',
|
||||
219: '[', 220: '\\', 221: ']', 222: "'",
|
||||
}
|
||||
|
||||
// 시스템 예약 조합
|
||||
const RESERVED_COMBOS = [
|
||||
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S', 'Ctrl+W',
|
||||
|
|
@ -235,7 +209,7 @@ export function HotkeyRecordModal({
|
|||
disableAutoFocus
|
||||
disableRestoreFocus
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 700, fontSize: d3roTypo.heading.size }}>{title ?? t('hotkey.title')}</DialogTitle>
|
||||
<DialogTitle sx={{ fontWeight: 500, fontSize: d3roTypo.heading.size }}>{title ?? t('hotkey.title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
{/* 녹화 영역 */}
|
||||
<Box
|
||||
|
|
@ -266,7 +240,7 @@ export function HotkeyRecordModal({
|
|||
key={`${key.keyCode}-${i}`}
|
||||
label={key.name}
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
color: d3roPalette.text.primary,
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export function LicenseTab(): React.ReactElement {
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Sparkles size={18} color={d3roPalette.accent.main} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<PhosphorText variant="compact" sx={{ color: d3roPalette.accent.main, fontWeight: 'bold' }}>
|
||||
<PhosphorText variant="compact" sx={{ color: d3roPalette.accent.main, fontWeight: 500 }}>
|
||||
14-Day Reverse Trial (Pro+)
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
|
|
@ -77,7 +77,7 @@ export function LicenseTab(): React.ReactElement {
|
|||
|
||||
{/* ── 오프라인 라이선스 키 등록 ── */}
|
||||
<MetalCard>
|
||||
<LicenseKeySection t={t} currentTier={currentTier} />
|
||||
<LicenseKeySection currentTier={currentTier} />
|
||||
</MetalCard>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
|
@ -113,7 +113,7 @@ function TierLabel({ tier, t }: { tier: string; t: (k: string) => string }): Rea
|
|||
return <PhosphorText variant="value" sx={{ color }}>{label}</PhosphorText>
|
||||
}
|
||||
|
||||
function LicenseKeySection({ t, currentTier }: { t: (k: string) => string; currentTier: string }): React.ReactElement {
|
||||
function LicenseKeySection({ currentTier }: { currentTier: string }): React.ReactElement {
|
||||
const [licenseKeyInput, setLicenseKeyInput] = useState('')
|
||||
const [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -157,7 +157,7 @@ function LicenseKeySection({ t, currentTier }: { t: (k: string) => string; curre
|
|||
</Box>
|
||||
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
|
||||
발급받은 디지털 서명 라이센스 키 (D3RO-LIC-... 또는 D3RO-PRO-...)를 입력하세요.
|
||||
발급받은 Ed25519 디지털 서명 라이센스 키 (D3RO-LIC-...)를 입력하세요.
|
||||
</PhosphorText>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ import {
|
|||
Play,
|
||||
Download,
|
||||
CheckCircle2,
|
||||
Cpu,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
|
|
@ -315,7 +313,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontSans,
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
fontSize: d3roTypo.heading.size,
|
||||
color: d3roPalette.text.primary,
|
||||
}}
|
||||
|
|
@ -393,7 +391,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
fontSize: '11px',
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
}}
|
||||
>
|
||||
|
|
@ -412,7 +410,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
{/* ── Step 1: Ollama 설치 및 기동 ── */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip label="STEP 1" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
|
||||
<Chip label="STEP 1" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
|
||||
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
|
||||
Ollama 설치 및 백그라운드 실행
|
||||
</Typography>
|
||||
|
|
@ -464,7 +462,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip label="STEP 2" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
|
||||
<Chip label="STEP 2" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
|
||||
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
|
||||
한국어 최적화 추천 모델 1-클릭 다운로드
|
||||
</Typography>
|
||||
|
|
@ -503,11 +501,11 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: d3roPalette.text.primary }}>
|
||||
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: d3roPalette.text.primary }}>
|
||||
{model.name}
|
||||
</Typography>
|
||||
{model.recommended && (
|
||||
<Chip label="추천" size="small" sx={{ height: 18, fontSize: '9px', fontWeight: 700, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
|
||||
<Chip label="추천" size="small" sx={{ height: 18, fontSize: '9px', fontWeight: 500, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
|
||||
)}
|
||||
</Box>
|
||||
<Chip label={model.size} size="small" sx={{ height: 18, fontSize: '10px', fontFamily: d3roFontMono }} />
|
||||
|
|
@ -562,7 +560,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
sx={{
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
py: 0.25,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
|
|
@ -593,7 +591,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
{/* ── Step 3: 실시간 연결 및 테스트 ── */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip label="STEP 3" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
|
||||
<Chip label="STEP 3" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
|
||||
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
|
||||
동작 테스트 (AI 문장 정제)
|
||||
</Typography>
|
||||
|
|
@ -622,7 +620,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
minWidth: 100,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
|
|
@ -644,7 +642,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
borderRadius: d3roRadius.small,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '11px', color: d3roPalette.accent.light, fontWeight: 700, mb: 0.5 }}>
|
||||
<Typography sx={{ fontSize: '11px', color: d3roPalette.accent.light, fontWeight: 500, mb: 0.5 }}>
|
||||
AI 정제 결과 ({activeModel}):
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.primary }}>
|
||||
|
|
@ -683,4 +681,3 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ import {
|
|||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Cloud,
|
||||
HardDrive,
|
||||
Lock,
|
||||
Download,
|
||||
Play,
|
||||
RefreshCw,
|
||||
|
|
@ -35,7 +33,7 @@ import {
|
|||
} from 'lucide-react'
|
||||
import { d3roPalette, d3roRadius, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme'
|
||||
import { Led } from '@d3ro/ui/components/ds'
|
||||
import type { LLMModel, LLMStatus, STTModel } from '@d3ro/core/types'
|
||||
import type { LLMModel, LLMStatus } from '@d3ro/core/types'
|
||||
|
||||
type Phase = 'select_mode' | 'local_ollama_setup' | 'online_auth' | 'success' | 'failed'
|
||||
|
||||
|
|
@ -68,7 +66,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
const [authLoading, setAuthLoading] = useState(false)
|
||||
|
||||
// Error State
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const errorMsg = ''
|
||||
|
||||
const isVisible = open || internalOpen
|
||||
|
||||
|
|
@ -277,11 +275,11 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Cpu size={22} color={d3roPalette.accent.main} />
|
||||
<Typography sx={{ fontWeight: 800, fontSize: '15px', color: d3roPalette.text.primary }}>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '15px', color: d3roPalette.text.primary }}>
|
||||
로컬 AI 모드
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip label="100% 무료 / 강력 추천" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
|
||||
<Chip label="100% 무료 / 강력 추천" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 2, lineHeight: 1.5 }}>
|
||||
<b>Ollama</b>와 로컬 <b>Whisper STT</b>를 사용하여 PC 내부에서 완전히 독립 실행됩니다.
|
||||
|
|
@ -310,7 +308,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
sx={{
|
||||
mt: 2.5,
|
||||
fontFamily: d3roFontSans,
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
|
|
@ -344,7 +342,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Cloud size={22} color={d3roPalette.tag.blue} />
|
||||
<Typography sx={{ fontWeight: 800, fontSize: '15px', color: d3roPalette.text.primary }}>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '15px', color: d3roPalette.text.primary }}>
|
||||
온라인 클라우드 모드
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
@ -407,7 +405,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Led color={isOllamaConnected ? 'green' : 'amber'} pulse={!isOllamaConnected} size={9} />
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: isOllamaConnected ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
|
||||
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: isOllamaConnected ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
|
||||
{isOllamaConnected
|
||||
? `Ollama 로컬 엔진 연결 완료 ${ollamaStatus?.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
|
||||
: 'Ollama 로컬 엔진 확인 필요'}
|
||||
|
|
@ -438,7 +436,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
startIcon={<Play size={13} />}
|
||||
onClick={handleStartOllama}
|
||||
disabled={startingOllama}
|
||||
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 700 }}
|
||||
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 500 }}
|
||||
>
|
||||
Ollama 자동 실행
|
||||
</Button>
|
||||
|
|
@ -455,7 +453,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
{/* Ollama 미설치 시 가이드 */}
|
||||
{!isOllamaConnected && (
|
||||
<Box sx={{ p: 2, bgcolor: d3roPalette.bg.elevated, borderRadius: d3roRadius.small, border: `1px solid ${d3roPalette.border.subtle}` }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '13px', mb: 1, color: d3roPalette.accent.main }}>
|
||||
<Typography sx={{ fontWeight: 500, fontSize: '13px', mb: 1, color: d3roPalette.accent.main }}>
|
||||
Ollama가 아직 설치되어 있지 않다면:
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
|
||||
|
|
@ -476,7 +474,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
|
||||
{/* 추천 모델 설치 섹션 */}
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '13px', mb: 1, color: d3roPalette.text.primary }}>
|
||||
<Typography sx={{ fontWeight: 500, fontSize: '13px', mb: 1, color: d3roPalette.text.primary }}>
|
||||
기본 한국어 AI 모델 설치 (Gemma 2 2B)
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
|
||||
|
|
@ -503,7 +501,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
onClick={() => handlePullModel('gemma2:2b')}
|
||||
disabled={!isOllamaConnected}
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
|
|
@ -529,7 +527,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
variant="contained"
|
||||
onClick={handleCompleteLocalSetup}
|
||||
disabled={!isOllamaConnected}
|
||||
sx={{ fontWeight: 700 }}
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
설정 완료 및 시작하기
|
||||
</Button>
|
||||
|
|
@ -616,7 +614,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
|
||||
{phase === 'success' && (
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button variant="contained" onClick={handleClose} fullWidth sx={{ py: 1.2, fontWeight: 700 }}>
|
||||
<Button variant="contained" onClick={handleClose} fullWidth sx={{ py: 1.2, fontWeight: 500 }}>
|
||||
D3RO Voice 시작하기
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
|
@ -624,4 +622,3 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
<CheckCircle2 size={14} color={d3roPalette.accent.main} />
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '12px', mb: 0.2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontSize: '12px', mb: 0.2 }}>
|
||||
{p.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
@ -244,7 +244,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
mt: 1,
|
||||
height: 18,
|
||||
fontSize: '9px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
bgcolor: isSelected ? d3roPalette.accent.muted : 'transparent',
|
||||
color: isSelected ? d3roPalette.accent.main : d3roPalette.text.disabled,
|
||||
border: isSelected ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
|
||||
|
|
@ -268,7 +268,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontFamily: d3roFontMono, color: d3roPalette.accent.main }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontFamily: d3roFontMono, color: d3roPalette.accent.main }}>
|
||||
{currentProviderInfo?.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
|
||||
|
|
@ -281,7 +281,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
sx={{
|
||||
height: 20,
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
bgcolor: currentProviderInfo?.isCloud ? d3roPalette.tag.purpleBg : d3roPalette.tag.greenBg,
|
||||
color: currentProviderInfo?.isCloud ? d3roPalette.tag.purple : d3roPalette.tag.green,
|
||||
}}
|
||||
|
|
@ -335,7 +335,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{selected.name} 모델 다운로드가 필요합니다
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
|
||||
|
|
@ -351,7 +351,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
|
|
@ -403,7 +403,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
minWidth: 105,
|
||||
height: 40,
|
||||
whiteSpace: 'nowrap',
|
||||
|
|
@ -457,7 +457,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.label.size,
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: d3roPalette.text.secondary,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
Stack,
|
||||
Paper,
|
||||
} from '@mui/material'
|
||||
import { X, Keyboard, Pencil, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle, Sparkles, CheckCircle2 } from 'lucide-react'
|
||||
import { X, Keyboard, Pencil, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle } from 'lucide-react'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { Led } from '@d3ro/ui/components/ds'
|
||||
import { HotkeyRecordModal } from './HotkeyRecordModal'
|
||||
|
|
@ -35,7 +35,6 @@ import { STTTab } from './STTTab'
|
|||
import { useI18n, LOCALE_META } from '@d3ro/i18n'
|
||||
import type { Locale } from '@d3ro/i18n'
|
||||
import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types'
|
||||
import { Feature } from '@d3ro/core/types'
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean
|
||||
|
|
@ -79,7 +78,7 @@ function HotkeyDisplay({
|
|||
label={key}
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
fontSize: d3roTypo.label.size,
|
||||
bgcolor: d3roPalette.bg.elevated,
|
||||
color: d3roPalette.text.primary,
|
||||
|
|
@ -138,7 +137,7 @@ function VoiceModeCard({
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 700, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, letterSpacing: '0.5px' }}
|
||||
sx={{ fontWeight: 500, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, letterSpacing: '0.5px' }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
|
|
@ -163,7 +162,7 @@ function VoiceModeCard({
|
|||
size="small"
|
||||
sx={{
|
||||
fontSize: '10px', // P4: 토큰에 없는 10px 보존
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
height: 20,
|
||||
bgcolor: enabled ? d3roPalette.tag.greenBg : 'transparent',
|
||||
color: enabled ? d3roPalette.tag.green : d3roPalette.text.disabled,
|
||||
|
|
@ -189,6 +188,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
const [activeTab, setActiveTab] = useState(initialTab)
|
||||
const [config, setConfig] = useState<Partial<AppConfig>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && initialTab !== undefined) {
|
||||
|
|
@ -196,13 +196,30 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
}
|
||||
}, [open, initialTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
let cancelled = false
|
||||
void window.electronAPI.system.getVersion()
|
||||
.then((result) => {
|
||||
if (!cancelled && result.success) setAppVersion(result.data)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAppVersion(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// 음성 모드 상태
|
||||
const [dictationEnabled, setDictationEnabled] = useState(true)
|
||||
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
|
||||
const [handsFreeEnabled, setHandsFreeEnabled] = useState(false)
|
||||
const [handsFreeBinding, setHandsFreeBinding] = useState<HotkeyBinding | null>(null)
|
||||
const [captionBinding, setCaptionBinding] = useState<HotkeyBinding | null>(null)
|
||||
const [hotkeyGlobalEnabled, setHotkeyGlobalEnabled] = useState(true)
|
||||
const [, setHotkeyGlobalEnabled] = useState(true)
|
||||
|
||||
// 핫키 녹화 모달
|
||||
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
|
||||
|
|
@ -350,7 +367,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontFamily: d3roFontMono,
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
fontSize: d3roTypo.body.size,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
textTransform: 'uppercase',
|
||||
|
|
@ -377,7 +394,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
'& .MuiTab-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.label.size,
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
color: d3roPalette.text.inactive,
|
||||
|
|
@ -700,7 +717,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Led color={ollamaStatus?.connectionState === 'connected' ? 'green' : 'red'} size={8} />
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: ollamaStatus?.connectionState === 'connected' ? d3roPalette.tag.green : d3roPalette.tag.red }}>
|
||||
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: ollamaStatus?.connectionState === 'connected' ? d3roPalette.tag.green : d3roPalette.tag.red }}>
|
||||
{ollamaStatus?.connectionState === 'connected'
|
||||
? `Ollama 연결됨 ${ollamaStatus.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
|
||||
: 'Ollama 오프라인 (서버 실행 필요)'}
|
||||
|
|
@ -746,7 +763,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
}
|
||||
}}
|
||||
disabled={startingOllama}
|
||||
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 700 }}
|
||||
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 500 }}
|
||||
>
|
||||
{startingOllama ? '실행 중' : 'Ollama 실행'}
|
||||
</Button>
|
||||
|
|
@ -923,7 +940,9 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t('settings.about.version')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">v1.0.0</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{appVersion === null ? '—' : `v${appVersion}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
|
|
@ -973,4 +992,3 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActi
|
|||
import { Plus, Trash2, Pencil, Play } from 'lucide-react'
|
||||
import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds'
|
||||
import { PageHeader, EmptyStateCard } from './shared'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
|
||||
import { d3roPalette, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@d3ro/core/types'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// src/renderer/components/TitleBar.tsx
|
||||
// v2 보더리스 UI: 커스텀 타이틀바 — 투명 드래그 스트립 + 우측 윈도우 컨트롤.
|
||||
// 콘텐츠 위에 오버레이되며(레퍼런스처럼 컨트롤이 콘텐츠 위에 떠 있음),
|
||||
// v3 보더리스 UI: 커스텀 타이틀바 — 투명 드래그 스트립 + 우측 윈도우 컨트롤.
|
||||
// 드래그 영역은 -webkit-app-region: drag, 버튼은 no-drag.
|
||||
// macOS(hiddenInset)에서는 네이티브 교통신호가 좌측에 표시되므로 커스텀
|
||||
// 컨트롤을 숨기고 그만큼의 좌측 여백을 확보한다.
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
|
|
@ -9,6 +10,7 @@ import { Minus, Square, Copy, X } from 'lucide-react'
|
|||
import { d3roPalette, d3roRadius } from '@d3ro/ui/theme'
|
||||
|
||||
const BAR_HEIGHT = 40
|
||||
const isDarwin = window.electronAPI?.platform === 'darwin'
|
||||
|
||||
export function TitleBar(): React.ReactElement {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
|
@ -37,6 +39,8 @@ export function TitleBar(): React.ReactElement {
|
|||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
px: 1.5,
|
||||
// macOS 교통신호(x:16) 전용 여백 — 드래그 영역에는 포함되지 않는다
|
||||
pl: isDarwin ? '78px' : 1.5,
|
||||
WebkitAppRegion: 'drag',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
|
|
@ -46,7 +50,8 @@ export function TitleBar(): React.ReactElement {
|
|||
{/* Drag Region Placeholder */}
|
||||
<Box sx={{ flex: 1, height: '100%', WebkitAppRegion: 'drag' }} />
|
||||
|
||||
{/* Window Controls */}
|
||||
{/* Window Controls — macOS는 네이티브 교통신호 사용 */}
|
||||
{!isDarwin && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
|
|
@ -65,6 +70,7 @@ export function TitleBar(): React.ReactElement {
|
|||
<X size={14} />
|
||||
</ControlButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue