diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 980f2713a..47a4a0f73 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,25 +21,33 @@ env: jobs: build-windows: + name: build-windows(${{ matrix.arch }}) if: github.event.inputs.platform == 'all' || contains(github.event.inputs.platform, 'windows') - runs-on: windows-latest + runs-on: ${{ matrix.runner }} strategy: matrix: - arch: [x64] include: - arch: x64 platform: win-x64 + runner: windows-2025-vs2026 + unpacked: win-unpacked + - arch: arm64 + platform: win-arm64 + runner: windows-11-arm + unpacked: win-arm64-unpacked steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.14.1' package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Install dependencies run: pnpm install @@ -58,11 +66,12 @@ jobs: npm_config_arch: ${{ matrix.arch }} - name: Report RTK install token source + if: matrix.arch == 'x64' shell: bash run: | echo "RTK runtime install token source: ${RTK_INSTALL_GITHUB_TOKEN_SOURCE}" - - name: Install Node Runtime + - name: Install Windows runtimes run: pnpm run installRuntime:win:${{ matrix.arch }} env: GITHUB_TOKEN: ${{ env.RTK_INSTALL_GITHUB_TOKEN }} @@ -81,10 +90,10 @@ jobs: - name: Verify bundled plugins shell: bash run: | - pnpm run plugin:verify -- --name feishu --platform win32 --arch ${{ matrix.arch }} --plugin-root dist/win-unpacked/resources/app.asar.unpacked/plugins + pnpm run plugin:verify -- --name feishu --platform win32 --arch ${{ matrix.arch }} --plugin-root dist/${{ matrix.unpacked }}/resources/app.asar.unpacked/plugins - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: deepchat-${{ matrix.platform }} path: | @@ -102,16 +111,18 @@ jobs: - arch: x64 platform: linux-x64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.14.1' package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Install dependencies run: pnpm install @@ -145,7 +156,7 @@ jobs: pnpm run plugin:verify -- --name feishu --platform linux --arch ${{ matrix.arch }} --plugin-root dist/linux-unpacked/resources/app.asar.unpacked/plugins - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: deepchat-${{ matrix.platform }} path: | @@ -164,16 +175,18 @@ jobs: - arch: arm64 platform: mac-arm64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.14.1' package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Install dependencies run: pnpm install @@ -231,7 +244,7 @@ jobs: pnpm run plugin:verify -- --name feishu --platform darwin --arch "$TARGET_ARCH" --plugin-root "$PLUGIN_ROOT" - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: deepchat-${{ matrix.platform }} path: | diff --git a/.github/workflows/prcheck.yml b/.github/workflows/prcheck.yml index 0724dabf7..6ddfd6b9d 100644 --- a/.github/workflows/prcheck.yml +++ b/.github/workflows/prcheck.yml @@ -23,8 +23,9 @@ jobs: exit 1 fi - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + persist-credentials: false ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -46,16 +47,18 @@ jobs: include: - arch: x64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.14.1' package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Install dependencies run: pnpm install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d0495900..70c17b089 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Resolve tag id: resolve - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const isDispatch = context.eventName === 'workflow_dispatch' @@ -97,8 +97,9 @@ jobs: needs: resolve-tag runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + persist-credentials: false ref: ${{ needs.resolve-tag.outputs.sha }} fetch-depth: 0 @@ -113,28 +114,35 @@ jobs: fi build-windows: + name: build-windows(${{ matrix.arch }}) needs: [resolve-tag, validate-main-ancestor] - runs-on: windows-latest + runs-on: ${{ matrix.runner }} strategy: matrix: - arch: [x64] include: - arch: x64 platform: win-x64 + runner: windows-2025-vs2026 + unpacked: win-unpacked + - arch: arm64 + platform: win-arm64 + runner: windows-11-arm + unpacked: win-arm64-unpacked steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + persist-credentials: false ref: ${{ needs.resolve-tag.outputs.sha }} fetch-depth: 1 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.14.1' package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Install dependencies run: pnpm install @@ -177,10 +185,10 @@ jobs: - name: Verify bundled plugins shell: bash run: | - pnpm run plugin:verify -- --name feishu --platform win32 --arch ${{ matrix.arch }} --plugin-root dist/win-unpacked/resources/app.asar.unpacked/plugins + pnpm run plugin:verify -- --name feishu --platform win32 --arch ${{ matrix.arch }} --plugin-root dist/${{ matrix.unpacked }}/resources/app.asar.unpacked/plugins - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: deepchat-${{ matrix.platform }} path: | @@ -198,19 +206,20 @@ jobs: - arch: x64 platform: linux-x64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + persist-credentials: false ref: ${{ needs.resolve-tag.outputs.sha }} fetch-depth: 1 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.14.1' package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Install dependencies run: pnpm install @@ -242,7 +251,7 @@ jobs: pnpm run plugin:verify -- --name feishu --platform linux --arch ${{ matrix.arch }} --plugin-root dist/linux-unpacked/resources/app.asar.unpacked/plugins - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: deepchat-${{ matrix.platform }} path: | @@ -261,19 +270,20 @@ jobs: - arch: arm64 platform: mac-arm64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + persist-credentials: false ref: ${{ needs.resolve-tag.outputs.sha }} fetch-depth: 1 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.14.1' package-manager-cache: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Install dependencies run: pnpm install @@ -332,7 +342,7 @@ jobs: pnpm run plugin:verify -- --name feishu --platform darwin --arch "$TARGET_ARCH" --plugin-root "$PLUGIN_ROOT" - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: deepchat-${{ matrix.platform }} path: | @@ -348,8 +358,9 @@ jobs: - build-mac runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + persist-credentials: false ref: ${{ needs.resolve-tag.outputs.sha }} fetch-depth: 1 @@ -395,7 +406,7 @@ jobs: fi - name: Download build artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: artifacts @@ -412,6 +423,44 @@ jobs: cp artifacts/deepchat-win-x64/*.blockmap release_assets/ 2>/dev/null || true fi + # Process Windows arm64 artifacts + if [ -d "artifacts/deepchat-win-arm64" ]; then + cp artifacts/deepchat-win-arm64/*.exe release_assets/ 2>/dev/null || true + cp artifacts/deepchat-win-arm64/*.msi release_assets/ 2>/dev/null || true + cp artifacts/deepchat-win-arm64/*.zip release_assets/ 2>/dev/null || true + cp artifacts/deepchat-win-arm64/*.blockmap release_assets/ 2>/dev/null || true + fi + + merge_windows_yml() { + local name="$1" + local x64="artifacts/deepchat-win-x64/$name" + local arm64="artifacts/deepchat-win-arm64/$name" + if [ -f "$x64" ] && [ -f "$arm64" ]; then + ruby -ryaml -e ' + x64 = YAML.load_file(ARGV[0]) || {} + arm = YAML.load_file(ARGV[1]) || {} + merged = x64.dup + merged["version"] ||= arm["version"] + merged["releaseDate"] ||= arm["releaseDate"] + merged["releaseNotes"] ||= arm["releaseNotes"] + merged["path"] ||= arm["path"] + merged["sha512"] ||= arm["sha512"] + files = [] + files.concat(x64["files"]) if x64["files"].is_a?(Array) + files.concat(arm["files"]) if arm["files"].is_a?(Array) + merged["files"] = files.uniq { |f| f["url"] } + File.write(ARGV[2], merged.to_yaml) + ' "$x64" "$arm64" "release_assets/$name" + elif [ -f "$x64" ]; then + cp "$x64" "release_assets/$name" + elif [ -f "$arm64" ]; then + cp "$arm64" "release_assets/$name" + fi + } + + merge_windows_yml latest.yml + merge_windows_yml beta.yml + # Process Linux x64 artifacts if [ -d "artifacts/deepchat-linux-x64" ]; then cp artifacts/deepchat-linux-x64/*.AppImage release_assets/ 2>/dev/null || true @@ -474,7 +523,7 @@ jobs: ls -la release_assets/ - name: Create Draft Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v0.1.15 with: tag_name: ${{ needs.resolve-tag.outputs.tag }} name: DeepChat V${{ steps.get_version.outputs.version }} diff --git a/.github/workflows/windows-arm64-e2e.yml b/.github/workflows/windows-arm64-e2e.yml new file mode 100644 index 000000000..28bafa8cc --- /dev/null +++ b/.github/workflows/windows-arm64-e2e.yml @@ -0,0 +1,175 @@ +name: Windows ARM64 E2E + +on: + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +jobs: + windows-arm64-e2e: + runs-on: windows-11-arm + timeout-minutes: 120 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: '24.14.1' + package-manager-cache: false + + - name: Setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 + + - name: Install dependencies + run: pnpm install + + - name: Configure pnpm workspace for Windows arm64 + run: pnpm run install:sharp + env: + TARGET_OS: win32 + TARGET_ARCH: arm64 + + - name: Install Windows arm64 dependencies + run: pnpm install + env: + npm_config_build_from_source: true + npm_config_platform: win32 + npm_config_arch: arm64 + + - name: Install Windows arm64 runtimes + run: pnpm run installRuntime:win:arm64 + + - name: Verify DuckDB and VSS on Windows arm64 + run: pnpm run smoke:duckdb:vss + + - name: Build Windows arm64 package + run: | + pnpm run build + pnpm run plugin:bundle:clean + pnpm run plugin:bundle -- --name feishu --platform win32 --arch arm64 + pnpm exec electron-builder --win --arm64 --publish=never + env: + VITE_GITHUB_CLIENT_ID: ${{ secrets.DC_GITHUB_CLIENT_ID }} + VITE_GITHUB_CLIENT_SECRET: ${{ secrets.DC_GITHUB_CLIENT_SECRET }} + VITE_GITHUB_REDIRECT_URI: ${{ secrets.DC_GITHUB_REDIRECT_URI }} + VITE_PROVIDER_DB_URL: ${{ secrets.CDN_PROVIDER_DB_URL }} + + - name: Verify bundled plugins + shell: bash + run: | + pnpm run plugin:verify -- --name feishu --platform win32 --arch arm64 --plugin-root dist/win-arm64-unpacked/resources/app.asar.unpacked/plugins + + - name: Run Windows arm64 launch smoke test + run: pnpm exec playwright test -c test/e2e/playwright.ci.config.ts test/e2e/specs/01-launch.smoke.spec.ts + + - name: Run packaged app process smoke + shell: pwsh + run: | + $diagRoot = Join-Path $env:GITHUB_WORKSPACE 'build/windows-arm64-e2e-diagnostics' + New-Item -ItemType Directory -Force -Path $diagRoot | Out-Null + + $exe = Join-Path $env:GITHUB_WORKSPACE 'dist/win-arm64-unpacked/DeepChat.exe' + $stdout = Join-Path $diagRoot 'packaged-smoke-stdout.log' + $stderr = Join-Path $diagRoot 'packaged-smoke-stderr.log' + $chromiumLog = Join-Path $diagRoot 'packaged-smoke-chromium.log' + + Get-Process -Name 'DeepChat' -ErrorAction SilentlyContinue | Stop-Process -Force + Start-Sleep -Seconds 2 + + if (!(Test-Path $exe)) { + throw "Packaged executable not found: $exe" + } + + $process = Start-Process ` + -FilePath $exe ` + -WorkingDirectory (Split-Path $exe) ` + -ArgumentList @('--enable-logging', '--v=1', "--log-file=$chromiumLog") ` + -RedirectStandardOutput $stdout ` + -RedirectStandardError $stderr ` + -PassThru + + try { + Start-Sleep -Seconds 20 + $process.Refresh() + if ($process.HasExited) { + "Packaged app exited during process smoke. Exit code: $($process.ExitCode)" | + Out-File -FilePath (Join-Path $diagRoot 'packaged-smoke-result.log') -Encoding utf8 + throw "Packaged app exited during process smoke. Exit code: $($process.ExitCode)" + } + + "Packaged app stayed alive for 20 seconds. PID: $($process.Id)" | + Out-File -FilePath (Join-Path $diagRoot 'packaged-smoke-result.log') -Encoding utf8 + } finally { + $process.Refresh() + if (!$process.HasExited) { + taskkill /pid $($process.Id) /t /f | Out-Null + Start-Sleep -Seconds 2 + } + } + + - name: Collect Windows arm64 diagnostics + if: always() + shell: pwsh + run: | + $diagRoot = Join-Path $env:GITHUB_WORKSPACE 'build/windows-arm64-e2e-diagnostics' + New-Item -ItemType Directory -Force -Path $diagRoot | Out-Null + + $filesystemLog = Join-Path $diagRoot 'filesystem.txt' + $paths = @( + "$env:APPDATA\DeepChat", + "$env:LOCALAPPDATA\DeepChat", + "$env:GITHUB_WORKSPACE\dist\win-arm64-unpacked", + "$env:GITHUB_WORKSPACE\runtime" + ) + + foreach ($path in $paths) { + "== $path ==" | Out-File -Append -FilePath $filesystemLog -Encoding utf8 + if (Test-Path $path) { + Get-ChildItem -Force -Recurse $path | + Select-Object FullName, Length, LastWriteTime | + Format-Table -AutoSize | + Out-String -Width 4096 | + Out-File -Append -FilePath $filesystemLog -Encoding utf8 + } else { + "MISSING" | Out-File -Append -FilePath $filesystemLog -Encoding utf8 + } + } + + $deepchatLogDir = Join-Path $env:APPDATA 'DeepChat\logs' + if (Test-Path $deepchatLogDir) { + Copy-Item -Path (Join-Path $deepchatLogDir '*') -Destination $diagRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + $nativeModulesLog = Join-Path $diagRoot 'native-modules.txt' + if (Test-Path "$env:GITHUB_WORKSPACE\dist\win-arm64-unpacked") { + Get-ChildItem -Force -Recurse "$env:GITHUB_WORKSPACE\dist\win-arm64-unpacked" -Filter '*.node' | + Select-Object FullName, Length, LastWriteTime | + Format-Table -AutoSize | + Out-String -Width 4096 | + Out-File -FilePath $nativeModulesLog -Encoding utf8 + } + + try { + Get-WinEvent -FilterHashtable @{ LogName = 'Application'; StartTime = (Get-Date).AddMinutes(-30) } -ErrorAction Stop | + Where-Object { $_.Message -match 'DeepChat|Electron|Application Error|Faulting application' } | + Select-Object TimeCreated, ProviderName, Id, LevelDisplayName, Message | + Format-List | + Out-File -FilePath (Join-Path $diagRoot 'windows-application-events.txt') -Encoding utf8 + } catch { + $_ | Out-File -FilePath (Join-Path $diagRoot 'windows-application-events.txt') -Encoding utf8 + } + + - name: Upload Windows arm64 E2E diagnostics + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: deepchat-win-arm64-e2e-diagnostics + if-no-files-found: warn + path: | + build/windows-arm64-e2e-diagnostics + test-results/e2e + playwright-report diff --git a/CHANGELOG.md b/CHANGELOG.md index 7603dbcec..d417d6aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v1.0.5-beta.3 (2026-05-22) +- Added encrypted SQLite database storage to strengthen local data protection +- Improved onboarding guide handoff by refreshing state after setup transitions +- Refined onboarding spotlight rendering with SVG paths and fixed panel stacking and hover performance issues +- 新增 SQLite 数据库加密存储,增强本地数据保护 +- 优化引导流程交接,在设置切换后刷新状态 +- 优化引导高亮的 SVG path 渲染,并修复面板层级与 hover 性能问题 + ## v1.0.5-beta.2 (2026-05-21) - Added provider configuration import with preview, validation, conflict handling, and localized settings UI - Added CC Switch configuration import and broader provider import path discovery for smoother migration from external tools diff --git a/docs/architecture/sqlite-database-encryption/plan.md b/docs/architecture/sqlite-database-encryption/plan.md new file mode 100644 index 000000000..0cfd732c4 --- /dev/null +++ b/docs/architecture/sqlite-database-encryption/plan.md @@ -0,0 +1,247 @@ +# SQLite Database Encryption Plan + +## Architecture + +- Add a main-process database security layer responsible for encryption metadata, safeStorage + wrapping, unlock, password validation, migration orchestration, and status reporting. +- Store non-secret encryption metadata outside SQLite in a small ElectronStore file, for example + `database-security.json`. +- Keep `ConfigPresenter` startup behavior unchanged until SQLite is attached. After attach, route + sensitive config keys through SQLite-backed storage. +- Extend `ConfigTables` with generic app configuration storage for sensitive settings that do not + need dedicated relational tables. +- Keep splash as the only startup unlock UI. The main window is created only after DB unlock and DB + initialization succeed. +- Expose settings operations through typed route contracts and `SettingsClient` or a dedicated + `DatabaseSecurityClient`. + +## Data Model + +Unencrypted metadata store: + +```ts +type DatabaseSecurityMetadata = { + version: 1 + enabled: boolean + cipher: 'sqlcipher' + passwordStorage: 'safeStorage' | 'manual' | 'none' + wrappedPassword?: string + safeStorageBackend?: string + lastMigrationAt?: number + lastMigrationDirection?: 'enable' | 'change-password' | 'disable' +} +``` + +SQLite additions: + +- `app_settings`: `key TEXT PRIMARY KEY`, `value_json TEXT NOT NULL`, `sensitive INTEGER NOT NULL`, + `updated_at INTEGER NOT NULL`. +- Store migrated values under existing logical keys: `remoteControl`, `mcprouterApiKey`, + `nowledgeMemConfig`, `hooksNotifications`, `knowledgeConfigs`, `customPrompts`, and + `systemPrompts`. +- Keep existing provider/model/MCP/agent SQLite tables unchanged. + +Password handling: + +- The user-provided SQLite password is the database password for v1. +- If `safeStorage.isEncryptionAvailable()` is true, store only `safeStorage.encryptString(password)` + as base64 in metadata. This uses the OS credential store, such as macOS Keychain, Windows + Credential Vault, or a Linux secret store, through Electron. +- If safeStorage is unavailable, do not persist the password; metadata records `passwordStorage: + 'manual'`. +- If a manual unlock succeeds later on a system where safeStorage is available, re-wrap the password + for future launches. This covers restored/imported data where the encrypted database exists but + the local OS credential store does not have a usable wrapped password. + +## Startup Unlock Flow + +1. `configInitHook` creates `ConfigPresenter` and applies startup settings as today. +2. `databaseInitHook` creates or retrieves the database security service before `DatabaseInitializer`. +3. If metadata says encryption is disabled, initialize SQLite without a password. +4. If encryption is enabled and a wrapped password exists, try `safeStorage.decryptString()`. +5. Validate the decrypted password by opening the database with the normal SQLite open helper and a + lightweight schema query. +6. If validation fails, if the local OS credential store entry is missing, or if safeStorage is + unavailable, ask `SplashWindowManager` to enter unlock mode and await a password submission. +7. On wrong password, return an unlock error to splash and keep waiting. +8. On cancel, quit startup before main window creation. +9. On success, pass the password into `DatabaseInitializer({ password })`. + +Splash unlock UI: + +```text ++----------------------------------------+ +| DeepChat | +| Local database is encrypted | +| | +| SQLite password | +| [ ******************************** ] | +| | +| Wrong password. Try again. | +| | +| [ Unlock ] [ Quit ] | +| | +| System unlock is unavailable on this | +| device, so manual unlock is required. | ++----------------------------------------+ +``` + +System unlock progress state: + +```text ++----------------------------------------+ +| DeepChat | +| Unlocking local database | +| | +| Use the system unlock prompt if one | +| appears. | +| | +| Opening local database... | ++----------------------------------------+ +``` + +## SQLite Open And Validation + +- Keep `cipher='sqlcipher'` and `legacy=4` configured before applying the key so newly encrypted + databases use SQLCipher 4 compatibility mode and can be opened by tools such as DB Browser for + SQLite with SQLCipher 4 defaults. +- Replace SQL string key interpolation with the native `db.key(Buffer.from(password, 'utf8'))` API + or an equivalent parameter-safe binding path. +- Validate encrypted opens with a read against `sqlite_master`, then use the existing transaction + validation in `DatabaseInitializer`. +- Ensure migration and unlock errors are normalized before reaching logs or renderer state. + +## Migration Flow + +Primary migration primitive: + +- Use one source connection plus an attached temporary target database, then copy normal schema + tables and rows into the target. +- `better-sqlite3-multiple-ciphers` backup cannot copy between incompatible encrypted/plaintext + database states, and the current binding does not treat `VACUUM INTO` filenames as SQLite URI + parameters. The attach/copy path is therefore the project-compatible migration primitive. +- Apply target passwords through `ATTACH DATABASE ? AS migration_target KEY ?` binding parameters + after configuring SQLCipher 4 compatibility mode, not by interpolating a password into SQL text. + For encrypted-to-plaintext disable, attach the target with an explicit empty key. +- Do not run native rekey against the active `agent.db`. + +Runtime migration sequence: + +1. Acquire a process-wide migration lock so settings, sync backup/import, and reset flows cannot run + concurrently. +2. Close the active `SQLitePresenter` connection. +3. Open the source DB with the current password if encrypted. +4. Run WAL checkpoint and switch the source connection to a non-WAL journal mode for the export. +5. Create a temp destination DB in the same `app_db` directory by attaching it to the source + connection. +6. Copy normal tables and row data into the attached target. Skip FTS virtual/shadow tables; the + existing table initialization rebuilds those derived search indexes after reopen. +7. Open the temp DB through the normal SQLite helper using the target password. +8. Run `PRAGMA quick_check`, schema version checks, and key table row count checks. +9. Rename the active database to a short-lived rollback file, move the temp DB to `agent.db`, and + remove sidecar files. +10. Reopen `SQLitePresenter` with the target password and reattach SQLite-backed config stores. +11. Update metadata only after reopen succeeds. +12. Delete the rollback file after successful reopen; on failure, restore it and reopen the original + DB. + +Status results returned to settings: + +```ts +type DatabaseSecurityStatus = { + enabled: boolean + cipher: 'sqlcipher' + safeStorageAvailable: boolean + safeStorageBackend?: string + passwordStorage: 'safeStorage' | 'manual' | 'none' + manualUnlockRequired: boolean + migrationInProgress: boolean + lastMigrationAt?: number +} +``` + +## Settings UI + +Add the section to the current Data settings page near privacy/data management controls. + +```text ++------------------------------------------------------------------+ +| SQLite database encryption Enabled | +| Protects local chat history, provider keys, MCP config, prompts, | +| and other sensitive settings stored in agent.db. | +| | +| Cipher SQLCipher | +| System unlock Available via OS secure storage | +| Startup unlock System unlock | +| Last migration 2026-05-22 14:30 | +| | +| Current password [ ************************ ] | +| New password [ ************************ ] | +| Confirm password [ ************************ ] | +| | +| [ Change password ] [ Disable encryption ] | ++------------------------------------------------------------------+ +``` + +Disabled state: + +```text ++------------------------------------------------------------------+ +| SQLite database encryption Disabled | +| | +| System unlock Unavailable | +| Startup unlock Manual password required after enabling | +| | +| New password [ ************************ ] | +| Confirm password [ ************************ ] | +| | +| [ Enable encryption ] | +| | +| This system cannot use Electron safeStorage. DeepChat can still | +| encrypt the database, but you must enter the password on every | +| startup. | ++------------------------------------------------------------------+ +``` + +## Sensitive Config Migration + +- Add DB-backed read/write adapters for sensitive app settings keys after SQLite attach. +- Migrate legacy values into `app_settings` once, guarded by a `config_migrations` marker such as + `sensitive-config-sqlite-v1`. +- After successful migration and verification, remove migrated sensitive keys from `app-settings` + JSON and replace prompt/knowledge JSON stores with empty defaults or remove the files. +- Keep non-sensitive startup settings in JSON. +- Update sync backup filtering so migrated sensitive settings are not copied into JSON backup paths. +- Import legacy backups by reading old JSON values and writing them into SQLite when config storage + migration is active. + +## Failure Modes + +- SafeStorage unavailable: allow encryption, set metadata to manual mode, show manual prompt on + startup. +- SafeStorage decrypt fails: show manual splash unlock; if manual unlock succeeds and safeStorage is + available, re-wrap the password. +- Wrong password: keep splash open and do not initialize SQLite presenters. +- Migration fails before replacement: delete temp DB and reopen original DB. +- Migration fails after replacement: restore rollback DB and old metadata. +- App exits during migration: next startup detects temp/rollback files, restores the last complete + database, and asks the user to retry migration. + +## Testing + +- Main tests cover safeStorage support states, password wrapping, unlock success/failure/cancel, + migration success/rollback, WAL cleanup, and metadata updates. +- Main integration tests use temp databases for plaintext-to-encrypted, encrypted password change, + encrypted-to-plaintext, and legacy sensitive config migration. +- Renderer tests cover Data settings states, validation errors, action disabling during migration, + and splash unlock states. +- Manual QA covers macOS system unlock, Linux/manual unlock fallback, wrong password retry, cancel + behavior, sync backup/import, and reset flows. + +## Quality Gates + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- `pnpm run typecheck` +- Focused Vitest suites for main encryption/migration and renderer settings/splash behavior. diff --git a/docs/architecture/sqlite-database-encryption/spec.md b/docs/architecture/sqlite-database-encryption/spec.md new file mode 100644 index 000000000..0828e5dc6 --- /dev/null +++ b/docs/architecture/sqlite-database-encryption/spec.md @@ -0,0 +1,117 @@ +# SQLite Database Encryption + +## User Story + +DeepChat users can store provider credentials, remote-control tokens, prompts, knowledge +configuration, and conversation history locally. Users need an optional database encryption mode so +the main SQLite database is encrypted at rest, can be unlocked during startup, and can be migrated +safely when the SQLite password changes. + +## Goals + +- Let users enable, change, and disable SQLite database encryption from the data settings screen. +- Require an unlock step before any SQLite-backed presenter opens an encrypted `agent.db`. +- Use Electron `safeStorage` to store the SQLite password when available. +- Fall back to manual unlock on every startup when `safeStorage` is unavailable or cannot decrypt. +- Migrate the existing database by writing a new database file, verifying it, and replacing the old + file only after successful validation. +- Move remaining sensitive JSON/ElectronStore configuration into SQLite so encryption covers it. + +## Acceptance Criteria + +- A new data settings section shows database encryption state, selected cipher, safeStorage support, + last migration time, and whether manual startup unlock is required. +- Enabling encryption requires a non-empty SQLite password and confirmation. On success, `agent.db` + is replaced by an encrypted database and opens only with that password. +- Changing the password requires the current password, writes a new encrypted database using the new + password, verifies the result, then replaces the active database. +- Disabling encryption requires the current password, writes a verified plaintext database, then + replaces the active encrypted database. +- A failed migration leaves the original database and sidecar files usable. +- Successful migration removes temporary files and old `agent.db` sidecar files (`-wal`, `-shm`). +- Startup does not initialize SQLite-backed presenters until the encrypted database is unlocked. +- When `safeStorage` decrypts the stored password successfully, startup proceeds without showing a + manual password prompt. In normal use this means the user unlocks once, and later launches open + automatically from the OS credential store. +- When `safeStorage` is unavailable, the OS credential store entry is missing, or decryption fails + after imported data is restored on a different device, the splash window shows an unlock form. + After a successful manual unlock on a system with safeStorage, DeepChat stores a fresh wrapped + password for future launches. +- Wrong passwords keep the user on the splash unlock form and do not open the main window. +- Canceling unlock exits startup without creating the main window. +- User-facing strings are localized through the renderer i18n system. +- No SQLite password or derived key is written to logs, route activity, migration errors, telemetry, + or renderer-visible state. +- Legacy plaintext config copies for migrated sensitive keys are removed or redacted after successful + SQLite migration. + +## Data To Encrypt + +Already covered once `agent.db` is encrypted: + +- Conversations, messages, tool traces, assistant blocks, pending inputs, usage stats, search + documents, search indexes, attachment metadata, projects, sessions, and agents. +- Provider credentials and provider metadata already stored in SQLite, including API keys, OAuth + tokens, AWS Bedrock secrets, and Vertex credentials. +- MCP server config, MCP env/custom headers, MCP settings, agent settings, and agent MCP selections + already stored in SQLite. + +Move into SQLite in this feature: + +- `remoteControl`: Telegram/Discord bot tokens, Feishu/Lark app secrets, verification tokens, + encrypt keys, QQBot client secrets, Weixin iLink bot tokens, account state, and bindings. +- `mcprouterApiKey`. +- `nowledgeMemConfig`, including `apiKey`. +- `hooksNotifications`, especially hook commands and webhook-like values. +- `custom_prompts` and `system_prompts`. +- `knowledge-configs`, including RAGFlow, Dify, FastGPT, and built-in knowledge config metadata. +- Legacy provider/model/MCP keys that remain in plaintext JSON after the previous config-to-SQLite + migration. + +Keep outside encrypted SQLite for startup: + +- Language, theme, logging, proxy mode, sync folder path, update channel, window/startup flags, and + other settings needed before the database is opened. +- `customProxyUrl` stays in startup config for compatibility, but credentials embedded in proxy URLs + should be rejected or split into encrypted storage in a follow-up. + +## Non-Goals + +- No cloud key backup or password recovery. +- No guaranteed secure deletion on SSDs or filesystems with snapshots. +- No encryption of external files already exported by the user, old sync backups, crash dumps, or OS + backups. +- No guarantee that every supported OS will show biometric UI; Electron delegates to the platform + password manager. +- No change to the core SQL schema names for existing conversation, provider, MCP, or agent tables. + +## Constraints + +- `configInitHook` must continue to read logging and proxy settings before SQLite opens. +- `databaseInitHook` is the earliest point where encrypted SQLite can be unlocked. +- The unlock UI belongs to the splash renderer so the main app is not shown before DB unlock. +- New renderer-main APIs should use typed route contracts and renderer API clients, not new direct + `useLegacyPresenter()` usage. +- Migration SQL must not include raw passwords in any logged SQL string. +- The implementation must support both safeStorage-backed startup unlock and manual startup unlock. + +## Security Notes + +- The SQLite password is present in main-process memory while the app is running. +- `safeStorage` protects stored password material at rest for the current OS user by using the OS + credential store, such as macOS Keychain, Windows Credential Vault, or a Linux secret store. It + does not defend against malware running as that user. +- Deleting old database files is ordinary file deletion, not cryptographic erasure. +- WAL/SHM sidecar files must be checkpointed and removed during migration because they may contain + plaintext data from before encryption. +- Sync backups created before encryption may still contain plaintext data and remain the user's + responsibility to delete. + +## References + +- Electron safeStorage: https://www.electronjs.org/docs/latest/api/safe-storage +- SQLite3 Multiple Ciphers overview: https://utelle.github.io/SQLite3MultipleCiphers/ +- SQLite3 Multiple Ciphers SQL pragmas: + https://utelle.github.io/SQLite3MultipleCiphers/docs/configuration/config_sql_pragmas/ +- SQLite3 Multiple Ciphers URI parameters: + https://utelle.github.io/SQLite3MultipleCiphers/docs/configuration/config_uri/ diff --git a/docs/architecture/sqlite-database-encryption/tasks.md b/docs/architecture/sqlite-database-encryption/tasks.md new file mode 100644 index 000000000..694ee41bb --- /dev/null +++ b/docs/architecture/sqlite-database-encryption/tasks.md @@ -0,0 +1,79 @@ +# SQLite Database Encryption Tasks + +## SDD + +- [x] Draft encryption spec, implementation plan, and task breakdown. + +## Database Security Infrastructure + +- [x] Add database security metadata store outside SQLite. +- [x] Add safeStorage wrapper with support detection, backend reporting, password wrap/unwrap, and + normalized errors. +- [x] Add database password validation using the project SQLite open helper. +- [x] Replace SQL string password interpolation with the native `db.key(Buffer)` path or equivalent + parameter-safe key application. +- [x] Configure SQLCipher 4 compatibility mode before keying encrypted database connections. +- [x] Add tests for metadata defaults, safeStorage unavailable, safeStorage decrypt failure, and + password validation. + +## Startup And Splash Unlock + +- [x] Extend `SplashWindowManager` with an awaitable database unlock request API. +- [x] Add typed splash IPC channels for unlock submit and cancel. +- [x] Update `databaseInitHook` to resolve the SQLite password before creating `DatabaseInitializer`. +- [x] Add splash renderer unlock and system-unlock-progress states. +- [ ] Add tests for successful system unlock, manual unlock, wrong password retry, and cancel before + main window creation. + +## Migration Engine + +- [x] Verify backup-plus-temp-`db.rekey(Buffer)` behavior against the project Electron/SQLite ABI + and replace it with attach/copy migration after incompatibility was confirmed. +- [ ] Add a migration lock shared by encryption changes, sync backup/import, DB repair, and reset + flows. +- [x] Implement plaintext-to-encrypted migration with temp DB validation and rollback. +- [x] Implement encrypted password change with temp DB validation and rollback. +- [x] Implement encrypted-to-plaintext disable flow with temp DB validation and rollback. +- [x] Add sidecar cleanup for `agent.db-wal` and `agent.db-shm`. +- [x] Add startup recovery for leftover temp/rollback migration files. +- [ ] Add tests for migration success, validation failure, replacement failure, rollback, and sidecar + cleanup. + +## Typed Routes And Settings UI + +- [x] Add shared route contracts for status, enable encryption, change password, and disable + encryption. +- [x] Add renderer API client methods for the new database security routes. +- [x] Add Data settings encryption section with i18n text and password validation. +- [x] Explain OS credential-store unlock behavior and when manual password input is required. +- [x] Disable encryption actions while migration is running or when required password fields are + invalid. +- [x] Add settings activity records without storing raw passwords. +- [ ] Add renderer tests for enabled, disabled, safeStorage unavailable, validation, and migration + progress states. + +## Sensitive Config Into SQLite + +- [x] Add SQLite-backed generic `app_settings` storage for sensitive app config. +- [x] Migrate `remoteControl` into SQLite and redact its legacy JSON copy. +- [x] Migrate `mcprouterApiKey` into SQLite and redact its legacy JSON copy. +- [x] Migrate `nowledgeMemConfig` into SQLite and redact its legacy JSON copy. +- [x] Migrate `hooksNotifications` into SQLite and redact its legacy JSON copy. +- [x] Migrate `knowledge-configs` into SQLite and clear the legacy ElectronStore file. +- [x] Migrate `custom_prompts` and `system_prompts` into SQLite and clear legacy prompt files. +- [x] Remove legacy provider/model/MCP sensitive leftovers from JSON once SQLite-backed config is + verified. +- [x] Remove the legacy `providers` JSON copy when database encryption or password migration runs + after provider config has moved to SQLite. +- [x] Update sync backup/import filtering and legacy backup import for migrated sensitive settings. +- [ ] Add tests for idempotent sensitive config migration and legacy JSON redaction. + +## Verification + +- [x] Run focused main tests for encryption, migration, unlock, and sensitive config migration. +- [x] Run focused renderer tests for Data settings encryption controls. +- [ ] Run focused renderer tests for splash unlock UI. +- [x] Run `pnpm run format`. +- [x] Run `pnpm run i18n`. +- [x] Run `pnpm run lint`. +- [x] Run `pnpm run typecheck`. diff --git a/docs/features/app-spotlight-search/tasks.md b/docs/features/app-spotlight-search/tasks.md index 7ab3fea5e..448261a74 100644 --- a/docs/features/app-spotlight-search/tasks.md +++ b/docs/features/app-spotlight-search/tasks.md @@ -8,77 +8,77 @@ ## T1 快捷键与事件 -- [ ] 新增快捷键配置项 `QuickSearch` -- [ ] 默认值设为 `CommandOrControl+P` -- [ ] 新增 `SHORTCUT_EVENTS.TOGGLE_SPOTLIGHT` -- [ ] `ShortcutPresenter` 注册 / 重注册 Spotlight 快捷键 -- [ ] 快捷键设置页展示并允许修改 `QuickSearch` +- [x] 新增快捷键配置项 `QuickSearch` +- [x] 默认值设为 `CommandOrControl+P` +- [x] 新增 `SHORTCUT_EVENTS.TOGGLE_SPOTLIGHT` +- [x] `ShortcutPresenter` 注册 / 重注册 Spotlight 快捷键 +- [x] 快捷键设置页展示并允许修改 `QuickSearch` ## T2 历史搜索服务 -- [ ] 抽离“消息可见文本抽取”公共逻辑 -- [ ] 新增 `deepchat_search_documents` 普通表 -- [ ] 新增 `deepchat_search_documents_fts` FTS5 虚表 -- [ ] 实现首次回填 / schema rebuild -- [ ] 实现会话创建 / 重命名 / 删除的索引同步 -- [ ] 实现消息写入 / 编辑 / 删除的索引同步 -- [ ] 实现 FTS 失败回退到 `LIKE` +- [x] 抽离"消息可见文本抽取"公共逻辑 +- [x] 新增 `deepchat_search_documents` 普通表 +- [x] 新增 `deepchat_search_documents_fts` FTS5 虚表 +- [x] 实现首次回填 / schema rebuild +- [x] 实现会话创建 / 重命名 / 删除的索引同步 +- [x] 实现消息写入 / 编辑 / 删除的索引同步 +- [x] 实现 FTS 失败回退到 `LIKE` ## T3 Presenter 与共享类型 -- [ ] 新增 `HistorySearchOptions` -- [ ] 新增 `HistorySearchHit / SessionHit / MessageHit` -- [ ] `IAgentSessionPresenter` 增加 `searchHistory(query, options?)` -- [ ] 补充共享类型导出 +- [x] 新增 `HistorySearchOptions` +- [x] 新增 `HistorySearchHit / SessionHit / MessageHit` +- [x] `IAgentSessionPresenter` 增加 `searchHistory(query, options?)` +- [x] 补充共享类型导出 ## T4 设置导航 registry -- [ ] 抽取设置页共享 registry -- [ ] 字段包含 `routeName / titleKey / icon / keywords[]` -- [ ] 设置窗口侧栏复用该 registry -- [ ] Spotlight setting items 复用该 registry +- [x] 抽取设置页共享 registry +- [x] 字段包含 `routeName / titleKey / icon / keywords[]` +- [x] 设置窗口侧栏复用该 registry +- [x] Spotlight setting items 复用该 registry ## T5 Spotlight Renderer 状态 -- [ ] 新增 `spotlight store` -- [ ] 管理 `open/query/results/activeIndex/loading/requestSeq/pendingMessageJump` -- [ ] 输入 80ms debounce -- [ ] 按 requestSeq 丢弃过期响应 -- [ ] 结果截断到 12 条 +- [x] 新增 `spotlight store` +- [x] 管理 `open/query/results/activeIndex/loading/requestSeq/pendingMessageJump` +- [x] 输入 80ms debounce +- [x] 按 requestSeq 丢弃过期响应 +- [x] 结果截断到 12 条 ## T6 Spotlight UI -- [ ] 新增主聊天窗口顶层 Spotlight overlay -- [ ] 沿用 `rounded-2xl + border + bg-card/40 + backdrop-blur` 视觉样式 -- [ ] 左侧 rail 增加 Spotlight 入口 -- [ ] 空查询展示 `Recent Sessions + Agents + Actions` -- [ ] 查询态展示单一混排结果列表 -- [ ] 增加 `kind pill` -- [ ] 支持 `Esc / ↑ / ↓ / Home / End / Enter / hover / click` +- [x] 新增主聊天窗口顶层 Spotlight overlay +- [x] 沿用 `rounded-2xl + border + bg-card/40 + backdrop-blur` 视觉样式 +- [x] 左侧 rail 增加 Spotlight 入口 +- [x] 空查询展示 `Recent Sessions + Agents + Actions` +- [x] 查询态展示单一混排结果列表 +- [x] 增加 `kind pill` +- [x] 支持 `Esc / ↑ / ↓ / Home / End / Enter / hover / click` - [ ] 尊重 `prefers-reduced-motion` ## T7 执行行为 -- [ ] `session` 命中切会话 -- [ ] `message` 命中写入 `pendingMessageJump` -- [ ] `ChatPage` 在消息加载完成后滚动并高亮目标消息 -- [ ] `agent` 命中复用现有侧栏切换逻辑 -- [ ] `setting` 命中打开 / 聚焦设置窗口并导航 -- [ ] `action` 命中只执行非破坏性动作 +- [x] `session` 命中切会话 +- [x] `message` 命中写入 `pendingMessageJump` +- [x] `ChatPage` 在消息加载完成后滚动并高亮目标消息 +- [x] `agent` 命中复用现有侧栏切换逻辑 +- [x] `setting` 命中打开 / 聚焦设置窗口并导航 +- [x] `action` 命中只执行非破坏性动作 ## T8 测试 -- [ ] main tests:排序、回填、增量同步、降级查询 -- [ ] renderer tests:打开关闭、自动聚焦、键盘链路 -- [ ] renderer tests:混排与去重 -- [ ] renderer tests:message jump + scroll highlight -- [ ] renderer tests:agent / setting / action 执行 +- [x] main tests:排序、回填、增量同步、降级查询 +- [x] renderer tests:打开关闭、自动聚焦、键盘链路 +- [x] renderer tests:混排与去重 +- [x] renderer tests:message jump + scroll highlight +- [x] renderer tests:agent / setting / action 执行 - [ ] 验收场景:sidebar 收起、空查询、设置窗口聚焦 ## T9 质量检查 -- [ ] `pnpm run format` -- [ ] `pnpm run i18n` -- [ ] `pnpm run lint` -- [ ] `pnpm run typecheck` -- [ ] 运行相关 main / renderer 测试 +- [x] `pnpm run format` +- [x] `pnpm run i18n` +- [x] `pnpm run lint` +- [x] `pnpm run typecheck` +- [x] 运行相关 main / renderer 测试 diff --git a/docs/features/windows-arm64-support/plan.md b/docs/features/windows-arm64-support/plan.md new file mode 100644 index 000000000..a7a43f9ce --- /dev/null +++ b/docs/features/windows-arm64-support/plan.md @@ -0,0 +1,35 @@ +# Windows ARM64 Support Plan + +## Architecture + +- Validate Windows ARM64 on GitHub's `windows-11-arm` runner with Playwright smoke tests against the built Electron app plus a separate process smoke for the packaged executable. +- Extend the manual build workflow's Windows matrix to produce `win-x64` and `win-arm64` artifacts while keeping the release workflow on Windows x64 only. +- Keep the Windows ARM64 runtime script explicit: install only verified native `uv`, `node`, and `ripgrep` artifacts. +- Use a `sharp` version that publishes `@img/sharp-win32-arm64`, otherwise main-process image helpers fail during E2E bootstrap on Windows ARM64. +- Provide a CI-specific E2E mode that runs only non-provider smoke specs against the runner profile. +- Keep `_electron.launch()` for interactive E2E coverage because the packaged Windows executable does not reliably expose a Playwright-controllable debug endpoint in CI. +- Start the packaged Windows ARM64 executable separately and verify it remains alive for a short smoke window, with process output, app logs, native module inventory, and Windows event logs uploaded as diagnostics. +- Run packaged executable smoke only after interactive E2E succeeds, so startup failures keep the main-process logs focused on the failing launch. + +## E2E Data Flow + +1. The Playwright fixture launches the built Electron app with the default Electron `userData` path for the current runner/user. +2. CI Playwright config matches only launch and settings-navigation smoke specs. +3. Chat, session persistence, and provider connectivity specs remain available for local/manual runs with configured providers. +4. The Playwright fixture attaches renderer diagnostics and `userData/logs` main-process logs to each test result. +5. The packaged executable smoke runs outside Playwright and writes stdout/stderr, Chromium logs, app logs, filesystem inventory, native module inventory, and Windows application events into the diagnostics artifact. + +## Runtime Behavior + +- `installRuntime:win:arm64` calls `tiny-runtime-injector` directly for `uv`, `node`, and `ripgrep`. +- `ripgrep` is pinned to `15.1.0` for Windows ARM64 because the injector default `14.1.1` has no ARM64 Windows release asset. +- `rtk` is intentionally omitted until upstream ships a Windows ARM64 release asset; existing runtime consumers continue to detect missing bundled binaries and fall back to system/runtime-unavailable behavior. + +## Validation + +- Runtime fallback tests cover missing bundled runtime behavior. +- Existing RTK fallback coverage remains in place. +- Skill runtime tests cover the no-UV/no-system-Python auto-runtime failure path. +- The manual build workflow validates Windows x64 and Windows ARM64 artifact generation. +- The new manual workflow validates Windows ARM64 build, plugin bundle, packaged executable startup, app launch, route switching, and settings navigation. +- The Windows ARM64 E2E workflow uploads Playwright reports, traces, screenshots, app logs, native module inventory, Windows event logs, and process-smoke logs only. diff --git a/docs/features/windows-arm64-support/spec.md b/docs/features/windows-arm64-support/spec.md new file mode 100644 index 000000000..965ad5d29 --- /dev/null +++ b/docs/features/windows-arm64-support/spec.md @@ -0,0 +1,29 @@ +# Windows ARM64 Support Spec + +## User Story + +DeepChat maintainers need a reliable way to validate Windows ARM64 builds without owning Windows ARM64 hardware, so the project can ship a Windows ARM64 package only after it passes smoke coverage on a real ARM64 Windows runner. + +## Acceptance Criteria + +- A manual GitHub Actions workflow runs on `windows-11-arm` and builds the Windows ARM64 app. +- The workflow runs E2E smoke tests that do not require configured provider credentials. +- The E2E run uses the runner's default profile and validates launch, routing, and settings window behavior. +- Packaged-app validation starts the unpacked Windows ARM64 executable and verifies it stays alive long enough for a process-level smoke check. +- The manual build workflow can produce both Windows x64 and Windows ARM64 artifacts. +- Windows ARM64 bundles only verified native runtimes: `uv`, `node`, and `ripgrep`. +- `rtk` is not bundled on Windows ARM64 until upstream provides a Windows ARM64 binary. +- Existing Windows x64, macOS, and Linux runtime install scripts remain strict. +- The Windows ARM64 E2E workflow uploads only diagnostics, not packaged build outputs. + +## Non-Goals + +- Enable Windows ARM64 in the release workflow only after the manual Windows ARM64 E2E workflow has passed. +- Not every optional runtime is bundled on Windows ARM64. +- Provider-backed chat requests must not run in this CI workflow. + +## Constraints + +- Keep CI smoke coverage provider-independent; provider-backed specs remain local/manual only. +- Keep local `pnpm run e2e:smoke` behavior compatible with existing manual smoke tests. +- Keep runtime fallback behavior aligned with existing `RuntimeHelper`, RTK, and skill runtime logic. diff --git a/docs/features/windows-arm64-support/tasks.md b/docs/features/windows-arm64-support/tasks.md new file mode 100644 index 000000000..bcb8d3bca --- /dev/null +++ b/docs/features/windows-arm64-support/tasks.md @@ -0,0 +1,17 @@ +# Windows ARM64 Support Tasks + +- [x] Add SDD spec, plan, and task tracking. +- [x] Verify Windows ARM64 runtime artifact availability. +- [x] Wire `installRuntime:win:arm64` to explicit `uv`, `node`, and `ripgrep` installation. +- [x] Add CI E2E support for non-provider smoke tests. +- [x] Keep E2E on the default runner profile. +- [x] Add packaged executable process smoke. +- [x] Split interactive E2E from packaged executable process smoke. +- [x] Add Windows ARM64 manual GitHub Actions workflow. +- [x] Enable Windows ARM64 in the manual build workflow. +- [x] Limit Windows ARM64 E2E artifacts to diagnostics. +- [x] Upload app logs, event logs, and native module inventory for Windows ARM64 failures. +- [x] Attach main-process logs directly to E2E test results. +- [x] Upgrade `sharp` to a version with Windows ARM64 optional dependency support. +- [x] Add targeted unit coverage for runtime fallback paths. +- [ ] Enable Windows ARM64 in the release workflow after the manual workflow passes on GitHub. diff --git a/docs/issues/onboarding-provider-mcp-handoff/plan.md b/docs/issues/onboarding-provider-mcp-handoff/plan.md new file mode 100644 index 000000000..d13105124 --- /dev/null +++ b/docs/issues/onboarding-provider-mcp-handoff/plan.md @@ -0,0 +1,42 @@ +# Implementation Plan + +## Cause + +There are two independent fragility points on the renderer side. Both surface +in packaged builds because timing in production is less forgiving than in dev. + +1. `useGuidedOnboardingStep.setStepStatus` returns the previous (possibly + `null`) value of its internal `onboardingState` ref when the backend IPC + throws. `continueGuidedOnboardingFromSettings` then resolves a `null` step + id, hits the fallback branch, and calls `windowPresenter.focusMainWindow()` + instead of `router.push({ name: 'settings-mcp' })`. The backend state is + already correct — the renderer just doesn't see it on the relevant tick. + +2. `GuidedOnboardingOverlay` always renders the dim `` from + `OnBoardingSpotlight`, even when `useOnBoarding` has not produced a + spotlight rect yet (target element not yet sized). With no cutout the path + covers the entire viewport with `pointer-events: auto`, producing the + "full-window dim, no popover, can't click" symptom while the layout + stabilizes. + +## Change + +- **Renderer composable resilience.** In `useGuidedOnboardingStep`, when an + onboarding IPC call fails, fall back to fetching fresh state via + `onboardingClient.getState()` before returning to the caller. Apply to + `setStepStatus`, `activateStep`, and `forceComplete` paths. +- **Navigation helper resilience.** `continueGuidedOnboardingFromSettings` + refreshes its `state` from `onboardingClient.getState()` when the caller + passes a `null`/stale value, so that a transient renderer hiccup cannot + force the helper into the "focus main window" branch. +- **Overlay defensive rendering.** `OnBoardingSpotlight` only renders its + dim `` when a cutout is present. With no cutout the parent overlay + still allows the panel to render at its fallback coordinates, but the + blocking dim no longer covers the window. + +## Validation + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- `pnpm run typecheck` diff --git a/docs/issues/onboarding-provider-mcp-handoff/spec.md b/docs/issues/onboarding-provider-mcp-handoff/spec.md new file mode 100644 index 000000000..4142dbb94 --- /dev/null +++ b/docs/issues/onboarding-provider-mcp-handoff/spec.md @@ -0,0 +1,39 @@ +# Onboarding Provider → MCP Handoff + +## Problem + +In packaged builds, after the user finishes the `provider-model` guided step the +settings window does not automatically advance to the `settings-mcp` route, so +the MCP coachmark never appears in the expected sequence. Reopening the +settings window and clicking the MCP tab afterwards does surface the MCP +overlay, but in this fallback path the overlay renders as a full-window dim +without a visible popover, blocking subsequent interaction. + +Locally (dev) the same flow is continuous. The divergence is timing- and +device-sensitive — the user could not reproduce on their own machine but +observed it on another machine. + +## User Story + +As a first-time user completing the provider step in the packaged app, I want +the guide to continue into the MCP step without me having to navigate manually, +and when the MCP overlay does appear I want to be able to read it and click +through it. + +## Acceptance Criteria + +- After `provider-model` completes, the settings window advances to + `settings-mcp` even when the per-step state returned from the backend is + stale or missing, as long as the backend has actually progressed. +- When the guided onboarding overlay is asked to render but the spotlight + target element is not yet sized, the dim layer does not cover the window — + no interaction is blocked while the target is still being laid out. +- Existing behavior is preserved: when the target element is sized the dim and + cutout render as before and the user-facing copy/keys do not change. + +## Non-goals + +- No change to the backend step ordering or migration logic in + `onboardingRouteSupport.ts`. +- No redesign of the onboarding panel layout or copy. +- No change to the welcome page / main-window flow. diff --git a/docs/issues/onboarding-provider-mcp-handoff/tasks.md b/docs/issues/onboarding-provider-mcp-handoff/tasks.md new file mode 100644 index 000000000..5a511048c --- /dev/null +++ b/docs/issues/onboarding-provider-mcp-handoff/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] Add SDD artifacts. +- [x] Harden `useGuidedOnboardingStep` IPC failure paths with a `getState` fallback. +- [x] Refresh `state` inside `continueGuidedOnboardingFromSettings` when caller passes a null/stale value. +- [x] Stop rendering the dim path in `OnBoardingSpotlight` when there is no cutout. +- [x] Run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, and `pnpm run typecheck`. diff --git a/docs/issues/windows-arm64-duckdb-upgrade/plan.md b/docs/issues/windows-arm64-duckdb-upgrade/plan.md new file mode 100644 index 000000000..a35b2a747 --- /dev/null +++ b/docs/issues/windows-arm64-duckdb-upgrade/plan.md @@ -0,0 +1,93 @@ +# Windows ARM64 DuckDB Upgrade Plan + +## Planning Summary + +The safest first increment is to upgrade DuckDB to a Windows ARM64-capable release and add a narrow CI smoke check for DuckDB + `vss` before launching the Electron app. + +This isolates two independent risks: + +1. native binding availability on `win32-arm64` +2. `vss` extension install/load compatibility on the upgraded DuckDB release + +## Current Repository Constraints + +- DeepChat imports DuckDB through `src/main/presenter/knowledgePresenter/database/duckdbPresenter.ts`. +- The main-process startup path reaches built-in knowledge base code early enough that a missing native binding crashes the app before E2E can observe a window. +- DeepChat's DuckDB flow uses both online `INSTALL/LOAD vss` and an offline copied extension path through `scripts/installVss.js` and runtime extension loading. +- Project guidance requires keeping an SDD folder for active issue work and running `pnpm run format`, `pnpm run i18n`, and `pnpm run lint` after implementation. + +## Proposed Changes + +### 1. Dependency Upgrade + +Update `package.json` to `@duckdb/node-api@1.5.3-r.1` and refresh `pnpm-lock.yaml`. + +Expected effect: + +- pnpm resolves `@duckdb/node-bindings@1.5.3-r.1` +- pnpm resolves `@duckdb/node-bindings-win32-arm64@1.5.3-r.1` +- Windows ARM64 can load the native binding instead of failing during module initialization + +### 2. Early Windows ARM64 Verification + +Add a dedicated workflow step before build/E2E that runs a repository script to verify: + +- `@duckdb/node-api` imports successfully +- `DuckDBInstance.create(':memory:')` works +- `INSTALL vss` succeeds +- `LOAD vss` succeeds +- the script prints the resolved DuckDB package version for diagnostics + +This step should fail with a narrow error message instead of allowing the workflow to proceed to a generic Electron launch timeout. + +### 3. Reuse Existing VSS Flow + +Keep the existing DeepChat runtime logic unchanged unless the upgraded API breaks it: + +- `scripts/installVss.js` still performs `INSTALL vss` and copies the installed extension into `runtime/duckdb/extensions` +- `duckdbPresenter.ts` still prefers the bundled extension and falls back to online `INSTALL/LOAD` + +This keeps the change focused on compatibility validation rather than behavior redesign. + +## Validation Strategy + +### Local / Repository Validation + +Run: + +- a targeted DuckDB/VSS smoke command +- any focused tests touching the changed code or scripts +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` + +### CI Validation + +The Windows ARM64 workflow itself becomes part of the validation by: + +1. installing dependencies for `win32-arm64` +2. running the dedicated DuckDB/VSS smoke check +3. only then building and launching the Electron app + +## Risks And Mitigations + +### Risk 1: `vss` fails on Windows ARM64 even after the binding upgrade + +Mitigation: + +- fail in the dedicated verification step with a precise error +- keep a fallback path available for a later issue to disable built-in knowledge base on Windows ARM64 if needed + +### Risk 2: DuckDB package upgrade changes API behavior + +Mitigation: + +- keep changes minimal and avoid refactoring knowledge-base code in the first pass +- use the existing `DuckDBInstance`/`connect`/`run` usage pattern already supported by current code + +### Risk 3: Offline extension installation path changes + +Mitigation: + +- validate `scripts/installVss.js` against the upgraded package +- prefer a dedicated smoke script that exercises the same `INSTALL vss` and `LOAD vss` sequence used by production code diff --git a/docs/issues/windows-arm64-duckdb-upgrade/spec.md b/docs/issues/windows-arm64-duckdb-upgrade/spec.md new file mode 100644 index 000000000..ae16a16e4 --- /dev/null +++ b/docs/issues/windows-arm64-duckdb-upgrade/spec.md @@ -0,0 +1,68 @@ +# Windows ARM64 DuckDB Upgrade + +## Status + +Draft on `2026-05-21`. + +## Goal + +Restore Windows ARM64 desktop startup and smoke E2E coverage by upgrading DeepChat's DuckDB Node bindings to a release that ships `win32-arm64` native binaries and preserves built-in knowledge base behavior. + +## Background + +The Windows ARM64 E2E workflow currently fails during Electron main-process startup before the first window becomes available. + +The failure is caused by DeepChat loading `@duckdb/node-api@1.3.2-alpha.25` during boot. That package version only ships native bindings for: + +- `darwin-arm64` +- `darwin-x64` +- `linux-arm64` +- `linux-x64` +- `win32-x64` + +It does not ship a `win32-arm64` binding, so the app crashes while loading the built-in knowledge base presenter on Windows ARM64. + +The newer `@duckdb/node-api@1.5.3-r.1` release depends on `@duckdb/node-bindings@1.5.3-r.1`, which publishes `@duckdb/node-bindings-win32-arm64` in npm metadata. DuckDB's current `vss` documentation still describes the same `INSTALL vss`, `LOAD vss`, and `SET hnsw_enable_experimental_persistence = true` workflow that DeepChat already uses. + +## User Stories + +- As a maintainer, I want the Windows ARM64 smoke workflow to launch DeepChat successfully instead of crashing on boot. +- As a maintainer, I want built-in knowledge base support to continue working on supported platforms after the DuckDB upgrade. +- As a maintainer, I want Windows ARM64 CI to verify DuckDB and `vss` availability early so failures are easier to diagnose than a generic Electron launch timeout. + +## In Scope + +- Upgrade `@duckdb/node-api` to a release that includes Windows ARM64 support +- Refresh the lockfile to pull the matching `@duckdb/node-bindings-win32-arm64` package +- Add a Windows ARM64 CI smoke check that verifies DuckDB can load and `vss` can be installed/loaded before app E2E launch +- Keep DeepChat's built-in knowledge base DuckDB/VSS flow unchanged if the newer release remains compatible + +## Acceptance Criteria + +- The repository no longer depends on `@duckdb/node-api@1.3.2-alpha.25`. +- The lockfile includes `@duckdb/node-bindings-win32-arm64` for the selected DuckDB version. +- The Windows ARM64 workflow runs a targeted DuckDB/VSS verification step before app smoke tests. +- The targeted verification step proves that a Windows ARM64 runner can: + - load `@duckdb/node-api` + - create an in-memory DuckDB instance + - `INSTALL vss` + - `LOAD vss` +- Existing built-in knowledge base code paths remain on the DuckDB + `vss` implementation. +- Repository quality gates required by project guidance still pass after the change. + +## Non-Goals + +- Replacing DuckDB with another vector store +- Redesigning built-in knowledge base UX +- Hiding or disabling knowledge base features on Windows ARM64 in this first attempt +- Reworking unrelated Windows ARM packaging behavior + +## Risks And Constraints + +- `vss` remains experimental in DuckDB, especially with persistence enabled. +- A DuckDB version upgrade may introduce API or SQL behavior changes even if the package API shape is stable. +- The CI verification step should fail fast and produce clear logs if `INSTALL vss` or `LOAD vss` breaks on Windows ARM64. + +## Open Questions + +None currently. diff --git a/docs/issues/windows-arm64-duckdb-upgrade/tasks.md b/docs/issues/windows-arm64-duckdb-upgrade/tasks.md new file mode 100644 index 000000000..e4e24c089 --- /dev/null +++ b/docs/issues/windows-arm64-duckdb-upgrade/tasks.md @@ -0,0 +1,46 @@ +# Windows ARM64 DuckDB Upgrade Tasks + +Feature: `windows-arm64-duckdb-upgrade` +Spec: [spec.md](./spec.md) +Plan: [plan.md](./plan.md) + +## Epic E1 Spec And Scope + +- [x] `T1.1` Create the SDD issue folder with `spec.md`, `plan.md`, and `tasks.md` for the Windows ARM64 DuckDB startup failure. + Owner: Maintainer + Effort: XS + Status: Completed + +## Epic E2 Upgrade And Verification + +- [x] `T2.1` Upgrade `@duckdb/node-api` to `1.5.3-r.1` and refresh the lockfile. + Owner: Maintainer + Effort: S + Status: Completed +- [x] `T2.2` Add a dedicated DuckDB/VSS smoke script that verifies import, in-memory startup, `INSTALL vss`, and `LOAD vss`. + Owner: Maintainer + Effort: S + Status: Completed +- [x] `T2.3` Add the dedicated DuckDB/VSS smoke verification to the Windows ARM64 workflow before app smoke tests. + Owner: Maintainer + Effort: S + Status: Completed +- [x] `T2.4` Scope the Windows ARM64 E2E workflow to launch-only Playwright coverage because this platform gate only needs to prove startup viability. + Owner: Maintainer + Effort: XS + Status: Completed + +## Epic E3 Validation + +- [x] `T3.1` Run targeted validation for the new DuckDB/VSS smoke path. + Owner: Maintainer + Effort: S + Status: Completed +- [x] `T3.2` Run repository-required quality gates: `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. + Owner: Maintainer + Effort: S + Status: Completed +- [ ] `T3.3` Re-run Windows ARM64 CI after scoping the E2E workflow to launch-only coverage. + Owner: Maintainer + Effort: S + Status: Pending diff --git a/docs/issues/windows-release-build-arch/plan.md b/docs/issues/windows-release-build-arch/plan.md new file mode 100644 index 000000000..b4c01cc6f --- /dev/null +++ b/docs/issues/windows-release-build-arch/plan.md @@ -0,0 +1,15 @@ +# Windows Release Build Architecture Plan + +## Workflow Changes + +- Add an explicit `name` to Windows matrix jobs so GitHub Actions does not render every matrix field in the display name. +- Replace Windows x64 `windows-latest` usage with the explicit `windows-2025-vs2026` runner label. +- Expand the release Windows matrix to include arm64 with its own runner and unpacked output directory. +- Copy Windows arm64 release assets from `deepchat-win-arm64` into `release_assets`. + +## Validation + +- Run `pnpm run format`. +- Run `pnpm run i18n`. +- Run `pnpm run lint`. +- Inspect workflow references for stale `windows-latest` usage. diff --git a/docs/issues/windows-release-build-arch/spec.md b/docs/issues/windows-release-build-arch/spec.md new file mode 100644 index 000000000..ce305f1d1 --- /dev/null +++ b/docs/issues/windows-release-build-arch/spec.md @@ -0,0 +1,24 @@ +# Windows Release Build Architecture + +## User Story + +Maintainers need Windows CI and release jobs to build separate x64 and arm64 packages while keeping GitHub Actions job names concise and stable. + +## Acceptance Criteria + +- Windows build jobs display as one x64 job and one arm64 job, without extra matrix metadata in the job name. +- Windows x64 build jobs no longer use the moving `windows-latest` runner label. +- Release builds include Windows arm64 artifacts alongside Windows x64 artifacts. +- Bundled plugin verification uses the correct unpacked output directory for each Windows architecture. + +## Non-goals + +- Do not change release version metadata. +- Do not remove existing macOS or Linux release behavior. +- Do not change installer naming. + +## Constraints + +- Keep workflow changes minimal. +- Preserve existing Windows x64 behavior except for the explicit runner label. +- Use the existing `windows-11-arm` runner for Windows arm64. diff --git a/docs/issues/windows-release-build-arch/tasks.md b/docs/issues/windows-release-build-arch/tasks.md new file mode 100644 index 000000000..46228ca03 --- /dev/null +++ b/docs/issues/windows-release-build-arch/tasks.md @@ -0,0 +1,7 @@ +# Windows Release Build Architecture Tasks + +- [x] Document the CI/release scope. +- [x] Update Windows build workflow job naming and x64 runner label. +- [x] Update release workflow Windows matrix and plugin verification path. +- [x] Include Windows arm64 assets in draft release preparation. +- [x] Run required project checks. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2161f6c67..fadd81f7b 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -42,6 +42,7 @@ export default defineConfig({ rollupOptions: { input: { index: resolve('src/preload/index.ts'), + splash: resolve('src/preload/splash-preload.ts'), floating: resolve('src/preload/floating-preload.ts'), browserOverlay: resolve('src/preload/browser-overlay-preload.ts'), pluginSettings: resolve('src/preload/plugin-settings-preload.ts') diff --git a/package.json b/package.json index c3bcdd8f2..f8bc3ee30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DeepChat", - "version": "1.0.5-beta.2", + "version": "1.0.5-beta.3", "description": "DeepChat,一个简单易用的 Agent 客户端", "main": "./out/main/index.js", "author": "ThinkInAIXYZ", @@ -20,6 +20,7 @@ "test:watch": "vitest --watch", "test:ui": "vitest --ui", "e2e:smoke": "playwright test -c test/e2e/playwright.config.ts", + "e2e:smoke:ci": "playwright test -c test/e2e/playwright.ci.config.ts", "format:check": "oxfmt --check .", "format": "oxfmt .", "lint": "pnpm run lint:agent-cleanup && pnpm run lint:architecture && oxlint .", @@ -59,12 +60,13 @@ "afterSign": "scripts/notarize.js", "installRuntime": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 && npx -y tiny-runtime-injector --type node --dir ./runtime/node && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep && npx -y tiny-runtime-injector --type rtk --dir ./runtime/rtk", "installRuntime:win:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a x64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p win32 && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep -a x64 -p win32 && npx -y tiny-runtime-injector --type rtk --dir ./runtime/rtk -a x64 -p win32", - "installRuntime:win:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a arm64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p win32 && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep -a arm64 -p win32 && npx -y tiny-runtime-injector --type rtk --dir ./runtime/rtk -a arm64 -p win32", + "installRuntime:win:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a arm64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p win32 && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep --runtime-version 15.1.0 -a arm64 -p win32", "installRuntime:mac:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a arm64 -p darwin && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p darwin && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep -a arm64 -p darwin && npx -y tiny-runtime-injector --type rtk --dir ./runtime/rtk -a arm64 -p darwin", "installRuntime:mac:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a x64 -p darwin && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p darwin && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep -a x64 -p darwin && npx -y tiny-runtime-injector --type rtk --dir ./runtime/rtk -a x64 -p darwin", "installRuntime:linux:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a x64 -p linux && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p linux && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep -a x64 -p linux && npx -y tiny-runtime-injector --type rtk --dir ./runtime/rtk -a x64 -p linux", "installRuntime:linux:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a arm64 -p linux && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p linux && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep -a arm64 -p linux && npx -y tiny-runtime-injector --type rtk --dir ./runtime/rtk -a arm64 -p linux", "installRuntime:duckdb:vss": "node scripts/installVss.js", + "smoke:duckdb:vss": "node scripts/smoke-duckdb-vss.js", "i18n": "i18n-check -s zh-CN -f i18next --locales src/renderer/src/i18n", "i18n:en": "i18n-check -s en-US -f i18next --locales src/renderer/src/i18n", "i18n:types": "node scripts/generate-i18n-types.js", @@ -82,7 +84,7 @@ "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@aws-sdk/client-bedrock": "^3.1049.0", - "@duckdb/node-api": "1.3.2-alpha.25", + "@duckdb/node-api": "1.5.3-r.1", "@e2b/code-interpreter": "^1.5.1", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", @@ -116,7 +118,7 @@ "pdf-parse-new": "^1.4.1", "run-applescript": "^7.1.0", "safe-regex2": "^5.1.1", - "sharp": "^0.33.5", + "sharp": "^0.34.5", "tokenx": "^0.4.1", "turndown": "^7.2.4", "undici": "^7.25.0", diff --git a/scripts/smoke-duckdb-vss.js b/scripts/smoke-duckdb-vss.js new file mode 100644 index 000000000..633fbf0db --- /dev/null +++ b/scripts/smoke-duckdb-vss.js @@ -0,0 +1,28 @@ +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const duckdbPackage = require('@duckdb/node-api/package.json') + +async function main() { + console.log(`[DuckDB Smoke] package version: ${duckdbPackage.version}`) + + const duckdb = await import('@duckdb/node-api') + const instance = await duckdb.DuckDBInstance.create(':memory:') + const connection = await instance.connect() + + try { + console.log('[DuckDB Smoke] created in-memory instance') + await connection.run('INSTALL vss') + console.log('[DuckDB Smoke] installed vss') + await connection.run('LOAD vss') + console.log('[DuckDB Smoke] loaded vss') + } finally { + connection.closeSync() + instance.closeSync() + } +} + +main().catch((error) => { + console.error('[DuckDB Smoke] failed:', error) + process.exit(1) +}) diff --git a/src/main/presenter/configPresenter/configDbStores.ts b/src/main/presenter/configPresenter/configDbStores.ts index f1166c34b..51314a70a 100644 --- a/src/main/presenter/configPresenter/configDbStores.ts +++ b/src/main/presenter/configPresenter/configDbStores.ts @@ -6,6 +6,18 @@ import type { StoreLike } from './storeLike' const MODEL_STATUS_KEY_PREFIX = 'model_status_' +export const SENSITIVE_APP_SETTING_KEYS = [ + 'remoteControl', + 'mcprouterApiKey', + 'nowledgeMemConfig', + 'hooksNotifications', + 'knowledgeConfigs', + 'customPrompts', + 'systemPrompts' +] as const + +const SENSITIVE_APP_SETTING_KEY_SET = new Set(SENSITIVE_APP_SETTING_KEYS) + type LegacyStore = StoreLike> const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T @@ -85,6 +97,17 @@ export class AppSettingsDbBackedStore implements StoreLike(key) return legacyValue === undefined ? defaultValue : clone(legacyValue) } + if (this.isSensitiveAppSettingKey(key)) { + const value = this.configTables.getAppSetting(key) + if (value !== undefined) { + return clone(value) + } + if (!this.shouldUseSensitiveLegacyFallback()) { + return defaultValue + } + const legacyValue = this.legacyStore.get(key) + return legacyValue === undefined ? defaultValue : clone(legacyValue) + } const value = this.legacyStore.get(key) return value === undefined ? defaultValue : value @@ -127,6 +150,10 @@ export class AppSettingsDbBackedStore implements StoreLike> @@ -675,7 +726,8 @@ export class ConfigPresenter implements IConfigPresenter { key === 'providers' || key === 'providerOrder' || key === 'providerTimestamps' || - key.startsWith('model_status_') + key.startsWith('model_status_') || + SENSITIVE_APP_SETTING_KEYS.includes(key as (typeof SENSITIVE_APP_SETTING_KEYS)[number]) ) } @@ -2697,7 +2749,9 @@ export class ConfigPresenter implements IConfigPresenter { // Load from store and cache it try { - const prompts = this.customPromptsStore.get('prompts') || [] + const prompts = this.dbBackedSettingsStore + ? this.getSetting('customPrompts') || [] + : this.customPromptsStore.get('prompts') || [] this.customPromptsCache = prompts console.log(`[Config] Custom prompts cache loaded: ${prompts.length} prompts`) return prompts @@ -2710,7 +2764,11 @@ export class ConfigPresenter implements IConfigPresenter { // 保存自定义 prompts (with cache update) async setCustomPrompts(prompts: Prompt[]): Promise { - await this.customPromptsStore.set('prompts', prompts) + if (this.dbBackedSettingsStore) { + this.setSetting('customPrompts', prompts) + } else { + await this.customPromptsStore.set('prompts', prompts) + } this.clearCustomPromptsCache() console.log(`[Config] Custom prompts cache updated: ${prompts.length} prompts`) // Notify all windows about custom prompts change @@ -2767,49 +2825,156 @@ export class ConfigPresenter implements IConfigPresenter { // 获取默认系统提示词 async getDefaultSystemPrompt(): Promise { + if (this.dbBackedSettingsStore) { + const prompts = await this.getSystemPrompts() + const defaultPrompt = prompts.find((prompt) => prompt.isDefault) + return defaultPrompt?.content ?? this.getSetting('default_system_prompt') ?? '' + } return this.systemPromptHelper.getDefaultSystemPrompt() } async setDefaultSystemPrompt(prompt: string): Promise { + if (this.dbBackedSettingsStore) { + this.setSetting('default_system_prompt', prompt) + await this.publishSystemPromptState() + return + } return this.systemPromptHelper.setDefaultSystemPrompt(prompt) } async resetToDefaultPrompt(): Promise { + if (this.dbBackedSettingsStore) { + this.setSetting('default_system_prompt', DEFAULT_SYSTEM_PROMPT) + await this.publishSystemPromptState() + return + } return this.systemPromptHelper.resetToDefaultPrompt() } async clearSystemPrompt(): Promise { + if (this.dbBackedSettingsStore) { + this.setSetting('default_system_prompt', '') + await this.publishSystemPromptState() + return + } return this.systemPromptHelper.clearSystemPrompt() } async getSystemPrompts(): Promise { + if (this.dbBackedSettingsStore) { + return this.getSetting('systemPrompts') || [] + } return this.systemPromptHelper.getSystemPrompts() } async setSystemPrompts(prompts: SystemPrompt[]): Promise { - return this.systemPromptHelper.setSystemPrompts(prompts) + if (!this.dbBackedSettingsStore) { + return this.systemPromptHelper.setSystemPrompts(prompts) + } + + this.setSetting('systemPrompts', prompts) + publishDeepchatEvent('config.systemPrompts.changed', { + prompts, + defaultPromptId: await this.getDefaultSystemPromptId(), + prompt: await this.getDefaultSystemPrompt(), + version: Date.now() + }) } async addSystemPrompt(prompt: SystemPrompt): Promise { + if (this.dbBackedSettingsStore) { + const prompts = await this.getSystemPrompts() + await this.setSystemPrompts([...prompts, prompt]) + return + } return this.systemPromptHelper.addSystemPrompt(prompt) } async updateSystemPrompt(promptId: string, updates: Partial): Promise { + if (this.dbBackedSettingsStore) { + const prompts = await this.getSystemPrompts() + const index = prompts.findIndex((prompt) => prompt.id === promptId) + if (index === -1) { + return + } + const nextPrompts = [...prompts] + nextPrompts[index] = { ...nextPrompts[index], ...updates } + await this.setSystemPrompts(nextPrompts) + return + } return this.systemPromptHelper.updateSystemPrompt(promptId, updates) } async deleteSystemPrompt(promptId: string): Promise { + if (this.dbBackedSettingsStore) { + const prompts = await this.getSystemPrompts() + await this.setSystemPrompts(prompts.filter((prompt) => prompt.id !== promptId)) + return + } return this.systemPromptHelper.deleteSystemPrompt(promptId) } async setDefaultSystemPromptId(promptId: string): Promise { + if (this.dbBackedSettingsStore) { + const prompts = await this.getSystemPrompts() + const updatedPrompts = prompts.map((prompt) => ({ ...prompt, isDefault: false })) + + if (promptId === 'empty') { + await this.setSystemPrompts(updatedPrompts) + await this.clearSystemPrompt() + eventBus.send(CONFIG_EVENTS.DEFAULT_SYSTEM_PROMPT_CHANGED, SendTarget.ALL_WINDOWS, { + promptId: 'empty', + content: '' + }) + await this.publishSystemPromptState() + return + } + + const targetIndex = updatedPrompts.findIndex((prompt) => prompt.id === promptId) + if (targetIndex !== -1) { + updatedPrompts[targetIndex].isDefault = true + await this.setSystemPrompts(updatedPrompts) + await this.setDefaultSystemPrompt(updatedPrompts[targetIndex].content) + eventBus.send(CONFIG_EVENTS.DEFAULT_SYSTEM_PROMPT_CHANGED, SendTarget.ALL_WINDOWS, { + promptId, + content: updatedPrompts[targetIndex].content + }) + await this.publishSystemPromptState() + } else { + await this.setSystemPrompts(updatedPrompts) + } + return + } return this.systemPromptHelper.setDefaultSystemPromptId(promptId) } async getDefaultSystemPromptId(): Promise { + if (this.dbBackedSettingsStore) { + const prompts = await this.getSystemPrompts() + const defaultPrompt = prompts.find((prompt) => prompt.isDefault) + if (defaultPrompt) { + return defaultPrompt.id + } + + const storedPrompt = this.getSetting('default_system_prompt') + if (!storedPrompt || storedPrompt.trim() === '') { + return 'empty' + } + + return prompts.find((prompt) => prompt.id === 'default')?.id || 'default' + } return this.systemPromptHelper.getDefaultSystemPromptId() } + private async publishSystemPromptState(): Promise { + publishDeepchatEvent('config.systemPrompts.changed', { + prompts: await this.getSystemPrompts(), + defaultPromptId: await this.getDefaultSystemPromptId(), + prompt: await this.getDefaultSystemPrompt(), + version: Date.now() + }) + } + // 获取更新渠道 getUpdateChannel(): string { const raw = this.getSetting('updateChannel') || 'stable' @@ -2853,11 +3018,17 @@ export class ConfigPresenter implements IConfigPresenter { // 获取知识库配置 getKnowledgeConfigs(): BuiltinKnowledgeConfig[] { - const configs = this.knowledgeConfHelper.getKnowledgeConfigs() + const configs = this.dbBackedSettingsStore + ? this.getSetting('knowledgeConfigs') || [] + : this.knowledgeConfHelper.getKnowledgeConfigs() const migratedConfigs = this.mcpConfHelper.migrateBuiltinKnowledgeConfigsFromEnv(configs) if (migratedConfigs !== configs) { - this.knowledgeConfHelper.setKnowledgeConfigs(migratedConfigs) + if (this.dbBackedSettingsStore) { + this.setSetting('knowledgeConfigs', migratedConfigs) + } else { + this.knowledgeConfHelper.setKnowledgeConfigs(migratedConfigs) + } } return migratedConfigs @@ -2865,7 +3036,11 @@ export class ConfigPresenter implements IConfigPresenter { // 设置知识库配置 setKnowledgeConfigs(configs: BuiltinKnowledgeConfig[]): void { - this.knowledgeConfHelper.setKnowledgeConfigs(configs) + if (this.dbBackedSettingsStore) { + this.setSetting('knowledgeConfigs', configs) + } else { + this.knowledgeConfHelper.setKnowledgeConfigs(configs) + } void Promise.all([this.getMcpServers(), this.getMcpEnabled()]) .then(([mcpServers, mcpEnabled]) => { eventBus.send(MCP_EVENTS.CONFIG_CHANGED, SendTarget.ALL_WINDOWS, { @@ -2963,7 +3138,7 @@ export class ConfigPresenter implements IConfigPresenter { timeout: number } | null> { try { - return this.store.get('nowledgeMemConfig', null) as { + return this.getSettingsStoreForKey('nowledgeMemConfig').get('nowledgeMemConfig', null) as { baseUrl: string apiKey?: string timeout: number @@ -2980,7 +3155,7 @@ export class ConfigPresenter implements IConfigPresenter { timeout: number }): Promise { try { - this.store.set('nowledgeMemConfig', config) + this.getSettingsStoreForKey('nowledgeMemConfig').set('nowledgeMemConfig', config) eventBus.sendToRenderer( CONFIG_EVENTS.NOWLEDGE_MEM_CONFIG_UPDATED, SendTarget.ALL_WINDOWS, @@ -2993,17 +3168,18 @@ export class ConfigPresenter implements IConfigPresenter { } getHooksNotificationsConfig(): HooksNotificationsSettings { - const raw = this.store.get('hooksNotifications') + const store = this.getSettingsStoreForKey('hooksNotifications') + const raw = store.get('hooksNotifications') const normalized = normalizeHooksNotificationsConfig(raw) if (!raw || JSON.stringify(raw) !== JSON.stringify(normalized)) { - this.store.set('hooksNotifications', normalized) + store.set('hooksNotifications', normalized) } return normalized } setHooksNotificationsConfig(config: HooksNotificationsSettings): HooksNotificationsSettings { const normalized = normalizeHooksNotificationsConfig(config) - this.store.set('hooksNotifications', normalized) + this.getSettingsStoreForKey('hooksNotifications').set('hooksNotifications', normalized) return normalized } diff --git a/src/main/presenter/databaseSecurityPresenter/index.ts b/src/main/presenter/databaseSecurityPresenter/index.ts new file mode 100644 index 000000000..8561b3e0a --- /dev/null +++ b/src/main/presenter/databaseSecurityPresenter/index.ts @@ -0,0 +1,639 @@ +import { app, safeStorage } from 'electron' +import ElectronStore from 'electron-store' +import fs from 'fs' +import path from 'path' +import Database from 'better-sqlite3-multiple-ciphers' +import type { IConfigPresenter } from '@shared/presenter' +import type { DatabaseSecurityStatus } from '@shared/contracts/routes' +import type { DatabaseUnlockReason } from '@shared/contracts/databaseSecurity' +import { openSQLiteDatabase, type SQLitePresenter } from '../sqlitePresenter' +import { configureSQLCipherCompatibility } from '../sqlitePresenter/connectionConfig' + +type DatabaseSecurityMetadata = { + version: 1 + enabled: boolean + cipher: 'sqlcipher' + passwordStorage: 'safeStorage' | 'manual' | 'none' + wrappedPassword?: string + safeStorageBackend?: string + lastMigrationAt?: number + lastMigrationDirection?: 'enable' | 'change-password' | 'disable' +} + +type UnlockRequest = { + reason: DatabaseUnlockReason + safeStorageAvailable: boolean +} + +type UnlockProvider = (request: UnlockRequest) => Promise + +type MigrationDirection = 'enable' | 'change-password' | 'disable' + +const DEFAULT_METADATA: DatabaseSecurityMetadata = { + version: 1, + enabled: false, + cipher: 'sqlcipher', + passwordStorage: 'none' +} + +const VALIDATION_TABLES = [ + 'schema_versions', + 'new_sessions', + 'deepchat_sessions', + 'providers', + 'mcp_servers', + 'agents' +] + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T + +const sidecarPaths = (dbPath: string): string[] => [`${dbPath}-wal`, `${dbPath}-shm`] + +const MIGRATION_TARGET_SCHEMA = 'migration_target' +const activeMigrationDbPaths = new Set() + +type SqliteSchemaRow = { + type: 'table' | 'index' | 'trigger' | 'view' + name: string + sql: string +} + +const quoteIdentifier = (value: string): string => `"${value.replace(/"/g, '""')}"` +const getMigrationLockPath = (dbPath: string): string => path.resolve(dbPath) + +export class DatabaseSecurityPresenter { + private readonly store: ElectronStore<{ metadata: DatabaseSecurityMetadata }> + private readonly dbPath: string + private migrationInProgress = false + + constructor(options?: { dbPath?: string }) { + const dbDir = path.join(app.getPath('userData'), 'app_db') + this.dbPath = options?.dbPath ?? path.join(dbDir, 'agent.db') + this.store = new ElectronStore<{ metadata: DatabaseSecurityMetadata }>({ + name: 'database-security', + defaults: { + metadata: DEFAULT_METADATA + } + }) + this.recoverInterruptedMigrationFiles() + } + + getStatus(): DatabaseSecurityStatus { + const metadata = this.getMetadata() + const safeStorageAvailable = this.isSafeStorageAvailable() + return { + enabled: metadata.enabled, + cipher: 'sqlcipher', + safeStorageAvailable, + safeStorageBackend: this.getSafeStorageBackend(), + passwordStorage: metadata.passwordStorage, + manualUnlockRequired: + metadata.enabled && + (!safeStorageAvailable || + metadata.passwordStorage !== 'safeStorage' || + !metadata.wrappedPassword), + migrationInProgress: this.isMigrationInProgress(), + lastMigrationAt: metadata.lastMigrationAt + } + } + + async resolveStartupPassword(unlockProvider: UnlockProvider): Promise { + const metadata = this.getMetadata() + if (!metadata.enabled) { + return undefined + } + + const safeStorageAvailable = this.isSafeStorageAvailable() + let safeStorageUnlockFailed = false + if ( + safeStorageAvailable && + metadata.passwordStorage === 'safeStorage' && + metadata.wrappedPassword + ) { + try { + const password = this.unwrapPassword(metadata.wrappedPassword) + this.validatePassword(password) + return password + } catch { + safeStorageUnlockFailed = true + console.warn('[DatabaseSecurity] safeStorage unlock failed; manual unlock required.') + } + } + + let reason: UnlockRequest['reason'] = safeStorageUnlockFailed + ? 'system-key-missing' + : safeStorageAvailable + ? 'manual-required' + : 'safe-storage-unavailable' + + while (true) { + const password = await unlockProvider({ reason, safeStorageAvailable }) + if (password === null) { + app.quit() + throw new Error('Database unlock canceled') + } + + try { + this.validatePassword(password) + if (safeStorageAvailable) { + this.persistMetadata({ + ...metadata, + passwordStorage: 'safeStorage', + wrappedPassword: this.wrapPassword(password), + safeStorageBackend: this.getSafeStorageBackend() + }) + } + return password + } catch { + reason = 'invalid' + } + } + } + + async enableEncryption(input: { + password: string + sqlitePresenter: SQLitePresenter + configPresenter: IConfigPresenter + }): Promise { + this.assertPassword(input.password) + const metadata = this.getMetadata() + if (metadata.enabled) { + throw new Error('Database encryption is already enabled') + } + + this.cleanupLegacyProviderJson(input.configPresenter) + await this.migrateDatabase({ + sqlitePresenter: input.sqlitePresenter, + configPresenter: input.configPresenter, + sourcePassword: undefined, + targetPassword: input.password, + direction: 'enable' + }) + this.persistUnlockedMetadata(input.password, 'enable') + return this.getStatus() + } + + async changePassword(input: { + currentPassword: string + newPassword: string + sqlitePresenter: SQLitePresenter + configPresenter: IConfigPresenter + }): Promise { + this.assertEnabled() + this.assertPassword(input.currentPassword) + this.assertPassword(input.newPassword) + if (input.currentPassword === input.newPassword) { + throw new Error('New SQLite password must differ from the current password') + } + this.validatePassword(input.currentPassword) + + this.cleanupLegacyProviderJson(input.configPresenter) + await this.migrateDatabase({ + sqlitePresenter: input.sqlitePresenter, + configPresenter: input.configPresenter, + sourcePassword: input.currentPassword, + targetPassword: input.newPassword, + direction: 'change-password' + }) + this.persistUnlockedMetadata(input.newPassword, 'change-password') + return this.getStatus() + } + + async disableEncryption(input: { + currentPassword: string + sqlitePresenter: SQLitePresenter + configPresenter: IConfigPresenter + }): Promise { + this.assertEnabled() + this.assertPassword(input.currentPassword) + this.validatePassword(input.currentPassword) + + await this.migrateDatabase({ + sqlitePresenter: input.sqlitePresenter, + configPresenter: input.configPresenter, + sourcePassword: input.currentPassword, + targetPassword: undefined, + direction: 'disable' + }) + this.persistMetadata({ + ...DEFAULT_METADATA, + lastMigrationAt: Date.now(), + lastMigrationDirection: 'disable' + }) + return this.getStatus() + } + + validatePassword(password: string): void { + const db = openSQLiteDatabase(this.dbPath, password) + try { + db.prepare('SELECT name FROM sqlite_master LIMIT 1').get() + } finally { + db.close() + } + } + + private async migrateDatabase(input: { + sqlitePresenter: SQLitePresenter + configPresenter: IConfigPresenter + sourcePassword: string | undefined + targetPassword: string | undefined + direction: MigrationDirection + }): Promise { + const dbPath = input.sqlitePresenter.getDatabasePath() + this.acquireMigrationLock(dbPath) + this.migrationInProgress = true + const tempPath = this.getTempPath(dbPath) + const rollbackPath = this.getRollbackPath(dbPath) + + try { + this.removeIfExists(tempPath) + this.removeSidecars(tempPath) + this.removeIfExists(rollbackPath) + this.removeSidecars(rollbackPath) + this.checkpointAndClose(input.sqlitePresenter) + + const expectedCounts = this.collectValidationCounts(dbPath, input.sourcePassword) + this.exportDatabaseToTemp(dbPath, tempPath, input.sourcePassword, input.targetPassword) + this.verifyMigratedDatabase(tempPath, input.targetPassword, expectedCounts) + + this.replaceDatabaseWithRollback(dbPath, tempPath, rollbackPath) + + try { + input.sqlitePresenter.reopenWithPassword(input.targetPassword) + ;( + input.configPresenter as IConfigPresenter & { + setSQLitePresenter?: (sqlitePresenter: SQLitePresenter) => void + } + ).setSQLitePresenter?.(input.sqlitePresenter) + } catch (error) { + input.sqlitePresenter.close() + this.restoreRollbackDatabase(dbPath, rollbackPath) + input.sqlitePresenter.reopenWithPassword(input.sourcePassword) + throw error + } + + this.removeIfExists(rollbackPath) + this.removeSidecars(rollbackPath) + } catch (error) { + this.removeIfExists(tempPath) + this.removeSidecars(tempPath) + if (!fs.existsSync(dbPath) && fs.existsSync(rollbackPath)) { + fs.renameSync(rollbackPath, dbPath) + } + if (!input.sqlitePresenter.getDatabase().open) { + try { + input.sqlitePresenter.reopenWithPassword(input.sourcePassword) + } catch (reopenError) { + console.error('[DatabaseSecurity] Failed to reopen original database:', reopenError) + } + } + throw error + } finally { + this.migrationInProgress = false + this.releaseMigrationLock(dbPath) + } + } + + private checkpointAndClose(sqlitePresenter: SQLitePresenter): void { + const db = sqlitePresenter.getDatabase() + if (db.open) { + db.pragma('wal_checkpoint(TRUNCATE)') + } + sqlitePresenter.close() + } + + private exportDatabaseToTemp( + sourcePath: string, + tempPath: string, + sourcePassword: string | undefined, + targetPassword: string | undefined + ): void { + const sourceDb = openSQLiteDatabase(sourcePath, sourcePassword) + try { + sourceDb.pragma('wal_checkpoint(TRUNCATE)') + sourceDb.pragma('journal_mode = DELETE') + this.attachMigrationTarget(sourceDb, tempPath, sourcePassword, targetPassword) + this.copySchemaAndData(sourceDb) + sourceDb.prepare(`DETACH DATABASE ${MIGRATION_TARGET_SCHEMA}`).run() + } finally { + sourceDb.close() + } + } + + private attachMigrationTarget( + db: Database.Database, + tempPath: string, + sourcePassword: string | undefined, + targetPassword: string | undefined + ): void { + if (targetPassword) { + configureSQLCipherCompatibility(db) + db.prepare(`ATTACH DATABASE ? AS ${MIGRATION_TARGET_SCHEMA} KEY ?`).run( + tempPath, + targetPassword + ) + return + } + + if (sourcePassword) { + db.prepare(`ATTACH DATABASE ? AS ${MIGRATION_TARGET_SCHEMA} KEY ?`).run(tempPath, '') + return + } + + db.prepare(`ATTACH DATABASE ? AS ${MIGRATION_TARGET_SCHEMA}`).run(tempPath) + } + + private copySchemaAndData(db: Database.Database): void { + const tables = this.listMigratableTables(db) + db.exec('BEGIN') + try { + for (const table of tables) { + db.exec(this.qualifyCreateTableSql(table.sql)) + } + + for (const table of tables) { + const tableName = quoteIdentifier(table.name) + db.exec( + `INSERT INTO ${MIGRATION_TARGET_SCHEMA}.${tableName} + SELECT * FROM main.${tableName}` + ) + } + + this.copySqliteSequence(db) + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } + } + + private listMigratableTables(db: Database.Database): SqliteSchemaRow[] { + const rows = db + .prepare( + `SELECT type, name, sql FROM sqlite_master + WHERE type = 'table' + AND sql IS NOT NULL + AND name NOT LIKE 'sqlite_%' + ORDER BY name ASC` + ) + .all() as SqliteSchemaRow[] + const virtualTableNames = new Set( + rows.filter((row) => /^CREATE\s+VIRTUAL\s+TABLE\s+/i.test(row.sql)).map((row) => row.name) + ) + + return rows.filter((row) => { + for (const virtualTableName of virtualTableNames) { + if (row.name === virtualTableName || row.name.startsWith(`${virtualTableName}_`)) { + return false + } + } + return !/^CREATE\s+VIRTUAL\s+TABLE\s+/i.test(row.sql) + }) + } + + private qualifyCreateTableSql(sql: string): string { + return sql.replace( + /^CREATE\s+TABLE\s+(IF\s+NOT\s+EXISTS\s+)?/i, + (_match, ifNotExists: string | undefined) => + `CREATE TABLE ${ifNotExists ?? ''}${MIGRATION_TARGET_SCHEMA}.` + ) + } + + private copySqliteSequence(db: Database.Database): void { + const sourceSequence = db + .prepare("SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'") + .get() + const targetSequence = db + .prepare( + `SELECT 1 FROM ${MIGRATION_TARGET_SCHEMA}.sqlite_master + WHERE type = 'table' AND name = 'sqlite_sequence'` + ) + .get() + if (!sourceSequence || !targetSequence) { + return + } + + const rows = db.prepare('SELECT name, seq FROM main.sqlite_sequence').all() as Array<{ + name: string + seq: number + }> + db.exec(`DELETE FROM ${MIGRATION_TARGET_SCHEMA}.sqlite_sequence`) + const insert = db.prepare( + `INSERT INTO ${MIGRATION_TARGET_SCHEMA}.sqlite_sequence (name, seq) VALUES (?, ?)` + ) + for (const row of rows) { + insert.run(row.name, row.seq) + } + } + + private verifyMigratedDatabase( + tempPath: string, + password: string | undefined, + expectedCounts: Record + ): void { + const db = openSQLiteDatabase(tempPath, password) + try { + const quickCheck = db.pragma('quick_check') as Array> + const firstResult = Object.values(quickCheck[0] ?? {})[0] + if (firstResult !== 'ok') { + throw new Error('Migrated database failed PRAGMA quick_check') + } + + const actualCounts = this.collectValidationCountsFromOpenDb(db) + for (const [table, expected] of Object.entries(expectedCounts)) { + if (actualCounts[table] !== expected) { + throw new Error(`Migrated database row count mismatch for ${table}`) + } + } + } finally { + db.close() + } + } + + private collectValidationCounts( + dbPath: string, + password: string | undefined + ): Record { + const db = openSQLiteDatabase(dbPath, password) + try { + return this.collectValidationCountsFromOpenDb(db) + } finally { + db.close() + } + } + + private collectValidationCountsFromOpenDb(db: Database.Database): Record { + const counts: Record = {} + for (const table of VALIDATION_TABLES) { + const exists = db + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(table) as { 1: number } | undefined + if (!exists) { + continue + } + const row = db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as + | { count: number } + | undefined + counts[table] = row?.count ?? 0 + } + return counts + } + + private persistUnlockedMetadata(password: string, direction: MigrationDirection): void { + const safeStorageAvailable = this.isSafeStorageAvailable() + this.persistMetadata({ + version: 1, + enabled: true, + cipher: 'sqlcipher', + passwordStorage: safeStorageAvailable ? 'safeStorage' : 'manual', + wrappedPassword: safeStorageAvailable ? this.wrapPassword(password) : undefined, + safeStorageBackend: this.getSafeStorageBackend(), + lastMigrationAt: Date.now(), + lastMigrationDirection: direction + }) + } + + private getMetadata(): DatabaseSecurityMetadata { + const raw = this.store.get('metadata') + return { + ...DEFAULT_METADATA, + ...clone(raw ?? DEFAULT_METADATA), + cipher: 'sqlcipher', + version: 1 + } + } + + private persistMetadata(metadata: DatabaseSecurityMetadata): void { + this.store.set('metadata', metadata) + } + + private cleanupLegacyProviderJson(configPresenter: IConfigPresenter): void { + const cleanup = ( + configPresenter as IConfigPresenter & { + cleanupLegacyProviderJsonForDatabaseEncryption?: () => number + } + ).cleanupLegacyProviderJsonForDatabaseEncryption + cleanup?.call(configPresenter) + } + + private wrapPassword(password: string): string { + return Buffer.from(safeStorage.encryptString(password)).toString('base64') + } + + private unwrapPassword(wrappedPassword: string): string { + return safeStorage.decryptString(Buffer.from(wrappedPassword, 'base64')) + } + + private isSafeStorageAvailable(): boolean { + try { + return safeStorage.isEncryptionAvailable() + } catch { + return false + } + } + + private getSafeStorageBackend(): string | undefined { + try { + return process.platform === 'linux' ? safeStorage.getSelectedStorageBackend() : undefined + } catch { + return undefined + } + } + + private assertEnabled(): void { + if (!this.getMetadata().enabled) { + throw new Error('Database encryption is not enabled') + } + } + + private assertPassword(password: string): void { + if (!password.trim()) { + throw new Error('SQLite password is required') + } + } + + private getTempPath(dbPath = this.dbPath): string { + return `${dbPath}.migration-tmp` + } + + private getRollbackPath(dbPath = this.dbPath): string { + return `${dbPath}.migration-rollback` + } + + private isMigrationInProgress(dbPath?: string): boolean { + if (this.migrationInProgress) { + return true + } + if (!dbPath) { + return activeMigrationDbPaths.size > 0 + } + return activeMigrationDbPaths.has(getMigrationLockPath(dbPath)) + } + + private acquireMigrationLock(dbPath: string): void { + const lockPath = getMigrationLockPath(dbPath) + if (activeMigrationDbPaths.has(lockPath)) { + throw new Error('Database migration is already in progress') + } + activeMigrationDbPaths.add(lockPath) + } + + private releaseMigrationLock(dbPath: string): void { + activeMigrationDbPaths.delete(getMigrationLockPath(dbPath)) + } + + private replaceDatabaseWithRollback( + dbPath: string, + tempPath: string, + rollbackPath: string + ): void { + this.removeSidecars(dbPath) + const hasOriginal = fs.existsSync(dbPath) + if (hasOriginal) { + fs.renameSync(dbPath, rollbackPath) + } + try { + fs.renameSync(tempPath, dbPath) + } catch (error) { + if (hasOriginal && fs.existsSync(rollbackPath) && !fs.existsSync(dbPath)) { + fs.renameSync(rollbackPath, dbPath) + } + throw error + } + this.removeSidecars(dbPath) + } + + private restoreRollbackDatabase(dbPath: string, rollbackPath: string): void { + this.removeSidecars(dbPath) + this.removeIfExists(dbPath) + if (fs.existsSync(rollbackPath)) { + fs.renameSync(rollbackPath, dbPath) + } + this.removeSidecars(dbPath) + } + + private removeSidecars(dbPath: string): void { + for (const filePath of sidecarPaths(dbPath)) { + this.removeIfExists(filePath) + } + } + + private removeIfExists(filePath: string): void { + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }) + } + } + + private recoverInterruptedMigrationFiles(): void { + const tempPath = this.getTempPath() + const rollbackPath = this.getRollbackPath() + this.removeIfExists(tempPath) + if (!fs.existsSync(this.dbPath) && fs.existsSync(rollbackPath)) { + fs.renameSync(rollbackPath, this.dbPath) + return + } + if (fs.existsSync(this.dbPath) && fs.existsSync(rollbackPath)) { + this.removeIfExists(rollbackPath) + } + } +} diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 5c3a65308..85fdd5ad9 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -72,6 +72,7 @@ import type { RemoteControlPresenterLike } from './remoteControlPresenter/interf import { PluginPresenter } from './pluginPresenter' import { AgentRepository } from './agentRepository' import type { SQLitePresenter } from './sqlitePresenter' +import { DatabaseSecurityPresenter } from './databaseSecurityPresenter' import { normalizeDeepChatSubagentSlots } from '@shared/lib/deepchatSubagents' import { subscribeDeepChatInternalSessionUpdates } from './agentRuntimePresenter/internalSessionEvents' import type { @@ -188,6 +189,7 @@ export class Presenter implements IPresenter { agentSessionPresenter: IAgentSessionPresenter projectPresenter: IProjectPresenter pluginPresenter: PluginPresenter + databaseSecurityPresenter: DatabaseSecurityPresenter hooksNotifications: HooksNotificationsService commandPermissionService: CommandPermissionService filePermissionService: FilePermissionService @@ -206,6 +208,9 @@ export class Presenter implements IPresenter { const context = lifecycleManager.getLifecycleContext() this.configPresenter = context.config as IConfigPresenter this.sqlitePresenter = context.database as ISQLitePresenter + this.databaseSecurityPresenter = + (context.databaseSecurity as DatabaseSecurityPresenter | undefined) ?? + new DatabaseSecurityPresenter() const agentRepository = new AgentRepository(this.sqlitePresenter as unknown as SQLitePresenter) ;( this.configPresenter as IConfigPresenter & { @@ -953,7 +958,8 @@ registerMainKernelRoutes(ipcMain, () => yoBrowserPresenter: presenter.yoBrowserPresenter, tabPresenter: presenter.tabPresenter, startupWorkloadCoordinator: presenter.startupWorkloadCoordinator, - pluginPresenter: presenter.pluginPresenter + pluginPresenter: presenter.pluginPresenter, + databaseSecurityPresenter: presenter.databaseSecurityPresenter })) : undefined ) diff --git a/src/main/presenter/lifecyclePresenter/SplashWindowManager.ts b/src/main/presenter/lifecyclePresenter/SplashWindowManager.ts index 0d4a87fc3..d2f2b0add 100644 --- a/src/main/presenter/lifecyclePresenter/SplashWindowManager.ts +++ b/src/main/presenter/lifecyclePresenter/SplashWindowManager.ts @@ -3,7 +3,7 @@ */ import path from 'path' -import { BrowserWindow, nativeImage } from 'electron' +import { BrowserWindow, ipcMain, nativeImage } from 'electron' import { eventBus } from '../../eventbus' import { LIFECYCLE_EVENTS, WINDOW_EVENTS } from '@/events' import { ISplashWindowManager } from '@shared/presenter' @@ -18,6 +18,15 @@ import { ProgressUpdatedEventData } from './types' import { releasePresenterCallErrorStateForWebContents } from '../presenterCallErrorHandler' +import { + DATABASE_UNLOCK_CANCEL_CHANNEL, + DATABASE_UNLOCK_PROGRESS_CHANNEL, + DATABASE_UNLOCK_REQUEST_CHANNEL, + DATABASE_UNLOCK_SUBMIT_CHANNEL, + type DatabaseUnlockProgressPayload, + type DatabaseUnlockRequestPayload, + type DatabaseUnlockReason +} from '@shared/contracts/databaseSecurity' type SplashActivityStatus = 'running' | 'completed' | 'failed' @@ -46,9 +55,19 @@ const SPLASH_SHOW_DELAY_MS = 200 export class SplashWindowManager implements ISplashWindowManager { private splashWindow: BrowserWindow | null = null private activities = new Map() + private unlockRequest: { + requestId: string + payload: DatabaseUnlockRequestPayload + resolve: (password: string | null) => void + } | null = null + private pendingUnlockProgress: DatabaseUnlockProgressPayload | null = null private splashReadyToShow = false + private splashDidFinishLoad = false private splashShowDelayElapsed = false private suppressSplashShow = false + private forceShowWhenLoaded = false + private splashLoadCanceled = false + private splashLoadPromise: Promise | null = null private splashShowDelayTimer: ReturnType | null = null private readonly onHookExecuted = (data: HookExecutedEventData) => { if (!this.isStartupPhase(data.phase)) { @@ -98,6 +117,7 @@ export class SplashWindowManager implements ISplashWindowManager { constructor() { this.setupLifecycleListeners() + this.setupDatabaseUnlockListeners() } /** @@ -109,8 +129,12 @@ export class SplashWindowManager implements ISplashWindowManager { } this.splashReadyToShow = false + this.splashDidFinishLoad = false this.splashShowDelayElapsed = false this.suppressSplashShow = false + this.forceShowWhenLoaded = false + this.splashLoadCanceled = false + this.splashLoadPromise = null this.clearSplashShowDelayTimer() eventBus.on(WINDOW_EVENTS.WINDOW_CREATED, this.onMainWindowCreated) @@ -123,8 +147,8 @@ export class SplashWindowManager implements ISplashWindowManager { try { this.splashWindow = new BrowserWindow({ - width: 400, - height: 300, + width: 420, + height: 340, icon: iconFile, resizable: false, movable: false, @@ -138,7 +162,7 @@ export class SplashWindowManager implements ISplashWindowManager { webPreferences: { nodeIntegration: false, contextIsolation: true, - preload: path.join(__dirname, '../preload/index.mjs'), + preload: path.join(__dirname, '../preload/splash.mjs'), sandbox: false, devTools: is.dev } @@ -155,20 +179,25 @@ export class SplashWindowManager implements ISplashWindowManager { }) this.splashWindow.webContents.on('did-finish-load', () => { - this.emitState() + this.markSplashLoaded() }) - // Load the splash HTML template - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - this.splashWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/splash/index.html') - } else { - this.splashWindow.loadFile(path.join(__dirname, '../renderer/splash/index.html')) - } + this.splashLoadPromise = this.loadSplashRenderer().catch((error) => { + if (!this.shouldContinueSplashLoad()) { + return + } + console.error('Failed to load splash window:', error) + this.markSplashLoaded() + }) // Handle window closed event6 this.splashWindow.on('closed', () => { this.clearSplashShowDelayTimer() this.splashWindow = null + this.splashDidFinishLoad = false + this.forceShowWhenLoaded = false + this.splashLoadCanceled = true + this.splashLoadPromise = null }) if (this.suppressSplashShow) { @@ -208,6 +237,37 @@ export class SplashWindowManager implements ISplashWindowManager { } as ProgressUpdatedEventData) } + showDatabaseUnlockProgress( + payload: DatabaseUnlockProgressPayload, + options: { skipDelay?: boolean } = {} + ): void { + this.pendingUnlockProgress = payload + if (payload.active) { + this.forceShowSplash({ skipDelay: options.skipDelay }) + } + this.emitDatabaseUnlockState() + } + + async requestDatabaseUnlock(payload: { + reason: DatabaseUnlockReason + safeStorageAvailable: boolean + }): Promise { + this.unlockRequest?.resolve(null) + + const requestId = `database-unlock-${Date.now()}-${Math.random().toString(36).slice(2)}` + const requestPayload: DatabaseUnlockRequestPayload = { + requestId, + reason: payload.reason, + safeStorageAvailable: payload.safeStorageAvailable + } + + return await new Promise((resolve) => { + this.unlockRequest = { requestId, payload: requestPayload, resolve } + this.forceShowSplash({ skipDelay: true }) + this.emitDatabaseUnlockState() + }) + } + /** * Close the splash window */ @@ -219,6 +279,12 @@ export class SplashWindowManager implements ISplashWindowManager { eventBus.off(WINDOW_EVENTS.WINDOW_CREATED, this.onMainWindowCreated) this.activities.clear() + this.unlockRequest?.resolve(null) + this.unlockRequest = null + this.pendingUnlockProgress = null + this.forceShowWhenLoaded = false + this.splashLoadCanceled = true + this.splashLoadPromise = null this.emitState() this.clearSplashShowDelayTimer() @@ -257,6 +323,47 @@ export class SplashWindowManager implements ISplashWindowManager { eventBus.on(LIFECYCLE_EVENTS.ERROR_OCCURRED, this.onErrorOccurred) } + private setupDatabaseUnlockListeners(): void { + ipcMain.on(DATABASE_UNLOCK_SUBMIT_CHANNEL, (event, payload: unknown) => { + if (!this.isSplashSender(event.sender.id) || !this.unlockRequest) { + return + } + if (!payload || typeof payload !== 'object') { + return + } + const requestId = (payload as { requestId?: unknown }).requestId + const password = (payload as { password?: unknown }).password + if (requestId !== this.unlockRequest.requestId || typeof password !== 'string') { + return + } + + const current = this.unlockRequest + this.unlockRequest = null + current.resolve(password) + }) + + ipcMain.on(DATABASE_UNLOCK_CANCEL_CHANNEL, (event, payload: unknown) => { + if (!this.isSplashSender(event.sender.id) || !this.unlockRequest) { + return + } + const requestId = + payload && typeof payload === 'object' + ? (payload as { requestId?: unknown }).requestId + : undefined + if (requestId !== this.unlockRequest.requestId) { + return + } + + const current = this.unlockRequest + this.unlockRequest = null + current.resolve(null) + }) + } + + private isSplashSender(webContentsId: number): boolean { + return this.splashWindow?.webContents.id === webContentsId + } + private isStartupPhase(phase: LifecyclePhase | null): phase is LifecyclePhase { return phase !== null && phase !== LifecyclePhase.BEFORE_QUIT } @@ -305,6 +412,26 @@ export class SplashWindowManager implements ISplashWindowManager { this.splashWindow.webContents.send('splash-update', payload) } + private emitDatabaseUnlockState(): void { + if (!this.splashWindow || this.splashWindow.isDestroyed() || !this.splashDidFinishLoad) { + return + } + + if (this.pendingUnlockProgress) { + this.splashWindow.webContents.send( + DATABASE_UNLOCK_PROGRESS_CHANNEL, + this.pendingUnlockProgress + ) + } + + if (this.unlockRequest) { + this.splashWindow.webContents.send( + DATABASE_UNLOCK_REQUEST_CHANNEL, + this.unlockRequest.payload + ) + } + } + private maybeShowSplash(): void { if ( !this.splashWindow || @@ -316,7 +443,269 @@ export class SplashWindowManager implements ISplashWindowManager { return } + this.showSplashWindow() + } + + private forceShowSplash(options: { skipDelay?: boolean } = {}): void { + if (!this.splashWindow || this.splashWindow.isDestroyed()) { + return + } + this.suppressSplashShow = false + this.splashShowDelayElapsed = true + if (options.skipDelay) { + this.clearSplashShowDelayTimer() + this.forceShowWhenLoaded = true + if (this.splashDidFinishLoad || this.splashReadyToShow) { + this.showSplashWindow() + return + } + void this.splashLoadPromise?.finally(() => { + if (this.forceShowWhenLoaded) { + this.showSplashWindow() + } + }) + return + } + if (this.splashReadyToShow) { + this.showSplashWindow() + return + } + this.splashWindow.once('ready-to-show', () => { + if (!this.splashWindow?.isDestroyed()) { + this.showSplashWindow() + } + }) + } + + private showSplashWindow(): void { + if (!this.splashWindow || this.splashWindow.isDestroyed()) { + return + } this.splashWindow.show() + this.splashWindow.focus() + } + + private markSplashLoaded(): void { + if (this.splashDidFinishLoad) { + return + } + this.splashDidFinishLoad = true + this.emitState() + this.emitDatabaseUnlockState() + if (this.forceShowWhenLoaded) { + this.showSplashWindow() + } + } + + private async loadSplashRenderer(): Promise { + if (!this.splashWindow || this.splashWindow.isDestroyed()) { + return + } + + const rendererUrl = process.env['ELECTRON_RENDERER_URL'] + + if (is.dev && rendererUrl) { + const devUrls = [ + new URL('/splash/index.html', rendererUrl).toString(), + new URL('/splash/', rendererUrl).toString() + ] + for (const devUrl of devUrls) { + if (await this.tryLoadSplashUrl(devUrl, 'dev splash URL', { quiet: true })) { + return + } + if (!this.shouldContinueSplashLoad()) { + return + } + } + } + + if ( + await this.tryLoadSplashFile(path.join(__dirname, '../renderer/splash/index.html'), { + quiet: is.dev + }) + ) { + return + } + if (!this.shouldContinueSplashLoad()) { + return + } + + if ( + await this.tryLoadSplashUrl(this.buildInlineFallbackSplashUrl(), 'inline fallback splash') + ) { + return + } + + throw new Error('Unable to load any splash renderer') + } + + private shouldContinueSplashLoad(): boolean { + return Boolean( + this.splashWindow && + !this.splashWindow.isDestroyed() && + !this.splashLoadCanceled && + (!this.suppressSplashShow || this.forceShowWhenLoaded) + ) + } + + private async tryLoadSplashUrl( + url: string, + source: string, + options: { quiet?: boolean } = {} + ): Promise { + const splashWindow = this.splashWindow + if (!splashWindow || !this.shouldContinueSplashLoad()) { + return false + } + + try { + await splashWindow.loadURL(url) + if (!this.shouldContinueSplashLoad()) { + return false + } + this.markSplashLoaded() + return true + } catch (error) { + if (!this.shouldContinueSplashLoad()) { + return false + } + if (!options.quiet) { + console.warn(`[SplashWindow] Failed to load ${source} (${url}); falling back:`, error) + } + return false + } + } + + private async tryLoadSplashFile( + filePath: string, + options: { quiet?: boolean } = {} + ): Promise { + const splashWindow = this.splashWindow + if (!splashWindow || !this.shouldContinueSplashLoad()) { + return false + } + + try { + await splashWindow.loadFile(filePath) + if (!this.shouldContinueSplashLoad()) { + return false + } + this.markSplashLoaded() + return true + } catch (error) { + if (!this.shouldContinueSplashLoad()) { + return false + } + if (!options.quiet) { + console.warn( + `[SplashWindow] Failed to load splash file (${filePath}); falling back:`, + error + ) + } + return false + } + } + + private buildInlineFallbackSplashUrl(): string { + const progressChannel = JSON.stringify(DATABASE_UNLOCK_PROGRESS_CHANNEL) + const requestChannel = JSON.stringify(DATABASE_UNLOCK_REQUEST_CHANNEL) + const submitChannel = JSON.stringify(DATABASE_UNLOCK_SUBMIT_CHANNEL) + const cancelChannel = JSON.stringify(DATABASE_UNLOCK_CANCEL_CHANNEL) + const html = ` + + + + + DeepChat + + + +
+
+
DeepChat
+
Unlocking local database
+ + + + +

DeepChat is reading the saved password from the system credential store.

+
+
+ + +` + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}` } private clearSplashShowDelayTimer(): void { diff --git a/src/main/presenter/lifecyclePresenter/hooks/init/databaseInitHook.ts b/src/main/presenter/lifecyclePresenter/hooks/init/databaseInitHook.ts index f741abd04..15cf92275 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/init/databaseInitHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/init/databaseInitHook.ts @@ -6,6 +6,7 @@ import { LifecycleHook, LifecycleContext } from '@shared/presenter' import { DatabaseInitializer } from '../../DatabaseInitializer' import { LifecyclePhase } from '@shared/lifecycle' +import { DatabaseSecurityPresenter } from '@/presenter/databaseSecurityPresenter' export const databaseInitHook: LifecycleHook = { name: 'database-initialization', @@ -16,8 +17,32 @@ export const databaseInitHook: LifecycleHook = { console.log('databaseInitHook: DatabaseInitHook: Starting database initialization') try { + const databaseSecurity = new DatabaseSecurityPresenter() + context.databaseSecurity = databaseSecurity + + const status = databaseSecurity.getStatus() + context.splashManager?.showDatabaseUnlockProgress?.( + { + active: status.enabled, + safeStorageAvailable: status.safeStorageAvailable + }, + { skipDelay: status.enabled } + ) + const password = await databaseSecurity.resolveStartupPassword(async (request) => { + return ( + (await context.splashManager?.requestDatabaseUnlock?.({ + reason: request.reason, + safeStorageAvailable: request.safeStorageAvailable + })) ?? null + ) + }) + context.splashManager?.showDatabaseUnlockProgress?.({ + active: false, + safeStorageAvailable: databaseSecurity.getStatus().safeStorageAvailable + }) + // Create database initializer - const dbInitializer = new DatabaseInitializer() + const dbInitializer = new DatabaseInitializer({ password }) // Initialize database const database = await dbInitializer.initialize() diff --git a/src/main/presenter/lifecyclePresenter/index.ts b/src/main/presenter/lifecyclePresenter/index.ts index 290eb6124..ac5fa0ede 100644 --- a/src/main/presenter/lifecyclePresenter/index.ts +++ b/src/main/presenter/lifecyclePresenter/index.ts @@ -56,7 +56,8 @@ export class LifecycleManager implements ILifecycleManager { // Initialize single lifecycle context instance this.lifecycleContext = { phase: LifecyclePhase.INIT, // Will be updated during execution - manager: this + manager: this, + splashManager: this.splashManager } // Set up shutdown interception diff --git a/src/main/presenter/sqlitePresenter/connectionConfig.ts b/src/main/presenter/sqlitePresenter/connectionConfig.ts new file mode 100644 index 000000000..15aa66c61 --- /dev/null +++ b/src/main/presenter/sqlitePresenter/connectionConfig.ts @@ -0,0 +1,21 @@ +import type Database from 'better-sqlite3-multiple-ciphers' + +export const SQLCIPHER_COMPATIBILITY_VERSION = 4 + +export function configureSQLCipherCompatibility(db: Database.Database): void { + db.pragma("cipher='sqlcipher'") + db.pragma(`legacy=${SQLCIPHER_COMPATIBILITY_VERSION}`) +} + +export function applySQLitePassword(db: Database.Database, password: string): void { + configureSQLCipherCompatibility(db) + db.key(Buffer.from(password, 'utf8')) +} + +export function configureSQLiteConnection(db: Database.Database, password?: string): void { + if (password) { + applySQLitePassword(db, password) + } + + db.pragma('journal_mode = WAL') +} diff --git a/src/main/presenter/sqlitePresenter/importData.ts b/src/main/presenter/sqlitePresenter/importData.ts index 3e129699d..19198aa64 100644 --- a/src/main/presenter/sqlitePresenter/importData.ts +++ b/src/main/presenter/sqlitePresenter/importData.ts @@ -1,4 +1,5 @@ import Database from 'better-sqlite3-multiple-ciphers' +import { configureSQLiteConnection } from './connectionConfig' export interface ImportSummary { tableCounts: Record @@ -24,28 +25,20 @@ export class DataImporter { targetPassword?: string ) { this.sourceDb = new Database(sourcePath) - this.sourceDb.pragma('journal_mode = WAL') - - if (sourcePassword) { - this.sourceDb.pragma("cipher='sqlcipher'") - const hex = Buffer.from(sourcePassword, 'utf8').toString('hex') - this.sourceDb.pragma(`key = "x'${hex}'"`) - } + this.configureConnection(this.sourceDb, sourcePassword) if (typeof targetDbOrPath === 'string') { this.targetDb = new Database(targetDbOrPath) - this.targetDb.pragma('journal_mode = WAL') - - if (targetPassword) { - this.targetDb.pragma("cipher='sqlcipher'") - const hex = Buffer.from(targetPassword, 'utf8').toString('hex') - this.targetDb.pragma(`key = "x'${hex}'"`) - } + this.configureConnection(this.targetDb, targetPassword) } else { this.targetDb = targetDbOrPath } } + private configureConnection(db: Database.Database, password?: string): void { + configureSQLiteConnection(db, password) + } + /** * 开始导入数据 */ diff --git a/src/main/presenter/sqlitePresenter/index.ts b/src/main/presenter/sqlitePresenter/index.ts index daff143be..b1705bace 100644 --- a/src/main/presenter/sqlitePresenter/index.ts +++ b/src/main/presenter/sqlitePresenter/index.ts @@ -38,6 +38,7 @@ import { NewSessionDisabledAgentToolsTable } from './tables/newSessionDisabledAg import { SettingsActivityTable } from './tables/settingsActivity' import { DatabaseRepairService, SchemaInspector } from './schemaRepair' import type { SettingsActivityInput, SettingsActivityRecord } from '@shared/contracts/routes' +import { configureSQLiteConnection } from './connectionConfig' const DESTRUCTIVE_DATABASE_ERROR_PATTERNS = [ /database disk image is malformed/i, @@ -70,20 +71,10 @@ function ensureDatabaseDirectory(dbPath: string): void { } } -function configureDatabaseConnection(db: Database.Database, password?: string): void { - if (password) { - db.pragma(`cipher='sqlcipher'`) - const hexPassword = Buffer.from(password, 'utf8').toString('hex') - db.pragma(`key = "x'${hexPassword}'"`) - } - - db.pragma('journal_mode = WAL') -} - export function openSQLiteDatabase(dbPath: string, password?: string): Database.Database { ensureDatabaseDirectory(dbPath) const db = new Database(dbPath) - configureDatabaseConnection(db, password) + configureSQLiteConnection(db, password) return db } @@ -262,6 +253,19 @@ export class SQLitePresenter implements ISQLitePresenter { return openSQLiteDatabase(dbPath, this.password) } + public getDatabasePath(): string { + return this.dbPath + } + + public getDatabasePassword(): string | undefined { + return this.password + } + + public reopenWithPassword(password?: string): void { + this.password = password + this.reopen() + } + public async diagnoseSchema(): Promise { return new SchemaInspector(this.db).diagnose() } diff --git a/src/main/presenter/sqlitePresenter/tables/configTables.ts b/src/main/presenter/sqlitePresenter/tables/configTables.ts index da343f930..3b5ece24d 100644 --- a/src/main/presenter/sqlitePresenter/tables/configTables.ts +++ b/src/main/presenter/sqlitePresenter/tables/configTables.ts @@ -43,6 +43,7 @@ type ModelConfigRow = { type SettingsRow = { key: string value_json: string + sensitive?: number updated_at: number } @@ -155,6 +156,13 @@ export class ConfigTables extends BaseTable { updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + sensitive INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS agent_mcp_selections ( agent_id TEXT NOT NULL, is_builtin INTEGER NOT NULL DEFAULT 0, @@ -174,11 +182,21 @@ export class ConfigTables extends BaseTable { if (version === 25) { return this.getCreateTableSQL() } + if (version === 26) { + return ` + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + sensitive INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ); + ` + } return null } getLatestVersion(): number { - return 25 + return 26 } hasConfigMigration(id = CONFIG_STORAGE_MIGRATION_ID): boolean { @@ -511,6 +529,39 @@ export class ConfigTables extends BaseTable { return this.listJsonSettings('agent_settings') } + getAppSetting(key: string): TValue | undefined { + return this.getJsonSetting('app_settings', key) + } + + setAppSetting(key: string, value: unknown, sensitive = true): void { + const timestamp = now() + this.db + .prepare( + `INSERT INTO app_settings (key, value_json, sensitive, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value_json = excluded.value_json, + sensitive = excluded.sensitive, + updated_at = excluded.updated_at` + ) + .run(key, stringifyJson(value), sensitive ? 1 : 0, timestamp) + } + + deleteAppSetting(key: string): void { + this.deleteJsonSetting('app_settings', key) + } + + hasAppSetting(key: string): boolean { + const row = this.db.prepare('SELECT 1 FROM app_settings WHERE key = ?').get(key) as + | { 1: number } + | undefined + return Boolean(row) + } + + listAppSettings(): Record { + return this.listJsonSettings('app_settings') + } + getAgentMcpSelections(agentId = SHARED_AGENT_MCP_SELECTION_ID, isBuiltin = false): string[] { const rows = this.db .prepare( @@ -670,7 +721,7 @@ export class ConfigTables extends BaseTable { } private getJsonSetting( - table: 'mcp_settings' | 'agent_settings', + table: 'mcp_settings' | 'agent_settings' | 'app_settings', key: string ): TValue | undefined { const row = this.db.prepare(`SELECT value_json FROM ${table} WHERE key = ?`).get(key) as @@ -679,7 +730,11 @@ export class ConfigTables extends BaseTable { return row ? parseJson(row.value_json, undefined) : undefined } - private setJsonSetting(table: 'mcp_settings' | 'agent_settings', key: string, value: unknown) { + private setJsonSetting( + table: 'mcp_settings' | 'agent_settings' | 'app_settings', + key: string, + value: unknown + ) { this.db .prepare( `INSERT INTO ${table} (key, value_json, updated_at) @@ -691,11 +746,16 @@ export class ConfigTables extends BaseTable { .run(key, stringifyJson(value), now()) } - private deleteJsonSetting(table: 'mcp_settings' | 'agent_settings', key: string) { + private deleteJsonSetting( + table: 'mcp_settings' | 'agent_settings' | 'app_settings', + key: string + ) { this.db.prepare(`DELETE FROM ${table} WHERE key = ?`).run(key) } - private listJsonSettings(table: 'mcp_settings' | 'agent_settings'): Record { + private listJsonSettings( + table: 'mcp_settings' | 'agent_settings' | 'app_settings' + ): Record { const rows = this.db.prepare(`SELECT key, value_json FROM ${table}`).all() as SettingsRow[] return Object.fromEntries(rows.map((row) => [row.key, parseJson(row.value_json, null)])) } diff --git a/src/main/presenter/syncPresenter/configImportService.ts b/src/main/presenter/syncPresenter/configImportService.ts index 3d57c44fc..5026a1360 100644 --- a/src/main/presenter/syncPresenter/configImportService.ts +++ b/src/main/presenter/syncPresenter/configImportService.ts @@ -14,6 +14,8 @@ export type SyncBackupManifest = { files?: string[] configStorage?: 'sqlite' | string configSchemaVersion?: number + databaseEncrypted?: boolean + databaseCipher?: 'sqlcipher' | string } export type SyncConfigImportMode = 'increment' | 'overwrite' @@ -41,6 +43,9 @@ type LegacyConfigPayload = { mcpServers: Record mcpSettings: Record agentSettings: Record + appSettings: Record + customPrompts: Array> + systemPrompts: Array> sharedAgentMcpSelections: string[] sections: { providers: boolean @@ -49,11 +54,16 @@ type LegacyConfigPayload = { modelConfigs: boolean mcp: boolean acp: boolean + sensitiveAppSettings: boolean + customPrompts: boolean + systemPrompts: boolean } } const LEGACY_CONFIG_PATHS = { appSettings: path.join('configs', 'app-settings.json'), + customPrompts: path.join('configs', 'custom_prompts.json'), + systemPrompts: path.join('configs', 'system_prompts.json'), mcpSettings: path.join('configs', 'mcp-settings.json'), modelConfig: path.join('configs', 'model-config.json'), acpAgents: path.join('configs', 'acp_agents.json'), @@ -61,6 +71,17 @@ const LEGACY_CONFIG_PATHS = { } const LEGACY_MCP_SETTING_EXCLUDE_KEYS = new Set(['mcpServers', 'defaultServer', 'defaultServers']) +const LEGACY_SENSITIVE_APP_SETTING_KEYS = [ + 'remoteControl', + 'mcprouterApiKey', + 'nowledgeMemConfig', + 'hooksNotifications' +] +const LEGACY_SENSITIVE_SQLITE_KEYS = [ + ...LEGACY_SENSITIVE_APP_SETTING_KEYS, + 'customPrompts', + 'systemPrompts' +] const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T @@ -138,6 +159,9 @@ export class SyncConfigImportService { mcpServers: {}, mcpSettings: {}, agentSettings: {}, + appSettings: {}, + customPrompts: [], + systemPrompts: [], sharedAgentMcpSelections: [], sections: { providers: false, @@ -145,7 +169,10 @@ export class SyncConfigImportService { modelStatuses: false, modelConfigs: false, mcp: false, - acp: false + acp: false, + sensitiveAppSettings: false, + customPrompts: false, + systemPrompts: false } } @@ -154,6 +181,7 @@ export class SyncConfigImportService { this.readLegacyModelConfig(extractionDir, payload) this.readLegacyMcpSettings(extractionDir, payload) this.readLegacyAcpAgents(extractionDir, payload) + this.readLegacyPromptStores(extractionDir, payload) return payload } @@ -178,6 +206,12 @@ export class SyncConfigImportService { const providerIds = this.collectProviderIds(payload, appSettings) for (const [key, value] of Object.entries(appSettings)) { + if (LEGACY_SENSITIVE_APP_SETTING_KEYS.includes(key) && value !== undefined) { + payload.sections.sensitiveAppSettings = true + payload.appSettings[key] = clone(value) + continue + } + if (key.startsWith('model_status_') && typeof value === 'boolean') { payload.sections.modelStatuses = true const parsed = this.parseModelStatusKey(key, providerIds) @@ -207,6 +241,24 @@ export class SyncConfigImportService { } } + private readLegacyPromptStores(extractionDir: string, payload: LegacyConfigPayload): void { + const customPrompts = this.readPromptStore( + path.join(extractionDir, LEGACY_CONFIG_PATHS.customPrompts) + ) + if (customPrompts) { + payload.sections.customPrompts = true + payload.customPrompts = customPrompts + } + + const systemPrompts = this.readPromptStore( + path.join(extractionDir, LEGACY_CONFIG_PATHS.systemPrompts) + ) + if (systemPrompts) { + payload.sections.systemPrompts = true + payload.systemPrompts = systemPrompts + } + } + private readLegacyProviderModelStores(extractionDir: string, payload: LegacyConfigPayload): void { const candidateDirs = [ path.join(extractionDir, LEGACY_CONFIG_PATHS.providerModelsDir), @@ -347,6 +399,15 @@ export class SyncConfigImportService { configTables.clearAgentSettings() configTables.clearAgentMcpSelections() } + if ( + payload.sections.sensitiveAppSettings || + payload.sections.customPrompts || + payload.sections.systemPrompts + ) { + for (const key of LEGACY_SENSITIVE_SQLITE_KEYS) { + configTables.deleteAppSetting(key) + } + } } if (payload.providers.length > 0) { @@ -419,6 +480,66 @@ export class SyncConfigImportService { configTables.setAgentMcpSelections(payload.sharedAgentMcpSelections) } } + + this.applySensitiveAppSettings(configTables, payload, overwrite) + } + + private applySensitiveAppSettings( + configTables: ConfigTables, + payload: LegacyConfigPayload, + overwrite: boolean + ): void { + for (const [key, value] of Object.entries(payload.appSettings)) { + if (overwrite || !configTables.hasAppSetting(key)) { + configTables.setAppSetting(key, value, true) + } + } + + if (payload.sections.customPrompts) { + this.mergeAppSettingArray(configTables, 'customPrompts', payload.customPrompts, overwrite) + } + if (payload.sections.systemPrompts) { + this.mergeAppSettingArray(configTables, 'systemPrompts', payload.systemPrompts, overwrite) + } + + if ( + Object.keys(payload.appSettings).length > 0 || + payload.sections.customPrompts || + payload.sections.systemPrompts + ) { + configTables.markConfigMigrationApplied('sensitive-config-sqlite-v1') + } + } + + private mergeAppSettingArray( + configTables: ConfigTables, + key: string, + incoming: Array>, + overwrite: boolean + ): void { + if (overwrite) { + configTables.setAppSetting(key, incoming, true) + return + } + + const existing = configTables.getAppSetting>>(key) || [] + const existingIds = new Set( + existing + .map((item) => item.id) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + ) + const merged = [...existing] + for (const item of incoming) { + const id = item.id + if (typeof id !== 'string' || existingIds.has(id)) { + continue + } + merged.push(item) + existingIds.add(id) + } + if (merged.length !== existing.length || !configTables.hasAppSetting(key)) { + configTables.setAppSetting(key, merged, true) + } } private mergeProviders(configTables: ConfigTables, payload: LegacyConfigPayload): void { @@ -580,11 +701,25 @@ export class SyncConfigImportService { Object.keys(payload.mcpServers).length > 0 || Object.keys(payload.mcpSettings).length > 0 || Object.keys(payload.agentSettings).length > 0 || + Object.keys(payload.appSettings).length > 0 || + payload.customPrompts.length > 0 || + payload.systemPrompts.length > 0 || payload.sharedAgentMcpSelections.length > 0 || Object.values(payload.sections).some(Boolean) ) } + private readPromptStore(filePath: string): Array> | null { + const store = this.readJsonFile<{ prompts?: unknown }>(filePath) + if (!store) { + return null + } + if (!Array.isArray(store.prompts)) { + return [] + } + return store.prompts.filter(isRecord).map(clone) + } + private readJsonFile(filePath: string): T | null { if (!fs.existsSync(filePath)) { return null diff --git a/src/main/presenter/syncPresenter/index.ts b/src/main/presenter/syncPresenter/index.ts index 7ff6dc2ff..a0194f26d 100644 --- a/src/main/presenter/syncPresenter/index.ts +++ b/src/main/presenter/syncPresenter/index.ts @@ -30,10 +30,23 @@ type BackupStatus = 'idle' | 'preparing' | 'collecting' | 'compressing' | 'final const BACKUP_PREFIX = 'backup-' const BACKUP_EXTENSION = '.zip' const BACKUP_FILE_NAME_REGEX = /^backup-\d+\.zip$/ -const MIGRATED_APP_SETTINGS_KEYS = new Set(['providers', 'providerOrder', 'providerTimestamps']) +const MIGRATED_APP_SETTINGS_KEYS = new Set([ + 'providers', + 'providerOrder', + 'providerTimestamps', + 'remoteControl', + 'mcprouterApiKey', + 'nowledgeMemConfig', + 'hooksNotifications', + 'knowledgeConfigs', + 'customPrompts', + 'systemPrompts' +]) const KNOWN_IMPORT_ERRORS = new Set([ 'sync.error.noValidBackup', - 'sync.error.unsupportedBackupVersion' + 'sync.error.unsupportedBackupVersion', + 'sync.error.encryptedBackupPasswordMissing', + 'sync.error.overwriteEncryptionMismatch' ]) const ZIP_PATHS = { @@ -208,6 +221,11 @@ export class SyncPresenter implements ISyncPresenter { const manifest = configImportService.readManifest(extractionDir) const backupVersion = this.resolveBackupVersion(manifest) const usesSqliteConfigStorage = backupVersion >= 2 && manifest?.configStorage === 'sqlite' + const activeDatabasePassword = this.getActiveDatabasePassword() + const backupDatabasePassword = this.resolveBackupDatabasePassword( + manifest, + activeDatabasePassword + ) const backupDbSource = this.resolveBackupDbSource(extractionDir) const backupAppSettingsPath = path.join(extractionDir, ZIP_PATHS.appSettings) @@ -220,6 +238,12 @@ export class SyncPresenter implements ISyncPresenter { if (usesSqliteConfigStorage && backupDbSource.type !== 'agent') { throw new Error('sync.error.noValidBackup') } + this.assertOverwriteEncryptionCompatible( + backupDbSource.type, + importMode, + manifest, + activeDatabasePassword + ) this.sqlitePresenter.close() sqliteClosed = true @@ -253,7 +277,7 @@ export class SyncPresenter implements ISyncPresenter { if (backupDbSource.type === 'agent') { if (importMode === ImportMode.OVERWRITE) { - const backupDb = new Database(backupDbSource.path, { readonly: true }) + const backupDb = this.openBackupDatabase(backupDbSource.path, backupDatabasePassword) importedConversationCount = this.countTableRows(backupDb, 'new_sessions') || this.countTableRows(backupDb, 'conversations') @@ -276,7 +300,12 @@ export class SyncPresenter implements ISyncPresenter { this.copyFile(backupSystemPromptsPath, this.SYSTEM_PROMPTS_PATH) } } else { - const importer = new DataImporter(backupDbSource.path, this.DB_PATH) + const importer = new DataImporter( + backupDbSource.path, + this.DB_PATH, + backupDatabasePassword, + activeDatabasePassword + ) const summary = await importer.importData() importer.close() importedConversationCount = @@ -409,6 +438,8 @@ export class SyncPresenter implements ISyncPresenter { createdAt: timestamp, configStorage: 'sqlite', configSchemaVersion: CURRENT_SYNC_CONFIG_SCHEMA_VERSION, + databaseEncrypted: Boolean(this.getActiveDatabasePassword()), + databaseCipher: this.getActiveDatabasePassword() ? 'sqlcipher' : undefined, files: Object.keys(files) } files[ZIP_PATHS.manifest] = new Uint8Array( @@ -503,6 +534,49 @@ export class SyncPresenter implements ISyncPresenter { ) } + private openBackupDatabase(dbPath: string, password: string | undefined): Database.Database { + const db = new Database(dbPath, { readonly: true }) + if (password) { + db.pragma("cipher='sqlcipher'") + db.key(Buffer.from(password, 'utf8')) + } + return db + } + + private getActiveDatabasePassword(): string | undefined { + return (this.sqlitePresenter as unknown as Partial).getDatabasePassword?.() + } + + private resolveBackupDatabasePassword( + manifest: SyncBackupManifest | null, + activeDatabasePassword: string | undefined + ): string | undefined { + if (!manifest?.databaseEncrypted) { + return undefined + } + if (!activeDatabasePassword) { + throw new Error('sync.error.encryptedBackupPasswordMissing') + } + return activeDatabasePassword + } + + private assertOverwriteEncryptionCompatible( + backupDbType: BackupDbSource['type'], + importMode: ImportMode, + manifest: SyncBackupManifest | null, + activeDatabasePassword: string | undefined + ): void { + if (backupDbType !== 'agent' || importMode !== ImportMode.OVERWRITE) { + return + } + + const backupDatabaseEncrypted = manifest?.databaseEncrypted === true + const activeDatabaseEncrypted = Boolean(activeDatabasePassword) + if (backupDatabaseEncrypted !== activeDatabaseEncrypted) { + throw new Error('sync.error.overwriteEncryptionMismatch') + } + } + private ensureSqliteConfigStorageReady(): void { const getConfigTables = () => (this.sqlitePresenter as unknown as Partial).configTables diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index 11d30e26f..4518f0be1 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -48,6 +48,10 @@ import { configSetSystemPromptsRoute, configUpdateCustomPromptRoute, configUpdateSystemPromptRoute, + databaseSecurityChangePasswordRoute, + databaseSecurityDisableRoute, + databaseSecurityEnableRoute, + databaseSecurityGetStatusRoute, dialogErrorRoute, dialogRespondRoute, deviceGetAppVersionRoute, @@ -237,6 +241,8 @@ import { createSettingsRouteHandler } from './settings/settingsHandler' import { SessionService } from './sessions/sessionService' import type { StartupWorkloadCoordinator } from '@/presenter/startupWorkloadCoordinator' import type { PluginPresenter } from '@/presenter/pluginPresenter' +import type { DatabaseSecurityPresenter } from '@/presenter/databaseSecurityPresenter' +import type { SQLitePresenter } from '@/presenter/sqlitePresenter' export type MainKernelRouteRuntime = { configPresenter: IConfigPresenter @@ -263,6 +269,7 @@ export type MainKernelRouteRuntime = { tabPresenter: ITabPresenter startupWorkloadCoordinator: StartupWorkloadCoordinator pluginPresenter: PluginPresenter + databaseSecurityPresenter: DatabaseSecurityPresenter } export function createMainKernelRouteRuntime(deps: { @@ -285,6 +292,7 @@ export function createMainKernelRouteRuntime(deps: { tabPresenter: ITabPresenter startupWorkloadCoordinator: StartupWorkloadCoordinator pluginPresenter: PluginPresenter + databaseSecurityPresenter: DatabaseSecurityPresenter }): MainKernelRouteRuntime { const scheduler = createNodeScheduler() const hotPathPorts = createPresenterHotPathPorts({ @@ -351,7 +359,8 @@ export function createMainKernelRouteRuntime(deps: { yoBrowserPresenter: deps.yoBrowserPresenter, tabPresenter: deps.tabPresenter, startupWorkloadCoordinator: deps.startupWorkloadCoordinator, - pluginPresenter: deps.pluginPresenter + pluginPresenter: deps.pluginPresenter, + databaseSecurityPresenter: deps.databaseSecurityPresenter } } @@ -393,6 +402,20 @@ function recordSettingsActivity( }) } +function getDatabaseSecuritySQLitePresenter(runtime: MainKernelRouteRuntime): SQLitePresenter { + const sqlitePresenter = runtime.sqlitePresenter as Partial + const requiredMethods: Array = [ + 'getDatabasePath', + 'getDatabase', + 'close', + 'reopenWithPassword' + ] + if (requiredMethods.some((method) => typeof sqlitePresenter[method] !== 'function')) { + throw new Error('SQLite presenter is required for database encryption') + } + return runtime.sqlitePresenter as unknown as SQLitePresenter +} + function recordSkillSettingsActivity( runtime: MainKernelRouteRuntime, action: SettingsActivityInput['action'], @@ -1425,6 +1448,83 @@ export async function dispatchDeepchatRoute( return settingsActivityListRoute.output.parse({ activities }) } + case databaseSecurityGetStatusRoute.name: { + databaseSecurityGetStatusRoute.input.parse(rawInput) + return databaseSecurityGetStatusRoute.output.parse({ + status: runtime.databaseSecurityPresenter.getStatus() + }) + } + + case databaseSecurityEnableRoute.name: { + const input = databaseSecurityEnableRoute.input.parse(rawInput) + const sqlitePresenter = getDatabaseSecuritySQLitePresenter(runtime) + const status = await runtime.databaseSecurityPresenter.enableEncryption({ + password: input.password, + sqlitePresenter, + configPresenter: runtime.configPresenter + }) + recordSettingsActivity(runtime, { + category: 'privacy', + action: 'enabled', + targetType: 'database-encryption', + targetId: 'agent.db', + targetLabel: 'SQLite database encryption', + routeName: 'settings-database', + summaryKey: 'settings.controlCenter.activity.settingUpdated', + summaryParams: { + key: 'databaseEncryption' + } + }) + return databaseSecurityEnableRoute.output.parse({ status }) + } + + case databaseSecurityChangePasswordRoute.name: { + const input = databaseSecurityChangePasswordRoute.input.parse(rawInput) + const sqlitePresenter = getDatabaseSecuritySQLitePresenter(runtime) + const status = await runtime.databaseSecurityPresenter.changePassword({ + currentPassword: input.currentPassword, + newPassword: input.newPassword, + sqlitePresenter, + configPresenter: runtime.configPresenter + }) + recordSettingsActivity(runtime, { + category: 'privacy', + action: 'updated', + targetType: 'database-encryption', + targetId: 'agent.db', + targetLabel: 'SQLite database encryption', + routeName: 'settings-database', + summaryKey: 'settings.controlCenter.activity.settingUpdated', + summaryParams: { + key: 'databaseEncryptionPassword' + } + }) + return databaseSecurityChangePasswordRoute.output.parse({ status }) + } + + case databaseSecurityDisableRoute.name: { + const input = databaseSecurityDisableRoute.input.parse(rawInput) + const sqlitePresenter = getDatabaseSecuritySQLitePresenter(runtime) + const status = await runtime.databaseSecurityPresenter.disableEncryption({ + currentPassword: input.currentPassword, + sqlitePresenter, + configPresenter: runtime.configPresenter + }) + recordSettingsActivity(runtime, { + category: 'privacy', + action: 'disabled', + targetType: 'database-encryption', + targetId: 'agent.db', + targetLabel: 'SQLite database encryption', + routeName: 'settings-database', + summaryKey: 'settings.controlCenter.activity.settingUpdated', + summaryParams: { + key: 'databaseEncryption' + } + }) + return databaseSecurityDisableRoute.output.parse({ status }) + } + case onboardingGetStateRoute.name: { onboardingGetStateRoute.input.parse(rawInput) const state = readGuidedOnboardingState(runtime.configPresenter) diff --git a/src/preload/splash-preload.ts b/src/preload/splash-preload.ts new file mode 100644 index 000000000..8189af064 --- /dev/null +++ b/src/preload/splash-preload.ts @@ -0,0 +1,9 @@ +import { webFrame } from 'electron' +import { exposeElectronAPI } from '@electron-toolkit/preload' + +exposeElectronAPI() + +window.addEventListener('DOMContentLoaded', () => { + webFrame.setVisualZoomLevelLimits(1, 1) + webFrame.setZoomFactor(1) +}) diff --git a/src/renderer/api/DatabaseSecurityClient.ts b/src/renderer/api/DatabaseSecurityClient.ts new file mode 100644 index 000000000..6c3993e2a --- /dev/null +++ b/src/renderer/api/DatabaseSecurityClient.ts @@ -0,0 +1,46 @@ +import type { DeepchatBridge } from '@shared/contracts/bridge' +import { + databaseSecurityChangePasswordRoute, + databaseSecurityDisableRoute, + databaseSecurityEnableRoute, + databaseSecurityGetStatusRoute, + type DatabaseSecurityStatus +} from '@shared/contracts/routes' +import { getDeepchatBridge } from './core' + +export function createDatabaseSecurityClient(bridge: DeepchatBridge = getDeepchatBridge()) { + async function getStatus(): Promise { + const result = await bridge.invoke(databaseSecurityGetStatusRoute.name, {}) + return result.status + } + + async function enable(password: string): Promise { + const result = await bridge.invoke(databaseSecurityEnableRoute.name, { password }) + return result.status + } + + async function changePassword( + currentPassword: string, + newPassword: string + ): Promise { + const result = await bridge.invoke(databaseSecurityChangePasswordRoute.name, { + currentPassword, + newPassword + }) + return result.status + } + + async function disable(currentPassword: string): Promise { + const result = await bridge.invoke(databaseSecurityDisableRoute.name, { currentPassword }) + return result.status + } + + return { + getStatus, + enable, + changePassword, + disable + } +} + +export type DatabaseSecurityClient = ReturnType diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index d897de5dc..1ae67a341 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -11,6 +11,7 @@ export * from './ProviderClient' export * from './ProjectClient' export * from './SessionClient' export * from './SettingsClient' +export * from './DatabaseSecurityClient' export * from './SkillClient' export * from './SyncClient' export * from './TabClient' diff --git a/src/renderer/settings/components/DataSettings.vue b/src/renderer/settings/components/DataSettings.vue index 4a40d5366..2c71a1dc7 100644 --- a/src/renderer/settings/components/DataSettings.vue +++ b/src/renderer/settings/components/DataSettings.vue @@ -169,6 +169,195 @@ +
+
+
+
+
+ +
+
+
+ {{ t('settings.data.databaseEncryption.title') }} +
+

+ {{ t('settings.data.databaseEncryption.description') }} +

+
+
+ + {{ databaseSecurityStatusLabel }} + +
+ +
+
+ {{ t('settings.data.databaseEncryption.cipher') }} + {{ databaseCipherLabel }} +
+
+ {{ t('settings.data.databaseEncryption.systemUnlock') }} + {{ systemUnlockLabel }} +
+
+ {{ t('settings.data.databaseEncryption.startupUnlock') }} + {{ startupUnlockLabel }} +
+
+ {{ t('settings.data.databaseEncryption.lastMigration') }} + {{ lastDatabaseMigrationLabel }} +
+
+ +

+ {{ t('settings.data.databaseEncryption.systemCredentialStore') }} +

+

+ {{ t('settings.data.databaseEncryption.safeStorageUnavailable') }} +

+ +
+ + + +
+ + + + + + + {{ databaseEncryptionDialogTitle }} + + + {{ databaseEncryptionDialogDescription }} + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +

+ {{ databasePasswordValidation }} +

+

+ {{ t('settings.data.databaseEncryption.safeStorageUnavailable') }} +

+ + + + + +
+
+
+
+
(null) +const databaseSecurityStatus = ref(null) +const isDatabaseSecurityStatusLoaded = ref(false) +const hasDatabaseSecurityStatusError = ref(false) +const isDatabaseSecurityBusy = ref(false) +const isDatabaseEncryptionDialogOpen = ref(false) +const databaseEncryptionAction = ref<'enable' | 'change' | 'disable'>('enable') +const databaseCurrentPassword = ref('') +const databaseNewPassword = ref('') +const databaseConfirmPassword = ref('') const isBackupActive = computed(() => isBackingUpRef.value) const isImporting = computed(() => isImportingRef.value) const isRepairActionDisabled = computed(() => { @@ -627,6 +828,162 @@ const isRepairActionDisabled = computed(() => { const isResetActionDisabled = computed(() => { return isResetting.value || isBackupActive.value || isImporting.value }) +const databasePasswordValidation = computed(() => { + if (databaseEncryptionAction.value === 'disable') { + return '' + } + if (!databaseNewPassword.value && !databaseConfirmPassword.value) { + return '' + } + if (databaseNewPassword.value !== databaseConfirmPassword.value) { + return t('settings.data.databaseEncryption.passwordMismatch') + } + return '' +}) +const isDatabaseSecurityActionDisabled = computed(() => { + return ( + !isDatabaseSecurityStatusLoaded.value || + hasDatabaseSecurityStatusError.value || + isDatabaseSecurityBusy.value || + isBackupActive.value || + isImporting.value || + Boolean(databaseSecurityStatus.value?.migrationInProgress) + ) +}) +const canEnableDatabaseEncryption = computed(() => { + return ( + !isDatabaseSecurityActionDisabled.value && + !databaseSecurityStatus.value?.enabled && + Boolean(databaseNewPassword.value) && + databaseNewPassword.value === databaseConfirmPassword.value + ) +}) +const canChangeDatabasePassword = computed(() => { + return ( + !isDatabaseSecurityActionDisabled.value && + Boolean(databaseSecurityStatus.value?.enabled) && + Boolean(databaseCurrentPassword.value) && + Boolean(databaseNewPassword.value) && + databaseNewPassword.value === databaseConfirmPassword.value + ) +}) +const canDisableDatabaseEncryption = computed(() => { + return ( + !isDatabaseSecurityActionDisabled.value && + Boolean(databaseSecurityStatus.value?.enabled) && + Boolean(databaseCurrentPassword.value) + ) +}) +const canSubmitDatabaseEncryptionDialog = computed(() => { + if (databaseEncryptionAction.value === 'enable') { + return canEnableDatabaseEncryption.value + } + if (databaseEncryptionAction.value === 'change') { + return canChangeDatabasePassword.value + } + return canDisableDatabaseEncryption.value +}) +const databaseEncryptionDialogIcon = computed(() => { + if (databaseEncryptionAction.value === 'enable') { + return 'lucide:shield-lock' + } + if (databaseEncryptionAction.value === 'change') { + return 'lucide:key-round' + } + return 'lucide:shield-off' +}) +const databaseEncryptionDialogTitle = computed(() => { + if (databaseEncryptionAction.value === 'enable') { + return t('settings.data.databaseEncryption.enableDialogTitle') + } + if (databaseEncryptionAction.value === 'change') { + return t('settings.data.databaseEncryption.changeDialogTitle') + } + return t('settings.data.databaseEncryption.disableDialogTitle') +}) +const databaseEncryptionDialogDescription = computed(() => { + if (databaseEncryptionAction.value === 'enable') { + return t('settings.data.databaseEncryption.enableDialogDescription') + } + if (databaseEncryptionAction.value === 'change') { + return t('settings.data.databaseEncryption.changeDialogDescription') + } + return t('settings.data.databaseEncryption.disableDialogDescription') +}) +const databaseEncryptionSubmitLabel = computed(() => { + if (databaseEncryptionAction.value === 'enable') { + return t('settings.data.databaseEncryption.enableButton') + } + if (databaseEncryptionAction.value === 'change') { + return t('settings.data.databaseEncryption.changeButton') + } + return t('settings.data.databaseEncryption.disableButton') +}) +const databaseSecurityUnknownLabel = computed(() => t('settings.data.databaseEncryption.unknown')) +const databaseSecurityLoadingLabel = computed(() => t('settings.data.databaseEncryption.loading')) +const databaseSecurityHasNoStatus = computed(() => !databaseSecurityStatus.value) +const databaseSecurityStatusLabel = computed(() => { + const status = databaseSecurityStatus.value + if (hasDatabaseSecurityStatusError.value && !status) { + return databaseSecurityUnknownLabel.value + } + if (!status) { + return databaseSecurityLoadingLabel.value + } + return status.enabled + ? t('settings.data.databaseEncryption.enabled') + : t('settings.data.databaseEncryption.disabled') +}) +const databaseCipherLabel = computed(() => { + const status = databaseSecurityStatus.value + if (hasDatabaseSecurityStatusError.value && !status) { + return databaseSecurityUnknownLabel.value + } + if (!status) { + return databaseSecurityLoadingLabel.value + } + return status.cipher +}) +const systemUnlockLabel = computed(() => { + const status = databaseSecurityStatus.value + if (hasDatabaseSecurityStatusError.value && !status) { + return databaseSecurityUnknownLabel.value + } + if (!status) { + return databaseSecurityLoadingLabel.value + } + return status.safeStorageAvailable + ? t('settings.data.databaseEncryption.systemUnlockAvailable') + : t('settings.data.databaseEncryption.systemUnlockUnavailable') +}) +const startupUnlockLabel = computed(() => { + const status = databaseSecurityStatus.value + if (hasDatabaseSecurityStatusError.value && !status) { + return databaseSecurityUnknownLabel.value + } + if (!status) { + return databaseSecurityLoadingLabel.value + } + if (!status.enabled) { + return t('settings.data.databaseEncryption.notRequired') + } + return status.manualUnlockRequired + ? t('settings.data.databaseEncryption.manualUnlock') + : t('settings.data.databaseEncryption.systemUnlockMode') +}) +const lastDatabaseMigrationLabel = computed(() => { + if (hasDatabaseSecurityStatusError.value && databaseSecurityHasNoStatus.value) { + return databaseSecurityUnknownLabel.value + } + if (databaseSecurityHasNoStatus.value) { + return databaseSecurityLoadingLabel.value + } + const lastMigrationAt = databaseSecurityStatus.value?.lastMigrationAt + if (!lastMigrationAt) { + return t('settings.data.never') + } + return new Date(lastMigrationAt).toLocaleString() +}) const syncEnabled = computed({ get: () => syncStore.syncEnabled, @@ -642,6 +999,124 @@ const handleSyncEnabledChange = (value: boolean) => { syncEnabled.value = value } +const clearDatabasePasswordFields = () => { + databaseCurrentPassword.value = '' + databaseNewPassword.value = '' + databaseConfirmPassword.value = '' +} + +const openDatabaseEncryptionDialog = (action: 'enable' | 'change' | 'disable') => { + if (isDatabaseSecurityActionDisabled.value) { + return + } + databaseEncryptionAction.value = action + clearDatabasePasswordFields() + isDatabaseEncryptionDialogOpen.value = true +} + +const closeDatabaseEncryptionDialog = () => { + if (isDatabaseSecurityBusy.value) { + return + } + isDatabaseEncryptionDialogOpen.value = false + clearDatabasePasswordFields() +} + +const refreshDatabaseSecurityStatus = async () => { + hasDatabaseSecurityStatusError.value = false + try { + databaseSecurityStatus.value = await databaseSecurityClient.getStatus() + isDatabaseSecurityStatusLoaded.value = true + } catch (error) { + console.error('Failed to load database encryption status:', error) + isDatabaseSecurityStatusLoaded.value = Boolean(databaseSecurityStatus.value) + hasDatabaseSecurityStatusError.value = true + } +} + +const runDatabaseSecurityAction = async ( + action: () => Promise, + successTitleKey: string +) => { + if (isDatabaseSecurityBusy.value) { + return + } + isDatabaseSecurityBusy.value = true + try { + databaseSecurityStatus.value = await action() + isDatabaseSecurityStatusLoaded.value = true + hasDatabaseSecurityStatusError.value = false + clearDatabasePasswordFields() + isDatabaseEncryptionDialogOpen.value = false + toast({ + title: t(successTitleKey), + duration: 4000 + }) + } catch (error) { + console.error('Database encryption action failed:', error) + toast({ + title: t('settings.data.databaseEncryption.failedTitle'), + description: + error instanceof Error + ? error.message + : t('settings.data.databaseEncryption.failedDescription'), + variant: 'destructive', + duration: 5000 + }) + } finally { + isDatabaseSecurityBusy.value = false + } +} + +const enableDatabaseEncryption = async () => { + if (!canEnableDatabaseEncryption.value) { + return + } + await runDatabaseSecurityAction( + () => databaseSecurityClient.enable(databaseNewPassword.value), + 'settings.data.databaseEncryption.enabledTitle' + ) +} + +const changeDatabasePassword = async () => { + if (!canChangeDatabasePassword.value) { + return + } + await runDatabaseSecurityAction( + () => + databaseSecurityClient.changePassword( + databaseCurrentPassword.value, + databaseNewPassword.value + ), + 'settings.data.databaseEncryption.changedTitle' + ) +} + +const disableDatabaseEncryption = async () => { + if (!canDisableDatabaseEncryption.value) { + return + } + await runDatabaseSecurityAction( + () => databaseSecurityClient.disable(databaseCurrentPassword.value), + 'settings.data.databaseEncryption.disabledTitle' + ) +} + +const submitDatabaseEncryptionDialog = async () => { + if (!canSubmitDatabaseEncryptionDialog.value) { + return + } + if (databaseEncryptionAction.value === 'enable') { + await enableDatabaseEncryption() + return + } + if (databaseEncryptionAction.value === 'change') { + await changeDatabasePassword() + return + } + await disableDatabaseEncryption() +} + const repairSummaryText = computed(() => { const report = lastRepairReport.value if (!report) { @@ -819,6 +1294,7 @@ const handleSettingsSectionNavigation = (event: Event) => { onMounted(async () => { await syncStore.initialize() + await refreshDatabaseSecurityStatus() window.addEventListener(SETTINGS_SECTION_EVENT, handleSettingsSectionNavigation as EventListener) if (consumePendingSection(PROVIDER_IMPORT_SECTION)) { diff --git a/src/renderer/settings/lib/guidedOnboardingSettings.ts b/src/renderer/settings/lib/guidedOnboardingSettings.ts index 312874946..a2d13129f 100644 --- a/src/renderer/settings/lib/guidedOnboardingSettings.ts +++ b/src/renderer/settings/lib/guidedOnboardingSettings.ts @@ -1,5 +1,6 @@ import type { GuidedOnboardingState, GuidedOnboardingStepId } from '@shared/contracts/routes' import { resolveGuidedOnboardingStepTarget } from '@shared/guidedOnboarding' +import { createOnboardingClient } from '@api/OnboardingClient' import { persistGuidedOnboardingResumeIntent } from '@/lib/onboardingResume' import type { Router } from 'vue-router' @@ -28,8 +29,23 @@ export async function continueGuidedOnboardingFromSettings(options: { focusMainWindow?: () => Promise | boolean } }) { - const { state, router, currentRoute, windowPresenter } = options - const stepId = resolveGuidedOnboardingResumeStepId(state) + const { router, currentRoute, windowPresenter } = options + let { state } = options + let stepId = resolveGuidedOnboardingResumeStepId(state) + + // If the caller passed a stale/null state, the local handler likely failed + // its IPC call (or never received a response). Re-read from the backend so a + // transient renderer hiccup cannot force the helper into the fallback branch + // that focuses the main window instead of advancing within settings. + if (!stepId) { + try { + state = await createOnboardingClient().getState() + stepId = resolveGuidedOnboardingResumeStepId(state) + } catch (error) { + console.warn('[GuidedOnboarding] Failed to refresh state from backend:', error) + } + } + const target = resolveGuidedOnboardingStepTarget(stepId) if (target?.surface === 'settings' && target.routeName) { diff --git a/src/renderer/splash/loading.vue b/src/renderer/splash/loading.vue index d9a38d702..ac4fe0765 100644 --- a/src/renderer/splash/loading.vue +++ b/src/renderer/splash/loading.vue @@ -1,6 +1,46 @@