mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
16f4137954
commit
81845fbc49
192
.github/workflows/release-desktop.yml
vendored
192
.github/workflows/release-desktop.yml
vendored
@ -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
|
||||
|
||||
@ -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=="],
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 />)
|
||||
|
||||
@ -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 />)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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'}`}
|
||||
/>
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,所以这个字段在桌面
|
||||
运行时完全不会被读到。用户也根本不知道该填什么端口(每次启动随机)。
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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__')
|
||||
})
|
||||
|
||||
|
||||
@ -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"] {
|
||||
|
||||
@ -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/
|
||||
|
||||
@ -67,7 +67,7 @@ H5 的第一版优先保证聊天主流程:
|
||||
- `@` 文件菜单会适配手机宽度。
|
||||
- Workspace 面板和底部 Terminal 面板在手机宽度下不作为主流程显示。
|
||||
|
||||
桌面端和 Tauri 应用仍保留原来的布局和交互。
|
||||
Electron 桌面端仍保留原来的布局和交互。
|
||||
|
||||
## 安全注意
|
||||
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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 适配器
|
||||
|
||||
@ -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/
|
||||
---
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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/')
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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]')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user