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/
|
||||||
**/dist/
|
**/dist/
|
||||||
build/
|
build/
|
||||||
|
**/build/
|
||||||
out/
|
out/
|
||||||
|
**/out/
|
||||||
test-results/
|
test-results/
|
||||||
|
**/test-results/
|
||||||
|
**/playwright-report/
|
||||||
coverage/
|
coverage/
|
||||||
|
**/coverage/
|
||||||
|
**/*.tsbuildinfo
|
||||||
*.log
|
*.log
|
||||||
*.tar
|
*.tar
|
||||||
bin/
|
bin/
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,12 @@ ADMIN_COOKIE_SECURE=true
|
||||||
# Use a distinct random value with at least 32 UTF-8 bytes.
|
# Use a distinct random value with at least 32 UTF-8 bytes.
|
||||||
D3RO_API_TOKEN=
|
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
|
||||||
SUPABASE_URL=
|
SUPABASE_URL=
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,28 @@ module.exports = {
|
||||||
sourceType: 'module'
|
sourceType: 'module'
|
||||||
},
|
},
|
||||||
plugins: ['@typescript-eslint'],
|
plugins: ['@typescript-eslint'],
|
||||||
|
ignorePatterns: ['**/next-env.d.ts', '**/.next/**', '**/out/**', '**/dist/**'],
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-explicit-any': 'error',
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
'no-console': 'error'
|
'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:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-win:
|
deploy-win:
|
||||||
|
|
@ -21,11 +19,10 @@ jobs:
|
||||||
git checkout -q -f FETCH_HEAD
|
git checkout -q -f FETCH_HEAD
|
||||||
git clean -qfdx
|
git clean -qfdx
|
||||||
|
|
||||||
- name: 사이트 빌드 & 바이너리 동기화
|
- name: 사이트 빌드
|
||||||
run: |
|
run: |
|
||||||
npm install --prefix site
|
npm ci --prefix site
|
||||||
npm run build --prefix site
|
npm run build --prefix site
|
||||||
node scripts/ci/sync-and-publish-forgejo-release.mjs
|
|
||||||
|
|
||||||
- name: Cloudflare Pages 배포
|
- name: Cloudflare Pages 배포
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@ name: deploy-site
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -26,9 +23,9 @@ jobs:
|
||||||
|
|
||||||
- name: 의존성 설치 및 사이트 빌드
|
- name: 의존성 설치 및 사이트 빌드
|
||||||
run: |
|
run: |
|
||||||
npm ci --prefix site || npm install --prefix site
|
npm ci --prefix site
|
||||||
npm run build --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)
|
- name: Cloudflare Pages 배포 (d3ro.chanpaca.net)
|
||||||
env:
|
env:
|
||||||
|
|
@ -46,5 +43,17 @@ jobs:
|
||||||
--branch main \
|
--branch main \
|
||||||
--commit-dirty=true
|
--commit-dirty=true
|
||||||
else
|
else
|
||||||
echo "CLOUDFLARE_API_TOKEN 없음"
|
echo "CLOUDFLARE_API_TOKEN 없음" >&2
|
||||||
|
exit 1
|
||||||
fi
|
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
|
name: Build macOS
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
notarize:
|
notarize:
|
||||||
|
|
|
||||||
237
.github/workflows/ci.yml
vendored
237
.github/workflows/ci.yml
vendored
|
|
@ -30,22 +30,80 @@ jobs:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js 20 LTS
|
- name: Setup Node.js 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version-file: '.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
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
|
- name: Lint Check
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Typecheck All Workspaces
|
- name: Typecheck All Workspaces
|
||||||
run: npm run typecheck
|
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)
|
# 2. Automated Test Matrix (Windows / macOS / Ubuntu)
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -61,10 +119,10 @@ jobs:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js 20 LTS
|
- name: Setup Node.js 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version-file: '.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
|
|
@ -93,10 +151,10 @@ jobs:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js 20 LTS
|
- name: Setup Node.js 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version-file: '.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
|
|
@ -104,3 +162,162 @@ jobs:
|
||||||
|
|
||||||
- name: Build Target Workspace
|
- name: Build Target Workspace
|
||||||
run: ${{ matrix.cmd }}
|
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
|
working-directory: site
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Verify Mobile Release Publication Boundary
|
||||||
|
run: node scripts/ci/verify-mobile-release-boundary.mjs --self-test
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
working-directory: site
|
working-directory: site
|
||||||
run: npm run build
|
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
|
name: Release & Code Signing CA Pipeline
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
||||||
400
.github/workflows/release.yml
vendored
400
.github/workflows/release.yml
vendored
|
|
@ -12,27 +12,92 @@ on:
|
||||||
version:
|
version:
|
||||||
description: 'Release version (e.g. 1.0.0)'
|
description: 'Release version (e.g. 1.0.0)'
|
||||||
required: true
|
required: true
|
||||||
default: '1.0.0'
|
default: '1.1.0'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
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)
|
# 1. Package Windows Installer (.exe & .blockmap & latest.yml)
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
package-windows:
|
package-windows:
|
||||||
name: Package Windows Desktop App
|
name: Package Windows Desktop App
|
||||||
|
needs: release-preflight
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js 20 LTS
|
- name: Setup Node.js 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version-file: '.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
|
|
@ -40,6 +105,7 @@ jobs:
|
||||||
|
|
||||||
- name: Build All Workspaces
|
- name: Build All Workspaces
|
||||||
run: |
|
run: |
|
||||||
|
npm run version:check
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run build --workspace=@d3ro/desktop
|
npm run build --workspace=@d3ro/desktop
|
||||||
|
|
||||||
|
|
@ -66,15 +132,16 @@ jobs:
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
package-macos:
|
package-macos:
|
||||||
name: Package macOS Desktop App
|
name: Package macOS Desktop App
|
||||||
|
needs: release-preflight
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js 20 LTS
|
- name: Setup Node.js 24
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version-file: '.nvmrc'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
|
|
@ -82,6 +149,7 @@ jobs:
|
||||||
|
|
||||||
- name: Build All Workspaces
|
- name: Build All Workspaces
|
||||||
run: |
|
run: |
|
||||||
|
npm run version:check
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run build --workspace=@d3ro/desktop
|
npm run build --workspace=@d3ro/desktop
|
||||||
|
|
||||||
|
|
@ -108,10 +176,267 @@ jobs:
|
||||||
apps/desktop/release/*/latest-mac.yml
|
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:
|
package-admin-docker:
|
||||||
name: Build & Publish Admin Docker Image
|
name: Build & Publish Admin Docker Image
|
||||||
|
needs: release-preflight
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
|
|
@ -137,21 +462,54 @@ jobs:
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.admin
|
file: ./apps/admin/Dockerfile
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# 4. Create GitHub Release & Upload Checksums
|
# 5. Create GitHub Release & Upload Checksums
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
publish-release:
|
publish-release:
|
||||||
name: Publish Official GitHub 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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Download Windows Artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
|
@ -167,9 +525,17 @@ jobs:
|
||||||
|
|
||||||
- name: Generate SHA-256 Checksums
|
- name: Generate SHA-256 Checksums
|
||||||
run: |
|
run: |
|
||||||
cd release-dist
|
set -euo pipefail
|
||||||
sha256sum * > SHA256SUMS.txt || shasum -a 256 * > SHA256SUMS.txt
|
find release-dist -type f ! -name SHA256SUMS.txt -print0 \
|
||||||
cat SHA256SUMS.txt
|
| 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
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
|
|
@ -178,6 +544,10 @@ jobs:
|
||||||
release-dist/*
|
release-dist/*
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: 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:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
|
||||||
20
.gitignore
vendored
20
.gitignore
vendored
|
|
@ -5,13 +5,19 @@ out/
|
||||||
.expo/
|
.expo/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
!.env.example
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
# Electron
|
# Electron release artifacts. Keep only release identity/public-key SSOT files.
|
||||||
release/
|
release/
|
||||||
|
!/release/
|
||||||
|
/release/*
|
||||||
|
!/release/product-version.json
|
||||||
|
!/release/android-release-identity.json
|
||||||
|
!/release/mobile-release-evidence-public.pem
|
||||||
*.unpacked
|
*.unpacked
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
|
|
@ -52,3 +58,15 @@ venv/
|
||||||
|
|
||||||
# Whisper models (large files)
|
# Whisper models (large files)
|
||||||
resources/models/
|
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
|
- validate
|
||||||
- test
|
- test
|
||||||
- build
|
- build
|
||||||
|
- e2e
|
||||||
- package
|
- package
|
||||||
- publish
|
- publish
|
||||||
- deploy
|
- deploy
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
NODE_VERSION: "20"
|
NODE_VERSION: "24.19.0"
|
||||||
PACKAGE_NAME: "d3ro-voice"
|
PACKAGE_NAME: "d3ro-voice"
|
||||||
|
|
||||||
default:
|
default:
|
||||||
image: node:20-bookworm
|
image: node:24.19.0-bookworm
|
||||||
|
tags:
|
||||||
|
- build-linux-x64
|
||||||
before_script:
|
before_script:
|
||||||
- npm ci
|
- npm ci
|
||||||
|
|
||||||
|
|
@ -24,7 +27,16 @@ default:
|
||||||
lint-and-typecheck:
|
lint-and-typecheck:
|
||||||
stage: validate
|
stage: validate
|
||||||
script:
|
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
|
- npm run typecheck
|
||||||
rules:
|
rules:
|
||||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
- 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_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"'
|
||||||
- if: '$CI_COMMIT_TAG'
|
- 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
|
# Build Workspaces
|
||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -71,11 +202,11 @@ build-workspaces:
|
||||||
package-windows:
|
package-windows:
|
||||||
stage: package
|
stage: package
|
||||||
tags:
|
tags:
|
||||||
- windows
|
- build-win-x64
|
||||||
- electron
|
|
||||||
before_script:
|
before_script:
|
||||||
- npm ci
|
- npm ci
|
||||||
script:
|
script:
|
||||||
|
- node scripts/ci/sync-version.mjs --check --tag "$CI_COMMIT_TAG"
|
||||||
- npm run build --workspace=@d3ro/desktop
|
- npm run build --workspace=@d3ro/desktop
|
||||||
- cd apps/desktop
|
- cd apps/desktop
|
||||||
- npx electron-builder --win --x64 --config electron-builder.yml
|
- npx electron-builder --win --x64 --config electron-builder.yml
|
||||||
|
|
@ -95,11 +226,11 @@ package-windows:
|
||||||
package-macos:
|
package-macos:
|
||||||
stage: package
|
stage: package
|
||||||
tags:
|
tags:
|
||||||
- macos
|
- build-mac-arm64
|
||||||
- arm64
|
|
||||||
before_script:
|
before_script:
|
||||||
- npm ci
|
- npm ci
|
||||||
script:
|
script:
|
||||||
|
- node scripts/ci/sync-version.mjs --check --tag "$CI_COMMIT_TAG"
|
||||||
- npm run build --workspace=@d3ro/desktop
|
- npm run build --workspace=@d3ro/desktop
|
||||||
- cd apps/desktop
|
- cd apps/desktop
|
||||||
- npx electron-builder --mac --arm64 --config electron-builder.yml
|
- npx electron-builder --mac --arm64 --config electron-builder.yml
|
||||||
|
|
@ -114,35 +245,196 @@ package-macos:
|
||||||
rules:
|
rules:
|
||||||
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+.*$/'
|
- 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 (GitLab Package Registry + Release Page)
|
||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
publish-release:
|
publish-release:
|
||||||
stage: publish
|
stage: publish
|
||||||
image: node:20-bookworm
|
image: node:24.19.0-bookworm
|
||||||
needs:
|
needs:
|
||||||
- job: package-windows
|
- job: package-windows
|
||||||
artifacts: true
|
artifacts: true
|
||||||
- job: package-macos
|
- job: package-macos
|
||||||
artifacts: true
|
artifacts: true
|
||||||
optional: true
|
optional: true
|
||||||
|
- job: mobile-production-release
|
||||||
|
artifacts: false
|
||||||
|
optional: true
|
||||||
script:
|
script:
|
||||||
- node scripts/ci/publish-gitlab-release.mjs
|
- node scripts/ci/publish-gitlab-release.mjs
|
||||||
rules:
|
rules:
|
||||||
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+.*$/'
|
- 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)
|
- Cloud-optional backup (encrypted, opt-in)
|
||||||
- Plugin system for custom pipelines
|
- 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
|
## [0.2.1-alpha] - 2026-07-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
@ -64,7 +91,7 @@ Midnight Glass v2 전면 재설계 + refactor-wave 대규모 코드 품질 정
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [1.0.0] - 2026-04-06
|
## [1.0.0] - 2026-08-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Dockerfile.admin
|
# Dockerfile.admin
|
||||||
# Multi-stage production build for @d3ro/admin Next.js App
|
# 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
|
WORKDIR /app
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
COPY package.json package-lock.json ./
|
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
|
COPY apps/web/package.json ./apps/web/package.json
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
FROM node:20-alpine AS builder
|
FROM node:24.19.0-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=deps /app/packages ./packages
|
COPY --from=deps /app/packages ./packages
|
||||||
|
|
@ -23,7 +23,7 @@ ENV NODE_ENV production
|
||||||
|
|
||||||
RUN npm run build --workspace=@d3ro/admin
|
RUN npm run build --workspace=@d3ro/admin
|
||||||
|
|
||||||
FROM node:20-alpine AS runner
|
FROM node:24.19.0-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"info": {
|
"info": {
|
||||||
"title": "D3RO-VOICE Admin API",
|
"title": "D3RO-VOICE Admin API",
|
||||||
"description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.",
|
"description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.",
|
||||||
"version": "1.0.0"
|
"version": "1.1.0"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
|
|
@ -38,52 +38,159 @@
|
||||||
"Profile": {
|
"Profile": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "format": "uuid" },
|
"id": {
|
||||||
"name": { "type": "string", "nullable": true },
|
"type": "string",
|
||||||
"avatar_url": { "type": "string", "nullable": true },
|
"format": "uuid"
|
||||||
"locale": { "type": "string" },
|
},
|
||||||
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
|
"name": {
|
||||||
"role": { "type": "string", "enum": ["user", "admin", "super_admin"] },
|
"type": "string",
|
||||||
"created_at": { "type": "string", "format": "date-time" },
|
"nullable": true
|
||||||
"updated_at": { "type": "string", "format": "date-time" }
|
},
|
||||||
|
"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": {
|
"Subscription": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "format": "uuid" },
|
"id": {
|
||||||
"user_id": { "type": "string", "format": "uuid" },
|
"type": "string",
|
||||||
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
|
"format": "uuid"
|
||||||
"status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] },
|
},
|
||||||
"payment_provider": { "type": "string", "enum": ["none", "stripe", "payple"] },
|
"user_id": {
|
||||||
"current_period_start": { "type": "string", "format": "date-time", "nullable": true },
|
"type": "string",
|
||||||
"current_period_end": { "type": "string", "format": "date-time", "nullable": true },
|
"format": "uuid"
|
||||||
"overage_credits": { "type": "integer" },
|
},
|
||||||
"admin_note": { "type": "string", "nullable": true },
|
"tier": {
|
||||||
"renewal_failures": { "type": "integer" },
|
"type": "string",
|
||||||
"created_at": { "type": "string", "format": "date-time" },
|
"enum": [
|
||||||
"updated_at": { "type": "string", "format": "date-time" }
|
"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": {
|
"AuditLog": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "integer" },
|
"id": {
|
||||||
"admin_id": { "type": "string", "format": "uuid" },
|
"type": "integer"
|
||||||
"admin_name": { "type": "string" },
|
},
|
||||||
"action": { "type": "string" },
|
"admin_id": {
|
||||||
"target_type": { "type": "string" },
|
"type": "string",
|
||||||
"target_id": { "type": "string", "format": "uuid" },
|
"format": "uuid"
|
||||||
"before_data": { "type": "object", "nullable": true },
|
},
|
||||||
"after_data": { "type": "object", "nullable": true },
|
"admin_name": {
|
||||||
"memo": { "type": "string" },
|
"type": "string"
|
||||||
"created_at": { "type": "string", "format": "date-time" }
|
},
|
||||||
|
"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": {
|
"Error": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"error": { "type": "string" }
|
"error": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,15 +198,57 @@
|
||||||
"paths": {
|
"paths": {
|
||||||
"/admin-users": {
|
"/admin-users": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Users"],
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
"summary": "List or get user details",
|
"summary": "List or get user details",
|
||||||
"description": "Admin+. Pass userId for single user detail, or omit for paginated list.",
|
"description": "Admin+. Pass userId for single user detail, or omit for paginated list.",
|
||||||
"parameters": [
|
"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": "userId",
|
||||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
|
"in": "query",
|
||||||
{ "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Name search (ilike)" },
|
"schema": {
|
||||||
{ "name": "role", "in": "query", "schema": { "type": "string", "enum": ["user", "admin", "super_admin"] } }
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
|
|
@ -111,17 +260,32 @@
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"profiles": { "type": "array", "items": { "$ref": "#/components/schemas/Profile" } },
|
"profiles": {
|
||||||
"total": { "type": "integer" },
|
"type": "array",
|
||||||
"page": { "type": "integer" },
|
"items": {
|
||||||
"limit": { "type": "integer" }
|
"$ref": "#/components/schemas/Profile"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"profile": { "$ref": "#/components/schemas/Profile" },
|
"profile": {
|
||||||
"subscription": { "$ref": "#/components/schemas/Subscription" }
|
"$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": {
|
"patch": {
|
||||||
"tags": ["Users"],
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
"summary": "Change user role",
|
"summary": "Change user role",
|
||||||
"description": "Super admin only. Changes both auth.users.app_metadata.role and profiles.role.",
|
"description": "Super admin only. Changes both auth.users.app_metadata.role and profiles.role.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
|
|
@ -142,39 +317,110 @@
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["userId", "newRole", "memo"],
|
"required": [
|
||||||
|
"userId",
|
||||||
|
"newRole",
|
||||||
|
"memo"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"userId": { "type": "string", "format": "uuid" },
|
"userId": {
|
||||||
"newRole": { "type": "string", "enum": ["user", "admin", "super_admin"] },
|
"type": "string",
|
||||||
"memo": { "type": "string", "description": "Required reason for audit log" }
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"newRole": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"user",
|
||||||
|
"admin",
|
||||||
|
"super_admin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"memo": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Required reason for audit log"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": { "description": "Role changed successfully" },
|
"200": {
|
||||||
"403": { "description": "Not super_admin" }
|
"description": "Role changed successfully"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not super_admin"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin-subscriptions": {
|
"/admin-subscriptions": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Subscriptions"],
|
"tags": [
|
||||||
|
"Subscriptions"
|
||||||
|
],
|
||||||
"summary": "List or get subscription details",
|
"summary": "List or get subscription details",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "userId", "in": "query", "schema": { "type": "string", "format": "uuid" } },
|
{
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
"name": "userId",
|
||||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
|
"in": "query",
|
||||||
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] } },
|
"schema": {
|
||||||
{ "name": "tier", "in": "query", "schema": { "type": "string", "enum": ["free", "pro", "pro_plus"] } }
|
"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": {
|
"responses": {
|
||||||
"200": { "description": "Subscription list or detail" }
|
"200": {
|
||||||
|
"description": "Subscription list or detail"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
"tags": ["Subscriptions"],
|
"tags": [
|
||||||
|
"Subscriptions"
|
||||||
|
],
|
||||||
"summary": "Create subscription (VIP grant / record recovery)",
|
"summary": "Create subscription (VIP grant / record recovery)",
|
||||||
"description": "Super admin only.",
|
"description": "Super admin only.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
|
|
@ -183,30 +429,74 @@
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["userId", "tier", "memo"],
|
"required": [
|
||||||
|
"userId",
|
||||||
|
"tier",
|
||||||
|
"memo"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"userId": { "type": "string", "format": "uuid" },
|
"userId": {
|
||||||
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
|
"type": "string",
|
||||||
"status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"], "default": "active" },
|
"format": "uuid"
|
||||||
"currentPeriodEnd": { "type": "string", "format": "date-time" },
|
},
|
||||||
"adminNote": { "type": "string" },
|
"tier": {
|
||||||
"memo": { "type": "string" }
|
"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": {
|
"responses": {
|
||||||
"201": { "description": "Subscription created" },
|
"201": {
|
||||||
"409": { "description": "Subscription already exists" }
|
"description": "Subscription created"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Subscription already exists"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"patch": {
|
"patch": {
|
||||||
"tags": ["Subscriptions"],
|
"tags": [
|
||||||
|
"Subscriptions"
|
||||||
|
],
|
||||||
"summary": "Update subscription",
|
"summary": "Update subscription",
|
||||||
"description": "Super admin only.",
|
"description": "Super admin only.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
{
|
||||||
|
"name": "userId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
|
|
@ -214,29 +504,67 @@
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["memo"],
|
"required": [
|
||||||
|
"memo"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"tier": { "type": "string", "enum": ["free", "pro", "pro_plus"] },
|
"tier": {
|
||||||
"status": { "type": "string", "enum": ["active", "canceled", "past_due", "expired"] },
|
"type": "string",
|
||||||
"currentPeriodEnd": { "type": "string", "format": "date-time" },
|
"enum": [
|
||||||
"overageCredits": { "type": "integer" },
|
"free",
|
||||||
"adminNote": { "type": "string" },
|
"pro",
|
||||||
"memo": { "type": "string" }
|
"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": {
|
"responses": {
|
||||||
"200": { "description": "Subscription updated" }
|
"200": {
|
||||||
|
"description": "Subscription updated"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"tags": ["Subscriptions"],
|
"tags": [
|
||||||
|
"Subscriptions"
|
||||||
|
],
|
||||||
"summary": "Soft-delete subscription",
|
"summary": "Soft-delete subscription",
|
||||||
"description": "Super admin only. Sets status=expired, tier=free.",
|
"description": "Super admin only. Sets status=expired, tier=free.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "userId", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
{
|
||||||
|
"name": "userId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
|
|
@ -244,27 +572,54 @@
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["memo"],
|
"required": [
|
||||||
|
"memo"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"memo": { "type": "string" }
|
"memo": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": { "description": "Subscription soft-deleted" }
|
"200": {
|
||||||
|
"description": "Subscription soft-deleted"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin-payments": {
|
"/admin-payments": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Payments"],
|
"tags": [
|
||||||
|
"Payments"
|
||||||
|
],
|
||||||
"summary": "Get payment history for a user",
|
"summary": "Get payment history for a user",
|
||||||
"description": "Admin+. Returns DB subscription data + audit logs. Pass source=payple for Payple API history.",
|
"description": "Admin+. Returns DB subscription data + audit logs. Pass source=payple for Payple API history.",
|
||||||
"parameters": [
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
|
|
@ -274,10 +629,23 @@
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"subscription": { "$ref": "#/components/schemas/Subscription" },
|
"subscription": {
|
||||||
"auditLogs": { "type": "array", "items": { "$ref": "#/components/schemas/AuditLog" } },
|
"$ref": "#/components/schemas/Subscription"
|
||||||
"paypleHistory": { "type": "object", "description": "Payple API response (when source=payple)" },
|
},
|
||||||
"paypleError": { "type": "string", "description": "Error message if Payple API call failed" }
|
"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": {
|
"/admin-audit-log": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Audit Log"],
|
"tags": [
|
||||||
|
"Audit Log"
|
||||||
|
],
|
||||||
"summary": "List or get audit log entries",
|
"summary": "List or get audit log entries",
|
||||||
"description": "Admin+. Pass id for single entry detail.",
|
"description": "Admin+. Pass id for single entry detail.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{ "name": "id", "in": "query", "schema": { "type": "integer" }, "description": "Specific log entry ID" },
|
{
|
||||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
"name": "id",
|
||||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
|
"in": "query",
|
||||||
{ "name": "target_type", "in": "query", "schema": { "type": "string", "enum": ["subscription", "profile"] } },
|
"schema": {
|
||||||
{ "name": "admin_id", "in": "query", "schema": { "type": "string", "format": "uuid" } },
|
"type": "integer"
|
||||||
{ "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)" },
|
"description": "Specific log entry ID"
|
||||||
{ "name": "to", "in": "query", "schema": { "type": "string", "format": "date" }, "description": "End date (YYYY-MM-DD)" }
|
},
|
||||||
|
{
|
||||||
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
|
|
@ -311,17 +742,32 @@
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"logs": { "type": "array", "items": { "$ref": "#/components/schemas/AuditLog" } },
|
"logs": {
|
||||||
"total": { "type": "integer" },
|
"type": "array",
|
||||||
"page": { "type": "integer" },
|
"items": {
|
||||||
"limit": { "type": "integer" }
|
"$ref": "#/components/schemas/AuditLog"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"log": { "$ref": "#/components/schemas/AuditLog" },
|
"log": {
|
||||||
"admin": { "$ref": "#/components/schemas/Profile" }
|
"$ref": "#/components/schemas/AuditLog"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"$ref": "#/components/schemas/Profile"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# apps/admin/Dockerfile
|
# apps/admin/Dockerfile
|
||||||
FROM node:24-alpine AS base
|
FROM node:24.19.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@d3ro/admin",
|
"name": "@d3ro/admin",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "D3RO Voice Admin CRM — SaaS 관리 도구",
|
"description": "D3RO Voice Admin CRM — SaaS 관리 도구",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
// D3RO Voice Admin CRM — Release & Distribution Hub (Forgejo live feed)
|
// D3RO Voice Admin CRM — Release & Distribution Hub (Forgejo live feed)
|
||||||
|
|
||||||
import { Box, Typography, Button } from '@mui/material'
|
import { Box, Typography, Button } from '@mui/material'
|
||||||
import Link from 'next/link'
|
|
||||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||||
import { requireManager } from '@/lib/admin-guard'
|
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' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
|
||||||
href={`${repoHtmlUrl}/releases`}
|
href={`${repoHtmlUrl}/releases`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|
@ -109,7 +107,6 @@ function HeaderBar({ feedLive, repoHtmlUrl }: { feedLive: boolean; repoHtmlUrl:
|
||||||
Forgejo Releases ↗
|
Forgejo Releases ↗
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
|
||||||
href="https://d3ro.chanpaca.net/download.html"
|
href="https://d3ro.chanpaca.net/download.html"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,7 @@
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import {
|
import { issueSignedLicenseKey, type SignedLicensePayload } from '@d3ro/core/utils/crypto-license'
|
||||||
issueSignedLicenseKey,
|
|
||||||
DEFAULT_LICENSE_PRIVATE_KEY,
|
|
||||||
type SignedLicensePayload
|
|
||||||
} from '@d3ro/core/utils/crypto-license'
|
|
||||||
import type { LicenseTier } from '@d3ro/core/types'
|
import type { LicenseTier } from '@d3ro/core/types'
|
||||||
import { AdminBackendError, fetchAdminBackend, requireVerifiedBackendSession } from '@/lib/backend-session'
|
import { AdminBackendError, fetchAdminBackend, requireVerifiedBackendSession } from '@/lib/backend-session'
|
||||||
|
|
||||||
|
|
@ -27,20 +23,11 @@ const MAX_DEVICES: Record<LicenseTier, number> = {
|
||||||
enterprise: 999
|
enterprise: 999
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SigningKey {
|
// 공개된 개발 키로의 fallback은 없다. 모든 환경에서 명시적인 전용 서명키가 필요하다.
|
||||||
privateKeyPem: string
|
function resolveSigningKey(): string | null {
|
||||||
usedDefaultKey: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// 저장소에 포함된 기본 키쌍은 공개된 것이므로 위조 방어력이 없다. 운영 발급은
|
|
||||||
// ADMIN_LICENSE_PRIVATE_KEY(전용 키 로테이션)로만 하고, 기본 키 사용은 명시적 opt-in.
|
|
||||||
function resolveSigningKey(): SigningKey | null {
|
|
||||||
const configured = process.env.ADMIN_LICENSE_PRIVATE_KEY?.trim()
|
const configured = process.env.ADMIN_LICENSE_PRIVATE_KEY?.trim()
|
||||||
if (configured) {
|
if (configured) {
|
||||||
return { privateKeyPem: configured.replace(/\\n/g, '\n'), usedDefaultKey: false }
|
return configured.replace(/\\n/g, '\n')
|
||||||
}
|
|
||||||
if (process.env.ADMIN_LICENSE_ALLOW_DEFAULT_KEY === 'true') {
|
|
||||||
return { privateKeyPem: DEFAULT_LICENSE_PRIVATE_KEY, usedDefaultKey: true }
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +106,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||||
|
|
||||||
let licenseKey: string
|
let licenseKey: string
|
||||||
try {
|
try {
|
||||||
licenseKey = issueSignedLicenseKey(payload, signingKey.privateKeyPem)
|
licenseKey = issueSignedLicenseKey(payload, signingKey)
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'license_signing_failed' }, { status: 500 })
|
return NextResponse.json({ error: 'license_signing_failed' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +138,6 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||||
licenseKey,
|
licenseKey,
|
||||||
licenseId: payload.licenseId,
|
licenseId: payload.licenseId,
|
||||||
expiresAt: payload.expiresAt,
|
expiresAt: payload.expiresAt,
|
||||||
usedDefaultKey: signingKey.usedDefaultKey,
|
|
||||||
auditRecorded
|
auditRecorded
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
||||||
const [machineId, setMachineId] = useState('')
|
const [machineId, setMachineId] = useState('')
|
||||||
const [teamId, setTeamId] = useState('')
|
const [teamId, setTeamId] = useState('')
|
||||||
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
|
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
|
||||||
const [usedDefaultKey, setUsedDefaultKey] = useState(false)
|
|
||||||
const [auditRecorded, setAuditRecorded] = useState(true)
|
const [auditRecorded, setAuditRecorded] = useState(true)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
@ -77,7 +76,6 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
||||||
throw new Error(describeIssueError(data.error))
|
throw new Error(describeIssueError(data.error))
|
||||||
}
|
}
|
||||||
setGeneratedKey(data.licenseKey)
|
setGeneratedKey(data.licenseKey)
|
||||||
setUsedDefaultKey(data.usedDefaultKey === true)
|
|
||||||
setAuditRecorded(data.auditRecorded !== false)
|
setAuditRecorded(data.auditRecorded !== false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to generate license key')
|
setError(err instanceof Error ? err.message : 'Failed to generate license key')
|
||||||
|
|
@ -211,12 +209,6 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
||||||
|
|
||||||
{generatedKey && (
|
{generatedKey && (
|
||||||
<Box sx={{ mt: 1, p: 2, bgcolor: 'rgba(0, 0, 0, 0.4)', borderRadius: '10px', border: `1px solid ${C.borderStrong}` }}>
|
<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 && (
|
{!auditRecorded && (
|
||||||
<Alert severity="info" sx={{ fontFamily: FONT_MONO, fontSize: '11px', mb: 1.5 }}>
|
<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;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Net.Mail;
|
||||||
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using D3ROVoice.Api.Dtos;
|
using D3ROVoice.Api.Dtos;
|
||||||
using D3ROVoice.Api.Services;
|
using D3ROVoice.Api.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
namespace D3ROVoice.Api.Controllers;
|
namespace D3ROVoice.Api.Controllers;
|
||||||
|
|
||||||
|
|
@ -13,18 +17,45 @@ namespace D3ROVoice.Api.Controllers;
|
||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IAuthService _authService;
|
private readonly IAuthService _authService;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
public AuthController(IAuthService authService)
|
public AuthController(IAuthService authService, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
|
_configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[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
|
try
|
||||||
|
|
@ -39,9 +70,17 @@ public class AuthController : ControllerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
|
[EnableRateLimiting("auth")]
|
||||||
|
[RequestSizeLimit(16 * 1024)]
|
||||||
public async Task<IActionResult> Login([FromBody] LoginDto dto)
|
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 = "이메일과 비밀번호를 입력해주세요." });
|
return BadRequest(new { message = "이메일과 비밀번호를 입력해주세요." });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,120 @@
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Security.Claims;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using D3ROVoice.Api.Data;
|
|
||||||
using D3ROVoice.Api.Dtos;
|
using D3ROVoice.Api.Dtos;
|
||||||
using D3ROVoice.Api.Services;
|
using D3ROVoice.Api.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace D3ROVoice.Api.Controllers;
|
namespace D3ROVoice.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class SttController : ControllerBase
|
public class SttController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private const string InternalGatewayHeader = "X-D3RO-STT-Gateway-Token";
|
||||||
private readonly ISttProxyService _sttService;
|
private readonly ISttProxyService _sttService;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
public SttController(ISttProxyService sttService)
|
public SttController(ISttProxyService sttService, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_sttService = sttService;
|
_sttService = sttService;
|
||||||
|
_configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("transcribe")]
|
[HttpPost("transcribe")]
|
||||||
[Consumes("application/json", "multipart/form-data")]
|
[Consumes("application/json", "multipart/form-data")]
|
||||||
public async Task<IActionResult> Transcribe()
|
public Task<IActionResult> Transcribe()
|
||||||
{
|
{
|
||||||
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
// User-facing transcription is exclusively handled by the Supabase
|
||||||
var userEmail = User.FindFirstValue(ClaimTypes.Email) ?? "user@d3ro.voice";
|
// stt-proxy, which owns authenticated identity, quota reservation and
|
||||||
int userId = int.TryParse(userIdStr, out var id) ? id : 1;
|
// usage persistence. This legacy provider path must not bypass it.
|
||||||
|
return Task.FromResult<IActionResult>(StatusCode(
|
||||||
|
StatusCodes.Status410Gone,
|
||||||
|
new { error = "stt_edge_gateway_required" }));
|
||||||
|
}
|
||||||
|
|
||||||
if (Request.HasFormContentType)
|
[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)
|
||||||
{
|
{
|
||||||
var form = await Request.ReadFormAsync();
|
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "stt_gateway_not_configured" });
|
||||||
var file = form.Files.GetFile("file") ?? form.Files.GetFile("audio");
|
}
|
||||||
|
|
||||||
if (file == null || file.Length == 0)
|
var suppliedToken = Request.Headers[InternalGatewayHeader].ToString();
|
||||||
{
|
if (!FixedTimeTokenEquals(configuredToken, suppliedToken))
|
||||||
return BadRequest(new { message = "전송할 오디오 파일(file 또는 audio)이 필요합니다." });
|
{
|
||||||
}
|
return Unauthorized(new { error = "stt_gateway_unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
using var memoryStream = new MemoryStream();
|
if (!Request.HasFormContentType)
|
||||||
await file.CopyToAsync(memoryStream);
|
{
|
||||||
var audioBytes = memoryStream.ToArray();
|
return StatusCode(StatusCodes.Status415UnsupportedMediaType, new { error = "unsupported_media_type" });
|
||||||
|
}
|
||||||
|
|
||||||
var language = form["language"].ToString();
|
var form = await Request.ReadFormAsync();
|
||||||
var prompt = form["prompt"].ToString();
|
var file = form.Files.GetFile("file") ?? form.Files.GetFile("audio");
|
||||||
var model = form["model"].ToString();
|
if (file == null || file.Length == 0)
|
||||||
var provider = form["provider"].ToString();
|
{
|
||||||
|
return BadRequest(new { error = "missing_audio" });
|
||||||
|
}
|
||||||
|
|
||||||
var request = new SttTranscribeRequest(
|
await using var memoryStream = new MemoryStream();
|
||||||
AudioBase64: null,
|
await file.CopyToAsync(memoryStream);
|
||||||
Language: string.IsNullOrWhiteSpace(language) ? "ko" : language,
|
var language = form["language"].ToString();
|
||||||
InitialPrompt: string.IsNullOrWhiteSpace(prompt) ? null : prompt,
|
var prompt = form["prompt"].ToString();
|
||||||
ModelId: string.IsNullOrWhiteSpace(model) ? null : model,
|
var request = new SttTranscribeRequest(
|
||||||
Provider: string.IsNullOrWhiteSpace(provider) ? null : provider
|
AudioBase64: null,
|
||||||
);
|
Language: string.IsNullOrWhiteSpace(language) ? "ko" : language,
|
||||||
|
InitialPrompt: string.IsNullOrWhiteSpace(prompt) ? null : prompt,
|
||||||
|
ModelId: null,
|
||||||
|
Provider: null);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
var result = await _sttService.TranscribeAsync(
|
var result = await _sttService.TranscribeAsync(
|
||||||
userId,
|
userId: 0,
|
||||||
userEmail,
|
userEmail: "edge-internal",
|
||||||
request,
|
request,
|
||||||
audioBytes,
|
memoryStream.ToArray(),
|
||||||
file.ContentType ?? "audio/webm",
|
file.ContentType ?? "application/octet-stream",
|
||||||
file.FileName ?? "recording.webm"
|
file.FileName ?? "recording.bin",
|
||||||
);
|
recordUsage: false);
|
||||||
|
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
else
|
catch (SttProviderUnavailableException)
|
||||||
{
|
{
|
||||||
// Read JSON body
|
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "stt_provider_unavailable" });
|
||||||
using var reader = new StreamReader(Request.Body);
|
}
|
||||||
var json = await reader.ReadToEndAsync();
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
return BadRequest(new { error = "invalid_audio" });
|
||||||
{
|
}
|
||||||
return BadRequest(new { message = "요청 본문이 비어있습니다." });
|
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))
|
|
||||||
{
|
|
||||||
return BadRequest(new { message = "AudioBase64 데이터가 필요합니다." });
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _sttService.TranscribeAsync(userId, userEmail, request);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool FixedTimeTokenEquals(string configured, string supplied)
|
||||||
|
{
|
||||||
|
var configuredDigest = SHA256.HashData(Encoding.UTF8.GetBytes(configured));
|
||||||
|
var suppliedDigest = SHA256.HashData(Encoding.UTF8.GetBytes(supplied));
|
||||||
|
return CryptographicOperations.FixedTimeEquals(configuredDigest, suppliedDigest);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("providers")]
|
[HttpGet("providers")]
|
||||||
|
[Authorize(Policy = "ManagerOrAbove")]
|
||||||
public async Task<IActionResult> GetActiveProviders()
|
public async Task<IActionResult> GetActiveProviders()
|
||||||
{
|
{
|
||||||
var endpoints = await _sttService.GetAllEndpointsAsync();
|
var endpoints = await _sttService.GetAllEndpointsAsync();
|
||||||
|
|
@ -101,6 +122,7 @@ public class SttController : ControllerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("test")]
|
[HttpPost("test")]
|
||||||
|
[Authorize(Policy = "ManagerOrAbove")]
|
||||||
public async Task<IActionResult> TestConnection([FromQuery] int endpointId = 0)
|
public async Task<IActionResult> TestConnection([FromQuery] int endpointId = 0)
|
||||||
{
|
{
|
||||||
var result = await _sttService.TestEndpointAsync(endpointId);
|
var result = await _sttService.TestEndpointAsync(endpointId);
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,24 @@
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Version>1.1.0</Version>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</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>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
<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="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="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.22.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.22.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,58 @@ public class SttUsageLog
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
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 class AppDbContext : DbContext
|
||||||
{
|
{
|
||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
@ -213,6 +265,8 @@ public class AppDbContext : DbContext
|
||||||
public DbSet<ServerErrorLog> ErrorLogs => Set<ServerErrorLog>();
|
public DbSet<ServerErrorLog> ErrorLogs => Set<ServerErrorLog>();
|
||||||
public DbSet<SttProviderEndpoint> SttProviderEndpoints => Set<SttProviderEndpoint>();
|
public DbSet<SttProviderEndpoint> SttProviderEndpoints => Set<SttProviderEndpoint>();
|
||||||
public DbSet<SttUsageLog> SttUsageLogs => Set<SttUsageLog>();
|
public DbSet<SttUsageLog> SttUsageLogs => Set<SttUsageLog>();
|
||||||
|
public DbSet<AdminOperationRequest> AdminOperationRequests => Set<AdminOperationRequest>();
|
||||||
|
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|
@ -231,5 +285,12 @@ public class AppDbContext : DbContext
|
||||||
|
|
||||||
modelBuilder.Entity<SttProviderEndpoint>()
|
modelBuilder.Entity<SttProviderEndpoint>()
|
||||||
.HasIndex(s => s.IsDefault);
|
.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
|
# 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
|
WORKDIR /src
|
||||||
|
|
||||||
COPY D3ROVoice.Api.csproj ./
|
COPY D3ROVoice.Api.csproj ./
|
||||||
|
|
@ -8,7 +8,7 @@ RUN dotnet restore
|
||||||
COPY . ./
|
COPY . ./
|
||||||
RUN dotnet publish -c Release -o /app/out
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Create persistent storage directory for SQLite database
|
# Create persistent storage directory for SQLite database
|
||||||
|
|
@ -24,4 +24,3 @@ ENV DATA_DIR=/app/data
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
ENTRYPOINT ["dotnet", "D3ROVoice.Api.dll"]
|
ENTRYPOINT ["dotnet", "D3ROVoice.Api.dll"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ using System.Threading.RateLimiting;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
var serverStartTime = DateTime.UtcNow;
|
var serverStartTime = DateTime.UtcNow;
|
||||||
|
var releaseVersion = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString(3)
|
||||||
|
?? "unknown";
|
||||||
|
|
||||||
// Add Services to Container
|
// Add Services to Container
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|
@ -34,7 +36,7 @@ builder.Services.AddSwaggerGen(c =>
|
||||||
c.SwaggerDoc("v1", new OpenApiInfo
|
c.SwaggerDoc("v1", new OpenApiInfo
|
||||||
{
|
{
|
||||||
Title = "D3RO Voice Cloud API & BackOffice",
|
Title = "D3RO Voice Cloud API & BackOffice",
|
||||||
Version = "v1",
|
Version = releaseVersion,
|
||||||
Description = "D3RO Voice Self-Hosted Cloud Backend for NAS & Docker"
|
Description = "D3RO Voice Self-Hosted Cloud Backend for NAS & Docker"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -460,7 +462,7 @@ app.MapGet("/health", () => Results.Ok(new
|
||||||
{
|
{
|
||||||
status = "Healthy",
|
status = "Healthy",
|
||||||
service = "D3RO Voice Cloud API",
|
service = "D3RO Voice Cloud API",
|
||||||
version = "1.0.0",
|
version = releaseVersion,
|
||||||
uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds,
|
uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds,
|
||||||
database = File.Exists(dbPath) ? "Connected" : "Initializing",
|
database = File.Exists(dbPath) ? "Connected" : "Initializing",
|
||||||
timestamp = DateTime.UtcNow
|
timestamp = DateTime.UtcNow
|
||||||
|
|
@ -470,7 +472,7 @@ app.MapGet("/api/health", () => Results.Ok(new
|
||||||
{
|
{
|
||||||
status = "Healthy",
|
status = "Healthy",
|
||||||
service = "D3RO Voice Cloud API",
|
service = "D3RO Voice Cloud API",
|
||||||
version = "1.0.0",
|
version = releaseVersion,
|
||||||
uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds,
|
uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds,
|
||||||
database = File.Exists(dbPath) ? "Connected" : "Initializing",
|
database = File.Exists(dbPath) ? "Connected" : "Initializing",
|
||||||
timestamp = DateTime.UtcNow
|
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;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using D3ROVoice.Api.Data;
|
using D3ROVoice.Api.Data;
|
||||||
using D3ROVoice.Api.Dtos;
|
using D3ROVoice.Api.Dtos;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
namespace D3ROVoice.Api.Services;
|
namespace D3ROVoice.Api.Services;
|
||||||
|
|
@ -21,43 +21,81 @@ public interface IAuthService
|
||||||
|
|
||||||
public class AuthService : IAuthService
|
public class AuthService : IAuthService
|
||||||
{
|
{
|
||||||
|
private static readonly SemaphoreSlim BootstrapLock = new(1, 1);
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
|
private readonly PasswordHasher<User> _passwordHasher = new();
|
||||||
|
private readonly string _dummyPasswordHash;
|
||||||
|
|
||||||
public AuthService(AppDbContext db, IConfiguration config)
|
public AuthService(AppDbContext db, IConfiguration config)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_config = config;
|
_config = config;
|
||||||
|
_dummyPasswordHash = _passwordHasher.HashPassword(new User(), "dummy-password-never-used");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthResponseDto> RegisterAsync(RegisterDto dto)
|
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)
|
||||||
if (existing != null)
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("이미 등록된 이메일 주소입니다.");
|
throw new InvalidOperationException("비밀번호는 12자 이상 256자 이하여야 합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var isFirstUser = !await _db.Users.AnyAsync();
|
await BootstrapLock.WaitAsync();
|
||||||
var user = new User
|
try
|
||||||
{
|
{
|
||||||
Email = dto.Email.Trim().ToLower(),
|
var normalizedEmail = dto.Email.Trim().ToLowerInvariant();
|
||||||
PasswordHash = HashPassword(dto.Password),
|
var existing = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail);
|
||||||
Role = isFirstUser ? "Admin" : "User",
|
if (existing != null)
|
||||||
CreatedAt = DateTime.UtcNow,
|
{
|
||||||
IsActive = true
|
throw new InvalidOperationException("이미 등록된 이메일 주소입니다.");
|
||||||
};
|
}
|
||||||
|
|
||||||
_db.Users.Add(user);
|
if (await _db.Users.AnyAsync(u => u.IsActive))
|
||||||
await _db.SaveChangesAsync();
|
{
|
||||||
|
throw new InvalidOperationException("관리자 초기 등록이 이미 완료되었습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
return GenerateToken(user);
|
var user = new 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)
|
public async Task<AuthResponseDto> LoginAsync(LoginDto dto)
|
||||||
{
|
{
|
||||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == dto.Email.ToLower());
|
var normalizedEmail = dto.Email.Trim().ToLowerInvariant();
|
||||||
if (user == null || !VerifyPassword(dto.Password, user.PasswordHash))
|
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("이메일 또는 비밀번호가 올바르지 않습니다.");
|
throw new UnauthorizedAccessException("이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +106,10 @@ public class AuthService : IAuthService
|
||||||
}
|
}
|
||||||
|
|
||||||
user.LastLoginAt = DateTime.UtcNow;
|
user.LastLoginAt = DateTime.UtcNow;
|
||||||
|
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
|
||||||
|
{
|
||||||
|
user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
|
||||||
|
}
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return GenerateToken(user);
|
return GenerateToken(user);
|
||||||
|
|
@ -82,7 +124,21 @@ public class AuthService : IAuthService
|
||||||
|
|
||||||
private AuthResponseDto GenerateToken(User user)
|
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 key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
|
||||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
|
@ -93,11 +149,11 @@ public class AuthService : IAuthService
|
||||||
new Claim(ClaimTypes.Role, user.Role)
|
new Claim(ClaimTypes.Role, user.Role)
|
||||||
};
|
};
|
||||||
|
|
||||||
var expiresAt = DateTime.UtcNow.AddDays(30);
|
var expiresAt = DateTime.UtcNow.AddHours(8);
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
var token = new JwtSecurityToken(
|
||||||
issuer: _config["Jwt:Issuer"] ?? "D3ROVoiceApi",
|
issuer: issuer,
|
||||||
audience: _config["Jwt:Audience"] ?? "D3ROVoiceClient",
|
audience: audience,
|
||||||
claims: claims,
|
claims: claims,
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
signingCredentials: creds
|
signingCredentials: creds
|
||||||
|
|
@ -107,15 +163,4 @@ public class AuthService : IAuthService
|
||||||
return new AuthResponseDto(tokenHandler.WriteToken(token), user.Email, user.Role, expiresAt);
|
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,
|
SttTranscribeRequest request,
|
||||||
byte[]? audioBytes = null,
|
byte[]? audioBytes = null,
|
||||||
string? contentType = null,
|
string? contentType = null,
|
||||||
string? fileName = null
|
string? fileName = null,
|
||||||
|
bool recordUsage = true
|
||||||
);
|
);
|
||||||
|
|
||||||
Task<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null);
|
Task<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null);
|
||||||
|
|
@ -35,6 +36,17 @@ public interface ISttProxyService
|
||||||
Task<SttUsageReportDto> GetUsageReportAsync();
|
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
|
public class SttProxyService : ISttProxyService
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
|
|
@ -54,7 +66,8 @@ public class SttProxyService : ISttProxyService
|
||||||
SttTranscribeRequest request,
|
SttTranscribeRequest request,
|
||||||
byte[]? audioBytes = null,
|
byte[]? audioBytes = null,
|
||||||
string? contentType = null,
|
string? contentType = null,
|
||||||
string? fileName = null
|
string? fileName = null,
|
||||||
|
bool recordUsage = true
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// 1. Resolve Audio Bytes
|
// 1. Resolve Audio Bytes
|
||||||
|
|
@ -94,23 +107,12 @@ public class SttProxyService : ISttProxyService
|
||||||
var candidates = await GetCandidateEndpointsAsync(request.Provider, request.ModelId);
|
var candidates = await GetCandidateEndpointsAsync(request.Provider, request.ModelId);
|
||||||
if (candidates.Count == 0)
|
if (candidates.Count == 0)
|
||||||
{
|
{
|
||||||
// Seed a dynamic fallback endpoint in memory
|
throw new SttProviderUnavailableException(providerAttempted: false);
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalSw = Stopwatch.StartNew();
|
var totalSw = Stopwatch.StartNew();
|
||||||
Exception? lastException = null;
|
Exception? lastException = null;
|
||||||
|
var providerAttempted = false;
|
||||||
|
|
||||||
// 3. Failover Execution Chain
|
// 3. Failover Execution Chain
|
||||||
foreach (var endpoint in candidates)
|
foreach (var endpoint in candidates)
|
||||||
|
|
@ -118,6 +120,14 @@ public class SttProxyService : ISttProxyService
|
||||||
var epSw = Stopwatch.StartNew();
|
var epSw = Stopwatch.StartNew();
|
||||||
try
|
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})",
|
_logger.LogInformation("Attempting STT transcription via provider {Provider} ({Name}, Model: {Model})",
|
||||||
endpoint.ProviderType, endpoint.Name, endpoint.ModelId);
|
endpoint.ProviderType, endpoint.Name, endpoint.ModelId);
|
||||||
|
|
||||||
|
|
@ -136,29 +146,33 @@ public class SttProxyService : ISttProxyService
|
||||||
var durationMinutes = (decimal)(finalDuration / 60.0);
|
var durationMinutes = (decimal)(finalDuration / 60.0);
|
||||||
var cost = Math.Max(0.000001m, durationMinutes * endpoint.CostPerMinute);
|
var cost = Math.Max(0.000001m, durationMinutes * endpoint.CostPerMinute);
|
||||||
|
|
||||||
// Record Usage Log
|
// Supabase stt-proxy owns user quota and usage for internal
|
||||||
try
|
// provider calls. Direct legacy callers may still record here.
|
||||||
|
if (recordUsage)
|
||||||
{
|
{
|
||||||
var usageLog = new SttUsageLog
|
try
|
||||||
{
|
{
|
||||||
UserId = userId,
|
var usageLog = new SttUsageLog
|
||||||
UserEmail = userEmail,
|
{
|
||||||
EndpointId = endpoint.Id,
|
UserId = userId,
|
||||||
Provider = endpoint.ProviderType,
|
UserEmail = userEmail,
|
||||||
ModelId = endpoint.ModelId,
|
EndpointId = endpoint.Id,
|
||||||
AudioDurationSeconds = Math.Round(finalDuration, 2),
|
Provider = endpoint.ProviderType,
|
||||||
CalculatedCost = cost,
|
ModelId = endpoint.ModelId,
|
||||||
LatencyMs = (int)epSw.ElapsedMilliseconds,
|
AudioDurationSeconds = Math.Round(finalDuration, 2),
|
||||||
StatusCode = 200,
|
CalculatedCost = cost,
|
||||||
TranscriptPreview = transcript.Length > 200 ? transcript.Substring(0, 200) + "..." : transcript,
|
LatencyMs = (int)epSw.ElapsedMilliseconds,
|
||||||
CreatedAt = DateTime.UtcNow
|
StatusCode = 200,
|
||||||
};
|
TranscriptPreview = transcript.Length > 200 ? transcript.Substring(0, 200) + "..." : transcript,
|
||||||
_db.SttUsageLogs.Add(usageLog);
|
CreatedAt = DateTime.UtcNow
|
||||||
await _db.SaveChangesAsync();
|
};
|
||||||
}
|
_db.SttUsageLogs.Add(usageLog);
|
||||||
catch (Exception dbEx)
|
await _db.SaveChangesAsync();
|
||||||
{
|
}
|
||||||
_logger.LogWarning(dbEx, "Failed to save STT usage log to database");
|
catch (Exception dbEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(dbEx, "Failed to save STT usage log to database");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new SttTranscribeResponse(
|
return new SttTranscribeResponse(
|
||||||
|
|
@ -176,18 +190,19 @@ public class SttProxyService : ISttProxyService
|
||||||
{
|
{
|
||||||
epSw.Stop();
|
epSw.Stop();
|
||||||
lastException = ex;
|
lastException = ex;
|
||||||
_logger.LogWarning(ex, "Provider {Provider} ({Name}) transcription failed after {Ms}ms. Trying fallback...",
|
var failureType = GetSafeFailureType(ex);
|
||||||
endpoint.ProviderType, endpoint.Name, epSw.ElapsedMilliseconds);
|
_logger.LogWarning("Provider {Provider} transcription failed after {Ms}ms ({FailureType}). Trying fallback...",
|
||||||
|
endpoint.ProviderType, epSw.ElapsedMilliseconds, failureType);
|
||||||
|
|
||||||
// Record error to ErrorLogs
|
// Record error to ErrorLogs
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_db.ErrorLogs.Add(new ServerErrorLog
|
_db.ErrorLogs.Add(new ServerErrorLog
|
||||||
{
|
{
|
||||||
ErrorType = $"SttProviderError:{endpoint.ProviderType}",
|
ErrorType = "SttProviderError",
|
||||||
Message = $"STT failed on endpoint {endpoint.Name} ({endpoint.EndpointUrl}): {ex.Message}",
|
Message = $"STT provider request failed ({failureType}).",
|
||||||
StackTrace = ex.StackTrace,
|
StackTrace = null,
|
||||||
Endpoint = endpoint.EndpointUrl,
|
Endpoint = null,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
@ -200,19 +215,9 @@ public class SttProxyService : ISttProxyService
|
||||||
}
|
}
|
||||||
|
|
||||||
totalSw.Stop();
|
totalSw.Stop();
|
||||||
_logger.LogError(lastException, "All STT candidate endpoints failed. Total duration: {Ms}ms", totalSw.ElapsedMilliseconds);
|
_logger.LogError("All STT candidate endpoints failed. Total duration: {Ms}ms; last failure type: {FailureType}",
|
||||||
|
totalSw.ElapsedMilliseconds, GetSafeFailureType(lastException));
|
||||||
// Standalone graceful mock echo response if no cloud API keys configured or network is isolated
|
throw new SttProviderUnavailableException(providerAttempted);
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(string Transcript, double Confidence, string? Language, double Duration)> ExecuteProviderTranscriptionAsync(
|
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 providerType = endpoint.ProviderType.ToLowerInvariant();
|
||||||
var apiKey = endpoint.ApiKey?.Trim() ?? "";
|
var apiKey = endpoint.ApiKey?.Trim() ?? "";
|
||||||
|
|
||||||
// If no API key configured or local mock
|
if (RequiresApiKey(providerType) && string.IsNullOrWhiteSpace(apiKey))
|
||||||
if (string.IsNullOrWhiteSpace(apiKey) && providerType != "local-sidecar" && providerType != "custom")
|
|
||||||
{
|
{
|
||||||
return (
|
throw new InvalidOperationException("STT provider credentials are not configured.");
|
||||||
$"[D3RO Online Cloud STT - {endpoint.Name}] 음성 인식이 성공적으로 처리되었습니다.",
|
|
||||||
0.99,
|
|
||||||
request.Language ?? endpoint.Language ?? "ko",
|
|
||||||
EstimateAudioDuration(audioBytes, contentType)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (providerType)
|
switch (providerType)
|
||||||
|
|
@ -325,17 +324,17 @@ public class SttProxyService : ISttProxyService
|
||||||
|
|
||||||
if (!httpResponse.IsSuccessStatusCode)
|
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);
|
using var doc = JsonDocument.Parse(responseBody);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
var text = "";
|
if (!root.TryGetProperty("text", out var textProp) || textProp.ValueKind != JsonValueKind.String)
|
||||||
if (root.TryGetProperty("text", out var textProp))
|
|
||||||
{
|
{
|
||||||
text = textProp.GetString() ?? "";
|
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||||
}
|
}
|
||||||
|
var text = textProp.GetString() ?? "";
|
||||||
|
|
||||||
var duration = 0.0;
|
var duration = 0.0;
|
||||||
if (root.TryGetProperty("duration", out var durProp))
|
if (root.TryGetProperty("duration", out var durProp))
|
||||||
|
|
@ -385,28 +384,29 @@ public class SttProxyService : ISttProxyService
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
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);
|
using var doc = JsonDocument.Parse(responseBody);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
var transcript = "";
|
string transcript;
|
||||||
var confidence = 0.95;
|
var confidence = 0.95;
|
||||||
var duration = 0.0;
|
var duration = 0.0;
|
||||||
|
|
||||||
if (root.TryGetProperty("results", out var results) &&
|
if (!root.TryGetProperty("results", out var results) ||
|
||||||
results.TryGetProperty("channels", out var channels) &&
|
!results.TryGetProperty("channels", out var channels) ||
|
||||||
channels.GetArrayLength() > 0)
|
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];
|
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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))
|
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"))
|
if (endpoint.EndpointUrl.Contains("generativelanguage.googleapis.com") || endpoint.ModelId.Contains("gemini"))
|
||||||
{
|
{
|
||||||
var apiKey = endpoint.ApiKey;
|
var apiKey = endpoint.ApiKey;
|
||||||
var url = endpoint.EndpointUrl.Contains("?")
|
|
||||||
? $"{endpoint.EndpointUrl}&key={apiKey}"
|
|
||||||
: $"{endpoint.EndpointUrl}?key={apiKey}";
|
|
||||||
|
|
||||||
var geminiPayload = new
|
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")
|
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 response = await client.SendAsync(httpRequest);
|
||||||
var responseBody = await response.Content.ReadAsStringAsync();
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
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);
|
using var doc = JsonDocument.Parse(responseBody);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
var text = "";
|
if (!root.TryGetProperty("candidates", out var cands) ||
|
||||||
if (root.TryGetProperty("candidates", out var cands) && cands.GetArrayLength() > 0)
|
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];
|
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||||
if (cand0.TryGetProperty("content", out var content) &&
|
|
||||||
content.TryGetProperty("parts", out var parts) &&
|
|
||||||
parts.GetArrayLength() > 0)
|
|
||||||
{
|
|
||||||
text = parts[0].GetProperty("text").GetString() ?? "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
var text = textProperty.GetString() ?? "";
|
||||||
|
|
||||||
return (text.Trim(), 0.98, lang, EstimateAudioDuration(audioBytes, contentType));
|
return (text.Trim(), 0.98, lang, EstimateAudioDuration(audioBytes, contentType));
|
||||||
}
|
}
|
||||||
|
|
@ -493,9 +491,6 @@ public class SttProxyService : ISttProxyService
|
||||||
{
|
{
|
||||||
// Google Cloud Speech-to-Text v1
|
// Google Cloud Speech-to-Text v1
|
||||||
var apiKey = endpoint.ApiKey;
|
var apiKey = endpoint.ApiKey;
|
||||||
var url = endpoint.EndpointUrl.Contains("?")
|
|
||||||
? $"{endpoint.EndpointUrl}&key={apiKey}"
|
|
||||||
: $"{endpoint.EndpointUrl}?key={apiKey}";
|
|
||||||
|
|
||||||
var gcpPayload = new
|
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")
|
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 response = await client.SendAsync(httpRequest);
|
||||||
var responseBody = await response.Content.ReadAsStringAsync();
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
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);
|
using var doc = JsonDocument.Parse(responseBody);
|
||||||
|
|
@ -530,7 +526,12 @@ public class SttProxyService : ISttProxyService
|
||||||
var transcript = "";
|
var transcript = "";
|
||||||
var confidence = 0.95;
|
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();
|
var sb = new StringBuilder();
|
||||||
foreach (var res in results.EnumerateArray())
|
foreach (var res in results.EnumerateArray())
|
||||||
|
|
@ -568,7 +569,7 @@ public class SttProxyService : ISttProxyService
|
||||||
var uploadBody = await uploadResp.Content.ReadAsStringAsync();
|
var uploadBody = await uploadResp.Content.ReadAsStringAsync();
|
||||||
if (!uploadResp.IsSuccessStatusCode)
|
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);
|
using var uploadDoc = JsonDocument.Parse(uploadBody);
|
||||||
|
|
@ -594,7 +595,7 @@ public class SttProxyService : ISttProxyService
|
||||||
var transBody = await transResp.Content.ReadAsStringAsync();
|
var transBody = await transResp.Content.ReadAsStringAsync();
|
||||||
if (!transResp.IsSuccessStatusCode)
|
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);
|
using var transDoc = JsonDocument.Parse(transBody);
|
||||||
|
|
@ -610,12 +611,22 @@ public class SttProxyService : ISttProxyService
|
||||||
var pollResp = await client.SendAsync(pollReq);
|
var pollResp = await client.SendAsync(pollReq);
|
||||||
var pollBody = await pollResp.Content.ReadAsStringAsync();
|
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);
|
using var pollDoc = JsonDocument.Parse(pollBody);
|
||||||
var status = pollDoc.RootElement.GetProperty("status").GetString();
|
var status = pollDoc.RootElement.GetProperty("status").GetString();
|
||||||
|
|
||||||
if (status == "completed")
|
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;
|
var confidence = 0.95;
|
||||||
if (pollDoc.RootElement.TryGetProperty("confidence", out var c)) confidence = c.GetDouble();
|
if (pollDoc.RootElement.TryGetProperty("confidence", out var c)) confidence = c.GetDouble();
|
||||||
var duration = 0.0;
|
var duration = 0.0;
|
||||||
|
|
@ -624,8 +635,7 @@ public class SttProxyService : ISttProxyService
|
||||||
}
|
}
|
||||||
if (status == "error")
|
if (status == "error")
|
||||||
{
|
{
|
||||||
var err = pollDoc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : "Unknown error";
|
throw new HttpRequestException("STT upstream processing failed.");
|
||||||
throw new HttpRequestException($"AssemblyAI processing error: {err}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -663,24 +673,32 @@ public class SttProxyService : ISttProxyService
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
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);
|
using var doc = JsonDocument.Parse(responseBody);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
var text = "";
|
string text;
|
||||||
var confidence = 0.95;
|
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() ?? "";
|
text = dt.GetString() ?? "";
|
||||||
}
|
}
|
||||||
else if (root.TryGetProperty("NBest", out var nbest) && nbest.GetArrayLength() > 0)
|
else if (root.TryGetProperty("NBest", out var nbest) && nbest.GetArrayLength() > 0)
|
||||||
{
|
{
|
||||||
var best = nbest[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();
|
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));
|
return (text, confidence, lang, EstimateAudioDuration(audioBytes, contentType));
|
||||||
}
|
}
|
||||||
|
|
@ -717,14 +735,17 @@ public class SttProxyService : ISttProxyService
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
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);
|
using var doc = JsonDocument.Parse(responseBody);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
var text = "";
|
if (!root.TryGetProperty("text", out var tProp) || tProp.ValueKind != JsonValueKind.String)
|
||||||
if (root.TryGetProperty("text", out var tProp)) text = tProp.GetString() ?? "";
|
{
|
||||||
|
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||||
|
}
|
||||||
|
var text = tProp.GetString() ?? "";
|
||||||
var duration = EstimateAudioDuration(audioBytes, contentType);
|
var duration = EstimateAudioDuration(audioBytes, contentType);
|
||||||
if (root.TryGetProperty("duration", out var dProp)) duration = dProp.GetDouble();
|
if (root.TryGetProperty("duration", out var dProp)) duration = dProp.GetDouble();
|
||||||
|
|
||||||
|
|
@ -789,7 +810,7 @@ public class SttProxyService : ISttProxyService
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
return new SttTestResultDto(
|
return new SttTestResultDto(
|
||||||
Success: false,
|
Success: false,
|
||||||
Message: $"연결 실패: {ex.Message}",
|
Message: $"연결 실패: {GetSafeFailureType(ex)}",
|
||||||
LatencyMs: sw.ElapsedMilliseconds,
|
LatencyMs: sw.ElapsedMilliseconds,
|
||||||
TranscriptPreview: null,
|
TranscriptPreview: null,
|
||||||
Provider: endpoint.ProviderType,
|
Provider: endpoint.ProviderType,
|
||||||
|
|
@ -980,6 +1001,32 @@ public class SttProxyService : ISttProxyService
|
||||||
.ToListAsync();
|
.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)
|
private static void ApplyExtraHeaders(HttpRequestMessage req, string? extraHeadersJson)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(extraHeadersJson)) return;
|
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>
|
<!doctype html>
|
||||||
<html lang="ko" class="dark">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>D3RO Voice — Official Download Center & Release History</title>
|
<meta http-equiv="refresh" content="0; url=https://d3ro.chanpaca.net/#download" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="canonical" href="https://d3ro.chanpaca.net/#download" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<title>D3RO Voice 릴리스 준비</title>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
</head>
|
||||||
<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">
|
<body>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<main>
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
<h1>D3RO Voice 릴리스 준비</h1>
|
||||||
<script>
|
<p>설치 파일은 서명과 업데이트 경로 검증이 끝난 뒤 공식 페이지에서 제공합니다.</p>
|
||||||
tailwind.config = {
|
<p><a href="https://d3ro.chanpaca.net/#download">공식 릴리스 준비 페이지로 이동</a></p>
|
||||||
darkMode: 'class',
|
</main>
|
||||||
theme: {
|
</body>
|
||||||
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>
|
|
||||||
|
|
||||||
</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>
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta http-equiv="refresh" content="0; url=https://d3ro.chanpaca.net/#download" />
|
||||||
<meta name="description" content="D3RO Voice - Fully local AI voice assistant. Whisper + Ollama powered, zero cloud, 100% private." />
|
<link rel="canonical" href="https://d3ro.chanpaca.net/#download" />
|
||||||
<meta name="theme-color" content="#f25b29" />
|
<title>D3RO Voice 릴리스 준비</title>
|
||||||
<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">
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-surface-950 text-white antialiased">
|
<body>
|
||||||
<div id="root"></div>
|
<main>
|
||||||
|
<h1>D3RO Voice 릴리스 준비</h1>
|
||||||
|
<p>설치 파일은 서명과 업데이트 경로 검증이 끝난 뒤 공식 페이지에서 제공합니다.</p>
|
||||||
|
<p><a href="https://d3ro.chanpaca.net/#download">공식 릴리스 준비 페이지로 이동</a></p>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ productName: D3RO Voice
|
||||||
copyright: Copyright © 2026 D3RO
|
copyright: Copyright © 2026 D3RO
|
||||||
# monorepo(npm workspaces)에서 electron이 루트 node_modules로 호이스팅되어
|
# monorepo(npm workspaces)에서 electron이 루트 node_modules로 호이스팅되어
|
||||||
# 자동 감지가 실패하는 문제를 피하려고 명시적으로 버전 고정.
|
# 자동 감지가 실패하는 문제를 피하려고 명시적으로 버전 고정.
|
||||||
electronVersion: "33.3.0"
|
electronVersion: "33.4.11"
|
||||||
|
|
||||||
directories:
|
directories:
|
||||||
# buildResources를 build/로 지정 — resources/icons/가 비어있어 아이콘 자동 스캔이 실패하는 것을 우회.
|
# buildResources를 build/로 지정 — resources/icons/가 비어있어 아이콘 자동 스캔이 실패하는 것을 우회.
|
||||||
|
|
@ -25,7 +25,7 @@ files:
|
||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
publish:
|
publish:
|
||||||
provider: generic
|
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을 만드는 동작 차단 —
|
# prerelease 버전(0.1.1-alpha)에서 채널을 "alpha"로 감지해 alpha.yml을 만드는 동작 차단 —
|
||||||
# electron-updater(기본 채널 latest)가 latest.yml을 찾으므로 항상 latest 채널로 고정.
|
# electron-updater(기본 채널 latest)가 latest.yml을 찾으므로 항상 latest 채널로 고정.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { createPublicKey } from 'crypto'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
@ -9,9 +11,19 @@ const sharedAlias = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceExclude = ['@d3ro/core', '@d3ro/ui', '@d3ro/i18n']
|
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({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
|
// 공개키만 main bundle에 주입한다. 대응 ADMIN_LICENSE_PRIVATE_KEY는 admin 서버 전용이다.
|
||||||
|
define: {
|
||||||
|
'process.env.D3RO_LICENSE_PUBLIC_KEY': JSON.stringify(licensePublicKey)
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
externalizeDepsPlugin({
|
externalizeDepsPlugin({
|
||||||
exclude: ['electron-store', ...workspaceExclude]
|
exclude: ['electron-store', ...workspaceExclude]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@d3ro/desktop",
|
"name": "@d3ro/desktop",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"productName": "d3ro-voice",
|
"productName": "d3ro-voice",
|
||||||
"description": "로컬 AI 음성 어시스턴트 (Electron)",
|
"description": "로컬 AI 음성 어시스턴트 (Electron)",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"electron": "^33.3.0",
|
"electron": "33.4.11",
|
||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "^26.8.1",
|
||||||
"electron-vite": "^2.3.0",
|
"electron-vite": "^2.3.0",
|
||||||
"vite": "^5.4.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 — 초기화 시퀀스
|
// src/main/bootstrap.ts — 초기화 시퀀스
|
||||||
|
|
||||||
import { join } from 'path'
|
|
||||||
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
|
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
|
||||||
import { initLoggerService, getLogger } from './services/LoggerService'
|
import { initLoggerService, getLogger } from './services/LoggerService'
|
||||||
import { initConfigService, configGet, configSet } from './services/ConfigService'
|
import { initConfigService, configGet, configSet } from './services/ConfigService'
|
||||||
import { getHotkeyService } from './services/HotkeyService'
|
import { getHotkeyService } from './services/HotkeyService'
|
||||||
import { getVoiceModeService } from './services/VoiceModeService'
|
import { getVoiceModeService } from './services/VoiceModeService'
|
||||||
import { getPremiumLLMService } from './services/PremiumLLMService'
|
|
||||||
import { startLocalLLMAvailability } from './services/LocalLLMService'
|
import { startLocalLLMAvailability } from './services/LocalLLMService'
|
||||||
import { getHistoryService } from './services/HistoryService'
|
import { getHistoryService } from './services/HistoryService'
|
||||||
import { persistCompletedVoiceSessionSafe } from './voice-session-persist'
|
import { persistCompletedVoiceSessionSafe } from './voice-session-persist'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// src/main/index.ts — 앱 진입점
|
// src/main/index.ts — 앱 진입점
|
||||||
|
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import electronLog from 'electron-log'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
// 앱 식별자 명시 (기본값 'Electron'과 충돌 방지)
|
// 앱 식별자 명시 (기본값 'Electron'과 충돌 방지)
|
||||||
|
|
@ -24,7 +25,7 @@ process.stderr?.on?.('error', () => { /* ignore EPIPE */ })
|
||||||
process.on('uncaughtException', (err) => {
|
process.on('uncaughtException', (err) => {
|
||||||
if (err.message?.includes('EPIPE')) return // EPIPE는 무시
|
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=...
|
// 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()
|
const gotTheLock = process.env.NODE_ENV === 'test' ? true : app.requestSingleInstanceLock()
|
||||||
|
|
||||||
if (!gotTheLock) {
|
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)
|
app.exit(0)
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', (_event, argv) => {
|
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 { getAudioCaptureService, calculateRMS } from '../services/AudioCaptureService'
|
||||||
import { getMainWindow } from '../windows/WindowManager'
|
import { getMainWindow } from '../windows/WindowManager'
|
||||||
import { configGet, configSet } from '../services/ConfigService'
|
import { configGet, configSet } from '../services/ConfigService'
|
||||||
import { getLogger } from '../services/LoggerService'
|
|
||||||
import type { SetDeviceParams } from '@d3ro/core/types'
|
import type { SetDeviceParams } from '@d3ro/core/types'
|
||||||
|
|
||||||
const logger = getLogger('audio-handlers')
|
|
||||||
|
|
||||||
let testTimer: ReturnType<typeof setTimeout> | null = null
|
let testTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let testAudioHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
|
let testAudioHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
|
||||||
let testLevelInterval: ReturnType<typeof setInterval> | null = null
|
let testLevelInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,246 @@
|
||||||
// apps/desktop/src/main/ipc/payment-handlers.ts
|
// 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 { ipcMain } from 'electron'
|
||||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
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 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 {
|
export function registerPaymentHandlers(): void {
|
||||||
// 1. Create Checkout Session
|
const attempts = new Map<string, CheckoutAttempt>()
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2. Verify Payment & Activate Tier
|
ipcMain.handle(
|
||||||
ipcMain.handle(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT, async (_event, { tier }: { tier: string }) => {
|
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 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 {
|
try {
|
||||||
const license = getLicenseService()
|
const status = await readServerSubscription(authenticated.cloud)
|
||||||
await license.activate(`D3RO-${tier.toUpperCase()}-${Date.now().toString(36).toUpperCase()}`)
|
return ok({ success: status.valid, activeTier: status.tier })
|
||||||
} catch {
|
} catch (error) {
|
||||||
// ignore
|
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 () => {
|
ipcMain.handle(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS, async () => {
|
||||||
const license = getLicenseService()
|
const authenticated = getAuthenticatedCloud()
|
||||||
const info = license.getLicenseInfo()
|
if (!authenticated) {
|
||||||
return ok({
|
return ipcError(ErrorCode.UnknownError, 'Sign in is required to read a subscription')
|
||||||
tier: info.tier,
|
}
|
||||||
valid: info.valid,
|
try {
|
||||||
expiresAt: info.expiresAt,
|
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)
|
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() })
|
safeSendToRenderer(IPC_CHANNELS.STT.STATUS_CHANGED, { status: getSTTManager().getStatus() })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// apps/desktop/src/main/ipc/support-handlers.ts
|
// apps/desktop/src/main/ipc/support-handlers.ts
|
||||||
// IPC handlers for Customer Assistance, AI Helpdesk & Diagnostics
|
// IPC handlers for Customer Assistance, AI Helpdesk & Diagnostics
|
||||||
|
|
||||||
import { ipcMain } from 'electron'
|
import { app, ipcMain } from 'electron'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||||
import { ok } from '@d3ro/core/errors'
|
import { ok } from '@d3ro/core/errors'
|
||||||
|
|
@ -30,7 +30,7 @@ export function registerSupportHandlers(): void {
|
||||||
|
|
||||||
const payload: SystemDiagnosticsPayload = {
|
const payload: SystemDiagnosticsPayload = {
|
||||||
machineId: `d3ro-${os.hostname().toLowerCase().slice(0, 12)}`,
|
machineId: `d3ro-${os.hostname().toLowerCase().slice(0, 12)}`,
|
||||||
appVersion: '1.0.0-release',
|
appVersion: app.getVersion(),
|
||||||
platform: `${os.platform()} (${os.arch()})`,
|
platform: `${os.platform()} (${os.arch()})`,
|
||||||
osRelease: `${os.type()} ${os.release()}`,
|
osRelease: `${os.type()} ${os.release()}`,
|
||||||
activeAudioDevice: (configGet('audio.selectedDevice') as string) || 'Default System Microphone',
|
activeAudioDevice: (configGet('audio.selectedDevice') as string) || 'Default System Microphone',
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export function registerVoiceHandlers(): void {
|
||||||
return ipcSuccess(getVoiceModeService().getState())
|
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: 모드만 설정에 저장 (실제 모드 전환은 핫키에서 처리)
|
// Phase 2: 모드만 설정에 저장 (실제 모드 전환은 핫키에서 처리)
|
||||||
return ipcSuccess(undefined)
|
return ipcSuccess(undefined)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
// Windows: SoX 직접 spawn (-t waveaudio). 기타: node-record-lpcm16.
|
// Windows: SoX 직접 spawn (-t waveaudio). 기타: node-record-lpcm16.
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import path from 'path'
|
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { spawn, type ChildProcess } from 'child_process'
|
import { spawn, type ChildProcess } from 'child_process'
|
||||||
import type { Readable } from 'stream'
|
import type { Readable } from 'stream'
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// 3초 청크 기반 스트리밍 전사. 싱글톤 + EventEmitter 패턴.
|
// 3초 청크 기반 스트리밍 전사. 싱글톤 + EventEmitter 패턴.
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
|
import { app } from 'electron'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { getAudioCaptureService, calculateRMS } from './AudioCaptureService'
|
import { getAudioCaptureService, calculateRMS } from './AudioCaptureService'
|
||||||
import { getSoundEffectService } from './SoundEffectService'
|
import { getSoundEffectService } from './SoundEffectService'
|
||||||
|
|
@ -485,7 +486,7 @@ class CaptionService extends EventEmitter {
|
||||||
llmModel: null,
|
llmModel: null,
|
||||||
sttLatencyMs: null,
|
sttLatencyMs: null,
|
||||||
llmLatencyMs: null,
|
llmLatencyMs: null,
|
||||||
appVersion: '1.0.0',
|
appVersion: app.getVersion(),
|
||||||
})
|
})
|
||||||
logger.info(
|
logger.info(
|
||||||
`캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}초`,
|
`캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}초`,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { getLogger } from './LoggerService';
|
import { getLogger } from './LoggerService';
|
||||||
import { getCloudSyncService } from './CloudSyncService';
|
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors';
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors';
|
||||||
|
import { D3ROCloudDriver } from './stt/drivers/D3ROCloudDriver';
|
||||||
|
|
||||||
export interface TranscriptionSegment {
|
export interface TranscriptionSegment {
|
||||||
readonly text: string;
|
readonly text: string;
|
||||||
|
|
@ -28,18 +28,10 @@ const logger = getLogger('CloudSTTService');
|
||||||
|
|
||||||
class CloudSTTService extends EventEmitter {
|
class CloudSTTService extends EventEmitter {
|
||||||
private _disposed = false;
|
private _disposed = false;
|
||||||
|
private readonly _driver = new D3ROCloudDriver();
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this._disposed) return;
|
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');
|
logger.info('CloudSTTService initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,36 +39,7 @@ class CloudSTTService extends EventEmitter {
|
||||||
if (this._disposed) {
|
if (this._disposed) {
|
||||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'CloudSTTService disposed');
|
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'CloudSTTService disposed');
|
||||||
}
|
}
|
||||||
const cloud = getCloudSyncService();
|
return this._driver.transcribe(audioBuffer, options);
|
||||||
|
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -380,7 +380,11 @@ class CloudSyncService extends EventEmitter {
|
||||||
* Phase 3.2: Supabase Edge Function 호출 — 클라이언트가 auth 헤더를 올바르게 처리.
|
* Phase 3.2: Supabase Edge Function 호출 — 클라이언트가 auth 헤더를 올바르게 처리.
|
||||||
* raw fetch 대신 이걸 사용해야 gateway 레벨 401 방지.
|
* 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) {
|
if (!this._client) {
|
||||||
return { data: null, error: { message: 'Supabase client not initialized' } }
|
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, {
|
const { data, error } = await this._client.functions.invoke(name, {
|
||||||
body,
|
body,
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: options?.signal,
|
||||||
|
timeout: options?.timeoutMs
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
import type {
|
import type {
|
||||||
DictationTemplate,
|
DictationTemplate,
|
||||||
TemplateField,
|
|
||||||
TemplateSessionState,
|
|
||||||
TemplateSessionInfo,
|
TemplateSessionInfo,
|
||||||
TemplateFieldCompletedEvent,
|
TemplateFieldCompletedEvent,
|
||||||
TemplateSessionCompletedEvent,
|
TemplateSessionCompletedEvent,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { getLocalSTTService } from './LocalSTTService'
|
|
||||||
import { getSTTManager } from './stt/STTManager'
|
import { getSTTManager } from './stt/STTManager'
|
||||||
import { getHistoryService } from './HistoryService'
|
import { getHistoryService } from './HistoryService'
|
||||||
import { configGet } from './ConfigService'
|
import { configGet } from './ConfigService'
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,23 @@ import { verifySignedLicenseKey, createDefaultTrialPayload } from '@d3ro/core/ut
|
||||||
|
|
||||||
const logger = getLogger('license')
|
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 생성 ─────────────────────────────────────────
|
// ── 머신 ID 생성 ─────────────────────────────────────────
|
||||||
function generateMachineId(): string {
|
function generateMachineId(): string {
|
||||||
const raw = `${os.hostname()}-${os.cpus()[0]?.model ?? 'unknown'}-${os.platform()}-${os.arch()}`
|
const raw = `${os.hostname()}-${os.cpus()[0]?.model ?? 'unknown'}-${os.platform()}-${os.arch()}`
|
||||||
|
|
@ -108,9 +125,6 @@ const TIER_ORDER: Record<LicenseTier, number> = {
|
||||||
/** 오프라인 유예 기간: 30일 */
|
/** 오프라인 유예 기간: 30일 */
|
||||||
const OFFLINE_GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000
|
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 {
|
function tierAtLeast(current: LicenseTier, required: LicenseTier): boolean {
|
||||||
return TIER_ORDER[current] >= TIER_ORDER[required]
|
return TIER_ORDER[current] >= TIER_ORDER[required]
|
||||||
}
|
}
|
||||||
|
|
@ -174,8 +188,6 @@ class LicenseService extends EventEmitter {
|
||||||
const storedTrialExpiresAt = this._readStoredField<number>('licenseTrialExpiresAt')
|
const storedTrialExpiresAt = this._readStoredField<number>('licenseTrialExpiresAt')
|
||||||
const storedExpiresAt = this._readStoredField<number>('licenseExpiresAt')
|
const storedExpiresAt = this._readStoredField<number>('licenseExpiresAt')
|
||||||
const storedCustomerEmail = this._readStoredField<string>('licenseCustomerEmail')
|
const storedCustomerEmail = this._readStoredField<string>('licenseCustomerEmail')
|
||||||
const trialEverStarted = this._readStoredField<boolean>('licenseTrialEverStarted')
|
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
if (storedTier && storedTier !== 'free') {
|
if (storedTier && storedTier !== 'free') {
|
||||||
|
|
@ -410,9 +422,9 @@ class LicenseService extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 라이센스 키 활성화 (Ed25519 암호화 서명 검증 + 레거시 호환).
|
* 라이센스 키 활성화 (Ed25519 암호화 서명 검증).
|
||||||
* D3RO-LIC-xxx (Ed25519 서명 키) 또는 D3RO-PRO-xxx (개발자 키)
|
* 운영은 D3RO_LICENSE_PUBLIC_KEY가 반드시 필요하고, 고정 개발 fixture는 test 또는
|
||||||
* 프로덕션 결제는 Payple/Stripe 웹 결제 → CloudSync 티어 갱신 또는 라이센스 키 입력
|
* 명시적으로 opt-in한 development 환경에서만 허용한다.
|
||||||
*/
|
*/
|
||||||
async activate(key: string): Promise<ActivateLicenseResult> {
|
async activate(key: string): Promise<ActivateLicenseResult> {
|
||||||
const trimmedKey = key.trim()
|
const trimmedKey = key.trim()
|
||||||
|
|
@ -420,7 +432,13 @@ class LicenseService extends EventEmitter {
|
||||||
return { success: false, tier: 'free', message: 'License key is empty' }
|
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) {
|
if (!verification.valid) {
|
||||||
return { success: false, tier: 'free', message: verification.message }
|
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 { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
import {
|
import {
|
||||||
parseMinutes,
|
|
||||||
buildExportMarkdown,
|
buildExportMarkdown,
|
||||||
markdownToSimpleHtml,
|
markdownToSimpleHtml,
|
||||||
formatTime,
|
formatTime,
|
||||||
|
|
@ -45,15 +44,6 @@ const logger = getLogger('MeetingModeService')
|
||||||
|
|
||||||
let isShowingMeetingSaveDialog = false
|
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 {
|
class MeetingModeService extends EventEmitter {
|
||||||
private _state: MeetingModeState = 'idle'
|
private _state: MeetingModeState = 'idle'
|
||||||
private _sessionId: string | null = null
|
private _sessionId: string | null = null
|
||||||
|
|
@ -1232,7 +1222,7 @@ ${speakerHint}
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: 'Malgun Gothic', sans-serif; margin: 40px; color: #222; }
|
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; }
|
h2 { font-size: 16px; color: #444; margin-top: 24px; }
|
||||||
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
|
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ class MemoService {
|
||||||
const db = getDatabase()
|
const db = getDatabase()
|
||||||
|
|
||||||
// 태그별 히스토리 조회
|
// 태그별 히스토리 조회
|
||||||
let tagFilter = params.tag
|
const tagFilter = params.tag
|
||||||
? eq(memoTags.tag, params.tag.trim().toLowerCase())
|
? eq(memoTags.tag, params.tag.trim().toLowerCase())
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,11 @@
|
||||||
// 온라인 모드 사용 시 필수 인증(JWT Bearer)을 통해 서버로 AI 요청 전달.
|
// 온라인 모드 사용 시 필수 인증(JWT Bearer)을 통해 서버로 AI 요청 전달.
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { getLogger } from './LoggerService'
|
|
||||||
import { configGet, configSet } from './ConfigService'
|
import { configGet, configSet } from './ConfigService'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
import type { LLMAction } from '@d3ro/core/types'
|
import type { LLMAction } from '@d3ro/core/types'
|
||||||
import { resolveSystemPrompt } from './llm-prompts'
|
import { resolveSystemPrompt } from './llm-prompts'
|
||||||
|
|
||||||
const logger = getLogger('OnlineLLMService')
|
|
||||||
|
|
||||||
interface OnlineGenerateOptions {
|
|
||||||
model?: string
|
|
||||||
temperature?: number
|
|
||||||
maxTokens?: number
|
|
||||||
systemPrompt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OnlineGenerateResponse {
|
interface OnlineGenerateResponse {
|
||||||
text: string
|
text: string
|
||||||
model: 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 변환용 */
|
/** Ollama-style 메시지 → Claude Messages 변환용 */
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
|
|
@ -55,17 +48,6 @@ interface ClaudeMessageResponse {
|
||||||
usage: { input_tokens: number; output_tokens: number }
|
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 {
|
export interface QuotaUsageSnapshot {
|
||||||
tier: 'free' | 'pro' | 'pro_plus'
|
tier: 'free' | 'pro' | 'pro_plus'
|
||||||
current: number
|
current: number
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { getPremiumLLMService } from './PremiumLLMService'
|
import { getPremiumLLMService } from './PremiumLLMService'
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// src/main/services/UpdateService.ts
|
// src/main/services/UpdateService.ts
|
||||||
// electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter.
|
// 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)
|
// 1. 사용자 인가 기반 다운로드 (autoDownload=false)
|
||||||
// 2. 이번 버전 건너뛰기 (Skip This Version) 지원
|
// 2. 이번 버전 건너뛰기 (Skip This Version) 지원
|
||||||
|
|
@ -172,7 +172,7 @@ class UpdateService extends EventEmitter {
|
||||||
/** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
|
/** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
|
||||||
private async _promptUserConsent(
|
private async _promptUserConsent(
|
||||||
version: string,
|
version: string,
|
||||||
releaseNotes?: string | any[]
|
releaseNotes?: string | ReadonlyArray<{ version: string; note: string | null }> | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this._promptShown || this._downloading) return
|
if (this._promptShown || this._downloading) return
|
||||||
this._promptShown = true
|
this._promptShown = true
|
||||||
|
|
@ -262,4 +262,3 @@ export function getUpdateService(): UpdateService {
|
||||||
}
|
}
|
||||||
|
|
||||||
export { UpdateService }
|
export { UpdateService }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { exec } from 'child_process'
|
||||||
import { shell } from 'electron'
|
import { shell } from 'electron'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { getPremiumLLMService } from './PremiumLLMService'
|
import { getPremiumLLMService } from './PremiumLLMService'
|
||||||
import { configGet } from './ConfigService'
|
|
||||||
import { getMainWindow } from '../windows/WindowManager'
|
import { getMainWindow } from '../windows/WindowManager'
|
||||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import {
|
||||||
hideRecordingTip,
|
hideRecordingTip,
|
||||||
updateRecordingTipState,
|
updateRecordingTipState,
|
||||||
sendAudioLevelToTip,
|
sendAudioLevelToTip,
|
||||||
sendPartialTranscriptToTip,
|
|
||||||
showResultPopup,
|
showResultPopup,
|
||||||
} from '../windows/WindowManager'
|
} from '../windows/WindowManager'
|
||||||
import type { ScreenContext } from '@d3ro/core/types'
|
import type { ScreenContext } from '@d3ro/core/types'
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import type {
|
||||||
AdImpressionEvent,
|
AdImpressionEvent,
|
||||||
AdRewardResult,
|
AdRewardResult,
|
||||||
AdRevenueStats,
|
AdRevenueStats,
|
||||||
AdNetworkId,
|
|
||||||
} from '@d3ro/core/types'
|
} from '@d3ro/core/types'
|
||||||
import type { IAdNetworkAdapter } from './BaseAdAdapter'
|
import type { IAdNetworkAdapter } from './BaseAdAdapter'
|
||||||
import { EthicalAdsAdapter } from './EthicalAdsAdapter'
|
import { EthicalAdsAdapter } from './EthicalAdsAdapter'
|
||||||
|
|
@ -29,10 +28,13 @@ export class AdMediationEngine {
|
||||||
private adapters: Map<string, IAdNetworkAdapter> = new Map()
|
private adapters: Map<string, IAdNetworkAdapter> = new Map()
|
||||||
private config: AdMediationConfig
|
private config: AdMediationConfig
|
||||||
private impressionHistory: AdImpressionEvent[] = []
|
private impressionHistory: AdImpressionEvent[] = []
|
||||||
private lastRewardTimestamp = 0
|
private deliveredCreatives = new Map<string, AdCreativePayload>()
|
||||||
|
private clickedCreatives = new Set<string>()
|
||||||
|
|
||||||
private constructor() {
|
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[] = [
|
const adapterList: IAdNetworkAdapter[] = [
|
||||||
new DirectHouseSponsorAdapter(),
|
new DirectHouseSponsorAdapter(),
|
||||||
new PlaywireAdapter(),
|
new PlaywireAdapter(),
|
||||||
|
|
@ -54,14 +56,14 @@ export class AdMediationEngine {
|
||||||
networks: adapterList.map((a, idx) => ({
|
networks: adapterList.map((a, idx) => ({
|
||||||
id: a.networkId,
|
id: a.networkId,
|
||||||
name: a.networkName,
|
name: a.networkName,
|
||||||
enabled: true,
|
enabled: false,
|
||||||
priority: idx + 1,
|
priority: idx + 1,
|
||||||
floorEcpm: a.defaultFloorEcpm,
|
floorEcpm: a.defaultFloorEcpm,
|
||||||
adapterType: 'rest_json',
|
adapterType: 'rest_json',
|
||||||
})),
|
})),
|
||||||
rewardTokensAmount: 50,
|
rewardTokensAmount: 50,
|
||||||
rewardCooldownSeconds: 60,
|
rewardCooldownSeconds: 60,
|
||||||
houseAdFallback: true,
|
houseAdFallback: false,
|
||||||
headerBiddingTimeoutMs: 800,
|
headerBiddingTimeoutMs: 800,
|
||||||
defaultFloorEcpm: 2.0,
|
defaultFloorEcpm: 2.0,
|
||||||
}
|
}
|
||||||
|
|
@ -75,14 +77,55 @@ export class AdMediationEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getConfig(): AdMediationConfig {
|
public getConfig(): AdMediationConfig {
|
||||||
return { ...this.config }
|
return {
|
||||||
|
...this.config,
|
||||||
|
networks: this.config.networks.map((network) => ({ ...network })),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setConfig(newConfig: Partial<AdMediationConfig>): AdMediationConfig {
|
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()
|
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
|
* 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 enabledAdapters = Array.from(this.adapters.values()).filter((adapter) => {
|
||||||
const netConfig = this.config.networks.find((n) => n.id === adapter.networkId)
|
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
|
// 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 {
|
return {
|
||||||
networkId: adapter.networkId,
|
networkId: adapter.networkId,
|
||||||
networkName: adapter.networkName,
|
networkName: adapter.networkName,
|
||||||
bidEcpm: bidResult.hasBid ? bidResult.bidEcpm : 0,
|
bidEcpm: bidResult.hasBid ? bidResult.bidEcpm : 0,
|
||||||
creative: bidResult.creative,
|
creative,
|
||||||
latencyMs: Date.now() - adapterStart,
|
latencyMs: Date.now() - adapterStart,
|
||||||
status: (bidResult.hasBid ? 'bid' : 'no_bid') as 'bid' | 'no_bid',
|
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)
|
.filter((b) => b.status === 'bid' && b.creative && b.bidEcpm >= floorEcpm)
|
||||||
.sort((a, b) => b.bidEcpm - a.bidEcpm)
|
.sort((a, b) => b.bidEcpm - a.bidEcpm)
|
||||||
|
|
||||||
let winningCreative: AdCreativePayload
|
const winningCreative = validBids[0]?.creative ?? null
|
||||||
|
if (winningCreative !== null) {
|
||||||
if (validBids.length > 0 && validBids[0].creative) {
|
this.deliveredCreatives.set(winningCreative.id, winningCreative)
|
||||||
winningCreative = validBids[0].creative
|
if (this.deliveredCreatives.size > 256) {
|
||||||
} else {
|
const oldest = this.deliveredCreatives.keys().next().value as string | undefined
|
||||||
// Fallback to Direct House Sponsor
|
if (oldest !== undefined) {
|
||||||
const houseAdapter = this.adapters.get('direct_sponsor') || new DirectHouseSponsorAdapter()
|
this.deliveredCreatives.delete(oldest)
|
||||||
const fallbackBid = await houseAdapter.requestBid(request)
|
this.clickedCreatives.delete(oldest)
|
||||||
winningCreative = fallbackBid.creative!
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalLatency = Date.now() - auctionStart
|
const totalLatency = Date.now() - auctionStart
|
||||||
|
|
||||||
return {
|
return {
|
||||||
winner: winningCreative,
|
winner: winningCreative,
|
||||||
winningBidEcpm: winningCreative.bidEcpm,
|
winningBidEcpm: winningCreative?.bidEcpm ?? 0,
|
||||||
participatingBids: bidResults.map((b) => ({
|
participatingBids: bidResults.map((b) => ({
|
||||||
networkId: b.networkId,
|
networkId: b.networkId,
|
||||||
networkName: b.networkName,
|
networkName: b.networkName,
|
||||||
|
|
@ -163,8 +232,18 @@ export class AdMediationEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
public recordImpression(event: Omit<AdImpressionEvent, 'timestamp'>): void {
|
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 = {
|
const fullEvent: AdImpressionEvent = {
|
||||||
...event,
|
...event,
|
||||||
|
networkName: delivered.networkName,
|
||||||
|
earnedEcpm: delivered.bidEcpm,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}
|
}
|
||||||
this.impressionHistory.push(fullEvent)
|
this.impressionHistory.push(fullEvent)
|
||||||
|
|
@ -175,10 +254,19 @@ export class AdMediationEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register into Settlement Ledger
|
// 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 {
|
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)
|
const adapter = this.adapters.get(networkId)
|
||||||
if (adapter) {
|
if (adapter) {
|
||||||
adapter.reportClick(adId).catch(() => {})
|
adapter.reportClick(adId).catch(() => {})
|
||||||
|
|
@ -187,39 +275,19 @@ export class AdMediationEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async claimReward(adId: string, networkId: string): Promise<AdRewardResult> {
|
public async claimReward(adId: string, networkId: string): Promise<AdRewardResult> {
|
||||||
const now = Date.now()
|
// Desktop mediation has no server-verified completion/nonce ledger. A
|
||||||
const cooldownMs = this.config.rewardCooldownSeconds * 1000
|
// timer, renderer-supplied ID, or adapter callback is not entitlement
|
||||||
|
// proof, so rewards remain unavailable until that boundary exists.
|
||||||
if (now - this.lastRewardTimestamp < cooldownMs) {
|
void adId
|
||||||
const waitSeconds = Math.ceil((cooldownMs - (now - this.lastRewardTimestamp)) / 1000)
|
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 {
|
return {
|
||||||
success: true,
|
success: false,
|
||||||
tokensAdded: tokenAmount,
|
tokensAdded: 0,
|
||||||
newTotalQuota: 100 + tokenAmount, // Demo / actual license service quota boost
|
newTotalQuota: 0,
|
||||||
rewardId: `rew_${Date.now()}`,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRevenueStats(period = '2026-08'): AdRevenueStats {
|
public getRevenueStats(period = new Date().toISOString().slice(0, 7)): AdRevenueStats {
|
||||||
return getAdSettlementService().getRevenueStats(period)
|
return getAdSettlementService().getRevenueStats(period)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,22 @@
|
||||||
// Ad Revenue Settlement, Tax Withholding & Payout Ledger Service
|
// Ad Revenue Settlement, Tax Withholding & Payout Ledger Service
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AdSettlementRecord,
|
|
||||||
AdRevenueStats,
|
AdRevenueStats,
|
||||||
|
AdSettlementRecord,
|
||||||
PublisherAccountConfig,
|
PublisherAccountConfig,
|
||||||
AdNetworkId,
|
|
||||||
} from '@d3ro/core/types'
|
} from '@d3ro/core/types'
|
||||||
|
|
||||||
export class AdSettlementService {
|
export class AdSettlementService {
|
||||||
private static instance: AdSettlementService | null = null
|
private static instance: AdSettlementService | null = null
|
||||||
|
|
||||||
private publisherAccount: PublisherAccountConfig = {
|
private publisherAccount: PublisherAccountConfig = {
|
||||||
accountEmail: 'yunchanpaca@gmail.com',
|
accountEmail: '',
|
||||||
beneficiaryName: 'D3RO Voice AI',
|
beneficiaryName: '',
|
||||||
payoutBank: 'KB국민은행 (Kookmin Bank)',
|
payoutBank: '',
|
||||||
payoutAccountNumber: '928702-00-184920',
|
payoutAccountNumber: '',
|
||||||
taxRegistrationNumber: '120-88-01923',
|
taxRegistrationNumber: '',
|
||||||
paypalEmail: 'yunchanpaca@gmail.com',
|
paypalEmail: '',
|
||||||
networksConfigured: 10,
|
networksConfigured: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network counters for current cycle
|
// Network counters for current cycle
|
||||||
|
|
@ -27,11 +26,7 @@ export class AdSettlementService {
|
||||||
{ impressions: number; clicks: number; completions: number; grossUsd: number }
|
{ impressions: number; clicks: number; completions: number; grossUsd: number }
|
||||||
> = new Map()
|
> = new Map()
|
||||||
|
|
||||||
private settlements: AdSettlementRecord[] = []
|
private constructor() {}
|
||||||
|
|
||||||
private constructor() {
|
|
||||||
this.seedInitialSettlementHistory()
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getInstance(): AdSettlementService {
|
public static getInstance(): AdSettlementService {
|
||||||
if (!AdSettlementService.instance) {
|
if (!AdSettlementService.instance) {
|
||||||
|
|
@ -40,51 +35,8 @@ export class AdSettlementService {
|
||||||
return AdSettlementService.instance
|
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 {
|
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 }
|
const cur = this.networkCounters.get(networkId) || { impressions: 0, clicks: 0, completions: 0, grossUsd: 0 }
|
||||||
cur.impressions += 1
|
cur.impressions += 1
|
||||||
cur.grossUsd += earnedEcpm / 1000
|
cur.grossUsd += earnedEcpm / 1000
|
||||||
|
|
@ -92,13 +44,15 @@ export class AdSettlementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public recordClick(networkId: string): void {
|
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
|
cur.clicks += 1
|
||||||
this.networkCounters.set(networkId, cur)
|
this.networkCounters.set(networkId, cur)
|
||||||
}
|
}
|
||||||
|
|
||||||
public recordCompletion(networkId: string): void {
|
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
|
cur.completions += 1
|
||||||
this.networkCounters.set(networkId, cur)
|
this.networkCounters.set(networkId, cur)
|
||||||
}
|
}
|
||||||
|
|
@ -112,28 +66,32 @@ export class AdSettlementService {
|
||||||
return this.getPublisherAccount()
|
return this.getPublisherAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRevenueStats(period = '2026-08'): AdRevenueStats {
|
public getRevenueStats(period = new Date().toISOString().slice(0, 7)): AdRevenueStats {
|
||||||
let totalImp = 0
|
let totalImp = 0
|
||||||
let totalClicks = 0
|
let totalClicks = 0
|
||||||
let totalCompletions = 0
|
let totalCompletions = 0
|
||||||
let totalGrossUsd = 0
|
let totalGrossUsd = 0
|
||||||
|
|
||||||
for (const record of this.settlements) {
|
for (const counters of this.networkCounters.values()) {
|
||||||
totalImp += record.impressions
|
totalImp += counters.impressions
|
||||||
totalClicks += record.clicks
|
totalClicks += counters.clicks
|
||||||
totalCompletions += record.completions
|
totalCompletions += counters.completions
|
||||||
totalGrossUsd += record.grossRevenueUsd
|
totalGrossUsd += counters.grossUsd
|
||||||
}
|
}
|
||||||
|
|
||||||
const avgEcpm = totalImp > 0 ? (totalGrossUsd / totalImp) * 1000 : 5.84
|
const avgEcpm = totalImp > 0 ? (totalGrossUsd / totalImp) * 1000 : 0
|
||||||
|
const networkBreakdown = Array.from(this.networkCounters.entries()).map(
|
||||||
const networkBreakdown = this.settlements.map((s) => ({
|
([network, counters]) => ({
|
||||||
network: s.networkName,
|
network,
|
||||||
impressions: s.impressions,
|
impressions: counters.impressions,
|
||||||
revenueUsd: s.grossRevenueUsd,
|
revenueUsd: parseFloat(counters.grossUsd.toFixed(6)),
|
||||||
ecpm: s.avgEcpm,
|
ecpm: counters.impressions > 0
|
||||||
fillRate: 98.4,
|
? parseFloat(((counters.grossUsd / counters.impressions) * 1000).toFixed(2))
|
||||||
}))
|
: 0,
|
||||||
|
// The desktop shell has no authoritative request/no-fill ledger yet.
|
||||||
|
fillRate: 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
period,
|
period,
|
||||||
|
|
@ -142,24 +100,15 @@ export class AdSettlementService {
|
||||||
totalCompletions: totalCompletions,
|
totalCompletions: totalCompletions,
|
||||||
totalRevenueUsd: parseFloat(totalGrossUsd.toFixed(2)),
|
totalRevenueUsd: parseFloat(totalGrossUsd.toFixed(2)),
|
||||||
avgEcpm: parseFloat(avgEcpm.toFixed(2)),
|
avgEcpm: parseFloat(avgEcpm.toFixed(2)),
|
||||||
fillRatePercent: 98.6,
|
fillRatePercent: 0,
|
||||||
networkBreakdown,
|
networkBreakdown,
|
||||||
settlements: [...this.settlements],
|
settlements: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public requestPayout(settlementId: string): { success: boolean; message: string; settlement?: AdSettlementRecord } {
|
public requestPayout(settlementId: string): { success: boolean; message: string; settlement?: AdSettlementRecord } {
|
||||||
const found = this.settlements.find((s) => s.id === settlementId)
|
void settlementId
|
||||||
if (!found) {
|
return { success: false, message: 'external_settlement_not_configured' }
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,12 @@
|
||||||
// apps/desktop/src/main/services/ads/AppLovinAdapter.ts
|
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||||
// AppLovin MAX Programmatic Bidding Adapter
|
|
||||||
|
|
||||||
import type {
|
export class AppLovinAdapter extends UnavailableAdAdapter {
|
||||||
AdNetworkId,
|
constructor() {
|
||||||
AdFormat,
|
super(
|
||||||
AdMediationAuctionRequest,
|
'applovin_max',
|
||||||
AdCreativePayload,
|
'AppLovin MAX',
|
||||||
} from '@d3ro/core/types'
|
['rewarded_video', 'banner_dock', 'export_sponsor'],
|
||||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
5.5,
|
||||||
|
)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,7 @@
|
||||||
// apps/desktop/src/main/services/ads/CarbonAdsAdapter.ts
|
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||||
// BuySellAds / Carbon Ads Curated Tech Single-Unit Adapter
|
|
||||||
|
|
||||||
import type {
|
export class CarbonAdsAdapter extends UnavailableAdAdapter {
|
||||||
AdNetworkId,
|
constructor() {
|
||||||
AdFormat,
|
super('carbon_ads', 'Carbon Ads', ['banner_dock'], 3.5)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,113 +1,12 @@
|
||||||
// apps/desktop/src/main/services/ads/DirectHouseSponsorAdapter.ts
|
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||||
// Direct House Sponsor Engine (Highest margin, premium AI/developer partnerships)
|
|
||||||
|
|
||||||
import type {
|
export class DirectHouseSponsorAdapter extends UnavailableAdAdapter {
|
||||||
AdNetworkId,
|
constructor() {
|
||||||
AdFormat,
|
super(
|
||||||
AdMediationAuctionRequest,
|
'direct_sponsor',
|
||||||
AdCreativePayload,
|
'Direct House Sponsor',
|
||||||
} from '@d3ro/core/types'
|
['banner_dock', 'rewarded_video', 'export_sponsor'],
|
||||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
12,
|
||||||
|
)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,7 @@
|
||||||
// apps/desktop/src/main/services/ads/EthicalAdsAdapter.ts
|
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||||
// Privacy-First Developer Native Ad Network Adapter (REST Decision API /api/v1/decision/)
|
|
||||||
|
|
||||||
import type {
|
export class EthicalAdsAdapter extends UnavailableAdAdapter {
|
||||||
AdNetworkId,
|
constructor() {
|
||||||
AdFormat,
|
super('ethical_ads', 'EthicalAds', ['banner_dock', 'export_sponsor'], 3.2)
|
||||||
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}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,12 @@
|
||||||
// apps/desktop/src/main/services/ads/GoogleAdManagerAdapter.ts
|
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||||
// Google Ad Manager 360 / AdMob Universal Global Demand Adapter
|
|
||||||
|
|
||||||
import type {
|
export class GoogleAdManagerAdapter extends UnavailableAdAdapter {
|
||||||
AdNetworkId,
|
constructor() {
|
||||||
AdFormat,
|
super(
|
||||||
AdMediationAuctionRequest,
|
'google_ad_manager',
|
||||||
AdCreativePayload,
|
'Google Ad Manager',
|
||||||
} from '@d3ro/core/types'
|
['banner_dock', 'rewarded_video', 'export_sponsor'],
|
||||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
2,
|
||||||
|
)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,7 @@
|
||||||
// apps/desktop/src/main/services/ads/InMobiAdapter.ts
|
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||||
// InMobi Programmatic Demand & Mobile/Hybrid Adapter
|
|
||||||
|
|
||||||
import type {
|
export class InMobiAdapter extends UnavailableAdAdapter {
|
||||||
AdNetworkId,
|
constructor() {
|
||||||
AdFormat,
|
super('inmobi', 'InMobi', ['banner_dock', 'rewarded_video'], 2.8)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,7 @@
|
||||||
// apps/desktop/src/main/services/ads/MintegralAdapter.ts
|
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||||
// Mintegral Global / APAC Rewarded Video & Interstitial Adapter
|
|
||||||
|
|
||||||
import type {
|
export class MintegralAdapter extends UnavailableAdAdapter {
|
||||||
AdNetworkId,
|
constructor() {
|
||||||
AdFormat,
|
super('mintegral', 'Mintegral', ['rewarded_video', 'banner_dock'], 4)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,12 @@
|
||||||
// apps/desktop/src/main/services/ads/PlaywireAdapter.ts
|
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||||
// Playwire Desktop Application Programmatic Header Bidding Adapter
|
|
||||||
|
|
||||||
import type {
|
export class PlaywireAdapter extends UnavailableAdAdapter {
|
||||||
AdNetworkId,
|
constructor() {
|
||||||
AdFormat,
|
super(
|
||||||
AdMediationAuctionRequest,
|
'playwire',
|
||||||
AdCreativePayload,
|
'Playwire RAMP',
|
||||||
} from '@d3ro/core/types'
|
['banner_dock', 'rewarded_video', 'export_sponsor'],
|
||||||
import type { IAdNetworkAdapter, AdBidResponse } from './BaseAdAdapter'
|
4.5,
|
||||||
|
)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,7 @@
|
||||||
// apps/desktop/src/main/services/ads/PubMaticAdapter.ts
|
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||||
// PubMatic OpenWrap Header Bidding SSP Adapter
|
|
||||||
|
|
||||||
import type {
|
export class PubMaticAdapter extends UnavailableAdAdapter {
|
||||||
AdNetworkId,
|
constructor() {
|
||||||
AdFormat,
|
super('pubmatic', 'PubMatic OpenWrap', ['banner_dock', 'export_sponsor'], 3)
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
||||||
// Unity Ads / Unity LevelPlay Rewarded Video Adapter
|
|
||||||
|
|
||||||
import type {
|
export class UnityAdsAdapter extends UnavailableAdAdapter {
|
||||||
AdNetworkId,
|
constructor() {
|
||||||
AdFormat,
|
super('unity_ads', 'Unity LevelPlay', ['rewarded_video', 'banner_dock'], 6)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import type {
|
||||||
TestSTTConnectionResult,
|
TestSTTConnectionResult,
|
||||||
STTStatus,
|
STTStatus,
|
||||||
} from '@d3ro/core/types'
|
} from '@d3ro/core/types'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
|
||||||
import type { ISTTDriver } from './types'
|
import type { ISTTDriver } from './types'
|
||||||
import { OpenAIDriver } from './drivers/OpenAIDriver'
|
import { OpenAIDriver } from './drivers/OpenAIDriver'
|
||||||
import { GroqDriver } from './drivers/GroqDriver'
|
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 { 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 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 { getLogger } from '../../LoggerService'
|
||||||
import { configGet } from '../../ConfigService'
|
import { pcmToWav } from '../audio-utils'
|
||||||
|
import type { ISTTDriver } from '../types'
|
||||||
|
|
||||||
const logger = getLogger('D3ROCloudDriver')
|
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 {
|
export class D3ROCloudDriver implements ISTTDriver {
|
||||||
readonly id = 'd3ro-cloud' as const
|
readonly id = 'd3ro-cloud' as const
|
||||||
readonly name = 'D3RO Cloud STT (Managed)'
|
readonly name = 'D3RO Cloud STT (Managed)'
|
||||||
|
|
||||||
|
constructor(private readonly cloud: CloudSttGateway = getCloudSyncService()) {}
|
||||||
|
|
||||||
async transcribe(
|
async transcribe(
|
||||||
audioBuffer: Buffer,
|
audioBuffer: Buffer,
|
||||||
options?: TranscribeOptions,
|
options?: TranscribeOptions,
|
||||||
config?: STTProviderConfig
|
config?: STTProviderConfig,
|
||||||
): Promise<TranscriptionResult> {
|
): Promise<TranscriptionResult> {
|
||||||
const apiBase = (config?.baseUrl || configGet('cloudApiUrl') || process.env.D3RO_API_URL || 'http://localhost:5000').replace(/\/+$/, '')
|
void config
|
||||||
const endpoint = `${apiBase}/api/stt/transcribe`
|
if (!Buffer.isBuffer(audioBuffer) || audioBuffer.length < 1) {
|
||||||
const startTime = Date.now()
|
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 wavBuffer = pcmToWav(audioBuffer, 16000, 1, 16)
|
||||||
const arrayBuf = wavBuffer.buffer.slice(
|
const arrayBuffer = wavBuffer.buffer.slice(
|
||||||
wavBuffer.byteOffset,
|
wavBuffer.byteOffset,
|
||||||
wavBuffer.byteOffset + wavBuffer.byteLength
|
wavBuffer.byteOffset + wavBuffer.byteLength,
|
||||||
) as ArrayBuffer
|
) as ArrayBuffer
|
||||||
|
|
||||||
const formData = new FormData()
|
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') {
|
if (options?.language && options.language !== 'auto') {
|
||||||
formData.append('language', options.language)
|
formData.append('language_code', 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}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now()
|
||||||
try {
|
try {
|
||||||
logger.info(`Sending audio to D3RO Cloud STT Gateway: ${endpoint}`)
|
logger.info('Sending audio to authenticated D3RO Cloud STT Edge gateway')
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(edgeEndpoint(supabaseUrl), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
apikey: anonKey,
|
||||||
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(120_000),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorBody = await response.text()
|
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 요청 실패 (${response.status})`)
|
||||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 오류 (${response.status}): ${errorBody}`)
|
|
||||||
}
|
}
|
||||||
|
const result = parseCloudSttPayload(await response.json().catch(() => null))
|
||||||
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 {
|
return {
|
||||||
text: rawText,
|
text: result.transcript,
|
||||||
language: data.language || options?.language || 'ko',
|
language: result.language,
|
||||||
duration,
|
duration: result.duration,
|
||||||
processingTime,
|
processingTime: Date.now() - startedAt,
|
||||||
segments: [
|
segments: [{
|
||||||
{
|
text: result.transcript,
|
||||||
text: rawText,
|
start: 0,
|
||||||
start: 0,
|
end: result.duration,
|
||||||
end: duration,
|
confidence: result.confidence,
|
||||||
confidence: 0.98,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
if (err instanceof D3ROError) throw err
|
if (error instanceof D3ROError) throw error
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
logger.error('D3RO Cloud driver transcribe error')
|
||||||
logger.error('D3RO Cloud driver transcribe error:', msg)
|
throw new D3ROError(ErrorCode.STTTranscriptionFailed, 'D3RO Cloud STT 전사에 실패했습니다.')
|
||||||
throw new D3ROError(ErrorCode.STTTranscriptionFailed, `D3RO Cloud STT 전사 실패: ${msg}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async testConnection(config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
|
async testConnection(_config: STTProviderConfig): Promise<{ success: boolean; latencyMs: number; message: string }> {
|
||||||
const apiBase = (config.baseUrl || configGet('cloudApiUrl') || 'http://localhost:5000').replace(/\/+$/, '')
|
const startedAt = Date.now()
|
||||||
const endpoint = `${apiBase}/api/stt/test`
|
|
||||||
|
|
||||||
const startTime = Date.now()
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {}
|
const token = await this.cloud.getAccessToken()
|
||||||
if (config.apiKey) {
|
const supabaseUrl = this.cloud.getSupabaseUrl()
|
||||||
headers.Authorization = `Bearer ${config.apiKey}`
|
const anonKey = this.cloud.getAnonKey()
|
||||||
}
|
if (!token || !supabaseUrl || !anonKey) throw new Error('로그인 또는 Supabase 설정이 없습니다.')
|
||||||
|
const response = await fetch(edgeEndpoint(supabaseUrl), {
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers: { Authorization: `Bearer ${token}`, apikey: anonKey },
|
||||||
signal: AbortSignal.timeout(10000),
|
body: new FormData(),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
})
|
})
|
||||||
|
const latencyMs = Date.now() - startedAt
|
||||||
const latencyMs = Date.now() - startTime
|
// The empty authenticated probe must be rejected before provider work,
|
||||||
|
// proving reachability without consuming quota or incurring provider cost.
|
||||||
if (response.ok) {
|
if ([400, 413, 415].includes(response.status)) {
|
||||||
const data = await response.json()
|
return { success: true, latencyMs, message: `D3RO Cloud STT 인증 경로 준비됨 (${latencyMs}ms)` }
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
latencyMs: data.latencyMs || latencyMs,
|
|
||||||
message: data.message || `D3RO Cloud STT 연결 성공 (${latencyMs}ms)`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return { success: false, latencyMs, message: `D3RO Cloud 연결 실패 (HTTP ${response.status})` }
|
||||||
const text = await response.text()
|
} catch {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
latencyMs,
|
latencyMs: Date.now() - startedAt,
|
||||||
message: `D3RO Cloud 연결 실패 (HTTP ${response.status}): ${text.slice(0, 100)}`,
|
message: 'D3RO Cloud 연결 또는 인증에 실패했습니다.',
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const latencyMs = Date.now() - startTime
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
latencyMs,
|
|
||||||
message: `D3RO Cloud 연결 실패: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,6 @@
|
||||||
//
|
//
|
||||||
// 빈 문자열이면 UpdateService가 비활성 상태로 동작한다.
|
// 빈 문자열이면 UpdateService가 비활성 상태로 동작한다.
|
||||||
// d3r0/voice 프로젝트 ID = 1172. electron-builder.yml publish.url과 동일 값 유지.
|
// d3r0/voice 프로젝트 ID = 1172. electron-builder.yml publish.url과 동일 값 유지.
|
||||||
|
// 버전 없는 `latest` 패키지를 가리켜야 기존 설치본이 새 릴리스를 계속 찾을 수 있다.
|
||||||
export const UPDATE_FEED_URL =
|
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,
|
minHeight: 600,
|
||||||
show: true,
|
show: true,
|
||||||
autoHideMenuBar: 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',
|
backgroundColor: '#0a0e1c',
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,6 @@ import type {
|
||||||
SetVoiceCommandKeywordsParams,
|
SetVoiceCommandKeywordsParams,
|
||||||
SetVoiceCommandEnabledParams,
|
SetVoiceCommandEnabledParams,
|
||||||
CaptureContextResult,
|
CaptureContextResult,
|
||||||
ScreenContext,
|
|
||||||
LLMChain,
|
LLMChain,
|
||||||
CreateChainParams,
|
CreateChainParams,
|
||||||
UpdateChainParams,
|
UpdateChainParams,
|
||||||
|
|
@ -95,7 +94,6 @@ import type {
|
||||||
CaptionState,
|
CaptionState,
|
||||||
CaptionSegment,
|
CaptionSegment,
|
||||||
CaptionConfig,
|
CaptionConfig,
|
||||||
CaptionSessionSummary,
|
|
||||||
// Phase 11
|
// Phase 11
|
||||||
LicenseInfo,
|
LicenseInfo,
|
||||||
ActivateLicenseParams,
|
ActivateLicenseParams,
|
||||||
|
|
@ -162,7 +160,6 @@ import type {
|
||||||
MeetingDocument,
|
MeetingDocument,
|
||||||
MeetingDocTemplate,
|
MeetingDocTemplate,
|
||||||
MeetingGenerateDocParams,
|
MeetingGenerateDocParams,
|
||||||
MeetingGenerateDocResult,
|
|
||||||
MeetingUpdateDocParams,
|
MeetingUpdateDocParams,
|
||||||
MeetingDeleteDocParams,
|
MeetingDeleteDocParams,
|
||||||
MeetingGetDocsParams,
|
MeetingGetDocsParams,
|
||||||
|
|
@ -845,10 +842,10 @@ const electronAPI = {
|
||||||
payment: {
|
payment: {
|
||||||
createCheckoutSession: (params: import('@d3ro/core/types').CheckoutSessionParams) =>
|
createCheckoutSession: (params: import('@d3ro/core/types').CheckoutSessionParams) =>
|
||||||
invoke<import('@d3ro/core/types').CheckoutSessionResult>(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, params),
|
invoke<import('@d3ro/core/types').CheckoutSessionResult>(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, params),
|
||||||
verifyPayment: (params: { tier: string }) =>
|
verifyPayment: () =>
|
||||||
invoke<{ success: boolean; activeTier: string }>(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT, params),
|
invoke<import('@d3ro/core/types').VerifyPaymentResult>(IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT),
|
||||||
getSubscriptionStatus: () =>
|
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
|
} as const
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ import {
|
||||||
Library,
|
Library,
|
||||||
Users,
|
Users,
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
|
||||||
Zap,
|
|
||||||
Headphones,
|
Headphones,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
||||||
|
|
@ -182,7 +180,7 @@ export function AppLayout(): React.ReactElement {
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: d3roFontSans,
|
fontFamily: d3roFontSans,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: 800,
|
fontWeight: 600,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
backgroundImage: d3roPalette.gradient.logo,
|
backgroundImage: d3roPalette.gradient.logo,
|
||||||
backgroundClip: 'text',
|
backgroundClip: 'text',
|
||||||
|
|
@ -198,7 +196,7 @@ export function AppLayout(): React.ReactElement {
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
letterSpacing: '0.08em',
|
letterSpacing: '0.08em',
|
||||||
color: d3roPalette.text.dimLabel,
|
color: d3roPalette.text.dimLabel,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
|
|
@ -401,7 +399,6 @@ export function AppLayout(): React.ReactElement {
|
||||||
onClose={() => setCheckoutModalOpen(false)}
|
onClose={() => setCheckoutModalOpen(false)}
|
||||||
onSuccess={(newTier) => {
|
onSuccess={(newTier) => {
|
||||||
setCurrentTier(newTier)
|
setCurrentTier(newTier)
|
||||||
setCheckoutModalOpen(false)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<RewardedQuotaModal
|
<RewardedQuotaModal
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { useEffect, useState } from 'react'
|
||||||
import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material'
|
import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material'
|
||||||
import { Cloud, CloudCheck, Globe, GitBranch } from 'lucide-react'
|
import { Cloud, CloudCheck, Globe, GitBranch } from 'lucide-react'
|
||||||
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
||||||
import { useI18n } from '@d3ro/i18n'
|
|
||||||
|
|
||||||
interface CloudSyncState {
|
interface CloudSyncState {
|
||||||
authenticated: boolean
|
authenticated: boolean
|
||||||
|
|
@ -22,7 +21,6 @@ interface SyncProgress {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CloudSyncSection(): React.ReactElement {
|
export function CloudSyncSection(): React.ReactElement {
|
||||||
const { t: _t } = useI18n()
|
|
||||||
const [state, setState] = useState<CloudSyncState>({
|
const [state, setState] = useState<CloudSyncState>({
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
userEmail: null,
|
userEmail: null,
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
fontSize: d3roTypo.body.size,
|
fontSize: d3roTypo.body.size,
|
||||||
letterSpacing: d3roTypo.engrave.spacing,
|
letterSpacing: d3roTypo.engrave.spacing,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
|
|
@ -91,7 +91,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
'& .MuiTab-root': {
|
'& .MuiTab-root': {
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontSize: d3roTypo.label.size,
|
fontSize: d3roTypo.label.size,
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
letterSpacing: '0.5px',
|
letterSpacing: '0.5px',
|
||||||
color: d3roPalette.text.inactive,
|
color: d3roPalette.text.inactive,
|
||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
|
|
@ -119,7 +119,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
'& .MuiAlert-icon': { color: d3roPalette.accent.main },
|
'& .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초 소요)
|
가장 빠르고 안정적인 연동 방법 (30초 소요)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
|
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
|
||||||
|
|
@ -128,7 +128,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Box>
|
<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 발급 절차]
|
[STEP-BY-STEP 발급 절차]
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
|
@ -143,7 +143,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<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 대시보드 열기
|
1단계: OpenAI Platform 대시보드 열기
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -154,7 +154,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
bgcolor: d3roPalette.accent.main,
|
bgcolor: d3roPalette.accent.main,
|
||||||
color: d3roPalette.bg.app,
|
color: d3roPalette.bg.app,
|
||||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||||
|
|
@ -177,7 +177,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
borderRadius: d3roRadius.sm,
|
borderRadius: d3roRadius.sm,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5 }}>
|
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.5 }}>
|
||||||
2단계: 새 비밀 키(Secret Key) 생성 및 복사
|
2단계: 새 비밀 키(Secret Key) 생성 및 복사
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive, display: 'block', mb: 1 }}>
|
<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,
|
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 설정에 붙여넣기 및 연결 테스트
|
3단계: D3RO-Voice STT 설정에 붙여넣기 및 연결 테스트
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
||||||
|
|
@ -229,7 +229,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
'& .MuiAlert-icon': { color: d3roPalette.tag.orange },
|
'& .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 구독자 안내
|
ChatGPT Plus / Pro / Team 구독자 안내
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
|
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
|
||||||
|
|
@ -238,7 +238,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Box>
|
<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 인증 방법]
|
[Codex Device Flow 인증 방법]
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
|
@ -252,7 +252,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
mb: 2,
|
mb: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, mb: 1 }}>
|
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}>
|
||||||
1. 터미널에서 Device Auth 로그인 실행
|
1. 터미널에서 Device Auth 로그인 실행
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
|
|
@ -292,7 +292,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
borderRadius: d3roRadius.sm,
|
borderRadius: d3roRadius.sm,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, mb: 1 }}>
|
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}>
|
||||||
2. 네트워크 문제 해결 팁 (Troubleshooting)
|
2. 네트워크 문제 해결 팁 (Troubleshooting)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5 }}>
|
<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>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Groq STT</Typography>
|
<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: 700 }} />
|
<Chip label="초고속 (~200ms) · 무료 티어" size="small" sx={{ bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green, height: 20, fontSize: '10px', fontWeight: 500 }} />
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
||||||
LPU 칩셋 기반으로 Whisper Large V3 모델을 0.2초만에 처리합니다.
|
LPU 칩셋 기반으로 Whisper Large V3 모델을 0.2초만에 처리합니다.
|
||||||
|
|
@ -360,8 +360,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Deepgram Nova-3</Typography>
|
<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: 700 }} />
|
<Chip label="업계 최고 정확도 · 무료 $200" size="small" sx={{ bgcolor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple, height: 20, fontSize: '10px', fontWeight: 500 }} />
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
||||||
스마트 구두점 및 포맷팅 지원. 가입 시 무료 크레딧 제공.
|
스마트 구두점 및 포맷팅 지원. 가입 시 무료 크레딧 제공.
|
||||||
|
|
@ -392,8 +392,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Google Gemini 2.0 Flash Audio</Typography>
|
<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: 700 }} />
|
<Chip label="한국어/아시아 언어 최강" size="small" sx={{ bgcolor: d3roPalette.tag.blueBg, color: d3roPalette.tag.blue, height: 20, fontSize: '10px', fontWeight: 500 }} />
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
||||||
Google AI Studio에서 무료 API 키를 발급받아 사용할 수 있습니다.
|
Google AI Studio에서 무료 API 키를 발급받아 사용할 수 있습니다.
|
||||||
|
|
@ -424,8 +424,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>AssemblyAI Universal-2</Typography>
|
<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: 700 }} />
|
<Chip label="고도화 음향 모델" size="small" sx={{ bgcolor: d3roPalette.tag.cyanBg, color: d3roPalette.tag.cyan, height: 20, fontSize: '10px', fontWeight: 500 }} />
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
|
||||||
대화형 문맥 이해 및 음성 오디오 인텔리전스 엔진.
|
대화형 문맥 이해 및 음성 오디오 인텔리전스 엔진.
|
||||||
|
|
@ -453,7 +453,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
||||||
variant="contained"
|
variant="contained"
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
fontSize: d3roTypo.label.size,
|
fontSize: d3roTypo.label.size,
|
||||||
bgcolor: d3roPalette.accent.main,
|
bgcolor: d3roPalette.accent.main,
|
||||||
color: d3roPalette.bg.app,
|
color: d3roPalette.bg.app,
|
||||||
|
|
|
||||||
|
|
@ -20,32 +20,6 @@ import { useI18n } from '@d3ro/i18n'
|
||||||
import type { HotkeyBinding } from '@d3ro/core/types'
|
import type { HotkeyBinding } from '@d3ro/core/types'
|
||||||
import { formatHotkeyLabel, getPlatform, keyCodeToName } from '../utils/format-hotkey'
|
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 = [
|
const RESERVED_COMBOS = [
|
||||||
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S', 'Ctrl+W',
|
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S', 'Ctrl+W',
|
||||||
|
|
@ -235,7 +209,7 @@ export function HotkeyRecordModal({
|
||||||
disableAutoFocus
|
disableAutoFocus
|
||||||
disableRestoreFocus
|
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>
|
<DialogContent>
|
||||||
{/* 녹화 영역 */}
|
{/* 녹화 영역 */}
|
||||||
<Box
|
<Box
|
||||||
|
|
@ -266,7 +240,7 @@ export function HotkeyRecordModal({
|
||||||
key={`${key.keyCode}-${i}`}
|
key={`${key.keyCode}-${i}`}
|
||||||
label={key.name}
|
label={key.name}
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
bgcolor: d3roPalette.bg.chassis,
|
bgcolor: d3roPalette.bg.chassis,
|
||||||
color: d3roPalette.text.primary,
|
color: d3roPalette.text.primary,
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export function LicenseTab(): React.ReactElement {
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
<Sparkles size={18} color={d3roPalette.accent.main} />
|
<Sparkles size={18} color={d3roPalette.accent.main} />
|
||||||
<Box sx={{ flex: 1 }}>
|
<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+)
|
14-Day Reverse Trial (Pro+)
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||||
|
|
@ -77,7 +77,7 @@ export function LicenseTab(): React.ReactElement {
|
||||||
|
|
||||||
{/* ── 오프라인 라이선스 키 등록 ── */}
|
{/* ── 오프라인 라이선스 키 등록 ── */}
|
||||||
<MetalCard>
|
<MetalCard>
|
||||||
<LicenseKeySection t={t} currentTier={currentTier} />
|
<LicenseKeySection currentTier={currentTier} />
|
||||||
</MetalCard>
|
</MetalCard>
|
||||||
|
|
||||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
<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>
|
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 [licenseKeyInput, setLicenseKeyInput] = useState('')
|
||||||
const [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null)
|
const [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
@ -157,7 +157,7 @@ function LicenseKeySection({ t, currentTier }: { t: (k: string) => string; curre
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
|
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
|
||||||
발급받은 디지털 서명 라이센스 키 (D3RO-LIC-... 또는 D3RO-PRO-...)를 입력하세요.
|
발급받은 Ed25519 디지털 서명 라이센스 키 (D3RO-LIC-...)를 입력하세요.
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@ import {
|
||||||
Play,
|
Play,
|
||||||
Download,
|
Download,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Cpu,
|
|
||||||
Sparkles,
|
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
|
|
@ -315,7 +313,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: d3roFontSans,
|
fontFamily: d3roFontSans,
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
fontSize: d3roTypo.heading.size,
|
fontSize: d3roTypo.heading.size,
|
||||||
color: d3roPalette.text.primary,
|
color: d3roPalette.text.primary,
|
||||||
}}
|
}}
|
||||||
|
|
@ -393,7 +391,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
bgcolor: d3roPalette.accent.main,
|
bgcolor: d3roPalette.accent.main,
|
||||||
color: d3roPalette.bg.app,
|
color: d3roPalette.bg.app,
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -412,7 +410,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
||||||
{/* ── Step 1: Ollama 설치 및 기동 ── */}
|
{/* ── Step 1: Ollama 설치 및 기동 ── */}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', 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 }}>
|
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
|
||||||
Ollama 설치 및 백그라운드 실행
|
Ollama 설치 및 백그라운드 실행
|
||||||
</Typography>
|
</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', flexDirection: 'column', gap: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<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 }}>
|
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
|
||||||
한국어 최적화 추천 모델 1-클릭 다운로드
|
한국어 최적화 추천 모델 1-클릭 다운로드
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
@ -503,11 +501,11 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<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}
|
{model.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
{model.recommended && (
|
{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>
|
</Box>
|
||||||
<Chip label={model.size} size="small" sx={{ height: 18, fontSize: '10px', fontFamily: d3roFontMono }} />
|
<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={{
|
sx={{
|
||||||
fontFamily: d3roFontSans,
|
fontFamily: d3roFontSans,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
py: 0.25,
|
py: 0.25,
|
||||||
bgcolor: d3roPalette.accent.main,
|
bgcolor: d3roPalette.accent.main,
|
||||||
color: d3roPalette.bg.app,
|
color: d3roPalette.bg.app,
|
||||||
|
|
@ -593,7 +591,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
||||||
{/* ── Step 3: 실시간 연결 및 테스트 ── */}
|
{/* ── Step 3: 실시간 연결 및 테스트 ── */}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<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 }}>
|
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
|
||||||
동작 테스트 (AI 문장 정제)
|
동작 테스트 (AI 문장 정제)
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
@ -622,7 +620,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
bgcolor: d3roPalette.accent.main,
|
bgcolor: d3roPalette.accent.main,
|
||||||
color: d3roPalette.bg.app,
|
color: d3roPalette.bg.app,
|
||||||
|
|
@ -644,7 +642,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
||||||
borderRadius: d3roRadius.small,
|
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}):
|
AI 정제 결과 ({activeModel}):
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.primary }}>
|
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.primary }}>
|
||||||
|
|
@ -683,4 +681,3 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,6 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Cloud,
|
Cloud,
|
||||||
HardDrive,
|
|
||||||
Lock,
|
|
||||||
Download,
|
Download,
|
||||||
Play,
|
Play,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
|
@ -35,7 +33,7 @@ import {
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { d3roPalette, d3roRadius, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme'
|
import { d3roPalette, d3roRadius, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme'
|
||||||
import { Led } from '@d3ro/ui/components/ds'
|
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'
|
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)
|
const [authLoading, setAuthLoading] = useState(false)
|
||||||
|
|
||||||
// Error State
|
// Error State
|
||||||
const [errorMsg, setErrorMsg] = useState('')
|
const errorMsg = ''
|
||||||
|
|
||||||
const isVisible = open || internalOpen
|
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', justifyContent: 'space-between', mb: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Cpu size={22} color={d3roPalette.accent.main} />
|
<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 모드
|
로컬 AI 모드
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 2, lineHeight: 1.5 }}>
|
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 2, lineHeight: 1.5 }}>
|
||||||
<b>Ollama</b>와 로컬 <b>Whisper STT</b>를 사용하여 PC 내부에서 완전히 독립 실행됩니다.
|
<b>Ollama</b>와 로컬 <b>Whisper STT</b>를 사용하여 PC 내부에서 완전히 독립 실행됩니다.
|
||||||
|
|
@ -310,7 +308,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
sx={{
|
sx={{
|
||||||
mt: 2.5,
|
mt: 2.5,
|
||||||
fontFamily: d3roFontSans,
|
fontFamily: d3roFontSans,
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
bgcolor: d3roPalette.accent.main,
|
bgcolor: d3roPalette.accent.main,
|
||||||
color: d3roPalette.bg.app,
|
color: d3roPalette.bg.app,
|
||||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
'&: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', justifyContent: 'space-between', mb: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Cloud size={22} color={d3roPalette.tag.blue} />
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -407,7 +405,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
<Led color={isOllamaConnected ? 'green' : 'amber'} pulse={!isOllamaConnected} size={9} />
|
<Led color={isOllamaConnected ? 'green' : 'amber'} pulse={!isOllamaConnected} size={9} />
|
||||||
<Box>
|
<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
|
{isOllamaConnected
|
||||||
? `Ollama 로컬 엔진 연결 완료 ${ollamaStatus?.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
|
? `Ollama 로컬 엔진 연결 완료 ${ollamaStatus?.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
|
||||||
: 'Ollama 로컬 엔진 확인 필요'}
|
: 'Ollama 로컬 엔진 확인 필요'}
|
||||||
|
|
@ -438,7 +436,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
startIcon={<Play size={13} />}
|
startIcon={<Play size={13} />}
|
||||||
onClick={handleStartOllama}
|
onClick={handleStartOllama}
|
||||||
disabled={startingOllama}
|
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 자동 실행
|
Ollama 자동 실행
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -455,7 +453,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
{/* Ollama 미설치 시 가이드 */}
|
{/* Ollama 미설치 시 가이드 */}
|
||||||
{!isOllamaConnected && (
|
{!isOllamaConnected && (
|
||||||
<Box sx={{ p: 2, bgcolor: d3roPalette.bg.elevated, borderRadius: d3roRadius.small, border: `1px solid ${d3roPalette.border.subtle}` }}>
|
<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가 아직 설치되어 있지 않다면:
|
Ollama가 아직 설치되어 있지 않다면:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
|
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
|
||||||
|
|
@ -476,7 +474,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
|
|
||||||
{/* 추천 모델 설치 섹션 */}
|
{/* 추천 모델 설치 섹션 */}
|
||||||
<Box>
|
<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)
|
기본 한국어 AI 모델 설치 (Gemma 2 2B)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
|
<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')}
|
onClick={() => handlePullModel('gemma2:2b')}
|
||||||
disabled={!isOllamaConnected}
|
disabled={!isOllamaConnected}
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
bgcolor: d3roPalette.accent.main,
|
bgcolor: d3roPalette.accent.main,
|
||||||
color: d3roPalette.bg.app,
|
color: d3roPalette.bg.app,
|
||||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||||
|
|
@ -529,7 +527,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleCompleteLocalSetup}
|
onClick={handleCompleteLocalSetup}
|
||||||
disabled={!isOllamaConnected}
|
disabled={!isOllamaConnected}
|
||||||
sx={{ fontWeight: 700 }}
|
sx={{ fontWeight: 500 }}
|
||||||
>
|
>
|
||||||
설정 완료 및 시작하기
|
설정 완료 및 시작하기
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -616,7 +614,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
|
|
||||||
{phase === 'success' && (
|
{phase === 'success' && (
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<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 시작하기
|
D3RO Voice 시작하기
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|
@ -624,4 +622,3 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
||||||
<CheckCircle2 size={14} color={d3roPalette.accent.main} />
|
<CheckCircle2 size={14} color={d3roPalette.accent.main} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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}
|
{p.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -244,7 +244,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
||||||
mt: 1,
|
mt: 1,
|
||||||
height: 18,
|
height: 18,
|
||||||
fontSize: '9px',
|
fontSize: '9px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
bgcolor: isSelected ? d3roPalette.accent.muted : 'transparent',
|
bgcolor: isSelected ? d3roPalette.accent.muted : 'transparent',
|
||||||
color: isSelected ? d3roPalette.accent.main : d3roPalette.text.disabled,
|
color: isSelected ? d3roPalette.accent.main : d3roPalette.text.disabled,
|
||||||
border: isSelected ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
|
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 sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||||
<Box>
|
<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}
|
{currentProviderInfo?.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
|
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
|
||||||
|
|
@ -281,7 +281,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
||||||
sx={{
|
sx={{
|
||||||
height: 20,
|
height: 20,
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
bgcolor: currentProviderInfo?.isCloud ? d3roPalette.tag.purpleBg : d3roPalette.tag.greenBg,
|
bgcolor: currentProviderInfo?.isCloud ? d3roPalette.tag.purpleBg : d3roPalette.tag.greenBg,
|
||||||
color: currentProviderInfo?.isCloud ? d3roPalette.tag.purple : d3roPalette.tag.green,
|
color: currentProviderInfo?.isCloud ? d3roPalette.tag.purple : d3roPalette.tag.green,
|
||||||
}}
|
}}
|
||||||
|
|
@ -335,7 +335,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
{selected.name} 모델 다운로드가 필요합니다
|
{selected.name} 모델 다운로드가 필요합니다
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
|
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
|
||||||
|
|
@ -351,7 +351,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
bgcolor: d3roPalette.accent.main,
|
bgcolor: d3roPalette.accent.main,
|
||||||
color: d3roPalette.bg.app,
|
color: d3roPalette.bg.app,
|
||||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||||
|
|
@ -403,7 +403,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
minWidth: 105,
|
minWidth: 105,
|
||||||
height: 40,
|
height: 40,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
|
@ -457,7 +457,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontSize: d3roTypo.label.size,
|
fontSize: d3roTypo.label.size,
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: d3roPalette.text.secondary,
|
color: d3roPalette.text.secondary,
|
||||||
letterSpacing: '0.5px',
|
letterSpacing: '0.5px',
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import {
|
||||||
Stack,
|
Stack,
|
||||||
Paper,
|
Paper,
|
||||||
} from '@mui/material'
|
} 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 { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
|
||||||
import { Led } from '@d3ro/ui/components/ds'
|
import { Led } from '@d3ro/ui/components/ds'
|
||||||
import { HotkeyRecordModal } from './HotkeyRecordModal'
|
import { HotkeyRecordModal } from './HotkeyRecordModal'
|
||||||
|
|
@ -35,7 +35,6 @@ import { STTTab } from './STTTab'
|
||||||
import { useI18n, LOCALE_META } from '@d3ro/i18n'
|
import { useI18n, LOCALE_META } from '@d3ro/i18n'
|
||||||
import type { Locale } from '@d3ro/i18n'
|
import type { Locale } from '@d3ro/i18n'
|
||||||
import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types'
|
import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types'
|
||||||
import { Feature } from '@d3ro/core/types'
|
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|
@ -79,7 +78,7 @@ function HotkeyDisplay({
|
||||||
label={key}
|
label={key}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
fontSize: d3roTypo.label.size,
|
fontSize: d3roTypo.label.size,
|
||||||
bgcolor: d3roPalette.bg.elevated,
|
bgcolor: d3roPalette.bg.elevated,
|
||||||
color: d3roPalette.text.primary,
|
color: d3roPalette.text.primary,
|
||||||
|
|
@ -138,7 +137,7 @@ function VoiceModeCard({
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle2"
|
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}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
@ -163,7 +162,7 @@ function VoiceModeCard({
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: '10px', // P4: 토큰에 없는 10px 보존
|
fontSize: '10px', // P4: 토큰에 없는 10px 보존
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
height: 20,
|
height: 20,
|
||||||
bgcolor: enabled ? d3roPalette.tag.greenBg : 'transparent',
|
bgcolor: enabled ? d3roPalette.tag.greenBg : 'transparent',
|
||||||
color: enabled ? d3roPalette.tag.green : d3roPalette.text.disabled,
|
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 [activeTab, setActiveTab] = useState(initialTab)
|
||||||
const [config, setConfig] = useState<Partial<AppConfig>>({})
|
const [config, setConfig] = useState<Partial<AppConfig>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && initialTab !== undefined) {
|
if (open && initialTab !== undefined) {
|
||||||
|
|
@ -196,13 +196,30 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
||||||
}
|
}
|
||||||
}, [open, initialTab])
|
}, [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 [dictationEnabled, setDictationEnabled] = useState(true)
|
||||||
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
|
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
|
||||||
const [handsFreeEnabled, setHandsFreeEnabled] = useState(false)
|
const [handsFreeEnabled, setHandsFreeEnabled] = useState(false)
|
||||||
const [handsFreeBinding, setHandsFreeBinding] = useState<HotkeyBinding | null>(null)
|
const [handsFreeBinding, setHandsFreeBinding] = useState<HotkeyBinding | null>(null)
|
||||||
const [captionBinding, setCaptionBinding] = 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)
|
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
|
||||||
|
|
@ -350,7 +367,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
fontSize: d3roTypo.body.size,
|
fontSize: d3roTypo.body.size,
|
||||||
letterSpacing: d3roTypo.engrave.spacing,
|
letterSpacing: d3roTypo.engrave.spacing,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
|
|
@ -377,7 +394,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
||||||
'& .MuiTab-root': {
|
'& .MuiTab-root': {
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontSize: d3roTypo.label.size,
|
fontSize: d3roTypo.label.size,
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
letterSpacing: '0.5px',
|
letterSpacing: '0.5px',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
color: d3roPalette.text.inactive,
|
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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
<Led color={ollamaStatus?.connectionState === 'connected' ? 'green' : 'red'} size={8} />
|
<Led color={ollamaStatus?.connectionState === 'connected' ? 'green' : 'red'} size={8} />
|
||||||
<Box>
|
<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'
|
{ollamaStatus?.connectionState === 'connected'
|
||||||
? `Ollama 연결됨 ${ollamaStatus.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
|
? `Ollama 연결됨 ${ollamaStatus.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
|
||||||
: 'Ollama 오프라인 (서버 실행 필요)'}
|
: 'Ollama 오프라인 (서버 실행 필요)'}
|
||||||
|
|
@ -746,7 +763,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={startingOllama}
|
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 실행'}
|
{startingOllama ? '실행 중' : 'Ollama 실행'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -923,7 +940,9 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t('settings.about.version')}</Typography>
|
<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>
|
||||||
|
|
||||||
<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 { Plus, Trash2, Pencil, Play } from 'lucide-react'
|
||||||
import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds'
|
import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds'
|
||||||
import { PageHeader, EmptyStateCard } from './shared'
|
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 { useI18n } from '@d3ro/i18n'
|
||||||
import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@d3ro/core/types'
|
import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@d3ro/core/types'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// src/renderer/components/TitleBar.tsx
|
// src/renderer/components/TitleBar.tsx
|
||||||
// v2 보더리스 UI: 커스텀 타이틀바 — 투명 드래그 스트립 + 우측 윈도우 컨트롤.
|
// v3 보더리스 UI: 커스텀 타이틀바 — 투명 드래그 스트립 + 우측 윈도우 컨트롤.
|
||||||
// 콘텐츠 위에 오버레이되며(레퍼런스처럼 컨트롤이 콘텐츠 위에 떠 있음),
|
|
||||||
// 드래그 영역은 -webkit-app-region: drag, 버튼은 no-drag.
|
// 드래그 영역은 -webkit-app-region: drag, 버튼은 no-drag.
|
||||||
|
// macOS(hiddenInset)에서는 네이티브 교통신호가 좌측에 표시되므로 커스텀
|
||||||
|
// 컨트롤을 숨기고 그만큼의 좌측 여백을 확보한다.
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
|
|
@ -9,6 +10,7 @@ import { Minus, Square, Copy, X } from 'lucide-react'
|
||||||
import { d3roPalette, d3roRadius } from '@d3ro/ui/theme'
|
import { d3roPalette, d3roRadius } from '@d3ro/ui/theme'
|
||||||
|
|
||||||
const BAR_HEIGHT = 40
|
const BAR_HEIGHT = 40
|
||||||
|
const isDarwin = window.electronAPI?.platform === 'darwin'
|
||||||
|
|
||||||
export function TitleBar(): React.ReactElement {
|
export function TitleBar(): React.ReactElement {
|
||||||
const [isMaximized, setIsMaximized] = useState(false)
|
const [isMaximized, setIsMaximized] = useState(false)
|
||||||
|
|
@ -37,6 +39,8 @@ export function TitleBar(): React.ReactElement {
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
px: 1.5,
|
px: 1.5,
|
||||||
|
// macOS 교통신호(x:16) 전용 여백 — 드래그 영역에는 포함되지 않는다
|
||||||
|
pl: isDarwin ? '78px' : 1.5,
|
||||||
WebkitAppRegion: 'drag',
|
WebkitAppRegion: 'drag',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
|
@ -46,25 +50,27 @@ export function TitleBar(): React.ReactElement {
|
||||||
{/* Drag Region Placeholder */}
|
{/* Drag Region Placeholder */}
|
||||||
<Box sx={{ flex: 1, height: '100%', WebkitAppRegion: 'drag' }} />
|
<Box sx={{ flex: 1, height: '100%', WebkitAppRegion: 'drag' }} />
|
||||||
|
|
||||||
{/* Window Controls */}
|
{/* Window Controls — macOS는 네이티브 교통신호 사용 */}
|
||||||
<Box
|
{!isDarwin && (
|
||||||
sx={{
|
<Box
|
||||||
display: 'flex',
|
sx={{
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
WebkitAppRegion: 'no-drag',
|
alignItems: 'center',
|
||||||
gap: '2px',
|
WebkitAppRegion: 'no-drag',
|
||||||
}}
|
gap: '2px',
|
||||||
>
|
}}
|
||||||
<ControlButton onClick={() => window.electronAPI.window.minimize()}>
|
>
|
||||||
<Minus size={14} />
|
<ControlButton onClick={() => window.electronAPI.window.minimize()}>
|
||||||
</ControlButton>
|
<Minus size={14} />
|
||||||
<ControlButton onClick={handleMaximize}>
|
</ControlButton>
|
||||||
{isMaximized ? <Copy size={12} style={{ transform: 'scaleX(-1)' }} /> : <Square size={11} />}
|
<ControlButton onClick={handleMaximize}>
|
||||||
</ControlButton>
|
{isMaximized ? <Copy size={12} style={{ transform: 'scaleX(-1)' }} /> : <Square size={11} />}
|
||||||
<ControlButton danger onClick={() => window.electronAPI.window.close()}>
|
</ControlButton>
|
||||||
<X size={14} />
|
<ControlButton danger onClick={() => window.electronAPI.window.close()}>
|
||||||
</ControlButton>
|
<X size={14} />
|
||||||
</Box>
|
</ControlButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</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