fix: finalize Electron migration boundaries

Complete the Electron replacement boundary before merging by removing the renderer-side Tauri host fallback, tightening H5/browser access so only desktop navigation is tokenless, and moving desktop release publication to a tag-driven GitHub Actions matrix with a single final publish job.

Constraint: H5/browser capability access must not gain tokenless access through localhost or retired Tauri origins

Constraint: Desktop release artifacts must be built by GitHub Actions from version tags, not treated as local build outputs

Rejected: Keep localhost browser origins trusted for convenience | local browser contexts can access loopback services and must use the H5 token path

Rejected: Publish from each matrix job | partial releases can be created before all platforms finish

Confidence: high

Scope-risk: broad

Directive: Do not reintroduce Tauri origins or localhost browser origins into the trusted desktop origin set without a reviewed security design

Tested: bun test src/server/__tests__/h5-access-policy.test.ts src/server/__tests__/h5-access-auth.test.ts src/server/__tests__/diagnostics-service.test.ts src/server/middleware/cors.test.ts

Tested: bun test scripts/pr/release-workflow.test.ts scripts/release-update-metadata.test.ts

Tested: bun run check:desktop

Tested: bun run check:native

Tested: git diff --check

Not-tested: bun run check:server is blocked by expired quarantine entries server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-02 22:42:53 +08:00
parent 16f4137954
commit 81845fbc49
36 changed files with 859 additions and 606 deletions

View File

@ -134,22 +134,6 @@ jobs:
exit 1
fi
- name: Load release notes
id: release_notes
shell: bash
run: |
NOTES_FILE="release-notes/v${{ steps.version.outputs.value }}.md"
if [ ! -f "$NOTES_FILE" ]; then
echo "::error::Missing release notes file: $NOTES_FILE"
exit 1
fi
{
echo 'body<<__RELEASE_NOTES__'
cat "$NOTES_FILE"
echo
echo '__RELEASE_NOTES__'
} >> "$GITHUB_OUTPUT"
- name: Install Linux dependencies
if: contains(matrix.platform, 'ubuntu')
run: |
@ -210,6 +194,69 @@ jobs:
if: matrix.smoke_platform == 'macos'
run: bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/electron --require-macos-gatekeeper
- name: Validate matrix release asset set
shell: bash
working-directory: desktop/build-artifacts/electron
env:
APP_VERSION: ${{ steps.version.outputs.value }}
run: |
case "${{ matrix.label }}" in
macOS-ARM64)
expected=(
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.dmg"
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.dmg.blockmap"
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.zip"
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.zip.blockmap"
"latest-mac.yml"
)
;;
macOS-x64)
expected=(
"Claude-Code-Haha-${APP_VERSION}-mac-x64.dmg"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.dmg.blockmap"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.zip"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.zip.blockmap"
"latest-mac.yml"
)
;;
Linux-x64)
expected=(
"Claude-Code-Haha-${APP_VERSION}-linux-x64.AppImage"
"Claude-Code-Haha-${APP_VERSION}-linux-x64.AppImage.blockmap"
"Claude-Code-Haha-${APP_VERSION}-linux-x64.deb"
"latest-linux.yml"
)
;;
Linux-ARM64)
expected=(
"Claude-Code-Haha-${APP_VERSION}-linux-arm64.AppImage"
"Claude-Code-Haha-${APP_VERSION}-linux-arm64.AppImage.blockmap"
"Claude-Code-Haha-${APP_VERSION}-linux-arm64.deb"
"latest-linux.yml"
)
;;
Windows-x64)
expected=(
"Claude-Code-Haha-${APP_VERSION}-win-x64.exe"
"Claude-Code-Haha-${APP_VERSION}-win-x64.exe.blockmap"
"latest.yml"
)
;;
*)
echo "::error::No expected asset list for matrix label ${{ matrix.label }}"
exit 1
;;
esac
missing=()
for file in "${expected[@]}"; do
[ -f "$file" ] || missing+=("$file")
done
if [ "${#missing[@]}" -gt 0 ]; then
printf '::error::Missing release assets for %s: %s\n' "${{ matrix.label }}" "${missing[*]}"
exit 1
fi
- name: Namespace update metadata assets
shell: bash
working-directory: desktop/build-artifacts/electron
@ -226,15 +273,11 @@ jobs:
path: desktop/build-artifacts/electron/latest*.yml
if-no-files-found: ignore
- name: Upload artifacts
uses: softprops/action-gh-release@v2
- name: Upload release artifacts for final publish
uses: actions/upload-artifact@v4
with:
tag_name: v${{ steps.version.outputs.value }}
name: Claude Code Haha v${{ steps.version.outputs.value }}
body: ${{ steps.release_notes.outputs.body }}
draft: ${{ github.event_name == 'workflow_dispatch' && inputs.draft || false }}
prerelease: false
files: |
name: desktop-release-artifacts-${{ matrix.label }}
path: |
desktop/build-artifacts/electron/*.dmg
desktop/build-artifacts/electron/*.zip
desktop/build-artifacts/electron/*.exe
@ -242,8 +285,9 @@ jobs:
desktop/build-artifacts/electron/*.deb
desktop/build-artifacts/electron/*.blockmap
desktop/build-artifacts/electron/*.yml
if-no-files-found: error
publish-update-metadata:
publish-release:
needs: build
runs-on: ubuntu-latest
@ -258,6 +302,32 @@ jobs:
VERSION=$(node -p "require('./desktop/package.json').version")
echo "value=$VERSION" >> "$GITHUB_OUTPUT"
- name: Validate tag matches version
if: github.event_name == 'push'
shell: bash
run: |
EXPECTED_TAG="v${{ steps.version.outputs.value }}"
if [ "${GITHUB_REF_NAME}" != "$EXPECTED_TAG" ]; then
echo "::error::Tag ${GITHUB_REF_NAME} does not match app version ${EXPECTED_TAG}"
exit 1
fi
- name: Load release notes
id: release_notes
shell: bash
run: |
NOTES_FILE="release-notes/v${{ steps.version.outputs.value }}.md"
if [ ! -f "$NOTES_FILE" ]; then
echo "::error::Missing release notes file: $NOTES_FILE"
exit 1
fi
{
echo 'body<<__RELEASE_NOTES__'
cat "$NOTES_FILE"
echo
echo '__RELEASE_NOTES__'
} >> "$GITHUB_OUTPUT"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
@ -266,6 +336,46 @@ jobs:
- name: Install root dependencies
run: bun install
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
pattern: desktop-release-artifacts-*
path: artifacts/release-assets
merge-multiple: true
- name: Validate complete release asset set
shell: bash
env:
APP_VERSION: ${{ steps.version.outputs.value }}
run: |
expected=(
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.dmg"
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.dmg.blockmap"
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.zip"
"Claude-Code-Haha-${APP_VERSION}-mac-arm64.zip.blockmap"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.dmg"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.dmg.blockmap"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.zip"
"Claude-Code-Haha-${APP_VERSION}-mac-x64.zip.blockmap"
"Claude-Code-Haha-${APP_VERSION}-linux-x64.AppImage"
"Claude-Code-Haha-${APP_VERSION}-linux-x64.AppImage.blockmap"
"Claude-Code-Haha-${APP_VERSION}-linux-x64.deb"
"Claude-Code-Haha-${APP_VERSION}-linux-arm64.AppImage"
"Claude-Code-Haha-${APP_VERSION}-linux-arm64.AppImage.blockmap"
"Claude-Code-Haha-${APP_VERSION}-linux-arm64.deb"
"Claude-Code-Haha-${APP_VERSION}-win-x64.exe"
"Claude-Code-Haha-${APP_VERSION}-win-x64.exe.blockmap"
)
missing=()
for file in "${expected[@]}"; do
[ -f "artifacts/release-assets/$file" ] || missing+=("$file")
done
if [ "${#missing[@]}" -gt 0 ]; then
printf '::error::Missing complete release assets: %s\n' "${missing[*]}"
exit 1
fi
- name: Download update metadata artifacts
uses: actions/download-artifact@v4
with:
@ -276,10 +386,38 @@ jobs:
- name: Merge standard update metadata
run: bun run scripts/release-update-metadata.ts --metadata-dir artifacts/update-metadata --out-dir artifacts/update-metadata-standard
- name: Upload standard update metadata
- name: Validate standard update metadata set
shell: bash
run: |
expected=(
"latest-mac.yml"
"latest-linux.yml"
"latest-linux-arm64.yml"
"latest.yml"
)
missing=()
for file in "${expected[@]}"; do
[ -f "artifacts/update-metadata-standard/$file" ] || missing+=("$file")
done
if [ "${#missing[@]}" -gt 0 ]; then
printf '::error::Missing standard update metadata: %s\n' "${missing[*]}"
exit 1
fi
- name: Publish complete GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.value }}
name: Claude Code Haha v${{ steps.version.outputs.value }}
body: ${{ steps.release_notes.outputs.body }}
draft: ${{ github.event_name == 'workflow_dispatch' && inputs.draft || false }}
prerelease: false
files: artifacts/update-metadata-standard/*.yml
fail_on_unmatched_files: true
files: |
artifacts/release-assets/**/*.dmg
artifacts/release-assets/**/*.zip
artifacts/release-assets/**/*.exe
artifacts/release-assets/**/*.AppImage
artifacts/release-assets/**/*.deb
artifacts/release-assets/**/*.blockmap
artifacts/update-metadata-standard/*.yml

View File

@ -44,15 +44,6 @@
"vite": "^8.0.10",
"vitest": "^3.0.4",
},
"optionalDependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/cli": "^2.3.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-notification": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.2.1",
},
},
},
"packages": {
@ -378,42 +369,6 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.2.2.tgz", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="],
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.10.1.tgz", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "https://registry.npmmirror.com/@tauri-apps/cli/-/cli-2.10.1.tgz", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.10.1", "https://registry.npmmirror.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ=="],
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.10.1", "https://registry.npmmirror.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw=="],
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.10.1", "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w=="],
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.10.1", "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA=="],
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.10.1", "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg=="],
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.10.1", "https://registry.npmmirror.com/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw=="],
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.10.1", "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw=="],
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.10.1", "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ=="],
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.10.1", "https://registry.npmmirror.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg=="],
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.10.1", "https://registry.npmmirror.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw=="],
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="],
"@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "https://registry.npmmirror.com/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="],
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "https://registry.npmmirror.com/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
"@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "https://registry.npmmirror.com/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="],
"@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "https://registry.npmmirror.com/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "https://registry.npmmirror.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],

View File

@ -13,7 +13,7 @@
"node_modules/node-pty/**",
"src-tauri/binaries/**"
],
"artifactName": "Claude-Code-Haha-${version}-${arch}.${ext}",
"artifactName": "Claude-Code-Haha-${version}-${os}-${arch}.${ext}",
"npmRebuild": false,
"directories": {
"output": "build-artifacts/electron"
@ -73,7 +73,6 @@
"electron:package:dir": "bun run clean:electron-output && bun run electron:build && bunx electron-builder --dir --publish never",
"check:electron": "node ./node_modules/typescript/bin/tsc -p electron/tsconfig.json && bun test electron && bun run build:electron",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest",
"test:ui": "vitest --ui",
"lint": "node ./node_modules/typescript/bin/tsc --noEmit"
@ -117,14 +116,5 @@
"typescript": "^5.9.3",
"vite": "^8.0.10",
"vitest": "^3.0.4"
},
"optionalDependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/cli": "^2.3.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-notification": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.2.1"
}
}

View File

@ -9,6 +9,7 @@ import { useUpdateStore } from '../stores/updateStore'
import type { SavedProvider } from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'
import type { AppMode, ChatSendBehavior, ThemeMode, UpdateProxySettings } from '../types/settings'
import { browserHost } from '../lib/desktopHost/browserHost'
const MOCK_DELETE_PROVIDER = vi.fn()
const MOCK_GET_SETTINGS = vi.fn()
@ -122,6 +123,39 @@ vi.mock('../components/chat/CodeViewer', () => ({
CodeViewer: ({ code }: { code: string }) => <pre data-testid="code-viewer">{code}</pre>,
}))
function installElectronDesktopHost() {
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
appMode: true,
dialogs: true,
notifications: true,
shell: true,
updates: true,
zoom: true,
},
app: {
getVersion: vi.fn().mockResolvedValue('0.3.2'),
},
dialogs: {
...browserHost.dialogs,
open: vi.fn((options) => tauriDialogMock.open(options)),
},
shell: {
...browserHost.shell,
open: vi.fn().mockResolvedValue(undefined),
},
appMode: {
...browserHost.appMode,
prepareRestart: vi.fn(() => tauriCoreMock.invoke('prepare_for_app_mode_restart')),
restart: vi.fn(() => tauriProcessMock.relaunch()),
},
}
}
describe('Settings > General tab', () => {
beforeEach(() => {
vi.useRealTimers()
@ -143,6 +177,8 @@ describe('Settings > General tab', () => {
tauriProcessMock.relaunch.mockReset()
tauriProcessMock.relaunch.mockResolvedValue(undefined)
delete (window as unknown as { __TAURI_INTERNALS__?: object }).__TAURI_INTERNALS__
delete (window as unknown as { __TAURI__?: object }).__TAURI__
installElectronDesktopHost()
MOCK_GET_SETTINGS.mockResolvedValue({})
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
providerStoreState.providers = []
@ -408,9 +444,6 @@ describe('Settings > General tab', () => {
})
it('keeps data storage at the bottom of General settings', () => {
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
render(<Settings />)
fireEvent.click(screen.getByText('General'))
@ -423,9 +456,6 @@ describe('Settings > General tab', () => {
})
it('lets desktop users choose a portable data directory and relaunch immediately', async () => {
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
render(<Settings />)
fireEvent.click(screen.getByText('General'))
@ -447,8 +477,6 @@ describe('Settings > General tab', () => {
})
it('switches back to the system directory without deleting portable data', async () => {
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
useSettingsStore.setState({
appMode: {
mode: 'portable',
@ -475,9 +503,6 @@ describe('Settings > General tab', () => {
})
it('validates portable directory input and lets users reset to the app-side folder', async () => {
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
render(<Settings />)
fireEvent.click(screen.getByText('General'))
@ -493,8 +518,6 @@ describe('Settings > General tab', () => {
})
it('shows folder picker failures as an inline storage error', async () => {
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
tauriDialogMock.open.mockRejectedValueOnce(new Error('dialog unavailable'))
render(<Settings />)
@ -506,8 +529,6 @@ describe('Settings > General tab', () => {
})
it('treats external CLAUDE_CONFIG_DIR as the controlling data source', async () => {
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
useSettingsStore.setState({
appMode: {
mode: 'portable',
@ -533,9 +554,6 @@ describe('Settings > General tab', () => {
})
it('keeps mode switch confirmation cancelable before restart starts', async () => {
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
render(<Settings />)
fireEvent.click(screen.getByText('General'))
@ -551,8 +569,6 @@ describe('Settings > General tab', () => {
})
it('shows restart preparation failures without relaunching', async () => {
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
tauriCoreMock.invoke.mockRejectedValueOnce(new Error('restart preparation failed'))
render(<Settings />)
@ -566,8 +582,6 @@ describe('Settings > General tab', () => {
})
it('shows the saved restart-required state inside the storage section', () => {
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
useSettingsStore.setState({ appModeRequiresRestart: true })
render(<Settings />)

View File

@ -61,19 +61,6 @@ vi.mock('../../api/websocket', () => ({
},
}))
vi.mock('@tauri-apps/plugin-dialog', () => ({
open: mocks.dialogOpen,
}))
vi.mock('@tauri-apps/api/webview', () => ({
getCurrentWebview: () => ({
onDragDropEvent: vi.fn(async (handler: (event: { payload: unknown }) => void) => {
mocks.webviewDragHandlers.push(handler)
return mocks.webviewUnlisten
}),
}),
}))
vi.mock('../../hooks/useMobileViewport', () => ({
useMobileViewport: () => viewportMocks.isMobile,
}))
@ -92,6 +79,7 @@ import { useSessionStore } from '../../stores/sessionStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { browserHost } from '../../lib/desktopHost/browserHost'
function okRepositoryContext() {
return {
@ -134,9 +122,33 @@ describe('ChatInput file mentions', () => {
const initialTabState = useTabStore.getInitialState()
const initialWorkspaceContextState = useWorkspaceChatContextStore.getInitialState()
const installElectronFileHost = () => {
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
dialogs: true,
},
dialogs: {
...browserHost.dialogs,
open: mocks.dialogOpen,
},
webview: {
...browserHost.webview,
onDragDropEvent: async (handler) => {
mocks.webviewDragHandlers.push(handler as (event: { payload: unknown }) => void)
return mocks.webviewUnlisten
},
},
}
}
beforeEach(() => {
vi.clearAllMocks()
mocks.webviewDragHandlers.length = 0
Reflect.deleteProperty(window, 'desktopHost')
delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__
viewportMocks.isMobile = false
useSettingsStore.setState({ locale: 'en' })
@ -785,10 +797,7 @@ describe('ChatInput file mentions', () => {
})
it('uses native desktop file paths instead of inlining selected files', async () => {
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: {},
})
installElectronFileHost()
mocks.dialogOpen.mockResolvedValueOnce([
'/Users/nanmi/tmp/large-a.log',
'C:\\Users\\Nanmi\\Desktop\\large-b.zip',
@ -832,10 +841,7 @@ describe('ChatInput file mentions', () => {
})
it('accepts native desktop file drops on the active session composer as path-only attachments', async () => {
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: {},
})
installElectronFileHost()
render(<ChatInput compact />)

View File

@ -7,20 +7,20 @@ import {
type ComposerAttachment,
} from '../../lib/composerAttachments'
type TauriDropPosition = {
type DesktopDropPosition = {
x: number
y: number
}
type TauriDragDropPayload =
| { type: 'enter'; paths: string[]; position: TauriDropPosition }
| { type: 'over'; position: TauriDropPosition }
| { type: 'drop'; paths: string[]; position: TauriDropPosition }
type DesktopDragDropPayload =
| { type: 'enter'; paths: string[]; position: DesktopDropPosition }
| { type: 'over'; position: DesktopDropPosition }
| { type: 'drop'; paths: string[]; position: DesktopDropPosition }
| { type: 'leave' }
| { type: 'cancel' }
type TauriDragDropEvent = {
payload: TauriDragDropPayload
type DesktopDragDropEvent = {
payload: DesktopDragDropPayload
}
type UseComposerFileDropOptions = {
@ -30,7 +30,7 @@ type UseComposerFileDropOptions = {
onError?: (error: unknown) => void
}
function isPointInsideElement(element: HTMLElement | null, position: TauriDropPosition): boolean {
function isPointInsideElement(element: HTMLElement | null, position: DesktopDropPosition): boolean {
if (!element) return false
const rect = element.getBoundingClientRect()
return (
@ -76,7 +76,7 @@ export function useComposerFileDrop({
.onDragDropEvent((event) => {
if (disposed) return
const payload = (event as TauriDragDropEvent).payload
const payload = (event as DesktopDragDropEvent).payload
if (payload.type === 'cancel' || payload.type === 'leave') {
dragDepthRef.current = 0
setIsDragActive(false)

View File

@ -254,7 +254,7 @@ describe('Sidebar', () => {
{ sessionId: 'session-new-1', title: 'New Session', type: 'session', status: 'idle' },
])
expect(useTabStore.getState().activeTabId).toBe('session-new-1')
expect(screen.getByRole('complementary')).not.toHaveAttribute('data-tauri-drag-region')
expect(screen.getByRole('complementary')).not.toHaveAttribute('data-desktop-drag-region')
})
it('groups sessions by project and expands overflow rows', () => {

View File

@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import type { PerSessionState } from '../../stores/chatStore'
import type { ChatState } from '../../types/chat'
import { browserHost } from '../../lib/desktopHost/browserHost'
const startDraggingMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const getCurrentWindowMock = vi.hoisted(() => vi.fn(() => ({
@ -98,6 +99,22 @@ vi.mock('./WindowControls', () => ({
}))
describe('TabBar', () => {
const installElectronDesktopHost = () => {
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
windowControls: true,
},
window: {
...browserHost.window,
startDragging: startDraggingMock,
},
}
}
beforeEach(() => {
class ResizeObserverMock {
constructor(_callback: ResizeObserverCallback) {}
@ -113,10 +130,8 @@ describe('TabBar', () => {
value: ResizeObserverMock,
})
Object.defineProperty(window, '__TAURI__', {
configurable: true,
value: {},
})
Reflect.deleteProperty(window, '__TAURI__')
installElectronDesktopHost()
Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
@ -157,7 +172,8 @@ describe('TabBar', () => {
useTerminalPanelStore.setState(useTerminalPanelStore.getInitialState(), true)
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__
Reflect.deleteProperty(window, 'desktopHost')
Reflect.deleteProperty(window, '__TAURI__')
})
it('scrolls the active tab into view when the active tab changes', async () => {
@ -291,8 +307,8 @@ describe('TabBar', () => {
render(<TabBar />)
})
expect(screen.getByTestId('tab-bar')).not.toHaveAttribute('data-tauri-drag-region')
expect(screen.getByTestId('tab-bar-drag-gutter')).toHaveAttribute('data-tauri-drag-region')
expect(screen.getByTestId('tab-bar')).not.toHaveAttribute('data-desktop-drag-region')
expect(screen.getByTestId('tab-bar-drag-gutter')).toHaveAttribute('data-desktop-drag-region')
})
it('keeps the desktop tab strip at a roomier titlebar height', async () => {
@ -452,7 +468,7 @@ describe('TabBar', () => {
})
it('hides the open-project control outside the desktop shell', async () => {
delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__
Reflect.deleteProperty(window, 'desktopHost')
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')

View File

@ -407,7 +407,7 @@ export function TabBar() {
{isDesktopRuntime && (
<div
data-testid="tab-bar-drag-gutter"
data-tauri-drag-region
data-desktop-drag-region
aria-hidden="true"
className={`min-h-11 flex-shrink-0 ${showWindowControls ? 'w-3' : 'w-4'}`}
/>

View File

@ -8,13 +8,13 @@ export function TitleBar() {
return (
<div
className="h-[var(--titlebar-height)] flex items-center border-b border-[var(--color-border)] bg-[var(--color-surface)] select-none"
data-tauri-drag-region
data-desktop-drag-region
>
{/* macOS traffic light spacer */}
<div className="w-[78px] flex-shrink-0" data-tauri-drag-region />
<div className="w-[78px] flex-shrink-0" data-desktop-drag-region />
{/* Logo */}
<div className="flex items-center gap-2 mr-4" data-tauri-drag-region>
<div className="flex items-center gap-2 mr-4" data-desktop-drag-region>
<span className="text-xs font-bold tracking-wider text-[var(--color-brand)] uppercase">Claude Code Companion</span>
</div>
@ -33,7 +33,7 @@ export function TitleBar() {
</div>
{/* Center tabs */}
<div className="flex-1 flex items-center justify-center gap-1" data-tauri-drag-region>
<div className="flex-1 flex items-center justify-center gap-1" data-desktop-drag-region>
<TabButton
active={activeView === 'code'}
onClick={() => setActiveView('code')}

View File

@ -10,11 +10,16 @@ import { useUpdateStore } from '../../stores/updateStore'
describe('UpdateChecker', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
Reflect.deleteProperty(window, 'desktopHost')
Object.defineProperty(window, '__TAURI__', {
value: {},
configurable: true,
})
Reflect.deleteProperty(window, '__TAURI__')
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
updates: true,
},
}
useUpdateStore.setState({
status: 'available',
@ -48,7 +53,6 @@ describe('UpdateChecker', () => {
})
it('renders the update prompt in Electron desktop runtime', () => {
Reflect.deleteProperty(window, '__TAURI__')
window.desktopHost = {
...browserHost,
kind: 'electron',

View File

@ -31,7 +31,7 @@ describe('desktop host contract', () => {
})
it('detects the browser fallback when native host globals are absent', () => {
expect(createDesktopHost({ electronHost: null, hasTauri: false })).toBe(browserHost)
expect(createDesktopHost({ electronHost: null })).toBe(browserHost)
})
it('prefers an injected Electron preload host over browser fallback', () => {
@ -41,20 +41,15 @@ describe('desktop host contract', () => {
isDesktop: true,
}
expect(createDesktopHost({ electronHost, hasTauri: false })).toBe(electronHost)
expect(createDesktopHost({ electronHost })).toBe(electronHost)
})
it('detects Tauri and Electron runtime globals without importing native modules', () => {
it('detects Electron runtime globals without importing native modules', () => {
const originalDesktopHost = window.desktopHost
const originalTauri = window.__TAURI_INTERNALS__
try {
Reflect.deleteProperty(window, 'desktopHost')
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
expect(detectDesktopHostEnvironment()).toEqual({ electronHost: null, hasTauri: false })
window.__TAURI_INTERNALS__ = {}
expect(detectDesktopHostEnvironment()).toEqual({ electronHost: null, hasTauri: true })
expect(detectDesktopHostEnvironment()).toEqual({ electronHost: null })
const electronHost = {
...browserHost,
@ -62,19 +57,13 @@ describe('desktop host contract', () => {
isDesktop: true,
}
window.desktopHost = electronHost
expect(detectDesktopHostEnvironment()).toEqual({ electronHost, hasTauri: true })
expect(detectDesktopHostEnvironment()).toEqual({ electronHost })
} finally {
if (typeof originalDesktopHost === 'undefined') {
Reflect.deleteProperty(window, 'desktopHost')
} else {
window.desktopHost = originalDesktopHost
}
if (typeof originalTauri === 'undefined') {
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
} else {
window.__TAURI_INTERNALS__ = originalTauri
}
}
})

View File

@ -1,21 +1,17 @@
import { browserHost } from './browserHost'
import { tauriHost } from './tauriHost'
import type { DesktopHost } from './types'
export type DesktopHostEnvironment = {
electronHost: DesktopHost | null
hasTauri: boolean
tauriHost?: DesktopHost | null
}
export function detectDesktopHostEnvironment(): DesktopHostEnvironment {
if (typeof window === 'undefined') {
return { electronHost: null, hasTauri: false }
return { electronHost: null }
}
return {
electronHost: window.desktopHost ?? null,
hasTauri: '__TAURI_INTERNALS__' in window || '__TAURI__' in window,
}
}
@ -23,15 +19,11 @@ export function createDesktopHost(
environment: DesktopHostEnvironment = detectDesktopHostEnvironment(),
): DesktopHost {
if (environment.electronHost) return environment.electronHost
if (environment.hasTauri && environment.tauriHost) return environment.tauriHost
return browserHost
}
export function getDesktopHost(): DesktopHost {
return createDesktopHost({
...detectDesktopHostEnvironment(),
tauriHost,
})
return createDesktopHost(detectDesktopHostEnvironment())
}
export const desktopHost = getDesktopHost()

View File

@ -1,256 +0,0 @@
import { browserHost } from './browserHost'
import type {
DesktopHost,
DesktopHostUnlisten,
TerminalExitEvent,
TerminalOutputEvent,
} from './types'
const tauriCapabilities: DesktopHost['capabilities'] = {
appMode: true,
dialogs: true,
notifications: true,
previewWebview: true,
shell: true,
terminal: true,
updates: true,
windowControls: true,
zoom: true,
}
async function invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const api = await import('@tauri-apps/api/core')
return typeof args === 'undefined'
? api.invoke<T>(command)
: api.invoke<T>(command, args)
}
async function listen<T>(
eventName: string,
handler: (payload: T) => void,
): Promise<DesktopHostUnlisten> {
const events = await import('@tauri-apps/api/event')
return events.listen<T>(eventName, (event) => handler(event.payload))
}
export const tauriHost: DesktopHost = {
...browserHost,
kind: 'tauri',
isDesktop: true,
capabilities: tauriCapabilities,
runtime: {
getServerUrl() {
return invoke<string>('get_server_url')
},
},
app: {
async getVersion() {
const { getVersion } = await import('@tauri-apps/api/app')
return getVersion()
},
},
commands: {
invoke,
},
events: {
listen,
},
webview: {
async onDragDropEvent(handler) {
const { getCurrentWebview } = await import('@tauri-apps/api/webview')
return getCurrentWebview().onDragDropEvent(handler)
},
},
shell: {
async open(target) {
const { open } = await import('@tauri-apps/plugin-shell')
await open(target)
},
async openPath(path) {
const { open } = await import('@tauri-apps/plugin-shell')
await open(path)
},
},
dialogs: {
async open(options) {
const { open } = await import('@tauri-apps/plugin-dialog')
return open(options)
},
async save(options) {
const { save } = await import('@tauri-apps/plugin-dialog')
return save(options)
},
},
notifications: {
async permissionState() {
const { isPermissionGranted } = await import('@tauri-apps/plugin-notification')
return await isPermissionGranted() ? 'granted' : 'default'
},
async requestPermission() {
const {
isPermissionGranted,
requestPermission,
} = await import('@tauri-apps/plugin-notification')
if (await isPermissionGranted()) return 'granted'
const permission = await requestPermission()
return permission === 'granted' || permission === 'denied' || permission === 'default'
? permission
: 'default'
},
async send(options) {
const { sendNotification } = await import('@tauri-apps/plugin-notification')
sendNotification(options)
},
async onAction(handler) {
const notification = await import('@tauri-apps/plugin-notification') as {
onAction?: (cb: (payload: unknown) => void) => Promise<unknown>
}
if (!notification.onAction) return () => {}
const listener = await notification.onAction(handler)
return () => {
if (typeof listener === 'function') {
listener()
return
}
const unregister = listener && typeof listener === 'object'
? (listener as { unregister?: () => Promise<void> | void }).unregister
: undefined
if (typeof unregister === 'function') void unregister.call(listener)
}
},
async ackAction() {
return false
},
},
window: {
async minimize() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().minimize()
},
async toggleMaximize() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().toggleMaximize()
},
async close() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().close()
},
async startDragging() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().startDragging()
},
async requestAttention() {
const { getCurrentWindow, UserAttentionType } = await import('@tauri-apps/api/window')
await getCurrentWindow().requestUserAttention(UserAttentionType.Critical)
},
async focus() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
const win = getCurrentWindow()
await (win as unknown as { show?: () => Promise<void> | void }).show?.()
await (win as unknown as { setFocus?: () => Promise<void> | void }).setFocus?.()
},
async isMaximized() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
return getCurrentWindow().isMaximized()
},
async onResized(handler) {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
return getCurrentWindow().onResized(handler)
},
onNativeMenuNavigate(handler) {
return listen('native-menu-navigate', handler)
},
},
updates: {
async check(options) {
const updater = await import('@tauri-apps/plugin-updater')
return updater.check(options)
},
prepareInstall() {
return invoke('prepare_for_update_install')
},
cancelInstall() {
return invoke('cancel_update_install')
},
async relaunch() {
const { relaunch } = await import('@tauri-apps/plugin-process')
await relaunch()
},
},
terminal: {
spawn(input) {
return invoke('terminal_spawn', input)
},
write(sessionId, data) {
return invoke('terminal_write', { sessionId, data })
},
resize(sessionId, cols, rows) {
return invoke('terminal_resize', { sessionId, cols, rows })
},
kill(sessionId) {
return invoke('terminal_kill', { sessionId })
},
onOutput(handler) {
return listen<TerminalOutputEvent>('terminal-output', handler)
},
onExit(handler) {
return listen<TerminalExitEvent>('terminal-exit', handler)
},
getBashPath() {
return invoke<string | null>('get_terminal_bash_path')
},
setBashPath(path) {
return invoke('set_terminal_bash_path', { path })
},
},
preview: {
open(url, bounds) {
return invoke('preview_open', { url, bounds })
},
navigate(url) {
return invoke('preview_navigate', { url })
},
setBounds(bounds) {
return invoke('preview_set_bounds', { bounds })
},
setVisible(visible) {
return invoke('preview_set_visible', { visible })
},
close() {
return invoke('preview_close')
},
message(payload) {
return invoke('preview_message', { raw: JSON.stringify(payload) })
},
onEvent(handler) {
return listen('preview://event', handler)
},
},
zoom: {
set(level) {
return invoke('set_app_zoom', { zoomFactor: level })
},
},
adapters: {
restartSidecar() {
return invoke('restart_adapters_sidecar')
},
},
appMode: {
get() {
return invoke('get_app_mode')
},
set(config) {
return invoke('set_app_mode', config)
},
detectPortableDir() {
return invoke('detect_portable_dir')
},
prepareRestart() {
return invoke('prepare_for_app_mode_restart')
},
restart() {
return tauriHost.updates.relaunch()
},
},
}

View File

@ -3,7 +3,7 @@ import type {
AppModeConfig as SettingsAppModeConfig,
} from '../../types/settings'
export type DesktopHostKind = 'browser' | 'tauri' | 'electron'
export type DesktopHostKind = 'browser' | 'electron'
export type DesktopHostCapability =
| 'appMode'
@ -228,7 +228,5 @@ export type DesktopHost = {
declare global {
interface Window {
desktopHost?: DesktopHost
__TAURI__?: unknown
__TAURI_INTERNALS__?: unknown
}
}

View File

@ -42,9 +42,61 @@ import {
resetDesktopNotificationsForTests,
setNativeNotificationSenderForTests,
} from './desktopNotifications'
import { browserHost } from './desktopHost/browserHost'
import { useSettingsStore } from '../stores/settingsStore'
describe('desktopNotifications', () => {
const installElectronNotificationHost = () => {
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
notifications: true,
shell: true,
},
commands: {
...browserHost.commands,
invoke: (command, args) => args === undefined
? coreApiMock.invoke(command)
: coreApiMock.invoke(command, args),
},
events: {
...browserHost.events,
listen: (eventName, handler) => eventApiMock.listen(eventName, handler),
},
shell: {
...browserHost.shell,
open: shellApiMock.open,
},
notifications: {
...browserHost.notifications,
permissionState: async () => {
const granted = await notificationPluginMock.isPermissionGranted()
return granted ? 'granted' : 'denied'
},
requestPermission: notificationPluginMock.requestPermission,
send: notificationPluginMock.sendNotification,
onAction: async (handler) => {
const unlisten = await notificationPluginMock.onAction(handler)
if (typeof unlisten === 'function') return unlisten
if (unlisten && typeof unlisten === 'object' && 'unregister' in unlisten) {
return () => {
void (unlisten as { unregister: () => unknown }).unregister()
}
}
return () => {}
},
},
window: {
...browserHost.window,
requestAttention: requestUserAttentionMock,
focus: vi.fn().mockResolvedValue(undefined),
},
}
}
beforeEach(() => {
vi.useRealTimers()
resetDesktopNotificationsForTests()
@ -62,14 +114,12 @@ describe('desktopNotifications', () => {
configurable: true,
value: 'Linux x86_64',
})
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: {},
})
Reflect.deleteProperty(window, 'desktopHost')
installElectronNotificationHost()
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
Reflect.deleteProperty(window, '__TAURI__')
})
it('sends through the Tauri plugin when native notification permission is already granted', async () => {
it('sends through the desktop notification host when permission is already granted', async () => {
notificationPluginMock.isPermissionGranted.mockResolvedValue(true)
notifyDesktop({
@ -87,7 +137,7 @@ describe('desktopNotifications', () => {
})
})
it('passes notification targets through the Tauri plugin payload', async () => {
it('passes notification targets through the desktop notification payload', async () => {
notificationPluginMock.isPermissionGranted.mockResolvedValue(true)
const target = { type: 'session' as const, sessionId: 'session-1', title: 'Build fix' }
@ -185,7 +235,7 @@ describe('desktopNotifications', () => {
warnSpy.mockRestore()
})
it('does not fall back to the Tauri plugin when the macOS bridge fails', async () => {
it('does not fall back to the generic notification bridge when the macOS bridge fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
Object.defineProperty(navigator, 'platform', {
configurable: true,
@ -336,7 +386,7 @@ describe('desktopNotifications', () => {
expect(notificationPluginMock.requestPermission).not.toHaveBeenCalled()
})
it('does not use the Tauri plugin permission fallback on macOS bridge errors', async () => {
it('does not use the generic notification permission fallback on macOS bridge errors', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
Object.defineProperty(navigator, 'platform', {
configurable: true,
@ -432,7 +482,7 @@ describe('desktopNotifications', () => {
await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(windowApiMock.requestUserAttention).toHaveBeenCalledTimes(1))
expect(windowApiMock.requestUserAttention).toHaveBeenCalledWith(windowApiMock.UserAttentionType.Critical)
expect(windowApiMock.requestUserAttention).toHaveBeenCalledWith()
})
it('throttles bursts within the same cooldown scope', async () => {

View File

@ -6,7 +6,7 @@ import {
setAuthToken,
setBaseUrl,
} from '../api/client'
import { detectDesktopHostEnvironment, getDesktopHost } from './desktopHost'
import { getDesktopHost } from './desktopHost'
export const H5_SERVER_URL_STORAGE_KEY = 'cc-haha-h5-server-url'
export const H5_TOKEN_STORAGE_KEY = 'cc-haha-h5-token'
@ -34,11 +34,6 @@ export class H5ConnectionRequiredError extends Error {
}
}
export function isTauriRuntime() {
if (typeof window === 'undefined') return false
return detectDesktopHostEnvironment().hasTauri
}
function getDetectedDesktopHost() {
return getDesktopHost()
}

View File

@ -3,11 +3,34 @@ import type { WebviewBounds } from '../components/browser/computeWebviewBounds'
import { browserHost } from './desktopHost/browserHost'
const invoke = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({ invoke: (...a: unknown[]) => invoke(...a) }))
vi.mock('./desktopRuntime', () => ({ isTauriRuntime: () => true }))
function installElectronPreviewHost() {
const open = vi.fn().mockResolvedValue(undefined)
const setBounds = vi.fn().mockResolvedValue(undefined)
const message = vi.fn().mockResolvedValue(undefined)
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
previewWebview: true,
},
preview: {
...browserHost.preview,
open,
setBounds,
message,
},
}
return { open, setBounds, message }
}
beforeEach(() => {
window.__TAURI_INTERNALS__ = {}
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
Reflect.deleteProperty(window, '__TAURI__')
})
afterEach(() => {
@ -18,28 +41,35 @@ afterEach(() => {
})
describe('previewBridge', () => {
it('openPreview forwards url + bounds to preview_open', async () => {
it('openPreview forwards url + bounds to the Electron preview host', async () => {
const { open } = installElectronPreviewHost()
const { previewBridge } = await import('./previewBridge')
const bounds: WebviewBounds = { x: 1, y: 2, width: 3, height: 4 }
await previewBridge.open('http://localhost/a', bounds)
expect(invoke).toHaveBeenCalledWith('preview_open', { url: 'http://localhost/a', bounds })
expect(open).toHaveBeenCalledWith('http://localhost/a', bounds)
expect(invoke).not.toHaveBeenCalled()
})
it('setBounds forwards to preview_set_bounds', async () => {
it('setBounds forwards to the Electron preview host', async () => {
const { setBounds } = installElectronPreviewHost()
const { previewBridge } = await import('./previewBridge')
await previewBridge.setBounds({ x: 0, y: 0, width: 10, height: 10 })
expect(invoke).toHaveBeenCalledWith('preview_set_bounds', { bounds: { x: 0, y: 0, width: 10, height: 10 } })
const bounds: WebviewBounds = { x: 0, y: 0, width: 10, height: 10 }
await previewBridge.setBounds(bounds)
expect(setBounds).toHaveBeenCalledWith(bounds)
expect(invoke).not.toHaveBeenCalled()
})
it('message forwards structured host messages to preview_message', async () => {
it('message forwards structured host messages to the Electron preview host', async () => {
const { message } = installElectronPreviewHost()
const { previewBridge } = await import('./previewBridge')
await previewBridge.message({ v: 1, type: 'capture', kind: 'full' })
expect(invoke).toHaveBeenCalledWith('preview_message', { raw: '{"v":1,"type":"capture","kind":"full"}' })
const payload = { v: 1, type: 'capture', kind: 'full' } as const
await previewBridge.message(payload)
expect(message).toHaveBeenCalledWith(payload)
expect(invoke).not.toHaveBeenCalled()
})
it('is a no-op outside the Tauri runtime', async () => {
it('is a no-op outside the desktop runtime', async () => {
vi.resetModules()
vi.doMock('./desktopRuntime', () => ({ isTauriRuntime: () => false }))
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
const { previewBridge } = await import('./previewBridge')
await previewBridge.open('http://localhost/a', { x: 0, y: 0, width: 1, height: 1 })
@ -48,7 +78,6 @@ describe('previewBridge', () => {
it('routes preview commands through an injected desktop host', async () => {
vi.resetModules()
vi.doMock('./desktopRuntime', () => ({ isTauriRuntime: () => false }))
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
const open = vi.fn().mockResolvedValue(undefined)
const setBounds = vi.fn().mockResolvedValue(undefined)

View File

@ -34,7 +34,7 @@ export function AdapterSettings() {
const [activeIm, setActiveIm] = useState<ImTab>('feishu')
// Server —— serverUrl 不再暴露在 UI 里(见下方 Server URL 注释),
// 桌面端用 Tauri env var 注入动态端口。
// 桌面端用 sidecar env var 注入动态端口。
const [defaultProjectDir, setDefaultProjectDir] = useState('')
// Telegram
@ -468,7 +468,7 @@ export function AdapterSettings() {
</div>
</section>
{/* Server URL Tauri adapter sidecar
{/* Server URL adapter sidecar
server ADAPTER_SERVER_URL env var
loadConfig() env file value

View File

@ -35,6 +35,7 @@ import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin'
import { OPENAI_OFFICIAL_PROVIDER_ID } from '../constants/openaiOfficialProvider'
import { useUpdateStore } from '../stores/updateStore'
import { getBaseUrl } from '../api/client'
import { formatBytes } from '../lib/formatBytes'
import { isDesktopRuntime } from '../lib/desktopRuntime'
import { getDesktopHost } from '../lib/desktopHost'
@ -691,6 +692,7 @@ function updateSettingsJsonProviderConnection(
apiKey: string,
preset: ProviderPreset,
baseUrl: string,
proxyBaseUrl: string,
): string {
try {
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
@ -700,7 +702,7 @@ function updateSettingsJsonProviderConnection(
const env = { ...existingEnv }
delete env.ANTHROPIC_API_KEY
delete env.ANTHROPIC_AUTH_TOKEN
env.ANTHROPIC_BASE_URL = apiFormat !== 'anthropic' ? 'http://127.0.0.1:3456/proxy' : baseUrl
env.ANTHROPIC_BASE_URL = apiFormat !== 'anthropic' ? proxyBaseUrl : baseUrl
Object.assign(env, buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, preset))
parsed.env = env
return JSON.stringify(parsed, null, 2)
@ -709,6 +711,10 @@ function updateSettingsJsonProviderConnection(
}
}
function getProviderProxyBaseUrl(): string {
return `${getBaseUrl().replace(/\/$/, '')}/proxy`
}
function buildFallbackPreset(provider?: SavedProvider): ProviderPreset {
return {
id: provider?.presetId ?? 'custom',
@ -776,6 +782,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
const [settingsJson, setSettingsJson] = useState('')
const [settingsJsonError, setSettingsJsonError] = useState<string | null>(null)
const jsonPastedRef = useRef(false)
const providerProxyBaseUrl = useMemo(() => getProviderProxyBaseUrl(), [])
// Load current settings.json and merge provider env vars
useEffect(() => {
@ -802,7 +809,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
...(Object.keys(modelContextWindows).length > 0
? { [MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows) }
: {}),
ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl,
ANTHROPIC_BASE_URL: needsProxy ? providerProxyBaseUrl : baseUrl,
...buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, selectedPreset),
ANTHROPIC_MODEL: normalizedModels.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: normalizedModels.haiku,
@ -816,7 +823,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
})
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPreset.id])
}, [selectedPreset.id, providerProxyBaseUrl])
const handlePresetChange = (preset: ProviderPreset) => {
setSelectedPreset(preset)
@ -912,19 +919,19 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
}
const handleBaseUrlChange = (value: string) => {
setBaseUrl(value)
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, apiKey, selectedPreset, value))
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, apiKey, selectedPreset, value, providerProxyBaseUrl))
}
const handleApiKeyChange = (value: string) => {
setApiKey(value)
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, value, selectedPreset, baseUrl))
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, authStrategy, value, selectedPreset, baseUrl, providerProxyBaseUrl))
}
const handleApiFormatChange = (value: ApiFormat) => {
setApiFormat(value)
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, value, authStrategy, apiKey, selectedPreset, baseUrl))
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, value, authStrategy, apiKey, selectedPreset, baseUrl, providerProxyBaseUrl))
}
const handleAuthStrategyChange = (value: ProviderAuthStrategy) => {
setAuthStrategy(value)
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, value, apiKey, selectedPreset, baseUrl))
setSettingsJson((current) => updateSettingsJsonProviderConnection(current, apiFormat, value, apiKey, selectedPreset, baseUrl, providerProxyBaseUrl))
}
const handleModelChange = (slot: ModelSlot, value: string) => {
const nextModels = { ...models, [slot]: value }

View File

@ -333,6 +333,22 @@ describe('settingsStore network persistence', () => {
})
describe('settingsStore app mode', () => {
const installElectronAppModeHost = (appMode: Partial<typeof browserHost.appMode>) => {
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
appMode: true,
},
appMode: {
...browserHost.appMode,
...appMode,
},
}
}
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
@ -341,21 +357,19 @@ describe('settingsStore app mode', () => {
Reflect.deleteProperty(window, '__TAURI__')
})
it('hydrates app mode from the native desktop command', async () => {
const invoke = vi.fn().mockResolvedValue({
it('hydrates app mode from the Electron desktop host', async () => {
const getAppMode = vi.fn().mockResolvedValue({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
})
vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
installElectronAppModeHost({ get: getAppMode })
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().fetchAppMode()
expect(invoke).toHaveBeenCalledWith('get_app_mode')
expect(getAppMode).toHaveBeenCalledTimes(1)
expect(useSettingsStore.getState().appMode).toEqual({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
@ -369,15 +383,7 @@ describe('settingsStore app mode', () => {
portableDir: 'D:\\cc-haha\\data',
defaultPortableDir: 'D:\\cc-haha\\data',
})
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
appMode: {
...browserHost.appMode,
get: getAppMode,
},
}
installElectronAppModeHost({ get: getAppMode })
const { useSettingsStore } = await import('./settingsStore')
@ -391,11 +397,9 @@ describe('settingsStore app mode', () => {
})
})
it('persists app mode through the native desktop command and marks restart required', async () => {
const invoke = vi.fn().mockResolvedValue(undefined)
vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
it('persists app mode through the Electron desktop host and marks restart required', async () => {
const setAppMode = vi.fn().mockResolvedValue(undefined)
installElectronAppModeHost({ set: setAppMode })
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.setState({
@ -409,7 +413,7 @@ describe('settingsStore app mode', () => {
await useSettingsStore.getState().setAppMode('portable')
expect(invoke).toHaveBeenCalledWith('set_app_mode', {
expect(setAppMode).toHaveBeenCalledWith({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
})
@ -425,15 +429,7 @@ describe('settingsStore app mode', () => {
it('persists app mode through an injected desktop host', async () => {
const setAppMode = vi.fn().mockResolvedValue(undefined)
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
appMode: {
...browserHost.appMode,
set: setAppMode,
},
}
installElectronAppModeHost({ set: setAppMode })
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.setState({
@ -455,10 +451,8 @@ describe('settingsStore app mode', () => {
})
it('persists a user-selected portable directory', async () => {
const invoke = vi.fn().mockResolvedValue(undefined)
vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
const setAppMode = vi.fn().mockResolvedValue(undefined)
installElectronAppModeHost({ set: setAppMode })
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.setState({
@ -472,7 +466,7 @@ describe('settingsStore app mode', () => {
await useSettingsStore.getState().setAppMode('portable', 'D:\\portable-data')
expect(invoke).toHaveBeenCalledWith('set_app_mode', {
expect(setAppMode).toHaveBeenCalledWith({
mode: 'portable',
portableDir: 'D:\\portable-data',
})
@ -485,10 +479,8 @@ describe('settingsStore app mode', () => {
})
it('switches app mode back to the system data source', async () => {
const invoke = vi.fn().mockResolvedValue(undefined)
vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
const setAppMode = vi.fn().mockResolvedValue(undefined)
installElectronAppModeHost({ set: setAppMode })
const { useSettingsStore } = await import('./settingsStore')
useSettingsStore.setState({
@ -504,7 +496,7 @@ describe('settingsStore app mode', () => {
await useSettingsStore.getState().setAppMode('default', null)
expect(invoke).toHaveBeenCalledWith('set_app_mode', {
expect(setAppMode).toHaveBeenCalledWith({
mode: 'default',
portableDir: null,
})

View File

@ -17,17 +17,33 @@ vi.mock('@tauri-apps/api/core', () => ({
invoke,
}))
function installElectronUpdateHost() {
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
updates: true,
},
updates: {
...browserHost.updates,
check,
prepareInstall: () => invoke('prepare_for_update_install'),
cancelInstall: () => invoke('cancel_update_install'),
relaunch,
},
}
}
describe('updateStore', () => {
beforeEach(() => {
check.mockReset()
relaunch.mockReset()
invoke.mockReset()
window.localStorage.clear()
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: {},
})
Reflect.deleteProperty(window, 'desktopHost')
installElectronUpdateHost()
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
Reflect.deleteProperty(window, '__TAURI__')
})

View File

@ -958,8 +958,8 @@ html, body, #root {
color: var(--color-selection-fg);
}
/* Tauri drag region */
[data-tauri-drag-region] {
/* Desktop drag region */
[data-desktop-drag-region] {
-webkit-app-region: drag;
}
button, input, textarea, select, a, [role="button"] {

View File

@ -28,7 +28,7 @@
| electron-builder | - | macOS/Windows/Linux 打包与 release artifact 生成 |
| electron-updater | - | 自动更新检查、下载与安装 |
| node-pty | - | Electron main 中的终端 PTY runtime |
| Tauri 2 | 2 | 迁移期 legacy host 与 sidecar 资源目录React 层通过 `desktopHost` adapter 隔离 |
| `desktop/src-tauri` 资源目录 | - | 历史资源位置,暂存 sidecar 二进制、图标和 preview agent不再作为桌面 runtime |
### 服务端
@ -144,7 +144,7 @@ Server 为每个 Session spawn 一个 CLI 子进程,通过 stdin/stdout JSON
- `cli` — 启动 CLI 子进程
- `adapters` — 启动 IM 适配器(解析 `--feishu`/`--telegram` 参数,检查凭据后按需加载)
编译产物仍放置在 `desktop/src-tauri/binaries/`,作为迁移期稳定资源目录Electron Builder 通过 `files``asarUnpack` 把它们打入 `app.asar.unpacked`
编译产物仍放置在 `desktop/src-tauri/binaries/`,作为历史稳定资源目录Electron Builder 通过 `files``asarUnpack` 把它们打入 `app.asar.unpacked`
---
@ -360,10 +360,10 @@ desktop/
│ ├── config/ # providerPresets, spinnerVerbs
│ └── lib/ # desktopRuntime, cronDescribe, parseRunOutput
├── src-tauri/
│ ├── binaries/ # Electron/Tauri 共用 sidecar 二进制
│ ├── binaries/ # Electron 打包使用的 sidecar 二进制
│ ├── resources/preview-agent.js # 预览页面注入脚本
│ ├── src/main.rs # 迁移期 Tauri legacy 入口
│ ├── src/lib.rs # 迁移期 Tauri legacy host
│ ├── src/main.rs # 历史 Tauri 入口,非当前 runtime
│ ├── src/lib.rs # 历史 Tauri host非当前 runtime
│ ├── Cargo.toml
│ └── tauri.conf.json
├── electron/

View File

@ -67,7 +67,7 @@ H5 的第一版优先保证聊天主流程:
- `@` 文件菜单会适配手机宽度。
- Workspace 面板和底部 Terminal 面板在手机宽度下不作为主流程显示。
桌面端和 Tauri 应用仍保留原来的布局和交互。
Electron 桌面端仍保留原来的布局和交互。
## 安全注意

View File

@ -43,7 +43,7 @@
- [x] `bun run test:package-smoke --platform macos`
- [x] `cd desktop && bun run test -- --run scripts/dev-launcher.test.ts && bun run check:electron`:新增 Electron dev launcher 代理绕过回归,最新 Electron checks 为 17 files / 79 tests passed。
- [x] `bun run test:package-smoke --platform macos`:结构检查仍通过,并明确提示该命令不做 Gatekeeper launch approval。
- [x] `cd desktop && bun run test -- src/lib/desktopRuntime.test.ts src/components/shared/UpdateChecker.test.tsx src/lib/composerAttachments.test.ts src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx src/components/layout/AppShell.test.tsx src/components/controls/PermissionModeSelector.test.tsx src/components/shared/RepositoryLaunchControls.test.tsx`8 files / 75 tests passed覆盖 Electron/Tauri 通用 desktop runtime 判断、更新提示、native attachment picker 和移动布局分支。
- [x] `cd desktop && bun run test -- src/lib/desktopRuntime.test.ts src/components/shared/UpdateChecker.test.tsx src/lib/composerAttachments.test.ts src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx src/components/layout/AppShell.test.tsx src/components/controls/PermissionModeSelector.test.tsx src/components/shared/RepositoryLaunchControls.test.tsx`8 files / 75 tests passed覆盖 Electron desktop runtime 判断、更新提示、native attachment picker 和移动布局分支。
- [x] `cd desktop && bun run check:electron`16 files / 77 tests passed覆盖 Electron updater proxy contract、IPC payload validation、main/preload/preview-preload bundle。
- [x] `bun test scripts/quality-gate/package-smoke/index.test.ts`7 tests passed发布型 macOS 包缺失 resources/app-update.yml 会失败,纯 `--dir` 开发包不强制该文件。
- [x] `bun test scripts/quality-gate/package-smoke/index.test.ts scripts/pr/quality-contract.test.ts`12 tests passed覆盖 host platform 到 package-smoke platform 的映射,并锁定 `check:native` 必须包含 `electron:package:dir``test:package-smoke:current`

View File

@ -60,7 +60,7 @@
2. 关键源码位置:
- `desktop/src/` — React 前端
- `desktop/electron/` — Electron main/preload/系统能力 host
- `desktop/src-tauri/`迁移期保留的 Tauri/Rust legacy host 与 sidecar 资源目录
- `desktop/src-tauri/`历史资源目录,当前仅作为 sidecar、图标和 preview agent 的 Electron 打包输入
- `desktop/sidecars/` — Sidecar 入口
- `src/server/` — Express API 服务端
- `adapters/` — IM 适配器

View File

@ -46,6 +46,6 @@ features:
link: /features/computer-use
- icon: "\U0001F5A5"
title: 桌面端
details: 基于 Tauri 2 + React 的图形化客户端多标签、多会话、IM 适配器接入,支持 macOS 和 Windows
details: 基于 Electron + React 的图形化客户端多标签、多会话、IM 适配器接入,支持 macOS、Windows 和 Linux
link: /desktop/
---

View File

@ -2,8 +2,18 @@ import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
describe('release desktop workflow', () => {
function readReleaseWorkflow() {
return readFileSync('.github/workflows/release-desktop.yml', 'utf8')
}
function extractJob(workflow: string, jobName: string) {
return workflow.match(
new RegExp(`${jobName}:[\\s\\S]*?(?:\\n {2}[a-zA-Z0-9_-]+:|$)`),
)?.[0]
}
test('build job waits for a PR-quality preflight before packaging', () => {
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
const workflow = readReleaseWorkflow()
expect(workflow).toContain('quality-preflight:')
expect(workflow).toContain('run: bun run verify')
@ -38,24 +48,22 @@ describe('release desktop workflow', () => {
})
test('release workflow requires macOS Gatekeeper launch approval before upload', () => {
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
const workflow = readReleaseWorkflow()
const gatekeeperStep = workflow.match(
/- name: Verify macOS launch policy[\s\S]*?(?:\n\s{6}- name:|$)/,
)?.[0]
expect(gatekeeperStep).toContain("if: matrix.smoke_platform == 'macos'")
expect(gatekeeperStep).toContain('bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/electron --require-macos-gatekeeper')
expect(workflow.indexOf('Verify macOS launch policy')).toBeLessThan(workflow.indexOf('Upload artifacts'))
expect(workflow.indexOf('Verify macOS launch policy')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish'))
})
test('release workflow fails globally before matrix fan-out when signing or notarization secrets are missing', () => {
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
const workflow = readReleaseWorkflow()
const signingJob = workflow.match(
/signing-preflight:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
)?.[0]
const buildJob = workflow.match(
/build:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
)?.[0]
const buildJob = extractJob(workflow, 'build')
expect(signingJob).toContain('Validate release signing and notarization secrets')
for (const secret of [
@ -73,33 +81,149 @@ describe('release desktop workflow', () => {
expect(buildJob).toContain('- quality-preflight')
expect(buildJob).toContain('- signing-preflight')
expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('build:'))
expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('Upload artifacts'))
expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish'))
})
test('release workflow avoids same-name updater metadata uploads from matrix builds', () => {
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
const workflow = readReleaseWorkflow()
const namespaceStep = workflow.match(
/- name: Namespace update metadata assets[\s\S]*?(?:\n\s{6}- name:|$)/,
)?.[0]
expect(namespaceStep).toContain('for file in latest*.yml')
expect(namespaceStep).toContain('"${file%.yml}-${{ matrix.label }}.yml"')
expect(workflow.indexOf('Namespace update metadata assets')).toBeLessThan(workflow.indexOf('Upload artifacts'))
expect(workflow.indexOf('Namespace update metadata assets')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish'))
})
test('release workflow republishes standard updater metadata after all matrix builds pass', () => {
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8')
const publishJob = workflow.match(
/publish-update-metadata:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
)?.[0]
test('release workflow uploads only Actions artifacts from matrix builds', () => {
const workflow = readReleaseWorkflow()
const buildJob = extractJob(workflow, 'build')
expect(buildJob).toContain('Validate matrix release asset set')
for (const label of ['macOS-ARM64', 'macOS-x64', 'Linux-x64', 'Linux-ARM64', 'Windows-x64']) {
expect(buildJob).toContain(`${label})`)
}
expect(buildJob).toContain('Upload release artifacts for final publish')
expect(buildJob).toContain('actions/upload-artifact@v4')
expect(buildJob).toContain('name: desktop-release-artifacts-${{ matrix.label }}')
expect(buildJob).not.toContain('softprops/action-gh-release@v2')
expect(buildJob).not.toContain('Load release notes')
})
test('release workflow publishes all release assets only after all matrix builds pass', () => {
const workflow = readReleaseWorkflow()
const publishJob = extractJob(workflow, 'publish-release')
expect(workflow).toContain('name: desktop-update-metadata-${{ matrix.label }}')
expect(workflow).toContain('name: desktop-release-artifacts-${{ matrix.label }}')
expect(publishJob).toContain('needs: build')
expect(publishJob).toContain('actions/download-artifact@v4')
expect(publishJob).toContain('pattern: desktop-release-artifacts-*')
expect(publishJob).toContain('pattern: desktop-update-metadata-*')
expect(publishJob).toContain('Validate complete release asset set')
expect(publishJob).toContain('bun run scripts/release-update-metadata.ts --metadata-dir artifacts/update-metadata --out-dir artifacts/update-metadata-standard')
expect(publishJob).toContain('files: artifacts/update-metadata-standard/*.yml')
expect(workflow.indexOf('publish-update-metadata:')).toBeGreaterThan(workflow.indexOf('build:'))
expect(publishJob).toContain('Validate standard update metadata set')
expect(publishJob).toContain('softprops/action-gh-release@v2')
expect(publishJob).toContain('artifacts/release-assets/**/*.dmg')
expect(publishJob).toContain('artifacts/release-assets/**/*.exe')
expect(publishJob).toContain('artifacts/release-assets/**/*.AppImage')
expect(publishJob).toContain('artifacts/update-metadata-standard/*.yml')
expect(publishJob).toContain('fail_on_unmatched_files: true')
expect(publishJob).toContain('Load release notes')
expect(workflow.indexOf('publish-release:')).toBeGreaterThan(workflow.indexOf('build:'))
})
test('release matrix asset basenames remain unique when final artifacts are flattened', () => {
const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as {
version: string
build: {
artifactName: string
}
}
const version = desktopPackage.version
expect(desktopPackage.build.artifactName).toBe('Claude-Code-Haha-${version}-${os}-${arch}.${ext}')
const expectedReleaseAssets = [
`Claude-Code-Haha-${version}-mac-arm64.dmg`,
`Claude-Code-Haha-${version}-mac-arm64.dmg.blockmap`,
`Claude-Code-Haha-${version}-mac-arm64.zip`,
`Claude-Code-Haha-${version}-mac-arm64.zip.blockmap`,
`Claude-Code-Haha-${version}-mac-x64.dmg`,
`Claude-Code-Haha-${version}-mac-x64.dmg.blockmap`,
`Claude-Code-Haha-${version}-mac-x64.zip`,
`Claude-Code-Haha-${version}-mac-x64.zip.blockmap`,
`Claude-Code-Haha-${version}-linux-x64.AppImage`,
`Claude-Code-Haha-${version}-linux-x64.AppImage.blockmap`,
`Claude-Code-Haha-${version}-linux-x64.deb`,
`Claude-Code-Haha-${version}-linux-arm64.AppImage`,
`Claude-Code-Haha-${version}-linux-arm64.AppImage.blockmap`,
`Claude-Code-Haha-${version}-linux-arm64.deb`,
`Claude-Code-Haha-${version}-win-x64.exe`,
`Claude-Code-Haha-${version}-win-x64.exe.blockmap`,
]
const namespacedMetadata = [
'latest-mac-macOS-ARM64.yml',
'latest-mac-macOS-x64.yml',
'latest-linux-Linux-x64.yml',
'latest-linux-Linux-ARM64.yml',
'latest-Windows-x64.yml',
]
const standardMetadata = [
'latest-mac.yml',
'latest-linux.yml',
'latest-linux-arm64.yml',
'latest.yml',
]
const flattenedNames = [
...expectedReleaseAssets,
...namespacedMetadata,
...standardMetadata,
]
expect(new Set(flattenedNames).size).toBe(flattenedNames.length)
expect(expectedReleaseAssets.filter((name) => name.endsWith('.dmg')).length).toBe(2)
expect(expectedReleaseAssets.filter((name) => name.endsWith('.zip')).length).toBe(2)
expect(expectedReleaseAssets.filter((name) => name.endsWith('.AppImage')).length).toBe(2)
expect(expectedReleaseAssets.filter((name) => name.endsWith('.deb')).length).toBe(2)
expect(expectedReleaseAssets.filter((name) => name.endsWith('.exe')).length).toBe(1)
for (const platform of ['mac', 'linux', 'win']) {
expect(expectedReleaseAssets.some((name) => name.includes(`-${platform}-`))).toBe(true)
}
expect(standardMetadata).toEqual([
'latest-mac.yml',
'latest-linux.yml',
'latest-linux-arm64.yml',
'latest.yml',
])
})
test('release workflow validates exact expected release assets and update metadata before publishing', () => {
const workflow = readReleaseWorkflow()
const buildJob = extractJob(workflow, 'build')
const publishJob = extractJob(workflow, 'publish-release')
const expectedFiles = [
'Claude-Code-Haha-${APP_VERSION}-mac-arm64.dmg',
'Claude-Code-Haha-${APP_VERSION}-mac-arm64.zip',
'Claude-Code-Haha-${APP_VERSION}-mac-x64.dmg',
'Claude-Code-Haha-${APP_VERSION}-mac-x64.zip',
'Claude-Code-Haha-${APP_VERSION}-linux-x64.AppImage',
'Claude-Code-Haha-${APP_VERSION}-linux-x64.deb',
'Claude-Code-Haha-${APP_VERSION}-linux-arm64.AppImage',
'Claude-Code-Haha-${APP_VERSION}-linux-arm64.deb',
'Claude-Code-Haha-${APP_VERSION}-win-x64.exe',
]
for (const file of expectedFiles) {
expect(buildJob).toContain(file)
expect(publishJob).toContain(file)
}
for (const metadata of ['latest-mac.yml', 'latest-linux.yml', 'latest-linux-arm64.yml', 'latest.yml']) {
expect(publishJob).toContain(`artifacts/update-metadata-standard/$file`)
expect(publishJob).toContain(metadata)
}
expect(buildJob).toContain('Missing release assets for %s')
expect(publishJob).toContain('Missing complete release assets')
expect(publishJob).toContain('Missing standard update metadata')
})
test('Electron Builder publish config does not rely on git remote autodetection', () => {

View File

@ -72,6 +72,7 @@ describe('DiagnosticsService', () => {
details: {
apiKey: 'sk-secret',
url: 'https://api.example.com?api_key=secret-value',
proxyUrl: 'https://proxy-user:p%40ss@example.com:8443/api',
nested: { message: `home=${os.homedir()}` },
},
})
@ -79,7 +80,10 @@ describe('DiagnosticsService', () => {
const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'diagnostics.jsonl'), 'utf-8')
expect(raw).toContain('cli_start_failed')
expect(raw).toContain('[REDACTED]')
expect(raw).toContain('https://[REDACTED]@example.com:8443/api')
expect(raw).not.toContain('sk-secret')
expect(raw).not.toContain('proxy-user')
expect(raw).not.toContain('p%40ss')
expect(raw).not.toContain(os.homedir())
const runtime = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'runtime-errors.log'), 'utf-8')

View File

@ -109,6 +109,16 @@ function spoofedLoopbackHeaders(port: string): Record<string, string> {
}
}
function localFileUrl(base: string, absPath: string): string {
const normalized = absPath.replace(/\\/g, '/')
const rooted = normalized.startsWith('/') ? normalized : `/${normalized}`
const encoded = rooted
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/')
return `${base}/local-file${encoded}`
}
async function enableH5Access(options: {
allowedOrigins?: string[]
publicBaseUrl?: string | null
@ -241,7 +251,7 @@ describe('remote H5 auth and CORS integration', () => {
await expect(assetResponse.text()).resolves.toContain('window.__h5')
})
test('finds Tauri packaged H5 resources under Resources/_up_/dist', async () => {
test('finds legacy packaged H5 resources under Resources/_up_/dist', async () => {
const appRoot = path.join(tmpDir, 'Fake.app', 'Contents', 'MacOS')
const mappedDistDir = path.join(tmpDir, 'Fake.app', 'Contents', 'Resources', '_up_', 'dist')
delete process.env.CLAUDE_H5_DIST_DIR
@ -279,31 +289,29 @@ describe('remote H5 auth and CORS integration', () => {
})
})
test('allows localhost WebUI origin without H5 token for browser development', async () => {
test('blocks localhost browser capability requests while H5 access is disabled', async () => {
const response = await fetch(`${baseUrl}/api/status`, {
headers: {
Origin: 'http://127.0.0.1:5179',
},
})
expect(response.status).toBe(200)
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('http://127.0.0.1:5179')
expect(response.status).toBe(403)
await expect(response.json()).resolves.toMatchObject({
status: 'ok',
error: 'Forbidden',
})
})
test('allows the Tauri desktop WebView origin to control the local sidecar without H5 token', async () => {
test('does not keep retired Tauri origins trusted after Electron replacement', async () => {
const response = await fetch(`${baseUrl}/api/status`, {
headers: {
Origin: 'http://tauri.localhost',
},
})
expect(response.status).toBe(200)
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('http://tauri.localhost')
expect(response.status).toBe(403)
await expect(response.json()).resolves.toMatchObject({
status: 'ok',
error: 'Forbidden',
})
})
@ -334,6 +342,39 @@ describe('remote H5 auth and CORS integration', () => {
expect(wsResponse.status).toBe(403)
})
test('blocks remote browser local-file and preview-fs requests while H5 access is disabled', async () => {
const localFileResponse = await fetch(localFileUrl(baseUrl, path.join(tmpDir, 'dist', 'index.html')), {
headers: {
Origin: PHONE_ORIGIN,
},
})
expect(localFileResponse.status).toBe(403)
const previewResponse = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
headers: {
Origin: PHONE_ORIGIN,
},
})
expect(previewResponse.status).toBe(403)
})
test('blocks loopback browser local-file and preview-fs requests while H5 access is disabled', async () => {
const loopbackBrowserOrigin = 'http://localhost:5173'
const localFileResponse = await fetch(localFileUrl(baseUrl, path.join(tmpDir, 'dist', 'index.html')), {
headers: {
Origin: loopbackBrowserOrigin,
},
})
expect(localFileResponse.status).toBe(403)
const previewResponse = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
headers: {
Origin: loopbackBrowserOrigin,
},
})
expect(previewResponse.status).toBe(403)
})
test('blocks remote browser SDK requests while H5 access is disabled', async () => {
const response = await fetch(`${baseUrl}/sdk/h5-auth-test`, {
headers: makeUpgradeHeaders(PHONE_ORIGIN),
@ -680,6 +721,75 @@ describe('remote H5 auth and CORS integration', () => {
}
})
test('requires H5 token for remote browser local-file and preview-fs requests when H5 access is enabled', async () => {
const token = await enableH5Access({
allowedOrigins: [PHONE_ORIGIN],
})
const localFile = localFileUrl(baseUrl, path.join(process.cwd(), 'package.json'))
const missingLocalFileToken = await fetch(localFile, {
headers: {
Origin: PHONE_ORIGIN,
},
})
expect(missingLocalFileToken.status).toBe(401)
const wrongLocalFileToken = await fetch(localFile, {
headers: {
Origin: PHONE_ORIGIN,
Authorization: 'Bearer wrong-token',
},
})
expect(wrongLocalFileToken.status).toBe(401)
const validLocalFileToken = await fetch(localFile, {
headers: {
Origin: PHONE_ORIGIN,
Authorization: `Bearer ${token}`,
},
})
expect(validLocalFileToken.status).toBe(200)
await expect(validLocalFileToken.text()).resolves.toContain('"name"')
const missingPreviewToken = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
headers: {
Origin: PHONE_ORIGIN,
},
})
expect(missingPreviewToken.status).toBe(401)
})
test('requires H5 token for loopback browser local-file and preview-fs requests when H5 access is enabled', async () => {
const loopbackBrowserOrigin = 'http://localhost:5173'
const token = await enableH5Access({
allowedOrigins: [loopbackBrowserOrigin],
})
const localFile = localFileUrl(baseUrl, path.join(process.cwd(), 'package.json'))
const missingLocalFileToken = await fetch(localFile, {
headers: {
Origin: loopbackBrowserOrigin,
},
})
expect(missingLocalFileToken.status).toBe(401)
const validLocalFileToken = await fetch(localFile, {
headers: {
Origin: loopbackBrowserOrigin,
Authorization: `Bearer ${token}`,
},
})
expect(validLocalFileToken.status).toBe(200)
await expect(validLocalFileToken.text()).resolves.toContain('"name"')
const missingPreviewToken = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
headers: {
Origin: loopbackBrowserOrigin,
},
})
expect(missingPreviewToken.status).toBe(401)
})
test('does not allow the server API key to replace the H5 token for remote browser requests', async () => {
process.env.ANTHROPIC_API_KEY = 'test-server-key'
await enableH5Access({
@ -750,7 +860,7 @@ describe('remote H5 auth and CORS integration', () => {
})
})
test('keeps Tauri loopback REST requests tokenless when H5 access is enabled', async () => {
test('does not keep retired Tauri loopback REST requests tokenless when H5 access is enabled', async () => {
await enableH5Access()
const response = await fetch(`${baseUrl}/api/status`, {
@ -759,7 +869,7 @@ describe('remote H5 auth and CORS integration', () => {
},
})
expect(response.status).toBe(200)
expect(response.status).toBe(403)
})
test('keeps local loopback websocket and SDK requests tokenless when H5 access is enabled', async () => {
@ -788,6 +898,15 @@ describe('remote H5 auth and CORS integration', () => {
}
})
test('keeps local loopback local-file navigations tokenless when H5 access is enabled', async () => {
await enableH5Access()
const response = await fetch(localFileUrl(baseUrl, path.join(process.cwd(), 'package.json')))
expect(response.status).toBe(200)
await expect(response.text()).resolves.toContain('"name"')
})
test('blocks adapter requests from non-local browser origins when H5 access is enabled', async () => {
await enableH5Access()

View File

@ -21,8 +21,8 @@ describe('h5AccessPolicy', () => {
expect(isLoopbackHost('192.168.0.20')).toBe(false)
})
test('keeps desktop WebView requests to loopback tokenless', () => {
for (const origin of ['file://', 'http://tauri.localhost']) {
test('keeps Electron desktop WebView requests to loopback tokenless', () => {
for (const origin of ['file://']) {
const request = req('http://127.0.0.1:3456/api/status', {
headers: { Origin: origin },
})
@ -31,6 +31,16 @@ describe('h5AccessPolicy', () => {
}
})
test('does not keep retired Tauri origins trusted after Electron replacement', () => {
for (const origin of ['http://tauri.localhost', 'https://tauri.localhost', 'tauri://localhost']) {
const request = req('http://127.0.0.1:3456/api/status', {
headers: { Origin: origin },
})
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser')
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(true)
}
})
test('keeps local internal SDK websocket routes tokenless', () => {
const request = req('http://127.0.0.1:3456/sdk/session-1')
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('internal-sdk')
@ -49,9 +59,32 @@ describe('h5AccessPolicy', () => {
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(false)
})
test('does not trust loopback adapter requests from non-local browser origins', () => {
test('does not trust loopback browser origins for H5 capability routes', () => {
for (const pathname of [
'/api/status',
'/proxy/openai/v1/chat/completions',
'/ws/session-1',
'/local-file/Users/alice/report.html',
'/preview-fs/session-1/index.html',
]) {
const request = req(`http://127.0.0.1:3456${pathname}`, {
headers: { Origin: 'http://localhost:5173' },
})
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser')
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(true)
expect(shouldBlockDisabledH5Access({
request,
url: new URL(request.url),
h5Enabled: false,
explicitAuthRequired: false,
context: localContext,
})).toBe(true)
}
})
test('does not trust adapter requests from browser origins', () => {
const request = req('http://127.0.0.1:3456/api/adapters', {
headers: { Origin: 'https://blocked.example.com' },
headers: { Origin: 'http://localhost:5173' },
})
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser')
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(true)
@ -72,7 +105,7 @@ describe('h5AccessPolicy', () => {
})
test('keeps local desktop chat websocket routes tokenless', () => {
for (const init of [{}, { headers: { Origin: 'file://' } }, { headers: { Origin: 'http://tauri.localhost' } }]) {
for (const init of [{}, { headers: { Origin: 'file://' } }]) {
const request = req('http://127.0.0.1:3456/ws/session-1', init)
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('local-trusted')
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(false)
@ -85,6 +118,8 @@ describe('h5AccessPolicy', () => {
'/api/mcp',
'/api/plugins',
'/api/agents',
'/local-file/Users/alice/report.html',
'/preview-fs/session-1/index.html',
'/proxy/openai/v1/chat/completions',
'/ws/session-1',
]) {
@ -102,6 +137,8 @@ describe('h5AccessPolicy', () => {
'/api/mcp',
'/api/plugins',
'/api/agents',
'/local-file/Users/alice/report.html',
'/preview-fs/session-1/index.html',
'/proxy/openai/v1/chat/completions',
'/ws/session-1',
'/sdk/session-1',
@ -119,8 +156,27 @@ describe('h5AccessPolicy', () => {
}
})
test('keeps local capability routes and static bootstrap routes available while H5 access is disabled', () => {
for (const pathname of ['/api/status', '/proxy/openai/v1/chat/completions', '/ws/session-1', '/sdk/session-1']) {
test('keeps local non-filesystem capability routes and static bootstrap routes available while H5 access is disabled', () => {
for (const pathname of [
'/api/status',
'/proxy/openai/v1/chat/completions',
'/ws/session-1',
'/sdk/session-1',
]) {
const request = req(`http://127.0.0.1:3456${pathname}`)
expect(shouldBlockDisabledH5Access({
request,
url: new URL(request.url),
h5Enabled: false,
explicitAuthRequired: false,
context: localContext,
})).toBe(false)
}
for (const pathname of [
'/local-file/Users/alice/report.html',
'/preview-fs/session-1/index.html',
]) {
const request = req(`http://127.0.0.1:3456${pathname}`)
expect(shouldBlockDisabledH5Access({
request,

View File

@ -4,12 +4,7 @@ export type H5RequestContext = {
}
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
const LOCAL_ORIGINS = new Set([
'file://',
'http://tauri.localhost',
'https://tauri.localhost',
'tauri://localhost',
])
const LOCAL_DESKTOP_ORIGINS = new Set(['file://'])
export function normalizeHostname(hostname: string): string {
return hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
@ -23,15 +18,14 @@ export function isLoopbackHost(hostname: string): boolean {
return LOCAL_HOSTS.has(normalized)
}
function isLocalOrigin(origin: string | null): boolean {
function isLocalDesktopOrNavigationOrigin(origin: string | null): boolean {
if (!origin) return true
if (LOCAL_ORIGINS.has(origin)) return true
return LOCAL_DESKTOP_ORIGINS.has(origin)
}
try {
return isLoopbackHost(new URL(origin).hostname)
} catch {
return false
}
function isFilesystemCapabilityPath(pathname: string): boolean {
return pathname.startsWith('/local-file/') ||
pathname.startsWith('/preview-fs/')
}
export function classifyH5Request(
@ -39,9 +33,18 @@ export function classifyH5Request(
url: URL,
context: H5RequestContext,
): H5RequestKind {
const origin = request.headers.get('Origin')
if (isFilesystemCapabilityPath(url.pathname)) {
const localFilesystemTrusted = Boolean(context.clientAddress) &&
isLoopbackHost(context.clientAddress!) &&
isLocalDesktopOrNavigationOrigin(origin)
return localFilesystemTrusted ? 'local-trusted' : 'h5-browser'
}
const localTrusted = Boolean(context.clientAddress) &&
isLoopbackHost(context.clientAddress!) &&
isLocalOrigin(request.headers.get('Origin'))
isLocalDesktopOrNavigationOrigin(origin)
if (url.pathname.startsWith('/sdk/') && localTrusted) {
return 'internal-sdk'
@ -102,6 +105,7 @@ export function shouldBlockDisabledH5Access({
function isH5ProtectedCapabilityPath(pathname: string): boolean {
return pathname.startsWith('/api/') ||
isFilesystemCapabilityPath(pathname) ||
pathname.startsWith('/proxy/') ||
pathname.startsWith('/ws/') ||
pathname.startsWith('/sdk/')
@ -109,6 +113,7 @@ function isH5ProtectedCapabilityPath(pathname: string): boolean {
function isH5BrowserCapabilityPath(pathname: string): boolean {
return pathname.startsWith('/api/') ||
isFilesystemCapabilityPath(pathname) ||
pathname.startsWith('/proxy/') ||
pathname.startsWith('/ws/')
}

View File

@ -7,10 +7,8 @@ describe('corsHeaders', () => {
expect(corsHeaders('http://localhost:3000')['Access-Control-Allow-Origin']).toBe('http://localhost:3000')
})
it('allows tauri webview origins used in production builds', () => {
expect(corsHeaders('http://tauri.localhost')['Access-Control-Allow-Origin']).toBe('http://tauri.localhost')
expect(corsHeaders('https://tauri.localhost')['Access-Control-Allow-Origin']).toBe('https://tauri.localhost')
expect(corsHeaders('tauri://localhost')['Access-Control-Allow-Origin']).toBe('tauri://localhost')
it('echoes explicit origins for open H5 responses', () => {
expect(corsHeaders('https://example.com')['Access-Control-Allow-Origin']).toBe('https://example.com')
})
it('allows arbitrary origins while H5 access is open', () => {
@ -74,7 +72,7 @@ describe('resolveCors', () => {
})
it('keeps trusted local desktop origins allowed when H5 token mode is active', async () => {
for (const origin of ['file://', 'http://tauri.localhost', 'http://127.0.0.1:5179']) {
for (const origin of ['file://']) {
const result = await resolveCors(origin, 'http://192.168.0.20:3456', {
h5Enabled: true,
isOriginAllowed: async () => false,
@ -86,6 +84,32 @@ describe('resolveCors', () => {
}
})
it('does not keep loopback browser origins allowed when H5 token mode is active', async () => {
for (const origin of ['http://localhost:5173', 'http://127.0.0.1:5179']) {
const result = await resolveCors(origin, 'http://192.168.0.20:3456', {
h5Enabled: true,
isOriginAllowed: async () => false,
})
expect(result.allowed).toBe(false)
expect(result.rejected).toBe(true)
expect(result.headers['Access-Control-Allow-Origin']).toBeUndefined()
}
})
it('does not keep retired Tauri origins allowed when H5 token mode is active', async () => {
for (const origin of ['http://tauri.localhost', 'https://tauri.localhost', 'tauri://localhost']) {
const result = await resolveCors(origin, 'http://192.168.0.20:3456', {
h5Enabled: true,
isOriginAllowed: async () => false,
})
expect(result.allowed).toBe(false)
expect(result.rejected).toBe(true)
expect(result.headers['Access-Control-Allow-Origin']).toBeUndefined()
}
})
it('does not trust non-local same-origin requests unless explicitly configured', async () => {
const result = await resolveCors('http://192.168.0.20:3456', 'http://192.168.0.20:3456', {
h5Enabled: true,

View File

@ -2,8 +2,6 @@
* CORS middleware for desktop and temporary open H5 access.
*/
import { isLoopbackHost } from '../h5AccessPolicy.js'
export function corsHeaders(origin?: string | null): Record<string, string> {
const allowedOrigin = origin || 'http://localhost:3000'
return {
@ -35,27 +33,14 @@ export type CorsResolutionOptions = {
isOriginAllowed?: (origin: string) => Promise<boolean>
}
const LOCAL_ORIGINS = new Set([
'file://',
'http://tauri.localhost',
'https://tauri.localhost',
'tauri://localhost',
])
const LOCAL_DESKTOP_ORIGINS = new Set(['file://'])
function isLocalOrigin(origin?: string | null): boolean {
if (!origin) {
return true
}
if (LOCAL_ORIGINS.has(origin)) {
return true
}
try {
return isLoopbackHost(new URL(origin).hostname)
} catch {
return false
}
return LOCAL_DESKTOP_ORIGINS.has(origin)
}
export async function resolveCors(

View File

@ -313,6 +313,7 @@ export class DiagnosticsService {
sanitizeString(value: string, maxLength = MAX_STRING_LENGTH): string {
let sanitized = value
.replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/gi, '$1[REDACTED]')
.replace(/([a-z][a-z0-9+.-]*:\/\/)([^/?#\s:@]+(?::[^/?#\s@]*)?@)/gi, '$1[REDACTED]@')
.replace(/((?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|token|secret|password)\s*[:=]\s*)[^\s,;"'}]+/gi, '$1[REDACTED]')
.replace(/(ANTHROPIC_(?:API_KEY|AUTH_TOKEN)\s*[:=]\s*)[^\s,;"'}]+/gi, '$1[REDACTED]')
.replace(/([?&](?:api[_-]?key|token|auth|access_token|refresh_token|key)=)[^&\s]+/gi, '$1[REDACTED]')