feat(desktop): enable Electron migration path

Introduce the Electron desktop shell alongside the existing React renderer and local Bun server boundary. The migration keeps the DesktopHost contract explicit across Tauri, Electron, and browser runtimes while adding Electron main/preload services for dialogs, shell, notifications, updates, tray/window lifecycle, terminal, preview WebContentsView, app mode, and release/package validation.

The commit also carries the latest local main desktop command updates, including agent slash entries and hidden-by-default markdown thinking details, so the packaged Electron build matches the current main UX surface.

Constraint: React renderer, local Bun server, REST/WebSocket, and sidecar boundaries must remain reusable during the migration
Constraint: macOS dev packages are ad-hoc signed and cannot prove Developer ID notarization or Gatekeeper release launch
Rejected: Browser-only smoke validation | it cannot exercise native dialogs, keychain prompts, notification behavior, or packaged app startup
Confidence: medium
Scope-risk: broad
Directive: Do not remove Tauri host support until signed Electron release artifacts pass native OS smoke on macOS, Windows, and Linux
Tested: bun run check:desktop
Tested: cd desktop && bun run check:electron
Tested: CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir
Tested: bun run test:package-smoke --platform macos --package-kind dir --artifacts-dir desktop/build-artifacts/electron
Tested: Computer Use read packaged Electron app window at desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app
Not-tested: Developer ID signed/notarized Gatekeeper launch
Not-tested: Real OS notification click-to-session action
Not-tested: Windows and Linux packaged app smoke on real hosts
This commit is contained in:
程序员阿江(Relakkes)
2026-06-01 22:43:16 +08:00
parent ebc78befbe
commit 386a41e606
203 changed files with 14661 additions and 1439 deletions
+21 -130
View File
@@ -1,6 +1,5 @@
name: Build Desktop (Dev)
# 开发阶段构建 — 不创建 Release,只上传 Artifacts 供下载测试
on:
workflow_dispatch:
inputs:
@@ -25,7 +24,6 @@ concurrency:
cancel-in-progress: true
jobs:
# ── 动态生成构建矩阵 ──────────────────────────────────────
prepare:
runs-on: ubuntu-latest
outputs:
@@ -33,8 +31,7 @@ jobs:
steps:
- id: set-matrix
run: |
ALL='[{"platform":"macos-latest","rust_target":"aarch64-apple-darwin","tauri_args":"--target aarch64-apple-darwin","bundles":"app,dmg","label":"macOS-ARM64","artifact_name":"macos-arm64","asset_platform":"macos","asset_arch":"arm64"},{"platform":"macos-latest","rust_target":"x86_64-apple-darwin","tauri_args":"--target x86_64-apple-darwin","bundles":"app,dmg","label":"macOS-x64","artifact_name":"macos-x64","asset_platform":"macos","asset_arch":"x64"},{"platform":"windows-latest","rust_target":"x86_64-pc-windows-msvc","tauri_args":"","bundles":"msi","label":"Windows-x64","artifact_name":"windows-x64","asset_platform":"windows","asset_arch":"x64"},{"platform":"ubuntu-22.04","rust_target":"x86_64-unknown-linux-gnu","tauri_args":"","bundles":"deb","label":"Linux-x64","artifact_name":"linux-x64","asset_platform":"linux","asset_arch":"x64"},{"platform":"ubuntu-22.04-arm","rust_target":"aarch64-unknown-linux-gnu","tauri_args":"","bundles":"deb","label":"Linux-ARM64","artifact_name":"linux-arm64","asset_platform":"linux","asset_arch":"arm64"}]'
ALL='[{"platform":"macos-latest","target_triple":"aarch64-apple-darwin","builder_args":"--mac dmg zip --arm64","label":"macOS-ARM64","artifact_name":"macos-arm64","smoke_platform":"macos"},{"platform":"macos-latest","target_triple":"x86_64-apple-darwin","builder_args":"--mac dmg zip --x64","label":"macOS-x64","artifact_name":"macos-x64","smoke_platform":"macos"},{"platform":"windows-latest","target_triple":"x86_64-pc-windows-msvc","builder_args":"--win nsis --x64","label":"Windows-x64","artifact_name":"windows-x64","smoke_platform":"windows"},{"platform":"ubuntu-22.04","target_triple":"x86_64-unknown-linux-gnu","builder_args":"--linux AppImage deb --x64","label":"Linux-x64","artifact_name":"linux-x64","smoke_platform":"linux"},{"platform":"ubuntu-22.04-arm","target_triple":"aarch64-unknown-linux-gnu","builder_args":"--linux AppImage deb --arm64","label":"Linux-ARM64","artifact_name":"linux-arm64","smoke_platform":"linux"}]'
SELECTED="${{ inputs.platforms }}"
if [ "$SELECTED" = "all" ]; then
MATRIX=$(echo "$ALL" | jq -c '{include: .}')
@@ -49,7 +46,6 @@ jobs:
fi
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
# ── 构建 ────────────────────────────────────────────────────
build:
needs: prepare
strategy:
@@ -63,54 +59,29 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
# ── 读取版本号 ───────────────────────────────────────────
- name: Read version
id: version
shell: bash
run: |
VERSION=$(jq -r '.version' desktop/src-tauri/tauri.conf.json)
VERSION=$(node -p "require('./desktop/package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# ── 系统依赖 (Linux) ─────────────────────────────────────
# libfuse2 是 AppImage 运行时依赖:linuxdeploy 工具链本身是 AppImage 格式,
# 运行时需要 libfuse2,Ubuntu 22.04 默认未安装。
- name: Install Linux dependencies
if: contains(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential curl wget file \
libxdo-dev libssl-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev patchelf \
libfuse2
sudo apt-get install -y build-essential curl wget file libfuse2
# ── Bun ──────────────────────────────────────────────────
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# ── Node.js ──────────────────────────────────────────────
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
# ── Rust ─────────────────────────────────────────────────
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: 'desktop/src-tauri -> target'
shared-key: ${{ matrix.rust_target }}
# ── 安装依赖 ─────────────────────────────────────────────
- name: Install root dependencies
run: bun install
@@ -122,104 +93,42 @@ jobs:
working-directory: adapters
run: bun install
# ── 构建 sidecars ────────────────────────────────────────
- name: Build sidecars
working-directory: desktop
env:
BUN_INSTALL_CACHE_DIR: ${{ runner.temp }}/bun-install-cache
TAURI_ENV_TARGET_TRIPLE: ${{ matrix.rust_target }}
SIDECAR_TARGET_TRIPLE: ${{ matrix.target_triple }}
run: bun run build:sidecars
# ── 构建 Tauri (无签名、无 updater) ──────────────────────
- name: Build Tauri app
- name: Build renderer and Electron bundles
working-directory: desktop
run: |
bun run build
bun run build:electron
- name: Build Electron app
working-directory: desktop
env:
BUN_INSTALL_CACHE_DIR: ${{ runner.temp }}/bun-install-cache
TAURI_ENV_TARGET_TRIPLE: ${{ matrix.rust_target }}
run: |
# 开发构建: 禁用 updater artifacts (不需要签名密钥)
echo '{"bundle":{"createUpdaterArtifacts":false}}' > /tmp/dev-build.json
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: bunx electron-builder ${{ matrix.builder_args }} --publish never
ARGS=(bunx tauri build --bundles ${{ matrix.bundles }} --ci --config /tmp/dev-build.json)
- name: Verify packaged app structure
run: bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --package-kind release --artifacts-dir desktop/build-artifacts/electron
if [ -n "${{ matrix.tauri_args }}" ]; then
ARGS+=(${{ matrix.tauri_args }})
fi
"${ARGS[@]}"
shell: bash
# ── 收集产物 ─────────────────────────────────────────────
- name: Collect artifacts
id: collect
shell: bash
run: |
BUNDLE_DIR="desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
FALLBACK_DIR="desktop/src-tauri/target/release/bundle"
# 优先使用带 target 的路径
if [ -d "$BUNDLE_DIR" ]; then
SEARCH_DIR="$BUNDLE_DIR"
else
SEARCH_DIR="$FALLBACK_DIR"
fi
STAGING="desktop/build-artifacts/ci-Claude-Code-Haha-v${{ steps.version.outputs.version }}-${{ matrix.artifact_name }}"
mkdir -p "$STAGING"
echo "Searching for artifacts in: $SEARCH_DIR"
if [ -d "$SEARCH_DIR" ]; then
find "$SEARCH_DIR" \
\( -type f \( \
-name "*.dmg" -o -name "*.msi" -o \
-name "*.deb" -o -name "*.AppImage" -o -name "*.zip" \
\) \) -o \
\( -type d -name "*.app" \) | while read -r f; do
base="$(basename "$f")"
bundle="artifact"
ext=""
case "$base" in
*.app)
bundle="app"
ext=".app"
;;
*.dmg)
bundle="dmg"
ext=".dmg"
;;
*.msi)
bundle="msi"
ext=".msi"
;;
*.deb)
bundle="deb"
ext=".deb"
;;
*.AppImage)
bundle="appimage"
ext=".AppImage"
;;
*.zip)
bundle="zip"
ext=".zip"
;;
*)
ext=""
;;
esac
target="Claude-Code-Haha_${{ steps.version.outputs.version }}_${{ matrix.asset_platform }}_${{ matrix.asset_arch }}_${bundle}${ext}"
echo " Copying: ${base} -> ${target}"
cp -R "$f" "$STAGING/$target"
find desktop/build-artifacts/electron -maxdepth 2 \
\( -type f \( -name "*.dmg" -o -name "*.exe" -o -name "*.deb" -o -name "*.AppImage" -o -name "*.zip" -o -name "*.blockmap" -o -name "*.yml" \) \) -o \
\( -type d -name "*.app" \) | while read -r artifact; do
cp -R "$artifact" "$STAGING/"
done
fi
echo "staging_dir=$STAGING" >> "$GITHUB_OUTPUT"
echo "=== Collected artifacts ==="
ls -lh "$STAGING/" 2>/dev/null || echo "(empty)"
ls -lh "$STAGING/" 2>/dev/null || true
# ── 上传为 GitHub Artifact ───────────────────────────────
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -227,21 +136,3 @@ jobs:
path: ${{ steps.collect.outputs.staging_dir }}
retention-days: 7
if-no-files-found: warn
# ── 构建完成汇总 ────────────────────────────────────────────
summary:
needs: build
runs-on: ubuntu-latest
if: always()
steps:
- name: Build summary
run: |
echo "## Desktop Dev Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Platform | Status |" >> $GITHUB_STEP_SUMMARY
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "Branch: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📦 Artifacts are available for download from the **Artifacts** section above." >> $GITHUB_STEP_SUMMARY
echo "Artifacts will be retained for **7 days**." >> $GITHUB_STEP_SUMMARY
+1 -16
View File
@@ -134,10 +134,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y \
build-essential curl wget file \
libxdo-dev libssl-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev patchelf \
libxdo-dev libssl-dev patchelf \
libfuse2
- name: Setup Bun
@@ -145,14 +142,6 @@ jobs:
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: 'desktop/src-tauri -> target'
- name: Install root dependencies
run: bun install
@@ -160,10 +149,6 @@ jobs:
working-directory: desktop
run: bun install
- name: Install adapter dependencies
working-directory: adapters
run: bun install
- name: Run desktop native checks
run: bun run check:native
+6 -5
View File
@@ -70,9 +70,10 @@ jobs:
'scripts/pr/check-pr.ts',
'scripts/pr/run-server-tests.ts',
'scripts/release.ts',
'desktop/src-tauri/tauri.conf.json',
'desktop/src-tauri/Cargo.toml',
'desktop/src-tauri/Cargo.lock',
'desktop/electron/tsconfig.json',
'desktop/scripts/build-linux.sh',
'desktop/scripts/build-macos-arm64.sh',
'desktop/scripts/build-windows-x64.ps1',
])
for (const filename of filenames) {
@@ -145,7 +146,7 @@ jobs:
file.startsWith('desktop/') ||
file.startsWith('adapters/') ||
file.startsWith('src/server/') ||
['bun.lock', 'package.json', 'desktop/bun.lock', 'desktop/package.json', 'desktop/package-lock.json', 'desktop/src-tauri/Cargo.lock', 'desktop/src-tauri/Cargo.toml', 'desktop/src-tauri/tauri.conf.json'].includes(file)
['bun.lock', 'package.json', 'desktop/bun.lock', 'desktop/package.json', 'desktop/package-lock.json', 'desktop/electron/tsconfig.json', 'desktop/scripts/build-linux.sh', 'desktop/scripts/build-macos-arm64.sh', 'desktop/scripts/build-windows-x64.ps1'].includes(file)
)
) requiredChecks.push('desktop-native-checks')
if (areas.has('area:docs')) requiredChecks.push('docs-checks')
@@ -172,7 +173,7 @@ jobs:
const riskNotes = []
if (filenames.some((file) => file.startsWith('desktop/src-tauri/'))) {
riskNotes.push('Tauri/native code changed: inspect sidecar build and cargo check.')
riskNotes.push('Legacy native resources changed: inspect Electron sidecar/package output and package-smoke results.')
}
if (filenames.some((file) => file.startsWith('desktop/src/stores/') || file.startsWith('desktop/src/api/'))) {
riskNotes.push('Desktop state/API layer changed: verify store persistence, WebSocket behavior, and startup errors.')
+175 -74
View File
@@ -19,45 +19,96 @@ concurrency:
cancel-in-progress: true
jobs:
quality-preflight:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install root dependencies
run: bun install
- name: Install desktop dependencies
working-directory: desktop
run: bun install
- name: Install adapter dependencies
working-directory: adapters
run: bun install
- name: Verify PR-quality gate before release packaging
run: bun run verify
signing-preflight:
runs-on: ubuntu-latest
steps:
- name: Validate release signing and notarization secrets
shell: bash
env:
CSC_LINK: ${{ secrets.MACOS_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WIN_CSC_LINK: ${{ secrets.WINDOWS_CERTIFICATE }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: |
missing=()
[ -n "$CSC_LINK" ] || missing+=("MACOS_CERTIFICATE")
[ -n "$CSC_KEY_PASSWORD" ] || missing+=("MACOS_CERTIFICATE_PASSWORD")
[ -n "$APPLE_ID" ] || missing+=("APPLE_ID")
[ -n "$APPLE_APP_SPECIFIC_PASSWORD" ] || missing+=("APPLE_APP_SPECIFIC_PASSWORD")
[ -n "$APPLE_TEAM_ID" ] || missing+=("APPLE_TEAM_ID")
[ -n "$WIN_CSC_LINK" ] || missing+=("WINDOWS_CERTIFICATE")
[ -n "$WIN_CSC_KEY_PASSWORD" ] || missing+=("WINDOWS_CERTIFICATE_PASSWORD")
if [ "${#missing[@]}" -gt 0 ]; then
printf '::error::Missing required release signing/notarization secrets: %s\n' "${missing[*]}"
exit 1
fi
build:
needs:
- quality-preflight
- signing-preflight
strategy:
fail-fast: false
matrix:
include:
# macOS Apple Silicon
- platform: macos-latest
rust_target: aarch64-apple-darwin
tauri_args: '--target aarch64-apple-darwin'
target_triple: aarch64-apple-darwin
builder_args: --mac dmg zip --arm64
label: macOS-ARM64
asset_name_pattern: 'Claude-Code-Haha_[version]_macos_arm64_[bundle][ext]'
# macOS Intel
smoke_platform: macos
- platform: macos-latest
rust_target: x86_64-apple-darwin
tauri_args: '--target x86_64-apple-darwin'
target_triple: x86_64-apple-darwin
builder_args: --mac dmg zip --x64
label: macOS-x64
asset_name_pattern: 'Claude-Code-Haha_[version]_macos_x64_[bundle][ext]'
# Linux x64
smoke_platform: macos
- platform: ubuntu-22.04
rust_target: x86_64-unknown-linux-gnu
tauri_args: '--bundles deb'
target_triple: x86_64-unknown-linux-gnu
builder_args: --linux AppImage deb --x64
label: Linux-x64
asset_name_pattern: 'Claude-Code-Haha_[version]_linux_x64_[bundle][ext]'
# Linux ARM64
smoke_platform: linux
- platform: ubuntu-22.04-arm
rust_target: aarch64-unknown-linux-gnu
tauri_args: '--bundles deb'
target_triple: aarch64-unknown-linux-gnu
builder_args: --linux AppImage deb --arm64
label: Linux-ARM64
asset_name_pattern: 'Claude-Code-Haha_[version]_linux_arm64_[bundle][ext]'
# Windows x64
smoke_platform: linux
- platform: windows-latest
rust_target: x86_64-pc-windows-msvc
tauri_args: '--bundles nsis'
target_triple: x86_64-pc-windows-msvc
builder_args: --win nsis --x64
label: Windows-x64
asset_name_pattern: 'Claude-Code-Haha_[version]_windows_x64_[bundle][ext]'
smoke_platform: windows
runs-on: ${{ matrix.platform }}
name: Build (${{ matrix.label }})
@@ -70,7 +121,7 @@ jobs:
id: version
shell: bash
run: |
VERSION=$(jq -r '.version' desktop/src-tauri/tauri.conf.json)
VERSION=$(node -p "require('./desktop/package.json').version")
echo "value=$VERSION" >> "$GITHUB_OUTPUT"
- name: Validate tag matches version
@@ -92,7 +143,6 @@ jobs:
echo "::error::Missing release notes file: $NOTES_FILE"
exit 1
fi
{
echo 'body<<__RELEASE_NOTES__'
cat "$NOTES_FILE"
@@ -100,49 +150,22 @@ jobs:
echo '__RELEASE_NOTES__'
} >> "$GITHUB_OUTPUT"
# ── System dependencies (Linux) ──────────────────────────
- name: Install Linux dependencies
if: contains(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
curl \
wget \
file \
libxdo-dev \
libssl-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
libfuse2
sudo apt-get install -y build-essential curl wget file libfuse2
# ── Bun ──────────────────────────────────────────────────
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# ── Node.js (for tauri-action compatibility) ─────────────
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
# ── Rust ─────────────────────────────────────────────────
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: 'desktop/src-tauri -> target'
shared-key: ${{ matrix.rust_target }}
# ── Install dependencies ─────────────────────────────────
- name: Install root dependencies
run: bun install
@@ -154,31 +177,109 @@ jobs:
working-directory: adapters
run: bun install
# ── Build sidecars ───────────────────────────────────────
- name: Build sidecars
working-directory: desktop
env:
BUN_INSTALL_CACHE_DIR: ${{ runner.temp }}/bun-install-cache
TAURI_ENV_TARGET_TRIPLE: ${{ matrix.rust_target }}
SIDECAR_TARGET_TRIPLE: ${{ matrix.target_triple }}
run: bun run build:sidecars
# ── Build Tauri app ──────────────────────────────────────
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
- name: Build renderer and Electron bundles
working-directory: desktop
run: |
bun run build
bun run build:electron
- name: Build Electron release artifacts
working-directory: desktop
env:
BUN_INSTALL_CACHE_DIR: ${{ runner.temp }}/bun-install-cache
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_ENV_TARGET_TRIPLE: ${{ matrix.rust_target }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MACOS_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WIN_CSC_LINK: ${{ secrets.WINDOWS_CERTIFICATE }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: bunx electron-builder ${{ matrix.builder_args }} --publish never
- name: Verify packaged app structure
run: bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --package-kind release --artifacts-dir desktop/build-artifacts/electron
- name: Verify macOS launch policy
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: Namespace update metadata assets
shell: bash
working-directory: desktop/build-artifacts/electron
run: |
shopt -s nullglob
for file in latest*.yml; do
mv "$file" "${file%.yml}-${{ matrix.label }}.yml"
done
- name: Upload update metadata for merge
uses: actions/upload-artifact@v4
with:
projectPath: desktop
tauriScript: bunx tauri
tagName: v__VERSION__
releaseName: 'Claude Code Haha v__VERSION__'
releaseBody: ${{ steps.release_notes.outputs.body }}
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && inputs.draft || false }}
name: desktop-update-metadata-${{ matrix.label }}
path: desktop/build-artifacts/electron/latest*.yml
if-no-files-found: ignore
- name: Upload artifacts
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
updaterJsonPreferNsis: true
assetNamePattern: ${{ matrix.asset_name_pattern }}
args: ${{ matrix.tauri_args }} --ci --config src-tauri/tauri.release-ci.json
files: |
desktop/build-artifacts/electron/*.dmg
desktop/build-artifacts/electron/*.zip
desktop/build-artifacts/electron/*.exe
desktop/build-artifacts/electron/*.AppImage
desktop/build-artifacts/electron/*.deb
desktop/build-artifacts/electron/*.blockmap
desktop/build-artifacts/electron/*.yml
publish-update-metadata:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Read version
id: version
shell: bash
run: |
VERSION=$(node -p "require('./desktop/package.json').version")
echo "value=$VERSION" >> "$GITHUB_OUTPUT"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install root dependencies
run: bun install
- name: Download update metadata artifacts
uses: actions/download-artifact@v4
with:
pattern: desktop-update-metadata-*
path: artifacts/update-metadata
merge-multiple: true
- 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
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.value }}
draft: ${{ github.event_name == 'workflow_dispatch' && inputs.draft || false }}
prerelease: false
files: artifacts/update-metadata-standard/*.yml
+4 -4
View File
@@ -27,7 +27,7 @@ This is a Bun-based Coding Agent product with a CLI, local server, desktop app,
- `bin/claude-haha` is the executable entrypoint; `bun run start` and `./bin/claude-haha` run the CLI locally.
- `src/` contains the CLI/runtime surface: `entrypoints/` for startup paths, `screens/` and `components/` for the Ink TUI, `commands/` for slash commands, `services/` for API/MCP/OAuth logic, `tools/` for agent tools, `utils/` for shared runtime helpers, and `server/` for the local API/WebSocket service.
- `desktop/` contains the desktop product: React UI in `desktop/src/`, API clients in `desktop/src/api/`, shared UI in `desktop/src/components/`, Tauri/Rust glue in `desktop/src-tauri/`, and desktop build scripts in `desktop/scripts/`.
- `desktop/` contains the desktop product: React UI in `desktop/src/`, API clients in `desktop/src/api/`, shared UI in `desktop/src/components/`, Electron host code in `desktop/electron/`, legacy Tauri resources/sidecar assets in `desktop/src-tauri/`, and desktop build scripts in `desktop/scripts/`.
- `adapters/` contains IM adapter sidecars for Telegram, Feishu, WeChat, DingTalk, and shared adapter utilities.
- `docs/` and `docs/en/` are VitePress documentation. Root screenshots and `docs/images/` are reference assets unless a task explicitly updates docs media.
- `release-notes/`, `scripts/release.ts`, and `.github/workflows/` define release and CI behavior. Treat workflow changes as product changes because they alter what future agents and contributors can safely ship.
@@ -54,7 +54,7 @@ Use the narrowest meaningful verification while iterating, then run the correct
| Desktop UI/store/API work | `bun run check:desktop` | Runs desktop lint, Vitest, and production build. For visible UI flows, also use browser/agent-browser smoke when unit tests cannot prove the workflow. |
| Server/API/provider/runtime/MCP/OAuth/WebSocket work | `bun run check:server` | Covers `src/server`, `src/tools`, provider/runtime, MCP, OAuth, WebSocket, and API behavior. |
| IM adapter work | `bun run check:adapters` | On a fresh checkout, run `cd adapters && bun install` first if dependencies are missing. |
| Tauri/native/sidecar/packaging/version changes | `bun run check:native` | Runs sidecar build and `cargo check` for the desktop native surface. |
| Electron/native/sidecar/packaging/version changes | `bun run check:native` | Runs sidecar build, Electron host checks, Electron `--dir` packaging, and current-platform package-smoke. |
| Docs, README, release notes, or docs workflow changes | `bun run check:docs` | This runs `npm ci`; run it sequentially, not in parallel with commands that depend on root `node_modules`. |
| Persistence shape changes | `bun run check:persistence-upgrade` | Required for local JSON, `localStorage`, app config migrations, and old-fixture upgrade behavior. |
| Coverage during handoff | `bun run check:coverage` or `bun run verify` | Changed executable production lines must meet the changed-line threshold. |
@@ -96,10 +96,10 @@ Every feature, bugfix, and behavior change must ship with proof that matches the
## Release Workflow
- Desktop releases are built remotely by GitHub Actions from tags matching `v*.*.*`; do not upload local build artifacts as the release source of truth.
- The release workflow `.github/workflows/release-desktop.yml` validates that the tag matches `desktop/src-tauri/tauri.conf.json`, loads `release-notes/vX.Y.Z.md`, builds sidecars, and packages the desktop app across the matrix.
- The release workflow `.github/workflows/release-desktop.yml` runs a non-live PR-quality preflight, validates that the tag matches `desktop/package.json`, loads `release-notes/vX.Y.Z.md`, builds sidecars, and packages the Electron desktop app across the matrix.
- The hosted tag workflow is not a substitute for local release verification. Before tagging or calling a release ready, run `bun run scripts/release.ts <version> --dry`, then run `bun run verify`, and run `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>` when live provider access is available.
- GitHub Release body is sourced from `release-notes/vX.Y.Z.md` in the tagged commit. Keep the filename, app version, and tag aligned exactly.
- Use `bun run scripts/release.ts <version>` to cut a desktop release. The script updates version files, refreshes `desktop/src-tauri/Cargo.lock`, requires the matching release-notes file, commits it, and creates the annotated tag.
- Use `bun run scripts/release.ts <version>` to cut a desktop release. The script updates Electron desktop version files, requires the matching release-notes file, commits it, and creates the annotated tag.
- The normal release push is `git push origin main --tags`. If no live provider is configured, or a provider quota/key is unavailable, run the non-live gate anyway and report the live-release blocker explicitly.
- For local macOS test packaging, `desktop/scripts/build-macos-arm64.sh` is the canonical Apple Silicon build entrypoint, with outputs under `desktop/build-artifacts/macos-arm64/`.
+2 -2
View File
@@ -19,7 +19,7 @@ bun run verify
| CLI / Server / 工具 | `bun run check:server` |
| 桌面端 | `bun run check:desktop` |
| IM Adapter | `bun run check:adapters` |
| Tauri / 原生 | `bun run check:native` |
| 桌面 Electron / 原生打包 | `bun run check:native` |
| 文档 | `bun run check:docs` |
门禁失败时,查看最新质量报告和对应 lane 日志定位问题:
@@ -31,7 +31,7 @@ artifacts/quality-runs/<timestamp>/logs/<lane>.log
## 2. 桌面端功能必须手工测试
改动涉及 `desktop/` 的 UI、store、API 或 Tauri 原生层时,除了自动门禁外,还必须在真机上做手工测试:
改动涉及 `desktop/` 的 UI、store、API、Electron host 或 native/packaging 层时,除了自动门禁外,还必须在真机上做手工测试:
- 起本地服务 `SERVER_PORT=3456 bun run src/server/index.ts`
- 起桌面端 `cd desktop && bun run dev`
@@ -246,6 +246,31 @@ describe('StreamingCard: ensureCreated (fallback 降级路径)', () => {
expect(sc._getMessageId()).toBe('om_fb2')
})
it('reply 发送失败后降级 create 时复用同一个 uuid 避免重复消息', async () => {
const { client, calls } = makeMockClient({
'card.create': { code: 0, data: { card_id: 'ck' } },
'im.message.reply': () => {
throw Object.assign(new Error('reply failed after send'), { code: 231003 })
},
'im.message.create': { data: { message_id: 'om_fb_after_reply' } },
})
const sc = new StreamingCard({
larkClient: client,
chatId: 'c',
replyToMessageId: 'om_parent',
})
await sc.ensureCreated()
const replyCall = calls.find((c) => c.api === 'im.message.reply')
const fallbackCreate = calls.find((c) => c.api === 'im.message.create')
expect(replyCall).toBeDefined()
expect(fallbackCreate).toBeDefined()
expect(typeof replyCall!.args.data.uuid).toBe('string')
expect(replyCall!.args.data.uuid.length).toBeGreaterThan(0)
expect(fallbackCreate!.args.data.uuid).toBe(replyCall!.args.data.uuid)
})
it('降级发送也失败 → aborted + throw', async () => {
const { client } = makeMockClient({
'card.create': { code: 99991672 },
+3 -1
View File
@@ -189,6 +189,7 @@ export async function sendCardAsMessage(
chatId: string,
cardId: string,
replyToMessageId?: string,
uuid?: string,
): Promise<string> {
const content = JSON.stringify({
type: 'card',
@@ -199,7 +200,7 @@ export async function sendCardAsMessage(
const resp = await withImCardRequestTimeout('im.message.reply', () =>
client.im.message.reply({
path: { message_id: replyToMessageId },
data: { content, msg_type: 'interactive' },
data: { content, msg_type: 'interactive', uuid },
}),
)
const messageId = resp.data?.message_id
@@ -221,6 +222,7 @@ export async function sendCardAsMessage(
receive_id: chatId,
msg_type: 'interactive',
content,
uuid,
},
}),
)
+5
View File
@@ -13,6 +13,7 @@
*/
import type * as Lark from '@larksuiteoapi/node-sdk'
import { randomUUID } from 'node:crypto'
import { FlushController, THROTTLE } from './flush-controller.js'
import {
createCardEntity,
@@ -152,6 +153,8 @@ export class StreamingCard {
private cardId: string | null = null
/** IM message_id。始终应该有值(否则连 patch 也做不了)。 */
private messageId: string | null = null
/** Same idempotency key for the first IM send and any create fallback. */
private readonly outboundMessageUuid = randomUUID()
/** CardKit cardElement.content() 单调递增序列号。 */
private sequence = 0
/** CardKit 流式还在工作。230099 或连续 N 次未知错误之后置为 false,
@@ -198,6 +201,7 @@ export class StreamingCard {
this.deps.chatId,
cardId,
this.deps.replyToMessageId,
this.outboundMessageUuid,
)
this.cardId = cardId
this.messageId = messageId
@@ -219,6 +223,7 @@ export class StreamingCard {
receive_id: this.deps.chatId,
msg_type: 'interactive',
content: JSON.stringify(buildRenderedCard(' ')),
uuid: this.outboundMessageUuid,
},
}),
)
+488 -11
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { ELECTRON_IPC_CHANNELS } from './channels'
import {
ELECTRON_IPC_VALIDATORS,
isElectronIpcChannel,
validateElectronIpcPayload,
} from './capabilities'
describe('Electron IPC capabilities', () => {
it('has a validator for every exposed invoke channel', () => {
expect(Object.keys(ELECTRON_IPC_VALIDATORS).sort()).toEqual(
Object.values(ELECTRON_IPC_CHANNELS).sort(),
)
})
it('rejects channels outside the desktop host contract', () => {
expect(isElectronIpcChannel(ELECTRON_IPC_CHANNELS.appGetVersion)).toBe(true)
expect(isElectronIpcChannel('ipcRenderer:send-anything')).toBe(false)
})
it('validates structured payloads before they reach ipcRenderer.invoke', () => {
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, 'https://example.com')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, { url: 'https://example.com' })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, {})).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalWrite, { sessionId: 1, data: 'pwd\n' })).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalWrite, { sessionId: '1', data: 'pwd\n' })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalSpawn, { cols: 80, rows: 24, cwd: '/tmp' })).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.terminalSpawn, { cols: '80', rows: 24 })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.updateCheck, { proxy: 'http://127.0.0.1:7890' })).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.updateCheck, { proxy: '' })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.updateCheck, { proxy: 'http://127.0.0.1:7890', extra: true })).toBe(false)
})
})
+122
View File
@@ -0,0 +1,122 @@
import { ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './channels'
type Validator = (payload: unknown) => boolean
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const noPayload: Validator = value => value === undefined
const optionalRecord: Validator = value => value === undefined || isRecord(value)
const stringPayload: Validator = value => typeof value === 'string'
const booleanPayload: Validator = value => typeof value === 'boolean'
const hasOnlyKeys = (value: Record<string, unknown>, allowedKeys: string[]) =>
Object.keys(value).every(key => allowedKeys.includes(key))
const commandInvoke: Validator = value =>
isRecord(value)
&& typeof value.command === 'string'
&& value.command.length > 0
&& (value.args === undefined || isRecord(value.args))
const terminalWrite: Validator = value =>
isRecord(value)
&& typeof value.sessionId === 'number'
&& typeof value.data === 'string'
const terminalSpawn: Validator = value =>
value === undefined
|| (
isRecord(value)
&& (value.cols === undefined || typeof value.cols === 'number')
&& (value.rows === undefined || typeof value.rows === 'number')
&& (value.cwd === undefined || typeof value.cwd === 'string')
&& (value.shell === undefined || typeof value.shell === 'string')
)
const terminalResize: Validator = value =>
isRecord(value)
&& typeof value.sessionId === 'number'
&& typeof value.cols === 'number'
&& typeof value.rows === 'number'
const terminalSessionId: Validator = value =>
isRecord(value)
&& typeof value.sessionId === 'number'
const boundsPayload: Validator = value =>
isRecord(value)
&& typeof value.x === 'number'
&& typeof value.y === 'number'
&& typeof value.width === 'number'
&& typeof value.height === 'number'
const urlWithOptionalBounds: Validator = value =>
isRecord(value)
&& typeof value.url === 'string'
&& (value.bounds === undefined || boundsPayload(value.bounds))
const zoomPayload: Validator = value => typeof value === 'number' && Number.isFinite(value)
const updateCheckOptions: Validator = value => {
if (value === undefined) return true
if (!isRecord(value) || !hasOnlyKeys(value, ['proxy'])) return false
return value.proxy === undefined || (typeof value.proxy === 'string' && value.proxy.trim().length > 0)
}
export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.appGetVersion]: noPayload,
[ELECTRON_IPC_CHANNELS.runtimeGetServerUrl]: noPayload,
[ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke,
[ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload,
[ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload,
[ELECTRON_IPC_CHANNELS.dialogOpen]: optionalRecord,
[ELECTRON_IPC_CHANNELS.dialogSave]: optionalRecord,
[ELECTRON_IPC_CHANNELS.updateCheck]: updateCheckOptions,
[ELECTRON_IPC_CHANNELS.updateDownload]: noPayload,
[ELECTRON_IPC_CHANNELS.updateInstall]: noPayload,
[ELECTRON_IPC_CHANNELS.updatePrepareInstall]: noPayload,
[ELECTRON_IPC_CHANNELS.updateCancelInstall]: noPayload,
[ELECTRON_IPC_CHANNELS.updateRelaunch]: noPayload,
[ELECTRON_IPC_CHANNELS.notificationPermissionState]: noPayload,
[ELECTRON_IPC_CHANNELS.notificationRequestPermission]: noPayload,
[ELECTRON_IPC_CHANNELS.notificationSend]: optionalRecord,
[ELECTRON_IPC_CHANNELS.notificationActionAck]: optionalRecord,
[ELECTRON_IPC_CHANNELS.windowMinimize]: noPayload,
[ELECTRON_IPC_CHANNELS.windowToggleMaximize]: noPayload,
[ELECTRON_IPC_CHANNELS.windowClose]: noPayload,
[ELECTRON_IPC_CHANNELS.windowStartDragging]: noPayload,
[ELECTRON_IPC_CHANNELS.windowRequestAttention]: noPayload,
[ELECTRON_IPC_CHANNELS.windowFocus]: noPayload,
[ELECTRON_IPC_CHANNELS.windowIsMaximized]: noPayload,
[ELECTRON_IPC_CHANNELS.terminalSpawn]: terminalSpawn,
[ELECTRON_IPC_CHANNELS.terminalWrite]: terminalWrite,
[ELECTRON_IPC_CHANNELS.terminalResize]: terminalResize,
[ELECTRON_IPC_CHANNELS.terminalKill]: terminalSessionId,
[ELECTRON_IPC_CHANNELS.terminalGetBashPath]: noPayload,
[ELECTRON_IPC_CHANNELS.terminalSetBashPath]: value => value === null || stringPayload(value),
[ELECTRON_IPC_CHANNELS.previewOpen]: urlWithOptionalBounds,
[ELECTRON_IPC_CHANNELS.previewNavigate]: stringPayload,
[ELECTRON_IPC_CHANNELS.previewSetBounds]: boundsPayload,
[ELECTRON_IPC_CHANNELS.previewSetVisible]: booleanPayload,
[ELECTRON_IPC_CHANNELS.previewClose]: noPayload,
[ELECTRON_IPC_CHANNELS.previewMessage]: () => true,
[ELECTRON_IPC_CHANNELS.appModeGet]: noPayload,
[ELECTRON_IPC_CHANNELS.appModeSet]: optionalRecord,
[ELECTRON_IPC_CHANNELS.appModeDetectPortableDir]: noPayload,
[ELECTRON_IPC_CHANNELS.appModePrepareRestart]: noPayload,
[ELECTRON_IPC_CHANNELS.appModeRestart]: noPayload,
[ELECTRON_IPC_CHANNELS.adaptersRestartSidecar]: noPayload,
[ELECTRON_IPC_CHANNELS.zoomSet]: zoomPayload,
} satisfies Record<ElectronIpcChannel, Validator>
const allowedChannels = new Set<ElectronIpcChannel>(
Object.values(ELECTRON_IPC_CHANNELS),
)
export function isElectronIpcChannel(channel: string): channel is ElectronIpcChannel {
return allowedChannels.has(channel as ElectronIpcChannel)
}
export function validateElectronIpcPayload(channel: ElectronIpcChannel, payload: unknown): boolean {
return ELECTRON_IPC_VALIDATORS[channel](payload)
}
+65
View File
@@ -0,0 +1,65 @@
export const ELECTRON_IPC_CHANNELS = {
appGetVersion: 'desktop:app:get-version',
runtimeGetServerUrl: 'desktop:runtime:get-server-url',
commandInvoke: 'desktop:command:invoke',
shellOpen: 'desktop:shell:open',
shellOpenPath: 'desktop:shell:open-path',
dialogOpen: 'desktop:dialog:open',
dialogSave: 'desktop:dialog:save',
updateCheck: 'desktop:update:check',
updateDownload: 'desktop:update:download',
updateInstall: 'desktop:update:install',
updatePrepareInstall: 'desktop:update:prepare-install',
updateCancelInstall: 'desktop:update:cancel-install',
updateRelaunch: 'desktop:update:relaunch',
notificationPermissionState: 'desktop:notification:permission-state',
notificationRequestPermission: 'desktop:notification:request-permission',
notificationSend: 'desktop:notification:send',
notificationActionAck: 'desktop:notification:action-ack',
windowMinimize: 'desktop:window:minimize',
windowToggleMaximize: 'desktop:window:toggle-maximize',
windowClose: 'desktop:window:close',
windowStartDragging: 'desktop:window:start-dragging',
windowRequestAttention: 'desktop:window:request-attention',
windowFocus: 'desktop:window:focus',
windowIsMaximized: 'desktop:window:is-maximized',
terminalSpawn: 'desktop:terminal:spawn',
terminalWrite: 'desktop:terminal:write',
terminalResize: 'desktop:terminal:resize',
terminalKill: 'desktop:terminal:kill',
terminalGetBashPath: 'desktop:terminal:get-bash-path',
terminalSetBashPath: 'desktop:terminal:set-bash-path',
previewOpen: 'desktop:preview:open',
previewNavigate: 'desktop:preview:navigate',
previewSetBounds: 'desktop:preview:set-bounds',
previewSetVisible: 'desktop:preview:set-visible',
previewClose: 'desktop:preview:close',
previewMessage: 'desktop:preview:message',
appModeGet: 'desktop:app-mode:get',
appModeSet: 'desktop:app-mode:set',
appModeDetectPortableDir: 'desktop:app-mode:detect-portable-dir',
appModePrepareRestart: 'desktop:app-mode:prepare-restart',
appModeRestart: 'desktop:app-mode:restart',
adaptersRestartSidecar: 'desktop:adapters:restart-sidecar',
zoomSet: 'desktop:zoom:set',
} as const
export const ELECTRON_EVENT_CHANNELS = {
event: 'desktop:event',
webviewDragDrop: 'desktop:webview:drag-drop',
notificationAction: 'desktop:notification:action',
updateDownloadEvent: 'desktop:update:download-event',
windowResized: 'desktop:window:resized',
nativeMenuNavigate: 'desktop:window:native-menu-navigate',
terminalOutput: 'desktop:terminal:output',
terminalExit: 'desktop:terminal:exit',
previewEvent: 'desktop:preview:event',
} as const
export const ELECTRON_INTERNAL_CHANNELS = {
previewMessageFromView: 'desktop:preview:message-from-view',
} as const
export type ElectronIpcChannel = typeof ELECTRON_IPC_CHANNELS[keyof typeof ELECTRON_IPC_CHANNELS]
export type ElectronEventChannel = typeof ELECTRON_EVENT_CHANNELS[keyof typeof ELECTRON_EVENT_CHANNELS]
export type ElectronInternalChannel = typeof ELECTRON_INTERNAL_CHANNELS[keyof typeof ELECTRON_INTERNAL_CHANNELS]
+99
View File
@@ -0,0 +1,99 @@
export const MAX_PREVIEW_EVENT_BYTES = 8 * 1024 * 1024
const MAX_PREVIEW_TEXT_LENGTH = 32_768
export type PreviewAgentMessage =
| { v: 1, type: 'ready' }
| { v: 1, type: 'picker-exited' }
| { v: 1, type: 'navigated', url: string, title: string }
| { v: 1, type: 'error', message: string }
| { v: 1, type: 'screenshot', dataUrl: string, kind: 'full' | 'viewport' | 'element' }
| { v: 1, type: 'selection', payload: Record<string, unknown> }
function byteLength(input: string): number {
return new TextEncoder().encode(input).byteLength
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isBoundedString(value: unknown, maxLength = MAX_PREVIEW_TEXT_LENGTH): value is string {
return typeof value === 'string' && value.length <= maxLength
}
function isPreviewDataUrl(value: unknown): value is string {
return typeof value === 'string' &&
value.length <= MAX_PREVIEW_EVENT_BYTES &&
/^data:image\/(?:png|jpeg|webp);base64,[a-z0-9+/=\r\n]+$/i.test(value)
}
function isPreviewKind(value: unknown): value is 'full' | 'viewport' | 'element' {
return value === 'full' || value === 'viewport' || value === 'element'
}
function isSelectionScreenshotKind(value: unknown): value is 'full' | 'viewport' | 'element' | 'region' {
return isPreviewKind(value) || value === 'region'
}
export function parsePreviewAgentMessage(raw: string): PreviewAgentMessage | null {
if (byteLength(raw) > MAX_PREVIEW_EVENT_BYTES) return null
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return null
}
if (!isPlainRecord(parsed) || parsed.v !== 1 || typeof parsed.type !== 'string') {
return null
}
switch (parsed.type) {
case 'ready':
case 'picker-exited':
return { v: 1, type: parsed.type }
case 'navigated':
if (!isBoundedString(parsed.url) || !isBoundedString(parsed.title)) return null
try {
const url = new URL(parsed.url)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
} catch {
return null
}
return { v: 1, type: 'navigated', url: parsed.url, title: parsed.title }
case 'error':
if (!isBoundedString(parsed.message)) return null
return { v: 1, type: 'error', message: parsed.message }
case 'screenshot':
if (!isPreviewDataUrl(parsed.dataUrl) || !isPreviewKind(parsed.kind)) return null
return { v: 1, type: 'screenshot', dataUrl: parsed.dataUrl, kind: parsed.kind }
case 'selection':
if (!isPlainRecord(parsed.payload)) return null
if ('screenshot' in parsed.payload) {
const screenshot = parsed.payload.screenshot
if (!isPlainRecord(screenshot)) return null
if (screenshot.dataUrl !== undefined && !isPreviewDataUrl(screenshot.dataUrl)) return null
if (screenshot.kind !== undefined && !isSelectionScreenshotKind(screenshot.kind)) return null
}
return { v: 1, type: 'selection', payload: parsed.payload }
default:
return null
}
}
export function shouldForwardPreviewMessage(input: {
raw: unknown
href: string
isTopFrame: boolean
}): input is { raw: string, href: string, isTopFrame: true } {
if (typeof input.raw !== 'string') return false
if (byteLength(input.raw) > MAX_PREVIEW_EVENT_BYTES) return false
if (!input.isTopFrame) return false
try {
const parsed = new URL(input.href)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}
+382
View File
@@ -0,0 +1,382 @@
import { app, BrowserWindow, ipcMain, Notification, screen, session, WebContentsView } from 'electron'
import { autoUpdater } from 'electron-updater'
import path from 'node:path'
import { ELECTRON_EVENT_CHANNELS, ELECTRON_INTERNAL_CHANNELS, ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './ipc/channels'
import { isElectronIpcChannel, validateElectronIpcPayload } from './ipc/capabilities'
import { ElectronServerRuntime } from './services/serverRuntime'
import { openDialog, saveDialog } from './services/dialogs'
import { openExternalUrl, openSystemPath, openSystemSettingsUrl } from './services/shell'
import {
notificationPermissionState,
requestNotificationPermission,
sendDesktopNotification,
} from './services/notifications'
import { installApplicationMenu } from './services/menu'
import { acquireSingleInstanceLock } from './services/singleInstance'
import { installTray, type TrayController } from './services/tray'
import { ElectronUpdaterService } from './services/updater'
import { createUpdateSmokeUpdaterFromEnv } from './services/updateSmoke'
import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal'
import { ElectronPreviewService, type PreviewBounds } from './services/preview'
import {
applyStartupPortableMode,
detectPortableDir,
getAppMode,
setAppMode,
type PortableDetection,
} from './services/appMode'
import { installMacOsChromiumKeychainPromptGuard } from './services/keychain'
import { logNotificationSmokeRendererAck, scheduleNotificationSmoke } from './services/notificationSmoke'
import { normalizeZoomFactor } from './services/zoom'
import { resolveRendererEntry } from './services/rendererEntry'
import { writeWindowSmokeSnapshot } from './services/windowSmoke'
import {
installWindowLifecycle,
readWindowState,
restoreWindowMaximized,
saveWindowState,
showMainWindow,
windowOptionsFromState,
MIN_WINDOW_HEIGHT,
MIN_WINDOW_WIDTH,
} from './services/windows'
let mainWindow: BrowserWindow | null = null
let serverRuntime: ElectronServerRuntime | null = null
let updaterService: ElectronUpdaterService | null = null
let terminalService: ElectronTerminalService | null = null
let previewService: ElectronPreviewService | null = null
let isQuitting = false
let trayController: TrayController | null = null
installMacOsChromiumKeychainPromptGuard(app)
function appRoot() {
return app.isPackaged ? app.getAppPath() : process.cwd()
}
function unpackedRoot() {
const root = appRoot()
return app.isPackaged ? root.replace(/\.asar$/, '.asar.unpacked') : root
}
function preloadPath() {
return path.join(appRoot(), 'electron-dist', 'preload.cjs')
}
function previewPreloadPath() {
return path.join(appRoot(), 'electron-dist', 'preview-preload.cjs')
}
function previewAgentPath() {
return path.join(appRoot(), 'src-tauri', 'resources', 'preview-agent.js')
}
function rendererEntry() {
return resolveRendererEntry({
isPackaged: app.isPackaged,
appRoot: appRoot(),
env: process.env,
})
}
function getServerRuntime() {
serverRuntime ??= new ElectronServerRuntime({
desktopRoot: unpackedRoot(),
appRoot: appRoot(),
h5DistDir: path.join(appRoot(), 'dist'),
})
return serverRuntime
}
function getUpdaterService() {
const smokeUpdater = createUpdateSmokeUpdaterFromEnv(process.env)
updaterService ??= new ElectronUpdaterService(smokeUpdater ?? autoUpdater, {
async apply(proxy) {
const config = proxy
? { proxyRules: proxy, proxyBypassRules: '<local>' }
: {}
await Promise.all([
app.setProxy(config),
session.defaultSession.setProxy(config),
])
await session.defaultSession.forceReloadProxyConfig()
},
}, {
updateConfigPath: !smokeUpdater && app.isPackaged ? path.join(process.resourcesPath, 'app-update.yml') : undefined,
})
return updaterService
}
function nodePtyRuntimeCacheDir() {
if (!app.isPackaged || process.platform !== 'darwin') return undefined
return path.join(app.getPath('userData'), 'native', `node-pty-${process.platform}-${process.arch}-${app.getVersion()}`)
}
function getTerminalService() {
terminalService ??= new ElectronTerminalService({
app,
nodePtySourceDir: app.isPackaged ? path.join(unpackedRoot(), 'node_modules', 'node-pty') : undefined,
nodePtyCacheDir: nodePtyRuntimeCacheDir(),
})
return terminalService
}
function getPreviewService() {
previewService ??= new ElectronPreviewService({
previewScriptPath: previewAgentPath(),
createView: () => new WebContentsView({
webPreferences: {
preload: previewPreloadPath(),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
}),
})
return previewService
}
function currentWindow(event: Electron.IpcMainInvokeEvent) {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window) throw new Error('No BrowserWindow for Electron IPC event')
return window
}
function registerHandler<T>(
channel: ElectronIpcChannel,
handler: (event: Electron.IpcMainInvokeEvent, payload: unknown) => T | Promise<T>,
) {
ipcMain.handle(channel, async (event, payload) => {
if (!isElectronIpcChannel(channel) || !validateElectronIpcPayload(channel, payload)) {
throw new Error(`Invalid Electron IPC payload for ${channel}`)
}
return handler(event, payload)
})
}
function unsupported(name: string): never {
throw new Error(`${name} is not implemented in the Electron host yet`)
}
function emitNotificationAction(payload: unknown) {
showMainWindow(mainWindow)
mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.notificationAction, payload)
}
async function handleCommandInvoke(payload: unknown): Promise<unknown> {
const { command, args } = payload as { command: string, args?: Record<string, unknown> }
switch (command) {
case 'plugin:notification|is_permission_granted':
return notificationPermissionState(Notification) === 'granted'
case 'plugin:notification|request_permission':
case 'macos_request_notification_permission':
return requestNotificationPermission(Notification)
case 'macos_notification_permission_state':
return notificationPermissionState(Notification)
case 'macos_send_notification':
return sendDesktopNotification({
NotificationClass: Notification,
options: args,
onAction: emitNotificationAction,
})
case 'macos_open_notification_settings':
return openSystemSettingsUrl('x-apple.systempreferences:com.apple.preference.notifications')
case 'open_windows_notification_settings':
return openSystemSettingsUrl('ms-settings:notifications')
default:
return unsupported(`Electron command ${command}`)
}
}
function registerIpcHandlers() {
ipcMain.on(ELECTRON_INTERNAL_CHANNELS.previewMessageFromView, (event, raw) => {
getPreviewService().sendMessageToRenderer(event.sender, raw, mainWindow?.webContents)
})
registerHandler(ELECTRON_IPC_CHANNELS.appGetVersion, () => app.getVersion())
registerHandler(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl, () => getServerRuntime().getServerUrl())
registerHandler(ELECTRON_IPC_CHANNELS.commandInvoke, (_event, payload) => handleCommandInvoke(payload))
registerHandler(ELECTRON_IPC_CHANNELS.shellOpen, (_event, payload) => openExternalUrl(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.dialogOpen, (event, payload) =>
openDialog(currentWindow(event), payload as Parameters<typeof openDialog>[1]))
registerHandler(ELECTRON_IPC_CHANNELS.dialogSave, (event, payload) =>
saveDialog(currentWindow(event), payload as Parameters<typeof saveDialog>[1]))
registerHandler(ELECTRON_IPC_CHANNELS.updateCheck, (_event, payload) =>
getUpdaterService().checkForUpdates(payload as Parameters<ElectronUpdaterService['checkForUpdates']>[0]))
registerHandler(ELECTRON_IPC_CHANNELS.updateDownload, () => getUpdaterService().downloadUpdate(event => {
mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.updateDownloadEvent, event)
}))
registerHandler(ELECTRON_IPC_CHANNELS.updateInstall, () => getUpdaterService().stageDownloadedUpdate())
registerHandler(ELECTRON_IPC_CHANNELS.updatePrepareInstall, () => getServerRuntime().stopAll())
registerHandler(ELECTRON_IPC_CHANNELS.updateCancelInstall, () => getUpdaterService().cancelInstall())
registerHandler(ELECTRON_IPC_CHANNELS.updateRelaunch, () => {
if (getUpdaterService().hasDownloadedUpdate()) {
isQuitting = true
getUpdaterService().quitAndInstallDownloadedUpdate()
return
}
app.relaunch()
app.quit()
})
registerHandler(ELECTRON_IPC_CHANNELS.notificationPermissionState, () => notificationPermissionState(Notification))
registerHandler(ELECTRON_IPC_CHANNELS.notificationRequestPermission, () => requestNotificationPermission(Notification))
registerHandler(ELECTRON_IPC_CHANNELS.notificationSend, (_event, payload) => sendDesktopNotification({
NotificationClass: Notification,
options: payload,
onAction: emitNotificationAction,
}))
registerHandler(ELECTRON_IPC_CHANNELS.notificationActionAck, (_event, payload) =>
logNotificationSmokeRendererAck(process.env, payload))
registerHandler(ELECTRON_IPC_CHANNELS.windowMinimize, event => currentWindow(event).minimize())
registerHandler(ELECTRON_IPC_CHANNELS.windowToggleMaximize, event => {
const window = currentWindow(event)
if (window.isMaximized()) window.unmaximize()
else window.maximize()
})
registerHandler(ELECTRON_IPC_CHANNELS.windowClose, event => currentWindow(event).close())
registerHandler(ELECTRON_IPC_CHANNELS.windowStartDragging, () => undefined)
registerHandler(ELECTRON_IPC_CHANNELS.windowRequestAttention, event => currentWindow(event).flashFrame(true))
registerHandler(ELECTRON_IPC_CHANNELS.windowFocus, event => currentWindow(event).focus())
registerHandler(ELECTRON_IPC_CHANNELS.windowIsMaximized, event => currentWindow(event).isMaximized())
registerHandler(ELECTRON_IPC_CHANNELS.terminalSpawn, (event, payload) =>
getTerminalService().spawn((payload ?? {}) as TerminalSpawnInput, event.sender))
registerHandler(ELECTRON_IPC_CHANNELS.terminalWrite, (_event, payload) => {
const { sessionId, data } = payload as { sessionId: number, data: string }
return getTerminalService().write(sessionId, data)
})
registerHandler(ELECTRON_IPC_CHANNELS.terminalResize, (_event, payload) => {
const { sessionId, cols, rows } = payload as { sessionId: number, cols: number, rows: number }
return getTerminalService().resize(sessionId, cols, rows)
})
registerHandler(ELECTRON_IPC_CHANNELS.terminalKill, (_event, payload) => {
const { sessionId } = payload as { sessionId: number }
return getTerminalService().kill(sessionId)
})
registerHandler(ELECTRON_IPC_CHANNELS.terminalGetBashPath, () => getTerminalService().getBashPath())
registerHandler(ELECTRON_IPC_CHANNELS.terminalSetBashPath, (_event, payload) => getTerminalService().setBashPath(payload as string | null))
registerHandler(ELECTRON_IPC_CHANNELS.previewOpen, (event, payload) => {
const { url, bounds } = payload as { url: string, bounds?: PreviewBounds }
return getPreviewService().open(currentWindow(event), url, bounds ?? { x: 0, y: 0, width: 0, height: 0 })
})
registerHandler(ELECTRON_IPC_CHANNELS.previewNavigate, (_event, payload) => getPreviewService().navigate(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.previewSetBounds, (_event, payload) => getPreviewService().setBounds(payload as PreviewBounds))
registerHandler(ELECTRON_IPC_CHANNELS.previewSetVisible, (_event, payload) => getPreviewService().setVisible(Boolean(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.previewClose, () => getPreviewService().close())
registerHandler(ELECTRON_IPC_CHANNELS.previewMessage, (_event, payload) => getPreviewService().message(payload))
registerHandler(ELECTRON_IPC_CHANNELS.appModeGet, () => getAppMode(app))
registerHandler(ELECTRON_IPC_CHANNELS.appModeSet, (_event, payload) => setAppMode(app, payload as Parameters<typeof setAppMode>[1]))
registerHandler(ELECTRON_IPC_CHANNELS.appModeDetectPortableDir, () => detectPortableDir(app) as PortableDetection)
registerHandler(ELECTRON_IPC_CHANNELS.appModePrepareRestart, () => getServerRuntime().stopAll())
registerHandler(ELECTRON_IPC_CHANNELS.appModeRestart, () => {
isQuitting = true
app.relaunch()
app.quit()
})
registerHandler(ELECTRON_IPC_CHANNELS.adaptersRestartSidecar, () => getServerRuntime().restartAdaptersSidecars())
registerHandler(ELECTRON_IPC_CHANNELS.zoomSet, (event, payload) => currentWindow(event).webContents.setZoomFactor(normalizeZoomFactor(payload)))
}
async function createMainWindow() {
const restoredState = readWindowState(app, screen.getAllDisplays())
const bounds = windowOptionsFromState(restoredState)
mainWindow = new BrowserWindow({
...bounds,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
show: false,
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
webPreferences: {
preload: preloadPath(),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
installWindowLifecycle({
app,
window: mainWindow,
shouldQuit: () => isQuitting,
})
mainWindow.on('resize', () => {
mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.windowResized)
})
mainWindow.webContents.on('did-finish-load', () => {
writeWindowSmokeSnapshot(mainWindow, 'did-finish-load')
})
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
writeWindowSmokeSnapshot(mainWindow, `did-fail-load:${errorCode}:${errorDescription}:${validatedURL}`)
})
writeWindowSmokeSnapshot(mainWindow, 'after-create')
showMainWindow(mainWindow)
writeWindowSmokeSnapshot(mainWindow, 'after-initial-show')
const entry = rendererEntry()
if (/^https?:\/\//.test(entry)) {
await mainWindow.loadURL(entry)
} else {
await mainWindow.loadFile(entry)
}
restoreWindowMaximized(mainWindow, restoredState)
showMainWindow(mainWindow)
writeWindowSmokeSnapshot(mainWindow, 'after-final-show')
}
if (!acquireSingleInstanceLock(app, () => mainWindow)) {
process.exit(0)
}
registerIpcHandlers()
app.whenReady().then(async () => {
applyStartupPortableMode(app)
await getServerRuntime().startServer().catch(error => {
console.error('[desktop] failed to start Electron server sidecar', error)
})
await installApplicationMenu(app, () => mainWindow)
trayController = await installTray({
app,
desktopRoot: appRoot(),
show: () => showMainWindow(mainWindow),
quit: () => {
isQuitting = true
app.quit()
},
}).catch(error => {
console.error('[desktop] failed to create Electron tray', error)
return null
})
await createMainWindow()
scheduleNotificationSmoke({
env: process.env,
NotificationClass: Notification,
onAction: emitNotificationAction,
})
app.on('activate', () => {
if (mainWindow) {
showMainWindow(mainWindow)
return
}
void createMainWindow()
})
})
app.on('window-all-closed', () => {
if (isQuitting && process.platform !== 'darwin') app.quit()
})
app.on('before-quit', () => {
isQuitting = true
if (mainWindow) saveWindowState(app, mainWindow)
trayController?.dispose()
trayController = null
terminalService?.killAll()
previewService?.close()
getServerRuntime().stopAll()
})
+22
View File
@@ -0,0 +1,22 @@
import { contextBridge, ipcRenderer } from 'electron'
import { createElectronHost } from '../src/lib/desktopHost/electronHost'
import type { DesktopHostUnlisten } from '../src/lib/desktopHost/types'
import type { ElectronEventChannel, ElectronIpcChannel } from './ipc/channels'
const electronHost = createElectronHost({
invoke<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T> {
return ipcRenderer.invoke(channel, payload) as Promise<T>
},
subscribe<T>(
channel: ElectronEventChannel,
handler: (payload: T) => void,
): Promise<DesktopHostUnlisten> {
const listener = (_event: Electron.IpcRendererEvent, payload: T) => handler(payload)
ipcRenderer.on(channel, listener)
return Promise.resolve(() => {
ipcRenderer.removeListener(channel, listener)
})
},
})
contextBridge.exposeInMainWorld('desktopHost', electronHost)
+29
View File
@@ -0,0 +1,29 @@
import * as electron from 'electron'
import { ELECTRON_INTERNAL_CHANNELS } from './ipc/channels'
import { shouldForwardPreviewMessage } from './ipc/previewMessage'
const { contextBridge, ipcRenderer } = electron
function isTopFrame(): boolean {
try {
return window.top === window
} catch {
return false
}
}
export function installPreviewPostBridge(): void {
if (!contextBridge?.exposeInMainWorld || !ipcRenderer?.send) return
contextBridge.exposeInMainWorld('__DESKTOP_PREVIEW_POST__', (raw: unknown) => {
if (!shouldForwardPreviewMessage({
raw,
href: window.location.href,
isTopFrame: isTopFrame(),
})) {
return
}
ipcRenderer.send(ELECTRON_INTERNAL_CHANNELS.previewMessageFromView, raw)
})
}
installPreviewPostBridge()
+130
View File
@@ -0,0 +1,130 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
applyStartupPortableMode,
defaultPortableDir,
detectPortableDir,
determineStartupPortableDir,
dirHasPortableData,
getAppMode,
setAppMode,
type AppModeAppLike,
} from './appMode'
const tempDirs: string[] = []
function tempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-app-mode-'))
tempDirs.push(dir)
return dir
}
function app(root = tempDir()): AppModeAppLike & { root: string } {
const exe = path.join(root, 'Claude Code Haha.app', 'Contents', 'MacOS', 'Claude Code Haha')
const userData = path.join(root, 'user-data')
fs.mkdirSync(path.dirname(exe), { recursive: true })
fs.writeFileSync(exe, '')
return {
root,
getPath(name) {
return name === 'exe' ? exe : userData
},
}
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
})
describe('Electron app mode service', () => {
it('detects portable data using the same sentinel files and directories as Tauri', () => {
const root = tempDir()
expect(dirHasPortableData(root)).toBe(false)
fs.writeFileSync(path.join(root, 'settings.json'), '{}')
expect(dirHasPortableData(root)).toBe(true)
fs.rmSync(path.join(root, 'settings.json'))
fs.mkdirSync(path.join(root, 'projects'))
expect(dirHasPortableData(root)).toBe(true)
})
it('resolves startup portable mode from default portable data or app-mode config', () => {
const fakeApp = app()
const defaultDir = defaultPortableDir(fakeApp)
fs.mkdirSync(defaultDir, { recursive: true })
fs.writeFileSync(path.join(defaultDir, 'settings.json'), '{}')
expect(determineStartupPortableDir(fakeApp, {})).toBe(defaultDir)
expect(determineStartupPortableDir(fakeApp, { CLAUDE_CONFIG_DIR: '/external' })).toBeNull()
fs.writeFileSync(path.join(defaultDir, 'app-mode.json'), JSON.stringify({ mode: 'default' }))
expect(determineStartupPortableDir(fakeApp, {})).toBeNull()
})
it('sets portable environment variables before sidecars start', () => {
const fakeApp = app()
const env: NodeJS.ProcessEnv = {}
const defaultDir = defaultPortableDir(fakeApp)
fs.mkdirSync(defaultDir, { recursive: true })
fs.writeFileSync(path.join(defaultDir, 'settings.json'), '{}')
expect(applyStartupPortableMode(fakeApp, env)).toBe(defaultDir)
expect(env.CLAUDE_CONFIG_DIR).toBe(defaultDir)
expect(env.CC_HAHA_APP_PORTABLE_DIR).toBe('1')
expect(env.WEBVIEW2_USER_DATA_FOLDER).toBe(path.join(defaultDir, 'EBWebView'))
})
it('returns the active app mode shape expected by settingsStore', () => {
const fakeApp = app()
expect(getAppMode(fakeApp, {})).toEqual({
mode: 'default',
portableDir: defaultPortableDir(fakeApp),
defaultPortableDir: defaultPortableDir(fakeApp),
activeConfigDir: fakeApp.getPath('userData'),
configDirSource: 'system',
})
expect(getAppMode(fakeApp, { CLAUDE_CONFIG_DIR: '/portable', CC_HAHA_APP_PORTABLE_DIR: '1' })).toMatchObject({
mode: 'portable',
portableDir: '/portable',
activeConfigDir: '/portable',
configDirSource: 'portable',
})
expect(getAppMode(fakeApp, { CLAUDE_CONFIG_DIR: '/external' })).toMatchObject({
configDirSource: 'environment',
})
})
it('writes app-mode.json to active, target portable, and system config dirs', () => {
const fakeApp = app()
const active = tempDir()
const selected = path.join(tempDir(), 'portable')
setAppMode(fakeApp, { mode: 'portable', portableDir: selected }, { CLAUDE_CONFIG_DIR: active })
const expected = { mode: 'portable', portable_dir: selected }
expect(JSON.parse(fs.readFileSync(path.join(active, 'app-mode.json'), 'utf8'))).toEqual(expected)
expect(JSON.parse(fs.readFileSync(path.join(selected, 'app-mode.json'), 'utf8'))).toEqual(expected)
expect(JSON.parse(fs.readFileSync(path.join(fakeApp.getPath('userData'), 'app-mode.json'), 'utf8'))).toEqual(expected)
setAppMode(fakeApp, { mode: 'default', portableDir: null }, { CLAUDE_CONFIG_DIR: active })
expect(JSON.parse(fs.readFileSync(path.join(active, 'app-mode.json'), 'utf8'))).toEqual({
mode: 'default',
portable_dir: null,
})
})
it('reports whether the default portable dir already has data', () => {
const fakeApp = app()
expect(detectPortableDir(fakeApp)).toEqual({
defaultPortableDir: defaultPortableDir(fakeApp),
hasData: false,
})
fs.mkdirSync(defaultPortableDir(fakeApp), { recursive: true })
fs.writeFileSync(path.join(defaultPortableDir(fakeApp), '.mcp.json'), '{}')
expect(detectPortableDir(fakeApp).hasData).toBe(true)
})
})
+157
View File
@@ -0,0 +1,157 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import type { AppModeConfig, AppModeSetInput } from '../../src/lib/desktopHost/types'
const APP_MODE_FILE = 'app-mode.json'
export type AppModeAppLike = {
getPath(name: 'exe' | 'userData'): string
}
type PersistedAppModeConfig = {
mode?: string
portable_dir?: string | null
}
export type PortableDetection = {
defaultPortableDir: string | null
hasData: boolean
}
export function defaultPortableDir(app: AppModeAppLike): string {
return path.join(path.dirname(app.getPath('exe')), 'CLAUDE_CONFIG_DIR')
}
export function dirHasPortableData(dir: string): boolean {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return false
return [
'settings.json',
'.claude.json',
'.mcp.json',
'window-state.json',
'terminal-config.json',
].some(file => fs.existsSync(path.join(dir, file)) && fs.statSync(path.join(dir, file)).isFile())
|| [
'Cache',
'EBWebView',
'projects',
'skills',
'plugins',
'cowork_plugins',
'cc-haha',
].some(file => fs.existsSync(path.join(dir, file)) && fs.statSync(path.join(dir, file)).isDirectory())
}
export function readAppModeConfig(configDir: string): PersistedAppModeConfig | null {
try {
const parsed = JSON.parse(fs.readFileSync(path.join(configDir, APP_MODE_FILE), 'utf8')) as PersistedAppModeConfig
return {
mode: typeof parsed.mode === 'string' ? parsed.mode.toLowerCase() : 'default',
portable_dir: typeof parsed.portable_dir === 'string' ? parsed.portable_dir : null,
}
} catch {
return null
}
}
export function writeAppModeConfig(configDir: string, config: PersistedAppModeConfig): void {
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, APP_MODE_FILE), JSON.stringify(config, null, 2))
}
export function determineStartupPortableDir(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
): string | null {
if (env.CLAUDE_CONFIG_DIR) return null
const defaultDir = defaultPortableDir(app)
const defaultMode = readAppModeConfig(defaultDir)
if (defaultMode) {
if (defaultMode.mode === 'portable') {
return dirHasPortableData(defaultDir) ? defaultDir : defaultMode.portable_dir ?? defaultDir
}
return null
}
const systemMode = readAppModeConfig(app.getPath('userData'))
if (systemMode) {
if (systemMode.mode === 'portable') return systemMode.portable_dir ?? defaultDir
return null
}
return dirHasPortableData(defaultDir) ? defaultDir : null
}
export function applyStartupPortableMode(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
): string | null {
const portableDir = determineStartupPortableDir(app, env)
if (!portableDir) return null
env.CLAUDE_CONFIG_DIR = portableDir
env.CC_HAHA_APP_PORTABLE_DIR = '1'
env.WEBVIEW2_USER_DATA_FOLDER = path.join(portableDir, 'EBWebView')
fs.mkdirSync(env.WEBVIEW2_USER_DATA_FOLDER, { recursive: true })
return portableDir
}
export function getAppMode(
app: AppModeAppLike,
env: NodeJS.ProcessEnv = process.env,
): AppModeConfig {
const envConfigDir = env.CLAUDE_CONFIG_DIR || null
const activeConfigDir = envConfigDir || app.getPath('userData')
const portableDir = envConfigDir || defaultPortableDir(app)
return {
mode: envConfigDir ? 'portable' : 'default',
portableDir,
defaultPortableDir: defaultPortableDir(app),
activeConfigDir,
configDirSource: envConfigDir
? env.CC_HAHA_APP_PORTABLE_DIR ? 'portable' : 'environment'
: 'system',
}
}
export function setAppMode(
app: AppModeAppLike,
input: AppModeSetInput,
env: NodeJS.ProcessEnv = process.env,
): void {
const activeConfigDir = env.CLAUDE_CONFIG_DIR || app.getPath('userData')
let config: PersistedAppModeConfig = { mode: 'default', portable_dir: null }
let targetPortableDir: string | null = null
if (input.mode === 'portable') {
const selectedDir = input.portableDir?.trim() || defaultPortableDir(app)
if (fs.existsSync(selectedDir) && !fs.statSync(selectedDir).isDirectory()) {
throw new Error(`portable config path is not a directory: ${selectedDir}`)
}
fs.mkdirSync(selectedDir, { recursive: true })
targetPortableDir = selectedDir
config = {
mode: 'portable',
portable_dir: selectedDir === defaultPortableDir(app) ? null : selectedDir,
}
}
writeAppModeConfig(activeConfigDir, config)
if (targetPortableDir && targetPortableDir !== activeConfigDir) {
writeAppModeConfig(targetPortableDir, config)
}
const systemConfigDir = app.getPath('userData')
if (systemConfigDir !== activeConfigDir) {
writeAppModeConfig(systemConfigDir, config)
}
}
export function detectPortableDir(app: AppModeAppLike): PortableDetection {
const portableDir = defaultPortableDir(app)
return {
defaultPortableDir: portableDir,
hasData: dirHasPortableData(portableDir),
}
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { toElectronOpenDialogOptions, toElectronSaveDialogOptions } from './dialogs'
describe('Electron dialog service', () => {
it('maps host open options to Electron file selection options', () => {
expect(toElectronOpenDialogOptions({
directory: true,
multiple: true,
title: 'Choose folder',
defaultPath: '/tmp',
filters: [{ name: 'Markdown', extensions: ['md'] }],
})).toEqual({
properties: ['openDirectory', 'multiSelections'],
title: 'Choose folder',
defaultPath: '/tmp',
filters: [{ name: 'Markdown', extensions: ['md'] }],
})
})
it('defaults open dialogs to single-file selection', () => {
expect(toElectronOpenDialogOptions()).toEqual({
properties: ['openFile'],
title: undefined,
defaultPath: undefined,
filters: undefined,
})
})
it('maps host save options to Electron save options', () => {
expect(toElectronSaveDialogOptions({
title: 'Save transcript',
defaultPath: '/tmp/session.md',
filters: [{ name: 'Markdown', extensions: ['md'] }],
})).toEqual({
title: 'Save transcript',
defaultPath: '/tmp/session.md',
filters: [{ name: 'Markdown', extensions: ['md'] }],
})
})
})
+41
View File
@@ -0,0 +1,41 @@
import type { BrowserWindow, OpenDialogOptions, SaveDialogOptions } from 'electron'
import type { DialogOpenOptions, DialogSaveOptions } from '../../src/lib/desktopHost/types'
export function toElectronOpenDialogOptions(options: DialogOpenOptions = {}): OpenDialogOptions {
return {
properties: [
options.directory ? 'openDirectory' : 'openFile',
options.multiple ? 'multiSelections' : undefined,
].filter(Boolean) as OpenDialogOptions['properties'],
title: options.title,
defaultPath: options.defaultPath,
filters: options.filters,
}
}
export function toElectronSaveDialogOptions(options: DialogSaveOptions = {}): SaveDialogOptions {
return {
title: options.title,
defaultPath: options.defaultPath,
filters: options.filters,
}
}
export async function openDialog(parentWindow: BrowserWindow | null, options?: DialogOpenOptions): Promise<string | string[] | null> {
const { dialog } = await import('electron')
const dialogOptions = toElectronOpenDialogOptions(options)
const result = parentWindow
? await dialog.showOpenDialog(parentWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions)
if (result.canceled) return null
return options?.multiple ? result.filePaths : result.filePaths[0] ?? null
}
export async function saveDialog(parentWindow: BrowserWindow | null, options?: DialogSaveOptions): Promise<string | null> {
const { dialog } = await import('electron')
const dialogOptions = toElectronSaveDialogOptions(options)
const result = parentWindow
? await dialog.showSaveDialog(parentWindow, dialogOptions)
: await dialog.showSaveDialog(dialogOptions)
return result.canceled ? null : result.filePath ?? null
}
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from 'vitest'
import { installMacOsChromiumKeychainPromptGuard } from './keychain'
describe('Electron Chromium keychain guard', () => {
it('uses Chromium mock keychain on macOS to avoid Safe Storage prompts', () => {
const appendSwitch = vi.fn()
const installed = installMacOsChromiumKeychainPromptGuard(
{ commandLine: { appendSwitch } },
'darwin',
)
expect(installed).toBe(true)
expect(appendSwitch).toHaveBeenCalledWith('use-mock-keychain')
})
it('leaves non-macOS platforms on their default credential backend', () => {
const appendSwitch = vi.fn()
const installed = installMacOsChromiumKeychainPromptGuard(
{ commandLine: { appendSwitch } },
'linux',
)
expect(installed).toBe(false)
expect(appendSwitch).not.toHaveBeenCalled()
})
})
+21
View File
@@ -0,0 +1,21 @@
type ElectronAppWithCommandLine = {
commandLine: {
appendSwitch(name: string, value?: string): void
}
}
/**
* Chromium creates a per-app "Safe Storage" key in macOS Keychain for browser
* profile encryption. Claude Code Haha does not rely on Chromium cookies or
* password storage for auth secrets; OAuth tokens live in the desktop sidecar
* files instead. Using Chromium's mock keychain avoids repeated macOS password
* prompts when dev/unsigned Electron builds cannot reuse the old Keychain ACL.
*/
export function installMacOsChromiumKeychainPromptGuard(
app: ElectronAppWithCommandLine,
platform: NodeJS.Platform = process.platform,
): boolean {
if (platform !== 'darwin') return false
app.commandLine.appendSwitch('use-mock-keychain')
return true
}
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MenuItemConstructorOptions } from 'electron'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import { buildApplicationMenuTemplate, installApplicationMenu } from './menu'
const menuMocksKey = '__electronMenuMocks'
function createElectronMenuMocks() {
return {
buildFromTemplate: vi.fn((template: unknown) => ({ template })),
setApplicationMenu: vi.fn(),
}
}
function getElectronMenuMocks() {
const store = globalThis as Record<string, unknown>
const existing = store[menuMocksKey] as ReturnType<typeof createElectronMenuMocks> | undefined
if (existing) return existing
const created = createElectronMenuMocks()
store[menuMocksKey] = created
return created
}
vi.mock('electron', () => {
const mocks = getElectronMenuMocks()
return {
Menu: {
buildFromTemplate: mocks.buildFromTemplate,
setApplicationMenu: mocks.setApplicationMenu,
},
}
})
describe('Electron application menu service', () => {
afterEach(() => {
const mocks = getElectronMenuMocks()
mocks.buildFromTemplate.mockClear()
mocks.setApplicationMenu.mockClear()
})
it('emits native navigation destinations from macOS app menu items', () => {
const onNavigate = vi.fn()
const template = buildApplicationMenuTemplate('Claude Code Haha', onNavigate, 'darwin')
const appMenu = template[0]
expect(appMenu).toBeDefined()
const submenu = appMenu!.submenu as MenuItemConstructorOptions[]
const aboutItem = submenu[0]
const settingsItem = submenu[2]
expect(aboutItem).toBeDefined()
expect(settingsItem).toBeDefined()
aboutItem!.click?.({} as never, {} as never, {} as never)
settingsItem!.click?.({} as never, {} as never, {} as never)
expect(onNavigate).toHaveBeenNthCalledWith(1, 'about')
expect(onNavigate).toHaveBeenNthCalledWith(2, 'settings')
})
it('keeps a settings entry available on non-macOS platforms', () => {
const template = buildApplicationMenuTemplate('Claude Code Haha', vi.fn(), 'win32')
const fileMenu = template[0]
expect(fileMenu).toBeDefined()
const fileSubmenu = fileMenu!.submenu as MenuItemConstructorOptions[]
expect(fileSubmenu.some(item => item.label === 'Settings...')).toBe(true)
})
it('installs a native menu that forwards settings navigation to the renderer event channel', async () => {
const menuMocks = getElectronMenuMocks()
menuMocks.buildFromTemplate.mockClear()
menuMocks.setApplicationMenu.mockClear()
const send = vi.fn()
await installApplicationMenu(
{ name: 'Claude Code Haha' } as never,
() => ({ webContents: { send } }) as never,
)
expect(menuMocks.buildFromTemplate).toHaveBeenCalledTimes(1)
expect(menuMocks.setApplicationMenu).toHaveBeenCalledWith({
template: menuMocks.buildFromTemplate.mock.calls[0]?.[0],
})
const template = menuMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[]
const settingsItem = template
.flatMap(item => (item.submenu as MenuItemConstructorOptions[] | undefined) ?? [])
.find(item => item.label === 'Settings...')
expect(settingsItem).toBeDefined()
settingsItem?.click?.({} as never, {} as never, {} as never)
expect(send).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, 'settings')
})
})
+74
View File
@@ -0,0 +1,74 @@
import type { App, BrowserWindow, MenuItemConstructorOptions } from 'electron'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
export type NativeMenuDestination = 'about' | 'settings'
export function buildApplicationMenuTemplate(
appName: string,
onNavigate: (destination: NativeMenuDestination) => void,
platform = process.platform,
): MenuItemConstructorOptions[] {
const appMenu: MenuItemConstructorOptions[] = platform === 'darwin'
? [{
label: appName,
submenu: [
{ label: `About ${appName}`, click: () => onNavigate('about') },
{ type: 'separator' },
{ label: 'Settings...', accelerator: 'CmdOrCtrl+,', click: () => onNavigate('settings') },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
}]
: [{
label: 'File',
submenu: [
{ label: 'Settings...', accelerator: 'Ctrl+,', click: () => onNavigate('settings') },
{ type: 'separator' },
{ role: 'quit' },
],
}]
return [
...appMenu,
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
{ role: 'close' },
],
},
]
}
export async function installApplicationMenu(app: App, getMainWindow: () => BrowserWindow | null) {
const { Menu } = await import('electron')
const template = buildApplicationMenuTemplate(app.name || 'Claude Code Haha', destination => {
getMainWindow()?.webContents.send(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, destination)
})
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
@@ -0,0 +1,187 @@
import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
appendNotificationSmokeLog,
logNotificationSmokeRendererAck,
parseNotificationSmokeDelay,
scheduleNotificationSmoke,
} from './notificationSmoke'
import type { ElectronNotificationConstructor } from './notifications'
function fakeNotificationClass() {
const handlers: Record<string, () => void> = {}
const show = vi.fn()
const NotificationClass = vi.fn(function (_options: unknown) {
return {
show,
on(event: 'click' | 'close' | 'failed', handler: () => void) {
handlers[event] = handler
return this
},
}
}) as unknown as ElectronNotificationConstructor & ReturnType<typeof vi.fn>
NotificationClass.isSupported = () => true
return { NotificationClass, handlers, show }
}
describe('Electron notification smoke hook', () => {
it('does not schedule a smoke notification without an explicit target session', () => {
const timer = vi.fn()
const { NotificationClass } = fakeNotificationClass()
expect(scheduleNotificationSmoke({
env: {},
NotificationClass,
onAction: vi.fn(),
setTimer: timer,
})).toBe(false)
expect(timer).not.toHaveBeenCalled()
expect(NotificationClass).not.toHaveBeenCalled()
})
it('schedules a targeted notification for Computer Use smoke validation', () => {
const timer = vi.fn((handler: () => void) => handler())
const { NotificationClass, handlers, show } = fakeNotificationClass()
const onAction = vi.fn()
const writeLog = vi.fn()
expect(scheduleNotificationSmoke({
env: {
CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_SESSION_ID: 'session-smoke',
CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_TITLE: 'Target smoke',
CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_BODY: 'Click target smoke',
CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_DELAY_MS: '10',
},
NotificationClass,
onAction,
setTimer: timer,
writeLog,
})).toBe(true)
expect(timer).toHaveBeenCalledWith(expect.any(Function), 10)
expect(NotificationClass).toHaveBeenCalledWith({
title: 'Target smoke',
body: 'Click target smoke',
icon: undefined,
})
expect(show).toHaveBeenCalledTimes(1)
handlers.click?.()
expect(onAction).toHaveBeenCalledWith({
id: undefined,
extra: undefined,
action: 'click',
target: {
type: 'session',
sessionId: 'session-smoke',
title: 'Target smoke',
},
})
expect(writeLog).toHaveBeenCalledWith(expect.objectContaining({
event: 'scheduled',
sessionId: 'session-smoke',
title: 'Target smoke',
delayMs: 10,
}))
expect(writeLog).toHaveBeenCalledWith(expect.objectContaining({
event: 'sent',
sessionId: 'session-smoke',
sent: true,
}))
expect(writeLog).toHaveBeenCalledWith(expect.objectContaining({
event: 'action',
sessionId: 'session-smoke',
}))
handlers.close?.()
expect(writeLog).toHaveBeenCalledWith(expect.objectContaining({
event: 'lifecycle',
sessionId: 'session-smoke',
lifecycle: 'close',
}))
})
it('can trigger an explicit synthetic action for packaged renderer ack smoke', () => {
const timer = vi.fn((handler: () => void) => handler())
const { NotificationClass } = fakeNotificationClass()
const onAction = vi.fn()
const writeLog = vi.fn()
expect(scheduleNotificationSmoke({
env: {
CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_SESSION_ID: 'session-smoke',
CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_TITLE: 'Target smoke',
CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_TRIGGER_ACTION: '1',
},
NotificationClass,
onAction,
setTimer: timer,
writeLog,
})).toBe(true)
expect(writeLog).toHaveBeenCalledWith(expect.objectContaining({
event: 'synthetic_action',
sessionId: 'session-smoke',
payload: {
action: 'synthetic-click',
target: {
type: 'session',
sessionId: 'session-smoke',
title: 'Target smoke',
},
},
}))
expect(onAction).toHaveBeenCalledWith({
action: 'synthetic-click',
target: {
type: 'session',
sessionId: 'session-smoke',
title: 'Target smoke',
},
})
})
it('clamps invalid or excessive notification smoke delays', () => {
expect(parseNotificationSmokeDelay(undefined)).toBe(2500)
expect(parseNotificationSmokeDelay('wat')).toBe(2500)
expect(parseNotificationSmokeDelay('-10')).toBe(0)
expect(parseNotificationSmokeDelay('999999')).toBe(60000)
})
it('writes JSONL smoke events when a log path is configured', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'cc-haha-notification-smoke-'))
const logPath = path.join(tmp, 'nested', 'notification.jsonl')
appendNotificationSmokeLog(logPath, {
event: 'sent',
timestamp: '2026-06-01T00:00:00.000Z',
sessionId: 'session-smoke',
sent: true,
})
expect(readFileSync(logPath, 'utf-8')).toBe(
'{"event":"sent","timestamp":"2026-06-01T00:00:00.000Z","sessionId":"session-smoke","sent":true}\n',
)
})
it('writes renderer acknowledgements to the notification smoke log only when configured', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'cc-haha-notification-smoke-'))
const logPath = path.join(tmp, 'nested', 'notification.jsonl')
expect(logNotificationSmokeRendererAck({}, { target: { type: 'session', sessionId: 'session-smoke' } })).toBe(false)
expect(logNotificationSmokeRendererAck({
CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_LOG: logPath,
} as NodeJS.ProcessEnv, { target: { type: 'session', sessionId: 'session-smoke' } })).toBe(true)
const [entry] = readFileSync(logPath, 'utf-8').trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
expect(entry).toMatchObject({
event: 'renderer_ack',
payload: {
target: { type: 'session', sessionId: 'session-smoke' },
},
})
})
})
@@ -0,0 +1,159 @@
import { appendFileSync, mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import {
sendDesktopNotification,
type ElectronNotificationConstructor,
} from './notifications'
const DEFAULT_DELAY_MS = 2500
const MAX_DELAY_MS = 60_000
export type NotificationSmokeTimer = (handler: () => void, delayMs: number) => unknown
export type NotificationSmokeLogEvent =
| {
event: 'scheduled'
timestamp: string
sessionId: string
title: string
body: string
delayMs: number
}
| {
event: 'sent'
timestamp: string
sessionId: string
sent: boolean
}
| {
event: 'action'
timestamp: string
sessionId: string
payload: unknown
}
| {
event: 'synthetic_action'
timestamp: string
sessionId: string
payload: unknown
}
| {
event: 'renderer_ack'
timestamp: string
payload: unknown
}
| {
event: 'lifecycle'
timestamp: string
sessionId: string
lifecycle: 'close' | 'failed'
}
| {
event: 'send_failed'
timestamp: string
sessionId: string
error: string
}
export type NotificationSmokeLogWriter = (event: NotificationSmokeLogEvent) => void
export function parseNotificationSmokeDelay(value: string | undefined): number {
if (!value) return DEFAULT_DELAY_MS
const parsed = Number(value)
if (!Number.isFinite(parsed)) return DEFAULT_DELAY_MS
return Math.min(Math.max(Math.round(parsed), 0), MAX_DELAY_MS)
}
function shouldTriggerSyntheticAction(value: string | undefined): boolean {
return value === '1' || value?.toLowerCase() === 'true'
}
export function appendNotificationSmokeLog(logPath: string, event: NotificationSmokeLogEvent) {
mkdirSync(dirname(logPath), { recursive: true })
appendFileSync(logPath, `${JSON.stringify(event)}\n`)
}
export function logNotificationSmokeRendererAck(env: NodeJS.ProcessEnv, payload: unknown): boolean {
const logPath = env.CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_LOG?.trim()
if (!logPath) return false
appendNotificationSmokeLog(logPath, {
event: 'renderer_ack',
timestamp: new Date().toISOString(),
payload,
})
return true
}
export function scheduleNotificationSmoke({
env,
NotificationClass,
onAction,
setTimer = setTimeout,
writeLog,
}: {
env: NodeJS.ProcessEnv
NotificationClass: ElectronNotificationConstructor
onAction: (payload: unknown) => void
setTimer?: NotificationSmokeTimer
writeLog?: NotificationSmokeLogWriter
}): boolean {
const sessionId = env.CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_SESSION_ID?.trim()
if (!sessionId) return false
const title = env.CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_TITLE?.trim() || 'Claude Code Haha notification smoke'
const body = env.CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_BODY?.trim() || 'Click to return to the target session.'
const delayMs = parseNotificationSmokeDelay(env.CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_DELAY_MS)
const triggerSyntheticAction = shouldTriggerSyntheticAction(env.CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_TRIGGER_ACTION)
const logPath = env.CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_LOG?.trim()
const log: NotificationSmokeLogWriter = (event) => {
if (writeLog) {
writeLog(event)
return
}
if (logPath) appendNotificationSmokeLog(logPath, event)
}
log({ event: 'scheduled', timestamp: new Date().toISOString(), sessionId, title, body, delayMs })
setTimer(() => {
try {
const target = {
type: 'session' as const,
sessionId,
title,
}
const sent = sendDesktopNotification({
NotificationClass,
options: {
title,
body,
target,
},
onAction: (payload) => {
log({ event: 'action', timestamp: new Date().toISOString(), sessionId, payload })
onAction(payload)
},
onLifecycle: (lifecycle) => {
log({ event: 'lifecycle', timestamp: new Date().toISOString(), sessionId, lifecycle })
},
})
log({ event: 'sent', timestamp: new Date().toISOString(), sessionId, sent })
if (sent && triggerSyntheticAction) {
const payload = {
target,
action: 'synthetic-click',
}
log({ event: 'synthetic_action', timestamp: new Date().toISOString(), sessionId, payload })
onAction(payload)
}
} catch (error) {
log({
event: 'send_failed',
timestamp: new Date().toISOString(),
sessionId,
error: error instanceof Error ? error.message : String(error),
})
throw error
}
}, delayMs)
return true
}
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest'
import {
notificationPermissionState,
requestNotificationPermission,
sendDesktopNotification,
validateNotificationOptions,
type ElectronNotificationConstructor,
} from './notifications'
function fakeNotificationClass(supported = true) {
const handlers = new Map<string, () => void>()
const show = vi.fn()
const NotificationClass = vi.fn(function (_options: unknown) {
return {
show,
on(event: 'click' | 'close' | 'failed', handler: () => void) {
handlers.set(event, handler)
return this
},
}
}) as unknown as ElectronNotificationConstructor & ReturnType<typeof vi.fn>
NotificationClass.isSupported = () => supported
return { NotificationClass, handlers, show }
}
describe('Electron notification service', () => {
it('reports Electron notification support as host permission state', () => {
expect(notificationPermissionState(fakeNotificationClass(true).NotificationClass)).toBe('granted')
expect(requestNotificationPermission(fakeNotificationClass(false).NotificationClass)).toBe('denied')
})
it('validates notification payloads before constructing OS notifications', () => {
expect(validateNotificationOptions({ title: 'Done', body: 'Task complete' })).toBe(true)
expect(validateNotificationOptions({ title: '' })).toBe(false)
expect(validateNotificationOptions({ title: 'Done', extra: [] })).toBe(false)
})
it('shows a notification and forwards click targets through the host action channel', () => {
const { NotificationClass, handlers, show } = fakeNotificationClass(true)
const onAction = vi.fn()
const onLifecycle = vi.fn()
const target = { type: 'session', sessionId: 'session-1' }
expect(sendDesktopNotification({
NotificationClass,
options: {
id: 1,
title: 'Done',
body: 'Task complete',
extra: { ccHahaTarget: JSON.stringify(target) },
target,
},
onAction,
onLifecycle,
})).toBe(true)
expect(NotificationClass).toHaveBeenCalledWith({
title: 'Done',
body: 'Task complete',
icon: undefined,
})
expect(show).toHaveBeenCalledTimes(1)
expect(handlers.has('close')).toBe(true)
expect(handlers.has('failed')).toBe(true)
handlers.get('click')?.()
expect(onAction).toHaveBeenCalledWith({
id: 1,
extra: { ccHahaTarget: JSON.stringify(target) },
target,
action: 'click',
})
expect(onLifecycle).not.toHaveBeenCalled()
})
it('reports close and failed lifecycle events for smoke diagnostics', () => {
const { NotificationClass, handlers } = fakeNotificationClass(true)
const onLifecycle = vi.fn()
expect(sendDesktopNotification({
NotificationClass,
options: { title: 'Done' },
onAction: vi.fn(),
onLifecycle,
})).toBe(true)
handlers.get('close')?.()
expect(onLifecycle).toHaveBeenCalledWith('close')
expect(sendDesktopNotification({
NotificationClass,
options: { title: 'Done again' },
onAction: vi.fn(),
onLifecycle,
})).toBe(true)
handlers.get('failed')?.()
expect(onLifecycle).toHaveBeenCalledWith('failed')
})
it('does not construct notifications when the platform does not support them', () => {
const { NotificationClass } = fakeNotificationClass(false)
expect(sendDesktopNotification({
NotificationClass,
options: { title: 'Done' },
onAction: vi.fn(),
})).toBe(false)
expect(NotificationClass).not.toHaveBeenCalled()
})
it('rejects malformed notification payloads before constructing Electron notifications', () => {
const { NotificationClass } = fakeNotificationClass(true)
expect(() => sendDesktopNotification({
NotificationClass,
options: { body: 'Missing title' },
onAction: vi.fn(),
})).toThrow('Invalid Electron notification payload')
expect(NotificationClass).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,83 @@
import type { DesktopNotificationOptions, NotificationPermissionState } from '../../src/lib/desktopHost/types'
export type ElectronNotificationInstance = {
show(): void
on(event: 'click' | 'close' | 'failed', handler: () => void): ElectronNotificationInstance
}
export type ElectronNotificationConstructor = {
new(options: { title: string, body?: string, icon?: string }): ElectronNotificationInstance
isSupported(): boolean
}
const activeNotifications = new Set<ElectronNotificationInstance>()
export function validateNotificationOptions(value: unknown): value is DesktopNotificationOptions {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const record = value as Record<string, unknown>
return typeof record.title === 'string'
&& record.title.trim().length > 0
&& (record.body === undefined || typeof record.body === 'string')
&& (record.icon === undefined || typeof record.icon === 'string')
&& (record.id === undefined || typeof record.id === 'number')
&& (record.extra === undefined || (typeof record.extra === 'object' && record.extra !== null && !Array.isArray(record.extra)))
}
export function notificationPermissionState(
NotificationClass: ElectronNotificationConstructor,
): NotificationPermissionState {
return NotificationClass.isSupported() ? 'granted' : 'denied'
}
export function requestNotificationPermission(
NotificationClass: ElectronNotificationConstructor,
): NotificationPermissionState {
return notificationPermissionState(NotificationClass)
}
export function sendDesktopNotification({
NotificationClass,
options,
onAction,
onLifecycle,
}: {
NotificationClass: ElectronNotificationConstructor
options: unknown
onAction: (payload: unknown) => void
onLifecycle?: (event: 'close' | 'failed') => void
}): boolean {
if (!validateNotificationOptions(options)) {
throw new Error('Invalid Electron notification payload')
}
if (!NotificationClass.isSupported()) return false
const notification = new NotificationClass({
title: options.title,
body: options.body,
icon: options.icon,
})
activeNotifications.add(notification)
const cleanup = () => {
activeNotifications.delete(notification)
}
notification.on('click', () => {
onAction({
id: options.id,
extra: options.extra,
target: options.target,
action: 'click',
})
cleanup()
})
notification.on('close', () => {
onLifecycle?.('close')
cleanup()
})
notification.on('failed', () => {
onLifecycle?.('failed')
cleanup()
})
notification.show()
return true
}
+290
View File
@@ -0,0 +1,290 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import {
ElectronPreviewService,
normalizePreviewBounds,
normalizePreviewUrl,
parsePreviewAgentMessage,
resolvePreviewScriptPath,
shouldForwardPreviewMessage,
type PreviewViewLike,
type PreviewWebContentsLike,
} from './preview'
class FakeWebContents implements PreviewWebContentsLike {
loadedUrls: string[] = []
scripts: string[] = []
sent: Array<{ channel: string, payload: unknown }> = []
close = vi.fn()
private loadHandler: (() => void) | null = null
async loadURL(url: string) {
this.loadedUrls.push(url)
this.loadHandler?.()
}
async executeJavaScript(script: string) {
this.scripts.push(script)
return 'ok'
}
on(_event: 'did-finish-load', handler: () => void) {
this.loadHandler = handler
}
isDestroyed() {
return false
}
send(channel: string, payload: unknown) {
this.sent.push({ channel, payload })
}
}
class FakeView implements PreviewViewLike {
webContents = new FakeWebContents()
bounds: unknown[] = []
visible: boolean[] = []
setBounds(bounds: unknown) {
this.bounds.push(bounds)
}
setVisible(visible: boolean) {
this.visible.push(visible)
}
}
const tempDirs: string[] = []
function previewScript() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-preview-'))
tempDirs.push(dir)
const file = path.join(dir, 'preview-agent.js')
fs.writeFileSync(file, 'window.__previewInjected = true')
return file
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
})
describe('Electron preview service', () => {
it('allows only http and https URLs', () => {
expect(normalizePreviewUrl(' https://example.com ')).toBe('https://example.com')
expect(normalizePreviewUrl('http://127.0.0.1:3000')).toBe('http://127.0.0.1:3000')
expect(() => normalizePreviewUrl('file:///tmp/index.html')).toThrow('unsupported url scheme')
expect(() => normalizePreviewUrl('javascript:alert(1)')).toThrow('unsupported url scheme')
})
it('normalizes finite bounds for WebContentsView', () => {
expect(normalizePreviewBounds({ x: 1.2, y: 2.7, width: 20.4, height: -1 })).toEqual({
x: 1,
y: 3,
width: 20,
height: 0,
})
expect(() => normalizePreviewBounds({ x: Number.NaN, y: 0, width: 1, height: 1 })).toThrow('invalid preview bounds x')
})
it('falls back from app.asar to app.asar.unpacked for the preview agent script', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-preview-asar-'))
tempDirs.push(dir)
const unpackedFile = path.join(dir, 'app.asar.unpacked', 'src-tauri', 'resources', 'preview-agent.js')
fs.mkdirSync(path.dirname(unpackedFile), { recursive: true })
fs.writeFileSync(unpackedFile, 'window.__previewInjected = true')
const packagedPath = path.join(dir, 'app.asar', 'src-tauri', 'resources', 'preview-agent.js')
expect(resolvePreviewScriptPath(packagedPath)).toBe(unpackedFile)
})
it('creates one child WebContentsView, loads URLs, and injects the preview agent after load', async () => {
const view = new FakeView()
const parent = {
contentView: {
addChildView: vi.fn(),
removeChildView: vi.fn(),
},
}
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open(parent, 'http://localhost:5173', { x: 1, y: 2, width: 300, height: 200 })
await service.open(parent, 'https://example.com', { x: 3, y: 4, width: 500, height: 240 })
service.setVisible(false)
service.setBounds({ x: 5, y: 6, width: 100, height: 80 })
expect(parent.contentView.addChildView).toHaveBeenCalledTimes(1)
expect(view.webContents.loadedUrls).toEqual(['http://localhost:5173', 'https://example.com'])
expect(view.bounds).toEqual([
{ x: 1, y: 2, width: 300, height: 200 },
{ x: 3, y: 4, width: 500, height: 240 },
{ x: 5, y: 6, width: 100, height: 80 },
])
expect(view.visible).toEqual([false])
expect(view.webContents.scripts).toEqual(['window.__previewInjected = true', 'window.__previewInjected = true'])
})
it('forwards only validated preview messages from the child view to the renderer', async () => {
const view = new FakeView()
const renderer = new FakeWebContents()
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', {
x: 0,
y: 0,
width: 100,
height: 100,
})
service.sendMessageToRenderer(view.webContents, '{"v":1,"type":"ready"}', renderer)
service.sendMessageToRenderer(new FakeWebContents(), '{"v":1,"type":"ready"}', renderer)
expect(() => service.sendMessageToRenderer(view.webContents, 'not-json', renderer)).not.toThrow()
service.sendMessageToRenderer(view.webContents, '{"v":1,"type":"unknown"}', renderer)
service.sendMessageToRenderer(view.webContents, JSON.stringify({ v: 1, type: 'screenshot', dataUrl: 'data:text/html;base64,AAAA', kind: 'full' }), renderer)
expect(renderer.sent).toEqual([
{
channel: ELECTRON_EVENT_CHANNELS.previewEvent,
payload: { v: 1, type: 'ready' },
},
])
})
it('stops forwarding preview messages after the view is closed', async () => {
const view = new FakeView()
const renderer = new FakeWebContents()
const parent = { contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open(parent, 'https://example.com', { x: 0, y: 0, width: 100, height: 100 })
service.close()
service.sendMessageToRenderer(view.webContents, '{"v":1,"type":"ready"}', renderer)
expect(renderer.sent).toEqual([])
})
it('forwards host messages into the injected preview bridge', async () => {
const view = new FakeView()
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', {
x: 0,
y: 0,
width: 100,
height: 100,
})
await service.message({ v: 1, type: 'capture', kind: 'viewport' })
expect(view.webContents.scripts.at(-1)).toBe(
'globalThis.__PREVIEW_BRIDGE__?.handleHostRaw("{\\"v\\":1,\\"type\\":\\"capture\\",\\"kind\\":\\"viewport\\"}")',
)
})
it('rejects host messages before a preview view exists', async () => {
const service = new ElectronPreviewService({
createView: () => new FakeView(),
previewScriptPath: previewScript(),
})
await expect(service.message({ v: 1, type: 'capture', kind: 'viewport' })).rejects.toThrow('preview not open')
})
it('allows preload forwarding only for top-level http/https preview pages', () => {
expect(shouldForwardPreviewMessage({
raw: '{"v":1,"type":"ready"}',
href: 'https://example.com/workbench',
isTopFrame: true,
})).toBe(true)
expect(shouldForwardPreviewMessage({
raw: '{"v":1,"type":"ready"}',
href: 'http://127.0.0.1:3000',
isTopFrame: true,
})).toBe(true)
expect(shouldForwardPreviewMessage({
raw: '{"v":1,"type":"ready"}',
href: 'https://example.com/frame',
isTopFrame: false,
})).toBe(false)
expect(shouldForwardPreviewMessage({
raw: '{"v":1,"type":"ready"}',
href: 'file:///tmp/index.html',
isTopFrame: true,
})).toBe(false)
expect(shouldForwardPreviewMessage({
raw: { type: 'ready' },
href: 'https://example.com',
isTopFrame: true,
})).toBe(false)
expect(shouldForwardPreviewMessage({
raw: '{"v":1,"type":"ready"}',
href: 'not-a-url',
isTopFrame: true,
})).toBe(false)
})
it('parses only bounded preview agent message shapes', () => {
expect(parsePreviewAgentMessage('{"v":1,"type":"ready"}')).toEqual({ v: 1, type: 'ready' })
expect(parsePreviewAgentMessage(JSON.stringify({
v: 1,
type: 'navigated',
url: 'https://example.com',
title: 'Example',
}))).toEqual({
v: 1,
type: 'navigated',
url: 'https://example.com',
title: 'Example',
})
expect(parsePreviewAgentMessage(JSON.stringify({
v: 1,
type: 'screenshot',
dataUrl: 'data:image/png;base64,AAAA',
kind: 'full',
}))).toEqual({
v: 1,
type: 'screenshot',
dataUrl: 'data:image/png;base64,AAAA',
kind: 'full',
})
expect(parsePreviewAgentMessage('{"v":1,"type":"screenshot","dataUrl":"data:text/html;base64,AAAA","kind":"full"}')).toBeNull()
expect(parsePreviewAgentMessage(JSON.stringify({ v: 1, type: 'navigated', url: 'file:///tmp/a', title: 'A' }))).toBeNull()
expect(parsePreviewAgentMessage(JSON.stringify({ v: 1, type: 'selection', payload: null }))).toBeNull()
})
it('removes and closes the preview view', async () => {
const view = new FakeView()
const parent = {
contentView: {
addChildView: vi.fn(),
removeChildView: vi.fn(),
},
}
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open(parent, 'https://example.com', { x: 0, y: 0, width: 100, height: 100 })
service.close()
expect(parent.contentView.removeChildView).toHaveBeenCalledWith(view)
expect(view.webContents.close).toHaveBeenCalled()
await expect(service.message({ v: 1, type: 'capture', kind: 'full' })).rejects.toThrow('preview not open')
})
})
+147
View File
@@ -0,0 +1,147 @@
import { existsSync, readFileSync } from 'node:fs'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import { parsePreviewAgentMessage } from '../ipc/previewMessage'
export { parsePreviewAgentMessage, shouldForwardPreviewMessage } from '../ipc/previewMessage'
export type PreviewBounds = {
x: number
y: number
width: number
height: number
}
export type PreviewWebContentsLike = {
loadURL(url: string): Promise<unknown>
executeJavaScript(script: string): Promise<unknown>
on(event: 'did-finish-load', handler: () => void): unknown
close?(): void
isDestroyed?(): boolean
send(channel: string, payload: unknown): void
}
export type PreviewViewLike = {
webContents: PreviewWebContentsLike
setBounds(bounds: PreviewBounds): void
setVisible?(visible: boolean): void
}
export type PreviewParentWindowLike = {
contentView: {
addChildView(view: unknown): void
removeChildView(view: unknown): void
}
}
export type ElectronPreviewServiceOptions = {
createView: () => PreviewViewLike
previewScriptPath: string
}
export function normalizePreviewUrl(input: string): string {
const trimmed = input.trim()
if (!trimmed) throw new Error('empty url')
const parsed = new URL(trimmed)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`unsupported url scheme: ${trimmed}`)
}
return trimmed
}
export function normalizePreviewBounds(bounds: PreviewBounds): PreviewBounds {
for (const [key, value] of Object.entries(bounds)) {
if (!Number.isFinite(value)) throw new Error(`invalid preview bounds ${key}`)
}
return {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(0, Math.round(bounds.width)),
height: Math.max(0, Math.round(bounds.height)),
}
}
export function resolvePreviewScriptPath(previewScriptPath: string): string {
if (existsSync(previewScriptPath)) return previewScriptPath
const unpackedPath = previewScriptPath.replace(/\.asar([/\\])/, '.asar.unpacked$1')
if (unpackedPath !== previewScriptPath && existsSync(unpackedPath)) return unpackedPath
return previewScriptPath
}
export class ElectronPreviewService {
private readonly createView: () => PreviewViewLike
private readonly previewScriptPath: string
private view: PreviewViewLike | null = null
private parent: PreviewParentWindowLike | null = null
constructor(options: ElectronPreviewServiceOptions) {
this.createView = options.createView
this.previewScriptPath = options.previewScriptPath
}
async open(parent: PreviewParentWindowLike, url: string, bounds: PreviewBounds): Promise<void> {
const normalizedUrl = normalizePreviewUrl(url)
const normalizedBounds = normalizePreviewBounds(bounds)
const view = this.ensureView(parent)
view.setBounds(normalizedBounds)
await view.webContents.loadURL(normalizedUrl)
}
async navigate(url: string): Promise<void> {
const view = this.requireView()
await view.webContents.loadURL(normalizePreviewUrl(url))
}
setBounds(bounds: PreviewBounds): void {
this.view?.setBounds(normalizePreviewBounds(bounds))
}
setVisible(visible: boolean): void {
this.view?.setVisible?.(visible)
}
close(): void {
if (!this.view) return
this.parent?.contentView.removeChildView(this.view)
if (!this.view.webContents.isDestroyed?.()) {
this.view.webContents.close?.()
}
this.view = null
this.parent = null
}
async message(payload: unknown): Promise<void> {
const raw = JSON.stringify(payload)
const script = `globalThis.__PREVIEW_BRIDGE__?.handleHostRaw(${JSON.stringify(raw)})`
await this.requireView().webContents.executeJavaScript(script)
}
sendMessageToRenderer(sender: PreviewWebContentsLike, raw: unknown, renderer: PreviewWebContentsLike | null | undefined): void {
if (sender !== this.view?.webContents) return
if (typeof raw !== 'string') return
const message = parsePreviewAgentMessage(raw)
if (!message) return
renderer?.send(ELECTRON_EVENT_CHANNELS.previewEvent, message)
}
private ensureView(parent: PreviewParentWindowLike): PreviewViewLike {
if (this.view) return this.view
const view = this.createView()
parent.contentView.addChildView(view)
view.webContents.on('did-finish-load', () => {
void this.injectPreviewAgent(view)
})
this.view = view
this.parent = parent
return view
}
private requireView(): PreviewViewLike {
if (!this.view) throw new Error('preview not open')
return this.view
}
private async injectPreviewAgent(view: PreviewViewLike): Promise<void> {
if (view.webContents.isDestroyed?.()) return
const script = readFileSync(resolvePreviewScriptPath(this.previewScriptPath), 'utf8')
await view.webContents.executeJavaScript(script)
}
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import path from 'node:path'
import { isAllowedDevRendererUrl, resolveRendererEntry } from './rendererEntry'
describe('Electron renderer entry resolution', () => {
it('allows only local http renderer URLs in development', () => {
expect(isAllowedDevRendererUrl('http://127.0.0.1:1420')).toBe(true)
expect(isAllowedDevRendererUrl('http://localhost:1420')).toBe(true)
expect(isAllowedDevRendererUrl('http://[::1]:1420')).toBe(true)
expect(isAllowedDevRendererUrl('https://127.0.0.1:1420')).toBe(false)
expect(isAllowedDevRendererUrl('http://example.com')).toBe(false)
expect(isAllowedDevRendererUrl('file:///tmp/index.html')).toBe(false)
})
it('ignores ELECTRON_RENDERER_URL once the app is packaged', () => {
expect(resolveRendererEntry({
isPackaged: true,
appRoot: '/Applications/Test.app/Contents/Resources/app.asar',
env: { ELECTRON_RENDERER_URL: 'http://127.0.0.1:1420' },
})).toBe(path.join('/Applications/Test.app/Contents/Resources/app.asar', 'dist', 'index.html'))
})
it('rejects non-local development renderer URLs', () => {
expect(() => resolveRendererEntry({
isPackaged: false,
appRoot: '/repo/desktop',
env: { ELECTRON_RENDERER_URL: 'http://example.com' },
})).toThrow('Refusing non-local Electron renderer URL')
})
})
@@ -0,0 +1,31 @@
import path from 'node:path'
export type RendererEntryOptions = {
isPackaged: boolean
appRoot: string
env?: NodeJS.ProcessEnv
}
export function isAllowedDevRendererUrl(input: string): boolean {
try {
const parsed = new URL(input)
if (parsed.protocol !== 'http:') return false
return parsed.hostname === '127.0.0.1' ||
parsed.hostname === 'localhost' ||
parsed.hostname === '::1' ||
parsed.hostname === '[::1]'
} catch {
return false
}
}
export function resolveRendererEntry(options: RendererEntryOptions): string {
const devUrl = options.env?.ELECTRON_RENDERER_URL?.trim()
if (!options.isPackaged && devUrl) {
if (!isAllowedDevRendererUrl(devUrl)) {
throw new Error(`Refusing non-local Electron renderer URL: ${devUrl}`)
}
return devUrl
}
return path.join(options.appRoot, 'dist', 'index.html')
}
+143
View File
@@ -0,0 +1,143 @@
import path from 'node:path'
import {
createAdapterPlan,
createServerPlan,
formatStartupError,
killSidecar,
pushStartupLog,
reserveLocalPort,
SERVER_BIND_HOST,
SERVER_CONTROL_HOST,
spawnSidecar,
waitForServer,
type SidecarChild,
} from './sidecarManager'
type ServerRuntimeOptions = {
desktopRoot: string
appRoot?: string
h5DistDir?: string
}
export class ElectronServerRuntime {
private readonly desktopRoot: string
private readonly appRoot: string
private readonly h5DistDir: string
private server: { url: string, child: SidecarChild } | null = null
private adapters: SidecarChild[] = []
private startupError: string | null = null
private startPromise: Promise<string> | null = null
constructor(options: ServerRuntimeOptions) {
this.desktopRoot = options.desktopRoot
this.appRoot = options.appRoot ?? options.desktopRoot
this.h5DistDir = options.h5DistDir ?? path.join(options.desktopRoot, 'dist')
}
async startServer(): Promise<string> {
if (this.server) return this.server.url
if (this.startPromise) return this.startPromise
this.startPromise = this.startServerOnce()
try {
return await this.startPromise
} finally {
this.startPromise = null
}
}
async getServerUrl(): Promise<string> {
if (this.server) return this.server.url
if (this.startupError) throw new Error(this.startupError)
return await this.startServer()
}
async restartAdaptersSidecars(): Promise<void> {
this.stopAdaptersSidecars()
const serverUrl = await this.getServerUrl()
this.startAdaptersSidecars(serverUrl)
}
stopAll() {
this.stopAdaptersSidecars()
if (this.server) {
killSidecar(this.server.child)
this.server = null
}
}
private async startServerOnce(): Promise<string> {
const port = await reserveLocalPort(SERVER_BIND_HOST)
const url = `http://${SERVER_CONTROL_HOST}:${port}`
const logs: string[] = []
const plan = createServerPlan({
desktopRoot: this.desktopRoot,
appRoot: this.appRoot,
port,
h5DistDir: this.h5DistDir,
})
try {
const child = spawnSidecar(plan)
this.captureLogs(child, 'claude-server', logs)
await waitForServer(SERVER_CONTROL_HOST, port)
this.server = { url, child }
this.startupError = null
this.startAdaptersSidecars(url)
return url
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.startupError = formatStartupError(message, logs)
throw new Error(this.startupError)
}
}
private startAdaptersSidecars(serverUrl: string) {
for (const [label, flag] of [
['feishu', '--feishu'],
['telegram', '--telegram'],
['wechat', '--wechat'],
['dingtalk', '--dingtalk'],
] as const) {
try {
const child = spawnSidecar(createAdapterPlan({
desktopRoot: this.desktopRoot,
appRoot: this.appRoot,
h5DistDir: this.h5DistDir,
serverUrl,
flag,
}))
this.captureLogs(child, `claude-adapters:${label}`)
this.adapters.push(child)
} catch (error) {
console.error(`[desktop] failed to start ${label} adapter sidecar`, error)
}
}
}
private stopAdaptersSidecars() {
for (const child of this.adapters.splice(0)) {
killSidecar(child)
}
}
private captureLogs(child: SidecarChild, label: string, startupLogs?: string[]) {
child.stdout.on('data', chunk => {
const line = String(chunk).trimEnd()
if (!line) return
console.log(`[${label}] ${line}`)
if (startupLogs) pushStartupLog(startupLogs, `[stdout] ${line}`)
})
child.stderr.on('data', chunk => {
const line = String(chunk).trimEnd()
if (!line) return
console.error(`[${label}] ${line}`)
if (startupLogs) pushStartupLog(startupLogs, `[stderr] ${line}`)
})
child.on('exit', (code, signal) => {
const line = `sidecar exited (code=${code}, signal=${signal})`
console.log(`[${label}] ${line}`)
if (startupLogs) pushStartupLog(startupLogs, `[exit] ${line}`)
})
}
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { chmodSync, mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { normalizeExternalUrl, normalizeOpenPath, normalizeSystemSettingsUrl } from './shell'
describe('Electron shell service', () => {
it('allows only explicit external URL schemes', () => {
expect(normalizeExternalUrl('https://example.com/path')).toBe('https://example.com/path')
expect(normalizeExternalUrl('mailto:support@example.com')).toBe('mailto:support@example.com')
expect(() => normalizeExternalUrl('file:///tmp/report.md')).toThrow('Unsupported external URL scheme')
expect(() => normalizeExternalUrl('/tmp/report.md')).toThrow('absolute URLs')
})
it('allows only existing non-executable file-system paths for openPath', () => {
const rootDir = mkdtempSync(join(tmpdir(), 'electron-shell-'))
const reportPath = join(rootDir, 'report.md')
const folderPath = join(rootDir, 'folder')
const scriptPath = join(rootDir, 'run.sh')
const appPath = join(rootDir, 'Tool.app')
try {
writeFileSync(reportPath, 'ok')
mkdirSync(folderPath)
writeFileSync(scriptPath, '#!/bin/sh\n')
chmodSync(scriptPath, 0o755)
mkdirSync(appPath)
expect(normalizeOpenPath(reportPath)).toBe(realpathSync(reportPath))
expect(normalizeOpenPath(new URL(`file://${reportPath}`).toString())).toBe(realpathSync(reportPath))
expect(normalizeOpenPath(folderPath)).toBe(realpathSync(folderPath))
expect(() => normalizeOpenPath(scriptPath)).toThrow('executable')
expect(() => normalizeOpenPath(appPath)).toThrow('executable')
} finally {
rmSync(rootDir, { recursive: true, force: true })
}
expect(() => normalizeOpenPath('relative/report.md')).toThrow('absolute')
})
it('allows only explicit system settings URLs', () => {
expect(normalizeSystemSettingsUrl('ms-settings:notifications')).toBe('ms-settings:notifications')
expect(normalizeSystemSettingsUrl('x-apple.systempreferences:com.apple.preference.notifications')).toBe(
'x-apple.systempreferences:com.apple.preference.notifications',
)
expect(() => normalizeSystemSettingsUrl('ms-settings:privacy')).toThrow('Unsupported system settings URL')
})
})
+82
View File
@@ -0,0 +1,82 @@
import { realpathSync, statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ALLOWED_EXTERNAL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
const ALLOWED_SYSTEM_SETTINGS_URLS = new Set([
'ms-settings:notifications',
'x-apple.systempreferences:com.apple.preference.notifications',
])
const BLOCKED_EXECUTABLE_EXTENSIONS = new Set([
'.app',
'.bat',
'.cmd',
'.com',
'.exe',
'.msi',
'.ps1',
'.scr',
'.sh',
])
export function normalizeExternalUrl(target: string): string {
let url: URL
try {
url = new URL(target)
} catch {
throw new Error('External shell targets must be absolute URLs')
}
if (!ALLOWED_EXTERNAL_PROTOCOLS.has(url.protocol)) {
throw new Error(`Unsupported external URL scheme: ${url.protocol}`)
}
return url.toString()
}
export function normalizeOpenPath(target: string): string {
const filePath = target.startsWith('file://') ? fileURLToPath(target) : target
if (!path.isAbsolute(filePath)) {
throw new Error('System file paths must be absolute')
}
const realPath = realpathSync(filePath)
const stat = statSync(realPath)
if (!stat.isFile() && !stat.isDirectory()) {
throw new Error('System file paths must point to a file or directory')
}
if (isBlockedExecutablePath(realPath, stat.isDirectory())) {
throw new Error('System file paths must not point to executable apps or scripts')
}
return realPath
}
function isBlockedExecutablePath(realPath: string, isDirectory: boolean) {
const ext = path.extname(realPath).toLowerCase()
if (BLOCKED_EXECUTABLE_EXTENSIONS.has(ext)) return true
if (isDirectory) return false
if (process.platform === 'win32') return false
return (statSync(realPath).mode & 0o111) !== 0
}
export async function openExternalUrl(target: string): Promise<void> {
const { shell } = await import('electron')
await shell.openExternal(normalizeExternalUrl(target))
}
export function normalizeSystemSettingsUrl(target: string): string {
if (!ALLOWED_SYSTEM_SETTINGS_URLS.has(target)) {
throw new Error(`Unsupported system settings URL: ${target}`)
}
return target
}
export async function openSystemSettingsUrl(target: string): Promise<boolean> {
const { shell } = await import('electron')
await shell.openExternal(normalizeSystemSettingsUrl(target))
return true
}
export async function openSystemPath(target: string): Promise<void> {
const { shell } = await import('electron')
const error = await shell.openPath(normalizeOpenPath(target))
if (error) throw new Error(error)
}
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import path from 'node:path'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import {
buildSidecarEnv,
createAdapterPlan,
createServerPlan,
httpToWebSocketUrl,
pushStartupLog,
resolveHostTriple,
} from './sidecarManager'
describe('Electron sidecar manager', () => {
it('maps host platform to existing sidecar target triples', () => {
expect(resolveHostTriple('darwin', 'arm64')).toBe('aarch64-apple-darwin')
expect(resolveHostTriple('darwin', 'x64')).toBe('x86_64-apple-darwin')
expect(resolveHostTriple('win32', 'x64')).toBe('x86_64-pc-windows-msvc')
expect(resolveHostTriple('linux', 'arm64')).toBe('aarch64-unknown-linux-gnu')
})
it('builds server sidecar args without changing the REST/WebSocket boundary', () => {
const plan = createServerPlan({
desktopRoot: '/app/desktop',
appRoot: '/app',
port: 49321,
env: {},
})
expect(plan.args).toEqual([
'server',
'--app-root',
'/app',
'--host',
'0.0.0.0',
'--port',
'49321',
])
expect(plan.env.CLAUDE_H5_AUTO_PUBLIC_URL).toBe('1')
expect(plan.env.CLAUDE_H5_DIST_DIR).toBe(path.join('/app/desktop', 'dist'))
})
it('can keep sidecar binaries unpacked while pointing app-root at app.asar', () => {
const plan = createServerPlan({
desktopRoot: '/Applications/App.app/Contents/Resources/app.asar.unpacked',
appRoot: '/Applications/App.app/Contents/Resources/app.asar',
h5DistDir: '/Applications/App.app/Contents/Resources/app.asar/dist',
port: 49321,
env: {},
})
expect(plan.command).toContain('/Applications/App.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-')
expect(plan.args).toContain('/Applications/App.app/Contents/Resources/app.asar')
expect(plan.env.CLAUDE_H5_DIST_DIR).toBe('/Applications/App.app/Contents/Resources/app.asar/dist')
})
it('passes portable config and adapter server URL through the sidecar env', () => {
const configDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-config-'))
try {
const env = buildSidecarEnv({ CLAUDE_CONFIG_DIR: configDir }, '/app/dist')
expect(env.CLAUDE_CONFIG_DIR).toBe(configDir)
expect(env.XDG_CACHE_HOME).toBe(path.join(configDir, 'Cache'))
const adapter = createAdapterPlan({
desktopRoot: '/app/desktop',
appRoot: '/app',
serverUrl: 'http://127.0.0.1:4567',
flag: '--telegram',
env: { CLAUDE_CONFIG_DIR: configDir },
})
expect(adapter.env.ADAPTER_SERVER_URL).toBe('ws://127.0.0.1:4567')
expect(adapter.args).toEqual(['adapters', '--app-root', '/app', '--telegram'])
} finally {
rmSync(configDir, { recursive: true, force: true })
}
})
it('keeps startup logs bounded', () => {
const logs: string[] = []
for (let index = 0; index < 85; index++) {
pushStartupLog(logs, `line ${index}`)
}
expect(logs).toHaveLength(80)
expect(logs[0]).toBe('line 5')
})
it('maps http urls to adapter websocket urls', () => {
expect(httpToWebSocketUrl('http://127.0.0.1:3456')).toBe('ws://127.0.0.1:3456')
expect(httpToWebSocketUrl('https://example.com')).toBe('wss://example.com')
})
})
+178
View File
@@ -0,0 +1,178 @@
import { spawn, type ChildProcessByStdio } from 'node:child_process'
import { mkdirSync, existsSync } from 'node:fs'
import type { Readable } from 'node:stream'
import net from 'node:net'
import path from 'node:path'
export const SERVER_BIND_HOST = '0.0.0.0'
export const SERVER_CONTROL_HOST = '127.0.0.1'
export const SERVER_STARTUP_LOG_LIMIT = 80
export type SidecarChild = ChildProcessByStdio<null, Readable, Readable>
export type SidecarPlan = {
command: string
args: string[]
env: NodeJS.ProcessEnv
}
export function resolveHostTriple(platform = process.platform, arch = process.arch): string {
if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin'
if (platform === 'darwin' && arch === 'x64') return 'x86_64-apple-darwin'
if (platform === 'win32' && arch === 'arm64') return 'aarch64-pc-windows-msvc'
if (platform === 'win32') return 'x86_64-pc-windows-msvc'
if (platform === 'linux' && arch === 'arm64') return 'aarch64-unknown-linux-gnu'
if (platform === 'linux') return 'x86_64-unknown-linux-gnu'
throw new Error(`Unsupported Electron sidecar platform: ${platform}/${arch}`)
}
export function resolveSidecarExecutable(desktopRoot: string, triple = resolveHostTriple()): string {
const base = path.join(desktopRoot, 'src-tauri', 'binaries', `claude-sidecar-${triple}`)
return process.platform === 'win32' ? `${base}.exe` : base
}
export function httpToWebSocketUrl(serverHttpUrl: string): string {
if (serverHttpUrl.startsWith('http://')) return `ws://${serverHttpUrl.slice('http://'.length)}`
if (serverHttpUrl.startsWith('https://')) return `wss://${serverHttpUrl.slice('https://'.length)}`
return serverHttpUrl
}
export async function reserveLocalPort(bindHost = SERVER_BIND_HOST): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', error => reject(error))
server.listen(0, bindHost, () => {
const address = server.address()
server.close(() => {
if (!address || typeof address === 'string') {
reject(new Error('Could not resolve reserved local port'))
return
}
resolve(address.port)
})
})
})
}
export async function waitForServer(host: string, port: number, timeoutMs = 10_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await canConnect(host, port)) return
await sleep(150)
}
throw new Error(`desktop server did not start listening on ${host}:${port} within ${Math.round(timeoutMs / 1000)} seconds`)
}
function canConnect(host: string, port: number): Promise<boolean> {
return new Promise(resolve => {
const socket = net.connect({ host, port, timeout: 200 })
socket.once('connect', () => {
socket.destroy()
resolve(true)
})
socket.once('timeout', () => {
socket.destroy()
resolve(false)
})
socket.once('error', () => resolve(false))
})
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
export function pushStartupLog(logs: string[], line: string) {
const trimmed = line.trimEnd()
if (!trimmed) return
if (logs.length >= SERVER_STARTUP_LOG_LIMIT) logs.shift()
logs.push(trimmed)
}
export function formatStartupError(message: string, logs: string[]): string {
const logText = logs.length > 0
? logs.join('\n')
: 'No server stdout/stderr was captured before the timeout.'
return `${message}\n\nRecent server logs:\n${logText}`
}
export function buildSidecarEnv(baseEnv: NodeJS.ProcessEnv, h5DistDir: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...baseEnv,
CLAUDE_H5_AUTO_PUBLIC_URL: '1',
CLAUDE_H5_DIST_DIR: h5DistDir,
}
const configDir = baseEnv.CLAUDE_CONFIG_DIR
if (configDir) {
const cacheDir = path.join(configDir, 'Cache')
mkdirSync(cacheDir, { recursive: true })
env.CLAUDE_CONFIG_DIR = configDir
env.XDG_CACHE_HOME = cacheDir
}
return env
}
export function createServerPlan({
desktopRoot,
appRoot,
port,
bindHost = SERVER_BIND_HOST,
h5DistDir = path.join(desktopRoot, 'dist'),
env = process.env,
}: {
desktopRoot: string
appRoot: string
port: number
bindHost?: string
h5DistDir?: string
env?: NodeJS.ProcessEnv
}): SidecarPlan {
return {
command: resolveSidecarExecutable(desktopRoot),
args: ['server', '--app-root', appRoot, '--host', bindHost, '--port', String(port)],
env: buildSidecarEnv(env, h5DistDir),
}
}
export function createAdapterPlan({
desktopRoot,
appRoot,
serverUrl,
flag,
h5DistDir = path.join(desktopRoot, 'dist'),
env = process.env,
}: {
desktopRoot: string
appRoot: string
serverUrl: string
flag: '--feishu' | '--telegram' | '--wechat' | '--dingtalk'
h5DistDir?: string
env?: NodeJS.ProcessEnv
}): SidecarPlan {
return {
command: resolveSidecarExecutable(desktopRoot),
args: ['adapters', '--app-root', appRoot, flag],
env: {
...buildSidecarEnv(env, h5DistDir),
ADAPTER_SERVER_URL: httpToWebSocketUrl(serverUrl),
},
}
}
export function spawnSidecar(plan: SidecarPlan): SidecarChild {
if (!existsSync(plan.command)) {
throw new Error(`Electron sidecar binary not found: ${plan.command}. Run "cd desktop && bun run build:sidecars" first.`)
}
return spawn(plan.command, plan.args, {
env: plan.env,
stdio: ['ignore', 'pipe', 'pipe'],
})
}
export function killSidecar(child: SidecarChild) {
if (process.platform === 'win32' && child.pid) {
spawn('taskkill', ['/F', '/T', '/PID', String(child.pid)], { stdio: 'ignore' })
return
}
child.kill()
}
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest'
import { acquireSingleInstanceLock } from './singleInstance'
describe('Electron single-instance service', () => {
it('allows validation runs to bypass the single-instance lock explicitly', () => {
const app = {
requestSingleInstanceLock: vi.fn(() => false),
quit: vi.fn(),
on: vi.fn(),
}
expect(acquireSingleInstanceLock(app as never, () => null, {
CC_HAHA_ELECTRON_DISABLE_SINGLE_INSTANCE_LOCK: '1',
})).toBe(true)
expect(app.requestSingleInstanceLock).not.toHaveBeenCalled()
expect(app.quit).not.toHaveBeenCalled()
expect(app.on).not.toHaveBeenCalled()
})
it('quits duplicate launches when the lock cannot be acquired', () => {
const app = {
requestSingleInstanceLock: vi.fn(() => false),
quit: vi.fn(),
on: vi.fn(),
}
expect(acquireSingleInstanceLock(app as never, () => null)).toBe(false)
expect(app.quit).toHaveBeenCalledTimes(1)
expect(app.on).not.toHaveBeenCalled()
})
it('registers a second-instance focus handler after acquiring the lock', () => {
const app = {
requestSingleInstanceLock: vi.fn(() => true),
quit: vi.fn(),
on: vi.fn(),
}
expect(acquireSingleInstanceLock(app as never, () => null)).toBe(true)
expect(app.on).toHaveBeenCalledWith('second-instance', expect.any(Function))
})
})
@@ -0,0 +1,24 @@
import type { App, BrowserWindow } from 'electron'
import { showMainWindow } from './windows'
export function acquireSingleInstanceLock(
app: App,
getMainWindow: () => BrowserWindow | null,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (env.CC_HAHA_ELECTRON_DISABLE_SINGLE_INSTANCE_LOCK === '1') {
return true
}
const hasLock = app.requestSingleInstanceLock()
if (!hasLock) {
app.quit()
return false
}
app.on('second-instance', () => {
showMainWindow(getMainWindow())
})
return true
}
+235
View File
@@ -0,0 +1,235 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import {
ElectronTerminalService,
defaultShell,
desktopTerminalSettingsPath,
ensureUtf8Locale,
normalizeTerminalBashPath,
parseEnvBlock,
prepareNodePtyRuntime,
resolveDesktopTerminalShell,
terminalConfigPath,
type TerminalPtyFactory,
type TerminalPtyProcess,
} from './terminal'
class FakePty implements TerminalPtyProcess {
writes: string[] = []
resizes: Array<{ cols: number, rows: number }> = []
killed = false
private dataHandler: ((data: string) => void) | null = null
private exitHandler: ((event: { exitCode: number, signal?: number | string | null }) => void) | null = null
write(data: string) {
this.writes.push(data)
}
resize(cols: number, rows: number) {
this.resizes.push({ cols, rows })
}
kill() {
this.killed = true
}
onData(handler: (data: string) => void) {
this.dataHandler = handler
}
onExit(handler: (event: { exitCode: number, signal?: number | string | null }) => void) {
this.exitHandler = handler
}
emitData(data: string) {
this.dataHandler?.(data)
}
emitExit(event: { exitCode: number, signal?: number | string | null }) {
this.exitHandler?.(event)
}
}
const tempDirs: string[] = []
function tempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-terminal-'))
tempDirs.push(dir)
return dir
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
vi.restoreAllMocks()
})
describe('Electron terminal service', () => {
it('uses the portable terminal config path before app userData', () => {
const app = { getPath: vi.fn(() => '/app/user-data') }
expect(terminalConfigPath(app, { CLAUDE_CONFIG_DIR: '/portable' })).toBe('/portable/terminal-config.json')
expect(terminalConfigPath(app, {})).toBe('/app/user-data/terminal-config.json')
})
it('persists the legacy bash path config and validates saved paths', () => {
const dir = tempDir()
const bash = path.join(dir, 'bash.exe')
fs.writeFileSync(bash, '')
const service = new ElectronTerminalService({
env: { CLAUDE_CONFIG_DIR: dir },
isFile: filePath => filePath === bash,
})
service.setBashPath(` ${bash} `)
expect(service.getBashPath()).toBe(bash)
expect(JSON.parse(fs.readFileSync(path.join(dir, 'terminal-config.json'), 'utf8'))).toEqual({
bash_path: bash,
})
expect(() => service.setBashPath('/missing/bash')).toThrow('terminal bash path does not exist')
expect(normalizeTerminalBashPath(' ', () => false)).toBeNull()
})
it('resolves platform-specific shells from the same settings shape as Tauri', () => {
expect(resolveDesktopTerminalShell('win32', { startupShell: 'pwsh' })).toBe('pwsh.exe')
expect(resolveDesktopTerminalShell('win32', { startupShell: 'powershell' })).toBe('powershell.exe')
expect(resolveDesktopTerminalShell('win32', { startupShell: 'cmd' })).toBe('cmd.exe')
expect(resolveDesktopTerminalShell('win32', { startupShell: 'custom', customShellPath: ' C:\\Tools\\shell.exe ' })).toBe('C:\\Tools\\shell.exe')
expect(() => resolveDesktopTerminalShell('win32', { startupShell: 'custom' })).toThrow('custom terminal shell path is empty')
expect(resolveDesktopTerminalShell('darwin', { startupShell: 'pwsh' })).toBeNull()
})
it('prefers Windows custom bash when valid and falls back to COMSPEC', () => {
expect(defaultShell('win32', { COMSPEC: 'cmd.exe' }, 'C:\\Git\\bin\\bash.exe', file => file.endsWith('bash.exe'))).toBe(
'C:\\Git\\bin\\bash.exe',
)
expect(defaultShell('win32', { COMSPEC: 'cmd.exe' }, 'C:\\missing\\bash.exe', () => false)).toBe('cmd.exe')
expect(defaultShell('linux', { SHELL: '/bin/fish' }, null, () => false)).toBe('/bin/fish')
})
it('reads desktop terminal settings from the Claude config directory', () => {
const dir = tempDir()
fs.mkdirSync(path.join(dir, '.claude'), { recursive: true })
fs.writeFileSync(
path.join(dir, '.claude', 'settings.json'),
JSON.stringify({ desktopTerminal: { startupShell: 'cmd' } }),
)
const service = new ElectronTerminalService({
env: { HOME: dir, COMSPEC: 'powershell.exe' },
platform: 'win32',
})
expect(desktopTerminalSettingsPath({ HOME: dir })).toBe(path.join(dir, '.claude', 'settings.json'))
expect(service.resolveShell()).toBe('cmd.exe')
})
it('normalizes terminal environment data to UTF-8 locale', () => {
expect(parseEnvBlock(Buffer.from('A=1\0B=two=2\0\0'))).toEqual({ A: '1', B: 'two=2' })
expect(ensureUtf8Locale({ LANG: 'C' }, 'darwin')).toMatchObject({
LANG: 'en_US.UTF-8',
LC_CTYPE: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
})
})
it('copies packaged node-pty to a writable runtime cache and restores helper executable bits', () => {
const source = tempDir()
const cache = path.join(tempDir(), 'node-pty-cache')
const helper = path.join(source, 'prebuilds', 'darwin-arm64', 'spawn-helper')
fs.mkdirSync(path.dirname(helper), { recursive: true })
fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'index.js' }))
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = { spawn() {} }\n')
fs.writeFileSync(helper, 'helper')
fs.chmodSync(helper, 0o644)
expect(prepareNodePtyRuntime(source, cache)).toBe(cache)
expect(fs.existsSync(path.join(cache, 'index.js'))).toBe(true)
expect(fs.statSync(cache).mode & 0o077).toBe(0)
expect(fs.statSync(path.join(cache, 'prebuilds', 'darwin-arm64', 'spawn-helper')).mode & 0o777).toBe(0o500)
expect(fs.existsSync(path.join(cache, '.cc-haha-node-pty-manifest.json'))).toBe(true)
})
it('rebuilds the packaged node-pty runtime cache when cached files are tampered', () => {
const source = tempDir()
const cache = path.join(tempDir(), 'node-pty-cache')
fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'index.js' }))
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = { spawn() { return "source" } }\n')
prepareNodePtyRuntime(source, cache)
fs.writeFileSync(path.join(cache, 'index.js'), 'module.exports = { spawn() { return "tampered" } }\n')
prepareNodePtyRuntime(source, cache)
expect(fs.readFileSync(path.join(cache, 'index.js'), 'utf8')).toBe('module.exports = { spawn() { return "source" } }\n')
})
it('spawns a PTY, forwards events, and controls the active session', async () => {
const dir = tempDir()
const fakePty = new FakePty()
const spawn = vi.fn(() => fakePty)
const sent: Array<{ channel: string, payload: unknown }> = []
const service = new ElectronTerminalService({
env: { HOME: dir, SHELL: '/bin/test-shell' },
platform: 'linux',
ptyFactory: { spawn } satisfies TerminalPtyFactory,
fileExists: filePath => filePath === '/bin/test-shell',
})
const session = await service.spawn(
{ cols: 10, rows: 4, cwd: dir },
{ send: (channel, payload) => sent.push({ channel, payload }) },
)
expect(session).toEqual({ session_id: 1, shell: '/bin/test-shell', cwd: dir })
expect(spawn).toHaveBeenCalledWith('/bin/test-shell', [], expect.objectContaining({
name: 'xterm-256color',
cols: 20,
rows: 8,
cwd: dir,
env: expect.objectContaining({
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
}),
}))
service.write(1, 'echo hello\r')
service.resize(1, 12, 6)
fakePty.emitData('hello\r\n')
fakePty.emitExit({ exitCode: 0 })
expect(fakePty.writes).toEqual(['echo hello\r'])
expect(fakePty.resizes).toEqual([{ cols: 20, rows: 8 }])
expect(sent).toEqual([
{
channel: ELECTRON_EVENT_CHANNELS.terminalOutput,
payload: { session_id: 1, data: 'hello\r\n' },
},
{
channel: ELECTRON_EVENT_CHANNELS.terminalExit,
payload: { session_id: 1, code: 0, signal: null },
},
])
expect(() => service.write(1, 'after exit')).toThrow('terminal session is not running')
})
it('kills a running PTY session without failing when the session is already gone', async () => {
const dir = tempDir()
const fakePty = new FakePty()
const service = new ElectronTerminalService({
env: { HOME: dir, SHELL: '/bin/test-shell' },
platform: 'linux',
ptyFactory: { spawn: vi.fn(() => fakePty) },
})
await service.spawn({ cols: 80, rows: 24, cwd: dir }, { send: vi.fn() })
service.kill(1)
service.kill(1)
expect(fakePty.killed).toBe(true)
})
})
+554
View File
@@ -0,0 +1,554 @@
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
const TERMINAL_CONFIG_FILE = 'terminal-config.json'
const MIN_TERMINAL_COLS = 20
const MIN_TERMINAL_ROWS = 8
const NODE_PTY_MANIFEST_FILE = '.cc-haha-node-pty-manifest.json'
export type TerminalSpawnInput = {
cols?: number
rows?: number
cwd?: string
shell?: string
}
export type TerminalSpawnResult = {
session_id: number
shell: string
cwd: string
}
export type TerminalOutputPayload = {
session_id: number
data: string
}
export type TerminalExitPayload = {
session_id: number
code: number
signal?: string | null
}
export type TerminalPtyProcess = {
pid?: number
process?: string
write(data: string): void
resize(cols: number, rows: number): void
kill(): void
onData(handler: (data: string) => void): unknown
onExit(handler: (event: { exitCode: number, signal?: number | string | null }) => void): unknown
}
export type TerminalPtySpawnOptions = {
name: string
cols: number
rows: number
cwd: string
env: Record<string, string>
}
export type TerminalPtyFactory = {
spawn(shell: string, args: string[], options: TerminalPtySpawnOptions): TerminalPtyProcess
}
export type TerminalAppLike = {
getPath(name: 'userData'): string
}
export type TerminalWebContentsLike = {
send(channel: string, payload: unknown): void
}
export type ElectronTerminalServiceOptions = {
app?: TerminalAppLike
env?: NodeJS.ProcessEnv
platform?: NodeJS.Platform
ptyFactory?: TerminalPtyFactory | (() => Promise<TerminalPtyFactory>)
nodePtySourceDir?: string
nodePtyCacheDir?: string
fileExists?: (filePath: string) => boolean
isFile?: (filePath: string) => boolean
cwd?: () => string
}
type TerminalConfig = {
bash_path?: string | null
}
type DesktopTerminalSettingsFile = {
desktopTerminal?: DesktopTerminalConfig | null
}
type DesktopTerminalConfig = {
startupShell?: string | null
customShellPath?: string | null
}
type TerminalSession = {
pty: TerminalPtyProcess
}
const preparedNodePtyDirs = new Set<string>()
export function terminalConfigPath(app: TerminalAppLike | undefined, env: NodeJS.ProcessEnv = process.env): string | null {
const portableDir = env.CLAUDE_CONFIG_DIR?.trim()
if (portableDir) {
return path.join(portableDir, TERMINAL_CONFIG_FILE)
}
if (!app) return null
return path.join(app.getPath('userData'), TERMINAL_CONFIG_FILE)
}
export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env): string | null {
const portableDir = env.CLAUDE_CONFIG_DIR?.trim()
if (portableDir) return portableDir
const home = env.HOME || env.USERPROFILE || os.homedir()
return home ? path.join(home, '.claude') : null
}
export function desktopTerminalSettingsPath(env: NodeJS.ProcessEnv = process.env): string | null {
const dir = claudeConfigDir(env)
return dir ? path.join(dir, 'settings.json') : null
}
export function normalizeTerminalBashPath(
value: string | null | undefined,
isFile: (filePath: string) => boolean = filePath => {
try {
return fs.statSync(filePath).isFile()
} catch {
return false
}
},
): string | null {
const trimmed = value?.trim()
if (!trimmed) return null
if (!isFile(trimmed)) {
throw new Error(`terminal bash path does not exist: ${trimmed}`)
}
return trimmed
}
export function defaultShell(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
customBashPath: string | null = null,
fileExists: (filePath: string) => boolean = fs.existsSync,
): string {
if (platform === 'win32') {
const bashPath = customBashPath?.trim()
if (bashPath && fileExists(bashPath)) return bashPath
return env.COMSPEC || 'powershell.exe'
}
return env.SHELL || (fileExists('/bin/zsh') ? '/bin/zsh' : '/bin/bash')
}
export function resolveDesktopTerminalShell(
platform: NodeJS.Platform,
config: DesktopTerminalConfig | null | undefined,
): string | null {
if (platform !== 'win32' || !config) return null
const startupShell = config.startupShell?.trim()
switch (startupShell) {
case undefined:
case '':
case 'system':
return null
case 'pwsh':
return 'pwsh.exe'
case 'powershell':
return 'powershell.exe'
case 'cmd':
return 'cmd.exe'
case 'custom': {
const customShellPath = config.customShellPath?.trim()
if (!customShellPath) throw new Error('custom terminal shell path is empty')
return customShellPath
}
default:
return null
}
}
export function ensureUtf8Locale(env: Record<string, string>, platform: NodeJS.Platform = process.platform): Record<string, string> {
const fallback = platform === 'darwin' ? 'en_US.UTF-8' : 'C.UTF-8'
for (const key of ['LANG', 'LC_CTYPE', 'LC_ALL']) {
const value = env[key]
if (!value || !value.trim().toLowerCase().replace(/-/g, '').includes('utf8')) {
env[key] = fallback
}
}
return env
}
export function parseEnvBlock(buffer: Buffer): Record<string, string> {
const env: Record<string, string> = {}
for (const entry of buffer.toString('utf8').split('\0')) {
if (!entry) continue
const equals = entry.indexOf('=')
if (equals <= 0) continue
env[entry.slice(0, equals)] = entry.slice(equals + 1)
}
return env
}
export function loginShellEnvironment(shell: string, platform: NodeJS.Platform = process.platform): Record<string, string> {
if (platform === 'win32') return {}
try {
const stdout = execFileSync(shell, ['-l', '-c', 'env -0'], {
encoding: 'buffer',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 2000,
})
return parseEnvBlock(stdout)
} catch {
return {}
}
}
export function terminalEnvironment(
shell: string,
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): Record<string, string> {
const merged: Record<string, string> = {}
for (const [key, value] of Object.entries(env)) {
if (typeof value === 'string') merged[key] = value
}
Object.assign(merged, loginShellEnvironment(shell, platform))
return ensureUtf8Locale(merged, platform)
}
export function readDesktopTerminalConfig(env: NodeJS.ProcessEnv = process.env): DesktopTerminalConfig | null {
const settingsPath = desktopTerminalSettingsPath(env)
if (!settingsPath) return null
try {
const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as DesktopTerminalSettingsFile
return parsed.desktopTerminal ?? null
} catch {
return null
}
}
function loadTerminalConfig(app: TerminalAppLike | undefined, env: NodeJS.ProcessEnv): TerminalConfig {
const configPath = terminalConfigPath(app, env)
if (!configPath) return {}
try {
return JSON.parse(fs.readFileSync(configPath, 'utf8')) as TerminalConfig
} catch {
return {}
}
}
function saveTerminalConfig(app: TerminalAppLike | undefined, env: NodeJS.ProcessEnv, config: TerminalConfig) {
const configPath = terminalConfigPath(app, env)
if (!configPath) throw new Error('terminal config path is unavailable')
fs.mkdirSync(path.dirname(configPath), { recursive: true })
fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
}
export function resolveTerminalCwd(
cwd: string | undefined,
env: NodeJS.ProcessEnv = process.env,
currentDirectory: () => string = process.cwd,
): string {
const trimmed = cwd?.trim()
const resolved = trimmed
|| env.CLAUDE_CONFIG_DIR
|| env.HOME
|| env.USERPROFILE
|| currentDirectory()
let isDirectory = false
try {
isDirectory = fs.statSync(resolved).isDirectory()
} catch {
isDirectory = false
}
if (!isDirectory) {
throw new Error(`terminal cwd does not exist: ${resolved}`)
}
return resolved
}
function ensureNodePtyHelpersExecutable(moduleDir: string): void {
const prebuildsDir = path.join(moduleDir, 'prebuilds')
if (!fs.existsSync(prebuildsDir)) return
for (const platformDir of fs.readdirSync(prebuildsDir)) {
const helperPath = path.join(prebuildsDir, platformDir, 'spawn-helper')
if (!fs.existsSync(helperPath)) continue
const stat = fs.statSync(helperPath)
if (!stat.isFile()) continue
fs.chmodSync(helperPath, 0o500)
}
}
type NodePtyIntegrityManifest = {
version: 1
files: Array<{ path: string, sha256: string }>
}
function walkNodePtyFiles(rootDir: string): string[] {
const results: string[] = []
const stack = [rootDir]
while (stack.length > 0) {
const current = stack.pop()
if (!current) continue
let entries: fs.Dirent[] = []
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name)
if (entry.isDirectory()) {
stack.push(fullPath)
} else if (entry.isFile() && entry.name !== NODE_PTY_MANIFEST_FILE) {
results.push(fullPath)
}
}
}
return results.sort()
}
function hashFile(filePath: string): string {
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
}
function buildNodePtyManifest(moduleDir: string): NodePtyIntegrityManifest {
return {
version: 1,
files: walkNodePtyFiles(moduleDir).map(filePath => ({
path: path.relative(moduleDir, filePath).replaceAll(path.sep, '/'),
sha256: hashFile(filePath),
})),
}
}
function manifestsEqual(left: NodePtyIntegrityManifest, right: NodePtyIntegrityManifest): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
function readNodePtyManifest(moduleDir: string): NodePtyIntegrityManifest | null {
try {
const parsed = JSON.parse(fs.readFileSync(path.join(moduleDir, NODE_PTY_MANIFEST_FILE), 'utf8')) as NodePtyIntegrityManifest
if (parsed.version !== 1 || !Array.isArray(parsed.files)) return null
return parsed
} catch {
return null
}
}
function writeNodePtyManifest(moduleDir: string, manifest: NodePtyIntegrityManifest): void {
fs.writeFileSync(path.join(moduleDir, NODE_PTY_MANIFEST_FILE), JSON.stringify(manifest, null, 2), { mode: 0o600 })
}
function chmodNodePtyDirectories(moduleDir: string): void {
for (const dir of [path.dirname(moduleDir), moduleDir]) {
try {
fs.chmodSync(dir, 0o700)
} catch {
// Best effort: chmod can fail on some filesystems, but the cache is still rebuilt from the bundle.
}
}
}
function isNodePtyCacheCurrent(sourceManifest: NodePtyIntegrityManifest, cacheDir: string): boolean {
const cacheManifest = readNodePtyManifest(cacheDir)
if (!cacheManifest || !manifestsEqual(sourceManifest, cacheManifest)) return false
try {
return manifestsEqual(sourceManifest, buildNodePtyManifest(cacheDir))
} catch {
return false
}
}
export function prepareNodePtyRuntime(sourceDir: string, cacheDir: string): string {
if (!fs.existsSync(path.join(sourceDir, 'package.json'))) {
throw new Error(`node-pty source directory is missing: ${sourceDir}`)
}
const sourceManifest = buildNodePtyManifest(sourceDir)
if (preparedNodePtyDirs.has(cacheDir) && isNodePtyCacheCurrent(sourceManifest, cacheDir)) {
return cacheDir
}
if (!preparedNodePtyDirs.has(cacheDir) && isNodePtyCacheCurrent(sourceManifest, cacheDir)) {
preparedNodePtyDirs.add(cacheDir)
return cacheDir
}
fs.rmSync(cacheDir, { recursive: true, force: true })
fs.mkdirSync(path.dirname(cacheDir), { recursive: true, mode: 0o700 })
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 })
fs.cpSync(sourceDir, cacheDir, { recursive: true })
ensureNodePtyHelpersExecutable(cacheDir)
chmodNodePtyDirectories(cacheDir)
if (!manifestsEqual(sourceManifest, buildNodePtyManifest(cacheDir))) {
throw new Error('node-pty runtime cache integrity check failed')
}
writeNodePtyManifest(cacheDir, sourceManifest)
preparedNodePtyDirs.add(cacheDir)
return cacheDir
}
async function loadNodePtyFactory(sourceDir?: string, cacheDir?: string): Promise<TerminalPtyFactory> {
if (sourceDir && cacheDir) {
const moduleDir = prepareNodePtyRuntime(sourceDir, cacheDir)
const requireFromNodePty = createRequire(path.join(moduleDir, 'package.json'))
return requireFromNodePty(moduleDir) as TerminalPtyFactory
}
return import('node-pty') as Promise<TerminalPtyFactory>
}
async function resolvePtyFactory(
factory: TerminalPtyFactory | (() => Promise<TerminalPtyFactory>) | undefined,
nodePtySourceDir: string | undefined,
nodePtyCacheDir: string | undefined,
): Promise<TerminalPtyFactory> {
if (!factory) return loadNodePtyFactory(nodePtySourceDir, nodePtyCacheDir)
if (typeof factory === 'function') return factory()
return factory
}
export class ElectronTerminalService {
private readonly app?: TerminalAppLike
private readonly env: NodeJS.ProcessEnv
private readonly platform: NodeJS.Platform
private readonly ptyFactory?: TerminalPtyFactory | (() => Promise<TerminalPtyFactory>)
private readonly nodePtySourceDir?: string
private readonly nodePtyCacheDir?: string
private readonly fileExists: (filePath: string) => boolean
private readonly isFile: (filePath: string) => boolean
private readonly cwd: () => string
private nextSessionId = 1
private readonly sessions = new Map<number, TerminalSession>()
constructor(options: ElectronTerminalServiceOptions = {}) {
this.app = options.app
this.env = options.env ?? process.env
this.platform = options.platform ?? process.platform
this.ptyFactory = options.ptyFactory
this.nodePtySourceDir = options.nodePtySourceDir
this.nodePtyCacheDir = options.nodePtyCacheDir
this.fileExists = options.fileExists ?? fs.existsSync
this.isFile = options.isFile ?? (filePath => {
try {
return fs.statSync(filePath).isFile()
} catch {
return false
}
})
this.cwd = options.cwd ?? process.cwd
}
getBashPath(): string | null {
return loadTerminalConfig(this.app, this.env).bash_path ?? null
}
setBashPath(value: string | null): void {
const config = loadTerminalConfig(this.app, this.env)
config.bash_path = normalizeTerminalBashPath(value, this.isFile)
saveTerminalConfig(this.app, this.env, config)
}
resolveShell(): string {
const terminalConfig = loadTerminalConfig(this.app, this.env)
const systemDefault = defaultShell(
this.platform,
this.env,
terminalConfig.bash_path ?? null,
this.fileExists,
)
return resolveDesktopTerminalShell(this.platform, readDesktopTerminalConfig(this.env)) ?? systemDefault
}
async spawn(input: TerminalSpawnInput, webContents: TerminalWebContentsLike): Promise<TerminalSpawnResult> {
const cols = Math.max(MIN_TERMINAL_COLS, Math.floor(input.cols ?? 80))
const rows = Math.max(MIN_TERMINAL_ROWS, Math.floor(input.rows ?? 24))
const cwd = resolveTerminalCwd(input.cwd, this.env, this.cwd)
const shell = this.resolveShell()
const ptyFactory = await resolvePtyFactory(this.ptyFactory, this.nodePtySourceDir, this.nodePtyCacheDir)
const sessionId = this.nextSessionId++
const pty = ptyFactory.spawn(shell, [], {
name: 'xterm-256color',
cols,
rows,
cwd,
env: {
...terminalEnvironment(shell, this.platform, this.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
},
})
this.sessions.set(sessionId, { pty })
pty.onData(data => {
webContents.send(ELECTRON_EVENT_CHANNELS.terminalOutput, {
session_id: sessionId,
data,
} satisfies TerminalOutputPayload)
})
pty.onExit(({ exitCode, signal }) => {
this.sessions.delete(sessionId)
webContents.send(ELECTRON_EVENT_CHANNELS.terminalExit, {
session_id: sessionId,
code: exitCode,
signal: signal == null ? null : String(signal),
} satisfies TerminalExitPayload)
})
return {
session_id: sessionId,
shell,
cwd,
}
}
write(sessionId: number, data: string): void {
this.getSession(sessionId).pty.write(data)
}
resize(sessionId: number, cols: number, rows: number): void {
this.getSession(sessionId).pty.resize(
Math.max(MIN_TERMINAL_COLS, Math.floor(cols)),
Math.max(MIN_TERMINAL_ROWS, Math.floor(rows)),
)
}
kill(sessionId: number): void {
const session = this.sessions.get(sessionId)
if (!session) return
this.sessions.delete(sessionId)
session.pty.kill()
}
killAll(): void {
for (const sessionId of Array.from(this.sessions.keys())) {
this.kill(sessionId)
}
}
private getSession(sessionId: number): TerminalSession {
const session = this.sessions.get(sessionId)
if (!session) throw new Error('terminal session is not running')
return session
}
}
+114
View File
@@ -0,0 +1,114 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { installTray, resolveTrayIconPath } from './tray'
const trayMocksKey = '__electronTrayMocks'
vi.mock('electron', () => {
const mocks = (globalThis as Record<string, unknown>)[trayMocksKey] as ReturnType<typeof createElectronTrayMocks> | undefined
if (!mocks) {
throw new Error('Electron tray mocks were not initialized for this test')
}
return {
Menu: {
buildFromTemplate: mocks.buildFromTemplate,
},
Tray: mocks.Tray.mockImplementation(() => mocks.tray),
nativeImage: {
createFromPath: mocks.createFromPath,
},
}
})
function createElectronTrayMocks() {
const handlers = new Map<string, () => void>()
return {
handlers,
buildFromTemplate: vi.fn((template: unknown) => ({ template })),
createFromPath: vi.fn((iconPath: string) => ({ iconPath })),
tray: {
setToolTip: vi.fn(),
setContextMenu: vi.fn(),
on: vi.fn((event: string, handler: () => void) => {
handlers.set(event, handler)
}),
destroy: vi.fn(),
},
Tray: vi.fn(),
}
}
describe('Electron tray service', () => {
afterEach(() => {
delete (globalThis as Record<string, unknown>)[trayMocksKey]
})
it('uses the existing desktop icon assets for the tray icon', () => {
const root = mkdtempSync(path.join(tmpdir(), 'electron-tray-'))
try {
const iconPath = path.join(root, 'src-tauri', 'icons', 'icon.png')
mkdirSync(path.dirname(iconPath), { recursive: true })
writeFileSync(iconPath, 'png')
expect(resolveTrayIconPath(root)).toBe(iconPath)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('fails clearly when no tray icon exists', () => {
const root = mkdtempSync(path.join(tmpdir(), 'electron-tray-missing-'))
try {
expect(() => resolveTrayIconPath(root)).toThrow('Electron tray icon not found')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('installs tray handlers that show the app, quit explicitly, and dispose cleanly', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'electron-tray-install-'))
try {
const trayMocks = createElectronTrayMocks()
;(globalThis as Record<string, unknown>)[trayMocksKey] = trayMocks
const iconPath = path.join(root, 'src-tauri', 'icons', 'icon.png')
mkdirSync(path.dirname(iconPath), { recursive: true })
writeFileSync(iconPath, 'png')
const show = vi.fn()
const quit = vi.fn()
const controller = await installTray({
app: { name: 'Claude Code Haha' } as never,
desktopRoot: root,
show,
quit,
})
expect(trayMocks.createFromPath).toHaveBeenCalledWith(iconPath)
expect(trayMocks.Tray).toHaveBeenCalledTimes(1)
expect(trayMocks.tray.setToolTip).toHaveBeenCalledWith('Claude Code Haha')
expect(trayMocks.buildFromTemplate).toHaveBeenCalledTimes(1)
const template = trayMocks.buildFromTemplate.mock.calls[0]?.[0] as Array<{ label?: string, click?: () => void, type?: string }>
expect(template.map(item => item.label ?? item.type)).toEqual([
'Show Claude Code Haha',
'separator',
'Quit Claude Code Haha',
])
template[0]?.click?.()
template[2]?.click?.()
trayMocks.handlers.get('click')?.()
expect(show).toHaveBeenCalledTimes(2)
expect(quit).toHaveBeenCalledTimes(1)
controller.dispose()
expect(trayMocks.tray.destroy).toHaveBeenCalledTimes(1)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
+51
View File
@@ -0,0 +1,51 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import type { App, Tray } from 'electron'
export type TrayController = {
tray: Tray
dispose(): void
}
export function resolveTrayIconPath(desktopRoot: string): string {
const candidates = [
path.join(desktopRoot, 'src-tauri', 'icons', 'icon.png'),
path.join(desktopRoot, 'public', 'app-icon.png'),
path.join(desktopRoot, 'dist', 'app-icon.png'),
]
const resolved = candidates.find(candidate => existsSync(candidate))
if (!resolved) {
throw new Error(`Electron tray icon not found under ${desktopRoot}`)
}
return resolved
}
export async function installTray({
app,
desktopRoot,
show,
quit,
}: {
app: App
desktopRoot: string
show: () => void
quit: () => void
}): Promise<TrayController> {
const { Menu, Tray, nativeImage } = await import('electron')
const icon = nativeImage.createFromPath(resolveTrayIconPath(desktopRoot))
const tray = new Tray(icon)
tray.setToolTip(app.name || 'Claude Code Haha')
tray.setContextMenu(Menu.buildFromTemplate([
{ label: 'Show Claude Code Haha', click: show },
{ type: 'separator' },
{ label: 'Quit Claude Code Haha', click: quit },
]))
tray.on('click', show)
return {
tray,
dispose() {
tray.destroy()
},
}
}
@@ -0,0 +1,51 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { createUpdateSmokeUpdaterFromEnv, parseUpdateSmokeEnv } from './updateSmoke'
import { ElectronUpdaterService } from './updater'
describe('Electron update smoke updater', () => {
it('stays disabled unless a smoke version is configured', () => {
expect(parseUpdateSmokeEnv({})).toBeNull()
expect(parseUpdateSmokeEnv({ CC_HAHA_ELECTRON_UPDATE_SMOKE_VERSION: ' ' })).toBeNull()
})
it('drives the real Electron updater service contract and logs the install signal', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'cc-haha-update-smoke-'))
const logPath = join(tempDir, 'update-smoke.jsonl')
try {
const updater = createUpdateSmokeUpdaterFromEnv({
CC_HAHA_ELECTRON_UPDATE_SMOKE_VERSION: '9.9.9-smoke',
CC_HAHA_ELECTRON_UPDATE_SMOKE_BODY: 'Smoke notes',
CC_HAHA_ELECTRON_UPDATE_SMOKE_TOTAL_BYTES: '200',
CC_HAHA_ELECTRON_UPDATE_SMOKE_LOG: logPath,
})
expect(updater).not.toBeNull()
const service = new ElectronUpdaterService(updater!)
const events: unknown[] = []
await expect(service.checkForUpdates()).resolves.toEqual({
version: '9.9.9-smoke',
body: 'Smoke notes',
})
await service.downloadUpdate(event => events.push(event))
service.stageDownloadedUpdate()
service.quitAndInstallDownloadedUpdate()
expect(events).toEqual([
{ event: 'Started', data: { contentLength: 200 } },
{ event: 'Progress', data: { chunkLength: 100 } },
{ event: 'Progress', data: { chunkLength: 100 } },
{ event: 'Finished' },
])
const logEvents = readFileSync(logPath, 'utf8')
.trim()
.split('\n')
.map(line => JSON.parse(line).event)
expect(logEvents).toEqual(['check', 'download-start', 'download-finish', 'quit-and-install'])
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
})
+104
View File
@@ -0,0 +1,104 @@
import { appendFileSync } from 'node:fs'
import type { ElectronUpdateCheckResult, ElectronUpdaterLike } from './updater'
type ProgressHandler = (progress: { transferred?: number, total?: number }) => void
export type UpdateSmokeEnv = {
CC_HAHA_ELECTRON_UPDATE_SMOKE_VERSION?: string
CC_HAHA_ELECTRON_UPDATE_SMOKE_BODY?: string
CC_HAHA_ELECTRON_UPDATE_SMOKE_TOTAL_BYTES?: string
CC_HAHA_ELECTRON_UPDATE_SMOKE_LOG?: string
}
type UpdateSmokeConfig = {
version: string
body: string | null
totalBytes: number
logPath?: string
}
function parsePositiveInteger(value: string | undefined, fallback: number) {
if (!value) return fallback
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
export function parseUpdateSmokeEnv(env: UpdateSmokeEnv): UpdateSmokeConfig | null {
const version = env.CC_HAHA_ELECTRON_UPDATE_SMOKE_VERSION?.trim()
if (!version) return null
return {
version,
body: env.CC_HAHA_ELECTRON_UPDATE_SMOKE_BODY ?? 'Electron update smoke release',
totalBytes: parsePositiveInteger(env.CC_HAHA_ELECTRON_UPDATE_SMOKE_TOTAL_BYTES, 100),
logPath: env.CC_HAHA_ELECTRON_UPDATE_SMOKE_LOG,
}
}
function writeLog(logPath: string | undefined, payload: Record<string, unknown>) {
if (!logPath) return
appendFileSync(logPath, `${JSON.stringify({
ts: new Date().toISOString(),
...payload,
})}\n`)
}
class UpdateSmokeUpdater implements ElectronUpdaterLike {
autoDownload = true
logger: unknown = null
private progressHandler: ProgressHandler | null = null
constructor(private readonly config: UpdateSmokeConfig) {}
async checkForUpdates(): Promise<ElectronUpdateCheckResult> {
writeLog(this.config.logPath, {
event: 'check',
version: this.config.version,
})
return {
updateInfo: {
version: this.config.version,
body: this.config.body,
},
}
}
async downloadUpdate(): Promise<unknown> {
writeLog(this.config.logPath, {
event: 'download-start',
totalBytes: this.config.totalBytes,
})
const firstChunk = Math.max(1, Math.floor(this.config.totalBytes / 2))
this.progressHandler?.({ transferred: firstChunk, total: this.config.totalBytes })
this.progressHandler?.({ transferred: this.config.totalBytes, total: this.config.totalBytes })
writeLog(this.config.logPath, {
event: 'download-finish',
totalBytes: this.config.totalBytes,
})
return undefined
}
quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void {
writeLog(this.config.logPath, {
event: 'quit-and-install',
isSilent: isSilent ?? null,
isForceRunAfter: isForceRunAfter ?? null,
})
}
on(event: 'download-progress', handler: ProgressHandler): ElectronUpdaterLike {
if (event === 'download-progress') this.progressHandler = handler
return this
}
off(event: 'download-progress', handler: ProgressHandler): ElectronUpdaterLike {
if (event === 'download-progress' && this.progressHandler === handler) {
this.progressHandler = null
}
return this
}
}
export function createUpdateSmokeUpdaterFromEnv(env: UpdateSmokeEnv): ElectronUpdaterLike | null {
const config = parseUpdateSmokeEnv(env)
return config ? new UpdateSmokeUpdater(config) : null
}
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest'
import { ElectronUpdaterService, normalizeUpdateInfo, type ElectronUpdaterLike } from './updater'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
function fakeUpdater(): ElectronUpdaterLike & {
emitProgress(progress: { transferred?: number, total?: number }): void
checkForUpdates: ReturnType<typeof vi.fn>
downloadUpdate: ReturnType<typeof vi.fn>
quitAndInstall: ReturnType<typeof vi.fn>
} {
let progressHandler: ((progress: { transferred?: number, total?: number }) => void) | null = null
const updater = {
autoDownload: true,
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
quitAndInstall: vi.fn(),
on: vi.fn((_event, handler) => {
progressHandler = handler
return updater
}),
off: vi.fn((_event, handler) => {
if (progressHandler === handler) progressHandler = null
return updater
}),
emitProgress(progress) {
progressHandler?.(progress)
},
} as ElectronUpdaterLike & {
emitProgress(progress: { transferred?: number, total?: number }): void
checkForUpdates: ReturnType<typeof vi.fn>
downloadUpdate: ReturnType<typeof vi.fn>
quitAndInstall: ReturnType<typeof vi.fn>
}
return updater
}
const updater = fakeUpdater()
describe('Electron updater service', () => {
it('normalizes update metadata from electron-updater', () => {
expect(normalizeUpdateInfo({ version: '1.2.3', releaseNotes: 'Notes' })).toEqual({
version: '1.2.3',
body: 'Notes',
})
expect(normalizeUpdateInfo({ version: '1.2.4', releaseNotes: [{ note: 'One' }, { note: 'Two' }] })).toEqual({
version: '1.2.4',
body: 'One\n\nTwo',
})
expect(normalizeUpdateInfo(undefined)).toBeNull()
})
it('keeps electron-updater autoDownload disabled and emits store-compatible progress', async () => {
updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.3', body: 'Fixes' } })
updater.downloadUpdate.mockImplementation(async () => {
updater.emitProgress({ transferred: 40, total: 100 })
updater.emitProgress({ transferred: 100, total: 100 })
})
const service = new ElectronUpdaterService(updater)
const events: unknown[] = []
await expect(service.checkForUpdates()).resolves.toEqual({ version: '1.2.3', body: 'Fixes' })
await service.downloadUpdate(event => events.push(event))
expect(updater.autoDownload).toBe(false)
expect(updater.logger).toBeNull()
expect(events).toEqual([
{ event: 'Started', data: { contentLength: 100 } },
{ event: 'Progress', data: { chunkLength: 40 } },
{ event: 'Progress', data: { chunkLength: 60 } },
{ event: 'Finished' },
])
})
it('treats missing unpacked app update metadata as no update', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockRejectedValueOnce(Object.assign(new Error("ENOENT: no such file or directory, open '/App/Contents/Resources/app-update.yml'"), {
code: 'ENOENT',
path: '/App/Contents/Resources/app-update.yml',
}))
await expect(service.checkForUpdates()).resolves.toBeNull()
})
it('skips electron-updater when packaged update config is absent', async () => {
const localUpdater = fakeUpdater()
const tempDir = mkdtempSync(join(tmpdir(), 'cc-haha-updater-'))
try {
const service = new ElectronUpdaterService(localUpdater, undefined, {
updateConfigPath: join(tempDir, 'app-update.yml'),
})
await expect(service.checkForUpdates()).resolves.toBeNull()
expect(localUpdater.checkForUpdates).not.toHaveBeenCalled()
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
it('treats missing GitHub channel metadata as no update', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockRejectedValueOnce(Object.assign(
new Error('Cannot find latest-mac.yml in the latest release artifacts (https://github.com/NanmiCoder/cc-haha/releases/download/v0.3.2/latest-mac.yml): HttpError: 404'),
{ code: 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND' },
))
await expect(service.checkForUpdates()).resolves.toBeNull()
})
it('treats missing GitHub channel metadata as no update even without an error code', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockRejectedValueOnce(
new Error('Cannot find latest-mac.yml in the latest release artifacts (https://github.com/NanmiCoder/cc-haha/releases/download/v0.3.2/latest-mac.yml): HttpError: 404'),
)
await expect(service.checkForUpdates()).resolves.toBeNull()
})
it('treats stringified missing GitHub channel metadata as no update', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockRejectedValueOnce(
'Error: Cannot find latest-mac.yml in the latest release artifacts (https://github.com/NanmiCoder/cc-haha/releases/download/v0.3.2/latest-mac.yml): HttpError: 404',
)
await expect(service.checkForUpdates()).resolves.toBeNull()
})
it('applies manual updater proxy before checking and clears it when returning to system proxy', async () => {
const localUpdater = fakeUpdater()
const proxyController = {
apply: vi.fn().mockResolvedValue(undefined),
}
localUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.5' } })
const service = new ElectronUpdaterService(localUpdater, proxyController)
await service.checkForUpdates({ proxy: 'http://127.0.0.1:7890' })
await service.checkForUpdates({ proxy: 'http://127.0.0.1:7890' })
await service.checkForUpdates()
expect(proxyController.apply).toHaveBeenNthCalledWith(1, 'http://127.0.0.1:7890')
expect(proxyController.apply).toHaveBeenNthCalledWith(2, null)
expect(proxyController.apply).toHaveBeenCalledTimes(2)
expect(localUpdater.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('does not hide non-metadata updater failures', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockRejectedValueOnce(new Error('feed unavailable'))
await expect(service.checkForUpdates()).rejects.toThrow('feed unavailable')
})
it('stages then installs through quitAndInstall only after an update has downloaded', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockResolvedValue({ updateInfo: { version: '1.2.4' } })
updater.downloadUpdate.mockResolvedValue(undefined)
expect(() => service.stageDownloadedUpdate()).toThrow('No Electron update is ready')
await service.checkForUpdates()
expect(() => service.stageDownloadedUpdate()).toThrow('has not finished downloading')
await service.downloadUpdate(() => {})
service.stageDownloadedUpdate()
service.quitAndInstallDownloadedUpdate()
expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true)
})
})
+180
View File
@@ -0,0 +1,180 @@
import type { DesktopUpdateDownloadEvent } from '../../src/lib/desktopHost/types'
import { existsSync } from 'node:fs'
export type ElectronUpdateInfo = {
version: string
body?: string | null
releaseNotes?: string | Array<{ note?: string | null }> | null
}
export type ElectronUpdateCheckResult = {
updateInfo?: ElectronUpdateInfo
} | null
export type ElectronUpdateCheckOptions = {
proxy?: string
}
export type ElectronUpdaterLike = {
autoDownload: boolean
logger?: unknown
checkForUpdates(): Promise<ElectronUpdateCheckResult>
downloadUpdate(): Promise<unknown>
quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void
on(event: 'download-progress', handler: (progress: { transferred?: number, total?: number }) => void): ElectronUpdaterLike
off(event: 'download-progress', handler: (progress: { transferred?: number, total?: number }) => void): ElectronUpdaterLike
}
export type ElectronUpdateMetadata = {
version: string
body: string | null
}
export type ElectronUpdaterProxyController = {
apply(proxy: string | null): Promise<void>
}
export type ElectronUpdaterRuntimeOptions = {
updateConfigPath?: string
}
export function normalizeUpdateInfo(info: ElectronUpdateInfo | undefined): ElectronUpdateMetadata | null {
if (!info?.version) return null
const releaseNotes = Array.isArray(info.releaseNotes)
? info.releaseNotes.map(note => note.note).filter(Boolean).join('\n\n')
: info.releaseNotes
return {
version: info.version,
body: info.body ?? releaseNotes ?? null,
}
}
function isMissingUpdateMetadataError(error: unknown): boolean {
if (!error) return false
const maybeError = typeof error === 'object'
? error as { code?: unknown, message?: unknown, path?: unknown }
: {}
const code = typeof maybeError.code === 'string' ? maybeError.code : ''
const path = typeof maybeError.path === 'string' ? maybeError.path : ''
const message = typeof maybeError.message === 'string' && maybeError.message
? maybeError.message
: String(error)
const referencesChannelMetadata = /latest(?:-[a-z0-9]+)?(?:-[a-z0-9]+)?\.ya?ml/i.test(message)
if (code === 'ENOENT') {
return path.endsWith('app-update.yml') || message.includes('app-update.yml')
}
if (code === 'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND') {
return referencesChannelMetadata
}
return referencesChannelMetadata && /cannot find|not found|404/i.test(message)
}
export class ElectronUpdaterService {
private readonly updater: ElectronUpdaterLike
private readonly proxyController?: ElectronUpdaterProxyController
private readonly updateConfigPath?: string
private pendingUpdate: ElectronUpdateMetadata | null = null
private downloaded = false
private proxyKey: string | null = null
constructor(
updater: ElectronUpdaterLike,
proxyController?: ElectronUpdaterProxyController,
runtimeOptions: ElectronUpdaterRuntimeOptions = {},
) {
this.updater = updater
this.proxyController = proxyController
this.updateConfigPath = runtimeOptions.updateConfigPath
this.updater.autoDownload = false
this.updater.logger = null
}
private async applyProxy(options?: ElectronUpdateCheckOptions) {
if (!this.proxyController) return
const proxy = options?.proxy?.trim() || null
const nextProxyKey = proxy ? `manual:${proxy}` : 'system'
if (this.proxyKey === nextProxyKey) return
await this.proxyController.apply(proxy)
this.proxyKey = nextProxyKey
}
async checkForUpdates(options?: ElectronUpdateCheckOptions): Promise<ElectronUpdateMetadata | null> {
let result: ElectronUpdateCheckResult
try {
await this.applyProxy(options)
if (this.updateConfigPath && !existsSync(this.updateConfigPath)) {
result = null
} else {
result = await this.updater.checkForUpdates()
}
} catch (error) {
if (!isMissingUpdateMetadataError(error)) throw error
result = null
}
this.pendingUpdate = normalizeUpdateInfo(result?.updateInfo)
this.downloaded = false
return this.pendingUpdate
}
async downloadUpdate(emit: (event: DesktopUpdateDownloadEvent) => void): Promise<void> {
if (!this.pendingUpdate) {
throw new Error('No Electron update is available to download')
}
if (this.downloaded) {
emit({ event: 'Finished' })
return
}
let lastTransferred = 0
let started = false
const onProgress = (progress: { transferred?: number, total?: number }) => {
const transferred = Math.max(0, progress.transferred ?? 0)
if (!started) {
started = true
emit({ event: 'Started', data: { contentLength: progress.total ?? null } })
}
const chunkLength = Math.max(0, transferred - lastTransferred)
lastTransferred = transferred
if (chunkLength > 0) {
emit({ event: 'Progress', data: { chunkLength } })
}
}
this.updater.on('download-progress', onProgress)
try {
await this.updater.downloadUpdate()
if (!started) {
emit({ event: 'Started', data: { contentLength: null } })
}
emit({ event: 'Finished' })
this.downloaded = true
} finally {
this.updater.off('download-progress', onProgress)
}
}
cancelInstall() {
this.pendingUpdate = null
this.downloaded = false
}
stageDownloadedUpdate() {
if (!this.pendingUpdate) {
throw new Error('No Electron update is ready to install')
}
if (!this.downloaded) {
throw new Error('Electron update has not finished downloading')
}
}
hasDownloadedUpdate(): boolean {
return !!this.pendingUpdate && this.downloaded
}
quitAndInstallDownloadedUpdate() {
this.stageDownloadedUpdate()
this.updater.quitAndInstall(false, true)
}
}
@@ -0,0 +1,47 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { writeWindowSmokeSnapshot } from './windowSmoke'
describe('Electron window smoke diagnostics', () => {
it('stays disabled unless a log path is configured', () => {
expect(() => writeWindowSmokeSnapshot(null, 'disabled', {})).not.toThrow()
})
it('writes a focused visible window snapshot for packaged UI diagnostics', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'cc-haha-window-smoke-'))
const logPath = join(tempDir, 'window-smoke.jsonl')
try {
writeWindowSmokeSnapshot({
getBounds: () => ({ x: 10, y: 20, width: 1280, height: 820 }),
getTitle: () => 'Claude Code Companion',
isDestroyed: () => false,
isFocused: () => true,
isFullScreen: () => false,
isMaximized: () => false,
isMinimized: () => false,
isVisible: () => true,
webContents: {
getURL: () => 'file:///app.asar/dist/index.html',
isLoading: vi.fn(() => false),
},
} as never, 'did-finish-load', {
CC_HAHA_ELECTRON_WINDOW_SMOKE_LOG: logPath,
})
expect(JSON.parse(readFileSync(logPath, 'utf8')).trim).toBeUndefined()
const payload = JSON.parse(readFileSync(logPath, 'utf8'))
expect(payload).toMatchObject({
reason: 'did-finish-load',
title: 'Claude Code Companion',
visible: true,
focused: true,
minimized: false,
url: 'file:///app.asar/dist/index.html',
})
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
})
+46
View File
@@ -0,0 +1,46 @@
import { appendFileSync } from 'node:fs'
import type { BrowserWindow } from 'electron'
export type WindowSmokeEnv = {
CC_HAHA_ELECTRON_WINDOW_SMOKE_LOG?: string
}
type WindowSmokeWindow = Pick<
BrowserWindow,
'getBounds' | 'getTitle' | 'isDestroyed' | 'isFocused' | 'isFullScreen' | 'isMaximized' | 'isMinimized' | 'isVisible'
> & {
webContents?: Pick<BrowserWindow['webContents'], 'getURL' | 'isLoading'>
}
export function writeWindowSmokeSnapshot(
window: WindowSmokeWindow | null,
reason: string,
env: WindowSmokeEnv = process.env,
) {
const logPath = env.CC_HAHA_ELECTRON_WINDOW_SMOKE_LOG
if (!logPath) return
const payload = window
? {
reason,
destroyed: window.isDestroyed(),
title: window.getTitle(),
visible: window.isVisible(),
focused: window.isFocused(),
minimized: window.isMinimized(),
maximized: window.isMaximized(),
fullScreen: window.isFullScreen(),
bounds: window.getBounds(),
url: window.webContents?.getURL() ?? null,
loading: window.webContents?.isLoading() ?? null,
}
: {
reason,
missingWindow: true,
}
appendFileSync(logPath, `${JSON.stringify({
ts: new Date().toISOString(),
...payload,
})}\n`)
}
+172
View File
@@ -0,0 +1,172 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import path from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import {
captureWindowState,
hasMeaningfulIntersection,
installWindowLifecycle,
isPersistableWindowState,
isWindowStateVisibleOnAnyDisplay,
readWindowState,
restoreWindowMaximized,
showMainWindow,
windowOptionsFromState,
windowStatePath,
writeWindowState,
} from './windows'
const fakeApp = (userData: string) => ({
getPath: vi.fn(() => userData),
})
describe('Electron window service', () => {
it('persists window state in CLAUDE_CONFIG_DIR when portable config is active', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-'))
try {
const app = fakeApp(path.join(tmp, 'user-data'))
const state = { x: 10, y: 20, width: 1280, height: 820, maximized: false }
writeWindowState(app as never, state, { CLAUDE_CONFIG_DIR: tmp })
const statePath = windowStatePath(app as never, { CLAUDE_CONFIG_DIR: tmp })
expect(JSON.parse(readFileSync(statePath, 'utf-8'))).toEqual(state)
expect(app.getPath).not.toHaveBeenCalled()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('rejects undersized or off-screen state before restore', () => {
expect(isPersistableWindowState({ x: 0, y: 0, width: 100, height: 100, maximized: false })).toBe(false)
expect(hasMeaningfulIntersection(
{ x: 5000, y: 5000, width: 1280, height: 820 },
{ x: 0, y: 0, width: 1440, height: 900 },
)).toBe(false)
expect(isWindowStateVisibleOnAnyDisplay(
{ x: 100, y: 100, width: 1280, height: 820, maximized: false },
[{ bounds: { x: 0, y: 0, width: 1440, height: 900 }, workArea: { x: 0, y: 0, width: 1440, height: 860 } }],
)).toBe(true)
})
it('reads only valid visible window state', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-read-'))
try {
const app = fakeApp(tmp)
const state = { x: 50, y: 60, width: 1280, height: 820, maximized: true }
writeWindowState(app as never, state, {})
expect(readWindowState(
app as never,
[{ bounds: { x: 0, y: 0, width: 1440, height: 900 }, workArea: { x: 0, y: 0, width: 1440, height: 860 } }],
{},
)).toEqual(state)
expect(readWindowState(
app as never,
[{ bounds: { x: 3000, y: 3000, width: 1440, height: 900 }, workArea: { x: 3000, y: 3000, width: 1440, height: 860 } }],
{},
)).toBeNull()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('does not capture minimized windows', () => {
const window = {
isMinimized: () => true,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
}
expect(captureWindowState(window as never)).toBeNull()
})
it('restores persisted bounds and maximized state when reopening the window', () => {
const state = { x: 12, y: 34, width: 1400, height: 900, maximized: true }
const maximize = vi.fn()
expect(windowOptionsFromState(state)).toEqual({
x: 12,
y: 34,
width: 1400,
height: 900,
})
restoreWindowMaximized({ maximize } as never, state)
expect(maximize).toHaveBeenCalledTimes(1)
})
it('shows, restores, and focuses the hidden main window when a tray or notification action reopens it', () => {
const window = {
isVisible: () => false,
isMinimized: () => true,
show: vi.fn(),
restore: vi.fn(),
focus: vi.fn(),
}
showMainWindow(window as never)
expect(window.show).toHaveBeenCalledTimes(1)
expect(window.restore).toHaveBeenCalledTimes(1)
expect(window.focus).toHaveBeenCalledTimes(1)
})
it('hides instead of closing until the app is explicitly quitting', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-close-'))
try {
const handlers = new Map<string, (...args: never[]) => void>()
const preventDefault = vi.fn()
const window = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers.set(event, handler)
}),
hide: vi.fn(),
isMinimized: () => false,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
}
installWindowLifecycle({
app: fakeApp(tmp) as never,
window: window as never,
shouldQuit: () => false,
})
handlers.get('close')?.({ preventDefault } as never)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(window.hide).toHaveBeenCalledTimes(1)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('allows the window to close normally once the app is explicitly quitting', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-quit-'))
try {
const handlers = new Map<string, (...args: never[]) => void>()
const preventDefault = vi.fn()
const window = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers.set(event, handler)
}),
hide: vi.fn(),
isMinimized: () => false,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
}
installWindowLifecycle({
app: fakeApp(tmp) as never,
window: window as never,
shouldQuit: () => true,
})
handlers.get('close')?.({ preventDefault } as never)
expect(preventDefault).not.toHaveBeenCalled()
expect(window.hide).not.toHaveBeenCalled()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
})
+144
View File
@@ -0,0 +1,144 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { App, BrowserWindow, Display } from 'electron'
export const WINDOW_STATE_FILE = 'window-state.json'
export const DEFAULT_WINDOW_WIDTH = 1280
export const DEFAULT_WINDOW_HEIGHT = 820
export const MIN_WINDOW_WIDTH = 960
export const MIN_WINDOW_HEIGHT = 640
const MIN_VISIBLE_PIXELS = 80
export type StoredWindowState = {
x: number
y: number
width: number
height: number
maximized: boolean
}
export type WindowStateBounds = Pick<StoredWindowState, 'x' | 'y' | 'width' | 'height'>
export type WindowCreateBounds =
& Partial<Pick<StoredWindowState, 'x' | 'y'>>
& Pick<StoredWindowState, 'width' | 'height'>
export function windowStatePath(app: App, env: NodeJS.ProcessEnv = process.env): string {
return path.join(env.CLAUDE_CONFIG_DIR || app.getPath('userData'), WINDOW_STATE_FILE)
}
export function isPersistableWindowState(state: StoredWindowState): boolean {
return Number.isFinite(state.x)
&& Number.isFinite(state.y)
&& state.width >= MIN_WINDOW_WIDTH
&& state.height >= MIN_WINDOW_HEIGHT
}
export function hasMeaningfulIntersection(
state: WindowStateBounds,
displayBounds: WindowStateBounds,
): boolean {
const stateRight = state.x + state.width
const stateBottom = state.y + state.height
const displayRight = displayBounds.x + displayBounds.width
const displayBottom = displayBounds.y + displayBounds.height
return stateRight > displayBounds.x + MIN_VISIBLE_PIXELS
&& stateBottom > displayBounds.y + MIN_VISIBLE_PIXELS
&& state.x < displayRight - MIN_VISIBLE_PIXELS
&& state.y < displayBottom - MIN_VISIBLE_PIXELS
}
export function isWindowStateVisibleOnAnyDisplay(
state: StoredWindowState,
displays: Array<Pick<Display, 'bounds' | 'workArea'>>,
): boolean {
if (displays.length === 0) return true
return displays.some(display =>
hasMeaningfulIntersection(state, display.workArea ?? display.bounds),
)
}
export function readWindowState(
app: App,
displays: Array<Pick<Display, 'bounds' | 'workArea'>>,
env: NodeJS.ProcessEnv = process.env,
): StoredWindowState | null {
const statePath = windowStatePath(app, env)
if (!existsSync(statePath)) return null
try {
const parsed = JSON.parse(readFileSync(statePath, 'utf-8')) as StoredWindowState
if (!isPersistableWindowState(parsed)) return null
if (!isWindowStateVisibleOnAnyDisplay(parsed, displays)) return null
return parsed
} catch (error) {
console.error(`[desktop] failed to read Electron window state ${statePath}:`, error)
return null
}
}
export function writeWindowState(
app: App,
state: StoredWindowState,
env: NodeJS.ProcessEnv = process.env,
) {
if (!isPersistableWindowState(state)) return
const statePath = windowStatePath(app, env)
mkdirSync(path.dirname(statePath), { recursive: true })
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`)
}
export function captureWindowState(window: BrowserWindow): StoredWindowState | null {
if (window.isMinimized()) return null
const bounds = window.getBounds()
const state = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
maximized: window.isMaximized(),
}
return isPersistableWindowState(state) ? state : null
}
export function windowOptionsFromState(state: StoredWindowState | null): WindowCreateBounds {
return state
? { x: state.x, y: state.y, width: state.width, height: state.height }
: { width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT }
}
export function restoreWindowMaximized(window: BrowserWindow, state: StoredWindowState | null) {
if (state?.maximized) window.maximize()
}
export function saveWindowState(app: App, window: BrowserWindow) {
const state = captureWindowState(window)
if (state) writeWindowState(app, state)
}
export function showMainWindow(window: BrowserWindow | null) {
if (!window) return
if (!window.isVisible()) window.show()
if (window.isMinimized()) window.restore()
window.focus()
}
export function installWindowLifecycle({
app,
window,
shouldQuit,
}: {
app: App
window: BrowserWindow
shouldQuit: () => boolean
}) {
window.on('close', (event) => {
saveWindowState(app, window)
if (shouldQuit()) return
event.preventDefault()
window.hide()
})
window.on('move', () => saveWindowState(app, window))
window.on('resize', () => saveWindowState(app, window))
}
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { normalizeZoomFactor } from './zoom'
describe('Electron zoom service', () => {
it('clamps native zoom to the same range as the renderer setting', () => {
expect(normalizeZoomFactor(0.1)).toBe(0.5)
expect(normalizeZoomFactor(1.25)).toBe(1.25)
expect(normalizeZoomFactor(4)).toBe(2)
expect(normalizeZoomFactor(Number.NaN)).toBe(1)
})
})
+8
View File
@@ -0,0 +1,8 @@
export const MIN_APP_ZOOM = 0.5
export const MAX_APP_ZOOM = 2
export function normalizeZoomFactor(value: unknown): number {
const numeric = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numeric)) return 1
return Math.min(Math.max(numeric, MIN_APP_ZOOM), MAX_APP_ZOOM)
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"types": ["node"],
"noEmit": true,
"allowImportingTsExtensions": false
},
"include": ["./**/*.ts", "../src/lib/desktopHost/types.ts"]
}
+66
View File
@@ -3,13 +3,74 @@
"private": true,
"version": "0.3.2",
"type": "module",
"main": "electron-dist/main.cjs",
"build": {
"appId": "com.claude-code-haha.desktop",
"productName": "Claude Code Haha",
"asar": true,
"asarUnpack": [
"node_modules/node-pty/**",
"src-tauri/binaries/**"
],
"artifactName": "Claude-Code-Haha-${version}-${arch}.${ext}",
"npmRebuild": false,
"directories": {
"output": "build-artifacts/electron"
},
"files": [
"dist/**",
"electron-dist/**",
"node_modules/node-pty/**",
"src-tauri/binaries/**",
"src-tauri/icons/**",
"src-tauri/resources/preview-agent.js",
"package.json"
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
"dmg",
"zip"
],
"icon": "src-tauri/icons/icon.icns"
},
"win": {
"target": "nsis",
"icon": "src-tauri/icons/icon.ico"
},
"linux": {
"target": [
"AppImage",
"deb"
],
"icon": "src-tauri/icons",
"category": "Development"
},
"publish": [
{
"provider": "github",
"owner": "NanmiCoder",
"repo": "cc-haha"
}
]
},
"scripts": {
"dev": "vite",
"build": "bun run build:preview-agent && node ./node_modules/typescript/bin/tsc -b && node ./node_modules/vite/bin/vite.js build",
"prepare:node-pty": "bun run ./scripts/prepare-node-pty.ts",
"build:electron": "bun run prepare:node-pty && bun build ./electron/main.ts --outfile ./electron-dist/main.cjs --target node --format cjs --external electron --external node-pty && bun build ./electron/preload.ts --outfile ./electron-dist/preload.cjs --target node --format cjs --external electron && bun build ./electron/preview-preload.ts --outfile ./electron-dist/preview-preload.cjs --target node --format cjs --external electron",
"build:preview-agent": "bun run ./scripts/build-preview-agent.ts",
"build:sidecars": "bun run ./scripts/build-sidecars.ts",
"clean:electron-output": "bun run ./scripts/clean-electron-output.ts",
"build:macos-arm64": "bash ./scripts/build-macos-arm64.sh",
"build:windows-x64": "powershell -ExecutionPolicy Bypass -File ./scripts/build-windows-x64.ps1",
"build:linux-x64": "bash ./scripts/build-linux.sh",
"build:linux-arm64": "LINUX_ARCH=arm64 bash ./scripts/build-linux.sh",
"electron:dev": "bun run build:electron && bun run ./scripts/electron-dev.ts",
"electron:build": "bun run build:sidecars && bun run build && bun run build:electron",
"electron:package": "bun run clean:electron-output && bun run electron:build && bunx electron-builder --publish never",
"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",
@@ -22,11 +83,13 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"dompurify": "^3.3.3",
"electron-updater": "^6.8.3",
"html2canvas": "^1.4.1",
"katex": "^0.16.45",
"lucide-react": "^0.469.0",
"marked": "^15.0.7",
"mermaid": "^11.14.0",
"node-pty": "^1.1.0",
"prism-react-renderer": "^2.4.1",
"qrcode": "^1.5.4",
"react": "^18.3.1",
@@ -41,10 +104,13 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/node": "^25.9.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "3.2.4",
"electron": "^42.3.0",
"electron-builder": "^26.8.1",
"jsdom": "^25.0.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.3",
+136
View File
@@ -0,0 +1,136 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DESKTOP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
REPO_ROOT="$(cd "${DESKTOP_DIR}/.." && pwd)"
LINUX_ARCH="${LINUX_ARCH:-x64}"
case "${LINUX_ARCH}" in
x64)
TARGET_TRIPLE="x86_64-unknown-linux-gnu"
BUILDER_ARCH="x64"
CANONICAL_ARCH="linux-x64"
;;
arm64)
TARGET_TRIPLE="aarch64-unknown-linux-gnu"
BUILDER_ARCH="arm64"
CANONICAL_ARCH="linux-arm64"
;;
*)
echo "[build-linux] Unsupported LINUX_ARCH=${LINUX_ARCH}. Expected x64 or arm64." >&2
exit 1
;;
esac
CANONICAL_OUTPUT_DIR="${DESKTOP_DIR}/build-artifacts/${CANONICAL_ARCH}"
ELECTRON_OUTPUT_DIR="${DESKTOP_DIR}/build-artifacts/electron"
usage() {
cat <<'EOF'
Build Claude Code Haha desktop for Linux with Electron Builder.
Usage:
./desktop/scripts/build-linux.sh [extra electron-builder args...]
Environment:
LINUX_ARCH=x64|arm64 Target architecture. Defaults to x64.
LINUX_TARGETS Electron Builder Linux targets. Defaults to "AppImage deb".
SKIP_INSTALL=1 Skip `bun install` in the repo root and desktop app.
REBUILD_NATIVE=1 Run `electron-builder install-app-deps` before packaging.
SKIP_PACKAGE_SMOKE=1 Skip package-smoke verification after copying artifacts.
OPEN_OUTPUT=1 Open the canonical artifact output directory after a successful build.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ "$(uname -s)" != "Linux" ]]; then
echo "[build-linux] This script must run on Linux." >&2
exit 1
fi
for command in bun; do
if ! command -v "${command}" >/dev/null 2>&1; then
echo "[build-linux] Missing required command: ${command}" >&2
exit 1
fi
done
read -r -a LINUX_TARGET_ARRAY <<< "${LINUX_TARGETS:-AppImage deb}"
if [[ "${#LINUX_TARGET_ARRAY[@]}" -eq 0 ]]; then
echo "[build-linux] LINUX_TARGETS must contain at least one electron-builder Linux target." >&2
exit 1
fi
if [[ "${SKIP_INSTALL:-0}" != "1" ]]; then
echo "[build-linux] Installing root dependencies..."
(cd "${REPO_ROOT}" && bun install)
echo "[build-linux] Installing desktop dependencies..."
(cd "${DESKTOP_DIR}" && bun install)
fi
echo "[build-linux] Cleaning stale Electron outputs..."
rm -rf "${DESKTOP_DIR}/dist"
rm -rf "${DESKTOP_DIR}/electron-dist"
rm -rf "${ELECTRON_OUTPUT_DIR}"
rm -rf "${CANONICAL_OUTPUT_DIR}"
rm -f "${DESKTOP_DIR}/tsconfig.tsbuildinfo"
rm -rf "${DESKTOP_DIR}/src-tauri/binaries/claude-sidecar-"*
echo "[build-linux] Building sidecars for ${TARGET_TRIPLE}..."
(cd "${DESKTOP_DIR}" && SIDECAR_TARGET_TRIPLE="${TARGET_TRIPLE}" bun run build:sidecars)
echo "[build-linux] Building renderer and Electron main/preload bundles..."
(cd "${DESKTOP_DIR}" && bun run build && bun run build:electron)
if [[ "${REBUILD_NATIVE:-0}" == "1" ]]; then
echo "[build-linux] Rebuilding native dependencies for Electron ABI..."
(cd "${DESKTOP_DIR}" && bunx electron-builder install-app-deps)
(cd "${DESKTOP_DIR}" && bun run prepare:node-pty)
fi
BUILDER_ARGS=(bunx electron-builder --linux "${LINUX_TARGET_ARRAY[@]}" "--${BUILDER_ARCH}" --publish never)
if [[ "$#" -gt 0 ]]; then
BUILDER_ARGS+=("$@")
fi
echo "[build-linux] Packaging Electron app..."
(cd "${DESKTOP_DIR}" && "${BUILDER_ARGS[@]}")
mkdir -p "${CANONICAL_OUTPUT_DIR}"
find "${CANONICAL_OUTPUT_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
if [[ -d "${ELECTRON_OUTPUT_DIR}/linux-unpacked" ]]; then
cp -R "${ELECTRON_OUTPUT_DIR}/linux-unpacked" "${CANONICAL_OUTPUT_DIR}/"
else
echo "[build-linux] Warning: linux-unpacked was not found under ${ELECTRON_OUTPUT_DIR}; package-smoke will only inspect packaged artifacts." >&2
fi
find "${ELECTRON_OUTPUT_DIR}" -maxdepth 1 -type f \( -name '*.AppImage' -o -name '*.deb' -o -name '*.blockmap' -o -name 'latest-linux*.yml' \) -exec cp -f {} "${CANONICAL_OUTPUT_DIR}/" \;
cat > "${CANONICAL_OUTPUT_DIR}/BUILD_INFO.txt" <<EOF
Target triple: ${TARGET_TRIPLE}
Linux arch: ${LINUX_ARCH}
Builder output: ${ELECTRON_OUTPUT_DIR}
Canonical output: ${CANONICAL_OUTPUT_DIR}
Built at: $(date '+%Y-%m-%d %H:%M:%S %z')
EOF
if [[ "${SKIP_PACKAGE_SMOKE:-0}" != "1" ]]; then
echo "[build-linux] Running package smoke..."
(cd "${REPO_ROOT}" && bun run test:package-smoke --platform linux --package-kind release --artifacts-dir "desktop/build-artifacts/${CANONICAL_ARCH}")
fi
echo
echo "[build-linux] Build finished."
echo "[build-linux] Canonical output: ${CANONICAL_OUTPUT_DIR}"
if [[ "${OPEN_OUTPUT:-0}" == "1" && -n "$(command -v xdg-open || true)" ]]; then
xdg-open "${CANONICAL_OUTPUT_DIR}" >/dev/null 2>&1 || true
fi
+71 -263
View File
@@ -7,31 +7,26 @@ DESKTOP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
REPO_ROOT="$(cd "${DESKTOP_DIR}/.." && pwd)"
TARGET_TRIPLE="aarch64-apple-darwin"
TAURI_TARGET_DIR="${DESKTOP_DIR}/src-tauri/target"
CANONICAL_OUTPUT_DIR="${DESKTOP_DIR}/build-artifacts/macos-arm64"
APP_BUNDLE_NAME="Claude Code Haha.app"
APP_BUNDLE_ID="com.claude-code-haha.desktop"
ELECTRON_OUTPUT_DIR="${DESKTOP_DIR}/build-artifacts/electron"
usage() {
cat <<'EOF'
Build Claude Code Haha desktop for macOS Apple Silicon and output a DMG.
Build Claude Code Haha desktop for macOS Apple Silicon with Electron Builder.
Usage:
./desktop/scripts/build-macos-arm64.sh [extra tauri build args...]
./desktop/scripts/build-macos-arm64.sh [extra electron-builder args...]
Environment:
SKIP_INSTALL=1 Skip `bun install` in the repo root and desktop app.
SIGN_BUILD=1 Remove the default `--no-sign` flag and allow signed builds.
SIGN_BUILD=1 Allow electron-builder to auto-discover signing identities.
REBUILD_NATIVE=1 Run `electron-builder install-app-deps` before packaging.
MAC_TARGETS Electron Builder macOS targets. Defaults to "dmg zip".
SKIP_PACKAGE_SMOKE=1
Skip package-smoke verification after copying artifacts.
REQUIRE_MACOS_GATEKEEPER_SMOKE=1
Require Gatekeeper approval during post-build package-smoke.
OPEN_OUTPUT=1 Open the canonical artifact output directory in Finder after a successful build.
PRESERVE_TAURI_TARGET=1
Keep Tauri/Rust target cache for a faster incremental build.
By default this script removes the macOS target cache so
packaged WebView assets cannot be silently reused.
Examples:
./desktop/scripts/build-macos-arm64.sh
SKIP_INSTALL=1 ./desktop/scripts/build-macos-arm64.sh
SIGN_BUILD=1 ./desktop/scripts/build-macos-arm64.sh --skip-stapling
EOF
}
@@ -50,13 +45,40 @@ if [[ "$(uname -m)" != "arm64" ]]; then
exit 1
fi
for command in bun cargo rustc codesign hdiutil; do
for command in bun codesign hdiutil; do
if ! command -v "${command}" >/dev/null 2>&1; then
echo "[build-macos-arm64] Missing required command: ${command}" >&2
exit 1
fi
done
read -r -a MAC_TARGET_ARRAY <<< "${MAC_TARGETS:-dmg zip}"
if [[ "${#MAC_TARGET_ARRAY[@]}" -eq 0 ]]; then
echo "[build-macos-arm64] MAC_TARGETS must contain at least one electron-builder macOS target." >&2
exit 1
fi
has_mac_target() {
local target="$1"
for candidate in "${MAC_TARGET_ARRAY[@]}"; do
if [[ "${candidate}" == "${target}" ]]; then
return 0
fi
done
return 1
}
if has_mac_target "dmg"; then
STALE_DMG_MOUNTS="$(hdiutil info | grep -F "${ELECTRON_OUTPUT_DIR}/.temp" || true)"
if [[ -n "${STALE_DMG_MOUNTS}" ]]; then
echo "[build-macos-arm64] Found stale Electron Builder temporary DMG mounts in this worktree:" >&2
echo "${STALE_DMG_MOUNTS}" >&2
echo "[build-macos-arm64] Detach the stale disk image or restart DiskImages before building the dmg target." >&2
echo "[build-macos-arm64] To verify the update zip path without DMG, rerun with MAC_TARGETS=zip." >&2
exit 1
fi
fi
if [[ "${SKIP_INSTALL:-0}" != "1" ]]; then
echo "[build-macos-arm64] Installing root dependencies..."
(cd "${REPO_ROOT}" && bun install)
@@ -65,281 +87,67 @@ if [[ "${SKIP_INSTALL:-0}" != "1" ]]; then
(cd "${DESKTOP_DIR}" && bun install)
fi
# ── 清理 + 显式预热前端 / sidecar ────────────────────────────
# 之前遇到过两类"改了源码,build 出来的 .app 还是旧行为"的诡异 case:
# 1) Bun.build / Tauri bundler 某一层缓存把旧 sidecar binary 复用进新 .app
# 2) Tauri/Rust target 缓存复用旧 claude-code-desktop,导致新 dist 没被嵌进去
# 第二类尤其隐蔽: dist 是新的,sidecar 也是新的,但 WebView 运行的还是旧前端。
#
# 默认做四件事强制 fresh build:
# 1) 硬删 sidecar 源 binary + Tauri target/bundle 目录 + 前端 dist
# 2) 显式跑 bun run build + bun run build:sidecars
# 3) tauri build 用 --config 覆盖 beforeBuildCommand 为 true(no-op),
# 避免 sidecar 被重复编译浪费 ~10s
# 4) 复制到 canonical output 前再次清空输出目录
# 任一步失败,整个脚本立即退出(set -e)。
echo "[build-macos-arm64] Cleaning stale sidecar binaries, frontend output, and Tauri bundle cache..."
rm -rf "${DESKTOP_DIR}/src-tauri/binaries/claude-sidecar-"*
echo "[build-macos-arm64] Cleaning stale Electron outputs..."
rm -rf "${DESKTOP_DIR}/dist"
rm -rf "${DESKTOP_DIR}/electron-dist"
rm -rf "${ELECTRON_OUTPUT_DIR}"
rm -rf "${CANONICAL_OUTPUT_DIR}"
rm -f "${DESKTOP_DIR}/tsconfig.tsbuildinfo"
rm -rf "${DESKTOP_DIR}/src-tauri/binaries/claude-sidecar-"*
if [[ "${PRESERVE_TAURI_TARGET:-0}" == "1" ]]; then
echo "[build-macos-arm64] PRESERVE_TAURI_TARGET=1: keeping Rust dependency cache, clearing app-specific artifacts only..."
rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/bundle"
rm -rf "${DESKTOP_DIR}/src-tauri/target/release/bundle"
rm -f "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/claude-code-desktop"
rm -f "${DESKTOP_DIR}/src-tauri/target/release/claude-code-desktop"
find "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/build" \
-maxdepth 1 -name 'claude-code-desktop-*' -exec rm -rf {} + 2>/dev/null || true
find "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/.fingerprint" \
-maxdepth 1 -name 'claude-code-desktop-*' -exec rm -rf {} + 2>/dev/null || true
find "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}/release/deps" \
-maxdepth 1 \( -name 'claude_code_desktop-*' -o -name 'libclaude_code_desktop-*' \) -exec rm -f {} + 2>/dev/null || true
else
echo "[build-macos-arm64] Removing Tauri target cache for ${TARGET_TRIPLE} to force fresh embedded frontend assets..."
rm -rf "${DESKTOP_DIR}/src-tauri/target/${TARGET_TRIPLE}"
rm -rf "${DESKTOP_DIR}/src-tauri/target/release/bundle"
rm -f "${DESKTOP_DIR}/src-tauri/target/release/claude-code-desktop"
find "${DESKTOP_DIR}/src-tauri/target/release/build" \
-maxdepth 1 -name 'claude-code-desktop-*' -exec rm -rf {} + 2>/dev/null || true
find "${DESKTOP_DIR}/src-tauri/target/release/.fingerprint" \
-maxdepth 1 -name 'claude-code-desktop-*' -exec rm -rf {} + 2>/dev/null || true
find "${DESKTOP_DIR}/src-tauri/target/release/deps" \
-maxdepth 1 \( -name 'claude_code_desktop-*' -o -name 'libclaude_code_desktop-*' \) -exec rm -f {} + 2>/dev/null || true
echo "[build-macos-arm64] Building sidecars for ${TARGET_TRIPLE}..."
(cd "${DESKTOP_DIR}" && SIDECAR_TARGET_TRIPLE="${TARGET_TRIPLE}" bun run build:sidecars)
echo "[build-macos-arm64] Building renderer and Electron main/preload bundles..."
(cd "${DESKTOP_DIR}" && bun run build && bun run build:electron)
if [[ "${REBUILD_NATIVE:-0}" == "1" ]]; then
echo "[build-macos-arm64] Rebuilding native dependencies for Electron ABI..."
(cd "${DESKTOP_DIR}" && bunx electron-builder install-app-deps)
(cd "${DESKTOP_DIR}" && bun run prepare:node-pty)
fi
echo "[build-macos-arm64] Rebuilding frontend (tsc + vite)..."
(cd "${DESKTOP_DIR}" && bun run build)
echo "[build-macos-arm64] Rebuilding sidecar for ${TARGET_TRIPLE}..."
(cd "${DESKTOP_DIR}" && TAURI_ENV_TARGET_TRIPLE="${TARGET_TRIPLE}" bun run build:sidecars)
TAURI_ARGS=(
node
./node_modules/@tauri-apps/cli/tauri.js
build
--target
"${TARGET_TRIPLE}"
--bundles
app,dmg
--ci
--config
'{"build":{"beforeBuildCommand":"true"}}'
)
echo "[build-macos-arm64] Cleaning empty dmg-builder cache directories..."
(cd "${DESKTOP_DIR}" && bash ./scripts/clean-dmg-builder-cache.sh)
BUILDER_ARGS=(bunx electron-builder --mac "${MAC_TARGET_ARRAY[@]}" --arm64 --publish never)
if [[ "${SIGN_BUILD:-0}" != "1" ]]; then
TAURI_ARGS+=(--no-sign)
export CSC_IDENTITY_AUTO_DISCOVERY=false
fi
if [[ "$#" -gt 0 ]]; then
TAURI_ARGS+=("$@")
BUILDER_ARGS+=("$@")
fi
echo "[build-macos-arm64] Building DMG for ${TARGET_TRIPLE}..."
(
cd "${DESKTOP_DIR}"
export TAURI_ENV_TARGET_TRIPLE="${TARGET_TRIPLE}"
"${TAURI_ARGS[@]}"
)
TARGETED_DMG_DIR="${TAURI_TARGET_DIR}/${TARGET_TRIPLE}/release/bundle/dmg"
FALLBACK_DMG_DIR="${TAURI_TARGET_DIR}/release/bundle/dmg"
TARGETED_APP_DIR="${TAURI_TARGET_DIR}/${TARGET_TRIPLE}/release/bundle/macos"
FALLBACK_APP_DIR="${TAURI_TARGET_DIR}/release/bundle/macos"
LEGACY_BUNDLE_ROOT="${TAURI_TARGET_DIR}/release/bundle"
echo "[build-macos-arm64] Packaging Electron app..."
(cd "${DESKTOP_DIR}" && "${BUILDER_ARGS[@]}")
mkdir -p "${CANONICAL_OUTPUT_DIR}"
find "${CANONICAL_OUTPUT_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
find_latest_file() {
local search_dir="$1"
local pattern="$2"
if [[ -d "${search_dir}" ]]; then
find "${search_dir}" -maxdepth 1 -type f -name "${pattern}" | sort | tail -n 1
fi
}
find_latest_dir() {
local search_dir="$1"
local pattern="$2"
if [[ -d "${search_dir}" ]]; then
find "${search_dir}" -maxdepth 1 -type d -name "${pattern}" | sort | tail -n 1
fi
}
LATEST_DMG="$(find_latest_file "${TARGETED_DMG_DIR}" '*.dmg')"
if [[ -z "${LATEST_DMG}" ]]; then
LATEST_DMG="$(find_latest_file "${FALLBACK_DMG_DIR}" '*.dmg')"
fi
LATEST_APP="$(find_latest_dir "${TARGETED_APP_DIR}" '*.app')"
if [[ -z "${LATEST_APP}" ]]; then
LATEST_APP="$(find_latest_dir "${FALLBACK_APP_DIR}" '*.app')"
fi
build_canonical_dmg() {
local app_bundle="$1"
local dmg_output="$2"
local staging_dir
local rw_dmg
staging_dir="$(mktemp -d "${TMPDIR:-/tmp}/cc-haha-dmg.XXXXXX")"
rw_dmg="$(mktemp "${TMPDIR:-/tmp}/cc-haha-rw.XXXXXX").dmg"
cp -R "${app_bundle}" "${staging_dir}/"
ln -s /Applications "${staging_dir}/Applications"
# Create a read-write DMG first so we can customize the Finder layout
hdiutil create \
-volname "Claude Code Haha" \
-srcfolder "${staging_dir}" \
-ov \
-format UDRW \
"${rw_dmg}" >/dev/null
rm -rf "${staging_dir}"
# Mount the read-write DMG and apply Finder layout via AppleScript
local dev_name mount_dir
dev_name=$(hdiutil attach -readwrite -noverify -noautoopen -nobrowse "${rw_dmg}" \
| grep -E '^/dev/' | head -1 | awk '{print $1}')
mount_dir=$(hdiutil info | grep -E "${dev_name}" | tail -1 | awk '{$1=$2=""; print}' | xargs)
# Finder AppleScript 在新版 macOS (Sequoia+) 上某些属性
# (toolbar/statusbar visible、某些 container window 属性) 不再支持,
# 会返回 -10006 错误。美化失败不是 blocker —— 即便 layout 没设上,
# DMG 本身还是可用的,只是用户打开时看到的是 Finder 默认排布。
# 所以这里允许 osascript 非零退出,只 warn,不让 set -e 炸掉整个脚本。
if ! osascript <<APPLESCRIPT
tell application "Finder"
tell disk "Claude Code Haha"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {100, 100, 760, 500}
set viewOptions to the icon view options of container window
set arrangement of viewOptions to not arranged
set icon size of viewOptions to 128
set position of item "${APP_BUNDLE_NAME}" of container window to {180, 170}
set position of item "Applications" of container window to {480, 170}
close
open
update without registering applications
delay 2
close
end tell
end tell
APPLESCRIPT
then
echo "[build-macos-arm64] WARN: Finder layout AppleScript failed (likely macOS version incompatible); DMG will use default Finder layout" >&2
fi
sync
# osascript 可能已经让 Finder 打开了 volume 窗口,正常 detach 可能因为
# "Resource busy" 失败。失败时用 -force 二次尝试。
hdiutil detach "${dev_name}" -quiet 2>/dev/null \
|| hdiutil detach "${dev_name}" -force -quiet
# Convert to compressed read-only DMG
hdiutil convert "${rw_dmg}" -format UDZO -o "${dmg_output}" -ov >/dev/null
rm -f "${rw_dmg}"
}
codesign_cdhash() {
local executable="$1"
codesign -d --verbose=4 "${executable}" 2>&1 \
| awk -F= '/^CDHash=/{print $2; exit}'
}
sign_canonical_app_bundle() {
local app_bundle="$1"
local sidecar="${app_bundle}/Contents/MacOS/claude-sidecar"
local sidecar_cdhash_before=""
local sidecar_cdhash_after=""
if [[ -x "${sidecar}" ]]; then
sidecar_cdhash_before="$(codesign_cdhash "${sidecar}")"
fi
# Tauri --no-sign leaves the outer .app with no sealed resources, which
# fails strict bundle validation once Resources/icon.icns exists. Sign only
# the outer bundle: do not pass --deep, because re-signing claude-sidecar
# changes its code-signature hash and breaks existing macOS Keychain ACLs.
codesign --force --sign - --timestamp=none "${app_bundle}"
if [[ -x "${sidecar}" ]]; then
sidecar_cdhash_after="$(codesign_cdhash "${sidecar}")"
if [[ "${sidecar_cdhash_before}" != "${sidecar_cdhash_after}" ]]; then
echo "[build-macos-arm64] ERROR: sidecar signature hash changed while signing app bundle" >&2
echo "[build-macos-arm64] before=${sidecar_cdhash_before}" >&2
echo "[build-macos-arm64] after=${sidecar_cdhash_after}" >&2
exit 1
fi
fi
codesign --verify --deep --strict --verbose=2 "${app_bundle}"
}
if [[ -n "${LATEST_APP}" ]]; then
# Normalize the Tauri-produced app in place before copying it anywhere.
# Without this, opening target/.../bundle/macos/Claude Code Haha.app directly
# uses the executable's ad-hoc signing identifier instead of the app bundle id,
# which makes macOS notification authorization behave like a different app.
sign_canonical_app_bundle "${LATEST_APP}"
# 不要 deep re-sign。曾经脚本在这里跑过
# `codesign --force --deep --sign - --identifier <bundle-id>` 来统一
# sidecar 和外层的 signing identifier,但这会改变 sidecar binary 的
# code signature hash —— macOS Keychain ACL 按 hash 识别 caller,
# 重签完再访问时会被 ACL 当作"陌生 binary"静默拒绝,导致 CLI 读不到
# OAuth token,最终请求打到 Anthropic 返回 403 "Request not allowed"。
# 这里只浅签外层 bundle,让 .app 拥有有效资源封印,同时保留 sidecar hash。
cp -R "${LATEST_APP}" "${CANONICAL_OUTPUT_DIR}/"
sign_canonical_app_bundle "${CANONICAL_OUTPUT_DIR}/${APP_BUNDLE_NAME}"
rm -f "${CANONICAL_OUTPUT_DIR}/"*.dmg
CANONICAL_DMG="${CANONICAL_OUTPUT_DIR}/$(basename "${LATEST_DMG:-Claude Code Haha_0.1.0_aarch64.dmg}")"
build_canonical_dmg \
"${CANONICAL_OUTPUT_DIR}/${APP_BUNDLE_NAME}" \
"${CANONICAL_DMG}"
if [[ -n "${LATEST_DMG}" ]]; then
cp -f "${CANONICAL_DMG}" "${LATEST_DMG}"
fi
elif [[ -n "${LATEST_DMG}" ]]; then
cp -f "${LATEST_DMG}" "${CANONICAL_OUTPUT_DIR}/"
if [[ -d "${ELECTRON_OUTPUT_DIR}/mac-arm64" ]]; then
find "${ELECTRON_OUTPUT_DIR}/mac-arm64" -maxdepth 1 -type d -name '*.app' -exec cp -R {} "${CANONICAL_OUTPUT_DIR}/" \;
fi
find "${ELECTRON_OUTPUT_DIR}" -maxdepth 1 -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.blockmap' -o -name 'latest-mac.yml' \) -exec cp -f {} "${CANONICAL_OUTPUT_DIR}/" \;
cat > "${CANONICAL_OUTPUT_DIR}/BUILD_INFO.txt" <<EOF
Target triple: ${TARGET_TRIPLE}
Builder output: ${ELECTRON_OUTPUT_DIR}
Canonical output: ${CANONICAL_OUTPUT_DIR}
Canonical app: ${CANONICAL_OUTPUT_DIR}/${APP_BUNDLE_NAME}
Canonical DMG: ${CANONICAL_DMG:-not found}
Tauri app output: ${LATEST_APP:-not found}
Tauri DMG output: ${LATEST_DMG:-not found}
Built at: $(date '+%Y-%m-%d %H:%M:%S %z')
EOF
if [[ -d "${LEGACY_BUNDLE_ROOT}" ]]; then
rm -rf "${LEGACY_BUNDLE_ROOT}"
if [[ "${SKIP_PACKAGE_SMOKE:-0}" != "1" ]]; then
PACKAGE_SMOKE_ARGS=(bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/macos-arm64)
if [[ "${REQUIRE_MACOS_GATEKEEPER_SMOKE:-0}" == "1" ]]; then
PACKAGE_SMOKE_ARGS+=(--require-macos-gatekeeper)
fi
echo "[build-macos-arm64] Running package smoke..."
(cd "${REPO_ROOT}" && "${PACKAGE_SMOKE_ARGS[@]}")
fi
echo
echo "[build-macos-arm64] Build finished."
if [[ -n "${LATEST_APP}" ]]; then
echo "[build-macos-arm64] Tauri app output (identity normalized): ${LATEST_APP}"
else
echo "[build-macos-arm64] No .app found in ${TARGETED_APP_DIR} or ${FALLBACK_APP_DIR}" >&2
fi
if [[ -n "${LATEST_DMG}" ]]; then
echo "[build-macos-arm64] Tauri DMG output (replaced with canonical DMG): ${LATEST_DMG}"
else
echo "[build-macos-arm64] No DMG found in ${TARGETED_DMG_DIR} or ${FALLBACK_DMG_DIR}" >&2
fi
echo "[build-macos-arm64] Canonical output: ${CANONICAL_OUTPUT_DIR}"
echo "[build-macos-arm64] Removed legacy bundle dir: ${LEGACY_BUNDLE_ROOT}"
if [[ "${OPEN_OUTPUT:-0}" == "1" ]]; then
open "${CANONICAL_OUTPUT_DIR}"
+17 -21
View File
@@ -6,6 +6,7 @@ const repoRoot = path.resolve(desktopRoot, '..')
const binariesDir = path.join(desktopRoot, 'src-tauri', 'binaries')
const targetTriple =
process.env.SIDECAR_TARGET_TRIPLE ||
process.env.TAURI_ENV_TARGET_TRIPLE ||
process.env.CARGO_BUILD_TARGET ||
(await detectHostTriple())
@@ -27,8 +28,8 @@ if (scanExit !== 0) {
await mkdir(binariesDir, { recursive: true })
// 单一合并 sidecar:server / cli 共享一份 bun runtime + 共享依赖代码。
// 调用方(Tauri lib.rs / conversationService)通过第一个 positional 参数
// 选择 'server' 或 'cli' 模式,详见 desktop/sidecars/claude-sidecar.ts。
// 调用方(Electron sidecar manager / legacy Tauri host / conversationService)
// 通过第一个 positional 参数选择 'server' 或 'cli' 模式,详见 desktop/sidecars/claude-sidecar.ts。
await compileExecutable({
entrypoint: path.join(desktopRoot, 'sidecars/claude-sidecar.ts'),
outfileBase: path.join(binariesDir, `claude-sidecar-${targetTriple}`),
@@ -39,30 +40,25 @@ await compileExecutable({
console.log(`[build-sidecars] Built desktop sidecar for ${targetTriple} (${bunTarget})`)
async function detectHostTriple() {
const proc = Bun.spawn(['rustc', '-vV'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
})
const platform = process.platform
const arch = process.arch
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
if (exitCode !== 0) {
throw new Error(`[build-sidecars] rustc -vV failed: ${stderr || stdout}`)
if (platform === 'darwin') {
if (arch === 'arm64') return 'aarch64-apple-darwin'
if (arch === 'x64') return 'x86_64-apple-darwin'
}
const hostLine = stdout
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('host: '))
if (!hostLine) {
throw new Error('[build-sidecars] Could not detect Rust host triple')
if (platform === 'win32') {
if (arch === 'x64') return 'x86_64-pc-windows-msvc'
if (arch === 'arm64') return 'aarch64-pc-windows-msvc'
}
return hostLine.replace('host: ', '')
if (platform === 'linux') {
if (arch === 'x64') return 'x86_64-unknown-linux-gnu'
if (arch === 'arm64') return 'aarch64-unknown-linux-gnu'
}
throw new Error(`[build-sidecars] Unsupported host platform/arch: ${platform}/${arch}`)
}
function mapTargetTripleToBun(triple: string) {
+89 -185
View File
@@ -1,9 +1,14 @@
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$TauriArgs
[string[]]$BuilderArgs
)
# Environment:
# SKIP_INSTALL=1 Skip root/desktop dependency installation.
# REBUILD_NATIVE=1 Rebuild Electron native dependencies before packaging.
# SKIP_PACKAGE_SMOKE=1 Skip static package-smoke verification after copying artifacts.
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
@@ -12,10 +17,8 @@ $desktopDir = (Resolve-Path (Join-Path $scriptDir '..')).Path
$repoRoot = (Resolve-Path (Join-Path $desktopDir '..')).Path
$targetTriple = 'x86_64-pc-windows-msvc'
$tauriTargetDir = Join-Path $desktopDir 'src-tauri\target'
$canonicalOutputDir = Join-Path $desktopDir 'build-artifacts\windows-x64'
$activeOutputDir = $canonicalOutputDir
$appVersion = (Get-Content -Path (Join-Path $desktopDir 'src-tauri\tauri.conf.json') -Raw | ConvertFrom-Json).version
$electronOutputDir = Join-Path $desktopDir 'build-artifacts\electron'
function Write-Step {
param([string]$Message)
@@ -30,7 +33,6 @@ function Assert-WindowsHost {
function Assert-Command {
param([string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "[build-windows-x64] Missing required command: $Name"
}
@@ -49,7 +51,7 @@ function Import-VsDevEnvironment {
Select-Object -First 1
if (-not $installationPath) {
throw '[build-windows-x64] Missing Visual C++ build tools. Install the "Desktop development with C++" / VC.Tools.x86.x64 workload first.'
throw '[build-windows-x64] Missing Visual C++ build tools. Install the Desktop development with C++ workload first.'
}
$vsDevCmd = Join-Path $installationPath 'Common7\Tools\VsDevCmd.bat'
@@ -58,7 +60,6 @@ function Import-VsDevEnvironment {
}
Write-Step "Importing MSVC environment from $vsDevCmd"
$env:VSCMD_SKIP_SENDTELEMETRY = '1'
$envDump = & cmd.exe /d /s /c "`"$vsDevCmd`" -arch=x64 -host_arch=x64 >nul && set"
if ($LASTEXITCODE -ne 0) {
@@ -72,84 +73,18 @@ function Import-VsDevEnvironment {
}
}
function Get-RustCargoBinDir {
return Join-Path $env:USERPROFILE '.cargo\bin'
}
function Ensure-RustInPath {
$cargoBinDir = Get-RustCargoBinDir
if ((Test-Path $cargoBinDir) -and -not (($env:Path -split ';') -contains $cargoBinDir)) {
$env:Path = "$cargoBinDir;$env:Path"
function Clear-Directory {
param([string]$Path)
if (Test-Path $Path) {
Remove-Item -LiteralPath $Path -Recurse -Force
}
}
function Get-LatestArtifact {
param(
[string[]]$SearchRoots,
[string[]]$Patterns
)
foreach ($root in $SearchRoots) {
if (-not (Test-Path $root)) {
continue
}
foreach ($pattern in $Patterns) {
$match = Get-ChildItem -Path $root -File -Filter $pattern -ErrorAction SilentlyContinue |
Sort-Object Name |
Select-Object -Last 1
if ($match) {
return $match
}
}
}
return $null
}
function Get-StagedArtifactName {
param([string]$ArtifactName)
switch -Regex ($ArtifactName) {
'^latest\.json$' { return 'latest.json' }
'\.msi\.zip\.sig$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.zip.sig" }
'\.msi\.zip$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.zip" }
'\.msi\.sig$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi.sig" }
'\.msi$' { return "Claude-Code-Haha_${appVersion}_windows_x64_msi.msi" }
default { return $ArtifactName }
}
}
function Resolve-OutputDirectory {
param([string]$PreferredPath)
New-Item -ItemType Directory -Force -Path $PreferredPath | Out-Null
$existingArtifacts = Get-ChildItem -Path $PreferredPath -Force -ErrorAction SilentlyContinue
foreach ($artifact in $existingArtifacts) {
try {
Remove-Item -LiteralPath $artifact.FullName -Force -Recurse
} catch {
$fallbackPath = "$PreferredPath-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Write-Step "Could not clear locked artifact '$($artifact.FullName)'. Using fallback output directory: $fallbackPath"
New-Item -ItemType Directory -Force -Path $fallbackPath | Out-Null
return $fallbackPath
}
}
return $PreferredPath
New-Item -ItemType Directory -Force -Path $Path | Out-Null
}
Assert-WindowsHost
Assert-Command bun
Ensure-RustInPath
Import-VsDevEnvironment
Assert-Command cargo
Assert-Command rustc
Assert-Command bunx
Import-VsDevEnvironment
if ($env:SKIP_INSTALL -ne '1') {
Write-Step 'Installing root dependencies...'
@@ -173,126 +108,95 @@ if ($env:SKIP_INSTALL -ne '1') {
} finally {
Pop-Location
}
$adaptersDir = Join-Path $repoRoot 'adapters'
if (Test-Path (Join-Path $adaptersDir 'package.json')) {
Write-Step 'Installing adapter dependencies...'
Push-Location $adaptersDir
try {
& bun install
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] bun install failed in adapters (exit $LASTEXITCODE)"
}
} finally {
Pop-Location
}
}
}
$tauriBuildArgs = @(
'tauri',
'build',
'--target',
$targetTriple,
'--bundles',
'msi',
'--ci'
)
$tempConfigPath = $null
if (-not $env:TAURI_SIGNING_PRIVATE_KEY) {
$tempConfigPath = Join-Path ([System.IO.Path]::GetTempPath()) 'cc-haha.tauri.local.windows.json'
$tempConfig = @{
bundle = @{
createUpdaterArtifacts = $false
}
} | ConvertTo-Json -Depth 10
Set-Content -Path $tempConfigPath -Value $tempConfig -Encoding UTF8
Write-Step 'TAURI_SIGNING_PRIVATE_KEY not set, disabling updater artifacts for local build'
$tauriBuildArgs += @('--config', $tempConfigPath)
}
if ($null -ne $TauriArgs) {
$remainingArgs = @($TauriArgs)
if ($remainingArgs.Count -gt 0) {
$tauriBuildArgs += $remainingArgs
}
}
Write-Step "Building Windows desktop app for $targetTriple"
Write-Step 'Cleaning stale Electron outputs...'
Remove-Item -LiteralPath (Join-Path $desktopDir 'dist') -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath (Join-Path $desktopDir 'electron-dist') -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $electronOutputDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path (Join-Path $desktopDir 'src-tauri\binaries\claude-sidecar-*') -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath (Join-Path $desktopDir 'tsconfig.tsbuildinfo') -Force -ErrorAction SilentlyContinue
Write-Step "Building sidecars for $targetTriple..."
Push-Location $desktopDir
try {
$env:TAURI_ENV_TARGET_TRIPLE = $targetTriple
& bunx @tauriBuildArgs
$env:SIDECAR_TARGET_TRIPLE = $targetTriple
& bun run build:sidecars
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] tauri build failed (exit $LASTEXITCODE)"
throw "[build-windows-x64] build:sidecars failed (exit $LASTEXITCODE)"
}
Write-Step 'Building renderer and Electron main/preload bundles...'
& bun run build
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] renderer build failed (exit $LASTEXITCODE)"
}
& bun run build:electron
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] Electron build failed (exit $LASTEXITCODE)"
}
if ($env:REBUILD_NATIVE -eq '1') {
Write-Step 'Rebuilding native dependencies for Electron ABI...'
& bunx electron-builder install-app-deps
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] electron-builder install-app-deps failed (exit $LASTEXITCODE)"
}
& bun run prepare:node-pty
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] prepare:node-pty failed (exit $LASTEXITCODE)"
}
}
$args = @('electron-builder', '--win', 'nsis', '--x64', '--publish', 'never')
$remainingArgs = @($BuilderArgs)
if ($remainingArgs.Count -gt 0) {
$args += $remainingArgs
}
Write-Step 'Packaging Electron app...'
& bunx @args
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] electron-builder failed (exit $LASTEXITCODE)"
}
} finally {
Pop-Location
if ($tempConfigPath -and (Test-Path $tempConfigPath)) {
Remove-Item -LiteralPath $tempConfigPath -Force
}
}
$activeOutputDir = Resolve-OutputDirectory -PreferredPath $canonicalOutputDir
Clear-Directory -Path $canonicalOutputDir
$bundleRoots = @(
(Join-Path $tauriTargetDir "$targetTriple\release\bundle"),
(Join-Path $tauriTargetDir 'release\bundle')
)
Get-ChildItem -Path $electronOutputDir -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '\.(exe|blockmap|yml)$' } |
ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $canonicalOutputDir $_.Name) -Force }
$artifactPatterns = @('*.msi', '*.msi.sig', '*.msi.zip', '*.msi.zip.sig', 'latest.json')
$copiedArtifacts = New-Object System.Collections.Generic.List[string]
foreach ($root in $bundleRoots) {
if (-not (Test-Path $root)) {
continue
}
foreach ($pattern in $artifactPatterns) {
$artifacts = Get-ChildItem -Path $root -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue
foreach ($artifact in $artifacts) {
$destinationName = Get-StagedArtifactName -ArtifactName $artifact.Name
$destination = Join-Path $activeOutputDir $destinationName
Copy-Item -LiteralPath $artifact.FullName -Destination $destination -Force
if (-not $copiedArtifacts.Contains($destination)) {
$copiedArtifacts.Add($destination) | Out-Null
}
}
}
}
$msiInstaller = Get-LatestArtifact -SearchRoots @(
(Join-Path $tauriTargetDir "$targetTriple\release\bundle\msi"),
(Join-Path $tauriTargetDir 'release\bundle\msi')
) -Patterns @('*.msi')
$msiInstallerPath = if ($msiInstaller) { $msiInstaller.FullName } else { 'not found' }
$buildInfo = @(
"App version: $appVersion"
"Target triple: $targetTriple"
"Canonical output: $canonicalOutputDir"
"Actual output: $activeOutputDir"
"Windows installer (MSI): $msiInstallerPath"
"Artifacts copied: $($copiedArtifacts.Count)"
"Built at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
)
Set-Content -Path (Join-Path $activeOutputDir 'BUILD_INFO.txt') -Value $buildInfo -Encoding UTF8
Write-Host ''
Write-Step 'Build finished.'
Write-Step "Artifacts output: $activeOutputDir"
if ($msiInstaller) {
Write-Step "MSI installer source: $($msiInstaller.FullName)"
$winUnpackedDir = Join-Path $electronOutputDir 'win-unpacked'
if (Test-Path $winUnpackedDir) {
Copy-Item -LiteralPath $winUnpackedDir -Destination (Join-Path $canonicalOutputDir 'win-unpacked') -Recurse -Force
} else {
Write-Step 'No MSI installer found under bundle directories.'
Write-Step "Warning: win-unpacked was not found under $electronOutputDir; package-smoke will fail if it is required."
}
Set-Content -Path (Join-Path $canonicalOutputDir 'BUILD_INFO.txt') -Value @"
Target triple: $targetTriple
Builder output: $electronOutputDir
Canonical output: $canonicalOutputDir
Built at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')
"@ -Encoding UTF8
if ($env:SKIP_PACKAGE_SMOKE -eq '1') {
Write-Step 'Skipping package-smoke because SKIP_PACKAGE_SMOKE=1.'
} else {
Write-Step 'Running package-smoke against canonical Windows artifacts...'
Push-Location $repoRoot
try {
& bun run test:package-smoke --platform windows --package-kind release --artifacts-dir desktop/build-artifacts/windows-x64
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] package-smoke failed (exit $LASTEXITCODE)"
}
} finally {
Pop-Location
}
}
Write-Step 'Build finished.'
Write-Step "Canonical output: $canonicalOutputDir"
if ($env:OPEN_OUTPUT -eq '1') {
Invoke-Item $canonicalOutputDir
}
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
CACHE_ROOT="${ELECTRON_BUILDER_CACHE:-${HOME}/Library/Caches/electron-builder}"
DMG_CACHE_DIR="${CACHE_ROOT}/dmg-builder@1.2.0"
has_files() {
local dir="$1"
find "${dir}" -mindepth 1 \( -type f -o -type l \) -print -quit 2>/dev/null | grep -q .
}
if [[ ! -d "${DMG_CACHE_DIR}" ]]; then
exit 0
fi
while IFS= read -r bundle_dir; do
if [[ -d "${bundle_dir}" ]] && ! has_files "${bundle_dir}"; then
echo "[clean-dmg-builder-cache] Removing empty cache dir: ${bundle_dir}"
rm -rf "${bundle_dir}"
fi
done < <(find "${DMG_CACHE_DIR}" -mindepth 1 -maxdepth 1 -type d -name 'dmgbuild-bundle-*' 2>/dev/null)
if ! has_files "${DMG_CACHE_DIR}"; then
echo "[clean-dmg-builder-cache] Removing empty release cache: ${DMG_CACHE_DIR}"
rm -rf "${DMG_CACHE_DIR}"
fi
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bun
import { rm } from 'node:fs/promises'
import path from 'node:path'
const desktopRoot = path.resolve(import.meta.dir, '..')
const electronOutputDir = path.join(desktopRoot, 'build-artifacts', 'electron')
await rm(electronOutputDir, { recursive: true, force: true })
console.log(`[clean-electron-output] removed ${electronOutputDir}`)
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { createElectronDevEnv, DEFAULT_RENDERER_URL, mergeNoProxy } from './electron-dev'
describe('desktop dev launcher environment', () => {
it('uses localhost and bypasses proxies for local renderer startup', () => {
const env = createElectronDevEnv({
HTTPS_PROXY: 'http://proxy.example',
NO_PROXY: 'example.com',
})
expect(env.ELECTRON_RENDERER_URL).toBe(DEFAULT_RENDERER_URL)
expect(env.NO_PROXY).toBe('example.com,localhost,127.0.0.1,::1')
expect(env.no_proxy).toBe(env.NO_PROXY)
})
it('preserves explicit renderer URL while adding missing local no_proxy entries', () => {
const env = createElectronDevEnv({
ELECTRON_RENDERER_URL: 'http://localhost:1777',
no_proxy: 'localhost',
})
expect(env.ELECTRON_RENDERER_URL).toBe('http://localhost:1777')
expect(env.NO_PROXY).toBe('localhost,127.0.0.1,::1')
expect(env.no_proxy).toBe(env.NO_PROXY)
})
it('deduplicates no_proxy entries', () => {
expect(mergeNoProxy('localhost,127.0.0.1')).toBe('localhost,127.0.0.1,::1')
})
})
+82
View File
@@ -0,0 +1,82 @@
export const DEFAULT_RENDERER_URL = 'http://localhost:1420'
export const LOCAL_NO_PROXY_ENTRIES = ['localhost', '127.0.0.1', '::1']
export function mergeNoProxy(existing: string | undefined, required = LOCAL_NO_PROXY_ENTRIES) {
const entries = new Set(
(existing ?? '')
.split(',')
.map(entry => entry.trim())
.filter(Boolean),
)
for (const entry of required) entries.add(entry)
return Array.from(entries).join(',')
}
export function createElectronDevEnv(env: NodeJS.ProcessEnv = process.env) {
const rendererUrl = env.ELECTRON_RENDERER_URL ?? DEFAULT_RENDERER_URL
const noProxy = mergeNoProxy(env.NO_PROXY ?? env.no_proxy)
return {
...env,
ELECTRON_RENDERER_URL: rendererUrl,
NO_PROXY: noProxy,
no_proxy: noProxy,
}
}
async function waitForRenderer(rendererUrl: string) {
const deadline = Date.now() + 30_000
while (Date.now() < deadline) {
try {
const response = await fetch(rendererUrl)
if (response.ok) return
} catch {
await Bun.sleep(250)
}
}
throw new Error(`Timed out waiting for Vite renderer at ${rendererUrl}`)
}
async function main() {
const desktopRoot = new URL('..', import.meta.url).pathname
const childEnv = createElectronDevEnv()
const rendererUrl = childEnv.ELECTRON_RENDERER_URL
process.env.NO_PROXY = childEnv.NO_PROXY
process.env.no_proxy = childEnv.no_proxy
const vite = Bun.spawn(['bun', 'run', 'dev'], {
cwd: desktopRoot,
env: childEnv,
stdout: 'inherit',
stderr: 'inherit',
})
function stopVite() {
vite.kill()
}
process.on('SIGINT', () => {
stopVite()
process.exit(130)
})
process.on('SIGTERM', () => {
stopVite()
process.exit(143)
})
await waitForRenderer(rendererUrl)
const electron = Bun.spawn(['bunx', 'electron', './electron-dist/main.cjs'], {
cwd: desktopRoot,
env: childEnv,
stdout: 'inherit',
stderr: 'inherit',
})
const exitCode = await electron.exited
stopVite()
process.exit(exitCode)
}
if (import.meta.main) {
await main()
}
+41
View File
@@ -0,0 +1,41 @@
import fs from 'node:fs'
import path from 'node:path'
const desktopDir = path.resolve(import.meta.dirname, '..')
const prebuildsDir = path.join(desktopDir, 'node_modules', 'node-pty', 'prebuilds')
function ensureExecutable(filePath: string): boolean {
const stat = fs.statSync(filePath)
if (!stat.isFile()) return false
const executableMode = stat.mode | 0o755
if ((stat.mode & 0o777) !== (executableMode & 0o777)) {
fs.chmodSync(filePath, executableMode)
return true
}
return false
}
if (!fs.existsSync(prebuildsDir)) {
throw new Error(`node-pty prebuilds directory is missing: ${prebuildsDir}`)
}
let helpersSeen = 0
let helpersChanged = 0
for (const platformDir of fs.readdirSync(prebuildsDir)) {
const helperPath = path.join(prebuildsDir, platformDir, 'spawn-helper')
if (!fs.existsSync(helperPath)) continue
helpersSeen += 1
if (ensureExecutable(helperPath)) {
helpersChanged += 1
}
}
if (process.platform === 'darwin' && helpersSeen === 0) {
throw new Error(`node-pty spawn-helper is missing under ${prebuildsDir}`)
}
console.log(`[prepare-node-pty] spawn-helper executable bits verified (${helpersChanged}/${helpersSeen} updated)`)
File diff suppressed because one or more lines are too long
+101 -6
View File
@@ -222,6 +222,7 @@ mod webview_panel;
const SERVER_STARTUP_LOG_LIMIT: usize = 80;
const SERVER_BIND_HOST: &str = "0.0.0.0";
const SERVER_CONTROL_HOST: &str = "127.0.0.1";
const CLAUDE_CODE_POWERSHELL_PATH_ENV: &str = "CLAUDE_CODE_POWERSHELL_PATH";
const MAIN_WINDOW_LABEL: &str = "main";
const TRAY_SHOW_ID: &str = "tray_show";
const TRAY_QUIT_ID: &str = "tray_quit";
@@ -1386,6 +1387,11 @@ fn resolved_terminal_shell(app: &AppHandle) -> Result<String, String> {
Ok(override_shell.unwrap_or(system_default))
}
fn read_agent_powershell_path_override() -> Option<String> {
let configured = read_desktop_terminal_config();
resolve_agent_powershell_path_override(current_terminal_host_platform(), configured.as_ref())
}
fn current_terminal_host_platform() -> TerminalHostPlatform {
#[cfg(target_os = "windows")]
{
@@ -1432,6 +1438,42 @@ fn resolve_desktop_terminal_shell(
}
}
fn is_powershell_executable_path(path: &str) -> bool {
let trimmed = path.trim();
if trimmed.is_empty() {
return false;
}
let file_name = trimmed.rsplit(['/', '\\']).next().unwrap_or(trimmed);
let lowercase = file_name.to_ascii_lowercase();
let base = lowercase.strip_suffix(".exe").unwrap_or(&lowercase);
matches!(base, "pwsh" | "powershell")
}
fn resolve_agent_powershell_path_override(
platform: TerminalHostPlatform,
config: Option<&DesktopTerminalConfig>,
) -> Option<String> {
if platform != TerminalHostPlatform::Windows {
return None;
}
let startup_shell = config?.startup_shell.as_deref()?.trim();
match startup_shell {
"pwsh" => Some("pwsh.exe".to_string()),
"powershell" => Some("powershell.exe".to_string()),
"custom" => {
let custom_path = config?.custom_shell_path.as_deref()?.trim();
if is_powershell_executable_path(custom_path) {
Some(custom_path.to_string())
} else {
None
}
}
_ => None,
}
}
fn normalize_terminal_bash_path(path: Option<String>) -> Result<Option<String>, String> {
let Some(path) = path else {
return Ok(None);
@@ -1593,6 +1635,9 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
for (key, value) in terminal_environment(&default_shell(None)) {
sidecar = sidecar.env(key, value);
}
if let Some(powershell_path) = read_agent_powershell_path_override() {
sidecar = sidecar.env(CLAUDE_CODE_POWERSHELL_PATH_ENV, powershell_path);
}
// Pass through CLAUDE_CONFIG_DIR so the sidecar (Node.js) uses the same
// portable config directory. Also set XDG_CACHE_HOME to redirect the
// env-paths cache from %LOCALAPPDATA%\claude-cli-nodejs\ to alongside
@@ -1920,12 +1965,11 @@ fn kill_windows_sidecars() {
#[cfg(test)]
mod tests {
use super::{
decode_terminal_output, default_utf8_locale, ensure_utf8_locale,
dir_has_portable_data, has_meaningful_intersection, is_persistable_window_state,
normalize_terminal_bash_path, parse_env_block, resolve_desktop_terminal_shell,
resolve_terminal_cwd, run_notification_bridge,
select_h5_dist_dir, DesktopTerminalConfig, StoredWindowState, TerminalHostPlatform,
SERVER_BIND_HOST, SERVER_CONTROL_HOST,
decode_terminal_output, default_utf8_locale, dir_has_portable_data, ensure_utf8_locale,
has_meaningful_intersection, is_persistable_window_state, normalize_terminal_bash_path,
parse_env_block, resolve_agent_powershell_path_override, resolve_desktop_terminal_shell,
resolve_terminal_cwd, run_notification_bridge, select_h5_dist_dir, DesktopTerminalConfig,
StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST, SERVER_CONTROL_HOST,
};
use std::{collections::HashMap, fs};
@@ -2177,6 +2221,57 @@ mod tests {
);
}
#[test]
fn agent_powershell_override_uses_windows_power_shell_preferences() {
let pwsh = DesktopTerminalConfig {
startup_shell: Some("pwsh".to_string()),
custom_shell_path: None,
};
assert_eq!(
resolve_agent_powershell_path_override(TerminalHostPlatform::Windows, Some(&pwsh)),
Some("pwsh.exe".to_string())
);
let powershell = DesktopTerminalConfig {
startup_shell: Some("powershell".to_string()),
custom_shell_path: None,
};
assert_eq!(
resolve_agent_powershell_path_override(
TerminalHostPlatform::Windows,
Some(&powershell),
),
Some("powershell.exe".to_string())
);
}
#[test]
fn agent_powershell_override_accepts_only_custom_power_shell_paths() {
let custom_pwsh = DesktopTerminalConfig {
startup_shell: Some("custom".to_string()),
custom_shell_path: Some(r"C:\Program Files\PowerShell\7\pwsh.exe".to_string()),
};
assert_eq!(
resolve_agent_powershell_path_override(
TerminalHostPlatform::Windows,
Some(&custom_pwsh),
),
Some(r"C:\Program Files\PowerShell\7\pwsh.exe".to_string())
);
let custom_bash = DesktopTerminalConfig {
startup_shell: Some("custom".to_string()),
custom_shell_path: Some(r"C:\Program Files\Git\bin\bash.exe".to_string()),
};
assert_eq!(
resolve_agent_powershell_path_override(
TerminalHostPlatform::Windows,
Some(&custom_bash),
),
None
);
}
#[test]
fn server_sidecar_binds_lan_but_reports_loopback_control_url() {
assert_eq!(SERVER_BIND_HOST, "0.0.0.0");
+10 -1
View File
@@ -4,6 +4,14 @@ use tauri::{AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, Runtime,
const PREVIEW_LABEL: &str = "preview";
const PREVIEW_INIT_SCRIPT: &str = include_str!("../resources/preview-agent.js");
const PREVIEW_TAURI_BRIDGE_SCRIPT: &str = r#"
window.__DESKTOP_PREVIEW_POST__ = function(raw) {
var internals = window.__TAURI_INTERNALS__;
if (internals && typeof internals.invoke === 'function') {
internals.invoke('preview_message', { raw: raw });
}
};
"#;
#[derive(Default)]
pub struct PreviewState(Mutex<PreviewInner>);
@@ -61,8 +69,9 @@ pub async fn preview_open<R: Runtime>(
let main = app
.get_window(crate::MAIN_WINDOW_LABEL)
.ok_or_else(|| "main window not found".to_string())?;
let init_script = format!("{PREVIEW_TAURI_BRIDGE_SCRIPT}\n{PREVIEW_INIT_SCRIPT}");
let builder = WebviewBuilder::new(PREVIEW_LABEL, WebviewUrl::External(target))
.initialization_script_for_all_frames(PREVIEW_INIT_SCRIPT);
.initialization_script_for_all_frames(init_script);
main.add_child(
builder,
LogicalPosition::new(bounds.x, bounds.y),
+1 -1
View File
@@ -625,7 +625,7 @@ describe('Content-only pages render without errors', () => {
expect(screen.getByText('Slash commands')).toBeInTheDocument()
expect(screen.getByText('/clear')).toBeInTheDocument()
expect(screen.getByText('/cost')).toBeInTheDocument()
expect(screen.getByText('14 more commands available. Type / to search the full command list.')).toBeInTheDocument()
expect(screen.getByText('15 more commands available. Type / to search the full command list.')).toBeInTheDocument()
resetPageStores()
})
+88
View File
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { browserHost } from '../lib/desktopHost/browserHost'
describe('terminalApi desktop host bridge', () => {
beforeEach(() => {
vi.resetModules()
Reflect.deleteProperty(window, 'desktopHost')
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
Reflect.deleteProperty(window, '__TAURI__')
})
afterEach(() => {
Reflect.deleteProperty(window, 'desktopHost')
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
Reflect.deleteProperty(window, '__TAURI__')
})
it('routes terminal commands through an injected desktop host', async () => {
const spawn = vi.fn().mockResolvedValue({
session_id: 9,
shell: '/bin/zsh',
cwd: '/tmp/project',
})
const write = vi.fn().mockResolvedValue(undefined)
const resize = vi.fn().mockResolvedValue(undefined)
const kill = vi.fn().mockResolvedValue(undefined)
const onOutput = vi.fn().mockResolvedValue(() => {})
const onExit = vi.fn().mockResolvedValue(() => {})
const getBashPath = vi.fn().mockResolvedValue('/bin/bash')
const setBashPath = vi.fn().mockResolvedValue(undefined)
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
terminal: true,
},
terminal: {
spawn,
write,
resize,
kill,
onOutput,
onExit,
getBashPath,
setBashPath,
},
}
const { terminalApi } = await import('./terminal')
const outputHandler = vi.fn()
const exitHandler = vi.fn()
await expect(terminalApi.spawn({ cols: 80, rows: 24, cwd: '/tmp/project' })).resolves.toEqual({
session_id: 9,
shell: '/bin/zsh',
cwd: '/tmp/project',
})
await terminalApi.write(9, 'ls\n')
await terminalApi.resize(9, 100, 30)
await terminalApi.kill(9)
await terminalApi.onOutput(outputHandler)
await terminalApi.onExit(exitHandler)
await expect(terminalApi.getBashPath()).resolves.toBe('/bin/bash')
await terminalApi.setBashPath('/opt/bash')
expect(terminalApi.isAvailable()).toBe(true)
expect(spawn).toHaveBeenCalledWith({ cols: 80, rows: 24, cwd: '/tmp/project' })
expect(write).toHaveBeenCalledWith(9, 'ls\n')
expect(resize).toHaveBeenCalledWith(9, 100, 30)
expect(kill).toHaveBeenCalledWith(9)
expect(onOutput).toHaveBeenCalledWith(outputHandler)
expect(onExit).toHaveBeenCalledWith(exitHandler)
expect(setBashPath).toHaveBeenCalledWith('/opt/bash')
})
it('keeps terminal unavailable in browser fallback', async () => {
const { terminalApi } = await import('./terminal')
expect(terminalApi.isAvailable()).toBe(false)
expect(() => terminalApi.spawn({ cols: 80, rows: 24 })).toThrow(
'Terminal is available in the desktop app runtime.',
)
})
})
+14 -16
View File
@@ -1,4 +1,4 @@
import { isTauriRuntime } from '../lib/desktopRuntime'
import { getDesktopHost } from '../lib/desktopHost'
export type TerminalSpawnResult = {
session_id: number
@@ -19,48 +19,46 @@ export type TerminalExitPayload = {
type Unlisten = () => void
async function invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
if (!isTauriRuntime()) {
function getTerminalHost() {
const host = getDesktopHost()
if (!host.capabilities.terminal) {
throw new Error('Terminal is available in the desktop app runtime.')
}
const api = await import('@tauri-apps/api/core')
return api.invoke<T>(command, args)
return host.terminal
}
export const terminalApi = {
isAvailable: isTauriRuntime,
isAvailable: () => getDesktopHost().capabilities.terminal,
spawn(input: { cols: number; rows: number; cwd?: string }) {
return invoke<TerminalSpawnResult>('terminal_spawn', input)
return getTerminalHost().spawn(input)
},
write(sessionId: number, data: string) {
return invoke<void>('terminal_write', { sessionId, data })
return getTerminalHost().write(sessionId, data)
},
resize(sessionId: number, cols: number, rows: number) {
return invoke<void>('terminal_resize', { sessionId, cols, rows })
return getTerminalHost().resize(sessionId, cols, rows)
},
kill(sessionId: number) {
return invoke<void>('terminal_kill', { sessionId })
return getTerminalHost().kill(sessionId)
},
async onOutput(handler: (payload: TerminalOutputPayload) => void): Promise<Unlisten> {
const events = await import('@tauri-apps/api/event')
return events.listen<TerminalOutputPayload>('terminal-output', (event) => handler(event.payload))
return getTerminalHost().onOutput(handler)
},
async onExit(handler: (payload: TerminalExitPayload) => void): Promise<Unlisten> {
const events = await import('@tauri-apps/api/event')
return events.listen<TerminalExitPayload>('terminal-exit', (event) => handler(event.payload))
return getTerminalHost().onExit(handler)
},
getBashPath() {
return invoke<string | null>('get_terminal_bash_path', undefined)
return getTerminalHost().getBashPath()
},
setBashPath(path: string | null) {
return invoke<void>('set_terminal_bash_path', { path })
return getTerminalHost().setBashPath(path)
},
}
@@ -10,7 +10,7 @@ beforeAll(() => {
})
const { bridge } = vi.hoisted(() => ({
bridge: { open: vi.fn(), navigate: vi.fn(), setBounds: vi.fn(), setVisible: vi.fn(), close: vi.fn(), eval: vi.fn() },
bridge: { open: vi.fn(), navigate: vi.fn(), setBounds: vi.fn(), setVisible: vi.fn(), close: vi.fn(), message: vi.fn() },
}))
vi.mock('../../lib/previewBridge', () => ({ previewBridge: bridge }))
vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {}) }))
@@ -70,11 +70,11 @@ describe('BrowserSurface', () => {
expect(bridge.close).toHaveBeenCalled()
})
it('截图 button triggers a capture via preview_eval', () => {
it('截图 button triggers a structured capture message', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
render(<BrowserSurface sessionId="s1" />)
fireEvent.click(screen.getByLabelText('截图'))
expect(bridge.eval).toHaveBeenCalledWith(expect.stringContaining('capture'))
expect(bridge.message).toHaveBeenCalledWith({ v: 1, type: 'capture', kind: 'full' })
})
it('places preview action buttons on the right side of the address toolbar', () => {
@@ -93,10 +93,10 @@ describe('BrowserSurface', () => {
render(<BrowserSurface sessionId="s1" />)
fireEvent.click(screen.getByLabelText('选择元素'))
expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(true)
expect(bridge.eval).toHaveBeenCalledWith(expect.stringContaining('enter-picker'))
expect(bridge.message).toHaveBeenCalledWith({ v: 1, type: 'enter-picker' })
fireEvent.click(screen.getByLabelText('选择元素'))
expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(false)
expect(bridge.eval).toHaveBeenLastCalledWith(expect.stringContaining('exit-picker'))
expect(bridge.message).toHaveBeenLastCalledWith({ v: 1, type: 'exit-picker' })
})
it('renders the loading indicator while the session is loading (open starts loading)', () => {
@@ -101,7 +101,7 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
'border-transparent text-[var(--color-text-secondary)] hover:border-[var(--color-border)]',
'hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-text-primary)]',
].join(' ')}
onClick={() => previewBridge.eval(`window.__PREVIEW_BRIDGE__?.handleHostRaw('{"v":1,"type":"capture","kind":"full"}')`)}
onClick={() => previewBridge.message({ v: 1, type: 'capture', kind: 'full' })}
>
<Camera size={16} />
</button>
@@ -119,7 +119,7 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
const cur = useBrowserPanelStore.getState().bySession[sessionId]
const next = !cur?.pickerActive
store.setPicker(sessionId, next)
previewBridge.eval(`window.__PREVIEW_BRIDGE__?.handleHostRaw('{"v":1,"type":"${next ? 'enter-picker' : 'exit-picker'}"}')`)
previewBridge.message({ v: 1, type: next ? 'enter-picker' : 'exit-picker' })
}}
>
<MousePointer2 size={16} />
@@ -7,6 +7,7 @@ import { InlineVideoGallery } from './InlineVideoGallery'
import { AssistantOutputTargetCard } from './AssistantOutputTargetCard'
import { handlePreviewLink } from '../../lib/handlePreviewLink'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { getDesktopHost } from '../../lib/desktopHost'
import { extractAssistantOutputTargets } from '../../lib/assistantOutputTargets'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
@@ -37,8 +38,7 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
void useWorkspacePanelStore.getState().openPreview(id, path, 'file')
},
openExternal: (url) => {
void import('@tauri-apps/plugin-shell')
.then((m) => m.open(url))
void getDesktopHost().shell.open(url)
.catch(() => window.open(url, '_blank'))
},
})
@@ -8,6 +8,7 @@ import { buildOpenWithItems, describeFileType, type OpenWithItem } from '../../l
import { openWithContextForHref } from '../../lib/openWithContextForHref'
import { handlePreviewLink } from '../../lib/handlePreviewLink'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { getDesktopHost } from '../../lib/desktopHost'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
@@ -45,8 +46,7 @@ export function AssistantOutputTargetCard({ target, sessionId, workDir }: Props)
void useWorkspacePanelStore.getState().openPreview(id, path, 'file')
},
openExternal: (url) => {
void import('@tauri-apps/plugin-shell')
.then((m) => m.open(url))
void getDesktopHost().shell.open(url)
.catch(() => window.open(url, '_blank'))
},
})
@@ -74,7 +74,7 @@ export function AssistantOutputTargetCard({ target, sessionId, workDir }: Props)
if (!ctx) return
const items = buildOpenWithItems(ctx, targets, {
openInAppBrowser: (url) => useBrowserPanelStore.getState().open(sessionId, url),
openSystem: (p) => { void import('@tauri-apps/plugin-shell').then((m) => m.open(p)).catch(() => window.open(p, '_blank')) },
openSystem: (p) => { void getDesktopHost().shell.openPath(p).catch(() => window.open(p, '_blank')) },
openWorkspacePreview: (relPath) => { void useWorkspacePanelStore.getState().openPreview(sessionId, relPath, 'file') },
openTarget: (id, abs) => { void useOpenTargetStore.getState().openTarget(id, abs) },
t: (k, v) => t(k as TranslationKey, v),
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
getMessages: vi.fn(),
getGitInfo: vi.fn(),
getSlashCommands: vi.fn(),
listAgents: vi.fn(),
getRepositoryContext: vi.fn(),
getRecentProjects: vi.fn(),
search: vi.fn(),
@@ -37,6 +38,12 @@ vi.mock('../../api/sessions', () => ({
},
}))
vi.mock('../../api/agents', () => ({
agentsApi: {
list: mocks.listAgents,
},
}))
vi.mock('../../api/filesystem', () => ({
filesystemApi: {
search: mocks.search,
@@ -185,6 +192,7 @@ describe('ChatInput file mentions', () => {
mocks.list.mockResolvedValue({ sessions: [], total: 0 })
mocks.getMessages.mockResolvedValue({ messages: [] })
mocks.getSlashCommands.mockResolvedValue({ commands: [] })
mocks.listAgents.mockResolvedValue({ activeAgents: [], allAgents: [] })
})
it('keeps unsent composer drafts isolated when switching between session tabs', async () => {
@@ -316,6 +324,72 @@ describe('ChatInput file mentions', () => {
})
})
it('appends a delayed browser screenshot without clearing an unsent draft after remount', async () => {
const { unmount } = render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'draft written while the agent is still running', selectionStart: 44 },
})
expect(input.value).toBe('draft written while the agent is still running')
unmount()
act(() => {
useChatStore.getState().queueComposerPrefill(sessionId, {
text: '',
mode: 'append',
attachments: [{
type: 'image',
name: 'screenshot-full.png',
mimeType: 'image/png',
data: 'data:image/png;base64,DELAYED',
}],
})
})
render(<ChatInput compact />)
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveValue('draft written while the agent is still running')
expect(screen.getByAltText('screenshot-full.png')).toBeInTheDocument()
})
})
it('does not replay a handled browser screenshot after the composer remounts', async () => {
const { unmount } = render(<ChatInput compact />)
act(() => {
useChatStore.getState().queueComposerPrefill(sessionId, {
text: '',
mode: 'append',
attachments: [{
type: 'image',
name: 'screenshot-full.png',
mimeType: 'image/png',
data: 'data:image/png;base64,OLD',
}],
})
})
await waitFor(() => {
expect(screen.getByAltText('screenshot-full.png')).toBeInTheDocument()
})
fireEvent.click(screen.getByLabelText('Remove screenshot-full.png'))
await waitFor(() => {
expect(screen.queryByAltText('screenshot-full.png')).not.toBeInTheDocument()
})
unmount()
render(<ChatInput compact />)
await waitFor(() => {
expect(screen.queryByAltText('screenshot-full.png')).not.toBeInTheDocument()
})
})
it('shows branch and worktree launch controls for an empty active Git session', async () => {
useSessionStore.setState({
sessions: [{
@@ -990,4 +1064,35 @@ describe('ChatInput file mentions', () => {
expect(commandButtons[0]).toHaveTextContent('/superpowers:brainstorming')
})
})
it('offers active agents as slash entries that insert /agent with the selected type', async () => {
mocks.listAgents.mockResolvedValue({
activeAgents: [
{
agentType: 'debugger',
description: 'Debug failures',
modelDisplay: 'OPUS',
source: 'userSettings',
isActive: true,
},
],
allAgents: [],
})
render(<ChatInput />)
await waitFor(() => {
expect(mocks.listAgents).toHaveBeenCalledWith('/repo')
})
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: '/debug', selectionStart: 6 },
})
const agentOption = await screen.findByText('/agent debugger')
fireEvent.click(agentOption)
expect(input).toHaveValue('/agent debugger ')
})
})
+65 -25
View File
@@ -13,6 +13,7 @@ import {
type WorkspaceChatReference,
} from '../../stores/workspaceChatContextStore'
import { sessionsApi, type SessionGitInfo } from '../../api/sessions'
import { agentsApi } from '../../api/agents'
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
import { ModelSelector } from '../controls/ModelSelector'
import type { AttachmentRef } from '../../types/chat'
@@ -24,6 +25,8 @@ import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel'
import { ContextUsageIndicator } from './ContextUsageIndicator'
import {
appendAgentSlashCommands,
buildAgentSlashCommands,
getLocalizedFallbackCommands,
filterSlashCommands,
findSlashTrigger,
@@ -32,7 +35,7 @@ import {
resolveSlashUiAction,
} from './composerUtils'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isTauriRuntime } from '../../lib/desktopRuntime'
import { isDesktopRuntime } from '../../lib/desktopRuntime'
import {
filesToComposerAttachments,
selectNativeFileAttachments,
@@ -83,7 +86,7 @@ function insertComposerTokenAtRange(value: string, start: number, end: number, t
export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) {
const t = useTranslation()
const isMobileComposer = useMobileViewport() && !isTauriRuntime()
const isMobileComposer = useMobileViewport() && !isDesktopRuntime()
const [input, setInput] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
@@ -94,6 +97,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const [atCursorPos, setAtCursorPos] = useState(-1)
const [slashFilter, setSlashFilter] = useState('')
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
const [agentSlashCommands, setAgentSlashCommands] = useState<ReturnType<typeof buildAgentSlashCommands>>([])
const [launchWorkDir, setLaunchWorkDir] = useState('')
const [launchBranch, setLaunchBranch] = useState<string | null>(null)
const [launchUseWorktree, setLaunchUseWorktree] = useState(false)
@@ -121,7 +125,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
return next
})
}, [])
const { sendMessage, stopGeneration, clearComposerInsertion } = useChatStore()
const { sendMessage, stopGeneration, clearComposerPrefill, clearComposerInsertion } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const chatState = sessionState?.chatState ?? 'idle'
@@ -230,21 +234,25 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
}, [isActive])
useEffect(() => {
if (!composerPrefill) return
if (!composerPrefill || !activeTabId) return
setComposerInput(composerPrefill.text)
setComposerAttachments(
(composerPrefill.attachments ?? [])
.filter((attachment) => attachment.type === 'image' || attachment.data)
.map((attachment, index) => ({
id: `rewind-prefill-${composerPrefill.nonce}-${index}`,
name: attachment.name,
type: attachment.type,
mimeType: attachment.mimeType,
previewUrl: attachment.type === 'image' ? attachment.data : undefined,
data: attachment.data,
})),
)
const nextAttachments = (composerPrefill.attachments ?? [])
.filter((attachment) => attachment.type === 'image' || attachment.data)
.map((attachment, index) => ({
id: `composer-prefill-${composerPrefill.nonce}-${index}`,
name: attachment.name,
type: attachment.type,
mimeType: attachment.mimeType,
previewUrl: attachment.type === 'image' ? attachment.data : undefined,
data: attachment.data,
}))
if (composerPrefill.mode === 'append') {
setComposerAttachments((previous) => [...previous, ...nextAttachments])
} else {
setComposerInput(composerPrefill.text)
setComposerAttachments(nextAttachments)
}
setPlusMenuOpen(false)
setSlashMenuOpen(false)
setFileSearchOpen(false)
@@ -255,10 +263,19 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
requestAnimationFrame(() => {
const el = textareaRef.current
el?.focus()
const cursor = composerPrefill.text.length
el?.setSelectionRange(cursor, cursor)
if (composerPrefill.mode !== 'append') {
const cursor = composerPrefill.text.length
el?.setSelectionRange(cursor, cursor)
}
})
}, [composerPrefill, setComposerAttachments, setComposerInput])
clearComposerPrefill(activeTabId, composerPrefill.nonce)
}, [
activeTabId,
clearComposerPrefill,
composerPrefill,
setComposerAttachments,
setComposerInput,
])
useEffect(() => {
if (!composerInsertion || !activeTabId || isMemberSession) return
@@ -322,6 +339,27 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
setFileSearchOpen(false)
}, [isMemberSession, activeTabId])
useEffect(() => {
if (isMemberSession) {
setAgentSlashCommands([])
return
}
let cancelled = false
agentsApi.list(resolvedWorkDir)
.then(({ activeAgents }) => {
if (cancelled) return
setAgentSlashCommands(buildAgentSlashCommands(activeAgents))
})
.catch(() => {
if (!cancelled) setAgentSlashCommands([])
})
return () => {
cancelled = true
}
}, [isMemberSession, resolvedWorkDir])
useEffect(() => {
if (!showLaunchControls) return
const nextWorkDir = activeSession?.workDir || gitInfo?.workDir || ''
@@ -402,8 +440,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
}, [fileSearchOpen])
const allSlashCommands = useMemo(
() => mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)),
[slashCommands, t],
() => appendAgentSlashCommands(
mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)),
agentSlashCommands,
),
[agentSlashCommands, slashCommands, t],
)
const filteredCommands = useMemo(() => {
@@ -791,9 +832,9 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
})
const openAttachmentPicker = useCallback(() => {
if (!isTauriRuntime()) {
setPlusMenuOpen(false)
if (!isDesktopRuntime()) {
fileInputRef.current?.click()
setPlusMenuOpen(false)
return
}
@@ -807,7 +848,6 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
}
fileInputRef.current?.click()
})
.finally(() => setPlusMenuOpen(false))
}, [setComposerAttachments])
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -7,6 +7,7 @@ import { OpenWithMenu } from '../common/OpenWithMenu'
import { buildOpenWithItems, describeFileType, isPreviewableChangedFile, type OpenWithItem } from '../../lib/openWithItems'
import { openWithContextForWorkspaceFile } from '../../lib/openWithContextForHref'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { getDesktopHost } from '../../lib/desktopHost'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
@@ -83,7 +84,7 @@ export function CurrentTurnChangeCard({
})
const items = buildOpenWithItems(ctx, targets, {
openInAppBrowser: (url) => useBrowserPanelStore.getState().open(sessionId, url),
openSystem: (p) => { void import('@tauri-apps/plugin-shell').then((m) => m.open(p)).catch(() => {}) },
openSystem: (p) => { void getDesktopHost().shell.openPath(p).catch(() => {}) },
openWorkspacePreview: (rel) => { void useWorkspacePanelStore.getState().openPreview(sessionId, rel, 'file') },
openTarget: (id, abs) => { void useOpenTargetStore.getState().openTarget(id, abs) },
t: (k, v) => t(k as TranslationKey, v),
@@ -1715,6 +1715,36 @@ describe('MessageList nested tool calls', () => {
expect(window.getSelection()?.toString()).toBe('')
})
it('dismisses the selected-message action when the message list scrolls', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [{
id: 'assistant-1',
type: 'assistant_text',
content: 'Scrolling should clear this selected reply.',
timestamp: 1,
}],
}),
},
})
const { container } = render(<MessageList />)
const assistantText = screen.getByText(/Scrolling should clear/)
await selectMessageText(assistantText, 'selected reply')
expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy()
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
await act(async () => {
fireEvent.scroll(scroller)
await Promise.resolve()
})
expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull()
expect(window.getSelection()?.toString()).toBe('')
})
it('keeps only the latest selected-message action when selecting across messages', async () => {
useChatStore.setState({
sessions: {
+26 -26
View File
@@ -1,27 +1,27 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from '../../i18n'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
export function ThinkingBlock({ content, isActive = false }: { content: string; isActive?: boolean }) {
const t = useTranslation()
const [expanded, setExpanded] = useState(false)
const contentRef = useRef<HTMLDivElement>(null)
const displayContent = useMemo(() => content.replace(/\r\n?/g, '\n').trimEnd(), [content])
const hasDisplayContent = displayContent.trim().length > 0
useEffect(() => {
if (expanded && isActive && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
}, [content, expanded, isActive])
// Preview: take first meaningful line, not first 140 chars
const lines = content.split('\n').filter((l) => l.trim())
const firstLine = lines[0]?.replace(/\s+/g, ' ').trim() || ''
const preview = firstLine.length > 80 ? firstLine.slice(0, 80) + '...' : firstLine
}, [displayContent, expanded, isActive])
return (
<div className="mb-1">
<style>{thinkingStyles}</style>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="flex w-full items-center gap-1.5 rounded-md px-1 py-0.5 text-left text-[12px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-secondary)]"
>
<span className="text-[10px] text-[var(--color-outline)]">
@@ -31,20 +31,21 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
{t('thinking.label')}
{isActive && <span className="thinking-dots" />}
</span>
{!expanded && preview && (
<span className="min-w-0 flex-1 truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{preview}
{isActive && <span className="thinking-inline-cursor" />}
</span>
)}
</button>
{expanded && (
{expanded && hasDisplayContent && (
<div
ref={contentRef}
className="mt-1 max-h-[300px] overflow-y-auto rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-lowest)] p-2.5 font-[var(--font-mono)] text-[11px] leading-[1.35] text-[var(--color-text-secondary)] whitespace-pre-wrap break-words"
data-thinking-content="expanded"
className="relative mt-1 max-h-[300px] overflow-y-auto rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-lowest)] p-2.5 text-[11px] text-[var(--color-text-secondary)]"
>
{content}
{isActive && expanded && <span className="thinking-cursor" />}
<MarkdownRenderer
content={displayContent}
variant="compact"
cache={!isActive}
streaming={isActive}
className="thinking-markdown text-[var(--color-text-secondary)]"
/>
{isActive && <span className="thinking-cursor" />}
</div>
)}
</div>
@@ -71,17 +72,16 @@ const thinkingStyles = `
margin-left: 1px;
animation: thinking-cursor-blink 1s step-end infinite;
}
.thinking-inline-cursor {
display: inline-block;
width: 1px;
height: 0.95em;
margin-left: 3px;
vertical-align: text-bottom;
background: var(--color-text-tertiary);
animation: thinking-cursor-blink 1s step-end infinite;
}
.thinking-dots::after {
content: '';
animation: thinking-dots 1.4s steps(1, end) infinite;
}
.thinking-markdown > :first-child,
.thinking-markdown > :first-child > :first-child {
margin-top: 0;
}
.thinking-markdown > :last-child,
.thinking-markdown > :last-child > :last-child {
margin-bottom: 0;
}
`
@@ -18,14 +18,44 @@ describe('chat blocks', () => {
const { container } = render(<ThinkingBlock content="this is a long internal reasoning trace" isActive />)
expect(screen.getByText(/Thinking/)).toBeTruthy()
expect(container.textContent).toContain('this is a long internal reasoning trace')
expect(container.textContent).not.toContain('this is a long internal reasoning trace')
expect(container.querySelector('.thinking-cursor')).toBeNull()
})
it('does not animate inactive historical thinking blocks', () => {
const { container } = render(<ThinkingBlock content="old reasoning" isActive={false} />)
expect(container.querySelector('.thinking-inline-cursor')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Thinking/ }))
expect(container.textContent).toContain('old reasoning')
expect(container.querySelector('.thinking-cursor')).toBeNull()
})
it('renders thinking content as markdown only after expanding', () => {
const { container } = render(<ThinkingBlock content={'**important**\n\n- item one'} />)
expect(container.textContent).not.toContain('important')
expect(container.querySelector('strong')).toBeNull()
expect(container.querySelector('li')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Thinking/ }))
expect(container.querySelector('strong')?.textContent).toBe('important')
expect(container.querySelector('li')?.textContent).toBe('item one')
})
it('hides full thinking content until expanded', () => {
const content = Array.from({ length: 12 }, (_, index) => `line-${index + 1}`).join('\n')
const { container } = render(<ThinkingBlock content={content} />)
expect(container.textContent).not.toContain('line-1')
expect(container.textContent).not.toContain('line-11')
fireEvent.click(screen.getByRole('button', { name: /Thinking/ }))
expect(container.textContent).toContain('line-1')
expect(container.textContent).toContain('line-11')
expect(container.textContent).toContain('line-12')
})
it('shows tool previews only after expanding the tool block', () => {
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
appendAgentSlashCommands,
buildAgentSlashCommands,
filterSlashCommands,
findSlashToken,
getLocalizedFallbackCommands,
@@ -110,6 +112,35 @@ describe('composerUtils', () => {
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal --tokens')
})
it('builds agent slash entries under the /agent namespace', () => {
expect(
buildAgentSlashCommands([
{
agentType: 'debugger',
description: 'Debug failures',
modelDisplay: 'OPUS',
source: 'userSettings',
},
]),
).toEqual([
{
name: 'agent debugger',
description: 'Debug failures (OPUS - userSettings)',
argumentHint: '<prompt>',
},
])
})
it('appends agent entries after normal slash commands without replacing them', () => {
const base = mergeSlashCommands([{ name: 'agent', description: 'CLI /agent' }])
const withAgents = appendAgentSlashCommands(base, [
{ name: 'agent debugger', description: 'Debug failures', argumentHint: '<prompt>' },
])
expect(withAgents.map((command) => command.name).slice(0, 2)).toEqual(['agent', 'mcp'])
expect(withAgents.map((command) => command.name)).toContain('agent debugger')
})
it('does not replace /goal arguments as slash command fragments', () => {
expect(replaceSlashCommand('/goal sta', 9, 'goal status')).toBeNull()
})
@@ -3,6 +3,7 @@ import type { TranslationKey } from '../../i18n'
/** Map from slash command name to its i18n description key */
const SLASH_CMD_DESCRIPTION_KEYS: Record<string, TranslationKey> = {
agent: 'slashCmd.agent.description',
mcp: 'slashCmd.mcp.description',
skills: 'slashCmd.skills.description',
help: 'slashCmd.help.description',
@@ -53,6 +54,7 @@ export const SLASH_COMMAND_ALIASES = [
/** Static fallback with English descriptions (for non-React contexts) */
export const FALLBACK_SLASH_COMMANDS: SlashCommandOption[] = [
{ name: 'agent', description: 'Run a prompt with a selected Agent', argumentHint: '<agent> <prompt>' },
{ name: 'mcp', description: 'Open available MCP tools for the current chat context' },
{ name: 'skills', description: 'Browse user-invocable skills for the current chat context' },
{ name: 'help', description: 'Show available desktop and agent commands' },
@@ -115,6 +117,51 @@ export type SlashCommandOption = {
argumentHint?: string
}
export type AgentSlashCommandSource = {
agentType: string
description?: string
modelDisplay?: string
source?: string
}
export function buildAgentSlashCommands(
agents: ReadonlyArray<AgentSlashCommandSource>,
): SlashCommandOption[] {
const seen = new Set<string>()
const commands: SlashCommandOption[] = []
for (const agent of agents) {
const agentType = agent.agentType.trim()
if (!agentType || seen.has(agentType)) continue
seen.add(agentType)
const details = [agent.modelDisplay, agent.source].filter(Boolean).join(' - ')
const description = [
agent.description?.trim() || `Run with the ${agentType} Agent`,
details ? `(${details})` : '',
].filter(Boolean).join(' ')
commands.push({
name: `agent ${agentType}`,
description,
argumentHint: '<prompt>',
})
}
return commands
}
export function appendAgentSlashCommands(
commands: ReadonlyArray<SlashCommandOption>,
agentCommands: ReadonlyArray<SlashCommandOption>,
): SlashCommandOption[] {
const names = new Set(commands.map((command) => command.name))
return [
...commands,
...agentCommands.filter((command) => !names.has(command.name)),
]
}
export type SlashUiAction =
| {
type: 'panel'
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, type DragEvent, type RefObject } from 'react'
import { isTauriRuntime } from '../../lib/desktopRuntime'
import { getDesktopHost } from '../../lib/desktopHost'
import {
dataTransferHasFiles,
dataTransferToComposerAttachments,
@@ -66,37 +66,36 @@ export function useComposerFileDrop({
}, [onError])
useEffect(() => {
if (!isTauriRuntime()) return
const host = getDesktopHost()
if (!host.isDesktop) return
let disposed = false
let unlisten: (() => void) | undefined
void import('@tauri-apps/api/webview')
.then(({ getCurrentWebview }) =>
getCurrentWebview().onDragDropEvent((event) => {
if (disposed) return
const payload = event.payload as TauriDragDropEvent['payload']
if (payload.type === 'cancel' || payload.type === 'leave') {
dragDepthRef.current = 0
setIsDragActive(false)
return
}
const isInside = isPointInsideElement(panelRef.current, payload.position)
if (payload.type === 'enter' || payload.type === 'over') {
setIsDragActive(!disabledRef.current && isInside)
return
}
void host.webview
.onDragDropEvent((event) => {
if (disposed) return
const payload = (event as TauriDragDropEvent).payload
if (payload.type === 'cancel' || payload.type === 'leave') {
dragDepthRef.current = 0
setIsDragActive(false)
if (disabledRef.current || !isInside) return
return
}
const attachments = pathsToComposerAttachments(payload.paths)
if (attachments.length > 0) onAttachmentsRef.current(attachments)
}),
)
const isInside = isPointInsideElement(panelRef.current, payload.position)
if (payload.type === 'enter' || payload.type === 'over') {
setIsDragActive(!disabledRef.current && isInside)
return
}
dragDepthRef.current = 0
setIsDragActive(false)
if (disabledRef.current || !isInside) return
const attachments = pathsToComposerAttachments(payload.paths)
if (attachments.length > 0) onAttachmentsRef.current(attachments)
})
.then((nextUnlisten) => {
if (disposed) {
nextUnlisten()
@@ -34,13 +34,98 @@ afterEach(() => {
useHahaOpenAIOAuthStore.setState(useHahaOpenAIOAuthStore.getInitialState(), true)
})
// Prevent real API calls from fetchStatus on mount
beforeEach(() => {
useHahaOAuthStore.setState({ fetchStatus: async () => {} })
useHahaOpenAIOAuthStore.setState({ fetchStatus: async () => {} })
})
describe('ModelSelector', () => {
it('does not query official OAuth status when mounted', () => {
const fetchClaudeStatus = vi.fn(async () => {})
const fetchOpenAIStatus = vi.fn(async () => {})
useHahaOAuthStore.setState({ fetchStatus: fetchClaudeStatus })
useHahaOpenAIOAuthStore.setState({ fetchStatus: fetchOpenAIStatus })
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,
currentModel: MODELS[0],
activeProviderName: 'Provider A',
})
useProviderStore.setState({
providers: [],
activeId: 'provider-a',
hasLoadedProviders: true,
isLoading: true,
})
render(<ModelSelector runtimeKey="session-no-keychain-prompt" />)
expect(fetchClaudeStatus).not.toHaveBeenCalled()
expect(fetchOpenAIStatus).not.toHaveBeenCalled()
})
it('queries official OAuth status once when the runtime dropdown is opened', async () => {
const fetchClaudeStatus = vi.fn(async () => {})
const fetchOpenAIStatus = vi.fn(async () => {})
useHahaOAuthStore.setState({ fetchStatus: fetchClaudeStatus })
useHahaOpenAIOAuthStore.setState({ fetchStatus: fetchOpenAIStatus })
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,
currentModel: MODELS[0],
activeProviderName: 'Provider A',
})
useProviderStore.setState({
providers: [{
id: 'provider-a',
presetId: 'custom',
name: 'Provider A',
apiKey: '***',
baseUrl: 'https://api.example.com',
apiFormat: 'anthropic',
models: {
main: 'provider-main',
haiku: '',
sonnet: '',
opus: '',
},
}],
activeId: 'provider-a',
hasLoadedProviders: true,
isLoading: true,
})
render(<ModelSelector runtimeKey="session-oauth-on-open" />)
await clickByRole(/alpha/i)
await act(async () => {
await Promise.resolve()
})
await clickByRole(/alpha/i)
expect(fetchClaudeStatus).toHaveBeenCalledTimes(1)
expect(fetchOpenAIStatus).toHaveBeenCalledTimes(1)
})
it('does not query official OAuth status for plain model dropdowns', async () => {
const fetchClaudeStatus = vi.fn(async () => {})
const fetchOpenAIStatus = vi.fn(async () => {})
useHahaOAuthStore.setState({ fetchStatus: fetchClaudeStatus })
useHahaOpenAIOAuthStore.setState({ fetchStatus: fetchOpenAIStatus })
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,
currentModel: MODELS[0],
})
render(<ModelSelector value="alpha" onChange={vi.fn()} />)
await clickByRole(/alpha/i)
expect(fetchClaudeStatus).not.toHaveBeenCalled()
expect(fetchOpenAIStatus).not.toHaveBeenCalled()
})
it('uses controlled model selection without mutating settings directly', async () => {
const onChange = vi.fn()
useSettingsStore.setState({
@@ -15,7 +15,7 @@ import type { SavedProvider } from '../../types/provider'
import type { RuntimeSelection } from '../../types/runtime'
import type { EffortLevel, ModelInfo } from '../../types/settings'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isTauriRuntime } from '../../lib/desktopRuntime'
import { isDesktopRuntime } from '../../lib/desktopRuntime'
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
@@ -172,7 +172,7 @@ export function ModelSelector({
compact = false,
}: Props = {}) {
const t = useTranslation()
const isMobileBrowser = useMobileViewport() && !isTauriRuntime()
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
const {
currentModel: storeModel,
availableModels,
@@ -198,6 +198,7 @@ export function ModelSelector({
const ref = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const requestedProvidersRef = useRef(false)
const requestedOAuthStatusRef = useRef(false)
const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [
{ value: 'low', label: t('settings.general.effort.low') },
@@ -219,9 +220,11 @@ export function ModelSelector({
}, [fetchProviders, isRuntimeScoped, providersLoading])
useEffect(() => {
if (!isRuntimeScoped || !open || requestedOAuthStatusRef.current) return
requestedOAuthStatusRef.current = true
void fetchClaudeOAuthStatus()
void fetchOpenAIOAuthStatus()
}, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus])
}, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open])
useEffect(() => {
if (!open) return
@@ -12,6 +12,7 @@ vi.mock('../../hooks/useMobileViewport', () => ({
vi.mock('../../lib/desktopRuntime', () => ({
isTauriRuntime: () => false,
isDesktopRuntime: () => false,
}))
vi.mock('../../i18n', () => ({
@@ -7,7 +7,7 @@ import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
import type { PermissionMode } from '../../types/settings'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isTauriRuntime } from '../../lib/desktopRuntime'
import { isDesktopRuntime } from '../../lib/desktopRuntime'
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
import { ActionDialog } from '../shared/ActionDialog'
@@ -30,7 +30,7 @@ type Props = {
export function PermissionModeSelector({ workDir: workDirProp, compact = false, value, onChange }: Props = {}) {
const t = useTranslation()
const isMobile = useMobileViewport() && !isTauriRuntime()
const isMobile = useMobileViewport() && !isDesktopRuntime()
const { permissionMode: storeMode } = useSettingsStore()
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
const activeTabId = useTabStore((s) => s.activeTabId)
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useUIStore } from '../../stores/uiStore'
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
restoreTabs: vi.fn(),
connectToSession: vi.fn(),
setActiveTab: vi.fn(),
openTab: vi.fn(),
tabState: {
activeTabId: null as string | null,
tabs: [] as Array<{ sessionId: string; title: string; type: string; status: string }>,
@@ -21,6 +22,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('../../lib/desktopRuntime', () => ({
initializeDesktopServerUrl: mocks.initializeDesktopServerUrl,
isTauriRuntime: () => mocks.isTauriRuntime,
isDesktopRuntime: () => mocks.isTauriRuntime,
isH5ConnectionRequiredError: (error: unknown) =>
error instanceof Error && error.name === 'H5ConnectionRequiredError',
}))
@@ -48,7 +50,7 @@ vi.mock('../../stores/tabStore', () => {
restoreTabs: mocks.restoreTabs,
activeTabId: mocks.tabState.activeTabId,
tabs: mocks.tabState.tabs,
openTab: vi.fn(),
openTab: mocks.openTab,
setActiveTab: mocks.setActiveTab,
})
useTabStore.setState = (next: { activeTabId?: string | null }) => {
@@ -116,6 +118,7 @@ describe('AppShell boot flow', () => {
mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456')
mocks.fetchAll.mockResolvedValue(undefined)
mocks.restoreTabs.mockResolvedValue(undefined)
mocks.openTab.mockReset()
mocks.setActiveTab.mockImplementation((sessionId: string) => {
mocks.tabState.activeTabId = sessionId
})
@@ -123,6 +126,7 @@ describe('AppShell boot flow', () => {
mocks.tabState.tabs = []
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useUIStore.setState({ sidebarOpen: true })
Reflect.deleteProperty(window, 'desktopHost')
})
it('renders the desktop chrome after server and settings bootstrap', async () => {
@@ -177,6 +181,33 @@ describe('AppShell boot flow', () => {
})
})
it('routes native menu navigation through the desktop host', async () => {
let navigate: ((target: string) => void) | undefined
const unlisten = vi.fn()
const onNativeMenuNavigate = vi.fn((handler: (target: string) => void) => {
navigate = handler
return Promise.resolve(unlisten)
})
window.desktopHost = {
isDesktop: true,
window: {
onNativeMenuNavigate,
},
} as any
render(<AppShell />)
await screen.findByText('sidebar loaded')
await waitFor(() => expect(onNativeMenuNavigate).toHaveBeenCalledTimes(1))
act(() => {
navigate?.('about')
})
expect(useUIStore.getState().pendingSettingsTab).toBe('about')
expect(mocks.openTab).toHaveBeenCalledWith('__settings__', 'Settings', 'settings')
})
it('shows the H5 connection view in browser mode when startup needs H5 auth', async () => {
mocks.initializeDesktopServerUrl.mockRejectedValueOnce(
Object.assign(new Error('Enter your H5 token to continue.'), {
+16 -17
View File
@@ -9,9 +9,10 @@ import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
import {
H5ConnectionRequiredError,
initializeDesktopServerUrl,
isDesktopRuntime,
isH5ConnectionRequiredError,
isTauriRuntime,
} from '../../lib/desktopRuntime'
import { getDesktopHost } from '../../lib/desktopHost'
import { TabBar } from './TabBar'
import { StartupErrorView } from './StartupErrorView'
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
@@ -37,8 +38,8 @@ export function AppShell() {
const [bootstrapNonce, setBootstrapNonce] = useState(0)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const t = useTranslation()
const tauriRuntime = isTauriRuntime()
const isMobileShell = useMobileViewport() && !tauriRuntime
const desktopRuntime = isDesktopRuntime()
const isMobileShell = useMobileViewport() && !desktopRuntime
const tabs = useTabStore((s) => s.tabs)
const activeTabId = useTabStore((s) => s.activeTabId)
const setActiveTab = useTabStore((s) => s.setActiveTab)
@@ -92,7 +93,7 @@ export function AppShell() {
})().catch(() => {})
} catch (error) {
if (!cancelled) {
if (!tauriRuntime && isH5ConnectionRequiredError(error)) {
if (!desktopRuntime && isH5ConnectionRequiredError(error)) {
setH5StartupError(error)
setStartupError(null)
} else {
@@ -109,22 +110,20 @@ export function AppShell() {
return () => {
cancelled = true
}
}, [bootstrapNonce, fetchSettings, tauriRuntime])
}, [bootstrapNonce, fetchSettings, desktopRuntime])
// Listen for macOS native menu navigation events (About / Settings)
useEffect(() => {
if (!tauriRuntime) return
const host = getDesktopHost()
if (!host.isDesktop) return
let unlisten: (() => void) | undefined
import('@tauri-apps/api/event')
.then(({ listen }) =>
listen<string>('native-menu-navigate', (event) => {
const target = event.payload as SettingsTab | 'settings'
if (target === 'about') {
useUIStore.getState().setPendingSettingsTab('about')
}
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
}),
)
host.window.onNativeMenuNavigate((target) => {
const destination = target as SettingsTab | 'settings'
if (destination === 'about') {
useUIStore.getState().setPendingSettingsTab('about')
}
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
})
.then((fn) => { unlisten = fn })
.catch(() => {})
return () => { unlisten?.() }
@@ -171,7 +170,7 @@ export function AppShell() {
toggleSidebar()
}
if (!tauriRuntime && h5StartupError) {
if (!desktopRuntime && h5StartupError) {
return (
<H5ConnectionView
initialServerUrl={h5StartupError.serverUrl}
+12 -13
View File
@@ -9,8 +9,13 @@ import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tab
import { useChatStore } from '../../stores/chatStore'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
import { getDesktopHost } from '../../lib/desktopHost'
import { publicAssetPath } from '../../lib/publicAsset'
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
const desktopHost = getDesktopHost()
const isDesktopRuntime = desktopHost.isDesktop
const canUseNativeDialogs = desktopHost.capabilities.dialogs
const canStartWindowDragging = desktopHost.capabilities.windowControls
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
const SESSION_LIST_AUTO_REFRESH_MS = 30_000
const SESSION_LIST_FOCUS_REFRESH_MIN_MS = 5_000
@@ -345,7 +350,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
const createSessionFromExistingFolder = useCallback(async () => {
setProjectHeaderMenu(null)
setProjectHeaderSubmenu(null)
if (!isTauri) {
if (!canUseNativeDialogs) {
addToast({
type: 'error',
message: t('sidebar.chooseProjectFolderUnavailable'),
@@ -353,8 +358,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
return
}
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
const selected = await getDesktopHost().dialogs.open({
directory: true,
multiple: false,
title: t('sidebar.useExistingFolder'),
@@ -574,13 +578,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
const startDraggingRef = useRef<(() => Promise<void>) | null>(null)
useEffect(() => {
if (!isTauri) return
import('@tauri-apps/api/window')
.then(({ getCurrentWindow }) => {
const win = getCurrentWindow()
startDraggingRef.current = () => win.startDragging()
})
.catch(() => {})
if (!canStartWindowDragging) return
startDraggingRef.current = () => getDesktopHost().window.startDragging()
}, [])
const handleSidebarDrag = useCallback((e: React.MouseEvent) => {
@@ -617,10 +616,10 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
data-state={expanded ? 'open' : 'closed'}
aria-label="Sidebar"
>
<div className={`px-3 pb-2 ${isTauri && !isWindows ? 'pt-[44px]' : 'pt-3'}`}>
<div className={`px-3 pb-2 ${isDesktopRuntime && !isWindows ? 'pt-[44px]' : 'pt-3'}`}>
<div className={`flex ${expanded ? 'items-center justify-between gap-3' : 'flex-col items-center gap-2'}`}>
<div className={`flex min-w-0 items-center ${expanded ? 'gap-2.5' : 'justify-center'}`}>
<img src="/app-icon.png" alt="" className="h-8 w-8 flex-shrink-0" />
<img src={publicAssetPath('app-icon.png')} alt="" className="h-8 w-8 flex-shrink-0" />
<span
className={`sidebar-copy ${expanded ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]`}
style={{ fontFamily: 'var(--font-headline)' }}
@@ -510,10 +510,6 @@ describe('TabBar', () => {
render(<TabBar />)
})
await waitFor(() => {
expect(getCurrentWindowMock).toHaveBeenCalled()
})
const scrollRegion = screen.getByTestId('tab-bar').querySelector('.overflow-x-hidden')
expect(scrollRegion).toBeInTheDocument()
@@ -544,10 +540,6 @@ describe('TabBar', () => {
render(<TabBar />)
})
await waitFor(() => {
expect(getCurrentWindowMock).toHaveBeenCalled()
})
fireEvent.mouseDown(screen.getByText('Untitled Session'))
expect(startDraggingMock).not.toHaveBeenCalled()
+8 -10
View File
@@ -12,6 +12,7 @@ import { useSessionStore } from '../../stores/sessionStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useTerminalPanelStore } from '../../stores/terminalPanelStore'
import { useTranslation } from '../../i18n'
import { getDesktopHost } from '../../lib/desktopHost'
import { WindowControls, showWindowControls } from './WindowControls'
import { OpenProjectMenu } from './OpenProjectMenu'
import { Folder, FolderOpen, SquareTerminal } from 'lucide-react'
@@ -19,7 +20,9 @@ import { ActionDialog } from '../shared/ActionDialog'
const TAB_WIDTH = 180
const DRAG_START_THRESHOLD = 4
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
const desktopHost = getDesktopHost()
const isDesktopRuntime = desktopHost.isDesktop
const canStartWindowDragging = desktopHost.capabilities.windowControls
type PendingCloseRequest = {
tabs: Tab[]
@@ -103,13 +106,8 @@ export function TabBar() {
}, [activeChatSessionIds, tabs])
useEffect(() => {
if (!isTauri) return
import('@tauri-apps/api/window')
.then(({ getCurrentWindow }) => {
const win = getCurrentWindow()
startDraggingRef.current = () => win.startDragging()
})
.catch(() => {})
if (!canStartWindowDragging) return
startDraggingRef.current = () => getDesktopHost().window.startDragging()
}, [])
const updateScrollState = useCallback(() => {
@@ -373,7 +371,7 @@ export function TabBar() {
</div>
<div className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border)]/70 px-2">
{isTauri && isActiveSessionTab && (
{isDesktopRuntime && isActiveSessionTab && (
<OpenProjectMenu path={openProjectPath} />
)}
<ToolbarIconButton
@@ -406,7 +404,7 @@ export function TabBar() {
)}
</div>
{isTauri && (
{isDesktopRuntime && (
<div
data-testid="tab-bar-drag-gutter"
data-tauri-drag-region
@@ -1,10 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
const minimize = vi.fn().mockResolvedValue(undefined)
const toggleMaximize = vi.fn().mockResolvedValue(undefined)
const close = vi.fn().mockResolvedValue(undefined)
const hostMinimize = vi.fn().mockResolvedValue(undefined)
const hostToggleMaximize = vi.fn().mockResolvedValue(undefined)
const hostClose = vi.fn().mockResolvedValue(undefined)
const hostIsMaximized = vi.fn().mockResolvedValue(false)
const hostOnResized = vi.fn().mockResolvedValue(() => {})
const isMaximized = vi.fn().mockResolvedValue(false)
const onResized = vi.fn().mockResolvedValue(() => {})
@@ -25,13 +30,43 @@ describe('WindowControls', () => {
minimize.mockClear()
toggleMaximize.mockClear()
close.mockClear()
hostMinimize.mockClear()
hostToggleMaximize.mockClear()
hostClose.mockClear()
hostIsMaximized.mockReset()
hostIsMaximized.mockResolvedValue(false)
hostOnResized.mockReset()
hostOnResized.mockResolvedValue(() => {})
isMaximized.mockClear()
onResized.mockClear()
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: {},
})
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
window.desktopHost = {
kind: 'electron',
isDesktop: true,
capabilities: {
appMode: false,
dialogs: false,
notifications: false,
previewWebview: false,
shell: false,
terminal: false,
updates: false,
windowControls: true,
zoom: false,
},
window: {
minimize: hostMinimize,
toggleMaximize: hostToggleMaximize,
close: hostClose,
startDragging: vi.fn(),
requestAttention: vi.fn(),
focus: vi.fn(),
isMaximized: hostIsMaximized,
onResized: hostOnResized,
onNativeMenuNavigate: vi.fn().mockResolvedValue(() => {}),
},
} as any
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: 'Win32',
@@ -41,13 +76,17 @@ describe('WindowControls', () => {
afterEach(() => {
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
Reflect.deleteProperty(window, 'desktopHost')
Object.defineProperty(navigator, 'platform', {
configurable: true,
value: originalPlatform,
})
})
it('invokes Tauri window APIs for custom controls on Windows', async () => {
it('invokes desktop host window APIs for custom controls on Windows', async () => {
hostIsMaximized
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
const { WindowControls } = await import('./WindowControls')
render(<WindowControls />)
@@ -55,15 +94,30 @@ describe('WindowControls', () => {
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Minimize window' })).toBeInTheDocument()
})
await waitFor(() => expect(hostOnResized).toHaveBeenCalledTimes(1))
const handleResize = hostOnResized.mock.calls[0]?.[0]
expect(handleResize).toBeDefined()
await act(async () => {
handleResize?.()
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Restore window' })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'Minimize window' }))
fireEvent.click(screen.getByRole('button', { name: 'Maximize window' }))
fireEvent.click(screen.getByRole('button', { name: 'Restore window' }))
fireEvent.click(screen.getByRole('button', { name: 'Close window' }))
await waitFor(() => {
expect(minimize).toHaveBeenCalledTimes(1)
expect(toggleMaximize).toHaveBeenCalledTimes(1)
expect(close).toHaveBeenCalledTimes(1)
expect(hostMinimize).toHaveBeenCalledTimes(1)
expect(hostToggleMaximize).toHaveBeenCalledTimes(1)
expect(hostClose).toHaveBeenCalledTimes(1)
})
expect(minimize).not.toHaveBeenCalled()
expect(toggleMaximize).not.toHaveBeenCalled()
expect(close).not.toHaveBeenCalled()
})
})
@@ -1,37 +1,42 @@
import { useState, useEffect } from 'react'
import { getDesktopHost } from '../../lib/desktopHost'
import type { DesktopHost } from '../../lib/desktopHost'
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
/** Whether to render custom window controls (Windows + Tauri only) */
export const showWindowControls = isTauri && isWindows
/** Whether to render custom window controls (Windows + desktop host only) */
export const showWindowControls = isWindows && getDesktopHost().capabilities.windowControls
export function WindowControls() {
const [maximized, setMaximized] = useState(false)
const [win, setWin] = useState<{
minimize: () => Promise<void>
toggleMaximize: () => Promise<void>
close: () => Promise<void>
isMaximized: () => Promise<boolean>
onResized: (handler: () => void) => Promise<() => void>
} | null>(null)
const [win, setWin] = useState<DesktopHost['window'] | null>(null)
useEffect(() => {
if (!showWindowControls) return
let unlisten: (() => void) | undefined
let cancelled = false
import('@tauri-apps/api/window')
.then(async ({ getCurrentWindow }) => {
const w = getCurrentWindow()
setWin(w as any)
setMaximized(await w.isMaximized())
unlisten = await w.onResized(async () => {
setMaximized(await w.isMaximized())
})
const w = getDesktopHost().window
setWin(w)
void w.isMaximized()
.then((nextMaximized) => {
if (!cancelled) setMaximized(nextMaximized)
})
.catch(() => {})
void w.onResized(() => {
void w.isMaximized()
.then((nextMaximized) => {
if (!cancelled) setMaximized(nextMaximized)
})
.catch(() => {})
})
.then((fn) => { unlisten = fn })
.catch(() => {})
return () => { unlisten?.() }
return () => {
cancelled = true
unlisten?.()
}
}, [])
const runWindowAction = (action: () => Promise<void>) => {
@@ -35,6 +35,7 @@ vi.mock('../chat/clipboard', () => ({
import { ChatGPTOfficialLogin } from './ChatGPTOfficialLogin'
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { browserHost } from '../../lib/desktopHost/browserHost'
const initialOAuthState = useHahaOpenAIOAuthStore.getState()
@@ -46,6 +47,7 @@ describe('ChatGPTOfficialLogin', () => {
logoutMock.mockReset()
shellOpenMock.mockReset()
copyTextToClipboardMock.mockReset()
Reflect.deleteProperty(window, 'desktopHost')
useSettingsStore.setState({ locale: 'en' })
useHahaOpenAIOAuthStore.setState({
...initialOAuthState,
@@ -68,9 +70,22 @@ describe('ChatGPTOfficialLogin', () => {
it('keeps an actionable authorization link when shell open fails', async () => {
const authorizeUrl = 'https://chatgpt.com/oauth/authorize?state=openai-state'
const hostOpen = vi.fn().mockRejectedValue(new Error('shell unavailable'))
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
shell: true,
},
shell: {
...browserHost.shell,
open: hostOpen,
},
}
statusMock.mockResolvedValue({ loggedIn: false })
startMock.mockResolvedValue({ authorizeUrl, state: 'openai-state' })
shellOpenMock.mockRejectedValue(new Error('shell unavailable'))
copyTextToClipboardMock.mockResolvedValue(true)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -81,7 +96,8 @@ describe('ChatGPTOfficialLogin', () => {
fireEvent.click(screen.getByRole('button', { name: 'Sign in with ChatGPT' }))
})
expect(shellOpenMock).toHaveBeenCalledWith(authorizeUrl)
expect(hostOpen).toHaveBeenCalledWith(authorizeUrl)
expect(shellOpenMock).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith('[ChatGPTOfficialLogin] shellOpen failed:', expect.any(Error))
expect(screen.getByText(/Unable to open browser/)).toBeInTheDocument()
@@ -98,6 +114,19 @@ describe('ChatGPTOfficialLogin', () => {
it('keeps the authorization link available when copy fails', async () => {
const authorizeUrl = 'https://chatgpt.com/oauth/authorize?state=openai-state'
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
shell: true,
},
shell: {
...browserHost.shell,
open: vi.fn().mockRejectedValue(new Error('shell unavailable')),
},
}
statusMock.mockResolvedValue({ loggedIn: false })
startMock.mockResolvedValue({ authorizeUrl, state: 'openai-state' })
shellOpenMock.mockRejectedValue(new Error('shell unavailable'))
@@ -1,11 +1,11 @@
// desktop/src/components/settings/ChatGPTOfficialLogin.tsx
import { useEffect, useState } from 'react'
import { open as shellOpen } from '@tauri-apps/plugin-shell'
import { Copy, LogIn, LogOut } from 'lucide-react'
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
import { useTranslation } from '../../i18n'
import { copyTextToClipboard } from '../chat/clipboard'
import { getDesktopHost } from '../../lib/desktopHost'
export function ChatGPTOfficialLogin() {
const t = useTranslation()
@@ -38,7 +38,7 @@ export function ChatGPTOfficialLogin() {
const { authorizeUrl } = await login()
setManualAuthorizeUrl(authorizeUrl)
try {
await shellOpen(authorizeUrl)
await getDesktopHost().shell.open(authorizeUrl)
setManualAuthorizeUrl(null)
startPolling()
} catch (err) {
@@ -1,13 +1,13 @@
// desktop/src/components/settings/ClaudeOfficialLogin.tsx
//
// 显示当前 Claude Official OAuth 登录状态,提供 Login / Logout 按钮。
// 点击 Login 调 Tauri shell.open 打开浏览器走 OAuth flow;浏览器回 callback
// 点击 Login 调 desktop host shell.open 打开浏览器走 OAuth flow;浏览器回 callback
// 到 haha server 后,store 的 polling 自动刷新 UI 展示"已登录"。
import { useEffect } from 'react'
import { open as shellOpen } from '@tauri-apps/plugin-shell'
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
import { useTranslation } from '../../i18n'
import { getDesktopHost } from '../../lib/desktopHost'
export function ClaudeOfficialLogin() {
const t = useTranslation()
@@ -31,7 +31,7 @@ export function ClaudeOfficialLogin() {
try {
const { authorizeUrl } = await login()
try {
await shellOpen(authorizeUrl)
await getDesktopHost().shell.open(authorizeUrl)
startPolling()
} catch (err) {
console.error('[ClaudeOfficialLogin] shellOpen failed:', err)
@@ -17,6 +17,7 @@ vi.mock('../../api/filesystem', () => ({
import { DirectoryPicker } from './DirectoryPicker'
import { sessionsApi } from '../../api/sessions'
import { filesystemApi } from '../../api/filesystem'
import { browserHost } from '../../lib/desktopHost/browserHost'
describe('DirectoryPicker', () => {
let originalInnerWidth: number
@@ -27,6 +28,7 @@ describe('DirectoryPicker', () => {
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth })
Reflect.deleteProperty(window, 'desktopHost')
vi.restoreAllMocks()
})
@@ -169,4 +171,36 @@ describe('DirectoryPicker', () => {
errorSpy.mockRestore()
})
it('uses the injected desktop host for native folder selection', async () => {
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({ projects: [] })
const open = vi.fn().mockResolvedValue('/workspace/native-project')
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: {
...browserHost.capabilities,
dialogs: true,
},
dialogs: {
...browserHost.dialogs,
open,
},
}
const onChange = vi.fn()
render(<DirectoryPicker value="" onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: /选择项目|Select a project/ }))
fireEvent.click(await screen.findByText(/选择其他文件夹|Choose a different folder/))
await waitFor(() => expect(onChange).toHaveBeenCalledWith('/workspace/native-project'))
expect(open).toHaveBeenCalledWith({
directory: true,
multiple: false,
title: expect.any(String),
})
expect(filesystemApi.browse).not.toHaveBeenCalled()
})
})
@@ -4,6 +4,7 @@ import { sessionsApi, type RecentProject } from '../../api/sessions'
import { filesystemApi } from '../../api/filesystem'
import { useTranslation } from '../../i18n'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { getDesktopHost } from '../../lib/desktopHost'
import { MobileBottomSheet } from './MobileBottomSheet'
type Props = {
@@ -24,8 +25,8 @@ const DROPDOWN_WIDTH = 400
const DROPDOWN_VIEWPORT_MARGIN = 12
const DROPDOWN_HEIGHT = 380 // approximate max height
function isTauriRuntime() {
return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
function isDesktopRuntime() {
return typeof window !== 'undefined' && getDesktopHost().isDesktop
}
function projectNameFromPath(filePath: string) {
@@ -47,7 +48,7 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number; direction: 'up' | 'down' } | null>(null)
const ref = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const isMobileBrowser = useMobileViewport() && !isTauriRuntime()
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
const dropdownRef = useRef<HTMLDivElement>(null)
@@ -132,17 +133,17 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
}
const handleChooseFolder = async () => {
if (isTauriRuntime()) {
const host = getDesktopHost()
if (host.isDesktop && host.capabilities.dialogs) {
// Desktop: native OS folder dialog
setIsOpen(false)
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
const selected = await host.dialogs.open({
directory: true,
multiple: false,
title: t('dirPicker.chooseProjectFolder'),
})
if (selected) onChange(selected)
if (typeof selected === 'string' && selected.length > 0) onChange(selected)
} catch (err) {
console.error('[DirectoryPicker] Failed to open folder dialog:', err)
}

Some files were not shown because too many files have changed in this diff Show More