mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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'] }],
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Executable
+136
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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}`)
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 ')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.'), {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user