diff --git a/.github/workflows/build-desktop-dev.yml b/.github/workflows/build-desktop-dev.yml index aaad88c0..33c7dcdc 100644 --- a/.github/workflows/build-desktop-dev.yml +++ b/.github/workflows/build-desktop-dev.yml @@ -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 diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index 28ce016a..d7ab2af4 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -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 diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index 1b139f62..cc979712 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -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.') diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 8eb5481d..e333b1d3 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 2b29f06f..cac73e9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 --dry`, then run `bun run verify`, and run `bun run quality:gate --mode release --allow-live --provider-model ` 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 ` 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 ` 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/`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ecad68db..43f66176 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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//logs/.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` diff --git a/adapters/feishu/__tests__/streaming-card.test.ts b/adapters/feishu/__tests__/streaming-card.test.ts index fbb090ae..2cec35df 100644 --- a/adapters/feishu/__tests__/streaming-card.test.ts +++ b/adapters/feishu/__tests__/streaming-card.test.ts @@ -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 }, diff --git a/adapters/feishu/cardkit.ts b/adapters/feishu/cardkit.ts index 4a343331..bb9eda9d 100644 --- a/adapters/feishu/cardkit.ts +++ b/adapters/feishu/cardkit.ts @@ -189,6 +189,7 @@ export async function sendCardAsMessage( chatId: string, cardId: string, replyToMessageId?: string, + uuid?: string, ): Promise { 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, }, }), ) diff --git a/adapters/feishu/streaming-card.ts b/adapters/feishu/streaming-card.ts index 233a4435..16b1b90f 100644 --- a/adapters/feishu/streaming-card.ts +++ b/adapters/feishu/streaming-card.ts @@ -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, }, }), ) diff --git a/desktop/bun.lock b/desktop/bun.lock index 8650597c..8ffd27ee 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -10,11 +10,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", @@ -29,10 +31,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", @@ -51,6 +56,8 @@ }, }, "packages": { + "7zip-bin": ["7zip-bin@5.2.0", "https://registry.npmmirror.com/7zip-bin/-/7zip-bin-5.2.0.tgz", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "https://registry.npmmirror.com/@adobe/css-tools/-/css-tools-4.4.4.tgz", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -105,6 +112,24 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], + + "@electron/asar": ["@electron/asar@3.4.1", "https://registry.npmmirror.com/@electron/asar/-/asar-3.4.1.tgz", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], + + "@electron/fuses": ["@electron/fuses@1.8.0", "https://registry.npmmirror.com/@electron/fuses/-/fuses-1.8.0.tgz", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], + + "@electron/get": ["@electron/get@5.0.0", "https://registry.npmmirror.com/@electron/get/-/get-5.0.0.tgz", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA=="], + + "@electron/notarize": ["@electron/notarize@2.5.0", "https://registry.npmmirror.com/@electron/notarize/-/notarize-2.5.0.tgz", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], + + "@electron/osx-sign": ["@electron/osx-sign@1.3.3", "https://registry.npmmirror.com/@electron/osx-sign/-/osx-sign-1.3.3.tgz", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="], + + "@electron/rebuild": ["@electron/rebuild@4.0.4", "https://registry.npmmirror.com/@electron/rebuild/-/rebuild-4.0.4.tgz", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg=="], + + "@electron/universal": ["@electron/universal@2.0.3", "https://registry.npmmirror.com/@electron/universal/-/universal-2.0.3.tgz", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="], + + "@electron/windows-sign": ["@electron/windows-sign@1.2.2", "https://registry.npmmirror.com/@electron/windows-sign/-/windows-sign-1.2.2.tgz", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -193,6 +218,8 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.6.tgz", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -205,6 +232,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], + + "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "https://registry.npmmirror.com/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.1.0.tgz", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], @@ -311,6 +342,10 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "https://registry.npmmirror.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + + "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.2.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.2.2.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], @@ -389,6 +424,8 @@ "@types/aria-query": ["@types/aria-query@5.0.4", "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], + "@types/chai": ["@types/chai@5.2.3", "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/d3": ["@types/d3@7.4.3", "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], @@ -463,16 +500,26 @@ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/fs-extra": ["@types/fs-extra@9.0.13", "https://registry.npmmirror.com/@types/fs-extra/-/fs-extra-9.0.13.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], + "@types/geojson": ["@types/geojson@7946.0.16", "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], "@types/hast": ["@types/hast@3.0.4", "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "https://registry.npmmirror.com/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], + + "@types/keyv": ["@types/keyv@3.1.4", "https://registry.npmmirror.com/@types/keyv/-/keyv-3.1.4.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], + "@types/mdast": ["@types/mdast@4.0.4", "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/ms": ["@types/ms@2.1.0", "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@25.9.1", "https://registry.npmmirror.com/@types/node/-/node-25.9.1.tgz", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/parse-json": ["@types/parse-json@4.0.2", "https://registry.npmmirror.com/@types/parse-json/-/parse-json-4.0.2.tgz", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + "@types/plist": ["@types/plist@3.0.5", "https://registry.npmmirror.com/@types/plist/-/plist-3.0.5.tgz", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], + "@types/prismjs": ["@types/prismjs@1.26.6", "https://registry.npmmirror.com/@types/prismjs/-/prismjs-1.26.6.tgz", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], "@types/prop-types": ["@types/prop-types@15.7.15", "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], @@ -481,10 +528,16 @@ "@types/react-dom": ["@types/react-dom@18.3.7", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + "@types/responselike": ["@types/responselike@1.0.3", "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/verror": ["@types/verror@1.10.11", "https://registry.npmmirror.com/@types/verror/-/verror-1.10.11.tgz", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="], + + "@types/yauzl": ["@types/yauzl@2.10.3", "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "https://registry.npmmirror.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], @@ -507,38 +560,78 @@ "@vitest/utils": ["@vitest/utils@3.2.4", "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], "@xterm/xterm": ["@xterm/xterm@6.0.0", "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "abbrev": ["abbrev@4.0.0", "https://registry.npmmirror.com/abbrev/-/abbrev-4.0.0.tgz", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], + "acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ajv-keywords": ["ajv-keywords@3.5.2", "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@5.2.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "https://registry.npmmirror.com/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="], + + "app-builder-lib": ["app-builder-lib@26.8.1", "https://registry.npmmirror.com/app-builder-lib/-/app-builder-lib-26.8.1.tgz", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], + "argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.0", "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "assert-plus": ["assert-plus@1.0.0", "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], + "assertion-error": ["assertion-error@2.0.1", "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "https://registry.npmmirror.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], + "astral-regex": ["astral-regex@2.0.0", "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], + + "async": ["async@3.2.6", "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "async-exit-hook": ["async-exit-hook@2.0.1", "https://registry.npmmirror.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], + "asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "at-least-node": ["at-least-node@1.0.0", "https://registry.npmmirror.com/at-least-node/-/at-least-node-1.0.0.tgz", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "https://registry.npmmirror.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], "balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "base64-js": ["base64-js@1.5.1", "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "boolean": ["boolean@3.2.0", "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + "brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "buffer": ["buffer@5.7.1", "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "buffer-from": ["buffer-from@1.1.2", "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "builder-util": ["builder-util@26.8.1", "https://registry.npmmirror.com/builder-util/-/builder-util-26.8.1.tgz", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="], + + "builder-util-runtime": ["builder-util-runtime@9.5.1", "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], + "cac": ["cac@6.7.14", "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "cacheable-lookup": ["cacheable-lookup@5.0.4", "https://registry.npmmirror.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], + + "cacheable-request": ["cacheable-request@7.0.4", "https://registry.npmmirror.com/cacheable-request/-/cacheable-request-7.0.4.tgz", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "callsites": ["callsites@3.1.0", "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], @@ -549,6 +642,8 @@ "chai": ["chai@5.3.3", "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "character-entities": ["character-entities@2.0.2", "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -563,9 +658,19 @@ "chevrotain-allstar": ["chevrotain-allstar@0.4.1", "https://registry.npmmirror.com/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^12.0.0" } }, "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA=="], + "chownr": ["chownr@3.0.0", "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "https://registry.npmmirror.com/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], + + "ci-info": ["ci-info@4.4.0", "https://registry.npmmirror.com/ci-info/-/ci-info-4.4.0.tgz", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + "classnames": ["classnames@2.5.1", "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], - "cliui": ["cliui@6.0.0", "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + "cli-truncate": ["cli-truncate@2.1.0", "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-2.1.0.tgz", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], + + "cliui": ["cliui@8.0.1", "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clone-response": ["clone-response@1.0.3", "https://registry.npmmirror.com/clone-response/-/clone-response-1.0.3.tgz", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], "clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -579,14 +684,24 @@ "commander": ["commander@8.3.0", "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "compare-version": ["compare-version@0.1.2", "https://registry.npmmirror.com/compare-version/-/compare-version-0.1.2.tgz", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="], + + "concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "confbox": ["confbox@0.1.8", "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "convert-source-map": ["convert-source-map@1.9.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + "core-util-is": ["core-util-is@1.0.2", "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + "cose-base": ["cose-base@1.0.3", "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], "cosmiconfig": ["cosmiconfig@7.1.0", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], + "crc": ["crc@3.8.0", "https://registry.npmmirror.com/crc/-/crc-3.8.0.tgz", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], + + "cross-dirname": ["cross-dirname@0.1.0", "https://registry.npmmirror.com/cross-dirname/-/cross-dirname-0.1.0.tgz", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], + "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-line-break": ["css-line-break@2.1.0", "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], @@ -683,8 +798,16 @@ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "decompress-response": ["decompress-response@6.0.0", "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "deep-eql": ["deep-eql@5.0.2", "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "defer-to-connect": ["defer-to-connect@2.0.1", "https://registry.npmmirror.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], + + "define-data-property": ["define-data-property@1.1.4", "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "delaunator": ["delaunator@5.1.0", "https://registry.npmmirror.com/delaunator/-/delaunator-5.1.0.tgz", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], "delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], @@ -693,26 +816,58 @@ "detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node": ["detect-node@2.1.0", "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + "devlop": ["devlop@1.1.0", "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "diff": ["diff@8.0.4", "https://registry.npmmirror.com/diff/-/diff-8.0.4.tgz", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "dijkstrajs": ["dijkstrajs@1.0.3", "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dir-compare": ["dir-compare@4.2.0", "https://registry.npmmirror.com/dir-compare/-/dir-compare-4.2.0.tgz", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], + + "dmg-builder": ["dmg-builder@26.8.1", "https://registry.npmmirror.com/dmg-builder/-/dmg-builder-26.8.1.tgz", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="], + + "dmg-license": ["dmg-license@1.0.11", "https://registry.npmmirror.com/dmg-license/-/dmg-license-1.0.11.tgz", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "dompurify": ["dompurify@3.3.3", "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.3.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="], + "dotenv": ["dotenv@16.6.1", "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "dotenv-expand": ["dotenv-expand@11.0.7", "https://registry.npmmirror.com/dotenv-expand/-/dotenv-expand-11.0.7.tgz", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], + "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ejs": ["ejs@3.1.10", "https://registry.npmmirror.com/ejs/-/ejs-3.1.10.tgz", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], + + "electron": ["electron@42.3.0", "https://registry.npmmirror.com/electron/-/electron-42.3.0.tgz", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-9ZiLdRXk+WDxW1OgIUz8J2rIQ5TYU9o629gCOjU48Q3dQiOmym7osWsH5Ubs/Jh4uuFLn6m6SBD2rmRXLAPz9g=="], + + "electron-builder": ["electron-builder@26.8.1", "https://registry.npmmirror.com/electron-builder/-/electron-builder-26.8.1.tgz", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="], + + "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "https://registry.npmmirror.com/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="], + + "electron-publish": ["electron-publish@26.8.1", "https://registry.npmmirror.com/electron-publish/-/electron-publish-26.8.1.tgz", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="], + + "electron-updater": ["electron-updater@6.8.3", "https://registry.npmmirror.com/electron-updater/-/electron-updater-6.8.3.tgz", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="], + + "electron-winstaller": ["electron-winstaller@5.4.0", "https://registry.npmmirror.com/electron-winstaller/-/electron-winstaller-5.4.0.tgz", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + "emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "end-of-stream": ["end-of-stream@1.4.5", "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "enhanced-resolve": ["enhanced-resolve@5.20.1", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], "entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "env-paths": ["env-paths@3.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-3.0.0.tgz", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + + "err-code": ["err-code@2.0.3", "https://registry.npmmirror.com/err-code/-/err-code-2.0.3.tgz", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + "error-ex": ["error-ex@1.3.4", "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.4.tgz", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -725,8 +880,12 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "es6-error": ["es6-error@4.1.1", "https://registry.npmmirror.com/es6-error/-/es6-error-4.1.1.tgz", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + "esbuild": ["esbuild@0.25.12", "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], @@ -735,8 +894,22 @@ "expect-type": ["expect-type@1.3.0", "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + + "extract-zip": ["extract-zip@2.0.1", "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + + "extsprintf": ["extsprintf@1.4.1", "https://registry.npmmirror.com/extsprintf/-/extsprintf-1.4.1.tgz", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fd-slicer": ["fd-slicer@1.1.0", "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "filelist": ["filelist@1.0.6", "https://registry.npmmirror.com/filelist/-/filelist-1.0.6.tgz", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], + "find-root": ["find-root@1.1.0", "https://registry.npmmirror.com/find-root/-/find-root-1.1.0.tgz", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], "find-up": ["find-up@4.1.0", "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -745,6 +918,10 @@ "form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "fs-extra": ["fs-extra@10.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + + "fs.realpath": ["fs.realpath@1.0.0", "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -755,16 +932,26 @@ "get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@5.2.0", "https://registry.npmmirror.com/get-stream/-/get-stream-5.2.0.tgz", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + "glob": ["glob@10.5.0", "https://registry.npmmirror.com/glob/-/glob-10.5.0.tgz", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "global-agent": ["global-agent@3.0.0", "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + + "globalthis": ["globalthis@1.0.4", "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "got": ["got@11.8.6", "https://registry.npmmirror.com/got/-/got-11.8.6.tgz", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="], + "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "hachure-fill": ["hachure-fill@0.5.2", "https://registry.npmmirror.com/hachure-fill/-/hachure-fill-0.5.2.tgz", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], "has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -779,6 +966,8 @@ "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "https://registry.npmmirror.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + "hosted-git-info": ["hosted-git-info@4.1.0", "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], "html-escaper": ["html-escaper@2.0.2", "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], @@ -787,16 +976,28 @@ "html2canvas": ["html2canvas@1.4.1", "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http2-wrapper": ["http2-wrapper@1.0.3", "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-corefoundation": ["iconv-corefoundation@1.1.7", "https://registry.npmmirror.com/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="], + "iconv-lite": ["iconv-lite@0.6.3", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "ieee754": ["ieee754@1.2.1", "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "indent-string": ["indent-string@4.0.0", "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inflight": ["inflight@1.0.6", "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "internmap": ["internmap@1.0.1", "https://registry.npmmirror.com/internmap/-/internmap-1.0.1.tgz", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], @@ -817,7 +1018,9 @@ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], - "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isbinaryfile": ["isbinaryfile@5.0.7", "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-5.0.7.tgz", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], + + "isexe": ["isexe@3.1.5", "https://registry.npmmirror.com/isexe/-/isexe-3.1.5.tgz", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], @@ -829,6 +1032,8 @@ "jackspeak": ["jackspeak@3.4.3", "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jake": ["jake@10.9.4", "https://registry.npmmirror.com/jake/-/jake-10.9.4.tgz", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + "jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": "lib/jiti-cli.mjs" }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -839,16 +1044,30 @@ "jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-buffer": ["json-buffer@3.0.1", "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stringify-safe": ["json-stringify-safe@5.0.1", "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + + "json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonfile": ["jsonfile@6.2.1", "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "katex": ["katex@0.16.45", "https://registry.npmmirror.com/katex/-/katex-0.16.45.tgz", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], + "keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "khroma": ["khroma@2.1.0", "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], "langium": ["langium@4.2.2", "https://registry.npmmirror.com/langium/-/langium-4.2.2.tgz", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ=="], "layout-base": ["layout-base@1.0.2", "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + "lazy-val": ["lazy-val@1.0.5", "https://registry.npmmirror.com/lazy-val/-/lazy-val-1.0.5.tgz", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], + "lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -877,15 +1096,23 @@ "locate-path": ["locate-path@5.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "https://registry.npmmirror.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], + + "lodash.isequal": ["lodash.isequal@4.5.0", "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], + "longest-streak": ["longest-streak@3.1.0", "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "loose-envify": ["loose-envify@1.4.0", "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loupe": ["loupe@3.2.1", "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], - "lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "https://registry.npmmirror.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], + + "lru-cache": ["lru-cache@6.0.0", "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "lucide-react": ["lucide-react@0.469.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.469.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="], @@ -899,6 +1126,8 @@ "marked": ["marked@15.0.12", "https://registry.npmmirror.com/marked/-/marked-15.0.12.tgz", { "bin": "bin/marked.js" }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "matcher": ["matcher@3.0.0", "https://registry.npmmirror.com/matcher/-/matcher-3.0.0.tgz", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], @@ -963,29 +1192,59 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "mime": ["mime@2.6.0", "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + "mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mimic-response": ["mimic-response@3.1.0", "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "min-indent": ["min-indent@1.0.1", "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "minizlib": ["minizlib@3.1.0", "https://registry.npmmirror.com/minizlib/-/minizlib-3.1.0.tgz", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "mkdirp": ["mkdirp@0.5.6", "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "mlly": ["mlly@1.8.2", "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "node-abi": ["node-abi@4.31.0", "https://registry.npmmirror.com/node-abi/-/node-abi-4.31.0.tgz", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw=="], + + "node-addon-api": ["node-addon-api@7.1.1", "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-api-version": ["node-api-version@0.2.1", "https://registry.npmmirror.com/node-api-version/-/node-api-version-0.2.1.tgz", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], + + "node-gyp": ["node-gyp@12.3.0", "https://registry.npmmirror.com/node-gyp/-/node-gyp-12.3.0.tgz", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="], + + "node-pty": ["node-pty@1.1.0", "https://registry.npmmirror.com/node-pty/-/node-pty-1.1.0.tgz", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], + + "nopt": ["nopt@9.0.0", "https://registry.npmmirror.com/nopt/-/nopt-9.0.0.tgz", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], + + "normalize-url": ["normalize-url@6.1.0", "https://registry.npmmirror.com/normalize-url/-/normalize-url-6.1.0.tgz", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], + "nwsapi": ["nwsapi@2.2.23", "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], + "object-keys": ["object-keys@1.1.1", "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "https://registry.npmmirror.com/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "https://registry.npmmirror.com/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], - "p-limit": ["p-limit@2.3.0", "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "p-cancelable": ["p-cancelable@2.1.1", "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-2.1.1.tgz", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], + + "p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@4.1.0", "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], @@ -1007,6 +1266,8 @@ "path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -1019,12 +1280,18 @@ "pathval": ["pathval@2.0.1", "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "pe-library": ["pe-library@0.4.1", "https://registry.npmmirror.com/pe-library/-/pe-library-0.4.1.tgz", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], + + "pend": ["pend@1.2.0", "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "pkg-types": ["pkg-types@1.3.1", "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "plist": ["plist@3.1.0", "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "pngjs": ["pngjs@5.0.0", "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "points-on-curve": ["points-on-curve@0.2.0", "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -1035,16 +1302,30 @@ "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + "postject": ["postject@1.0.0-alpha.6", "https://registry.npmmirror.com/postject/-/postject-1.0.0-alpha.6.tgz", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], + "pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "prism-react-renderer": ["prism-react-renderer@2.4.1", "https://registry.npmmirror.com/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="], + "proc-log": ["proc-log@6.1.0", "https://registry.npmmirror.com/proc-log/-/proc-log-6.1.0.tgz", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "progress": ["progress@2.0.3", "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "promise-retry": ["promise-retry@2.0.1", "https://registry.npmmirror.com/promise-retry/-/promise-retry-2.0.1.tgz", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "https://registry.npmmirror.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "property-information": ["property-information@7.1.0", "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "pump": ["pump@3.0.4", "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "qrcode": ["qrcode@1.5.4", "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "quick-lru": ["quick-lru@5.1.1", "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + "react": ["react@18.3.1", "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], "react-diff-viewer-continued": ["react-diff-viewer-continued@4.2.0", "https://registry.npmmirror.com/react-diff-viewer-continued/-/react-diff-viewer-continued-4.2.0.tgz", { "dependencies": { "@emotion/css": "^11.13.5", "@emotion/react": "^11.14.0", "classnames": "^2.5.1", "diff": "^8.0.3", "js-yaml": "^4.1.1", "memoize-one": "^6.0.0" }, "peerDependencies": { "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-KXeevuPpMRNDAtF878G04Yih/01DBBoC+RjDzWiA5S6TPtUzSfqF5XOlEWyXVWvJuz5n+EQ9QdUQd0ffK2By6w=="], @@ -1055,6 +1336,8 @@ "react-shiki": ["react-shiki@0.9.3", "https://registry.npmmirror.com/react-shiki/-/react-shiki-0.9.3.tgz", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^4.0.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F2Uju1/BeUTFQeS+3v3HM0Ry4p+8gcLC4ssObmXxwrzlwPJYq5RGAKcA1r5JBEnJCpEVKf9PajnwM+JMwZnzGg=="], + "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], + "redent": ["redent@3.0.0", "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "regex": ["regex@6.1.0", "https://registry.npmmirror.com/regex/-/regex-6.1.0.tgz", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], @@ -1067,10 +1350,22 @@ "require-main-filename": ["require-main-filename@2.0.0", "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resedit": ["resedit@1.7.2", "https://registry.npmmirror.com/resedit/-/resedit-1.7.2.tgz", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], + "resolve": ["resolve@1.22.11", "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], + "resolve-alpn": ["resolve-alpn@1.2.1", "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], + "resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "responselike": ["responselike@2.0.1", "https://registry.npmmirror.com/responselike/-/responselike-2.0.1.tgz", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], + + "retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "rimraf": ["rimraf@2.6.3", "https://registry.npmmirror.com/rimraf/-/rimraf-2.6.3.tgz", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="], + + "roarr": ["roarr@2.15.4", "https://registry.npmmirror.com/roarr/-/roarr-2.15.4.tgz", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "robust-predicates": ["robust-predicates@3.0.3", "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], "rolldown": ["rolldown@1.0.0-rc.17", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.17.tgz", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], @@ -1085,12 +1380,20 @@ "safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sanitize-filename": ["sanitize-filename@1.6.4", "https://registry.npmmirror.com/sanitize-filename/-/sanitize-filename-1.6.4.tgz", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="], + + "sax": ["sax@1.6.0", "https://registry.npmmirror.com/sax/-/sax-1.6.0.tgz", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "saxes": ["saxes@6.0.0", "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "scheduler": ["scheduler@0.23.2", "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], "semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "semver-compare": ["semver-compare@1.0.0", "https://registry.npmmirror.com/semver-compare/-/semver-compare-1.0.0.tgz", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + + "serialize-error": ["serialize-error@7.0.1", "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1101,16 +1404,28 @@ "siginfo": ["siginfo@2.0.0", "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "source-map": ["source-map@0.5.7", "https://registry.npmmirror.com/source-map/-/source-map-0.5.7.tgz", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + "simple-update-notifier": ["simple-update-notifier@2.0.0", "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], + + "slice-ansi": ["slice-ansi@3.0.0", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-3.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], + + "smart-buffer": ["smart-buffer@4.2.0", "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "source-map": ["source-map@0.6.1", "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "sprintf-js": ["sprintf-js@1.1.3", "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "stackback": ["stackback@0.0.2", "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "stat-mode": ["stat-mode@1.0.0", "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], + "std-env": ["std-env@3.10.0", "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1133,6 +1448,8 @@ "stylis": ["stylis@4.3.6", "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + "sumchecker": ["sumchecker@3.0.1", "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], + "supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1143,10 +1460,20 @@ "tapable": ["tapable@2.3.2", "https://registry.npmmirror.com/tapable/-/tapable-2.3.2.tgz", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + "tar": ["tar@7.5.15", "https://registry.npmmirror.com/tar/-/tar-7.5.15.tgz", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="], + + "temp": ["temp@0.9.4", "https://registry.npmmirror.com/temp/-/temp-0.9.4.tgz", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="], + + "temp-file": ["temp-file@3.4.0", "https://registry.npmmirror.com/temp-file/-/temp-file-3.4.0.tgz", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], + "test-exclude": ["test-exclude@7.0.2", "https://registry.npmmirror.com/test-exclude/-/test-exclude-7.0.2.tgz", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="], "text-segmentation": ["text-segmentation@1.0.3", "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "https://registry.npmmirror.com/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], + + "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "https://registry.npmmirror.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], + "tinybench": ["tinybench@2.9.0", "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@0.3.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], @@ -1163,20 +1490,32 @@ "tldts-core": ["tldts-core@6.1.86", "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "tmp": ["tmp@0.2.7", "https://registry.npmmirror.com/tmp/-/tmp-0.2.7.tgz", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], + + "tmp-promise": ["tmp-promise@3.0.3", "https://registry.npmmirror.com/tmp-promise/-/tmp-promise-3.0.3.tgz", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + "tough-cookie": ["tough-cookie@5.1.2", "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], "tr46": ["tr46@5.1.1", "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], "trim-lines": ["trim-lines@3.0.1", "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], + "ts-dedent": ["ts-dedent@2.2.0", "https://registry.npmmirror.com/ts-dedent/-/ts-dedent-2.2.0.tgz", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], "tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-fest": ["type-fest@0.13.1", "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ufo": ["ufo@1.6.3", "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "undici": ["undici@7.26.0", "https://registry.npmmirror.com/undici/-/undici-7.26.0.tgz", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="], + + "undici-types": ["undici-types@7.24.6", "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "unist-util-is": ["unist-util-is@6.0.1", "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], "unist-util-position": ["unist-util-position@5.0.0", "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], @@ -1187,12 +1526,20 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universalify": ["universalify@2.0.1", "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "utf8-byte-length": ["utf8-byte-length@1.0.5", "https://registry.npmmirror.com/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], + "util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "utrie": ["utrie@1.0.2", "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], "uuid": ["uuid@11.1.0", "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "verror": ["verror@1.10.1", "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], + "vfile": ["vfile@6.0.3", "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], @@ -1225,29 +1572,39 @@ "whatwg-url": ["whatwg-url@14.2.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], - "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which": ["which@5.0.0", "https://registry.npmmirror.com/which/-/which-5.0.0.tgz", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "which-module": ["which-module@2.0.1", "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], "why-is-node-running": ["why-is-node-running@2.3.0", "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "wrap-ansi": ["wrap-ansi@6.2.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "xml-name-validator": ["xml-name-validator@5.0.0", "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "xmlchars": ["xmlchars@2.2.0", "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], - "y18n": ["y18n@4.0.3", "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + "y18n": ["y18n@5.0.8", "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@5.0.0", "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yaml": ["yaml@2.8.3", "https://registry.npmmirror.com/yaml/-/yaml-2.8.3.tgz", { "bin": "bin.mjs" }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], - "yargs": ["yargs@15.4.1", "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - "yargs-parser": ["yargs-parser@18.1.3", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "yargs-parser": ["yargs-parser@21.1.1", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yauzl": ["yauzl@2.10.0", "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + + "yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zustand": ["zustand@5.0.12", "https://registry.npmmirror.com/zustand/-/zustand-5.0.12.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["immer", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], @@ -1255,6 +1612,28 @@ "@antfu/install-pkg/tinyexec": ["tinyexec@1.1.1", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.1.tgz", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@electron/asar/commander": ["commander@5.1.0", "https://registry.npmmirror.com/commander/-/commander-5.1.0.tgz", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + + "@electron/asar/glob": ["glob@7.2.3", "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "@electron/asar/minimatch": ["minimatch@3.1.5", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], + + "@electron/universal/fs-extra": ["fs-extra@11.3.5", "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.5.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], + + "@electron/universal/minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "@electron/windows-sign/fs-extra": ["fs-extra@11.3.5", "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.5.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], + + "@emotion/babel-plugin/source-map": ["source-map@0.5.7", "https://registry.npmmirror.com/source-map/-/source-map-0.5.7.tgz", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + "@emotion/babel-plugin/stylis": ["stylis@4.2.0", "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], "@emotion/cache/stylis": ["stylis@4.2.0", "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], @@ -1265,14 +1644,26 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.2.tgz", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "https://registry.npmmirror.com/@electron/get/-/get-3.1.0.tgz", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], + + "app-builder-lib/ci-info": ["ci-info@4.3.1", "https://registry.npmmirror.com/ci-info/-/ci-info-4.3.1.tgz", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + "ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "chalk/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "clone-response/mimic-response": ["mimic-response@1.0.1", "https://registry.npmmirror.com/mimic-response/-/mimic-response-1.0.1.tgz", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], + "cosmiconfig/yaml": ["yaml@1.10.3", "https://registry.npmmirror.com/yaml/-/yaml-1.10.3.tgz", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + "cross-spawn/which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "cssstyle/rrweb-cssom": ["rrweb-cssom@0.8.0", "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "https://registry.npmmirror.com/cose-base/-/cose-base-2.2.0.tgz", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], @@ -1283,18 +1674,52 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "https://registry.npmmirror.com/d3-shape/-/d3-shape-1.3.7.tgz", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "dir-compare/minimatch": ["minimatch@3.1.5", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "electron/@types/node": ["@types/node@24.12.4", "https://registry.npmmirror.com/@types/node/-/node-24.12.4.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], + + "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "https://registry.npmmirror.com/fs-extra/-/fs-extra-7.0.1.tgz", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + + "filelist/minimatch": ["minimatch@5.1.9", "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.9.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + + "foreground-child/signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "glob/minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "hoist-non-react-statics/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-1.7.2.tgz", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + + "lru-cache/yallist": ["yallist@4.0.0", "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "mermaid/marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "node-gyp/env-paths": ["env-paths@2.2.1", "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "node-gyp/undici": ["undici@6.26.0", "https://registry.npmmirror.com/undici/-/undici-6.26.0.tgz", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], + + "node-gyp/which": ["which@6.0.1", "https://registry.npmmirror.com/which/-/which-6.0.1.tgz", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "p-locate/p-limit": ["p-limit@2.3.0", "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "postject/commander": ["commander@9.5.0", "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + + "qrcode/yargs": ["yargs@15.4.1", "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "rimraf/glob": ["glob@7.2.3", "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="], + "slice-ansi/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "tiny-async-pool/semver": ["semver@5.7.2", "https://registry.npmmirror.com/semver/-/semver-5.7.2.tgz", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + "vite-node/vite": ["vite@6.4.1", "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx"], "bin": "bin/vite.js" }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], "vitest/tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -1305,24 +1730,76 @@ "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.15", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + + "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.0", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + + "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "cross-spawn/which/isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.15", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + + "electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "electron/@types/node/undici-types": ["undici-types@7.16.0", "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.0", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "node-gyp/which/isexe": ["isexe@4.0.0", "https://registry.npmmirror.com/isexe/-/isexe-4.0.0.tgz", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "qrcode/yargs/cliui": ["cliui@6.0.0", "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "qrcode/yargs/y18n": ["y18n@4.0.3", "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + + "rimraf/glob/minimatch": ["minimatch@3.1.5", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "vite-node/vite/postcss": ["postcss@8.5.8", "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "vite-node/vite/tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "vitest/vite/postcss": ["postcss@8.5.8", "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.15", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.15.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + + "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/desktop/electron/ipc/capabilities.test.ts b/desktop/electron/ipc/capabilities.test.ts new file mode 100644 index 00000000..044c22a1 --- /dev/null +++ b/desktop/electron/ipc/capabilities.test.ts @@ -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) + }) +}) diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts new file mode 100644 index 00000000..6721d8a5 --- /dev/null +++ b/desktop/electron/ipc/capabilities.ts @@ -0,0 +1,122 @@ +import { ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './channels' + +type Validator = (payload: unknown) => boolean + +const isRecord = (value: unknown): value is Record => + 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, 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 + +const allowedChannels = new Set( + 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) +} diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts new file mode 100644 index 00000000..58613476 --- /dev/null +++ b/desktop/electron/ipc/channels.ts @@ -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] diff --git a/desktop/electron/ipc/previewMessage.ts b/desktop/electron/ipc/previewMessage.ts new file mode 100644 index 00000000..2fbc83e2 --- /dev/null +++ b/desktop/electron/ipc/previewMessage.ts @@ -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 } + +function byteLength(input: string): number { + return new TextEncoder().encode(input).byteLength +} + +function isPlainRecord(value: unknown): value is Record { + 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 + } +} diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts new file mode 100644 index 00000000..dbe7a3a9 --- /dev/null +++ b/desktop/electron/main.ts @@ -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: '' } + : {} + 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( + channel: ElectronIpcChannel, + handler: (event: Electron.IpcMainInvokeEvent, payload: unknown) => T | Promise, +) { + 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 { + const { command, args } = payload as { command: string, args?: Record } + + 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[1])) + registerHandler(ELECTRON_IPC_CHANNELS.dialogSave, (event, payload) => + saveDialog(currentWindow(event), payload as Parameters[1])) + registerHandler(ELECTRON_IPC_CHANNELS.updateCheck, (_event, payload) => + getUpdaterService().checkForUpdates(payload as Parameters[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[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() +}) diff --git a/desktop/electron/preload.ts b/desktop/electron/preload.ts new file mode 100644 index 00000000..1c8091f6 --- /dev/null +++ b/desktop/electron/preload.ts @@ -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(channel: ElectronIpcChannel, payload?: unknown): Promise { + return ipcRenderer.invoke(channel, payload) as Promise + }, + subscribe( + channel: ElectronEventChannel, + handler: (payload: T) => void, + ): Promise { + const listener = (_event: Electron.IpcRendererEvent, payload: T) => handler(payload) + ipcRenderer.on(channel, listener) + return Promise.resolve(() => { + ipcRenderer.removeListener(channel, listener) + }) + }, +}) + +contextBridge.exposeInMainWorld('desktopHost', electronHost) diff --git a/desktop/electron/preview-preload.ts b/desktop/electron/preview-preload.ts new file mode 100644 index 00000000..cf7a343f --- /dev/null +++ b/desktop/electron/preview-preload.ts @@ -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() diff --git a/desktop/electron/services/appMode.test.ts b/desktop/electron/services/appMode.test.ts new file mode 100644 index 00000000..1dde171a --- /dev/null +++ b/desktop/electron/services/appMode.test.ts @@ -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) + }) +}) diff --git a/desktop/electron/services/appMode.ts b/desktop/electron/services/appMode.ts new file mode 100644 index 00000000..fffdb298 --- /dev/null +++ b/desktop/electron/services/appMode.ts @@ -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), + } +} diff --git a/desktop/electron/services/dialogs.test.ts b/desktop/electron/services/dialogs.test.ts new file mode 100644 index 00000000..6bfa4047 --- /dev/null +++ b/desktop/electron/services/dialogs.test.ts @@ -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'] }], + }) + }) + +}) diff --git a/desktop/electron/services/dialogs.ts b/desktop/electron/services/dialogs.ts new file mode 100644 index 00000000..b5e40c6b --- /dev/null +++ b/desktop/electron/services/dialogs.ts @@ -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 { + 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 { + 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 +} diff --git a/desktop/electron/services/keychain.test.ts b/desktop/electron/services/keychain.test.ts new file mode 100644 index 00000000..bf70e967 --- /dev/null +++ b/desktop/electron/services/keychain.test.ts @@ -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() + }) +}) diff --git a/desktop/electron/services/keychain.ts b/desktop/electron/services/keychain.ts new file mode 100644 index 00000000..44074210 --- /dev/null +++ b/desktop/electron/services/keychain.ts @@ -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 +} diff --git a/desktop/electron/services/menu.test.ts b/desktop/electron/services/menu.test.ts new file mode 100644 index 00000000..5330181d --- /dev/null +++ b/desktop/electron/services/menu.test.ts @@ -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 + const existing = store[menuMocksKey] as ReturnType | 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') + }) +}) diff --git a/desktop/electron/services/menu.ts b/desktop/electron/services/menu.ts new file mode 100644 index 00000000..2f750c90 --- /dev/null +++ b/desktop/electron/services/menu.ts @@ -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)) +} diff --git a/desktop/electron/services/notificationSmoke.test.ts b/desktop/electron/services/notificationSmoke.test.ts new file mode 100644 index 00000000..27bd0e3b --- /dev/null +++ b/desktop/electron/services/notificationSmoke.test.ts @@ -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 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 + 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) + expect(entry).toMatchObject({ + event: 'renderer_ack', + payload: { + target: { type: 'session', sessionId: 'session-smoke' }, + }, + }) + }) +}) diff --git a/desktop/electron/services/notificationSmoke.ts b/desktop/electron/services/notificationSmoke.ts new file mode 100644 index 00000000..fda82739 --- /dev/null +++ b/desktop/electron/services/notificationSmoke.ts @@ -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 +} diff --git a/desktop/electron/services/notifications.test.ts b/desktop/electron/services/notifications.test.ts new file mode 100644 index 00000000..d8b93447 --- /dev/null +++ b/desktop/electron/services/notifications.test.ts @@ -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 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 + 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() + }) +}) diff --git a/desktop/electron/services/notifications.ts b/desktop/electron/services/notifications.ts new file mode 100644 index 00000000..68156ab4 --- /dev/null +++ b/desktop/electron/services/notifications.ts @@ -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() + +export function validateNotificationOptions(value: unknown): value is DesktopNotificationOptions { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const record = value as Record + 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 +} diff --git a/desktop/electron/services/preview.test.ts b/desktop/electron/services/preview.test.ts new file mode 100644 index 00000000..f74c2f64 --- /dev/null +++ b/desktop/electron/services/preview.test.ts @@ -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') + }) +}) diff --git a/desktop/electron/services/preview.ts b/desktop/electron/services/preview.ts new file mode 100644 index 00000000..fa7b67a0 --- /dev/null +++ b/desktop/electron/services/preview.ts @@ -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 + executeJavaScript(script: string): Promise + 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 { + 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 { + 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 { + 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 { + if (view.webContents.isDestroyed?.()) return + const script = readFileSync(resolvePreviewScriptPath(this.previewScriptPath), 'utf8') + await view.webContents.executeJavaScript(script) + } +} diff --git a/desktop/electron/services/rendererEntry.test.ts b/desktop/electron/services/rendererEntry.test.ts new file mode 100644 index 00000000..12dc336e --- /dev/null +++ b/desktop/electron/services/rendererEntry.test.ts @@ -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') + }) +}) diff --git a/desktop/electron/services/rendererEntry.ts b/desktop/electron/services/rendererEntry.ts new file mode 100644 index 00000000..33e970ca --- /dev/null +++ b/desktop/electron/services/rendererEntry.ts @@ -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') +} diff --git a/desktop/electron/services/serverRuntime.ts b/desktop/electron/services/serverRuntime.ts new file mode 100644 index 00000000..e16134c3 --- /dev/null +++ b/desktop/electron/services/serverRuntime.ts @@ -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 | 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 { + 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 { + if (this.server) return this.server.url + if (this.startupError) throw new Error(this.startupError) + return await this.startServer() + } + + async restartAdaptersSidecars(): Promise { + 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 { + 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}`) + }) + } +} diff --git a/desktop/electron/services/shell.test.ts b/desktop/electron/services/shell.test.ts new file mode 100644 index 00000000..d814fb79 --- /dev/null +++ b/desktop/electron/services/shell.test.ts @@ -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') + }) +}) diff --git a/desktop/electron/services/shell.ts b/desktop/electron/services/shell.ts new file mode 100644 index 00000000..c4bcb4c8 --- /dev/null +++ b/desktop/electron/services/shell.ts @@ -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 { + 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 { + const { shell } = await import('electron') + await shell.openExternal(normalizeSystemSettingsUrl(target)) + return true +} + +export async function openSystemPath(target: string): Promise { + const { shell } = await import('electron') + const error = await shell.openPath(normalizeOpenPath(target)) + if (error) throw new Error(error) +} diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts new file mode 100644 index 00000000..8814cf9d --- /dev/null +++ b/desktop/electron/services/sidecarManager.test.ts @@ -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') + }) +}) diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts new file mode 100644 index 00000000..4a5207c1 --- /dev/null +++ b/desktop/electron/services/sidecarManager.ts @@ -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 + +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 { + 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 { + 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 { + 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 { + 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() +} diff --git a/desktop/electron/services/singleInstance.test.ts b/desktop/electron/services/singleInstance.test.ts new file mode 100644 index 00000000..35cdf81f --- /dev/null +++ b/desktop/electron/services/singleInstance.test.ts @@ -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)) + }) +}) diff --git a/desktop/electron/services/singleInstance.ts b/desktop/electron/services/singleInstance.ts new file mode 100644 index 00000000..026f620b --- /dev/null +++ b/desktop/electron/services/singleInstance.ts @@ -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 +} diff --git a/desktop/electron/services/terminal.test.ts b/desktop/electron/services/terminal.test.ts new file mode 100644 index 00000000..b3cceb26 --- /dev/null +++ b/desktop/electron/services/terminal.test.ts @@ -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) + }) +}) diff --git a/desktop/electron/services/terminal.ts b/desktop/electron/services/terminal.ts new file mode 100644 index 00000000..d7e07981 --- /dev/null +++ b/desktop/electron/services/terminal.ts @@ -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 +} + +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) + 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() + +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, platform: NodeJS.Platform = process.platform): Record { + 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 { + const env: Record = {} + 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 { + 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 { + const merged: Record = {} + 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 { + 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 +} + +async function resolvePtyFactory( + factory: TerminalPtyFactory | (() => Promise) | undefined, + nodePtySourceDir: string | undefined, + nodePtyCacheDir: string | undefined, +): Promise { + 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) + 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() + + 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 { + 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 + } +} diff --git a/desktop/electron/services/tray.test.ts b/desktop/electron/services/tray.test.ts new file mode 100644 index 00000000..f342c93c --- /dev/null +++ b/desktop/electron/services/tray.test.ts @@ -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)[trayMocksKey] as ReturnType | 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 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)[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)[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 }) + } + }) +}) diff --git a/desktop/electron/services/tray.ts b/desktop/electron/services/tray.ts new file mode 100644 index 00000000..eb0b6a3b --- /dev/null +++ b/desktop/electron/services/tray.ts @@ -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 { + 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() + }, + } +} diff --git a/desktop/electron/services/updateSmoke.test.ts b/desktop/electron/services/updateSmoke.test.ts new file mode 100644 index 00000000..70a5e794 --- /dev/null +++ b/desktop/electron/services/updateSmoke.test.ts @@ -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 }) + } + }) +}) diff --git a/desktop/electron/services/updateSmoke.ts b/desktop/electron/services/updateSmoke.ts new file mode 100644 index 00000000..dd119a22 --- /dev/null +++ b/desktop/electron/services/updateSmoke.ts @@ -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) { + 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 { + writeLog(this.config.logPath, { + event: 'check', + version: this.config.version, + }) + return { + updateInfo: { + version: this.config.version, + body: this.config.body, + }, + } + } + + async downloadUpdate(): Promise { + 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 +} diff --git a/desktop/electron/services/updater.test.ts b/desktop/electron/services/updater.test.ts new file mode 100644 index 00000000..9208c501 --- /dev/null +++ b/desktop/electron/services/updater.test.ts @@ -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 + downloadUpdate: ReturnType + quitAndInstall: ReturnType +} { + 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 + downloadUpdate: ReturnType + quitAndInstall: ReturnType + } + 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) + }) +}) diff --git a/desktop/electron/services/updater.ts b/desktop/electron/services/updater.ts new file mode 100644 index 00000000..1a1ec48e --- /dev/null +++ b/desktop/electron/services/updater.ts @@ -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 + downloadUpdate(): Promise + 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 +} + +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 { + 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 { + 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) + } +} diff --git a/desktop/electron/services/windowSmoke.test.ts b/desktop/electron/services/windowSmoke.test.ts new file mode 100644 index 00000000..a144ec14 --- /dev/null +++ b/desktop/electron/services/windowSmoke.test.ts @@ -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 }) + } + }) +}) diff --git a/desktop/electron/services/windowSmoke.ts b/desktop/electron/services/windowSmoke.ts new file mode 100644 index 00000000..05063922 --- /dev/null +++ b/desktop/electron/services/windowSmoke.ts @@ -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 +} + +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`) +} diff --git a/desktop/electron/services/windows.test.ts b/desktop/electron/services/windows.test.ts new file mode 100644 index 00000000..8193a1d8 --- /dev/null +++ b/desktop/electron/services/windows.test.ts @@ -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 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 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 }) + } + }) +}) diff --git a/desktop/electron/services/windows.ts b/desktop/electron/services/windows.ts new file mode 100644 index 00000000..879bbbe3 --- /dev/null +++ b/desktop/electron/services/windows.ts @@ -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 +export type WindowCreateBounds = + & Partial> + & Pick + +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>, +): boolean { + if (displays.length === 0) return true + return displays.some(display => + hasMeaningfulIntersection(state, display.workArea ?? display.bounds), + ) +} + +export function readWindowState( + app: App, + displays: Array>, + 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)) +} diff --git a/desktop/electron/services/zoom.test.ts b/desktop/electron/services/zoom.test.ts new file mode 100644 index 00000000..8fd22fa2 --- /dev/null +++ b/desktop/electron/services/zoom.test.ts @@ -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) + }) +}) diff --git a/desktop/electron/services/zoom.ts b/desktop/electron/services/zoom.ts new file mode 100644 index 00000000..5d0e5781 --- /dev/null +++ b/desktop/electron/services/zoom.ts @@ -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) +} diff --git a/desktop/electron/tsconfig.json b/desktop/electron/tsconfig.json new file mode 100644 index 00000000..2f933711 --- /dev/null +++ b/desktop/electron/tsconfig.json @@ -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"] +} diff --git a/desktop/package.json b/desktop/package.json index abe5b388..1b22ab90 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/scripts/build-linux.sh b/desktop/scripts/build-linux.sh new file mode 100755 index 00000000..17549e5e --- /dev/null +++ b/desktop/scripts/build-linux.sh @@ -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" </dev/null 2>&1 || true +fi diff --git a/desktop/scripts/build-macos-arm64.sh b/desktop/scripts/build-macos-arm64.sh index f5dabada..70ea0b1b 100755 --- a/desktop/scripts/build-macos-arm64.sh +++ b/desktop/scripts/build-macos-arm64.sh @@ -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 <&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 ` 来统一 - # 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" <&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}" diff --git a/desktop/scripts/build-sidecars.ts b/desktop/scripts/build-sidecars.ts index c113d117..82f477a4 100644 --- a/desktop/scripts/build-sidecars.ts +++ b/desktop/scripts/build-sidecars.ts @@ -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) { diff --git a/desktop/scripts/build-windows-x64.ps1 b/desktop/scripts/build-windows-x64.ps1 index ffd72f68..5f4029bb 100644 --- a/desktop/scripts/build-windows-x64.ps1 +++ b/desktop/scripts/build-windows-x64.ps1 @@ -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 -} diff --git a/desktop/scripts/clean-dmg-builder-cache.sh b/desktop/scripts/clean-dmg-builder-cache.sh new file mode 100644 index 00000000..2920e1ba --- /dev/null +++ b/desktop/scripts/clean-dmg-builder-cache.sh @@ -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 diff --git a/desktop/scripts/clean-electron-output.ts b/desktop/scripts/clean-electron-output.ts new file mode 100644 index 00000000..fa330894 --- /dev/null +++ b/desktop/scripts/clean-electron-output.ts @@ -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}`) diff --git a/desktop/scripts/dev-launcher.test.ts b/desktop/scripts/dev-launcher.test.ts new file mode 100644 index 00000000..007e22df --- /dev/null +++ b/desktop/scripts/dev-launcher.test.ts @@ -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') + }) +}) diff --git a/desktop/scripts/electron-dev.ts b/desktop/scripts/electron-dev.ts new file mode 100644 index 00000000..50e9fcf5 --- /dev/null +++ b/desktop/scripts/electron-dev.ts @@ -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() +} diff --git a/desktop/scripts/prepare-node-pty.ts b/desktop/scripts/prepare-node-pty.ts new file mode 100644 index 00000000..96e994fd --- /dev/null +++ b/desktop/scripts/prepare-node-pty.ts @@ -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)`) diff --git a/desktop/src-tauri/resources/preview-agent.js b/desktop/src-tauri/resources/preview-agent.js index b4c78fe2..4c3caf69 100644 --- a/desktop/src-tauri/resources/preview-agent.js +++ b/desktop/src-tauri/resources/preview-agent.js @@ -19,4 +19,4 @@ content: "" !important; display: none !important; }`,D5=function(M){W5(M,"."+$6+F5+GQ+` - .`+G6+z5+GQ)},W5=function(M,Q){var Z=M.ownerDocument;if(Z){var q=Z.createElement("style");q.textContent=Q,M.appendChild(q)}},HQ=function(){function M(){}return M.getOrigin=function(Q){var Z=M._link;if(!Z)return"about:blank";return Z.href=Q,Z.href=Z.href,Z.protocol+Z.hostname+Z.port},M.isSameOrigin=function(Q){return M.getOrigin(Q)===M._origin},M.setContext=function(Q){M._link=Q.document.createElement("a"),M._origin=M.getOrigin(Q.location.href)},M._origin="about:blank",M}(),O5=function(){function M(Q,Z){this.context=Q,this._options=Z,this._cache={}}return M.prototype.addImage=function(Q){var Z=Promise.resolve();if(this.has(Q))return Z;if(K6(Q)||A5(Q))return(this._cache[Q]=this.loadImage(Q)).catch(function(){}),Z;return Z},M.prototype.match=function(Q){return this._cache[Q]},M.prototype.loadImage=function(Q){return D(this,void 0,void 0,function(){var Z,q,J,j,Y=this;return A(this,function(V){switch(V.label){case 0:if(Z=HQ.isSameOrigin(Q),q=!H6(Q)&&this._options.useCORS===!0&&t.SUPPORT_CORS_IMAGES&&!Z,J=!H6(Q)&&!Z&&!K6(Q)&&typeof this._options.proxy==="string"&&t.SUPPORT_CORS_XHR&&!q,!Z&&this._options.allowTaint===!1&&!H6(Q)&&!K6(Q)&&!J&&!q)return[2];if(j=Q,!J)return[3,2];return[4,this.proxy(j)];case 1:j=V.sent(),V.label=2;case 2:return this.context.logger.debug("Added image "+Q.substring(0,256)),[4,new Promise(function(X,$){var G=new Image;if(G.onload=function(){return X(G)},G.onerror=$,R5(j)||q)G.crossOrigin="anonymous";if(G.src=j,G.complete===!0)setTimeout(function(){return X(G)},500);if(Y._options.imageTimeout>0)setTimeout(function(){return $("Timed out ("+Y._options.imageTimeout+"ms) loading image")},Y._options.imageTimeout)})];case 3:return[2,V.sent()]}})})},M.prototype.has=function(Q){return typeof this._cache[Q]<"u"},M.prototype.keys=function(){return Promise.resolve(Object.keys(this._cache))},M.prototype.proxy=function(Q){var Z=this,q=this._options.proxy;if(!q)throw Error("No proxy defined");var J=Q.substring(0,256);return new Promise(function(j,Y){var V=t.SUPPORT_RESPONSE_TYPE?"blob":"text",X=new XMLHttpRequest;X.onload=function(){if(X.status===200)if(V==="text")j(X.response);else{var H=new FileReader;H.addEventListener("load",function(){return j(H.result)},!1),H.addEventListener("error",function(U){return Y(U)},!1),H.readAsDataURL(X.response)}else Y("Failed to proxy resource "+J+" with status code "+X.status)},X.onerror=Y;var $=q.indexOf("?")>-1?"&":"?";if(X.open("GET",""+q+$+"url="+encodeURIComponent(Q)+"&responseType="+V),V!=="text"&&X instanceof XMLHttpRequest)X.responseType=V;if(Z._options.imageTimeout){var G=Z._options.imageTimeout;X.timeout=G,X.ontimeout=function(){return Y("Timed out ("+G+"ms) proxying "+J)}}X.send()})},M}(),N5=/^data:image\/svg\+xml/i,B5=/^data:image\/.*;base64,/i,L5=/^data:image\/.*/i,A5=function(M){return t.SUPPORT_SVG_DRAWING||!I5(M)},H6=function(M){return L5.test(M)},R5=function(M){return B5.test(M)},K6=function(M){return M.substr(0,4)==="blob"},I5=function(M){return M.substr(-3).toLowerCase()==="svg"||N5.test(M)},N=function(){function M(Q,Z){this.type=0,this.x=Q,this.y=Z}return M.prototype.add=function(Q,Z){return new M(this.x+Q,this.y+Z)},M}(),j0=function(M,Q,Z){return new N(M.x+(Q.x-M.x)*Z,M.y+(Q.y-M.y)*Z)},U8=function(){function M(Q,Z,q,J){this.type=1,this.start=Q,this.startControl=Z,this.endControl=q,this.end=J}return M.prototype.subdivide=function(Q,Z){var q=j0(this.start,this.startControl,Q),J=j0(this.startControl,this.endControl,Q),j=j0(this.endControl,this.end,Q),Y=j0(q,J,Q),V=j0(J,j,Q),X=j0(Y,V,Q);return Z?new M(this.start,q,Y,X):new M(X,V,j,this.end)},M.prototype.add=function(Q,Z){return new M(this.start.add(Q,Z),this.startControl.add(Q,Z),this.endControl.add(Q,Z),this.end.add(Q,Z))},M.prototype.reverse=function(){return new M(this.end,this.endControl,this.startControl,this.start)},M}(),$1=function(M){return M.type===1},w5=function(){function M(Q){var{styles:Z,bounds:q}=Q,J=R0(Z.borderTopLeftRadius,q.width,q.height),j=J[0],Y=J[1],V=R0(Z.borderTopRightRadius,q.width,q.height),X=V[0],$=V[1],G=R0(Z.borderBottomRightRadius,q.width,q.height),H=G[0],U=G[1],L=R0(Z.borderBottomLeftRadius,q.width,q.height),W=L[0],O=L[1],S=[];S.push((j+X)/q.width),S.push((W+H)/q.width),S.push((Y+O)/q.height),S.push(($+U)/q.height);var w=Math.max.apply(Math,S);if(w>1)j/=w,Y/=w,X/=w,$/=w,H/=w,U/=w,W/=w,O/=w;var I=q.width-X,b=q.height-U,y=q.width-H,C=q.height-O,B=Z.borderTopWidth,v=Z.borderRightWidth,x=Z.borderBottomWidth,E=Z.borderLeftWidth,r=u(Z.paddingTop,Q.bounds.width),q1=u(Z.paddingRight,Q.bounds.width),Y1=u(Z.paddingBottom,Q.bounds.width),h=u(Z.paddingLeft,Q.bounds.width);this.topLeftBorderDoubleOuterBox=j>0||Y>0?l(q.left+E/3,q.top+B/3,j-E/3,Y-B/3,_.TOP_LEFT):new N(q.left+E/3,q.top+B/3),this.topRightBorderDoubleOuterBox=j>0||Y>0?l(q.left+I,q.top+B/3,X-v/3,$-B/3,_.TOP_RIGHT):new N(q.left+q.width-v/3,q.top+B/3),this.bottomRightBorderDoubleOuterBox=H>0||U>0?l(q.left+y,q.top+b,H-v/3,U-x/3,_.BOTTOM_RIGHT):new N(q.left+q.width-v/3,q.top+q.height-x/3),this.bottomLeftBorderDoubleOuterBox=W>0||O>0?l(q.left+E/3,q.top+C,W-E/3,O-x/3,_.BOTTOM_LEFT):new N(q.left+E/3,q.top+q.height-x/3),this.topLeftBorderDoubleInnerBox=j>0||Y>0?l(q.left+E*2/3,q.top+B*2/3,j-E*2/3,Y-B*2/3,_.TOP_LEFT):new N(q.left+E*2/3,q.top+B*2/3),this.topRightBorderDoubleInnerBox=j>0||Y>0?l(q.left+I,q.top+B*2/3,X-v*2/3,$-B*2/3,_.TOP_RIGHT):new N(q.left+q.width-v*2/3,q.top+B*2/3),this.bottomRightBorderDoubleInnerBox=H>0||U>0?l(q.left+y,q.top+b,H-v*2/3,U-x*2/3,_.BOTTOM_RIGHT):new N(q.left+q.width-v*2/3,q.top+q.height-x*2/3),this.bottomLeftBorderDoubleInnerBox=W>0||O>0?l(q.left+E*2/3,q.top+C,W-E*2/3,O-x*2/3,_.BOTTOM_LEFT):new N(q.left+E*2/3,q.top+q.height-x*2/3),this.topLeftBorderStroke=j>0||Y>0?l(q.left+E/2,q.top+B/2,j-E/2,Y-B/2,_.TOP_LEFT):new N(q.left+E/2,q.top+B/2),this.topRightBorderStroke=j>0||Y>0?l(q.left+I,q.top+B/2,X-v/2,$-B/2,_.TOP_RIGHT):new N(q.left+q.width-v/2,q.top+B/2),this.bottomRightBorderStroke=H>0||U>0?l(q.left+y,q.top+b,H-v/2,U-x/2,_.BOTTOM_RIGHT):new N(q.left+q.width-v/2,q.top+q.height-x/2),this.bottomLeftBorderStroke=W>0||O>0?l(q.left+E/2,q.top+C,W-E/2,O-x/2,_.BOTTOM_LEFT):new N(q.left+E/2,q.top+q.height-x/2),this.topLeftBorderBox=j>0||Y>0?l(q.left,q.top,j,Y,_.TOP_LEFT):new N(q.left,q.top),this.topRightBorderBox=X>0||$>0?l(q.left+I,q.top,X,$,_.TOP_RIGHT):new N(q.left+q.width,q.top),this.bottomRightBorderBox=H>0||U>0?l(q.left+y,q.top+b,H,U,_.BOTTOM_RIGHT):new N(q.left+q.width,q.top+q.height),this.bottomLeftBorderBox=W>0||O>0?l(q.left,q.top+C,W,O,_.BOTTOM_LEFT):new N(q.left,q.top+q.height),this.topLeftPaddingBox=j>0||Y>0?l(q.left+E,q.top+B,Math.max(0,j-E),Math.max(0,Y-B),_.TOP_LEFT):new N(q.left+E,q.top+B),this.topRightPaddingBox=X>0||$>0?l(q.left+Math.min(I,q.width-v),q.top+B,I>q.width+v?0:Math.max(0,X-v),Math.max(0,$-B),_.TOP_RIGHT):new N(q.left+q.width-v,q.top+B),this.bottomRightPaddingBox=H>0||U>0?l(q.left+Math.min(y,q.width-E),q.top+Math.min(b,q.height-x),Math.max(0,H-v),Math.max(0,U-x),_.BOTTOM_RIGHT):new N(q.left+q.width-v,q.top+q.height-x),this.bottomLeftPaddingBox=W>0||O>0?l(q.left+E,q.top+Math.min(C,q.height-x),Math.max(0,W-E),Math.max(0,O-x),_.BOTTOM_LEFT):new N(q.left+E,q.top+q.height-x),this.topLeftContentBox=j>0||Y>0?l(q.left+E+h,q.top+B+r,Math.max(0,j-(E+h)),Math.max(0,Y-(B+r)),_.TOP_LEFT):new N(q.left+E+h,q.top+B+r),this.topRightContentBox=X>0||$>0?l(q.left+Math.min(I,q.width+E+h),q.top+B+r,I>q.width+E+h?0:X-E+h,$-(B+r),_.TOP_RIGHT):new N(q.left+q.width-(v+q1),q.top+B+r),this.bottomRightContentBox=H>0||U>0?l(q.left+Math.min(y,q.width-(E+h)),q.top+Math.min(b,q.height+B+r),Math.max(0,H-(v+q1)),U-(x+Y1),_.BOTTOM_RIGHT):new N(q.left+q.width-(v+q1),q.top+q.height-(x+Y1)),this.bottomLeftContentBox=W>0||O>0?l(q.left+E+h,q.top+C,Math.max(0,W-(E+h)),O-(x+Y1),_.BOTTOM_LEFT):new N(q.left+E+h,q.top+q.height-(x+Y1))}return M}(),_;(function(M){M[M.TOP_LEFT=0]="TOP_LEFT",M[M.TOP_RIGHT=1]="TOP_RIGHT",M[M.BOTTOM_RIGHT=2]="BOTTOM_RIGHT",M[M.BOTTOM_LEFT=3]="BOTTOM_LEFT"})(_||(_={}));var l=function(M,Q,Z,q,J){var j=4*((Math.sqrt(2)-1)/3),Y=Z*j,V=q*j,X=M+Z,$=Q+q;switch(J){case _.TOP_LEFT:return new U8(new N(M,$),new N(M,$-V),new N(X-Y,Q),new N(X,Q));case _.TOP_RIGHT:return new U8(new N(M,Q),new N(M+Y,Q),new N(X,$-V),new N(X,$));case _.BOTTOM_RIGHT:return new U8(new N(X,Q),new N(X,Q+V),new N(M+Y,$),new N(M,$));case _.BOTTOM_LEFT:default:return new U8(new N(X,$),new N(X-Y,$),new N(M,Q+V),new N(M,Q))}},F8=function(M){return[M.topLeftBorderBox,M.topRightBorderBox,M.bottomRightBorderBox,M.bottomLeftBorderBox]},T5=function(M){return[M.topLeftContentBox,M.topRightContentBox,M.bottomRightContentBox,M.bottomLeftContentBox]},z8=function(M){return[M.topLeftPaddingBox,M.topRightPaddingBox,M.bottomRightPaddingBox,M.bottomLeftPaddingBox]},C5=function(){function M(Q,Z,q){this.offsetX=Q,this.offsetY=Z,this.matrix=q,this.type=0,this.target=6}return M}(),D8=function(){function M(Q,Z){this.path=Q,this.target=Z,this.type=1}return M}(),E5=function(){function M(Q){this.opacity=Q,this.type=2,this.target=6}return M}(),S5=function(M){return M.type===0},KQ=function(M){return M.type===1},y5=function(M){return M.type===2},UQ=function(M,Q){if(M.length===Q.length)return M.some(function(Z,q){return Z===Q[q]});return!1},P5=function(M,Q,Z,q,J){return M.map(function(j,Y){switch(Y){case 0:return j.add(Q,Z);case 1:return j.add(Q+q,Z);case 2:return j.add(Q+q,Z+J);case 3:return j.add(Q,Z+J)}return j})},FQ=function(){function M(Q){this.element=Q,this.inlineLevel=[],this.nonInlineLevel=[],this.negativeZIndex=[],this.zeroOrAutoZIndexOrTransformedOrOpacity=[],this.positiveZIndex=[],this.nonPositionedFloats=[],this.nonPositionedInlineLevel=[]}return M}(),zQ=function(){function M(Q,Z){if(this.container=Q,this.parent=Z,this.effects=[],this.curves=new w5(this.container),this.container.styles.opacity<1)this.effects.push(new E5(this.container.styles.opacity));if(this.container.styles.transform!==null){var q=this.container.bounds.left+this.container.styles.transformOrigin[0].number,J=this.container.bounds.top+this.container.styles.transformOrigin[1].number,j=this.container.styles.transform;this.effects.push(new C5(q,J,j))}if(this.container.styles.overflowX!==0){var Y=F8(this.curves),V=z8(this.curves);if(UQ(Y,V))this.effects.push(new D8(Y,6));else this.effects.push(new D8(Y,2)),this.effects.push(new D8(V,4))}}return M.prototype.getEffects=function(Q){var Z=[2,3].indexOf(this.container.styles.position)===-1,q=this.parent,J=this.effects.slice(0);while(q){var j=q.effects.filter(function(X){return!KQ(X)});if(Z||q.container.styles.position!==0||!q.parent){if(J.unshift.apply(J,j),Z=[2,3].indexOf(q.container.styles.position)===-1,q.container.styles.overflowX!==0){var Y=F8(q.curves),V=z8(q.curves);if(!UQ(Y,V))J.unshift(new D8(V,6))}}else J.unshift.apply(J,j);q=q.parent}return J.filter(function(X){return i(X.target,Q)})},M}(),U6=function(M,Q,Z,q){M.container.elements.forEach(function(J){var j=i(J.flags,4),Y=i(J.flags,2),V=new zQ(J,M);if(i(J.styles.display,2048))q.push(V);var X=i(J.flags,8)?[]:q;if(j||Y){var $=j||J.styles.isPositioned()?Z:Q,G=new FQ(V);if(J.styles.isPositioned()||J.styles.opacity<1||J.styles.isTransformed()){var H=J.styles.zIndex.order;if(H<0){var U=0;$.negativeZIndex.some(function(W,O){if(H>W.element.container.styles.zIndex.order)return U=O,!1;else if(U>0)return!0;return!1}),$.negativeZIndex.splice(U,0,G)}else if(H>0){var L=0;$.positiveZIndex.some(function(W,O){if(H>=W.element.container.styles.zIndex.order)return L=O+1,!1;else if(L>0)return!0;return!1}),$.positiveZIndex.splice(L,0,G)}else $.zeroOrAutoZIndexOrTransformedOrOpacity.push(G)}else if(J.styles.isFloating())$.nonPositionedFloats.push(G);else $.nonPositionedInlineLevel.push(G);U6(V,G,j?G:Z,X)}else{if(J.styles.isInlineLevel())Q.inlineLevel.push(V);else Q.nonInlineLevel.push(V);U6(V,Q,Z,X)}if(i(J.flags,8))DQ(J,X)})},DQ=function(M,Q){var Z=M instanceof q6?M.start:1,q=M instanceof q6?M.reversed:!1;for(var J=0;J"u")return M[0];return Z},_5=function(M,Q,Z,q,J){var j=Q[0],Y=Q[1],V=Z[0],X=Z[1];switch(M){case 2:return[new N(Math.round(q.left),Math.round(q.top+Y)),new N(Math.round(q.left+q.width),Math.round(q.top+Y)),new N(Math.round(q.left+q.width),Math.round(X+q.top+Y)),new N(Math.round(q.left),Math.round(X+q.top+Y))];case 3:return[new N(Math.round(q.left+j),Math.round(q.top)),new N(Math.round(q.left+j+V),Math.round(q.top)),new N(Math.round(q.left+j+V),Math.round(q.height+q.top)),new N(Math.round(q.left+j),Math.round(q.height+q.top))];case 1:return[new N(Math.round(q.left+j),Math.round(q.top+Y)),new N(Math.round(q.left+j+V),Math.round(q.top+Y)),new N(Math.round(q.left+j+V),Math.round(q.top+Y+X)),new N(Math.round(q.left+j),Math.round(q.top+Y+X))];default:return[new N(Math.round(J.left),Math.round(J.top)),new N(Math.round(J.left+J.width),Math.round(J.top)),new N(Math.round(J.left+J.width),Math.round(J.height+J.top)),new N(Math.round(J.left),Math.round(J.height+J.top))]}},p5="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",NQ="Hidden Text",h5=function(){function M(Q){this._data={},this._document=Q}return M.prototype.parseMetrics=function(Q,Z){var q=this._document.createElement("div"),J=this._document.createElement("img"),j=this._document.createElement("span"),Y=this._document.body;q.style.visibility="hidden",q.style.fontFamily=Q,q.style.fontSize=Z,q.style.margin="0",q.style.padding="0",q.style.whiteSpace="nowrap",Y.appendChild(q),J.src=p5,J.width=1,J.height=1,J.style.margin="0",J.style.padding="0",J.style.verticalAlign="baseline",j.style.fontFamily=Q,j.style.fontSize=Z,j.style.margin="0",j.style.padding="0",j.appendChild(this._document.createTextNode(NQ)),q.appendChild(j),q.appendChild(J);var V=J.offsetTop-j.offsetTop+2;q.removeChild(j),q.appendChild(this._document.createTextNode(NQ)),q.style.lineHeight="normal",J.style.verticalAlign="super";var X=J.offsetTop-q.offsetTop+2;return Y.removeChild(q),{baseline:V,middle:X}},M.prototype.getMetrics=function(Q,Z){var q=Q+" "+Z;if(typeof this._data[q]>"u")this._data[q]=this.parseMetrics(Q,Z);return this._data[q]},M}(),BQ=function(){function M(Q,Z){this.context=Q,this.options=Z}return M}(),u5=1e4,l5=function(M){K(Q,M);function Q(Z,q){var J=M.call(this,Z,q)||this;if(J._activeEffects=[],J.canvas=q.canvas?q.canvas:document.createElement("canvas"),J.ctx=J.canvas.getContext("2d"),!q.canvas)J.canvas.width=Math.floor(q.width*q.scale),J.canvas.height=Math.floor(q.height*q.scale),J.canvas.style.width=q.width+"px",J.canvas.style.height=q.height+"px";return J.fontMetrics=new h5(document),J.ctx.scale(J.options.scale,J.options.scale),J.ctx.translate(-q.x,-q.y),J.ctx.textBaseline="bottom",J._activeEffects=[],J.context.logger.debug("Canvas renderer initialized ("+q.width+"x"+q.height+") with scale "+q.scale),J}return Q.prototype.applyEffects=function(Z){var q=this;while(this._activeEffects.length)this.popEffect();Z.forEach(function(J){return q.applyEffect(J)})},Q.prototype.applyEffect=function(Z){if(this.ctx.save(),y5(Z))this.ctx.globalAlpha=Z.opacity;if(S5(Z))this.ctx.translate(Z.offsetX,Z.offsetY),this.ctx.transform(Z.matrix[0],Z.matrix[1],Z.matrix[2],Z.matrix[3],Z.matrix[4],Z.matrix[5]),this.ctx.translate(-Z.offsetX,-Z.offsetY);if(KQ(Z))this.path(Z.path),this.ctx.clip();this._activeEffects.push(Z)},Q.prototype.popEffect=function(){this._activeEffects.pop(),this.ctx.restore()},Q.prototype.renderStack=function(Z){return D(this,void 0,void 0,function(){var q;return A(this,function(J){switch(J.label){case 0:if(q=Z.element.container.styles,!q.isVisible())return[3,2];return[4,this.renderStackContent(Z)];case 1:J.sent(),J.label=2;case 2:return[2]}})})},Q.prototype.renderNode=function(Z){return D(this,void 0,void 0,function(){return A(this,function(q){switch(q.label){case 0:if(i(Z.container.flags,16))debugger;if(!Z.container.styles.isVisible())return[3,3];return[4,this.renderNodeBackgroundAndBorders(Z)];case 1:return q.sent(),[4,this.renderNodeContent(Z)];case 2:q.sent(),q.label=3;case 3:return[2]}})})},Q.prototype.renderTextWithLetterSpacing=function(Z,q,J){var j=this;if(q===0)this.ctx.fillText(Z.text,Z.bounds.left,Z.bounds.top+J);else{var Y=M6(Z.text);Y.reduce(function(V,X){return j.ctx.fillText(X,V,Z.bounds.top+J),V+j.ctx.measureText(X).width},Z.bounds.left)}},Q.prototype.createFontStyle=function(Z){var q=Z.fontVariant.filter(function(Y){return Y==="normal"||Y==="small-caps"}).join(""),J=r5(Z.fontFamily).join(", "),j=A0(Z.fontSize)?""+Z.fontSize.number+Z.fontSize.unit:Z.fontSize.number+"px";return[[Z.fontStyle,q,Z.fontWeight,j,J].join(" "),J,j]},Q.prototype.renderTextNode=function(Z,q){return D(this,void 0,void 0,function(){var J,j,Y,V,X,$,G,H,U=this;return A(this,function(L){return J=this.createFontStyle(q),j=J[0],Y=J[1],V=J[2],this.ctx.font=j,this.ctx.direction=q.direction===1?"rtl":"ltr",this.ctx.textAlign="left",this.ctx.textBaseline="alphabetic",X=this.fontMetrics.getMetrics(Y,V),$=X.baseline,G=X.middle,H=q.paintOrder,Z.textBounds.forEach(function(W){H.forEach(function(O){switch(O){case 0:U.ctx.fillStyle=n(q.color),U.renderTextWithLetterSpacing(W,q.letterSpacing,$);var S=q.textShadow;if(S.length&&W.text.trim().length)S.slice(0).reverse().forEach(function(w){U.ctx.shadowColor=n(w.color),U.ctx.shadowOffsetX=w.offsetX.number*U.options.scale,U.ctx.shadowOffsetY=w.offsetY.number*U.options.scale,U.ctx.shadowBlur=w.blur.number,U.renderTextWithLetterSpacing(W,q.letterSpacing,$)}),U.ctx.shadowColor="",U.ctx.shadowOffsetX=0,U.ctx.shadowOffsetY=0,U.ctx.shadowBlur=0;if(q.textDecorationLine.length)U.ctx.fillStyle=n(q.textDecorationColor||q.color),q.textDecorationLine.forEach(function(w){switch(w){case 1:U.ctx.fillRect(W.bounds.left,Math.round(W.bounds.top+$),W.bounds.width,1);break;case 2:U.ctx.fillRect(W.bounds.left,Math.round(W.bounds.top),W.bounds.width,1);break;case 3:U.ctx.fillRect(W.bounds.left,Math.ceil(W.bounds.top+G),W.bounds.width,1);break}});break;case 1:if(q.webkitTextStrokeWidth&&W.text.trim().length)U.ctx.strokeStyle=n(q.webkitTextStrokeColor),U.ctx.lineWidth=q.webkitTextStrokeWidth,U.ctx.lineJoin=window.chrome?"miter":"round",U.ctx.strokeText(W.text,W.bounds.left,W.bounds.top+$);U.ctx.strokeStyle="",U.ctx.lineWidth=0,U.ctx.lineJoin="miter";break}})}),[2]})})},Q.prototype.renderReplacedElement=function(Z,q,J){if(J&&Z.intrinsicWidth>0&&Z.intrinsicHeight>0){var j=O8(Z),Y=z8(q);this.path(Y),this.ctx.save(),this.ctx.clip(),this.ctx.drawImage(J,0,0,Z.intrinsicWidth,Z.intrinsicHeight,j.left,j.top,j.width,j.height),this.ctx.restore()}},Q.prototype.renderNodeContent=function(Z){return D(this,void 0,void 0,function(){var q,J,j,Y,V,X,I,I,$,G,H,U,y,L,W,C,O,S,w,I,b,y,C;return A(this,function(B){switch(B.label){case 0:this.applyEffects(Z.getEffects(4)),q=Z.container,J=Z.curves,j=q.styles,Y=0,V=q.textNodes,B.label=1;case 1:if(!(Y0&&e>0)x=j.ctx.createPattern(C,"repeat"),j.renderRepeat(r,x,Z1,J1)}else if(yq(G)){if(E=F6(Z,q,[null,null,null]),r=E[0],q1=E[1],Y1=E[2],h=E[3],e=E[4],b1=G.position.length===0?[p8]:G.position,Z1=u(b1[0],h),J1=u(b1[b1.length-1],e),d1=wq(G,Z1,J1,h,e),W1=d1[0],g1=d1[1],W1>0&&g1>0)if(s1=j.ctx.createRadialGradient(q1+Z1,Y1+J1,0,q1+Z1,Y1+J1,W1),V9(G.stops,W1*2).forEach(function(P0){return s1.addColorStop(P0.stop,n(P0.color))}),j.path(r),j.ctx.fillStyle=s1,W1!==g1)a1=Z.bounds.left+0.5*Z.bounds.width,w1=Z.bounds.top+0.5*Z.bounds.height,r1=g1/W1,T1=1/r1,j.ctx.save(),j.ctx.translate(a1,w1),j.ctx.transform(1,0,0,r1,0,0),j.ctx.translate(-a1,-w1),j.ctx.fillRect(q1,T1*(Y1-w1)+w1,h,e*T1),j.ctx.restore();else j.ctx.fill()}V0.label=6;case 6:return q--,[2]}})},j=this,Y=0,V=Z.styles.backgroundImage.slice(0).reverse(),$.label=1;case 1:if(!(Y0))return[3,11];if(G.style!==2)return[3,5];return[4,this.renderDashedDottedBorder(G.color,G.width,V,Z.curves,2)];case 4:return U.sent(),[3,11];case 5:if(G.style!==3)return[3,7];return[4,this.renderDashedDottedBorder(G.color,G.width,V,Z.curves,3)];case 6:return U.sent(),[3,11];case 7:if(G.style!==4)return[3,9];return[4,this.renderDoubleBorder(G.color,G.width,V,Z.curves)];case 8:return U.sent(),[3,11];case 9:return[4,this.renderSolidBorder(G.color,V,Z.curves)];case 10:U.sent(),U.label=11;case 11:V++,U.label=12;case 12:return X++,[3,3];case 13:return[2]}})})},Q.prototype.renderDashedDottedBorder=function(Z,q,J,j,Y){return D(this,void 0,void 0,function(){var V,X,$,G,H,U,L,W,O,S,w,I,b,y,C,B,C,B;return A(this,function(v){if(this.ctx.save(),V=v5(j,J),X=WQ(j,J),Y===2)this.path(X),this.ctx.clip();if($1(X[0]))$=X[0].start.x,G=X[0].start.y;else $=X[0].x,G=X[0].y;if($1(X[1]))H=X[1].end.x,U=X[1].end.y;else H=X[1].x,U=X[1].y;if(J===0||J===2)L=Math.abs($-H);else L=Math.abs(G-U);if(this.ctx.beginPath(),Y===3)this.formatPath(V);else this.formatPath(X.slice(0,2));if(W=q<3?q*3:q*2,O=q<3?q*2:q,Y===3)W=q,O=q;if(S=!0,L<=W*2)S=!1;else if(L<=W*2+O)w=L/(2*W+O),W*=w,O*=w;else I=Math.floor((L+O)/(W+O)),b=(L-I*W)/(I-1),y=(L-(I+1)*W)/I,O=y<=0||Math.abs(O-b)F.postToHost(AQ(D));return{reportReady:()=>z({type:"ready"}),reportNavigated:()=>z({type:"navigated",url:F.location.href,title:F.title}),reportError:(D)=>z({type:"error",message:D}),send:z,on(D,A){let T=K.get(D)??[];T.push(A),K.set(D,T)},handleHostRaw(D){let A=RQ(D);if(!A)return;for(let T of K.get(A.type)??[])T(A)}}}var O6=$7(wQ(),1);function K7(F,K,z){let D=Math.max(F,K);if(D<=z)return{width:F,height:K};let A=z/D;return{width:Math.round(F*A),height:Math.round(K*A)}}async function W6(F,K=1600,z=0.85){let D=new Image;await new Promise((d,s)=>{D.onload=()=>d(),D.onerror=()=>s(Error("load")),D.src=F});let{width:A,height:T}=K7(D.naturalWidth,D.naturalHeight,K),f=document.createElement("canvas");return f.width=A,f.height=T,f.getContext("2d").drawImage(D,0,0,A,T),f.toDataURL("image/png",z)}async function N6(F,K){let z=F==="element"&&K?K:document.body,D=await O6.default(z,{...F==="viewport"?{windowWidth:window.innerWidth,height:window.innerHeight}:{},useCORS:!0,logging:!1});return W6(D.toDataURL("image/png"))}function g(F,K,z){F.setProperty(K,z,"important")}var p1=26,TQ=4,_1=4;function CQ(F,K,z){return Math.max(K,Math.min(F,z))}function U7(F){let K=F.left+F.width/2-p1/2,z=Math.max(_1,window.innerWidth-p1-_1),D=CQ(K,_1,z),A=F.top-p1-TQ,T=F.bottom+TQ;if(A>=_1)return{left:D,top:A};if(T+p1<=window.innerHeight-_1)return{left:D,top:T};return{left:D,top:CQ(A,_1,Math.max(_1,window.innerHeight-p1-_1))}}function F7(F,K){let z=F.getBoundingClientRect(),D=document.createElement("div");D.dataset.previewSelectionAnnotationRoot="true",D.setAttribute("aria-hidden","true"),g(D.style,"position","fixed"),g(D.style,"inset","0"),g(D.style,"overflow","visible"),g(D.style,"pointer-events","none"),g(D.style,"z-index","2147483647");let A=document.createElement("div");A.dataset.previewSelectionAnnotation="true",g(A.style,"position","fixed"),g(A.style,"left",`${z.left}px`),g(A.style,"top",`${z.top}px`),g(A.style,"width",`${z.width}px`),g(A.style,"height",`${z.height}px`),g(A.style,"box-sizing","border-box"),g(A.style,"border","3px solid #2f7bff"),g(A.style,"border-radius","8px"),g(A.style,"background","rgba(47, 123, 255, 0.16)"),g(A.style,"box-shadow","0 0 0 2px rgba(255, 255, 255, 0.9)"),g(A.style,"pointer-events","none");let T=document.createElement("div");T.dataset.previewSelectionBadge="true",T.textContent=String(K);let f=U7(z);return g(T.style,"position","fixed"),g(T.style,"left",`${f.left}px`),g(T.style,"top",`${f.top}px`),g(T.style,"display","flex"),g(T.style,"align-items","center"),g(T.style,"justify-content","center"),g(T.style,"width",`${p1}px`),g(T.style,"height",`${p1}px`),g(T.style,"border-radius","999px"),g(T.style,"background","#2f7bff"),g(T.style,"border","2px solid #ffffff"),g(T.style,"color","white"),g(T.style,"font",'700 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'),g(T.style,"line-height",`${p1}px`),g(T.style,"box-sizing","border-box"),g(T.style,"box-shadow","0 1px 8px rgba(47, 123, 255, 0.28), 0 0 0 1px rgba(47, 123, 255, 0.22)"),D.appendChild(A),D.appendChild(T),document.documentElement.appendChild(D),D}async function EQ(F,K=1){let z=F7(F,K);try{let D=await O6.default(document.documentElement,{useCORS:!0,logging:!1,scale:1,x:window.scrollX,y:window.scrollY,width:window.innerWidth,height:window.innerHeight,windowWidth:window.innerWidth,windowHeight:window.innerHeight,scrollX:window.scrollX,scrollY:window.scrollY});return W6(D.toDataURL("image/png"))}finally{z.remove()}}function SQ(F){let K=F.parentElement;if(!K||K.tagName.toLowerCase()==="body"||K.tagName.toLowerCase()==="html")return null;return K}function yQ(F){return F.firstElementChild}function PQ(F){let K=!1,z=null,D=null,A=document.createElement("div");A.style.cssText="position:fixed;inset:0;pointer-events:none;z-index:2147483647";let T=A.attachShadow({mode:"open"}),f=document.createElement("div");f.style.cssText="position:fixed;border:2px solid #2f7bff;background:rgba(47,123,255,.12);pointer-events:none",f.hidden=!0,T.appendChild(f);let d=(k)=>{if(!k){f.hidden=!0;return}let P=k.getBoundingClientRect();f.hidden=!1,f.style.left=`${P.left}px`,f.style.top=`${P.top}px`,f.style.width=`${P.width}px`,f.style.height=`${P.height}px`},s=(k)=>{z=k,d(k),F.onSelect(k)};return{enter(){if(K=!0,z=null,D=null,!A.isConnected)document.documentElement.appendChild(A)},hover(k){if(!K||z)return;D=k,d(k)},select(){if(D)s(D)},climb(){if(z){let k=SQ(z);if(k)z=k,d(k)}},descend(){if(z){let k=yQ(z);if(k)z=k,d(k)}},current(){return z},exit(){K=!1,z=null,D=null,f.hidden=!0,A.remove()}}}function z7(F){let K=F.tagName.toLowerCase(),z=F.parentElement;if(!z)return K;let D=Array.from(z.children).filter((A)=>A.tagName===F.tagName);if(D.length<=1)return K;return`${K}:nth-of-type(${D.indexOf(F)+1})`}function fQ(F){if(F.id)return`#${F.id}`;let K=[],z=F;while(z&&z.nodeType===1&&z.tagName.toLowerCase()!=="html"&&z.tagName.toLowerCase()!=="body"){if(z.id){K.unshift(`#${z.id}`);break}K.unshift(z7(z)),z=z.parentElement}return K.join(" > ")}function kQ(F){let K=[],z=F;while(z&&z.parentElement){let D=Array.from(z.parentElement.children).indexOf(z)+1;K.unshift(`${z.tagName.toLowerCase()}:nth-child(${D})`),z=z.parentElement}return K.join(" > ")}var D7=["color","backgroundColor","opacity","fontFamily","fontSize","fontWeight","textAlign","padding","margin"];function mQ(F){let K=window.getComputedStyle(F),z={};for(let T of D7)z[T]=K[T]??"";let D=F.getBoundingClientRect(),A=fQ(F);return{selector:A,nthPath:kQ(F),tag:F.tagName.toLowerCase(),id:F.id||void 0,classes:Array.from(F.classList),text:(F.textContent??"").trim().slice(0,200)||void 0,boundingBox:{x:D.x,y:D.y,w:D.width,h:D.height},computedStyles:z,outerHtmlSnippet:A.includes(":nth")?F.outerHTML.slice(0,300):void 0}}function B6(F,K){let z=window.getComputedStyle(F),D={};if(K.text!=null&&K.text!==F.textContent)D.text={from:F.textContent??"",to:K.text},F.textContent=K.text;if(K.color)D.color={from:z.color,to:K.color},F.style.color=K.color;if(K.background)D.background={from:z.backgroundColor,to:K.background},F.style.background=K.background;if(K.opacity)D.opacity={from:z.opacity,to:K.opacity},F.style.opacity=K.opacity;if(K.fontFamily)D.fontFamily={from:z.fontFamily,to:K.fontFamily},F.style.fontFamily=K.fontFamily;return D}var W7=[{key:"text",label:"文本"},{key:"color",label:"文字颜色"},{key:"background",label:"背景"},{key:"opacity",label:"Opacity"},{key:"fontFamily",label:"字体"}];function O7(F){let K=window.getComputedStyle(F);return{text:F.textContent??"",color:K.color,background:K.backgroundColor,opacity:K.opacity,fontFamily:K.fontFamily}}function N7(F,K){let z={};if(K.text!==F.text)z.text={from:F.text,to:K.text};if(K.color!==F.color)z.color={from:F.color,to:K.color};if(K.background!==F.background)z.background={from:F.background,to:K.background};if(K.opacity!==F.opacity)z.opacity={from:F.opacity,to:K.opacity};if(K.fontFamily!==F.fontFamily)z.fontFamily={from:F.fontFamily,to:K.fontFamily};return z}function B7(F,K){let z={};if(F==="text")z.text=K;else if(F==="color")z.color=K;else if(F==="background")z.background=K;else if(F==="opacity")z.opacity=K;else if(F==="fontFamily")z.fontFamily=K;return z}function vQ(F,K){let z=O7(F),D={...z},A="",T=document.createElement("div"),f=F.getBoundingClientRect(),d=Math.max(8,Math.min(f.bottom+8,window.innerHeight-380)),s=Math.max(8,Math.min(f.left,window.innerWidth-356));T.style.cssText=`position:fixed;top:${d}px;left:${s}px;z-index:2147483647`;let k=T.attachShadow({mode:"open"}),P=document.createElement("div");P.setAttribute("style","width:340px;box-sizing:border-box;background:#fff;border-radius:14px;box-shadow:0 10px 34px rgba(0,0,0,.2);padding:12px;font:13px/1.45 -apple-system,system-ui,sans-serif;color:#111");let O1=document.createElement("input");O1.setAttribute("data-field","description"),O1.placeholder="描述这些更改…",O1.setAttribute("style","width:100%;box-sizing:border-box;border:none;outline:none;font-size:14px;padding:6px 4px"),O1.addEventListener("input",()=>{A=O1.value}),P.appendChild(O1);let $0=document.createElement("div");$0.textContent=F.tagName.toLowerCase(),$0.setAttribute("style","color:#8a8a8a;border-top:1px solid #eee;margin-top:6px;padding:8px 4px 4px;font-weight:600"),P.appendChild($0);for(let B1 of W7){let i1=document.createElement("label");i1.setAttribute("style","display:flex;align-items:center;gap:10px;padding:5px 4px");let G0=document.createElement("span");G0.textContent=B1.label,G0.setAttribute("style","width:74px;color:#555;flex:none");let L1=document.createElement("input");L1.setAttribute("data-field",B1.key),L1.value=z[B1.key],L1.setAttribute("style","flex:1;min-width:0;border:1px solid #e2e2e2;border-radius:8px;padding:5px 9px;font:inherit");let U1=B1.key;L1.addEventListener("input",()=>{D[U1]=L1.value,B6(F,B7(U1,L1.value))}),i1.appendChild(G0),i1.appendChild(L1),P.appendChild(i1)}let N1=document.createElement("div");N1.setAttribute("style","display:flex;justify-content:space-between;align-items:center;margin-top:12px");let C1=document.createElement("button");C1.setAttribute("data-action","cancel"),C1.textContent="取消",C1.setAttribute("style","border:none;background:#f1f1f1;border-radius:18px;padding:7px 16px;cursor:pointer;font:inherit");let H1=document.createElement("button");return H1.setAttribute("data-action","confirm"),H1.textContent="✓",H1.setAttribute("style","border:none;background:#2f7bff;color:#fff;border-radius:50%;width:34px;height:34px;cursor:pointer;font-size:16px"),C1.addEventListener("click",()=>{B6(F,z),K.onCancel()}),H1.addEventListener("click",()=>{K.onConfirm({...N7(z,D),description:A||void 0})}),N1.appendChild(C1),N1.appendChild(H1),P.appendChild(N1),k.appendChild(P),document.documentElement.appendChild(T),{host:T,destroy:()=>{T.remove()}}}(()=>{window.__PREVIEW_AGENT__=!0;let K=IQ({postToHost:(s)=>{let k=window.__TAURI_INTERNALS__;if(k?.invoke)k.invoke("preview_message",{raw:s})},location:window.location,title:document.title});window.__PREVIEW_BRIDGE__=K,window.__PREVIEW_AGENT_CAPTURE__=N6,K.on("capture",async(s)=>{try{K.send({type:"screenshot",dataUrl:await N6(s.kind),kind:s.kind})}catch(k){K.reportError(String(k))}});let z=!1,D=null,A=PQ({onSelect:()=>{}}),T=()=>{D?.destroy(),D=null,z=!1,A.exit(),K.send({type:"picker-exited"})},f=async(s,k)=>{try{let P=await EQ(s);K.send({type:"selection",payload:{pageUrl:window.location.href,sourceHint:document.title||void 0,element:mQ(s),change:k,screenshot:{dataUrl:P,kind:"region"}}})}catch(P){K.reportError(String(P))}};K.on("enter-picker",()=>{z=!0,A.enter()}),K.on("exit-picker",()=>{T()}),document.addEventListener("mousemove",(s)=>{if(!z)return;let k=s.target;if(k instanceof Element)A.hover(k)},!0),document.addEventListener("click",(s)=>{if(!z||D)return;s.preventDefault(),s.stopPropagation(),A.select();let k=A.current();if(z=!1,!(k instanceof HTMLElement)){T();return}D=vQ(k,{onConfirm:(P)=>{T(),f(k,P)},onCancel:()=>{T()}})},!0);let d=()=>{K.reportReady(),K.reportNavigated()};if(document.readyState!=="loading")d();else document.addEventListener("DOMContentLoaded",d);window.addEventListener("popstate",()=>K.reportNavigated())})();})(); + .`+G6+z5+GQ)},W5=function(M,Q){var Z=M.ownerDocument;if(Z){var q=Z.createElement("style");q.textContent=Q,M.appendChild(q)}},HQ=function(){function M(){}return M.getOrigin=function(Q){var Z=M._link;if(!Z)return"about:blank";return Z.href=Q,Z.href=Z.href,Z.protocol+Z.hostname+Z.port},M.isSameOrigin=function(Q){return M.getOrigin(Q)===M._origin},M.setContext=function(Q){M._link=Q.document.createElement("a"),M._origin=M.getOrigin(Q.location.href)},M._origin="about:blank",M}(),O5=function(){function M(Q,Z){this.context=Q,this._options=Z,this._cache={}}return M.prototype.addImage=function(Q){var Z=Promise.resolve();if(this.has(Q))return Z;if(K6(Q)||A5(Q))return(this._cache[Q]=this.loadImage(Q)).catch(function(){}),Z;return Z},M.prototype.match=function(Q){return this._cache[Q]},M.prototype.loadImage=function(Q){return D(this,void 0,void 0,function(){var Z,q,J,j,Y=this;return A(this,function(V){switch(V.label){case 0:if(Z=HQ.isSameOrigin(Q),q=!H6(Q)&&this._options.useCORS===!0&&t.SUPPORT_CORS_IMAGES&&!Z,J=!H6(Q)&&!Z&&!K6(Q)&&typeof this._options.proxy==="string"&&t.SUPPORT_CORS_XHR&&!q,!Z&&this._options.allowTaint===!1&&!H6(Q)&&!K6(Q)&&!J&&!q)return[2];if(j=Q,!J)return[3,2];return[4,this.proxy(j)];case 1:j=V.sent(),V.label=2;case 2:return this.context.logger.debug("Added image "+Q.substring(0,256)),[4,new Promise(function(X,$){var G=new Image;if(G.onload=function(){return X(G)},G.onerror=$,R5(j)||q)G.crossOrigin="anonymous";if(G.src=j,G.complete===!0)setTimeout(function(){return X(G)},500);if(Y._options.imageTimeout>0)setTimeout(function(){return $("Timed out ("+Y._options.imageTimeout+"ms) loading image")},Y._options.imageTimeout)})];case 3:return[2,V.sent()]}})})},M.prototype.has=function(Q){return typeof this._cache[Q]<"u"},M.prototype.keys=function(){return Promise.resolve(Object.keys(this._cache))},M.prototype.proxy=function(Q){var Z=this,q=this._options.proxy;if(!q)throw Error("No proxy defined");var J=Q.substring(0,256);return new Promise(function(j,Y){var V=t.SUPPORT_RESPONSE_TYPE?"blob":"text",X=new XMLHttpRequest;X.onload=function(){if(X.status===200)if(V==="text")j(X.response);else{var H=new FileReader;H.addEventListener("load",function(){return j(H.result)},!1),H.addEventListener("error",function(U){return Y(U)},!1),H.readAsDataURL(X.response)}else Y("Failed to proxy resource "+J+" with status code "+X.status)},X.onerror=Y;var $=q.indexOf("?")>-1?"&":"?";if(X.open("GET",""+q+$+"url="+encodeURIComponent(Q)+"&responseType="+V),V!=="text"&&X instanceof XMLHttpRequest)X.responseType=V;if(Z._options.imageTimeout){var G=Z._options.imageTimeout;X.timeout=G,X.ontimeout=function(){return Y("Timed out ("+G+"ms) proxying "+J)}}X.send()})},M}(),N5=/^data:image\/svg\+xml/i,B5=/^data:image\/.*;base64,/i,L5=/^data:image\/.*/i,A5=function(M){return t.SUPPORT_SVG_DRAWING||!I5(M)},H6=function(M){return L5.test(M)},R5=function(M){return B5.test(M)},K6=function(M){return M.substr(0,4)==="blob"},I5=function(M){return M.substr(-3).toLowerCase()==="svg"||N5.test(M)},N=function(){function M(Q,Z){this.type=0,this.x=Q,this.y=Z}return M.prototype.add=function(Q,Z){return new M(this.x+Q,this.y+Z)},M}(),j0=function(M,Q,Z){return new N(M.x+(Q.x-M.x)*Z,M.y+(Q.y-M.y)*Z)},U8=function(){function M(Q,Z,q,J){this.type=1,this.start=Q,this.startControl=Z,this.endControl=q,this.end=J}return M.prototype.subdivide=function(Q,Z){var q=j0(this.start,this.startControl,Q),J=j0(this.startControl,this.endControl,Q),j=j0(this.endControl,this.end,Q),Y=j0(q,J,Q),V=j0(J,j,Q),X=j0(Y,V,Q);return Z?new M(this.start,q,Y,X):new M(X,V,j,this.end)},M.prototype.add=function(Q,Z){return new M(this.start.add(Q,Z),this.startControl.add(Q,Z),this.endControl.add(Q,Z),this.end.add(Q,Z))},M.prototype.reverse=function(){return new M(this.end,this.endControl,this.startControl,this.start)},M}(),$1=function(M){return M.type===1},w5=function(){function M(Q){var{styles:Z,bounds:q}=Q,J=R0(Z.borderTopLeftRadius,q.width,q.height),j=J[0],Y=J[1],V=R0(Z.borderTopRightRadius,q.width,q.height),X=V[0],$=V[1],G=R0(Z.borderBottomRightRadius,q.width,q.height),H=G[0],U=G[1],L=R0(Z.borderBottomLeftRadius,q.width,q.height),W=L[0],O=L[1],S=[];S.push((j+X)/q.width),S.push((W+H)/q.width),S.push((Y+O)/q.height),S.push(($+U)/q.height);var w=Math.max.apply(Math,S);if(w>1)j/=w,Y/=w,X/=w,$/=w,H/=w,U/=w,W/=w,O/=w;var I=q.width-X,b=q.height-U,y=q.width-H,C=q.height-O,B=Z.borderTopWidth,v=Z.borderRightWidth,x=Z.borderBottomWidth,E=Z.borderLeftWidth,r=u(Z.paddingTop,Q.bounds.width),q1=u(Z.paddingRight,Q.bounds.width),Y1=u(Z.paddingBottom,Q.bounds.width),h=u(Z.paddingLeft,Q.bounds.width);this.topLeftBorderDoubleOuterBox=j>0||Y>0?l(q.left+E/3,q.top+B/3,j-E/3,Y-B/3,_.TOP_LEFT):new N(q.left+E/3,q.top+B/3),this.topRightBorderDoubleOuterBox=j>0||Y>0?l(q.left+I,q.top+B/3,X-v/3,$-B/3,_.TOP_RIGHT):new N(q.left+q.width-v/3,q.top+B/3),this.bottomRightBorderDoubleOuterBox=H>0||U>0?l(q.left+y,q.top+b,H-v/3,U-x/3,_.BOTTOM_RIGHT):new N(q.left+q.width-v/3,q.top+q.height-x/3),this.bottomLeftBorderDoubleOuterBox=W>0||O>0?l(q.left+E/3,q.top+C,W-E/3,O-x/3,_.BOTTOM_LEFT):new N(q.left+E/3,q.top+q.height-x/3),this.topLeftBorderDoubleInnerBox=j>0||Y>0?l(q.left+E*2/3,q.top+B*2/3,j-E*2/3,Y-B*2/3,_.TOP_LEFT):new N(q.left+E*2/3,q.top+B*2/3),this.topRightBorderDoubleInnerBox=j>0||Y>0?l(q.left+I,q.top+B*2/3,X-v*2/3,$-B*2/3,_.TOP_RIGHT):new N(q.left+q.width-v*2/3,q.top+B*2/3),this.bottomRightBorderDoubleInnerBox=H>0||U>0?l(q.left+y,q.top+b,H-v*2/3,U-x*2/3,_.BOTTOM_RIGHT):new N(q.left+q.width-v*2/3,q.top+q.height-x*2/3),this.bottomLeftBorderDoubleInnerBox=W>0||O>0?l(q.left+E*2/3,q.top+C,W-E*2/3,O-x*2/3,_.BOTTOM_LEFT):new N(q.left+E*2/3,q.top+q.height-x*2/3),this.topLeftBorderStroke=j>0||Y>0?l(q.left+E/2,q.top+B/2,j-E/2,Y-B/2,_.TOP_LEFT):new N(q.left+E/2,q.top+B/2),this.topRightBorderStroke=j>0||Y>0?l(q.left+I,q.top+B/2,X-v/2,$-B/2,_.TOP_RIGHT):new N(q.left+q.width-v/2,q.top+B/2),this.bottomRightBorderStroke=H>0||U>0?l(q.left+y,q.top+b,H-v/2,U-x/2,_.BOTTOM_RIGHT):new N(q.left+q.width-v/2,q.top+q.height-x/2),this.bottomLeftBorderStroke=W>0||O>0?l(q.left+E/2,q.top+C,W-E/2,O-x/2,_.BOTTOM_LEFT):new N(q.left+E/2,q.top+q.height-x/2),this.topLeftBorderBox=j>0||Y>0?l(q.left,q.top,j,Y,_.TOP_LEFT):new N(q.left,q.top),this.topRightBorderBox=X>0||$>0?l(q.left+I,q.top,X,$,_.TOP_RIGHT):new N(q.left+q.width,q.top),this.bottomRightBorderBox=H>0||U>0?l(q.left+y,q.top+b,H,U,_.BOTTOM_RIGHT):new N(q.left+q.width,q.top+q.height),this.bottomLeftBorderBox=W>0||O>0?l(q.left,q.top+C,W,O,_.BOTTOM_LEFT):new N(q.left,q.top+q.height),this.topLeftPaddingBox=j>0||Y>0?l(q.left+E,q.top+B,Math.max(0,j-E),Math.max(0,Y-B),_.TOP_LEFT):new N(q.left+E,q.top+B),this.topRightPaddingBox=X>0||$>0?l(q.left+Math.min(I,q.width-v),q.top+B,I>q.width+v?0:Math.max(0,X-v),Math.max(0,$-B),_.TOP_RIGHT):new N(q.left+q.width-v,q.top+B),this.bottomRightPaddingBox=H>0||U>0?l(q.left+Math.min(y,q.width-E),q.top+Math.min(b,q.height-x),Math.max(0,H-v),Math.max(0,U-x),_.BOTTOM_RIGHT):new N(q.left+q.width-v,q.top+q.height-x),this.bottomLeftPaddingBox=W>0||O>0?l(q.left+E,q.top+Math.min(C,q.height-x),Math.max(0,W-E),Math.max(0,O-x),_.BOTTOM_LEFT):new N(q.left+E,q.top+q.height-x),this.topLeftContentBox=j>0||Y>0?l(q.left+E+h,q.top+B+r,Math.max(0,j-(E+h)),Math.max(0,Y-(B+r)),_.TOP_LEFT):new N(q.left+E+h,q.top+B+r),this.topRightContentBox=X>0||$>0?l(q.left+Math.min(I,q.width+E+h),q.top+B+r,I>q.width+E+h?0:X-E+h,$-(B+r),_.TOP_RIGHT):new N(q.left+q.width-(v+q1),q.top+B+r),this.bottomRightContentBox=H>0||U>0?l(q.left+Math.min(y,q.width-(E+h)),q.top+Math.min(b,q.height+B+r),Math.max(0,H-(v+q1)),U-(x+Y1),_.BOTTOM_RIGHT):new N(q.left+q.width-(v+q1),q.top+q.height-(x+Y1)),this.bottomLeftContentBox=W>0||O>0?l(q.left+E+h,q.top+C,Math.max(0,W-(E+h)),O-(x+Y1),_.BOTTOM_LEFT):new N(q.left+E+h,q.top+q.height-(x+Y1))}return M}(),_;(function(M){M[M.TOP_LEFT=0]="TOP_LEFT",M[M.TOP_RIGHT=1]="TOP_RIGHT",M[M.BOTTOM_RIGHT=2]="BOTTOM_RIGHT",M[M.BOTTOM_LEFT=3]="BOTTOM_LEFT"})(_||(_={}));var l=function(M,Q,Z,q,J){var j=4*((Math.sqrt(2)-1)/3),Y=Z*j,V=q*j,X=M+Z,$=Q+q;switch(J){case _.TOP_LEFT:return new U8(new N(M,$),new N(M,$-V),new N(X-Y,Q),new N(X,Q));case _.TOP_RIGHT:return new U8(new N(M,Q),new N(M+Y,Q),new N(X,$-V),new N(X,$));case _.BOTTOM_RIGHT:return new U8(new N(X,Q),new N(X,Q+V),new N(M+Y,$),new N(M,$));case _.BOTTOM_LEFT:default:return new U8(new N(X,$),new N(X-Y,$),new N(M,Q+V),new N(M,Q))}},F8=function(M){return[M.topLeftBorderBox,M.topRightBorderBox,M.bottomRightBorderBox,M.bottomLeftBorderBox]},T5=function(M){return[M.topLeftContentBox,M.topRightContentBox,M.bottomRightContentBox,M.bottomLeftContentBox]},z8=function(M){return[M.topLeftPaddingBox,M.topRightPaddingBox,M.bottomRightPaddingBox,M.bottomLeftPaddingBox]},C5=function(){function M(Q,Z,q){this.offsetX=Q,this.offsetY=Z,this.matrix=q,this.type=0,this.target=6}return M}(),D8=function(){function M(Q,Z){this.path=Q,this.target=Z,this.type=1}return M}(),E5=function(){function M(Q){this.opacity=Q,this.type=2,this.target=6}return M}(),S5=function(M){return M.type===0},KQ=function(M){return M.type===1},y5=function(M){return M.type===2},UQ=function(M,Q){if(M.length===Q.length)return M.some(function(Z,q){return Z===Q[q]});return!1},P5=function(M,Q,Z,q,J){return M.map(function(j,Y){switch(Y){case 0:return j.add(Q,Z);case 1:return j.add(Q+q,Z);case 2:return j.add(Q+q,Z+J);case 3:return j.add(Q,Z+J)}return j})},FQ=function(){function M(Q){this.element=Q,this.inlineLevel=[],this.nonInlineLevel=[],this.negativeZIndex=[],this.zeroOrAutoZIndexOrTransformedOrOpacity=[],this.positiveZIndex=[],this.nonPositionedFloats=[],this.nonPositionedInlineLevel=[]}return M}(),zQ=function(){function M(Q,Z){if(this.container=Q,this.parent=Z,this.effects=[],this.curves=new w5(this.container),this.container.styles.opacity<1)this.effects.push(new E5(this.container.styles.opacity));if(this.container.styles.transform!==null){var q=this.container.bounds.left+this.container.styles.transformOrigin[0].number,J=this.container.bounds.top+this.container.styles.transformOrigin[1].number,j=this.container.styles.transform;this.effects.push(new C5(q,J,j))}if(this.container.styles.overflowX!==0){var Y=F8(this.curves),V=z8(this.curves);if(UQ(Y,V))this.effects.push(new D8(Y,6));else this.effects.push(new D8(Y,2)),this.effects.push(new D8(V,4))}}return M.prototype.getEffects=function(Q){var Z=[2,3].indexOf(this.container.styles.position)===-1,q=this.parent,J=this.effects.slice(0);while(q){var j=q.effects.filter(function(X){return!KQ(X)});if(Z||q.container.styles.position!==0||!q.parent){if(J.unshift.apply(J,j),Z=[2,3].indexOf(q.container.styles.position)===-1,q.container.styles.overflowX!==0){var Y=F8(q.curves),V=z8(q.curves);if(!UQ(Y,V))J.unshift(new D8(V,6))}}else J.unshift.apply(J,j);q=q.parent}return J.filter(function(X){return i(X.target,Q)})},M}(),U6=function(M,Q,Z,q){M.container.elements.forEach(function(J){var j=i(J.flags,4),Y=i(J.flags,2),V=new zQ(J,M);if(i(J.styles.display,2048))q.push(V);var X=i(J.flags,8)?[]:q;if(j||Y){var $=j||J.styles.isPositioned()?Z:Q,G=new FQ(V);if(J.styles.isPositioned()||J.styles.opacity<1||J.styles.isTransformed()){var H=J.styles.zIndex.order;if(H<0){var U=0;$.negativeZIndex.some(function(W,O){if(H>W.element.container.styles.zIndex.order)return U=O,!1;else if(U>0)return!0;return!1}),$.negativeZIndex.splice(U,0,G)}else if(H>0){var L=0;$.positiveZIndex.some(function(W,O){if(H>=W.element.container.styles.zIndex.order)return L=O+1,!1;else if(L>0)return!0;return!1}),$.positiveZIndex.splice(L,0,G)}else $.zeroOrAutoZIndexOrTransformedOrOpacity.push(G)}else if(J.styles.isFloating())$.nonPositionedFloats.push(G);else $.nonPositionedInlineLevel.push(G);U6(V,G,j?G:Z,X)}else{if(J.styles.isInlineLevel())Q.inlineLevel.push(V);else Q.nonInlineLevel.push(V);U6(V,Q,Z,X)}if(i(J.flags,8))DQ(J,X)})},DQ=function(M,Q){var Z=M instanceof q6?M.start:1,q=M instanceof q6?M.reversed:!1;for(var J=0;J"u")return M[0];return Z},_5=function(M,Q,Z,q,J){var j=Q[0],Y=Q[1],V=Z[0],X=Z[1];switch(M){case 2:return[new N(Math.round(q.left),Math.round(q.top+Y)),new N(Math.round(q.left+q.width),Math.round(q.top+Y)),new N(Math.round(q.left+q.width),Math.round(X+q.top+Y)),new N(Math.round(q.left),Math.round(X+q.top+Y))];case 3:return[new N(Math.round(q.left+j),Math.round(q.top)),new N(Math.round(q.left+j+V),Math.round(q.top)),new N(Math.round(q.left+j+V),Math.round(q.height+q.top)),new N(Math.round(q.left+j),Math.round(q.height+q.top))];case 1:return[new N(Math.round(q.left+j),Math.round(q.top+Y)),new N(Math.round(q.left+j+V),Math.round(q.top+Y)),new N(Math.round(q.left+j+V),Math.round(q.top+Y+X)),new N(Math.round(q.left+j),Math.round(q.top+Y+X))];default:return[new N(Math.round(J.left),Math.round(J.top)),new N(Math.round(J.left+J.width),Math.round(J.top)),new N(Math.round(J.left+J.width),Math.round(J.height+J.top)),new N(Math.round(J.left),Math.round(J.height+J.top))]}},p5="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",NQ="Hidden Text",h5=function(){function M(Q){this._data={},this._document=Q}return M.prototype.parseMetrics=function(Q,Z){var q=this._document.createElement("div"),J=this._document.createElement("img"),j=this._document.createElement("span"),Y=this._document.body;q.style.visibility="hidden",q.style.fontFamily=Q,q.style.fontSize=Z,q.style.margin="0",q.style.padding="0",q.style.whiteSpace="nowrap",Y.appendChild(q),J.src=p5,J.width=1,J.height=1,J.style.margin="0",J.style.padding="0",J.style.verticalAlign="baseline",j.style.fontFamily=Q,j.style.fontSize=Z,j.style.margin="0",j.style.padding="0",j.appendChild(this._document.createTextNode(NQ)),q.appendChild(j),q.appendChild(J);var V=J.offsetTop-j.offsetTop+2;q.removeChild(j),q.appendChild(this._document.createTextNode(NQ)),q.style.lineHeight="normal",J.style.verticalAlign="super";var X=J.offsetTop-q.offsetTop+2;return Y.removeChild(q),{baseline:V,middle:X}},M.prototype.getMetrics=function(Q,Z){var q=Q+" "+Z;if(typeof this._data[q]>"u")this._data[q]=this.parseMetrics(Q,Z);return this._data[q]},M}(),BQ=function(){function M(Q,Z){this.context=Q,this.options=Z}return M}(),u5=1e4,l5=function(M){K(Q,M);function Q(Z,q){var J=M.call(this,Z,q)||this;if(J._activeEffects=[],J.canvas=q.canvas?q.canvas:document.createElement("canvas"),J.ctx=J.canvas.getContext("2d"),!q.canvas)J.canvas.width=Math.floor(q.width*q.scale),J.canvas.height=Math.floor(q.height*q.scale),J.canvas.style.width=q.width+"px",J.canvas.style.height=q.height+"px";return J.fontMetrics=new h5(document),J.ctx.scale(J.options.scale,J.options.scale),J.ctx.translate(-q.x,-q.y),J.ctx.textBaseline="bottom",J._activeEffects=[],J.context.logger.debug("Canvas renderer initialized ("+q.width+"x"+q.height+") with scale "+q.scale),J}return Q.prototype.applyEffects=function(Z){var q=this;while(this._activeEffects.length)this.popEffect();Z.forEach(function(J){return q.applyEffect(J)})},Q.prototype.applyEffect=function(Z){if(this.ctx.save(),y5(Z))this.ctx.globalAlpha=Z.opacity;if(S5(Z))this.ctx.translate(Z.offsetX,Z.offsetY),this.ctx.transform(Z.matrix[0],Z.matrix[1],Z.matrix[2],Z.matrix[3],Z.matrix[4],Z.matrix[5]),this.ctx.translate(-Z.offsetX,-Z.offsetY);if(KQ(Z))this.path(Z.path),this.ctx.clip();this._activeEffects.push(Z)},Q.prototype.popEffect=function(){this._activeEffects.pop(),this.ctx.restore()},Q.prototype.renderStack=function(Z){return D(this,void 0,void 0,function(){var q;return A(this,function(J){switch(J.label){case 0:if(q=Z.element.container.styles,!q.isVisible())return[3,2];return[4,this.renderStackContent(Z)];case 1:J.sent(),J.label=2;case 2:return[2]}})})},Q.prototype.renderNode=function(Z){return D(this,void 0,void 0,function(){return A(this,function(q){switch(q.label){case 0:if(i(Z.container.flags,16))debugger;if(!Z.container.styles.isVisible())return[3,3];return[4,this.renderNodeBackgroundAndBorders(Z)];case 1:return q.sent(),[4,this.renderNodeContent(Z)];case 2:q.sent(),q.label=3;case 3:return[2]}})})},Q.prototype.renderTextWithLetterSpacing=function(Z,q,J){var j=this;if(q===0)this.ctx.fillText(Z.text,Z.bounds.left,Z.bounds.top+J);else{var Y=M6(Z.text);Y.reduce(function(V,X){return j.ctx.fillText(X,V,Z.bounds.top+J),V+j.ctx.measureText(X).width},Z.bounds.left)}},Q.prototype.createFontStyle=function(Z){var q=Z.fontVariant.filter(function(Y){return Y==="normal"||Y==="small-caps"}).join(""),J=r5(Z.fontFamily).join(", "),j=A0(Z.fontSize)?""+Z.fontSize.number+Z.fontSize.unit:Z.fontSize.number+"px";return[[Z.fontStyle,q,Z.fontWeight,j,J].join(" "),J,j]},Q.prototype.renderTextNode=function(Z,q){return D(this,void 0,void 0,function(){var J,j,Y,V,X,$,G,H,U=this;return A(this,function(L){return J=this.createFontStyle(q),j=J[0],Y=J[1],V=J[2],this.ctx.font=j,this.ctx.direction=q.direction===1?"rtl":"ltr",this.ctx.textAlign="left",this.ctx.textBaseline="alphabetic",X=this.fontMetrics.getMetrics(Y,V),$=X.baseline,G=X.middle,H=q.paintOrder,Z.textBounds.forEach(function(W){H.forEach(function(O){switch(O){case 0:U.ctx.fillStyle=n(q.color),U.renderTextWithLetterSpacing(W,q.letterSpacing,$);var S=q.textShadow;if(S.length&&W.text.trim().length)S.slice(0).reverse().forEach(function(w){U.ctx.shadowColor=n(w.color),U.ctx.shadowOffsetX=w.offsetX.number*U.options.scale,U.ctx.shadowOffsetY=w.offsetY.number*U.options.scale,U.ctx.shadowBlur=w.blur.number,U.renderTextWithLetterSpacing(W,q.letterSpacing,$)}),U.ctx.shadowColor="",U.ctx.shadowOffsetX=0,U.ctx.shadowOffsetY=0,U.ctx.shadowBlur=0;if(q.textDecorationLine.length)U.ctx.fillStyle=n(q.textDecorationColor||q.color),q.textDecorationLine.forEach(function(w){switch(w){case 1:U.ctx.fillRect(W.bounds.left,Math.round(W.bounds.top+$),W.bounds.width,1);break;case 2:U.ctx.fillRect(W.bounds.left,Math.round(W.bounds.top),W.bounds.width,1);break;case 3:U.ctx.fillRect(W.bounds.left,Math.ceil(W.bounds.top+G),W.bounds.width,1);break}});break;case 1:if(q.webkitTextStrokeWidth&&W.text.trim().length)U.ctx.strokeStyle=n(q.webkitTextStrokeColor),U.ctx.lineWidth=q.webkitTextStrokeWidth,U.ctx.lineJoin=window.chrome?"miter":"round",U.ctx.strokeText(W.text,W.bounds.left,W.bounds.top+$);U.ctx.strokeStyle="",U.ctx.lineWidth=0,U.ctx.lineJoin="miter";break}})}),[2]})})},Q.prototype.renderReplacedElement=function(Z,q,J){if(J&&Z.intrinsicWidth>0&&Z.intrinsicHeight>0){var j=O8(Z),Y=z8(q);this.path(Y),this.ctx.save(),this.ctx.clip(),this.ctx.drawImage(J,0,0,Z.intrinsicWidth,Z.intrinsicHeight,j.left,j.top,j.width,j.height),this.ctx.restore()}},Q.prototype.renderNodeContent=function(Z){return D(this,void 0,void 0,function(){var q,J,j,Y,V,X,I,I,$,G,H,U,y,L,W,C,O,S,w,I,b,y,C;return A(this,function(B){switch(B.label){case 0:this.applyEffects(Z.getEffects(4)),q=Z.container,J=Z.curves,j=q.styles,Y=0,V=q.textNodes,B.label=1;case 1:if(!(Y0&&e>0)x=j.ctx.createPattern(C,"repeat"),j.renderRepeat(r,x,Z1,J1)}else if(yq(G)){if(E=F6(Z,q,[null,null,null]),r=E[0],q1=E[1],Y1=E[2],h=E[3],e=E[4],b1=G.position.length===0?[p8]:G.position,Z1=u(b1[0],h),J1=u(b1[b1.length-1],e),d1=wq(G,Z1,J1,h,e),W1=d1[0],g1=d1[1],W1>0&&g1>0)if(s1=j.ctx.createRadialGradient(q1+Z1,Y1+J1,0,q1+Z1,Y1+J1,W1),V9(G.stops,W1*2).forEach(function(P0){return s1.addColorStop(P0.stop,n(P0.color))}),j.path(r),j.ctx.fillStyle=s1,W1!==g1)a1=Z.bounds.left+0.5*Z.bounds.width,w1=Z.bounds.top+0.5*Z.bounds.height,r1=g1/W1,T1=1/r1,j.ctx.save(),j.ctx.translate(a1,w1),j.ctx.transform(1,0,0,r1,0,0),j.ctx.translate(-a1,-w1),j.ctx.fillRect(q1,T1*(Y1-w1)+w1,h,e*T1),j.ctx.restore();else j.ctx.fill()}V0.label=6;case 6:return q--,[2]}})},j=this,Y=0,V=Z.styles.backgroundImage.slice(0).reverse(),$.label=1;case 1:if(!(Y0))return[3,11];if(G.style!==2)return[3,5];return[4,this.renderDashedDottedBorder(G.color,G.width,V,Z.curves,2)];case 4:return U.sent(),[3,11];case 5:if(G.style!==3)return[3,7];return[4,this.renderDashedDottedBorder(G.color,G.width,V,Z.curves,3)];case 6:return U.sent(),[3,11];case 7:if(G.style!==4)return[3,9];return[4,this.renderDoubleBorder(G.color,G.width,V,Z.curves)];case 8:return U.sent(),[3,11];case 9:return[4,this.renderSolidBorder(G.color,V,Z.curves)];case 10:U.sent(),U.label=11;case 11:V++,U.label=12;case 12:return X++,[3,3];case 13:return[2]}})})},Q.prototype.renderDashedDottedBorder=function(Z,q,J,j,Y){return D(this,void 0,void 0,function(){var V,X,$,G,H,U,L,W,O,S,w,I,b,y,C,B,C,B;return A(this,function(v){if(this.ctx.save(),V=v5(j,J),X=WQ(j,J),Y===2)this.path(X),this.ctx.clip();if($1(X[0]))$=X[0].start.x,G=X[0].start.y;else $=X[0].x,G=X[0].y;if($1(X[1]))H=X[1].end.x,U=X[1].end.y;else H=X[1].x,U=X[1].y;if(J===0||J===2)L=Math.abs($-H);else L=Math.abs(G-U);if(this.ctx.beginPath(),Y===3)this.formatPath(V);else this.formatPath(X.slice(0,2));if(W=q<3?q*3:q*2,O=q<3?q*2:q,Y===3)W=q,O=q;if(S=!0,L<=W*2)S=!1;else if(L<=W*2+O)w=L/(2*W+O),W*=w,O*=w;else I=Math.floor((L+O)/(W+O)),b=(L-I*W)/(I-1),y=(L-(I+1)*W)/I,O=y<=0||Math.abs(O-b)F.postToHost(AQ(D));return{reportReady:()=>z({type:"ready"}),reportNavigated:()=>z({type:"navigated",url:F.location.href,title:F.title}),reportError:(D)=>z({type:"error",message:D}),send:z,on(D,A){let T=K.get(D)??[];T.push(A),K.set(D,T)},handleHostRaw(D){let A=RQ(D);if(!A)return;for(let T of K.get(A.type)??[])T(A)}}}var O6=$7(wQ(),1);function K7(F,K,z){let D=Math.max(F,K);if(D<=z)return{width:F,height:K};let A=z/D;return{width:Math.round(F*A),height:Math.round(K*A)}}async function W6(F,K=1600,z=0.85){let D=new Image;await new Promise((d,s)=>{D.onload=()=>d(),D.onerror=()=>s(Error("load")),D.src=F});let{width:A,height:T}=K7(D.naturalWidth,D.naturalHeight,K),f=document.createElement("canvas");return f.width=A,f.height=T,f.getContext("2d").drawImage(D,0,0,A,T),f.toDataURL("image/png",z)}async function N6(F,K){let z=F==="element"&&K?K:document.body,D=await O6.default(z,{...F==="viewport"?{windowWidth:window.innerWidth,height:window.innerHeight}:{},useCORS:!0,logging:!1});return W6(D.toDataURL("image/png"))}function g(F,K,z){F.setProperty(K,z,"important")}var p1=26,TQ=4,_1=4;function CQ(F,K,z){return Math.max(K,Math.min(F,z))}function U7(F){let K=F.left+F.width/2-p1/2,z=Math.max(_1,window.innerWidth-p1-_1),D=CQ(K,_1,z),A=F.top-p1-TQ,T=F.bottom+TQ;if(A>=_1)return{left:D,top:A};if(T+p1<=window.innerHeight-_1)return{left:D,top:T};return{left:D,top:CQ(A,_1,Math.max(_1,window.innerHeight-p1-_1))}}function F7(F,K){let z=F.getBoundingClientRect(),D=document.createElement("div");D.dataset.previewSelectionAnnotationRoot="true",D.setAttribute("aria-hidden","true"),g(D.style,"position","fixed"),g(D.style,"inset","0"),g(D.style,"overflow","visible"),g(D.style,"pointer-events","none"),g(D.style,"z-index","2147483647");let A=document.createElement("div");A.dataset.previewSelectionAnnotation="true",g(A.style,"position","fixed"),g(A.style,"left",`${z.left}px`),g(A.style,"top",`${z.top}px`),g(A.style,"width",`${z.width}px`),g(A.style,"height",`${z.height}px`),g(A.style,"box-sizing","border-box"),g(A.style,"border","3px solid #2f7bff"),g(A.style,"border-radius","8px"),g(A.style,"background","rgba(47, 123, 255, 0.16)"),g(A.style,"box-shadow","0 0 0 2px rgba(255, 255, 255, 0.9)"),g(A.style,"pointer-events","none");let T=document.createElement("div");T.dataset.previewSelectionBadge="true",T.textContent=String(K);let f=U7(z);return g(T.style,"position","fixed"),g(T.style,"left",`${f.left}px`),g(T.style,"top",`${f.top}px`),g(T.style,"display","flex"),g(T.style,"align-items","center"),g(T.style,"justify-content","center"),g(T.style,"width",`${p1}px`),g(T.style,"height",`${p1}px`),g(T.style,"border-radius","999px"),g(T.style,"background","#2f7bff"),g(T.style,"border","2px solid #ffffff"),g(T.style,"color","white"),g(T.style,"font",'700 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'),g(T.style,"line-height",`${p1}px`),g(T.style,"box-sizing","border-box"),g(T.style,"box-shadow","0 1px 8px rgba(47, 123, 255, 0.28), 0 0 0 1px rgba(47, 123, 255, 0.22)"),D.appendChild(A),D.appendChild(T),document.documentElement.appendChild(D),D}async function EQ(F,K=1){let z=F7(F,K);try{let D=await O6.default(document.documentElement,{useCORS:!0,logging:!1,scale:1,x:window.scrollX,y:window.scrollY,width:window.innerWidth,height:window.innerHeight,windowWidth:window.innerWidth,windowHeight:window.innerHeight,scrollX:window.scrollX,scrollY:window.scrollY});return W6(D.toDataURL("image/png"))}finally{z.remove()}}function SQ(F){let K=F.parentElement;if(!K||K.tagName.toLowerCase()==="body"||K.tagName.toLowerCase()==="html")return null;return K}function yQ(F){return F.firstElementChild}function PQ(F){let K=!1,z=null,D=null,A=document.createElement("div");A.style.cssText="position:fixed;inset:0;pointer-events:none;z-index:2147483647";let T=A.attachShadow({mode:"open"}),f=document.createElement("div");f.style.cssText="position:fixed;border:2px solid #2f7bff;background:rgba(47,123,255,.12);pointer-events:none",f.hidden=!0,T.appendChild(f);let d=(k)=>{if(!k){f.hidden=!0;return}let P=k.getBoundingClientRect();f.hidden=!1,f.style.left=`${P.left}px`,f.style.top=`${P.top}px`,f.style.width=`${P.width}px`,f.style.height=`${P.height}px`},s=(k)=>{z=k,d(k),F.onSelect(k)};return{enter(){if(K=!0,z=null,D=null,!A.isConnected)document.documentElement.appendChild(A)},hover(k){if(!K||z)return;D=k,d(k)},select(){if(D)s(D)},climb(){if(z){let k=SQ(z);if(k)z=k,d(k)}},descend(){if(z){let k=yQ(z);if(k)z=k,d(k)}},current(){return z},exit(){K=!1,z=null,D=null,f.hidden=!0,A.remove()}}}function z7(F){let K=F.tagName.toLowerCase(),z=F.parentElement;if(!z)return K;let D=Array.from(z.children).filter((A)=>A.tagName===F.tagName);if(D.length<=1)return K;return`${K}:nth-of-type(${D.indexOf(F)+1})`}function fQ(F){if(F.id)return`#${F.id}`;let K=[],z=F;while(z&&z.nodeType===1&&z.tagName.toLowerCase()!=="html"&&z.tagName.toLowerCase()!=="body"){if(z.id){K.unshift(`#${z.id}`);break}K.unshift(z7(z)),z=z.parentElement}return K.join(" > ")}function kQ(F){let K=[],z=F;while(z&&z.parentElement){let D=Array.from(z.parentElement.children).indexOf(z)+1;K.unshift(`${z.tagName.toLowerCase()}:nth-child(${D})`),z=z.parentElement}return K.join(" > ")}var D7=["color","backgroundColor","opacity","fontFamily","fontSize","fontWeight","textAlign","padding","margin"];function mQ(F){let K=window.getComputedStyle(F),z={};for(let T of D7)z[T]=K[T]??"";let D=F.getBoundingClientRect(),A=fQ(F);return{selector:A,nthPath:kQ(F),tag:F.tagName.toLowerCase(),id:F.id||void 0,classes:Array.from(F.classList),text:(F.textContent??"").trim().slice(0,200)||void 0,boundingBox:{x:D.x,y:D.y,w:D.width,h:D.height},computedStyles:z,outerHtmlSnippet:A.includes(":nth")?F.outerHTML.slice(0,300):void 0}}function B6(F,K){let z=window.getComputedStyle(F),D={};if(K.text!=null&&K.text!==F.textContent)D.text={from:F.textContent??"",to:K.text},F.textContent=K.text;if(K.color)D.color={from:z.color,to:K.color},F.style.color=K.color;if(K.background)D.background={from:z.backgroundColor,to:K.background},F.style.background=K.background;if(K.opacity)D.opacity={from:z.opacity,to:K.opacity},F.style.opacity=K.opacity;if(K.fontFamily)D.fontFamily={from:z.fontFamily,to:K.fontFamily},F.style.fontFamily=K.fontFamily;return D}var W7=[{key:"text",label:"文本"},{key:"color",label:"文字颜色"},{key:"background",label:"背景"},{key:"opacity",label:"Opacity"},{key:"fontFamily",label:"字体"}];function O7(F){let K=window.getComputedStyle(F);return{text:F.textContent??"",color:K.color,background:K.backgroundColor,opacity:K.opacity,fontFamily:K.fontFamily}}function N7(F,K){let z={};if(K.text!==F.text)z.text={from:F.text,to:K.text};if(K.color!==F.color)z.color={from:F.color,to:K.color};if(K.background!==F.background)z.background={from:F.background,to:K.background};if(K.opacity!==F.opacity)z.opacity={from:F.opacity,to:K.opacity};if(K.fontFamily!==F.fontFamily)z.fontFamily={from:F.fontFamily,to:K.fontFamily};return z}function B7(F,K){let z={};if(F==="text")z.text=K;else if(F==="color")z.color=K;else if(F==="background")z.background=K;else if(F==="opacity")z.opacity=K;else if(F==="fontFamily")z.fontFamily=K;return z}function vQ(F,K){let z=O7(F),D={...z},A="",T=document.createElement("div"),f=F.getBoundingClientRect(),d=Math.max(8,Math.min(f.bottom+8,window.innerHeight-380)),s=Math.max(8,Math.min(f.left,window.innerWidth-356));T.style.cssText=`position:fixed;top:${d}px;left:${s}px;z-index:2147483647`;let k=T.attachShadow({mode:"open"}),P=document.createElement("div");P.setAttribute("style","width:340px;box-sizing:border-box;background:#fff;border-radius:14px;box-shadow:0 10px 34px rgba(0,0,0,.2);padding:12px;font:13px/1.45 -apple-system,system-ui,sans-serif;color:#111");let O1=document.createElement("input");O1.setAttribute("data-field","description"),O1.placeholder="描述这些更改…",O1.setAttribute("style","width:100%;box-sizing:border-box;border:none;outline:none;font-size:14px;padding:6px 4px"),O1.addEventListener("input",()=>{A=O1.value}),P.appendChild(O1);let $0=document.createElement("div");$0.textContent=F.tagName.toLowerCase(),$0.setAttribute("style","color:#8a8a8a;border-top:1px solid #eee;margin-top:6px;padding:8px 4px 4px;font-weight:600"),P.appendChild($0);for(let B1 of W7){let i1=document.createElement("label");i1.setAttribute("style","display:flex;align-items:center;gap:10px;padding:5px 4px");let G0=document.createElement("span");G0.textContent=B1.label,G0.setAttribute("style","width:74px;color:#555;flex:none");let L1=document.createElement("input");L1.setAttribute("data-field",B1.key),L1.value=z[B1.key],L1.setAttribute("style","flex:1;min-width:0;border:1px solid #e2e2e2;border-radius:8px;padding:5px 9px;font:inherit");let U1=B1.key;L1.addEventListener("input",()=>{D[U1]=L1.value,B6(F,B7(U1,L1.value))}),i1.appendChild(G0),i1.appendChild(L1),P.appendChild(i1)}let N1=document.createElement("div");N1.setAttribute("style","display:flex;justify-content:space-between;align-items:center;margin-top:12px");let C1=document.createElement("button");C1.setAttribute("data-action","cancel"),C1.textContent="取消",C1.setAttribute("style","border:none;background:#f1f1f1;border-radius:18px;padding:7px 16px;cursor:pointer;font:inherit");let H1=document.createElement("button");return H1.setAttribute("data-action","confirm"),H1.textContent="✓",H1.setAttribute("style","border:none;background:#2f7bff;color:#fff;border-radius:50%;width:34px;height:34px;cursor:pointer;font-size:16px"),C1.addEventListener("click",()=>{B6(F,z),K.onCancel()}),H1.addEventListener("click",()=>{K.onConfirm({...N7(z,D),description:A||void 0})}),N1.appendChild(C1),N1.appendChild(H1),P.appendChild(N1),k.appendChild(P),document.documentElement.appendChild(T),{host:T,destroy:()=>{T.remove()}}}(()=>{window.__PREVIEW_AGENT__=!0;let K=IQ({postToHost:(s)=>{let k=window.__DESKTOP_PREVIEW_POST__;if(k)k(s)},location:window.location,title:document.title});window.__PREVIEW_BRIDGE__=K,window.__PREVIEW_AGENT_CAPTURE__=N6,K.on("capture",async(s)=>{try{K.send({type:"screenshot",dataUrl:await N6(s.kind),kind:s.kind})}catch(k){K.reportError(String(k))}});let z=!1,D=null,A=PQ({onSelect:()=>{}}),T=()=>{D?.destroy(),D=null,z=!1,A.exit(),K.send({type:"picker-exited"})},f=async(s,k)=>{try{let P=await EQ(s);K.send({type:"selection",payload:{pageUrl:window.location.href,sourceHint:document.title||void 0,element:mQ(s),change:k,screenshot:{dataUrl:P,kind:"region"}}})}catch(P){K.reportError(String(P))}};K.on("enter-picker",()=>{z=!0,A.enter()}),K.on("exit-picker",()=>{T()}),document.addEventListener("mousemove",(s)=>{if(!z)return;let k=s.target;if(k instanceof Element)A.hover(k)},!0),document.addEventListener("click",(s)=>{if(!z||D)return;s.preventDefault(),s.stopPropagation(),A.select();let k=A.current();if(z=!1,!(k instanceof HTMLElement)){T();return}D=vQ(k,{onConfirm:(P)=>{T(),f(k,P)},onCancel:()=>{T()}})},!0);let d=()=>{K.reportReady(),K.reportNavigated()};if(document.readyState!=="loading")d();else document.addEventListener("DOMContentLoaded",d);window.addEventListener("popstate",()=>K.reportNavigated())})();})(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 93d8f08c..3c508931 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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 { Ok(override_shell.unwrap_or(system_default)) } +fn read_agent_powershell_path_override() -> Option { + 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 { + 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) -> Result, String> { let Some(path) = path else { return Ok(None); @@ -1593,6 +1635,9 @@ fn start_server_sidecar(app: &AppHandle) -> Result { 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"); diff --git a/desktop/src-tauri/src/webview_panel.rs b/desktop/src-tauri/src/webview_panel.rs index 41661673..2500b86b 100644 --- a/desktop/src-tauri/src/webview_panel.rs +++ b/desktop/src-tauri/src/webview_panel.rs @@ -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); @@ -61,8 +69,9 @@ pub async fn preview_open( 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), diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 0c60ed13..4ea9864e 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -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() }) diff --git a/desktop/src/api/terminal.test.ts b/desktop/src/api/terminal.test.ts new file mode 100644 index 00000000..295c61d2 --- /dev/null +++ b/desktop/src/api/terminal.test.ts @@ -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.', + ) + }) +}) diff --git a/desktop/src/api/terminal.ts b/desktop/src/api/terminal.ts index 857f96b0..81b0bca1 100644 --- a/desktop/src/api/terminal.ts +++ b/desktop/src/api/terminal.ts @@ -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(command: string, args?: Record): Promise { - 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(command, args) + return host.terminal } export const terminalApi = { - isAvailable: isTauriRuntime, + isAvailable: () => getDesktopHost().capabilities.terminal, spawn(input: { cols: number; rows: number; cwd?: string }) { - return invoke('terminal_spawn', input) + return getTerminalHost().spawn(input) }, write(sessionId: number, data: string) { - return invoke('terminal_write', { sessionId, data }) + return getTerminalHost().write(sessionId, data) }, resize(sessionId: number, cols: number, rows: number) { - return invoke('terminal_resize', { sessionId, cols, rows }) + return getTerminalHost().resize(sessionId, cols, rows) }, kill(sessionId: number) { - return invoke('terminal_kill', { sessionId }) + return getTerminalHost().kill(sessionId) }, async onOutput(handler: (payload: TerminalOutputPayload) => void): Promise { - const events = await import('@tauri-apps/api/event') - return events.listen('terminal-output', (event) => handler(event.payload)) + return getTerminalHost().onOutput(handler) }, async onExit(handler: (payload: TerminalExitPayload) => void): Promise { - const events = await import('@tauri-apps/api/event') - return events.listen('terminal-exit', (event) => handler(event.payload)) + return getTerminalHost().onExit(handler) }, getBashPath() { - return invoke('get_terminal_bash_path', undefined) + return getTerminalHost().getBashPath() }, setBashPath(path: string | null) { - return invoke('set_terminal_bash_path', { path }) + return getTerminalHost().setBashPath(path) }, } diff --git a/desktop/src/components/browser/BrowserSurface.test.tsx b/desktop/src/components/browser/BrowserSurface.test.tsx index 1618c28f..a7cbcf65 100644 --- a/desktop/src/components/browser/BrowserSurface.test.tsx +++ b/desktop/src/components/browser/BrowserSurface.test.tsx @@ -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() 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() 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)', () => { diff --git a/desktop/src/components/browser/BrowserSurface.tsx b/desktop/src/components/browser/BrowserSurface.tsx index e281f81e..57afdd52 100644 --- a/desktop/src/components/browser/BrowserSurface.tsx +++ b/desktop/src/components/browser/BrowserSurface.tsx @@ -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' })} > @@ -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' }) }} > diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index f75c9463..aa958abd 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -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')) }, }) diff --git a/desktop/src/components/chat/AssistantOutputTargetCard.tsx b/desktop/src/components/chat/AssistantOutputTargetCard.tsx index 83413cf1..fbb6e045 100644 --- a/desktop/src/components/chat/AssistantOutputTargetCard.tsx +++ b/desktop/src/components/chat/AssistantOutputTargetCard.tsx @@ -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), diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index c0236c6e..9e39ec2d 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + + 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 ') + }) }) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 1a03397c..54fa73bb 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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([]) 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>([]) const [launchWorkDir, setLaunchWorkDir] = useState('') const [launchBranch, setLaunchBranch] = useState(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) => { diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.tsx index c871d81e..28dc3a0b 100644 --- a/desktop/src/components/chat/CurrentTurnChangeCard.tsx +++ b/desktop/src/components/chat/CurrentTurnChangeCard.tsx @@ -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), diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 5823c4fc..da15625e 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -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() + + 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: { diff --git a/desktop/src/components/chat/ThinkingBlock.tsx b/desktop/src/components/chat/ThinkingBlock.tsx index 3975525d..284f9a3f 100644 --- a/desktop/src/components/chat/ThinkingBlock.tsx +++ b/desktop/src/components/chat/ThinkingBlock.tsx @@ -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(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 (
- {expanded && ( + {expanded && hasDisplayContent && (
- {content} - {isActive && expanded && } + + {isActive && }
)}
@@ -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; +} ` diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index 71331e54..25e41d3c 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -18,14 +18,44 @@ describe('chat blocks', () => { const { container } = render() 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() - 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() + + 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() + + 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', () => { diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 32c3a057..0fa899df 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -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: '', + }, + ]) + }) + + 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: '' }, + ]) + + 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() }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 517474cc..f385103e 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -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 = { + 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: ' ' }, { 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, +): SlashCommandOption[] { + const seen = new Set() + 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: '', + }) + } + + return commands +} + +export function appendAgentSlashCommands( + commands: ReadonlyArray, + agentCommands: ReadonlyArray, +): SlashCommandOption[] { + const names = new Set(commands.map((command) => command.name)) + return [ + ...commands, + ...agentCommands.filter((command) => !names.has(command.name)), + ] +} + export type SlashUiAction = | { type: 'panel' diff --git a/desktop/src/components/chat/useComposerFileDrop.ts b/desktop/src/components/chat/useComposerFileDrop.ts index a4060fd6..16b6b71b 100644 --- a/desktop/src/components/chat/useComposerFileDrop.ts +++ b/desktop/src/components/chat/useComposerFileDrop.ts @@ -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() diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 632a0a9d..25495a67 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -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() + + 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() + + 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() + + 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({ diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index f4b86fdf..392e2c5c 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -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(null) const dropdownRef = useRef(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 diff --git a/desktop/src/components/controls/PermissionModeSelector.test.tsx b/desktop/src/components/controls/PermissionModeSelector.test.tsx index 8998cca0..98d2bfba 100644 --- a/desktop/src/components/controls/PermissionModeSelector.test.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.test.tsx @@ -12,6 +12,7 @@ vi.mock('../../hooks/useMobileViewport', () => ({ vi.mock('../../lib/desktopRuntime', () => ({ isTauriRuntime: () => false, + isDesktopRuntime: () => false, })) vi.mock('../../i18n', () => ({ diff --git a/desktop/src/components/controls/PermissionModeSelector.tsx b/desktop/src/components/controls/PermissionModeSelector.tsx index 72fffb56..7b8c4370 100644 --- a/desktop/src/components/controls/PermissionModeSelector.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.tsx @@ -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) diff --git a/desktop/src/components/layout/AppShell.test.tsx b/desktop/src/components/layout/AppShell.test.tsx index 223cb14c..e086166f 100644 --- a/desktop/src/components/layout/AppShell.test.tsx +++ b/desktop/src/components/layout/AppShell.test.tsx @@ -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() + + 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.'), { diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index c9e578ab..1dc4d57c 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -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('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 ( { 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) | 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" > -
+
- + { render() }) - 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() }) - await waitFor(() => { - expect(getCurrentWindowMock).toHaveBeenCalled() - }) - fireEvent.mouseDown(screen.getByText('Untitled Session')) expect(startDraggingMock).not.toHaveBeenCalled() diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 72a42b9f..4ea1bdd4 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -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() {
- {isTauri && isActiveSessionTab && ( + {isDesktopRuntime && isActiveSessionTab && ( )} - {isTauri && ( + {isDesktopRuntime && (
{}) 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() @@ -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() }) }) diff --git a/desktop/src/components/layout/WindowControls.tsx b/desktop/src/components/layout/WindowControls.tsx index 8707b150..0a354a8f 100644 --- a/desktop/src/components/layout/WindowControls.tsx +++ b/desktop/src/components/layout/WindowControls.tsx @@ -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 - toggleMaximize: () => Promise - close: () => Promise - isMaximized: () => Promise - onResized: (handler: () => void) => Promise<() => void> - } | null>(null) + const [win, setWin] = useState(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) => { diff --git a/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx b/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx index d568f080..f810e5c9 100644 --- a/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx +++ b/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx @@ -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')) diff --git a/desktop/src/components/settings/ChatGPTOfficialLogin.tsx b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx index 087596b3..e1bb7597 100644 --- a/desktop/src/components/settings/ChatGPTOfficialLogin.tsx +++ b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx @@ -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) { diff --git a/desktop/src/components/settings/ClaudeOfficialLogin.tsx b/desktop/src/components/settings/ClaudeOfficialLogin.tsx index 91a5be3b..8bfbe0e0 100644 --- a/desktop/src/components/settings/ClaudeOfficialLogin.tsx +++ b/desktop/src/components/settings/ClaudeOfficialLogin.tsx @@ -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) diff --git a/desktop/src/components/shared/DirectoryPicker.test.tsx b/desktop/src/components/shared/DirectoryPicker.test.tsx index e762fdcb..31be4351 100644 --- a/desktop/src/components/shared/DirectoryPicker.test.tsx +++ b/desktop/src/components/shared/DirectoryPicker.test.tsx @@ -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() + + 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() + }) }) diff --git a/desktop/src/components/shared/DirectoryPicker.tsx b/desktop/src/components/shared/DirectoryPicker.tsx index 446fc671..a1c8a9da 100644 --- a/desktop/src/components/shared/DirectoryPicker.tsx +++ b/desktop/src/components/shared/DirectoryPicker.tsx @@ -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(null) const triggerRef = useRef(null) - const isMobileBrowser = useMobileViewport() && !isTauriRuntime() + const isMobileBrowser = useMobileViewport() && !isDesktopRuntime() const dropdownRef = useRef(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) } diff --git a/desktop/src/components/shared/RepositoryLaunchControls.test.tsx b/desktop/src/components/shared/RepositoryLaunchControls.test.tsx index db05ca1a..130033d8 100644 --- a/desktop/src/components/shared/RepositoryLaunchControls.test.tsx +++ b/desktop/src/components/shared/RepositoryLaunchControls.test.tsx @@ -18,6 +18,7 @@ vi.mock('../../hooks/useMobileViewport', () => ({ vi.mock('../../lib/desktopRuntime', () => ({ isTauriRuntime: () => viewportMocks.isTauri, + isDesktopRuntime: () => viewportMocks.isTauri, })) vi.mock('../../api/sessions', () => ({ diff --git a/desktop/src/components/shared/RepositoryLaunchControls.tsx b/desktop/src/components/shared/RepositoryLaunchControls.tsx index 856c3bfd..815d60e4 100644 --- a/desktop/src/components/shared/RepositoryLaunchControls.tsx +++ b/desktop/src/components/shared/RepositoryLaunchControls.tsx @@ -17,7 +17,7 @@ import { import { useTranslation } from '../../i18n' import { DirectoryPicker } from './DirectoryPicker' import { useMobileViewport } from '../../hooks/useMobileViewport' -import { isTauriRuntime } from '../../lib/desktopRuntime' +import { isDesktopRuntime } from '../../lib/desktopRuntime' import { MobileBottomSheet } from './MobileBottomSheet' type Props = { @@ -59,7 +59,7 @@ export function RepositoryLaunchControls({ placement = 'standalone', }: Props) { const t = useTranslation() - const isMobileBrowser = useMobileViewport() && !isTauriRuntime() + const isMobileBrowser = useMobileViewport() && !isDesktopRuntime() const isComposerPlacement = placement === 'composer' && !isMobileBrowser const [context, setContext] = useState(null) const [loading, setLoading] = useState(false) diff --git a/desktop/src/components/shared/UpdateChecker.test.tsx b/desktop/src/components/shared/UpdateChecker.test.tsx index a5799871..88d26084 100644 --- a/desktop/src/components/shared/UpdateChecker.test.tsx +++ b/desktop/src/components/shared/UpdateChecker.test.tsx @@ -1,14 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { UpdateChecker } from './UpdateChecker' +import { browserHost } from '../../lib/desktopHost/browserHost' import { useSettingsStore } from '../../stores/settingsStore' import { useUpdateStore } from '../../stores/updateStore' describe('UpdateChecker', () => { beforeEach(() => { useSettingsStore.setState({ locale: 'en' }) + Reflect.deleteProperty(window, 'desktopHost') Object.defineProperty(window, '__TAURI__', { value: {}, configurable: true, @@ -45,6 +47,25 @@ describe('UpdateChecker', () => { expect(link).toHaveAttribute('target', '_blank') }) + it('renders the update prompt in Electron desktop runtime', () => { + Reflect.deleteProperty(window, '__TAURI__') + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { + ...browserHost.capabilities, + updates: true, + }, + } + useUpdateStore.setState({ status: 'downloaded' }) + + render() + + expect(screen.getByText('Update ready')).toBeInTheDocument() + expect(screen.getByText('Install and restart')).toBeInTheDocument() + }) + it('shows downloaded bytes when the updater does not provide total size', () => { useUpdateStore.setState({ status: 'downloading', @@ -95,4 +116,73 @@ describe('UpdateChecker', () => { expect(screen.getByText('Update failed: installer failed')).toBeInTheDocument() expect(screen.getByText('Install and restart')).toBeInTheDocument() }) + + it('drives the Electron mock-feed check/download/install flow without leaving the prompt stuck', async () => { + Reflect.deleteProperty(window, '__TAURI__') + const download = vi.fn(async (onEvent?: (event: unknown) => void) => { + onEvent?.({ event: 'Started', data: { contentLength: 100 } }) + onEvent?.({ event: 'Progress', data: { chunkLength: 100 } }) + onEvent?.({ event: 'Finished' }) + }) + const install = vi.fn().mockResolvedValue(undefined) + const prepareInstall = vi.fn().mockResolvedValue(undefined) + const relaunch = vi.fn().mockResolvedValue(undefined) + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { + ...browserHost.capabilities, + updates: true, + }, + updates: { + ...browserHost.updates, + check: vi.fn().mockResolvedValue({ + version: '0.2.0', + body: 'Mock release feed', + download, + install, + close: vi.fn().mockResolvedValue(undefined), + }), + prepareInstall, + relaunch, + }, + } + + vi.resetModules() + const { UpdateChecker: FreshUpdateChecker } = await import('./UpdateChecker') + const { useUpdateStore: freshUpdateStore } = await import('../../stores/updateStore') + const { useSettingsStore: freshSettingsStore } = await import('../../stores/settingsStore') + freshSettingsStore.setState({ locale: 'en' }) + freshUpdateStore.setState({ + status: 'idle', + availableVersion: null, + releaseNotes: null, + progressPercent: 0, + downloadedBytes: 0, + totalBytes: null, + error: null, + checkedAt: null, + shouldPrompt: false, + }) + + render() + await act(async () => { + await freshUpdateStore.getState().checkForUpdates() + }) + + expect(await screen.findByText('Update ready')).toBeInTheDocument() + expect(screen.getByText('v0.2.0 has been downloaded. Restart when you are ready to use it.')).toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByText('Install and restart')) + }) + + await waitFor(() => { + expect(prepareInstall).toHaveBeenCalledTimes(1) + expect(install).toHaveBeenCalledTimes(1) + expect(relaunch).toHaveBeenCalledTimes(1) + expect(freshUpdateStore.getState().status).toBe('restarting') + }) + expect(screen.queryByText('Update ready')).not.toBeInTheDocument() + }) }) diff --git a/desktop/src/components/shared/UpdateChecker.tsx b/desktop/src/components/shared/UpdateChecker.tsx index 3d915881..c4286ff6 100644 --- a/desktop/src/components/shared/UpdateChecker.tsx +++ b/desktop/src/components/shared/UpdateChecker.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { useTranslation } from '../../i18n' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' -import { isTauriRuntime } from '../../lib/desktopRuntime' +import { isDesktopRuntime } from '../../lib/desktopRuntime' import { useUpdateStore } from '../../stores/updateStore' export function UpdateChecker() { @@ -19,7 +19,7 @@ export function UpdateChecker() { void initialize() }, [initialize]) - if (!isTauriRuntime()) return null + if (!isDesktopRuntime()) return null const showPopup = shouldPrompt && !!availableVersion && status === 'downloaded' diff --git a/desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx b/desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx index 3237200f..000284bb 100644 --- a/desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx +++ b/desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx @@ -2,9 +2,11 @@ import '@testing-library/jest-dom' import { render, fireEvent } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' +import { browserHost } from '../../lib/desktopHost/browserHost' const openTarget = vi.hoisted(() => vi.fn()) const shellOpen = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) +const hostOpenPath = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) vi.mock('../../stores/openTargetStore', () => ({ useOpenTargetStore: (sel: (s: unknown) => unknown) => @@ -30,6 +32,19 @@ import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith' describe('WorkspaceFileOpenWith', () => { beforeEach(() => { vi.clearAllMocks() + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { + ...browserHost.capabilities, + shell: true, + }, + shell: { + ...browserHost.shell, + openPath: hostOpenPath, + }, + } }) it('renders only IDE and file-manager items', () => { @@ -65,5 +80,6 @@ describe('WorkspaceFileOpenWith', () => { it('does not call shell open from the file open-with menu', () => { render() expect(shellOpen).not.toHaveBeenCalled() + expect(hostOpenPath).not.toHaveBeenCalled() }) }) diff --git a/desktop/src/components/workspace/WorkspaceFileOpenWith.tsx b/desktop/src/components/workspace/WorkspaceFileOpenWith.tsx index 40e94048..8a481d4b 100644 --- a/desktop/src/components/workspace/WorkspaceFileOpenWith.tsx +++ b/desktop/src/components/workspace/WorkspaceFileOpenWith.tsx @@ -3,10 +3,11 @@ import { ExternalLink } from 'lucide-react' import { useTranslation, type TranslationKey } from '../../i18n' import { useOpenTargetStore } from '../../stores/openTargetStore' import { buildOpenWithItems, type OpenWithItem, type OpenWithDeps } from '../../lib/openWithItems' +import { getDesktopHost } from '../../lib/desktopHost' import { TargetIcon } from '../common/TargetIcon' function openExternal(path: string) { - void import('@tauri-apps/plugin-shell').then((m) => m.open(path)).catch(() => {}) + void getDesktopHost().shell.openPath(path).catch(() => {}) } export function WorkspaceFileOpenWith({ diff --git a/desktop/src/hooks/useSelectionPopoverDismiss.ts b/desktop/src/hooks/useSelectionPopoverDismiss.ts index a7c17acf..505efe42 100644 --- a/desktop/src/hooks/useSelectionPopoverDismiss.ts +++ b/desktop/src/hooks/useSelectionPopoverDismiss.ts @@ -76,6 +76,11 @@ export function useSelectionPopoverDismiss({ useEffect(() => { if (!active) return + const dismiss = () => { + onDismiss() + clearWindowSelection() + } + const handlePointerDown = (event: PointerEvent) => { const popover = popoverRef.current const target = event.target @@ -83,11 +88,18 @@ export function useSelectionPopoverDismiss({ return } - onDismiss() - clearWindowSelection() + dismiss() + } + + const handleScroll = () => { + dismiss() } document.addEventListener('pointerdown', handlePointerDown, true) - return () => document.removeEventListener('pointerdown', handlePointerDown, true) + document.addEventListener('scroll', handleScroll, true) + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true) + document.removeEventListener('scroll', handleScroll, true) + } }, [active, onDismiss, popoverRef]) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 06fd47b9..cb8270ca 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1660,6 +1660,7 @@ export const en = { 'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?', 'tabs.closeConfirmKeep': 'Keep Running', // ─── Slash Command Descriptions ────────────────────────────────────── + 'slashCmd.agent.description': 'Run a prompt with a selected Agent', 'slashCmd.mcp.description': 'Open available MCP tools for the current chat context', 'slashCmd.skills.description': 'Browse user-invocable skills for the current chat context', 'slashCmd.help.description': 'Show available desktop and agent commands', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 5874c2fd..9f563821 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1664,6 +1664,7 @@ export const zh: Record = { 'tabs.closeConfirmStop': '停止并关闭', 'tabs.closeAllConfirmTitle': '多个会话运行中', // ─── Slash Command Descriptions ────────────────────────────────────── + 'slashCmd.agent.description': '使用指定 Agent 执行提示', 'slashCmd.mcp.description': '打开当前聊天上下文中可用的 MCP 工具', 'slashCmd.skills.description': '浏览当前上下文中可直接调用的技能', 'slashCmd.help.description': '查看可用的桌面端与 Agent 命令', diff --git a/desktop/src/lib/appZoom.test.ts b/desktop/src/lib/appZoom.test.ts index 7e5196fc..4a6ae773 100644 --- a/desktop/src/lib/appZoom.test.ts +++ b/desktop/src/lib/appZoom.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { APP_ZOOM_STORAGE_KEY, LEGACY_UI_ZOOM_STORAGE_KEY, @@ -8,6 +8,7 @@ import { nextAppZoomLevel, normalizeAppZoomLevel, } from './appZoom' +import { browserHost } from './desktopHost/browserHost' describe('appZoom', () => { beforeEach(() => { @@ -16,6 +17,9 @@ describe('appZoom', () => { document.documentElement.removeAttribute('data-app-zoom-percent') document.documentElement.style.removeProperty('--app-zoom') document.body.style.removeProperty('zoom') + Reflect.deleteProperty(window, 'desktopHost') + Reflect.deleteProperty(window, '__TAURI_INTERNALS__') + Reflect.deleteProperty(window, '__TAURI__') }) it('normalizes, clamps, and steps app zoom levels', () => { @@ -56,6 +60,28 @@ describe('appZoom', () => { expect(document.documentElement.style.getPropertyValue('--app-zoom')).toBe('1.3') }) + it('uses injected desktop host native zoom when available', async () => { + const setZoom = vi.fn().mockResolvedValue(undefined) + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { + ...browserHost.capabilities, + zoom: true, + }, + zoom: { + set: setZoom, + }, + } + + await applyAppZoomLevel(1.4) + + expect(setZoom).toHaveBeenCalledWith(1.4) + expect(document.documentElement.getAttribute('data-app-zoom-mode')).toBe('native') + expect(document.body.style.zoom).toBe('') + }) + it('maps IDE-style zoom shortcuts by platform', () => { expect(getAppZoomKeyboardAction({ altKey: false, diff --git a/desktop/src/lib/appZoom.ts b/desktop/src/lib/appZoom.ts index 23fcb2e4..de5e0143 100644 --- a/desktop/src/lib/appZoom.ts +++ b/desktop/src/lib/appZoom.ts @@ -1,3 +1,5 @@ +import { getDesktopHost } from './desktopHost' + export const APP_ZOOM_STORAGE_KEY = 'cc-haha-app-zoom' export const LEGACY_UI_ZOOM_STORAGE_KEY = 'cc-haha-ui-zoom' export const DEFAULT_APP_ZOOM = 1 @@ -11,10 +13,6 @@ export type AppZoomAction = 'in' | 'out' | 'reset' type StorageLike = Pick type KeyboardShortcutInput = Pick -function isTauriRuntime() { - return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) -} - function getDefaultStorage(): StorageLike | null { try { return globalThis.localStorage ?? null @@ -73,10 +71,10 @@ function setCssAppZoomMode(level: number, mode: 'css' | 'native') { } async function trySetNativeAppZoom(level: number): Promise { - if (!isTauriRuntime()) return false + const host = getDesktopHost() + if (!host.capabilities.zoom) return false try { - const { invoke } = await import('@tauri-apps/api/core') - await invoke('set_app_zoom', { zoomFactor: level }) + await host.zoom.set(level) setCssAppZoomMode(level, 'native') return true } catch { diff --git a/desktop/src/lib/composerAttachments.test.ts b/desktop/src/lib/composerAttachments.test.ts index 92a8328b..3b5fc97b 100644 --- a/desktop/src/lib/composerAttachments.test.ts +++ b/desktop/src/lib/composerAttachments.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from 'vitest' -import { pathToComposerAttachment } from './composerAttachments' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { browserHost } from './desktopHost/browserHost' +import { pathToComposerAttachment, selectNativeFileAttachments } from './composerAttachments' describe('composer attachment payloads', () => { + afterEach(() => { + Reflect.deleteProperty(window, 'desktopHost') + }) + it('keeps many selected desktop project files as paths instead of request-body data', () => { const projectRoot = '/tmp/cc-haha-issue-444-regression' const files = Array.from({ length: 12 }, (_, index) => ( @@ -31,4 +36,29 @@ describe('composer attachment payloads', () => { expect(pathOnlyPayload.length).toBeLessThan(3 * 1024) expect(pathOnlyAttachments.every((attachment) => attachment.path && !attachment.data)).toBe(true) }) + + it('selects native file attachments through the injected desktop host', async () => { + const open = vi.fn().mockResolvedValue(['/workspace/a.txt', '/workspace/b.log']) + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { + ...browserHost.capabilities, + dialogs: true, + }, + dialogs: { + ...browserHost.dialogs, + open, + }, + } + + const attachments = await selectNativeFileAttachments() + + expect(open).toHaveBeenCalledWith({ multiple: true, directory: false }) + expect(attachments?.map((attachment) => attachment.path)).toEqual([ + '/workspace/a.txt', + '/workspace/b.log', + ]) + }) }) diff --git a/desktop/src/lib/composerAttachments.ts b/desktop/src/lib/composerAttachments.ts index f6722b45..6ce9d06c 100644 --- a/desktop/src/lib/composerAttachments.ts +++ b/desktop/src/lib/composerAttachments.ts @@ -1,4 +1,5 @@ -import { isTauriRuntime } from './desktopRuntime' +import { isDesktopRuntime } from './desktopRuntime' +import { getDesktopHost } from './desktopHost' export type ComposerAttachment = { id: string @@ -49,11 +50,11 @@ export async function dataTransferToComposerAttachments(dataTransfer: DataTransf } export async function selectNativeFileAttachments(): Promise { - if (!isTauriRuntime()) return null + const host = getDesktopHost() + if (!host.isDesktop || !host.capabilities.dialogs) return null try { - const { open } = await import('@tauri-apps/plugin-dialog') - const selected = await open({ + const selected = await host.dialogs.open({ multiple: true, directory: false, }) @@ -83,7 +84,7 @@ function getNativeFilePath(file: File): string | undefined { } async function fileToComposerAttachment(file: File): Promise { - const nativePath = isTauriRuntime() ? getNativeFilePath(file) : undefined + const nativePath = isDesktopRuntime() ? getNativeFilePath(file) : undefined if (nativePath) { return pathToComposerAttachment(nativePath) } diff --git a/desktop/src/lib/desktopHost/browserHost.ts b/desktop/src/lib/desktopHost/browserHost.ts new file mode 100644 index 00000000..d5ae0ead --- /dev/null +++ b/desktop/src/lib/desktopHost/browserHost.ts @@ -0,0 +1,234 @@ +import type { + AppModeConfig, + DesktopHost, + DesktopHostCapabilities, + DesktopHostUnlisten, + NotificationPermissionState, +} from './types' + +const browserCapabilities: DesktopHostCapabilities = { + appMode: false, + dialogs: false, + notifications: false, + previewWebview: false, + shell: false, + terminal: false, + updates: false, + windowControls: false, + zoom: false, +} + +function unsupported(feature: string): never { + throw new Error(`${feature} requires the desktop app runtime.`) +} + +function noopUnlisten(): void { + // Intentionally empty: browser fallback has no native event subscriptions. +} + +const defaultAppMode: AppModeConfig = { + mode: 'default', + portableDir: null, + defaultPortableDir: null, +} + +const defaultPermissionState: NotificationPermissionState = 'default' + +export const browserHost: DesktopHost = { + kind: 'browser', + isDesktop: false, + capabilities: browserCapabilities, + runtime: { + async getServerUrl() { + unsupported('Resolving the bundled server URL') + }, + }, + app: { + async getVersion() { + return '0.1.0' + }, + }, + commands: { + async invoke() { + unsupported('Native commands') + }, + }, + events: { + async listen() { + return noopUnlisten + }, + }, + webview: { + async onDragDropEvent() { + return noopUnlisten + }, + }, + shell: { + async open(target) { + if (typeof window !== 'undefined') { + window.open(target, '_blank', 'noopener,noreferrer') + return + } + unsupported('Opening system targets') + }, + async openPath() { + unsupported('Opening system file paths') + }, + }, + dialogs: { + async open() { + unsupported('Native file dialogs') + }, + async save() { + unsupported('Native save dialogs') + }, + }, + updates: { + async check() { + return null + }, + async prepareInstall() { + unsupported('Installing desktop updates') + }, + async cancelInstall() { + unsupported('Cancelling desktop update installs') + }, + async relaunch() { + unsupported('Relaunching the desktop app') + }, + }, + notifications: { + async permissionState() { + if (typeof Notification === 'undefined') return defaultPermissionState + return Notification.permission + }, + async requestPermission() { + if (typeof Notification === 'undefined') return defaultPermissionState + return Notification.requestPermission() + }, + async send(options) { + if (typeof Notification === 'undefined') { + unsupported('Native notifications') + } + new Notification(options.title, { + body: options.body, + icon: options.icon, + }) + }, + async onAction() { + return noopUnlisten + }, + async ackAction() { + return false + }, + }, + window: { + async minimize() { + unsupported('Native window controls') + }, + async toggleMaximize() { + unsupported('Native window controls') + }, + async close() { + unsupported('Native window controls') + }, + async startDragging() { + unsupported('Native window dragging') + }, + async requestAttention() { + unsupported('Native window attention') + }, + async focus() { + if (typeof window !== 'undefined') { + window.focus() + return + } + unsupported('Native window focus') + }, + async isMaximized() { + return false + }, + async onResized() { + return noopUnlisten + }, + async onNativeMenuNavigate() { + return noopUnlisten + }, + }, + terminal: { + async spawn() { + unsupported('Native terminal sessions') + }, + async write() { + unsupported('Native terminal sessions') + }, + async resize() { + unsupported('Native terminal sessions') + }, + async kill() { + unsupported('Native terminal sessions') + }, + async onOutput(): Promise { + return noopUnlisten + }, + async onExit(): Promise { + return noopUnlisten + }, + async getBashPath() { + return null + }, + async setBashPath() { + unsupported('Native shell path settings') + }, + }, + preview: { + async open() { + unsupported('Native preview webview') + }, + async navigate() { + unsupported('Native preview webview') + }, + async setBounds() { + unsupported('Native preview webview') + }, + async setVisible() { + unsupported('Native preview webview') + }, + async close() { + unsupported('Native preview webview') + }, + async message() { + unsupported('Native preview webview') + }, + async onEvent(): Promise { + return noopUnlisten + }, + }, + appMode: { + async get() { + return defaultAppMode + }, + async set() { + unsupported('Desktop app mode') + }, + async detectPortableDir() { + return null + }, + async prepareRestart() { + unsupported('Desktop app restart') + }, + async restart() { + unsupported('Desktop app restart') + }, + }, + adapters: { + async restartSidecar() { + unsupported('Adapter sidecar restart') + }, + }, + zoom: { + async set() { + unsupported('Native app zoom') + }, + }, +} diff --git a/desktop/src/lib/desktopHost/contract.test.ts b/desktop/src/lib/desktopHost/contract.test.ts new file mode 100644 index 00000000..3df9516c --- /dev/null +++ b/desktop/src/lib/desktopHost/contract.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' + +import { browserHost } from './browserHost' +import { createDesktopHost, detectDesktopHostEnvironment } from './index' + +describe('desktop host contract', () => { + it('keeps browser fallback explicit for non-desktop runtimes', () => { + expect(browserHost.kind).toBe('browser') + expect(browserHost.isDesktop).toBe(false) + expect(browserHost.capabilities).toEqual({ + appMode: false, + dialogs: false, + notifications: false, + previewWebview: false, + shell: false, + terminal: false, + updates: false, + windowControls: false, + zoom: false, + }) + }) + + it('rejects desktop-only browser calls with actionable errors', async () => { + await expect(browserHost.runtime.getServerUrl()).rejects.toThrow('desktop app runtime') + await expect(browserHost.dialogs.open({ directory: true })).rejects.toThrow('desktop app runtime') + await expect(browserHost.shell.openPath('/tmp/report.md')).rejects.toThrow('desktop app runtime') + await expect(browserHost.terminal.spawn({ cwd: '/tmp', cols: 80, rows: 24 })).rejects.toThrow( + 'desktop app runtime', + ) + await expect(browserHost.updates.check()).resolves.toBeNull() + }) + + it('detects the browser fallback when native host globals are absent', () => { + expect(createDesktopHost({ electronHost: null, hasTauri: false })).toBe(browserHost) + }) + + it('prefers an injected Electron preload host over browser fallback', () => { + const electronHost = { + ...browserHost, + kind: 'electron' as const, + isDesktop: true, + } + + expect(createDesktopHost({ electronHost, hasTauri: false })).toBe(electronHost) + }) + + it('detects Tauri and Electron runtime globals without importing native modules', () => { + const originalDesktopHost = window.desktopHost + const originalTauri = window.__TAURI_INTERNALS__ + + try { + Reflect.deleteProperty(window, 'desktopHost') + Reflect.deleteProperty(window, '__TAURI_INTERNALS__') + expect(detectDesktopHostEnvironment()).toEqual({ electronHost: null, hasTauri: false }) + + window.__TAURI_INTERNALS__ = {} + expect(detectDesktopHostEnvironment()).toEqual({ electronHost: null, hasTauri: true }) + + const electronHost = { + ...browserHost, + kind: 'electron' as const, + isDesktop: true, + } + window.desktopHost = electronHost + expect(detectDesktopHostEnvironment()).toEqual({ electronHost, hasTauri: true }) + } finally { + if (typeof originalDesktopHost === 'undefined') { + Reflect.deleteProperty(window, 'desktopHost') + } else { + window.desktopHost = originalDesktopHost + } + + if (typeof originalTauri === 'undefined') { + Reflect.deleteProperty(window, '__TAURI_INTERNALS__') + } else { + window.__TAURI_INTERNALS__ = originalTauri + } + } + }) + + it('allows event unlisteners to stay synchronous across host implementations', async () => { + const outputHandler = vi.fn() + const exitHandler = vi.fn() + + const stopOutput = await browserHost.terminal.onOutput(outputHandler) + const stopExit = await browserHost.terminal.onExit(exitHandler) + + expect(stopOutput()).toBeUndefined() + expect(stopExit()).toBeUndefined() + expect(outputHandler).not.toHaveBeenCalled() + expect(exitHandler).not.toHaveBeenCalled() + }) +}) diff --git a/desktop/src/lib/desktopHost/electronHost.test.ts b/desktop/src/lib/desktopHost/electronHost.test.ts new file mode 100644 index 00000000..b1983ab8 --- /dev/null +++ b/desktop/src/lib/desktopHost/electronHost.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest' +import { ELECTRON_EVENT_CHANNELS, ELECTRON_IPC_CHANNELS } from '../../../electron/ipc/channels' +import { createElectronHost } from './electronHost' + +describe('electron desktop host', () => { + it('wraps dialog, shell URL, and shell path calls in explicit IPC channels', async () => { + const invoke = vi.fn().mockResolvedValue('/tmp/report.md') + const host = createElectronHost({ + invoke, + subscribe: vi.fn(), + }) + + await host.shell.open('https://example.com') + await host.shell.openPath('/tmp/report.md') + await host.dialogs.open({ directory: true, multiple: false, title: 'Choose folder' }) + + expect(invoke).toHaveBeenNthCalledWith(1, ELECTRON_IPC_CHANNELS.shellOpen, 'https://example.com') + expect(invoke).toHaveBeenNthCalledWith(2, ELECTRON_IPC_CHANNELS.shellOpenPath, '/tmp/report.md') + expect(invoke).toHaveBeenNthCalledWith(3, ELECTRON_IPC_CHANNELS.dialogOpen, { + directory: true, + multiple: false, + title: 'Choose folder', + }) + }) + + it('rejects invalid preload payloads before invoking Electron IPC', async () => { + const invoke = vi.fn() + const host = createElectronHost({ + invoke, + subscribe: vi.fn(), + }) + + await expect(host.shell.openPath({ path: '/tmp/report.md' } as unknown as string)).rejects.toThrow( + 'Invalid Electron IPC payload', + ) + expect(invoke).not.toHaveBeenCalled() + }) + + it('does not advertise custom window chrome until Electron frameless drag is implemented', () => { + const host = createElectronHost({ + invoke: vi.fn(), + subscribe: vi.fn(), + }) + + expect(host.capabilities.windowControls).toBe(false) + }) + + it('keeps event subscriptions behind named event channels', async () => { + const unlisten = vi.fn() + const subscribe = vi.fn().mockResolvedValue(unlisten) + const handler = vi.fn() + const host = createElectronHost({ + invoke: vi.fn(), + subscribe, + }) + + const stop = await host.window.onNativeMenuNavigate(handler) + stop() + + expect(subscribe).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, handler) + expect(unlisten).toHaveBeenCalledTimes(1) + }) + + it('acknowledges handled notification actions through a diagnostics IPC channel', async () => { + const invoke = vi.fn().mockResolvedValue(true) + const payload = { target: { type: 'session', sessionId: 'session-1' } } + const host = createElectronHost({ + invoke, + subscribe: vi.fn(), + }) + + await expect(host.notifications.ackAction(payload)).resolves.toBe(true) + + expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.notificationActionAck, payload) + }) + + it('wraps Electron update metadata with download/install methods', async () => { + const unlisten = vi.fn() + const invoke = vi.fn() + .mockResolvedValueOnce({ version: '1.2.3', body: 'Fixes' }) + .mockResolvedValue(undefined) + const subscribe = vi.fn().mockResolvedValue(unlisten) + const onProgress = vi.fn() + const host = createElectronHost({ invoke, subscribe }) + + const update = await host.updates.check() + await update?.download(onProgress) + await update?.install() + await update?.close() + + expect(update?.version).toBe('1.2.3') + expect(subscribe).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.updateDownloadEvent, onProgress) + expect(invoke).toHaveBeenNthCalledWith(1, ELECTRON_IPC_CHANNELS.updateCheck, undefined) + expect(invoke).toHaveBeenNthCalledWith(2, ELECTRON_IPC_CHANNELS.updateDownload, undefined) + expect(invoke).toHaveBeenNthCalledWith(3, ELECTRON_IPC_CHANNELS.updateInstall, undefined) + expect(invoke).toHaveBeenNthCalledWith(4, ELECTRON_IPC_CHANNELS.updateCancelInstall, undefined) + expect(unlisten).toHaveBeenCalledTimes(1) + }) +}) diff --git a/desktop/src/lib/desktopHost/electronHost.ts b/desktop/src/lib/desktopHost/electronHost.ts new file mode 100644 index 00000000..60430a81 --- /dev/null +++ b/desktop/src/lib/desktopHost/electronHost.ts @@ -0,0 +1,158 @@ +import type { + DesktopHost, + DesktopHostUnlisten, + DesktopUpdate, + DesktopUpdateDownloadEvent, +} from './types' +import { + ELECTRON_EVENT_CHANNELS, + ELECTRON_IPC_CHANNELS, + type ElectronEventChannel, + type ElectronIpcChannel, +} from '../../../electron/ipc/channels' +import { validateElectronIpcPayload } from '../../../electron/ipc/capabilities' + +export type ElectronHostBridge = { + invoke(channel: ElectronIpcChannel, payload?: unknown): Promise + subscribe( + channel: ElectronEventChannel, + handler: (payload: T) => void, + ): Promise +} + +type ElectronUpdateMetadata = { + version: string + body?: string | null +} + +function safeInvoke( + bridge: ElectronHostBridge, + channel: ElectronIpcChannel, + payload?: unknown, +): Promise { + if (!validateElectronIpcPayload(channel, payload)) { + return Promise.reject(new Error(`Invalid Electron IPC payload for ${channel}`)) + } + return bridge.invoke(channel, payload) +} + +export function createElectronHost(bridge: ElectronHostBridge): DesktopHost { + const invoke = (channel: ElectronIpcChannel, payload?: unknown) => + safeInvoke(bridge, channel, payload) + const subscribe = (channel: ElectronEventChannel, handler: (payload: T) => void) => + bridge.subscribe(channel, handler) + const createUpdate = (metadata: ElectronUpdateMetadata): DesktopUpdate => ({ + version: metadata.version, + body: metadata.body ?? null, + async download(onEvent) { + const unlisten = onEvent + ? await subscribe(ELECTRON_EVENT_CHANNELS.updateDownloadEvent, onEvent) + : null + try { + await invoke(ELECTRON_IPC_CHANNELS.updateDownload) + } finally { + unlisten?.() + } + }, + install: () => invoke(ELECTRON_IPC_CHANNELS.updateInstall), + close: () => invoke(ELECTRON_IPC_CHANNELS.updateCancelInstall), + }) + + return { + kind: 'electron', + isDesktop: true, + capabilities: { + appMode: true, + dialogs: true, + notifications: true, + previewWebview: true, + shell: true, + terminal: true, + updates: true, + windowControls: false, + zoom: true, + }, + runtime: { + getServerUrl: () => invoke(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl), + }, + app: { + getVersion: () => invoke(ELECTRON_IPC_CHANNELS.appGetVersion), + }, + commands: { + invoke: (command, args) => invoke(ELECTRON_IPC_CHANNELS.commandInvoke, { command, args }), + }, + events: { + listen: (_eventName, handler) => subscribe(ELECTRON_EVENT_CHANNELS.event, handler), + }, + webview: { + onDragDropEvent: handler => subscribe(ELECTRON_EVENT_CHANNELS.webviewDragDrop, handler), + }, + shell: { + open: target => invoke(ELECTRON_IPC_CHANNELS.shellOpen, target), + openPath: path => invoke(ELECTRON_IPC_CHANNELS.shellOpenPath, path), + }, + dialogs: { + open: options => invoke(ELECTRON_IPC_CHANNELS.dialogOpen, options), + save: options => invoke(ELECTRON_IPC_CHANNELS.dialogSave, options), + }, + updates: { + check: async (options) => { + const update = await invoke(ELECTRON_IPC_CHANNELS.updateCheck, options) + return update ? createUpdate(update) : null + }, + prepareInstall: () => invoke(ELECTRON_IPC_CHANNELS.updatePrepareInstall), + cancelInstall: () => invoke(ELECTRON_IPC_CHANNELS.updateCancelInstall), + relaunch: () => invoke(ELECTRON_IPC_CHANNELS.updateRelaunch), + }, + notifications: { + permissionState: () => invoke(ELECTRON_IPC_CHANNELS.notificationPermissionState), + requestPermission: () => invoke(ELECTRON_IPC_CHANNELS.notificationRequestPermission), + send: options => invoke(ELECTRON_IPC_CHANNELS.notificationSend, options), + onAction: handler => subscribe(ELECTRON_EVENT_CHANNELS.notificationAction, handler), + ackAction: payload => invoke(ELECTRON_IPC_CHANNELS.notificationActionAck, payload), + }, + window: { + minimize: () => invoke(ELECTRON_IPC_CHANNELS.windowMinimize), + toggleMaximize: () => invoke(ELECTRON_IPC_CHANNELS.windowToggleMaximize), + close: () => invoke(ELECTRON_IPC_CHANNELS.windowClose), + startDragging: () => invoke(ELECTRON_IPC_CHANNELS.windowStartDragging), + requestAttention: () => invoke(ELECTRON_IPC_CHANNELS.windowRequestAttention), + focus: () => invoke(ELECTRON_IPC_CHANNELS.windowFocus), + isMaximized: () => invoke(ELECTRON_IPC_CHANNELS.windowIsMaximized), + onResized: handler => subscribe(ELECTRON_EVENT_CHANNELS.windowResized, handler), + onNativeMenuNavigate: handler => subscribe(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, handler), + }, + terminal: { + spawn: options => invoke(ELECTRON_IPC_CHANNELS.terminalSpawn, options), + write: (sessionId, data) => invoke(ELECTRON_IPC_CHANNELS.terminalWrite, { sessionId, data }), + resize: (sessionId, cols, rows) => invoke(ELECTRON_IPC_CHANNELS.terminalResize, { sessionId, cols, rows }), + kill: sessionId => invoke(ELECTRON_IPC_CHANNELS.terminalKill, { sessionId }), + onOutput: handler => subscribe(ELECTRON_EVENT_CHANNELS.terminalOutput, handler), + onExit: handler => subscribe(ELECTRON_EVENT_CHANNELS.terminalExit, handler), + getBashPath: () => invoke(ELECTRON_IPC_CHANNELS.terminalGetBashPath), + setBashPath: path => invoke(ELECTRON_IPC_CHANNELS.terminalSetBashPath, path), + }, + preview: { + open: (url, bounds) => invoke(ELECTRON_IPC_CHANNELS.previewOpen, { url, bounds }), + navigate: url => invoke(ELECTRON_IPC_CHANNELS.previewNavigate, url), + setBounds: bounds => invoke(ELECTRON_IPC_CHANNELS.previewSetBounds, bounds), + setVisible: visible => invoke(ELECTRON_IPC_CHANNELS.previewSetVisible, visible), + close: () => invoke(ELECTRON_IPC_CHANNELS.previewClose), + message: payload => invoke(ELECTRON_IPC_CHANNELS.previewMessage, payload), + onEvent: handler => subscribe(ELECTRON_EVENT_CHANNELS.previewEvent, handler), + }, + appMode: { + get: () => invoke(ELECTRON_IPC_CHANNELS.appModeGet), + set: config => invoke(ELECTRON_IPC_CHANNELS.appModeSet, config), + detectPortableDir: () => invoke(ELECTRON_IPC_CHANNELS.appModeDetectPortableDir), + prepareRestart: () => invoke(ELECTRON_IPC_CHANNELS.appModePrepareRestart), + restart: () => invoke(ELECTRON_IPC_CHANNELS.appModeRestart), + }, + adapters: { + restartSidecar: () => invoke(ELECTRON_IPC_CHANNELS.adaptersRestartSidecar), + }, + zoom: { + set: level => invoke(ELECTRON_IPC_CHANNELS.zoomSet, level), + }, + } +} diff --git a/desktop/src/lib/desktopHost/index.ts b/desktop/src/lib/desktopHost/index.ts new file mode 100644 index 00000000..e94a5d64 --- /dev/null +++ b/desktop/src/lib/desktopHost/index.ts @@ -0,0 +1,39 @@ +import { browserHost } from './browserHost' +import { tauriHost } from './tauriHost' +import type { DesktopHost } from './types' + +export type DesktopHostEnvironment = { + electronHost: DesktopHost | null + hasTauri: boolean + tauriHost?: DesktopHost | null +} + +export function detectDesktopHostEnvironment(): DesktopHostEnvironment { + if (typeof window === 'undefined') { + return { electronHost: null, hasTauri: false } + } + + return { + electronHost: window.desktopHost ?? null, + hasTauri: '__TAURI_INTERNALS__' in window || '__TAURI__' in window, + } +} + +export function createDesktopHost( + environment: DesktopHostEnvironment = detectDesktopHostEnvironment(), +): DesktopHost { + if (environment.electronHost) return environment.electronHost + if (environment.hasTauri && environment.tauriHost) return environment.tauriHost + return browserHost +} + +export function getDesktopHost(): DesktopHost { + return createDesktopHost({ + ...detectDesktopHostEnvironment(), + tauriHost, + }) +} + +export const desktopHost = getDesktopHost() + +export type * from './types' diff --git a/desktop/src/lib/desktopHost/tauriHost.ts b/desktop/src/lib/desktopHost/tauriHost.ts new file mode 100644 index 00000000..56016d0d --- /dev/null +++ b/desktop/src/lib/desktopHost/tauriHost.ts @@ -0,0 +1,256 @@ +import { browserHost } from './browserHost' +import type { + DesktopHost, + DesktopHostUnlisten, + TerminalExitEvent, + TerminalOutputEvent, +} from './types' + +const tauriCapabilities: DesktopHost['capabilities'] = { + appMode: true, + dialogs: true, + notifications: true, + previewWebview: true, + shell: true, + terminal: true, + updates: true, + windowControls: true, + zoom: true, +} + +async function invoke(command: string, args?: Record): Promise { + const api = await import('@tauri-apps/api/core') + return typeof args === 'undefined' + ? api.invoke(command) + : api.invoke(command, args) +} + +async function listen( + eventName: string, + handler: (payload: T) => void, +): Promise { + const events = await import('@tauri-apps/api/event') + return events.listen(eventName, (event) => handler(event.payload)) +} + +export const tauriHost: DesktopHost = { + ...browserHost, + kind: 'tauri', + isDesktop: true, + capabilities: tauriCapabilities, + runtime: { + getServerUrl() { + return invoke('get_server_url') + }, + }, + app: { + async getVersion() { + const { getVersion } = await import('@tauri-apps/api/app') + return getVersion() + }, + }, + commands: { + invoke, + }, + events: { + listen, + }, + webview: { + async onDragDropEvent(handler) { + const { getCurrentWebview } = await import('@tauri-apps/api/webview') + return getCurrentWebview().onDragDropEvent(handler) + }, + }, + shell: { + async open(target) { + const { open } = await import('@tauri-apps/plugin-shell') + await open(target) + }, + async openPath(path) { + const { open } = await import('@tauri-apps/plugin-shell') + await open(path) + }, + }, + dialogs: { + async open(options) { + const { open } = await import('@tauri-apps/plugin-dialog') + return open(options) + }, + async save(options) { + const { save } = await import('@tauri-apps/plugin-dialog') + return save(options) + }, + }, + notifications: { + async permissionState() { + const { isPermissionGranted } = await import('@tauri-apps/plugin-notification') + return await isPermissionGranted() ? 'granted' : 'default' + }, + async requestPermission() { + const { + isPermissionGranted, + requestPermission, + } = await import('@tauri-apps/plugin-notification') + if (await isPermissionGranted()) return 'granted' + const permission = await requestPermission() + return permission === 'granted' || permission === 'denied' || permission === 'default' + ? permission + : 'default' + }, + async send(options) { + const { sendNotification } = await import('@tauri-apps/plugin-notification') + sendNotification(options) + }, + async onAction(handler) { + const notification = await import('@tauri-apps/plugin-notification') as { + onAction?: (cb: (payload: unknown) => void) => Promise + } + if (!notification.onAction) return () => {} + const listener = await notification.onAction(handler) + return () => { + if (typeof listener === 'function') { + listener() + return + } + const unregister = listener && typeof listener === 'object' + ? (listener as { unregister?: () => Promise | void }).unregister + : undefined + if (typeof unregister === 'function') void unregister.call(listener) + } + }, + async ackAction() { + return false + }, + }, + window: { + async minimize() { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + await getCurrentWindow().minimize() + }, + async toggleMaximize() { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + await getCurrentWindow().toggleMaximize() + }, + async close() { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + await getCurrentWindow().close() + }, + async startDragging() { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + await getCurrentWindow().startDragging() + }, + async requestAttention() { + const { getCurrentWindow, UserAttentionType } = await import('@tauri-apps/api/window') + await getCurrentWindow().requestUserAttention(UserAttentionType.Critical) + }, + async focus() { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + const win = getCurrentWindow() + await (win as unknown as { show?: () => Promise | void }).show?.() + await (win as unknown as { setFocus?: () => Promise | void }).setFocus?.() + }, + async isMaximized() { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + return getCurrentWindow().isMaximized() + }, + async onResized(handler) { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + return getCurrentWindow().onResized(handler) + }, + onNativeMenuNavigate(handler) { + return listen('native-menu-navigate', handler) + }, + }, + updates: { + async check(options) { + const updater = await import('@tauri-apps/plugin-updater') + return updater.check(options) + }, + prepareInstall() { + return invoke('prepare_for_update_install') + }, + cancelInstall() { + return invoke('cancel_update_install') + }, + async relaunch() { + const { relaunch } = await import('@tauri-apps/plugin-process') + await relaunch() + }, + }, + terminal: { + spawn(input) { + return invoke('terminal_spawn', input) + }, + write(sessionId, data) { + return invoke('terminal_write', { sessionId, data }) + }, + resize(sessionId, cols, rows) { + return invoke('terminal_resize', { sessionId, cols, rows }) + }, + kill(sessionId) { + return invoke('terminal_kill', { sessionId }) + }, + onOutput(handler) { + return listen('terminal-output', handler) + }, + onExit(handler) { + return listen('terminal-exit', handler) + }, + getBashPath() { + return invoke('get_terminal_bash_path') + }, + setBashPath(path) { + return invoke('set_terminal_bash_path', { path }) + }, + }, + preview: { + open(url, bounds) { + return invoke('preview_open', { url, bounds }) + }, + navigate(url) { + return invoke('preview_navigate', { url }) + }, + setBounds(bounds) { + return invoke('preview_set_bounds', { bounds }) + }, + setVisible(visible) { + return invoke('preview_set_visible', { visible }) + }, + close() { + return invoke('preview_close') + }, + message(payload) { + return invoke('preview_message', { raw: JSON.stringify(payload) }) + }, + onEvent(handler) { + return listen('preview://event', handler) + }, + }, + zoom: { + set(level) { + return invoke('set_app_zoom', { zoomFactor: level }) + }, + }, + adapters: { + restartSidecar() { + return invoke('restart_adapters_sidecar') + }, + }, + appMode: { + get() { + return invoke('get_app_mode') + }, + set(config) { + return invoke('set_app_mode', config) + }, + detectPortableDir() { + return invoke('detect_portable_dir') + }, + prepareRestart() { + return invoke('prepare_for_app_mode_restart') + }, + restart() { + return tauriHost.updates.relaunch() + }, + }, +} diff --git a/desktop/src/lib/desktopHost/types.ts b/desktop/src/lib/desktopHost/types.ts new file mode 100644 index 00000000..240239f3 --- /dev/null +++ b/desktop/src/lib/desktopHost/types.ts @@ -0,0 +1,234 @@ +import type { + AppMode as SettingsAppMode, + AppModeConfig as SettingsAppModeConfig, +} from '../../types/settings' + +export type DesktopHostKind = 'browser' | 'tauri' | 'electron' + +export type DesktopHostCapability = + | 'appMode' + | 'dialogs' + | 'notifications' + | 'previewWebview' + | 'shell' + | 'terminal' + | 'updates' + | 'windowControls' + | 'zoom' + +export type DesktopHostCapabilities = Record + +export type DesktopHostUnlisten = () => void + +export type DialogFileFilter = { + name: string + extensions: string[] +} + +export type DialogOpenOptions = { + directory?: boolean + multiple?: boolean + title?: string + defaultPath?: string + filters?: DialogFileFilter[] +} + +export type DialogSaveOptions = { + title?: string + defaultPath?: string + filters?: DialogFileFilter[] +} + +export type NotificationPermissionState = 'granted' | 'denied' | 'default' + +export type DesktopNotificationOptions = { + title: string + body?: string + icon?: string + id?: number + extra?: Record + target?: unknown +} + +export type DesktopUpdateDownloadEvent = + | { + event: 'Started' + data: { + contentLength?: number | null + } + } + | { + event: 'Progress' + data: { + chunkLength: number + } + } + | { + event: 'Finished' + } + +export type DesktopUpdate = { + version: string + body?: string | null + download(onEvent?: (event: DesktopUpdateDownloadEvent) => void): Promise + install(): Promise + close(): Promise +} + +export type DesktopUpdateCheckOptions = { + proxy?: string +} + +export type TerminalSpawnOptions = { + cwd?: string + shell?: string + cols: number + rows: number +} + +export type TerminalSession = { + session_id: number + shell: string + cwd: string +} + +export type TerminalOutputEvent = { + session_id: number + data: string +} + +export type TerminalExitEvent = { + session_id: number + code: number + signal?: string | null +} + +export type PreviewBounds = { + x: number + y: number + width: number + height: number +} + +export type PreviewEvent = { + type: string + payload?: unknown +} + +export type PreviewCaptureMessage = { + v: 1 + type: 'capture' + kind: 'full' +} + +export type PreviewPickerMessage = { + v: 1 + type: 'enter-picker' | 'exit-picker' +} + +export type PreviewHostMessage = PreviewCaptureMessage | PreviewPickerMessage + +export type AppModeConfig = SettingsAppModeConfig + +export type AppModeSetInput = { + mode: SettingsAppMode + portableDir: string | null +} + +export type PortableDirDetection = { + defaultPortableDir: string | null + hasData: boolean +} + +export type DesktopHost = { + kind: DesktopHostKind + isDesktop: boolean + capabilities: DesktopHostCapabilities + runtime: { + getServerUrl(): Promise + } + app: { + getVersion(): Promise + } + commands: { + invoke(command: string, args?: Record): Promise + } + events: { + listen(eventName: string, handler: (payload: T) => void): Promise + } + webview: { + onDragDropEvent(handler: (event: unknown) => void): Promise + } + shell: { + open(target: string): Promise + openPath(path: string): Promise + } + dialogs: { + open(options?: DialogOpenOptions): Promise + save(options?: DialogSaveOptions): Promise + } + updates: { + check(options?: DesktopUpdateCheckOptions): Promise + prepareInstall(): Promise + cancelInstall(): Promise + relaunch(): Promise + } + notifications: { + permissionState(): Promise + requestPermission(): Promise + send(options: DesktopNotificationOptions): Promise + onAction(handler: (payload: unknown) => void): Promise + ackAction(payload: unknown): Promise + } + window: { + minimize(): Promise + toggleMaximize(): Promise + close(): Promise + startDragging(): Promise + requestAttention(): Promise + focus(): Promise + isMaximized(): Promise + onResized(handler: () => void): Promise + onNativeMenuNavigate(handler: (destination: string) => void): Promise + } + terminal: { + spawn(options: TerminalSpawnOptions): Promise + write(sessionId: number, data: string): Promise + resize(sessionId: number, cols: number, rows: number): Promise + kill(sessionId: number): Promise + onOutput(handler: (event: TerminalOutputEvent) => void): Promise + onExit(handler: (event: TerminalExitEvent) => void): Promise + getBashPath(): Promise + setBashPath(path: string | null): Promise + } + preview: { + open(url: string, bounds?: PreviewBounds): Promise + navigate(url: string): Promise + setBounds(bounds: PreviewBounds): Promise + setVisible(visible: boolean): Promise + close(): Promise + message(payload: PreviewHostMessage): Promise + onEvent(handler: (event: unknown) => void): Promise + } + appMode: { + get(): Promise + set(config: AppModeSetInput): Promise + detectPortableDir(): Promise + prepareRestart(): Promise + restart(): Promise + } + adapters: { + restartSidecar(): Promise + } + zoom: { + set(level: number): Promise + } +} + +declare global { + interface Window { + desktopHost?: DesktopHost + __TAURI__?: unknown + __TAURI_INTERNALS__?: unknown + } +} diff --git a/desktop/src/lib/desktopNotifications.test.ts b/desktop/src/lib/desktopNotifications.test.ts index e9deb983..a8371536 100644 --- a/desktop/src/lib/desktopNotifications.test.ts +++ b/desktop/src/lib/desktopNotifications.test.ts @@ -62,6 +62,11 @@ describe('desktopNotifications', () => { configurable: true, value: 'Linux x86_64', }) + Object.defineProperty(window, '__TAURI_INTERNALS__', { + configurable: true, + value: {}, + }) + Reflect.deleteProperty(window, 'desktopHost') }) it('sends through the Tauri plugin when native notification permission is already granted', async () => { @@ -288,6 +293,32 @@ describe('desktopNotifications', () => { expect(shellApiMock.open).not.toHaveBeenCalled() }) + it('opens macOS notification settings through the native command before shell fallback', async () => { + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: 'MacIntel', + }) + coreApiMock.invoke.mockResolvedValueOnce(true) + + await expect(openDesktopNotificationSettings()).resolves.toBe(true) + + expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_open_notification_settings') + expect(shellApiMock.open).not.toHaveBeenCalled() + }) + + it('falls back to shell.open for macOS notification settings when native command is unavailable', async () => { + Object.defineProperty(navigator, 'platform', { + configurable: true, + value: 'MacIntel', + }) + coreApiMock.invoke.mockRejectedValueOnce(new Error('unavailable')) + + await expect(openDesktopNotificationSettings()).resolves.toBe(true) + + expect(coreApiMock.invoke).toHaveBeenCalledWith('macos_open_notification_settings') + expect(shellApiMock.open).toHaveBeenCalledWith('x-apple.systempreferences:com.apple.preference.notifications') + }) + it('reports and requests macOS notification permission through the native bridge', async () => { Object.defineProperty(navigator, 'platform', { configurable: true, diff --git a/desktop/src/lib/desktopNotifications.ts b/desktop/src/lib/desktopNotifications.ts index d39f52d4..058e12b9 100644 --- a/desktop/src/lib/desktopNotifications.ts +++ b/desktop/src/lib/desktopNotifications.ts @@ -1,4 +1,5 @@ import { useSettingsStore } from '../stores/settingsStore' +import { getDesktopHost } from './desktopHost' const DEFAULT_COOLDOWN_MS = 750 @@ -81,8 +82,7 @@ async function invokeWindowsNotificationPermissionState(): Promise('plugin:notification|is_permission_granted') + const granted = await getDesktopHost().commands.invoke('plugin:notification|is_permission_granted') return normalizePermission(granted) } catch (err) { if (typeof console !== 'undefined') { @@ -96,8 +96,7 @@ async function invokeWindowsNotificationPermissionRequest(): Promise('plugin:notification|request_permission') + const permission = await getDesktopHost().commands.invoke('plugin:notification|request_permission') return normalizePermission(permission) } catch (err) { if (typeof console !== 'undefined') { @@ -111,8 +110,7 @@ async function invokeMacNotificationPermissionState(): Promise('macos_notification_permission_state') + const permission = await getDesktopHost().commands.invoke('macos_notification_permission_state') return ['default', 'denied', 'granted', 'unsupported'].includes(permission) ? permission : 'unsupported' } catch (err) { if (typeof console !== 'undefined') { @@ -126,8 +124,7 @@ async function invokeMacNotificationPermissionRequest(): Promise('macos_request_notification_permission') + const permission = await getDesktopHost().commands.invoke('macos_request_notification_permission') return ['default', 'denied', 'granted', 'unsupported'].includes(permission) ? permission : 'unsupported' } catch (err) { if (typeof console !== 'undefined') { @@ -169,6 +166,9 @@ function notificationTargetFromPayload(payload: unknown): DesktopNotificationTar const directTarget = notificationTargetFromPayload(record.target) if (directTarget) return directTarget + const eventPayloadTarget = notificationTargetFromPayload(record.payload) + if (eventPayloadTarget) return eventPayloadTarget + const extra = record.extra && typeof record.extra === 'object' ? record.extra as Record : null @@ -210,9 +210,8 @@ async function sendMacNotification(options: { title: string; body?: string; targ if (detectPlatform() !== 'darwin') return null try { - const { invoke } = await import('@tauri-apps/api/core') const target = options.target ? JSON.stringify(options.target) : undefined - const sent = await invoke('macos_send_notification', { + const sent = await getDesktopHost().commands.invoke('macos_send_notification', { title: options.title, body: options.body, ...(target ? { target } : {}), @@ -234,8 +233,8 @@ export async function getDesktopNotificationPermission(): Promise { const url = getNotificationSettingsUrl() if (!url) return false - if (detectPlatform() === 'win32') { + const platform = detectPlatform() + if (platform === 'darwin') { try { - const { invoke } = await import('@tauri-apps/api/core') - const opened = await invoke('open_windows_notification_settings') + const opened = await getDesktopHost().commands.invoke('macos_open_notification_settings') + if (opened) return true + } catch { + // Fall back to shell.open/window.open below. + } + } + + if (platform === 'win32') { + try { + const opened = await getDesktopHost().commands.invoke('open_windows_notification_settings') if (opened) return true } catch { // Fall back to shell.open/window.open below. @@ -277,8 +279,7 @@ export async function openDesktopNotificationSettings(): Promise { } try { - const { open } = await import('@tauri-apps/plugin-shell') - await open(url) + await getDesktopHost().shell.open(url) return true } catch { try { @@ -294,26 +295,22 @@ async function sendNativeNotification(options: { title: string; body?: string; t const macSent = await sendMacNotification(options) if (macSent !== null) return macSent - const { - isPermissionGranted, - sendNotification, - } = await import('@tauri-apps/plugin-notification') - const windowsPermission = await invokeWindowsNotificationPermissionState() - const permissionGranted = windowsPermission ? windowsPermission === 'granted' : await isPermissionGranted() + const permissionGranted = windowsPermission + ? windowsPermission === 'granted' + : await getDesktopHost().notifications.permissionState() === 'granted' if (!permissionGranted) { return false } const payload = buildNativeNotificationPayload(options) - sendNotification(payload) + await getDesktopHost().notifications.send(payload) return true } async function requestWindowAttention(): Promise { try { - const { getCurrentWindow, UserAttentionType } = await import('@tauri-apps/api/window') - await getCurrentWindow().requestUserAttention(UserAttentionType.Critical) + await getDesktopHost().window.requestAttention() return true } catch { return false @@ -322,26 +319,12 @@ async function requestWindowAttention(): Promise { async function focusCurrentWindow(): Promise { try { - const { getCurrentWindow } = await import('@tauri-apps/api/window') - const win = getCurrentWindow() - await (win as unknown as { show?: () => Promise | void }).show?.() - await (win as unknown as { setFocus?: () => Promise | void }).setFocus?.() + await getDesktopHost().window.focus() } catch { // Best effort only: the notification target can still be opened in the UI. } } -function disposePluginListener(listener: unknown): void { - if (typeof listener === 'function') { - listener() - return - } - const unregister = listener && typeof listener === 'object' - ? (listener as { unregister?: () => Promise | void }).unregister - : undefined - if (typeof unregister === 'function') void unregister.call(listener) -} - export async function installDesktopNotificationClickListener( onTarget: (target: DesktopNotificationTarget) => void, ): Promise<() => void> { @@ -351,26 +334,19 @@ export async function installDesktopNotificationClickListener( if (!target) return void focusCurrentWindow() onTarget(target) + void getDesktopHost().notifications.ackAction({ target, payload }).catch(() => {}) } try { - const { listen } = await import('@tauri-apps/api/event') - const unlisten = await listen('desktop-notification-clicked', (event) => { - handlePayload(event.payload) - }) + const unlisten = await getDesktopHost().events.listen('desktop-notification-clicked', handlePayload) cleanups.push(unlisten) } catch { // Non-Tauri browser tests and unsupported runtimes do not expose native events. } try { - const { onAction } = await import('@tauri-apps/plugin-notification') as { - onAction?: (cb: (notification: unknown) => void) => Promise - } - if (onAction) { - const listener = await onAction(handlePayload) - cleanups.push(() => disposePluginListener(listener)) - } + const unlisten = await getDesktopHost().notifications.onAction(handlePayload) + cleanups.push(unlisten) } catch { // The desktop plugin does not expose click actions on every platform. } diff --git a/desktop/src/lib/desktopRuntime.test.ts b/desktop/src/lib/desktopRuntime.test.ts index 0e39c865..a8989443 100644 --- a/desktop/src/lib/desktopRuntime.test.ts +++ b/desktop/src/lib/desktopRuntime.test.ts @@ -23,10 +23,13 @@ import { H5_SERVER_URL_STORAGE_KEY, H5_TOKEN_STORAGE_KEY, initializeDesktopServerUrl, + isBrowserH5Runtime, + isDesktopRuntime, isLoopbackHostname, requiresH5AuthForServerUrl, saveAndVerifyH5Connection, } from './desktopRuntime' +import { browserHost } from './desktopHost/browserHost' function healthOkResponse() { return Response.json({ status: 'ok' }) @@ -42,6 +45,9 @@ describe('desktopRuntime browser H5 bootstrap', () => { vi.useRealTimers() window.localStorage.clear() window.history.pushState({}, '', '/') + Reflect.deleteProperty(window, 'desktopHost') + Reflect.deleteProperty(window, '__TAURI_INTERNALS__') + Reflect.deleteProperty(window, '__TAURI__') globalThis.fetch = originalFetch }) @@ -119,6 +125,66 @@ describe('desktopRuntime browser H5 bootstrap', () => { }) }) + it('uses an injected desktop host server URL before browser fallback', async () => { + const serverUrl = 'http://127.0.0.1:59231' + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + runtime: { + getServerUrl: vi.fn().mockResolvedValue(serverUrl), + }, + } + globalThis.fetch = vi.fn().mockResolvedValue( + healthOkResponse(), + ) as typeof fetch + + await expect(initializeDesktopServerUrl()).resolves.toBe(serverUrl) + + expect(window.desktopHost.runtime.getServerUrl).toHaveBeenCalledTimes(1) + expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(serverUrl) + expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) + expect(globalThis.fetch).toHaveBeenCalledWith(`${serverUrl}/health`, { + cache: 'no-store', + }) + expect(globalThis.fetch).toHaveBeenCalledTimes(1) + }) + + it('classifies browser H5 runtime using the desktop host boundary', () => { + expect(isBrowserH5Runtime()).toBe(true) + expect(isDesktopRuntime()).toBe(false) + + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + } + + expect(isBrowserH5Runtime()).toBe(false) + expect(isDesktopRuntime()).toBe(true) + }) + + it('normalizes injected desktop host startup failures', async () => { + const error = new Error('electron sidecar failed') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + runtime: { + getServerUrl: vi.fn().mockRejectedValue(error), + }, + } + + await expect(initializeDesktopServerUrl()).rejects.toThrow('electron sidecar failed') + expect(consoleError).toHaveBeenCalledWith( + '[desktop] Failed to initialize desktop server URL', + error, + ) + + consoleError.mockRestore() + }) + it('does not treat a Vite SPA fallback response as a desktop server healthcheck', async () => { vi.useFakeTimers() globalThis.fetch = vi.fn().mockResolvedValue( diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index bafa0298..d9fda06f 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -6,6 +6,7 @@ import { setAuthToken, setBaseUrl, } from '../api/client' +import { detectDesktopHostEnvironment, getDesktopHost } from './desktopHost' export const H5_SERVER_URL_STORAGE_KEY = 'cc-haha-h5-server-url' export const H5_TOKEN_STORAGE_KEY = 'cc-haha-h5-token' @@ -35,11 +36,19 @@ export class H5ConnectionRequiredError extends Error { export function isTauriRuntime() { if (typeof window === 'undefined') return false - return '__TAURI_INTERNALS__' in window || '__TAURI__' in window + return detectDesktopHostEnvironment().hasTauri +} + +function getDetectedDesktopHost() { + return getDesktopHost() +} + +export function isDesktopRuntime() { + return getDetectedDesktopHost().isDesktop } export function isBrowserH5Runtime() { - return typeof window !== 'undefined' && !isTauriRuntime() + return typeof window !== 'undefined' && !isDesktopRuntime() } /** @@ -124,14 +133,14 @@ export function isH5ConnectionRequiredError(error: unknown): error is H5Connecti export async function initializeDesktopServerUrl() { const fallbackUrl = getDefaultBaseUrl() + const host = getDetectedDesktopHost() - if (!isTauriRuntime()) { + if (!host.isDesktop) { return initializeBrowserServerUrl(fallbackUrl) } try { - const { invoke } = await import('@tauri-apps/api/core') - const serverUrl = await invoke('get_server_url') + const serverUrl = await host.runtime.getServerUrl() setBaseUrl(serverUrl) setAuthToken(null) await waitForHealth(serverUrl) diff --git a/desktop/src/lib/previewBridge.test.ts b/desktop/src/lib/previewBridge.test.ts index ab544dcf..968b3672 100644 --- a/desktop/src/lib/previewBridge.test.ts +++ b/desktop/src/lib/previewBridge.test.ts @@ -1,11 +1,21 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WebviewBounds } from '../components/browser/computeWebviewBounds' +import { browserHost } from './desktopHost/browserHost' const invoke = vi.fn() vi.mock('@tauri-apps/api/core', () => ({ invoke: (...a: unknown[]) => invoke(...a) })) vi.mock('./desktopRuntime', () => ({ isTauriRuntime: () => true })) -afterEach(() => { invoke.mockReset() }) +beforeEach(() => { + window.__TAURI_INTERNALS__ = {} +}) + +afterEach(() => { + invoke.mockReset() + Reflect.deleteProperty(window, 'desktopHost') + Reflect.deleteProperty(window, '__TAURI_INTERNALS__') + Reflect.deleteProperty(window, '__TAURI__') +}) describe('previewBridge', () => { it('openPreview forwards url + bounds to preview_open', async () => { @@ -21,17 +31,55 @@ describe('previewBridge', () => { expect(invoke).toHaveBeenCalledWith('preview_set_bounds', { bounds: { x: 0, y: 0, width: 10, height: 10 } }) }) - it('eval forwards js to preview_eval', async () => { + it('message forwards structured host messages to preview_message', async () => { const { previewBridge } = await import('./previewBridge') - await previewBridge.eval('window.x=1') - expect(invoke).toHaveBeenCalledWith('preview_eval', { js: 'window.x=1' }) + await previewBridge.message({ v: 1, type: 'capture', kind: 'full' }) + expect(invoke).toHaveBeenCalledWith('preview_message', { raw: '{"v":1,"type":"capture","kind":"full"}' }) }) it('is a no-op outside the Tauri runtime', async () => { vi.resetModules() vi.doMock('./desktopRuntime', () => ({ isTauriRuntime: () => false })) + Reflect.deleteProperty(window, '__TAURI_INTERNALS__') const { previewBridge } = await import('./previewBridge') await previewBridge.open('http://localhost/a', { x: 0, y: 0, width: 1, height: 1 }) expect(invoke).not.toHaveBeenCalled() }) + + it('routes preview commands through an injected desktop host', async () => { + vi.resetModules() + vi.doMock('./desktopRuntime', () => ({ isTauriRuntime: () => false })) + Reflect.deleteProperty(window, '__TAURI_INTERNALS__') + const open = vi.fn().mockResolvedValue(undefined) + const setBounds = vi.fn().mockResolvedValue(undefined) + const message = vi.fn().mockResolvedValue(undefined) + + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { + ...browserHost.capabilities, + previewWebview: true, + }, + preview: { + ...browserHost.preview, + open, + setBounds, + message, + }, + } + + const { previewBridge } = await import('./previewBridge') + const bounds: WebviewBounds = { x: 1, y: 2, width: 3, height: 4 } + + await previewBridge.open('http://localhost/a', bounds) + await previewBridge.setBounds(bounds) + await previewBridge.message({ v: 1, type: 'enter-picker' }) + + expect(open).toHaveBeenCalledWith('http://localhost/a', bounds) + expect(setBounds).toHaveBeenCalledWith(bounds) + expect(message).toHaveBeenCalledWith({ v: 1, type: 'enter-picker' }) + expect(invoke).not.toHaveBeenCalled() + }) }) diff --git a/desktop/src/lib/previewBridge.ts b/desktop/src/lib/previewBridge.ts index c3311217..527d9abf 100644 --- a/desktop/src/lib/previewBridge.ts +++ b/desktop/src/lib/previewBridge.ts @@ -1,17 +1,17 @@ -import { invoke } from '@tauri-apps/api/core' -import { isTauriRuntime } from './desktopRuntime' import type { WebviewBounds } from '../components/browser/computeWebviewBounds' +import { getDesktopHost } from './desktopHost' +import type { PreviewHostMessage } from './desktopHost' -async function call(cmd: string, args?: Record): Promise { - if (!isTauriRuntime()) return - await invoke(cmd, args) +function getPreviewHost() { + const host = getDesktopHost() + return host.capabilities.previewWebview ? host.preview : null } export const previewBridge = { - open: (url: string, bounds: WebviewBounds) => call('preview_open', { url, bounds }), - navigate: (url: string) => call('preview_navigate', { url }), - setBounds: (bounds: WebviewBounds) => call('preview_set_bounds', { bounds }), - setVisible: (visible: boolean) => call('preview_set_visible', { visible }), - close: () => call('preview_close'), - eval: (js: string) => call('preview_eval', { js }), + open: (url: string, bounds: WebviewBounds) => getPreviewHost()?.open(url, bounds) ?? Promise.resolve(), + navigate: (url: string) => getPreviewHost()?.navigate(url) ?? Promise.resolve(), + setBounds: (bounds: WebviewBounds) => getPreviewHost()?.setBounds(bounds) ?? Promise.resolve(), + setVisible: (visible: boolean) => getPreviewHost()?.setVisible(visible) ?? Promise.resolve(), + close: () => getPreviewHost()?.close() ?? Promise.resolve(), + message: (payload: PreviewHostMessage) => getPreviewHost()?.message(payload) ?? Promise.resolve(), } diff --git a/desktop/src/lib/previewEvents.test.ts b/desktop/src/lib/previewEvents.test.ts index 54d3f6c7..940ba867 100644 --- a/desktop/src/lib/previewEvents.test.ts +++ b/desktop/src/lib/previewEvents.test.ts @@ -1,9 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { browserHost } from './desktopHost/browserHost' -const listeners: Record void> = {} -vi.mock('@tauri-apps/api/event', () => ({ - listen: (name: string, cb: (e: { payload: string }) => void) => { listeners[name] = cb; return Promise.resolve(() => {}) }, -})) +let previewHandler: ((payload: unknown) => void) | null = null const { prefill, sendMessage } = vi.hoisted(() => ({ prefill: vi.fn(), @@ -23,21 +21,41 @@ import { useBrowserPanelStore } from '../stores/browserPanelStore' describe('subscribePreviewEvents', () => { beforeEach(() => { + previewHandler = null prefill.mockClear() sendMessage.mockClear() + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { + ...browserHost.capabilities, + previewWebview: true, + }, + preview: { + ...browserHost.preview, + onEvent: async (handler) => { + previewHandler = handler + return () => { + previewHandler = null + } + }, + }, + } }) it('routes navigated event to the store', async () => { useBrowserPanelStore.getState().open('s1', 'http://x/a') await subscribePreviewEvents('s1') - listeners['preview://event']!({ payload: JSON.stringify({ v: 1, type: 'navigated', url: 'http://x/c', title: 'C' }) }) + previewHandler!(JSON.stringify({ v: 1, type: 'navigated', url: 'http://x/c', title: 'C' })) expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe('http://x/c') }) it('screenshot event prefills composer with an image attachment', async () => { await subscribePreviewEvents('s1') - listeners['preview://event']!({ payload: JSON.stringify({ v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,AAAA', kind: 'full' }) }) + previewHandler!({ v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,AAAA', kind: 'full' }) expect(prefill).toHaveBeenCalledWith('s1', expect.objectContaining({ + mode: 'append', attachments: [expect.objectContaining({ type: 'image', data: 'data:image/png;base64,AAAA' })], })) }) @@ -45,7 +63,7 @@ describe('subscribePreviewEvents', () => { it('selection event sends a chat turn directly with hidden prompt text + annotated screenshot', async () => { await subscribePreviewEvents('s1') const payload = { pageUrl: 'http://x/', element: { selector: '#t', tag: 'h1', classes: [] }, change: { description: '改一下' }, screenshot: { dataUrl: 'data:image/png;base64,AAAA', kind: 'element' } } - listeners['preview://event']!({ payload: JSON.stringify({ v: 1, type: 'selection', payload }) }) + previewHandler!(JSON.stringify({ v: 1, type: 'selection', payload })) expect(prefill).not.toHaveBeenCalled() expect(sendMessage).toHaveBeenCalledWith( 's1', @@ -67,7 +85,7 @@ describe('subscribePreviewEvents', () => { useBrowserPanelStore.getState().open('s1', 'http://x/a') useBrowserPanelStore.getState().setPicker('s1', true) await subscribePreviewEvents('s1') - listeners['preview://event']!({ payload: JSON.stringify({ v: 1, type: 'selection', payload: { pageUrl: 'http://x/', element: { selector: '#t', tag: 'h1', classes: [] }, screenshot: { dataUrl: 'data:image/png;base64,AAAA', kind: 'element' } } }) }) + previewHandler!(JSON.stringify({ v: 1, type: 'selection', payload: { pageUrl: 'http://x/', element: { selector: '#t', tag: 'h1', classes: [] }, screenshot: { dataUrl: 'data:image/png;base64,AAAA', kind: 'element' } } })) expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(false) }) @@ -75,7 +93,7 @@ describe('subscribePreviewEvents', () => { useBrowserPanelStore.getState().open('s1', 'http://x/a') useBrowserPanelStore.getState().setPicker('s1', true) await subscribePreviewEvents('s1') - expect(() => listeners['preview://event']!({ payload: JSON.stringify({ v: 1, type: 'selection', payload: { pageUrl: 'http://x/' } }) })).not.toThrow() + expect(() => previewHandler!(JSON.stringify({ v: 1, type: 'selection', payload: { pageUrl: 'http://x/' } }))).not.toThrow() expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(false) }) @@ -83,7 +101,7 @@ describe('subscribePreviewEvents', () => { useBrowserPanelStore.getState().open('s1', 'http://x/a') useBrowserPanelStore.getState().setPicker('s1', true) await subscribePreviewEvents('s1') - listeners['preview://event']!({ payload: JSON.stringify({ v: 1, type: 'picker-exited' }) }) + previewHandler!(JSON.stringify({ v: 1, type: 'picker-exited' })) expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(false) }) }) diff --git a/desktop/src/lib/previewEvents.ts b/desktop/src/lib/previewEvents.ts index 02c4b1c7..ba75a42d 100644 --- a/desktop/src/lib/previewEvents.ts +++ b/desktop/src/lib/previewEvents.ts @@ -1,6 +1,6 @@ -import { listen } from '@tauri-apps/api/event' import { useBrowserPanelStore } from '../stores/browserPanelStore' import { useChatStore } from '../stores/chatStore' +import { getDesktopHost } from './desktopHost' import { buildSelectionDirectMessage, type SelectionPayload } from './selectionComposer' function kindLabel(kind?: string): string { @@ -10,15 +10,23 @@ function kindLabel(kind?: string): string { } export async function subscribePreviewEvents(sessionId: string): Promise<() => void> { - return listen('preview://event', (e) => { + const host = getDesktopHost() + if (!host.capabilities.previewWebview) return () => {} + + return host.preview.onEvent((payload) => { let msg: { type?: string; url?: string; title?: string; dataUrl?: string; kind?: string; payload?: unknown } - try { msg = JSON.parse(e.payload) } catch { return } + try { + msg = typeof payload === 'string' + ? JSON.parse(payload) + : payload as typeof msg + } catch { return } const store = useBrowserPanelStore.getState() if (msg.type === 'navigated' && msg.url) store.setNavigated(sessionId, msg.url, msg.title ?? '') else if (msg.type === 'ready') store.setReady(sessionId) else if (msg.type === 'screenshot' && msg.dataUrl) { useChatStore.getState().queueComposerPrefill(sessionId, { text: '', + mode: 'append', attachments: [{ type: 'image', name: `screenshot-${kindLabel(msg.kind)}.png`, mimeType: 'image/png', data: msg.dataUrl }], }) } diff --git a/desktop/src/lib/publicAsset.ts b/desktop/src/lib/publicAsset.ts new file mode 100644 index 00000000..67929966 --- /dev/null +++ b/desktop/src/lib/publicAsset.ts @@ -0,0 +1,6 @@ +export function publicAssetPath(path: string): string { + const base = import.meta.env.BASE_URL || '/' + const normalizedBase = base.endsWith('/') ? base : `${base}/` + const normalizedPath = path.replace(/^\/+/, '') + return `${normalizedBase}${normalizedPath}` +} diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 976cf30a..ffbe29e5 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -29,7 +29,8 @@ import { TerminalSettings } from './TerminalSettings' import type { SessionListItem } from '../types/session' import type { ActiveGoalState } from '../types/chat' import { useMobileViewport } from '../hooks/useMobileViewport' -import { isTauriRuntime } from '../lib/desktopRuntime' +import { isDesktopRuntime } from '../lib/desktopRuntime' +import { publicAssetPath } from '../lib/publicAsset' const TASK_POLL_INTERVAL_MS = 1000 const WORKSPACE_RESIZE_STEP = 32 @@ -256,7 +257,7 @@ function TerminalResizeHandle() { } export function ActiveSession() { - const isMobileLayout = useMobileViewport() && !isTauriRuntime() + const isMobileLayout = useMobileViewport() && !isDesktopRuntime() const activeTabId = useTabStore((s) => s.activeTabId) const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null) const sessions = useSessionStore((s) => s.sessions) @@ -427,7 +428,7 @@ export function ActiveSession() { ) : ( <> - Claude Code Haha + Claude Code Haha

{t('empty.title')}

diff --git a/desktop/src/pages/ActivitySettings.tsx b/desktop/src/pages/ActivitySettings.tsx index a6b4b235..24320968 100644 --- a/desktop/src/pages/ActivitySettings.tsx +++ b/desktop/src/pages/ActivitySettings.tsx @@ -8,6 +8,7 @@ import { } from '../api/desktopUiPreferences' import { type Locale, useTranslation } from '../i18n' import { useSettingsStore } from '../stores/settingsStore' +import { publicAssetPath } from '../lib/publicAsset' type HeatmapDay = { date: string @@ -57,6 +58,7 @@ const DEFAULT_PROFILE: DesktopProfilePreferences = { avatarFile: null, avatarUpdatedAt: null, } +const DEFAULT_AVATAR_SRC = publicAssetPath('app-icon.png') function localDateKey(date: Date) { const year = date.getFullYear() @@ -489,7 +491,7 @@ export function ActivitySettings() { detail: formatSessionCount(last30Usage.sessions, t), }, ] - const avatarSrc = profile.avatarFile ? getProfileAvatarUrl(profile.avatarUpdatedAt) : '/app-icon.png' + const avatarSrc = profile.avatarFile ? getProfileAvatarUrl(profile.avatarUpdatedAt) : DEFAULT_AVATAR_SRC const avatarClassName = profile.avatarFile ? 'h-full w-full object-cover' : 'h-full w-full scale-[1.28] object-contain transition-transform' @@ -568,7 +570,7 @@ export function ActivitySettings() { alt={`${profile.displayName} avatar`} className={avatarClassName} onError={(event) => { - event.currentTarget.src = '/app-icon.png' + event.currentTarget.src = DEFAULT_AVATAR_SRC event.currentTarget.className = 'h-full w-full scale-[1.28] object-contain transition-transform' }} /> diff --git a/desktop/src/pages/ComputerUseSettings.test.tsx b/desktop/src/pages/ComputerUseSettings.test.tsx index 80ef37b4..cf87df4a 100644 --- a/desktop/src/pages/ComputerUseSettings.test.tsx +++ b/desktop/src/pages/ComputerUseSettings.test.tsx @@ -4,6 +4,7 @@ import '@testing-library/jest-dom' import { ComputerUseSettings } from './ComputerUseSettings' import { useSettingsStore } from '../stores/settingsStore' +import { browserHost } from '../lib/desktopHost/browserHost' const computerUseApiMock = vi.hoisted(() => ({ getStatus: vi.fn(), @@ -70,6 +71,7 @@ describe('ComputerUseSettings', () => { computerUseApiMock.setAuthorizedApps.mockReset() computerUseApiMock.runSetup.mockReset() computerUseApiMock.openSettings.mockReset() + Reflect.deleteProperty(window, 'desktopHost') computerUseApiMock.getStatus.mockResolvedValue(readyStatus) computerUseApiMock.getAuthorizedApps.mockResolvedValue(enabledConfig) @@ -126,6 +128,63 @@ describe('ComputerUseSettings', () => { expect(computerUseApiMock.getStatus).toHaveBeenCalledTimes(2) }) + it('selects a custom Python interpreter through the injected desktop host', async () => { + const open = vi.fn().mockResolvedValue('/opt/python/bin/python3') + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { + ...browserHost.capabilities, + dialogs: true, + }, + dialogs: { + ...browserHost.dialogs, + open, + }, + } + + render() + + await screen.findByLabelText('Python Interpreter Path') + await act(async () => { + fireEvent.click(screen.getByText('Browse')) + await Promise.resolve() + }) + + expect(open).toHaveBeenCalledWith({ + multiple: false, + directory: false, + title: 'Select Python Interpreter', + }) + expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({ + pythonPath: '/opt/python/bin/python3', + }) + }) + + it('falls back to manual Python path entry when dialogs are unavailable', async () => { + window.desktopHost = { + ...browserHost, + kind: 'browser', + isDesktop: false, + capabilities: { + ...browserHost.capabilities, + dialogs: false, + }, + } + + render() + + await screen.findByLabelText('Python Interpreter Path') + await act(async () => { + fireEvent.click(screen.getByText('Browse')) + await Promise.resolve() + }) + + expect(screen.getByText('Could not open the file picker. Paste the path manually.')).toBeInTheDocument() + expect(computerUseApiMock.setAuthorizedApps).not.toHaveBeenCalled() + }) + it('keeps the user-selected enablement when a stale refresh resolves later', async () => { const staleRefresh = deferred() computerUseApiMock.getStatus.mockResolvedValue({ diff --git a/desktop/src/pages/ComputerUseSettings.tsx b/desktop/src/pages/ComputerUseSettings.tsx index 35479778..4194594c 100644 --- a/desktop/src/pages/ComputerUseSettings.tsx +++ b/desktop/src/pages/ComputerUseSettings.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { computerUseApi, type ComputerUseStatus, type SetupResult, type InstalledApp, type AuthorizedApp } from '../api/computerUse' import { useTranslation } from '../i18n' +import { getDesktopHost } from '../lib/desktopHost' type CheckState = 'loading' | 'ready' | 'error' const PYTHON_DOWNLOAD_URLS: Record = { @@ -36,9 +37,9 @@ async function openSystemSettings(pane: 'Privacy_ScreenCapture' | 'Privacy_Acces } async function openExternalUrl(url: string) { + const host = getDesktopHost() try { - const { open } = await import('@tauri-apps/plugin-shell') - await open(url) + await host.shell.open(url) } catch { window.open(url, '_blank', 'noopener,noreferrer') } @@ -215,9 +216,13 @@ export function ComputerUseSettings() { } const choosePythonPath = async () => { + const host = getDesktopHost() + if (!host.capabilities.dialogs) { + setPythonPathMessage(t('settings.computerUse.pythonPathDialogFailed')) + return + } try { - const { open } = await import('@tauri-apps/plugin-dialog') - const selected = await open({ + const selected = await host.dialogs.open({ multiple: false, directory: false, title: t('settings.computerUse.pythonPathDialogTitle'), diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index f2f565c9..7f19ee76 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ getMessages: vi.fn(), getSlashCommands: vi.fn(), listSkills: vi.fn(), + listAgents: vi.fn(), search: vi.fn(), browse: vi.fn(), getTasksForList: vi.fn(), @@ -41,6 +42,12 @@ vi.mock('../api/skills', () => ({ }, })) +vi.mock('../api/agents', () => ({ + agentsApi: { + list: mocks.listAgents, + }, +})) + vi.mock('../api/filesystem', () => ({ filesystemApi: { search: mocks.search, @@ -71,6 +78,7 @@ vi.mock('../hooks/useMobileViewport', () => ({ vi.mock('../lib/desktopRuntime', () => ({ isTauriRuntime: () => mocks.isTauriRuntime, + isDesktopRuntime: () => mocks.isTauriRuntime, })) vi.mock('@tauri-apps/plugin-dialog', () => ({ @@ -200,6 +208,7 @@ describe('EmptySession', () => { mocks.getMessages.mockResolvedValue({ messages: [] }) mocks.getSlashCommands.mockResolvedValue({ commands: [] }) mocks.listSkills.mockResolvedValue({ skills: [] }) + mocks.listAgents.mockResolvedValue({ activeAgents: [], allAgents: [] }) mocks.search.mockResolvedValue({ currentPath: '/workspace/project', parentPath: null, @@ -212,6 +221,7 @@ describe('EmptySession', () => { afterEach(() => { cleanup() + Reflect.deleteProperty(window, 'desktopHost') useSessionStore.setState(initialSessionState, true) useChatStore.setState(initialChatState, true) useTabStore.setState(initialTabState, true) @@ -312,6 +322,37 @@ describe('EmptySession', () => { }) }) + 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() + + await waitFor(() => { + expect(mocks.listAgents).toHaveBeenCalledWith(undefined) + }) + + 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 ') + }) + it('integrates repository launch controls into the desktop composer panel', async () => { render() @@ -418,6 +459,27 @@ describe('EmptySession', () => { it('uses native desktop file paths for draft attachments', async () => { mocks.isTauriRuntime = true + window.desktopHost = { + kind: 'electron', + isDesktop: true, + capabilities: { + appMode: false, + dialogs: true, + notifications: false, + previewWebview: false, + shell: false, + terminal: false, + updates: false, + windowControls: false, + zoom: false, + }, + dialogs: { + open: mocks.dialogOpen, + }, + webview: { + onDragDropEvent: vi.fn().mockResolvedValue(mocks.webviewUnlisten), + }, + } as any mocks.dialogOpen.mockResolvedValueOnce([ 'C:\\Users\\Nanmi\\Desktop\\huge-a.log', '/Users/nanmi/tmp/huge-b.zip', diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 669292fb..e733a348 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ApiError } from '../api/client' +import { agentsApi } from '../api/agents' import { skillsApi } from '../api/skills' import { useTranslation } from '../i18n' import { useSessionStore } from '../stores/sessionStore' @@ -18,7 +19,8 @@ import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator' import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu' import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel' import { useMobileViewport } from '../hooks/useMobileViewport' -import { isTauriRuntime } from '../lib/desktopRuntime' +import { isDesktopRuntime } from '../lib/desktopRuntime' +import { publicAssetPath } from '../lib/publicAsset' import { filesToComposerAttachments, selectNativeFileAttachments, @@ -27,6 +29,8 @@ import { import { useComposerFileDrop } from '../components/chat/useComposerFileDrop' import { shouldSubmitOnEnter } from '../components/chat/sendShortcut' import { + appendAgentSlashCommands, + buildAgentSlashCommands, getLocalizedFallbackCommands, filterSlashCommands, findSlashToken, @@ -97,6 +101,7 @@ export function EmptySession() { const [slashFilter, setSlashFilter] = useState('') const [slashSelectedIndex, setSlashSelectedIndex] = useState(0) const [slashCommands, setSlashCommands] = useState([]) + const [agentSlashCommands, setAgentSlashCommands] = useState([]) const textareaRef = useRef(null) const panelRef = useRef(null) const fileInputRef = useRef(null) @@ -119,7 +124,7 @@ export function EmptySession() { ? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}:${draftRuntimeSelection.effortLevel ?? 'auto'}` : undefined const draftModelLabel = draftRuntimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id - const isMobileComposer = useMobileViewport() && !isTauriRuntime() + const isMobileComposer = useMobileViewport() && !isDesktopRuntime() useEffect(() => { textareaRef.current?.focus() @@ -188,7 +193,9 @@ export function EmptySession() { useEffect(() => { let cancelled = false - skillsApi.list(workDir || undefined) + const cwd = workDir || undefined + + skillsApi.list(cwd) .then(({ skills }) => { if (cancelled) return setSlashCommands( @@ -211,9 +218,32 @@ export function EmptySession() { } }, [workDir, lastPluginReloadSummary]) + useEffect(() => { + let cancelled = false + const cwd = workDir || undefined + + agentsApi.list(cwd) + .then(({ activeAgents }) => { + if (cancelled) return + setAgentSlashCommands(buildAgentSlashCommands(activeAgents)) + }) + .catch(() => { + if (!cancelled) { + setAgentSlashCommands([]) + } + }) + + return () => { + cancelled = true + } + }, [workDir, lastPluginReloadSummary]) + const allSlashCommands = useMemo( - () => mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)), - [slashCommands, t], + () => appendAgentSlashCommands( + mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)), + agentSlashCommands, + ), + [agentSlashCommands, slashCommands, t], ) const handleWorkDirChange = (newWorkDir: string) => { @@ -472,9 +502,9 @@ export function EmptySession() { }) const openAttachmentPicker = useCallback(() => { - if (!isTauriRuntime()) { + setPlusMenuOpen(false) + if (!isDesktopRuntime()) { fileInputRef.current?.click() - setPlusMenuOpen(false) return } @@ -488,7 +518,6 @@ export function EmptySession() { } fileInputRef.current?.click() }) - .finally(() => setPlusMenuOpen(false)) }, []) const handleFileSelect = (event: React.ChangeEvent) => { @@ -540,7 +569,7 @@ export function EmptySession() { isMobileComposer ? 'max-w-[300px]' : 'max-w-md' }`}> Claude Code Haha diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index f14556d8..6dec93ef 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -36,7 +36,9 @@ import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogi import { OPENAI_OFFICIAL_PROVIDER_ID } from '../constants/openaiOfficialProvider' import { useUpdateStore } from '../stores/updateStore' import { formatBytes } from '../lib/formatBytes' -import { isTauriRuntime } from '../lib/desktopRuntime' +import { isDesktopRuntime } from '../lib/desktopRuntime' +import { getDesktopHost } from '../lib/desktopHost' +import { publicAssetPath } from '../lib/publicAsset' import { getDesktopNotificationPermission, notifyDesktop, @@ -725,13 +727,7 @@ function buildFallbackPreset(provider?: SavedProvider): ProviderPreset { } function openExternalUrl(url: string) { - if (!isTauriRuntime()) { - window.open(url, '_blank', 'noopener,noreferrer') - return - } - - void import('@tauri-apps/plugin-shell') - .then((mod) => mod.open(url)) + void getDesktopHost().shell.open(url) .catch(() => window.open(url, '_blank', 'noopener,noreferrer')) } @@ -1507,7 +1503,7 @@ function GeneralSettings() { }, []) useEffect(() => { - if (!isTauriRuntime()) return + if (!isDesktopRuntime()) return void fetchAppMode() }, [fetchAppMode]) @@ -1714,9 +1710,13 @@ function GeneralSettings() { const openPortableDirPicker = async () => { setModeError(null) + const host = getDesktopHost() + if (!host.capabilities.dialogs) { + setModeError(t('settings.general.storagePickerError')) + return + } 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('settings.general.storageChooseDirTitle'), @@ -1761,10 +1761,9 @@ function GeneralSettings() { setModeError(null) try { await setAppModeAction(pendingMode, pendingPortableDir) - const { invoke } = await import('@tauri-apps/api/core') - await invoke('prepare_for_app_mode_restart') - const { relaunch } = await import('@tauri-apps/plugin-process') - await relaunch() + const host = getDesktopHost() + await host.appMode.prepareRestart() + await host.appMode.restart() } catch (error) { setModeError( error instanceof Error @@ -2317,7 +2316,7 @@ function GeneralSettings() {
- {isTauriRuntime() && ( + {isDesktopRuntime() && (

{t('settings.general.storageTitle')}

{t('settings.general.storageDescription')}

@@ -3482,8 +3481,7 @@ function AboutSettings() { useEffect(() => { let cancelled = false - import('@tauri-apps/api/app') - .then((mod) => mod.getVersion()) + getDesktopHost().app.getVersion() .then((value) => { if (!cancelled) setVersion(value) }) @@ -3506,7 +3504,7 @@ function AboutSettings() { }, [updateProxy]) const openUrl = (url: string) => { - import('@tauri-apps/plugin-shell').then((mod) => mod.open(url)).catch(() => window.open(url, '_blank')) + void getDesktopHost().shell.open(url).catch(() => window.open(url, '_blank')) } const checkedAtText = @@ -3582,7 +3580,7 @@ function AboutSettings() { return (
{/* Logo + App Name + Version */} - Claude Code Haha + Claude Code Haha

Claude Code Haha

{version && (
@@ -3603,7 +3601,7 @@ function AboutSettings() { onClick={() => openUrl(GITHUB_REPO)} className="w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer" > - GitHub + GitHub
NanmiCoder/cc-haha
{t('settings.about.starHint')}
@@ -3809,7 +3807,7 @@ function AboutSettings() { onClick={() => openUrl(AUTHOR_GITHUB)} className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer" > - GitHub + GitHub 程序员阿江-Relakkes GitHub diff --git a/desktop/src/pages/TerminalSettings.test.tsx b/desktop/src/pages/TerminalSettings.test.tsx index 5a7b3bbe..3b90eca2 100644 --- a/desktop/src/pages/TerminalSettings.test.tsx +++ b/desktop/src/pages/TerminalSettings.test.tsx @@ -3,6 +3,7 @@ import '@testing-library/jest-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useSettingsStore } from '../stores/settingsStore' import { destroyTerminalRuntime } from '../lib/terminalRuntime' +import { browserHost } from '../lib/desktopHost/browserHost' const terminalMocks = vi.hoisted(() => { const terminalInstance = { @@ -98,6 +99,7 @@ describe('TerminalSettings', () => { shell: '/bin/zsh', cwd: '/Users/test', }) + Reflect.deleteProperty(window, 'desktopHost') vi.stubGlobal('ResizeObserver', class { observe = vi.fn() disconnect = vi.fn() @@ -275,4 +277,38 @@ describe('TerminalSettings', () => { expect(await screen.findByText('Path does not exist. Select a valid Bash executable.')).toBeInTheDocument() }) + + it('selects a Windows bash path through the injected desktop host', async () => { + vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32') + terminalMocks.available = true + const open = vi.fn().mockResolvedValue('C:\\Program Files\\Git\\bin\\bash.exe') + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { + ...browserHost.capabilities, + dialogs: true, + }, + dialogs: { + ...browserHost.dialogs, + open, + }, + } + + render() + + await screen.findByPlaceholderText('Bash Path') + fireEvent.click(screen.getByText('folder_open').closest('button')!) + + expect(await screen.findByDisplayValue('C:\\Program Files\\Git\\bin\\bash.exe')).toBeInTheDocument() + expect(open).toHaveBeenCalledWith({ + title: 'Bash Path', + multiple: false, + filters: [{ + name: 'Bash Executable', + extensions: ['exe', '', 'bat', 'cmd', 'ps1'], + }], + }) + }) }) diff --git a/desktop/src/pages/TerminalSettings.tsx b/desktop/src/pages/TerminalSettings.tsx index 357a4d9d..eeb20ec3 100644 --- a/desktop/src/pages/TerminalSettings.tsx +++ b/desktop/src/pages/TerminalSettings.tsx @@ -7,6 +7,7 @@ import { Dropdown } from '../components/shared/Dropdown' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import type { DesktopTerminalStartupShell } from '../types/settings' +import { getDesktopHost } from '../lib/desktopHost' import { attachTerminalRuntime, createLocalTerminalRuntimeId, @@ -632,9 +633,10 @@ function BashPathSettings({ isTauri }: { isTauri: boolean }) { const handleBrowse = async () => { if (!isTauri) return + const host = getDesktopHost() + if (!host.capabilities.dialogs) return try { - const { open } = await import('@tauri-apps/plugin-dialog') - const selected = await open({ + const selected = await host.dialogs.open({ title: t('settings.terminal.bashPathLabel'), multiple: false, filters: [{ diff --git a/desktop/src/preview-agent/index.ts b/desktop/src/preview-agent/index.ts index 18772306..1df7dc78 100644 --- a/desktop/src/preview-agent/index.ts +++ b/desktop/src/preview-agent/index.ts @@ -8,8 +8,8 @@ import { createEditBubble } from './editBubble' ;(window as unknown as { __PREVIEW_AGENT__?: boolean }).__PREVIEW_AGENT__ = true const postToHost = (raw: string) => { - const internals = (window as unknown as { __TAURI_INTERNALS__?: { invoke?: (c: string, a: unknown) => void } }).__TAURI_INTERNALS__ - if (internals?.invoke) internals.invoke('preview_message', { raw }) + const post = (window as unknown as { __DESKTOP_PREVIEW_POST__?: (raw: string) => void }).__DESKTOP_PREVIEW_POST__ + if (post) post(raw) // 回退(M1 证伪 IPC 时启用):new WebSocket('ws://127.0.0.1:'+PORT+'/preview-agent') ... } diff --git a/desktop/src/stores/adapterStore.test.ts b/desktop/src/stores/adapterStore.test.ts index 73ab270c..bd4e4831 100644 --- a/desktop/src/stores/adapterStore.test.ts +++ b/desktop/src/stores/adapterStore.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { browserHost } from '../lib/desktopHost/browserHost' describe('adapterStore IM pairing behavior', () => { const adaptersApi = { @@ -18,6 +19,60 @@ describe('adapterStore IM pairing behavior', () => { adaptersApi.updateConfig.mockImplementation(async (patch) => patch) vi.doMock('../api/adapters', () => ({ adaptersApi })) vi.doMock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) + Reflect.deleteProperty(window, 'desktopHost') + Reflect.deleteProperty(window, '__TAURI_INTERNALS__') + Reflect.deleteProperty(window, '__TAURI__') + }) + + it('restarts adapter sidecar through an injected desktop host after config changes', async () => { + const restartSidecar = vi.fn().mockResolvedValue(undefined) + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { + ...browserHost.capabilities, + }, + adapters: { + restartSidecar, + }, + } + + const { useAdapterStore } = await import('./adapterStore') + + await useAdapterStore.getState().updateConfig({ telegram: { botToken: 'token' } }) + + await vi.waitFor(() => { + expect(restartSidecar).toHaveBeenCalledTimes(1) + }) + }) + + it('does not block config changes when desktop sidecar restart fails', async () => { + const restartError = new Error('restart failed') + const restartSidecar = vi.fn().mockRejectedValue(restartError) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + adapters: { + restartSidecar, + }, + } + + const { useAdapterStore } = await import('./adapterStore') + + await useAdapterStore.getState().updateConfig({ telegram: { botToken: 'token' } }) + + await vi.waitFor(() => { + expect(warn).toHaveBeenCalledWith( + '[adapterStore] restart_adapters_sidecar failed:', + restartError, + ) + }) + expect(adaptersApi.updateConfig).toHaveBeenCalledWith({ telegram: { botToken: 'token' } }) + + warn.mockRestore() }) it('removes a WeChat paired user without clearing the bound account', async () => { diff --git a/desktop/src/stores/adapterStore.ts b/desktop/src/stores/adapterStore.ts index 69dbd799..1d807160 100644 --- a/desktop/src/stores/adapterStore.ts +++ b/desktop/src/stores/adapterStore.ts @@ -2,20 +2,22 @@ import { create } from 'zustand' import { adaptersApi } from '../api/adapters' import type { AdapterFileConfig } from '../types/adapter' import type { DingtalkRegistrationBegin, DingtalkRegistrationPoll } from '../api/adapters' +import { getDesktopHost } from '../lib/desktopHost' /** - * Tauri command 触发器:让主进程 kill + respawn adapter sidecar, + * Desktop host trigger: let the app process kill + respawn adapter sidecar, * 让 ~/.claude/adapters.json 里的最新凭据被新进程读到,建立飞书 / Telegram / 微信 / 钉钉 * 的 WebSocket 连接。 * - * 在非 Tauri 环境(纯浏览器调试 / 单元测试)这会安静失败 —— 那种场景下 + * 在非桌面环境(纯浏览器调试 / 单元测试)这会安静跳过 —— 那种场景下 * 本来也没有 sidecar 可重启。 */ -async function notifyTauriRestartAdapters(): Promise { +async function notifyDesktopRestartAdapters(): Promise { + const host = getDesktopHost() + if (!host.isDesktop) return + try { - // 用 dynamic import 避开 SSR / non-tauri 测试环境的硬依赖 - const { invoke } = await import('@tauri-apps/api/core') - await invoke('restart_adapters_sidecar') + await host.adapters.restartSidecar() } catch (err) { // 不阻塞保存流程 —— 配置文件已经写入,下次启动 App 也会生效 if (typeof console !== 'undefined') { @@ -81,7 +83,7 @@ export const useAdapterStore = create((set, get) => ({ // 触发飞书 / Telegram WebSocket 用新凭据重连。pairing code / paired users // 这种轻量更新也会触发重启 —— 这是个有意为之的简化:保证"任何配置变更 // 都立刻生效",比起精细判断哪些字段值得重启更可靠。 - void notifyTauriRestartAdapters() + void notifyDesktopRestartAdapters() }, generatePairingCode: async () => { @@ -108,7 +110,7 @@ export const useAdapterStore = create((set, get) => ({ } if ('wechat' in result || 'telegram' in result || 'feishu' in result || 'dingtalk' in result) { set({ config: result }) - void notifyTauriRestartAdapters() + void notifyDesktopRestartAdapters() return { connected: true } } return { connected: false } @@ -120,7 +122,7 @@ export const useAdapterStore = create((set, get) => ({ const result = await adaptersApi.pollDingtalkRegistration(deviceCode) if (result.config) { set({ config: result.config }) - void notifyTauriRestartAdapters() + void notifyDesktopRestartAdapters() } return result }, @@ -128,13 +130,13 @@ export const useAdapterStore = create((set, get) => ({ unbindWechatAccount: async () => { const config = await adaptersApi.unbindWechat() set({ config }) - void notifyTauriRestartAdapters() + void notifyDesktopRestartAdapters() }, unbindDingtalkBot: async () => { const config = await adaptersApi.unbindDingtalk() set({ config }) - void notifyTauriRestartAdapters() + void notifyDesktopRestartAdapters() }, removePairedUser: async (platform, userId) => { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index c370c053..11f01147 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -53,6 +53,8 @@ export type ComposerReferenceInsertion = { nonce: number } +export type ComposerPrefillMode = 'replace' | 'append' + export type PerSessionState = { messages: UIMessage[] chatState: ChatState @@ -87,6 +89,7 @@ export type PerSessionState = { composerPrefill?: { text: string attachments?: UIAttachment[] + mode?: ComposerPrefillMode nonce: number } | null composerInsertion?: ComposerReferenceInsertion | null @@ -157,8 +160,9 @@ type ChatStore = { reloadHistory: (sessionId: string) => Promise queueComposerPrefill: ( sessionId: string, - prefill: { text: string; attachments?: UIAttachment[] }, + prefill: { text: string; attachments?: UIAttachment[]; mode?: ComposerPrefillMode }, ) => void + clearComposerPrefill: (sessionId: string, nonce?: number) => void queueComposerInsertion: ( sessionId: string, insertion: Omit, @@ -1203,12 +1207,22 @@ export const useChatStore = create((set, get) => ({ composerPrefill: { text: prefill.text, attachments: prefill.attachments, + mode: prefill.mode, nonce: Date.now(), }, })), })) }, + clearComposerPrefill: (sessionId, nonce) => { + set((state) => ({ + sessions: updateSessionIn(state.sessions, sessionId, (session) => { + if (nonce !== undefined && session.composerPrefill?.nonce !== nonce) return {} + return { composerPrefill: null } + }), + })) + }, + queueComposerInsertion: (sessionId, insertion) => { set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 598b769d..e976dc7c 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ApiError } from '../api/client' +import { browserHost } from '../lib/desktopHost/browserHost' describe('settingsStore locale defaults', () => { beforeEach(() => { @@ -336,6 +337,8 @@ describe('settingsStore app mode', () => { vi.resetModules() vi.clearAllMocks() delete (window as unknown as { __TAURI_INTERNALS__?: object }).__TAURI_INTERNALS__ + Reflect.deleteProperty(window, 'desktopHost') + Reflect.deleteProperty(window, '__TAURI__') }) it('hydrates app mode from the native desktop command', async () => { @@ -360,6 +363,34 @@ describe('settingsStore app mode', () => { }) }) + it('hydrates app mode from an injected desktop host', async () => { + const getAppMode = vi.fn().mockResolvedValue({ + mode: 'portable', + portableDir: 'D:\\cc-haha\\data', + defaultPortableDir: 'D:\\cc-haha\\data', + }) + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + appMode: { + ...browserHost.appMode, + get: getAppMode, + }, + } + + const { useSettingsStore } = await import('./settingsStore') + + await useSettingsStore.getState().fetchAppMode() + + expect(getAppMode).toHaveBeenCalledTimes(1) + expect(useSettingsStore.getState().appMode).toEqual({ + mode: 'portable', + portableDir: 'D:\\cc-haha\\data', + defaultPortableDir: 'D:\\cc-haha\\data', + }) + }) + it('persists app mode through the native desktop command and marks restart required', async () => { const invoke = vi.fn().mockResolvedValue(undefined) vi.doMock('@tauri-apps/api/core', () => ({ invoke })) @@ -392,6 +423,37 @@ describe('settingsStore app mode', () => { expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true) }) + it('persists app mode through an injected desktop host', async () => { + const setAppMode = vi.fn().mockResolvedValue(undefined) + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + appMode: { + ...browserHost.appMode, + set: setAppMode, + }, + } + + const { useSettingsStore } = await import('./settingsStore') + useSettingsStore.setState({ + appMode: { + mode: 'default', + portableDir: null, + defaultPortableDir: 'D:\\cc-haha\\data', + }, + appModeRequiresRestart: false, + }) + + await useSettingsStore.getState().setAppMode('portable') + + expect(setAppMode).toHaveBeenCalledWith({ + mode: 'portable', + portableDir: 'D:\\cc-haha\\data', + }) + expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true) + }) + it('persists a user-selected portable directory', async () => { const invoke = vi.fn().mockResolvedValue(undefined) vi.doMock('@tauri-apps/api/core', () => ({ invoke })) diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index a18bf908..e1aef29a 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -21,7 +21,7 @@ import { type UpdateProxySettings, type WebSearchSettings, } from '../types/settings' -import { isTauriRuntime } from '../lib/desktopRuntime' +import { getDesktopHost } from '../lib/desktopHost' import type { Locale } from '../i18n' import { APP_ZOOM_CONTROL_STEP, @@ -440,16 +440,17 @@ export const useSettingsStore = create((set, get) => ({ }, fetchAppMode: async () => { - if (!isTauriRuntime()) return + const host = getDesktopHost() + if (!host.isDesktop) return try { - const { invoke } = await import('@tauri-apps/api/core') - const result: AppModeConfig = await invoke('get_app_mode') + const result: AppModeConfig = await host.appMode.get() set({ appMode: result }) } catch { /* silently ignore - not in Tauri or command unavailable */ } }, setAppMode: async (mode, portableDir) => { - if (!isTauriRuntime()) return + const host = getDesktopHost() + if (!host.isDesktop) return const prev = get().appMode const newMode: AppModeConfig = { ...prev, @@ -464,8 +465,7 @@ export const useSettingsStore = create((set, get) => ({ } set({ appMode: newMode, appModeRequiresRestart: true }) try { - const { invoke } = await import('@tauri-apps/api/core') - await invoke('set_app_mode', { + await host.appMode.set({ mode, portableDir: newMode.portableDir || null, }) diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts index 1baac2f0..bef14016 100644 --- a/desktop/src/stores/updateStore.test.ts +++ b/desktop/src/stores/updateStore.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { browserHost } from '../lib/desktopHost/browserHost' const check = vi.fn() const relaunch = vi.fn() @@ -26,6 +27,8 @@ describe('updateStore', () => { configurable: true, value: {}, }) + Reflect.deleteProperty(window, 'desktopHost') + Reflect.deleteProperty(window, '__TAURI__') }) it('stores available update metadata after a successful check', async () => { @@ -55,6 +58,56 @@ describe('updateStore', () => { expect(useUpdateStore.getState().shouldPrompt).toBe(true) }) + it('checks, installs, and relaunches through an injected desktop host', async () => { + Reflect.deleteProperty(window, '__TAURI_INTERNALS__') + const download = vi.fn(async (onEvent?: (event: unknown) => void) => { + onEvent?.({ event: 'Started', data: { contentLength: 100 } }) + onEvent?.({ event: 'Progress', data: { chunkLength: 100 } }) + onEvent?.({ event: 'Finished' }) + }) + const install = vi.fn().mockResolvedValue(undefined) + const checkUpdate = vi.fn().mockResolvedValue({ + version: '0.4.0', + body: 'Electron host update', + download, + install, + close: vi.fn().mockResolvedValue(undefined), + }) + const prepareInstall = vi.fn().mockResolvedValue(undefined) + const relaunchHost = vi.fn().mockResolvedValue(undefined) + + window.desktopHost = { + ...browserHost, + kind: 'electron', + isDesktop: true, + capabilities: { + ...browserHost.capabilities, + updates: true, + }, + updates: { + ...browserHost.updates, + check: checkUpdate, + prepareInstall, + relaunch: relaunchHost, + }, + } + + vi.resetModules() + const { useUpdateStore } = await import('./updateStore') + + const result = await useUpdateStore.getState().checkForUpdates() + await useUpdateStore.getState().installUpdate() + + expect(result?.version).toBe('0.4.0') + expect(checkUpdate).toHaveBeenCalledWith(undefined) + expect(download).toHaveBeenCalledTimes(1) + expect(prepareInstall).toHaveBeenCalledTimes(1) + expect(install).toHaveBeenCalledTimes(1) + expect(relaunchHost).toHaveBeenCalledTimes(1) + expect(invoke).not.toHaveBeenCalled() + expect(relaunch).not.toHaveBeenCalled() + }) + it('does not show the global prompt while a background download is still running', async () => { let finishDownload!: () => void const download = vi.fn( diff --git a/desktop/src/stores/updateStore.ts b/desktop/src/stores/updateStore.ts index eb64792c..244b4ff2 100644 --- a/desktop/src/stores/updateStore.ts +++ b/desktop/src/stores/updateStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' -import type { Update } from '@tauri-apps/plugin-updater' -import { isTauriRuntime } from '../lib/desktopRuntime' +import { getDesktopHost } from '../lib/desktopHost' +import type { DesktopHost, DesktopUpdate } from '../lib/desktopHost' import type { UpdateProxySettings } from '../types/settings' import { useSettingsStore } from './settingsStore' @@ -33,13 +33,13 @@ type UpdateStore = { checkedAt: number | null shouldPrompt: boolean initialize: () => Promise - checkForUpdates: (options?: CheckOptions) => Promise + checkForUpdates: (options?: CheckOptions) => Promise downloadUpdate: () => Promise installUpdate: () => Promise dismissPrompt: () => void } -let pendingUpdate: Update | null = null +let pendingUpdate: DesktopUpdate | null = null let pendingUpdateProxyKey: string | null = null let pendingUpdateDownloaded = false let downloadPromise: Promise | null = null @@ -86,7 +86,12 @@ function getUpdateCheckOptions() { return proxy ? { proxy } : undefined } -async function setPendingUpdate(next: Update | null, proxyKey: string | null) { +function getUpdateHost(): DesktopHost | null { + const host = getDesktopHost() + return host.capabilities.updates ? host : null +} + +async function setPendingUpdate(next: DesktopUpdate | null, proxyKey: string | null) { const previous = pendingUpdate pendingUpdate = next pendingUpdateProxyKey = next ? proxyKey : null @@ -124,7 +129,7 @@ export const useUpdateStore = create((set, get) => ({ shouldPrompt: false, initialize: async () => { - if (!isTauriRuntime()) return + if (!getUpdateHost()) return if (!startupCheckPromise) { startupCheckPromise = (async () => { await new Promise((resolve) => setTimeout(resolve, 5000)) @@ -138,7 +143,8 @@ export const useUpdateStore = create((set, get) => ({ }, checkForUpdates: async ({ silent = false, autoDownload = true } = {}) => { - if (!isTauriRuntime()) return null + const host = getUpdateHost() + if (!host) return null if (downloadPromise && get().status === 'downloading' && pendingUpdate) return pendingUpdate set((state) => ({ @@ -148,9 +154,8 @@ export const useUpdateStore = create((set, get) => ({ })) try { - const { check } = await import('@tauri-apps/plugin-updater') const updateProxyKey = getUpdateProxyKey() - const update = await check(getUpdateCheckOptions()) + const update = await host.updates.check(getUpdateCheckOptions()) await setPendingUpdate(update, updateProxyKey) const checkedAt = Date.now() @@ -214,7 +219,7 @@ export const useUpdateStore = create((set, get) => ({ }, downloadUpdate: async () => { - if (!isTauriRuntime()) return + if (!getUpdateHost()) return let update = pendingUpdate if (update && pendingUpdateProxyKey !== getUpdateProxyKey()) { @@ -332,7 +337,8 @@ export const useUpdateStore = create((set, get) => ({ }, installUpdate: async () => { - if (!isTauriRuntime()) return + const host = getUpdateHost() + if (!host) return let update = pendingUpdate if (update && pendingUpdateProxyKey !== getUpdateProxyKey()) { @@ -352,9 +358,6 @@ export const useUpdateStore = create((set, get) => ({ } if (!pendingUpdateDownloaded) return - const { invoke } = await import('@tauri-apps/api/core') - const { relaunch } = await import('@tauri-apps/plugin-process') - set((state) => ({ ...state, status: 'installing', @@ -364,7 +367,7 @@ export const useUpdateStore = create((set, get) => ({ })) prepareInstallAttempted = true - await invoke('prepare_for_update_install') + await host.updates.prepareInstall() await update.install() set((state) => ({ @@ -373,12 +376,11 @@ export const useUpdateStore = create((set, get) => ({ progressPercent: 100, })) - await relaunch() + await host.updates.relaunch() } catch (error) { if (prepareInstallAttempted) { try { - const { invoke } = await import('@tauri-apps/api/core') - await invoke('cancel_update_install') + await host.updates.cancelInstall() } catch { // Best effort: keep the update prompt recoverable even if native reset fails. } diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index ba2422eb..7bb092db 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -10,7 +10,7 @@ font-style: normal; font-weight: 400 600; font-display: swap; - src: url('/fonts/inter-latin.woff2') format('woff2'); + src: url('../../public/fonts/inter-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { @@ -18,7 +18,7 @@ font-style: normal; font-weight: 400 600; font-display: swap; - src: url('/fonts/inter-latin-ext.woff2') format('woff2'); + src: url('../../public/fonts/inter-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @@ -28,7 +28,7 @@ font-style: normal; font-weight: 400 800; font-display: swap; - src: url('/fonts/manrope-latin.woff2') format('woff2'); + src: url('../../public/fonts/manrope-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { @@ -36,7 +36,7 @@ font-style: normal; font-weight: 400 800; font-display: swap; - src: url('/fonts/manrope-latin-ext.woff2') format('woff2'); + src: url('../../public/fonts/manrope-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @@ -46,7 +46,7 @@ font-style: normal; font-weight: 400; font-display: swap; - src: url('/fonts/jetbrains-mono-latin.woff2') format('woff2'); + src: url('../../public/fonts/jetbrains-mono-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { @@ -54,7 +54,7 @@ font-style: normal; font-weight: 400; font-display: swap; - src: url('/fonts/jetbrains-mono-latin-ext.woff2') format('woff2'); + src: url('../../public/fonts/jetbrains-mono-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @@ -64,7 +64,7 @@ font-style: normal; font-weight: 100 700; font-display: block; - src: url('/fonts/material-symbols-outlined.woff2') format('woff2'); + src: url('../../public/fonts/material-symbols-outlined.woff2') format('woff2'); } .code-viewer-area .line, diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index 6587856c..a32b50b2 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -6,6 +6,7 @@ import path from 'path' const host = process.env.TAURI_DEV_HOST export default defineConfig({ + base: './', plugins: [react(), tailwindcss()], build: { // Vite 8 defaults to baseline-widely-available (safari16.4+), which diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4a1d44e8..29e15d04 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -92,6 +92,9 @@ const zhSidebar = [ { text: '功能详解', link: '/desktop/03-features' }, { text: '安装与构建', link: '/desktop/04-installation' }, { text: 'H5 访问', link: '/desktop/06-h5-access' }, + { text: 'Electron 迁移调研', link: '/desktop/07-electron-migration-research' }, + { text: 'Electron 迁移任务', link: '/desktop/08-electron-migration-tasks' }, + { text: 'Electron 迁移验证', link: '/desktop/09-electron-migration-validation-checklist' }, ], }, { diff --git a/docs/desktop/02-architecture.md b/docs/desktop/02-architecture.md index 7e318966..4b7b7626 100644 --- a/docs/desktop/02-architecture.md +++ b/docs/desktop/02-architecture.md @@ -1,6 +1,6 @@ # 架构设计 -> 从 Tauri 窗口到 CLI 子进程,桌面端的三层通信架构。 +> 从 Electron host 到 CLI 子进程,桌面端的三层通信架构。 --- @@ -24,11 +24,11 @@ | 技术 | 版本 | 职责 | |------|------|------| -| Tauri | 2 | 跨平台桌面框架 (Rust) | -| tauri-plugin-shell | 2 | Sidecar 进程管理 | -| tauri-plugin-dialog | 2 | 原生对话框 | -| tauri-plugin-process | 2 | 进程生命周期 | -| tauri-plugin-updater | 2 | 自动更新 | +| Electron | - | 跨平台桌面 host,管理 Chromium renderer、窗口、IPC 和系统能力 | +| electron-builder | - | macOS/Windows/Linux 打包与 release artifact 生成 | +| electron-updater | - | 自动更新检查、下载与安装 | +| node-pty | - | Electron main 中的终端 PTY runtime | +| Tauri 2 | 2 | 迁移期 legacy host 与 sidecar 资源目录,React 层通过 `desktopHost` adapter 隔离 | ### 服务端 @@ -50,9 +50,9 @@ ``` ┌─────────────────────────────────────┐ -│ Tauri 主进程 (Rust) │ +│ Electron Host (main/preload) │ │ ┌─────────────────────────────┐ │ -│ │ WebView (React App) │ │ ← 用户界面 +│ │ Chromium Renderer │ │ ← React 用户界面 │ └───────────┬─────────────────┘ │ │ │ HTTP + WebSocket │ │ ┌───────────▼─────────────────┐ │ @@ -70,33 +70,40 @@ └─────────────────────────────────────┘ ``` -### 第一层:Tauri 主进程 +### 第一层:Electron Host **职责**:窗口管理、Sidecar 进程编排、原生 API 桥接 -核心文件 `desktop/src-tauri/src/lib.rs`,暴露两个 Tauri Command: +核心文件位于 `desktop/electron/`,通过 preload 暴露 `window.desktopHost`,renderer 只调用 typed host contract,不直接调用 Electron IPC。 -| Command | 说明 | -|---------|------| -| `get_server_url` | 前端获取 Server Sidecar 的 HTTP 地址 | -| `restart_adapters_sidecar` | 热重启 Adapter Sidecar(修改配置后生效) | +| 能力 | 说明 | +|------|------| +| `runtime.getServerUrl` | 前端获取 Server Sidecar 的 HTTP 地址 | +| `dialogs.open/save` | 系统文件/目录选择与保存对话框 | +| `shell.open/openPath` | 外链与本地路径打开,main 侧做 scheme/path allowlist | +| `updates.check/download/install` | electron-updater 检查、下载、安装与 relaunch | +| `terminal.*` | node-pty 会话创建、写入、resize、kill 和事件转发 | +| `preview.*` | WebContentsView 预览面板与 preview-agent message bridge | +| `appMode.*` | 默认/便携模式和 `CLAUDE_CONFIG_DIR` 启动语义 | **状态管理**: -| Struct | 说明 | -|--------|------| -| `ServerState` | `Mutex` — 含 server URL 和 Child 进程 handle | -| `AdapterState` | `Mutex>` — adapter 子进程 handle | +| 模块 | 说明 | +|------|------| +| `serverRuntime.ts` | server/adapters sidecar 生命周期、动态端口、日志和 env 注入 | +| `sidecarManager.ts` | sidecar 二进制定位、参数构造、子进程管理 | +| `windows.ts` | 窗口状态持久化、显示/聚焦、close-to-hide 生命周期 | +| `updater.ts` | 更新元数据归一化、下载进度和安装前状态校验 | **启动流程**: -1. `reserve_local_port()` — 绑定 `127.0.0.1:0` 获取 OS 随机端口,再释放 -2. `start_server_sidecar(port)` — 启动 `claude-sidecar server --host 127.0.0.1 --port {port}` -3. `wait_for_server()` — TCP 探活轮询,150ms 间隔,10s 超时 -4. WebView 加载 React 应用 -5. `start_adapters_sidecar()` — 启动 `claude-sidecar adapters --feishu --telegram`,注入 `ADAPTER_SERVER_URL` 环境变量 +1. 应用启动时解析便携模式,必要时设置 `CLAUDE_CONFIG_DIR`。 +2. 创建 `BrowserWindow` 并注入 preload host;dev 模式只允许 localhost renderer,packaged 模式加载 `app.asar/dist/index.html`。 +3. renderer 调用 `runtime.getServerUrl()`,Electron host 启动 `claude-sidecar server --app-root ... --host 0.0.0.0 --port {dynamic}`。 +4. server 健康检查通过后,React 继续加载设置、会话和 WebSocket。 +5. 修改 IM adapter 配置后,renderer 调用 `adapters.restartSidecar()`,host 重启 `claude-sidecar adapters` 并注入 `ADAPTER_SERVER_URL`。 -**退出处理**:`RunEvent::Exit` / `ExitRequested` 时自动 kill 两个 sidecar。 +**退出处理**:窗口默认 close-to-hide;明确退出、更新安装或 app mode restart 前会停止 server/adapters sidecar。 **平台差异**: - **macOS**:overlay titlebar + 自定义菜单(关于、设置 Cmd+,、编辑、窗口) @@ -137,7 +144,7 @@ Server 为每个 Session spawn 一个 CLI 子进程,通过 stdin/stdout JSON - `cli` — 启动 CLI 子进程 - `adapters` — 启动 IM 适配器(解析 `--feishu`/`--telegram` 参数,检查凭据后按需加载) -编译产物放置在 `desktop/src-tauri/binaries/`,Tauri 打包时自动包含。 +编译产物仍放置在 `desktop/src-tauri/binaries/`,作为迁移期稳定资源目录;Electron Builder 通过 `files` 和 `asarUnpack` 把它们打入 `app.asar.unpacked`。 --- @@ -353,10 +360,18 @@ desktop/ │ ├── config/ # providerPresets, spinnerVerbs │ └── lib/ # desktopRuntime, cronDescribe, parseRunOutput ├── src-tauri/ -│ ├── src/main.rs # 入口 -│ ├── src/lib.rs # 核心(~415 行) +│ ├── binaries/ # Electron/Tauri 共用 sidecar 二进制 +│ ├── resources/preview-agent.js # 预览页面注入脚本 +│ ├── src/main.rs # 迁移期 Tauri legacy 入口 +│ ├── src/lib.rs # 迁移期 Tauri legacy host │ ├── Cargo.toml │ └── tauri.conf.json +├── electron/ +│ ├── main.ts # Electron main 入口 +│ ├── preload.ts # renderer host bridge +│ ├── preview-preload.ts # preview WebContentsView bridge +│ ├── ipc/ # channel registry + payload validators +│ └── services/ # dialogs, shell, updater, terminal, preview ... ├── sidecars/ │ └── claude-sidecar.ts # 统一入口 (server/cli/adapters) └── scripts/ diff --git a/docs/desktop/07-electron-migration-research.md b/docs/desktop/07-electron-migration-research.md new file mode 100644 index 00000000..6015db42 --- /dev/null +++ b/docs/desktop/07-electron-migration-research.md @@ -0,0 +1,205 @@ +# Tauri 迁移 Electron 调研索引 + +> 日期:2026-05-31 +> 范围:桌面端壳层从 Tauri 2 迁移到 Electron,React/Vite 渲染层尽量复用。 +> 状态:调研与迁移方案,不是最终实现说明。 + +## 结论 + +迁移方向可行,但不能按“把 Tauri 项目换成 Electron 模板”处理。当前桌面端把 Tauri 2 当作完整 host runtime 使用:它负责自动更新、系统通知、文件选择、外链打开、窗口/托盘/菜单、子进程 sidecar、PTY 终端、子 WebView 预览面板、应用模式配置、窗口状态持久化和权限白名单。React 页面主体可以复用,但必须先把 `desktop/src` 里的 `@tauri-apps/*` 直接调用收敛为一层 `desktopHost` adapter,然后再并行保留 Tauri 实现和新增 Electron 实现。 + +推荐目标架构是: + +```text +desktop/src React renderer + -> desktop/src/lib/desktopHost/* typed host adapter + -> Tauri implementation during transition + -> Electron preload contextBridge implementation + -> Electron main process services + - sidecar manager + - updater service + - notification service + - dialog/open service + - window/tray/menu service + - terminal/pty service + - preview WebContentsView service +``` + +这样可以让前端继续用同一套 Zustand store、API client、WebSocket 和组件测试,同时把高风险系统能力迁移压缩到 adapter contract 和 Electron main/preload 层。不要把本地 Bun server 合并进 Electron main,也不要把 renderer 从 HTTP/WebSocket 改成全 IPC;现有 `desktop/src/api/*`、`chatStore`、`workspacePanelStore`、`teamStore` 已经围绕 local server contract 做了重连、去重、流式 flush 和多客户端观察,这些成熟行为应作为迁移边界保留。 + +## 外部依据 + +本次调研以官方文档和 GitHub issue 为准,Twitter/X 搜索结果信噪比较低,未作为决策依据。 + +- Tauri README 明确 Tauri 通过 WRY 使用系统 WebView:macOS/iOS 是 WKWebView,Windows 是 WebView2,Linux 是 WebKitGTK;同时 Tauri 提供 bundler、自更新、托盘和原生通知等桌面能力。参考:[tauri-apps/tauri](https://github.com/tauri-apps/tauri)。 +- Tauri 2 updater 插件使用 endpoint/static JSON 和 capability permission;危险命令默认阻断,必须在 capabilities 里放行。参考:[Tauri Updater](https://v2.tauri.app/plugin/updater/)。 +- Tauri capability 是按 window/webview 授权的权限集合,权限名使用 `plugin:permission` 形式。参考:[Tauri Capability](https://v2.tauri.app/reference/acl/capability/)。 +- Electron 推荐使用 preload + `contextBridge`,不要把 `ipcRenderer` 原样暴露给 renderer;`contextIsolation` 从 Electron 12 起默认启用。参考:[Electron Context Isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation) 与 [Electron Security](https://www.electronjs.org/docs/latest/tutorial/security)。 +- Electron 内置 `autoUpdater` 不支持 Linux;如果要覆盖 macOS/Windows/Linux,更适合用 `electron-builder` + `electron-updater`。参考:[Electron autoUpdater](https://www.electronjs.org/docs/api/auto-updater/) 与 [electron-builder Auto Update](https://www.electron.build/docs/features/auto-update)。 +- Electron `BrowserView` 已弃用,应使用 `WebContentsView` 承载内嵌浏览器/预览面板。参考:[Electron BrowserView](https://www.electronjs.org/docs/latest/api/browser-view)。 +- Tauri 社区 issue 里也能看到系统 WebView 的真实世界差异,例如 macOS WebView 特定异常和 WebKit memory/performance 争议。参考:[tauri #13141](https://github.com/tauri-apps/tauri/issues/13141)、[tauri #5889](https://github.com/tauri-apps/tauri/issues/5889)。 + +## 本仓库现状 + +### 桌面三层 + +- React/Vite renderer:`desktop/src`。 +- Tauri host:`desktop/src-tauri`。 +- 本地 server/CLI sidecar:`desktop/sidecars/claude-sidecar.ts` 打包成 `desktop/src-tauri/binaries/claude-sidecar-*`,再由 Tauri host 启动。 + +迁移后仍应保留这个三层关系,只把中间 host 从 Tauri 改成 Electron: + +- React renderer 继续调用 `desktop/src/api/client.ts`、`sessionsApi`、`teamsApi`、WebSocket manager。 +- Electron main 负责启动和监管 local server sidecar。 +- renderer 通过 `getServerUrl` 得到 local server URL,而不是直接通过 IPC 读写会话数据。 + +关键入口: + +- `desktop/src-tauri/src/lib.rs` 注册 Tauri plugins、commands、tray/menu、sidecar、terminal、update 准备、通知 bridge、窗口生命周期。 +- `desktop/src-tauri/capabilities/default.json` 声明 Tauri 2 权限。 +- `desktop/src-tauri/tauri.conf.json` 声明窗口、CSP、updater、bundle、externalBin、icons。 +- `desktop/src/lib/desktopRuntime.ts` 解析 Tauri runtime 并获取 local server URL。 +- `desktop/src/stores/updateStore.ts` 管理自动更新状态机。 +- `desktop/src/lib/desktopNotifications.ts` 管理通知权限、发送、点击回跳和系统设置入口。 +- `desktop/src/api/terminal.ts` 通过 Tauri command + event 管理 PTY。 +- `desktop/src/lib/previewBridge.ts` 和 `desktop/src-tauri/src/webview_panel.rs` 管理内嵌预览 WebView。 + +### 当前性能背景 + +`SCROLL_PERF_INVESTIGATION.md` 的关键判断是:卡顿主要来自 macOS Tauri 使用 WKWebView,而 Electron 固定 Chromium/Blink。已合并的 `content-visibility:auto` 是 WebKit 侧缓解方案,但如果目标是接近 CodeX/官方 Claude Code 的一致滚动性能,Electron 是合理方向。迁移收益是可控渲染栈和跨平台一致性;成本是安装体积、内存、签名更新链路和安全边界全部要重建。 + +## Tauri 2 能力盘点与 Electron 对照 + +| 能力 | 当前 Tauri 位置 | Electron 目标 | 迁移风险 | +| --- | --- | --- | --- | +| 自动更新 | `tauri.conf.json` `plugins.updater`、`updateStore.ts`、`prepare_for_update_install`、`tauri.release-ci.json` | `electron-builder` + `electron-updater`,main process 暴露 `check/download/install/relaunch` IPC | 签名、feed metadata、Windows sidecar 文件锁、Linux update 支持不能照搬 | +| 通知 | `tauri-plugin-notification`、`desktopNotifications.ts`、`macos_notifications.m` | Electron `Notification` + main process click routing,必要时保留 macOS native bridge | macOS 权限状态和点击回跳语义必须回归测试 | +| 文件/目录选择 | `@tauri-apps/plugin-dialog.open/save` | `dialog.showOpenDialog/showSaveDialog` | 从 Tauri capability 变成自管 IPC 白名单,路径和 options 要验证 | +| 外链/系统打开 | `@tauri-apps/plugin-shell.open` | `shell.openExternal/openPath` | 必须限制 URL scheme,避免 renderer 任意打开危险目标 | +| sidecar | Tauri shell plugin + `start_server_sidecar` / `spawn_and_track_adapters_sidecar` | Electron main `child_process.spawn` + sidecar manager | 动态端口、启动日志、adapter 重启、Windows kill 逻辑必须等价 | +| PTY 终端 | Rust `portable-pty` commands + Tauri events | Electron main 继续用 Node/Rust sidecar/原生模块之一提供 PTY | 如果从 Rust 移到 Node,shell 解析和 Windows 行为会变 | +| 窗口/托盘/菜单 | `setup_system_tray`、macOS `MenuBuilder`、`RunEvent` | `BrowserWindow`、`Tray`、`Menu`、`app` lifecycle | 关闭隐藏到托盘、macOS reopen、窗口位置恢复必须逐平台验证 | +| 窗口控制/拖拽 | `getCurrentWindow().minimize/toggleMaximize/close/startDragging` | preload bridge 到 `BrowserWindow` actions | 自定义标题栏和拖拽区域需要真实窗口 smoke | +| 子 WebView 预览 | Tauri `WebviewBuilder.add_child` + `preview_*` commands | `WebContentsView`,renderer 只发 typed IPC | `BrowserView` 已弃用,URL 白名单和消息 JSON 校验必须保留 | +| 单实例 | `tauri_plugin_single_instance` | `app.requestSingleInstanceLock()` | 第二实例参数、显示主窗口、协议启动要补测试 | +| app mode / portable config | `get_app_mode`、`set_app_mode`、`detect_portable_dir`、`CLAUDE_CONFIG_DIR` | main process config service | 这是持久化形状变更,要跑 persistence upgrade gate | +| zoom | `set_app_zoom` | `webContents.setZoomFactor` | 范围 clamp 和设置页行为要保持 | +| 权限模型 | `capabilities/default.json` | 自定义 IPC capability registry | 这是安全边界,不能让 renderer 直接拿 Node/Electron API | + +## React 可复用边界 + +可复用: + +- `desktop/src/components/**` 中大部分聊天、设置、任务、workspace、markdown、browser UI。 +- `desktop/src/stores/**` 的业务状态机,前提是依赖 host 能力的 store 改成调用 adapter。 +- `desktop/src/api/**` 的 HTTP/WebSocket client。 +- `desktop/scripts/build-preview-agent.ts`、`desktop/scripts/build-sidecars.ts` 的 sidecar 编译逻辑。 +- `scripts/quality-gate/**` 的报告和 lane 框架。 + +必须抽象: + +- runtime 探测:`isTauriRuntime()` 需要替换为 `getDesktopHostKind()`,支持 `browser`、`tauri`、`electron`。 +- command 调用:`invoke('...')` 改为 typed host methods。 +- event 订阅:Tauri `listen(...)` 改为 typed `desktopHost.events.on(...)`。 +- shell/dialog/notification/update/window/preview/terminal:全部通过 adapter 暴露。 + +不应复用为最终形态: + +- `desktop/src-tauri/**` 的 Tauri 配置和 Rust host crate。 +- Tauri capability JSON。Electron 需要等价的 IPC allowlist,但不是同一格式。 +- `tauri-apps/tauri-action` 和 `tauri build` CI 步骤。 + +必须避免的重写: + +- 不重写 `desktop/src/api/websocket.ts` 的重连、退避、ping 和 pending queue。 +- 不重写 `chatStore` 的历史加载去重和 streaming flush。 +- 不重写 workspace 请求的 latest-request 防陈旧覆盖。 +- 不把 session/team/workspace API 从 HTTP/WS 改成 Electron IPC。 + +## 推荐迁移路径 + +### 1. 冻结基线 + +先在现有 Tauri 实现上跑并记录: + +```bash +bun run check:policy +bun run check:desktop +bun run check:server +bun run check:coverage +bun run quality:gate --mode baseline --allow-live --provider-model +``` + +目的不是证明迁移完成,而是固定“现有能力本来是什么状态”。 + +### 2. 新增 host adapter,但 Tauri 仍是唯一实现 + +新建建议路径: + +- `desktop/src/lib/desktopHost/types.ts` +- `desktop/src/lib/desktopHost/index.ts` +- `desktop/src/lib/desktopHost/tauriHost.ts` +- `desktop/src/lib/desktopHost/browserHost.ts` +- `desktop/src/lib/desktopHost/__tests__/contract.test.ts` + +先把 renderer 中直接 import `@tauri-apps/*` 的位置迁走。完成标准: + +```bash +rg "@tauri-apps|invoke\\(|getCurrentWindow" desktop/src +``` + +生产代码中除 `desktopHost/tauriHost.ts` 外不应再命中。 + +### 3. 增加 Electron 壳 + +建议新增: + +- `desktop/electron/main.ts` +- `desktop/electron/preload.ts` +- `desktop/electron/services/sidecarManager.ts` +- `desktop/electron/services/updater.ts` +- `desktop/electron/services/notifications.ts` +- `desktop/electron/services/dialogs.ts` +- `desktop/electron/services/windows.ts` +- `desktop/electron/services/preview.ts` +- `desktop/electron/ipc/capabilities.ts` + +renderer 通过 preload 暴露的 `window.desktopHost` 调用,不直接使用 Electron API。 + +### 4. 构建与发布改造 + +保留 Vite build、sidecar build、质量门禁骨架,替换 Tauri build: + +- `check:native` 改名或重定义为 Electron main/preload compile + package config check。 +- 新增 `check:electron`。 +- `release-desktop.yml` 从 `tauri-action` 迁到 `electron-builder` 的 macOS/Windows/Linux matrix。 +- `scripts/release.ts` 从更新 `tauri.conf.json` / `Cargo.toml` 改为更新 Electron package/build config。 + +### 5. 逐能力迁移和验收 + +每迁移一个能力都要满足三层证据: + +1. contract/unit test 通过; +2. Electron main/preload 实现测试通过; +3. 打包 app smoke 通过。 + +不要等到全部迁移完才第一次打开 Electron app。 + +## 安全设计要求 + +Electron 迁移最大的安全风险不是 Electron 本身,而是从 Tauri 声明式 capability 退化成“renderer 想要什么就 IPC 什么”。必须建立等价能力层: + +- renderer 不暴露 Node.js、`ipcRenderer`、`shell`、`dialog`、`child_process`。 +- preload 只暴露固定函数,不暴露通用 invoke/send。 +- IPC channel 必须枚举,payload 用 schema 校验。 +- shell open 只允许 `https:`, `http:`, `mailto:` 和明确需要的本地 path 打开场景。 +- child process 只允许 sidecar 和 PTY 服务,参数白名单要保留 Tauri capability 中的约束。 +- preview URL 继续只允许 `http/https`。 +- 更新安装前必须停止 sidecar,失败后恢复可重试状态。 + +## 文档索引 + +- 具体执行任务见:[Electron 迁移任务清单](./08-electron-migration-tasks.md)。 +- 当前桌面架构见:[桌面端架构设计](./02-architecture.md)。 +- 当前安装构建见:[安装与构建](./04-installation.md)。 +- 性能背景见本地文档:`SCROLL_PERF_INVESTIGATION.md`。 diff --git a/docs/desktop/08-electron-migration-tasks.md b/docs/desktop/08-electron-migration-tasks.md new file mode 100644 index 00000000..c900884b --- /dev/null +++ b/docs/desktop/08-electron-migration-tasks.md @@ -0,0 +1,705 @@ +# Electron 迁移任务清单 + +> **For agentic workers:** 按任务顺序执行。每个任务完成后先跑该任务列出的核对命令,再进入下一任务。不要跳过打包壳 smoke,也不要把浏览器/Vite smoke 当作 Electron 壳验收。 + +**目标:** 将桌面端 host runtime 从 Tauri 2 迁移到 Electron,同时保持 React 层、server/sidecar、自动更新、系统交互、通知、构建发布和 Computer Use 能力可用。 +**架构:** 先抽象 `desktopHost` contract,再并行实现 Tauri 与 Electron,最后切换构建发布链路。保留本地 Bun server + REST/WebSocket 边界,不把会话、聊天、workspace、team 业务协议改成 Electron IPC。 +**技术栈:** React 18、Vite、Zustand、Bun、Electron、electron-builder/electron-updater、现有 sidecar、现有质量门禁。 + +--- + +## 当前同步状态 + +**执行记录(2026-06-01):** + +- 已按本地 `main` 重新同步当前迁移工作区:当前分支 `feat/electron-migration-main-sync-2392` 已快进到本地 `main` 的最新提交后再恢复 Electron 迁移改动。 +- 已在当前工作区内解决同步冲突,重点文件包括 `desktop/package.json`、`desktop/scripts/build-macos-arm64.sh`、`desktop/src-tauri/resources/preview-agent.js`、`desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx`、`desktop/src/lib/previewEvents.ts` 和 `desktop/src/lib/previewEvents.test.ts`。 +- 冲突解决后已重建 preview agent,并运行 targeted tests 与 Electron package dir 构建;后续迭代均基于同步后的本地 `main`。 +- packaged Electron renderer 使用 `file://.../app.asar/dist/index.html`,真实模型 smoke 暴露出 H5 token mode 下 WebSocket 会因 `Origin: file://` 被拒绝;已在 server CORS/H5 access policy 中把 `file://` 纳入本地桌面可信来源,并补回归测试。 + +--- + +## Phase 0:迁移前基线 + +### 1. 冻结当前 Tauri 行为基线 + +**涉及文件:** + +- 只读:`desktop/src/**` +- 只读:`desktop/src-tauri/**` +- 只读:`scripts/quality-gate/**` + +**步骤:** + +- [x] 运行 `git status --short`,确认当前工作区是否有无关改动。 +- [x] 运行 `bun run check:policy`。 +- [x] 运行 `bun run check:desktop`。 +- [x] 运行 `bun run check:server`。 +- [x] 运行 `bun run check:coverage`。 +- [ ] 有 live provider 时运行 `bun run quality:gate --mode baseline --allow-live --provider-model `。 +- [x] 记录 quality report 路径和失败项;失败时先修现有基线,不进入迁移。 + +**执行记录(2026-05-31):** + +- `bun run check:policy` 通过。 +- `bun run check:desktop` 通过;首次缺少 desktop 依赖,已在 `desktop/` 执行 `bun install` 后复跑通过。 +- `bun run check:server` 通过。 +- `bun run check:coverage` 初次运行未通过;归因后在 `adapters/` 执行 `bun install` 修复 `grammy` 缺包环境问题,并补足 changed-line 覆盖率。 +- 后续 `bun run check:coverage` 复跑曾因本机 WeChat 占用随机端口 `24023` 导致 `src/server/__tests__/h5-access-auth.test.ts` 超时;已把该测试的随机端口选择改为先探测可绑定端口。 +- 最新 `bun run check:coverage` 通过;report 为 `artifacts/coverage/2026-05-31T13-15-40-522Z/coverage-report.md`,summary 为 `passed=5 failed=0`,changed-lines coverage 为 `90.67% (204/225)`。 + +**通过标准:** 现有 Tauri 主干的 desktop/server/coverage/baseline 状态可复现。 +**阻断条件:** 当前主干已有红灯且未归因。 + +--- + +## Phase 1:Host Adapter Contract + +### 2. 新增 host adapter 类型与 browser fallback + +**创建:** + +- `desktop/src/lib/desktopHost/types.ts` +- `desktop/src/lib/desktopHost/browserHost.ts` +- `desktop/src/lib/desktopHost/index.ts` +- `desktop/src/lib/desktopHost/contract.test.ts` + +**步骤:** + +- [x] 定义 `DesktopHost` 接口,至少覆盖 `runtime/server URL`、`updates`、`notifications`、`dialogs`、`shell`、`window`、`terminal`、`preview`、`appMode`。 +- [x] 在 `browserHost` 中保留 H5/browser fallback:没有桌面能力时返回明确错误或 no-op。 +- [x] 在 contract test 中固定每个方法的 payload 和错误语义。 +- [x] 运行 `cd desktop && bun run test -- src/lib/desktopHost/contract.test.ts --run`。 + +**执行记录(2026-05-31):** + +- 新增 `desktop/src/lib/desktopHost/types.ts`、`browserHost.ts`、`index.ts`、`contract.test.ts`。 +- 先运行 contract test 得到预期红灯(缺少 `browserHost` 模块),补实现后 `desktopHost/contract.test.ts` 6 tests 通过。 + +**通过标准:** host adapter 类型能表达当前所有 Tauri command/plugin 能力。 +**阻断条件:** 仍需要 renderer 直接 import Tauri API 才能表达某个能力。 + +### 3. 实现 Tauri host adapter + +**创建:** + +- `desktop/src/lib/desktopHost/tauriHost.ts` + +**修改:** + +- `desktop/src/lib/desktopRuntime.ts` +- `desktop/src/api/terminal.ts` +- `desktop/src/stores/updateStore.ts` +- `desktop/src/lib/desktopNotifications.ts` +- `desktop/src/lib/previewBridge.ts` +- `desktop/src/lib/previewEvents.ts` +- `desktop/src/lib/appZoom.ts` +- `desktop/src/stores/adapterStore.ts` +- `desktop/src/stores/settingsStore.ts` +- dialog/shell 调用组件:`DirectoryPicker.tsx`、`Sidebar.tsx`、`Settings.tsx`、`TerminalSettings.tsx`、`ComputerUseSettings.tsx`、`WorkspaceFileOpenWith.tsx`、聊天链接打开组件 + +**步骤:** + +- [x] 把 `@tauri-apps/api/core.invoke` 包进 `tauriHost.commands.invoke` 或具体方法。 +- [x] 把 Tauri `listen` 包进 `desktopHost.events`。 +- [x] 把 dialog/shell/window/update/notification 调用全部迁到 `tauriHost`。 +- [x] 运行 `rg "@tauri-apps|__TAURI_INTERNALS__|window\\.__TAURI__|from '@tauri" desktop/src`。 +- [x] 确认生产代码只在 `desktop/src/lib/desktopHost/tauriHost.ts` 命中。 +- [x] 运行 `bun run check:desktop`。 + +**通过标准:** renderer 业务文件不再知道 Tauri API。 +**阻断条件:** 任一 store/component 仍直接动态 import `@tauri-apps/*`。 + +**当前进度(2026-05-31):** + +- 新增 `desktop/src/lib/desktopHost/tauriHost.ts`,先迁入 `get_server_url` 运行时调用。 +- `desktop/src/lib/desktopRuntime.ts` 已改为通过 `desktopHost` 选择 Electron preload host、Tauri host 或 browser fallback;新增回归测试确保 `window.desktopHost` 优先于 H5/browser fallback。 +- 已迁移 `desktop/src/api/terminal.ts`、`desktop/src/lib/previewBridge.ts`、`desktop/src/lib/previewEvents.ts`、`desktop/src/lib/appZoom.ts`、`desktop/src/lib/desktopNotifications.ts`、`desktop/src/stores/updateStore.ts`、`desktop/src/stores/adapterStore.ts`、`desktop/src/stores/settingsStore.ts` 的 runtime/terminal/preview/zoom/notification/update/adapter restart/app mode 调用到 `desktopHost`。 +- `desktopHost` 现在覆盖 `app.getVersion`、`commands.invoke`、`events.listen`、`webview.onDragDropEvent`、`dialogs.open/save`、`shell.open`、`notifications.permission/request/send/onAction`、`window.minimize/toggleMaximize/close/startDragging/focus/requestAttention/isMaximized/onResized/onNativeMenuNavigate`、`updates.check/prepare/cancel/relaunch` 等 Tauri 能力。 +- 已运行 focused tests:`desktopRuntime.test.ts`、`desktopHost/contract.test.ts`、`terminal.test.ts`、`previewBridge.test.ts`、`previewEvents.test.ts`、`appZoom.test.ts`、`desktopNotifications.test.ts`、`updateStore.test.ts`、`adapterStore.test.ts`、`settingsStore.test.ts`、`AppShell.test.tsx`、`WindowControls.test.tsx`、`ComputerUseSettings.test.tsx` 全部通过。 +- 继续迁移了 dialog/shell/window/menu 组件层、file drop webview、official login/open-with 等打开路径:`DirectoryPicker`、`composerAttachments`、`TerminalSettings`、`ComputerUseSettings`、`Settings`、`Sidebar`、`TabBar`、`WindowControls`、`AppShell`、`WorkspaceFileOpenWith`、`AssistantMessage`、`AssistantOutputTargetCard`、`CurrentTurnChangeCard`。 +- 2026-05-31 扫描结果:业务 renderer 生产代码不再直接 import `@tauri-apps/*`;剩余 Tauri 命中为 `desktopHost` 自身、runtime detection 类型声明和测试 mock。生产路径排除 `desktopHost` 后扫描为空:`rg -n "@tauri-apps|__TAURI_INTERNALS__|window\\.__TAURI__|from '@tauri" desktop/src --glob '!**/*.test.ts' --glob '!**/*.test.tsx' --glob '!**/lib/desktopHost/**' --glob '!src-tauri/**'`。 +- `bun run check:desktop` 通过:desktop `tsc --noEmit`、Vitest 全量 146 test files / 1127 tests passed,production build 成功;现有 React `act(...)` warnings 仍为非阻断输出。 + +--- + +## Phase 2:Electron 最小壳 + +### 4. 新增 Electron main/preload 骨架 + +**创建:** + +- `desktop/electron/main.ts` +- `desktop/electron/preload.ts` +- `desktop/electron/ipc/channels.ts` +- `desktop/electron/ipc/capabilities.ts` +- `desktop/electron/tsconfig.json` + +**修改:** + +- `desktop/package.json` +- 根 `package.json` + +**步骤:** + +- [x] 新增 `electron:dev`、`electron:build`、`check:electron` 脚本。 +- [x] `BrowserWindow` 使用 preload,保持 `contextIsolation`,不启用 renderer Node。 +- [x] preload 暴露 `window.desktopHost`,不暴露通用 `ipcRenderer`。 +- [x] `capabilities.ts` 枚举允许的 IPC channel 和 payload validator。 +- [x] 运行 `bun run check:electron`。 + +**执行记录(2026-05-31):** + +- 新增 `desktop/electron/main.ts`、`preload.ts`、`ipc/channels.ts`、`ipc/capabilities.ts`、`ipc/capabilities.test.ts`、`electron/tsconfig.json`、`scripts/electron-dev.ts`。 +- `BrowserWindow` 配置为 `preload + contextIsolation: true + nodeIntegration: false + sandbox: true`;preload 只通过 `contextBridge.exposeInMainWorld('desktopHost', ...)` 暴露 typed host contract。 +- `check:electron` 通过:Electron IPC validator 测试 3/3 通过,`electron-dist/main.cjs` 与 `electron-dist/preload.cjs` 构建成功。 +- `electron:build` 通过:renderer `desktop/dist` 和 Electron CJS entrypoints 均构建成功。 +- 短时 Electron 启动 smoke 已尝试;当前机器首次运行 `electron` 会触发 Electron binary 下载,下载未在本轮完成,因此真实窗口启动验证保留到下一轮 runtime/server bridge smoke。 + +**通过标准:** Electron 壳能启动并加载 Vite/dev 或 `desktop/dist`。 +**阻断条件:** renderer 需要 Node API 或 Electron API 才能工作。 + +### 5. 实现 Electron runtime/server bridge + +**创建:** + +- `desktop/electron/services/sidecarManager.ts` +- `desktop/electron/services/serverRuntime.ts` + +**步骤:** + +- [x] 复刻 `start_server_sidecar`:保留动态端口、host、config、startup log、超时错误。 +- [x] 复刻 adapter sidecar 启动和 `restart_adapters_sidecar`。 +- [x] 保留 `CLAUDE_CONFIG_DIR`、`CC_HAHA_APP_PORTABLE_DIR`、`ADAPTER_SERVER_URL` 注入。 +- [x] 提供 `get_server_url` 等价 IPC。 +- [x] 确认 renderer 仍通过 `desktop/src/api/client.ts` 和 WebSocket manager 访问会话/聊天/workspace/team,不新增这些业务的 IPC handler。 +- [x] 运行 `bun run check:electron`。 +- [x] 打开 Electron dev app,确认首页能连上 local server。 + +**执行记录(2026-05-31):** + +- 新增 `desktop/electron/services/sidecarManager.ts` 和 `serverRuntime.ts`;Electron main 在 ready 阶段启动 server sidecar,`runtime.getServerUrl()` 通过 IPC 返回动态 loopback URL。 +- `sidecarManager` 复用现有 `desktop/src-tauri/binaries/claude-sidecar-`,server 参数保持 `server --app-root --host 0.0.0.0 --port `,adapter 参数保持 `adapters --app-root --feishu/--telegram/--wechat/--dingtalk`。 +- 环境变量保留 `CLAUDE_CONFIG_DIR`、`XDG_CACHE_HOME`、`CLAUDE_H5_AUTO_PUBLIC_URL`、`CLAUDE_H5_DIST_DIR`;adapter 注入 `ADAPTER_SERVER_URL=ws://`。 +- headless smoke 通过:`bun run build:sidecars` 后用 `ElectronServerRuntime` 启动 server,`GET /health` 返回 200,随后 `stopAll()` 停止 server/adapters 子进程。 +- 2026-06-01 Electron dev 壳已由 Computer Use 多轮复测:renderer 加载 `localhost:1420/`,main process 自动启动 local server/sidecar,首页、设置页和会话页均可通过 server API/WebSocket 工作。 + +**通过标准:** Electron app 不依赖外部手动启动 server。 +**阻断条件:** 需要用户先运行 `SERVER_PORT=... bun run src/server/index.ts` 才能使用桌面端,或迁移方案开始绕过 local server 直接重写 session/chat IPC。 + +--- + +## Phase 3:系统能力迁移 + +### 6. 迁移文件选择、保存、外链打开 + +**创建/修改:** + +- `desktop/electron/services/dialogs.ts` +- `desktop/electron/services/shell.ts` +- `desktop/src/lib/desktopHost/electronHost.ts` + +**步骤:** + +- [x] `openFile`、`openDirectory`、`saveFile` 映射到 Electron dialog。 +- [x] `openExternal` 限制 URL scheme,`openPath` 限制为明确来自文件选择或 workspace 的路径。 +- [x] 更新 adapter contract tests。 +- [x] 运行 `bun run check:desktop && bun run check:electron`。 +- [x] 手动 smoke:选择工作目录、选择附件、选择 Python 路径、打开外链。 + +**执行记录(2026-05-31):** + +- 新增 `desktop/electron/services/dialogs.ts` 与 `shell.ts`;dialog options 映射到 Electron `showOpenDialog/showSaveDialog`,shell URL 只允许 `http: / https: / mailto:`。 +- `DesktopHost.shell` 拆分为 `open(url)` 与 `openPath(path)`;workspace/open-with 本地文件路径改走 `openPath`,普通网页/OAuth/通知设置等外链继续走 `open`。 +- 新增 `desktop/src/lib/desktopHost/electronHost.ts`,preload 改为复用该 typed host factory;IPC payload 在 preload 进入 `ipcRenderer.invoke` 前先校验。 +- Focused tests 通过:`electron/services/shell.test.ts`、`dialogs.test.ts`、`electronHost.test.ts`、`desktopHost/contract.test.ts`、`WorkspaceFileOpenWith.test.tsx`。 +- `bun run check:electron` 通过:16 tests passed,Electron main/preload 构建成功。 +- `bun run check:desktop` 通过,现有 React/Tauri 主干未被 `openPath` contract 破坏。 +- 2026-06-01 Electron dev 壳 Computer Use 复测附件选择:点击 composer `添加文件或图片` 打开 macOS 原生 `打开` 面板,选择 `/Users/nanmi/cc-haha-cua-attachment-smoke.txt` 后回到应用,composer 显示 `cc-haha-cua-attachment-smoke.txt` 附件 chip。 +- 2026-06-01 Electron dev 壳 Computer Use 复测 Python 路径选择:在 Computer Use 设置中点击 `选择` 打开 macOS 原生 `选择 Python 解释器` 面板,选择 `/Users/nanmi/cc-haha-cua-python3-smoke` 后应用解析并保存为 `/opt/homebrew/Cellar/python@3.14/3.14.5/Frameworks/Python.framework/Versions/3.14/bin/python3.14`;随后恢复自动检测,`/api/computer-use/authorized-apps` 返回 `pythonPath: null`。 +- 2026-06-01 Electron dev 壳 Computer Use 复测外链:点击关于页 GitHub 项目卡片后,系统浏览器 Google Chrome 被拉到前台,地址栏为 `github.com/NanmiCoder/cc-haha`;Electron renderer 仍停留在 `localhost:1420/`,未在应用内导航外链。 + +**通过标准:** 所有原 `plugin-dialog` 和 `plugin-shell` 用户路径可用。 +**阻断条件:** 任一路径绕过 preload 或接受未校验目标。 + +### 7. 迁移窗口、托盘、菜单和单实例 + +**创建/修改:** + +- `desktop/electron/services/windows.ts` +- `desktop/electron/services/tray.ts` +- `desktop/electron/services/menu.ts` +- `desktop/electron/services/singleInstance.ts` + +**步骤:** + +- [x] 实现 minimize/toggleMaximize/close/startDragging。 +- [x] 实现关闭隐藏到托盘、托盘 show/quit。 +- [x] 实现 macOS About/Settings 菜单并发送 `native-menu-navigate` 等价事件。 +- [x] 实现窗口位置/尺寸持久化和 monitor 可见性保护。 +- [x] 实现 `app.requestSingleInstanceLock()`,第二实例显示主窗口。 +- [x] 运行 `bun run check:electron`。 +- [ ] 打包 app smoke:关闭窗口、托盘恢复、菜单打开设置页、重启后窗口仍可见。 + +**执行记录(2026-05-31):** + +- 新增 `desktop/electron/services/windows.ts`、`tray.ts`、`menu.ts`、`singleInstance.ts`。 +- Electron main 现在启动前申请 single-instance lock;重复启动会 show/focus 主窗口。 +- 主窗口关闭默认隐藏到托盘,托盘菜单提供 `Show Claude Code Haha` 和 `Quit Claude Code Haha`;显式 quit 才停止 sidecar 并退出。 +- 窗口位置/尺寸/maximized 状态写入 `window-state.json`,优先使用 `CLAUDE_CONFIG_DIR`,恢复前会验证尺寸和 display/workArea 可见性。 +- 原生菜单发送 `desktop:window:native-menu-navigate`,保持 renderer 现有 settings/about 路由入口。 +- Electron 先不声明自定义 Windows chrome 能力:`windowControls=false`,renderer 不渲染自定义窗口按钮,也不会把空白 tab/sidebar 区当成可拖拽窗口区域;后续若启用 frameless window,需要同时实现真实拖拽。 +- `bun run check:electron` 通过:26 tests passed,Electron main/preload 构建成功。 +- 2026-06-01 Electron dev 壳 Computer Use 复测窗口关闭/恢复:点击 macOS 关闭按钮后 Electron 进程仍在 `list_apps` 中保持 running,主窗口消失;通过 macOS app activation 路径重新激活同一 Electron app 后,Computer Use 再次看到 `Claude Code Companion` 主窗口和 `localhost:1420/` renderer。Computer Use 当前不能稳定枚举 Dock,因此本轮验证的是与 Dock 点击等价的 app activation/show 主窗口路径。 +- 2026-06-01 packaged app Computer Use 复测菜单与窗口生命周期:在 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app` 隔离实例中,通过 macOS app menu 点击 `Settings...` 后 renderer 打开设置页;点击关闭按钮后 Computer Use 返回 `noWindowsAvailable`,System Events 显示同一 PID `count=0` 且进程仍存活;随后用 app activation 恢复,Computer Use 再次读取到 `Claude Code Companion` 设置页窗口。继续通过 app menu `Quit` 退出后,隔离配置写入 `window-state.json`;用同一 `CLAUDE_CONFIG_DIR` 和 `--user-data-dir` 重启,Computer Use 成功读取新 PID 的 packaged `file://.../app.asar/dist/index.html` 主窗口,System Events 显示 `count=1 names=Claude Code Companion`。该轮覆盖 packaged 菜单打开设置、关闭隐藏、activation 恢复和重启后窗口仍可见;真实 tray 图标点击仍需后续实机/可枚举 tray 环境复验。 + +**通过标准:** macOS/Windows/Linux 主窗口生命周期等价。 +**阻断条件:** 关闭行为会误退出或窗口状态可能恢复到屏幕外。 + +### 8. 迁移通知 + +**创建/修改:** + +- `desktop/electron/services/notifications.ts` +- `desktop/src/lib/desktopNotifications.ts` +- `desktop/src/lib/desktopHost/contract.test.ts` + +**步骤:** + +- [x] 实现权限状态、请求权限、发送通知。 +- [x] 实现通知点击回跳并传递 target。 +- [x] Windows 保留打开系统通知设置入口。 +- [x] macOS 若 Electron `Notification` 无法满足权限/点击语义,保留 native bridge 或补原生模块。 +- [x] 运行 `bun run check:electron`。 +- [x] 运行 `bun run check:desktop`。 +- [ ] 打包 app smoke:发送通知、点击通知、跳回目标 session。 + +**执行记录(2026-05-31):** + +- 新增 `desktop/electron/services/notifications.ts`,使用 Electron main-process `Notification` 发送系统通知并监听 `click`。 +- 通知点击通过 `desktop:notification:action` 发送 `{ id, extra, target, action: 'click' }`,renderer 继续复用现有 `installDesktopNotificationClickListener` target 解析和导航。 +- Electron `commandInvoke` 兼容现有 renderer 的 legacy 通知入口:`macos_notification_permission_state`、`macos_request_notification_permission`、`macos_send_notification`、`plugin:notification|is_permission_granted`、`plugin:notification|request_permission`。 +- Windows 通知设置入口通过 allowlisted `ms-settings:notifications` 打开;macOS 系统通知设置 URL 仍限制在 allowlist。 +- 参考 Electron 官方 Notification 文档:main process 使用 `new Notification(...).show()`,点击通过 `notification.on('click', ...)` 处理;Electron 没有独立的桌面通知 request-permission API,因此 Electron host 将 `Notification.isSupported()` 映射为 host permission state。 +- `bun run check:electron` 通过:32 tests passed,Electron main/preload 构建成功。 +- `bun run check:desktop` 通过:141 test files / 1103 tests passed,production build 成功;现有 React `act(...)` warnings 仍为非阻断输出。 + +**执行记录(2026-06-01):** + +- 新增 `desktop/electron/services/notificationSmoke.ts`,提供显式环境变量触发的 Electron 通知 smoke hook:设置 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_SESSION_ID=` 后,main process 会延迟发送带 `{ type: 'session', sessionId }` target 的真实 Electron `Notification`。默认不启用,不影响正常启动和生产行为。 +- 新增 `desktop/electron/services/notificationSmoke.test.ts`,锁定默认不发送、target payload 透传和 delay clamp;`cd desktop && bun test electron/services/notificationSmoke.test.ts electron/services/notifications.test.ts electron/services/windows.test.ts` 通过:16 tests passed。 +- Computer Use 真实 OS 点击复测:带 smoke env 启动 Electron dev 壳并关闭主窗口后,Electron 仍 running;但本机 Computer Use 对 `/System/Library/CoreServices/NotificationCenter.app` 和 `SystemUIServer` 均返回 `timeoutReached`,无法稳定枚举或点击 macOS 通知横幅。重新 `get_app_state(Electron)` 会触发 app activation 并恢复窗口,因此不能作为“点击通知回跳”的证据。该项保持未完成,需在可操作 Notification Center/横幅的 macOS 会话或签名 packaged app 上复验。 +- 2026-06-01 通知点击二次复测:用 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_DELAY_MS=30000` 启动 Electron dev 壳,Computer Use 确认主窗口可见后点击 macOS close button,Electron 无主窗口且进程保持 running;等待通知触发后,Computer Use 对 `SystemUIServer` 与 Notification Center 仍 `timeoutReached`,前台 Chrome 截图/AX 树也没有出现可点击通知横幅。真实 OS 通知点击回跳仍未验收通过。 +- 2026-06-01 通知 smoke 可审计化:新增 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_LOG=`,仅在显式 smoke env 下把 scheduled/sent/action/send_failed 写入 JSONL,方便真实 macOS/Windows runner 点击通知后确认 Electron main 是否收到 OS click。`cd desktop && bun test electron/services/notificationSmoke.test.ts electron/services/notifications.test.ts electron/services/windows.test.ts` 通过 17 tests,`cd desktop && bun run check:electron` 通过 85 tests。 +- 2026-06-01 packaged 通知 smoke 复测:重新运行 `CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir` 后,用隔离配置启动 packaged app,并设置 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_LOG=/tmp/.../notification-smoke.jsonl`。Computer Use 确认 packaged `file://.../app.asar/dist/index.html` 主窗口可见;JSONL 记录 `scheduled` 与 `sent:true`,证明 Electron main 已向系统发出真实通知。但 Computer Use 仍无法读取 `SystemUIServer` 或 `NotificationCenter.app`(均 `timeoutReached`),日志也未出现 `action`,所以“点击通知回到目标 session”仍未完成。 +- 2026-06-01 通知点击当前态再复测:用 canonical packaged app 启动隔离通知 smoke,设置 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_SESSION_ID=notification-click-smoke-session`、`CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_DELAY_MS=8000` 和 JSONL log;Computer Use 关闭主窗口后 app 留在后台,JSONL 记录 `scheduled` 与 `sent:true`。Computer Use 对 `/System/Library/CoreServices/UserNotificationCenter.app/` 返回安全策略拒绝,对 `NotificationCenter.app` 仍 `timeoutReached`,所以仍无法执行真实 OS 通知点击。为后续实机复验,通知 smoke 日志已扩展记录 `lifecycle: close|failed`,可区分通知发出后被系统关闭、发送失败和用户点击;`cd desktop && bun test electron/services/notificationSmoke.test.ts electron/services/notifications.test.ts` 通过 10 tests,`cd desktop && bun run check:electron` 通过 90 tests。 + +**通过标准:** 通知提示、权限、点击回跳都能在真实打包 app 里证明。 +**阻断条件:** 只在 unit test 中 mock 成功,未走系统通知。 + +### 9. 迁移自动更新 + +**创建/修改:** + +- `desktop/electron/services/updater.ts` +- `desktop/src/stores/updateStore.ts` +- `desktop/package.json` +- `.github/workflows/release-desktop.yml` + +**步骤:** + +- [x] 选定 `electron-builder` + `electron-updater`,不要用 Electron 内置 updater 覆盖 Linux。 +- [x] 保留 `check -> download -> progress -> install -> relaunch` 状态机。 +- [x] `install` 前调用 sidecar manager 停止 server/adapters,失败后恢复可重试状态。 +- [x] 建立 mock update feed 测试。 +- [x] macOS 签名/zip metadata、Windows NSIS metadata、Linux AppImage/deb update 策略写入 Electron build metadata。 +- [x] 运行 `bun run check:electron`。 +- [x] 运行 `bun run check:desktop`。 +- [x] release workflow 切换到 Electron builder,并在打包前验证签名/notarization/update metadata secrets 是否配置齐全。 + +**通过标准:** 自动更新全链路有 unit、mock feed、打包产物证据。 +**阻断条件:** 只能检查到新版本,不能安全安装并 relaunch。 + +**执行记录(2026-05-31):** + +- 新增运行时依赖 `electron-updater@6.8.3` 和打包依赖 `electron-builder@26.8.1`。 +- 新增 `desktop/electron/services/updater.ts`;`autoDownload=false`,`checkForUpdates()` 只返回 metadata,`downloadUpdate()` 将 electron-updater `download-progress` 转成现有 `DesktopUpdateDownloadEvent`,`relaunch()` 阶段调用 `quitAndInstall(false, true)`。 +- 扩展 Electron IPC:`desktop:update:download`、`desktop:update:install` 和 `desktop:update:download-event`。 +- `desktop/src/lib/desktopHost/electronHost.ts` 将 Electron update metadata 包装成现有 `DesktopUpdate` 对象,renderer `updateStore` 不需要改状态机。 +- `desktop/package.json` 已加入 Electron builder metadata:`appId=com.claude-code-haha.desktop`、GitHub publish 到 `NanmiCoder/cc-haha`、macOS `dmg/zip`、Windows `nsis`、Linux `AppImage/deb`;packaged runtime 使用 `asar=true`,并通过 `asarUnpack` 保留 `node-pty` 与 sidecar binaries。 +- `bun run check:electron` 通过:36 tests passed,Electron main/preload 构建成功。 +- `bun run check:desktop` 通过:desktop `tsc --noEmit`、Vitest 全量 146 test files / 1127 tests passed,production build 成功。 + +**执行记录(2026-06-01):** + +- 完整 macOS Electron Builder release packaging 暴露出两个自动更新发布问题:平台级 `publish: ["github"]` 会在 worktree/CI 中触发 git remote 自动探测并导致 update info 生成崩溃;默认含空格 artifact name 会让 `latest-mac.yml` 指向 `Claude-Code-Haha-...`,但实际上传文件是 `Claude Code Haha-...`。 +- 已修复 `desktop/package.json`:保留顶层显式 GitHub publish 元数据,并移除 mac/win/linux 平台级 publish 覆盖;新增统一无空格 `artifactName`,避免 update metadata 与上传 asset 文件名漂移。 +- 已增强 `scripts/quality-gate/package-smoke/index.ts`:当存在 `latest-mac.yml` / `latest.yml` / `latest-linux.yml` 时,会解析 `url` / `path` 并确认引用的 artifact 文件实际存在;当发布 archive 存在但 update metadata 缺失时会失败。 +- 已新增回归测试:`scripts/pr/release-workflow.test.ts` 防止 Electron Builder 再依赖 git remote autodetection;`scripts/quality-gate/package-smoke/index.test.ts` 会捕获 `latest-mac.yml` 指向缺失 asset 的情况。 +- 2026-06-01 packaged app updater 缺失 channel metadata 修复:本地打包 app 启动后 electron-updater 可访问 GitHub release,但当前 release 缺少 `latest-mac.yml` 时会抛 `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`。该错误现在仅在 message 指向 `latest*.yml` 时降级为“无更新”,继续保留其它 updater 错误抛出;`cd desktop && bun test electron/services/updater.test.ts` 通过 7 tests,`cd desktop && bun run check:electron` 通过 84 tests。 +- 2026-06-01 packaged app 当前态复验:重新运行 `cd desktop && CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir` 后,从 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app` 以隔离 `CLAUDE_CONFIG_DIR` / `--user-data-dir` 启动;Computer Use 确认主窗口加载 `file://.../app.asar/dist/index.html`,sidecar 自动启动,未出现 Safe Storage 钥匙串弹窗,日志未再出现 `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND` IPC handler 崩错。纯 `--dir` 包缺少 `app-update.yml` 的提示仍按无更新处理,不代表 signed release update feed 已验收。 +- 当前本机 `.dmg` 构建仍受 macOS DiskImages/hdiutil 状态阻塞:`hdiutil: create failed - 操作超时`,且当前 worktree 下残留的临时读写 disk image 无法正常 detach;这属于本机磁盘镜像服务阻塞,不应被 package-smoke 或浏览器 smoke 替代为发布通过。 + +### 10. 迁移 PTY 终端 + +**创建/修改:** + +- `desktop/electron/services/terminal.ts` +- `desktop/src/api/terminal.ts` + +**步骤:** + +- [x] 明确 PTY 实现:保留 Rust sidecar command 或引入 Node PTY 方案。 +- [x] 实现 spawn/write/resize/kill 和 output/exit event。 +- [x] 保留 terminal shell 配置读写语义。 +- [x] 运行 `bun run check:desktop && bun run check:electron`。 +- [x] 打包 app smoke:打开终端、输入命令;resize/kill 需随完整 packaged smoke 继续复验。 + +**通过标准:** 终端交互和事件流等价。 +**阻断条件:** Windows shell 或自定义 shell path 行为不明确。 + +**执行记录(2026-05-31):** + +- 已选择 Node PTY 方案:新增运行时依赖 `node-pty@1.1.0`,Electron main process 负责 `pty.spawn()`,renderer 只通过 preload IPC 发送 spawn/write/resize/kill。Context7 `/microsoft/node-pty` 文档确认主进程集成方式为 `spawn` + `onData`/`onExit` + `write`/`resize`/`kill`。 +- 新增 `desktop/electron/services/terminal.ts`,保留 Tauri 终端语义:`CLAUDE_CONFIG_DIR/terminal-config.json` 优先,其次 Electron `userData/terminal-config.json`;继续读取 `~/.claude/settings.json` 的 `desktopTerminal` Windows shell 配置;保留 Windows `bash_path`、`COMSPEC`、POSIX `SHELL`、`/bin/zsh`/`/bin/bash` 默认 shell 选择。 +- `terminal_spawn` 等价实现最小尺寸限制:`cols >= 20`、`rows >= 8`;默认 cwd 仍按 `cwd -> CLAUDE_CONFIG_DIR -> HOME/USERPROFILE -> process.cwd()`;环境变量合并 login shell env,并强制 UTF-8 locale,补充 `TERM=xterm-256color` 与 `COLORTERM=truecolor`。 +- `desktop/electron/main.ts` 已注册 `desktop:terminal:*` IPC:`spawn/write/resize/kill/get-bash-path/set-bash-path`,并通过 `desktop:terminal:output`、`desktop:terminal:exit` 事件回传现有 renderer payload。 +- `desktop/electron/ipc/capabilities.ts` 收紧 `terminalSpawn` payload 校验,避免 renderer 传入非数字尺寸或非字符串 cwd/shell。 +- 新增 `desktop/electron/services/terminal.test.ts`,覆盖配置路径、bash path 持久化、Windows shell 解析、UTF-8 env、spawn event 转发、write/resize/kill、exit 后 session 清理。 +- packaged macOS `node-pty` 直接从 `.app/Contents/Resources/app.asar.unpacked` 执行会在 `spawn-helper` 上触发 `posix_spawnp failed`;已改为打包时先执行 `prepare:node-pty` 修正 helper executable bit,并在 packaged macOS runtime 将 `node-pty` 复制到 Electron `userData/native/node-pty---` 后加载,避免嵌套 app bundle 资源执行限制。 +- `bun run check:electron` 通过:58 tests passed,Electron main/preload/preview-preload 构建成功。 +- `bun run check:desktop` 通过:desktop `tsc --noEmit`、Vitest 全量 146 test files / 1127 tests passed,production build 成功。 +- `CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir` 通过并产出 unpacked macOS `.app`;Computer Use packaged smoke 通过:启动 `Claude Code Haha.app`,点击“打开终端”后显示 `运行中 /bin/zsh`,在终端输入 `printf electron-terminal-ok` 并看到输出 `electron-terminal-ok`。终端 resize/close kill 仍需在完整 packaged smoke 中补齐。 + +### 11. 迁移预览面板 + +**创建/修改:** + +- `desktop/electron/services/preview.ts` +- `desktop/src/lib/previewBridge.ts` +- `desktop/src/lib/previewEvents.ts` + +**步骤:** + +- [x] 使用 `WebContentsView`,不要用已弃用的 `BrowserView`。 +- [x] 保留 `http/https` URL 白名单。 +- [x] 保留 preview agent 注入和 JSON 消息校验。 +- [x] 实现 open/navigate/setBounds/setVisible/close/eval/message。 +- [x] 运行 `bun run check:desktop && bun run check:electron`。 +- [x] 打包 app smoke:打开 workbench/browser panel,导航 URL,完成 preview/screenshot 回填 smoke。 + +**通过标准:** 预览面板功能等价且安全边界保留。 +**阻断条件:** 允许 `file:` 或 `javascript:` URL 进入 preview。 + +**执行记录(2026-05-31):** + +- Context7 `/electron/electron` 文档确认当前 API 使用 `WebContentsView` + `contentView.addChildView()` + `setBounds()` + `webContents.loadURL()`,不再新增 `BrowserView`。 +- 新增 `desktop/electron/services/preview.ts`,使用 `WebContentsView` 实现 `open/navigate/setBounds/setVisible/close/eval`,继续拒绝空 URL、`file:`、`javascript:` 等非 `http/https` URL。 +- 新增 `desktop/electron/preview-preload.ts`,只暴露 `window.__DESKTOP_PREVIEW_POST__(raw)`;外部页面无法拿到 Node/Electron API,只能把 JSON 字符串发回 main process。 +- `desktop/src/preview-agent/index.ts` 改为调用通用 `__DESKTOP_PREVIEW_POST__`;Tauri 端在 `desktop/src-tauri/src/webview_panel.rs` 创建 child webview 时先注入该函数,再注入 preview agent;Electron 端由 preview preload 注入同名函数。 +- Electron main process 增加内部 channel `desktop:preview:message-from-view`,只接受当前 preview `webContents` 发出的 raw JSON;校验 JSON 后通过 `desktop:preview:event` 转发给 React。 +- `desktop/package.json` 的 `build:electron` 增加 `preview-preload.cjs` 构建,Electron builder files 增加 `src-tauri/resources/preview-agent.js`,保证 packaged app 能读取注入脚本。 +- 新增 `desktop/electron/services/preview.test.ts`,覆盖 URL 白名单、bounds 规范化、child view 复用、load 后注入 preview agent、JSON event 转发、close 清理。 +- `bun run build:preview-agent` 已重建 `desktop/src-tauri/resources/preview-agent.js`。 +- `bun run check:electron` 通过:49 tests passed,Electron main/preload/preview-preload 构建成功。 +- `bun run check:desktop` 通过:desktop `tsc --noEmit`、Vitest 全量 146 test files / 1127 tests passed,production build 成功。 +- 2026-06-01 Computer Use packaged smoke 覆盖 preview/workbench:临时 packaged app 中打开 browser panel,导航 `https://example.com/` 成功,并完成 screenshot 回填 composer;完整 preview event 回归继续由 `desktop/electron/services/preview.test.ts` 覆盖。 + +### 12. 迁移 app mode、portable config 和 zoom + +**创建/修改:** + +- `desktop/electron/services/appMode.ts` +- `desktop/electron/services/zoom.ts` +- `desktop/src/stores/settingsStore.ts` +- `desktop/src/lib/appZoom.ts` + +**步骤:** + +- [x] 保留 `get_app_mode`、`set_app_mode`、`detect_portable_dir` 返回形状。 +- [x] 保留同时写系统默认配置目录和 portable 目录的语义。 +- [x] zoom 继续 clamp 到 `0.5..2.0`。 +- [x] 运行 `bun run check:persistence-upgrade`。 +- [x] 运行 `bun run check:desktop && bun run check:electron`。 + +**通过标准:** 老配置可升级,unknown fields 保留。 +**阻断条件:** 写入 `~/.claude/settings.json` 或受保护配置路径。 + +**执行记录(2026-05-31):** + +- 新增 `desktop/electron/services/appMode.ts`,复刻 Tauri `app-mode.json` 语义:启动时按 `external CLAUDE_CONFIG_DIR -> default portable app-mode.json -> system app-mode.json -> default portable data auto-detect` 判定是否设置 `CLAUDE_CONFIG_DIR`。 +- Electron `app.whenReady()` 后、server sidecar 启动前调用 `applyStartupPortableMode(app)`,保证 sidecar、window state、terminal config 都能看到 portable env;同时设置 `CC_HAHA_APP_PORTABLE_DIR=1` 与 `WEBVIEW2_USER_DATA_FOLDER=/EBWebView`。 +- `desktop:app-mode:get` 返回现有 renderer 形状:`mode`、`portableDir`、`defaultPortableDir`、`activeConfigDir`、`configDirSource`;`configDirSource` 区分 `system`、外部环境变量 `environment`、应用切换产生的 `portable`。 +- `desktop:app-mode:set` 在当前 active config、目标 portable dir、系统默认 `userData` 三处写入 `app-mode.json`,不写 `~/.claude/settings.json`,也不移动/删除已有数据。 +- `desktop:app-mode:restart` 现在 `relaunch()` 后立即 `quit()`,与设置页“切换后重启”的语义一致;`prepareRestart` 继续先停止 server/adapters。 +- 新增 `desktop/electron/services/zoom.ts`,native zoom 统一 clamp 到 `0.5..2.0`,避免 Electron IPC 绕过 renderer 侧限制。 +- 新增 `appMode.test.ts` 与 `zoom.test.ts`,覆盖 portable 数据探测、启动模式解析、env 设置、返回形状、三处 app-mode 写入、zoom clamp。 +- `bun run check:electron` 通过:56 tests passed,Electron main/preload/preview-preload 构建成功。 +- `bun run check:persistence-upgrade` 通过:server persistence 6 tests passed,desktop localStorage migration 8 tests passed。 +- `bun run check:desktop` 通过:desktop `tsc --noEmit`、Vitest 全量 146 test files / 1127 tests passed,production build 成功。 + +--- + +## Phase 4:Build、CI、Release + +### 13. 替换本地构建脚本 + +**修改:** + +- `desktop/package.json` +- `package.json` +- `desktop/scripts/build-macos-arm64.sh` +- `desktop/scripts/build-windows-x64.ps1` + +**步骤:** + +- [x] 保留 `desktop build` 作为 renderer build。 +- [x] 保留 `build:sidecars`。 +- [x] 新增 Electron package 脚本,输出到 `desktop/build-artifacts/`。 +- [x] `check:native` 替换为 Electron main/preload/package config check,或新增 `check:electron` 并让 `quality:gate` 使用它。 +- [x] 运行 `bun run check:desktop && bun run check:electron`。 + +**通过标准:** 本地 macOS/Windows 构建命令产出可启动 app。 +**阻断条件:** sidecar 没有被打进产物或产物启动后仍读开发路径。 + +**执行记录(2026-05-31):** + +- `desktop/package.json` 保留 `build` 为 renderer build,新增/调整 `electron:build`、`electron:package`、`electron:package:dir`,并将 `build:electron` 扩展为 main/preload/preview-preload 三个 bundle。 +- Electron builder 配置包含 `dist/**`、`electron-dist/**`、`src-tauri/binaries/**`、`src-tauri/resources/preview-agent.js` 和 `node_modules/node-pty/**`;`electron-updater` 已打进 main bundle,`node-pty` 保持外部原生模块并通过 `asarUnpack` 解包。 +- `check:native` 已从 Tauri `cargo check` 改为 `build:sidecars + check:electron`;`quality:gate` native lane 描述同步为 Electron host/package checks。 +- `desktop/scripts/build-macos-arm64.sh` 改为 Electron Builder:构建 sidecar、renderer、Electron bundles,执行 `electron-builder --mac dmg zip --arm64 --publish never`,输出复制到 `desktop/build-artifacts/macos-arm64`。 +- `desktop/scripts/build-windows-x64.ps1` 改为 Electron Builder:导入 MSVC 环境,构建 sidecar、renderer、Electron bundles,执行 `electron-builder --win nsis --x64 --publish never`,输出复制到 `desktop/build-artifacts/windows-x64`。 +- `desktop/scripts/build-macos-arm64.sh` 支持 `MAC_TARGETS` 覆盖 Electron Builder macOS target,默认仍为 `dmg zip`;本机 DiskImages 异常时可用 `MAC_TARGETS=zip` 单独验证 zip/update metadata 链路。脚本会对当前 worktree 的 stale Electron Builder 临时 DMG 挂载 fail fast,成功复制 canonical artifacts 后默认运行 package-smoke。 +- `desktop/scripts/build-windows-x64.ps1` 会把 installer、update metadata、blockmap 和 `win-unpacked` 复制到 canonical `desktop/build-artifacts/windows-x64`;成功复制后默认运行 `bun run test:package-smoke --platform windows --package-kind release --artifacts-dir desktop/build-artifacts/windows-x64`,可用 `SKIP_PACKAGE_SMOKE=1` 跳过静态验包。 +- `desktop/scripts/build-linux.sh` 支持 `LINUX_ARCH=x64|arm64` 和 `LINUX_TARGETS` 覆盖,默认构建 AppImage/deb x64;脚本会复制 `.AppImage`、`.deb`、`latest-linux.yml`、blockmap 和 `linux-unpacked` 到 canonical `desktop/build-artifacts/linux-`,并默认运行 `bun run test:package-smoke --platform linux --package-kind release --artifacts-dir desktop/build-artifacts/linux-`。 +- `node-pty` 原生模块验证:`bunx electron ./tmp-electron-node-pty-smoke.cjs` 在 Electron 42.3.0 runtime 下成功输出 `node-pty spawn type: function`。 +- `electron-builder install-app-deps` 在本机 `@electron/rebuild` node-gyp worker 上空转无输出;由于 `node-pty` Electron runtime smoke 已通过,默认配置设为 `npmRebuild=false`,脚本保留 `REBUILD_NATIVE=1` 作为 runner/调试开关。 +- packaged `.app` 首轮 smoke 暴露两个打包差异:Vite 默认绝对 `/assets/...` 在 `file://` 下加载失败,已通过 `vite.config.ts base: './'`、`publicAssetPath()` 和字体 URL 改造修复;`asar=false` 的全量 unpacked resources 会触发 macOS assessment `Too many open files`,已切换为 `asar=true` + `asarUnpack`。 +- `CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir` 通过并产出 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app`;`codesign --verify --deep --strict --verbose=2` 通过;packaged app 启动后 sidecar 从 `app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-apple-darwin` 运行,renderer 从 `app.asar/dist/index.html` 加载。 +- `bun run check:native` 通过:sidecar build 成功,Electron host 56 tests passed,main/preload/preview-preload 构建成功。 +- 2026-06-01 已把 `check:native` 扩展为 `build:sidecars + check:electron + electron:package:dir + test:package-smoke:current`;最新复跑通过:Electron 77 tests passed,Electron `--dir` 产出 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app`,当前平台 macOS package-smoke PASS。 +- `bun run check:desktop` 通过:desktop `tsc --noEmit`、Vitest 全量 146 test files / 1127 tests passed,production build 成功。 +- 生产 React 直接 Tauri 扫描无输出:`rg -n "@tauri-apps|__TAURI_INTERNALS__|window\\.__TAURI__|from '@tauri" desktop/src --glob '!**/*.test.ts' --glob '!**/*.test.tsx' --glob '!**/lib/desktopHost/**' --glob '!src-tauri/**'`。 + +### 14. 替换 CI workflow + +**修改:** + +- `.github/workflows/build-desktop-dev.yml` +- `.github/workflows/release-desktop.yml` +- `.github/workflows/pr-quality.yml` +- `scripts/quality-gate/modes.ts` +- `scripts/pr/change-policy.ts` +- `scripts/pr/check-pr.ts` + +**步骤:** + +- [x] 移除 `tauri-apps/tauri-action@v0`。 +- [x] 移除 Linux WebKitGTK/Rust/Tauri 专用依赖,保留 Electron/Linux 打包需要的依赖。 +- [x] 配置 macOS arm64/x64、Windows x64、Linux x64/arm64 matrix。 +- [x] 配置 signing/notarization/update metadata secrets。 +- [x] 让 PR/change policy 识别 `desktop/electron/**` 和 Electron build config。 +- [x] 运行 `bun run check:policy`。 + +**通过标准:** CI lane 名称、触发条件和产物路径都更新为 Electron。 +**阻断条件:** PR 改 Electron host 文件但不会触发桌面/原生检查。 + +**执行记录(2026-05-31):** + +- `.github/workflows/build-desktop-dev.yml` 改为 Electron Builder matrix:macOS arm64/x64、Windows x64、Linux x64/arm64;每个 job 安装 Bun/Node、构建 sidecars、构建 renderer/Electron bundles、运行 `electron-builder` 并上传 `desktop/build-artifacts/electron` 产物。 +- `.github/workflows/release-desktop.yml` 移除 `tauri-apps/tauri-action@v0`,改为 `electron-builder --publish never` 生成产物,再用 `softprops/action-gh-release@v2` 上传 release assets。 +- Linux CI 依赖删除 WebKitGTK/Rust/Tauri 专用包,仅保留 Electron packaging 与 AppImage 需要的基础构建工具和 `libfuse2`。 +- PR `desktop-native-checks` 不再安装 Rust toolchain 或 Rust cache;`desktop/scripts/build-sidecars.ts` 改为用 `process.platform` / `process.arch` 推导 host triple,避免 Electron `check:native` 继续依赖 `rustc -vV`。 +- sidecar build target env 已改为 `SIDECAR_TARGET_TRIPLE`;`build-sidecars.ts` 暂时兼容旧 `TAURI_ENV_TARGET_TRIPLE`,但 CI 和平台脚本不再使用 Tauri 命名。 +- `scripts/pr/change-policy.ts` 将 `desktop/electron/tsconfig.json`、Electron macOS/Windows build scripts 纳入 native/release paths;`scripts/quality-gate/modes.ts` 和 `scripts/pr/impact-report.ts` 文案从 Tauri native 改为 Electron host/package。 +- `bun run check:policy` 通过:64 tests passed,quarantine review 0 expired。 + +### 15. 替换 release 脚本 + +**修改:** + +- `scripts/release.ts` +- `scripts/pr/release-workflow.test.ts` + +**步骤:** + +- [x] 版本来源从 `desktop/src-tauri/tauri.conf.json` 改为 Electron package/build config。 +- [x] 删除 `Cargo.toml`/`Cargo.lock` release 更新。 +- [x] 保留 release note 校验、commit、tag 流程。 +- [x] 运行 `bun run scripts/release.ts --dry`。 +- [x] 运行 `bun test scripts/pr/release-workflow.test.ts`。 + +**通过标准:** dry run 显示的版本文件全部属于 Electron 发布链。 +**阻断条件:** release 仍依赖 Tauri 版本文件。 + +**执行记录(2026-05-31):** + +- `scripts/release.ts` 当前版本来源改为 `desktop/package.json`,版本更新文件只保留 `desktop/package.json`。 +- release 脚本不再更新 `desktop/src-tauri/tauri.conf.json`、`Cargo.toml` 或 `Cargo.lock`,也不再运行 `cargo generate-lockfile`。 +- Git commit/tag 流程仍保留 release notes 校验、`git add desktop/package.json release-notes/vX.Y.Z.md`、commit、annotated tag。 +- `bun run scripts/release.ts patch --dry` 通过,显示 `0.3.1 -> 0.3.2`,只会更新 `desktop/package.json` 和 `release-notes/v0.3.2.md`。 +- `bun test scripts/pr/release-workflow.test.ts scripts/pr/change-policy.test.ts` 通过:11 tests passed。 + +--- + +## Phase 5:最终验收 + +### 16. Electron packaged smoke + +**步骤:** + +- [ ] macOS:构建 `.app/.dmg`,启动 app,验证聊天、附件、通知、托盘、更新 mock、终端、预览面板。 +- [ ] Windows:构建 NSIS 安装器,安装后启动 app,验证 sidecar 文件锁和更新前停进程。 +- [ ] Linux:构建 deb/AppImage,验证启动、tray、通知、更新策略。 +- [x] 运行 `bun run test:package-smoke --platform macos` 作为包结构预检;Windows/Linux 待对应平台产物。 + +**通过标准:** 每个平台至少一个真实发布格式完成 smoke。 +**阻断条件:** 只有 dev build 或 Vite 页面通过。 + +**执行记录(2026-05-31):** + +- macOS unpacked `.app` smoke 已完成一部分:`CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir` 成功,Computer Use 启动 packaged app 后主界面正常渲染,server sidecar 正常启动,终端面板可打开并执行 `printf electron-terminal-ok`。 +- Review 后重新运行 `cd desktop && CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir` 成功;`bun run test:package-smoke --platform macos` 通过,确认当前 `.app` 结构、`app.asar`、unpacked sidecar、unpacked `node-pty` native module 和 `spawn-helper` 存在。 +- 因本机已有正式 `/Applications/Claude Code Haha.app` 实例持有单实例锁,Computer Use 最终 smoke 使用同一构建输出重新打包临时 `Claude Code Haha Smoke.app`(仅临时 appId/productName 不同)执行:packaged renderer 从 `app.asar/dist/index.html` 加载,sidecar 自动启动,终端执行 `electron-terminal-ok`,系统目录选择器可打开,preview WebContentsView 可导航 `https://example.com/`,截图可回填 composer。 +- 注意:`test:package-smoke` 是 bundle-structure/static-artifact preflight,不启动 GUI,也不替代 Computer Use 或实机 smoke。 +- 2026-06-01 同步本地 `main` 后重新运行 `cd desktop && CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir` 成功,产出 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app`。 +- 2026-06-01 使用 Computer Use 操作真实 packaged app,并把本机 cc-haha provider/model 配置复制到隔离临时 `CLAUDE_CONFIG_DIR`:模型选择器显示 `gpt-5.5 Sub2API-ChatGPT`,系统目录选择器选中 `/private/tmp/cc-haha-electron-real-fixed-sRFUeO/project`。 +- 真实模型 smoke 会话 `e0d7abc2-27db-4ff1-9954-7923f6c3385e` 通过:packaged renderer 从 `file://.../app.asar/dist/index.html` 加载,server sidecar 建立 WebSocket,CLI subprocess 使用 `--model gpt-5.5` 启动,模型读取 `package.json` 与 `src/greeting.ts` 后经权限审批执行 `bun test`,结果为 `1 pass / 0 fail`,最终回复“测试通过。”。 +- 2026-06-01 重跑 `SKIP_INSTALL=1 SIGN_BUILD=0 desktop/scripts/build-macos-arm64.sh`:修复平台级 publish 后曾产出 `.app/.zip/.dmg/latest-mac.yml`,但 `latest-mac.yml` 与实际含空格 asset 文件名不一致;加入稳定 `artifactName` 后,清理旧 disk image 挂载前本机 DiskImages/hdiutil 在 `.dmg` 创建阶段超时,zip-only Electron Builder 也曾在本机 7za 子进程处失败。 +- 2026-06-01 清理旧 Tauri/Electron disk image 挂载后,`MAC_TARGETS=zip SKIP_INSTALL=1 SIGN_BUILD=0 desktop/scripts/build-macos-arm64.sh` 通过,canonical `desktop/build-artifacts/macos-arm64` 产出 `.app`、`Claude-Code-Haha-0.3.2-arm64.zip`、`.zip.blockmap` 和 `latest-mac.yml`;脚本自动运行 `bun run test:package-smoke --platform macos --artifacts-dir desktop/build-artifacts/macos-arm64` 并 PASS,确认 `latest-mac.yml` 引用真实 zip 且 packaged resources 含 `app-update.yml`。 +- 2026-06-01 `.dmg` target 最新复验通过:后续默认 `SKIP_INSTALL=1 SIGN_BUILD=0 desktop/scripts/build-macos-arm64.sh` 已完成 `dmg zip` target,canonical `desktop/build-artifacts/macos-arm64` 包含 `.app`、`.dmg`、`.zip`、blockmap 和 `latest-mac.yml`,脚本自动 release package-smoke PASS。Gatekeeper/signed/notarized launch 仍保持 release blocker。 +- 2026-06-01 macOS build script hardening:默认 `dmg zip` target 在当前 worktree 检测到 stale Electron Builder `.temp...dmg` 挂载时会提前失败并给出 `MAC_TARGETS=zip` 替代路径,避免继续进入会卡死的 DiskImages detach/create 分支。 +- 2026-06-01 Windows build script hardening:canonical 输出现在保留 `win-unpacked` 供 package-smoke 检查 `app.asar`、`app-update.yml`、unpacked sidecar 和 `node-pty` 原生模块;脚本默认在 Windows runner 上执行 canonical package-smoke,但本机 macOS 未执行 NSIS 真实构建/安装。 +- 2026-06-01 Linux build script hardening:新增 `desktop/scripts/build-linux.sh` 与 `build:linux-x64` / `build:linux-arm64` scripts,canonical 输出现在保留 AppImage/deb、`latest-linux.yml` 和 `linux-unpacked` 供 package-smoke 检查;本机 macOS 不能执行 Linux 真实构建/安装。 +- 2026-06-01 Linux package-smoke 区分开发目录包和发布包:`electron-builder --dir` 只产出 `linux-unpacked` 时仍检查 `app.asar`、sidecar 和 `node-pty` 并通过 `check:native`;AppImage/deb 发布产物存在时继续要求 `latest-linux.yml` 和 `app-update.yml`。 +- 2026-06-01 Linux arm64 updater metadata 修复:`build-linux.sh` 现在复制 `latest-linux*.yml`,避免 arm64 Electron Builder 产出 `latest-linux-arm64.yml` 时 canonical `linux-arm64` 目录丢失 update metadata;`package-smoke` 同步识别 `latest-linux.yml` 和 `latest-linux-arm64.yml`,并检查 metadata 引用的 AppImage/deb artifact 是否真实存在。 +- 2026-06-01 package-smoke 增加显式 `--package-kind dir|release`:`check:native` 使用 `dir + desktop/build-artifacts/electron`,发布/dev packaging workflow 和平台 release 脚本使用 `release + 精确 artifacts-dir`,避免旧 installer/metadata 污染本轮验包。 +- 2026-06-01 Gatekeeper 诊断加固:`--require-macos-gatekeeper` 的 `spctl` 失败时,package-smoke 会同时记录 `codesign --verify --deep --strict --verbose=2`、`codesign -dv --verbose=4` 与 `xcrun stapler validate` 摘要,方便区分未签名、签名链、bundle 格式和 notarization ticket 问题。 +- 2026-06-01 SubAgent Review 修复:release workflow 在打包矩阵前新增 `bun run verify` 非 live 预检;`quality:gate --mode release` 会包含 PR checks,并把 `desktop-package-smoke:` 改为 `--package-kind release --artifacts-dir desktop/build-artifacts/`,避免验到旧的 `--dir` 包。 +- 2026-06-01 多架构 metadata 风险收敛:release workflow 上传前会把 `latest*.yml` 改名为带 matrix label 后缀的唯一 asset,避免 GitHub Release 中同名 metadata 互相覆盖或上传失败;matrix 全部通过后由 `scripts/release-update-metadata.ts` 重新发布标准 updater channel metadata。macOS 会把 x64/arm64 zip entries 合并回 `latest-mac.yml`,Linux 会恢复 `latest-linux.yml` / `latest-linux-arm64.yml`,Windows 会恢复 `latest.yml`。完整自动更新 release 仍需在 signed/notarized artifact 上复验。 +- 2026-06-01 signing/notarization secrets preflight:release workflow 新增非 matrix `signing-preflight` job,在任何平台 artifact 上传前检查 `MACOS_CERTIFICATE`、`MACOS_CERTIFICATE_PASSWORD`、`APPLE_ID`、`APPLE_APP_SPECIFIC_PASSWORD`、`APPLE_TEAM_ID`、`WINDOWS_CERTIFICATE` 和 `WINDOWS_CERTIFICATE_PASSWORD`。缺失时用 GitHub Actions error 早停,避免部分 matrix leg 先发布 partial release。真实 Developer ID signing/notarization 仍需在 CI secrets 存在时复验。 +- 2026-06-01 native/package 当前态复验:`bun run check:native` 通过,完成 sidecar 构建和 ad-hoc signing、`check:electron` 83 tests、desktop production build、`electron-builder --dir`,并运行 `bun run test:package-smoke --platform macos --package-kind dir --artifacts-dir desktop/build-artifacts/electron` PASS。该证据只覆盖 directory-only unpacked app bundle 结构、`app.asar`、sidecar、`node-pty` 和 `spawn-helper`,不替代 `.dmg`、Gatekeeper 或 signed release smoke。 +- 2026-06-01 DMG 当前态复验:默认 `SKIP_INSTALL=1 SIGN_BUILD=0 desktop/scripts/build-macos-arm64.sh` 已通过,canonical `desktop/build-artifacts/macos-arm64` 恢复 `.app`、`.dmg`、`.dmg.blockmap`、`.zip`、`.zip.blockmap`、`latest-mac.yml`,并由 release package-smoke PASS。 +- 2026-06-01 跨平台 package-smoke 当前态复验:`bun test scripts/quality-gate/package-smoke/index.test.ts scripts/pr/release-workflow.test.ts scripts/release-update-metadata.test.ts` 通过 26 tests,覆盖 Windows canonical output、Linux x64/arm64 update metadata、release workflow Gatekeeper/signing preflight 和 metadata republish;`bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/macos-arm64` PASS,确认当前 macOS zip/update canonical artifact set 仍完整。 +- 本轮尚未完成 `.dmg` 安装包、通知点击、signed/notarized Gatekeeper launch、Windows NSIS 和 Linux AppImage/deb 的真实发布格式 smoke,因此本阶段保持未完成。附件选择、目录选择、外链、终端和 preview 已有 dev 或 unpacked packaged Computer Use 证据,但还不能替代最终 signed release artifact 的全量验收。 + +### 17. Release gate + +**步骤:** + +- [x] 运行 `bun run verify`。 +- [x] 有 live provider 时运行 `bun run quality:gate --mode release --allow-live --provider-model `。 +- [x] 检查 quality report 的 pass/fail/skip。 +- [x] 记录 coverage report 路径。 + +**通过标准:** PR/release 门禁通过,coverage 未退化。 +**阻断条件:** 任一 release 必需 lane 失败。 + +**执行记录(2026-06-01):** + +- `bun run verify` 通过:最新为 `artifacts/quality-runs/2026-05-31T21-42-57-279Z/report.md`,`passed=9 failed=0 skipped=1`。 +- 首次重跑完整 `release --allow-live` 后,旧 agent-browser/Vite blocker 已消失;唯一失败为 coverage 中 `h5-access-auth.test.ts` 随机端口撞到本机 WeChat 的 `127.0.0.1:24023`。 +- 已把 `h5-access-auth.test.ts` 端口选择改成 OS 分配 ephemeral port;focused `bun test src/server/__tests__/h5-access-auth.test.ts` 通过,`bun run check:coverage` 最新通过:`artifacts/coverage/2026-05-31T21-46-15-873Z/coverage-report.md`,`passed=5 failed=0`。 +- 历史 `bun run quality:gate --mode release --allow-live --provider-model sub2api-chatgpt:main:sub2api-chatgpt-main` 曾通过:`artifacts/quality-runs/2026-05-31T20-29-45-758Z/report.md`,`passed=15 failed=0 skipped=0`。SubAgent Review 后 release gate 已收窄为必须检查 canonical release artifact,当前仍需在 signed/notarized artifact 与可用 Computer Use 桌面会话上重新跑完整 release gate。 + +### 18. Computer Use 全面测试 + +**步骤:** + +- [x] 用真实打包 app 运行 Computer Use 设置流程。 +- [x] 验证 Python 路径选择、权限授权、屏幕录制/辅助功能授权提示、授权应用列表。 +- [x] 验证点击、输入、滚动、系统快捷键、剪贴板。 +- [ ] 验证通知点击回到目标会话。 +- [ ] macOS 和 Windows 至少各完成一次;Linux 如能力降级,写入 release note 和已知限制。 + +**通过标准:** Computer Use 在真实 Electron app 中完成端到端操作。 +**阻断条件:** 只用浏览器/Vite 或 mock 路径证明。 + +**验证清单:** 迁移收口使用 `docs/desktop/09-electron-migration-validation-checklist.md`,其中区分自动化证据、macOS Computer Use smoke、Windows 待实机和 Linux 待实机。 + +**执行记录(2026-06-01):** + +- 尝试继续 Computer Use GUI 验收时,Computer Use 工具层对 `list_apps` 和 `get_app_state(Finder)` 均返回 `NSOSStatusErrorDomain Code=-600 procNotFound`。该结果说明当前 macOS 自动化桥未拿到可用进程,不是 Electron app 单点问题;剩余附件、Python 路径、外链、Dock/tray、通知点击和完整 update feed 验收保持未完成。 +- 2026-06-01 后续复测 Computer Use 仍返回同一 `procNotFound`,这是连续第二个 goal turn 复现;目标仍可在代码和发布门禁层继续推进,因此暂不标记 goal blocked。 +- 2026-06-01 再次复测后 Computer Use `list_apps` 已恢复,但 `get_app_state(Electron)` 对 Electron dev 壳超时,`get_app_state(Finder)` 为 `cgWindowNotFound`;系统 `screencapture` 同时返回 `could not create image from display`。这说明当前桌面会话没有可用截图/窗口捕获能力,Computer Use 交互验收仍不能推进。 +- 2026-06-01 continuation 复测:Computer Use `list_apps` 仍可列应用,`get_app_state(Finder)` 仍为 `cgWindowNotFound`,`screencapture` 仍失败为 `could not create image from display`;当前仍不能完成附件、Python 路径、外链、Dock/tray、通知点击等 GUI 验收。 +- 2026-06-01 packaged app 启动被 macOS `AppleSystemPolicy` 拦截:`spctl -a -t execute desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app` 报 `bundle format unrecognized, invalid, or unsuitable`,系统日志显示 ad-hoc/unknown-chain app 被终止。本轮已新增 `bun run test:package-smoke --platform macos --require-macos-gatekeeper` 作为可选 release-readiness 检查;当前该检查失败,release-ready 需要 Developer ID signing/notarization 或真实签名产物复验。 +- 2026-06-01 release workflow 已补强:macOS release artifact 上传前强制运行 `bun run test:package-smoke --platform macos --require-macos-gatekeeper`,防止未通过 macOS 启动策略的 `.app/.dmg/.zip` 被发布;dev build 仍保留结构型 package-smoke。 +- 2026-06-01 修复 Electron dev launcher 代理问题:`electron:dev` 默认 renderer URL 改为 `http://localhost:1420`,并对当前进程、Vite 与 Electron 子进程补齐 `NO_PROXY/no_proxy=localhost,127.0.0.1,::1`,避免本机代理把 localhost renderer 等待请求打成 502。 +- 2026-06-01 主窗口显示逻辑加固:创建 `BrowserWindow` 后立即 `show/focus`,renderer load 完成后再次 `show/focus`,避免隐藏窗口在 load 等待期间被托盘/单实例/Computer Use 路径视为无窗口。 +- 2026-06-01 SubAgent gap follow-up:renderer 生产代码不再用 `isTauriRuntime()` 判断通用桌面能力,新增 `isDesktopRuntime()`,更新弹窗、附件 native picker、设置 app mode、移动布局分支均识别 Electron/Tauri desktop host。 +- 2026-06-01 Electron updater proxy contract 已补齐:renderer 传入的 `{ proxy }` 会进入 Electron main,检查更新前应用到 Electron `app/session` proxy;切回系统代理时会清理手动代理,避免更新检查继续沿用旧 proxy。 +- 2026-06-01 package-smoke 增加发布型包的 installed updater metadata 检查:当 artifact set 含 `.zip/.dmg/latest-mac.yml`、Windows installer/latest.yml 或 Linux package/latest-linux.yml 时,安装后 resources 目录必须包含 `app-update.yml`;纯 `--dir` 开发包会记录 note 但不失败。 +- 2026-06-01 真实 provider smoke 使用本机 `~/.claude/cc-haha/providers.json` selector `sub2api-chatgpt:main:sub2api-chatgpt-main` 通过:`artifacts/quality-runs/2026-05-31T21-28-07-154Z/report.md`,`passed=1 failed=0 skipped=0`。该路径复用 cc-haha provider 配置,不走 `QUALITY_GATE_PROVIDER_*` 明文 env-only 分支。 +- 2026-06-01 `check:native` 已补上打包后验包:根脚本现在会在 Electron `--dir` 打包后运行 `test:package-smoke:current`。最新 `bun run check:native` 通过,package-smoke notes 明确当前纯 `--dir` macOS artifact set 不强制 `app-update.yml`,也不代表 GUI 启动或 Gatekeeper approval。 +- 2026-06-01 final-definition 复测:Computer Use `list_apps` 正常,但 `get_app_state(Finder)` 仍返回 `cgWindowNotFound`;系统 `screencapture -x /tmp/cc-haha-cua-retake-final.png` 仍失败为 `could not create image from display`。当前机器继续不能承载真实 GUI 操作验收。 +- 2026-06-01 cross-platform-script 复测:Computer Use `list_apps` 正常且可见 `/Applications/Claude Code Haha.app`,`get_app_state(Finder)` 仍为 `cgWindowNotFound`,`get_app_state(Claude Code Haha)` 返回 `remoteConnection`,系统 `screencapture -x /tmp/cc-haha-computer-use-check.png` 仍失败为 `could not create image from display`。当前仍不能完成点击、输入、附件选择、托盘恢复、通知点击等 GUI 验收。 +- 2026-06-01 package-kind 复测:Computer Use `list_apps` 正常且显示 `/Applications/Claude Code Haha.app` 正在运行,`get_app_state(Claude Code Haha)` 和 `get_app_state(Finder)` 均返回 `cgWindowNotFound`,系统 `screencapture -x /tmp/cc-haha-computer-use-check-2.png` 仍失败为 `could not create image from display`。当前机器仍不能承载真实 GUI 操作验收。 +- 2026-06-01 Gatekeeper 诊断加固:release-readiness package-smoke 在 `spctl` 失败时会补充 `codesign` verify/details 摘要,后续 signed/notarized artifact 的失败可以直接从 quality log 判断是签名链、bundle 格式还是 notarization policy 问题。 +- 2026-06-01 Gatekeeper 诊断复验:当前 ad-hoc `.app` 仍被 `spctl` 拒绝为 `bundle format unrecognized, invalid, or unsuitable`,但 package-smoke 已输出 `codesign verification exited with status 0` 与 bundle identifier/signature detail 摘要,证明诊断链路可用;release blocker 仍是 Developer ID signing/notarization 或真实签名产物复验。 +- 2026-06-01 Gatekeeper 诊断后 Computer Use 复测:`list_apps` 可见运行中的 `/Applications/Claude Code Haha.app`,但 `get_app_state(Claude Code Haha)` 和 `get_app_state(Finder)` 均仍为 `cgWindowNotFound`,系统 `screencapture -x /tmp/cc-haha-computer-use-check-3.png` 仍失败为 `could not create image from display`。当前机器继续无法执行点击、输入、文件选择器、托盘/通知等 GUI 验收。 +- 2026-06-01 Security/Runtime Review 修复:Electron 移除了 renderer 可调用的 `previewEval` 任意 JS 注入通道,预览截图/元素选择改走结构化 `preview.message`;`shell.openPath` 改为只允许已存在的普通文件/目录,并拒绝 `.app`、脚本/安装器/Windows 可执行扩展和 POSIX executable 文件;Electron 暂不声明自定义 window controls,避免 Windows 原生标题栏与自定义标题栏双渲染。 +- 2026-06-01 release metadata 合并后 Computer Use 复测:`list_apps` 可见运行中的 `/Applications/Claude Code Haha.app`,但 `get_app_state(Claude Code Haha)` 和 `get_app_state(Finder)` 均仍为 `cgWindowNotFound`,系统 `screencapture -x /tmp/cc-haha-computer-use-check-4.png` 仍失败为 `could not create image from display`。当前机器继续无法执行点击、输入、文件选择器、托盘/通知等 GUI 验收。 +- 2026-06-01 mock update feed UI 流程补强:`UpdateChecker` 集成测试通过 Electron `desktopHost` mock update feed 驱动真实 `updateStore`,覆盖 check/download/install/relaunch 状态流、下载完成弹窗、点击安装后 prompt 退出。signed/release feed 仍需真实 signed/notarized artifact 复验。 +- 2026-06-01 mock update feed UI 补强后 Computer Use 复测:`list_apps` 可见运行中的 `/Applications/Claude Code Haha.app`,但 `get_app_state(Claude Code Haha)` 和 `get_app_state(Finder)` 均仍为 `cgWindowNotFound`,系统 `screencapture -x /tmp/cc-haha-computer-use-check-5.png` 仍失败为 `could not create image from display`。当前机器继续无法执行点击、输入、文件选择器、托盘/通知等 GUI 验收。 +- 2026-06-01 macOS Keychain 弹窗修复:用户截图显示 Electron/Chromium 反复请求 `claude-code-desktop Safe Storage`。原因是 Chromium profile safe storage 会访问 macOS Keychain;本项目 OAuth token 已走 sidecar file-backed storage,不依赖 Chromium cookie/password store。Electron main 现在在启动早期对 macOS 设置 `use-mock-keychain`,并把 `ModelSelector` 的官方 OAuth status 从挂载即请求改为 runtime 下拉打开时按需请求一次,避免启动/重渲染时反复触发敏感存储路径。验证:`cd desktop && bun run test -- --run src/components/controls/ModelSelector.test.tsx electron/services/keychain.test.ts` 12 tests passed;`cd desktop && bun run check:electron` 80 tests passed 并完成 main/preload bundle。 +- 2026-06-01 Keychain 修复后 Computer Use 复测:`bun run electron:dev` 启动当前 Electron dev 壳,`get_app_state(Electron)` 成功返回 `localhost:1420/` 主界面和模型选择器 `gpt-5.5 Sub2API-ChatGPT`;截图/AX 树中未再出现 `claude-code-desktop Safe Storage` 钥匙串授权弹窗。随后已终止 dev Electron/Vite 进程。 +- 2026-06-01 Keychain 当前态复验:`cd desktop && bun run test -- --run electron/services/keychain.test.ts src/components/controls/ModelSelector.test.tsx` 通过 12 tests;`cd desktop && bun run check:electron` 通过 83 tests 并完成 Electron main/preload/preview-preload bundle。Computer Use `get_app_state(Electron)` 返回 `Claude Code Companion` 主窗口和 `localhost:1420/` renderer,未出现 Safe Storage 钥匙串授权弹窗。 +- 2026-06-01 原生附件文件选择补强:Electron dialog IPC 改为使用当前 `BrowserWindow` 作为 parent window,composer 打开原生选择器前先关闭 plus 菜单,避免无 owner sheet 返回后菜单状态残留。验证:`bun test electron/services/dialogs.test.ts` 3 tests passed;`bun run test -- --run src/lib/composerAttachments.test.ts src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx` 33 tests passed;`bun run check:electron` 80 tests passed;Computer Use 在 Electron dev 壳中成功选择 `/Users/nanmi/cc-haha-cua-attachment-smoke.txt` 并显示附件 chip,未出现 Safe Storage 弹窗。 +- 2026-06-01 packaged updater 噪音修复:Electron updater service 在 packaged `app-update.yml` 缺失时直接返回“无更新”,并将 electron-updater 内置 logger 设为 `null`,避免目录包或 GitHub release 暂缺 `latest-mac.yml` 时在启动日志里打印 404 stack;非 metadata 错误仍会抛出。验证:`cd desktop && bun test electron/services/updater.test.ts electron/services/singleInstance.test.ts` 13 tests passed;`cd desktop && bun run check:electron` 89 tests passed。 +- 2026-06-01 single-instance 验证绕过:新增显式 `CC_HAHA_ELECTRON_DISABLE_SINGLE_INSTANCE_LOCK=1` 供本地验证启动同 bundle 的 worktree/canonical app,不影响默认生产单实例行为,避免需要杀掉用户正在使用的 `/Applications/Claude Code Haha.app`。 +- 2026-06-01 canonical macOS release Computer Use 复测:`SKIP_INSTALL=1 SIGN_BUILD=0 desktop/scripts/build-macos-arm64.sh` 通过默认 `dmg zip`,release package-smoke PASS;用隔离配置和 `CC_HAHA_ELECTRON_DISABLE_SINGLE_INSTANCE_LOCK=1` 启动 `desktop/build-artifacts/macos-arm64/Claude Code Haha.app`,server sidecar 启动,Computer Use `get_app_state` 成功读取 `Claude Code Companion` 主窗口和 packaged renderer。启动日志没有 `app-update.yml` 或 `latest-mac.yml` updater stack。 +- 2026-06-01 canonical packaged 系统交互复测:在同一个 `desktop/build-artifacts/macos-arm64/Claude Code Haha.app` 隔离实例中,Computer Use 确认项目选择器已选中系统原生目录选择结果 `/private/tmp/cc-haha-electron-real-fixed-sRFUeO/project`;点击 `打开终端` 后终端面板显示 `运行中 /bin/zsh / /tmp/cc-haha-canonical-cua-flow.xTxSmt/claude-config`,输入 `printf 'canonical-terminal-ok\n'; pwd` 后画面可见 `canonical-terminal-ok` 和实际工作目录输出。该轮未出现 `claude-code-desktop Safe Storage` 钥匙串弹窗。 +- 2026-06-01 canonical packaged Computer Use 设置流程复测:用隔离 `CLAUDE_CONFIG_DIR` 启动 `desktop/build-artifacts/macos-arm64/Claude Code Haha.app`,Computer Use 打开设置页后进入 `Computer Use` tab。首次状态请求曾因 30s timeout 显示 `Failed to check status`,点击 `重试` 后页面正常显示 Python 3.14.5、venv 未创建、依赖未安装。通过页面 `安装环境` 按钮完成真实 server/setup 链路,随后 UI 显示 venv 已就绪、依赖包已安装、辅助功能权限已授权、屏幕录制权限未授权;`curl /api/computer-use/status` 返回 `venv.created=true`、`dependencies.installed=true`、`permissions.accessibility=true`、`permissions.screenRecording=false`。点击 `打开屏幕录制设置` 后 Computer Use 成功读取 macOS 系统设置窗口 `录屏与系统录音`,并看到 `Claude Code Haha` 权限项。 +- 2026-06-01 canonical packaged Computer Use 授权应用复测:复用隔离 packaged app,Computer Use 在设置页搜索 `Terminal`,应用列表返回 `Terminal / com.apple.Terminal`;点击该项后 UI 显示 check 状态,隔离配置 `cc-haha/computer-use-config.json` 与 `GET /api/computer-use/authorized-apps` 均返回 `authorizedApps[0].bundleId=com.apple.Terminal`,`grantFlags.clipboardRead=true`、`grantFlags.clipboardWrite=true`、`grantFlags.systemKeyCombos=true`。该轮覆盖了真实 UI 点击、输入、滚动、应用枚举和授权配置持久化。 +- 2026-06-01 notarization 诊断复验:`bun test scripts/quality-gate/package-smoke/index.test.ts` 15 tests passed;`bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/macos-arm64 --require-macos-gatekeeper` 预期失败,但输出现在同时包含 `spctl`、`codesign` 和 `notarization ticket validation` 诊断,当前 ad-hoc app 被分类为没有 stapled notarization ticket。 +- 2026-06-01 Gatekeeper 当前态复跑:`bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/macos-arm64` 通过,确认 release artifact 结构、`latest-mac.yml` 引用 zip、`app-update.yml`、sidecar 和 `node-pty` 完整;追加 `--require-macos-gatekeeper` 后仍失败,`codesign` verify/details 通过但 `spctl` 返回 `bundle format unrecognized, invalid, or unsuitable`,`stapler` 返回 `does not have a ticket stapled to it`。package-smoke 现已在 `spctl` 报 `Too many open files` 时用 raised file descriptor limit 自动重试,避免该临时诊断掩盖真实 Gatekeeper 结果。release-ready 仍需要 Developer ID signing/notarization 或真实签名产物复验。 +- 2026-06-01 Gatekeeper 诊断补强后 Computer Use 复测:用隔离 `CLAUDE_CONFIG_DIR` 和 `--user-data-dir` 启动 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app`,Computer Use 成功读取 `Claude Code Companion` 主窗口和 packaged `file://.../app.asar/dist/index.html` renderer,证明 package-smoke 诊断补强未破坏 packaged app 运行路径。 +- 2026-06-01 release gate 口径收紧:`quality:gate --mode release` 的当前平台 `desktop-package-smoke:macos` lane 现在会追加 `--require-macos-gatekeeper`,与 GitHub release workflow 上传前 Gatekeeper 检查保持一致。验证:`bun test scripts/quality-gate/runner.test.ts scripts/quality-gate/package-smoke/index.test.ts scripts/pr/release-workflow.test.ts` 通过 39 tests;`bun run quality:gate --mode release --dry-run --only 'desktop-package-smoke:*'` 报告命令包含 `--require-macos-gatekeeper`;实际运行 `bun run quality:gate --mode release --only 'desktop-package-smoke:*'` 失败于 Gatekeeper,报告为 `artifacts/quality-runs/2026-06-01T12-16-06-442Z/report.md`。 +- 2026-06-01 当前 release provider smoke 复验:`bun run quality:gate --mode release --allow-live --only 'provider-smoke:*' --provider-model sub2api-chatgpt:main:sub2api-chatgpt-main` 通过,报告为 `artifacts/quality-runs/2026-06-01T12-18-47-144Z/report.md`;随后用 Computer Use 启动隔离 packaged app,成功读取 `Claude Code Companion` 主窗口和 packaged `file://.../app.asar/dist/index.html` renderer。 +- 2026-06-01 governance gate 复验:`bun run check:policy` 通过 77 tests,并执行 `check:quarantine`,覆盖 release workflow、quality gate runner、provider/desktop smoke、change policy、quality contract 和 quarantine governance。随后用 Computer Use 再次启动隔离 packaged app;首次读取遇到 ScreenCaptureKit stream error,但 System Events 显示同一 PID 有 `Claude Code Companion` 窗口,重试后 Computer Use 成功读取 packaged `file://.../app.asar/dist/index.html` renderer。 +- 2026-06-01 本地 `main` 同步后复验:已把本地 `main` 最近 10 个提交的非重叠改动应用到当前迁移工作区,并手动合并 `ChatInput.tsx` / `previewEvents.ts` / `previewEvents.test.ts` 中的 append prefill 与 Electron `desktopHost.preview` 改造。验证:`cd desktop && bun run test -- --run src/components/chat/ChatInput.test.tsx src/lib/previewEvents.test.ts` 通过 24 tests;`bun test src/cli/__tests__/structuredIO.test.ts src/services/api/withRetry.test.ts src/utils/__tests__/imageResizer.test.ts src/utils/shell/powershellDetection.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/conversation-service.test.ts src/server/__tests__/conversation-attachments.test.ts src/server/__tests__/websocket-handler.test.ts` 通过 124 tests;`cd adapters && bun test feishu/__tests__/streaming-card.test.ts` 通过 36 tests;`cd desktop && bun run check:electron` 通过 94 tests;`CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir` 重新生成 packaged dir;`bun run test:package-smoke --platform macos --package-kind dir --artifacts-dir desktop/build-artifacts/electron` 通过;Computer Use 成功读取新打包的 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app` 主窗口和 packaged renderer。 +- 2026-06-01 release update blockmap 验证补强:`package-smoke` 的 release 模式现在强制检查 macOS `.zip/.dmg.blockmap`、Windows `.exe.blockmap` 和 Linux `.AppImage.blockmap`,避免只有 channel metadata 和安装包但缺少 electron-updater 差分更新文件。验证:`bun test scripts/quality-gate/package-smoke/index.test.ts` 通过 17 tests;`bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/macos-arm64` 通过,并确认当前 macOS release artifact set 含 `.dmg.blockmap` 和 `.zip.blockmap`。 +- 2026-06-01 packaged update smoke 补强:新增 `CC_HAHA_ELECTRON_UPDATE_SMOKE_VERSION` 驱动的 Electron main updater stub,仅在显式 smoke env 下启用;`app-update.yml` 缺失不会阻断该 smoke stub,生产 `electron-updater` 仍保持缺失 metadata 时 no-op。验证:`cd desktop && bun run check:electron` 通过 94 tests;`CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir` 生成新的 packaged dir;复制为唯一 bundle id 的 smoke app 后,packaged renderer `file://.../app.asar/dist/index.html` 通过 preload 调用 `desktopHost.updates.check/download/prepareInstall/install/relaunch`,JSONL 记录 `check`、`download-start`、`download-finish`、`quit-and-install`。同轮 Computer Use `list_apps` 可见唯一 smoke app,但 `get_app_state` 对该 app 仍超时,故真实“点击安装”仍不能声明通过。 +- 2026-06-01 packaged window smoke 诊断:新增 `CC_HAHA_ELECTRON_WINDOW_SMOKE_LOG`,main process 在 `after-create`、`after-initial-show`、`did-finish-load` 和 `after-final-show` 写入窗口快照。早前同一 packaged dir 启动日志显示 Electron `BrowserWindow` 已 `visible:true`,bounds 为 `1280x820`,URL 为 packaged `file://.../app.asar/dist/index.html`,但 System Events 曾读取到 `windows count=0` 且 Computer Use `get_app_state` 超时;随后用完整 `.app` 路径复测,Computer Use 成功读取 `Claude Code Companion` 主窗口和 packaged renderer,System Events 返回 `front=true count=1 names=Claude Code Companion`。当前结论是之前的卡点在 macOS AX/窗口捕获状态波动,不是 renderer、server、更新 preload smoke 或关闭智能体流程。 +- 2026-06-01 packaged notification click 复测:直接执行 app binary 时 smoke env 未进入最终 Electron app 进程,改用 `launchctl setenv` + `open -n ... --args --user-data-dir=` 后,packaged app 的 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_LOG` 正常写入 `scheduled` 和 `sent:true`。Computer Use 对 `/System/Library/CoreServices/NotificationCenter.app` 和 `SystemUIServer` 仍返回 `timeoutReached`,Finder 状态读取返回 ScreenCaptureKit stream error,JSONL 未出现 `action`;因此“点击通知回到目标 session”仍保持未完成。 +- 2026-06-01 通知点击再次复测:用 `launchctl setenv` 启动 worktree packaged app 后,JSONL 正常写入 `scheduled` 和 `sent:true`,但 Computer Use 对该 worktree app 连续返回 ScreenCaptureKit stream error;`list_apps` 能看到 worktree app running。随后复制当前 packaged app 为唯一 bundle id smoke app 并 ad-hoc 重签名,LaunchServices 能注册该 smoke app,但应用立即退出,系统日志显示 appDeath;因此本轮仍不能把真实 OS 通知点击回跳勾选为完成。 +- 2026-06-01 当前全量桌面/服务端门禁复验:`cd desktop && bun run check:desktop` 通过 desktop lint、151 个 Vitest 文件、1199 个测试和 production build;`bun run check:server` 通过 88 files / 969 tests。通知真实点击、signed/notarized Gatekeeper、Windows/Linux 实机 release smoke 仍保持未完成项。 +- 2026-06-01 通知点击 renderer ack 补强:新增 Electron `desktop:notification:action-ack` IPC,renderer 处理通知 action 并打开目标 session 后,会把 `{ target, payload }` 写入 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_LOG` 的 `renderer_ack` 事件。该补强不模拟 OS 点击,只让后续可点击通知的 macOS/Windows runner 能证明 main action 已进入 UI 导航链路。验证:focused notification/desktopHost tests 42 passed;`cd desktop && bun run check:electron` 通过 97 tests;`CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir` 重新生成 packaged dir;`bun run test:package-smoke --platform macos --package-kind dir --artifacts-dir desktop/build-artifacts/electron` PASS。 +- 2026-06-01 renderer ack 补强后 Computer Use 复测:用隔离 `CLAUDE_CONFIG_DIR`、隔离 `--user-data-dir` 和 notification smoke env 启动当前 worktree packaged app 时,Computer Use 首次 `get_app_state` 返回 `timeoutReached`,直接执行也曾退出 137。随后用隔离 `CLAUDE_CONFIG_DIR` / `--user-data-dir` 重新直接运行同一 `.app` 后保持运行,Computer Use 成功读取 `Claude Code Companion` 主窗口,renderer URL 为当前 worktree 的 packaged `file://.../app.asar/dist/index.html`;点击设置入口后进入 packaged 设置页。该轮确认当前 Electron build 可被 Computer Use 读取和交互;`spctl -a -t execute desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app` 仍返回 `bundle format unrecognized, invalid, or unsuitable`,`codesign --verify --deep --strict` 通过,因此真实 OS 通知点击和 signed/notarized launch 仍未完成。 +- 2026-06-01 packaged synthetic notification action 复测:新增显式 smoke-only 开关 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_TRIGGER_ACTION=1`,只在测试环境中由 main process 对刚发送的通知 action 触发同一条 renderer ack 链路。用 `launchctl setenv` + `open -n desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app --args --user-data-dir=` 启动当前 worktree packaged app 后,Computer Use 成功读取 `Claude Code Companion`,renderer URL 为 `file:///Users/nanmi/.codex/worktrees/2392/claude-code-haha/desktop/build-artifacts/electron/mac-arm64/Claude%20Code%20Haha.app/Contents/Resources/app.asar/dist/index.html`;JSONL 记录 `scheduled`、`sent:true`、`synthetic_action` 和 `renderer_ack`。该 synthetic action 仅证明 packaged main -> renderer action -> ack 链路,不替代真实 OS 通知点击;真实“点击通知后回到目标 session”仍未完成。 + +--- + +## Review 收口记录 + +### 2026-05-31 SubAgent Review + +- [x] Security Review:收紧 packaged renderer entry,packaged app 不再接受 `ELECTRON_RENDERER_URL`;dev 模式只允许 local HTTP renderer。 +- [x] Security Review:preview bridge 增加大小上限、事件 allowlist、字段 schema、`data:image/*` 校验,外部页面不能向 host 注入无限大或未知事件。 +- [x] Security Review:packaged macOS `node-pty` runtime cache 增加 manifest 校验、tamper 后重建、cache/helper 权限收紧。 +- [x] Code Review:macOS “打开通知设置”改走 native command 和 allowlisted system settings URL。 +- [x] Code Review:Electron `preview.message()` 改为转发到 injected preview bridge,不再 silent no-op。 +- [x] Code Review:dev/release desktop workflows 在 Electron builder 后运行 `test:package-smoke`,避免上传缺少 sidecar 或 `node-pty` 关键资源的产物。 +- [x] Verification Review:新增 `docs/desktop/09-electron-migration-validation-checklist.md`,明确 `package-smoke` 只是结构预检,最终完成仍需要真实 packaged app + Computer Use/实机 smoke。 +- [x] Release gate follow-up:`desktop-smoke:agent-browser-chat:*` 保留为 baseline/browser confidence lane,不再作为 release 必需 lane;release mode 新增 `desktop-package-smoke:`,用当前平台 Electron packaged artifact 结构检查配合 Computer Use 真实 app 验收,避免用浏览器/Vite open 结果代表桌面构建可用性。 + +--- + +## 最终完成定义 + +- [x] `desktop/src` 生产代码不再直接依赖 `@tauri-apps/*`。 +- [x] Electron main/preload IPC 有 capability registry 和 payload validation。 +- [x] 本地 Bun server + REST/WebSocket contract 保留,renderer 没有把 session/chat/workspace/team 主链改成 IPC。 +- [x] 自动更新、通知、文件选择、外链打开、窗口/托盘/菜单、sidecar、terminal、preview、app mode、zoom 全部有测试或 smoke 证据。 +- [ ] macOS、Windows、Linux 发布产物可构建并启动。 +- [x] `bun run verify` 通过。 +- [x] release gate 通过,或 live provider blocker 被明确记录。 +- [ ] Computer Use 对真实打包 app 完成全面测试。 + +**最终定义复核(2026-06-01):** + +- 生产 Tauri 边界扫描通过:`rg -n "@tauri-apps|__TAURI_INTERNALS__|window\\.__TAURI__|from ['\\\"]@tauri" desktop/src --glob '!**/*.test.ts' --glob '!**/*.test.tsx' --glob '!**/lib/desktopHost/**' --glob '!src-tauri/**'` 无输出。 +- Electron IPC 复核通过:`desktop/electron/ipc/capabilities.ts` 为每个 `ELECTRON_IPC_CHANNELS` channel 定义 validator,`desktop/electron/main.ts` 的 `registerHandler()` 在进入 handler 前执行 `isElectronIpcChannel()` 和 `validateElectronIpcPayload()`;renderer `electronHost` 也在 preload bridge 调用前执行相同 payload validation。 +- REST/WebSocket 主干复核通过:session/workspace/team 仍在 `desktop/src/api/sessions.ts`、`desktop/src/api/teams.ts`、`desktop/src/api/websocket.ts` 调用 `/api/**` 和 `/ws/:sessionId`;Electron IPC 命中仅覆盖 host/system 能力、terminal 和 preview,不承载 chat/session/workspace/team 主业务。 +- 系统能力证据复核通过:Electron service/API tests 覆盖 updater、notifications、dialogs、shell、windows/tray/menu/single-instance、sidecar、terminal、preview、app mode、zoom;macOS `.dmg/.zip/latest-mac.yml` canonical release artifact set 已通过 `package-smoke`,canonical `.app` 已通过 Computer Use 窗口可见性 smoke;完整完成仍受 signed/notarized Gatekeeper、通知真实点击、Windows/Linux 实机发布格式 smoke 阻塞。 diff --git a/docs/desktop/09-electron-migration-validation-checklist.md b/docs/desktop/09-electron-migration-validation-checklist.md new file mode 100644 index 00000000..d81df535 --- /dev/null +++ b/docs/desktop/09-electron-migration-validation-checklist.md @@ -0,0 +1,172 @@ +# Electron 迁移验证清单 + +> 用于迁移收口,不替代 `docs/desktop/08-electron-migration-tasks.md`。`package-smoke` 只验证包结构和关键资源,不声明 GUI 启动或交互成功。 + +交互式执行版见 [`electron-migration-qa-checklist.html`](/desktop/electron-migration-qa-checklist.html),可在本地浏览器直接打开并用 `localStorage` 保存勾选进度与问题记录。 + +## 已有自动化证据 + +- [x] `bun run check:native`:sidecar build、Electron TypeScript、83 个 Electron IPC/service 测试、main/preload/preview-preload bundle、Electron `--dir` 打包和当前平台 package-smoke 均通过。 +- [x] `bun run check:desktop`:desktop lint、151 个 Vitest 文件、1199 个测试、renderer production build 均通过。 +- [x] `bun test scripts/quality-gate/package-smoke/index.test.ts scripts/pr/release-workflow.test.ts`:package-smoke harness 与 release workflow 静态回归通过。 +- [x] `bun test scripts/quality-gate/package-smoke/index.test.ts scripts/pr/release-workflow.test.ts scripts/quality-gate/runner.test.ts`:25 tests passed;覆盖 release lane、显式 GitHub publish 配置和 `latest-mac.yml` 引用真实 artifact 的 package-smoke 回归。 +- [x] `bun test scripts/quality-gate/package-smoke/index.test.ts scripts/pr/release-workflow.test.ts scripts/release-update-metadata.test.ts`:26 tests passed;覆盖 Windows canonical output、Linux x64/arm64 `latest-linux*.yml` update metadata、release workflow Gatekeeper/signing preflight 和 post-matrix metadata republish。 +- [x] `cd desktop && CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir`:macOS unpacked `.app` 可打包。 +- [x] `bun run test:package-smoke --platform macos`:macOS `.app` 结构、`app.asar`、unpacked sidecar、unpacked `node-pty` native module 和 `spawn-helper` 通过静态检查。 +- [x] macOS `.zip/latest-mac.yml` 发布产物复验:`MAC_TARGETS=zip SKIP_INSTALL=1 SIGN_BUILD=0 desktop/scripts/build-macos-arm64.sh` 通过,canonical 输出包含 `.app`、`.zip`、`.zip.blockmap` 和 `latest-mac.yml`,脚本末尾自动运行 canonical package-smoke 并 PASS。 +- [x] macOS directory-only native/package 当前态:`bun run check:native` 通过,覆盖 sidecar 构建/ad-hoc signing、Electron 83 tests、production build、`electron-builder --dir` 和 `package-smoke --package-kind dir --artifacts-dir desktop/build-artifacts/electron`。该项只证明 unpacked bundle 结构和关键资源,不证明 `.dmg`、Gatekeeper 或 signed release launch。 +- [x] macOS `.dmg/.zip/latest-mac.yml` 当前态复验:`SKIP_INSTALL=1 SIGN_BUILD=0 desktop/scripts/build-macos-arm64.sh` 已通过默认 `dmg zip` target;canonical `desktop/build-artifacts/macos-arm64` 包含 `.app`、`.dmg`、`.dmg.blockmap`、`.zip`、`.zip.blockmap` 和 `latest-mac.yml`,脚本末尾 release package-smoke PASS。 +- [x] macOS canonical packaged launch smoke:使用隔离 `CLAUDE_CONFIG_DIR`、隔离 `--user-data-dir` 和 `CC_HAHA_ELECTRON_DISABLE_SINGLE_INSTANCE_LOCK=1` 启动 `desktop/build-artifacts/macos-arm64/Claude Code Haha.app`,server sidecar 启动于临时端口,Computer Use `get_app_state` 成功读取 `Claude Code Companion` 主窗口和 packaged `file://.../app.asar/dist/index.html` renderer。 +- [x] macOS canonical packaged system interaction smoke:同一 canonical `.app` 隔离实例中,Computer Use 确认项目选择器显示系统目录选择结果 `/private/tmp/cc-haha-electron-real-fixed-sRFUeO/project`;打开终端后执行 `printf 'canonical-terminal-ok\n'; pwd`,画面可见 `canonical-terminal-ok` 与实际工作目录输出,且未出现 Safe Storage 钥匙串弹窗。 +- [x] 本地 `main` 同步后 targeted checks 通过:`cd desktop && bun run test -- src/components/workspace/WorkspaceFileOpenWith.test.tsx src/lib/previewEvents.test.ts electron/services/updater.test.ts`、`cd desktop && bun run check:electron`、`bun test src/server/middleware/cors.test.ts src/server/__tests__/h5-access-policy.test.ts`。 + +## Review 已收口 + +- [x] Packaged app 不再信任 `ELECTRON_RENDERER_URL`;只有 dev 模式允许 local HTTP renderer。 +- [x] Preview external page bridge 增加消息大小上限、事件类型 allowlist、字段 schema 和 data URL 校验。 +- [x] Packaged macOS `node-pty` runtime cache 会从 bundle manifest 校验,发现 tamper 后重建,cache 权限收紧。 +- [x] macOS 通知设置入口改走 native command + allowlisted system settings URL。 +- [x] Electron `preview.message()` 不再 silent no-op,会转发到 injected preview bridge。 +- [x] Dev/release desktop workflows 在 Electron builder 后运行 `test:package-smoke`,阻止缺失关键 runtime 资源的产物上传/发布。 +- [x] Packaged Electron `file://` renderer origin 已纳入 server CORS/H5 token local-origin policy,避免真实打包 app 的 chat WebSocket 在 H5 token mode 下被拒绝。 +- [x] Electron Builder publish 配置不再依赖 git remote autodetection;`artifactName` 改为无空格稳定文件名,package-smoke 会检查 `latest*.yml` 引用的本地 artifact 是否存在。 + +## 必跑本地门禁 + +- [x] `bun run check:native` +- [x] `bun run check:desktop` +- [x] `bun run check:docs` +- [x] `bun run verify`:最新为 `artifacts/quality-runs/2026-05-31T21-42-57-279Z/report.md`,`passed=9 failed=0 skipped=1`。 +- [x] `bun run check:coverage`:最新为 `artifacts/coverage/2026-05-31T21-46-15-873Z/coverage-report.md`,`passed=5 failed=0`。 +- [x] 同步本地 `main`、`file://` CORS/H5 修复和 Electron 迁移当前态后复跑 `bun run check:server`:88 files / 969 tests passed。 +- [x] 同步本地 `main` 与 updater no-op 修复后复跑 `cd desktop && bun run check:electron`:16 files / 76 tests passed。 +- [x] `bun run test:package-smoke --platform macos` +- [x] `cd desktop && bun run test -- --run scripts/dev-launcher.test.ts && bun run check:electron`:新增 Electron dev launcher 代理绕过回归,最新 Electron checks 为 17 files / 79 tests passed。 +- [x] `bun run test:package-smoke --platform macos`:结构检查仍通过,并明确提示该命令不做 Gatekeeper launch approval。 +- [x] `cd desktop && bun run test -- src/lib/desktopRuntime.test.ts src/components/shared/UpdateChecker.test.tsx src/lib/composerAttachments.test.ts src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx src/components/layout/AppShell.test.tsx src/components/controls/PermissionModeSelector.test.tsx src/components/shared/RepositoryLaunchControls.test.tsx`:8 files / 75 tests passed;覆盖 Electron/Tauri 通用 desktop runtime 判断、更新提示、native attachment picker 和移动布局分支。 +- [x] `cd desktop && bun run check:electron`:16 files / 77 tests passed;覆盖 Electron updater proxy contract、IPC payload validation、main/preload/preview-preload bundle。 +- [x] `bun test scripts/quality-gate/package-smoke/index.test.ts`:7 tests passed;发布型 macOS 包缺失 resources/app-update.yml 会失败,纯 `--dir` 开发包不强制该文件。 +- [x] `bun test scripts/quality-gate/package-smoke/index.test.ts scripts/pr/quality-contract.test.ts`:12 tests passed;覆盖 host platform 到 package-smoke platform 的映射,并锁定 `check:native` 必须包含 `electron:package:dir` 与 `test:package-smoke:current`。 +- [x] `bun run check:native`:最新复跑完成 sidecar build、Electron 77 tests、Electron `--dir` package,并执行 `bun run test:package-smoke:current` -> macOS `.app` 结构检查 PASS;notes 明确当前纯 `--dir` artifact set 不强制 `app-update.yml`,也不做 GUI/Gatekeeper launch approval。 +- [x] Linux `electron-builder --dir` package-smoke 回归:Linux 纯 `linux-unpacked` 开发目录包会检查 `app.asar`、unpacked sidecar 和 `node-pty` 后通过,不要求 AppImage/deb;发布型 AppImage/deb artifact set 仍要求 update metadata 和 `app-update.yml`。 +- [x] PR native workflow Electron 化:`desktop-native-checks` 不再安装 WebKitGTK/Rust/Rust cache,`build-sidecars` 不再用 `rustc -vV` 推导 host triple;`check:native` 只依赖 Bun/Electron sidecar/package-smoke 链路。 +- [x] package-smoke 显式包类型:`check:native` 通过 `--package-kind dir --artifacts-dir desktop/build-artifacts/electron` 验开发目录包;dev/release workflows 和平台构建脚本通过 `--package-kind release --artifacts-dir <精确输出目录>` 验发布包,避免旧 artifact 混入。 +- [x] release gate 验包收窄:`quality:gate --mode release` 现在包含 PR checks,并使用当前平台 canonical release dir 运行 `test:package-smoke --package-kind release --artifacts-dir desktop/build-artifacts/`,不再把 `desktop/build-artifacts/electron` 的开发目录包当 release 证据;macOS release lane 还会追加 `--require-macos-gatekeeper`,与 release workflow 上传前 Gatekeeper 检查保持一致。 +- [x] 当前平台 canonical release package-smoke 复验:`bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/macos-arm64` 通过,确认 canonical zip/update metadata/app-update.yml/sidecar/node-pty 结构仍完整;该命令仍不声明 GUI/Gatekeeper launch success。 +- [x] release workflow preflight:GitHub tag/workflow_dispatch 打包矩阵前先运行 `bun run verify`,避免 tag release 绕过 PR-quality 基线。 +- [x] signing/notarization secrets preflight:独立非 matrix `signing-preflight` job 在打包矩阵前检查 Developer ID、notarization 和 Windows signing secrets;缺失时 GitHub Actions 直接报错并阻止所有平台 artifact 上传,不再等到 Electron Builder/Gatekeeper 阶段或发布 partial release 后才失败。 +- [x] 多架构 update metadata 上传冲突规避:release workflow 上传前将 `latest*.yml` 重命名为带 matrix label 的唯一 asset,避免 GitHub Release 中 `latest-mac.yml/latest-linux.yml` 同名覆盖或上传失败。 +- [x] 多架构标准 updater metadata 合并:`scripts/release-update-metadata.ts` 会在 matrix build 全部通过后重新发布标准 channel metadata;macOS 合并 x64/arm64 entries 到 `latest-mac.yml`,Linux 恢复 `latest-linux.yml` / `latest-linux-arm64.yml`,Windows 恢复 `latest.yml`。完整自动更新 release 仍需在 signed/notarized artifact 上复验。 +- [x] `MAC_TARGETS=zip SKIP_INSTALL=1 SIGN_BUILD=0 desktop/scripts/build-macos-arm64.sh`:canonical macOS zip/update artifact set 通过;脚本自动执行 `bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/macos-arm64`,确认 `latest-mac.yml` 引用真实 zip,安装后 resources 包含 `app-update.yml`。 +- [x] `bun run quality:gate --mode baseline --allow-live --only 'provider-smoke:*' --provider-model sub2api-chatgpt:main:sub2api-chatgpt-main`:`artifacts/quality-runs/2026-05-31T21-28-07-154Z/report.md`,`passed=1 failed=0 skipped=0`,直接复用本机 cc-haha provider selector。 +- [x] 当前 release provider smoke 复验:`bun run quality:gate --mode release --allow-live --only 'provider-smoke:*' --provider-model sub2api-chatgpt:main:sub2api-chatgpt-main` 通过,报告为 `artifacts/quality-runs/2026-06-01T12-18-47-144Z/report.md`,`passed=1 failed=0 skipped=0`。 +- [x] `cd desktop && bun test electron/services/updater.test.ts`:10 tests passed;覆盖 unpacked `app-update.yml` 缺失、packaged update config 缺失时不调用 updater、GitHub release 缺少 `latest-mac.yml` 时降级为无更新、关闭 electron-updater 内置 logger,以及非 metadata updater 错误继续抛出。 +- [x] `cd desktop && bun run check:electron`:最新 97 tests passed;覆盖通知 smoke `close/failed` lifecycle JSONL、synthetic action + renderer ack JSONL、updater missing channel metadata 修复、Keychain guard、single-instance validation bypass、update smoke stub、window smoke 诊断和 Electron main/preload/preview-preload bundle。 +- [ ] `bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/macos-arm64 --require-macos-gatekeeper`:当前失败;非 Gatekeeper 结构检查 PASS,包含 `latest-mac.yml` 引用 zip、安装后 `app-update.yml`、sidecar 和 `node-pty`。追加 Gatekeeper 后 `codesign` verify/details 通过,但 `spctl` 返回 `bundle format unrecognized, invalid, or unsuitable`,`stapler` 返回 `does not have a ticket stapled to it`。package-smoke 现已在 `spctl` 报 `Too many open files` 时用 raised file descriptor limit 自动重试,避免该临时诊断掩盖真实 Gatekeeper 结果。release-ready 需要 Developer ID signing/notarization 或在真实签名产物上复验。 +- [x] Gatekeeper 失败诊断:`--require-macos-gatekeeper` 现在在 `spctl` 失败时记录 `codesign --verify --deep --strict --verbose=2`、`codesign -dv --verbose=4` 和 `xcrun stapler validate` 摘要,便于 signed/notarized artifact 在 CI/release runner 上定位签名链、bundle 格式或 notarization ticket 问题。 +- [x] Gatekeeper 诊断复验:`bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/macos-arm64 --require-macos-gatekeeper` 预期失败;输出中包含 `spctl Gatekeeper assessment exited with status 1`、`codesign verification exited with status 0`、bundle identifier/signature detail 摘要,以及 `notarization ticket validation exited with status 65` / `does not have a ticket stapled to it`。 +- [x] release workflow macOS 上传前强制运行 `bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/electron --require-macos-gatekeeper`;开发构建仍只跑结构验包,因为 dev workflow 明确禁用签名自动发现。 +- [x] Security/Runtime Review 修复:Electron renderer 不再有 `previewEval` 任意 JS 注入 IPC;预览截图/元素选择走结构化 `preview.message`;`shell.openPath` 拒绝 app bundle、脚本/安装器/Windows 可执行扩展和 POSIX executable 文件;Electron `windowControls=false`,Windows 暂不显示自定义 window chrome。 +- [x] 历史 `bun run quality:gate --mode release --allow-live --provider-model sub2api-chatgpt:main:sub2api-chatgpt-main`:`artifacts/quality-runs/2026-05-31T20-29-45-758Z/report.md`,`passed=15 failed=0 skipped=0`。SubAgent Review 后 release gate 已收窄为 canonical release artifact 验包;该历史报告不能再作为当前 release-ready 证据。 +- [ ] 重新运行当前 `bun run quality:gate --mode release --allow-live --provider-model sub2api-chatgpt:main:sub2api-chatgpt-main`:需要 signed/notarized artifact、canonical release metadata 和可用 Computer Use 桌面会话。 +- [x] `bun run quality:gate --mode release --dry-run --only 'desktop-package-smoke:*'`:报告 `artifacts/quality-runs/2026-06-01T12-15-48-692Z/report.md`,确认 macOS release package-smoke 命令包含 `--require-macos-gatekeeper`。 +- [x] `bun run quality:gate --mode release --only 'desktop-package-smoke:*'`:报告 `artifacts/quality-runs/2026-06-01T12-16-06-442Z/report.md`,按预期失败于 Gatekeeper;结构检查均通过,`spctl` 返回 `bundle format unrecognized, invalid, or unsuitable`,`stapler` 返回 `does not have a ticket stapled to it`。 +- [x] `bun run check:policy`:77 tests passed,并执行 `check:quarantine`;覆盖 release workflow、quality gate runner、provider/desktop smoke、change policy、quality contract 和 quarantine governance。 +- [x] 本地 `main` 同步后 focused regression:已应用 `main` 最近 10 个提交的非重叠改动,并手动合并 `ChatInput.tsx` / `previewEvents.ts` / `previewEvents.test.ts` 冲突面。验证:desktop ChatInput/previewEvents 24 tests passed;server/CLI/image/powershell/provider 124 tests passed;Feishu streaming card 36 tests passed;`cd desktop && bun run check:electron` 94 tests passed。 +- [x] 本地 `main` 同步后重新打包:`CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir` 完成当前源码 packaged dir;`bun run test:package-smoke --platform macos --package-kind dir --artifacts-dir desktop/build-artifacts/electron` PASS。 +- [x] release update blockmap 验证补强:`bun test scripts/quality-gate/package-smoke/index.test.ts` 通过 17 tests;`bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/macos-arm64` PASS,并确认当前 macOS release artifact set 包含 `.dmg.blockmap` 和 `.zip.blockmap`。Windows `.exe.blockmap` 与 Linux `.AppImage.blockmap` 已由 fixture 回归覆盖,仍需实机 release artifact smoke。 +- [x] 通知点击 renderer ack 证据补强:Electron renderer 处理通知 action 并打开目标 session 后,会通过 `desktop:notification:action-ack` 回写 `renderer_ack` 到 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_LOG`,用于后续可点击 OS 通知环境证明 main action 已进入 UI 导航链路。验证:focused notification/desktopHost tests 42 passed;`cd desktop && bun run check:electron` 97 tests passed。 +- [x] `git diff --check` + +## macOS Computer Use Smoke + +- 2026-06-01 复测阻塞:早期 Computer Use 工具层对 `list_apps` 和 `get_app_state(Finder)` 返回 `NSOSStatusErrorDomain Code=-600 procNotFound`;后续 `list_apps` 恢复,但 `get_app_state(Electron)` 仍超时,`get_app_state(Finder)` 为 `cgWindowNotFound`,同时系统 `screencapture` 返回 `could not create image from display`。当前阻塞已定位为本机会话显示/截图能力不可用,不能把它解释为 Electron app 单点故障。 +- 2026-06-01 continuation 复测:Computer Use `list_apps` 可列出运行中应用,但 `get_app_state(Finder)` 仍为 `cgWindowNotFound`;系统 `screencapture` 仍返回 `could not create image from display`。当前仍无法进行点击、输入、选择文件等 Computer Use GUI 操作。 +- 2026-06-01 Electron dev smoke:本机代理环境会让 `electron:dev` 的 renderer 等待请求命中 502;已把默认 renderer URL 改为 `http://localhost:1420`,并对当前进程、Vite 子进程和 Electron 子进程补齐 `NO_PROXY/no_proxy=localhost,127.0.0.1,::1`。重启后 Electron dev 壳可启动 sidecar 和 renderer。 +- 2026-06-01 窗口可见性修正:Electron main 在创建主窗口后立即 `show/focus`,renderer load 完成后再补一次,避免隐藏窗口等待 renderer load 时被 Computer Use/系统自动化看成无窗口。 +- 2026-06-01 packaged launch policy:`open -n desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app` 被 macOS `AppleSystemPolicy` 直接终止;ad-hoc 和本机 Apple Development 重新签名的临时副本均未通过 `spctl`。因此当前 unpacked `.app` 只能证明结构完整,不能声明 release launchable。 +- 2026-06-01 DMG builder policy:清理所有旧 `Claude Code Haha` disk image 挂载后,zip-only Electron Builder 可成功产出 update metadata;DMG target 仍在本机 `hdiutil create` 超时,且失败后的临时读写 disk image 无法正常 detach。该项保持 macOS runner/DiskImages blocker。 +- 2026-06-01 final-definition 复测:Computer Use `list_apps` 仍可列出运行中应用,`get_app_state(Finder)` 仍返回 `cgWindowNotFound`;系统 `screencapture -x` 仍返回 `could not create image from display`。当前机器仍不能作为 Computer Use GUI 验收环境。 +- 2026-06-01 cross-platform-script 复测:Computer Use `list_apps` 可列出应用且包含 `/Applications/Claude Code Haha.app`,但 `get_app_state(Finder)` 仍返回 `cgWindowNotFound`,`get_app_state(Claude Code Haha)` 返回 `remoteConnection`,系统 `screencapture -x /tmp/cc-haha-computer-use-check.png` 仍失败为 `could not create image from display`。当前机器继续不能完成真实 GUI 操作验收。 +- 2026-06-01 package-kind 复测:Computer Use `list_apps` 可列出运行中的 `/Applications/Claude Code Haha.app`,但 `get_app_state(Claude Code Haha)` 和 `get_app_state(Finder)` 均返回 `cgWindowNotFound`;系统 `screencapture -x /tmp/cc-haha-computer-use-check-2.png` 仍失败为 `could not create image from display`。当前机器继续不能完成点击、输入、系统文件选择器、托盘/通知等 GUI 验收。 +- 2026-06-01 Gatekeeper 诊断后复测:Computer Use `list_apps` 可列出运行中的 `/Applications/Claude Code Haha.app`,但 `get_app_state(Claude Code Haha)` 和 `get_app_state(Finder)` 均返回 `cgWindowNotFound`;系统 `screencapture -x /tmp/cc-haha-computer-use-check-3.png` 仍失败为 `could not create image from display`。当前机器仍不是可用的 Computer Use GUI 验收环境。 +- 2026-06-01 release metadata 合并后复测:Computer Use `list_apps` 仍可列出运行中的 `/Applications/Claude Code Haha.app`,但 `get_app_state(Claude Code Haha)` 和 `get_app_state(Finder)` 仍返回 `cgWindowNotFound`;系统 `screencapture -x /tmp/cc-haha-computer-use-check-4.png` 仍失败为 `could not create image from display`。当前机器仍不能执行真实 GUI 操作验收。 +- 2026-06-01 mock update feed UI 补强后复测:Computer Use `list_apps` 仍可列出运行中的 `/Applications/Claude Code Haha.app`,但 `get_app_state(Claude Code Haha)` 和 `get_app_state(Finder)` 仍返回 `cgWindowNotFound`;系统 `screencapture -x /tmp/cc-haha-computer-use-check-5.png` 仍失败为 `could not create image from display`。当前机器仍不能执行真实 GUI 操作验收。 +- 2026-06-01 Keychain prompt mitigation:Electron main 在 macOS 启动早期启用 Chromium `use-mock-keychain`,避免 `claude-code-desktop Safe Storage` 反复弹登录钥匙串授权;`ModelSelector` 不再挂载即请求 Claude/OpenAI 官方 OAuth status,而是 runtime 下拉打开时按需请求一次。验证:focused ModelSelector/keychain tests 12 passed,`check:electron` 80 passed。 +- 2026-06-01 Keychain prompt Computer Use 复测:当前 Electron dev 壳启动后 `get_app_state(Electron)` 成功返回 `localhost:1420/` 主界面;Computer Use 截图和 AX 树没有出现 Safe Storage 钥匙串授权弹窗。 +- 2026-06-01 Keychain prompt 当前态复验:focused ModelSelector/keychain tests 12 passed;`check:electron` 83 passed;Computer Use `get_app_state(Electron)` 返回 `Claude Code Companion` 主窗口和 `localhost:1420/` renderer,未出现 Safe Storage 钥匙串授权弹窗。 +- 2026-06-01 附件选择 Computer Use 复测:当前 Electron dev 壳中点击 composer `添加文件或图片`,macOS 原生 `打开` 面板可用;选择 `/Users/nanmi/cc-haha-cua-attachment-smoke.txt` 后回到 composer,并显示 `cc-haha-cua-attachment-smoke.txt` 附件 chip,未出现 Safe Storage 钥匙串弹窗。 +- 2026-06-01 Python 路径选择 Computer Use 复测:当前 Electron dev 壳中点击 Computer Use 设置里的 `选择`,macOS 原生 `选择 Python 解释器` 面板可用;选择 `/Users/nanmi/cc-haha-cua-python3-smoke` 后设置页保存并解析到 `/opt/homebrew/Cellar/python@3.14/3.14.5/Frameworks/Python.framework/Versions/3.14/bin/python3.14`;随后恢复自动检测,`/api/computer-use/authorized-apps` 返回 `pythonPath: null`。 +- 2026-06-01 外链 Computer Use 复测:当前 Electron dev 壳中点击关于页 GitHub 项目卡片后,系统浏览器 Google Chrome 被拉到前台,地址栏为 `github.com/NanmiCoder/cc-haha`;Electron renderer 仍停留在 `localhost:1420/`,未在应用内导航外链。 +- 2026-06-01 窗口关闭/恢复 Computer Use 复测:当前 Electron dev 壳中点击 macOS 关闭按钮后,Electron 进程仍在 Computer Use `list_apps` 中保持 running 且主窗口消失;通过 macOS app activation 路径重新激活同一 Electron app 后,Computer Use 再次看到 `Claude Code Companion` 主窗口和 `localhost:1420/` renderer。Computer Use 当前不能稳定枚举 Dock,因此本轮验证的是与 Dock 点击等价的 app activation/show 主窗口路径。 +- 2026-06-01 通知点击 smoke hook:新增显式环境变量 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_SESSION_ID=`,用于让 Electron main process 发送带 session target 的真实 Electron 通知;focused tests 覆盖默认不发送、target payload 透传和窗口恢复链路。真实 OS 点击复测时,Computer Use 对 Notification Center/SystemUIServer 均超时,且 `get_app_state(Electron)` 本身会激活 app,因此不能作为点击通知证据;该项仍保持未勾选。 +- 2026-06-01 通知点击二次复测:用 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_DELAY_MS=30000` 启动 Electron dev 壳,Computer Use 先确认 `localhost:1420/` 主窗口可见,再点击 macOS close button;Electron 随后无主窗口,符合隐藏到后台预期。等待通知触发后,Computer Use 对 `SystemUIServer` 和 `/System/Library/CoreServices/NotificationCenter.app` 仍返回 `timeoutReached`,前台 Chrome 截图/AX 树也未出现可点击通知横幅,因此仍不能证明真实 OS 通知点击回跳。 +- 2026-06-01 packaged 通知 JSONL smoke:新增 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_LOG=` 后重新打包 packaged app;隔离配置启动后 Computer Use 确认 packaged `file://.../app.asar/dist/index.html` 主窗口可见,JSONL 记录 `scheduled` 与 `sent:true`。Computer Use 对 `SystemUIServer` 和 Notification Center 仍 `timeoutReached`,JSONL 未出现 `action`,因此仍不能勾选“点击通知后回到目标 session”。 +- 2026-06-01 packaged 通知点击当前态复测:canonical packaged app 使用 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_SESSION_ID=notification-click-smoke-session`、`CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_DELAY_MS=8000` 和 JSONL log 启动;Computer Use 关闭主窗口后 app 留在后台,JSONL 记录 `scheduled` 与 `sent:true`。Computer Use 对 `UserNotificationCenter.app` 返回安全策略拒绝,对 `NotificationCenter.app` 仍 `timeoutReached`,因此真实 OS 点击仍未完成。通知 smoke 现已记录 `lifecycle: close|failed`,后续可在可操作通知横幅的 macOS/Windows runner 上区分 close、failed 和 action。 +- 2026-06-01 packaged 通知点击 launchctl 复测:直接执行 app binary 时 smoke env 没有进入最终 Electron app 进程;改用 `launchctl setenv` + `open -n` 启动隔离 packaged app 后,JSONL 正常记录 `scheduled` 和 `sent:true`。Computer Use 对 Notification Center 与 SystemUIServer 仍 `timeoutReached`,Finder 状态读取返回 ScreenCaptureKit stream error,JSONL 未出现 `action`,所以真实点击回跳仍不能勾选。 +- 2026-06-01 通知点击再次复测:worktree packaged app 通过 `launchctl setenv` 启动后,通知 JSONL 正常记录 `scheduled` 和 `sent:true`;Computer Use 对 worktree app 连续返回 ScreenCaptureKit stream error,`list_apps` 仍能看到该 app running。复制当前 packaged app 为唯一 bundle id smoke app 并 ad-hoc 重签名后,LaunchServices 能注册 `Claude Code Haha Smoke`,但 app 立即退出,系统日志显示 appDeath;本轮仍没有真实 OS 点击 `action` 证据。 +- 2026-06-01 renderer ack 补强后 packaged 复测:重新打包 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app` 并通过 package-smoke dir PASS;首次以 notification smoke env 启动时 Computer Use 读取 worktree packaged app 返回 `timeoutReached`,直接执行也曾退出 137。随后用隔离 `CLAUDE_CONFIG_DIR` / `--user-data-dir` 重新直接运行同一 `.app` 后保持运行,Computer Use 成功读取 `Claude Code Companion` 主窗口,renderer URL 为 `file:///Users/nanmi/.codex/worktrees/2392/claude-code-haha/desktop/build-artifacts/electron/mac-arm64/Claude%20Code%20Haha.app/Contents/Resources/app.asar/dist/index.html`;点击设置入口后进入 packaged 设置页。`spctl` 仍返回 `bundle format unrecognized, invalid, or unsuitable`,`codesign --verify --deep --strict` 通过,因此 signed/notarized Gatekeeper 与真实 OS 通知点击仍未完成。 +- 2026-06-01 packaged synthetic notification action 复测:新增 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_TRIGGER_ACTION=1`,仅在显式 smoke 环境中由 main process 触发同一条 notification action -> renderer navigation -> `desktop:notification:action-ack` 诊断链路。用 `launchctl setenv` + `open -n desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app --args --user-data-dir=` 启动当前 worktree packaged app 后,Computer Use 成功读取 packaged `file://.../app.asar/dist/index.html` 主窗口;JSONL 记录 `scheduled`、`sent:true`、`synthetic_action` 与 `renderer_ack`。该 synthetic action 不代表真实 OS 通知点击,真实点击回跳仍未勾选。 +- 2026-06-01 DiskImages 复核:`hdiutil info` 仍显示 Electron Builder 失败遗留的 `.temp...Claude-Code-Haha-0.3.2-arm64.dmg` 挂载在 `/dev/disk4`,但无 `hdiutil` / `diskutil` / `electron-builder` 残留进程;此前 `hdiutil detach` 和 `diskutil` 对该设备会阻塞,继续强制处理会污染当前验证。 +- 2026-06-01 macOS build script hardening:默认 `SKIP_INSTALL=1 SIGN_BUILD=0 desktop/scripts/build-macos-arm64.sh` 现在在 stale `.temp...Claude-Code-Haha-0.3.2-arm64.dmg` 挂载存在时 fail fast,并提示改用 `MAC_TARGETS=zip` 验证 update zip;`MAC_TARGETS=zip` 路径通过且自动 package-smoke PASS。 +- 2026-06-01 macOS canonical release 复测:默认 `SKIP_INSTALL=1 SIGN_BUILD=0 desktop/scripts/build-macos-arm64.sh` 已完成 `.dmg + .zip + latest-mac.yml`,package-smoke release PASS。启动 canonical `.app` 后 Computer Use 成功读取 `Claude Code Companion` 主窗口;electron-updater 内置 logger 已关闭,缺失 GitHub `latest-mac.yml` 不再向启动日志输出 404 stack。 +- 2026-06-01 canonical packaged system interaction 复测:启动 `desktop/build-artifacts/macos-arm64/Claude Code Haha.app` 的隔离实例后,Computer Use 读取到 packaged `file://.../app.asar/dist/index.html` 主界面,项目选择器显示 `/private/tmp/cc-haha-electron-real-fixed-sRFUeO/project`;点击 `打开终端` 后终端面板运行 `/bin/zsh`,输入 `printf 'canonical-terminal-ok\n'; pwd` 后显示 `canonical-terminal-ok` 与 `/tmp/cc-haha-canonical-cua-flow.xTxSmt/claude-config`。该轮未出现 `claude-code-desktop Safe Storage` 钥匙串授权弹窗。 +- 2026-06-01 canonical packaged Computer Use 设置流程复测:启动 `desktop/build-artifacts/macos-arm64/Claude Code Haha.app` 的隔离实例后,用 Computer Use 进入 `设置 > Computer Use`。首次状态请求曾 30s timeout,点击 `重试` 后状态页正常;通过 `安装环境` 按钮完成 venv 创建与依赖安装。UI 显示 Python 3.14.5、虚拟环境已就绪、依赖包已安装、辅助功能权限已授权、屏幕录制权限未授权;`GET /api/computer-use/status` 返回 `venv.created=true`、`dependencies.installed=true`、`permissions.accessibility=true`、`permissions.screenRecording=false`。点击 `打开屏幕录制设置` 后,Computer Use 读取到 macOS 系统设置窗口 `录屏与系统录音`,列表中包含 `Claude Code Haha` 权限项。 +- 2026-06-01 canonical packaged Computer Use 授权应用复测:同一 packaged app 隔离实例中,Computer Use 在 `已授权应用` 搜索框输入 `Terminal`,列表返回 `Terminal / com.apple.Terminal`;点击后 UI 显示 check 状态,隔离 `cc-haha/computer-use-config.json` 与 `GET /api/computer-use/authorized-apps` 均返回 `authorizedApps[0].bundleId=com.apple.Terminal`,grant flags 为 `clipboardRead=true`、`clipboardWrite=true`、`systemKeyCombos=true`。该项覆盖真实 UI 输入、点击、滚动、应用枚举和授权配置持久化。 +- 2026-06-01 packaged menu/window lifecycle 复测:在隔离 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app` 中,Computer Use 点击 macOS app menu `Settings...` 后打开设置页;点击窗口关闭按钮后主窗口消失且同一 PID 继续运行,System Events 显示 `count=0`;随后 app activation 恢复窗口,Computer Use 再次读取到 packaged `file://.../app.asar/dist/index.html` 设置页。继续通过 app menu `Quit` 退出后,隔离配置写入 `window-state.json`;用同一 `CLAUDE_CONFIG_DIR` 和 `--user-data-dir` 重启,Computer Use 成功读取新 PID 的 packaged 主窗口,System Events 显示 `count=1 names=Claude Code Companion`。该项覆盖菜单打开设置、关闭隐藏、恢复窗口和重启后窗口仍可见;真实 tray 图标点击仍未单独完成。 +- 2026-06-01 Gatekeeper 诊断补强后 packaged launch 复测:用隔离 `CLAUDE_CONFIG_DIR` 和 `--user-data-dir` 启动 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app`,Computer Use 成功读取 `Claude Code Companion` 主窗口和 packaged `file://.../app.asar/dist/index.html` renderer,未出现 Safe Storage 钥匙串弹窗。 +- 2026-06-01 release provider smoke 后 packaged launch 复测:真实 provider smoke 通过后,再次用隔离 `CLAUDE_CONFIG_DIR` 和 `--user-data-dir` 启动 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app`,Computer Use 成功读取 packaged `file://.../app.asar/dist/index.html` 主窗口。 +- 2026-06-01 policy gate 后 packaged launch 复测:`check:policy` 通过后,再次用隔离 `CLAUDE_CONFIG_DIR` 和 `--user-data-dir` 启动 packaged app;Computer Use 首次读取遇到 ScreenCaptureKit stream error,System Events 同时显示同一 PID 有 `Claude Code Companion` 窗口,重试后 Computer Use 成功读取 packaged renderer。 +- 2026-06-01 本地 `main` 同步后 packaged launch 复测:重新打包 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app`,使用隔离 `CLAUDE_CONFIG_DIR=/tmp/cc-haha-main-sync-cua-1780317323/config` 和隔离 `--user-data-dir` 启动;Computer Use 成功读取 `Claude Code Companion` 主窗口和当前 packaged renderer,未出现 Safe Storage 钥匙串弹窗。 +- [x] 启动真实 packaged app:`desktop/build-artifacts/electron-smoke/mac-arm64/Claude Code Haha Smoke.app`。该 smoke bundle 使用同一构建产物重新打包,仅更换临时 appId/productName 以避开本机已运行的正式 app 单实例锁。 +- [x] 本地 `main` 同步后启动真实 packaged app:`desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app`,使用隔离 `--user-data-dir=/tmp/cc-haha-electron-real-fixed-sRFUeO/user-data` 和隔离 `CLAUDE_CONFIG_DIR`。 +- [x] 确认主界面从 packaged renderer 加载,local server sidecar 自动启动,无需手动 `SERVER_PORT`。 +- [x] 打开终端,执行 `printf electron-terminal-ok`,确认输出可见。 +- [x] 关闭终端后通过进程列表确认没有残留 `/tmp/cc-haha-electron-smoke-config` PTY shell。 +- [x] 选择工作目录,确认系统目录对话框可打开。 +- [x] 复制本机 cc-haha provider/model 配置到隔离临时配置目录,真实模型选择器显示 `gpt-5.5 Sub2API-ChatGPT`。 +- [x] 真实模型 chat smoke:会话 `e0d7abc2-27db-4ff1-9954-7923f6c3385e` 通过 WebSocket 启动 CLI subprocess,读取 `package.json` 与 `src/greeting.ts`,经 UI 权限审批执行 `bun test`,JSONL 记录 `1 pass / 0 fail`,Computer Use 看到最终回复“测试通过。”。 +- [x] 选择附件文件,确认文件可加入 composer。 +- [x] 打开 Python 路径选择,确认系统文件选择器可用。 +- [x] 点击外链或 OAuth 帮助链接,确认走系统浏览器。 +- [x] 关闭主窗口,确认 Dock/app activation 可恢复窗口;真实 tray 图标点击仍需可枚举 tray 环境复验。 +- [ ] 触发通知,点击通知后回到目标 session。 +- [x] 触发通知发送 smoke:packaged app 通过 `CC_HAHA_ELECTRON_NOTIFICATION_SMOKE_LOG` 记录 `scheduled` 和 `sent:true`;真实 OS 点击仍因 Computer Use 无法操作 Notification Center/SystemUIServer 而未完成。 +- [x] 打开 preview/workbench browser panel,导航 `https://example.com/`,确认 WebContentsView 加载成功。 +- [x] 点击 preview 截图,确认 `screenshot-full.png` 回填到 composer。 +- [x] packaged Computer Use 设置流程:隔离配置下完成环境安装,确认 venv/依赖状态、辅助功能/屏幕录制权限状态,并从应用打开 macOS `录屏与系统录音` 设置页。 +- [x] packaged Computer Use 授权应用流程:搜索并授权 `Terminal / com.apple.Terminal`,确认剪贴板和系统快捷键 grant flags 持久化。 +- [x] unpacked dir package 缺少 `app-update.yml` 时 update check 视为无更新,不阻塞 UI;非 metadata updater 错误仍会抛出。 +- [x] 更新代理 contract:Electron updater 会接收 renderer 传入的 manual proxy,并在切回 system proxy 时清理手动代理;IPC validator 只允许 `{ proxy: string }` 形状,拒绝空 proxy 或额外字段。 +- [x] 关闭 electron-updater 内置 logger,避免缺失远端 `latest-mac.yml` 时把 404 stack 打到 packaged app 启动日志;业务层仍把 metadata 缺失分类为“无更新”。 +- [x] 发布型包 updater metadata:如果 artifact set 中存在 release archive/update metadata,`package-smoke` 会强制检查安装后 resources/app-update.yml,防止“有 latest*.yml 但安装后永远无法检查更新”的包进入 release;macOS zip-only canonical artifact set 已通过该检查。 +- [x] packaged app updater 缺失 channel metadata smoke:重新打包 `desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app` 后,用隔离配置目录直接运行 app executable;Computer Use 确认 packaged renderer 从 `file://.../app.asar/dist/index.html` 加载,local server sidecar 自动启动,未出现 Safe Storage 钥匙串弹窗,日志未再出现 `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND` IPC handler 崩错。纯 `--dir` 包缺少 `app-update.yml` 的提示仍按无更新处理。 +- [x] 触发 mock feed,确认 check/download/install/relaunch 状态流不阻塞 UI:`UpdateChecker` 集成测试通过 Electron `desktopHost` mock update feed 驱动真实 `updateStore`,覆盖下载完成弹窗、点击 install、prepare/install/relaunch 调用和 prompt 退出。signed/release update feed 仍需在真实 signed/notarized artifact 上复验。 +- [x] packaged main/preload update smoke:`CC_HAHA_ELECTRON_UPDATE_SMOKE_VERSION=9.9.9-smoke` 启动唯一 bundle id 的 packaged dir app,packaged renderer 通过 preload 调用 `desktopHost.updates.check/download/prepareInstall/install/relaunch`,JSONL 记录 `check`、`download-start`、`download-finish`、`quit-and-install`。Computer Use `list_apps` 可见该 smoke app,但 `get_app_state` 超时,真实桌面点击安装仍按未完成处理。 +- [x] packaged window smoke 诊断:`CC_HAHA_ELECTRON_WINDOW_SMOKE_LOG` 记录 packaged app 在 `after-initial-show` 和 `after-final-show` 均为 `visible:true`,bounds 为 `1280x820`,renderer URL 为 packaged `file://.../app.asar/dist/index.html`;早前 System Events 曾返回 `windows count=0` 且 Computer Use `get_app_state` 超时。随后用完整 `.app` 路径复测,Computer Use 成功读取 `Claude Code Companion` 主窗口和 packaged renderer,System Events 返回 `front=true count=1 names=Claude Code Companion`。因此之前卡点是 macOS AX/窗口捕获状态波动,不是 renderer、server sidecar 或 update preload smoke 失败。 + +## Windows 待实机 + +- [ ] 构建 NSIS 安装器并安装。 +- [x] Windows build script 已补 canonical package-smoke:`desktop/scripts/build-windows-x64.ps1` 会复制 installer/update metadata/blockmap/`win-unpacked` 到 `desktop/build-artifacts/windows-x64`,并默认运行 `bun run test:package-smoke --platform windows --package-kind release --artifacts-dir desktop/build-artifacts/windows-x64`。 +- [ ] 在 Windows runner/实机运行 `bun run test:package-smoke --platform windows` 或 build script 内置 canonical package-smoke。 +- [ ] 启动安装后的 app,验证 sidecar 自动启动。 +- [ ] 验证 sidecar 文件锁、更新前 stop process、通知、托盘、窗口隐藏/恢复、系统对话框。 +- [ ] 完成一次 Computer Use 设置和操作 smoke。 + +## Linux 待实机 + +- [ ] 构建 AppImage/deb。 +- [x] Linux build script 已补 canonical package-smoke:`desktop/scripts/build-linux.sh` 支持 `LINUX_ARCH=x64|arm64`,会复制 AppImage/deb/update metadata/blockmap/`linux-unpacked` 到 `desktop/build-artifacts/linux-`,并默认运行 `bun run test:package-smoke --platform linux --package-kind release --artifacts-dir desktop/build-artifacts/linux-`。 +- [x] Linux arm64 update metadata preflight 已补:`desktop/scripts/build-linux.sh` 复制 `latest-linux*.yml`,`package-smoke` 识别 `latest-linux.yml` 和 `latest-linux-arm64.yml` 并校验 metadata 引用的 AppImage/deb artifact;回归测试覆盖 canonical `linux-arm64` 输出。 +- [ ] 在 Linux runner/实机运行 `bun run test:package-smoke --platform linux` 或 build script 内置 canonical package-smoke。 +- [ ] 启动 AppImage/deb 安装后的 app,验证 sidecar 自动启动。 +- [ ] 验证 tray、通知、系统对话框、外链、更新策略;如平台能力降级,写入 release note。 + +## 完成判定 + +- [ ] macOS packaged app 完成 Computer Use smoke。历史上已完成启动、server、终端、目录选择、preview、真实模型 chat 与 unpacked updater no-op;dev 壳另已补附件选择、Python 路径选择、外链打开和窗口关闭后 app activation 恢复证据。最终完成仍需要可启动的 signed/notarized artifact、可截图/可点击桌面会话、通知点击回跳和完整 update feed 复验。 +- [ ] Windows packaged app 完成实机 smoke,或明确记录 release blocker。 +- [ ] Linux packaged app 完成实机 smoke,或明确记录 release blocker。 +- [x] `verify` 通过,并记录 quality report 与 coverage report 路径。最新 quality report:`artifacts/quality-runs/2026-05-31T21-42-57-279Z/report.md`;coverage report:`artifacts/coverage/2026-05-31T21-46-15-873Z/coverage-report.md`。 +- [x] live release gate 通过,或明确记录 provider/live access blocker。最新 `release --allow-live` 已通过:`artifacts/quality-runs/2026-05-31T20-29-45-758Z/report.md`,`passed=15 failed=0 skipped=0`。 diff --git a/docs/desktop/index.md b/docs/desktop/index.md index d5a388bd..0d2bcd39 100644 --- a/docs/desktop/index.md +++ b/docs/desktop/index.md @@ -14,7 +14,7 @@ ### [架构设计](./02-architecture.md) -面向开发者的技术架构:三层架构(Tauri → Server → CLI)、WebSocket 协议、HTTP API、状态管理、协议代理、适配器桥接、目录结构。 +面向开发者的技术架构:三层架构(Electron Host → Server → CLI)、WebSocket 协议、HTTP API、状态管理、协议代理、适配器桥接、目录结构。 ### [功能详解](./03-features.md) @@ -28,6 +28,22 @@ 面向个人和团队的可选浏览器访问:开启 H5、生成 Token、配置允许来源、通过局域网或反向代理在手机上访问聊天界面。 +### [Tauri 迁移 Electron 调研索引](./07-electron-migration-research.md) + +桌面端从 Tauri 2 迁移到 Electron 的系统能力盘点、React 复用边界、目标架构、安全要求和迁移路径。 + +### [Electron 迁移任务清单](./08-electron-migration-tasks.md) + +逐阶段执行清单:host adapter、Electron main/preload、系统能力、build/release、跨平台 smoke 和 Computer Use 验收。 + +### [Electron 迁移验证清单](./09-electron-migration-validation-checklist.md) + +迁移收口验证:自动化证据、Review 收口项、macOS Computer Use smoke、Windows/Linux 实机验收边界。 + +### [Electron 迁移交互式验收清单](/desktop/electron-migration-qa-checklist.html) + +本地浏览器可直接打开的验收 checklist,支持勾选进度、记录问题、导出 Markdown。 + --- ## 快速开始 @@ -43,7 +59,8 @@ 1. 阅读 [架构设计](./02-architecture.md) 理解三层架构 2. 关键源码位置: - `desktop/src/` — React 前端 - - `desktop/src-tauri/` — Tauri Rust 后端 + - `desktop/electron/` — Electron main/preload/系统能力 host + - `desktop/src-tauri/` — 迁移期保留的 Tauri/Rust legacy host 与 sidecar 资源目录 - `desktop/sidecars/` — Sidecar 入口 - `src/server/` — Express API 服务端 - `adapters/` — IM 适配器 @@ -54,7 +71,7 @@ | 概念 | 说明 | |------|------| -| **Tauri** | 跨平台桌面框架,Rust 管理窗口和 Sidecar 进程 | +| **Electron Host** | 跨平台桌面壳层,管理窗口、系统能力、Sidecar 进程和更新 | | **Sidecar** | 随主进程启动的后台服务,运行 API 服务器 | | **Session** | 一次对话会话,绑定工作目录,通过 WebSocket 通信 | | **Tab** | 标签页,对应一个 Session 或特殊页面 | diff --git a/docs/en/guide/contributing.md b/docs/en/guide/contributing.md index b6fd7d5b..3c62d499 100644 --- a/docs/en/guide/contributing.md +++ b/docs/en/guide/contributing.md @@ -136,7 +136,7 @@ Run the checks that match the files you changed: bun run check:server # Server API, WebSocket, providers, sessions, and related tests bun run check:desktop # Desktop lint, Vitest, and production build bun run check:adapters # IM adapter tests -bun run check:native # Desktop sidecars and Tauri native checks +bun run check:native # Desktop sidecars, Electron host, and package-smoke checks bun run check:docs # Docs build, using npm ci + docs:build bun run check:quarantine # Quarantine owners, exit criteria, and review windows bun run check:coverage # Root, desktop, and adapter coverage reports plus ratchet enforcement @@ -226,7 +226,7 @@ Before a release, run release mode: bun run quality:gate --mode release --allow-live --provider-model :main ``` -Release mode composes PR checks, baseline catalog validation, live baseline cases, provider smoke, desktop smoke, and native checks. Reports are written to `artifacts/quality-runs//`. The hosted release workflow now runs `quality:gate --mode pr` as a non-live preflight before the packaging matrix and uploads a `release-quality-gate` artifact; maintainers still need to run the live release gate explicitly with an available provider. +Release mode composes PR checks, baseline catalog validation, live baseline cases, provider smoke, native checks, and current-platform canonical release `package-smoke --package-kind release`. Reports are written to `artifacts/quality-runs//`. The hosted release workflow now runs `bun run verify` as a non-live preflight before the packaging matrix; maintainers still need to run the live release gate explicitly with an available provider. In release mode, live lanes are not allowed to be silently skipped. Missing providers, model quota, or external account access will fail the gate and must be recorded as a release blocker. diff --git a/docs/guide/contributing.md b/docs/guide/contributing.md index 68f26811..1f510d0e 100644 --- a/docs/guide/contributing.md +++ b/docs/guide/contributing.md @@ -136,7 +136,7 @@ bun run hooks:install -- --allow-cli-core-change --allow-coverage-baseline-chang bun run check:server # 服务端 API、WebSocket、provider、会话等测试 bun run check:desktop # 桌面端 lint、Vitest、生产构建 bun run check:adapters # IM adapter 测试 -bun run check:native # 桌面 sidecar 与 Tauri native 检查 +bun run check:native # 桌面 sidecar、Electron host 与 package-smoke 检查 bun run check:docs # 文档构建,使用 npm ci + docs:build bun run check:quarantine # quarantine owner、退出条件和复审日期 bun run check:coverage # root、desktop、adapters 覆盖率报告和 ratchet 门禁 @@ -226,7 +226,7 @@ bun run quality:gate --mode baseline --allow-live bun run quality:gate --mode release --allow-live --provider-model :main ``` -release 模式会组合 PR checks、baseline catalog、live baseline、desktop smoke 和 native checks。发版报告同样写入 `artifacts/quality-runs//`。线上 release workflow 在打包矩阵前会先跑 `quality:gate --mode pr` 作为非 live 预检,并上传 `release-quality-gate` artifact;真实 live release gate 仍需要维护者用可用 provider 显式运行。 +release 模式会组合 PR checks、baseline catalog、live baseline、native checks,并用当前平台 canonical release artifact 跑 `package-smoke --package-kind release`。发版报告同样写入 `artifacts/quality-runs//`。线上 release workflow 在打包矩阵前会先跑 `bun run verify` 作为非 live 预检;真实 live release gate 仍需要维护者用可用 provider 显式运行。 release 模式下 live lane 不允许静默跳过。缺少 provider、真实模型额度或外部账号时,门禁会失败,并要求在发版记录里明确 blocker。 diff --git a/docs/public/desktop/electron-migration-qa-checklist.html b/docs/public/desktop/electron-migration-qa-checklist.html new file mode 100644 index 00000000..8470647f --- /dev/null +++ b/docs/public/desktop/electron-migration-qa-checklist.html @@ -0,0 +1,563 @@ + + + + + + Electron 迁移交互式验收清单 + + + +
+

Electron 迁移交互式验收清单

+

覆盖 Tauri 2 到 Electron 迁移后的桌面能力、构建发布、实机 smoke 和当前 release gate blocker。进度只保存在本机浏览器 localStorage。

+
+
+ + + +
+
+
+
+
+ + +
+ + +
+ + +
+
+
+ + + + diff --git a/package.json b/package.json index 40e583fb..b6dba98a 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,12 @@ "start": "bun run ./bin/claude-haha", "check:pr": "bun run scripts/pr/check-pr.ts", "check:impact": "bun run scripts/pr/impact-report.ts", - "check:policy": "bun test scripts/pr/change-policy.test.ts scripts/pr/changed-files.test.ts scripts/pr/pr-triage-workflow.test.ts scripts/pr/pr-quality-workflow.test.ts scripts/pr/release-workflow.test.ts scripts/pr/quality-contract.test.ts scripts/git-hooks/install.test.ts scripts/quality-gate/quarantine.test.ts scripts/quality-gate/coverage.test.ts scripts/quality-gate/provider-smoke/execute.test.ts scripts/quality-gate/desktop-smoke/execute.test.ts scripts/quality-gate/providerTargets.test.ts scripts/quality-gate/runner.test.ts && bun run check:quarantine", + "check:policy": "bun test scripts/pr/change-policy.test.ts scripts/pr/changed-files.test.ts scripts/pr/pr-triage-workflow.test.ts scripts/pr/pr-quality-workflow.test.ts scripts/pr/release-workflow.test.ts scripts/pr/quality-contract.test.ts scripts/release-update-metadata.test.ts scripts/git-hooks/install.test.ts scripts/quality-gate/quarantine.test.ts scripts/quality-gate/coverage.test.ts scripts/quality-gate/provider-smoke/execute.test.ts scripts/quality-gate/desktop-smoke/execute.test.ts scripts/quality-gate/providerTargets.test.ts scripts/quality-gate/runner.test.ts && bun run check:quarantine", "check:server": "bun run scripts/pr/run-server-tests.ts", "check:desktop": "cd desktop && bun run lint && bun run test -- --run && bun run build", + "check:electron": "cd desktop && bun run check:electron", "check:adapters": "cd adapters && bun test", - "check:native": "cd desktop && bun run build:sidecars && cd src-tauri && cargo check", + "check:native": "cd desktop && bun run build:sidecars && bun run check:electron && CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron:package:dir && cd .. && bun run test:package-smoke:current", "check:docs": "npm ci --loglevel=error && npm run --loglevel=error docs:build", "check:persistence-upgrade": "bun run scripts/quality-gate/persistence-upgrade.ts", "check:quarantine": "bun run scripts/quality-gate/quarantine.ts --enforce-review-date", @@ -30,6 +31,8 @@ "quality:baseline": "bun run quality:gate --mode baseline", "quality:release": "bun run quality:gate --mode release", "quality:smoke": "bun run quality:gate --mode baseline --allow-live --only 'provider-smoke:*' --only 'desktop-smoke:*'", + "test:package-smoke": "bun run scripts/quality-gate/package-smoke/index.ts", + "test:package-smoke:current": "bun run scripts/quality-gate/package-smoke/current.ts", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs" diff --git a/scripts/pr/change-policy.ts b/scripts/pr/change-policy.ts index 7d475d2b..b07ce1a8 100644 --- a/scripts/pr/change-policy.ts +++ b/scripts/pr/change-policy.ts @@ -60,9 +60,10 @@ const desktopNativeExactPaths = new Set([ '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', + 'desktop/electron/tsconfig.json', + 'desktop/scripts/build-macos-arm64.sh', + 'desktop/scripts/build-windows-x64.ps1', + 'desktop/scripts/build-linux.sh', ]) const docsExactPaths = new Set([ @@ -83,9 +84,10 @@ const releaseExactPaths = new Set([ '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-macos-arm64.sh', + 'desktop/scripts/build-windows-x64.ps1', + 'desktop/scripts/build-linux.sh', ]) const coveragePolicyExactPaths = new Set([ diff --git a/scripts/pr/impact-report.ts b/scripts/pr/impact-report.ts index 8789d5c0..132be065 100644 --- a/scripts/pr/impact-report.ts +++ b/scripts/pr/impact-report.ts @@ -97,8 +97,8 @@ function coverageWarnings(files: string[]) { function riskNotes(files: string[]) { const notes: string[] = [] - if (files.some((file) => file.startsWith('desktop/src-tauri/'))) { - notes.push('Tauri/native code changed: check sidecar build and cargo check output closely.') + if (files.some((file) => file.startsWith('desktop/src-tauri/') || file.startsWith('desktop/electron/'))) { + notes.push('Desktop native host code changed: check sidecar build, Electron IPC, packaging config, and runtime smoke output closely.') } if (files.some((file) => file.startsWith('desktop/src/stores/') || file.startsWith('desktop/src/api/'))) { notes.push('Desktop state/API layer changed: verify store persistence, WebSocket behavior, and startup errors.') diff --git a/scripts/pr/quality-contract.test.ts b/scripts/pr/quality-contract.test.ts index 56a0b9db..b2d7b4b7 100644 --- a/scripts/pr/quality-contract.test.ts +++ b/scripts/pr/quality-contract.test.ts @@ -45,6 +45,9 @@ describe('feature quality contract', () => { expect(packageJson.scripts?.['quality:verify']).toBe('bun run quality:pr') expect(packageJson.scripts?.['quality:push']).toBe('bun run quality:gate --mode pr --skip coverage') expect(packageJson.scripts?.['check:persistence-upgrade']).toBe('bun run scripts/quality-gate/persistence-upgrade.ts') + expect(packageJson.scripts?.['check:native']).toContain('electron:package:dir') + expect(packageJson.scripts?.['check:native']).toContain('test:package-smoke:current') + expect(packageJson.scripts?.['test:package-smoke:current']).toBe('bun run scripts/quality-gate/package-smoke/current.ts') expect(prePushHook).toContain('bun run quality:push') expect(prePushHook).not.toContain('\nbun run quality:pr\n') expect(contributing).toContain('bun run verify') @@ -57,6 +60,20 @@ describe('feature quality contract', () => { expect(rootContributing).toContain('bun run quality:push') }) + test('keeps desktop native CI aligned with Electron packaging', () => { + const prQuality = readFileSync('.github/workflows/pr-quality.yml', 'utf8') + const buildSidecars = readFileSync('desktop/scripts/build-sidecars.ts', 'utf8') + + expect(prQuality).toContain('run: bun run check:native') + expect(prQuality).not.toContain('dtolnay/rust-toolchain') + expect(prQuality).not.toContain('swatinem/rust-cache') + expect(prQuality).not.toContain('libwebkit2gtk') + expect(prQuality).not.toContain('libayatana-appindicator') + expect(buildSidecars).not.toContain("Bun.spawn(['rustc'") + expect(buildSidecars).toContain("process.platform") + expect(buildSidecars).toContain("process.arch") + }) + test('keeps general AI coding tools pointed at the same quality bar', () => { const instructions = readFileSync('.github/copilot-instructions.md', 'utf8') diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts index bef3d1b5..0ae5668f 100644 --- a/scripts/pr/release-workflow.test.ts +++ b/scripts/pr/release-workflow.test.ts @@ -2,12 +2,12 @@ import { describe, expect, test } from 'bun:test' import { readFileSync } from 'node:fs' describe('release desktop workflow', () => { - test('build job runs directly without quality preflight dependency', () => { + test('build job waits for a PR-quality preflight before packaging', () => { const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8') - expect(workflow).not.toContain('quality-preflight:') - expect(workflow).not.toContain('run: bun run quality:gate --mode pr') - expect(workflow).not.toContain('needs: quality-preflight') + expect(workflow).toContain('quality-preflight:') + expect(workflow).toContain('run: bun run verify') + expect(workflow).toContain('- quality-preflight') expect(workflow).toContain('name: Build (${{ matrix.label }})') }) @@ -17,18 +17,110 @@ describe('release desktop workflow', () => { '.github/workflows/release-desktop.yml', ]) { const workflow = readFileSync(workflowPath, 'utf8') - for (const stepName of ['Build sidecars', 'Build Tauri app']) { + for (const stepName of ['Build sidecars']) { const step = workflow.match( - new RegExp(`- name: ${stepName}[\\s\\S]*?(?:\\n\\s{6}- name:|\\n\\s*# ──|\\n\\s*with:|$)`), + new RegExp(`- name: ${stepName}[\\s\\S]*?(?:\\n\\s{6}- name:|\\n\\s*with:|$)`), )?.[0] expect(step, `${workflowPath} ${stepName}`).toContain( 'BUN_INSTALL_CACHE_DIR: ${{ runner.temp }}/bun-install-cache', ) expect(step, `${workflowPath} ${stepName}`).toContain( - 'TAURI_ENV_TARGET_TRIPLE: ${{ matrix.rust_target }}', + 'SIDECAR_TARGET_TRIPLE: ${{ matrix.target_triple }}', ) } + + expect(workflow).toContain('Build Electron') + expect(workflow).toContain('smoke_platform') + expect(workflow).toContain('bun run test:package-smoke --platform ${{ matrix.smoke_platform }} --package-kind release --artifacts-dir desktop/build-artifacts/electron') + expect(workflow).not.toContain('tauri-apps/tauri-action@v0') } }) + + test('release workflow requires macOS Gatekeeper launch approval before upload', () => { + const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8') + const gatekeeperStep = workflow.match( + /- name: Verify macOS launch policy[\s\S]*?(?:\n\s{6}- name:|$)/, + )?.[0] + + expect(gatekeeperStep).toContain("if: matrix.smoke_platform == 'macos'") + expect(gatekeeperStep).toContain('bun run test:package-smoke --platform macos --package-kind release --artifacts-dir desktop/build-artifacts/electron --require-macos-gatekeeper') + expect(workflow.indexOf('Verify macOS launch policy')).toBeLessThan(workflow.indexOf('Upload artifacts')) + }) + + test('release workflow fails globally before matrix fan-out when signing or notarization secrets are missing', () => { + const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8') + const signingJob = workflow.match( + /signing-preflight:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/, + )?.[0] + const buildJob = workflow.match( + /build:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/, + )?.[0] + + expect(signingJob).toContain('Validate release signing and notarization secrets') + for (const secret of [ + 'MACOS_CERTIFICATE', + 'MACOS_CERTIFICATE_PASSWORD', + 'APPLE_ID', + 'APPLE_APP_SPECIFIC_PASSWORD', + 'APPLE_TEAM_ID', + 'WINDOWS_CERTIFICATE', + 'WINDOWS_CERTIFICATE_PASSWORD', + ]) { + expect(signingJob).toContain(secret) + } + expect(signingJob).toContain('Missing required release signing/notarization secrets') + expect(buildJob).toContain('- quality-preflight') + expect(buildJob).toContain('- signing-preflight') + expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('build:')) + expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('Upload artifacts')) + }) + + test('release workflow avoids same-name updater metadata uploads from matrix builds', () => { + const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8') + const namespaceStep = workflow.match( + /- name: Namespace update metadata assets[\s\S]*?(?:\n\s{6}- name:|$)/, + )?.[0] + + expect(namespaceStep).toContain('for file in latest*.yml') + expect(namespaceStep).toContain('"${file%.yml}-${{ matrix.label }}.yml"') + expect(workflow.indexOf('Namespace update metadata assets')).toBeLessThan(workflow.indexOf('Upload artifacts')) + }) + + test('release workflow republishes standard updater metadata after all matrix builds pass', () => { + const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8') + const publishJob = workflow.match( + /publish-update-metadata:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/, + )?.[0] + + expect(workflow).toContain('name: desktop-update-metadata-${{ matrix.label }}') + expect(publishJob).toContain('needs: build') + expect(publishJob).toContain('actions/download-artifact@v4') + expect(publishJob).toContain('pattern: desktop-update-metadata-*') + expect(publishJob).toContain('bun run scripts/release-update-metadata.ts --metadata-dir artifacts/update-metadata --out-dir artifacts/update-metadata-standard') + expect(publishJob).toContain('files: artifacts/update-metadata-standard/*.yml') + expect(workflow.indexOf('publish-update-metadata:')).toBeGreaterThan(workflow.indexOf('build:')) + }) + + test('Electron Builder publish config does not rely on git remote autodetection', () => { + const desktopPackage = JSON.parse(readFileSync('desktop/package.json', 'utf8')) as { + build: { + publish?: Array<{ provider?: string, owner?: string, repo?: string }> + mac?: { publish?: unknown } + win?: { publish?: unknown } + linux?: { publish?: unknown } + } + } + + expect(desktopPackage.build.publish).toEqual([ + { + provider: 'github', + owner: 'NanmiCoder', + repo: 'cc-haha', + }, + ]) + expect(desktopPackage.build.mac?.publish).toBeUndefined() + expect(desktopPackage.build.win?.publish).toBeUndefined() + expect(desktopPackage.build.linux?.publish).toBeUndefined() + }) }) diff --git a/scripts/quality-gate/desktop-smoke/execute.test.ts b/scripts/quality-gate/desktop-smoke/execute.test.ts index 0531d06c..3d5f1b9f 100644 --- a/scripts/quality-gate/desktop-smoke/execute.test.ts +++ b/scripts/quality-gate/desktop-smoke/execute.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { resolveDesktopSmokeRuntimeSelection } from './execute' +import { buildDesktopSmokeBrowserEnv, resolveDesktopSmokeRuntimeSelection } from './execute' describe('desktop smoke runtime selection', () => { test('lets current-runtime use the desktop default active provider', () => { @@ -30,3 +30,27 @@ describe('desktop smoke runtime selection', () => { }) }) }) + +describe('desktop smoke browser environment', () => { + test('scopes agent-browser to a temporary session and bypasses loopback proxy traffic', () => { + expect(buildDesktopSmokeBrowserEnv('session-a', '/tmp/profile-a', { + NO_PROXY: 'internal.example.com', + })).toEqual({ + AGENT_BROWSER_SESSION: 'session-a', + AGENT_BROWSER_PROFILE: '/tmp/profile-a', + NO_PROXY: 'internal.example.com,127.0.0.1,localhost,::1,[::1]', + no_proxy: 'internal.example.com,127.0.0.1,localhost,::1,[::1]', + }) + }) + + test('deduplicates existing lowercase no_proxy loopback entries', () => { + expect(buildDesktopSmokeBrowserEnv('session-b', '/tmp/profile-b', { + no_proxy: 'localhost,127.0.0.1', + })).toEqual({ + AGENT_BROWSER_SESSION: 'session-b', + AGENT_BROWSER_PROFILE: '/tmp/profile-b', + NO_PROXY: 'localhost,127.0.0.1,::1,[::1]', + no_proxy: 'localhost,127.0.0.1,::1,[::1]', + }) + }) +}) diff --git a/scripts/quality-gate/desktop-smoke/execute.ts b/scripts/quality-gate/desktop-smoke/execute.ts index e3deda8e..3b31426d 100644 --- a/scripts/quality-gate/desktop-smoke/execute.ts +++ b/scripts/quality-gate/desktop-smoke/execute.ts @@ -1,18 +1,37 @@ -import { appendFileSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { mkdtemp } from 'node:fs/promises' import { createServer } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' +import treeKill from 'tree-kill' import { changedFiles, writeDiffPatch } from '../baseline/execute' import type { BaselineTarget, LaneResult } from '../types' const FIXTURE = 'scripts/quality-gate/desktop-smoke/fixtures/chat-edit' +const AGENT_BROWSER_HOME = join(process.env.HOME ?? '', '.agent-browser') +const LOOPBACK_PROXY_BYPASS = '127.0.0.1,localhost,::1,[::1]' const PROMPT = [ 'Run the tests in this project, fix the failing greeting implementation, and rerun the tests.', 'Only edit src/greeting.ts. Do not edit package.json or tests.', 'When the tests pass, briefly say done.', ].join(' ') +type DesktopSmokeStage = 'open' | 'eval' | 'reload' | 'wait' | 'screenshot' | 'fill' | 'press' | 'verify' + +type DesktopSmokeFailureContext = { + stage: DesktopSmokeStage + sessionName: string + browserProfileDir: string + artifactDir: string + browserLogPath: string + serverLogPath: string + viteLogPath: string + appUrl: string + baseUrl: string + serverPort: number + vitePort: number +} + export function resolveDesktopSmokeRuntimeSelection(target: BaselineTarget | undefined) { if (!target) return null if (!target.providerId && target.modelId === 'current' && target.label === 'current-runtime') { @@ -24,6 +43,142 @@ export function resolveDesktopSmokeRuntimeSelection(target: BaselineTarget | und } } +function mergeProxyBypass(value: string | undefined) { + const entries = new Set( + (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean), + ) + for (const entry of LOOPBACK_PROXY_BYPASS.split(',')) { + entries.add(entry) + } + return Array.from(entries).join(',') +} + +export function buildDesktopSmokeBrowserEnv( + sessionName: string, + browserProfileDir: string, + baseEnv: Record = process.env, +) { + const noProxy = mergeProxyBypass(baseEnv.NO_PROXY ?? baseEnv.no_proxy) + return { + AGENT_BROWSER_SESSION: sessionName, + AGENT_BROWSER_PROFILE: browserProfileDir, + NO_PROXY: noProxy, + no_proxy: noProxy, + } +} + +function agentBrowserCommand(args: string[]) { + return ['agent-browser', '--proxy-bypass', LOOPBACK_PROXY_BYPASS, ...args] +} + +function isProcessAlive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function waitForProcessExit(pid: number, timeoutMs: number) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) return true + await Bun.sleep(100) + } + return !isProcessAlive(pid) +} + +async function killProcessTree(pid: number, signal: 'SIGTERM' | 'SIGKILL') { + await new Promise((resolve, reject) => { + treeKill(pid, signal, (error) => { + if (error) reject(error) + else resolve() + }) + }) +} + +async function cleanupAgentBrowserSession(sessionName: string, logPath: string) { + if (!sessionName || !existsSync(AGENT_BROWSER_HOME)) return + + const metadataSuffixes = ['pid', 'sock', 'stream', 'engine', 'version'] + const pidPath = join(AGENT_BROWSER_HOME, `${sessionName}.pid`) + if (existsSync(pidPath)) { + const rawPid = readFileSync(pidPath, 'utf8').trim() + const pid = Number(rawPid) + if (Number.isInteger(pid) && pid > 0) { + try { + await killProcessTree(pid, 'SIGTERM') + if (!await waitForProcessExit(pid, 1_500)) { + await killProcessTree(pid, 'SIGKILL') + } + appendFileSync(logPath, `\n[quality-gate] Killed agent-browser session process tree pid=${pid}\n`) + } catch (error) { + appendFileSync(logPath, `\n[quality-gate] Failed to kill agent-browser session process tree pid=${pid}: ${error instanceof Error ? error.message : String(error)}\n`) + } + } + } + + for (const suffix of metadataSuffixes) { + rmSync(join(AGENT_BROWSER_HOME, `${sessionName}.${suffix}`), { force: true }) + } +} + +function cleanupBrowserProfileProcesses(browserProfileDir: string, logPath: string) { + if (process.platform === 'win32') { + appendFileSync(logPath, '\n[quality-gate] Skipped browser profile process cleanup on Windows\n') + return + } + + const proc = Bun.spawnSync(['pkill', '-9', '-f', browserProfileDir], { + stdout: 'pipe', + stderr: 'pipe', + }) + const output = `${proc.stdout ? Buffer.from(proc.stdout).toString('utf8') : ''}${proc.stderr ? Buffer.from(proc.stderr).toString('utf8') : ''}`.trim() + if (proc.exitCode === 0) { + appendFileSync(logPath, `\n[quality-gate] Killed browser processes for profile ${browserProfileDir}\n`) + } else if (output) { + appendFileSync(logPath, `\n[quality-gate] Browser profile process cleanup reported: ${output}\n`) + } +} + +function writeDesktopSmokeFailure(context: DesktopSmokeFailureContext, error: unknown) { + const message = error instanceof Error ? error.message : String(error) + const diagnostics = { + stage: context.stage, + message, + sessionName: context.sessionName, + browserProfileDir: context.browserProfileDir, + appUrl: context.appUrl, + baseUrl: context.baseUrl, + serverPort: context.serverPort, + vitePort: context.vitePort, + logs: { + browser: context.browserLogPath, + server: context.serverLogPath, + vite: context.viteLogPath, + }, + } + writeFileSync(join(context.artifactDir, 'desktop-smoke-failure.json'), JSON.stringify(diagnostics, null, 2) + '\n') + return new Error(`Desktop smoke failed during ${context.stage}: ${message}. session=${context.sessionName} appUrl=${context.appUrl} profile=${context.browserProfileDir} diagnostics=${join(context.artifactDir, 'desktop-smoke-failure.json')}`) +} + +async function runBrowserStep( + stage: DesktopSmokeStage, + args: string[], + options: Parameters[1], + context: Omit, +) { + try { + return await runLoggedCommand(agentBrowserCommand(args), options) + } catch (error) { + throw writeDesktopSmokeFailure({ ...context, stage }, error) + } +} + async function getPort(): Promise { return await new Promise((resolve, reject) => { const server = createServer() @@ -143,7 +298,7 @@ async function waitForVerifiedProject( const deadline = Date.now() + timeoutMs let lastVerificationError = 'project verification has not run yet' while (Date.now() < deadline) { - const body = await runLoggedCommand(['agent-browser', 'get', 'text', '#content-area'], { + const body = await runLoggedCommand(agentBrowserCommand(['get', 'text', '#content-area']), { cwd: rootDir, env: browserEnv, logPath: browserLogPath, @@ -234,9 +389,18 @@ export async function executeDesktopSmoke( const baseUrl = `http://127.0.0.1:${serverPort}` const appUrl = `http://127.0.0.1:${vitePort}` const sessionName = `quality-gate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - const browserEnv = { - AGENT_BROWSER_SESSION: sessionName, - AGENT_BROWSER_PROFILE: browserProfileDir, + const browserEnv = buildDesktopSmokeBrowserEnv(sessionName, browserProfileDir) + const browserStepContext = { + sessionName, + browserProfileDir, + artifactDir, + browserLogPath, + serverLogPath, + viteLogPath, + appUrl, + baseUrl, + serverPort, + vitePort, } const server = Bun.spawn(['bun', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(serverPort)], { @@ -285,12 +449,12 @@ export async function executeDesktopSmoke( ? { source: 'explicit-target', ...runtimeSelection } : { source: 'default-runtime' }, null, 2) + '\n') - await runLoggedCommand(['agent-browser', 'open', appUrl], { + await runBrowserStep('open', ['open', appUrl], { cwd: rootDir, env: browserEnv, logPath: browserLogPath, timeoutMs: 30_000, - }) + }, browserStepContext) const browserSetup = [ `localStorage.setItem('cc-haha-open-tabs', ${JSON.stringify(JSON.stringify({ openTabs: [{ sessionId: session.sessionId, title: 'Desktop Smoke', type: 'session' }], @@ -302,47 +466,43 @@ export async function executeDesktopSmoke( }))})` : `localStorage.removeItem('cc-haha-session-runtime')`, ] - await runLoggedCommand([ - 'agent-browser', - 'eval', - browserSetup.join(';'), - ], { + await runBrowserStep('eval', ['eval', browserSetup.join(';')], { cwd: rootDir, env: browserEnv, logPath: browserLogPath, timeoutMs: 15_000, - }) - await runLoggedCommand(['agent-browser', 'reload'], { + }, browserStepContext) + await runBrowserStep('reload', ['reload'], { cwd: rootDir, env: browserEnv, logPath: browserLogPath, timeoutMs: 30_000, - }) - await runLoggedCommand(['agent-browser', 'wait', 'textarea'], { + }, browserStepContext) + await runBrowserStep('wait', ['wait', 'textarea'], { cwd: rootDir, env: browserEnv, logPath: browserLogPath, timeoutMs: 30_000, - }) - await runLoggedCommand(['agent-browser', 'screenshot', join(artifactDir, 'initial.png')], { + }, browserStepContext) + await runBrowserStep('screenshot', ['screenshot', join(artifactDir, 'initial.png')], { cwd: rootDir, env: browserEnv, logPath: browserLogPath, timeoutMs: 20_000, allowFailure: true, - }) - await runLoggedCommand(['agent-browser', 'fill', 'textarea', PROMPT], { + }, browserStepContext) + await runBrowserStep('fill', ['fill', 'textarea', PROMPT], { cwd: rootDir, env: browserEnv, logPath: browserLogPath, timeoutMs: 20_000, - }) - await runLoggedCommand(['agent-browser', 'press', 'Enter'], { + }, browserStepContext) + await runBrowserStep('press', ['press', 'Enter'], { cwd: rootDir, env: browserEnv, logPath: browserLogPath, timeoutMs: 15_000, - }) + }, browserStepContext) await waitForVerifiedProject( browserEnv, @@ -353,13 +513,13 @@ export async function executeDesktopSmoke( artifactDir, 360_000, ) - await runLoggedCommand(['agent-browser', 'screenshot', join(artifactDir, 'final.png')], { + await runBrowserStep('screenshot', ['screenshot', join(artifactDir, 'final.png')], { cwd: rootDir, env: browserEnv, logPath: browserLogPath, timeoutMs: 20_000, allowFailure: true, - }) + }, browserStepContext) return { id: resultId, @@ -383,13 +543,17 @@ export async function executeDesktopSmoke( appendFileSync(serverLogPath, `\n[quality-gate] Failed to restore permission mode: ${error instanceof Error ? error.message : String(error)}\n`) }) } - await runLoggedCommand(['agent-browser', 'close'], { + await runLoggedCommand(agentBrowserCommand(['close']), { cwd: rootDir, env: browserEnv, logPath: browserLogPath, timeoutMs: 10_000, allowFailure: true, }).catch(() => {}) + await cleanupAgentBrowserSession(sessionName, browserLogPath) + cleanupBrowserProfileProcesses(browserProfileDir, browserLogPath) + rmSync(browserProfileDir, { recursive: true, force: true }) + appendFileSync(browserLogPath, `\n[quality-gate] Removed browser profile ${browserProfileDir}\n`) server.kill() vite.kill() rmSync(workRoot, { recursive: true, force: true }) diff --git a/scripts/quality-gate/modes.ts b/scripts/quality-gate/modes.ts index 863dc478..cd74fbfd 100644 --- a/scripts/quality-gate/modes.ts +++ b/scripts/quality-gate/modes.ts @@ -1,7 +1,26 @@ import { baselineCases } from './baseline/cases' import type { BaselineTarget, LaneDefinition, QualityGateMode } from './types' +export function currentPackageSmokePlatform(platform: NodeJS.Platform = process.platform) { + if (platform === 'darwin') return 'macos' + if (platform === 'win32') return 'windows' + if (platform === 'linux') return 'linux' + return null +} + +export function currentReleaseArtifactsDir( + platform: NodeJS.Platform = process.platform, + arch: NodeJS.Architecture = process.arch, +) { + if (platform === 'darwin') return arch === 'x64' ? 'desktop/build-artifacts/macos-x64' : 'desktop/build-artifacts/macos-arm64' + if (platform === 'win32') return arch === 'arm64' ? 'desktop/build-artifacts/windows-arm64' : 'desktop/build-artifacts/windows-x64' + if (platform === 'linux') return arch === 'arm64' ? 'desktop/build-artifacts/linux-arm64' : 'desktop/build-artifacts/linux-x64' + return null +} + export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTarget[] = []): LaneDefinition[] { + const packageSmokePlatform = currentPackageSmokePlatform() + const releaseArtifactsDir = currentReleaseArtifactsDir() const lanes: LaneDefinition[] = [ { id: 'impact-report', @@ -29,7 +48,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar kind: 'command', command: ['bun', 'run', 'check:desktop'], impactRequiredCheck: 'bun run check:desktop', - requiredForModes: ['pr'], + requiredForModes: ['pr', 'release'], category: 'unit', }, { @@ -39,7 +58,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar kind: 'command', command: ['bun', 'run', 'check:server'], impactRequiredCheck: 'bun run check:server', - requiredForModes: ['pr'], + requiredForModes: ['pr', 'release'], category: 'unit', }, { @@ -49,13 +68,13 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar kind: 'command', command: ['bun', 'run', 'check:adapters'], impactRequiredCheck: 'bun run check:adapters', - requiredForModes: ['pr'], + requiredForModes: ['pr', 'release'], category: 'unit', }, { id: 'native-checks', title: 'Native desktop checks', - description: 'Build sidecars and run the Tauri native compile check when native or packaging paths changed.', + description: 'Build sidecars and run Electron main/preload checks when native or packaging paths changed.', kind: 'command', command: ['bun', 'run', 'check:native'], impactRequiredCheck: 'bun run check:native', @@ -69,7 +88,7 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar kind: 'command', command: ['bun', 'run', 'check:docs'], impactRequiredCheck: 'bun run check:docs', - requiredForModes: ['pr'], + requiredForModes: ['pr', 'release'], category: 'docs', }, { @@ -110,6 +129,23 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar }, ] + if (packageSmokePlatform && releaseArtifactsDir) { + const packageSmokeCommand = ['bun', 'run', 'test:package-smoke', '--platform', packageSmokePlatform, '--package-kind', 'release', '--artifacts-dir', releaseArtifactsDir] + if (packageSmokePlatform === 'macos') { + packageSmokeCommand.push('--require-macos-gatekeeper') + } + + lanes.push({ + id: `desktop-package-smoke:${packageSmokePlatform}`, + title: `Desktop packaged artifact smoke (${packageSmokePlatform})`, + description: 'Inspect the current-platform canonical Electron release artifact for app metadata, app.asar, sidecar binaries, update metadata, and unpacked native runtime resources. GUI behavior is verified separately with Computer Use against the real packaged app.', + kind: 'command', + command: packageSmokeCommand, + requiredForModes: ['release'], + category: 'smoke', + }) + } + const targets = baselineTargets.length > 0 ? baselineTargets : [{ providerId: null, modelId: 'current', label: 'current-runtime' }] @@ -150,10 +186,10 @@ export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTar lanes.push({ id: `desktop-smoke:agent-browser-chat:${targetSlug}`, title: `Desktop agent-browser chat smoke (${target.label})`, - description: 'Open the desktop web app with agent-browser, send a real chat task, and verify the model edits a fixture project through the UI.', + description: 'Open the desktop web app with agent-browser, send a real chat task, and verify the model edits a fixture project through the UI. This remains a browser/web-app confidence lane; Electron packaged-app acceptance is covered by package-smoke plus manual Computer Use evidence.', kind: 'desktop-smoke', baselineTarget: target, - requiredForModes: ['baseline', 'release'], + requiredForModes: ['baseline'], category: 'smoke', live: true, }) diff --git a/scripts/quality-gate/package-smoke/current.ts b/scripts/quality-gate/package-smoke/current.ts new file mode 100644 index 00000000..e34ae7e0 --- /dev/null +++ b/scripts/quality-gate/package-smoke/current.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env bun + +import { spawnSync } from 'node:child_process' + +export function currentPackageSmokePlatform(platform: NodeJS.Platform = process.platform) { + if (platform === 'darwin') return 'macos' + if (platform === 'win32') return 'windows' + if (platform === 'linux') return 'linux' + return null +} + +if (import.meta.main) { + const platform = currentPackageSmokePlatform() + if (!platform) { + console.log(`[package-smoke] skipping unsupported host platform: ${process.platform}`) + process.exit(0) + } + + const result = spawnSync('bun', [ + 'run', + 'test:package-smoke', + '--platform', + platform, + '--package-kind', + 'dir', + '--artifacts-dir', + 'desktop/build-artifacts/electron', + ], { + stdio: 'inherit', + }) + process.exit(result.status ?? 1) +} diff --git a/scripts/quality-gate/package-smoke/index.test.ts b/scripts/quality-gate/package-smoke/index.test.ts new file mode 100644 index 00000000..3478154d --- /dev/null +++ b/scripts/quality-gate/package-smoke/index.test.ts @@ -0,0 +1,412 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { + currentPackageSmokePlatform, +} from './current' +import { + inspectPackagedArtifacts, + parsePackageSmokeArgs, +} from './index' + +function createRepoRoot() { + const rootDir = mkdtempSync(join(tmpdir(), 'package-smoke-')) + mkdirSync(join(rootDir, 'desktop'), { recursive: true }) + writeFileSync( + join(rootDir, 'desktop', 'package.json'), + JSON.stringify({ + name: 'claude-code-desktop', + version: '0.3.1', + build: { + productName: 'Claude Code Haha', + }, + }, null, 2), + ) + return rootDir +} + +function writeFile(rootDir: string, relativePath: string, content = 'ok') { + const fullPath = join(rootDir, relativePath) + mkdirSync(dirname(fullPath), { recursive: true }) + writeFileSync(fullPath, content) +} + +const tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('package smoke args', () => { + test('requires a supported platform value', () => { + expect(() => parsePackageSmokeArgs([])).toThrow('--platform') + expect(() => parsePackageSmokeArgs(['--platform', 'android'])).toThrow('macos|windows|linux') + expect(() => parsePackageSmokeArgs(['--platform', 'macos', '--package-kind', 'installer'])).toThrow('auto|dir|release') + expect(parsePackageSmokeArgs(['--platform', 'macos']).platform).toBe('macos') + expect(parsePackageSmokeArgs(['--platform', 'macos']).packageKind).toBe('auto') + expect(parsePackageSmokeArgs(['--platform', 'macos', '--package-kind', 'dir']).packageKind).toBe('dir') + expect(parsePackageSmokeArgs(['--platform', 'macos', '--require-macos-gatekeeper']).requireMacosGatekeeper).toBe(true) + }) + + test('maps host platforms to current package-smoke platforms', () => { + expect(currentPackageSmokePlatform('darwin')).toBe('macos') + expect(currentPackageSmokePlatform('win32')).toBe('windows') + expect(currentPackageSmokePlatform('linux')).toBe('linux') + expect(currentPackageSmokePlatform('freebsd')).toBeNull() + }) +}) + +describe('packaged artifact inspection', () => { + test('passes macOS bundle structure checks and records optional archive artifacts', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Info.plist') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-apple-darwin') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper') + writeFile(rootDir, 'desktop/build-artifacts/electron/Claude-Code-Haha-0.3.1-arm64.zip') + writeFile(rootDir, 'desktop/build-artifacts/electron/Claude-Code-Haha-0.3.1-arm64.zip.blockmap') + writeFile(rootDir, 'desktop/build-artifacts/electron/Claude-Code-Haha-0.3.1-arm64.dmg') + writeFile(rootDir, 'desktop/build-artifacts/electron/Claude-Code-Haha-0.3.1-arm64.dmg.blockmap') + writeFile(rootDir, 'desktop/build-artifacts/electron/latest-mac.yml', [ + 'version: 0.3.1', + 'files:', + ' - url: Claude-Code-Haha-0.3.1-arm64.zip', + ' - url: Claude-Code-Haha-0.3.1-arm64.dmg', + 'path: Claude-Code-Haha-0.3.1-arm64.zip', + ].join('\n')) + + const report = await inspectPackagedArtifacts(rootDir, { platform: 'macos' }) + + expect(report.passed).toBe(true) + expect(report.verificationMode).toBe('bundle-structure') + expect(report.notes.join('\n')).toContain('No GUI launch was attempted.') + expect(report.optionalArtifacts.some((artifact) => artifact.path.endsWith('.zip'))).toBe(true) + expect(report.optionalArtifacts.some((artifact) => artifact.path.endsWith('.dmg'))).toBe(true) + expect(report.optionalArtifacts.some((artifact) => artifact.path.endsWith('latest-mac.yml'))).toBe(true) + expect(report.passedChecks.some((check) => check.label.includes('update metadata referenced artifact'))).toBe(true) + expect(report.passedChecks.some((check) => check.label.includes('macOS update artifact blockmap'))).toBe(true) + }) + + test('fails macOS archive checks when latest-mac.yml points at missing assets', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Info.plist') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-apple-darwin') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper') + writeFile(rootDir, 'desktop/build-artifacts/electron/Claude Code Haha-0.3.1-arm64-mac.zip') + writeFile(rootDir, 'desktop/build-artifacts/electron/latest-mac.yml', 'path: Claude-Code-Haha-0.3.1-arm64-mac.zip\n') + + const report = await inspectPackagedArtifacts(rootDir, { platform: 'macos' }) + + expect(report.passed).toBe(false) + expect(report.missingChecks.some((check) => check.label.includes('update metadata referenced artifact'))).toBe(true) + }) + + test('fails macOS inspection when the installed updater metadata is missing from Resources', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Info.plist') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-apple-darwin') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper') + writeFile(rootDir, 'desktop/build-artifacts/electron/Claude-Code-Haha-0.3.1-arm64.zip') + writeFile(rootDir, 'desktop/build-artifacts/electron/latest-mac.yml', [ + 'version: 0.3.1', + 'files:', + ' - url: Claude-Code-Haha-0.3.1-arm64.zip', + 'path: Claude-Code-Haha-0.3.1-arm64.zip', + ].join('\n')) + + const report = await inspectPackagedArtifacts(rootDir, { platform: 'macos' }) + + expect(report.passed).toBe(false) + expect(report.missingChecks.some((check) => check.label === 'macOS app-update.yml')).toBe(true) + }) + + test('fails release inspection when an update artifact blockmap is missing', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Info.plist') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-apple-darwin') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper') + writeFile(rootDir, 'desktop/build-artifacts/electron/Claude-Code-Haha-0.3.1-arm64.zip') + writeFile(rootDir, 'desktop/build-artifacts/electron/latest-mac.yml', 'path: Claude-Code-Haha-0.3.1-arm64.zip\n') + + const report = await inspectPackagedArtifacts(rootDir, { platform: 'macos', packageKind: 'release' }) + + expect(report.passed).toBe(false) + expect(report.missingChecks.some((check) => check.label.includes('macOS update artifact blockmap'))).toBe(true) + }) + + test('adds codesign diagnostics when macOS Gatekeeper assessment fails', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Info.plist') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-apple-darwin') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper') + + const report = await inspectPackagedArtifacts(rootDir, { + platform: 'macos', + packageKind: 'dir', + requireMacosGatekeeper: true, + hostPlatform: 'macos', + commandRunner: (command, args) => { + if (command.endsWith('/spctl')) { + return { status: 1, stdout: '', stderr: 'rejected\nsource=Unnotarized Developer ID\n' } + } + if (command.endsWith('/codesign')) { + return { status: 1, stdout: '', stderr: 'code object is not signed at all\n' } + } + if (command.endsWith('/xcrun') && args[0] === 'stapler') { + return { status: 65, stdout: '', stderr: 'The validate action failed!\n' } + } + return { status: 0, stdout: '', stderr: '' } + }, + }) + + expect(report.passed).toBe(false) + expect(report.missingChecks.some((check) => check.label.includes('macOS Gatekeeper launch approval'))).toBe(true) + expect(report.notes.join('\n')).toContain('spctl Gatekeeper assessment exited with status 1') + expect(report.notes.join('\n')).toContain('codesign verification exited with status 1') + expect(report.notes.join('\n')).toContain('codesign signature details exited with status 1') + expect(report.notes.join('\n')).toContain('notarization ticket validation exited with status 65') + }) + + test('retries macOS Gatekeeper assessment with a raised file limit when spctl hits open-file limits', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Info.plist') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/MacOS/Claude Code Haha') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-apple-darwin') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper') + + const commands: string[] = [] + const report = await inspectPackagedArtifacts(rootDir, { + platform: 'macos', + packageKind: 'dir', + requireMacosGatekeeper: true, + hostPlatform: 'macos', + commandRunner: (command, args) => { + commands.push(`${command} ${args.join(' ')}`) + if (command.endsWith('/spctl')) { + return { status: 1, stdout: '', stderr: 'Too many open files\n' } + } + if (command.endsWith('/zsh')) { + return { status: 1, stdout: '', stderr: 'bundle format unrecognized, invalid, or unsuitable\n' } + } + if (command.endsWith('/codesign')) { + return { status: 0, stdout: '', stderr: 'valid on disk\n' } + } + if (command.endsWith('/xcrun') && args[0] === 'stapler') { + return { status: 65, stdout: '', stderr: 'does not have a ticket stapled to it\n' } + } + return { status: 0, stdout: '', stderr: '' } + }, + }) + + expect(report.passed).toBe(false) + expect(commands.some(command => command.startsWith('/bin/zsh -lc ulimit -n'))).toBe(true) + expect(report.missingChecks.some((check) => check.label.includes('bundle format unrecognized'))).toBe(true) + expect(report.notes.join('\n')).toContain('retried with a raised file descriptor limit') + expect(report.notes.join('\n')).toContain('spctl Gatekeeper assessment exited with status 1: bundle format unrecognized') + }) + + test('treats Windows checks as static inspection on non-Windows hosts', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/electron/Claude Code Haha Setup 0.3.1.exe') + writeFile(rootDir, 'desktop/build-artifacts/electron/Claude Code Haha Setup 0.3.1.exe.blockmap') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-pc-windows-msvc.exe') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-x64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/electron/latest.yml', 'path: Claude Code Haha Setup 0.3.1.exe\n') + + const report = await inspectPackagedArtifacts(rootDir, { platform: 'windows' }) + + expect(report.passed).toBe(true) + expect(report.verificationMode).toBe('static-artifact') + expect(report.notes.join('\n')).toContain('not claim installer execution or app launch success') + }) + + test('passes Windows checks against the canonical build script output directory', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/windows-x64/Claude-Code-Haha-0.3.1-x64.exe') + writeFile(rootDir, 'desktop/build-artifacts/windows-x64/Claude-Code-Haha-0.3.1-x64.exe.blockmap') + writeFile(rootDir, 'desktop/build-artifacts/windows-x64/win-unpacked/resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/windows-x64/win-unpacked/resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/windows-x64/win-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-pc-windows-msvc.exe') + writeFile(rootDir, 'desktop/build-artifacts/windows-x64/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/windows-x64/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-x64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/windows-x64/latest.yml', 'path: Claude-Code-Haha-0.3.1-x64.exe\n') + + const report = await inspectPackagedArtifacts(rootDir, { + platform: 'windows', + packageKind: 'release', + artifactsDir: 'desktop/build-artifacts/windows-x64', + }) + + expect(report.passed).toBe(true) + expect(report.artifactsDir.endsWith('desktop/build-artifacts/windows-x64')).toBe(true) + }) + + test('passes Windows directory-only checks for electron-builder --dir output', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/Claude Code Haha.exe') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-pc-windows-msvc.exe') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-x64/pty.node') + + const report = await inspectPackagedArtifacts(rootDir, { platform: 'windows', packageKind: 'dir' }) + + expect(report.passed).toBe(true) + expect(report.notes.join('\n')).toContain('directory-only development package') + expect(report.notes.join('\n')).toContain('Windows app-update.yml was not required') + }) + + test('does not treat the win-unpacked app executable as a Windows release installer', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/Claude Code Haha.exe') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-pc-windows-msvc.exe') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/electron/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/win32-x64/pty.node') + + const report = await inspectPackagedArtifacts(rootDir, { platform: 'windows', packageKind: 'release' }) + + expect(report.passed).toBe(false) + expect(report.missingChecks.some((check) => check.label.includes('.exe installer'))).toBe(true) + }) + + test('passes Linux checks against the canonical build script output directory', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/linux-x64/Claude-Code-Haha-0.3.1-x64.AppImage') + writeFile(rootDir, 'desktop/build-artifacts/linux-x64/Claude-Code-Haha-0.3.1-x64.AppImage.blockmap') + writeFile(rootDir, 'desktop/build-artifacts/linux-x64/claude-code-desktop_0.3.1_amd64.deb') + writeFile(rootDir, 'desktop/build-artifacts/linux-x64/linux-unpacked/resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/linux-x64/linux-unpacked/resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/linux-x64/linux-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-unknown-linux-gnu') + writeFile(rootDir, 'desktop/build-artifacts/linux-x64/linux-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/linux-x64/linux-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/linux-x64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/linux-x64/latest-linux.yml', 'path: Claude-Code-Haha-0.3.1-x64.AppImage\n') + + const report = await inspectPackagedArtifacts(rootDir, { + platform: 'linux', + packageKind: 'release', + artifactsDir: 'desktop/build-artifacts/linux-x64', + }) + + expect(report.passed).toBe(true) + expect(report.artifactsDir.endsWith('desktop/build-artifacts/linux-x64')).toBe(true) + }) + + test('accepts Linux architecture-specific update metadata from arm64 builds', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/linux-arm64/Claude-Code-Haha-0.3.1-arm64.AppImage') + writeFile(rootDir, 'desktop/build-artifacts/linux-arm64/Claude-Code-Haha-0.3.1-arm64.AppImage.blockmap') + writeFile(rootDir, 'desktop/build-artifacts/linux-arm64/claude-code-desktop_0.3.1_arm64.deb') + writeFile(rootDir, 'desktop/build-artifacts/linux-arm64/linux-unpacked/resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/linux-arm64/linux-unpacked/resources/app-update.yml') + writeFile(rootDir, 'desktop/build-artifacts/linux-arm64/linux-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-aarch64-unknown-linux-gnu') + writeFile(rootDir, 'desktop/build-artifacts/linux-arm64/linux-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/linux-arm64/linux-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/linux-arm64/pty.node') + writeFile(rootDir, 'desktop/build-artifacts/linux-arm64/latest-linux-arm64.yml', 'path: Claude-Code-Haha-0.3.1-arm64.AppImage\n') + + const report = await inspectPackagedArtifacts(rootDir, { + platform: 'linux', + packageKind: 'release', + artifactsDir: 'desktop/build-artifacts/linux-arm64', + }) + + expect(report.passed).toBe(true) + expect(report.optionalArtifacts.some((artifact) => artifact.path.endsWith('latest-linux-arm64.yml'))).toBe(true) + expect(report.passedChecks.some((check) => check.label.includes('update metadata referenced artifact'))).toBe(true) + }) + + test('passes Linux directory-only checks for electron-builder --dir output', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/electron/linux-unpacked/resources/app.asar') + writeFile(rootDir, 'desktop/build-artifacts/electron/linux-unpacked/resources/app.asar.unpacked/src-tauri/binaries/claude-sidecar-x86_64-unknown-linux-gnu') + writeFile(rootDir, 'desktop/build-artifacts/electron/linux-unpacked/resources/app.asar.unpacked/node_modules/node-pty/package.json') + writeFile(rootDir, 'desktop/build-artifacts/electron/linux-unpacked/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/linux-x64/pty.node') + + const report = await inspectPackagedArtifacts(rootDir, { platform: 'linux', packageKind: 'dir' }) + + expect(report.passed).toBe(true) + expect(report.notes.join('\n')).toContain('directory-only development package') + expect(report.notes.join('\n')).toContain('Linux app-update.yml was not required') + }) + + test('fails Linux inspection when no packaged artifact is present', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + const report = await inspectPackagedArtifacts(rootDir, { platform: 'linux' }) + + expect(report.passed).toBe(false) + expect(report.missingChecks.some((check) => check.label.includes('linux packaged artifact'))).toBe(true) + }) + + test('fails Linux release inspection without linux-unpacked static resources', async () => { + const rootDir = createRepoRoot() + tempDirs.push(rootDir) + + writeFile(rootDir, 'desktop/build-artifacts/electron/Claude-Code-Haha-0.3.1-x64.AppImage') + writeFile(rootDir, 'desktop/build-artifacts/electron/latest-linux.yml', 'path: Claude-Code-Haha-0.3.1-x64.AppImage\n') + + const report = await inspectPackagedArtifacts(rootDir, { platform: 'linux', packageKind: 'release' }) + + expect(report.passed).toBe(false) + expect(report.missingChecks.some((check) => check.label.includes('linux-unpacked'))).toBe(true) + }) +}) diff --git a/scripts/quality-gate/package-smoke/index.ts b/scripts/quality-gate/package-smoke/index.ts new file mode 100644 index 00000000..a7901b36 --- /dev/null +++ b/scripts/quality-gate/package-smoke/index.ts @@ -0,0 +1,791 @@ +#!/usr/bin/env bun + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { dirname, join, relative, resolve } from 'node:path' + +export type PackageSmokePlatform = 'macos' | 'windows' | 'linux' +export type VerificationMode = 'bundle-structure' | 'static-artifact' +export type PackageKind = 'auto' | 'dir' | 'release' + +type CheckRecord = { + label: string + path: string +} + +type InspectOptions = { + platform: PackageSmokePlatform + artifactsDir?: string + requireMacosGatekeeper?: boolean + packageKind?: PackageKind + commandRunner?: PackageSmokeCommandRunner + hostPlatform?: string +} + +export type PackageSmokeArgs = { + platform: PackageSmokePlatform + artifactsDir?: string + requireMacosGatekeeper?: boolean + packageKind?: PackageKind +} + +export type PackageSmokeReport = { + platform: PackageSmokePlatform + hostPlatform: string + productName: string + version: string + verificationMode: VerificationMode + packageKind: PackageKind + artifactsDir: string + packagedArtifacts: CheckRecord[] + optionalArtifacts: CheckRecord[] + passedChecks: CheckRecord[] + missingChecks: CheckRecord[] + notes: string[] + passed: boolean +} + +type DesktopMetadata = { + productName: string + version: string +} + +type PackageSmokeCommandResult = { + status: number | null + stdout?: string + stderr?: string +} + +type PackageSmokeCommandRunner = (command: string, args: string[]) => PackageSmokeCommandResult + +function usage() { + return 'Usage: bun run test:package-smoke --platform [--package-kind ] [--artifacts-dir ] [--require-macos-gatekeeper]' +} + +function readArgValue(argv: string[], index: number, flag: string) { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${flag}\n${usage()}`) + } + return value +} + +export function parsePackageSmokeArgs(argv: string[]): PackageSmokeArgs { + let platform: PackageSmokePlatform | undefined + let artifactsDir: string | undefined + let requireMacosGatekeeper = false + let packageKind: PackageKind = 'auto' + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === '--platform') { + const value = readArgValue(argv, index, arg) + if (value === 'macos' || value === 'windows' || value === 'linux') { + platform = value + } else { + throw new Error(`Unsupported --platform value: ${value}. Expected macos|windows|linux.\n${usage()}`) + } + index += 1 + continue + } + + if (arg === '--artifacts-dir') { + artifactsDir = readArgValue(argv, index, arg) + index += 1 + continue + } + + if (arg === '--package-kind') { + const value = readArgValue(argv, index, arg) + if (value === 'auto' || value === 'dir' || value === 'release') { + packageKind = value + } else { + throw new Error(`Unsupported --package-kind value: ${value}. Expected auto|dir|release.\n${usage()}`) + } + index += 1 + continue + } + + if (arg === '--require-macos-gatekeeper') { + requireMacosGatekeeper = true + continue + } + } + + if (!platform) { + throw new Error(`Missing required --platform \n${usage()}`) + } + + return { + platform, + artifactsDir, + requireMacosGatekeeper, + packageKind, + } +} + +function detectHostPlatform() { + if (process.platform === 'darwin') return 'macos' + if (process.platform === 'win32') return 'windows' + if (process.platform === 'linux') return 'linux' + return process.platform +} + +function readDesktopMetadata(rootDir: string): DesktopMetadata { + const packageJsonPath = join(rootDir, 'desktop', 'package.json') + const raw = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + version?: string + productName?: string + build?: { productName?: string } + name?: string + } + + return { + productName: raw.build?.productName ?? raw.productName ?? raw.name ?? 'app', + version: raw.version ?? 'unknown', + } +} + +function toRelative(rootDir: string, targetPath: string) { + return relative(rootDir, targetPath) || '.' +} + +function normalizePath(targetPath: string) { + return targetPath.replaceAll('\\', '/') +} + +function walkPaths(rootDir: string, options?: { directoriesOnly?: boolean }) { + if (!existsSync(rootDir)) { + return [] as string[] + } + + const results: string[] = [] + const stack = [rootDir] + + while (stack.length > 0) { + const current = stack.pop() + if (!current) continue + + let entries: ReturnType = [] + try { + entries = readdirSync(current, { withFileTypes: true }) + } catch { + continue + } + + for (const entry of entries) { + const fullPath = join(current, entry.name) + if (entry.isDirectory()) { + results.push(fullPath) + stack.push(fullPath) + } else if (!options?.directoriesOnly) { + results.push(fullPath) + } + } + } + + return results +} + +function findMatches(rootDir: string, matcher: (candidate: string) => boolean, options?: { directoriesOnly?: boolean }) { + return walkPaths(rootDir, options).filter(matcher).sort() +} + +function addPresenceCheck( + report: PackageSmokeReport, + rootDir: string, + label: string, + targetPath: string, +) { + const record = { + label, + path: toRelative(rootDir, targetPath), + } + + if (existsSync(targetPath)) { + report.passedChecks.push(record) + } else { + report.missingChecks.push(record) + } +} + +function addMatchCheck( + report: PackageSmokeReport, + rootDir: string, + label: string, + matches: string[], + fallbackPath: string, +) { + if (matches.length > 0) { + report.passedChecks.push({ + label, + path: toRelative(rootDir, matches[0]), + }) + return + } + + report.missingChecks.push({ + label, + path: toRelative(rootDir, fallbackPath), + }) +} + +function parseUpdateMetadataReferences(content: string) { + const references = [] as string[] + const pattern = /^\s*(?:url|path):\s*['"]?([^'"\n]+?)['"]?\s*$/gm + let match: RegExpExecArray | null + while ((match = pattern.exec(content))) { + const value = match[1]?.trim() + if (!value || value.includes('://')) continue + references.push(value) + } + return references +} + +function addUpdateMetadataChecks( + report: PackageSmokeReport, + rootDir: string, + metadataFiles: string[], +) { + for (const metadataFile of metadataFiles) { + report.optionalArtifacts.push({ + label: 'update metadata', + path: toRelative(rootDir, metadataFile), + }) + + const metadataDir = dirname(metadataFile) + const references = parseUpdateMetadataReferences(readFileSync(metadataFile, 'utf8')) + for (const reference of references) { + const decodedReference = decodeURIComponent(reference) + addPresenceCheck( + report, + rootDir, + `update metadata referenced artifact (${reference})`, + join(metadataDir, decodedReference), + ) + } + } +} + +function addBlockmapChecks( + report: PackageSmokeReport, + rootDir: string, + labelPrefix: string, + artifacts: string[], +) { + for (const artifact of artifacts) { + addPresenceCheck( + report, + rootDir, + `${labelPrefix} blockmap (${relative(dirname(artifact), artifact)})`, + `${artifact}.blockmap`, + ) + } +} + +function firstDiagnosticLine(output: string) { + return output + .split(/\r?\n/) + .map(line => line.trim()) + .find(Boolean) +} + +function collectDiagnosticLines(output: string, limit = 3) { + return output + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .slice(0, limit) +} + +function defaultCommandRunner(command: string, args: string[]): PackageSmokeCommandResult { + const result = spawnSync(command, args, { + encoding: 'utf8', + }) + + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + } +} + +function shellQuote(value: string) { + return `'${value.replace(/'/g, "'\\''")}'` +} + +function isTooManyOpenFiles(result: PackageSmokeCommandResult) { + return /Too many open files/i.test(`${result.stdout ?? ''}${result.stderr ?? ''}`) +} + +function runMacosGatekeeperAssessment( + appBundle: string, + commandRunner: PackageSmokeCommandRunner, +) { + const initial = commandRunner('/usr/sbin/spctl', ['-a', '-vvv', '-t', 'execute', appBundle]) + if (!isTooManyOpenFiles(initial)) { + return { result: initial, retriedAfterTooManyOpenFiles: false } + } + + const retry = commandRunner('/bin/zsh', [ + '-lc', + `ulimit -n 1048575 2>/dev/null || true; exec /usr/sbin/spctl -a -vvv -t execute ${shellQuote(appBundle)}`, + ]) + return { result: retry, retriedAfterTooManyOpenFiles: true } +} + +function addCommandDiagnostics( + report: PackageSmokeReport, + label: string, + result: PackageSmokeCommandResult, +) { + const output = `${result.stdout ?? ''}${result.stderr ?? ''}` + const lines = collectDiagnosticLines(output) + const status = result.status ?? 'unknown' + + if (lines.length === 0) { + report.notes.push(`${label} exited with status ${status} and produced no diagnostic output.`) + return + } + + report.notes.push(`${label} exited with status ${status}: ${lines.join(' | ')}`) +} + +function addMacosGatekeeperCheck( + report: PackageSmokeReport, + rootDir: string, + appBundle: string, + commandRunner: PackageSmokeCommandRunner = defaultCommandRunner, +) { + if (report.hostPlatform !== 'macos') { + report.notes.push(`macOS Gatekeeper assessment was requested but skipped because host platform is ${report.hostPlatform}.`) + return + } + + const { result, retriedAfterTooManyOpenFiles } = runMacosGatekeeperAssessment(appBundle, commandRunner) + if (retriedAfterTooManyOpenFiles) { + report.notes.push('spctl Gatekeeper assessment initially failed with Too many open files; retried with a raised file descriptor limit.') + } + const output = `${result.stdout ?? ''}${result.stderr ?? ''}` + const detail = firstDiagnosticLine(output) + const record = { + label: detail ? `macOS Gatekeeper launch approval (${detail})` : 'macOS Gatekeeper launch approval', + path: toRelative(rootDir, appBundle), + } + + if (result.status === 0) { + report.passedChecks.push(record) + } else { + report.missingChecks.push(record) + addCommandDiagnostics(report, 'spctl Gatekeeper assessment', result) + addCommandDiagnostics( + report, + 'codesign verification', + commandRunner('/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=2', appBundle]), + ) + addCommandDiagnostics( + report, + 'codesign signature details', + commandRunner('/usr/bin/codesign', ['-dv', '--verbose=4', appBundle]), + ) + addCommandDiagnostics( + report, + 'notarization ticket validation', + commandRunner('/usr/bin/xcrun', ['stapler', 'validate', appBundle]), + ) + } +} + +function addInstalledUpdateMetadataCheck( + report: PackageSmokeReport, + rootDir: string, + label: string, + resourcesDir: string, + hasReleaseMetadata: boolean, +) { + if (hasReleaseMetadata) { + addPresenceCheck(report, rootDir, label, join(resourcesDir, 'app-update.yml')) + } else { + report.notes.push(`${label} was not required because no release archive/update metadata was found in this artifact set.`) + } +} + +function createReport( + rootDir: string, + platform: PackageSmokePlatform, + metadata: DesktopMetadata, + artifactsDir: string, + verificationMode: VerificationMode, + packageKind: PackageKind, + hostPlatform = detectHostPlatform(), +): PackageSmokeReport { + return { + platform, + hostPlatform, + productName: metadata.productName, + version: metadata.version, + verificationMode, + packageKind, + artifactsDir, + packagedArtifacts: [], + optionalArtifacts: [], + passedChecks: [], + missingChecks: [], + notes: [], + passed: false, + } +} + +function inspectMacosArtifacts(rootDir: string, report: PackageSmokeReport, options: InspectOptions) { + const appBundles = findMatches( + report.artifactsDir, + (candidate) => { + const normalized = normalizePath(candidate) + return normalized.endsWith(`/${report.productName}.app`) && !normalized.includes('/Contents/Frameworks/') + }, + { directoriesOnly: true }, + ) + const archives = findMatches(report.artifactsDir, (candidate) => candidate.endsWith('.zip') || candidate.endsWith('.dmg')) + const updateMetadata = findMatches(report.artifactsDir, (candidate) => candidate.endsWith('latest-mac.yml')) + const releaseMode = report.packageKind === 'release' || (report.packageKind === 'auto' && (archives.length > 0 || updateMetadata.length > 0)) + + report.packagedArtifacts.push(...appBundles.map((candidate) => ({ + label: 'macOS app bundle', + path: toRelative(rootDir, candidate), + }))) + report.optionalArtifacts.push(...archives.map((candidate) => ({ + label: candidate.endsWith('.dmg') ? 'macOS dmg archive' : 'macOS zip archive', + path: toRelative(rootDir, candidate), + }))) + if (releaseMode) { + addUpdateMetadataChecks(report, rootDir, updateMetadata) + addBlockmapChecks(report, rootDir, 'macOS update artifact', archives) + } + + if (appBundles.length === 0) { + report.missingChecks.push({ + label: 'macOS app bundle', + path: toRelative(rootDir, join(report.artifactsDir, 'electron')), + }) + return + } + + const appBundle = appBundles[0] + const contentsDir = join(appBundle, 'Contents') + const resourcesDir = join(contentsDir, 'Resources') + const unpackedDir = join(resourcesDir, 'app.asar.unpacked') + const nodePtyDir = join(unpackedDir, 'node_modules', 'node-pty') + const prebuildsDir = join(nodePtyDir, 'prebuilds') + + addPresenceCheck(report, rootDir, 'macOS Info.plist', join(contentsDir, 'Info.plist')) + addPresenceCheck(report, rootDir, 'macOS app executable', join(contentsDir, 'MacOS', report.productName)) + addPresenceCheck(report, rootDir, 'macOS app.asar', join(resourcesDir, 'app.asar')) + addInstalledUpdateMetadataCheck( + report, + rootDir, + 'macOS app-update.yml', + resourcesDir, + releaseMode, + ) + addPresenceCheck(report, rootDir, 'macOS node-pty package.json', join(nodePtyDir, 'package.json')) + addMatchCheck( + report, + rootDir, + 'macOS unpacked sidecar binary', + findMatches(join(unpackedDir, 'src-tauri', 'binaries'), (candidate) => normalizePath(candidate).includes('/claude-sidecar-')), + join(unpackedDir, 'src-tauri', 'binaries'), + ) + addMatchCheck( + report, + rootDir, + 'macOS node-pty native module', + findMatches(prebuildsDir, (candidate) => normalizePath(candidate).includes('/darwin-') && normalizePath(candidate).endsWith('/pty.node')), + prebuildsDir, + ) + addMatchCheck( + report, + rootDir, + 'macOS node-pty spawn-helper', + findMatches(prebuildsDir, (candidate) => normalizePath(candidate).includes('/darwin-') && normalizePath(candidate).endsWith('/spawn-helper')), + prebuildsDir, + ) + + report.notes.push('No GUI launch was attempted. This command only inspects packaged bundle structure and key unpacked resources.') + if (options.requireMacosGatekeeper) { + addMacosGatekeeperCheck(report, rootDir, appBundle, options.commandRunner) + } else if (report.hostPlatform === 'macos') { + report.notes.push('macOS Gatekeeper launch approval was not assessed. Add --require-macos-gatekeeper for release-readiness launch policy checks.') + } + if (report.packageKind === 'dir') { + report.notes.push('macOS release archive and update metadata checks were skipped for a directory-only development package.') + } else if (archives.length === 0) { + report.notes.push('No .zip or .dmg archive was found to record alongside the .app bundle.') + } else if (updateMetadata.length === 0) { + report.missingChecks.push({ + label: 'macOS update metadata (latest-mac.yml)', + path: toRelative(rootDir, join(report.artifactsDir, 'electron', 'latest-mac.yml')), + }) + } + if (report.packageKind === 'release' && archives.length === 0) { + report.missingChecks.push({ + label: 'macOS release archive (.zip or .dmg)', + path: toRelative(rootDir, report.artifactsDir), + }) + } +} + +function inspectWindowsArtifacts(rootDir: string, report: PackageSmokeReport) { + const installers = findMatches(report.artifactsDir, (candidate) => { + const normalized = normalizePath(candidate) + return normalized.endsWith('.exe') && !normalized.includes('/win-unpacked/') + }) + const unpackedDir = findMatches(report.artifactsDir, (candidate) => normalizePath(candidate).endsWith('/win-unpacked'), { directoriesOnly: true })[0] + const electronDir = join(report.artifactsDir, 'electron') + const updateMetadata = findMatches(report.artifactsDir, (candidate) => candidate.endsWith('latest.yml')) + const releaseMode = report.packageKind === 'release' || (report.packageKind === 'auto' && (installers.length > 0 || updateMetadata.length > 0)) + + report.packagedArtifacts.push(...installers.map((candidate) => ({ + label: 'Windows installer', + path: toRelative(rootDir, candidate), + }))) + if (releaseMode) { + addUpdateMetadataChecks(report, rootDir, updateMetadata) + addBlockmapChecks(report, rootDir, 'Windows update artifact', installers) + addMatchCheck( + report, + rootDir, + 'windows packaged artifact (.exe installer)', + installers, + electronDir, + ) + } else { + report.notes.push('Windows installer and update metadata checks were skipped for a directory-only development package.') + } + + if (unpackedDir) { + const resourcesDir = join(unpackedDir, 'resources') + const unpackedResourcesDir = join(resourcesDir, 'app.asar.unpacked') + const nodePtyDir = join(unpackedResourcesDir, 'node_modules', 'node-pty') + addPresenceCheck(report, rootDir, 'Windows app.asar', join(resourcesDir, 'app.asar')) + addInstalledUpdateMetadataCheck( + report, + rootDir, + 'Windows app-update.yml', + resourcesDir, + releaseMode, + ) + addPresenceCheck(report, rootDir, 'Windows node-pty package.json', join(nodePtyDir, 'package.json')) + addMatchCheck( + report, + rootDir, + 'Windows unpacked sidecar binary', + findMatches(join(unpackedResourcesDir, 'src-tauri', 'binaries'), (candidate) => normalizePath(candidate).includes('/claude-sidecar-')), + join(unpackedResourcesDir, 'src-tauri', 'binaries'), + ) + addMatchCheck( + report, + rootDir, + 'Windows node-pty native module', + findMatches(join(nodePtyDir, 'prebuilds'), (candidate) => normalizePath(candidate).includes('/win32-') && normalizePath(candidate).endsWith('/pty.node')), + join(nodePtyDir, 'prebuilds'), + ) + } else { + report.missingChecks.push({ + label: 'Windows unpacked directory (win-unpacked) for static resource inspection', + path: toRelative(rootDir, join(electronDir, 'win-unpacked')), + }) + } + + report.notes.push('This is a static artifact check only. It does not claim installer execution or app launch success.') + if (releaseMode && updateMetadata.length === 0) { + report.missingChecks.push({ + label: 'Windows update metadata (latest.yml)', + path: toRelative(rootDir, join(electronDir, 'latest.yml')), + }) + } + if (report.hostPlatform !== 'windows') { + report.notes.push(`Host platform is ${report.hostPlatform}, so Windows verification stayed artifact-only.`) + } +} + +function inspectLinuxArtifacts(rootDir: string, report: PackageSmokeReport) { + const packagedArtifacts = findMatches( + report.artifactsDir, + (candidate) => candidate.endsWith('.AppImage') || candidate.endsWith('.deb'), + ) + const unpackedDir = findMatches(report.artifactsDir, (candidate) => normalizePath(candidate).endsWith('/linux-unpacked'), { directoriesOnly: true })[0] + const electronDir = join(report.artifactsDir, 'electron') + const updateMetadata = findMatches(report.artifactsDir, (candidate) => /latest-linux(?:-[a-z0-9]+)?\.yml$/.test(candidate)) + const releaseMode = report.packageKind === 'release' || (report.packageKind === 'auto' && (packagedArtifacts.length > 0 || updateMetadata.length > 0)) + + report.packagedArtifacts.push(...packagedArtifacts.map((candidate) => ({ + label: candidate.endsWith('.deb') ? 'Linux deb package' : 'Linux AppImage', + path: toRelative(rootDir, candidate), + }))) + if (releaseMode) { + addUpdateMetadataChecks(report, rootDir, updateMetadata) + addBlockmapChecks( + report, + rootDir, + 'Linux AppImage update artifact', + packagedArtifacts.filter(candidate => candidate.endsWith('.AppImage')), + ) + } + + if (releaseMode) { + addMatchCheck( + report, + rootDir, + 'linux packaged artifact (.AppImage or .deb)', + packagedArtifacts, + electronDir, + ) + } else if (!unpackedDir) { + report.missingChecks.push({ + label: 'linux packaged artifact (.AppImage or .deb)', + path: toRelative(rootDir, electronDir), + }) + } else { + report.notes.push('No .AppImage or .deb was found; treating linux-unpacked as a directory-only development package.') + } + + if (unpackedDir) { + const resourcesDir = join(unpackedDir, 'resources') + const unpackedResourcesDir = join(resourcesDir, 'app.asar.unpacked') + const nodePtyDir = join(unpackedResourcesDir, 'node_modules', 'node-pty') + addPresenceCheck(report, rootDir, 'Linux app.asar', join(resourcesDir, 'app.asar')) + addInstalledUpdateMetadataCheck( + report, + rootDir, + 'Linux app-update.yml', + resourcesDir, + releaseMode, + ) + addPresenceCheck(report, rootDir, 'Linux node-pty package.json', join(nodePtyDir, 'package.json')) + addMatchCheck( + report, + rootDir, + 'Linux unpacked sidecar binary', + findMatches(join(unpackedResourcesDir, 'src-tauri', 'binaries'), (candidate) => normalizePath(candidate).includes('/claude-sidecar-')), + join(unpackedResourcesDir, 'src-tauri', 'binaries'), + ) + addMatchCheck( + report, + rootDir, + 'Linux node-pty native module', + findMatches(nodePtyDir, (candidate) => normalizePath(candidate).endsWith('/pty.node') && (normalizePath(candidate).includes('/linux-') || normalizePath(candidate).includes('/Release/'))), + nodePtyDir, + ) + } else { + if (releaseMode) { + report.missingChecks.push({ + label: 'Linux unpacked directory (linux-unpacked) for static resource inspection', + path: toRelative(rootDir, join(electronDir, 'linux-unpacked')), + }) + } else { + report.notes.push('linux-unpacked was not found, so this check only verified packaged artifact presence.') + } + } + + report.notes.push('This is a static artifact check only. It does not claim installer execution or app launch success.') + if (releaseMode && updateMetadata.length === 0) { + report.missingChecks.push({ + label: 'Linux update metadata (latest-linux*.yml)', + path: toRelative(rootDir, join(electronDir, 'latest-linux.yml')), + }) + } + if (report.hostPlatform !== 'linux') { + report.notes.push(`Host platform is ${report.hostPlatform}, so Linux verification stayed artifact-only.`) + } +} + +export async function inspectPackagedArtifacts(rootDir: string, options: InspectOptions): Promise { + const resolvedRootDir = resolve(rootDir) + const artifactsDir = options.artifactsDir + ? resolve(resolvedRootDir, options.artifactsDir) + : join(resolvedRootDir, 'desktop', 'build-artifacts') + const metadata = readDesktopMetadata(resolvedRootDir) + const verificationMode = options.platform === 'macos' ? 'bundle-structure' : 'static-artifact' + const packageKind = options.packageKind ?? 'auto' + const report = createReport( + resolvedRootDir, + options.platform, + metadata, + artifactsDir, + verificationMode, + packageKind, + options.hostPlatform, + ) + + if (options.platform === 'macos') { + inspectMacosArtifacts(resolvedRootDir, report, options) + } else if (options.platform === 'windows') { + inspectWindowsArtifacts(resolvedRootDir, report) + } else { + inspectLinuxArtifacts(resolvedRootDir, report) + } + + report.passed = report.missingChecks.length === 0 + return report +} + +function printRecord(prefix: string, record: CheckRecord) { + console.log(`${prefix} ${record.label}: ${record.path}`) +} + +function printReport(report: PackageSmokeReport) { + console.log(`[package-smoke] platform=${report.platform} host=${report.hostPlatform} mode=${report.verificationMode}`) + console.log(`[package-smoke] packageKind=${report.packageKind}`) + console.log(`[package-smoke] product=${report.productName} version=${report.version}`) + console.log(`[package-smoke] artifactsDir=${report.artifactsDir}`) + + if (report.packagedArtifacts.length > 0) { + console.log('[package-smoke] packaged artifacts:') + for (const artifact of report.packagedArtifacts) { + printRecord(' -', artifact) + } + } + + if (report.optionalArtifacts.length > 0) { + console.log('[package-smoke] optional artifacts:') + for (const artifact of report.optionalArtifacts) { + printRecord(' -', artifact) + } + } + + if (report.passedChecks.length > 0) { + console.log('[package-smoke] passed checks:') + for (const check of report.passedChecks) { + printRecord(' -', check) + } + } + + if (report.missingChecks.length > 0) { + console.log('[package-smoke] missing checks:') + for (const check of report.missingChecks) { + printRecord(' -', check) + } + } + + if (report.notes.length > 0) { + console.log('[package-smoke] notes:') + for (const note of report.notes) { + console.log(` - ${note}`) + } + } + + console.log(`[package-smoke] result=${report.passed ? 'PASS' : 'FAIL'}`) +} + +if (import.meta.main) { + try { + const args = parsePackageSmokeArgs(process.argv.slice(2)) + const report = await inspectPackagedArtifacts(process.cwd(), args) + printReport(report) + process.exit(report.passed ? 0 : 1) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + process.exit(1) + } +} diff --git a/scripts/quality-gate/runner.test.ts b/scripts/quality-gate/runner.test.ts index 28668bdc..9d923882 100644 --- a/scripts/quality-gate/runner.test.ts +++ b/scripts/quality-gate/runner.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { lanesForMode } from './modes' +import { currentPackageSmokePlatform, currentReleaseArtifactsDir, lanesForMode } from './modes' import { renderJUnitReport, renderMarkdownReport } from './reporter' import { runQualityGate, runQualityGateLanes } from './runner' import type { LaneDefinition, QualityGateReport } from './types' @@ -36,17 +36,45 @@ describe('quality gate modes', () => { }) test('release mode composes PR, baseline, and native lanes', () => { - const lanes = lanesForMode('release').map((lane) => lane.id) + const laneDefinitions = lanesForMode('release') + const lanes = laneDefinitions.map((lane) => lane.id) + const packageSmokePlatform = currentPackageSmokePlatform() expect(lanes).toContain('policy-checks') + expect(lanes).toContain('desktop-checks') + expect(lanes).toContain('server-checks') + expect(lanes).toContain('adapter-checks') + expect(lanes).toContain('docs-checks') expect(lanes).toContain('persistence-upgrade') expect(lanes).toContain('quarantine') expect(lanes).toContain('coverage') expect(lanes).toContain('baseline:failing-unit:current-runtime') expect(lanes).toContain('provider-smoke:current-runtime') - expect(lanes).toContain('desktop-smoke:agent-browser-chat:current-runtime') + expect(lanes).not.toContain('desktop-smoke:agent-browser-chat:current-runtime') + if (packageSmokePlatform) { + expect(lanes).toContain(`desktop-package-smoke:${packageSmokePlatform}`) + const packageSmokeLane = laneDefinitions.find((lane) => lane.id === `desktop-package-smoke:${packageSmokePlatform}`) + expect(packageSmokeLane?.command).toContain('--package-kind') + expect(packageSmokeLane?.command).toContain('release') + expect(packageSmokeLane?.command).toContain('--artifacts-dir') + if (packageSmokePlatform === 'macos') { + expect(packageSmokeLane?.command).toContain('--require-macos-gatekeeper') + } + } expect(lanes).toContain('native-checks') }) + test('maps host platforms to package-smoke platform names', () => { + expect(currentPackageSmokePlatform('darwin')).toBe('macos') + expect(currentPackageSmokePlatform('win32')).toBe('windows') + expect(currentPackageSmokePlatform('linux')).toBe('linux') + expect(currentPackageSmokePlatform('freebsd')).toBeNull() + expect(currentReleaseArtifactsDir('darwin', 'arm64')).toBe('desktop/build-artifacts/macos-arm64') + expect(currentReleaseArtifactsDir('darwin', 'x64')).toBe('desktop/build-artifacts/macos-x64') + expect(currentReleaseArtifactsDir('win32', 'x64')).toBe('desktop/build-artifacts/windows-x64') + expect(currentReleaseArtifactsDir('linux', 'arm64')).toBe('desktop/build-artifacts/linux-arm64') + expect(currentReleaseArtifactsDir('freebsd', 'x64')).toBeNull() + }) + test('baseline mode expands cases across explicit provider/model targets', () => { const lanes = lanesForMode('baseline', [ { providerId: 'provider-a', modelId: 'model-a', label: 'provider-a-model-a' }, diff --git a/scripts/release-update-metadata.test.ts b/scripts/release-update-metadata.test.ts new file mode 100644 index 00000000..0dcf5eda --- /dev/null +++ b/scripts/release-update-metadata.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { parse } from 'yaml' +import { mergeUpdateMetadataArtifacts } from './release-update-metadata' + +function tempDir() { + return mkdtempSync(join(tmpdir(), 'cc-haha-release-metadata-')) +} + +function writeYaml(path: string, content: string) { + writeFileSync(path, content.trimStart().replace(/^ {6}/gm, '')) +} + +describe('release update metadata merge', () => { + test('merges namespaced macOS x64 and arm64 metadata into standard latest-mac.yml', () => { + const inputDir = tempDir() + const outputDir = tempDir() + + writeYaml(join(inputDir, 'latest-mac-macOS-ARM64.yml'), ` + version: 0.3.2 + files: + - url: Claude-Code-Haha-0.3.2-arm64.zip + sha512: arm64-checksum + size: 222 + path: Claude-Code-Haha-0.3.2-arm64.zip + sha512: arm64-checksum + releaseDate: '2026-06-01T02:00:00.000Z' + `) + writeYaml(join(inputDir, 'latest-mac-macOS-x64.yml'), ` + version: 0.3.2 + files: + - url: Claude-Code-Haha-0.3.2-x64.zip + sha512: x64-checksum + size: 111 + path: Claude-Code-Haha-0.3.2-x64.zip + sha512: x64-checksum + releaseDate: '2026-06-01T01:00:00.000Z' + `) + + const result = mergeUpdateMetadataArtifacts({ metadataDir: inputDir, outDir: outputDir }) + expect(result.writtenFiles.map(file => file.endsWith('latest-mac.yml'))).toContain(true) + + const merged = parse(readFileSync(join(outputDir, 'latest-mac.yml'), 'utf8')) as { + files: Array<{ url: string, sha512: string, size: number }> + path: string + sha512: string + releaseDate: string + } + + expect(merged.files.map(file => file.url)).toEqual([ + 'Claude-Code-Haha-0.3.2-x64.zip', + 'Claude-Code-Haha-0.3.2-arm64.zip', + ]) + expect(merged.path).toBe('Claude-Code-Haha-0.3.2-x64.zip') + expect(merged.sha512).toBe('x64-checksum') + expect(merged.releaseDate).toBe('2026-06-01T02:00:00.000Z') + }) + + test('keeps macOS zip metadata primary when each architecture has dmg and zip files', () => { + const inputDir = tempDir() + const outputDir = tempDir() + + writeYaml(join(inputDir, 'latest-mac-macOS-ARM64.yml'), ` + version: 0.3.2 + files: + - url: Claude-Code-Haha-0.3.2-arm64.dmg + sha512: arm64-dmg-checksum + size: 444 + - url: Claude-Code-Haha-0.3.2-arm64.zip + sha512: arm64-zip-checksum + size: 333 + path: Claude-Code-Haha-0.3.2-arm64.zip + sha512: arm64-zip-checksum + `) + writeYaml(join(inputDir, 'latest-mac-macOS-x64.yml'), ` + version: 0.3.2 + files: + - url: Claude-Code-Haha-0.3.2-x64.dmg + sha512: x64-dmg-checksum + size: 222 + - url: Claude-Code-Haha-0.3.2-x64.zip + sha512: x64-zip-checksum + size: 111 + path: Claude-Code-Haha-0.3.2-x64.zip + sha512: x64-zip-checksum + `) + + mergeUpdateMetadataArtifacts({ metadataDir: inputDir, outDir: outputDir }) + + const merged = parse(readFileSync(join(outputDir, 'latest-mac.yml'), 'utf8')) as { + files: Array<{ url: string, sha512: string }> + path: string + sha512: string + } + + expect(merged.files.map(file => file.url)).toEqual([ + 'Claude-Code-Haha-0.3.2-x64.zip', + 'Claude-Code-Haha-0.3.2-x64.dmg', + 'Claude-Code-Haha-0.3.2-arm64.zip', + 'Claude-Code-Haha-0.3.2-arm64.dmg', + ]) + expect(merged.path).toBe('Claude-Code-Haha-0.3.2-x64.zip') + expect(merged.sha512).toBe('x64-zip-checksum') + }) + + test('restores standard linux channel names for x64 and arm64 metadata', () => { + const inputDir = tempDir() + const outputDir = tempDir() + + writeYaml(join(inputDir, 'latest-linux-Linux-x64.yml'), ` + version: 0.3.2 + files: + - url: Claude-Code-Haha-0.3.2-x64.AppImage + sha512: linux-x64-checksum + size: 111 + path: Claude-Code-Haha-0.3.2-x64.AppImage + sha512: linux-x64-checksum + `) + writeYaml(join(inputDir, 'latest-linux-Linux-ARM64.yml'), ` + version: 0.3.2 + files: + - url: Claude-Code-Haha-0.3.2-arm64.AppImage + sha512: linux-arm64-checksum + size: 222 + path: Claude-Code-Haha-0.3.2-arm64.AppImage + sha512: linux-arm64-checksum + `) + + mergeUpdateMetadataArtifacts({ metadataDir: inputDir, outDir: outputDir }) + + const x64 = parse(readFileSync(join(outputDir, 'latest-linux.yml'), 'utf8')) as { path: string } + const arm64 = parse(readFileSync(join(outputDir, 'latest-linux-arm64.yml'), 'utf8')) as { path: string } + expect(x64.path).toBe('Claude-Code-Haha-0.3.2-x64.AppImage') + expect(arm64.path).toBe('Claude-Code-Haha-0.3.2-arm64.AppImage') + }) + + test('rejects metadata groups with mixed app versions', () => { + const inputDir = tempDir() + const outputDir = tempDir() + + writeYaml(join(inputDir, 'latest-mac-macOS-x64.yml'), ` + version: 0.3.2 + files: + - url: Claude-Code-Haha-0.3.2-x64.zip + sha512: x64-checksum + `) + writeYaml(join(inputDir, 'latest-mac-macOS-ARM64.yml'), ` + version: 0.3.3 + files: + - url: Claude-Code-Haha-0.3.3-arm64.zip + sha512: arm64-checksum + `) + + expect(() => mergeUpdateMetadataArtifacts({ metadataDir: inputDir, outDir: outputDir })) + .toThrow('Cannot merge update metadata with different versions') + }) +}) diff --git a/scripts/release-update-metadata.ts b/scripts/release-update-metadata.ts new file mode 100644 index 00000000..297e9d14 --- /dev/null +++ b/scripts/release-update-metadata.ts @@ -0,0 +1,282 @@ +#!/usr/bin/env bun + +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { basename, join, relative, resolve } from 'node:path' +import { parse, stringify } from 'yaml' + +type UpdateFileMetadata = { + url?: string + sha512?: string + sha2?: string + size?: number + [key: string]: unknown +} + +type UpdateMetadata = { + version?: string + files?: UpdateFileMetadata[] + path?: string + sha512?: string + sha2?: string + releaseDate?: string + [key: string]: unknown +} + +type MetadataEntry = { + sourcePath: string + canonicalName: string + metadata: UpdateMetadata +} + +export type MergeUpdateMetadataOptions = { + metadataDir: string + outDir: string +} + +export type MergeUpdateMetadataResult = { + writtenFiles: string[] +} + +function usage() { + return 'Usage: bun run scripts/release-update-metadata.ts --metadata-dir --out-dir ' +} + +function readArgValue(argv: string[], index: number, flag: string) { + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${flag}\n${usage()}`) + } + return value +} + +function parseArgs(argv: string[]): MergeUpdateMetadataOptions { + let metadataDir: string | undefined + let outDir: string | undefined + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === '--metadata-dir') { + metadataDir = readArgValue(argv, index, arg) + index += 1 + continue + } + if (arg === '--out-dir') { + outDir = readArgValue(argv, index, arg) + index += 1 + continue + } + } + + if (!metadataDir || !outDir) { + throw new Error(usage()) + } + + return { metadataDir, outDir } +} + +function walkMetadataFiles(rootDir: string) { + if (!existsSync(rootDir)) { + return [] as string[] + } + + const files: string[] = [] + const stack = [rootDir] + while (stack.length > 0) { + const current = stack.pop() + if (!current) continue + + for (const entry of readdirSync(current, { withFileTypes: true })) { + const fullPath = join(current, entry.name) + if (entry.isDirectory()) { + stack.push(fullPath) + } else if (/^latest.*\.ya?ml$/i.test(entry.name)) { + files.push(fullPath) + } + } + } + + return files.sort() +} + +function readMetadata(filePath: string): UpdateMetadata { + const parsed = parse(readFileSync(filePath, 'utf8')) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Update metadata must be a YAML object: ${filePath}`) + } + return parsed as UpdateMetadata +} + +function metadataHaystack(fileName: string, metadata: UpdateMetadata) { + const fileUrls = Array.isArray(metadata.files) + ? metadata.files.map(file => file.url ?? '').join(' ') + : '' + return `${fileName} ${metadata.path ?? ''} ${fileUrls}`.toLowerCase() +} + +function canonicalChannelName(filePath: string, metadata: UpdateMetadata) { + const fileName = basename(filePath).replace(/\.ya?ml$/i, '') + if (fileName.startsWith('latest-mac')) { + return 'latest-mac.yml' + } + if (fileName.startsWith('latest-linux')) { + const haystack = metadataHaystack(fileName, metadata) + return /(?:^|[-_])(?:arm64|aarch64)(?:$|[-_.])/.test(haystack) + ? 'latest-linux-arm64.yml' + : 'latest-linux.yml' + } + if (fileName.startsWith('latest')) { + return 'latest.yml' + } + return null +} + +function metadataFiles(metadata: UpdateMetadata, sourcePath: string) { + const files = Array.isArray(metadata.files) && metadata.files.length > 0 + ? metadata.files + : metadata.path + ? [{ url: metadata.path, sha512: metadata.sha512, sha2: metadata.sha2 }] + : [] + + if (files.length === 0) { + throw new Error(`No update files found in ${sourcePath}`) + } + + return files.map((file) => { + if (!file.url) { + throw new Error(`Update file entry is missing url in ${sourcePath}`) + } + if (!file.sha512 && !file.sha2) { + throw new Error(`Update file entry is missing checksum for ${file.url} in ${sourcePath}`) + } + return file + }) +} + +function archRank(url: string) { + const lowerUrl = url.toLowerCase() + if (/(^|[-_.])x64($|[-_.])/.test(lowerUrl)) return 0 + if (/(^|[-_.])arm64($|[-_.])|(^|[-_.])aarch64($|[-_.])/.test(lowerUrl)) return 1 + if (/(^|[-_.])ia32($|[-_.])/.test(lowerUrl)) return 2 + return 3 +} + +function artifactRank(canonicalName: string, url: string) { + if (canonicalName !== 'latest-mac.yml') return 0 + const lowerUrl = url.toLowerCase() + if (lowerUrl.endsWith('.zip')) return 0 + if (lowerUrl.endsWith('.dmg')) return 1 + return 2 +} + +function sortUpdateFiles(canonicalName: string, files: UpdateFileMetadata[]) { + return [...files].sort((left, right) => { + const leftUrl = left.url ?? '' + const rightUrl = right.url ?? '' + return archRank(leftUrl) - archRank(rightUrl) + || artifactRank(canonicalName, leftUrl) - artifactRank(canonicalName, rightUrl) + || leftUrl.localeCompare(rightUrl) + }) +} + +function latestReleaseDate(entries: MetadataEntry[]) { + const dates = entries + .map(entry => entry.metadata.releaseDate) + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .sort() + return dates.at(-1) +} + +function mergeEntries(entries: MetadataEntry[]) { + const [first] = entries + if (!first) { + throw new Error('Cannot merge an empty update metadata group') + } + + const version = first.metadata.version + for (const entry of entries) { + if (entry.metadata.version !== version) { + throw new Error( + `Cannot merge update metadata with different versions for ${entry.canonicalName}: ${version} vs ${entry.metadata.version}`, + ) + } + } + + const filesByUrl = new Map() + for (const entry of entries) { + for (const file of metadataFiles(entry.metadata, entry.sourcePath)) { + const existing = filesByUrl.get(file.url ?? '') + if (existing && (existing.sha512 ?? existing.sha2) !== (file.sha512 ?? file.sha2)) { + throw new Error(`Conflicting checksums for update artifact ${file.url}`) + } + filesByUrl.set(file.url ?? '', file) + } + } + + const files = sortUpdateFiles(first.canonicalName, [...filesByUrl.values()]) + const primaryFile = files[0] + const merged: UpdateMetadata = { + ...first.metadata, + files, + path: primaryFile.url, + sha512: primaryFile.sha512, + } + if (primaryFile.sha2) { + merged.sha2 = primaryFile.sha2 + } else { + delete merged.sha2 + } + + const releaseDate = latestReleaseDate(entries) + if (releaseDate) { + merged.releaseDate = releaseDate + } + + return merged +} + +export function mergeUpdateMetadataArtifacts(options: MergeUpdateMetadataOptions): MergeUpdateMetadataResult { + const metadataDir = resolve(options.metadataDir) + const outDir = resolve(options.outDir) + const metadataFiles = walkMetadataFiles(metadataDir) + if (metadataFiles.length === 0) { + throw new Error(`No latest*.yml metadata files found in ${metadataDir}`) + } + + const groups = new Map() + for (const sourcePath of metadataFiles) { + const metadata = readMetadata(sourcePath) + const canonicalName = canonicalChannelName(sourcePath, metadata) + if (!canonicalName) continue + + const entries = groups.get(canonicalName) ?? [] + entries.push({ sourcePath, canonicalName, metadata }) + groups.set(canonicalName, entries) + } + + if (groups.size === 0) { + throw new Error(`No recognized update metadata files found in ${metadataDir}`) + } + + mkdirSync(outDir, { recursive: true }) + const writtenFiles: string[] = [] + for (const [canonicalName, entries] of [...groups.entries()].sort(([left], [right]) => left.localeCompare(right))) { + const outputPath = join(outDir, canonicalName) + writeFileSync(outputPath, stringify(mergeEntries(entries))) + writtenFiles.push(outputPath) + } + + return { writtenFiles } +} + +if (import.meta.main) { + try { + const result = mergeUpdateMetadataArtifacts(parseArgs(process.argv.slice(2))) + for (const file of result.writtenFiles) { + console.log(`[release-update-metadata] wrote ${relative(process.cwd(), file)}`) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + process.exit(1) + } +} diff --git a/scripts/release.ts b/scripts/release.ts index dd13233d..b4f89898 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -22,25 +22,13 @@ const VERSION_FILES = [ return content.replace(/"version":\s*"[^"]*"/, `"version": "${version}"`) }, }, - { - path: path.join(root, 'desktop/src-tauri/tauri.conf.json'), - update(content: string, version: string) { - return content.replace(/"version":\s*"[^"]*"/, `"version": "${version}"`) - }, - }, - { - path: path.join(root, 'desktop/src-tauri/Cargo.toml'), - update(content: string, version: string) { - return content.replace(/^version\s*=\s*"[^"]*"/m, `version = "${version}"`) - }, - }, ] function getCurrentVersion(): string { - const tauriConf = JSON.parse( - readFileSync(path.join(root, 'desktop/src-tauri/tauri.conf.json'), 'utf-8'), + const desktopPackage = JSON.parse( + readFileSync(path.join(root, 'desktop/package.json'), 'utf-8'), ) - return tauriConf.version + return desktopPackage.version } function getReleaseNotesPath(version: string): string { @@ -122,19 +110,12 @@ for (const file of VERSION_FILES) { console.log(` Updated: ${path.relative(root, file.path)}`) } -// Regenerate Cargo.lock -console.log('\n Updating Cargo.lock...') -await run(['cargo', 'generate-lockfile'], path.join(root, 'desktop/src-tauri')) - // Git commit + tag console.log(' Creating git commit...') await run([ 'git', 'add', 'desktop/package.json', - 'desktop/src-tauri/tauri.conf.json', - 'desktop/src-tauri/Cargo.toml', - 'desktop/src-tauri/Cargo.lock', path.relative(root, releaseNotesPath), ]) await run(['git', 'commit', '-m', `release: v${next}`]) diff --git a/src/cli/__tests__/structuredIO.test.ts b/src/cli/__tests__/structuredIO.test.ts new file mode 100644 index 00000000..43f7c1b3 --- /dev/null +++ b/src/cli/__tests__/structuredIO.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { StructuredIO } from '../structuredIO.js' +import { + clearOAuthTokenCache, + getClaudeAIOAuthTokens, +} from '../../utils/auth.js' + +describe('StructuredIO environment updates', () => { + const originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN + + afterEach(() => { + if (originalOAuthToken === undefined) { + delete process.env.CLAUDE_CODE_OAUTH_TOKEN + } else { + process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOAuthToken + } + clearOAuthTokenCache() + }) + + test('clears OAuth token cache when CLAUDE_CODE_OAUTH_TOKEN changes at runtime', async () => { + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'stale-env-token' + clearOAuthTokenCache() + expect(getClaudeAIOAuthTokens()?.accessToken).toBe('stale-env-token') + + async function* input() { + yield `${JSON.stringify({ + type: 'update_environment_variables', + variables: { CLAUDE_CODE_OAUTH_TOKEN: 'fresh-env-token' }, + })}\n` + } + + const io = new StructuredIO(input()) + for await (const _message of io.structuredInput) { + // update_environment_variables messages are consumed internally. + } + + expect(process.env.CLAUDE_CODE_OAUTH_TOKEN).toBe('fresh-env-token') + expect(getClaudeAIOAuthTokens()?.accessToken).toBe('fresh-env-token') + }) +}) diff --git a/src/cli/structuredIO.ts b/src/cli/structuredIO.ts index 366b56f5..c70b3f70 100644 --- a/src/cli/structuredIO.ts +++ b/src/cli/structuredIO.ts @@ -354,6 +354,10 @@ export class StructuredIO { for (const [key, value] of Object.entries(message.variables)) { process.env[key] = value } + if (Object.hasOwn(message.variables, 'CLAUDE_CODE_OAUTH_TOKEN')) { + const { clearOAuthTokenCache } = await import('../utils/auth.js') + clearOAuthTokenCache() + } logForDebugging( `[structuredIO] applied update_environment_variables: ${keys.join(', ')}`, ) diff --git a/src/commands.ts b/src/commands.ts index 55c82fa7..85cf49be 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -130,6 +130,7 @@ import privacySettings from './commands/privacy-settings/index.js' import hooks from './commands/hooks/index.js' import files from './commands/files/index.js' import branch from './commands/branch/index.js' +import agent from './commands/agent.js' import agents from './commands/agents/index.js' import plugin from './commands/plugin/index.js' import reloadPlugins from './commands/reload-plugins/index.js' @@ -257,6 +258,7 @@ export const INTERNAL_ONLY_COMMANDS = [ const COMMANDS = memoize((): Command[] => [ addDir, advisor, + agent, agents, branch, btw, diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts new file mode 100644 index 00000000..67f20f31 --- /dev/null +++ b/src/commands/agent.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' +import agentCommand, { parseAgentCommandArgs } from './agent.js' + +describe('/agent command', () => { + test('parses an agent type and prompt', () => { + expect(parseAgentCommandArgs('debugger fix the failing tests')).toEqual({ + agentType: 'debugger', + prompt: 'fix the failing tests', + }) + }) + + test('requires both an agent type and prompt', () => { + expect(parseAgentCommandArgs('')).toBeNull() + expect(parseAgentCommandArgs('debugger')).toBeNull() + }) + + test('passes only the prompt body to forked execution', async () => { + await expect(agentCommand.getPromptForCommand('debugger inspect auth', {} as never)).resolves.toEqual([ + { type: 'text', text: 'inspect auth' }, + ]) + }) +}) diff --git a/src/commands/agent.ts b/src/commands/agent.ts new file mode 100644 index 00000000..c1120c9e --- /dev/null +++ b/src/commands/agent.ts @@ -0,0 +1,41 @@ +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js' +import type { Command } from '../commands.js' + +export type ParsedAgentCommandArgs = { + agentType: string + prompt: string +} + +export function parseAgentCommandArgs(args: string): ParsedAgentCommandArgs | null { + const trimmed = args.trim() + if (!trimmed) return null + + const match = /^(\S+)(?:\s+([\s\S]+))?$/.exec(trimmed) + const agentType = match?.[1]?.trim() + const prompt = match?.[2]?.trim() + if (!agentType || !prompt) return null + + return { agentType, prompt } +} + +const agentCommand: Command = { + type: 'prompt', + name: 'agent', + description: 'Run a prompt with a selected Agent', + argumentHint: ' ', + progressMessage: 'running agent', + contentLength: 0, + source: 'builtin', + context: 'fork', + async getPromptForCommand(args): Promise { + const parsed = parseAgentCommandArgs(args) + return [ + { + type: 'text', + text: parsed?.prompt ?? args.trim(), + }, + ] + }, +} + +export default agentCommand diff --git a/src/constants/apiLimits.ts b/src/constants/apiLimits.ts index 9746b030..609df1f7 100644 --- a/src/constants/apiLimits.ts +++ b/src/constants/apiLimits.ts @@ -4,8 +4,8 @@ * These constants define server-side limits enforced by the Anthropic API. * Keep this file dependency-free to prevent circular imports. * - * Last verified: 2025-12-22 - * Source: api/api/schemas/messages/blocks/ and api/api/config.py + * Last verified: 2026-06-01 + * Source: Claude Vision API docs and api/api/config.py * * Future: See issue #13240 for dynamic limits fetching from server. */ @@ -29,18 +29,18 @@ export const API_IMAGE_MAX_BASE64_SIZE = 5 * 1024 * 1024 // 5 MB export const IMAGE_TARGET_RAW_SIZE = (API_IMAGE_MAX_BASE64_SIZE * 3) / 4 // 3.75 MB /** - * Client-side maximum dimensions for image resizing. + * API-enforced maximum dimensions for image inputs. * - * Note: The API internally resizes images larger than 1568px (source: - * encoding/full_encoding.py), but this is handled server-side and doesn't - * cause errors. These client-side limits (2000px) are slightly larger to - * preserve quality when beneficial. + * Note: The API internally resizes images whose long edge is larger than + * 1568px, but that is handled server-side and doesn't cause errors. Keep this + * limit aligned with the API's rejection threshold so common tall screenshots + * are not locally resized or rejected just because they exceed 2000px. * * The API_IMAGE_MAX_BASE64_SIZE (5MB) is the actual hard limit that causes - * API errors if exceeded. + * API errors for ordinary screenshots before dimensions usually matter. */ -export const IMAGE_MAX_WIDTH = 2000 -export const IMAGE_MAX_HEIGHT = 2000 +export const IMAGE_MAX_WIDTH = 8000 +export const IMAGE_MAX_HEIGHT = 8000 // ============================================================================= // PDF LIMITS diff --git a/src/server/__tests__/conversation-attachments.test.ts b/src/server/__tests__/conversation-attachments.test.ts index 5662046c..fb89d82a 100644 --- a/src/server/__tests__/conversation-attachments.test.ts +++ b/src/server/__tests__/conversation-attachments.test.ts @@ -59,7 +59,7 @@ afterEach(async () => { }) describe('ConversationService attachment materialization', () => { - test('inlines normalized image data attachments as SDK image blocks', async () => { + test('inlines image data attachments without resizing when already within API limits', async () => { const svc = new ConversationService() const sent: unknown[] = [] const sessionId = 'session-image-normalize' @@ -96,13 +96,13 @@ describe('ConversationService attachment materialization', () => { expect(textBlocks.some((block) => block.text?.includes('@"'))).toBe(false) expect(imageBlocks).toHaveLength(1) expect(imageBlocks[0]?.source?.media_type).toBe('image/png') - expect(imageBlocks[0]?.source?.data).toBe(Buffer.from('resized-image').toString('base64')) + expect(imageBlocks[0]?.source?.data).toBe(original.toString('base64')) const metadataText = textBlocks.find((block) => block.text?.startsWith('[Image:'))?.text const uploadPath = metadataText?.match(/source: ([^,\]]+)/)?.[1] expect(uploadPath).toBeTruthy() expect(uploadPath?.endsWith('.png')).toBe(true) - expect(await fs.readFile(uploadPath!)).toEqual(Buffer.from('resized-image')) + expect(await fs.readFile(uploadPath!)).toEqual(original) }) test('falls back to an upload path when image normalization cannot produce a block', async () => { @@ -176,7 +176,7 @@ describe('ConversationService attachment materialization', () => { expect(textBlocks.some((block) => block.text?.includes(`source: ${imagePath}`))).toBe(true) expect(imageBlocks).toHaveLength(1) expect(imageBlocks[0]?.source?.media_type).toBe('image/png') - expect(imageBlocks[0]?.source?.data).toBe(Buffer.from('resized-image').toString('base64')) + expect(imageBlocks[0]?.source?.data).toBe(original.toString('base64')) }) }) @@ -186,7 +186,7 @@ function createOversizedPngHeader(): Buffer { buffer[1] = 0x50 buffer[2] = 0x4e buffer[3] = 0x47 - buffer.writeUInt32BE(3000, 16) - buffer.writeUInt32BE(4000, 20) + buffer.writeUInt32BE(9000, 16) + buffer.writeUInt32BE(9000, 20) return buffer } diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 064bce63..7c7f6d3b 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -260,6 +260,51 @@ describe('ConversationService', () => { expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('haha-fresh-token') }) + test('sendMessage updates a running official OAuth CLI token before the user turn', async () => { + const { hahaOAuthService } = await import('../services/hahaOAuthService.js') + await hahaOAuthService.saveTokens({ + accessToken: 'fresh-after-wake-token', + refreshToken: 'refresh-xxx', + expiresAt: Date.now() + 30 * 60_000, + scopes: ['user:inference'], + subscriptionType: 'max', + }) + + const service = new ConversationService() as any + const sent: string[] = [] + service.sessions.set('sleep-wake-session', { + proc: {}, + outputCallbacks: [], + workDir: tmpDir, + permissionMode: 'default', + sdkToken: 'sdk-token', + sdkSocket: { + send(line: string) { + sent.push(line) + }, + }, + pendingOutbound: [], + startupPending: false, + startupExitCode: null, + stdoutLines: [], + stderrLines: [], + outputDrain: Promise.resolve(), + sdkMessages: [], + initMessage: null, + pendingPermissionRequests: new Map(), + usesOfficialOAuth: true, + officialOAuthToken: 'stale-before-sleep-token', + }) + + const ok = await service.sendMessage('sleep-wake-session', 'hello after wake') + + expect(ok).toBe(true) + expect(sent).toHaveLength(2) + expect(JSON.parse(sent[0]!).type).toBe('update_environment_variables') + expect(JSON.parse(sent[0]!).variables.CLAUDE_CODE_OAUTH_TOKEN).toBe('fresh-after-wake-token') + expect(JSON.parse(sent[1]!).type).toBe('user') + }) + test('buildChildEnv does NOT inject CLAUDE_CODE_OAUTH_TOKEN when not official mode', async () => { const ccHahaDir = path.join(tmpDir, 'cc-haha') await fs.mkdir(ccHahaDir, { recursive: true }) diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts index b17e5448..10c95c9c 100644 --- a/src/server/__tests__/h5-access-auth.test.ts +++ b/src/server/__tests__/h5-access-auth.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import * as fs from 'node:fs/promises' +import { createServer } from 'node:net' import * as os from 'node:os' import * as path from 'node:path' import { startServer } from '../index.js' @@ -35,8 +36,20 @@ async function waitForServer(url: string): Promise { throw new Error(`Timed out waiting for server at ${url}`) } -function randomPort(): number { - return 18000 + Math.floor(Math.random() * 10000) +async function availablePort(): Promise { + return await new Promise((resolve, reject) => { + const probe = createServer() + probe.once('error', reject) + probe.listen(0, '0.0.0.0', () => { + const address = probe.address() + if (!address || typeof address === 'string') { + probe.close(() => reject(new Error('Failed to allocate an H5 access test port'))) + return + } + const port = address.port + probe.close(() => resolve(port)) + }) + }) } function resolvePrivateLanBaseUrl(port: number): string | null { @@ -66,7 +79,7 @@ async function startRemoteServer(options: { authRequired?: boolean } = {}): Prom delete process.env.SERVER_AUTH_REQUIRED } - const port = randomPort() + const port = await availablePort() server = startServer(port, '0.0.0.0') baseUrl = `http://127.0.0.1:${port}` wsBaseUrl = `ws://127.0.0.1:${port}` diff --git a/src/server/__tests__/h5-access-policy.test.ts b/src/server/__tests__/h5-access-policy.test.ts index a9bf1a3e..68187a5d 100644 --- a/src/server/__tests__/h5-access-policy.test.ts +++ b/src/server/__tests__/h5-access-policy.test.ts @@ -21,12 +21,14 @@ describe('h5AccessPolicy', () => { expect(isLoopbackHost('192.168.0.20')).toBe(false) }) - test('keeps Tauri WebView requests to loopback tokenless', () => { - const request = req('http://127.0.0.1:3456/api/status', { - headers: { Origin: 'http://tauri.localhost' }, - }) - expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('local-trusted') - expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(false) + test('keeps desktop WebView requests to loopback tokenless', () => { + for (const origin of ['file://', 'http://tauri.localhost']) { + const request = req('http://127.0.0.1:3456/api/status', { + headers: { Origin: origin }, + }) + expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('local-trusted') + expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(false) + } }) test('keeps local internal SDK websocket routes tokenless', () => { @@ -70,7 +72,7 @@ describe('h5AccessPolicy', () => { }) test('keeps local desktop chat websocket routes tokenless', () => { - for (const init of [{}, { headers: { Origin: 'http://tauri.localhost' } }]) { + for (const init of [{}, { headers: { Origin: 'file://' } }, { headers: { Origin: 'http://tauri.localhost' } }]) { const request = req('http://127.0.0.1:3456/ws/session-1', init) expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('local-trusted') expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(false) diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 6ed3ecae..7381db61 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -1054,6 +1054,47 @@ describe('ProviderService', () => { globalThis.fetch = originalFetch } }) + + test('normalizes context-window suffixes before forwarding OpenAI Chat proxy requests', async () => { + const originalFetch = globalThis.fetch + const calls: Array<{ body: Record }> = [] + globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => { + calls.push({ body: JSON.parse(String(init?.body)) as Record }) + return new Response(JSON.stringify({ + id: 'chatcmpl-1', + object: 'chat.completion', + created: 0, + model: 'mimo-v2.5-pro', + choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + + try { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput({ apiFormat: 'openai_chat' })) + await svc.activateProvider(provider.id) + + const req = new Request('http://localhost:3456/proxy/v1/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'mimo-v2.5-pro[1m]', + max_tokens: 64, + messages: [{ role: 'user', content: 'hello from proxy' }], + }), + }) + + const res = await handleProxyRequest(req, new URL(req.url)) + expect(res.status).toBe(200) + expect(calls[0].body.model).toBe('mimo-v2.5-pro') + } finally { + globalThis.fetch = originalFetch + } + }) }) describe('testProvider', () => { @@ -1151,6 +1192,77 @@ describe('ProviderService', () => { } }) + test('normalizes context-window suffixes for Anthropic-compatible connectivity tests', async () => { + const originalFetch = globalThis.fetch + const calls: Array<{ body: Record }> = [] + globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => { + calls.push({ body: JSON.parse(String(init?.body)) as Record }) + return new Response(JSON.stringify({ + type: 'message', + model: 'mimo-v2.5-pro', + content: [], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + + try { + const svc = new ProviderService() + const result = await svc.testProviderConfig({ + baseUrl: 'https://api.xiaomimimo.com/anthropic', + apiKey: 'sk-api', + modelId: 'mimo-v2.5-pro[1m]', + authStrategy: 'auth_token', + apiFormat: 'anthropic', + }) + + expect(result.connectivity.success).toBe(true) + expect(result.connectivity.modelUsed).toBe('mimo-v2.5-pro') + expect(calls[0].body.model).toBe('mimo-v2.5-pro') + } finally { + globalThis.fetch = originalFetch + } + }) + + test('normalizes context-window suffixes for provider proxy pipeline tests', async () => { + const originalFetch = globalThis.fetch + const calls: Array<{ body: Record }> = [] + globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => { + calls.push({ body: JSON.parse(String(init?.body)) as Record }) + return new Response(JSON.stringify({ + id: 'chatcmpl-1', + object: 'chat.completion', + created: 0, + model: 'mimo-v2.5-pro', + choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + + try { + const svc = new ProviderService() + const result = await svc.testProviderConfig({ + baseUrl: 'https://api.example.com', + apiKey: 'sk-api', + modelId: 'mimo-v2.5-pro[1m]', + authStrategy: 'api_key', + apiFormat: 'openai_chat', + }) + + expect(result.connectivity.success).toBe(true) + expect(result.proxy?.success).toBe(true) + expect(result.connectivity.modelUsed).toBe('mimo-v2.5-pro') + expect(result.proxy?.modelUsed).toBe('mimo-v2.5-pro') + expect(calls.map((call) => call.body.model)).toEqual(['mimo-v2.5-pro', 'mimo-v2.5-pro']) + } finally { + globalThis.fetch = originalFetch + } + }) + test('should use configured network timeout for provider tests', async () => { await fs.writeFile( path.join(tmpDir, 'settings.json'), diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index 4584ec2b..34adcdba 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' import type { ServerWebSocket } from 'bun' import { + __markPrewarmPendingForTests, __resetWebSocketHandlerStateForTests, closeSessionConnection, getActiveSessionIds, @@ -135,4 +136,30 @@ describe('WebSocket handler session isolation', () => { expect(setTimeoutSpy).toHaveBeenCalled() expect(setTimeoutSpy.mock.calls[0]?.[1]).toBeGreaterThan(30_000) }) + + it('does not forward prewarm startup status to a reconnecting client', async () => { + const sessionId = `prewarm-reconnect-${crypto.randomUUID()}` + const second = makeClientSocket(sessionId) + let outputCallback: ((cliMsg: any) => void) | null = null + + __markPrewarmPendingForTests(sessionId) + spyOn(conversationService, 'hasSession').mockReturnValue(true) + spyOn(conversationService, 'getRecentSdkMessages').mockReturnValue([]) + spyOn(conversationService, 'onOutput').mockImplementation((_sid, callback) => { + outputCallback = callback + }) + spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {}) + spyOn(conversationService, 'clearOutputCallbacks').mockImplementation(() => { + outputCallback = null + }) + + handleWebSocket.open(second) + outputCallback?.({ + type: 'stream_event', + event: { type: 'message_start' }, + }) + + const secondMessages = second.sent.map((payload) => JSON.parse(payload)) + expect(secondMessages).not.toContainEqual({ type: 'status', state: 'thinking' }) + }) }) diff --git a/src/server/h5AccessPolicy.ts b/src/server/h5AccessPolicy.ts index cdfb49e1..68a731b6 100644 --- a/src/server/h5AccessPolicy.ts +++ b/src/server/h5AccessPolicy.ts @@ -5,6 +5,7 @@ export type H5RequestContext = { const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']) const LOCAL_ORIGINS = new Set([ + 'file://', 'http://tauri.localhost', 'https://tauri.localhost', 'tauri://localhost', diff --git a/src/server/middleware/cors.test.ts b/src/server/middleware/cors.test.ts index 49cf7330..4f76cce1 100644 --- a/src/server/middleware/cors.test.ts +++ b/src/server/middleware/cors.test.ts @@ -74,7 +74,7 @@ describe('resolveCors', () => { }) it('keeps trusted local desktop origins allowed when H5 token mode is active', async () => { - for (const origin of ['http://tauri.localhost', 'http://127.0.0.1:5179']) { + for (const origin of ['file://', 'http://tauri.localhost', 'http://127.0.0.1:5179']) { const result = await resolveCors(origin, 'http://192.168.0.20:3456', { h5Enabled: true, isOriginAllowed: async () => false, diff --git a/src/server/middleware/cors.ts b/src/server/middleware/cors.ts index fea295c4..e82333b8 100644 --- a/src/server/middleware/cors.ts +++ b/src/server/middleware/cors.ts @@ -36,6 +36,7 @@ export type CorsResolutionOptions = { } const LOCAL_ORIGINS = new Set([ + 'file://', 'http://tauri.localhost', 'https://tauri.localhost', 'tauri://localhost', diff --git a/src/server/proxy/handler.ts b/src/server/proxy/handler.ts index 5a8112de..2a1583ac 100644 --- a/src/server/proxy/handler.ts +++ b/src/server/proxy/handler.ts @@ -21,6 +21,7 @@ import { openaiResponsesStreamToAnthropic } from './streaming/openaiResponsesStr import type { AnthropicRequest } from './transform/types.js' import { getProxyFetchOptions } from '../../utils/proxy.js' import { getManualNetworkProxyUrl, loadNetworkSettings } from '../services/networkSettings.js' +import { normalizeModelStringForAPI } from '../../utils/model/model.js' const providerService = new ProviderService() @@ -127,7 +128,10 @@ export async function handleProxyRequest(req: Request, url: URL): Promise sdkMessages: any[] initMessage: any | null + usesOfficialOAuth: boolean + officialOAuthToken: string | null pendingPermissionRequests: Map< string, { @@ -261,6 +263,7 @@ export class ConversationService { // chdir 后落到正确目录。 // const childEnv = await this.buildChildEnv(launchWorkDir, sdkUrl, options) + const usesOfficialOAuth = this.shouldMarkManagedOAuth(options?.providerId) let proc: ReturnType try { @@ -308,6 +311,8 @@ export class ConversationService { outputDrain: Promise.resolve(), sdkMessages: [], initMessage: null, + usesOfficialOAuth, + officialOAuthToken: childEnv.CLAUDE_CODE_OAUTH_TOKEN ?? null, pendingPermissionRequests: new Map(), } this.sessions.set(sessionId, session) @@ -412,6 +417,10 @@ export class ConversationService { content: string, attachments?: AttachmentRef[], ): Promise { + const session = this.sessions.get(sessionId) + if (session) { + await this.refreshOfficialOAuthTokenBeforeTurn(sessionId, session) + } const userContent = await this.buildUserContent(content, sessionId, attachments) return this.sendSdkMessage(sessionId, { type: 'user', @@ -1139,6 +1148,33 @@ export class ConversationService { return env } + private async refreshOfficialOAuthTokenBeforeTurn( + sessionId: string, + session: SessionProcess, + ): Promise { + if (!session.usesOfficialOAuth) return + + let token: string | null = null + try { + const { hahaOAuthService } = await import('./hahaOAuthService.js') + token = await hahaOAuthService.ensureFreshAccessToken() + } catch (err) { + console.error( + '[conversationService] refresh official OAuth token before turn failed:', + err instanceof Error ? err.message : err, + ) + return + } + + if (!token || token === session.officialOAuthToken) return + + session.officialOAuthToken = token + this.sendSdkMessage(sessionId, { + type: 'update_environment_variables', + variables: { CLAUDE_CODE_OAUTH_TOKEN: token }, + }) + } + private shouldStripInheritedProviderEnv(providerId?: string | null): boolean { if (providerId !== undefined) { return true diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 940c069c..7d43fc33 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -41,6 +41,7 @@ import { loadNetworkSettings, type NetworkSettings, } from './networkSettings.js' +import { normalizeModelStringForAPI } from '../../utils/model/model.js' import type { SavedProvider, ProvidersIndex, @@ -445,11 +446,12 @@ export class ProviderService { const format: ApiFormat = input.apiFormat ?? 'anthropic' const authStrategy = input.authStrategy ?? 'api_key' const base = input.baseUrl.replace(/\/+$/, '') + const modelId = normalizeModelStringForAPI(input.modelId) const networkSettings = await loadNetworkSettings() // ── Step 1: Basic connectivity ─────────────────────────── // Directly call the upstream API to verify URL, key, and model. - const step1 = await this.testConnectivity(base, input.apiKey, input.modelId, format, authStrategy, networkSettings) + const step1 = await this.testConnectivity(base, input.apiKey, modelId, format, authStrategy, networkSettings) // If connectivity failed, no point running step 2 if (!step1.success) { @@ -463,7 +465,7 @@ export class ProviderService { // ── Step 2: Full proxy pipeline ────────────────────────── // Anthropic request → transform → upstream → transform back → validate - const step2 = await this.testProxyPipeline(base, input.apiKey, input.modelId, format, networkSettings) + const step2 = await this.testProxyPipeline(base, input.apiKey, modelId, format, networkSettings) return { connectivity: step1, proxy: step2 } } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 2c51c768..6abb0107 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -150,7 +150,7 @@ export const handleWebSocket = { } addActiveClient(sessionId, ws) - if (prewarmedSessions.has(sessionId)) { + if (prewarmPendingSessions.has(sessionId) || prewarmedSessions.has(sessionId)) { bindPrewarmMetadataCapture(sessionId) } else { bindClientSessionOutput(sessionId, ws) @@ -2235,5 +2235,11 @@ export function __resetWebSocketHandlerStateForTests(): void { activeSessions.clear() clientOutputCallbacks.clear() sessionCleanupTimers.clear() + prewarmPendingSessions.clear() + prewarmedSessions.clear() prewarmIdleTimers.clear() } + +export function __markPrewarmPendingForTests(sessionId: string): void { + prewarmPendingSessions.add(sessionId) +} diff --git a/src/services/api/withRetry.test.ts b/src/services/api/withRetry.test.ts new file mode 100644 index 00000000..a4b12132 --- /dev/null +++ b/src/services/api/withRetry.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' +import type Anthropic from '@anthropic-ai/sdk' +import { APIConnectionError } from '@anthropic-ai/sdk' +import { _resetKeepAliveForTesting, getProxyFetchOptions } from '../../utils/proxy.js' +import { withRetry } from './withRetry.js' + +describe('withRetry stale connections', () => { + test('disables keep-alive before retrying ECONNRESET connection failures', async () => { + _resetKeepAliveForTesting() + let attempts = 0 + const cause = Object.assign(new Error('socket hang up'), { + code: 'ECONNRESET', + }) + const staleConnection = new APIConnectionError({ + message: 'Connection error.', + cause, + }) + + const generator = withRetry( + async () => ({} as Anthropic), + async () => { + attempts += 1 + if (attempts === 1) { + throw staleConnection + } + return 'ok' + }, + { + model: 'claude-opus-4-7', + thinkingConfig: { type: 'disabled' }, + maxRetries: 1, + }, + ) + + let finalValue: string | undefined + for (;;) { + const next = await generator.next() + if (next.done) { + finalValue = next.value + break + } + } + + expect(finalValue).toBe('ok') + expect(attempts).toBe(2) + expect(getProxyFetchOptions().keepalive).toBe(false) + _resetKeepAliveForTesting() + }) +}) diff --git a/src/services/api/withRetry.ts b/src/services/api/withRetry.ts index 5ec9ad08..f63b6f77 100644 --- a/src/services/api/withRetry.ts +++ b/src/services/api/withRetry.ts @@ -35,7 +35,6 @@ import { isNonCustomOpusModel } from '../../utils/model/model.js' import { disableKeepAlive } from '../../utils/proxy.js' import { sleep } from '../../utils/sleep.js' import type { ThinkingConfig } from '../../utils/thinking.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, @@ -216,13 +215,7 @@ export async function* withRetry( // - Vertex-specific auth errors (credential refresh failures, 401) // - ECONNRESET/EPIPE: stale keep-alive socket; disable pooling and reconnect const isStaleConnection = isStaleConnectionError(lastError) - if ( - isStaleConnection && - getFeatureValue_CACHED_MAY_BE_STALE( - 'tengu_disable_keepalive_on_econnreset', - false, - ) - ) { + if (isStaleConnection) { logForDebugging( 'Stale connection (ECONNRESET/EPIPE) — disabling keep-alive for retry', ) diff --git a/src/tools/FileReadTool/FileReadTool.ts b/src/tools/FileReadTool/FileReadTool.ts index 91f7766a..37b31f7d 100644 --- a/src/tools/FileReadTool/FileReadTool.ts +++ b/src/tools/FileReadTool/FileReadTool.ts @@ -44,6 +44,7 @@ import { compressImageBufferWithTokenLimit, createImageMetadataText, detectImageFormatFromBuffer, + downsampleImageBufferToVisionTokenBudget, type ImageDimensions, ImageResizeError, maybeResizeAndDownsampleImageBuffer, @@ -798,6 +799,18 @@ function createImageResponse( } } +function estimateVisionImageTokens(dimensions?: ImageDimensions): number | null { + const width = dimensions?.displayWidth + const height = dimensions?.displayHeight + if (!width || !height || width <= 0 || height <= 0) { + return null + } + // Claude vision charges approximately width * height / 750 image tokens. + // Image blocks are not base64 text blocks, so using base64 length here would + // massively over-compress ordinary screenshots before the model sees them. + return Math.ceil((width * height) / 750) +} + /** * Inner implementation of call, separated to allow ENOENT handling in the outer call. */ @@ -1133,10 +1146,31 @@ export async function readImageWithTokenBudget( result = createImageResponse(imageBuffer, detectedFormat, originalSize) } - // Check if it fits in token budget - const estimatedTokens = Math.ceil(result.file.base64.length * 0.125) - if (estimatedTokens > maxTokens) { - // Aggressive compression from the SAME buffer (no re-read) + // Check if it fits in vision token budget. This is intentionally based on + // image dimensions, not base64 payload length, because the tool result is an + // image content block rather than text. + const estimatedTokens = estimateVisionImageTokens(result.file.dimensions) + if (estimatedTokens !== null && estimatedTokens > maxTokens) { + // Downsample by vision pixel budget first. This preserves detail far better + // than the legacy base64-text token heuristic for image blocks. + try { + const downsampled = await downsampleImageBufferToVisionTokenBudget( + imageBuffer, + originalSize, + detectedFormat, + maxTokens, + ) + return createImageResponse( + downsampled.buffer, + downsampled.mediaType, + originalSize, + downsampled.dimensions, + ) + } catch (e) { + logError(e) + } + + // Compatibility fallback from the SAME buffer (no re-read) try { const compressed = await compressImageBufferWithTokenLimit( imageBuffer, diff --git a/src/tools/FileReadTool/imageProcessor.ts b/src/tools/FileReadTool/imageProcessor.ts index 20afa900..6d433dd9 100644 --- a/src/tools/FileReadTool/imageProcessor.ts +++ b/src/tools/FileReadTool/imageProcessor.ts @@ -14,7 +14,7 @@ export type SharpInstance = { palette?: boolean colors?: number }): SharpInstance - webp(options?: { quality?: number }): SharpInstance + webp(options?: { quality?: number; lossless?: boolean }): SharpInstance toBuffer(): Promise } diff --git a/src/utils/__tests__/imageResizer.test.ts b/src/utils/__tests__/imageResizer.test.ts new file mode 100644 index 00000000..c653718d --- /dev/null +++ b/src/utils/__tests__/imageResizer.test.ts @@ -0,0 +1,437 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +type ProcessorMode = 'metadata' | 'throw' + +let processorMode: ProcessorMode = 'metadata' +let resizeCalls: Array<{ width: number; height: number }> = [] +let jpegCalls: Array<{ quality?: number; resized: boolean }> = [] +let pngCalls: Array<{ compressionLevel?: number; palette?: boolean }> = [] +let webpCalls: Array<{ quality?: number; lossless?: boolean }> = [] + +let originalPngDimensions: { width?: number; height?: number; format?: string } = + { width: 1394, height: 4404, format: 'png' } +let outputForOperations: (operations: Operation[]) => Buffer = () => + Buffer.from('resized-image') + +type Operation = + | { type: 'resize'; width: number; height: number } + | { type: 'jpeg'; quality?: number } + | { type: 'png'; compressionLevel?: number; palette?: boolean } + | { type: 'webp'; quality?: number; lossless?: boolean } + +mock.module('../../tools/FileReadTool/imageProcessor.js', () => ({ + getImageProcessor: async () => { + if (processorMode === 'throw') { + throw new Error('image processor unavailable') + } + + return (_input: Buffer) => { + const operations: Operation[] = [] + const instance = { + metadata: async () => originalPngDimensions, + resize: (width: number, height: number) => { + resizeCalls.push({ width, height }) + operations.push({ type: 'resize', width, height }) + return instance + }, + jpeg: (options?: { quality?: number }) => { + jpegCalls.push({ + quality: options?.quality, + resized: operations.some(op => op.type === 'resize'), + }) + operations.push({ type: 'jpeg', quality: options?.quality }) + return instance + }, + png: (options?: { compressionLevel?: number; palette?: boolean }) => { + pngCalls.push(options ?? {}) + operations.push({ + type: 'png', + compressionLevel: options?.compressionLevel, + palette: options?.palette, + }) + return instance + }, + webp: (options?: { quality?: number; lossless?: boolean }) => { + webpCalls.push(options ?? {}) + operations.push({ + type: 'webp', + quality: options?.quality, + lossless: options?.lossless, + }) + return instance + }, + toBuffer: async () => outputForOperations(operations), + } + return instance + } + }, +})) + +const { + ImageResizeError, + downsampleImageBufferToVisionTokenBudget, + maybeResizeAndDownsampleImageBuffer, +} = await import('../imageResizer.js') +const { readImageWithTokenBudget } = await import( + '../../tools/FileReadTool/FileReadTool.js' +) + +function makePngHeader(width: number, height: number): Buffer { + const buffer = Buffer.alloc(32) + buffer[0] = 0x89 + buffer[1] = 0x50 + buffer[2] = 0x4e + buffer[3] = 0x47 + buffer.writeUInt32BE(width, 16) + buffer.writeUInt32BE(height, 20) + return buffer +} + +beforeEach(() => { + processorMode = 'metadata' + resizeCalls = [] + jpegCalls = [] + pngCalls = [] + webpCalls = [] + originalPngDimensions = { width: 1394, height: 4404, format: 'png' } + outputForOperations = () => Buffer.from('resized-image') +}) + +describe('maybeResizeAndDownsampleImageBuffer', () => { + test('passes through a tall screenshot when bytes are already within API limits', async () => { + const imageBuffer = Buffer.alloc(1024, 1) + + const result = await maybeResizeAndDownsampleImageBuffer( + imageBuffer, + imageBuffer.length, + 'png', + ) + + expect(result.buffer).toBe(imageBuffer) + expect(result.mediaType).toBe('png') + expect(resizeCalls).toEqual([]) + expect(result.dimensions).toEqual({ + originalWidth: 1394, + originalHeight: 4404, + displayWidth: 1394, + displayHeight: 4404, + }) + }) + + test('falls back to the original tall screenshot if local image processing is unavailable', async () => { + processorMode = 'throw' + const imageBuffer = makePngHeader(1394, 4404) + + const result = await maybeResizeAndDownsampleImageBuffer( + imageBuffer, + imageBuffer.length, + 'png', + ) + + expect(result.buffer).toBe(imageBuffer) + expect(result.mediaType).toBe('png') + }) + + test('tries lossless webp compression before lossy jpeg for oversized screenshots', async () => { + originalPngDimensions = { width: 4096, height: 2304, format: 'png' } + outputForOperations = operations => { + if (operations.some(op => op.type === 'webp')) { + return Buffer.alloc(1024, 3) + } + return Buffer.alloc(4 * 1024 * 1024, 2) + } + const imageBuffer = Buffer.alloc(4 * 1024 * 1024, 1) + + const result = await maybeResizeAndDownsampleImageBuffer( + imageBuffer, + imageBuffer.length, + 'png', + ) + + expect(result.mediaType).toBe('webp') + expect(result.buffer.length).toBe(1024) + expect(resizeCalls).toEqual([]) + expect(webpCalls).toEqual([{ lossless: true }]) + expect(jpegCalls).toEqual([]) + expect(result.dimensions).toEqual({ + originalWidth: 4096, + originalHeight: 2304, + displayWidth: 4096, + displayHeight: 2304, + }) + }) + + test('downsamples to a codex-sized long edge before lowering jpeg below readable quality', async () => { + originalPngDimensions = { width: 5000, height: 3000, format: 'png' } + outputForOperations = operations => { + const resizeOp = operations.find( + (op): op is Extract => + op.type === 'resize', + ) + const jpegOp = operations.find( + (op): op is Extract => + op.type === 'jpeg', + ) + if (resizeOp?.width === 2048 && resizeOp.height === 1229) { + if (jpegOp?.quality === 85) { + return Buffer.alloc(1024, 4) + } + } + return Buffer.alloc(6 * 1024 * 1024, 5) + } + const imageBuffer = Buffer.alloc(6 * 1024 * 1024, 1) + + const result = await maybeResizeAndDownsampleImageBuffer( + imageBuffer, + imageBuffer.length, + 'png', + ) + + expect(result.mediaType).toBe('jpeg') + expect(result.buffer.length).toBe(1024) + expect(resizeCalls).toContainEqual({ width: 2048, height: 1229 }) + expect(jpegCalls.some(call => (call.quality ?? 100) < 75)).toBe(false) + expect(result.dimensions).toEqual({ + originalWidth: 5000, + originalHeight: 3000, + displayWidth: 2048, + displayHeight: 1229, + }) + }) + + test('resizes to the hard dimension cap when only height exceeds the API limit', async () => { + originalPngDimensions = { width: 1000, height: 9000, format: 'png' } + outputForOperations = operations => { + const resizeOp = operations.find( + (op): op is Extract => + op.type === 'resize', + ) + const jpegOp = operations.find( + (op): op is Extract => + op.type === 'jpeg', + ) + if ( + resizeOp?.width === 889 && + resizeOp.height === 8000 && + jpegOp?.quality === 85 + ) { + return Buffer.alloc(1024, 6) + } + return Buffer.alloc(6 * 1024 * 1024, 5) + } + const imageBuffer = Buffer.alloc(200_000, 1) + + const result = await maybeResizeAndDownsampleImageBuffer( + imageBuffer, + imageBuffer.length, + 'png', + ) + + expect(result.mediaType).toBe('jpeg') + expect(resizeCalls).toContainEqual({ width: 889, height: 8000 }) + expect(result.dimensions).toEqual({ + originalWidth: 1000, + originalHeight: 9000, + displayWidth: 889, + displayHeight: 8000, + }) + }) + + test('uses readable fallback dimensions before dropping to fallback jpeg quality', async () => { + originalPngDimensions = { width: 6000, height: 5000, format: 'png' } + outputForOperations = operations => { + const resizeOp = operations.find( + (op): op is Extract => + op.type === 'resize', + ) + const jpegOp = operations.find( + (op): op is Extract => + op.type === 'jpeg', + ) + if ( + resizeOp?.width === 1568 && + resizeOp.height === 1307 && + jpegOp?.quality === 65 + ) { + return Buffer.alloc(1024, 6) + } + return Buffer.alloc(6 * 1024 * 1024, 5) + } + const imageBuffer = Buffer.alloc(6 * 1024 * 1024, 1) + + const result = await maybeResizeAndDownsampleImageBuffer( + imageBuffer, + imageBuffer.length, + 'png', + ) + + expect(result.mediaType).toBe('jpeg') + expect(resizeCalls).toContainEqual({ width: 2048, height: 1707 }) + expect(resizeCalls).toContainEqual({ width: 1568, height: 1307 }) + expect(jpegCalls.some(call => call.quality === 65)).toBe(true) + expect(result.dimensions).toEqual({ + originalWidth: 6000, + originalHeight: 5000, + displayWidth: 1568, + displayHeight: 1307, + }) + }) + + test('throws instead of over-compressing when readable fallback cannot fit', async () => { + originalPngDimensions = { width: 6000, height: 5000, format: 'png' } + outputForOperations = () => Buffer.alloc(6 * 1024 * 1024, 5) + const imageBuffer = Buffer.alloc(6 * 1024 * 1024, 1) + + expect( + maybeResizeAndDownsampleImageBuffer( + imageBuffer, + imageBuffer.length, + 'png', + ), + ).rejects.toBeInstanceOf(ImageResizeError) + }) + + test('tries lossless webp for oversized webp screenshots before jpeg conversion', async () => { + originalPngDimensions = { width: 4096, height: 2304, format: 'webp' } + outputForOperations = operations => { + if (operations.some(op => op.type === 'webp')) { + return Buffer.alloc(1024, 3) + } + return Buffer.alloc(4 * 1024 * 1024, 2) + } + const imageBuffer = Buffer.alloc(4 * 1024 * 1024, 1) + + const result = await maybeResizeAndDownsampleImageBuffer( + imageBuffer, + imageBuffer.length, + 'webp', + ) + + expect(result.mediaType).toBe('webp') + expect(webpCalls).toEqual([{ lossless: true }]) + expect(jpegCalls).toEqual([]) + }) +}) + +describe('readImageWithTokenBudget', () => { + test('does not recompress readable screenshots just because their base64 text would exceed maxTokens', async () => { + originalPngDimensions = { width: 1920, height: 1080, format: 'png' } + outputForOperations = () => Buffer.from('compressed-image') + const imageBuffer = Buffer.alloc(200_000, 1) + const imagePath = join(tmpdir(), `cc-haha-image-budget-${Date.now()}.png`) + await writeFile(imagePath, imageBuffer) + + try { + const result = await readImageWithTokenBudget(imagePath, 25_000) + + expect(result.file.base64).toBe(imageBuffer.toString('base64')) + expect(result.file.type).toBe('image/png') + expect(jpegCalls).toEqual([]) + expect(resizeCalls).toEqual([]) + } finally { + await rm(imagePath, { force: true }) + } + }) + + test('downsamples over-budget vision images by pixel budget instead of base64 text budget', async () => { + originalPngDimensions = { width: 6000, height: 4000, format: 'png' } + outputForOperations = operations => { + const resizeOp = operations.find( + (op): op is Extract => + op.type === 'resize', + ) + const jpegOp = operations.find( + (op): op is Extract => + op.type === 'jpeg', + ) + if ( + resizeOp?.width === 3674 && + resizeOp.height === 2449 && + jpegOp?.quality === 85 + ) { + return Buffer.alloc(1024, 7) + } + return Buffer.alloc(6 * 1024 * 1024, 8) + } + const imageBuffer = Buffer.alloc(200_000, 1) + const imagePath = join( + tmpdir(), + `cc-haha-image-vision-budget-${Date.now()}.png`, + ) + await writeFile(imagePath, imageBuffer) + + try { + const result = await readImageWithTokenBudget(imagePath, 12_000) + + expect(result.file.type).toBe('image/jpeg') + expect(Buffer.from(result.file.base64, 'base64').length).toBe(1024) + expect(resizeCalls).toContainEqual({ width: 3674, height: 2449 }) + expect(jpegCalls.some(call => (call.quality ?? 100) < 75)).toBe(false) + expect(result.file.dimensions).toEqual({ + originalWidth: 6000, + originalHeight: 4000, + displayWidth: 3674, + displayHeight: 2449, + }) + } finally { + await rm(imagePath, { force: true }) + } + }) + + test('uses a readable fallback when the vision-budget resize cannot fit the byte cap', async () => { + originalPngDimensions = { width: 6000, height: 4000, format: 'png' } + outputForOperations = operations => { + const resizeOp = operations.find( + (op): op is Extract => + op.type === 'resize', + ) + const jpegOp = operations.find( + (op): op is Extract => + op.type === 'jpeg', + ) + if ( + resizeOp?.width === 1568 && + resizeOp.height === 1045 && + jpegOp?.quality === 65 + ) { + return Buffer.alloc(1024, 7) + } + return Buffer.alloc(6 * 1024 * 1024, 8) + } + const imageBuffer = Buffer.alloc(200_000, 1) + + const result = await downsampleImageBufferToVisionTokenBudget( + imageBuffer, + imageBuffer.length, + 'png', + 12_000, + ) + + expect(result.mediaType).toBe('jpeg') + expect(resizeCalls).toContainEqual({ width: 3674, height: 2449 }) + expect(resizeCalls).toContainEqual({ width: 1568, height: 1045 }) + expect(result.dimensions).toEqual({ + originalWidth: 6000, + originalHeight: 4000, + displayWidth: 1568, + displayHeight: 1045, + }) + }) + + test('reports an image resize error when token-budget dimensions are unavailable', async () => { + originalPngDimensions = { format: 'png' } + const imageBuffer = Buffer.alloc(200_000, 1) + + expect( + downsampleImageBufferToVisionTokenBudget( + imageBuffer, + imageBuffer.length, + 'png', + 12_000, + ), + ).rejects.toBeInstanceOf(ImageResizeError) + }) +}) diff --git a/src/utils/forkedAgent.test.ts b/src/utils/forkedAgent.test.ts new file mode 100644 index 00000000..b4c50a32 --- /dev/null +++ b/src/utils/forkedAgent.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' +import type { PromptCommand } from '../commands.js' +import type { ToolUseContext } from '../Tool.js' +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' +import { prepareForkedCommandContext } from './forkedAgent.js' + +const makeAgent = (agentType: string): AgentDefinition => ({ + agentType, + whenToUse: `Use ${agentType}`, + source: 'built-in', + baseDir: 'built-in', + getSystemPrompt: () => `${agentType} prompt`, +}) + +function makeContext(activeAgents: AgentDefinition[]): ToolUseContext { + return { + getAppState: () => ({ + toolPermissionContext: { + alwaysAllowRules: { command: [] }, + }, + }), + options: { + agentDefinitions: { activeAgents }, + }, + } as unknown as ToolUseContext +} + +describe('prepareForkedCommandContext', () => { + const command: PromptCommand = { + type: 'prompt', + progressMessage: 'running', + contentLength: 0, + source: 'builtin', + getPromptForCommand: async (args) => [{ type: 'text', text: args }], + } + + test('uses explicit agent and prompt overrides for dynamic forked commands', async () => { + const prepared = await prepareForkedCommandContext( + command, + 'debugger fix tests', + makeContext([makeAgent('general-purpose'), makeAgent('debugger')]), + { + agentType: 'debugger', + promptArgs: 'fix tests', + requireAgentType: true, + }, + ) + + expect(prepared.baseAgent.agentType).toBe('debugger') + expect(prepared.skillContent).toBe('fix tests') + }) + + test('throws when a required explicit agent is unavailable', async () => { + await expect( + prepareForkedCommandContext( + command, + 'debugger fix tests', + makeContext([makeAgent('general-purpose')]), + { + agentType: 'debugger', + promptArgs: 'fix tests', + requireAgentType: true, + }, + ), + ).rejects.toThrow('Agent not available: debugger') + }) +}) diff --git a/src/utils/forkedAgent.ts b/src/utils/forkedAgent.ts index eff95953..9e2ca02a 100644 --- a/src/utils/forkedAgent.ts +++ b/src/utils/forkedAgent.ts @@ -184,6 +184,15 @@ export type PreparedForkedContext = { promptMessages: Message[] } +export type ForkedCommandContextOptions = { + /** Override which agent type executes this forked command. */ + agentType?: string + /** When true, a missing explicit agent is an error instead of falling back. */ + requireAgentType?: boolean + /** Override the args passed to getPromptForCommand. */ + promptArgs?: string +} + /** * Prepares the context for executing a forked command/skill. * This handles the common setup that both SkillTool and slash commands need. @@ -192,9 +201,11 @@ export async function prepareForkedCommandContext( command: PromptCommand, args: string, context: ToolUseContext, + options: ForkedCommandContextOptions = {}, ): Promise { // Get skill content with $ARGUMENTS replaced - const skillPrompt = await command.getPromptForCommand(args, context) + const promptArgs = options.promptArgs ?? args + const skillPrompt = await command.getPromptForCommand(promptArgs, context) const skillContent = skillPrompt .map(block => (block.type === 'text' ? block.text : '')) .join('\n') @@ -208,11 +219,15 @@ export async function prepareForkedCommandContext( allowedTools, ) - // Use command.agent if specified, otherwise 'general-purpose' - const agentTypeName = command.agent ?? 'general-purpose' + // Use an explicit override first, then command.agent, then general-purpose. + const agentTypeName = options.agentType ?? command.agent ?? 'general-purpose' const agents = context.options.agentDefinitions.activeAgents + const requestedAgent = agents.find(a => a.agentType === agentTypeName) + if (options.requireAgentType && !requestedAgent) { + throw new Error(`Agent not available: ${agentTypeName}`) + } const baseAgent = - agents.find(a => a.agentType === agentTypeName) ?? + requestedAgent ?? agents.find(a => a.agentType === 'general-purpose') ?? agents[0] diff --git a/src/utils/imageResizer.ts b/src/utils/imageResizer.ts index 149b4087..bdfd61f2 100644 --- a/src/utils/imageResizer.ts +++ b/src/utils/imageResizer.ts @@ -162,6 +162,16 @@ interface CompressedImageResult { originalSize: number } +type ResizeDimensions = { + width: number + height: number +} + +const IMAGE_PROMPT_SOFT_LONG_EDGE = 2048 +const IMAGE_PROMPT_READABLE_LONG_EDGE = 1568 +const READABLE_JPEG_QUALITIES = [85, 75] as const +const FALLBACK_JPEG_QUALITIES = [65] as const + /** * Extracted from FileReadTool's readImage function * Resizes image buffer to meet size and dimension constraints @@ -226,160 +236,99 @@ export async function maybeResizeAndDownsampleImageBuffer( } } + const hardDims = constrainDimensions( + width, + height, + IMAGE_MAX_WIDTH, + IMAGE_MAX_HEIGHT, + ) const needsDimensionResize = - width > IMAGE_MAX_WIDTH || height > IMAGE_MAX_HEIGHT - const isPng = normalizedMediaType === 'png' + hardDims.width !== width || hardDims.height !== height - // If dimensions are within limits but file is too large, try compression first - // This preserves full resolution when possible + // Try lossless or high-quality compression at original dimensions first. + // This keeps screenshot text sharp when the only problem is payload size. if (!needsDimensionResize && originalSize > IMAGE_TARGET_RAW_SIZE) { - // For PNGs, try PNG compression first to preserve transparency - if (isPng) { - // Create fresh sharp instance for each compression attempt - const pngCompressed = await sharp(imageBuffer) - .png({ compressionLevel: 9, palette: true }) - .toBuffer() - if (pngCompressed.length <= IMAGE_TARGET_RAW_SIZE) { - return { - buffer: pngCompressed, - mediaType: 'png', - dimensions: { - originalWidth, - originalHeight, - displayWidth: width, - displayHeight: height, - }, - } - } - } - // Try JPEG compression (lossy but much smaller) - for (const quality of [80, 60, 40, 20]) { - // Create fresh sharp instance for each attempt - const compressedBuffer = await sharp(imageBuffer) - .jpeg({ quality }) - .toBuffer() - if (compressedBuffer.length <= IMAGE_TARGET_RAW_SIZE) { - return { - buffer: compressedBuffer, - mediaType: 'jpeg', - dimensions: { - originalWidth, - originalHeight, - displayWidth: width, - displayHeight: height, - }, - } - } - } - // Quality reduction alone wasn't enough, fall through to resize - } - - // Constrain dimensions if needed - if (width > IMAGE_MAX_WIDTH) { - height = Math.round((height * IMAGE_MAX_WIDTH) / width) - width = IMAGE_MAX_WIDTH - } - - if (height > IMAGE_MAX_HEIGHT) { - width = Math.round((width * IMAGE_MAX_HEIGHT) / height) - height = IMAGE_MAX_HEIGHT - } - - // IMPORTANT: Always create fresh sharp(imageBuffer) instances for each operation. - // The native image-processor-napi module doesn't properly apply format conversions - // when reusing a sharp instance after calling toBuffer(). This caused a bug where - // all compression attempts (PNG, JPEG at various qualities) returned identical sizes. - logForDebugging(`Resizing to ${width}x${height}`) - const resizedImageBuffer = await sharp(imageBuffer) - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .toBuffer() - - // If still too large after resize, try compression - if (resizedImageBuffer.length > IMAGE_TARGET_RAW_SIZE) { - // For PNGs, try PNG compression first to preserve transparency - if (isPng) { - const pngCompressed = await sharp(imageBuffer) - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .png({ compressionLevel: 9, palette: true }) - .toBuffer() - if (pngCompressed.length <= IMAGE_TARGET_RAW_SIZE) { - return { - buffer: pngCompressed, - mediaType: 'png', - dimensions: { - originalWidth, - originalHeight, - displayWidth: width, - displayHeight: height, - }, - } - } - } - - // Try JPEG with progressively lower quality - for (const quality of [80, 60, 40, 20]) { - const compressedBuffer = await sharp(imageBuffer) - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .jpeg({ quality }) - .toBuffer() - if (compressedBuffer.length <= IMAGE_TARGET_RAW_SIZE) { - return { - buffer: compressedBuffer, - mediaType: 'jpeg', - dimensions: { - originalWidth, - originalHeight, - displayWidth: width, - displayHeight: height, - }, - } - } - } - // If still too large, resize smaller and compress aggressively - const smallerWidth = Math.min(width, 1000) - const smallerHeight = Math.round( - (height * smallerWidth) / Math.max(width, 1), - ) - logForDebugging('Still too large, compressing with JPEG') - const compressedBuffer = await sharp(imageBuffer) - .resize(smallerWidth, smallerHeight, { - fit: 'inside', - withoutEnlargement: true, - }) - .jpeg({ quality: 20 }) - .toBuffer() - logForDebugging(`JPEG compressed buffer size: ${compressedBuffer.length}`) - return { - buffer: compressedBuffer, - mediaType: 'jpeg', - dimensions: { - originalWidth, - originalHeight, - displayWidth: smallerWidth, - displayHeight: smallerHeight, - }, - } - } - - return { - buffer: resizedImageBuffer, - mediaType: normalizedMediaType, - dimensions: { + const compressed = await tryEncodeImageCandidate({ + sharp, + imageBuffer, originalWidth, originalHeight, - displayWidth: width, - displayHeight: height, - }, + dimensions: { width, height }, + format: normalizedMediaType, + maxBytes: IMAGE_TARGET_RAW_SIZE, + }) + if (compressed) return compressed } + + if (needsDimensionResize) { + logForDebugging(`Resizing to ${hardDims.width}x${hardDims.height}`) + const hardResize = await tryEncodeImageCandidate({ + sharp, + imageBuffer, + originalWidth, + originalHeight, + dimensions: hardDims, + format: normalizedMediaType, + maxBytes: IMAGE_TARGET_RAW_SIZE, + }) + if (hardResize) return hardResize + } + + const softDims = constrainDimensions( + hardDims.width, + hardDims.height, + IMAGE_PROMPT_SOFT_LONG_EDGE, + IMAGE_PROMPT_SOFT_LONG_EDGE, + ) + if ( + softDims.width !== hardDims.width || + softDims.height !== hardDims.height + ) { + logForDebugging( + `Downsampling image to ${softDims.width}x${softDims.height}`, + ) + const softResize = await tryEncodeImageCandidate({ + sharp, + imageBuffer, + originalWidth, + originalHeight, + dimensions: softDims, + format: normalizedMediaType, + maxBytes: IMAGE_TARGET_RAW_SIZE, + }) + if (softResize) return softResize + } + + const readableDims = constrainDimensions( + hardDims.width, + hardDims.height, + IMAGE_PROMPT_READABLE_LONG_EDGE, + IMAGE_PROMPT_READABLE_LONG_EDGE, + ) + if ( + readableDims.width !== softDims.width || + readableDims.height !== softDims.height + ) { + logForDebugging( + `Downsampling image to readable fallback ${readableDims.width}x${readableDims.height}`, + ) + const readableResize = await tryEncodeImageCandidate({ + sharp, + imageBuffer, + originalWidth, + originalHeight, + dimensions: readableDims, + format: normalizedMediaType, + maxBytes: IMAGE_TARGET_RAW_SIZE, + allowFallbackQuality: true, + }) + if (readableResize) return readableResize + } + + throw new ImageResizeError( + `Unable to compress image (${formatFileSize(originalSize)}) below the 5MB API base64 limit without over-compressing it. ` + + `Please resize the image manually or use a smaller image.`, + ) } catch (error) { // Log the error and emit analytics event logError(error as Error) @@ -432,6 +381,277 @@ export async function maybeResizeAndDownsampleImageBuffer( } } +function constrainDimensions( + width: number, + height: number, + maxWidth: number, + maxHeight: number, +): ResizeDimensions { + let nextWidth = width + let nextHeight = height + + if (nextWidth > maxWidth) { + nextHeight = Math.round((nextHeight * maxWidth) / nextWidth) + nextWidth = maxWidth + } + + if (nextHeight > maxHeight) { + nextWidth = Math.round((nextWidth * maxHeight) / nextHeight) + nextHeight = maxHeight + } + + return { + width: Math.max(1, nextWidth), + height: Math.max(1, nextHeight), + } +} + +function constrainDimensionsToPixelBudget( + width: number, + height: number, + maxPixels: number, +): ResizeDimensions { + if (width * height <= maxPixels) { + return { width, height } + } + + const scale = Math.sqrt(maxPixels / (width * height)) + let nextWidth = Math.max(1, Math.round(width * scale)) + let nextHeight = Math.max(1, Math.round(height * scale)) + + while (nextWidth * nextHeight > maxPixels) { + if (nextWidth >= nextHeight) { + nextWidth -= 1 + } else { + nextHeight -= 1 + } + } + + return { + width: nextWidth, + height: nextHeight, + } +} + +function createResizeResult( + buffer: Buffer, + mediaType: string, + originalWidth: number, + originalHeight: number, + dimensions: ResizeDimensions, +): ResizeResult { + return { + buffer, + mediaType, + dimensions: { + originalWidth, + originalHeight, + displayWidth: dimensions.width, + displayHeight: dimensions.height, + }, + } +} + +function createImagePipeline( + sharp: SharpFunction, + imageBuffer: Buffer, + originalWidth: number, + originalHeight: number, + dimensions: ResizeDimensions, +): SharpInstance { + const pipeline = sharp(imageBuffer) + if ( + dimensions.width === originalWidth && + dimensions.height === originalHeight + ) { + return pipeline + } + + return pipeline.resize(dimensions.width, dimensions.height, { + fit: 'inside', + withoutEnlargement: true, + }) +} + +async function tryEncodeImageCandidate({ + sharp, + imageBuffer, + originalWidth, + originalHeight, + dimensions, + format, + maxBytes, + allowFallbackQuality = false, +}: { + sharp: SharpFunction + imageBuffer: Buffer + originalWidth: number + originalHeight: number + dimensions: ResizeDimensions + format: string + maxBytes: number + allowFallbackQuality?: boolean +}): Promise { + const normalizedFormat = format === 'jpg' ? 'jpeg' : format + + if (normalizedFormat === 'png') { + const pngCompressed = await createImagePipeline( + sharp, + imageBuffer, + originalWidth, + originalHeight, + dimensions, + ) + .png({ compressionLevel: 9 }) + .toBuffer() + if (pngCompressed.length <= maxBytes) { + return createResizeResult( + pngCompressed, + 'png', + originalWidth, + originalHeight, + dimensions, + ) + } + + const webpLossless = await createImagePipeline( + sharp, + imageBuffer, + originalWidth, + originalHeight, + dimensions, + ) + .webp({ lossless: true }) + .toBuffer() + if (webpLossless.length <= maxBytes) { + return createResizeResult( + webpLossless, + 'webp', + originalWidth, + originalHeight, + dimensions, + ) + } + } else if (normalizedFormat === 'webp') { + const webpLossless = await createImagePipeline( + sharp, + imageBuffer, + originalWidth, + originalHeight, + dimensions, + ) + .webp({ lossless: true }) + .toBuffer() + if (webpLossless.length <= maxBytes) { + return createResizeResult( + webpLossless, + 'webp', + originalWidth, + originalHeight, + dimensions, + ) + } + } + + const jpegQualities = allowFallbackQuality + ? [...READABLE_JPEG_QUALITIES, ...FALLBACK_JPEG_QUALITIES] + : READABLE_JPEG_QUALITIES + for (const quality of jpegQualities) { + const jpegBuffer = await createImagePipeline( + sharp, + imageBuffer, + originalWidth, + originalHeight, + dimensions, + ) + .jpeg({ quality }) + .toBuffer() + if (jpegBuffer.length <= maxBytes) { + return createResizeResult( + jpegBuffer, + 'jpeg', + originalWidth, + originalHeight, + dimensions, + ) + } + } + + return null +} + +export async function downsampleImageBufferToVisionTokenBudget( + imageBuffer: Buffer, + originalSize: number, + ext: string, + maxTokens: number, +): Promise { + const sharp = await getImageProcessor() + const metadata = await sharp(imageBuffer).metadata() + const format = metadata.format ?? ext + const normalizedMediaType = format === 'jpg' ? 'jpeg' : format + + if (!metadata.width || !metadata.height) { + throw new ImageResizeError( + 'Unable to downsample image for token budget because dimensions are unavailable.', + ) + } + + const originalWidth = metadata.width + const originalHeight = metadata.height + const hardDims = constrainDimensions( + originalWidth, + originalHeight, + IMAGE_MAX_WIDTH, + IMAGE_MAX_HEIGHT, + ) + const maxPixels = Math.max(1, maxTokens * 750) + const tokenDims = constrainDimensionsToPixelBudget( + hardDims.width, + hardDims.height, + maxPixels, + ) + + const tokenResize = await tryEncodeImageCandidate({ + sharp, + imageBuffer, + originalWidth, + originalHeight, + dimensions: tokenDims, + format: normalizedMediaType, + maxBytes: IMAGE_TARGET_RAW_SIZE, + allowFallbackQuality: true, + }) + if (tokenResize) return tokenResize + + const readableDims = constrainDimensions( + tokenDims.width, + tokenDims.height, + IMAGE_PROMPT_READABLE_LONG_EDGE, + IMAGE_PROMPT_READABLE_LONG_EDGE, + ) + if ( + readableDims.width !== tokenDims.width || + readableDims.height !== tokenDims.height + ) { + const readableResize = await tryEncodeImageCandidate({ + sharp, + imageBuffer, + originalWidth, + originalHeight, + dimensions: readableDims, + format: normalizedMediaType, + maxBytes: IMAGE_TARGET_RAW_SIZE, + allowFallbackQuality: true, + }) + if (readableResize) return readableResize + } + + throw new ImageResizeError( + `Unable to downsample image (${formatFileSize(originalSize)}) below the ${maxTokens} token budget without over-compressing it.`, + ) +} + export interface ImageBlockWithDimensions { block: ImageBlockParam dimensions?: ImageDimensions diff --git a/src/utils/processUserInput/processSlashCommand.test.ts b/src/utils/processUserInput/processSlashCommand.test.ts new file mode 100644 index 00000000..607a605a --- /dev/null +++ b/src/utils/processUserInput/processSlashCommand.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test' +import agentCommand from '../../commands/agent.js' +import type { ToolUseContext } from '../../Tool.js' +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' +import { createAssistantMessage } from '../messages.js' + +process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? 'test-key' + +const runAgentMock = mock(() => + (async function* () { + yield createAssistantMessage({ content: 'debugger result' }) + })(), +) + +mock.module('../../tools/AgentTool/runAgent.js', () => ({ + runAgent: runAgentMock, +})) + +const { processSlashCommand } = await import('./processSlashCommand.js') + +const makeAgent = (agentType: string): AgentDefinition => ({ + agentType, + whenToUse: `Use ${agentType}`, + source: 'built-in', + baseDir: 'built-in', + getSystemPrompt: () => `${agentType} prompt`, +}) + +function makeContext(activeAgents: AgentDefinition[]): ToolUseContext { + return { + abortController: new AbortController(), + messages: [], + getAppState: () => ({ + kairosEnabled: false, + mcp: { clients: [] }, + toolPermissionContext: { + alwaysAllowRules: { command: [] }, + }, + }), + setResponseLength: () => {}, + options: { + commands: [agentCommand], + tools: [], + agentDefinitions: { activeAgents }, + }, + } as unknown as ToolUseContext +} + +describe('/agent slash command processing', () => { + beforeEach(() => { + runAgentMock.mockClear() + }) + + test('runs the selected agent with only the prompt body', async () => { + const result = await processSlashCommand( + '/agent debugger fix failing tests', + [], + [], + [], + makeContext([makeAgent('general-purpose'), makeAgent('debugger')]), + () => {}, + ) + + expect(result.shouldQuery).toBe(false) + expect(runAgentMock.mock.calls.length).toBe(1) + + const params = runAgentMock.mock.calls[0]?.[0] as { + agentDefinition: AgentDefinition + promptMessages: Array<{ message: { content: string } }> + } + expect(params.agentDefinition.agentType).toBe('debugger') + expect(params.promptMessages[0]?.message.content).toBe('fix failing tests') + + const stdout = result.messages.find( + message => + message.type === 'user' && + typeof message.message.content === 'string' && + message.message.content.includes(''), + ) + expect(stdout?.message.content).toContain('debugger result') + }) + + test('shows usage when the agent prompt is missing', async () => { + const result = await processSlashCommand( + '/agent debugger', + [], + [], + [], + makeContext([makeAgent('general-purpose'), makeAgent('debugger')]), + () => {}, + ) + + expect(result.shouldQuery).toBe(false) + expect(runAgentMock.mock.calls.length).toBe(0) + expect( + result.messages.some( + message => message.message.content === 'Usage: /agent ', + ), + ).toBe(true) + }) +}) diff --git a/src/utils/processUserInput/processSlashCommand.tsx b/src/utils/processUserInput/processSlashCommand.tsx index a0bf451b..985940fd 100644 --- a/src/utils/processUserInput/processSlashCommand.tsx +++ b/src/utils/processUserInput/processSlashCommand.tsx @@ -2,6 +2,7 @@ import { feature } from 'bun:bundle'; import type { ContentBlockParam, TextBlockParam } from '@anthropic-ai/sdk/resources'; import { randomUUID } from 'crypto'; import { setPromptId } from 'src/bootstrap/state.js'; +import { parseAgentCommandArgs } from 'src/commands/agent.js'; import { builtInCommandNames, type Command, type CommandBase, findCommand, getCommand, getCommandName, hasCommand, type PromptCommand } from 'src/commands.js'; import { NO_CONTENT_MESSAGE } from 'src/constants/messages.js'; import type { SetToolJSXFn, ToolUseContext } from 'src/Tool.js'; @@ -61,6 +62,10 @@ const MCP_SETTLE_TIMEOUT_MS = 10_000; */ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, args: string, context: ProcessUserInputContext, precedingInputBlocks: ContentBlockParam[], setToolJSX: SetToolJSXFn, canUseTool: CanUseToolFn): Promise { const agentId = createAgentId(); + const dynamicAgentCommand = command.name === 'agent' ? parseAgentCommandArgs(args) : null; + if (command.name === 'agent' && !dynamicAgentCommand) { + throw new MalformedCommandError('Usage: /agent '); + } const pluginMarketplace = command.pluginInfo ? parsePluginIdentifier(command.pluginInfo.repository).marketplace : undefined; logEvent('tengu_slash_command_forked', { command_name: command.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, @@ -78,7 +83,10 @@ async function executeForkedSlashCommand(command: CommandBase & PromptCommand, a modifiedGetAppState, baseAgent, promptMessages - } = await prepareForkedCommandContext(command, args, context); + } = await prepareForkedCommandContext(command, args, context, dynamicAgentCommand ? { + agentType: dynamicAgentCommand.agentType, + requireAgentType: true + } : undefined); // Merge skill's effort into the agent definition so runAgent applies it const agentDefinition = command.effort !== undefined ? { @@ -744,6 +752,18 @@ async function getMessagesForSlashCommand(commandName: string, args: string, set command }; } + if (e instanceof MalformedCommandError) { + return { + messages: [createUserMessage({ + content: prepareUserContent({ + inputString: e.message, + precedingInputBlocks + }) + })], + shouldQuery: false, + command + }; + } return { messages: [createUserMessage({ content: prepareUserContent({ @@ -919,4 +939,4 @@ async function getMessagesForPromptSlashCommand(command: CommandBase & PromptCom command }; } -//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmZWF0dXJlIiwiQ29udGVudEJsb2NrUGFyYW0iLCJUZXh0QmxvY2tQYXJhbSIsInJhbmRvbVVVSUQiLCJzZXRQcm9tcHRJZCIsImJ1aWx0SW5Db21tYW5kTmFtZXMiLCJDb21tYW5kIiwiQ29tbWFuZEJhc2UiLCJmaW5kQ29tbWFuZCIsImdldENvbW1hbmQiLCJnZXRDb21tYW5kTmFtZSIsImhhc0NvbW1hbmQiLCJQcm9tcHRDb21tYW5kIiwiTk9fQ09OVEVOVF9NRVNTQUdFIiwiU2V0VG9vbEpTWEZuIiwiVG9vbFVzZUNvbnRleHQiLCJBc3Npc3RhbnRNZXNzYWdlIiwiQXR0YWNobWVudE1lc3NhZ2UiLCJNZXNzYWdlIiwiTm9ybWFsaXplZFVzZXJNZXNzYWdlIiwiUHJvZ3Jlc3NNZXNzYWdlIiwiVXNlck1lc3NhZ2UiLCJhZGRJbnZva2VkU2tpbGwiLCJnZXRTZXNzaW9uSWQiLCJDT01NQU5EX01FU1NBR0VfVEFHIiwiQ09NTUFORF9OQU1FX1RBRyIsIkNhblVzZVRvb2xGbiIsIkFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMiLCJBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfUElJX1RBR0dFRCIsImxvZ0V2ZW50IiwiZ2V0RHVtcFByb21wdHNQYXRoIiwiYnVpbGRQb3N0Q29tcGFjdE1lc3NhZ2VzIiwicmVzZXRNaWNyb2NvbXBhY3RTdGF0ZSIsIlByb2dyZXNzIiwiQWdlbnRQcm9ncmVzcyIsInJ1bkFnZW50IiwicmVuZGVyVG9vbFVzZVByb2dyZXNzTWVzc2FnZSIsIkNvbW1hbmRSZXN1bHREaXNwbGF5IiwiY3JlYXRlQWJvcnRDb250cm9sbGVyIiwiZ2V0QWdlbnRDb250ZXh0IiwiY3JlYXRlQXR0YWNobWVudE1lc3NhZ2UiLCJnZXRBdHRhY2htZW50TWVzc2FnZXMiLCJsb2dGb3JEZWJ1Z2dpbmciLCJpc0VudlRydXRoeSIsIkFib3J0RXJyb3IiLCJNYWxmb3JtZWRDb21tYW5kRXJyb3IiLCJnZXREaXNwbGF5UGF0aCIsImV4dHJhY3RSZXN1bHRUZXh0IiwicHJlcGFyZUZvcmtlZENvbW1hbmRDb250ZXh0IiwiZ2V0RnNJbXBsZW1lbnRhdGlvbiIsImlzRnVsbHNjcmVlbkVudkVuYWJsZWQiLCJ0b0FycmF5IiwicmVnaXN0ZXJTa2lsbEhvb2tzIiwibG9nRXJyb3IiLCJlbnF1ZXVlUGVuZGluZ05vdGlmaWNhdGlvbiIsImNyZWF0ZUNvbW1hbmRJbnB1dE1lc3NhZ2UiLCJjcmVhdGVTeW50aGV0aWNVc2VyQ2F2ZWF0TWVzc2FnZSIsImNyZWF0ZVN5c3RlbU1lc3NhZ2UiLCJjcmVhdGVVc2VySW50ZXJydXB0aW9uTWVzc2FnZSIsImNyZWF0ZVVzZXJNZXNzYWdlIiwiZm9ybWF0Q29tbWFuZElucHV0VGFncyIsImlzQ29tcGFjdEJvdW5kYXJ5TWVzc2FnZSIsImlzU3lzdGVtTG9jYWxDb21tYW5kTWVzc2FnZSIsIm5vcm1hbGl6ZU1lc3NhZ2VzIiwicHJlcGFyZVVzZXJDb250ZW50IiwiTW9kZWxBbGlhcyIsInBhcnNlVG9vbExpc3RGcm9tQ0xJIiwiaGFzUGVybWlzc2lvbnNUb1VzZVRvb2wiLCJpc09mZmljaWFsTWFya2V0cGxhY2VOYW1lIiwicGFyc2VQbHVnaW5JZGVudGlmaWVyIiwiaXNSZXN0cmljdGVkVG9QbHVnaW5Pbmx5IiwiaXNTb3VyY2VBZG1pblRydXN0ZWQiLCJwYXJzZVNsYXNoQ29tbWFuZCIsInNsZWVwIiwicmVjb3JkU2tpbGxVc2FnZSIsImxvZ09UZWxFdmVudCIsInJlZGFjdElmRGlzYWJsZWQiLCJidWlsZFBsdWdpbkNvbW1hbmRUZWxlbWV0cnlGaWVsZHMiLCJnZXRBc3Npc3RhbnRNZXNzYWdlQ29udGVudExlbmd0aCIsImNyZWF0ZUFnZW50SWQiLCJnZXRXb3JrbG9hZCIsIlByb2Nlc3NVc2VySW5wdXRCYXNlUmVzdWx0IiwiUHJvY2Vzc1VzZXJJbnB1dENvbnRleHQiLCJTbGFzaENvbW1hbmRSZXN1bHQiLCJjb21tYW5kIiwiTUNQX1NFVFRMRV9QT0xMX01TIiwiTUNQX1NFVFRMRV9USU1FT1VUX01TIiwiZXhlY3V0ZUZvcmtlZFNsYXNoQ29tbWFuZCIsImFyZ3MiLCJjb250ZXh0IiwicHJlY2VkaW5nSW5wdXRCbG9ja3MiLCJzZXRUb29sSlNYIiwiY2FuVXNlVG9vbCIsIlByb21pc2UiLCJhZ2VudElkIiwicGx1Z2luTWFya2V0cGxhY2UiLCJwbHVnaW5JbmZvIiwicmVwb3NpdG9yeSIsIm1hcmtldHBsYWNlIiwidW5kZWZpbmVkIiwiY29tbWFuZF9uYW1lIiwibmFtZSIsImludm9jYXRpb25fdHJpZ2dlciIsIl9QUk9UT19wbHVnaW5fbmFtZSIsInBsdWdpbk1hbmlmZXN0IiwiX1BST1RPX21hcmtldHBsYWNlX25hbWUiLCJza2lsbENvbnRlbnQiLCJtb2RpZmllZEdldEFwcFN0YXRlIiwiYmFzZUFnZW50IiwicHJvbXB0TWVzc2FnZXMiLCJhZ2VudERlZmluaXRpb24iLCJlZmZvcnQiLCJhZ2VudFR5cGUiLCJnZXRBcHBTdGF0ZSIsImthaXJvc0VuYWJsZWQiLCJiZ0Fib3J0Q29udHJvbGxlciIsImNvbW1hbmROYW1lIiwic3Bhd25UaW1lV29ya2xvYWQiLCJlbnF1ZXVlUmVzdWx0IiwidmFsdWUiLCJtb2RlIiwicHJpb3JpdHkiLCJpc01ldGEiLCJza2lwU2xhc2hDb21tYW5kcyIsIndvcmtsb2FkIiwiZGVhZGxpbmUiLCJEYXRlIiwibm93IiwicyIsIm1jcCIsImNsaWVudHMiLCJzb21lIiwiYyIsInR5cGUiLCJmcmVzaFRvb2xzIiwib3B0aW9ucyIsInJlZnJlc2hUb29scyIsInRvb2xzIiwiYWdlbnRNZXNzYWdlcyIsIm1lc3NhZ2UiLCJ0b29sVXNlQ29udGV4dCIsImFib3J0Q29udHJvbGxlciIsImlzQXN5bmMiLCJxdWVyeVNvdXJjZSIsIm1vZGVsIiwiYXZhaWxhYmxlVG9vbHMiLCJvdmVycmlkZSIsInB1c2giLCJyZXN1bHRUZXh0IiwiY2F0Y2giLCJlcnIiLCJFcnJvciIsIlN0cmluZyIsIm1lc3NhZ2VzIiwic2hvdWxkUXVlcnkiLCJwcm9ncmVzc01lc3NhZ2VzIiwicGFyZW50VG9vbFVzZUlEIiwidG9vbFVzZUNvdW50ZXIiLCJjcmVhdGVQcm9ncmVzc01lc3NhZ2UiLCJkYXRhIiwicHJvbXB0IiwidG9vbFVzZUlEIiwidGltZXN0YW1wIiwidG9JU09TdHJpbmciLCJ1dWlkIiwidXBkYXRlUHJvZ3Jlc3MiLCJqc3giLCJ2ZXJib3NlIiwic2hvdWxkSGlkZVByb21wdElucHV0Iiwic2hvdWxkQ29udGludWVBbmltYXRpb24iLCJzaG93U3Bpbm5lciIsIm5vcm1hbGl6ZWROZXciLCJjb250ZW50TGVuZ3RoIiwic2V0UmVzcG9uc2VMZW5ndGgiLCJsZW4iLCJub3JtYWxpemVkTXNnIiwiY29udGVudCIsImlucHV0U3RyaW5nIiwidHJpbSIsImxvb2tzTGlrZUNvbW1hbmQiLCJ0ZXN0IiwicHJvY2Vzc1NsYXNoQ29tbWFuZCIsImltYWdlQ29udGVudEJsb2NrcyIsImF0dGFjaG1lbnRNZXNzYWdlcyIsImlzQWxyZWFkeVByb2Nlc3NpbmciLCJwYXJzZWQiLCJlcnJvck1lc3NhZ2UiLCJwYXJzZWRBcmdzIiwiaXNNY3AiLCJzYW5pdGl6ZWRDb21tYW5kTmFtZSIsImhhcyIsImNvbW1hbmRzIiwiaXNGaWxlUGF0aCIsInN0YXQiLCJpbnB1dCIsInVua25vd25NZXNzYWdlIiwicHJvbXB0SWQiLCJwcm9tcHRfbGVuZ3RoIiwibGVuZ3RoIiwibmV3TWVzc2FnZXMiLCJtZXNzYWdlU2hvdWxkUXVlcnkiLCJhbGxvd2VkVG9vbHMiLCJyZXR1cm5lZENvbW1hbmQiLCJuZXh0SW5wdXQiLCJzdWJtaXROZXh0SW5wdXQiLCJnZXRNZXNzYWdlc0ZvclNsYXNoQ29tbWFuZCIsImV2ZW50RGF0YSIsIlJlY29yZCIsImlzT2ZmaWNpYWwiLCJwbHVnaW5fcmVwb3NpdG9yeSIsInBsdWdpbl9uYW1lIiwidmVyc2lvbiIsInBsdWdpbl92ZXJzaW9uIiwiT2JqZWN0IiwiYXNzaWduIiwic2tpbGxfbmFtZSIsInNraWxsX3NvdXJjZSIsInNvdXJjZSIsImxvYWRlZEZyb20iLCJza2lsbF9sb2FkZWRfZnJvbSIsImtpbmQiLCJza2lsbF9raW5kIiwic3RhcnRzV2l0aCIsImxvb2tzTGlrZUZpbGVQYXRoIiwiaXNDb21wYWN0UmVzdWx0IiwiZXZlcnkiLCJfaXNBbHJlYWR5UHJvY2Vzc2luZyIsInVzZXJJbnZvY2FibGUiLCJyZXNvbHZlIiwiZG9uZVdhc0NhbGxlZCIsIm9uRG9uZSIsInJlc3VsdCIsImRpc3BsYXkiLCJtZXRhTWVzc2FnZXMiLCJtYXAiLCJza2lwVHJhbnNjcmlwdCIsImVuZHNXaXRoIiwiZm9ybWF0Q29tbWFuZElucHV0IiwibG9hZCIsInRoZW4iLCJtb2QiLCJjYWxsIiwiaXNOb25JbnRlcmFjdGl2ZVNlc3Npb24iLCJpc0xvY2FsSlNYQ29tbWFuZCIsImlzSW1tZWRpYXRlIiwiaW1tZWRpYXRlIiwiZSIsImNsZWFyTG9jYWxKU1giLCJkaXNwbGF5QXJncyIsImlzU2Vuc2l0aXZlIiwidXNlck1lc3NhZ2UiLCJzeW50aGV0aWNDYXZlYXRNZXNzYWdlIiwic2xhc2hDb21tYW5kTWVzc2FnZXMiLCJkaXNwbGF5VGV4dCIsImNvbXBhY3Rpb25SZXN1bHRXaXRoU2xhc2hNZXNzYWdlcyIsImNvbXBhY3Rpb25SZXN1bHQiLCJtZXNzYWdlc1RvS2VlcCIsImdldE1lc3NhZ2VzRm9yUHJvbXB0U2xhc2hDb21tYW5kIiwidG9vbFVzZSIsImZvcm1hdFNraWxsTG9hZGluZ01ldGFkYXRhIiwic2tpbGxOYW1lIiwiX3Byb2dyZXNzTWVzc2FnZSIsImpvaW4iLCJmb3JtYXRTbGFzaENvbW1hbmRMb2FkaW5nTWV0YWRhdGEiLCJmaWx0ZXIiLCJCb29sZWFuIiwiZm9ybWF0Q29tbWFuZExvYWRpbmdNZXRhZGF0YSIsInByb2dyZXNzTWVzc2FnZSIsInByb2Nlc3NQcm9tcHRTbGFzaENvbW1hbmQiLCJwcm9jZXNzIiwiZW52IiwiQ0xBVURFX0NPREVfQ09PUkRJTkFUT1JfTU9ERSIsIm1ldGFkYXRhIiwicGFydHMiLCJkZXNjcmlwdGlvbiIsIndoZW5Ub1VzZSIsInNraWxsQWxsb3dlZFRvb2xzIiwic3VtbWFyeUNvbnRlbnQiLCJ0ZXh0IiwiZ2V0UHJvbXB0Rm9yQ29tbWFuZCIsImhvb2tzQWxsb3dlZEZvclRoaXNTa2lsbCIsImhvb2tzIiwic2Vzc2lvbklkIiwic2V0QXBwU3RhdGUiLCJza2lsbFJvb3QiLCJza2lsbFBhdGgiLCJiIiwiYWRkaXRpb25hbEFsbG93ZWRUb29scyIsIm1haW5NZXNzYWdlQ29udGVudCIsImJsb2NrIiwic2tpcFNraWxsRGlzY292ZXJ5Il0sInNvdXJjZXMiOlsicHJvY2Vzc1NsYXNoQ29tbWFuZC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgZmVhdHVyZSB9IGZyb20gJ2J1bjpidW5kbGUnXG5pbXBvcnQgdHlwZSB7XG4gIENvbnRlbnRCbG9ja1BhcmFtLFxuICBUZXh0QmxvY2tQYXJhbSxcbn0gZnJvbSAnQGFudGhyb3BpYy1haS9zZGsvcmVzb3VyY2VzJ1xuaW1wb3J0IHsgcmFuZG9tVVVJRCB9IGZyb20gJ2NyeXB0bydcbmltcG9ydCB7IHNldFByb21wdElkIH0gZnJvbSAnc3JjL2Jvb3RzdHJhcC9zdGF0ZS5qcydcbmltcG9ydCB7XG4gIGJ1aWx0SW5Db21tYW5kTmFtZXMsXG4gIHR5cGUgQ29tbWFuZCxcbiAgdHlwZSBDb21tYW5kQmFzZSxcbiAgZmluZENvbW1hbmQsXG4gIGdldENvbW1hbmQsXG4gIGdldENvbW1hbmROYW1lLFxuICBoYXNDb21tYW5kLFxuICB0eXBlIFByb21wdENvbW1hbmQsXG59IGZyb20gJ3NyYy9jb21tYW5kcy5qcydcbmltcG9ydCB7IE5PX0NPTlRFTlRfTUVTU0FHRSB9IGZyb20gJ3NyYy9jb25zdGFudHMvbWVzc2FnZXMuanMnXG5pbXBvcnQgdHlwZSB7IFNldFRvb2xKU1hGbiwgVG9vbFVzZUNvbnRleHQgfSBmcm9tICdzcmMvVG9vbC5qcydcbmltcG9ydCB0eXBlIHtcbiAgQXNzaXN0YW50TWVzc2FnZSxcbiAgQXR0YWNobWVudE1lc3NhZ2UsXG4gIE1lc3NhZ2UsXG4gIE5vcm1hbGl6ZWRVc2VyTWVzc2FnZSxcbiAgUHJvZ3Jlc3NNZXNzYWdlLFxuICBVc2VyTWVzc2FnZSxcbn0gZnJvbSAnc3JjL3R5cGVzL21lc3NhZ2UuanMnXG5pbXBvcnQgeyBhZGRJbnZva2VkU2tpbGwsIGdldFNlc3Npb25JZCB9IGZyb20gJy4uLy4uL2Jvb3RzdHJhcC9zdGF0ZS5qcydcbmltcG9ydCB7IENPTU1BTkRfTUVTU0FHRV9UQUcsIENPTU1BTkRfTkFNRV9UQUcgfSBmcm9tICcuLi8uLi9jb25zdGFudHMveG1sLmpzJ1xuaW1wb3J0IHR5cGUgeyBDYW5Vc2VUb29sRm4gfSBmcm9tICcuLi8uLi9ob29rcy91c2VDYW5Vc2VUb29sLmpzJ1xuaW1wb3J0IHtcbiAgdHlwZSBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICB0eXBlIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19QSUlfVEFHR0VELFxuICBsb2dFdmVudCxcbn0gZnJvbSAnLi4vLi4vc2VydmljZXMvYW5hbHl0aWNzL2luZGV4LmpzJ1xuaW1wb3J0IHsgZ2V0RHVtcFByb21wdHNQYXRoIH0gZnJvbSAnLi4vLi4vc2VydmljZXMvYXBpL2R1bXBQcm9tcHRzLmpzJ1xuaW1wb3J0IHsgYnVpbGRQb3N0Q29tcGFjdE1lc3NhZ2VzIH0gZnJvbSAnLi4vLi4vc2VydmljZXMvY29tcGFjdC9jb21wYWN0LmpzJ1xuaW1wb3J0IHsgcmVzZXRNaWNyb2NvbXBhY3RTdGF0ZSB9IGZyb20gJy4uLy4uL3NlcnZpY2VzL2NvbXBhY3QvbWljcm9Db21wYWN0LmpzJ1xuaW1wb3J0IHR5cGUgeyBQcm9ncmVzcyBhcyBBZ2VudFByb2dyZXNzIH0gZnJvbSAnLi4vLi4vdG9vbHMvQWdlbnRUb29sL0FnZW50VG9vbC5qcydcbmltcG9ydCB7IHJ1bkFnZW50IH0gZnJvbSAnLi4vLi4vdG9vbHMvQWdlbnRUb29sL3J1bkFnZW50LmpzJ1xuaW1wb3J0IHsgcmVuZGVyVG9vbFVzZVByb2dyZXNzTWVzc2FnZSB9IGZyb20gJy4uLy4uL3Rvb2xzL0FnZW50VG9vbC9VSS5qcydcbmltcG9ydCB0eXBlIHsgQ29tbWFuZFJlc3VsdERpc3BsYXkgfSBmcm9tICcuLi8uLi90eXBlcy9jb21tYW5kLmpzJ1xuaW1wb3J0IHsgY3JlYXRlQWJvcnRDb250cm9sbGVyIH0gZnJvbSAnLi4vYWJvcnRDb250cm9sbGVyLmpzJ1xuaW1wb3J0IHsgZ2V0QWdlbnRDb250ZXh0IH0gZnJvbSAnLi4vYWdlbnRDb250ZXh0LmpzJ1xuaW1wb3J0IHtcbiAgY3JlYXRlQXR0YWNobWVudE1lc3NhZ2UsXG4gIGdldEF0dGFjaG1lbnRNZXNzYWdlcyxcbn0gZnJvbSAnLi4vYXR0YWNobWVudHMuanMnXG5pbXBvcnQgeyBsb2dGb3JEZWJ1Z2dpbmcgfSBmcm9tICcuLi9kZWJ1Zy5qcydcbmltcG9ydCB7IGlzRW52VHJ1dGh5IH0gZnJvbSAnLi4vZW52VXRpbHMuanMnXG5pbXBvcnQgeyBBYm9ydEVycm9yLCBNYWxmb3JtZWRDb21tYW5kRXJyb3IgfSBmcm9tICcuLi9lcnJvcnMuanMnXG5pbXBvcnQgeyBnZXREaXNwbGF5UGF0aCB9IGZyb20gJy4uL2ZpbGUuanMnXG5pbXBvcnQge1xuICBleHRyYWN0UmVzdWx0VGV4dCxcbiAgcHJlcGFyZUZvcmtlZENvbW1hbmRDb250ZXh0LFxufSBmcm9tICcuLi9mb3JrZWRBZ2VudC5qcydcbmltcG9ydCB7IGdldEZzSW1wbGVtZW50YXRpb24gfSBmcm9tICcuLi9mc09wZXJhdGlvbnMuanMnXG5pbXBvcnQgeyBpc0Z1bGxzY3JlZW5FbnZFbmFibGVkIH0gZnJvbSAnLi4vZnVsbHNjcmVlbi5qcydcbmltcG9ydCB7IHRvQXJyYXkgfSBmcm9tICcuLi9nZW5lcmF0b3JzLmpzJ1xuaW1wb3J0IHsgcmVnaXN0ZXJTa2lsbEhvb2tzIH0gZnJvbSAnLi4vaG9va3MvcmVnaXN0ZXJTa2lsbEhvb2tzLmpzJ1xuaW1wb3J0IHsgbG9nRXJyb3IgfSBmcm9tICcuLi9sb2cuanMnXG5pbXBvcnQgeyBlbnF1ZXVlUGVuZGluZ05vdGlmaWNhdGlvbiB9IGZyb20gJy4uL21lc3NhZ2VRdWV1ZU1hbmFnZXIuanMnXG5pbXBvcnQge1xuICBjcmVhdGVDb21tYW5kSW5wdXRNZXNzYWdlLFxuICBjcmVhdGVTeW50aGV0aWNVc2VyQ2F2ZWF0TWVzc2FnZSxcbiAgY3JlYXRlU3lzdGVtTWVzc2FnZSxcbiAgY3JlYXRlVXNlckludGVycnVwdGlvbk1lc3NhZ2UsXG4gIGNyZWF0ZVVzZXJNZXNzYWdlLFxuICBmb3JtYXRDb21tYW5kSW5wdXRUYWdzLFxuICBpc0NvbXBhY3RCb3VuZGFyeU1lc3NhZ2UsXG4gIGlzU3lzdGVtTG9jYWxDb21tYW5kTWVzc2FnZSxcbiAgbm9ybWFsaXplTWVzc2FnZXMsXG4gIHByZXBhcmVVc2VyQ29udGVudCxcbn0gZnJvbSAnLi4vbWVzc2FnZXMuanMnXG5pbXBvcnQgdHlwZSB7IE1vZGVsQWxpYXMgfSBmcm9tICcuLi9tb2RlbC9hbGlhc2VzLmpzJ1xuaW1wb3J0IHsgcGFyc2VUb29sTGlzdEZyb21DTEkgfSBmcm9tICcuLi9wZXJtaXNzaW9ucy9wZXJtaXNzaW9uU2V0dXAuanMnXG5pbXBvcnQgeyBoYXNQZXJtaXNzaW9uc1RvVXNlVG9vbCB9IGZyb20gJy4uL3Blcm1pc3Npb25zL3Blcm1pc3Npb25zLmpzJ1xuaW1wb3J0IHtcbiAgaXNPZmZpY2lhbE1hcmtldHBsYWNlTmFtZSxcbiAgcGFyc2VQbHVnaW5JZGVudGlmaWVyLFxufSBmcm9tICcuLi9wbHVnaW5zL3BsdWdpbklkZW50aWZpZXIuanMnXG5pbXBvcnQge1xuICBpc1Jlc3RyaWN0ZWRUb1BsdWdpbk9ubHksXG4gIGlzU291cmNlQWRtaW5UcnVzdGVkLFxufSBmcm9tICcuLi9zZXR0aW5ncy9wbHVnaW5Pbmx5UG9saWN5LmpzJ1xuaW1wb3J0IHsgcGFyc2VTbGFzaENvbW1hbmQgfSBmcm9tICcuLi9zbGFzaENvbW1hbmRQYXJzaW5nLmpzJ1xuaW1wb3J0IHsgc2xlZXAgfSBmcm9tICcuLi9zbGVlcC5qcydcbmltcG9ydCB7IHJlY29yZFNraWxsVXNhZ2UgfSBmcm9tICcuLi9zdWdnZXN0aW9ucy9za2lsbFVzYWdlVHJhY2tpbmcuanMnXG5pbXBvcnQgeyBsb2dPVGVsRXZlbnQsIHJlZGFjdElmRGlzYWJsZWQgfSBmcm9tICcuLi90ZWxlbWV0cnkvZXZlbnRzLmpzJ1xuaW1wb3J0IHsgYnVpbGRQbHVnaW5Db21tYW5kVGVsZW1ldHJ5RmllbGRzIH0gZnJvbSAnLi4vdGVsZW1ldHJ5L3BsdWdpblRlbGVtZXRyeS5qcydcbmltcG9ydCB7IGdldEFzc2lzdGFudE1lc3NhZ2VDb250ZW50TGVuZ3RoIH0gZnJvbSAnLi4vdG9rZW5zLmpzJ1xuaW1wb3J0IHsgY3JlYXRlQWdlbnRJZCB9IGZyb20gJy4uL3V1aWQuanMnXG5pbXBvcnQgeyBnZXRXb3JrbG9hZCB9IGZyb20gJy4uL3dvcmtsb2FkQ29udGV4dC5qcydcbmltcG9ydCB0eXBlIHtcbiAgUHJvY2Vzc1VzZXJJbnB1dEJhc2VSZXN1bHQsXG4gIFByb2Nlc3NVc2VySW5wdXRDb250ZXh0LFxufSBmcm9tICcuL3Byb2Nlc3NVc2VySW5wdXQuanMnXG5cbnR5cGUgU2xhc2hDb21tYW5kUmVzdWx0ID0gUHJvY2Vzc1VzZXJJbnB1dEJhc2VSZXN1bHQgJiB7XG4gIGNvbW1hbmQ6IENvbW1hbmRcbn1cblxuLy8gUG9sbCBpbnRlcnZhbCBhbmQgZGVhZGxpbmUgZm9yIE1DUCBzZXR0bGUgYmVmb3JlIGxhdW5jaGluZyBhIGJhY2tncm91bmRcbi8vIGZvcmtlZCBzdWJhZ2VudC4gTUNQIHNlcnZlcnMgdHlwaWNhbGx5IGNvbm5lY3Qgd2l0aGluIDEtM3Mgb2Ygc3RhcnR1cDtcbi8vIDEwcyBoZWFkcm9vbSBjb3ZlcnMgc2xvdyBTU0UgaGFuZHNoYWtlcy5cbmNvbnN0IE1DUF9TRVRUTEVfUE9MTF9NUyA9IDIwMFxuY29uc3QgTUNQX1NFVFRMRV9USU1FT1VUX01TID0gMTBfMDAwXG5cbi8qKlxuICogRXhlY3V0ZXMgYSBzbGFzaCBjb21tYW5kIHdpdGggY29udGV4dDogZm9yayBpbiBhIHN1Yi1hZ2VudC5cbiAqL1xuYXN5bmMgZnVuY3Rpb24gZXhlY3V0ZUZvcmtlZFNsYXNoQ29tbWFuZChcbiAgY29tbWFuZDogQ29tbWFuZEJhc2UgJiBQcm9tcHRDb21tYW5kLFxuICBhcmdzOiBzdHJpbmcsXG4gIGNvbnRleHQ6IFByb2Nlc3NVc2VySW5wdXRDb250ZXh0LFxuICBwcmVjZWRpbmdJbnB1dEJsb2NrczogQ29udGVudEJsb2NrUGFyYW1bXSxcbiAgc2V0VG9vbEpTWDogU2V0VG9vbEpTWEZuLFxuICBjYW5Vc2VUb29sOiBDYW5Vc2VUb29sRm4sXG4pOiBQcm9taXNlPFNsYXNoQ29tbWFuZFJlc3VsdD4ge1xuICBjb25zdCBhZ2VudElkID0gY3JlYXRlQWdlbnRJZCgpXG5cbiAgY29uc3QgcGx1Z2luTWFya2V0cGxhY2UgPSBjb21tYW5kLnBsdWdpbkluZm9cbiAgICA/IHBhcnNlUGx1Z2luSWRlbnRpZmllcihjb21tYW5kLnBsdWdpbkluZm8ucmVwb3NpdG9yeSkubWFya2V0cGxhY2VcbiAgICA6IHVuZGVmaW5lZFxuICBsb2dFdmVudCgndGVuZ3Vfc2xhc2hfY29tbWFuZF9mb3JrZWQnLCB7XG4gICAgY29tbWFuZF9uYW1lOlxuICAgICAgY29tbWFuZC5uYW1lIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgaW52b2NhdGlvbl90cmlnZ2VyOlxuICAgICAgJ3VzZXItc2xhc2gnIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgLi4uKGNvbW1hbmQucGx1Z2luSW5mbyAmJiB7XG4gICAgICBfUFJPVE9fcGx1Z2luX25hbWU6IGNvbW1hbmQucGx1Z2luSW5mby5wbHVnaW5NYW5pZmVzdFxuICAgICAgICAubmFtZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfUElJX1RBR0dFRCxcbiAgICAgIC4uLihwbHVnaW5NYXJrZXRwbGFjZSAmJiB7XG4gICAgICAgIF9QUk9UT19tYXJrZXRwbGFjZV9uYW1lOlxuICAgICAgICAgIHBsdWdpbk1hcmtldHBsYWNlIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19QSUlfVEFHR0VELFxuICAgICAgfSksXG4gICAgICAuLi5idWlsZFBsdWdpbkNvbW1hbmRUZWxlbWV0cnlGaWVsZHMoY29tbWFuZC5wbHVnaW5JbmZvKSxcbiAgICB9KSxcbiAgfSlcblxuICBjb25zdCB7IHNraWxsQ29udGVudCwgbW9kaWZpZWRHZXRBcHBTdGF0ZSwgYmFzZUFnZW50LCBwcm9tcHRNZXNzYWdlcyB9ID1cbiAgICBhd2FpdCBwcmVwYXJlRm9ya2VkQ29tbWFuZENvbnRleHQoY29tbWFuZCwgYXJncywgY29udGV4dClcblxuICAvLyBNZXJnZSBza2lsbCdzIGVmZm9ydCBpbnRvIHRoZSBhZ2VudCBkZWZpbml0aW9uIHNvIHJ1bkFnZW50IGFwcGxpZXMgaXRcbiAgY29uc3QgYWdlbnREZWZpbml0aW9uID1cbiAgICBjb21tYW5kLmVmZm9ydCAhPT0gdW5kZWZpbmVkXG4gICAgICA/IHsgLi4uYmFzZUFnZW50LCBlZmZvcnQ6IGNvbW1hbmQuZWZmb3J0IH1cbiAgICAgIDogYmFzZUFnZW50XG5cbiAgbG9nRm9yRGVidWdnaW5nKFxuICAgIGBFeGVjdXRpbmcgZm9ya2VkIHNsYXNoIGNvbW1hbmQgLyR7Y29tbWFuZC5uYW1lfSB3aXRoIGFnZW50ICR7YWdlbnREZWZpbml0aW9uLmFnZW50VHlwZX1gLFxuICApXG5cbiAgLy8gQXNzaXN0YW50IG1vZGU6IGZpcmUtYW5kLWZvcmdldC4gTGF1bmNoIHN1YmFnZW50IGluIGJhY2tncm91bmQsIHJldHVyblxuICAvLyBpbW1lZGlhdGVseSwgcmUtZW5xdWV1ZSB0aGUgcmVzdWx0IGFzIGFuIGlzTWV0YSBwcm9tcHQgd2hlbiBkb25lLlxuICAvLyBXaXRob3V0IHRoaXMsIE4gc2NoZWR1bGVkIHRhc2tzIG9uIHN0YXJ0dXAgPSBOIHNlcmlhbCAoc3ViYWdlbnQgKyBtYWluXG4gIC8vIGFnZW50IHR1cm4pIGN5Y2xlcyBibG9ja2luZyB1c2VyIGlucHV0LiBXaXRoIHRoaXMsIE4gc3ViYWdlbnRzIHJ1biBpblxuICAvLyBwYXJhbGxlbCBhbmQgcmVzdWx0cyB0cmlja2xlIGludG8gdGhlIHF1ZXVlIGFzIHRoZXkgZmluaXNoLlxuICAvL1xuICAvLyBHYXRlZCBvbiBrYWlyb3NFbmFibGVkIChub3QgQ0xBVURFX0NPREVfQlJJRUYpIGJlY2F1c2UgdGhlIGNsb3NlZCBsb29wXG4gIC8vIGRlcGVuZHMgb24gYXNzaXN0YW50LW1vZGUgaW52YXJpYW50czogc2NoZWR1bGVkX3Rhc2tzLmpzb24gZXhpc3RzLFxuICAvLyB0aGUgbWFpbiBhZ2VudCBrbm93cyB0byBwaXBlIHJlc3VsdHMgdGhyb3VnaCBTZW5kVXNlck1lc3NhZ2UsIGFuZFxuICAvLyBpc01ldGEgcHJvbXB0cyBhcmUgaGlkZGVuLiBPdXRzaWRlIGFzc2lzdGFudCBtb2RlLCBjb250ZXh0OmZvcmsgY29tbWFuZHNcbiAgLy8gYXJlIHVzZXItaW52b2tlZCBza2lsbHMgKC9jb21taXQgZXRjLikgdGhhdCBzaG91bGQgcnVuIHN5bmNocm9ub3VzbHlcbiAgLy8gd2l0aCB0aGUgcHJvZ3Jlc3MgVUkuXG4gIGlmIChmZWF0dXJlKCdLQUlST1MnKSAmJiAoYXdhaXQgY29udGV4dC5nZXRBcHBTdGF0ZSgpKS5rYWlyb3NFbmFibGVkKSB7XG4gICAgLy8gU3RhbmRhbG9uZSBhYm9ydENvbnRyb2xsZXIg4oCUIGJhY2tncm91bmQgc3ViYWdlbnRzIHN1cnZpdmUgbWFpbi10aHJlYWRcbiAgICAvLyBFU0MgKHNhbWUgcG9saWN5IGFzIEFnZW50VG9vbCdzIGFzeW5jIHBhdGgpLiBUaGV5J3JlIGNyb24tZHJpdmVuOyBpZlxuICAgIC8vIGtpbGxlZCBtaWQtcnVuIHRoZXkganVzdCByZS1maXJlIG9uIHRoZSBuZXh0IHNjaGVkdWxlLlxuICAgIGNvbnN0IGJnQWJvcnRDb250cm9sbGVyID0gY3JlYXRlQWJvcnRDb250cm9sbGVyKClcbiAgICBjb25zdCBjb21tYW5kTmFtZSA9IGdldENvbW1hbmROYW1lKGNvbW1hbmQpXG5cbiAgICAvLyBXb3JrbG9hZDogaGFuZGxlUHJvbXB0U3VibWl0IHdyYXBzIHRoZSBlbnRpcmUgdHVybiBpbiBydW5XaXRoV29ya2xvYWRcbiAgICAvLyAoQXN5bmNMb2NhbFN0b3JhZ2UpLiBBTFMgY29udGV4dCBpcyBjYXB0dXJlZCB3aGVuIHRoaXMgYHZvaWRgIGZpcmVzXG4gICAgLy8gYW5kIHN1cnZpdmVzIGV2ZXJ5IGF3YWl0IGluc2lkZSDigJQgaXNvbGF0ZWQgZnJvbSB0aGUgcGFyZW50J3NcbiAgICAvLyBjb250aW51YXRpb24uIFRoZSBkZXRhY2hlZCBjbG9zdXJlJ3MgcnVuQWdlbnQgY2FsbHMgc2VlIHRoZSBjcm9uIHRhZ1xuICAgIC8vIGF1dG9tYXRpY2FsbHkuIFdlIHN0aWxsIGNhcHR1cmUgdGhlIHZhbHVlIGhlcmUgT05MWSBmb3IgdGhlXG4gICAgLy8gcmUtZW5xdWV1ZWQgcmVzdWx0IHByb21wdCBiZWxvdzogdGhhdCBzZWNvbmQgdHVybiBydW5zIGluIGEgZnJlc2hcbiAgICAvLyBoYW5kbGVQcm9tcHRTdWJtaXQg4oaSIGZyZXNoIHJ1bldpdGhXb3JrbG9hZCBib3VuZGFyeSAod2hpY2ggYWx3YXlzXG4gICAgLy8gZXN0YWJsaXNoZXMgYSBuZXcgY29udGV4dCwgZXZlbiBmb3IgYHVuZGVmaW5lZGApIOKGkiBzbyBpdCBuZWVkcyBpdHNcbiAgICAvLyBvd24gUXVldWVkQ29tbWFuZC53b3JrbG9hZCB0YWcgdG8gcHJlc2VydmUgYXR0cmlidXRpb24uXG4gICAgY29uc3Qgc3Bhd25UaW1lV29ya2xvYWQgPSBnZXRXb3JrbG9hZCgpXG5cbiAgICAvLyBSZS1lbnRlciB0aGUgcXVldWUgYXMgYSBoaWRkZW4gcHJvbXB0LiBpc01ldGE6IGhpZGVzIGZyb20gcXVldWVcbiAgICAvLyBwcmV2aWV3ICsgcGxhY2Vob2xkZXIgKyB0cmFuc2NyaXB0LiBza2lwU2xhc2hDb21tYW5kczogcHJldmVudHNcbiAgICAvLyByZS1wYXJzaW5nIGlmIHRoZSByZXN1bHQgdGV4dCBoYXBwZW5zIHRvIHN0YXJ0IHdpdGggJy8nLiBXaGVuXG4gICAgLy8gZHJhaW5lZCwgdGhpcyB0cmlnZ2VycyBhIG1haW4tYWdlbnQgdHVybiB0aGF0IHNlZXMgdGhlIHJlc3VsdCBhbmRcbiAgICAvLyBkZWNpZGVzIHdoZXRoZXIgdG8gU2VuZFVzZXJNZXNzYWdlLiBQcm9wYWdhdGUgd29ya2xvYWQgc28gdGhhdFxuICAgIC8vIHNlY29uZCB0dXJuIGlzIGFsc28gdGFnZ2VkLlxuICAgIGNvbnN0IGVucXVldWVSZXN1bHQgPSAodmFsdWU6IHN0cmluZyk6IHZvaWQgPT5cbiAgICAgIGVucXVldWVQZW5kaW5nTm90aWZpY2F0aW9uKHtcbiAgICAgICAgdmFsdWUsXG4gICAgICAgIG1vZGU6ICdwcm9tcHQnLFxuICAgICAgICBwcmlvcml0eTogJ2xhdGVyJyxcbiAgICAgICAgaXNNZXRhOiB0cnVlLFxuICAgICAgICBza2lwU2xhc2hDb21tYW5kczogdHJ1ZSxcbiAgICAgICAgd29ya2xvYWQ6IHNwYXduVGltZVdvcmtsb2FkLFxuICAgICAgfSlcblxuICAgIHZvaWQgKGFzeW5jICgpID0+IHtcbiAgICAgIC8vIFdhaXQgZm9yIE1DUCBzZXJ2ZXJzIHRvIHNldHRsZS4gU2NoZWR1bGVkIHRhc2tzIGZpcmUgYXQgc3RhcnR1cCBhbmRcbiAgICAgIC8vIGFsbCBOIGRyYWluIHdpdGhpbiB+MW1zIChzaW5jZSB3ZSByZXR1cm4gaW1tZWRpYXRlbHkpLCBjYXB0dXJpbmdcbiAgICAgIC8vIGNvbnRleHQub3B0aW9ucy50b29scyBiZWZvcmUgTUNQIGNvbm5lY3RzLiBUaGUgc3luYyBwYXRoXG4gICAgICAvLyBhY2NpZGVudGFsbHkgYXZvaWRlZCB0aGlzIOKAlCB0YXNrcyBzZXJpYWxpemVkLCBzbyB0YXNrIE4ncyBkcmFpblxuICAgICAgLy8gaGFwcGVuZWQgYWZ0ZXIgdGFzayBOLTEncyAzMHMgcnVuLCBieSB3aGljaCB0aW1lIE1DUCB3YXMgdXAuXG4gICAgICAvLyBQb2xsIHVudGlsIG5vICdwZW5kaW5nJyBjbGllbnRzIHJlbWFpbiwgdGhlbiByZWZyZXNoLlxuICAgICAgY29uc3QgZGVhZGxpbmUgPSBEYXRlLm5vdygpICsgTUNQX1NFVFRMRV9USU1FT1VUX01TXG4gICAgICB3aGlsZSAoRGF0ZS5ub3coKSA8IGRlYWRsaW5lKSB7XG4gICAgICAgIGNvbnN0IHMgPSBjb250ZXh0LmdldEFwcFN0YXRlKClcbiAgICAgICAgaWYgKCFzLm1jcC5jbGllbnRzLnNvbWUoYyA9PiBjLnR5cGUgPT09ICdwZW5kaW5nJykpIGJyZWFrXG4gICAgICAgIGF3YWl0IHNsZWVwKE1DUF9TRVRUTEVfUE9MTF9NUylcbiAgICAgIH1cbiAgICAgIGNvbnN0IGZyZXNoVG9vbHMgPVxuICAgICAgICBjb250ZXh0Lm9wdGlvbnMucmVmcmVzaFRvb2xzPy4oKSA/PyBjb250ZXh0Lm9wdGlvbnMudG9vbHNcblxuICAgICAgY29uc3QgYWdlbnRNZXNzYWdlczogTWVzc2FnZVtdID0gW11cbiAgICAgIGZvciBhd2FpdCAoY29uc3QgbWVzc2FnZSBvZiBydW5BZ2VudCh7XG4gICAgICAgIGFnZW50RGVmaW5pdGlvbixcbiAgICAgICAgcHJvbXB0TWVzc2FnZXMsXG4gICAgICAgIHRvb2xVc2VDb250ZXh0OiB7XG4gICAgICAgICAgLi4uY29udGV4dCxcbiAgICAgICAgICBnZXRBcHBTdGF0ZTogbW9kaWZpZWRHZXRBcHBTdGF0ZSxcbiAgICAgICAgICBhYm9ydENvbnRyb2xsZXI6IGJnQWJvcnRDb250cm9sbGVyLFxuICAgICAgICB9LFxuICAgICAgICBjYW5Vc2VUb29sLFxuICAgICAgICBpc0FzeW5jOiB0cnVlLFxuICAgICAgICBxdWVyeVNvdXJjZTogJ2FnZW50OmN1c3RvbScsXG4gICAgICAgIG1vZGVsOiBjb21tYW5kLm1vZGVsIGFzIE1vZGVsQWxpYXMgfCB1bmRlZmluZWQsXG4gICAgICAgIGF2YWlsYWJsZVRvb2xzOiBmcmVzaFRvb2xzLFxuICAgICAgICBvdmVycmlkZTogeyBhZ2VudElkIH0sXG4gICAgICB9KSkge1xuICAgICAgICBhZ2VudE1lc3NhZ2VzLnB1c2gobWVzc2FnZSlcbiAgICAgIH1cbiAgICAgIGNvbnN0IHJlc3VsdFRleHQgPSBleHRyYWN0UmVzdWx0VGV4dChhZ2VudE1lc3NhZ2VzLCAnQ29tbWFuZCBjb21wbGV0ZWQnKVxuICAgICAgbG9nRm9yRGVidWdnaW5nKFxuICAgICAgICBgQmFja2dyb3VuZCBmb3JrZWQgY29tbWFuZCAvJHtjb21tYW5kTmFtZX0gY29tcGxldGVkIChhZ2VudCAke2FnZW50SWR9KWAsXG4gICAgICApXG4gICAgICBlbnF1ZXVlUmVzdWx0KFxuICAgICAgICBgPHNjaGVkdWxlZC10YXNrLXJlc3VsdCBjb21tYW5kPVwiLyR7Y29tbWFuZE5hbWV9XCI+XFxuJHtyZXN1bHRUZXh0fVxcbjwvc2NoZWR1bGVkLXRhc2stcmVzdWx0PmAsXG4gICAgICApXG4gICAgfSkoKS5jYXRjaChlcnIgPT4ge1xuICAgICAgbG9nRXJyb3IoZXJyKVxuICAgICAgZW5xdWV1ZVJlc3VsdChcbiAgICAgICAgYDxzY2hlZHVsZWQtdGFzay1yZXN1bHQgY29tbWFuZD1cIi8ke2NvbW1hbmROYW1lfVwiIHN0YXR1cz1cImZhaWxlZFwiPlxcbiR7ZXJyIGluc3RhbmNlb2YgRXJyb3IgPyBlcnIubWVzc2FnZSA6IFN0cmluZyhlcnIpfVxcbjwvc2NoZWR1bGVkLXRhc2stcmVzdWx0PmAsXG4gICAgICApXG4gICAgfSlcblxuICAgIC8vIE5vdGhpbmcgdG8gcmVuZGVyLCBub3RoaW5nIHRvIHF1ZXJ5IOKAlCB0aGUgYmFja2dyb3VuZCBydW5uZXIgcmUtZW50ZXJzXG4gICAgLy8gdGhlIHF1ZXVlIG9uIGl0cyBvd24gc2NoZWR1bGUuXG4gICAgcmV0dXJuIHsgbWVzc2FnZXM6IFtdLCBzaG91bGRRdWVyeTogZmFsc2UsIGNvbW1hbmQgfVxuICB9XG5cbiAgLy8gQ29sbGVjdCBtZXNzYWdlcyBmcm9tIHRoZSBmb3JrZWQgYWdlbnRcbiAgY29uc3QgYWdlbnRNZXNzYWdlczogTWVzc2FnZVtdID0gW11cblxuICAvLyBCdWlsZCBwcm9ncmVzcyBtZXNzYWdlcyBmb3IgdGhlIGFnZW50IHByb2dyZXNzIFVJXG4gIGNvbnN0IHByb2dyZXNzTWVzc2FnZXM6IFByb2dyZXNzTWVzc2FnZTxBZ2VudFByb2dyZXNzPltdID0gW11cbiAgY29uc3QgcGFyZW50VG9vbFVzZUlEID0gYGZvcmtlZC1jb21tYW5kLSR7Y29tbWFuZC5uYW1lfWBcbiAgbGV0IHRvb2xVc2VDb3VudGVyID0gMFxuXG4gIC8vIEhlbHBlciB0byBjcmVhdGUgYSBwcm9ncmVzcyBtZXNzYWdlIGZyb20gYW4gYWdlbnQgbWVzc2FnZVxuICBjb25zdCBjcmVhdGVQcm9ncmVzc01lc3NhZ2UgPSAoXG4gICAgbWVzc2FnZTogQXNzaXN0YW50TWVzc2FnZSB8IE5vcm1hbGl6ZWRVc2VyTWVzc2FnZSxcbiAgKTogUHJvZ3Jlc3NNZXNzYWdlPEFnZW50UHJvZ3Jlc3M+ID0+IHtcbiAgICB0b29sVXNlQ291bnRlcisrXG4gICAgcmV0dXJuIHtcbiAgICAgIHR5cGU6ICdwcm9ncmVzcycsXG4gICAgICBkYXRhOiB7XG4gICAgICAgIG1lc3NhZ2UsXG4gICAgICAgIHR5cGU6ICdhZ2VudF9wcm9ncmVzcycsXG4gICAgICAgIHByb21wdDogc2tpbGxDb250ZW50LFxuICAgICAgICBhZ2VudElkLFxuICAgICAgfSxcbiAgICAgIHBhcmVudFRvb2xVc2VJRCxcbiAgICAgIHRvb2xVc2VJRDogYCR7cGFyZW50VG9vbFVzZUlEfS0ke3Rvb2xVc2VDb3VudGVyfWAsXG4gICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSxcbiAgICAgIHV1aWQ6IHJhbmRvbVVVSUQoKSxcbiAgICB9XG4gIH1cblxuICAvLyBIZWxwZXIgdG8gdXBkYXRlIHByb2dyZXNzIGRpc3BsYXkgdXNpbmcgYWdlbnQgcHJvZ3Jlc3MgVUlcbiAgY29uc3QgdXBkYXRlUHJvZ3Jlc3MgPSAoKTogdm9pZCA9PiB7XG4gICAgc2V0VG9vbEpTWCh7XG4gICAgICBqc3g6IHJlbmRlclRvb2xVc2VQcm9ncmVzc01lc3NhZ2UocHJvZ3Jlc3NNZXNzYWdlcywge1xuICAgICAgICB0b29sczogY29udGV4dC5vcHRpb25zLnRvb2xzLFxuICAgICAgICB2ZXJib3NlOiBmYWxzZSxcbiAgICAgIH0pLFxuICAgICAgc2hvdWxkSGlkZVByb21wdElucHV0OiBmYWxzZSxcbiAgICAgIHNob3VsZENvbnRpbnVlQW5pbWF0aW9uOiB0cnVlLFxuICAgICAgc2hvd1NwaW5uZXI6IHRydWUsXG4gICAgfSlcbiAgfVxuXG4gIC8vIFNob3cgaW5pdGlhbCBcIkluaXRpYWxpemluZ+KAplwiIHN0YXRlXG4gIHVwZGF0ZVByb2dyZXNzKClcblxuICAvLyBSdW4gdGhlIHN1Yi1hZ2VudFxuICB0cnkge1xuICAgIGZvciBhd2FpdCAoY29uc3QgbWVzc2FnZSBvZiBydW5BZ2VudCh7XG4gICAgICBhZ2VudERlZmluaXRpb24sXG4gICAgICBwcm9tcHRNZXNzYWdlcyxcbiAgICAgIHRvb2xVc2VDb250ZXh0OiB7XG4gICAgICAgIC4uLmNvbnRleHQsXG4gICAgICAgIGdldEFwcFN0YXRlOiBtb2RpZmllZEdldEFwcFN0YXRlLFxuICAgICAgfSxcbiAgICAgIGNhblVzZVRvb2wsXG4gICAgICBpc0FzeW5jOiBmYWxzZSxcbiAgICAgIHF1ZXJ5U291cmNlOiAnYWdlbnQ6Y3VzdG9tJyxcbiAgICAgIG1vZGVsOiBjb21tYW5kLm1vZGVsIGFzIE1vZGVsQWxpYXMgfCB1bmRlZmluZWQsXG4gICAgICBhdmFpbGFibGVUb29sczogY29udGV4dC5vcHRpb25zLnRvb2xzLFxuICAgIH0pKSB7XG4gICAgICBhZ2VudE1lc3NhZ2VzLnB1c2gobWVzc2FnZSlcbiAgICAgIGNvbnN0IG5vcm1hbGl6ZWROZXcgPSBub3JtYWxpemVNZXNzYWdlcyhbbWVzc2FnZV0pXG5cbiAgICAgIC8vIEFkZCBwcm9ncmVzcyBtZXNzYWdlIGZvciBhc3Npc3RhbnQgbWVzc2FnZXMgKHdoaWNoIGNvbnRhaW4gdG9vbCB1c2VzKVxuICAgICAgaWYgKG1lc3NhZ2UudHlwZSA9PT0gJ2Fzc2lzdGFudCcpIHtcbiAgICAgICAgLy8gSW5jcmVtZW50IHRva2VuIGNvdW50IGluIHNwaW5uZXIgZm9yIGFzc2lzdGFudCBtZXNzYWdlc1xuICAgICAgICBjb25zdCBjb250ZW50TGVuZ3RoID0gZ2V0QXNzaXN0YW50TWVzc2FnZUNvbnRlbnRMZW5ndGgobWVzc2FnZSlcbiAgICAgICAgaWYgKGNvbnRlbnRMZW5ndGggPiAwKSB7XG4gICAgICAgICAgY29udGV4dC5zZXRSZXNwb25zZUxlbmd0aChsZW4gPT4gbGVuICsgY29udGVudExlbmd0aClcbiAgICAgICAgfVxuXG4gICAgICAgIGNvbnN0IG5vcm1hbGl6ZWRNc2cgPSBub3JtYWxpemVkTmV3WzBdXG4gICAgICAgIGlmIChub3JtYWxpemVkTXNnICYmIG5vcm1hbGl6ZWRNc2cudHlwZSA9PT0gJ2Fzc2lzdGFudCcpIHtcbiAgICAgICAgICBwcm9ncmVzc01lc3NhZ2VzLnB1c2goY3JlYXRlUHJvZ3Jlc3NNZXNzYWdlKG1lc3NhZ2UpKVxuICAgICAgICAgIHVwZGF0ZVByb2dyZXNzKClcbiAgICAgICAgfVxuICAgICAgfVxuXG4gICAgICAvLyBBZGQgcHJvZ3Jlc3MgbWVzc2FnZSBmb3IgdXNlciBtZXNzYWdlcyAod2hpY2ggY29udGFpbiB0b29sIHJlc3VsdHMpXG4gICAgICBpZiAobWVzc2FnZS50eXBlID09PSAndXNlcicpIHtcbiAgICAgICAgY29uc3Qgbm9ybWFsaXplZE1zZyA9IG5vcm1hbGl6ZWROZXdbMF1cbiAgICAgICAgaWYgKG5vcm1hbGl6ZWRNc2cgJiYgbm9ybWFsaXplZE1zZy50eXBlID09PSAndXNlcicpIHtcbiAgICAgICAgICBwcm9ncmVzc01lc3NhZ2VzLnB1c2goY3JlYXRlUHJvZ3Jlc3NNZXNzYWdlKG5vcm1hbGl6ZWRNc2cpKVxuICAgICAgICAgIHVwZGF0ZVByb2dyZXNzKClcbiAgICAgICAgfVxuICAgICAgfVxuICAgIH1cbiAgfSBmaW5hbGx5IHtcbiAgICAvLyBDbGVhciB0aGUgcHJvZ3Jlc3MgZGlzcGxheVxuICAgIHNldFRvb2xKU1gobnVsbClcbiAgfVxuXG4gIGxldCByZXN1bHRUZXh0ID0gZXh0cmFjdFJlc3VsdFRleHQoYWdlbnRNZXNzYWdlcywgJ0NvbW1hbmQgY29tcGxldGVkJylcblxuICBsb2dGb3JEZWJ1Z2dpbmcoXG4gICAgYEZvcmtlZCBzbGFzaCBjb21tYW5kIC8ke2NvbW1hbmQubmFtZX0gY29tcGxldGVkIHdpdGggYWdlbnQgJHthZ2VudElkfWAsXG4gIClcblxuICAvLyBQcmVwZW5kIGRlYnVnIGxvZyBmb3IgYW50IHVzZXJzIHNvIGl0IGFwcGVhcnMgaW5zaWRlIHRoZSBjb21tYW5kIG91dHB1dFxuICBpZiAoXCJleHRlcm5hbFwiID09PSAnYW50Jykge1xuICAgIHJlc3VsdFRleHQgPSBgW0FOVC1PTkxZXSBBUEkgY2FsbHM6ICR7Z2V0RGlzcGxheVBhdGgoZ2V0RHVtcFByb21wdHNQYXRoKGFnZW50SWQpKX1cXG4ke3Jlc3VsdFRleHR9YFxuICB9XG5cbiAgLy8gUmV0dXJuIHRoZSByZXN1bHQgYXMgYSB1c2VyIG1lc3NhZ2UgKHNpbXVsYXRlcyB0aGUgYWdlbnQncyBvdXRwdXQpXG4gIGNvbnN0IG1lc3NhZ2VzOiBVc2VyTWVzc2FnZVtdID0gW1xuICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgIGNvbnRlbnQ6IHByZXBhcmVVc2VyQ29udGVudCh7XG4gICAgICAgIGlucHV0U3RyaW5nOiBgLyR7Z2V0Q29tbWFuZE5hbWUoY29tbWFuZCl9ICR7YXJnc31gLnRyaW0oKSxcbiAgICAgICAgcHJlY2VkaW5nSW5wdXRCbG9ja3MsXG4gICAgICB9KSxcbiAgICB9KSxcbiAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICBjb250ZW50OiBgPGxvY2FsLWNvbW1hbmQtc3Rkb3V0PlxcbiR7cmVzdWx0VGV4dH1cXG48L2xvY2FsLWNvbW1hbmQtc3Rkb3V0PmAsXG4gICAgfSksXG4gIF1cblxuICByZXR1cm4ge1xuICAgIG1lc3NhZ2VzLFxuICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcbiAgICBjb21tYW5kLFxuICAgIHJlc3VsdFRleHQsXG4gIH1cbn1cblxuLyoqXG4gKiBEZXRlcm1pbmVzIGlmIGEgc3RyaW5nIGxvb2tzIGxpa2UgYSB2YWxpZCBjb21tYW5kIG5hbWUuXG4gKiBWYWxpZCBjb21tYW5kIG5hbWVzIG9ubHkgY29udGFpbiBsZXR0ZXJzLCBudW1iZXJzLCBjb2xvbnMsIGh5cGhlbnMsIGFuZCB1bmRlcnNjb3Jlcy5cbiAqXG4gKiBAcGFyYW0gY29tbWFuZE5hbWUgLSBUaGUgcG90ZW50aWFsIGNvbW1hbmQgbmFtZSB0byBjaGVja1xuICogQHJldHVybnMgdHJ1ZSBpZiBpdCBsb29rcyBsaWtlIGEgY29tbWFuZCBuYW1lLCBmYWxzZSBpZiBpdCBjb250YWlucyBub24tY29tbWFuZCBjaGFyYWN0ZXJzXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBsb29rc0xpa2VDb21tYW5kKGNvbW1hbmROYW1lOiBzdHJpbmcpOiBib29sZWFuIHtcbiAgLy8gQ29tbWFuZCBuYW1lcyBzaG91bGQgb25seSBjb250YWluIFthLXpBLVowLTk6Xy1dXG4gIC8vIElmIGl0IGNvbnRhaW5zIG90aGVyIGNoYXJhY3RlcnMsIGl0J3MgcHJvYmFibHkgYSBmaWxlIHBhdGggb3Igb3RoZXIgaW5wdXRcbiAgcmV0dXJuICEvW15hLXpBLVowLTk6XFwtX10vLnRlc3QoY29tbWFuZE5hbWUpXG59XG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBwcm9jZXNzU2xhc2hDb21tYW5kKFxuICBpbnB1dFN0cmluZzogc3RyaW5nLFxuICBwcmVjZWRpbmdJbnB1dEJsb2NrczogQ29udGVudEJsb2NrUGFyYW1bXSxcbiAgaW1hZ2VDb250ZW50QmxvY2tzOiBDb250ZW50QmxvY2tQYXJhbVtdLFxuICBhdHRhY2htZW50TWVzc2FnZXM6IEF0dGFjaG1lbnRNZXNzYWdlW10sXG4gIGNvbnRleHQ6IFByb2Nlc3NVc2VySW5wdXRDb250ZXh0LFxuICBzZXRUb29sSlNYOiBTZXRUb29sSlNYRm4sXG4gIHV1aWQ/OiBzdHJpbmcsXG4gIGlzQWxyZWFkeVByb2Nlc3Npbmc/OiBib29sZWFuLFxuICBjYW5Vc2VUb29sPzogQ2FuVXNlVG9vbEZuLFxuKTogUHJvbWlzZTxQcm9jZXNzVXNlcklucHV0QmFzZVJlc3VsdD4ge1xuICBjb25zdCBwYXJzZWQgPSBwYXJzZVNsYXNoQ29tbWFuZChpbnB1dFN0cmluZylcbiAgaWYgKCFwYXJzZWQpIHtcbiAgICBsb2dFdmVudCgndGVuZ3VfaW5wdXRfc2xhc2hfbWlzc2luZycsIHt9KVxuICAgIGNvbnN0IGVycm9yTWVzc2FnZSA9ICdDb21tYW5kcyBhcmUgaW4gdGhlIGZvcm0gYC9jb21tYW5kIFthcmdzXWAnXG4gICAgcmV0dXJuIHtcbiAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgIGNyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlKCksXG4gICAgICAgIC4uLmF0dGFjaG1lbnRNZXNzYWdlcyxcbiAgICAgICAgY3JlYXRlVXNlck1lc3NhZ2Uoe1xuICAgICAgICAgIGNvbnRlbnQ6IHByZXBhcmVVc2VyQ29udGVudCh7XG4gICAgICAgICAgICBpbnB1dFN0cmluZzogZXJyb3JNZXNzYWdlLFxuICAgICAgICAgICAgcHJlY2VkaW5nSW5wdXRCbG9ja3MsXG4gICAgICAgICAgfSksXG4gICAgICAgIH0pLFxuICAgICAgXSxcbiAgICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcbiAgICAgIHJlc3VsdFRleHQ6IGVycm9yTWVzc2FnZSxcbiAgICB9XG4gIH1cblxuICBjb25zdCB7IGNvbW1hbmROYW1lLCBhcmdzOiBwYXJzZWRBcmdzLCBpc01jcCB9ID0gcGFyc2VkXG5cbiAgY29uc3Qgc2FuaXRpemVkQ29tbWFuZE5hbWUgPSBpc01jcFxuICAgID8gJ21jcCdcbiAgICA6ICFidWlsdEluQ29tbWFuZE5hbWVzKCkuaGFzKGNvbW1hbmROYW1lKVxuICAgICAgPyAnY3VzdG9tJ1xuICAgICAgOiBjb21tYW5kTmFtZVxuXG4gIC8vIENoZWNrIGlmIGl0J3MgYSByZWFsIGNvbW1hbmQgYmVmb3JlIHByb2Nlc3NpbmdcbiAgaWYgKCFoYXNDb21tYW5kKGNvbW1hbmROYW1lLCBjb250ZXh0Lm9wdGlvbnMuY29tbWFuZHMpKSB7XG4gICAgLy8gQ2hlY2sgaWYgdGhpcyBsb29rcyBsaWtlIGEgY29tbWFuZCBuYW1lIHZzIGEgZmlsZSBwYXRoIG9yIG90aGVyIGlucHV0XG4gICAgLy8gQWxzbyBjaGVjayBpZiBpdCdzIGFuIGFjdHVhbCBmaWxlIHBhdGggdGhhdCBleGlzdHNcbiAgICBsZXQgaXNGaWxlUGF0aCA9IGZhbHNlXG4gICAgdHJ5IHtcbiAgICAgIGF3YWl0IGdldEZzSW1wbGVtZW50YXRpb24oKS5zdGF0KGAvJHtjb21tYW5kTmFtZX1gKVxuICAgICAgaXNGaWxlUGF0aCA9IHRydWVcbiAgICB9IGNhdGNoIHtcbiAgICAgIC8vIE5vdCBhIGZpbGUgcGF0aCDigJQgdHJlYXQgYXMgY29tbWFuZCBuYW1lXG4gICAgfVxuICAgIGlmIChsb29rc0xpa2VDb21tYW5kKGNvbW1hbmROYW1lKSAmJiAhaXNGaWxlUGF0aCkge1xuICAgICAgbG9nRXZlbnQoJ3Rlbmd1X2lucHV0X3NsYXNoX2ludmFsaWQnLCB7XG4gICAgICAgIGlucHV0OlxuICAgICAgICAgIGNvbW1hbmROYW1lIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICB9KVxuXG4gICAgICBjb25zdCB1bmtub3duTWVzc2FnZSA9IGBVbmtub3duIHNraWxsOiAke2NvbW1hbmROYW1lfWBcbiAgICAgIHJldHVybiB7XG4gICAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgICAgY3JlYXRlU3ludGhldGljVXNlckNhdmVhdE1lc3NhZ2UoKSxcbiAgICAgICAgICAuLi5hdHRhY2htZW50TWVzc2FnZXMsXG4gICAgICAgICAgY3JlYXRlVXNlck1lc3NhZ2Uoe1xuICAgICAgICAgICAgY29udGVudDogcHJlcGFyZVVzZXJDb250ZW50KHtcbiAgICAgICAgICAgICAgaW5wdXRTdHJpbmc6IHVua25vd25NZXNzYWdlLFxuICAgICAgICAgICAgICBwcmVjZWRpbmdJbnB1dEJsb2NrcyxcbiAgICAgICAgICAgIH0pLFxuICAgICAgICAgIH0pLFxuICAgICAgICAgIC8vIGdoLTMyNTkxOiBwcmVzZXJ2ZSBhcmdzIHNvIHRoZSB1c2VyIGNhbiBjb3B5L3Jlc3VibWl0IHdpdGhvdXRcbiAgICAgICAgICAvLyByZXR5cGluZy4gU3lzdGVtIHdhcm5pbmcgaXMgVUktb25seSAoZmlsdGVyZWQgYmVmb3JlIEFQSSkuXG4gICAgICAgICAgLi4uKHBhcnNlZEFyZ3NcbiAgICAgICAgICAgID8gW1xuICAgICAgICAgICAgICAgIGNyZWF0ZVN5c3RlbU1lc3NhZ2UoXG4gICAgICAgICAgICAgICAgICBgQXJncyBmcm9tIHVua25vd24gc2tpbGw6ICR7cGFyc2VkQXJnc31gLFxuICAgICAgICAgICAgICAgICAgJ3dhcm5pbmcnLFxuICAgICAgICAgICAgICAgICksXG4gICAgICAgICAgICAgIF1cbiAgICAgICAgICAgIDogW10pLFxuICAgICAgICBdLFxuICAgICAgICBzaG91bGRRdWVyeTogZmFsc2UsXG4gICAgICAgIHJlc3VsdFRleHQ6IHVua25vd25NZXNzYWdlLFxuICAgICAgfVxuICAgIH1cblxuICAgIGNvbnN0IHByb21wdElkID0gcmFuZG9tVVVJRCgpXG4gICAgc2V0UHJvbXB0SWQocHJvbXB0SWQpXG4gICAgbG9nRXZlbnQoJ3Rlbmd1X2lucHV0X3Byb21wdCcsIHt9KVxuICAgIC8vIExvZyB1c2VyIHByb21wdCBldmVudCBmb3IgT1RMUFxuICAgIHZvaWQgbG9nT1RlbEV2ZW50KCd1c2VyX3Byb21wdCcsIHtcbiAgICAgIHByb21wdF9sZW5ndGg6IFN0cmluZyhpbnB1dFN0cmluZy5sZW5ndGgpLFxuICAgICAgcHJvbXB0OiByZWRhY3RJZkRpc2FibGVkKGlucHV0U3RyaW5nKSxcbiAgICAgICdwcm9tcHQuaWQnOiBwcm9tcHRJZCxcbiAgICB9KVxuICAgIHJldHVybiB7XG4gICAgICBtZXNzYWdlczogW1xuICAgICAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICAgICAgY29udGVudDogcHJlcGFyZVVzZXJDb250ZW50KHsgaW5wdXRTdHJpbmcsIHByZWNlZGluZ0lucHV0QmxvY2tzIH0pLFxuICAgICAgICAgIHV1aWQ6IHV1aWQsXG4gICAgICAgIH0pLFxuICAgICAgICAuLi5hdHRhY2htZW50TWVzc2FnZXMsXG4gICAgICBdLFxuICAgICAgc2hvdWxkUXVlcnk6IHRydWUsXG4gICAgfVxuICB9XG5cbiAgLy8gVHJhY2sgc2xhc2ggY29tbWFuZCB1c2FnZSBmb3IgZmVhdHVyZSBkaXNjb3ZlcnlcblxuICBjb25zdCB7XG4gICAgbWVzc2FnZXM6IG5ld01lc3NhZ2VzLFxuICAgIHNob3VsZFF1ZXJ5OiBtZXNzYWdlU2hvdWxkUXVlcnksXG4gICAgYWxsb3dlZFRvb2xzLFxuICAgIG1vZGVsLFxuICAgIGVmZm9ydCxcbiAgICBjb21tYW5kOiByZXR1cm5lZENvbW1hbmQsXG4gICAgcmVzdWx0VGV4dCxcbiAgICBuZXh0SW5wdXQsXG4gICAgc3VibWl0TmV4dElucHV0LFxuICB9ID0gYXdhaXQgZ2V0TWVzc2FnZXNGb3JTbGFzaENvbW1hbmQoXG4gICAgY29tbWFuZE5hbWUsXG4gICAgcGFyc2VkQXJncyxcbiAgICBzZXRUb29sSlNYLFxuICAgIGNvbnRleHQsXG4gICAgcHJlY2VkaW5nSW5wdXRCbG9ja3MsXG4gICAgaW1hZ2VDb250ZW50QmxvY2tzLFxuICAgIGlzQWxyZWFkeVByb2Nlc3NpbmcsXG4gICAgY2FuVXNlVG9vbCxcbiAgICB1dWlkLFxuICApXG5cbiAgLy8gTG9jYWwgc2xhc2ggY29tbWFuZHMgdGhhdCBza2lwIG1lc3NhZ2VzXG4gIGlmIChuZXdNZXNzYWdlcy5sZW5ndGggPT09IDApIHtcbiAgICBjb25zdCBldmVudERhdGE6IFJlY29yZDxzdHJpbmcsIGJvb2xlYW4gfCBudW1iZXIgfCB1bmRlZmluZWQ+ID0ge1xuICAgICAgaW5wdXQ6XG4gICAgICAgIHNhbml0aXplZENvbW1hbmROYW1lIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgfVxuXG4gICAgLy8gQWRkIHBsdWdpbiBtZXRhZGF0YSBpZiB0aGlzIGlzIGEgcGx1Z2luIGNvbW1hbmRcbiAgICBpZiAocmV0dXJuZWRDb21tYW5kLnR5cGUgPT09ICdwcm9tcHQnICYmIHJldHVybmVkQ29tbWFuZC5wbHVnaW5JbmZvKSB7XG4gICAgICBjb25zdCB7IHBsdWdpbk1hbmlmZXN0LCByZXBvc2l0b3J5IH0gPSByZXR1cm5lZENvbW1hbmQucGx1Z2luSW5mb1xuICAgICAgY29uc3QgeyBtYXJrZXRwbGFjZSB9ID0gcGFyc2VQbHVnaW5JZGVudGlmaWVyKHJlcG9zaXRvcnkpXG4gICAgICBjb25zdCBpc09mZmljaWFsID0gaXNPZmZpY2lhbE1hcmtldHBsYWNlTmFtZShtYXJrZXRwbGFjZSlcbiAgICAgIC8vIF9QUk9UT18qIHJvdXRlcyB0byBQSUktdGFnZ2VkIHBsdWdpbl9uYW1lL21hcmtldHBsYWNlX25hbWUgQlEgY29sdW1uc1xuICAgICAgLy8gKHVucmVkYWN0ZWQsIGFsbCB1c2Vycyk7IHBsdWdpbl9uYW1lL3BsdWdpbl9yZXBvc2l0b3J5IHN0YXkgaW5cbiAgICAgIC8vIGFkZGl0aW9uYWxfbWV0YWRhdGEgYXMgcmVkYWN0ZWQgdmFyaWFudHMgZm9yIGdlbmVyYWwtYWNjZXNzIGRhc2hib2FyZHMuXG4gICAgICBldmVudERhdGEuX1BST1RPX3BsdWdpbl9uYW1lID1cbiAgICAgICAgcGx1Z2luTWFuaWZlc3QubmFtZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfUElJX1RBR0dFRFxuICAgICAgaWYgKG1hcmtldHBsYWNlKSB7XG4gICAgICAgIGV2ZW50RGF0YS5fUFJPVE9fbWFya2V0cGxhY2VfbmFtZSA9XG4gICAgICAgICAgbWFya2V0cGxhY2UgYXMgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX1BJSV9UQUdHRURcbiAgICAgIH1cbiAgICAgIGV2ZW50RGF0YS5wbHVnaW5fcmVwb3NpdG9yeSA9IChcbiAgICAgICAgaXNPZmZpY2lhbCA/IHJlcG9zaXRvcnkgOiAndGhpcmQtcGFydHknXG4gICAgICApIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFNcbiAgICAgIGV2ZW50RGF0YS5wbHVnaW5fbmFtZSA9IChcbiAgICAgICAgaXNPZmZpY2lhbCA/IHBsdWdpbk1hbmlmZXN0Lm5hbWUgOiAndGhpcmQtcGFydHknXG4gICAgICApIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFNcbiAgICAgIGlmIChpc09mZmljaWFsICYmIHBsdWdpbk1hbmlmZXN0LnZlcnNpb24pIHtcbiAgICAgICAgZXZlbnREYXRhLnBsdWdpbl92ZXJzaW9uID1cbiAgICAgICAgICBwbHVnaW5NYW5pZmVzdC52ZXJzaW9uIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFNcbiAgICAgIH1cbiAgICAgIE9iamVjdC5hc3NpZ24oXG4gICAgICAgIGV2ZW50RGF0YSxcbiAgICAgICAgYnVpbGRQbHVnaW5Db21tYW5kVGVsZW1ldHJ5RmllbGRzKHJldHVybmVkQ29tbWFuZC5wbHVnaW5JbmZvKSxcbiAgICAgIClcbiAgICB9XG5cbiAgICBsb2dFdmVudCgndGVuZ3VfaW5wdXRfY29tbWFuZCcsIHtcbiAgICAgIC4uLmV2ZW50RGF0YSxcbiAgICAgIGludm9jYXRpb25fdHJpZ2dlcjpcbiAgICAgICAgJ3VzZXItc2xhc2gnIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICAuLi4oXCJleHRlcm5hbFwiID09PSAnYW50JyAmJiB7XG4gICAgICAgIHNraWxsX25hbWU6XG4gICAgICAgICAgY29tbWFuZE5hbWUgYXMgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyxcbiAgICAgICAgLi4uKHJldHVybmVkQ29tbWFuZC50eXBlID09PSAncHJvbXB0JyAmJiB7XG4gICAgICAgICAgc2tpbGxfc291cmNlOlxuICAgICAgICAgICAgcmV0dXJuZWRDb21tYW5kLnNvdXJjZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgICB9KSxcbiAgICAgICAgLi4uKHJldHVybmVkQ29tbWFuZC5sb2FkZWRGcm9tICYmIHtcbiAgICAgICAgICBza2lsbF9sb2FkZWRfZnJvbTpcbiAgICAgICAgICAgIHJldHVybmVkQ29tbWFuZC5sb2FkZWRGcm9tIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICAgIH0pLFxuICAgICAgICAuLi4ocmV0dXJuZWRDb21tYW5kLmtpbmQgJiYge1xuICAgICAgICAgIHNraWxsX2tpbmQ6XG4gICAgICAgICAgICByZXR1cm5lZENvbW1hbmQua2luZCBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgICB9KSxcbiAgICAgIH0pLFxuICAgIH0pXG4gICAgcmV0dXJuIHtcbiAgICAgIG1lc3NhZ2VzOiBbXSxcbiAgICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcblxuICAgICAgbW9kZWwsXG4gICAgICBuZXh0SW5wdXQsXG4gICAgICBzdWJtaXROZXh0SW5wdXQsXG4gICAgfVxuICB9XG5cbiAgLy8gRm9yIGludmFsaWQgY29tbWFuZHMsIHByZXNlcnZlIGJvdGggdGhlIHVzZXIgbWVzc2FnZSBhbmQgZXJyb3JcbiAgaWYgKFxuICAgIG5ld01lc3NhZ2VzLmxlbmd0aCA9PT0gMiAmJlxuICAgIG5ld01lc3NhZ2VzWzFdIS50eXBlID09PSAndXNlcicgJiZcbiAgICB0eXBlb2YgbmV3TWVzc2FnZXNbMV0hLm1lc3NhZ2UuY29udGVudCA9PT0gJ3N0cmluZycgJiZcbiAgICBuZXdNZXNzYWdlc1sxXSEubWVzc2FnZS5jb250ZW50LnN0YXJ0c1dpdGgoJ1Vua25vd24gY29tbWFuZDonKVxuICApIHtcbiAgICAvLyBEb24ndCBsb2cgYXMgaW52YWxpZCBpZiBpdCBsb29rcyBsaWtlIGEgY29tbW9uIGZpbGUgcGF0aFxuICAgIGNvbnN0IGxvb2tzTGlrZUZpbGVQYXRoID1cbiAgICAgIGlucHV0U3RyaW5nLnN0YXJ0c1dpdGgoJy92YXInKSB8fFxuICAgICAgaW5wdXRTdHJpbmcuc3RhcnRzV2l0aCgnL3RtcCcpIHx8XG4gICAgICBpbnB1dFN0cmluZy5zdGFydHNXaXRoKCcvcHJpdmF0ZScpXG5cbiAgICBpZiAoIWxvb2tzTGlrZUZpbGVQYXRoKSB7XG4gICAgICBsb2dFdmVudCgndGVuZ3VfaW5wdXRfc2xhc2hfaW52YWxpZCcsIHtcbiAgICAgICAgaW5wdXQ6XG4gICAgICAgICAgY29tbWFuZE5hbWUgYXMgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyxcbiAgICAgIH0pXG4gICAgfVxuXG4gICAgcmV0dXJuIHtcbiAgICAgIG1lc3NhZ2VzOiBbY3JlYXRlU3ludGhldGljVXNlckNhdmVhdE1lc3NhZ2UoKSwgLi4ubmV3TWVzc2FnZXNdLFxuICAgICAgc2hvdWxkUXVlcnk6IG1lc3NhZ2VTaG91bGRRdWVyeSxcbiAgICAgIGFsbG93ZWRUb29scyxcblxuICAgICAgbW9kZWwsXG4gICAgfVxuICB9XG5cbiAgLy8gQSB2YWxpZCBjb21tYW5kXG4gIGNvbnN0IGV2ZW50RGF0YTogUmVjb3JkPHN0cmluZywgYm9vbGVhbiB8IG51bWJlciB8IHVuZGVmaW5lZD4gPSB7XG4gICAgaW5wdXQ6XG4gICAgICBzYW5pdGl6ZWRDb21tYW5kTmFtZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICB9XG5cbiAgLy8gQWRkIHBsdWdpbiBtZXRhZGF0YSBpZiB0aGlzIGlzIGEgcGx1Z2luIGNvbW1hbmRcbiAgaWYgKHJldHVybmVkQ29tbWFuZC50eXBlID09PSAncHJvbXB0JyAmJiByZXR1cm5lZENvbW1hbmQucGx1Z2luSW5mbykge1xuICAgIGNvbnN0IHsgcGx1Z2luTWFuaWZlc3QsIHJlcG9zaXRvcnkgfSA9IHJldHVybmVkQ29tbWFuZC5wbHVnaW5JbmZvXG4gICAgY29uc3QgeyBtYXJrZXRwbGFjZSB9ID0gcGFyc2VQbHVnaW5JZGVudGlmaWVyKHJlcG9zaXRvcnkpXG4gICAgY29uc3QgaXNPZmZpY2lhbCA9IGlzT2ZmaWNpYWxNYXJrZXRwbGFjZU5hbWUobWFya2V0cGxhY2UpXG4gICAgZXZlbnREYXRhLl9QUk9UT19wbHVnaW5fbmFtZSA9XG4gICAgICBwbHVnaW5NYW5pZmVzdC5uYW1lIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19QSUlfVEFHR0VEXG4gICAgaWYgKG1hcmtldHBsYWNlKSB7XG4gICAgICBldmVudERhdGEuX1BST1RPX21hcmtldHBsYWNlX25hbWUgPVxuICAgICAgICBtYXJrZXRwbGFjZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfUElJX1RBR0dFRFxuICAgIH1cbiAgICBldmVudERhdGEucGx1Z2luX3JlcG9zaXRvcnkgPSAoXG4gICAgICBpc09mZmljaWFsID8gcmVwb3NpdG9yeSA6ICd0aGlyZC1wYXJ0eSdcbiAgICApIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFNcbiAgICBldmVudERhdGEucGx1Z2luX25hbWUgPSAoXG4gICAgICBpc09mZmljaWFsID8gcGx1Z2luTWFuaWZlc3QubmFtZSA6ICd0aGlyZC1wYXJ0eSdcbiAgICApIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFNcbiAgICBpZiAoaXNPZmZpY2lhbCAmJiBwbHVnaW5NYW5pZmVzdC52ZXJzaW9uKSB7XG4gICAgICBldmVudERhdGEucGx1Z2luX3ZlcnNpb24gPVxuICAgICAgICBwbHVnaW5NYW5pZmVzdC52ZXJzaW9uIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFNcbiAgICB9XG4gICAgT2JqZWN0LmFzc2lnbihcbiAgICAgIGV2ZW50RGF0YSxcbiAgICAgIGJ1aWxkUGx1Z2luQ29tbWFuZFRlbGVtZXRyeUZpZWxkcyhyZXR1cm5lZENvbW1hbmQucGx1Z2luSW5mbyksXG4gICAgKVxuICB9XG5cbiAgbG9nRXZlbnQoJ3Rlbmd1X2lucHV0X2NvbW1hbmQnLCB7XG4gICAgLi4uZXZlbnREYXRhLFxuICAgIGludm9jYXRpb25fdHJpZ2dlcjpcbiAgICAgICd1c2VyLXNsYXNoJyBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgIC4uLihcImV4dGVybmFsXCIgPT09ICdhbnQnICYmIHtcbiAgICAgIHNraWxsX25hbWU6XG4gICAgICAgIGNvbW1hbmROYW1lIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICAuLi4ocmV0dXJuZWRDb21tYW5kLnR5cGUgPT09ICdwcm9tcHQnICYmIHtcbiAgICAgICAgc2tpbGxfc291cmNlOlxuICAgICAgICAgIHJldHVybmVkQ29tbWFuZC5zb3VyY2UgYXMgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyxcbiAgICAgIH0pLFxuICAgICAgLi4uKHJldHVybmVkQ29tbWFuZC5sb2FkZWRGcm9tICYmIHtcbiAgICAgICAgc2tpbGxfbG9hZGVkX2Zyb206XG4gICAgICAgICAgcmV0dXJuZWRDb21tYW5kLmxvYWRlZEZyb20gYXMgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyxcbiAgICAgIH0pLFxuICAgICAgLi4uKHJldHVybmVkQ29tbWFuZC5raW5kICYmIHtcbiAgICAgICAgc2tpbGxfa2luZDpcbiAgICAgICAgICByZXR1cm5lZENvbW1hbmQua2luZCBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgfSksXG4gICAgfSksXG4gIH0pXG5cbiAgLy8gQ2hlY2sgaWYgdGhpcyBpcyBhIGNvbXBhY3QgcmVzdWx0IHdoaWNoIGhhbmRsZSB0aGVpciBvd24gc3ludGhldGljIGNhdmVhdCBtZXNzYWdlIG9yZGVyaW5nXG4gIGNvbnN0IGlzQ29tcGFjdFJlc3VsdCA9XG4gICAgbmV3TWVzc2FnZXMubGVuZ3RoID4gMCAmJlxuICAgIG5ld01lc3NhZ2VzWzBdICYmXG4gICAgaXNDb21wYWN0Qm91bmRhcnlNZXNzYWdlKG5ld01lc3NhZ2VzWzBdKVxuXG4gIHJldHVybiB7XG4gICAgbWVzc2FnZXM6XG4gICAgICBtZXNzYWdlU2hvdWxkUXVlcnkgfHxcbiAgICAgIG5ld01lc3NhZ2VzLmV2ZXJ5KGlzU3lzdGVtTG9jYWxDb21tYW5kTWVzc2FnZSkgfHxcbiAgICAgIGlzQ29tcGFjdFJlc3VsdFxuICAgICAgICA/IG5ld01lc3NhZ2VzXG4gICAgICAgIDogW2NyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlKCksIC4uLm5ld01lc3NhZ2VzXSxcbiAgICBzaG91bGRRdWVyeTogbWVzc2FnZVNob3VsZFF1ZXJ5LFxuICAgIGFsbG93ZWRUb29scyxcbiAgICBtb2RlbCxcbiAgICBlZmZvcnQsXG4gICAgcmVzdWx0VGV4dCxcbiAgICBuZXh0SW5wdXQsXG4gICAgc3VibWl0TmV4dElucHV0LFxuICB9XG59XG5cbmFzeW5jIGZ1bmN0aW9uIGdldE1lc3NhZ2VzRm9yU2xhc2hDb21tYW5kKFxuICBjb21tYW5kTmFtZTogc3RyaW5nLFxuICBhcmdzOiBzdHJpbmcsXG4gIHNldFRvb2xKU1g6IFNldFRvb2xKU1hGbixcbiAgY29udGV4dDogUHJvY2Vzc1VzZXJJbnB1dENvbnRleHQsXG4gIHByZWNlZGluZ0lucHV0QmxvY2tzOiBDb250ZW50QmxvY2tQYXJhbVtdLFxuICBpbWFnZUNvbnRlbnRCbG9ja3M6IENvbnRlbnRCbG9ja1BhcmFtW10sXG4gIF9pc0FscmVhZHlQcm9jZXNzaW5nPzogYm9vbGVhbixcbiAgY2FuVXNlVG9vbD86IENhblVzZVRvb2xGbixcbiAgdXVpZD86IHN0cmluZyxcbik6IFByb21pc2U8U2xhc2hDb21tYW5kUmVzdWx0PiB7XG4gIGNvbnN0IGNvbW1hbmQgPSBnZXRDb21tYW5kKGNvbW1hbmROYW1lLCBjb250ZXh0Lm9wdGlvbnMuY29tbWFuZHMpXG5cbiAgLy8gVHJhY2sgc2tpbGwgdXNhZ2UgZm9yIHJhbmtpbmcgKG9ubHkgZm9yIHByb21wdCBjb21tYW5kcyB0aGF0IGFyZSB1c2VyLWludm9jYWJsZSlcbiAgaWYgKGNvbW1hbmQudHlwZSA9PT0gJ3Byb21wdCcgJiYgY29tbWFuZC51c2VySW52b2NhYmxlICE9PSBmYWxzZSkge1xuICAgIHJlY29yZFNraWxsVXNhZ2UoY29tbWFuZE5hbWUpXG4gIH1cblxuICAvLyBDaGVjayBpZiB0aGUgY29tbWFuZCBpcyB1c2VyLWludm9jYWJsZVxuICAvLyBTa2lsbHMgd2l0aCB1c2VySW52b2NhYmxlID09PSBmYWxzZSBjYW4gb25seSBiZSBpbnZva2VkIGJ5IHRoZSBtb2RlbCB2aWEgU2tpbGxUb29sXG4gIGlmIChjb21tYW5kLnVzZXJJbnZvY2FibGUgPT09IGZhbHNlKSB7XG4gICAgcmV0dXJuIHtcbiAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICBjb250ZW50OiBwcmVwYXJlVXNlckNvbnRlbnQoe1xuICAgICAgICAgICAgaW5wdXRTdHJpbmc6IGAvJHtjb21tYW5kTmFtZX1gLFxuICAgICAgICAgICAgcHJlY2VkaW5nSW5wdXRCbG9ja3MsXG4gICAgICAgICAgfSksXG4gICAgICAgIH0pLFxuICAgICAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICAgICAgY29udGVudDogYFRoaXMgc2tpbGwgY2FuIG9ubHkgYmUgaW52b2tlZCBieSBDbGF1ZGUsIG5vdCBkaXJlY3RseSBieSB1c2Vycy4gQXNrIENsYXVkZSB0byB1c2UgdGhlIFwiJHtjb21tYW5kTmFtZX1cIiBza2lsbCBmb3IgeW91LmAsXG4gICAgICAgIH0pLFxuICAgICAgXSxcbiAgICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcbiAgICAgIGNvbW1hbmQsXG4gICAgfVxuICB9XG5cbiAgdHJ5IHtcbiAgICBzd2l0Y2ggKGNvbW1hbmQudHlwZSkge1xuICAgICAgY2FzZSAnbG9jYWwtanN4Jzoge1xuICAgICAgICByZXR1cm4gbmV3IFByb21pc2U8U2xhc2hDb21tYW5kUmVzdWx0PihyZXNvbHZlID0+IHtcbiAgICAgICAgICBsZXQgZG9uZVdhc0NhbGxlZCA9IGZhbHNlXG4gICAgICAgICAgY29uc3Qgb25Eb25lID0gKFxuICAgICAgICAgICAgcmVzdWx0Pzogc3RyaW5nLFxuICAgICAgICAgICAgb3B0aW9ucz86IHtcbiAgICAgICAgICAgICAgZGlzcGxheT86IENvbW1hbmRSZXN1bHREaXNwbGF5XG4gICAgICAgICAgICAgIHNob3VsZFF1ZXJ5PzogYm9vbGVhblxuICAgICAgICAgICAgICBtZXRhTWVzc2FnZXM/OiBzdHJpbmdbXVxuICAgICAgICAgICAgICBuZXh0SW5wdXQ/OiBzdHJpbmdcbiAgICAgICAgICAgICAgc3VibWl0TmV4dElucHV0PzogYm9vbGVhblxuICAgICAgICAgICAgfSxcbiAgICAgICAgICApID0+IHtcbiAgICAgICAgICAgIGRvbmVXYXNDYWxsZWQgPSB0cnVlXG4gICAgICAgICAgICAvLyBJZiBkaXNwbGF5IGlzICdza2lwJywgZG9uJ3QgYWRkIGFueSBtZXNzYWdlcyB0byB0aGUgY29udmVyc2F0aW9uXG4gICAgICAgICAgICBpZiAob3B0aW9ucz8uZGlzcGxheSA9PT0gJ3NraXAnKSB7XG4gICAgICAgICAgICAgIHZvaWQgcmVzb2x2ZSh7XG4gICAgICAgICAgICAgICAgbWVzc2FnZXM6IFtdLFxuICAgICAgICAgICAgICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcbiAgICAgICAgICAgICAgICBjb21tYW5kLFxuICAgICAgICAgICAgICAgIG5leHRJbnB1dDogb3B0aW9ucz8ubmV4dElucHV0LFxuICAgICAgICAgICAgICAgIHN1Ym1pdE5leHRJbnB1dDogb3B0aW9ucz8uc3VibWl0TmV4dElucHV0LFxuICAgICAgICAgICAgICB9KVxuICAgICAgICAgICAgICByZXR1cm5cbiAgICAgICAgICAgIH1cblxuICAgICAgICAgICAgLy8gTWV0YSBtZXNzYWdlcyBhcmUgbW9kZWwtdmlzaWJsZSBidXQgaGlkZGVuIGZyb20gdGhlIHVzZXJcbiAgICAgICAgICAgIGNvbnN0IG1ldGFNZXNzYWdlcyA9IChvcHRpb25zPy5tZXRhTWVzc2FnZXMgPz8gW10pLm1hcChcbiAgICAgICAgICAgICAgKGNvbnRlbnQ6IHN0cmluZykgPT4gY3JlYXRlVXNlck1lc3NhZ2UoeyBjb250ZW50LCBpc01ldGE6IHRydWUgfSksXG4gICAgICAgICAgICApXG5cbiAgICAgICAgICAgIC8vIEluIGZ1bGxzY3JlZW4gdGhlIGNvbW1hbmQganVzdCBzaG93ZWQgYXMgYSBjZW50ZXJlZCBtb2RhbFxuICAgICAgICAgICAgLy8gcGFuZSDigJQgdGhlIHRyYW5zaWVudCBub3RpZmljYXRpb24gaXMgZW5vdWdoIGZlZWRiYWNrLiBUaGVcbiAgICAgICAgICAgIC8vIFwi4p2vIC9jb25maWdcIiArIFwi4o6/IGRpc21pc3NlZFwiIHRyYW5zY3JpcHQgZW50cmllcyBhcmVcbiAgICAgICAgICAgIC8vIHR5cGU6c3lzdGVtIHN1YnR5cGU6bG9jYWxfY29tbWFuZCAodXNlci12aXNpYmxlIGJ1dCBOT1Qgc2VudFxuICAgICAgICAgICAgLy8gdG8gdGhlIG1vZGVsKSwgc28gc2tpcHBpbmcgdGhlbSBkb2Vzbid0IGFmZmVjdCBtb2RlbCBjb250ZXh0LlxuICAgICAgICAgICAgLy8gT3V0c2lkZSBmdWxsc2NyZWVuIGtlZXAgdGhlbSBzbyBzY3JvbGxiYWNrIHNob3dzIHdoYXQgcmFuLlxuICAgICAgICAgICAgLy8gT25seSBza2lwIFwiPE5hbWU+IGRpc21pc3NlZFwiIG1vZGFsLWNsb3NlIG5vdGlmaWNhdGlvbnMg4oCUXG4gICAgICAgICAgICAvLyBjb21tYW5kcyB0aGF0IGVhcmx5LWV4aXQgYmVmb3JlIHNob3dpbmcgYSBtb2RhbCAoL3VsdHJhcGxhblxuICAgICAgICAgICAgLy8gdXNhZ2UsIC9yZW5hbWUsIC9wcm9hY3RpdmUpIHVzZSBkaXNwbGF5OnN5c3RlbSBmb3IgYWN0dWFsXG4gICAgICAgICAgICAvLyBvdXRwdXQgdGhhdCBtdXN0IHJlYWNoIHRoZSB0cmFuc2NyaXB0LlxuICAgICAgICAgICAgY29uc3Qgc2tpcFRyYW5zY3JpcHQgPVxuICAgICAgICAgICAgICBpc0Z1bGxzY3JlZW5FbnZFbmFibGVkKCkgJiZcbiAgICAgICAgICAgICAgdHlwZW9mIHJlc3VsdCA9PT0gJ3N0cmluZycgJiZcbiAgICAgICAgICAgICAgcmVzdWx0LmVuZHNXaXRoKCcgZGlzbWlzc2VkJylcblxuICAgICAgICAgICAgdm9pZCByZXNvbHZlKHtcbiAgICAgICAgICAgICAgbWVzc2FnZXM6XG4gICAgICAgICAgICAgICAgb3B0aW9ucz8uZGlzcGxheSA9PT0gJ3N5c3RlbSdcbiAgICAgICAgICAgICAgICAgID8gc2tpcFRyYW5zY3JpcHRcbiAgICAgICAgICAgICAgICAgICAgPyBtZXRhTWVzc2FnZXNcbiAgICAgICAgICAgICAgICAgICAgOiBbXG4gICAgICAgICAgICAgICAgICAgICAgICBjcmVhdGVDb21tYW5kSW5wdXRNZXNzYWdlKFxuICAgICAgICAgICAgICAgICAgICAgICAgICBmb3JtYXRDb21tYW5kSW5wdXQoY29tbWFuZCwgYXJncyksXG4gICAgICAgICAgICAgICAgICAgICAgICApLFxuICAgICAgICAgICAgICAgICAgICAgICAgY3JlYXRlQ29tbWFuZElucHV0TWVzc2FnZShcbiAgICAgICAgICAgICAgICAgICAgICAgICAgYDxsb2NhbC1jb21tYW5kLXN0ZG91dD4ke3Jlc3VsdH08L2xvY2FsLWNvbW1hbmQtc3Rkb3V0PmAsXG4gICAgICAgICAgICAgICAgICAgICAgICApLFxuICAgICAgICAgICAgICAgICAgICAgICAgLi4ubWV0YU1lc3NhZ2VzLFxuICAgICAgICAgICAgICAgICAgICAgIF1cbiAgICAgICAgICAgICAgICAgIDogW1xuICAgICAgICAgICAgICAgICAgICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICAgICAgICAgICAgICAgIGNvbnRlbnQ6IHByZXBhcmVVc2VyQ29udGVudCh7XG4gICAgICAgICAgICAgICAgICAgICAgICAgIGlucHV0U3RyaW5nOiBmb3JtYXRDb21tYW5kSW5wdXQoY29tbWFuZCwgYXJncyksXG4gICAgICAgICAgICAgICAgICAgICAgICAgIHByZWNlZGluZ0lucHV0QmxvY2tzLFxuICAgICAgICAgICAgICAgICAgICAgICAgfSksXG4gICAgICAgICAgICAgICAgICAgICAgfSksXG4gICAgICAgICAgICAgICAgICAgICAgcmVzdWx0XG4gICAgICAgICAgICAgICAgICAgICAgICA/IGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICAgICAgICAgICAgICAgICAgICBjb250ZW50OiBgPGxvY2FsLWNvbW1hbmQtc3Rkb3V0PiR7cmVzdWx0fTwvbG9jYWwtY29tbWFuZC1zdGRvdXQ+YCxcbiAgICAgICAgICAgICAgICAgICAgICAgICAgfSlcbiAgICAgICAgICAgICAgICAgICAgICAgIDogY3JlYXRlVXNlck1lc3NhZ2Uoe1xuICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNvbnRlbnQ6IGA8bG9jYWwtY29tbWFuZC1zdGRvdXQ+JHtOT19DT05URU5UX01FU1NBR0V9PC9sb2NhbC1jb21tYW5kLXN0ZG91dD5gLFxuICAgICAgICAgICAgICAgICAgICAgICAgICB9KSxcbiAgICAgICAgICAgICAgICAgICAgICAuLi5tZXRhTWVzc2FnZXMsXG4gICAgICAgICAgICAgICAgICAgIF0sXG4gICAgICAgICAgICAgIHNob3VsZFF1ZXJ5OiBvcHRpb25zPy5zaG91bGRRdWVyeSA/PyBmYWxzZSxcbiAgICAgICAgICAgICAgY29tbWFuZCxcbiAgICAgICAgICAgICAgbmV4dElucHV0OiBvcHRpb25zPy5uZXh0SW5wdXQsXG4gICAgICAgICAgICAgIHN1Ym1pdE5leHRJbnB1dDogb3B0aW9ucz8uc3VibWl0TmV4dElucHV0LFxuICAgICAgICAgICAgfSlcbiAgICAgICAgICB9XG5cbiAgICAgICAgICB2b2lkIGNvbW1hbmRcbiAgICAgICAgICAgIC5sb2FkKClcbiAgICAgICAgICAgIC50aGVuKG1vZCA9PiBtb2QuY2FsbChvbkRvbmUsIHsgLi4uY29udGV4dCwgY2FuVXNlVG9vbCB9LCBhcmdzKSlcbiAgICAgICAgICAgIC50aGVuKGpzeCA9PiB7XG4gICAgICAgICAgICAgIGlmIChqc3ggPT0gbnVsbCkgcmV0dXJuXG4gICAgICAgICAgICAgIGlmIChjb250ZXh0Lm9wdGlvbnMuaXNOb25JbnRlcmFjdGl2ZVNlc3Npb24pIHtcbiAgICAgICAgICAgICAgICB2b2lkIHJlc29sdmUoe1xuICAgICAgICAgICAgICAgICAgbWVzc2FnZXM6IFtdLFxuICAgICAgICAgICAgICAgICAgc2hvdWxkUXVlcnk6IGZhbHNlLFxuICAgICAgICAgICAgICAgICAgY29tbWFuZCxcbiAgICAgICAgICAgICAgICB9KVxuICAgICAgICAgICAgICAgIHJldHVyblxuICAgICAgICAgICAgICB9XG4gICAgICAgICAgICAgIC8vIEd1YXJkOiBpZiBvbkRvbmUgZmlyZWQgZHVyaW5nIG1vZC5jYWxsKCkgKGVhcmx5LWV4aXQgcGF0aFxuICAgICAgICAgICAgICAvLyB0aGF0IGNhbGxzIG9uRG9uZSB0aGVuIHJldHVybnMgSlNYKSwgc2tpcCBzZXRUb29sSlNYLiBUaGlzXG4gICAgICAgICAgICAgIC8vIGNoYWluIGlzIGZpcmUtYW5kLWZvcmdldCDigJQgdGhlIG91dGVyIFByb21pc2UgcmVzb2x2ZXMgd2hlblxuICAgICAgICAgICAgICAvLyBvbkRvbmUgaXMgY2FsbGVkLCBzbyBleGVjdXRlVXNlcklucHV0IG1heSBoYXZlIGFscmVhZHkgcnVuXG4gICAgICAgICAgICAgIC8vIGl0cyBzZXRUb29sSlNYKHtjbGVhckxvY2FsSlNYOiB0cnVlfSkgYmVmb3JlIHdlIGdldCBoZXJlLlxuICAgICAgICAgICAgICAvLyBTZXR0aW5nIGlzTG9jYWxKU1hDb21tYW5kIGFmdGVyIGNsZWFyIGxlYXZlcyBpdCBzdHVjayB0cnVlLFxuICAgICAgICAgICAgICAvLyBibG9ja2luZyB1c2VRdWV1ZVByb2Nlc3NvciBhbmQgVGV4dElucHV0IGZvY3VzLlxuICAgICAgICAgICAgICBpZiAoZG9uZVdhc0NhbGxlZCkgcmV0dXJuXG4gICAgICAgICAgICAgIHNldFRvb2xKU1goe1xuICAgICAgICAgICAgICAgIGpzeCxcbiAgICAgICAgICAgICAgICBzaG91bGRIaWRlUHJvbXB0SW5wdXQ6IHRydWUsXG4gICAgICAgICAgICAgICAgc2hvd1NwaW5uZXI6IGZhbHNlLFxuICAgICAgICAgICAgICAgIGlzTG9jYWxKU1hDb21tYW5kOiB0cnVlLFxuICAgICAgICAgICAgICAgIGlzSW1tZWRpYXRlOiBjb21tYW5kLmltbWVkaWF0ZSA9PT0gdHJ1ZSxcbiAgICAgICAgICAgICAgfSlcbiAgICAgICAgICAgIH0pXG4gICAgICAgICAgICAuY2F0Y2goZSA9PiB7XG4gICAgICAgICAgICAgIC8vIElmIGxvYWQoKS9jYWxsKCkgdGhyb3dzIGFuZCBvbkRvbmUgbmV2ZXIgZmlyZWQsIHRoZSBvdXRlclxuICAgICAgICAgICAgICAvLyBQcm9taXNlIGhhbmdzIGZvcmV2ZXIsIGxlYXZpbmcgcXVlcnlHdWFyZCBzdHVjayBpblxuICAgICAgICAgICAgICAvLyAnZGlzcGF0Y2hpbmcnIGFuZCBkZWFkbG9ja2luZyB0aGUgcXVldWUgcHJvY2Vzc29yLlxuICAgICAgICAgICAgICBsb2dFcnJvcihlKVxuICAgICAgICAgICAgICBpZiAoZG9uZVdhc0NhbGxlZCkgcmV0dXJuXG4gICAgICAgICAgICAgIGRvbmVXYXNDYWxsZWQgPSB0cnVlXG4gICAgICAgICAgICAgIHNldFRvb2xKU1goe1xuICAgICAgICAgICAgICAgIGpzeDogbnVsbCxcbiAgICAgICAgICAgICAgICBzaG91bGRIaWRlUHJvbXB0SW5wdXQ6IGZhbHNlLFxuICAgICAgICAgICAgICAgIGNsZWFyTG9jYWxKU1g6IHRydWUsXG4gICAgICAgICAgICAgIH0pXG4gICAgICAgICAgICAgIHZvaWQgcmVzb2x2ZSh7IG1lc3NhZ2VzOiBbXSwgc2hvdWxkUXVlcnk6IGZhbHNlLCBjb21tYW5kIH0pXG4gICAgICAgICAgICB9KVxuICAgICAgICB9KVxuICAgICAgfVxuICAgICAgY2FzZSAnbG9jYWwnOiB7XG4gICAgICAgIGNvbnN0IGRpc3BsYXlBcmdzID0gY29tbWFuZC5pc1NlbnNpdGl2ZSAmJiBhcmdzLnRyaW0oKSA/ICcqKionIDogYXJnc1xuICAgICAgICBjb25zdCB1c2VyTWVzc2FnZSA9IGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICBjb250ZW50OiBwcmVwYXJlVXNlckNvbnRlbnQoe1xuICAgICAgICAgICAgaW5wdXRTdHJpbmc6IGZvcm1hdENvbW1hbmRJbnB1dChjb21tYW5kLCBkaXNwbGF5QXJncyksXG4gICAgICAgICAgICBwcmVjZWRpbmdJbnB1dEJsb2NrcyxcbiAgICAgICAgICB9KSxcbiAgICAgICAgfSlcblxuICAgICAgICB0cnkge1xuICAgICAgICAgIGNvbnN0IHN5bnRoZXRpY0NhdmVhdE1lc3NhZ2UgPSBjcmVhdGVTeW50aGV0aWNVc2VyQ2F2ZWF0TWVzc2FnZSgpXG4gICAgICAgICAgY29uc3QgbW9kID0gYXdhaXQgY29tbWFuZC5sb2FkKClcbiAgICAgICAgICBjb25zdCByZXN1bHQgPSBhd2FpdCBtb2QuY2FsbChhcmdzLCBjb250ZXh0KVxuXG4gICAgICAgICAgaWYgKHJlc3VsdC50eXBlID09PSAnc2tpcCcpIHtcbiAgICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICAgIG1lc3NhZ2VzOiBbXSxcbiAgICAgICAgICAgICAgc2hvdWxkUXVlcnk6IGZhbHNlLFxuICAgICAgICAgICAgICBjb21tYW5kLFxuICAgICAgICAgICAgfVxuICAgICAgICAgIH1cblxuICAgICAgICAgIC8vIFVzZSBkaXNjcmltaW5hdGVkIHVuaW9uIHRvIGhhbmRsZSBkaWZmZXJlbnQgcmVzdWx0IHR5cGVzXG4gICAgICAgICAgaWYgKHJlc3VsdC50eXBlID09PSAnY29tcGFjdCcpIHtcbiAgICAgICAgICAgIC8vIEFwcGVuZCBzbGFzaCBjb21tYW5kIG1lc3NhZ2VzIHRvIG1lc3NhZ2VzVG9LZWVwIHNvIHRoYXRcbiAgICAgICAgICAgIC8vIGF0dGFjaG1lbnRzIGFuZCBob29rUmVzdWx0cyBjb21lIGFmdGVyIHVzZXIgbWVzc2FnZXNcbiAgICAgICAgICAgIGNvbnN0IHNsYXNoQ29tbWFuZE1lc3NhZ2VzID0gW1xuICAgICAgICAgICAgICBzeW50aGV0aWNDYXZlYXRNZXNzYWdlLFxuICAgICAgICAgICAgICB1c2VyTWVzc2FnZSxcbiAgICAgICAgICAgICAgLi4uKHJlc3VsdC5kaXNwbGF5VGV4dFxuICAgICAgICAgICAgICAgID8gW1xuICAgICAgICAgICAgICAgICAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICAgICAgICAgICAgICAgICAgY29udGVudDogYDxsb2NhbC1jb21tYW5kLXN0ZG91dD4ke3Jlc3VsdC5kaXNwbGF5VGV4dH08L2xvY2FsLWNvbW1hbmQtc3Rkb3V0PmAsXG4gICAgICAgICAgICAgICAgICAgICAgLy8gLS1yZXN1bWUgbG9va3MgYXQgbGF0ZXN0IHRpbWVzdGFtcCBtZXNzYWdlIHRvIGRldGVybWluZSB3aGljaCBtZXNzYWdlIHRvIHJlc3VtZSBmcm9tXG4gICAgICAgICAgICAgICAgICAgICAgLy8gVGhpcyBpcyBhIHBlcmYgb3B0aW1pemF0aW9uIHRvIGF2b2lkIGhhdmluZyB0byByZWNhY3VsY2F0ZSB0aGUgbGVhZiBub2RlIGV2ZXJ5IHRpbWVcbiAgICAgICAgICAgICAgICAgICAgICAvLyBTaW5jZSB3ZSdyZSBjcmVhdGluZyBhIGJ1bmNoIG9mIHN5bnRoZXRpYyBtZXNzYWdlcyBmb3IgY29tcGFjdCwgaXQncyBpbXBvcnRhbnQgdG8gc2V0XG4gICAgICAgICAgICAgICAgICAgICAgLy8gdGhlIHRpbWVzdGFtcCBvZiB0aGUgbGFzdCBtZXNzYWdlIHRvIGJlIHNsaWdodGx5IGFmdGVyIHRoZSBjdXJyZW50IHRpbWVcbiAgICAgICAgICAgICAgICAgICAgICAvLyBUaGlzIGlzIG1vc3RseSBpbXBvcnRhbnQgZm9yIHNkayAvIC1wIG1vZGVcbiAgICAgICAgICAgICAgICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKERhdGUubm93KCkgKyAxMDApLnRvSVNPU3RyaW5nKCksXG4gICAgICAgICAgICAgICAgICAgIH0pLFxuICAgICAgICAgICAgICAgICAgXVxuICAgICAgICAgICAgICAgIDogW10pLFxuICAgICAgICAgICAgXVxuICAgICAgICAgICAgY29uc3QgY29tcGFjdGlvblJlc3VsdFdpdGhTbGFzaE1lc3NhZ2VzID0ge1xuICAgICAgICAgICAgICAuLi5yZXN1bHQuY29tcGFjdGlvblJlc3VsdCxcbiAgICAgICAgICAgICAgbWVzc2FnZXNUb0tlZXA6IFtcbiAgICAgICAgICAgICAgICAuLi4ocmVzdWx0LmNvbXBhY3Rpb25SZXN1bHQubWVzc2FnZXNUb0tlZXAgPz8gW10pLFxuICAgICAgICAgICAgICAgIC4uLnNsYXNoQ29tbWFuZE1lc3NhZ2VzLFxuICAgICAgICAgICAgICBdLFxuICAgICAgICAgICAgfVxuICAgICAgICAgICAgLy8gUmVzZXQgbWljcm9jb21wYWN0IHN0YXRlIHNpbmNlIGZ1bGwgY29tcGFjdCByZXBsYWNlcyBhbGxcbiAgICAgICAgICAgIC8vIG1lc3NhZ2VzIOKAlCBvbGQgdG9vbCBJRHMgYXJlIG5vIGxvbmdlciByZWxldmFudC4gQnVkZ2V0IHN0YXRlXG4gICAgICAgICAgICAvLyAob24gdG9vbFVzZUNvbnRleHQpIG5lZWRzIG5vIHJlc2V0OiBzdGFsZSBlbnRyaWVzIGFyZSBpbmVydFxuICAgICAgICAgICAgLy8gKFVVSURzIG5ldmVyIHJlcGVhdCwgc28gdGhleSdyZSBuZXZlciBsb29rZWQgdXApLlxuICAgICAgICAgICAgcmVzZXRNaWNyb2NvbXBhY3RTdGF0ZSgpXG4gICAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgICBtZXNzYWdlczogYnVpbGRQb3N0Q29tcGFjdE1lc3NhZ2VzKFxuICAgICAgICAgICAgICAgIGNvbXBhY3Rpb25SZXN1bHRXaXRoU2xhc2hNZXNzYWdlcyxcbiAgICAgICAgICAgICAgKSxcbiAgICAgICAgICAgICAgc2hvdWxkUXVlcnk6IGZhbHNlLFxuICAgICAgICAgICAgICBjb21tYW5kLFxuICAgICAgICAgICAgfVxuICAgICAgICAgIH1cblxuICAgICAgICAgIC8vIFRleHQgcmVzdWx0IOKAlCB1c2Ugc3lzdGVtIG1lc3NhZ2Ugc28gaXQgZG9lc24ndCByZW5kZXIgYXMgYSB1c2VyIGJ1YmJsZVxuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBtZXNzYWdlczogW1xuICAgICAgICAgICAgICB1c2VyTWVzc2FnZSxcbiAgICAgICAgICAgICAgY3JlYXRlQ29tbWFuZElucHV0TWVzc2FnZShcbiAgICAgICAgICAgICAgICBgPGxvY2FsLWNvbW1hbmQtc3Rkb3V0PiR7cmVzdWx0LnZhbHVlfTwvbG9jYWwtY29tbWFuZC1zdGRvdXQ+YCxcbiAgICAgICAgICAgICAgKSxcbiAgICAgICAgICAgIF0sXG4gICAgICAgICAgICBzaG91bGRRdWVyeTogZmFsc2UsXG4gICAgICAgICAgICBjb21tYW5kLFxuICAgICAgICAgICAgcmVzdWx0VGV4dDogcmVzdWx0LnZhbHVlLFxuICAgICAgICAgIH1cbiAgICAgICAgfSBjYXRjaCAoZSkge1xuICAgICAgICAgIGxvZ0Vycm9yKGUpXG4gICAgICAgICAgcmV0dXJuIHtcbiAgICAgICAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgICAgICAgIHVzZXJNZXNzYWdlLFxuICAgICAgICAgICAgICBjcmVhdGVDb21tYW5kSW5wdXRNZXNzYWdlKFxuICAgICAgICAgICAgICAgIGA8bG9jYWwtY29tbWFuZC1zdGRlcnI+JHtTdHJpbmcoZSl9PC9sb2NhbC1jb21tYW5kLXN0ZGVycj5gLFxuICAgICAgICAgICAgICApLFxuICAgICAgICAgICAgXSxcbiAgICAgICAgICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcbiAgICAgICAgICAgIGNvbW1hbmQsXG4gICAgICAgICAgfVxuICAgICAgICB9XG4gICAgICB9XG4gICAgICBjYXNlICdwcm9tcHQnOiB7XG4gICAgICAgIHRyeSB7XG4gICAgICAgICAgLy8gQ2hlY2sgaWYgY29tbWFuZCBzaG91bGQgcnVuIGFzIGZvcmtlZCBzdWItYWdlbnRcbiAgICAgICAgICBpZiAoY29tbWFuZC5jb250ZXh0ID09PSAnZm9yaycpIHtcbiAgICAgICAgICAgIHJldHVybiBhd2FpdCBleGVjdXRlRm9ya2VkU2xhc2hDb21tYW5kKFxuICAgICAgICAgICAgICBjb21tYW5kLFxuICAgICAgICAgICAgICBhcmdzLFxuICAgICAgICAgICAgICBjb250ZXh0LFxuICAgICAgICAgICAgICBwcmVjZWRpbmdJbnB1dEJsb2NrcyxcbiAgICAgICAgICAgICAgc2V0VG9vbEpTWCxcbiAgICAgICAgICAgICAgY2FuVXNlVG9vbCA/PyBoYXNQZXJtaXNzaW9uc1RvVXNlVG9vbCxcbiAgICAgICAgICAgIClcbiAgICAgICAgICB9XG5cbiAgICAgICAgICByZXR1cm4gYXdhaXQgZ2V0TWVzc2FnZXNGb3JQcm9tcHRTbGFzaENvbW1hbmQoXG4gICAgICAgICAgICBjb21tYW5kLFxuICAgICAgICAgICAgYXJncyxcbiAgICAgICAgICAgIGNvbnRleHQsXG4gICAgICAgICAgICBwcmVjZWRpbmdJbnB1dEJsb2NrcyxcbiAgICAgICAgICAgIGltYWdlQ29udGVudEJsb2NrcyxcbiAgICAgICAgICAgIHV1aWQsXG4gICAgICAgICAgKVxuICAgICAgICB9IGNhdGNoIChlKSB7XG4gICAgICAgICAgLy8gSGFuZGxlIGFib3J0IGVycm9ycyBzcGVjaWFsbHkgdG8gc2hvdyBwcm9wZXIgXCJJbnRlcnJ1cHRlZFwiIG1lc3NhZ2VcbiAgICAgICAgICBpZiAoZSBpbnN0YW5jZW9mIEFib3J0RXJyb3IpIHtcbiAgICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgICAgICAgICAgY3JlYXRlVXNlck1lc3NhZ2Uoe1xuICAgICAgICAgICAgICAgICAgY29udGVudDogcHJlcGFyZVVzZXJDb250ZW50KHtcbiAgICAgICAgICAgICAgICAgICAgaW5wdXRTdHJpbmc6IGZvcm1hdENvbW1hbmRJbnB1dChjb21tYW5kLCBhcmdzKSxcbiAgICAgICAgICAgICAgICAgICAgcHJlY2VkaW5nSW5wdXRCbG9ja3MsXG4gICAgICAgICAgICAgICAgICB9KSxcbiAgICAgICAgICAgICAgICB9KSxcbiAgICAgICAgICAgICAgICBjcmVhdGVVc2VySW50ZXJydXB0aW9uTWVzc2FnZSh7IHRvb2xVc2U6IGZhbHNlIH0pLFxuICAgICAgICAgICAgICBdLFxuICAgICAgICAgICAgICBzaG91bGRRdWVyeTogZmFsc2UsXG4gICAgICAgICAgICAgIGNvbW1hbmQsXG4gICAgICAgICAgICB9XG4gICAgICAgICAgfVxuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBtZXNzYWdlczogW1xuICAgICAgICAgICAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICAgICAgICAgICAgY29udGVudDogcHJlcGFyZVVzZXJDb250ZW50KHtcbiAgICAgICAgICAgICAgICAgIGlucHV0U3RyaW5nOiBmb3JtYXRDb21tYW5kSW5wdXQoY29tbWFuZCwgYXJncyksXG4gICAgICAgICAgICAgICAgICBwcmVjZWRpbmdJbnB1dEJsb2NrcyxcbiAgICAgICAgICAgICAgICB9KSxcbiAgICAgICAgICAgICAgfSksXG4gICAgICAgICAgICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICAgICAgICBjb250ZW50OiBgPGxvY2FsLWNvbW1hbmQtc3RkZXJyPiR7U3RyaW5nKGUpfTwvbG9jYWwtY29tbWFuZC1zdGRlcnI+YCxcbiAgICAgICAgICAgICAgfSksXG4gICAgICAgICAgICBdLFxuICAgICAgICAgICAgc2hvdWxkUXVlcnk6IGZhbHNlLFxuICAgICAgICAgICAgY29tbWFuZCxcbiAgICAgICAgICB9XG4gICAgICAgIH1cbiAgICAgIH1cbiAgICB9XG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBpZiAoZSBpbnN0YW5jZW9mIE1hbGZvcm1lZENvbW1hbmRFcnJvcikge1xuICAgICAgcmV0dXJuIHtcbiAgICAgICAgbWVzc2FnZXM6IFtcbiAgICAgICAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICAgICAgICBjb250ZW50OiBwcmVwYXJlVXNlckNvbnRlbnQoe1xuICAgICAgICAgICAgICBpbnB1dFN0cmluZzogZS5tZXNzYWdlLFxuICAgICAgICAgICAgICBwcmVjZWRpbmdJbnB1dEJsb2NrcyxcbiAgICAgICAgICAgIH0pLFxuICAgICAgICAgIH0pLFxuICAgICAgICBdLFxuICAgICAgICBzaG91bGRRdWVyeTogZmFsc2UsXG4gICAgICAgIGNvbW1hbmQsXG4gICAgICB9XG4gICAgfVxuICAgIHRocm93IGVcbiAgfVxufVxuXG5mdW5jdGlvbiBmb3JtYXRDb21tYW5kSW5wdXQoY29tbWFuZDogQ29tbWFuZEJhc2UsIGFyZ3M6IHN0cmluZyk6IHN0cmluZyB7XG4gIHJldHVybiBmb3JtYXRDb21tYW5kSW5wdXRUYWdzKGdldENvbW1hbmROYW1lKGNvbW1hbmQpLCBhcmdzKVxufVxuXG4vKipcbiAqIEZvcm1hdHMgdGhlIG1ldGFkYXRhIGZvciBhIHNraWxsIGxvYWRpbmcgbWVzc2FnZS5cbiAqIFVzZWQgYnkgdGhlIFNraWxsIHRvb2wgYW5kIGZvciBzdWJhZ2VudCBza2lsbCBwcmVsb2FkaW5nLlxuICovXG5leHBvcnQgZnVuY3Rpb24gZm9ybWF0U2tpbGxMb2FkaW5nTWV0YWRhdGEoXG4gIHNraWxsTmFtZTogc3RyaW5nLFxuICBfcHJvZ3Jlc3NNZXNzYWdlOiBzdHJpbmcgPSAnbG9hZGluZycsXG4pOiBzdHJpbmcge1xuICAvLyBVc2Ugc2tpbGwgbmFtZSBvbmx5IC0gVXNlckNvbW1hbmRNZXNzYWdlIHJlbmRlcnMgYXMgXCJTa2lsbChuYW1lKVwiXG4gIHJldHVybiBbXG4gICAgYDwke0NPTU1BTkRfTUVTU0FHRV9UQUd9PiR7c2tpbGxOYW1lfTwvJHtDT01NQU5EX01FU1NBR0VfVEFHfT5gLFxuICAgIGA8JHtDT01NQU5EX05BTUVfVEFHfT4ke3NraWxsTmFtZX08LyR7Q09NTUFORF9OQU1FX1RBR30+YCxcbiAgICBgPHNraWxsLWZvcm1hdD50cnVlPC9za2lsbC1mb3JtYXQ+YCxcbiAgXS5qb2luKCdcXG4nKVxufVxuXG4vKipcbiAqIEZvcm1hdHMgdGhlIG1ldGFkYXRhIGZvciBhIHNsYXNoIGNvbW1hbmQgbG9hZGluZyBtZXNzYWdlLlxuICovXG5mdW5jdGlvbiBmb3JtYXRTbGFzaENvbW1hbmRMb2FkaW5nTWV0YWRhdGEoXG4gIGNvbW1hbmROYW1lOiBzdHJpbmcsXG4gIGFyZ3M/OiBzdHJpbmcsXG4pOiBzdHJpbmcge1xuICByZXR1cm4gW1xuICAgIGA8JHtDT01NQU5EX01FU1NBR0VfVEFHfT4ke2NvbW1hbmROYW1lfTwvJHtDT01NQU5EX01FU1NBR0VfVEFHfT5gLFxuICAgIGA8JHtDT01NQU5EX05BTUVfVEFHfT4vJHtjb21tYW5kTmFtZX08LyR7Q09NTUFORF9OQU1FX1RBR30+YCxcbiAgICBhcmdzID8gYDxjb21tYW5kLWFyZ3M+JHthcmdzfTwvY29tbWFuZC1hcmdzPmAgOiBudWxsLFxuICBdXG4gICAgLmZpbHRlcihCb29sZWFuKVxuICAgIC5qb2luKCdcXG4nKVxufVxuXG4vKipcbiAqIEZvcm1hdHMgdGhlIGxvYWRpbmcgbWV0YWRhdGEgZm9yIGEgY29tbWFuZCAoc2tpbGwgb3Igc2xhc2ggY29tbWFuZCkuXG4gKiBVc2VyLWludm9jYWJsZSBza2lsbHMgdXNlIHNsYXNoIGNvbW1hbmQgZm9ybWF0ICgvbmFtZSksIHdoaWxlIG1vZGVsLW9ubHlcbiAqIHNraWxscyB1c2UgdGhlIHNraWxsIGZvcm1hdCAoXCJUaGUgWCBza2lsbCBpcyBydW5uaW5nXCIpLlxuICovXG5mdW5jdGlvbiBmb3JtYXRDb21tYW5kTG9hZGluZ01ldGFkYXRhKFxuICBjb21tYW5kOiBDb21tYW5kQmFzZSAmIFByb21wdENvbW1hbmQsXG4gIGFyZ3M/OiBzdHJpbmcsXG4pOiBzdHJpbmcge1xuICAvLyBVc2UgY29tbWFuZC5uYW1lICh0aGUgcXVhbGlmaWVkIG5hbWUgaW5jbHVkaW5nIHBsdWdpbiBwcmVmaXgsIGUuZy5cbiAgLy8gXCJwcm9kdWN0LW1hbmFnZW1lbnQ6ZmVhdHVyZS1zcGVjXCIpIGluc3RlYWQgb2YgdXNlckZhY2luZ05hbWUoKSB3aGljaCBtYXlcbiAgLy8gc3RyaXAgdGhlIHBsdWdpbiBwcmVmaXggdmlhIGRpc3BsYXlOYW1lIGZhbGxiYWNrLlxuICAvLyBVc2VyLWludm9jYWJsZSBza2lsbHMgc2hvdWxkIHNob3cgYXMgL2NvbW1hbmQtbmFtZSBsaWtlIHJlZ3VsYXIgc2xhc2ggY29tbWFuZHNcbiAgaWYgKGNvbW1hbmQudXNlckludm9jYWJsZSAhPT0gZmFsc2UpIHtcbiAgICByZXR1cm4gZm9ybWF0U2xhc2hDb21tYW5kTG9hZGluZ01ldGFkYXRhKGNvbW1hbmQubmFtZSwgYXJncylcbiAgfVxuICAvLyBNb2RlbC1vbmx5IHNraWxscyAodXNlckludm9jYWJsZTogZmFsc2UpIHNob3cgYXMgXCJUaGUgWCBza2lsbCBpcyBydW5uaW5nXCJcbiAgaWYgKFxuICAgIGNvbW1hbmQubG9hZGVkRnJvbSA9PT0gJ3NraWxscycgfHxcbiAgICBjb21tYW5kLmxvYWRlZEZyb20gPT09ICdwbHVnaW4nIHx8XG4gICAgY29tbWFuZC5sb2FkZWRGcm9tID09PSAnbWNwJ1xuICApIHtcbiAgICByZXR1cm4gZm9ybWF0U2tpbGxMb2FkaW5nTWV0YWRhdGEoY29tbWFuZC5uYW1lLCBjb21tYW5kLnByb2dyZXNzTWVzc2FnZSlcbiAgfVxuICByZXR1cm4gZm9ybWF0U2xhc2hDb21tYW5kTG9hZGluZ01ldGFkYXRhKGNvbW1hbmQubmFtZSwgYXJncylcbn1cblxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHByb2Nlc3NQcm9tcHRTbGFzaENvbW1hbmQoXG4gIGNvbW1hbmROYW1lOiBzdHJpbmcsXG4gIGFyZ3M6IHN0cmluZyxcbiAgY29tbWFuZHM6IENvbW1hbmRbXSxcbiAgY29udGV4dDogVG9vbFVzZUNvbnRleHQsXG4gIGltYWdlQ29udGVudEJsb2NrczogQ29udGVudEJsb2NrUGFyYW1bXSA9IFtdLFxuKTogUHJvbWlzZTxTbGFzaENvbW1hbmRSZXN1bHQ+IHtcbiAgY29uc3QgY29tbWFuZCA9IGZpbmRDb21tYW5kKGNvbW1hbmROYW1lLCBjb21tYW5kcylcbiAgaWYgKCFjb21tYW5kKSB7XG4gICAgdGhyb3cgbmV3IE1hbGZvcm1lZENvbW1hbmRFcnJvcihgVW5rbm93biBjb21tYW5kOiAke2NvbW1hbmROYW1lfWApXG4gIH1cbiAgaWYgKGNvbW1hbmQudHlwZSAhPT0gJ3Byb21wdCcpIHtcbiAgICB0aHJvdyBuZXcgRXJyb3IoXG4gICAgICBgVW5leHBlY3RlZCAke2NvbW1hbmQudHlwZX0gY29tbWFuZC4gRXhwZWN0ZWQgJ3Byb21wdCcgY29tbWFuZC4gVXNlIC8ke2NvbW1hbmROYW1lfSBkaXJlY3RseSBpbiB0aGUgbWFpbiBjb252ZXJzYXRpb24uYCxcbiAgICApXG4gIH1cbiAgcmV0dXJuIGdldE1lc3NhZ2VzRm9yUHJvbXB0U2xhc2hDb21tYW5kKFxuICAgIGNvbW1hbmQsXG4gICAgYXJncyxcbiAgICBjb250ZXh0LFxuICAgIFtdLFxuICAgIGltYWdlQ29udGVudEJsb2NrcyxcbiAgKVxufVxuXG5hc3luYyBmdW5jdGlvbiBnZXRNZXNzYWdlc0ZvclByb21wdFNsYXNoQ29tbWFuZChcbiAgY29tbWFuZDogQ29tbWFuZEJhc2UgJiBQcm9tcHRDb21tYW5kLFxuICBhcmdzOiBzdHJpbmcsXG4gIGNvbnRleHQ6IFRvb2xVc2VDb250ZXh0LFxuICBwcmVjZWRpbmdJbnB1dEJsb2NrczogQ29udGVudEJsb2NrUGFyYW1bXSA9IFtdLFxuICBpbWFnZUNvbnRlbnRCbG9ja3M6IENvbnRlbnRCbG9ja1BhcmFtW10gPSBbXSxcbiAgdXVpZD86IHN0cmluZyxcbik6IFByb21pc2U8U2xhc2hDb21tYW5kUmVzdWx0PiB7XG4gIC8vIEluIGNvb3JkaW5hdG9yIG1vZGUgKG1haW4gdGhyZWFkIG9ubHkpLCBza2lwIGxvYWRpbmcgdGhlIGZ1bGwgc2tpbGwgY29udGVudFxuICAvLyBhbmQgcGVybWlzc2lvbnMuIFRoZSBjb29yZGluYXRvciBvbmx5IGhhcyBBZ2VudCArIFRhc2tTdG9wIHRvb2xzLCBzbyB0aGVcbiAgLy8gc2tpbGwgY29udGVudCBhbmQgYWxsb3dlZFRvb2xzIGFyZSB1c2VsZXNzLiBJbnN0ZWFkLCBzZW5kIGEgYnJpZWYgc3VtbWFyeVxuICAvLyB0ZWxsaW5nIHRoZSBjb29yZGluYXRvciBob3cgdG8gZGVsZWdhdGUgdGhpcyBza2lsbCB0byBhIHdvcmtlci5cbiAgLy9cbiAgLy8gV29ya2VycyBydW4gaW4tcHJvY2VzcyBhbmQgaW5oZXJpdCBDTEFVREVfQ09ERV9DT09SRElOQVRPUl9NT0RFIGZyb20gdGhlXG4gIC8vIHBhcmVudCBlbnYsIHNvIHdlIGFsc28gY2hlY2sgIWNvbnRleHQuYWdlbnRJZDogYWdlbnRJZCBpcyBvbmx5IHNldCBmb3JcbiAgLy8gc3ViYWdlbnRzLCBsZXR0aW5nIHdvcmtlcnMgZmFsbCB0aHJvdWdoIHRvIGdldFByb21wdEZvckNvbW1hbmQgYW5kIHJlY2VpdmVcbiAgLy8gdGhlIHJlYWwgc2tpbGwgY29udGVudCB3aGVuIHRoZXkgaW52b2tlIHRoZSBTa2lsbCB0b29sLlxuICBpZiAoXG4gICAgZmVhdHVyZSgnQ09PUkRJTkFUT1JfTU9ERScpICYmXG4gICAgaXNFbnZUcnV0aHkocHJvY2Vzcy5lbnYuQ0xBVURFX0NPREVfQ09PUkRJTkFUT1JfTU9ERSkgJiZcbiAgICAhY29udGV4dC5hZ2VudElkXG4gICkge1xuICAgIGNvbnN0IG1ldGFkYXRhID0gZm9ybWF0Q29tbWFuZExvYWRpbmdNZXRhZGF0YShjb21tYW5kLCBhcmdzKVxuICAgIGNvbnN0IHBhcnRzOiBzdHJpbmdbXSA9IFtcbiAgICAgIGBTa2lsbCBcIi8ke2NvbW1hbmQubmFtZX1cIiBpcyBhdmFpbGFibGUgZm9yIHdvcmtlcnMuYCxcbiAgICBdXG4gICAgaWYgKGNvbW1hbmQuZGVzY3JpcHRpb24pIHtcbiAgICAgIHBhcnRzLnB1c2goYERlc2NyaXB0aW9uOiAke2NvbW1hbmQuZGVzY3JpcHRpb259YClcbiAgICB9XG4gICAgaWYgKGNvbW1hbmQud2hlblRvVXNlKSB7XG4gICAgICBwYXJ0cy5wdXNoKGBXaGVuIHRvIHVzZTogJHtjb21tYW5kLndoZW5Ub1VzZX1gKVxuICAgIH1cbiAgICBjb25zdCBza2lsbEFsbG93ZWRUb29scyA9IGNvbW1hbmQuYWxsb3dlZFRvb2xzID8/IFtdXG4gICAgaWYgKHNraWxsQWxsb3dlZFRvb2xzLmxlbmd0aCA+IDApIHtcbiAgICAgIHBhcnRzLnB1c2goXG4gICAgICAgIGBUaGlzIHNraWxsIGdyYW50cyB3b3JrZXJzIGFkZGl0aW9uYWwgdG9vbCBwZXJtaXNzaW9uczogJHtza2lsbEFsbG93ZWRUb29scy5qb2luKCcsICcpfWAsXG4gICAgICApXG4gICAgfVxuICAgIHBhcnRzLnB1c2goXG4gICAgICBgXFxuSW5zdHJ1Y3QgYSB3b3JrZXIgdG8gdXNlIHRoaXMgc2tpbGwgYnkgaW5jbHVkaW5nIFwiVXNlIHRoZSAvJHtjb21tYW5kLm5hbWV9IHNraWxsXCIgaW4geW91ciBBZ2VudCBwcm9tcHQuIFRoZSB3b3JrZXIgaGFzIGFjY2VzcyB0byB0aGUgU2tpbGwgdG9vbCBhbmQgd2lsbCByZWNlaXZlIHRoZSBza2lsbCdzIGNvbnRlbnQgYW5kIHBlcm1pc3Npb25zIHdoZW4gaXQgaW52b2tlcyBpdC5gLFxuICAgIClcbiAgICBjb25zdCBzdW1tYXJ5Q29udGVudDogQ29udGVudEJsb2NrUGFyYW1bXSA9IFtcbiAgICAgIHsgdHlwZTogJ3RleHQnLCB0ZXh0OiBwYXJ0cy5qb2luKCdcXG4nKSB9LFxuICAgIF1cbiAgICByZXR1cm4ge1xuICAgICAgbWVzc2FnZXM6IFtcbiAgICAgICAgY3JlYXRlVXNlck1lc3NhZ2UoeyBjb250ZW50OiBtZXRhZGF0YSwgdXVpZCB9KSxcbiAgICAgICAgY3JlYXRlVXNlck1lc3NhZ2UoeyBjb250ZW50OiBzdW1tYXJ5Q29udGVudCwgaXNNZXRhOiB0cnVlIH0pLFxuICAgICAgXSxcbiAgICAgIHNob3VsZFF1ZXJ5OiB0cnVlLFxuICAgICAgbW9kZWw6IGNvbW1hbmQubW9kZWwsXG4gICAgICBlZmZvcnQ6IGNvbW1hbmQuZWZmb3J0LFxuICAgICAgY29tbWFuZCxcbiAgICB9XG4gIH1cblxuICBjb25zdCByZXN1bHQgPSBhd2FpdCBjb21tYW5kLmdldFByb21wdEZvckNvbW1hbmQoYXJncywgY29udGV4dClcblxuICAvLyBSZWdpc3RlciBza2lsbCBob29rcyBpZiBkZWZpbmVkLiBVbmRlciBbXCJob29rc1wiXS1vbmx5IChza2lsbHMgbm90IGxvY2tlZCksXG4gIC8vIHVzZXIgc2tpbGxzIHN0aWxsIGxvYWQgYW5kIHJlYWNoIHRoaXMgcG9pbnQg4oCUIGJsb2NrIGhvb2sgUkVHSVNUUkFUSU9OIGhlcmVcbiAgLy8gd2hlcmUgc291cmNlIGlzIGtub3duLiBNaXJyb3JzIHRoZSBhZ2VudCBmcm9udG1hdHRlciBnYXRlIGluIHJ1bkFnZW50LnRzLlxuICBjb25zdCBob29rc0FsbG93ZWRGb3JUaGlzU2tpbGwgPVxuICAgICFpc1Jlc3RyaWN0ZWRUb1BsdWdpbk9ubHkoJ2hvb2tzJykgfHwgaXNTb3VyY2VBZG1pblRydXN0ZWQoY29tbWFuZC5zb3VyY2UpXG4gIGlmIChjb21tYW5kLmhvb2tzICYmIGhvb2tzQWxsb3dlZEZvclRoaXNTa2lsbCkge1xuICAgIGNvbnN0IHNlc3Npb25JZCA9IGdldFNlc3Npb25JZCgpXG4gICAgcmVnaXN0ZXJTa2lsbEhvb2tzKFxuICAgICAgY29udGV4dC5zZXRBcHBTdGF0ZSxcbiAgICAgIHNlc3Npb25JZCxcbiAgICAgIGNvbW1hbmQuaG9va3MsXG4gICAgICBjb21tYW5kLm5hbWUsXG4gICAgICBjb21tYW5kLnR5cGUgPT09ICdwcm9tcHQnID8gY29tbWFuZC5za2lsbFJvb3QgOiB1bmRlZmluZWQsXG4gICAgKVxuICB9XG5cbiAgLy8gUmVjb3JkIHNraWxsIGludm9jYXRpb24gZm9yIGNvbXBhY3Rpb24gcHJlc2VydmF0aW9uLCBzY29wZWQgYnkgYWdlbnQgY29udGV4dC5cbiAgLy8gU2tpbGxzIGFyZSB0YWdnZWQgd2l0aCB0aGVpciBhZ2VudElkIHNvIG9ubHkgc2tpbGxzIGJlbG9uZ2luZyB0byB0aGUgY3VycmVudFxuICAvLyBhZ2VudCBhcmUgcmVzdG9yZWQgZHVyaW5nIGNvbXBhY3Rpb24gKHByZXZlbnRpbmcgY3Jvc3MtYWdlbnQgbGVha3MpLlxuICBjb25zdCBza2lsbFBhdGggPSBjb21tYW5kLnNvdXJjZVxuICAgID8gYCR7Y29tbWFuZC5zb3VyY2V9OiR7Y29tbWFuZC5uYW1lfWBcbiAgICA6IGNvbW1hbmQubmFtZVxuICBjb25zdCBza2lsbENvbnRlbnQgPSByZXN1bHRcbiAgICAuZmlsdGVyKChiKTogYiBpcyBUZXh0QmxvY2tQYXJhbSA9PiBiLnR5cGUgPT09ICd0ZXh0JylcbiAgICAubWFwKGIgPT4gYi50ZXh0KVxuICAgIC5qb2luKCdcXG5cXG4nKVxuICBhZGRJbnZva2VkU2tpbGwoXG4gICAgY29tbWFuZC5uYW1lLFxuICAgIHNraWxsUGF0aCxcbiAgICBza2lsbENvbnRlbnQsXG4gICAgZ2V0QWdlbnRDb250ZXh0KCk/LmFnZW50SWQgPz8gbnVsbCxcbiAgKVxuXG4gIGNvbnN0IG1ldGFkYXRhID0gZm9ybWF0Q29tbWFuZExvYWRpbmdNZXRhZGF0YShjb21tYW5kLCBhcmdzKVxuXG4gIGNvbnN0IGFkZGl0aW9uYWxBbGxvd2VkVG9vbHMgPSBwYXJzZVRvb2xMaXN0RnJvbUNMSShcbiAgICBjb21tYW5kLmFsbG93ZWRUb29scyA/PyBbXSxcbiAgKVxuXG4gIC8vIENyZWF0ZSBjb250ZW50IGZvciB0aGUgbWFpbiBtZXNzYWdlLCBpbmNsdWRpbmcgYW55IHBhc3RlZCBpbWFnZXNcbiAgY29uc3QgbWFpbk1lc3NhZ2VDb250ZW50OiBDb250ZW50QmxvY2tQYXJhbVtdID1cbiAgICBpbWFnZUNvbnRlbnRCbG9ja3MubGVuZ3RoID4gMCB8fCBwcmVjZWRpbmdJbnB1dEJsb2Nrcy5sZW5ndGggPiAwXG4gICAgICA/IFsuLi5pbWFnZUNvbnRlbnRCbG9ja3MsIC4uLnByZWNlZGluZ0lucHV0QmxvY2tzLCAuLi5yZXN1bHRdXG4gICAgICA6IHJlc3VsdFxuXG4gIC8vIEV4dHJhY3QgYXR0YWNobWVudHMgZnJvbSBjb21tYW5kIGFyZ3VtZW50cyAoQC1tZW50aW9ucywgTUNQIHJlc291cmNlcyxcbiAgLy8gYWdlbnQgbWVudGlvbnMgaW4gU0tJTEwubWQpLiBza2lwU2tpbGxEaXNjb3ZlcnkgcHJldmVudHMgdGhlIFNLSUxMLm1kXG4gIC8vIGNvbnRlbnQgaXRzZWxmIGZyb20gdHJpZ2dlcmluZyBkaXNjb3Zlcnkg4oCUIGl0J3MgbWV0YS1jb250ZW50LCBub3QgdXNlclxuICAvLyBpbnRlbnQsIGFuZCBhIGxhcmdlIFNLSUxMLm1kIChlLmcuIDExMEtCKSB3b3VsZCBmaXJlIGNodW5rZWQgQUtJIHF1ZXJpZXNcbiAgLy8gYWRkaW5nIHNlY29uZHMgb2YgbGF0ZW5jeSB0byBldmVyeSBza2lsbCBpbnZvY2F0aW9uLlxuICBjb25zdCBhdHRhY2htZW50TWVzc2FnZXMgPSBhd2FpdCB0b0FycmF5KFxuICAgIGdldEF0dGFjaG1lbnRNZXNzYWdlcyhcbiAgICAgIHJlc3VsdFxuICAgICAgICAuZmlsdGVyKChibG9jayk6IGJsb2NrIGlzIFRleHRCbG9ja1BhcmFtID0+IGJsb2NrLnR5cGUgPT09ICd0ZXh0JylcbiAgICAgICAgLm1hcChibG9jayA9PiBibG9jay50ZXh0KVxuICAgICAgICAuam9pbignICcpLFxuICAgICAgY29udGV4dCxcbiAgICAgIG51bGwsXG4gICAgICBbXSwgLy8gcXVldWVkQ29tbWFuZHMgLSBoYW5kbGVkIGJ5IHF1ZXJ5LnRzIGZvciBtaWQtdHVybiBhdHRhY2htZW50c1xuICAgICAgY29udGV4dC5tZXNzYWdlcyxcbiAgICAgICdyZXBsX21haW5fdGhyZWFkJyxcbiAgICAgIHsgc2tpcFNraWxsRGlzY292ZXJ5OiB0cnVlIH0sXG4gICAgKSxcbiAgKVxuXG4gIGNvbnN0IG1lc3NhZ2VzID0gW1xuICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgIGNvbnRlbnQ6IG1ldGFkYXRhLFxuICAgICAgdXVpZCxcbiAgICB9KSxcbiAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICBjb250ZW50OiBtYWluTWVzc2FnZUNvbnRlbnQsXG4gICAgICBpc01ldGE6IHRydWUsXG4gICAgfSksXG4gICAgLi4uYXR0YWNobWVudE1lc3NhZ2VzLFxuICAgIGNyZWF0ZUF0dGFjaG1lbnRNZXNzYWdlKHtcbiAgICAgIHR5cGU6ICdjb21tYW5kX3Blcm1pc3Npb25zJyxcbiAgICAgIGFsbG93ZWRUb29sczogYWRkaXRpb25hbEFsbG93ZWRUb29scyxcbiAgICAgIG1vZGVsOiBjb21tYW5kLm1vZGVsLFxuICAgIH0pLFxuICBdXG5cbiAgcmV0dXJuIHtcbiAgICBtZXNzYWdlcyxcbiAgICBzaG91bGRRdWVyeTogdHJ1ZSxcbiAgICBhbGxvd2VkVG9vbHM6IGFkZGl0aW9uYWxBbGxvd2VkVG9vbHMsXG4gICAgbW9kZWw6IGNvbW1hbmQubW9kZWwsXG4gICAgZWZmb3J0OiBjb21tYW5kLmVmZm9ydCxcbiAgICBjb21tYW5kLFxuICB9XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLFNBQVNBLE9BQU8sUUFBUSxZQUFZO0FBQ3BDLGNBQ0VDLGlCQUFpQixFQUNqQkMsY0FBYyxRQUNULDZCQUE2QjtBQUNwQyxTQUFTQyxVQUFVLFFBQVEsUUFBUTtBQUNuQyxTQUFTQyxXQUFXLFFBQVEsd0JBQXdCO0FBQ3BELFNBQ0VDLG1CQUFtQixFQUNuQixLQUFLQyxPQUFPLEVBQ1osS0FBS0MsV0FBVyxFQUNoQkMsV0FBVyxFQUNYQyxVQUFVLEVBQ1ZDLGNBQWMsRUFDZEMsVUFBVSxFQUNWLEtBQUtDLGFBQWEsUUFDYixpQkFBaUI7QUFDeEIsU0FBU0Msa0JBQWtCLFFBQVEsMkJBQTJCO0FBQzlELGNBQWNDLFlBQVksRUFBRUMsY0FBYyxRQUFRLGFBQWE7QUFDL0QsY0FDRUMsZ0JBQWdCLEVBQ2hCQyxpQkFBaUIsRUFDakJDLE9BQU8sRUFDUEMscUJBQXFCLEVBQ3JCQyxlQUFlLEVBQ2ZDLFdBQVcsUUFDTixzQkFBc0I7QUFDN0IsU0FBU0MsZUFBZSxFQUFFQyxZQUFZLFFBQVEsMEJBQTBCO0FBQ3hFLFNBQVNDLG1CQUFtQixFQUFFQyxnQkFBZ0IsUUFBUSx3QkFBd0I7QUFDOUUsY0FBY0MsWUFBWSxRQUFRLDhCQUE4QjtBQUNoRSxTQUNFLEtBQUtDLDBEQUEwRCxFQUMvRCxLQUFLQywrQ0FBK0MsRUFDcERDLFFBQVEsUUFDSCxtQ0FBbUM7QUFDMUMsU0FBU0Msa0JBQWtCLFFBQVEsbUNBQW1DO0FBQ3RFLFNBQVNDLHdCQUF3QixRQUFRLG1DQUFtQztBQUM1RSxTQUFTQyxzQkFBc0IsUUFB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 \ No newline at end of file +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmZWF0dXJlIiwiQ29udGVudEJsb2NrUGFyYW0iLCJUZXh0QmxvY2tQYXJhbSIsInJhbmRvbVVVSUQiLCJzZXRQcm9tcHRJZCIsImJ1aWx0SW5Db21tYW5kTmFtZXMiLCJDb21tYW5kIiwiQ29tbWFuZEJhc2UiLCJmaW5kQ29tbWFuZCIsImdldENvbW1hbmQiLCJnZXRDb21tYW5kTmFtZSIsImhhc0NvbW1hbmQiLCJQcm9tcHRDb21tYW5kIiwiTk9fQ09OVEVOVF9NRVNTQUdFIiwiU2V0VG9vbEpTWEZuIiwiVG9vbFVzZUNvbnRleHQiLCJBc3Npc3RhbnRNZXNzYWdlIiwiQXR0YWNobWVudE1lc3NhZ2UiLCJNZXNzYWdlIiwiTm9ybWFsaXplZFVzZXJNZXNzYWdlIiwiUHJvZ3Jlc3NNZXNzYWdlIiwiVXNlck1lc3NhZ2UiLCJhZGRJbnZva2VkU2tpbGwiLCJnZXRTZXNzaW9uSWQiLCJDT01NQU5EX01FU1NBR0VfVEFHIiwiQ09NTUFORF9OQU1FX1RBRyIsIkNhblVzZVRvb2xGbiIsIkFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMiLCJBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfUElJX1RBR0dFRCIsImxvZ0V2ZW50IiwiZ2V0RHVtcFByb21wdHNQYXRoIiwiYnVpbGRQb3N0Q29tcGFjdE1lc3NhZ2VzIiwicmVzZXRNaWNyb2NvbXBhY3RTdGF0ZSIsIlByb2dyZXNzIiwiQWdlbnRQcm9ncmVzcyIsInJ1bkFnZW50IiwicmVuZGVyVG9vbFVzZVByb2dyZXNzTWVzc2FnZSIsIkNvbW1hbmRSZXN1bHREaXNwbGF5IiwiY3JlYXRlQWJvcnRDb250cm9sbGVyIiwiZ2V0QWdlbnRDb250ZXh0IiwiY3JlYXRlQXR0YWNobWVudE1lc3NhZ2UiLCJnZXRBdHRhY2htZW50TWVzc2FnZXMiLCJsb2dGb3JEZWJ1Z2dpbmciLCJpc0VudlRydXRoeSIsIkFib3J0RXJyb3IiLCJNYWxmb3JtZWRDb21tYW5kRXJyb3IiLCJnZXREaXNwbGF5UGF0aCIsImV4dHJhY3RSZXN1bHRUZXh0IiwicHJlcGFyZUZvcmtlZENvbW1hbmRDb250ZXh0IiwiZ2V0RnNJbXBsZW1lbnRhdGlvbiIsImlzRnVsbHNjcmVlbkVudkVuYWJsZWQiLCJ0b0FycmF5IiwicmVnaXN0ZXJTa2lsbEhvb2tzIiwibG9nRXJyb3IiLCJlbnF1ZXVlUGVuZGluZ05vdGlmaWNhdGlvbiIsImNyZWF0ZUNvbW1hbmRJbnB1dE1lc3NhZ2UiLCJjcmVhdGVTeW50aGV0aWNVc2VyQ2F2ZWF0TWVzc2FnZSIsImNyZWF0ZVN5c3RlbU1lc3NhZ2UiLCJjcmVhdGVVc2VySW50ZXJydXB0aW9uTWVzc2FnZSIsImNyZWF0ZVVzZXJNZXNzYWdlIiwiZm9ybWF0Q29tbWFuZElucHV0VGFncyIsImlzQ29tcGFjdEJvdW5kYXJ5TWVzc2FnZSIsImlzU3lzdGVtTG9jYWxDb21tYW5kTWVzc2FnZSIsIm5vcm1hbGl6ZU1lc3NhZ2VzIiwicHJlcGFyZVVzZXJDb250ZW50IiwiTW9kZWxBbGlhcyIsInBhcnNlVG9vbExpc3RGcm9tQ0xJIiwiaGFzUGVybWlzc2lvbnNUb1VzZVRvb2wiLCJpc09mZmljaWFsTWFya2V0cGxhY2VOYW1lIiwicGFyc2VQbHVnaW5JZGVudGlmaWVyIiwiaXNSZXN0cmljdGVkVG9QbHVnaW5Pbmx5IiwiaXNTb3VyY2VBZG1pblRydXN0ZWQiLCJwYXJzZVNsYXNoQ29tbWFuZCIsInNsZWVwIiwicmVjb3JkU2tpbGxVc2FnZSIsImxvZ09UZWxFdmVudCIsInJlZGFjdElmRGlzYWJsZWQiLCJidWlsZFBsdWdpbkNvbW1hbmRUZWxlbWV0cnlGaWVsZHMiLCJnZXRBc3Npc3RhbnRNZXNzYWdlQ29udGVudExlbmd0aCIsImNyZWF0ZUFnZW50SWQiLCJnZXRXb3JrbG9hZCIsIlByb2Nlc3NVc2VySW5wdXRCYXNlUmVzdWx0IiwiUHJvY2Vzc1VzZXJJbnB1dENvbnRleHQiLCJTbGFzaENvbW1hbmRSZXN1bHQiLCJjb21tYW5kIiwiTUNQX1NFVFRMRV9QT0xMX01TIiwiTUNQX1NFVFRMRV9USU1FT1VUX01TIiwiZXhlY3V0ZUZvcmtlZFNsYXNoQ29tbWFuZCIsImFyZ3MiLCJjb250ZXh0IiwicHJlY2VkaW5nSW5wdXRCbG9ja3MiLCJzZXRUb29sSlNYIiwiY2FuVXNlVG9vbCIsIlByb21pc2UiLCJhZ2VudElkIiwicGx1Z2luTWFya2V0cGxhY2UiLCJwbHVnaW5JbmZvIiwicmVwb3NpdG9yeSIsIm1hcmtldHBsYWNlIiwidW5kZWZpbmVkIiwiY29tbWFuZF9uYW1lIiwibmFtZSIsImludm9jYXRpb25fdHJpZ2dlciIsIl9QUk9UT19wbHVnaW5fbmFtZSIsInBsdWdpbk1hbmlmZXN0IiwiX1BST1RPX21hcmtldHBsYWNlX25hbWUiLCJza2lsbENvbnRlbnQiLCJtb2RpZmllZEdldEFwcFN0YXRlIiwiYmFzZUFnZW50IiwicHJvbXB0TWVzc2FnZXMiLCJhZ2VudERlZmluaXRpb24iLCJlZmZvcnQiLCJhZ2VudFR5cGUiLCJnZXRBcHBTdGF0ZSIsImthaXJvc0VuYWJsZWQiLCJiZ0Fib3J0Q29udHJvbGxlciIsImNvbW1hbmROYW1lIiwic3Bhd25UaW1lV29ya2xvYWQiLCJlbnF1ZXVlUmVzdWx0IiwidmFsdWUiLCJtb2RlIiwicHJpb3JpdHkiLCJpc01ldGEiLCJza2lwU2xhc2hDb21tYW5kcyIsIndvcmtsb2FkIiwiZGVhZGxpbmUiLCJEYXRlIiwibm93IiwicyIsIm1jcCIsImNsaWVudHMiLCJzb21lIiwiYyIsInR5cGUiLCJmcmVzaFRvb2xzIiwib3B0aW9ucyIsInJlZnJlc2hUb29scyIsInRvb2xzIiwiYWdlbnRNZXNzYWdlcyIsIm1lc3NhZ2UiLCJ0b29sVXNlQ29udGV4dCIsImFib3J0Q29udHJvbGxlciIsImlzQXN5bmMiLCJxdWVyeVNvdXJjZSIsIm1vZGVsIiwiYXZhaWxhYmxlVG9vbHMiLCJvdmVycmlkZSIsInB1c2giLCJyZXN1bHRUZXh0IiwiY2F0Y2giLCJlcnIiLCJFcnJvciIsIlN0cmluZyIsIm1lc3NhZ2VzIiwic2hvdWxkUXVlcnkiLCJwcm9ncmVzc01lc3NhZ2VzIiwicGFyZW50VG9vbFVzZUlEIiwidG9vbFVzZUNvdW50ZXIiLCJjcmVhdGVQcm9ncmVzc01lc3NhZ2UiLCJkYXRhIiwicHJvbXB0IiwidG9vbFVzZUlEIiwidGltZXN0YW1wIiwidG9JU09TdHJpbmciLCJ1dWlkIiwidXBkYXRlUHJvZ3Jlc3MiLCJqc3giLCJ2ZXJib3NlIiwic2hvdWxkSGlkZVByb21wdElucHV0Iiwic2hvdWxkQ29udGludWVBbmltYXRpb24iLCJzaG93U3Bpbm5lciIsIm5vcm1hbGl6ZWROZXciLCJjb250ZW50TGVuZ3RoIiwic2V0UmVzcG9uc2VMZW5ndGgiLCJsZW4iLCJub3JtYWxpemVkTXNnIiwiY29udGVudCIsImlucHV0U3RyaW5nIiwidHJpbSIsImxvb2tzTGlrZUNvbW1hbmQiLCJ0ZXN0IiwicHJvY2Vzc1NsYXNoQ29tbWFuZCIsImltYWdlQ29udGVudEJsb2NrcyIsImF0dGFjaG1lbnRNZXNzYWdlcyIsImlzQWxyZWFkeVByb2Nlc3NpbmciLCJwYXJzZWQiLCJlcnJvck1lc3NhZ2UiLCJwYXJzZWRBcmdzIiwiaXNNY3AiLCJzYW5pdGl6ZWRDb21tYW5kTmFtZSIsImhhcyIsImNvbW1hbmRzIiwiaXNGaWxlUGF0aCIsInN0YXQiLCJpbnB1dCIsInVua25vd25NZXNzYWdlIiwicHJvbXB0SWQiLCJwcm9tcHRfbGVuZ3RoIiwibGVuZ3RoIiwibmV3TWVzc2FnZXMiLCJtZXNzYWdlU2hvdWxkUXVlcnkiLCJhbGxvd2VkVG9vbHMiLCJyZXR1cm5lZENvbW1hbmQiLCJuZXh0SW5wdXQiLCJzdWJtaXROZXh0SW5wdXQiLCJnZXRNZXNzYWdlc0ZvclNsYXNoQ29tbWFuZCIsImV2ZW50RGF0YSIsIlJlY29yZCIsImlzT2ZmaWNpYWwiLCJwbHVnaW5fcmVwb3NpdG9yeSIsInBsdWdpbl9uYW1lIiwidmVyc2lvbiIsInBsdWdpbl92ZXJzaW9uIiwiT2JqZWN0IiwiYXNzaWduIiwic2tpbGxfbmFtZSIsInNraWxsX3NvdXJjZSIsInNvdXJjZSIsImxvYWRlZEZyb20iLCJza2lsbF9sb2FkZWRfZnJvbSIsImtpbmQiLCJza2lsbF9raW5kIiwic3RhcnRzV2l0aCIsImxvb2tzTGlrZUZpbGVQYXRoIiwiaXNDb21wYWN0UmVzdWx0IiwiZXZlcnkiLCJfaXNBbHJlYWR5UHJvY2Vzc2luZyIsInVzZXJJbnZvY2FibGUiLCJyZXNvbHZlIiwiZG9uZVdhc0NhbGxlZCIsIm9uRG9uZSIsInJlc3VsdCIsImRpc3BsYXkiLCJtZXRhTWVzc2FnZXMiLCJtYXAiLCJza2lwVHJhbnNjcmlwdCIsImVuZHNXaXRoIiwiZm9ybWF0Q29tbWFuZElucHV0IiwibG9hZCIsInRoZW4iLCJtb2QiLCJjYWxsIiwiaXNOb25JbnRlcmFjdGl2ZVNlc3Npb24iLCJpc0xvY2FsSlNYQ29tbWFuZCIsImlzSW1tZWRpYXRlIiwiaW1tZWRpYXRlIiwiZSIsImNsZWFyTG9jYWxKU1giLCJkaXNwbGF5QXJncyIsImlzU2Vuc2l0aXZlIiwidXNlck1lc3NhZ2UiLCJzeW50aGV0aWNDYXZlYXRNZXNzYWdlIiwic2xhc2hDb21tYW5kTWVzc2FnZXMiLCJkaXNwbGF5VGV4dCIsImNvbXBhY3Rpb25SZXN1bHRXaXRoU2xhc2hNZXNzYWdlcyIsImNvbXBhY3Rpb25SZXN1bHQiLCJtZXNzYWdlc1RvS2VlcCIsImdldE1lc3NhZ2VzRm9yUHJvbXB0U2xhc2hDb21tYW5kIiwidG9vbFVzZSIsImZvcm1hdFNraWxsTG9hZGluZ01ldGFkYXRhIiwic2tpbGxOYW1lIiwiX3Byb2dyZXNzTWVzc2FnZSIsImpvaW4iLCJmb3JtYXRTbGFzaENvbW1hbmRMb2FkaW5nTWV0YWRhdGEiLCJmaWx0ZXIiLCJCb29sZWFuIiwiZm9ybWF0Q29tbWFuZExvYWRpbmdNZXRhZGF0YSIsInByb2dyZXNzTWVzc2FnZSIsInByb2Nlc3NQcm9tcHRTbGFzaENvbW1hbmQiLCJwcm9jZXNzIiwiZW52IiwiQ0xBVURFX0NPREVfQ09PUkRJTkFUT1JfTU9ERSIsIm1ldGFkYXRhIiwicGFydHMiLCJkZXNjcmlwdGlvbiIsIndoZW5Ub1VzZSIsInNraWxsQWxsb3dlZFRvb2xzIiwic3VtbWFyeUNvbnRlbnQiLCJ0ZXh0IiwiZ2V0UHJvbXB0Rm9yQ29tbWFuZCIsImhvb2tzQWxsb3dlZEZvclRoaXNTa2lsbCIsImhvb2tzIiwic2Vzc2lvbklkIiwic2V0QXBwU3RhdGUiLCJza2lsbFJvb3QiLCJza2lsbFBhdGgiLCJiIiwiYWRkaXRpb25hbEFsbG93ZWRUb29scyIsIm1haW5NZXNzYWdlQ29udGVudCIsImJsb2NrIiwic2tpcFNraWxsRGlzY292ZXJ5Il0sInNvdXJjZXMiOlsicHJvY2Vzc1NsYXNoQ29tbWFuZC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgZmVhdHVyZSB9IGZyb20gJ2J1bjpidW5kbGUnXG5pbXBvcnQgdHlwZSB7XG4gIENvbnRlbnRCbG9ja1BhcmFtLFxuICBUZXh0QmxvY2tQYXJhbSxcbn0gZnJvbSAnQGFudGhyb3BpYy1haS9zZGsvcmVzb3VyY2VzJ1xuaW1wb3J0IHsgcmFuZG9tVVVJRCB9IGZyb20gJ2NyeXB0bydcbmltcG9ydCB7IHNldFByb21wdElkIH0gZnJvbSAnc3JjL2Jvb3RzdHJhcC9zdGF0ZS5qcydcbmltcG9ydCB7XG4gIGJ1aWx0SW5Db21tYW5kTmFtZXMsXG4gIHR5cGUgQ29tbWFuZCxcbiAgdHlwZSBDb21tYW5kQmFzZSxcbiAgZmluZENvbW1hbmQsXG4gIGdldENvbW1hbmQsXG4gIGdldENvbW1hbmROYW1lLFxuICBoYXNDb21tYW5kLFxuICB0eXBlIFByb21wdENvbW1hbmQsXG59IGZyb20gJ3NyYy9jb21tYW5kcy5qcydcbmltcG9ydCB7IE5PX0NPTlRFTlRfTUVTU0FHRSB9IGZyb20gJ3NyYy9jb25zdGFudHMvbWVzc2FnZXMuanMnXG5pbXBvcnQgdHlwZSB7IFNldFRvb2xKU1hGbiwgVG9vbFVzZUNvbnRleHQgfSBmcm9tICdzcmMvVG9vbC5qcydcbmltcG9ydCB0eXBlIHtcbiAgQXNzaXN0YW50TWVzc2FnZSxcbiAgQXR0YWNobWVudE1lc3NhZ2UsXG4gIE1lc3NhZ2UsXG4gIE5vcm1hbGl6ZWRVc2VyTWVzc2FnZSxcbiAgUHJvZ3Jlc3NNZXNzYWdlLFxuICBVc2VyTWVzc2FnZSxcbn0gZnJvbSAnc3JjL3R5cGVzL21lc3NhZ2UuanMnXG5pbXBvcnQgeyBhZGRJbnZva2VkU2tpbGwsIGdldFNlc3Npb25JZCB9IGZyb20gJy4uLy4uL2Jvb3RzdHJhcC9zdGF0ZS5qcydcbmltcG9ydCB7IENPTU1BTkRfTUVTU0FHRV9UQUcsIENPTU1BTkRfTkFNRV9UQUcgfSBmcm9tICcuLi8uLi9jb25zdGFudHMveG1sLmpzJ1xuaW1wb3J0IHR5cGUgeyBDYW5Vc2VUb29sRm4gfSBmcm9tICcuLi8uLi9ob29rcy91c2VDYW5Vc2VUb29sLmpzJ1xuaW1wb3J0IHtcbiAgdHlwZSBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICB0eXBlIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19QSUlfVEFHR0VELFxuICBsb2dFdmVudCxcbn0gZnJvbSAnLi4vLi4vc2VydmljZXMvYW5hbHl0aWNzL2luZGV4LmpzJ1xuaW1wb3J0IHsgZ2V0RHVtcFByb21wdHNQYXRoIH0gZnJvbSAnLi4vLi4vc2VydmljZXMvYXBpL2R1bXBQcm9tcHRzLmpzJ1xuaW1wb3J0IHsgYnVpbGRQb3N0Q29tcGFjdE1lc3NhZ2VzIH0gZnJvbSAnLi4vLi4vc2VydmljZXMvY29tcGFjdC9jb21wYWN0LmpzJ1xuaW1wb3J0IHsgcmVzZXRNaWNyb2NvbXBhY3RTdGF0ZSB9IGZyb20gJy4uLy4uL3NlcnZpY2VzL2NvbXBhY3QvbWljcm9Db21wYWN0LmpzJ1xuaW1wb3J0IHR5cGUgeyBQcm9ncmVzcyBhcyBBZ2VudFByb2dyZXNzIH0gZnJvbSAnLi4vLi4vdG9vbHMvQWdlbnRUb29sL0FnZW50VG9vbC5qcydcbmltcG9ydCB7IHJ1bkFnZW50IH0gZnJvbSAnLi4vLi4vdG9vbHMvQWdlbnRUb29sL3J1bkFnZW50LmpzJ1xuaW1wb3J0IHsgcmVuZGVyVG9vbFVzZVByb2dyZXNzTWVzc2FnZSB9IGZyb20gJy4uLy4uL3Rvb2xzL0FnZW50VG9vbC9VSS5qcydcbmltcG9ydCB0eXBlIHsgQ29tbWFuZFJlc3VsdERpc3BsYXkgfSBmcm9tICcuLi8uLi90eXBlcy9jb21tYW5kLmpzJ1xuaW1wb3J0IHsgY3JlYXRlQWJvcnRDb250cm9sbGVyIH0gZnJvbSAnLi4vYWJvcnRDb250cm9sbGVyLmpzJ1xuaW1wb3J0IHsgZ2V0QWdlbnRDb250ZXh0IH0gZnJvbSAnLi4vYWdlbnRDb250ZXh0LmpzJ1xuaW1wb3J0IHtcbiAgY3JlYXRlQXR0YWNobWVudE1lc3NhZ2UsXG4gIGdldEF0dGFjaG1lbnRNZXNzYWdlcyxcbn0gZnJvbSAnLi4vYXR0YWNobWVudHMuanMnXG5pbXBvcnQgeyBsb2dGb3JEZWJ1Z2dpbmcgfSBmcm9tICcuLi9kZWJ1Zy5qcydcbmltcG9ydCB7IGlzRW52VHJ1dGh5IH0gZnJvbSAnLi4vZW52VXRpbHMuanMnXG5pbXBvcnQgeyBBYm9ydEVycm9yLCBNYWxmb3JtZWRDb21tYW5kRXJyb3IgfSBmcm9tICcuLi9lcnJvcnMuanMnXG5pbXBvcnQgeyBnZXREaXNwbGF5UGF0aCB9IGZyb20gJy4uL2ZpbGUuanMnXG5pbXBvcnQge1xuICBleHRyYWN0UmVzdWx0VGV4dCxcbiAgcHJlcGFyZUZvcmtlZENvbW1hbmRDb250ZXh0LFxufSBmcm9tICcuLi9mb3JrZWRBZ2VudC5qcydcbmltcG9ydCB7IGdldEZzSW1wbGVtZW50YXRpb24gfSBmcm9tICcuLi9mc09wZXJhdGlvbnMuanMnXG5pbXBvcnQgeyBpc0Z1bGxzY3JlZW5FbnZFbmFibGVkIH0gZnJvbSAnLi4vZnVsbHNjcmVlbi5qcydcbmltcG9ydCB7IHRvQXJyYXkgfSBmcm9tICcuLi9nZW5lcmF0b3JzLmpzJ1xuaW1wb3J0IHsgcmVnaXN0ZXJTa2lsbEhvb2tzIH0gZnJvbSAnLi4vaG9va3MvcmVnaXN0ZXJTa2lsbEhvb2tzLmpzJ1xuaW1wb3J0IHsgbG9nRXJyb3IgfSBmcm9tICcuLi9sb2cuanMnXG5pbXBvcnQgeyBlbnF1ZXVlUGVuZGluZ05vdGlmaWNhdGlvbiB9IGZyb20gJy4uL21lc3NhZ2VRdWV1ZU1hbmFnZXIuanMnXG5pbXBvcnQge1xuICBjcmVhdGVDb21tYW5kSW5wdXRNZXNzYWdlLFxuICBjcmVhdGVTeW50aGV0aWNVc2VyQ2F2ZWF0TWVzc2FnZSxcbiAgY3JlYXRlU3lzdGVtTWVzc2FnZSxcbiAgY3JlYXRlVXNlckludGVycnVwdGlvbk1lc3NhZ2UsXG4gIGNyZWF0ZVVzZXJNZXNzYWdlLFxuICBmb3JtYXRDb21tYW5kSW5wdXRUYWdzLFxuICBpc0NvbXBhY3RCb3VuZGFyeU1lc3NhZ2UsXG4gIGlzU3lzdGVtTG9jYWxDb21tYW5kTWVzc2FnZSxcbiAgbm9ybWFsaXplTWVzc2FnZXMsXG4gIHByZXBhcmVVc2VyQ29udGVudCxcbn0gZnJvbSAnLi4vbWVzc2FnZXMuanMnXG5pbXBvcnQgdHlwZSB7IE1vZGVsQWxpYXMgfSBmcm9tICcuLi9tb2RlbC9hbGlhc2VzLmpzJ1xuaW1wb3J0IHsgcGFyc2VUb29sTGlzdEZyb21DTEkgfSBmcm9tICcuLi9wZXJtaXNzaW9ucy9wZXJtaXNzaW9uU2V0dXAuanMnXG5pbXBvcnQgeyBoYXNQZXJtaXNzaW9uc1RvVXNlVG9vbCB9IGZyb20gJy4uL3Blcm1pc3Npb25zL3Blcm1pc3Npb25zLmpzJ1xuaW1wb3J0IHtcbiAgaXNPZmZpY2lhbE1hcmtldHBsYWNlTmFtZSxcbiAgcGFyc2VQbHVnaW5JZGVudGlmaWVyLFxufSBmcm9tICcuLi9wbHVnaW5zL3BsdWdpbklkZW50aWZpZXIuanMnXG5pbXBvcnQge1xuICBpc1Jlc3RyaWN0ZWRUb1BsdWdpbk9ubHksXG4gIGlzU291cmNlQWRtaW5UcnVzdGVkLFxufSBmcm9tICcuLi9zZXR0aW5ncy9wbHVnaW5Pbmx5UG9saWN5LmpzJ1xuaW1wb3J0IHsgcGFyc2VTbGFzaENvbW1hbmQgfSBmcm9tICcuLi9zbGFzaENvbW1hbmRQYXJzaW5nLmpzJ1xuaW1wb3J0IHsgc2xlZXAgfSBmcm9tICcuLi9zbGVlcC5qcydcbmltcG9ydCB7IHJlY29yZFNraWxsVXNhZ2UgfSBmcm9tICcuLi9zdWdnZXN0aW9ucy9za2lsbFVzYWdlVHJhY2tpbmcuanMnXG5pbXBvcnQgeyBsb2dPVGVsRXZlbnQsIHJlZGFjdElmRGlzYWJsZWQgfSBmcm9tICcuLi90ZWxlbWV0cnkvZXZlbnRzLmpzJ1xuaW1wb3J0IHsgYnVpbGRQbHVnaW5Db21tYW5kVGVsZW1ldHJ5RmllbGRzIH0gZnJvbSAnLi4vdGVsZW1ldHJ5L3BsdWdpblRlbGVtZXRyeS5qcydcbmltcG9ydCB7IGdldEFzc2lzdGFudE1lc3NhZ2VDb250ZW50TGVuZ3RoIH0gZnJvbSAnLi4vdG9rZW5zLmpzJ1xuaW1wb3J0IHsgY3JlYXRlQWdlbnRJZCB9IGZyb20gJy4uL3V1aWQuanMnXG5pbXBvcnQgeyBnZXRXb3JrbG9hZCB9IGZyb20gJy4uL3dvcmtsb2FkQ29udGV4dC5qcydcbmltcG9ydCB0eXBlIHtcbiAgUHJvY2Vzc1VzZXJJbnB1dEJhc2VSZXN1bHQsXG4gIFByb2Nlc3NVc2VySW5wdXRDb250ZXh0LFxufSBmcm9tICcuL3Byb2Nlc3NVc2VySW5wdXQuanMnXG5cbnR5cGUgU2xhc2hDb21tYW5kUmVzdWx0ID0gUHJvY2Vzc1VzZXJJbnB1dEJhc2VSZXN1bHQgJiB7XG4gIGNvbW1hbmQ6IENvbW1hbmRcbn1cblxuLy8gUG9sbCBpbnRlcnZhbCBhbmQgZGVhZGxpbmUgZm9yIE1DUCBzZXR0bGUgYmVmb3JlIGxhdW5jaGluZyBhIGJhY2tncm91bmRcbi8vIGZvcmtlZCBzdWJhZ2VudC4gTUNQIHNlcnZlcnMgdHlwaWNhbGx5IGNvbm5lY3Qgd2l0aGluIDEtM3Mgb2Ygc3RhcnR1cDtcbi8vIDEwcyBoZWFkcm9vbSBjb3ZlcnMgc2xvdyBTU0UgaGFuZHNoYWtlcy5cbmNvbnN0IE1DUF9TRVRUTEVfUE9MTF9NUyA9IDIwMFxuY29uc3QgTUNQX1NFVFRMRV9USU1FT1VUX01TID0gMTBfMDAwXG5cbi8qKlxuICogRXhlY3V0ZXMgYSBzbGFzaCBjb21tYW5kIHdpdGggY29udGV4dDogZm9yayBpbiBhIHN1Yi1hZ2VudC5cbiAqL1xuYXN5bmMgZnVuY3Rpb24gZXhlY3V0ZUZvcmtlZFNsYXNoQ29tbWFuZChcbiAgY29tbWFuZDogQ29tbWFuZEJhc2UgJiBQcm9tcHRDb21tYW5kLFxuICBhcmdzOiBzdHJpbmcsXG4gIGNvbnRleHQ6IFByb2Nlc3NVc2VySW5wdXRDb250ZXh0LFxuICBwcmVjZWRpbmdJbnB1dEJsb2NrczogQ29udGVudEJsb2NrUGFyYW1bXSxcbiAgc2V0VG9vbEpTWDogU2V0VG9vbEpTWEZuLFxuICBjYW5Vc2VUb29sOiBDYW5Vc2VUb29sRm4sXG4pOiBQcm9taXNlPFNsYXNoQ29tbWFuZFJlc3VsdD4ge1xuICBjb25zdCBhZ2VudElkID0gY3JlYXRlQWdlbnRJZCgpXG5cbiAgY29uc3QgcGx1Z2luTWFya2V0cGxhY2UgPSBjb21tYW5kLnBsdWdpbkluZm9cbiAgICA/IHBhcnNlUGx1Z2luSWRlbnRpZmllcihjb21tYW5kLnBsdWdpbkluZm8ucmVwb3NpdG9yeSkubWFya2V0cGxhY2VcbiAgICA6IHVuZGVmaW5lZFxuICBsb2dFdmVudCgndGVuZ3Vfc2xhc2hfY29tbWFuZF9mb3JrZWQnLCB7XG4gICAgY29tbWFuZF9uYW1lOlxuICAgICAgY29tbWFuZC5uYW1lIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgaW52b2NhdGlvbl90cmlnZ2VyOlxuICAgICAgJ3VzZXItc2xhc2gnIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgLi4uKGNvbW1hbmQucGx1Z2luSW5mbyAmJiB7XG4gICAgICBfUFJPVE9fcGx1Z2luX25hbWU6IGNvbW1hbmQucGx1Z2luSW5mby5wbHVnaW5NYW5pZmVzdFxuICAgICAgICAubmFtZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfUElJX1RBR0dFRCxcbiAgICAgIC4uLihwbHVnaW5NYXJrZXRwbGFjZSAmJiB7XG4gICAgICAgIF9QUk9UT19tYXJrZXRwbGFjZV9uYW1lOlxuICAgICAgICAgIHBsdWdpbk1hcmtldHBsYWNlIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19QSUlfVEFHR0VELFxuICAgICAgfSksXG4gICAgICAuLi5idWlsZFBsdWdpbkNvbW1hbmRUZWxlbWV0cnlGaWVsZHMoY29tbWFuZC5wbHVnaW5JbmZvKSxcbiAgICB9KSxcbiAgfSlcblxuICBjb25zdCB7IHNraWxsQ29udGVudCwgbW9kaWZpZWRHZXRBcHBTdGF0ZSwgYmFzZUFnZW50LCBwcm9tcHRNZXNzYWdlcyB9ID1cbiAgICBhd2FpdCBwcmVwYXJlRm9ya2VkQ29tbWFuZENvbnRleHQoY29tbWFuZCwgYXJncywgY29udGV4dClcblxuICAvLyBNZXJnZSBza2lsbCdzIGVmZm9ydCBpbnRvIHRoZSBhZ2VudCBkZWZpbml0aW9uIHNvIHJ1bkFnZW50IGFwcGxpZXMgaXRcbiAgY29uc3QgYWdlbnREZWZpbml0aW9uID1cbiAgICBjb21tYW5kLmVmZm9ydCAhPT0gdW5kZWZpbmVkXG4gICAgICA/IHsgLi4uYmFzZUFnZW50LCBlZmZvcnQ6IGNvbW1hbmQuZWZmb3J0IH1cbiAgICAgIDogYmFzZUFnZW50XG5cbiAgbG9nRm9yRGVidWdnaW5nKFxuICAgIGBFeGVjdXRpbmcgZm9ya2VkIHNsYXNoIGNvbW1hbmQgLyR7Y29tbWFuZC5uYW1lfSB3aXRoIGFnZW50ICR7YWdlbnREZWZpbml0aW9uLmFnZW50VHlwZX1gLFxuICApXG5cbiAgLy8gQXNzaXN0YW50IG1vZGU6IGZpcmUtYW5kLWZvcmdldC4gTGF1bmNoIHN1YmFnZW50IGluIGJhY2tncm91bmQsIHJldHVyblxuICAvLyBpbW1lZGlhdGVseSwgcmUtZW5xdWV1ZSB0aGUgcmVzdWx0IGFzIGFuIGlzTWV0YSBwcm9tcHQgd2hlbiBkb25lLlxuICAvLyBXaXRob3V0IHRoaXMsIE4gc2NoZWR1bGVkIHRhc2tzIG9uIHN0YXJ0dXAgPSBOIHNlcmlhbCAoc3ViYWdlbnQgKyBtYWluXG4gIC8vIGFnZW50IHR1cm4pIGN5Y2xlcyBibG9ja2luZyB1c2VyIGlucHV0LiBXaXRoIHRoaXMsIE4gc3ViYWdlbnRzIHJ1biBpblxuICAvLyBwYXJhbGxlbCBhbmQgcmVzdWx0cyB0cmlja2xlIGludG8gdGhlIHF1ZXVlIGFzIHRoZXkgZmluaXNoLlxuICAvL1xuICAvLyBHYXRlZCBvbiBrYWlyb3NFbmFibGVkIChub3QgQ0xBVURFX0NPREVfQlJJRUYpIGJlY2F1c2UgdGhlIGNsb3NlZCBsb29wXG4gIC8vIGRlcGVuZHMgb24gYXNzaXN0YW50LW1vZGUgaW52YXJpYW50czogc2NoZWR1bGVkX3Rhc2tzLmpzb24gZXhpc3RzLFxuICAvLyB0aGUgbWFpbiBhZ2VudCBrbm93cyB0byBwaXBlIHJlc3VsdHMgdGhyb3VnaCBTZW5kVXNlck1lc3NhZ2UsIGFuZFxuICAvLyBpc01ldGEgcHJvbXB0cyBhcmUgaGlkZGVuLiBPdXRzaWRlIGFzc2lzdGFudCBtb2RlLCBjb250ZXh0OmZvcmsgY29tbWFuZHNcbiAgLy8gYXJlIHVzZXItaW52b2tlZCBza2lsbHMgKC9jb21taXQgZXRjLikgdGhhdCBzaG91bGQgcnVuIHN5bmNocm9ub3VzbHlcbiAgLy8gd2l0aCB0aGUgcHJvZ3Jlc3MgVUkuXG4gIGlmIChmZWF0dXJlKCdLQUlST1MnKSAmJiAoYXdhaXQgY29udGV4dC5nZXRBcHBTdGF0ZSgpKS5rYWlyb3NFbmFibGVkKSB7XG4gICAgLy8gU3RhbmRhbG9uZSBhYm9ydENvbnRyb2xsZXIg4oCUIGJhY2tncm91bmQgc3ViYWdlbnRzIHN1cnZpdmUgbWFpbi10aHJlYWRcbiAgICAvLyBFU0MgKHNhbWUgcG9saWN5IGFzIEFnZW50VG9vbCdzIGFzeW5jIHBhdGgpLiBUaGV5J3JlIGNyb24tZHJpdmVuOyBpZlxuICAgIC8vIGtpbGxlZCBtaWQtcnVuIHRoZXkganVzdCByZS1maXJlIG9uIHRoZSBuZXh0IHNjaGVkdWxlLlxuICAgIGNvbnN0IGJnQWJvcnRDb250cm9sbGVyID0gY3JlYXRlQWJvcnRDb250cm9sbGVyKClcbiAgICBjb25zdCBjb21tYW5kTmFtZSA9IGdldENvbW1hbmROYW1lKGNvbW1hbmQpXG5cbiAgICAvLyBXb3JrbG9hZDogaGFuZGxlUHJvbXB0U3VibWl0IHdyYXBzIHRoZSBlbnRpcmUgdHVybiBpbiBydW5XaXRoV29ya2xvYWRcbiAgICAvLyAoQXN5bmNMb2NhbFN0b3JhZ2UpLiBBTFMgY29udGV4dCBpcyBjYXB0dXJlZCB3aGVuIHRoaXMgYHZvaWRgIGZpcmVzXG4gICAgLy8gYW5kIHN1cnZpdmVzIGV2ZXJ5IGF3YWl0IGluc2lkZSDigJQgaXNvbGF0ZWQgZnJvbSB0aGUgcGFyZW50J3NcbiAgICAvLyBjb250aW51YXRpb24uIFRoZSBkZXRhY2hlZCBjbG9zdXJlJ3MgcnVuQWdlbnQgY2FsbHMgc2VlIHRoZSBjcm9uIHRhZ1xuICAgIC8vIGF1dG9tYXRpY2FsbHkuIFdlIHN0aWxsIGNhcHR1cmUgdGhlIHZhbHVlIGhlcmUgT05MWSBmb3IgdGhlXG4gICAgLy8gcmUtZW5xdWV1ZWQgcmVzdWx0IHByb21wdCBiZWxvdzogdGhhdCBzZWNvbmQgdHVybiBydW5zIGluIGEgZnJlc2hcbiAgICAvLyBoYW5kbGVQcm9tcHRTdWJtaXQg4oaSIGZyZXNoIHJ1bldpdGhXb3JrbG9hZCBib3VuZGFyeSAod2hpY2ggYWx3YXlzXG4gICAgLy8gZXN0YWJsaXNoZXMgYSBuZXcgY29udGV4dCwgZXZlbiBmb3IgYHVuZGVmaW5lZGApIOKGkiBzbyBpdCBuZWVkcyBpdHNcbiAgICAvLyBvd24gUXVldWVkQ29tbWFuZC53b3JrbG9hZCB0YWcgdG8gcHJlc2VydmUgYXR0cmlidXRpb24uXG4gICAgY29uc3Qgc3Bhd25UaW1lV29ya2xvYWQgPSBnZXRXb3JrbG9hZCgpXG5cbiAgICAvLyBSZS1lbnRlciB0aGUgcXVldWUgYXMgYSBoaWRkZW4gcHJvbXB0LiBpc01ldGE6IGhpZGVzIGZyb20gcXVldWVcbiAgICAvLyBwcmV2aWV3ICsgcGxhY2Vob2xkZXIgKyB0cmFuc2NyaXB0LiBza2lwU2xhc2hDb21tYW5kczogcHJldmVudHNcbiAgICAvLyByZS1wYXJzaW5nIGlmIHRoZSByZXN1bHQgdGV4dCBoYXBwZW5zIHRvIHN0YXJ0IHdpdGggJy8nLiBXaGVuXG4gICAgLy8gZHJhaW5lZCwgdGhpcyB0cmlnZ2VycyBhIG1haW4tYWdlbnQgdHVybiB0aGF0IHNlZXMgdGhlIHJlc3VsdCBhbmRcbiAgICAvLyBkZWNpZGVzIHdoZXRoZXIgdG8gU2VuZFVzZXJNZXNzYWdlLiBQcm9wYWdhdGUgd29ya2xvYWQgc28gdGhhdFxuICAgIC8vIHNlY29uZCB0dXJuIGlzIGFsc28gdGFnZ2VkLlxuICAgIGNvbnN0IGVucXVldWVSZXN1bHQgPSAodmFsdWU6IHN0cmluZyk6IHZvaWQgPT5cbiAgICAgIGVucXVldWVQZW5kaW5nTm90aWZpY2F0aW9uKHtcbiAgICAgICAgdmFsdWUsXG4gICAgICAgIG1vZGU6ICdwcm9tcHQnLFxuICAgICAgICBwcmlvcml0eTogJ2xhdGVyJyxcbiAgICAgICAgaXNNZXRhOiB0cnVlLFxuICAgICAgICBza2lwU2xhc2hDb21tYW5kczogdHJ1ZSxcbiAgICAgICAgd29ya2xvYWQ6IHNwYXduVGltZVdvcmtsb2FkLFxuICAgICAgfSlcblxuICAgIHZvaWQgKGFzeW5jICgpID0+IHtcbiAgICAgIC8vIFdhaXQgZm9yIE1DUCBzZXJ2ZXJzIHRvIHNldHRsZS4gU2NoZWR1bGVkIHRhc2tzIGZpcmUgYXQgc3RhcnR1cCBhbmRcbiAgICAgIC8vIGFsbCBOIGRyYWluIHdpdGhpbiB+MW1zIChzaW5jZSB3ZSByZXR1cm4gaW1tZWRpYXRlbHkpLCBjYXB0dXJpbmdcbiAgICAgIC8vIGNvbnRleHQub3B0aW9ucy50b29scyBiZWZvcmUgTUNQIGNvbm5lY3RzLiBUaGUgc3luYyBwYXRoXG4gICAgICAvLyBhY2NpZGVudGFsbHkgYXZvaWRlZCB0aGlzIOKAlCB0YXNrcyBzZXJpYWxpemVkLCBzbyB0YXNrIE4ncyBkcmFpblxuICAgICAgLy8gaGFwcGVuZWQgYWZ0ZXIgdGFzayBOLTEncyAzMHMgcnVuLCBieSB3aGljaCB0aW1lIE1DUCB3YXMgdXAuXG4gICAgICAvLyBQb2xsIHVudGlsIG5vICdwZW5kaW5nJyBjbGllbnRzIHJlbWFpbiwgdGhlbiByZWZyZXNoLlxuICAgICAgY29uc3QgZGVhZGxpbmUgPSBEYXRlLm5vdygpICsgTUNQX1NFVFRMRV9USU1FT1VUX01TXG4gICAgICB3aGlsZSAoRGF0ZS5ub3coKSA8IGRlYWRsaW5lKSB7XG4gICAgICAgIGNvbnN0IHMgPSBjb250ZXh0LmdldEFwcFN0YXRlKClcbiAgICAgICAgaWYgKCFzLm1jcC5jbGllbnRzLnNvbWUoYyA9PiBjLnR5cGUgPT09ICdwZW5kaW5nJykpIGJyZWFrXG4gICAgICAgIGF3YWl0IHNsZWVwKE1DUF9TRVRUTEVfUE9MTF9NUylcbiAgICAgIH1cbiAgICAgIGNvbnN0IGZyZXNoVG9vbHMgPVxuICAgICAgICBjb250ZXh0Lm9wdGlvbnMucmVmcmVzaFRvb2xzPy4oKSA/PyBjb250ZXh0Lm9wdGlvbnMudG9vbHNcblxuICAgICAgY29uc3QgYWdlbnRNZXNzYWdlczogTWVzc2FnZVtdID0gW11cbiAgICAgIGZvciBhd2FpdCAoY29uc3QgbWVzc2FnZSBvZiBydW5BZ2VudCh7XG4gICAgICAgIGFnZW50RGVmaW5pdGlvbixcbiAgICAgICAgcHJvbXB0TWVzc2FnZXMsXG4gICAgICAgIHRvb2xVc2VDb250ZXh0OiB7XG4gICAgICAgICAgLi4uY29udGV4dCxcbiAgICAgICAgICBnZXRBcHBTdGF0ZTogbW9kaWZpZWRHZXRBcHBTdGF0ZSxcbiAgICAgICAgICBhYm9ydENvbnRyb2xsZXI6IGJnQWJvcnRDb250cm9sbGVyLFxuICAgICAgICB9LFxuICAgICAgICBjYW5Vc2VUb29sLFxuICAgICAgICBpc0FzeW5jOiB0cnVlLFxuICAgICAgICBxdWVyeVNvdXJjZTogJ2FnZW50OmN1c3RvbScsXG4gICAgICAgIG1vZGVsOiBjb21tYW5kLm1vZGVsIGFzIE1vZGVsQWxpYXMgfCB1bmRlZmluZWQsXG4gICAgICAgIGF2YWlsYWJsZVRvb2xzOiBmcmVzaFRvb2xzLFxuICAgICAgICBvdmVycmlkZTogeyBhZ2VudElkIH0sXG4gICAgICB9KSkge1xuICAgICAgICBhZ2VudE1lc3NhZ2VzLnB1c2gobWVzc2FnZSlcbiAgICAgIH1cbiAgICAgIGNvbnN0IHJlc3VsdFRleHQgPSBleHRyYWN0UmVzdWx0VGV4dChhZ2VudE1lc3NhZ2VzLCAnQ29tbWFuZCBjb21wbGV0ZWQnKVxuICAgICAgbG9nRm9yRGVidWdnaW5nKFxuICAgICAgICBgQmFja2dyb3VuZCBmb3JrZWQgY29tbWFuZCAvJHtjb21tYW5kTmFtZX0gY29tcGxldGVkIChhZ2VudCAke2FnZW50SWR9KWAsXG4gICAgICApXG4gICAgICBlbnF1ZXVlUmVzdWx0KFxuICAgICAgICBgPHNjaGVkdWxlZC10YXNrLXJlc3VsdCBjb21tYW5kPVwiLyR7Y29tbWFuZE5hbWV9XCI+XFxuJHtyZXN1bHRUZXh0fVxcbjwvc2NoZWR1bGVkLXRhc2stcmVzdWx0PmAsXG4gICAgICApXG4gICAgfSkoKS5jYXRjaChlcnIgPT4ge1xuICAgICAgbG9nRXJyb3IoZXJyKVxuICAgICAgZW5xdWV1ZVJlc3VsdChcbiAgICAgICAgYDxzY2hlZHVsZWQtdGFzay1yZXN1bHQgY29tbWFuZD1cIi8ke2NvbW1hbmROYW1lfVwiIHN0YXR1cz1cImZhaWxlZFwiPlxcbiR7ZXJyIGluc3RhbmNlb2YgRXJyb3IgPyBlcnIubWVzc2FnZSA6IFN0cmluZyhlcnIpfVxcbjwvc2NoZWR1bGVkLXRhc2stcmVzdWx0PmAsXG4gICAgICApXG4gICAgfSlcblxuICAgIC8vIE5vdGhpbmcgdG8gcmVuZGVyLCBub3RoaW5nIHRvIHF1ZXJ5IOKAlCB0aGUgYmFja2dyb3VuZCBydW5uZXIgcmUtZW50ZXJzXG4gICAgLy8gdGhlIHF1ZXVlIG9uIGl0cyBvd24gc2NoZWR1bGUuXG4gICAgcmV0dXJuIHsgbWVzc2FnZXM6IFtdLCBzaG91bGRRdWVyeTogZmFsc2UsIGNvbW1hbmQgfVxuICB9XG5cbiAgLy8gQ29sbGVjdCBtZXNzYWdlcyBmcm9tIHRoZSBmb3JrZWQgYWdlbnRcbiAgY29uc3QgYWdlbnRNZXNzYWdlczogTWVzc2FnZVtdID0gW11cblxuICAvLyBCdWlsZCBwcm9ncmVzcyBtZXNzYWdlcyBmb3IgdGhlIGFnZW50IHByb2dyZXNzIFVJXG4gIGNvbnN0IHByb2dyZXNzTWVzc2FnZXM6IFByb2dyZXNzTWVzc2FnZTxBZ2VudFByb2dyZXNzPltdID0gW11cbiAgY29uc3QgcGFyZW50VG9vbFVzZUlEID0gYGZvcmtlZC1jb21tYW5kLSR7Y29tbWFuZC5uYW1lfWBcbiAgbGV0IHRvb2xVc2VDb3VudGVyID0gMFxuXG4gIC8vIEhlbHBlciB0byBjcmVhdGUgYSBwcm9ncmVzcyBtZXNzYWdlIGZyb20gYW4gYWdlbnQgbWVzc2FnZVxuICBjb25zdCBjcmVhdGVQcm9ncmVzc01lc3NhZ2UgPSAoXG4gICAgbWVzc2FnZTogQXNzaXN0YW50TWVzc2FnZSB8IE5vcm1hbGl6ZWRVc2VyTWVzc2FnZSxcbiAgKTogUHJvZ3Jlc3NNZXNzYWdlPEFnZW50UHJvZ3Jlc3M+ID0+IHtcbiAgICB0b29sVXNlQ291bnRlcisrXG4gICAgcmV0dXJuIHtcbiAgICAgIHR5cGU6ICdwcm9ncmVzcycsXG4gICAgICBkYXRhOiB7XG4gICAgICAgIG1lc3NhZ2UsXG4gICAgICAgIHR5cGU6ICdhZ2VudF9wcm9ncmVzcycsXG4gICAgICAgIHByb21wdDogc2tpbGxDb250ZW50LFxuICAgICAgICBhZ2VudElkLFxuICAgICAgfSxcbiAgICAgIHBhcmVudFRvb2xVc2VJRCxcbiAgICAgIHRvb2xVc2VJRDogYCR7cGFyZW50VG9vbFVzZUlEfS0ke3Rvb2xVc2VDb3VudGVyfWAsXG4gICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSxcbiAgICAgIHV1aWQ6IHJhbmRvbVVVSUQoKSxcbiAgICB9XG4gIH1cblxuICAvLyBIZWxwZXIgdG8gdXBkYXRlIHByb2dyZXNzIGRpc3BsYXkgdXNpbmcgYWdlbnQgcHJvZ3Jlc3MgVUlcbiAgY29uc3QgdXBkYXRlUHJvZ3Jlc3MgPSAoKTogdm9pZCA9PiB7XG4gICAgc2V0VG9vbEpTWCh7XG4gICAgICBqc3g6IHJlbmRlclRvb2xVc2VQcm9ncmVzc01lc3NhZ2UocHJvZ3Jlc3NNZXNzYWdlcywge1xuICAgICAgICB0b29sczogY29udGV4dC5vcHRpb25zLnRvb2xzLFxuICAgICAgICB2ZXJib3NlOiBmYWxzZSxcbiAgICAgIH0pLFxuICAgICAgc2hvdWxkSGlkZVByb21wdElucHV0OiBmYWxzZSxcbiAgICAgIHNob3VsZENvbnRpbnVlQW5pbWF0aW9uOiB0cnVlLFxuICAgICAgc2hvd1NwaW5uZXI6IHRydWUsXG4gICAgfSlcbiAgfVxuXG4gIC8vIFNob3cgaW5pdGlhbCBcIkluaXRpYWxpemluZ+KAplwiIHN0YXRlXG4gIHVwZGF0ZVByb2dyZXNzKClcblxuICAvLyBSdW4gdGhlIHN1Yi1hZ2VudFxuICB0cnkge1xuICAgIGZvciBhd2FpdCAoY29uc3QgbWVzc2FnZSBvZiBydW5BZ2VudCh7XG4gICAgICBhZ2VudERlZmluaXRpb24sXG4gICAgICBwcm9tcHRNZXNzYWdlcyxcbiAgICAgIHRvb2xVc2VDb250ZXh0OiB7XG4gICAgICAgIC4uLmNvbnRleHQsXG4gICAgICAgIGdldEFwcFN0YXRlOiBtb2RpZmllZEdldEFwcFN0YXRlLFxuICAgICAgfSxcbiAgICAgIGNhblVzZVRvb2wsXG4gICAgICBpc0FzeW5jOiBmYWxzZSxcbiAgICAgIHF1ZXJ5U291cmNlOiAnYWdlbnQ6Y3VzdG9tJyxcbiAgICAgIG1vZGVsOiBjb21tYW5kLm1vZGVsIGFzIE1vZGVsQWxpYXMgfCB1bmRlZmluZWQsXG4gICAgICBhdmFpbGFibGVUb29sczogY29udGV4dC5vcHRpb25zLnRvb2xzLFxuICAgIH0pKSB7XG4gICAgICBhZ2VudE1lc3NhZ2VzLnB1c2gobWVzc2FnZSlcbiAgICAgIGNvbnN0IG5vcm1hbGl6ZWROZXcgPSBub3JtYWxpemVNZXNzYWdlcyhbbWVzc2FnZV0pXG5cbiAgICAgIC8vIEFkZCBwcm9ncmVzcyBtZXNzYWdlIGZvciBhc3Npc3RhbnQgbWVzc2FnZXMgKHdoaWNoIGNvbnRhaW4gdG9vbCB1c2VzKVxuICAgICAgaWYgKG1lc3NhZ2UudHlwZSA9PT0gJ2Fzc2lzdGFudCcpIHtcbiAgICAgICAgLy8gSW5jcmVtZW50IHRva2VuIGNvdW50IGluIHNwaW5uZXIgZm9yIGFzc2lzdGFudCBtZXNzYWdlc1xuICAgICAgICBjb25zdCBjb250ZW50TGVuZ3RoID0gZ2V0QXNzaXN0YW50TWVzc2FnZUNvbnRlbnRMZW5ndGgobWVzc2FnZSlcbiAgICAgICAgaWYgKGNvbnRlbnRMZW5ndGggPiAwKSB7XG4gICAgICAgICAgY29udGV4dC5zZXRSZXNwb25zZUxlbmd0aChsZW4gPT4gbGVuICsgY29udGVudExlbmd0aClcbiAgICAgICAgfVxuXG4gICAgICAgIGNvbnN0IG5vcm1hbGl6ZWRNc2cgPSBub3JtYWxpemVkTmV3WzBdXG4gICAgICAgIGlmIChub3JtYWxpemVkTXNnICYmIG5vcm1hbGl6ZWRNc2cudHlwZSA9PT0gJ2Fzc2lzdGFudCcpIHtcbiAgICAgICAgICBwcm9ncmVzc01lc3NhZ2VzLnB1c2goY3JlYXRlUHJvZ3Jlc3NNZXNzYWdlKG1lc3NhZ2UpKVxuICAgICAgICAgIHVwZGF0ZVByb2dyZXNzKClcbiAgICAgICAgfVxuICAgICAgfVxuXG4gICAgICAvLyBBZGQgcHJvZ3Jlc3MgbWVzc2FnZSBmb3IgdXNlciBtZXNzYWdlcyAod2hpY2ggY29udGFpbiB0b29sIHJlc3VsdHMpXG4gICAgICBpZiAobWVzc2FnZS50eXBlID09PSAndXNlcicpIHtcbiAgICAgICAgY29uc3Qgbm9ybWFsaXplZE1zZyA9IG5vcm1hbGl6ZWROZXdbMF1cbiAgICAgICAgaWYgKG5vcm1hbGl6ZWRNc2cgJiYgbm9ybWFsaXplZE1zZy50eXBlID09PSAndXNlcicpIHtcbiAgICAgICAgICBwcm9ncmVzc01lc3NhZ2VzLnB1c2goY3JlYXRlUHJvZ3Jlc3NNZXNzYWdlKG5vcm1hbGl6ZWRNc2cpKVxuICAgICAgICAgIHVwZGF0ZVByb2dyZXNzKClcbiAgICAgICAgfVxuICAgICAgfVxuICAgIH1cbiAgfSBmaW5hbGx5IHtcbiAgICAvLyBDbGVhciB0aGUgcHJvZ3Jlc3MgZGlzcGxheVxuICAgIHNldFRvb2xKU1gobnVsbClcbiAgfVxuXG4gIGxldCByZXN1bHRUZXh0ID0gZXh0cmFjdFJlc3VsdFRleHQoYWdlbnRNZXNzYWdlcywgJ0NvbW1hbmQgY29tcGxldGVkJylcblxuICBsb2dGb3JEZWJ1Z2dpbmcoXG4gICAgYEZvcmtlZCBzbGFzaCBjb21tYW5kIC8ke2NvbW1hbmQubmFtZX0gY29tcGxldGVkIHdpdGggYWdlbnQgJHthZ2VudElkfWAsXG4gIClcblxuICAvLyBQcmVwZW5kIGRlYnVnIGxvZyBmb3IgYW50IHVzZXJzIHNvIGl0IGFwcGVhcnMgaW5zaWRlIHRoZSBjb21tYW5kIG91dHB1dFxuICBpZiAoXCJleHRlcm5hbFwiID09PSAnYW50Jykge1xuICAgIHJlc3VsdFRleHQgPSBgW0FOVC1PTkxZXSBBUEkgY2FsbHM6ICR7Z2V0RGlzcGxheVBhdGgoZ2V0RHVtcFByb21wdHNQYXRoKGFnZW50SWQpKX1cXG4ke3Jlc3VsdFRleHR9YFxuICB9XG5cbiAgLy8gUmV0dXJuIHRoZSByZXN1bHQgYXMgYSB1c2VyIG1lc3NhZ2UgKHNpbXVsYXRlcyB0aGUgYWdlbnQncyBvdXRwdXQpXG4gIGNvbnN0IG1lc3NhZ2VzOiBVc2VyTWVzc2FnZVtdID0gW1xuICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgIGNvbnRlbnQ6IHByZXBhcmVVc2VyQ29udGVudCh7XG4gICAgICAgIGlucHV0U3RyaW5nOiBgLyR7Z2V0Q29tbWFuZE5hbWUoY29tbWFuZCl9ICR7YXJnc31gLnRyaW0oKSxcbiAgICAgICAgcHJlY2VkaW5nSW5wdXRCbG9ja3MsXG4gICAgICB9KSxcbiAgICB9KSxcbiAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICBjb250ZW50OiBgPGxvY2FsLWNvbW1hbmQtc3Rkb3V0PlxcbiR7cmVzdWx0VGV4dH1cXG48L2xvY2FsLWNvbW1hbmQtc3Rkb3V0PmAsXG4gICAgfSksXG4gIF1cblxuICByZXR1cm4ge1xuICAgIG1lc3NhZ2VzLFxuICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcbiAgICBjb21tYW5kLFxuICAgIHJlc3VsdFRleHQsXG4gIH1cbn1cblxuLyoqXG4gKiBEZXRlcm1pbmVzIGlmIGEgc3RyaW5nIGxvb2tzIGxpa2UgYSB2YWxpZCBjb21tYW5kIG5hbWUuXG4gKiBWYWxpZCBjb21tYW5kIG5hbWVzIG9ubHkgY29udGFpbiBsZXR0ZXJzLCBudW1iZXJzLCBjb2xvbnMsIGh5cGhlbnMsIGFuZCB1bmRlcnNjb3Jlcy5cbiAqXG4gKiBAcGFyYW0gY29tbWFuZE5hbWUgLSBUaGUgcG90ZW50aWFsIGNvbW1hbmQgbmFtZSB0byBjaGVja1xuICogQHJldHVybnMgdHJ1ZSBpZiBpdCBsb29rcyBsaWtlIGEgY29tbWFuZCBuYW1lLCBmYWxzZSBpZiBpdCBjb250YWlucyBub24tY29tbWFuZCBjaGFyYWN0ZXJzXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBsb29rc0xpa2VDb21tYW5kKGNvbW1hbmROYW1lOiBzdHJpbmcpOiBib29sZWFuIHtcbiAgLy8gQ29tbWFuZCBuYW1lcyBzaG91bGQgb25seSBjb250YWluIFthLXpBLVowLTk6Xy1dXG4gIC8vIElmIGl0IGNvbnRhaW5zIG90aGVyIGNoYXJhY3RlcnMsIGl0J3MgcHJvYmFibHkgYSBmaWxlIHBhdGggb3Igb3RoZXIgaW5wdXRcbiAgcmV0dXJuICEvW15hLXpBLVowLTk6XFwtX10vLnRlc3QoY29tbWFuZE5hbWUpXG59XG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBwcm9jZXNzU2xhc2hDb21tYW5kKFxuICBpbnB1dFN0cmluZzogc3RyaW5nLFxuICBwcmVjZWRpbmdJbnB1dEJsb2NrczogQ29udGVudEJsb2NrUGFyYW1bXSxcbiAgaW1hZ2VDb250ZW50QmxvY2tzOiBDb250ZW50QmxvY2tQYXJhbVtdLFxuICBhdHRhY2htZW50TWVzc2FnZXM6IEF0dGFjaG1lbnRNZXNzYWdlW10sXG4gIGNvbnRleHQ6IFByb2Nlc3NVc2VySW5wdXRDb250ZXh0LFxuICBzZXRUb29sSlNYOiBTZXRUb29sSlNYRm4sXG4gIHV1aWQ/OiBzdHJpbmcsXG4gIGlzQWxyZWFkeVByb2Nlc3Npbmc/OiBib29sZWFuLFxuICBjYW5Vc2VUb29sPzogQ2FuVXNlVG9vbEZuLFxuKTogUHJvbWlzZTxQcm9jZXNzVXNlcklucHV0QmFzZVJlc3VsdD4ge1xuICBjb25zdCBwYXJzZWQgPSBwYXJzZVNsYXNoQ29tbWFuZChpbnB1dFN0cmluZylcbiAgaWYgKCFwYXJzZWQpIHtcbiAgICBsb2dFdmVudCgndGVuZ3VfaW5wdXRfc2xhc2hfbWlzc2luZycsIHt9KVxuICAgIGNvbnN0IGVycm9yTWVzc2FnZSA9ICdDb21tYW5kcyBhcmUgaW4gdGhlIGZvcm0gYC9jb21tYW5kIFthcmdzXWAnXG4gICAgcmV0dXJuIHtcbiAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgIGNyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlKCksXG4gICAgICAgIC4uLmF0dGFjaG1lbnRNZXNzYWdlcyxcbiAgICAgICAgY3JlYXRlVXNlck1lc3NhZ2Uoe1xuICAgICAgICAgIGNvbnRlbnQ6IHByZXBhcmVVc2VyQ29udGVudCh7XG4gICAgICAgICAgICBpbnB1dFN0cmluZzogZXJyb3JNZXNzYWdlLFxuICAgICAgICAgICAgcHJlY2VkaW5nSW5wdXRCbG9ja3MsXG4gICAgICAgICAgfSksXG4gICAgICAgIH0pLFxuICAgICAgXSxcbiAgICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcbiAgICAgIHJlc3VsdFRleHQ6IGVycm9yTWVzc2FnZSxcbiAgICB9XG4gIH1cblxuICBjb25zdCB7IGNvbW1hbmROYW1lLCBhcmdzOiBwYXJzZWRBcmdzLCBpc01jcCB9ID0gcGFyc2VkXG5cbiAgY29uc3Qgc2FuaXRpemVkQ29tbWFuZE5hbWUgPSBpc01jcFxuICAgID8gJ21jcCdcbiAgICA6ICFidWlsdEluQ29tbWFuZE5hbWVzKCkuaGFzKGNvbW1hbmROYW1lKVxuICAgICAgPyAnY3VzdG9tJ1xuICAgICAgOiBjb21tYW5kTmFtZVxuXG4gIC8vIENoZWNrIGlmIGl0J3MgYSByZWFsIGNvbW1hbmQgYmVmb3JlIHByb2Nlc3NpbmdcbiAgaWYgKCFoYXNDb21tYW5kKGNvbW1hbmROYW1lLCBjb250ZXh0Lm9wdGlvbnMuY29tbWFuZHMpKSB7XG4gICAgLy8gQ2hlY2sgaWYgdGhpcyBsb29rcyBsaWtlIGEgY29tbWFuZCBuYW1lIHZzIGEgZmlsZSBwYXRoIG9yIG90aGVyIGlucHV0XG4gICAgLy8gQWxzbyBjaGVjayBpZiBpdCdzIGFuIGFjdHVhbCBmaWxlIHBhdGggdGhhdCBleGlzdHNcbiAgICBsZXQgaXNGaWxlUGF0aCA9IGZhbHNlXG4gICAgdHJ5IHtcbiAgICAgIGF3YWl0IGdldEZzSW1wbGVtZW50YXRpb24oKS5zdGF0KGAvJHtjb21tYW5kTmFtZX1gKVxuICAgICAgaXNGaWxlUGF0aCA9IHRydWVcbiAgICB9IGNhdGNoIHtcbiAgICAgIC8vIE5vdCBhIGZpbGUgcGF0aCDigJQgdHJlYXQgYXMgY29tbWFuZCBuYW1lXG4gICAgfVxuICAgIGlmIChsb29rc0xpa2VDb21tYW5kKGNvbW1hbmROYW1lKSAmJiAhaXNGaWxlUGF0aCkge1xuICAgICAgbG9nRXZlbnQoJ3Rlbmd1X2lucHV0X3NsYXNoX2ludmFsaWQnLCB7XG4gICAgICAgIGlucHV0OlxuICAgICAgICAgIGNvbW1hbmROYW1lIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICB9KVxuXG4gICAgICBjb25zdCB1bmtub3duTWVzc2FnZSA9IGBVbmtub3duIHNraWxsOiAke2NvbW1hbmROYW1lfWBcbiAgICAgIHJldHVybiB7XG4gICAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgICAgY3JlYXRlU3ludGhldGljVXNlckNhdmVhdE1lc3NhZ2UoKSxcbiAgICAgICAgICAuLi5hdHRhY2htZW50TWVzc2FnZXMsXG4gICAgICAgICAgY3JlYXRlVXNlck1lc3NhZ2Uoe1xuICAgICAgICAgICAgY29udGVudDogcHJlcGFyZVVzZXJDb250ZW50KHtcbiAgICAgICAgICAgICAgaW5wdXRTdHJpbmc6IHVua25vd25NZXNzYWdlLFxuICAgICAgICAgICAgICBwcmVjZWRpbmdJbnB1dEJsb2NrcyxcbiAgICAgICAgICAgIH0pLFxuICAgICAgICAgIH0pLFxuICAgICAgICAgIC8vIGdoLTMyNTkxOiBwcmVzZXJ2ZSBhcmdzIHNvIHRoZSB1c2VyIGNhbiBjb3B5L3Jlc3VibWl0IHdpdGhvdXRcbiAgICAgICAgICAvLyByZXR5cGluZy4gU3lzdGVtIHdhcm5pbmcgaXMgVUktb25seSAoZmlsdGVyZWQgYmVmb3JlIEFQSSkuXG4gICAgICAgICAgLi4uKHBhcnNlZEFyZ3NcbiAgICAgICAgICAgID8gW1xuICAgICAgICAgICAgICAgIGNyZWF0ZVN5c3RlbU1lc3NhZ2UoXG4gICAgICAgICAgICAgICAgICBgQXJncyBmcm9tIHVua25vd24gc2tpbGw6ICR7cGFyc2VkQXJnc31gLFxuICAgICAgICAgICAgICAgICAgJ3dhcm5pbmcnLFxuICAgICAgICAgICAgICAgICksXG4gICAgICAgICAgICAgIF1cbiAgICAgICAgICAgIDogW10pLFxuICAgICAgICBdLFxuICAgICAgICBzaG91bGRRdWVyeTogZmFsc2UsXG4gICAgICAgIHJlc3VsdFRleHQ6IHVua25vd25NZXNzYWdlLFxuICAgICAgfVxuICAgIH1cblxuICAgIGNvbnN0IHByb21wdElkID0gcmFuZG9tVVVJRCgpXG4gICAgc2V0UHJvbXB0SWQocHJvbXB0SWQpXG4gICAgbG9nRXZlbnQoJ3Rlbmd1X2lucHV0X3Byb21wdCcsIHt9KVxuICAgIC8vIExvZyB1c2VyIHByb21wdCBldmVudCBmb3IgT1RMUFxuICAgIHZvaWQgbG9nT1RlbEV2ZW50KCd1c2VyX3Byb21wdCcsIHtcbiAgICAgIHByb21wdF9sZW5ndGg6IFN0cmluZyhpbnB1dFN0cmluZy5sZW5ndGgpLFxuICAgICAgcHJvbXB0OiByZWRhY3RJZkRpc2FibGVkKGlucHV0U3RyaW5nKSxcbiAgICAgICdwcm9tcHQuaWQnOiBwcm9tcHRJZCxcbiAgICB9KVxuICAgIHJldHVybiB7XG4gICAgICBtZXNzYWdlczogW1xuICAgICAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICAgICAgY29udGVudDogcHJlcGFyZVVzZXJDb250ZW50KHsgaW5wdXRTdHJpbmcsIHByZWNlZGluZ0lucHV0QmxvY2tzIH0pLFxuICAgICAgICAgIHV1aWQ6IHV1aWQsXG4gICAgICAgIH0pLFxuICAgICAgICAuLi5hdHRhY2htZW50TWVzc2FnZXMsXG4gICAgICBdLFxuICAgICAgc2hvdWxkUXVlcnk6IHRydWUsXG4gICAgfVxuICB9XG5cbiAgLy8gVHJhY2sgc2xhc2ggY29tbWFuZCB1c2FnZSBmb3IgZmVhdHVyZSBkaXNjb3ZlcnlcblxuICBjb25zdCB7XG4gICAgbWVzc2FnZXM6IG5ld01lc3NhZ2VzLFxuICAgIHNob3VsZFF1ZXJ5OiBtZXNzYWdlU2hvdWxkUXVlcnksXG4gICAgYWxsb3dlZFRvb2xzLFxuICAgIG1vZGVsLFxuICAgIGVmZm9ydCxcbiAgICBjb21tYW5kOiByZXR1cm5lZENvbW1hbmQsXG4gICAgcmVzdWx0VGV4dCxcbiAgICBuZXh0SW5wdXQsXG4gICAgc3VibWl0TmV4dElucHV0LFxuICB9ID0gYXdhaXQgZ2V0TWVzc2FnZXNGb3JTbGFzaENvbW1hbmQoXG4gICAgY29tbWFuZE5hbWUsXG4gICAgcGFyc2VkQXJncyxcbiAgICBzZXRUb29sSlNYLFxuICAgIGNvbnRleHQsXG4gICAgcHJlY2VkaW5nSW5wdXRCbG9ja3MsXG4gICAgaW1hZ2VDb250ZW50QmxvY2tzLFxuICAgIGlzQWxyZWFkeVByb2Nlc3NpbmcsXG4gICAgY2FuVXNlVG9vbCxcbiAgICB1dWlkLFxuICApXG5cbiAgLy8gTG9jYWwgc2xhc2ggY29tbWFuZHMgdGhhdCBza2lwIG1lc3NhZ2VzXG4gIGlmIChuZXdNZXNzYWdlcy5sZW5ndGggPT09IDApIHtcbiAgICBjb25zdCBldmVudERhdGE6IFJlY29yZDxzdHJpbmcsIGJvb2xlYW4gfCBudW1iZXIgfCB1bmRlZmluZWQ+ID0ge1xuICAgICAgaW5wdXQ6XG4gICAgICAgIHNhbml0aXplZENvbW1hbmROYW1lIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgfVxuXG4gICAgLy8gQWRkIHBsdWdpbiBtZXRhZGF0YSBpZiB0aGlzIGlzIGEgcGx1Z2luIGNvbW1hbmRcbiAgICBpZiAocmV0dXJuZWRDb21tYW5kLnR5cGUgPT09ICdwcm9tcHQnICYmIHJldHVybmVkQ29tbWFuZC5wbHVnaW5JbmZvKSB7XG4gICAgICBjb25zdCB7IHBsdWdpbk1hbmlmZXN0LCByZXBvc2l0b3J5IH0gPSByZXR1cm5lZENvbW1hbmQucGx1Z2luSW5mb1xuICAgICAgY29uc3QgeyBtYXJrZXRwbGFjZSB9ID0gcGFyc2VQbHVnaW5JZGVudGlmaWVyKHJlcG9zaXRvcnkpXG4gICAgICBjb25zdCBpc09mZmljaWFsID0gaXNPZmZpY2lhbE1hcmtldHBsYWNlTmFtZShtYXJrZXRwbGFjZSlcbiAgICAgIC8vIF9QUk9UT18qIHJvdXRlcyB0byBQSUktdGFnZ2VkIHBsdWdpbl9uYW1lL21hcmtldHBsYWNlX25hbWUgQlEgY29sdW1uc1xuICAgICAgLy8gKHVucmVkYWN0ZWQsIGFsbCB1c2Vycyk7IHBsdWdpbl9uYW1lL3BsdWdpbl9yZXBvc2l0b3J5IHN0YXkgaW5cbiAgICAgIC8vIGFkZGl0aW9uYWxfbWV0YWRhdGEgYXMgcmVkYWN0ZWQgdmFyaWFudHMgZm9yIGdlbmVyYWwtYWNjZXNzIGRhc2hib2FyZHMuXG4gICAgICBldmVudERhdGEuX1BST1RPX3BsdWdpbl9uYW1lID1cbiAgICAgICAgcGx1Z2luTWFuaWZlc3QubmFtZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfUElJX1RBR0dFRFxuICAgICAgaWYgKG1hcmtldHBsYWNlKSB7XG4gICAgICAgIGV2ZW50RGF0YS5fUFJPVE9fbWFya2V0cGxhY2VfbmFtZSA9XG4gICAgICAgICAgbWFya2V0cGxhY2UgYXMgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX1BJSV9UQUdHRURcbiAgICAgIH1cbiAgICAgIGV2ZW50RGF0YS5wbHVnaW5fcmVwb3NpdG9yeSA9IChcbiAgICAgICAgaXNPZmZpY2lhbCA/IHJlcG9zaXRvcnkgOiAndGhpcmQtcGFydHknXG4gICAgICApIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFNcbiAgICAgIGV2ZW50RGF0YS5wbHVnaW5fbmFtZSA9IChcbiAgICAgICAgaXNPZmZpY2lhbCA/IHBsdWdpbk1hbmlmZXN0Lm5hbWUgOiAndGhpcmQtcGFydHknXG4gICAgICApIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFNcbiAgICAgIGlmIChpc09mZmljaWFsICYmIHBsdWdpbk1hbmlmZXN0LnZlcnNpb24pIHtcbiAgICAgICAgZXZlbnREYXRhLnBsdWdpbl92ZXJzaW9uID1cbiAgICAgICAgICBwbHVnaW5NYW5pZmVzdC52ZXJzaW9uIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFNcbiAgICAgIH1cbiAgICAgIE9iamVjdC5hc3NpZ24oXG4gICAgICAgIGV2ZW50RGF0YSxcbiAgICAgICAgYnVpbGRQbHVnaW5Db21tYW5kVGVsZW1ldHJ5RmllbGRzKHJldHVybmVkQ29tbWFuZC5wbHVnaW5JbmZvKSxcbiAgICAgIClcbiAgICB9XG5cbiAgICBsb2dFdmVudCgndGVuZ3VfaW5wdXRfY29tbWFuZCcsIHtcbiAgICAgIC4uLmV2ZW50RGF0YSxcbiAgICAgIGludm9jYXRpb25fdHJpZ2dlcjpcbiAgICAgICAgJ3VzZXItc2xhc2gnIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICAuLi4oXCJleHRlcm5hbFwiID09PSAnYW50JyAmJiB7XG4gICAgICAgIHNraWxsX25hbWU6XG4gICAgICAgICAgY29tbWFuZE5hbWUgYXMgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyxcbiAgICAgICAgLi4uKHJldHVybmVkQ29tbWFuZC50eXBlID09PSAncHJvbXB0JyAmJiB7XG4gICAgICAgICAgc2tpbGxfc291cmNlOlxuICAgICAgICAgICAgcmV0dXJuZWRDb21tYW5kLnNvdXJjZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgICB9KSxcbiAgICAgICAgLi4uKHJldHVybmVkQ29tbWFuZC5sb2FkZWRGcm9tICYmIHtcbiAgICAgICAgICBza2lsbF9sb2FkZWRfZnJvbTpcbiAgICAgICAgICAgIHJldHVybmVkQ29tbWFuZC5sb2FkZWRGcm9tIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICAgIH0pLFxuICAgICAgICAuLi4ocmV0dXJuZWRDb21tYW5kLmtpbmQgJiYge1xuICAgICAgICAgIHNraWxsX2tpbmQ6XG4gICAgICAgICAgICByZXR1cm5lZENvbW1hbmQua2luZCBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgICB9KSxcbiAgICAgIH0pLFxuICAgIH0pXG4gICAgcmV0dXJuIHtcbiAgICAgIG1lc3NhZ2VzOiBbXSxcbiAgICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcblxuICAgICAgbW9kZWwsXG4gICAgICBuZXh0SW5wdXQsXG4gICAgICBzdWJtaXROZXh0SW5wdXQsXG4gICAgfVxuICB9XG5cbiAgLy8gRm9yIGludmFsaWQgY29tbWFuZHMsIHByZXNlcnZlIGJvdGggdGhlIHVzZXIgbWVzc2FnZSBhbmQgZXJyb3JcbiAgaWYgKFxuICAgIG5ld01lc3NhZ2VzLmxlbmd0aCA9PT0gMiAmJlxuICAgIG5ld01lc3NhZ2VzWzFdIS50eXBlID09PSAndXNlcicgJiZcbiAgICB0eXBlb2YgbmV3TWVzc2FnZXNbMV0hLm1lc3NhZ2UuY29udGVudCA9PT0gJ3N0cmluZycgJiZcbiAgICBuZXdNZXNzYWdlc1sxXSEubWVzc2FnZS5jb250ZW50LnN0YXJ0c1dpdGgoJ1Vua25vd24gY29tbWFuZDonKVxuICApIHtcbiAgICAvLyBEb24ndCBsb2cgYXMgaW52YWxpZCBpZiBpdCBsb29rcyBsaWtlIGEgY29tbW9uIGZpbGUgcGF0aFxuICAgIGNvbnN0IGxvb2tzTGlrZUZpbGVQYXRoID1cbiAgICAgIGlucHV0U3RyaW5nLnN0YXJ0c1dpdGgoJy92YXInKSB8fFxuICAgICAgaW5wdXRTdHJpbmcuc3RhcnRzV2l0aCgnL3RtcCcpIHx8XG4gICAgICBpbnB1dFN0cmluZy5zdGFydHNXaXRoKCcvcHJpdmF0ZScpXG5cbiAgICBpZiAoIWxvb2tzTGlrZUZpbGVQYXRoKSB7XG4gICAgICBsb2dFdmVudCgndGVuZ3VfaW5wdXRfc2xhc2hfaW52YWxpZCcsIHtcbiAgICAgICAgaW5wdXQ6XG4gICAgICAgICAgY29tbWFuZE5hbWUgYXMgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyxcbiAgICAgIH0pXG4gICAgfVxuXG4gICAgcmV0dXJuIHtcbiAgICAgIG1lc3NhZ2VzOiBbY3JlYXRlU3ludGhldGljVXNlckNhdmVhdE1lc3NhZ2UoKSwgLi4ubmV3TWVzc2FnZXNdLFxuICAgICAgc2hvdWxkUXVlcnk6IG1lc3NhZ2VTaG91bGRRdWVyeSxcbiAgICAgIGFsbG93ZWRUb29scyxcblxuICAgICAgbW9kZWwsXG4gICAgfVxuICB9XG5cbiAgLy8gQSB2YWxpZCBjb21tYW5kXG4gIGNvbnN0IGV2ZW50RGF0YTogUmVjb3JkPHN0cmluZywgYm9vbGVhbiB8IG51bWJlciB8IHVuZGVmaW5lZD4gPSB7XG4gICAgaW5wdXQ6XG4gICAgICBzYW5pdGl6ZWRDb21tYW5kTmFtZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICB9XG5cbiAgLy8gQWRkIHBsdWdpbiBtZXRhZGF0YSBpZiB0aGlzIGlzIGEgcGx1Z2luIGNvbW1hbmRcbiAgaWYgKHJldHVybmVkQ29tbWFuZC50eXBlID09PSAncHJvbXB0JyAmJiByZXR1cm5lZENvbW1hbmQucGx1Z2luSW5mbykge1xuICAgIGNvbnN0IHsgcGx1Z2luTWFuaWZlc3QsIHJlcG9zaXRvcnkgfSA9IHJldHVybmVkQ29tbWFuZC5wbHVnaW5JbmZvXG4gICAgY29uc3QgeyBtYXJrZXRwbGFjZSB9ID0gcGFyc2VQbHVnaW5JZGVudGlmaWVyKHJlcG9zaXRvcnkpXG4gICAgY29uc3QgaXNPZmZpY2lhbCA9IGlzT2ZmaWNpYWxNYXJrZXRwbGFjZU5hbWUobWFya2V0cGxhY2UpXG4gICAgZXZlbnREYXRhLl9QUk9UT19wbHVnaW5fbmFtZSA9XG4gICAgICBwbHVnaW5NYW5pZmVzdC5uYW1lIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19QSUlfVEFHR0VEXG4gICAgaWYgKG1hcmtldHBsYWNlKSB7XG4gICAgICBldmVudERhdGEuX1BST1RPX21hcmtldHBsYWNlX25hbWUgPVxuICAgICAgICBtYXJrZXRwbGFjZSBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfUElJX1RBR0dFRFxuICAgIH1cbiAgICBldmVudERhdGEucGx1Z2luX3JlcG9zaXRvcnkgPSAoXG4gICAgICBpc09mZmljaWFsID8gcmVwb3NpdG9yeSA6ICd0aGlyZC1wYXJ0eSdcbiAgICApIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFNcbiAgICBldmVudERhdGEucGx1Z2luX25hbWUgPSAoXG4gICAgICBpc09mZmljaWFsID8gcGx1Z2luTWFuaWZlc3QubmFtZSA6ICd0aGlyZC1wYXJ0eSdcbiAgICApIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFNcbiAgICBpZiAoaXNPZmZpY2lhbCAmJiBwbHVnaW5NYW5pZmVzdC52ZXJzaW9uKSB7XG4gICAgICBldmVudERhdGEucGx1Z2luX3ZlcnNpb24gPVxuICAgICAgICBwbHVnaW5NYW5pZmVzdC52ZXJzaW9uIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFNcbiAgICB9XG4gICAgT2JqZWN0LmFzc2lnbihcbiAgICAgIGV2ZW50RGF0YSxcbiAgICAgIGJ1aWxkUGx1Z2luQ29tbWFuZFRlbGVtZXRyeUZpZWxkcyhyZXR1cm5lZENvbW1hbmQucGx1Z2luSW5mbyksXG4gICAgKVxuICB9XG5cbiAgbG9nRXZlbnQoJ3Rlbmd1X2lucHV0X2NvbW1hbmQnLCB7XG4gICAgLi4uZXZlbnREYXRhLFxuICAgIGludm9jYXRpb25fdHJpZ2dlcjpcbiAgICAgICd1c2VyLXNsYXNoJyBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgIC4uLihcImV4dGVybmFsXCIgPT09ICdhbnQnICYmIHtcbiAgICAgIHNraWxsX25hbWU6XG4gICAgICAgIGNvbW1hbmROYW1lIGFzIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gICAgICAuLi4ocmV0dXJuZWRDb21tYW5kLnR5cGUgPT09ICdwcm9tcHQnICYmIHtcbiAgICAgICAgc2tpbGxfc291cmNlOlxuICAgICAgICAgIHJldHVybmVkQ29tbWFuZC5zb3VyY2UgYXMgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyxcbiAgICAgIH0pLFxuICAgICAgLi4uKHJldHVybmVkQ29tbWFuZC5sb2FkZWRGcm9tICYmIHtcbiAgICAgICAgc2tpbGxfbG9hZGVkX2Zyb206XG4gICAgICAgICAgcmV0dXJuZWRDb21tYW5kLmxvYWRlZEZyb20gYXMgQW5hbHl0aWNzTWV0YWRhdGFfSV9WRVJJRklFRF9USElTX0lTX05PVF9DT0RFX09SX0ZJTEVQQVRIUyxcbiAgICAgIH0pLFxuICAgICAgLi4uKHJldHVybmVkQ29tbWFuZC5raW5kICYmIHtcbiAgICAgICAgc2tpbGxfa2luZDpcbiAgICAgICAgICByZXR1cm5lZENvbW1hbmQua2luZCBhcyBBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfTk9UX0NPREVfT1JfRklMRVBBVEhTLFxuICAgICAgfSksXG4gICAgfSksXG4gIH0pXG5cbiAgLy8gQ2hlY2sgaWYgdGhpcyBpcyBhIGNvbXBhY3QgcmVzdWx0IHdoaWNoIGhhbmRsZSB0aGVpciBvd24gc3ludGhldGljIGNhdmVhdCBtZXNzYWdlIG9yZGVyaW5nXG4gIGNvbnN0IGlzQ29tcGFjdFJlc3VsdCA9XG4gICAgbmV3TWVzc2FnZXMubGVuZ3RoID4gMCAmJlxuICAgIG5ld01lc3NhZ2VzWzBdICYmXG4gICAgaXNDb21wYWN0Qm91bmRhcnlNZXNzYWdlKG5ld01lc3NhZ2VzWzBdKVxuXG4gIHJldHVybiB7XG4gICAgbWVzc2FnZXM6XG4gICAgICBtZXNzYWdlU2hvdWxkUXVlcnkgfHxcbiAgICAgIG5ld01lc3NhZ2VzLmV2ZXJ5KGlzU3lzdGVtTG9jYWxDb21tYW5kTWVzc2FnZSkgfHxcbiAgICAgIGlzQ29tcGFjdFJlc3VsdFxuICAgICAgICA/IG5ld01lc3NhZ2VzXG4gICAgICAgIDogW2NyZWF0ZVN5bnRoZXRpY1VzZXJDYXZlYXRNZXNzYWdlKCksIC4uLm5ld01lc3NhZ2VzXSxcbiAgICBzaG91bGRRdWVyeTogbWVzc2FnZVNob3VsZFF1ZXJ5LFxuICAgIGFsbG93ZWRUb29scyxcbiAgICBtb2RlbCxcbiAgICBlZmZvcnQsXG4gICAgcmVzdWx0VGV4dCxcbiAgICBuZXh0SW5wdXQsXG4gICAgc3VibWl0TmV4dElucHV0LFxuICB9XG59XG5cbmFzeW5jIGZ1bmN0aW9uIGdldE1lc3NhZ2VzRm9yU2xhc2hDb21tYW5kKFxuICBjb21tYW5kTmFtZTogc3RyaW5nLFxuICBhcmdzOiBzdHJpbmcsXG4gIHNldFRvb2xKU1g6IFNldFRvb2xKU1hGbixcbiAgY29udGV4dDogUHJvY2Vzc1VzZXJJbnB1dENvbnRleHQsXG4gIHByZWNlZGluZ0lucHV0QmxvY2tzOiBDb250ZW50QmxvY2tQYXJhbVtdLFxuICBpbWFnZUNvbnRlbnRCbG9ja3M6IENvbnRlbnRCbG9ja1BhcmFtW10sXG4gIF9pc0FscmVhZHlQcm9jZXNzaW5nPzogYm9vbGVhbixcbiAgY2FuVXNlVG9vbD86IENhblVzZVRvb2xGbixcbiAgdXVpZD86IHN0cmluZyxcbik6IFByb21pc2U8U2xhc2hDb21tYW5kUmVzdWx0PiB7XG4gIGNvbnN0IGNvbW1hbmQgPSBnZXRDb21tYW5kKGNvbW1hbmROYW1lLCBjb250ZXh0Lm9wdGlvbnMuY29tbWFuZHMpXG5cbiAgLy8gVHJhY2sgc2tpbGwgdXNhZ2UgZm9yIHJhbmtpbmcgKG9ubHkgZm9yIHByb21wdCBjb21tYW5kcyB0aGF0IGFyZSB1c2VyLWludm9jYWJsZSlcbiAgaWYgKGNvbW1hbmQudHlwZSA9PT0gJ3Byb21wdCcgJiYgY29tbWFuZC51c2VySW52b2NhYmxlICE9PSBmYWxzZSkge1xuICAgIHJlY29yZFNraWxsVXNhZ2UoY29tbWFuZE5hbWUpXG4gIH1cblxuICAvLyBDaGVjayBpZiB0aGUgY29tbWFuZCBpcyB1c2VyLWludm9jYWJsZVxuICAvLyBTa2lsbHMgd2l0aCB1c2VySW52b2NhYmxlID09PSBmYWxzZSBjYW4gb25seSBiZSBpbnZva2VkIGJ5IHRoZSBtb2RlbCB2aWEgU2tpbGxUb29sXG4gIGlmIChjb21tYW5kLnVzZXJJbnZvY2FibGUgPT09IGZhbHNlKSB7XG4gICAgcmV0dXJuIHtcbiAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICBjb250ZW50OiBwcmVwYXJlVXNlckNvbnRlbnQoe1xuICAgICAgICAgICAgaW5wdXRTdHJpbmc6IGAvJHtjb21tYW5kTmFtZX1gLFxuICAgICAgICAgICAgcHJlY2VkaW5nSW5wdXRCbG9ja3MsXG4gICAgICAgICAgfSksXG4gICAgICAgIH0pLFxuICAgICAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICAgICAgY29udGVudDogYFRoaXMgc2tpbGwgY2FuIG9ubHkgYmUgaW52b2tlZCBieSBDbGF1ZGUsIG5vdCBkaXJlY3RseSBieSB1c2Vycy4gQXNrIENsYXVkZSB0byB1c2UgdGhlIFwiJHtjb21tYW5kTmFtZX1cIiBza2lsbCBmb3IgeW91LmAsXG4gICAgICAgIH0pLFxuICAgICAgXSxcbiAgICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcbiAgICAgIGNvbW1hbmQsXG4gICAgfVxuICB9XG5cbiAgdHJ5IHtcbiAgICBzd2l0Y2ggKGNvbW1hbmQudHlwZSkge1xuICAgICAgY2FzZSAnbG9jYWwtanN4Jzoge1xuICAgICAgICByZXR1cm4gbmV3IFByb21pc2U8U2xhc2hDb21tYW5kUmVzdWx0PihyZXNvbHZlID0+IHtcbiAgICAgICAgICBsZXQgZG9uZVdhc0NhbGxlZCA9IGZhbHNlXG4gICAgICAgICAgY29uc3Qgb25Eb25lID0gKFxuICAgICAgICAgICAgcmVzdWx0Pzogc3RyaW5nLFxuICAgICAgICAgICAgb3B0aW9ucz86IHtcbiAgICAgICAgICAgICAgZGlzcGxheT86IENvbW1hbmRSZXN1bHREaXNwbGF5XG4gICAgICAgICAgICAgIHNob3VsZFF1ZXJ5PzogYm9vbGVhblxuICAgICAgICAgICAgICBtZXRhTWVzc2FnZXM/OiBzdHJpbmdbXVxuICAgICAgICAgICAgICBuZXh0SW5wdXQ/OiBzdHJpbmdcbiAgICAgICAgICAgICAgc3VibWl0TmV4dElucHV0PzogYm9vbGVhblxuICAgICAgICAgICAgfSxcbiAgICAgICAgICApID0+IHtcbiAgICAgICAgICAgIGRvbmVXYXNDYWxsZWQgPSB0cnVlXG4gICAgICAgICAgICAvLyBJZiBkaXNwbGF5IGlzICdza2lwJywgZG9uJ3QgYWRkIGFueSBtZXNzYWdlcyB0byB0aGUgY29udmVyc2F0aW9uXG4gICAgICAgICAgICBpZiAob3B0aW9ucz8uZGlzcGxheSA9PT0gJ3NraXAnKSB7XG4gICAgICAgICAgICAgIHZvaWQgcmVzb2x2ZSh7XG4gICAgICAgICAgICAgICAgbWVzc2FnZXM6IFtdLFxuICAgICAgICAgICAgICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcbiAgICAgICAgICAgICAgICBjb21tYW5kLFxuICAgICAgICAgICAgICAgIG5leHRJbnB1dDogb3B0aW9ucz8ubmV4dElucHV0LFxuICAgICAgICAgICAgICAgIHN1Ym1pdE5leHRJbnB1dDogb3B0aW9ucz8uc3VibWl0TmV4dElucHV0LFxuICAgICAgICAgICAgICB9KVxuICAgICAgICAgICAgICByZXR1cm5cbiAgICAgICAgICAgIH1cblxuICAgICAgICAgICAgLy8gTWV0YSBtZXNzYWdlcyBhcmUgbW9kZWwtdmlzaWJsZSBidXQgaGlkZGVuIGZyb20gdGhlIHVzZXJcbiAgICAgICAgICAgIGNvbnN0IG1ldGFNZXNzYWdlcyA9IChvcHRpb25zPy5tZXRhTWVzc2FnZXMgPz8gW10pLm1hcChcbiAgICAgICAgICAgICAgKGNvbnRlbnQ6IHN0cmluZykgPT4gY3JlYXRlVXNlck1lc3NhZ2UoeyBjb250ZW50LCBpc01ldGE6IHRydWUgfSksXG4gICAgICAgICAgICApXG5cbiAgICAgICAgICAgIC8vIEluIGZ1bGxzY3JlZW4gdGhlIGNvbW1hbmQganVzdCBzaG93ZWQgYXMgYSBjZW50ZXJlZCBtb2RhbFxuICAgICAgICAgICAgLy8gcGFuZSDigJQgdGhlIHRyYW5zaWVudCBub3RpZmljYXRpb24gaXMgZW5vdWdoIGZlZWRiYWNrLiBUaGVcbiAgICAgICAgICAgIC8vIFwi4p2vIC9jb25maWdcIiArIFwi4o6/IGRpc21pc3NlZFwiIHRyYW5zY3JpcHQgZW50cmllcyBhcmVcbiAgICAgICAgICAgIC8vIHR5cGU6c3lzdGVtIHN1YnR5cGU6bG9jYWxfY29tbWFuZCAodXNlci12aXNpYmxlIGJ1dCBOT1Qgc2VudFxuICAgICAgICAgICAgLy8gdG8gdGhlIG1vZGVsKSwgc28gc2tpcHBpbmcgdGhlbSBkb2Vzbid0IGFmZmVjdCBtb2RlbCBjb250ZXh0LlxuICAgICAgICAgICAgLy8gT3V0c2lkZSBmdWxsc2NyZWVuIGtlZXAgdGhlbSBzbyBzY3JvbGxiYWNrIHNob3dzIHdoYXQgcmFuLlxuICAgICAgICAgICAgLy8gT25seSBza2lwIFwiPE5hbWU+IGRpc21pc3NlZFwiIG1vZGFsLWNsb3NlIG5vdGlmaWNhdGlvbnMg4oCUXG4gICAgICAgICAgICAvLyBjb21tYW5kcyB0aGF0IGVhcmx5LWV4aXQgYmVmb3JlIHNob3dpbmcgYSBtb2RhbCAoL3VsdHJhcGxhblxuICAgICAgICAgICAgLy8gdXNhZ2UsIC9yZW5hbWUsIC9wcm9hY3RpdmUpIHVzZSBkaXNwbGF5OnN5c3RlbSBmb3IgYWN0dWFsXG4gICAgICAgICAgICAvLyBvdXRwdXQgdGhhdCBtdXN0IHJlYWNoIHRoZSB0cmFuc2NyaXB0LlxuICAgICAgICAgICAgY29uc3Qgc2tpcFRyYW5zY3JpcHQgPVxuICAgICAgICAgICAgICBpc0Z1bGxzY3JlZW5FbnZFbmFibGVkKCkgJiZcbiAgICAgICAgICAgICAgdHlwZW9mIHJlc3VsdCA9PT0gJ3N0cmluZycgJiZcbiAgICAgICAgICAgICAgcmVzdWx0LmVuZHNXaXRoKCcgZGlzbWlzc2VkJylcblxuICAgICAgICAgICAgdm9pZCByZXNvbHZlKHtcbiAgICAgICAgICAgICAgbWVzc2FnZXM6XG4gICAgICAgICAgICAgICAgb3B0aW9ucz8uZGlzcGxheSA9PT0gJ3N5c3RlbSdcbiAgICAgICAgICAgICAgICAgID8gc2tpcFRyYW5zY3JpcHRcbiAgICAgICAgICAgICAgICAgICAgPyBtZXRhTWVzc2FnZXNcbiAgICAgICAgICAgICAgICAgICAgOiBbXG4gICAgICAgICAgICAgICAgICAgICAgICBjcmVhdGVDb21tYW5kSW5wdXRNZXNzYWdlKFxuICAgICAgICAgICAgICAgICAgICAgICAgICBmb3JtYXRDb21tYW5kSW5wdXQoY29tbWFuZCwgYXJncyksXG4gICAgICAgICAgICAgICAgICAgICAgICApLFxuICAgICAgICAgICAgICAgICAgICAgICAgY3JlYXRlQ29tbWFuZElucHV0TWVzc2FnZShcbiAgICAgICAgICAgICAgICAgICAgICAgICAgYDxsb2NhbC1jb21tYW5kLXN0ZG91dD4ke3Jlc3VsdH08L2xvY2FsLWNvbW1hbmQtc3Rkb3V0PmAsXG4gICAgICAgICAgICAgICAgICAgICAgICApLFxuICAgICAgICAgICAgICAgICAgICAgICAgLi4ubWV0YU1lc3NhZ2VzLFxuICAgICAgICAgICAgICAgICAgICAgIF1cbiAgICAgICAgICAgICAgICAgIDogW1xuICAgICAgICAgICAgICAgICAgICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICAgICAgICAgICAgICAgIGNvbnRlbnQ6IHByZXBhcmVVc2VyQ29udGVudCh7XG4gICAgICAgICAgICAgICAgICAgICAgICAgIGlucHV0U3RyaW5nOiBmb3JtYXRDb21tYW5kSW5wdXQoY29tbWFuZCwgYXJncyksXG4gICAgICAgICAgICAgICAgICAgICAgICAgIHByZWNlZGluZ0lucHV0QmxvY2tzLFxuICAgICAgICAgICAgICAgICAgICAgICAgfSksXG4gICAgICAgICAgICAgICAgICAgICAgfSksXG4gICAgICAgICAgICAgICAgICAgICAgcmVzdWx0XG4gICAgICAgICAgICAgICAgICAgICAgICA/IGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICAgICAgICAgICAgICAgICAgICBjb250ZW50OiBgPGxvY2FsLWNvbW1hbmQtc3Rkb3V0PiR7cmVzdWx0fTwvbG9jYWwtY29tbWFuZC1zdGRvdXQ+YCxcbiAgICAgICAgICAgICAgICAgICAgICAgICAgfSlcbiAgICAgICAgICAgICAgICAgICAgICAgIDogY3JlYXRlVXNlck1lc3NhZ2Uoe1xuICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNvbnRlbnQ6IGA8bG9jYWwtY29tbWFuZC1zdGRvdXQ+JHtOT19DT05URU5UX01FU1NBR0V9PC9sb2NhbC1jb21tYW5kLXN0ZG91dD5gLFxuICAgICAgICAgICAgICAgICAgICAgICAgICB9KSxcbiAgICAgICAgICAgICAgICAgICAgICAuLi5tZXRhTWVzc2FnZXMsXG4gICAgICAgICAgICAgICAgICAgIF0sXG4gICAgICAgICAgICAgIHNob3VsZFF1ZXJ5OiBvcHRpb25zPy5zaG91bGRRdWVyeSA/PyBmYWxzZSxcbiAgICAgICAgICAgICAgY29tbWFuZCxcbiAgICAgICAgICAgICAgbmV4dElucHV0OiBvcHRpb25zPy5uZXh0SW5wdXQsXG4gICAgICAgICAgICAgIHN1Ym1pdE5leHRJbnB1dDogb3B0aW9ucz8uc3VibWl0TmV4dElucHV0LFxuICAgICAgICAgICAgfSlcbiAgICAgICAgICB9XG5cbiAgICAgICAgICB2b2lkIGNvbW1hbmRcbiAgICAgICAgICAgIC5sb2FkKClcbiAgICAgICAgICAgIC50aGVuKG1vZCA9PiBtb2QuY2FsbChvbkRvbmUsIHsgLi4uY29udGV4dCwgY2FuVXNlVG9vbCB9LCBhcmdzKSlcbiAgICAgICAgICAgIC50aGVuKGpzeCA9PiB7XG4gICAgICAgICAgICAgIGlmIChqc3ggPT0gbnVsbCkgcmV0dXJuXG4gICAgICAgICAgICAgIGlmIChjb250ZXh0Lm9wdGlvbnMuaXNOb25JbnRlcmFjdGl2ZVNlc3Npb24pIHtcbiAgICAgICAgICAgICAgICB2b2lkIHJlc29sdmUoe1xuICAgICAgICAgICAgICAgICAgbWVzc2FnZXM6IFtdLFxuICAgICAgICAgICAgICAgICAgc2hvdWxkUXVlcnk6IGZhbHNlLFxuICAgICAgICAgICAgICAgICAgY29tbWFuZCxcbiAgICAgICAgICAgICAgICB9KVxuICAgICAgICAgICAgICAgIHJldHVyblxuICAgICAgICAgICAgICB9XG4gICAgICAgICAgICAgIC8vIEd1YXJkOiBpZiBvbkRvbmUgZmlyZWQgZHVyaW5nIG1vZC5jYWxsKCkgKGVhcmx5LWV4aXQgcGF0aFxuICAgICAgICAgICAgICAvLyB0aGF0IGNhbGxzIG9uRG9uZSB0aGVuIHJldHVybnMgSlNYKSwgc2tpcCBzZXRUb29sSlNYLiBUaGlzXG4gICAgICAgICAgICAgIC8vIGNoYWluIGlzIGZpcmUtYW5kLWZvcmdldCDigJQgdGhlIG91dGVyIFByb21pc2UgcmVzb2x2ZXMgd2hlblxuICAgICAgICAgICAgICAvLyBvbkRvbmUgaXMgY2FsbGVkLCBzbyBleGVjdXRlVXNlcklucHV0IG1heSBoYXZlIGFscmVhZHkgcnVuXG4gICAgICAgICAgICAgIC8vIGl0cyBzZXRUb29sSlNYKHtjbGVhckxvY2FsSlNYOiB0cnVlfSkgYmVmb3JlIHdlIGdldCBoZXJlLlxuICAgICAgICAgICAgICAvLyBTZXR0aW5nIGlzTG9jYWxKU1hDb21tYW5kIGFmdGVyIGNsZWFyIGxlYXZlcyBpdCBzdHVjayB0cnVlLFxuICAgICAgICAgICAgICAvLyBibG9ja2luZyB1c2VRdWV1ZVByb2Nlc3NvciBhbmQgVGV4dElucHV0IGZvY3VzLlxuICAgICAgICAgICAgICBpZiAoZG9uZVdhc0NhbGxlZCkgcmV0dXJuXG4gICAgICAgICAgICAgIHNldFRvb2xKU1goe1xuICAgICAgICAgICAgICAgIGpzeCxcbiAgICAgICAgICAgICAgICBzaG91bGRIaWRlUHJvbXB0SW5wdXQ6IHRydWUsXG4gICAgICAgICAgICAgICAgc2hvd1NwaW5uZXI6IGZhbHNlLFxuICAgICAgICAgICAgICAgIGlzTG9jYWxKU1hDb21tYW5kOiB0cnVlLFxuICAgICAgICAgICAgICAgIGlzSW1tZWRpYXRlOiBjb21tYW5kLmltbWVkaWF0ZSA9PT0gdHJ1ZSxcbiAgICAgICAgICAgICAgfSlcbiAgICAgICAgICAgIH0pXG4gICAgICAgICAgICAuY2F0Y2goZSA9PiB7XG4gICAgICAgICAgICAgIC8vIElmIGxvYWQoKS9jYWxsKCkgdGhyb3dzIGFuZCBvbkRvbmUgbmV2ZXIgZmlyZWQsIHRoZSBvdXRlclxuICAgICAgICAgICAgICAvLyBQcm9taXNlIGhhbmdzIGZvcmV2ZXIsIGxlYXZpbmcgcXVlcnlHdWFyZCBzdHVjayBpblxuICAgICAgICAgICAgICAvLyAnZGlzcGF0Y2hpbmcnIGFuZCBkZWFkbG9ja2luZyB0aGUgcXVldWUgcHJvY2Vzc29yLlxuICAgICAgICAgICAgICBsb2dFcnJvcihlKVxuICAgICAgICAgICAgICBpZiAoZG9uZVdhc0NhbGxlZCkgcmV0dXJuXG4gICAgICAgICAgICAgIGRvbmVXYXNDYWxsZWQgPSB0cnVlXG4gICAgICAgICAgICAgIHNldFRvb2xKU1goe1xuICAgICAgICAgICAgICAgIGpzeDogbnVsbCxcbiAgICAgICAgICAgICAgICBzaG91bGRIaWRlUHJvbXB0SW5wdXQ6IGZhbHNlLFxuICAgICAgICAgICAgICAgIGNsZWFyTG9jYWxKU1g6IHRydWUsXG4gICAgICAgICAgICAgIH0pXG4gICAgICAgICAgICAgIHZvaWQgcmVzb2x2ZSh7IG1lc3NhZ2VzOiBbXSwgc2hvdWxkUXVlcnk6IGZhbHNlLCBjb21tYW5kIH0pXG4gICAgICAgICAgICB9KVxuICAgICAgICB9KVxuICAgICAgfVxuICAgICAgY2FzZSAnbG9jYWwnOiB7XG4gICAgICAgIGNvbnN0IGRpc3BsYXlBcmdzID0gY29tbWFuZC5pc1NlbnNpdGl2ZSAmJiBhcmdzLnRyaW0oKSA/ICcqKionIDogYXJnc1xuICAgICAgICBjb25zdCB1c2VyTWVzc2FnZSA9IGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICBjb250ZW50OiBwcmVwYXJlVXNlckNvbnRlbnQoe1xuICAgICAgICAgICAgaW5wdXRTdHJpbmc6IGZvcm1hdENvbW1hbmRJbnB1dChjb21tYW5kLCBkaXNwbGF5QXJncyksXG4gICAgICAgICAgICBwcmVjZWRpbmdJbnB1dEJsb2NrcyxcbiAgICAgICAgICB9KSxcbiAgICAgICAgfSlcblxuICAgICAgICB0cnkge1xuICAgICAgICAgIGNvbnN0IHN5bnRoZXRpY0NhdmVhdE1lc3NhZ2UgPSBjcmVhdGVTeW50aGV0aWNVc2VyQ2F2ZWF0TWVzc2FnZSgpXG4gICAgICAgICAgY29uc3QgbW9kID0gYXdhaXQgY29tbWFuZC5sb2FkKClcbiAgICAgICAgICBjb25zdCByZXN1bHQgPSBhd2FpdCBtb2QuY2FsbChhcmdzLCBjb250ZXh0KVxuXG4gICAgICAgICAgaWYgKHJlc3VsdC50eXBlID09PSAnc2tpcCcpIHtcbiAgICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICAgIG1lc3NhZ2VzOiBbXSxcbiAgICAgICAgICAgICAgc2hvdWxkUXVlcnk6IGZhbHNlLFxuICAgICAgICAgICAgICBjb21tYW5kLFxuICAgICAgICAgICAgfVxuICAgICAgICAgIH1cblxuICAgICAgICAgIC8vIFVzZSBkaXNjcmltaW5hdGVkIHVuaW9uIHRvIGhhbmRsZSBkaWZmZXJlbnQgcmVzdWx0IHR5cGVzXG4gICAgICAgICAgaWYgKHJlc3VsdC50eXBlID09PSAnY29tcGFjdCcpIHtcbiAgICAgICAgICAgIC8vIEFwcGVuZCBzbGFzaCBjb21tYW5kIG1lc3NhZ2VzIHRvIG1lc3NhZ2VzVG9LZWVwIHNvIHRoYXRcbiAgICAgICAgICAgIC8vIGF0dGFjaG1lbnRzIGFuZCBob29rUmVzdWx0cyBjb21lIGFmdGVyIHVzZXIgbWVzc2FnZXNcbiAgICAgICAgICAgIGNvbnN0IHNsYXNoQ29tbWFuZE1lc3NhZ2VzID0gW1xuICAgICAgICAgICAgICBzeW50aGV0aWNDYXZlYXRNZXNzYWdlLFxuICAgICAgICAgICAgICB1c2VyTWVzc2FnZSxcbiAgICAgICAgICAgICAgLi4uKHJlc3VsdC5kaXNwbGF5VGV4dFxuICAgICAgICAgICAgICAgID8gW1xuICAgICAgICAgICAgICAgICAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICAgICAgICAgICAgICAgICAgY29udGVudDogYDxsb2NhbC1jb21tYW5kLXN0ZG91dD4ke3Jlc3VsdC5kaXNwbGF5VGV4dH08L2xvY2FsLWNvbW1hbmQtc3Rkb3V0PmAsXG4gICAgICAgICAgICAgICAgICAgICAgLy8gLS1yZXN1bWUgbG9va3MgYXQgbGF0ZXN0IHRpbWVzdGFtcCBtZXNzYWdlIHRvIGRldGVybWluZSB3aGljaCBtZXNzYWdlIHRvIHJlc3VtZSBmcm9tXG4gICAgICAgICAgICAgICAgICAgICAgLy8gVGhpcyBpcyBhIHBlcmYgb3B0aW1pemF0aW9uIHRvIGF2b2lkIGhhdmluZyB0byByZWNhY3VsY2F0ZSB0aGUgbGVhZiBub2RlIGV2ZXJ5IHRpbWVcbiAgICAgICAgICAgICAgICAgICAgICAvLyBTaW5jZSB3ZSdyZSBjcmVhdGluZyBhIGJ1bmNoIG9mIHN5bnRoZXRpYyBtZXNzYWdlcyBmb3IgY29tcGFjdCwgaXQncyBpbXBvcnRhbnQgdG8gc2V0XG4gICAgICAgICAgICAgICAgICAgICAgLy8gdGhlIHRpbWVzdGFtcCBvZiB0aGUgbGFzdCBtZXNzYWdlIHRvIGJlIHNsaWdodGx5IGFmdGVyIHRoZSBjdXJyZW50IHRpbWVcbiAgICAgICAgICAgICAgICAgICAgICAvLyBUaGlzIGlzIG1vc3RseSBpbXBvcnRhbnQgZm9yIHNkayAvIC1wIG1vZGVcbiAgICAgICAgICAgICAgICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKERhdGUubm93KCkgKyAxMDApLnRvSVNPU3RyaW5nKCksXG4gICAgICAgICAgICAgICAgICAgIH0pLFxuICAgICAgICAgICAgICAgICAgXVxuICAgICAgICAgICAgICAgIDogW10pLFxuICAgICAgICAgICAgXVxuICAgICAgICAgICAgY29uc3QgY29tcGFjdGlvblJlc3VsdFdpdGhTbGFzaE1lc3NhZ2VzID0ge1xuICAgICAgICAgICAgICAuLi5yZXN1bHQuY29tcGFjdGlvblJlc3VsdCxcbiAgICAgICAgICAgICAgbWVzc2FnZXNUb0tlZXA6IFtcbiAgICAgICAgICAgICAgICAuLi4ocmVzdWx0LmNvbXBhY3Rpb25SZXN1bHQubWVzc2FnZXNUb0tlZXAgPz8gW10pLFxuICAgICAgICAgICAgICAgIC4uLnNsYXNoQ29tbWFuZE1lc3NhZ2VzLFxuICAgICAgICAgICAgICBdLFxuICAgICAgICAgICAgfVxuICAgICAgICAgICAgLy8gUmVzZXQgbWljcm9jb21wYWN0IHN0YXRlIHNpbmNlIGZ1bGwgY29tcGFjdCByZXBsYWNlcyBhbGxcbiAgICAgICAgICAgIC8vIG1lc3NhZ2VzIOKAlCBvbGQgdG9vbCBJRHMgYXJlIG5vIGxvbmdlciByZWxldmFudC4gQnVkZ2V0IHN0YXRlXG4gICAgICAgICAgICAvLyAob24gdG9vbFVzZUNvbnRleHQpIG5lZWRzIG5vIHJlc2V0OiBzdGFsZSBlbnRyaWVzIGFyZSBpbmVydFxuICAgICAgICAgICAgLy8gKFVVSURzIG5ldmVyIHJlcGVhdCwgc28gdGhleSdyZSBuZXZlciBsb29rZWQgdXApLlxuICAgICAgICAgICAgcmVzZXRNaWNyb2NvbXBhY3RTdGF0ZSgpXG4gICAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgICBtZXNzYWdlczogYnVpbGRQb3N0Q29tcGFjdE1lc3NhZ2VzKFxuICAgICAgICAgICAgICAgIGNvbXBhY3Rpb25SZXN1bHRXaXRoU2xhc2hNZXNzYWdlcyxcbiAgICAgICAgICAgICAgKSxcbiAgICAgICAgICAgICAgc2hvdWxkUXVlcnk6IGZhbHNlLFxuICAgICAgICAgICAgICBjb21tYW5kLFxuICAgICAgICAgICAgfVxuICAgICAgICAgIH1cblxuICAgICAgICAgIC8vIFRleHQgcmVzdWx0IOKAlCB1c2Ugc3lzdGVtIG1lc3NhZ2Ugc28gaXQgZG9lc24ndCByZW5kZXIgYXMgYSB1c2VyIGJ1YmJsZVxuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBtZXNzYWdlczogW1xuICAgICAgICAgICAgICB1c2VyTWVzc2FnZSxcbiAgICAgICAgICAgICAgY3JlYXRlQ29tbWFuZElucHV0TWVzc2FnZShcbiAgICAgICAgICAgICAgICBgPGxvY2FsLWNvbW1hbmQtc3Rkb3V0PiR7cmVzdWx0LnZhbHVlfTwvbG9jYWwtY29tbWFuZC1zdGRvdXQ+YCxcbiAgICAgICAgICAgICAgKSxcbiAgICAgICAgICAgIF0sXG4gICAgICAgICAgICBzaG91bGRRdWVyeTogZmFsc2UsXG4gICAgICAgICAgICBjb21tYW5kLFxuICAgICAgICAgICAgcmVzdWx0VGV4dDogcmVzdWx0LnZhbHVlLFxuICAgICAgICAgIH1cbiAgICAgICAgfSBjYXRjaCAoZSkge1xuICAgICAgICAgIGxvZ0Vycm9yKGUpXG4gICAgICAgICAgcmV0dXJuIHtcbiAgICAgICAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgICAgICAgIHVzZXJNZXNzYWdlLFxuICAgICAgICAgICAgICBjcmVhdGVDb21tYW5kSW5wdXRNZXNzYWdlKFxuICAgICAgICAgICAgICAgIGA8bG9jYWwtY29tbWFuZC1zdGRlcnI+JHtTdHJpbmcoZSl9PC9sb2NhbC1jb21tYW5kLXN0ZGVycj5gLFxuICAgICAgICAgICAgICApLFxuICAgICAgICAgICAgXSxcbiAgICAgICAgICAgIHNob3VsZFF1ZXJ5OiBmYWxzZSxcbiAgICAgICAgICAgIGNvbW1hbmQsXG4gICAgICAgICAgfVxuICAgICAgICB9XG4gICAgICB9XG4gICAgICBjYXNlICdwcm9tcHQnOiB7XG4gICAgICAgIHRyeSB7XG4gICAgICAgICAgLy8gQ2hlY2sgaWYgY29tbWFuZCBzaG91bGQgcnVuIGFzIGZvcmtlZCBzdWItYWdlbnRcbiAgICAgICAgICBpZiAoY29tbWFuZC5jb250ZXh0ID09PSAnZm9yaycpIHtcbiAgICAgICAgICAgIHJldHVybiBhd2FpdCBleGVjdXRlRm9ya2VkU2xhc2hDb21tYW5kKFxuICAgICAgICAgICAgICBjb21tYW5kLFxuICAgICAgICAgICAgICBhcmdzLFxuICAgICAgICAgICAgICBjb250ZXh0LFxuICAgICAgICAgICAgICBwcmVjZWRpbmdJbnB1dEJsb2NrcyxcbiAgICAgICAgICAgICAgc2V0VG9vbEpTWCxcbiAgICAgICAgICAgICAgY2FuVXNlVG9vbCA/PyBoYXNQZXJtaXNzaW9uc1RvVXNlVG9vbCxcbiAgICAgICAgICAgIClcbiAgICAgICAgICB9XG5cbiAgICAgICAgICByZXR1cm4gYXdhaXQgZ2V0TWVzc2FnZXNGb3JQcm9tcHRTbGFzaENvbW1hbmQoXG4gICAgICAgICAgICBjb21tYW5kLFxuICAgICAgICAgICAgYXJncyxcbiAgICAgICAgICAgIGNvbnRleHQsXG4gICAgICAgICAgICBwcmVjZWRpbmdJbnB1dEJsb2NrcyxcbiAgICAgICAgICAgIGltYWdlQ29udGVudEJsb2NrcyxcbiAgICAgICAgICAgIHV1aWQsXG4gICAgICAgICAgKVxuICAgICAgICB9IGNhdGNoIChlKSB7XG4gICAgICAgICAgLy8gSGFuZGxlIGFib3J0IGVycm9ycyBzcGVjaWFsbHkgdG8gc2hvdyBwcm9wZXIgXCJJbnRlcnJ1cHRlZFwiIG1lc3NhZ2VcbiAgICAgICAgICBpZiAoZSBpbnN0YW5jZW9mIEFib3J0RXJyb3IpIHtcbiAgICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICAgIG1lc3NhZ2VzOiBbXG4gICAgICAgICAgICAgICAgY3JlYXRlVXNlck1lc3NhZ2Uoe1xuICAgICAgICAgICAgICAgICAgY29udGVudDogcHJlcGFyZVVzZXJDb250ZW50KHtcbiAgICAgICAgICAgICAgICAgICAgaW5wdXRTdHJpbmc6IGZvcm1hdENvbW1hbmRJbnB1dChjb21tYW5kLCBhcmdzKSxcbiAgICAgICAgICAgICAgICAgICAgcHJlY2VkaW5nSW5wdXRCbG9ja3MsXG4gICAgICAgICAgICAgICAgICB9KSxcbiAgICAgICAgICAgICAgICB9KSxcbiAgICAgICAgICAgICAgICBjcmVhdGVVc2VySW50ZXJydXB0aW9uTWVzc2FnZSh7IHRvb2xVc2U6IGZhbHNlIH0pLFxuICAgICAgICAgICAgICBdLFxuICAgICAgICAgICAgICBzaG91bGRRdWVyeTogZmFsc2UsXG4gICAgICAgICAgICAgIGNvbW1hbmQsXG4gICAgICAgICAgICB9XG4gICAgICAgICAgfVxuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBtZXNzYWdlczogW1xuICAgICAgICAgICAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICAgICAgICAgICAgY29udGVudDogcHJlcGFyZVVzZXJDb250ZW50KHtcbiAgICAgICAgICAgICAgICAgIGlucHV0U3RyaW5nOiBmb3JtYXRDb21tYW5kSW5wdXQoY29tbWFuZCwgYXJncyksXG4gICAgICAgICAgICAgICAgICBwcmVjZWRpbmdJbnB1dEJsb2NrcyxcbiAgICAgICAgICAgICAgICB9KSxcbiAgICAgICAgICAgICAgfSksXG4gICAgICAgICAgICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgICAgICAgICAgICBjb250ZW50OiBgPGxvY2FsLWNvbW1hbmQtc3RkZXJyPiR7U3RyaW5nKGUpfTwvbG9jYWwtY29tbWFuZC1zdGRlcnI+YCxcbiAgICAgICAgICAgICAgfSksXG4gICAgICAgICAgICBdLFxuICAgICAgICAgICAgc2hvdWxkUXVlcnk6IGZhbHNlLFxuICAgICAgICAgICAgY29tbWFuZCxcbiAgICAgICAgICB9XG4gICAgICAgIH1cbiAgICAgIH1cbiAgICB9XG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBpZiAoZSBpbnN0YW5jZW9mIE1hbGZvcm1lZENvbW1hbmRFcnJvcikge1xuICAgICAgcmV0dXJuIHtcbiAgICAgICAgbWVzc2FnZXM6IFtcbiAgICAgICAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICAgICAgICBjb250ZW50OiBwcmVwYXJlVXNlckNvbnRlbnQoe1xuICAgICAgICAgICAgICBpbnB1dFN0cmluZzogZS5tZXNzYWdlLFxuICAgICAgICAgICAgICBwcmVjZWRpbmdJbnB1dEJsb2NrcyxcbiAgICAgICAgICAgIH0pLFxuICAgICAgICAgIH0pLFxuICAgICAgICBdLFxuICAgICAgICBzaG91bGRRdWVyeTogZmFsc2UsXG4gICAgICAgIGNvbW1hbmQsXG4gICAgICB9XG4gICAgfVxuICAgIHRocm93IGVcbiAgfVxufVxuXG5mdW5jdGlvbiBmb3JtYXRDb21tYW5kSW5wdXQoY29tbWFuZDogQ29tbWFuZEJhc2UsIGFyZ3M6IHN0cmluZyk6IHN0cmluZyB7XG4gIHJldHVybiBmb3JtYXRDb21tYW5kSW5wdXRUYWdzKGdldENvbW1hbmROYW1lKGNvbW1hbmQpLCBhcmdzKVxufVxuXG4vKipcbiAqIEZvcm1hdHMgdGhlIG1ldGFkYXRhIGZvciBhIHNraWxsIGxvYWRpbmcgbWVzc2FnZS5cbiAqIFVzZWQgYnkgdGhlIFNraWxsIHRvb2wgYW5kIGZvciBzdWJhZ2VudCBza2lsbCBwcmVsb2FkaW5nLlxuICovXG5leHBvcnQgZnVuY3Rpb24gZm9ybWF0U2tpbGxMb2FkaW5nTWV0YWRhdGEoXG4gIHNraWxsTmFtZTogc3RyaW5nLFxuICBfcHJvZ3Jlc3NNZXNzYWdlOiBzdHJpbmcgPSAnbG9hZGluZycsXG4pOiBzdHJpbmcge1xuICAvLyBVc2Ugc2tpbGwgbmFtZSBvbmx5IC0gVXNlckNvbW1hbmRNZXNzYWdlIHJlbmRlcnMgYXMgXCJTa2lsbChuYW1lKVwiXG4gIHJldHVybiBbXG4gICAgYDwke0NPTU1BTkRfTUVTU0FHRV9UQUd9PiR7c2tpbGxOYW1lfTwvJHtDT01NQU5EX01FU1NBR0VfVEFHfT5gLFxuICAgIGA8JHtDT01NQU5EX05BTUVfVEFHfT4ke3NraWxsTmFtZX08LyR7Q09NTUFORF9OQU1FX1RBR30+YCxcbiAgICBgPHNraWxsLWZvcm1hdD50cnVlPC9za2lsbC1mb3JtYXQ+YCxcbiAgXS5qb2luKCdcXG4nKVxufVxuXG4vKipcbiAqIEZvcm1hdHMgdGhlIG1ldGFkYXRhIGZvciBhIHNsYXNoIGNvbW1hbmQgbG9hZGluZyBtZXNzYWdlLlxuICovXG5mdW5jdGlvbiBmb3JtYXRTbGFzaENvbW1hbmRMb2FkaW5nTWV0YWRhdGEoXG4gIGNvbW1hbmROYW1lOiBzdHJpbmcsXG4gIGFyZ3M/OiBzdHJpbmcsXG4pOiBzdHJpbmcge1xuICByZXR1cm4gW1xuICAgIGA8JHtDT01NQU5EX01FU1NBR0VfVEFHfT4ke2NvbW1hbmROYW1lfTwvJHtDT01NQU5EX01FU1NBR0VfVEFHfT5gLFxuICAgIGA8JHtDT01NQU5EX05BTUVfVEFHfT4vJHtjb21tYW5kTmFtZX08LyR7Q09NTUFORF9OQU1FX1RBR30+YCxcbiAgICBhcmdzID8gYDxjb21tYW5kLWFyZ3M+JHthcmdzfTwvY29tbWFuZC1hcmdzPmAgOiBudWxsLFxuICBdXG4gICAgLmZpbHRlcihCb29sZWFuKVxuICAgIC5qb2luKCdcXG4nKVxufVxuXG4vKipcbiAqIEZvcm1hdHMgdGhlIGxvYWRpbmcgbWV0YWRhdGEgZm9yIGEgY29tbWFuZCAoc2tpbGwgb3Igc2xhc2ggY29tbWFuZCkuXG4gKiBVc2VyLWludm9jYWJsZSBza2lsbHMgdXNlIHNsYXNoIGNvbW1hbmQgZm9ybWF0ICgvbmFtZSksIHdoaWxlIG1vZGVsLW9ubHlcbiAqIHNraWxscyB1c2UgdGhlIHNraWxsIGZvcm1hdCAoXCJUaGUgWCBza2lsbCBpcyBydW5uaW5nXCIpLlxuICovXG5mdW5jdGlvbiBmb3JtYXRDb21tYW5kTG9hZGluZ01ldGFkYXRhKFxuICBjb21tYW5kOiBDb21tYW5kQmFzZSAmIFByb21wdENvbW1hbmQsXG4gIGFyZ3M/OiBzdHJpbmcsXG4pOiBzdHJpbmcge1xuICAvLyBVc2UgY29tbWFuZC5uYW1lICh0aGUgcXVhbGlmaWVkIG5hbWUgaW5jbHVkaW5nIHBsdWdpbiBwcmVmaXgsIGUuZy5cbiAgLy8gXCJwcm9kdWN0LW1hbmFnZW1lbnQ6ZmVhdHVyZS1zcGVjXCIpIGluc3RlYWQgb2YgdXNlckZhY2luZ05hbWUoKSB3aGljaCBtYXlcbiAgLy8gc3RyaXAgdGhlIHBsdWdpbiBwcmVmaXggdmlhIGRpc3BsYXlOYW1lIGZhbGxiYWNrLlxuICAvLyBVc2VyLWludm9jYWJsZSBza2lsbHMgc2hvdWxkIHNob3cgYXMgL2NvbW1hbmQtbmFtZSBsaWtlIHJlZ3VsYXIgc2xhc2ggY29tbWFuZHNcbiAgaWYgKGNvbW1hbmQudXNlckludm9jYWJsZSAhPT0gZmFsc2UpIHtcbiAgICByZXR1cm4gZm9ybWF0U2xhc2hDb21tYW5kTG9hZGluZ01ldGFkYXRhKGNvbW1hbmQubmFtZSwgYXJncylcbiAgfVxuICAvLyBNb2RlbC1vbmx5IHNraWxscyAodXNlckludm9jYWJsZTogZmFsc2UpIHNob3cgYXMgXCJUaGUgWCBza2lsbCBpcyBydW5uaW5nXCJcbiAgaWYgKFxuICAgIGNvbW1hbmQubG9hZGVkRnJvbSA9PT0gJ3NraWxscycgfHxcbiAgICBjb21tYW5kLmxvYWRlZEZyb20gPT09ICdwbHVnaW4nIHx8XG4gICAgY29tbWFuZC5sb2FkZWRGcm9tID09PSAnbWNwJ1xuICApIHtcbiAgICByZXR1cm4gZm9ybWF0U2tpbGxMb2FkaW5nTWV0YWRhdGEoY29tbWFuZC5uYW1lLCBjb21tYW5kLnByb2dyZXNzTWVzc2FnZSlcbiAgfVxuICByZXR1cm4gZm9ybWF0U2xhc2hDb21tYW5kTG9hZGluZ01ldGFkYXRhKGNvbW1hbmQubmFtZSwgYXJncylcbn1cblxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHByb2Nlc3NQcm9tcHRTbGFzaENvbW1hbmQoXG4gIGNvbW1hbmROYW1lOiBzdHJpbmcsXG4gIGFyZ3M6IHN0cmluZyxcbiAgY29tbWFuZHM6IENvbW1hbmRbXSxcbiAgY29udGV4dDogVG9vbFVzZUNvbnRleHQsXG4gIGltYWdlQ29udGVudEJsb2NrczogQ29udGVudEJsb2NrUGFyYW1bXSA9IFtdLFxuKTogUHJvbWlzZTxTbGFzaENvbW1hbmRSZXN1bHQ+IHtcbiAgY29uc3QgY29tbWFuZCA9IGZpbmRDb21tYW5kKGNvbW1hbmROYW1lLCBjb21tYW5kcylcbiAgaWYgKCFjb21tYW5kKSB7XG4gICAgdGhyb3cgbmV3IE1hbGZvcm1lZENvbW1hbmRFcnJvcihgVW5rbm93biBjb21tYW5kOiAke2NvbW1hbmROYW1lfWApXG4gIH1cbiAgaWYgKGNvbW1hbmQudHlwZSAhPT0gJ3Byb21wdCcpIHtcbiAgICB0aHJvdyBuZXcgRXJyb3IoXG4gICAgICBgVW5leHBlY3RlZCAke2NvbW1hbmQudHlwZX0gY29tbWFuZC4gRXhwZWN0ZWQgJ3Byb21wdCcgY29tbWFuZC4gVXNlIC8ke2NvbW1hbmROYW1lfSBkaXJlY3RseSBpbiB0aGUgbWFpbiBjb252ZXJzYXRpb24uYCxcbiAgICApXG4gIH1cbiAgcmV0dXJuIGdldE1lc3NhZ2VzRm9yUHJvbXB0U2xhc2hDb21tYW5kKFxuICAgIGNvbW1hbmQsXG4gICAgYXJncyxcbiAgICBjb250ZXh0LFxuICAgIFtdLFxuICAgIGltYWdlQ29udGVudEJsb2NrcyxcbiAgKVxufVxuXG5hc3luYyBmdW5jdGlvbiBnZXRNZXNzYWdlc0ZvclByb21wdFNsYXNoQ29tbWFuZChcbiAgY29tbWFuZDogQ29tbWFuZEJhc2UgJiBQcm9tcHRDb21tYW5kLFxuICBhcmdzOiBzdHJpbmcsXG4gIGNvbnRleHQ6IFRvb2xVc2VDb250ZXh0LFxuICBwcmVjZWRpbmdJbnB1dEJsb2NrczogQ29udGVudEJsb2NrUGFyYW1bXSA9IFtdLFxuICBpbWFnZUNvbnRlbnRCbG9ja3M6IENvbnRlbnRCbG9ja1BhcmFtW10gPSBbXSxcbiAgdXVpZD86IHN0cmluZyxcbik6IFByb21pc2U8U2xhc2hDb21tYW5kUmVzdWx0PiB7XG4gIC8vIEluIGNvb3JkaW5hdG9yIG1vZGUgKG1haW4gdGhyZWFkIG9ubHkpLCBza2lwIGxvYWRpbmcgdGhlIGZ1bGwgc2tpbGwgY29udGVudFxuICAvLyBhbmQgcGVybWlzc2lvbnMuIFRoZSBjb29yZGluYXRvciBvbmx5IGhhcyBBZ2VudCArIFRhc2tTdG9wIHRvb2xzLCBzbyB0aGVcbiAgLy8gc2tpbGwgY29udGVudCBhbmQgYWxsb3dlZFRvb2xzIGFyZSB1c2VsZXNzLiBJbnN0ZWFkLCBzZW5kIGEgYnJpZWYgc3VtbWFyeVxuICAvLyB0ZWxsaW5nIHRoZSBjb29yZGluYXRvciBob3cgdG8gZGVsZWdhdGUgdGhpcyBza2lsbCB0byBhIHdvcmtlci5cbiAgLy9cbiAgLy8gV29ya2VycyBydW4gaW4tcHJvY2VzcyBhbmQgaW5oZXJpdCBDTEFVREVfQ09ERV9DT09SRElOQVRPUl9NT0RFIGZyb20gdGhlXG4gIC8vIHBhcmVudCBlbnYsIHNvIHdlIGFsc28gY2hlY2sgIWNvbnRleHQuYWdlbnRJZDogYWdlbnRJZCBpcyBvbmx5IHNldCBmb3JcbiAgLy8gc3ViYWdlbnRzLCBsZXR0aW5nIHdvcmtlcnMgZmFsbCB0aHJvdWdoIHRvIGdldFByb21wdEZvckNvbW1hbmQgYW5kIHJlY2VpdmVcbiAgLy8gdGhlIHJlYWwgc2tpbGwgY29udGVudCB3aGVuIHRoZXkgaW52b2tlIHRoZSBTa2lsbCB0b29sLlxuICBpZiAoXG4gICAgZmVhdHVyZSgnQ09PUkRJTkFUT1JfTU9ERScpICYmXG4gICAgaXNFbnZUcnV0aHkocHJvY2Vzcy5lbnYuQ0xBVURFX0NPREVfQ09PUkRJTkFUT1JfTU9ERSkgJiZcbiAgICAhY29udGV4dC5hZ2VudElkXG4gICkge1xuICAgIGNvbnN0IG1ldGFkYXRhID0gZm9ybWF0Q29tbWFuZExvYWRpbmdNZXRhZGF0YShjb21tYW5kLCBhcmdzKVxuICAgIGNvbnN0IHBhcnRzOiBzdHJpbmdbXSA9IFtcbiAgICAgIGBTa2lsbCBcIi8ke2NvbW1hbmQubmFtZX1cIiBpcyBhdmFpbGFibGUgZm9yIHdvcmtlcnMuYCxcbiAgICBdXG4gICAgaWYgKGNvbW1hbmQuZGVzY3JpcHRpb24pIHtcbiAgICAgIHBhcnRzLnB1c2goYERlc2NyaXB0aW9uOiAke2NvbW1hbmQuZGVzY3JpcHRpb259YClcbiAgICB9XG4gICAgaWYgKGNvbW1hbmQud2hlblRvVXNlKSB7XG4gICAgICBwYXJ0cy5wdXNoKGBXaGVuIHRvIHVzZTogJHtjb21tYW5kLndoZW5Ub1VzZX1gKVxuICAgIH1cbiAgICBjb25zdCBza2lsbEFsbG93ZWRUb29scyA9IGNvbW1hbmQuYWxsb3dlZFRvb2xzID8/IFtdXG4gICAgaWYgKHNraWxsQWxsb3dlZFRvb2xzLmxlbmd0aCA+IDApIHtcbiAgICAgIHBhcnRzLnB1c2goXG4gICAgICAgIGBUaGlzIHNraWxsIGdyYW50cyB3b3JrZXJzIGFkZGl0aW9uYWwgdG9vbCBwZXJtaXNzaW9uczogJHtza2lsbEFsbG93ZWRUb29scy5qb2luKCcsICcpfWAsXG4gICAgICApXG4gICAgfVxuICAgIHBhcnRzLnB1c2goXG4gICAgICBgXFxuSW5zdHJ1Y3QgYSB3b3JrZXIgdG8gdXNlIHRoaXMgc2tpbGwgYnkgaW5jbHVkaW5nIFwiVXNlIHRoZSAvJHtjb21tYW5kLm5hbWV9IHNraWxsXCIgaW4geW91ciBBZ2VudCBwcm9tcHQuIFRoZSB3b3JrZXIgaGFzIGFjY2VzcyB0byB0aGUgU2tpbGwgdG9vbCBhbmQgd2lsbCByZWNlaXZlIHRoZSBza2lsbCdzIGNvbnRlbnQgYW5kIHBlcm1pc3Npb25zIHdoZW4gaXQgaW52b2tlcyBpdC5gLFxuICAgIClcbiAgICBjb25zdCBzdW1tYXJ5Q29udGVudDogQ29udGVudEJsb2NrUGFyYW1bXSA9IFtcbiAgICAgIHsgdHlwZTogJ3RleHQnLCB0ZXh0OiBwYXJ0cy5qb2luKCdcXG4nKSB9LFxuICAgIF1cbiAgICByZXR1cm4ge1xuICAgICAgbWVzc2FnZXM6IFtcbiAgICAgICAgY3JlYXRlVXNlck1lc3NhZ2UoeyBjb250ZW50OiBtZXRhZGF0YSwgdXVpZCB9KSxcbiAgICAgICAgY3JlYXRlVXNlck1lc3NhZ2UoeyBjb250ZW50OiBzdW1tYXJ5Q29udGVudCwgaXNNZXRhOiB0cnVlIH0pLFxuICAgICAgXSxcbiAgICAgIHNob3VsZFF1ZXJ5OiB0cnVlLFxuICAgICAgbW9kZWw6IGNvbW1hbmQubW9kZWwsXG4gICAgICBlZmZvcnQ6IGNvbW1hbmQuZWZmb3J0LFxuICAgICAgY29tbWFuZCxcbiAgICB9XG4gIH1cblxuICBjb25zdCByZXN1bHQgPSBhd2FpdCBjb21tYW5kLmdldFByb21wdEZvckNvbW1hbmQoYXJncywgY29udGV4dClcblxuICAvLyBSZWdpc3RlciBza2lsbCBob29rcyBpZiBkZWZpbmVkLiBVbmRlciBbXCJob29rc1wiXS1vbmx5IChza2lsbHMgbm90IGxvY2tlZCksXG4gIC8vIHVzZXIgc2tpbGxzIHN0aWxsIGxvYWQgYW5kIHJlYWNoIHRoaXMgcG9pbnQg4oCUIGJsb2NrIGhvb2sgUkVHSVNUUkFUSU9OIGhlcmVcbiAgLy8gd2hlcmUgc291cmNlIGlzIGtub3duLiBNaXJyb3JzIHRoZSBhZ2VudCBmcm9udG1hdHRlciBnYXRlIGluIHJ1bkFnZW50LnRzLlxuICBjb25zdCBob29rc0FsbG93ZWRGb3JUaGlzU2tpbGwgPVxuICAgICFpc1Jlc3RyaWN0ZWRUb1BsdWdpbk9ubHkoJ2hvb2tzJykgfHwgaXNTb3VyY2VBZG1pblRydXN0ZWQoY29tbWFuZC5zb3VyY2UpXG4gIGlmIChjb21tYW5kLmhvb2tzICYmIGhvb2tzQWxsb3dlZEZvclRoaXNTa2lsbCkge1xuICAgIGNvbnN0IHNlc3Npb25JZCA9IGdldFNlc3Npb25JZCgpXG4gICAgcmVnaXN0ZXJTa2lsbEhvb2tzKFxuICAgICAgY29udGV4dC5zZXRBcHBTdGF0ZSxcbiAgICAgIHNlc3Npb25JZCxcbiAgICAgIGNvbW1hbmQuaG9va3MsXG4gICAgICBjb21tYW5kLm5hbWUsXG4gICAgICBjb21tYW5kLnR5cGUgPT09ICdwcm9tcHQnID8gY29tbWFuZC5za2lsbFJvb3QgOiB1bmRlZmluZWQsXG4gICAgKVxuICB9XG5cbiAgLy8gUmVjb3JkIHNraWxsIGludm9jYXRpb24gZm9yIGNvbXBhY3Rpb24gcHJlc2VydmF0aW9uLCBzY29wZWQgYnkgYWdlbnQgY29udGV4dC5cbiAgLy8gU2tpbGxzIGFyZSB0YWdnZWQgd2l0aCB0aGVpciBhZ2VudElkIHNvIG9ubHkgc2tpbGxzIGJlbG9uZ2luZyB0byB0aGUgY3VycmVudFxuICAvLyBhZ2VudCBhcmUgcmVzdG9yZWQgZHVyaW5nIGNvbXBhY3Rpb24gKHByZXZlbnRpbmcgY3Jvc3MtYWdlbnQgbGVha3MpLlxuICBjb25zdCBza2lsbFBhdGggPSBjb21tYW5kLnNvdXJjZVxuICAgID8gYCR7Y29tbWFuZC5zb3VyY2V9OiR7Y29tbWFuZC5uYW1lfWBcbiAgICA6IGNvbW1hbmQubmFtZVxuICBjb25zdCBza2lsbENvbnRlbnQgPSByZXN1bHRcbiAgICAuZmlsdGVyKChiKTogYiBpcyBUZXh0QmxvY2tQYXJhbSA9PiBiLnR5cGUgPT09ICd0ZXh0JylcbiAgICAubWFwKGIgPT4gYi50ZXh0KVxuICAgIC5qb2luKCdcXG5cXG4nKVxuICBhZGRJbnZva2VkU2tpbGwoXG4gICAgY29tbWFuZC5uYW1lLFxuICAgIHNraWxsUGF0aCxcbiAgICBza2lsbENvbnRlbnQsXG4gICAgZ2V0QWdlbnRDb250ZXh0KCk/LmFnZW50SWQgPz8gbnVsbCxcbiAgKVxuXG4gIGNvbnN0IG1ldGFkYXRhID0gZm9ybWF0Q29tbWFuZExvYWRpbmdNZXRhZGF0YShjb21tYW5kLCBhcmdzKVxuXG4gIGNvbnN0IGFkZGl0aW9uYWxBbGxvd2VkVG9vbHMgPSBwYXJzZVRvb2xMaXN0RnJvbUNMSShcbiAgICBjb21tYW5kLmFsbG93ZWRUb29scyA/PyBbXSxcbiAgKVxuXG4gIC8vIENyZWF0ZSBjb250ZW50IGZvciB0aGUgbWFpbiBtZXNzYWdlLCBpbmNsdWRpbmcgYW55IHBhc3RlZCBpbWFnZXNcbiAgY29uc3QgbWFpbk1lc3NhZ2VDb250ZW50OiBDb250ZW50QmxvY2tQYXJhbVtdID1cbiAgICBpbWFnZUNvbnRlbnRCbG9ja3MubGVuZ3RoID4gMCB8fCBwcmVjZWRpbmdJbnB1dEJsb2Nrcy5sZW5ndGggPiAwXG4gICAgICA/IFsuLi5pbWFnZUNvbnRlbnRCbG9ja3MsIC4uLnByZWNlZGluZ0lucHV0QmxvY2tzLCAuLi5yZXN1bHRdXG4gICAgICA6IHJlc3VsdFxuXG4gIC8vIEV4dHJhY3QgYXR0YWNobWVudHMgZnJvbSBjb21tYW5kIGFyZ3VtZW50cyAoQC1tZW50aW9ucywgTUNQIHJlc291cmNlcyxcbiAgLy8gYWdlbnQgbWVudGlvbnMgaW4gU0tJTEwubWQpLiBza2lwU2tpbGxEaXNjb3ZlcnkgcHJldmVudHMgdGhlIFNLSUxMLm1kXG4gIC8vIGNvbnRlbnQgaXRzZWxmIGZyb20gdHJpZ2dlcmluZyBkaXNjb3Zlcnkg4oCUIGl0J3MgbWV0YS1jb250ZW50LCBub3QgdXNlclxuICAvLyBpbnRlbnQsIGFuZCBhIGxhcmdlIFNLSUxMLm1kIChlLmcuIDExMEtCKSB3b3VsZCBmaXJlIGNodW5rZWQgQUtJIHF1ZXJpZXNcbiAgLy8gYWRkaW5nIHNlY29uZHMgb2YgbGF0ZW5jeSB0byBldmVyeSBza2lsbCBpbnZvY2F0aW9uLlxuICBjb25zdCBhdHRhY2htZW50TWVzc2FnZXMgPSBhd2FpdCB0b0FycmF5KFxuICAgIGdldEF0dGFjaG1lbnRNZXNzYWdlcyhcbiAgICAgIHJlc3VsdFxuICAgICAgICAuZmlsdGVyKChibG9jayk6IGJsb2NrIGlzIFRleHRCbG9ja1BhcmFtID0+IGJsb2NrLnR5cGUgPT09ICd0ZXh0JylcbiAgICAgICAgLm1hcChibG9jayA9PiBibG9jay50ZXh0KVxuICAgICAgICAuam9pbignICcpLFxuICAgICAgY29udGV4dCxcbiAgICAgIG51bGwsXG4gICAgICBbXSwgLy8gcXVldWVkQ29tbWFuZHMgLSBoYW5kbGVkIGJ5IHF1ZXJ5LnRzIGZvciBtaWQtdHVybiBhdHRhY2htZW50c1xuICAgICAgY29udGV4dC5tZXNzYWdlcyxcbiAgICAgICdyZXBsX21haW5fdGhyZWFkJyxcbiAgICAgIHsgc2tpcFNraWxsRGlzY292ZXJ5OiB0cnVlIH0sXG4gICAgKSxcbiAgKVxuXG4gIGNvbnN0IG1lc3NhZ2VzID0gW1xuICAgIGNyZWF0ZVVzZXJNZXNzYWdlKHtcbiAgICAgIGNvbnRlbnQ6IG1ldGFkYXRhLFxuICAgICAgdXVpZCxcbiAgICB9KSxcbiAgICBjcmVhdGVVc2VyTWVzc2FnZSh7XG4gICAgICBjb250ZW50OiBtYWluTWVzc2FnZUNvbnRlbnQsXG4gICAgICBpc01ldGE6IHRydWUsXG4gICAgfSksXG4gICAgLi4uYXR0YWNobWVudE1lc3NhZ2VzLFxuICAgIGNyZWF0ZUF0dGFjaG1lbnRNZXNzYWdlKHtcbiAgICAgIHR5cGU6ICdjb21tYW5kX3Blcm1pc3Npb25zJyxcbiAgICAgIGFsbG93ZWRUb29sczogYWRkaXRpb25hbEFsbG93ZWRUb29scyxcbiAgICAgIG1vZGVsOiBjb21tYW5kLm1vZGVsLFxuICAgIH0pLFxuICBdXG5cbiAgcmV0dXJuIHtcbiAgICBtZXNzYWdlcyxcbiAgICBzaG91bGRRdWVyeTogdHJ1ZSxcbiAgICBhbGxvd2VkVG9vbHM6IGFkZGl0aW9uYWxBbGxvd2VkVG9vbHMsXG4gICAgbW9kZWw6IGNvbW1hbmQubW9kZWwsXG4gICAgZWZmb3J0OiBjb21tYW5kLmVmZm9ydCxcbiAgICBjb21tYW5kLFxuICB9XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLFNBQVNBLE9BQU8sUUFBUSxZQUFZO0FBQ3BDLGNBQ0VDLGlCQUFpQixFQUNqQkMsY0FBYyxRQUNULDZCQUE2QjtBQUNwQyxTQUFTQyxVQUFVLFFBQVEsUUFBUTtBQUNuQyxTQUFTQyxXQUFXLFFBQVEsd0JBQXdCO0FBQ3BELFNBQ0VDLG1CQUFtQixFQUNuQixLQUFLQyxPQUFPLEVBQ1osS0FBS0MsV0FBVyxFQUNoQkMsV0FBVyxFQUNYQyxVQUFVLEVBQ1ZDLGNBQWMsRUFDZEMsVUFBVSxFQUNWLEtBQUtDLGFBQWEsUUFDYixpQkFBaUI7QUFDeEIsU0FBU0Msa0JBQWtCLFFBQVEsMkJBQTJCO0FBQzlELGNBQWNDLFlBQVksRUFBRUMsY0FBYyxRQUFRLGFBQWE7QUFDL0QsY0FDRUMsZ0JBQWdCLEVBQ2hCQyxpQkFBaUIsRUFDakJDLE9BQU8sRUFDUEMscUJBQXFCLEVBQ3JCQyxlQUFlLEVBQ2ZDLFdBQVcsUUFDTixzQkFBc0I7QUFDN0IsU0FBU0MsZUFBZSxFQUFFQyxZQUFZLFFBQVEsMEJBQTBCO0FBQ3hFLFNBQVNDLG1CQUFtQixFQUFFQyxnQkFBZ0IsUUFBUSx3QkFBd0I7QUFDOUUsY0FBY0MsWUFBWSxRQUFRLDhCQUE4QjtBQUNoRSxTQUNFLEtBQUtDLDBEQUEwRCxFQUMvRCxLQUFLQywrQ0FBK0MsRUFDcERDLFFBQVEsUUFDSCxtQ0FBbUM7QUFDMUMsU0FBU0Msa0JBQWtCLFFBQVEsbUNBQW1DO0FBQ3RFLFNBQVNDLHdCQUF3QixRQUFRLG1DQUFtQztBQUM1RSxTQUFTQyxzQkFBc0IsUUFB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 diff --git a/src/utils/shell/powershellDetection.test.ts b/src/utils/shell/powershellDetection.test.ts new file mode 100644 index 00000000..e36314ee --- /dev/null +++ b/src/utils/shell/powershellDetection.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test' +import { + isPowerShellExecutablePath, + resolvePowerShellPathOverride, +} from './powershellDetection.js' + +describe('PowerShell detection override', () => { + test('accepts pwsh and powershell executable paths', () => { + expect( + isPowerShellExecutablePath('C:\\Program Files\\PowerShell\\7\\pwsh.exe'), + ).toBe(true) + expect( + isPowerShellExecutablePath( + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + ), + ).toBe(true) + }) + + test('rejects custom shells that are not PowerShell executables', () => { + expect( + isPowerShellExecutablePath('C:\\Program Files\\Git\\bin\\bash.exe'), + ).toBe(false) + expect(isPowerShellExecutablePath('cmd.exe')).toBe(false) + }) + + test('resolves configured command names through PATH lookup', async () => { + const resolved = await resolvePowerShellPathOverride('pwsh.exe', { + getPlatform: () => 'macos', + probePath: async () => null, + which: async command => + command === 'pwsh.exe' + ? 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' + : null, + }) + + expect(resolved).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe') + }) + + test('resolves Windows pwsh through PSHOME before PATH lookup', async () => { + const resolved = await resolvePowerShellPathOverride('pwsh.exe', { + getPlatform: () => 'windows', + probePath: async path => + path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' ? path : null, + readWindowsPwshHome: async () => 'C:\\Program Files\\PowerShell\\7', + which: async () => null, + }) + + expect(resolved).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe') + }) + + test('falls back to known Windows PowerShell install paths', async () => { + const resolved = await resolvePowerShellPathOverride('powershell.exe', { + getPlatform: () => 'windows', + probePath: async path => + path === + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' + ? path + : null, + which: async () => null, + }) + + expect(resolved).toBe( + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + ) + }) + + test('ignores missing or non-PowerShell overrides', async () => { + await expect( + resolvePowerShellPathOverride('C:\\Tools\\bash.exe', { + probePath: async () => 'C:\\Tools\\bash.exe', + which: async () => null, + }), + ).resolves.toBeNull() + + await expect( + resolvePowerShellPathOverride('C:\\Missing\\pwsh.exe', { + getPlatform: () => 'macos', + probePath: async () => null, + which: async () => null, + }), + ).resolves.toBeNull() + }) +}) diff --git a/src/utils/shell/powershellDetection.ts b/src/utils/shell/powershellDetection.ts index acc3fb77..0baaa29b 100644 --- a/src/utils/shell/powershellDetection.ts +++ b/src/utils/shell/powershellDetection.ts @@ -1,7 +1,15 @@ +import { execFile } from 'child_process' import { realpath, stat } from 'fs/promises' +import { win32 as pathWin32 } from 'path' import { getPlatform } from '../platform.js' +import type { Platform } from '../platform.js' import { which } from '../which.js' +export const POWERSHELL_PATH_OVERRIDE_ENV = 'CLAUDE_CODE_POWERSHELL_PATH' +const WINDOWS_DEFAULT_PWSH_PATH = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' +const WINDOWS_DEFAULT_POWERSHELL_PATH = + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' + async function probePath(p: string): Promise { try { return (await stat(p)).isFile() ? p : null @@ -10,6 +18,117 @@ async function probePath(p: string): Promise { } } +function basenameWithoutExe(candidate: string): string { + return candidate + .trim() + .split(/[/\\]/) + .pop()! + .toLowerCase() + .replace(/\.exe$/, '') +} + +export function isPowerShellExecutablePath(candidate: string): boolean { + const basename = basenameWithoutExe(candidate) + return basename === 'pwsh' || basename === 'powershell' +} + +function hasPathSeparator(candidate: string): boolean { + return candidate.includes('/') || candidate.includes('\\') +} + +async function readWindowsPwshHome(): Promise { + return new Promise(resolve => { + execFile( + 'cmd.exe', + ['/C', 'pwsh', '-NoProfile', '-Command', '$PSHOME'], + { + maxBuffer: 8192, + timeout: 3000, + windowsHide: true, + }, + (error, stdout) => { + if (error) { + resolve(null) + return + } + + const trimmed = stdout.toString().trim() + resolve(trimmed || null) + }, + ) + }) +} + +type PowerShellDetectionDeps = { + getPlatform?: () => Platform + probePath?: (p: string) => Promise + readWindowsPwshHome?: () => Promise + which?: (command: string) => Promise +} + +async function resolveWindowsPowerShellFallbackPath( + basename: 'pwsh' | 'powershell', + deps: PowerShellDetectionDeps, +): Promise { + const platform = (deps.getPlatform ?? getPlatform)() + if (platform !== 'windows') { + return null + } + + const pathProbe = deps.probePath ?? probePath + + if (basename === 'pwsh') { + // PowerShell Core may be invokable via Windows shell/app aliases while + // absent from the sidecar PATH. Ask pwsh where it actually lives. + const psHome = await (deps.readWindowsPwshHome ?? readWindowsPwshHome)() + if (psHome) { + const resolvedFromHome = await pathProbe( + pathWin32.join(psHome, 'pwsh.exe'), + ) + if (resolvedFromHome) { + return resolvedFromHome + } + } + } + + return pathProbe( + basename === 'pwsh' + ? WINDOWS_DEFAULT_PWSH_PATH + : WINDOWS_DEFAULT_POWERSHELL_PATH, + ) +} + +export async function resolvePowerShellPathOverride( + override = process.env[POWERSHELL_PATH_OVERRIDE_ENV], + deps: PowerShellDetectionDeps = {}, +): Promise { + const trimmed = override?.trim() + if (!trimmed || !isPowerShellExecutablePath(trimmed)) { + return null + } + + const pathProbe = deps.probePath ?? probePath + const basename = basenameWithoutExe(trimmed) as 'pwsh' | 'powershell' + const resolvedPath = await pathProbe(trimmed) + if (resolvedPath) { + return resolvedPath + } + + const windowsFallback = await resolveWindowsPowerShellFallbackPath( + basename, + deps, + ) + if (windowsFallback) { + return windowsFallback + } + + if (!hasPathSeparator(trimmed)) { + return (deps.which ?? which)(trimmed) + } + + return null +} + /** * Attempts to find PowerShell on the system via PATH. * Prefers pwsh (PowerShell Core 7+), falls back to powershell (5.1). @@ -22,6 +141,11 @@ async function probePath(p: string): Promise { * Windows/macOS, PATH is sufficient. */ export async function findPowerShell(): Promise { + const overridePath = await resolvePowerShellPathOverride() + if (overridePath) { + return overridePath + } + const pwshPath = await which('pwsh') if (pwshPath) { // Snap launcher hangs in subprocesses. Prefer the direct binary.