mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-3919-mobile-picker-lag
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
name: Install Node dependencies
|
||||
description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
|
||||
description: Installs the Node toolchain and repository dependencies for CI jobs.
|
||||
|
||||
inputs:
|
||||
native-runtime:
|
||||
@@ -10,6 +10,18 @@ inputs:
|
||||
description: Node.js version override; defaults to the version declared in package.json.
|
||||
required: false
|
||||
default: ''
|
||||
persist-native-cache:
|
||||
description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI.
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
outputs:
|
||||
node-version:
|
||||
description: Resolved Node.js version used for the install.
|
||||
value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
|
||||
native-cache-scope:
|
||||
description: Operating-system image scope used by the native module cache.
|
||||
value: ${{ steps.native-cache-scope.outputs.scope }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -51,7 +63,7 @@ runs:
|
||||
|
||||
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
|
||||
- name: Use external node-gyp
|
||||
if: inputs.native-runtime != 'none'
|
||||
if: runner.os == 'Linux' && inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
@@ -80,12 +92,42 @@ runs:
|
||||
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
|
||||
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
|
||||
# Node version, and the patch whose contents the build has to match.
|
||||
- name: Restore compiled native modules
|
||||
# Windows extra globs are empty on Linux. No restore-keys: a partial-match key is
|
||||
# an ABI-mismatched build, and ensure-native-runtime would recompile it anyway.
|
||||
# Native addons built on a newer Linux image can require glibc symbols
|
||||
# missing from an older runner/container. ImageOS distinguishes hosted
|
||||
# Windows/macOS images; /etc/os-release also distinguishes Linux containers.
|
||||
- name: Resolve native cache scope
|
||||
id: native-cache-scope
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
scope="${ImageOS:-$RUNNER_OS}"
|
||||
if [ -r /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
scope="${ID:-linux}-${VERSION_ID:-unknown}"
|
||||
fi
|
||||
echo "scope=$scope" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore compiled native modules
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
key: native-modules-${{ runner.os }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch') }}
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Restore compiled native modules without saving
|
||||
if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Prepare native runtime
|
||||
if: inputs.native-runtime != 'none'
|
||||
|
||||
@@ -71,30 +71,17 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool
|
||||
# Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
|
||||
# permission on Linux runners; node-pty's install fallback then fails
|
||||
# before this smoke job can exercise the native package.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
|
||||
# is no root config) and falls back to the 5s default timeout with no
|
||||
# Windows worker cap, so the real csc.exe launcher-compile tests time out
|
||||
# on hosted Windows. Use the shared config so this job matches pnpm test.
|
||||
- run: >-
|
||||
pnpm vitest run --config config/vitest.config.ts
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
|
||||
@@ -159,13 +146,9 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
- name: Owner-loss benchmark process cleanup
|
||||
run: >-
|
||||
pnpm vitest run
|
||||
|
||||
+13
-100
@@ -40,36 +40,9 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: the E2E build compiles native modules via node-gyp. Mirrors the
|
||||
# install step in pr.yml's verify job so E2E doesn't hit missing-toolchain
|
||||
# errors.
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
# Why no native-runtime: this job only produces JS bundles. Native modules
|
||||
# are prepared in the consumer shards that actually launch Electron.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: building here avoids parallel builds inside Playwright globalSetup;
|
||||
# paired-browser specs also need the standalone web bundle.
|
||||
@@ -130,11 +103,11 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
# Why: pnpm install rebuilds native modules, and those postinstall
|
||||
# scripts still need the Linux toolchain even though this shard reuses
|
||||
# the prebuilt Electron output.
|
||||
# Why: pnpm install used to rebuild native modules here; the composite
|
||||
# action restores them from cache and only compiles on a miss. The
|
||||
# toolchain is still required for that miss path, and for paired Quick
|
||||
# Open coverage which exercises the resource-bounded host search.
|
||||
- name: Install native build tools
|
||||
# Why: paired Quick Open coverage exercises the resource-bounded host search.
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh
|
||||
|
||||
# Why: Electron on Linux needs an X display even when the app
|
||||
@@ -143,33 +116,9 @@ jobs:
|
||||
- name: Install xvfb
|
||||
run: sudo apt-get install -y xvfb
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: this job runs the same pnpm install path as pr.yml's verify
|
||||
# job, so it needs the same pinned node-gyp override to avoid pnpm's
|
||||
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
|
||||
# release.yml so the invariant "this workaround is Linux-only" is
|
||||
# consistent across all three workflows, even though this job
|
||||
# currently pins runs-on: ubuntu-latest.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -222,25 +171,9 @@ jobs:
|
||||
# lane now receives those specs from pr.yml's SSH source mapping.
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -305,29 +238,9 @@ jobs:
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
|
||||
|
||||
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
|
||||
# Without that cache every E2E job re-downloaded the whole dependency set.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
|
||||
# stays consistent across workflows even while this job is ubuntu-latest.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
|
||||
- name: Download E2E build output
|
||||
uses: actions/download-artifact@v8
|
||||
|
||||
+107
-77
@@ -16,15 +16,28 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Why: a README/docs-only PR used to start the full matrix (32 test shards,
|
||||
# Why: a README/docs-only PR used to start the full matrix (test shards,
|
||||
# two package jobs, typecheck, git compat, xterm, shell contracts). Path
|
||||
# filters on `on.pull_request` would drop the `verify` check entirely; this
|
||||
# detector keeps verify as the required aggregate and skips the expensive jobs.
|
||||
# Per-job outputs also skip git-compat/xterm/packaging/shell when those
|
||||
# inputs are unchanged; empty diffs fail closed and run everything.
|
||||
code_paths:
|
||||
name: detect code-relevant changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
static_analysis: ${{ steps.filter.outputs.static_analysis }}
|
||||
typecheck: ${{ steps.filter.outputs.typecheck }}
|
||||
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
|
||||
xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
|
||||
shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
|
||||
test: ${{ steps.filter.outputs.test }}
|
||||
orcad_browser: ${{ steps.filter.outputs.orcad_browser }}
|
||||
cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }}
|
||||
managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
|
||||
package: ${{ steps.filter.outputs.package }}
|
||||
package_windows: ${{ steps.filter.outputs.package_windows }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -48,14 +61,12 @@ jobs:
|
||||
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")"
|
||||
echo "Changed paths:"
|
||||
printf '%s\n' "$CHANGED"
|
||||
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs)"
|
||||
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
|
||||
echo "should_run=$SHOULD_RUN"
|
||||
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
static_analysis:
|
||||
name: static analysis
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.static_analysis == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -70,6 +81,8 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- name: Lint
|
||||
run: pnpm exec oxlint --format github
|
||||
@@ -188,7 +201,7 @@ jobs:
|
||||
|
||||
typecheck:
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.typecheck == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -216,7 +229,7 @@ jobs:
|
||||
git_compatibility:
|
||||
name: Git compatibility
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.git_compatibility == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -283,7 +296,7 @@ jobs:
|
||||
xterm_patch_sync:
|
||||
name: xterm patch sync
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.xterm_patch_sync == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -313,7 +326,7 @@ jobs:
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.shell_contracts == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Why: this job's cost is almost entirely package download, and a stalled mirror has
|
||||
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
|
||||
@@ -432,14 +445,14 @@ jobs:
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.test == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['24', '26']
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
shard_total: [16]
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard_total: [8]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -477,12 +490,12 @@ jobs:
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
# Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix
|
||||
# would pay for it 32 times to run one file in whichever shard it landed in.
|
||||
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
|
||||
# would pay for it on every shard to run one file in whichever shard it landed in.
|
||||
orcad_browser:
|
||||
name: orcad browser provider
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.orcad_browser == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -518,7 +531,7 @@ jobs:
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.cross-version-wire == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -550,7 +563,7 @@ jobs:
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.managed_hook_node18 == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -575,7 +588,7 @@ jobs:
|
||||
package:
|
||||
name: package
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.package == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -647,7 +660,7 @@ jobs:
|
||||
package_windows:
|
||||
name: package (windows)
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
if: needs.code_paths.outputs.package_windows == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -657,17 +670,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
@@ -678,18 +680,22 @@ jobs:
|
||||
restore-keys: |
|
||||
electron-builder-windows-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
# Why persist-native-cache false: this job later rebuilds the same path for
|
||||
# Electron. A post-job save would store the Electron ABI under the Node key.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
id: deps
|
||||
with:
|
||||
native-runtime: node
|
||||
persist-native-cache: 'false'
|
||||
|
||||
# Why: node-pty prefers its upstream prebuild, which does not contain
|
||||
# Orca's Windows patch, so the job-object exports would be absent and the
|
||||
# suite below would test an unpatched binary. build_from_source removes
|
||||
# the prebuild, and the package's postinstall restores the ConPTY runtime
|
||||
# files that a bare node-gyp rebuild would miss.
|
||||
- name: Rebuild node-pty from patched source
|
||||
env:
|
||||
npm_config_build_from_source: 'true'
|
||||
run: pnpm rebuild node-pty
|
||||
- name: Save compiled Node native modules
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
@@ -724,9 +730,26 @@ jobs:
|
||||
# Why the :parallel variant: identical to build:release except the three
|
||||
# electron-vite targets overlap instead of running back to back. The Linux package
|
||||
# job already packages and smoke-tests an AppImage built that way.
|
||||
- name: Cache Windows CLI launcher
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: native/windows-cli-launcher/.build
|
||||
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs') }}
|
||||
|
||||
- name: Build package inputs
|
||||
env:
|
||||
ORCA_REUSE_WINDOWS_CLI_LAUNCHER: '1'
|
||||
run: pnpm run build:release:parallel
|
||||
|
||||
- name: Restore compiled Electron native modules
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
|
||||
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Prepare Electron native runtime
|
||||
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
|
||||
@@ -807,6 +830,7 @@ jobs:
|
||||
- shell_contracts
|
||||
- test
|
||||
- orcad_browser
|
||||
- cross-version-wire
|
||||
- managed_hook_node18
|
||||
- package
|
||||
- package_windows
|
||||
@@ -826,57 +850,63 @@ jobs:
|
||||
CODE_PATHS: ${{ needs.code_paths.result }}
|
||||
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
|
||||
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
|
||||
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
|
||||
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
|
||||
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
|
||||
XTERM_PATCH_SYNC_SHOULD_RUN: ${{ needs.code_paths.outputs.xterm_patch_sync }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
SHELL_CONTRACTS_SHOULD_RUN: ${{ needs.code_paths.outputs.shell_contracts }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }}
|
||||
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
|
||||
ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }}
|
||||
CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }}
|
||||
CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }}
|
||||
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
|
||||
MANAGED_HOOK_NODE18_SHOULD_RUN: ${{ needs.code_paths.outputs.managed_hook_node18 }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }}
|
||||
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
|
||||
PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }}
|
||||
run: |
|
||||
if [ "$CODE_PATHS" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
if [ "$SHOULD_RUN" != "true" ]; then
|
||||
echo "Docs-only change; expensive PR checks skipped."
|
||||
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
for result in \
|
||||
"$STATIC_ANALYSIS" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$ORCAD_BROWSER" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "skipped" ]; then
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
# Require success when the PR has code-relevant changes
|
||||
for result in \
|
||||
"$CODE_PATHS" \
|
||||
"$STATIC_ANALYSIS" \
|
||||
"$ROOT_DIRECTORY_GUARD" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$ORCAD_BROWSER" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
exit 1
|
||||
failed=0
|
||||
check_job() {
|
||||
local name="$1" result="$2" should="$3"
|
||||
if [ "$should" = "true" ]; then
|
||||
if [ "$result" != "success" ]; then
|
||||
echo "$name: expected success, got $result"
|
||||
failed=1
|
||||
fi
|
||||
else
|
||||
if [ "$result" != "skipped" ]; then
|
||||
echo "$name: expected skipped, got $result"
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
# Require success when the PR has code-relevant changes
|
||||
check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
|
||||
check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
|
||||
check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
|
||||
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
|
||||
check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
|
||||
check_job test "$TEST" "$TEST_SHOULD_RUN"
|
||||
check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN"
|
||||
check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN"
|
||||
check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN"
|
||||
check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN"
|
||||
check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN"
|
||||
exit "$failed"
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
|
||||
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
|
||||
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
|
||||
inline src/cli/handlers/automations.ts
|
||||
inline src/cli/help.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/automations/hermes-cron-output.ts
|
||||
@@ -12,7 +10,6 @@ inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/browser/cdp-bridge.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/cli/wsl-cli-installer.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
inline src/main/codex/hook-service.ts
|
||||
@@ -23,14 +20,8 @@ inline src/main/index.ts
|
||||
inline src/main/ipc/filesystem-watcher.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/keybindings/keybinding-file.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/memory/collector.ts
|
||||
inline src/main/ports/advertised-url-watcher.ts
|
||||
inline src/main/ports/local-workspace-port-scanner.ts
|
||||
inline src/main/project-groups/nested-repo-discovery.ts
|
||||
inline src/main/providers/local-pty-provider.ts
|
||||
inline src/main/rate-limits/claude-pty.ts
|
||||
inline src/main/rate-limits/service.ts
|
||||
inline src/main/runtime/orca-runtime-browser.ts
|
||||
inline src/main/runtime/orca-runtime-files.ts
|
||||
@@ -69,7 +60,6 @@ inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
|
||||
inline src/renderer/src/components/settings/AccountsPane.tsx
|
||||
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
|
||||
inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
|
||||
inline src/renderer/src/components/status-bar/StatusBar.tsx
|
||||
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
|
||||
@@ -82,40 +72,25 @@ inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
|
||||
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.ts
|
||||
inline src/renderer/src/runtime/runtime-linear-client.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.ts
|
||||
inline src/renderer/src/store/slices/agent-status.ts
|
||||
inline src/renderer/src/store/slices/browser.ts
|
||||
inline src/renderer/src/store/slices/diffComments.ts
|
||||
inline src/renderer/src/store/slices/hosted-review.ts
|
||||
inline src/renderer/src/store/slices/linear.ts
|
||||
inline src/renderer/src/store/slices/tabs.ts
|
||||
inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/shared/automation-schedules.ts
|
||||
inline src/shared/commit-message-agent-spec.ts
|
||||
inline src/shared/constants.ts
|
||||
inline src/shared/keybindings.ts
|
||||
inline src/shared/marine-creatures.ts
|
||||
inline src/shared/telemetry-events.ts
|
||||
inline src/shared/text-search.ts
|
||||
inline tests/e2e/helpers/terminal.ts
|
||||
mobile-config app/h/*/files/*.tsx
|
||||
mobile-config app/h/*/index.tsx
|
||||
mobile-config app/h/*/session/*.tsx
|
||||
mobile-config app/h/*/source-control/*.tsx
|
||||
mobile-config app/h/*/tasks.tsx
|
||||
mobile-config app/index.tsx
|
||||
mobile-config app/pair-scan.tsx
|
||||
mobile-config app/troubleshoot.tsx
|
||||
mobile-config scripts/mock-server.ts
|
||||
mobile-config scripts/repro-terminal-colors.ts
|
||||
mobile-config scripts/repro-worktree-startup-stream.ts
|
||||
mobile-config src/browser/MobileBrowserPane.tsx
|
||||
mobile-config src/components/CustomKeyModal.tsx
|
||||
mobile-config src/components/NewWorktreeModal.tsx
|
||||
mobile-config src/components/mobile-rich-markdown-editor-html.ts
|
||||
mobile-config src/terminal/terminal-accessory-keys.ts
|
||||
mobile-config src/terminal/terminal-webview-html.ts
|
||||
mobile-config src/transport/rpc-client.ts
|
||||
|
||||
+110
-505
File diff suppressed because one or more lines are too long
@@ -1,41 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
// Why: electron-builder treats a skipped native build like success and can
|
||||
// continue toward a Windows package whose declared orca.exe does not exist.
|
||||
throw new Error(
|
||||
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
|
||||
)
|
||||
}
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
|
||||
const compilerPath = findFrameworkCompiler(process.env)
|
||||
|
||||
if (!compilerPath) {
|
||||
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
const result = spawnSync(
|
||||
compilerPath,
|
||||
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
|
||||
{ cwd: repoRoot, stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
export function shouldReuseCompiledWindowsCliLauncher(
|
||||
outputPath,
|
||||
sourcePath,
|
||||
{ reuseCached = false } = {}
|
||||
) {
|
||||
if (!existsSync(outputPath)) {
|
||||
return false
|
||||
}
|
||||
// Why reuseCached: Actions cache keys already hash the C# source, but restore
|
||||
// does not preserve mtimes, so a hit would look stale and recompile anyway.
|
||||
if (reuseCached) {
|
||||
return true
|
||||
}
|
||||
return statSync(outputPath).mtimeMs >= statSync(sourcePath).mtimeMs
|
||||
}
|
||||
|
||||
function defaultOutputPath(projectRoot) {
|
||||
@@ -58,3 +41,47 @@ function readArg(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
return index !== -1 ? process.argv[index + 1] : undefined
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
if (process.platform !== 'win32') {
|
||||
// Why: electron-builder treats a skipped native build like success and can
|
||||
// continue toward a Windows package whose declared orca.exe does not exist.
|
||||
throw new Error(
|
||||
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
|
||||
)
|
||||
}
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
|
||||
const compilerPath = findFrameworkCompiler(process.env)
|
||||
|
||||
if (!compilerPath) {
|
||||
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
if (
|
||||
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, {
|
||||
reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1'
|
||||
})
|
||||
) {
|
||||
console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`)
|
||||
process.exit(0)
|
||||
}
|
||||
const result = spawnSync(
|
||||
compilerPath,
|
||||
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
|
||||
{ cwd: repoRoot, stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs'
|
||||
|
||||
const itCrossHost = process.platform === 'win32' ? it.skip : it
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
@@ -37,6 +40,33 @@ function itWindows(name, test) {
|
||||
}
|
||||
|
||||
describe('Windows CLI launcher', () => {
|
||||
it('reuses a compiled launcher that is at least as new as the C# source', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
|
||||
try {
|
||||
const sourcePath = join(root, 'OrcaCliLauncher.cs')
|
||||
const outputPath = join(root, '.build', 'orca.exe')
|
||||
mkdirSync(join(root, '.build'))
|
||||
writeFileSync(sourcePath, 'source\n')
|
||||
writeFileSync(outputPath, 'binary\n')
|
||||
const later = new Date(statSync(sourcePath).mtimeMs + 1_000)
|
||||
utimesSync(outputPath, later, later)
|
||||
|
||||
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true)
|
||||
writeFileSync(sourcePath, 'changed\n')
|
||||
const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000)
|
||||
utimesSync(sourcePath, sourceLater, sourceLater)
|
||||
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false)
|
||||
expect(
|
||||
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true })
|
||||
).toBe(true)
|
||||
expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe(
|
||||
false
|
||||
)
|
||||
} finally {
|
||||
removeFixtureTree(root)
|
||||
}
|
||||
})
|
||||
|
||||
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
|
||||
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
|
||||
try {
|
||||
|
||||
@@ -76,7 +76,6 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
|
||||
const expectedRegressionFiles = [
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
@@ -126,10 +125,14 @@ describe('computer-use e2e workflow', () => {
|
||||
const job = workflow.jobs['mac-native-owner-smoke']
|
||||
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
|
||||
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
|
||||
const install = job.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(job.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(job['runs-on']).toBe('macos-15')
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
expect(install.with['native-runtime']).toBe('electron')
|
||||
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
|
||||
const cleanupRun = runs.find((run) =>
|
||||
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
|
||||
|
||||
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { release } from 'node:os'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
@@ -67,7 +67,12 @@ function ensureNodeRuntime() {
|
||||
if (!initial.ok) {
|
||||
printCheckError(initial)
|
||||
}
|
||||
runPnpm(['rebuild', 'node-pty'])
|
||||
const failedModules = initial.failures.map((failure) => failure.moduleName)
|
||||
const rebuildModules = [
|
||||
'node-pty',
|
||||
...failedModules.filter((moduleName) => moduleName !== 'node-pty')
|
||||
]
|
||||
rebuildNodeRuntimeModules(rebuildModules)
|
||||
verifyNodeRuntimeAfterRebuild()
|
||||
return
|
||||
}
|
||||
@@ -77,7 +82,7 @@ function ensureNodeRuntime() {
|
||||
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
|
||||
)
|
||||
printCheckError(initial)
|
||||
runPnpm(['rebuild', ...failedModules])
|
||||
rebuildNodeRuntimeModules(failedModules)
|
||||
verifyNodeRuntimeAfterRebuild()
|
||||
}
|
||||
|
||||
@@ -312,14 +317,10 @@ function getPatchedNodePtyRebuildReason() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
|
||||
// patch only lands in the source-built build/Release artifacts.
|
||||
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and
|
||||
// Windows patches only land in the source-built build/Release artifacts.
|
||||
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
|
||||
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
||||
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
||||
if (process.platform === 'darwin') {
|
||||
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
||||
}
|
||||
const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir)
|
||||
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
|
||||
|
||||
if (!missingArtifact) {
|
||||
@@ -329,11 +330,24 @@ function getPatchedNodePtyRebuildReason() {
|
||||
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
|
||||
}
|
||||
|
||||
function requiresPatchedNodePtySourceBuild() {
|
||||
function patchedNodePtyArtifactPaths(nodePtyDir) {
|
||||
if (process.platform === 'win32') {
|
||||
return false
|
||||
const releaseDir = resolve(nodePtyDir, 'build', 'Release')
|
||||
return [
|
||||
resolve(releaseDir, 'conpty.node'),
|
||||
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename))
|
||||
]
|
||||
}
|
||||
|
||||
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
||||
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
||||
if (process.platform === 'darwin') {
|
||||
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
||||
}
|
||||
return artifactPaths
|
||||
}
|
||||
|
||||
function requiresPatchedNodePtySourceBuild() {
|
||||
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
|
||||
if (!existsSync(nodePtyPatchPath)) {
|
||||
return false
|
||||
@@ -351,16 +365,28 @@ function getWindowsBuildNumber() {
|
||||
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
|
||||
}
|
||||
|
||||
function runPnpm(args) {
|
||||
function rebuildNodeRuntimeModules(moduleNames) {
|
||||
for (const moduleName of moduleNames) {
|
||||
const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
|
||||
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
|
||||
runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
|
||||
if (moduleName === 'node-pty' && process.platform === 'win32') {
|
||||
runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runPnpm(args, { cwd = projectDir } = {}) {
|
||||
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: projectDir,
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32'
|
||||
shell: process.platform === 'win32',
|
||||
env: process.env
|
||||
})
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
|
||||
console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
|
||||
if (result.error) {
|
||||
console.error(formatError(result.error))
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log).toContain('pnpm rebuild node-pty\n')
|
||||
expect(log).toContain('pnpm exec node-gyp rebuild\n')
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
|
||||
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
|
||||
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
|
||||
@@ -52,6 +53,41 @@ describe('ensure-native-runtime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'rebuilds other failed Windows addons with patched node-pty',
|
||||
() => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: envWithPrependedPath(binDir, {
|
||||
ORCA_NATIVE_TEST_LOG: logPath,
|
||||
ORCA_NATIVE_TEST_MARKER: markerPath
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2)
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
expect(log).toContain(join('node_modules', 'windows-native-registry'))
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rebuilds patched node-pty artifacts even when the Node load check passes',
|
||||
() => {
|
||||
@@ -80,7 +116,7 @@ describe('ensure-native-runtime', () => {
|
||||
expect(result.stderr).toContain(
|
||||
'Patched node-pty build artifacts are missing; rebuilding native deps.'
|
||||
)
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -114,7 +150,7 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
|
||||
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -148,7 +184,7 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
|
||||
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -178,9 +214,15 @@ function envWithPrependedPath(binDir, extraEnv) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeFakeNativeModules(projectDir) {
|
||||
function writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker = false } = {}) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(nodePtyDir, 'package.json'),
|
||||
'{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
|
||||
)
|
||||
mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
|
||||
|
||||
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
@@ -200,12 +242,18 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
}
|
||||
`
|
||||
)
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker })
|
||||
}
|
||||
|
||||
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(nodePtyDir, 'package.json'),
|
||||
'{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
|
||||
)
|
||||
mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
|
||||
|
||||
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
@@ -218,23 +266,40 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
const dir = ${JSON.stringify(nativeDir)} ??
|
||||
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
|
||||
return { dir, module: {} }
|
||||
return {
|
||||
dir,
|
||||
module: {
|
||||
listJobProcessIds: () => [],
|
||||
terminateJob: () => true,
|
||||
assignCurrentProcessToJob: () => true
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
}
|
||||
|
||||
function writeFakeWindowsRegistry(projectDir) {
|
||||
function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
|
||||
mkdirSync(registryDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(registryDir, 'index.js'),
|
||||
'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
|
||||
join(registryDir, 'package.json'),
|
||||
'{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n'
|
||||
)
|
||||
const markerGate = requiresMarker
|
||||
? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }`
|
||||
: ''
|
||||
writeFileSync(
|
||||
join(registryDir, 'index.js'),
|
||||
`exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => { ${markerGate}; return {} }\n`
|
||||
)
|
||||
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
mkdirSync(processTreeDir, { recursive: true })
|
||||
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
|
||||
}
|
||||
|
||||
function writeNodePtyPatchFile(projectDir) {
|
||||
@@ -245,6 +310,13 @@ function writeNodePtyPatchFile(projectDir) {
|
||||
function writePatchedNodePtyBuildArtifacts(projectDir) {
|
||||
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
|
||||
mkdirSync(buildDir, { recursive: true })
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(join(buildDir, 'conpty.node'), '')
|
||||
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
|
||||
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
|
||||
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
|
||||
return
|
||||
}
|
||||
writeFileSync(join(buildDir, 'pty.node'), '')
|
||||
if (process.platform === 'darwin') {
|
||||
writeFileSync(join(buildDir, 'spawn-helper'), '')
|
||||
@@ -260,6 +332,11 @@ function writeFakePnpm(binDir) {
|
||||
const { appendFileSync, writeFileSync } = require('node:fs')
|
||||
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
|
||||
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`)
|
||||
appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
|
||||
)
|
||||
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
|
||||
`
|
||||
)
|
||||
|
||||
@@ -16,6 +16,183 @@ const DOCS_ONLY_FILES = new Set([
|
||||
|
||||
const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/']
|
||||
|
||||
export const PR_CHECK_JOBS = [
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'xterm_patch_sync',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'orcad_browser',
|
||||
'cross-version-wire',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
]
|
||||
|
||||
const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
|
||||
|
||||
const GLOBAL_FORCE_PREFIXES = [
|
||||
'.github/workflows/pr.yml',
|
||||
'.github/actions/install-node-dependencies/',
|
||||
'config/scripts/pr-code-change-scope'
|
||||
]
|
||||
|
||||
const GLOBAL_FORCE_FILES = new Set(['package.json', 'pnpm-lock.yaml'])
|
||||
|
||||
const GIT_COMPAT_PREFIXES = [
|
||||
'src/shared/git-',
|
||||
'src/shared/review-head-tracking-ref',
|
||||
'src/main/git/',
|
||||
'src/relay/git-',
|
||||
'config/scripts/git-binary-compatibility'
|
||||
]
|
||||
|
||||
const XTERM_PREFIXES = [
|
||||
'config/patches/xterm-upstream.json',
|
||||
'config/patches/@xterm',
|
||||
'config/patches/xterm-src/',
|
||||
'config/scripts/regenerate-xterm-patches'
|
||||
]
|
||||
|
||||
const SHELL_PREFIXES = [
|
||||
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec',
|
||||
'src/main/daemon/shell-ready',
|
||||
'src/main/daemon/daemon-bash-shell-ready',
|
||||
'src/main/daemon/daemon-shell-ready-wrapper',
|
||||
'src/main/daemon/node-pty-fd-leak',
|
||||
'src/main/providers/local-pty-shell-ready',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example',
|
||||
'src/main/pty/',
|
||||
'src/main/shell-templates',
|
||||
'src/main/shell-startup-',
|
||||
'src/main/shell-wrapper-',
|
||||
'src/main/terminal-history-fish',
|
||||
'src/main/zsh-',
|
||||
'src/renderer/src/components/terminal-pane/fish-color-scheme',
|
||||
'src/shared/fish-',
|
||||
'src/shared/pty-reply-echo-shapes',
|
||||
'src/shared/startup-shell-portability',
|
||||
'src/shared/posix-command-path-lookup',
|
||||
'config/patches/node-pty@',
|
||||
'config/scripts/ensure-native-runtime',
|
||||
'config/scripts/node-pty-job-ownership'
|
||||
]
|
||||
|
||||
const ORCAD_BROWSER_PREFIXES = [
|
||||
'src/main/orcad/external-chromium-',
|
||||
'src/main/orcad/orcad-browser-provider',
|
||||
'src/main/orcad/orcad-agent-browser-binary',
|
||||
'src/main/orcad/electron-serve-browser-process'
|
||||
]
|
||||
|
||||
const CROSS_VERSION_WIRE_PREFIXES = [
|
||||
'tests/e2e/cross-version-wire/',
|
||||
'src/shared/protocol-version',
|
||||
'src/shared/terminal-stream-protocol',
|
||||
'src/shared/browser-client-host-protocol',
|
||||
'src/shared/browser-network-tunnel-protocol',
|
||||
'src/shared/browser-client-host-placement',
|
||||
'src/main/runtime/rpc/dispatcher',
|
||||
'src/main/runtime/rpc/methods/browser-tab-create-schema',
|
||||
'src/main/runtime/rpc/methods/terminal',
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
|
||||
]
|
||||
|
||||
const MANAGED_HOOK_PREFIXES = [
|
||||
'config/scripts/smoke-managed-hook-runtime-node18',
|
||||
'config/scripts/build-relay',
|
||||
'src/relay/',
|
||||
'src/shared/agent-hook',
|
||||
'src/main/agent-hooks/'
|
||||
]
|
||||
|
||||
const NATIVE_RUNTIME_PREFIXES = [
|
||||
'config/scripts/ensure-native-runtime',
|
||||
'config/scripts/rebuild-native-deps',
|
||||
'config/scripts/node-pty-job-ownership',
|
||||
'config/scripts/electron-builder-native-rebuild',
|
||||
'config/patches/node-pty@',
|
||||
'config/patches/@vscode__windows-process-tree'
|
||||
]
|
||||
|
||||
const SHARED_PACKAGE_PREFIXES = [
|
||||
'electron.vite.config.ts',
|
||||
'config/electron-builder',
|
||||
'config/packaged-runtime',
|
||||
'config/build-plugins/',
|
||||
'config/scripts/build-',
|
||||
'config/scripts/smoke-packaged',
|
||||
'config/scripts/install-electron-package-binary',
|
||||
'config/scripts/verify-packaged',
|
||||
'config/scripts/verify-linux-glibc',
|
||||
'config/scripts/run-electron-vite',
|
||||
'skills/',
|
||||
'skill-guides/',
|
||||
'resources/build/',
|
||||
'resources/onboarding/',
|
||||
'resources/plugins/',
|
||||
'resources/skills/',
|
||||
...NATIVE_RUNTIME_PREFIXES
|
||||
]
|
||||
|
||||
const LINUX_PACKAGE_PREFIXES = [
|
||||
...SHARED_PACKAGE_PREFIXES,
|
||||
'native/computer-use-linux/',
|
||||
'resources/linux/',
|
||||
'config/scripts/run-headless-serve'
|
||||
]
|
||||
|
||||
const WINDOWS_PACKAGE_PREFIXES = [
|
||||
...SHARED_PACKAGE_PREFIXES,
|
||||
'native/windows-cli-launcher/',
|
||||
'native/computer-use-windows/',
|
||||
'resources/win32/',
|
||||
'config/scripts/build-windows-cli-launcher',
|
||||
'config/scripts/windows-pty-native-capability',
|
||||
'tests/tools/windows-pty-native-capability-smoke/'
|
||||
]
|
||||
|
||||
const LINUX_PACKAGE_TESTS = [
|
||||
'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts',
|
||||
'src/main/browser/browser-route-tcp-egress.electron.test.ts',
|
||||
'src/main/browser/browser-route-webrtc-egress.electron.test.ts',
|
||||
'src/main/browser/browser-route-h3-egress.electron.test.ts',
|
||||
'src/main/browser/browser-route-dns-prefetch.electron.test.ts'
|
||||
]
|
||||
|
||||
const WINDOWS_PACKAGE_TESTS = [
|
||||
...LINUX_PACKAGE_TESTS,
|
||||
'config/scripts/rebuild-native-deps.test.mjs',
|
||||
'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
|
||||
'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
|
||||
'src/shared/child-process/windows-command-line.win32.test.ts',
|
||||
'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
|
||||
'src/main/windows/windows-pty-job.win32.test.ts',
|
||||
'src/main/windows/windows-host-job.win32.test.ts',
|
||||
'src/main/wsl/wsl-runner.test.ts',
|
||||
'src/main/wsl/wsl-guest-environment.test.ts',
|
||||
'src/main/wsl/wsl-invocation-boundary.test.ts',
|
||||
'src/main/wsl/wsl-executable-path.win32.test.ts',
|
||||
'src/main/wsl/wsl-w1-w3-contract.test.ts',
|
||||
'src/shared/source-scan/source-tree-scan.test.ts',
|
||||
'src/main/cli/wsl-cli-powershell-boundary.test.ts',
|
||||
'src/main/cursor/hook-service.test.ts',
|
||||
'src/main/orca-profiles/profile-index-store.test.ts',
|
||||
'src/main/runtime/repo-worktree-admin-fingerprint.test.ts',
|
||||
'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts',
|
||||
'src/shared/secure-file-fsync-flags.test.ts',
|
||||
'src/main/ipc/pty-codex-account-attribution.test.ts',
|
||||
'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts'
|
||||
]
|
||||
|
||||
const DESKTOP_IRRELEVANT_PREFIXES = [
|
||||
'mobile/',
|
||||
'.github/workflows/mobile.yml',
|
||||
'.github/workflows/mobile-ios-release.yml',
|
||||
'.github/workflows/mobile-android-release.yml'
|
||||
]
|
||||
|
||||
export function isDocsOnlyPath(file) {
|
||||
if (DOCS_ONLY_FILES.has(file)) {
|
||||
return true
|
||||
@@ -32,10 +209,83 @@ export function shouldRunPrChecks(changedFiles) {
|
||||
if (changedFiles.length === 0) {
|
||||
return true
|
||||
}
|
||||
return changedFiles.some((file) => !isDocsOnlyPath(file))
|
||||
return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
|
||||
}
|
||||
|
||||
export function classifyPrJobs(changedFiles) {
|
||||
const emptyDiff = changedFiles.length === 0
|
||||
const shouldRun = shouldRunPrChecks(changedFiles)
|
||||
const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath)
|
||||
const jobs = Object.fromEntries(
|
||||
PR_CHECK_JOBS.map((job) => [
|
||||
job,
|
||||
shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
|
||||
])
|
||||
)
|
||||
return { should_run: shouldRun, ...jobs }
|
||||
}
|
||||
|
||||
function jobDetector(job) {
|
||||
switch (job) {
|
||||
case 'git_compatibility':
|
||||
return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES))
|
||||
case 'xterm_patch_sync':
|
||||
return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES))
|
||||
case 'shell_contracts':
|
||||
return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES))
|
||||
case 'orcad_browser':
|
||||
return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES))
|
||||
case 'cross-version-wire':
|
||||
return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES))
|
||||
case 'managed_hook_node18':
|
||||
return (files) => files.some((file) => matchesPrefix(file, MANAGED_HOOK_PREFIXES))
|
||||
case 'package':
|
||||
return (files) => files.some(isLinuxPackagePath)
|
||||
case 'package_windows':
|
||||
return (files) => files.some(isWindowsPackagePath)
|
||||
default:
|
||||
return () => true
|
||||
}
|
||||
}
|
||||
|
||||
function isLinuxPackagePath(file) {
|
||||
return LINUX_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, LINUX_PACKAGE_PREFIXES)
|
||||
}
|
||||
|
||||
function isWindowsPackagePath(file) {
|
||||
return WINDOWS_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, WINDOWS_PACKAGE_PREFIXES)
|
||||
}
|
||||
|
||||
function isProductBundlePath(file, extraPrefixes) {
|
||||
if (isTestFile(file)) {
|
||||
return false
|
||||
}
|
||||
if (file.startsWith('src/')) {
|
||||
return true
|
||||
}
|
||||
return matchesPrefix(file, extraPrefixes)
|
||||
}
|
||||
|
||||
function isTestFile(file) {
|
||||
return /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || file.includes('/__tests__/')
|
||||
}
|
||||
|
||||
function isDesktopIrrelevantPath(file) {
|
||||
return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
|
||||
}
|
||||
|
||||
function isGlobalForcePath(file) {
|
||||
return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
|
||||
}
|
||||
|
||||
function matchesPrefix(file, prefixes) {
|
||||
return prefixes.some((prefix) => file === prefix || file.startsWith(prefix))
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
|
||||
process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n')
|
||||
const classification = classifyPrJobs(files)
|
||||
for (const [name, value] of Object.entries(classification)) {
|
||||
process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs'
|
||||
import {
|
||||
classifyPrJobs,
|
||||
isDocsOnlyPath,
|
||||
PR_CHECK_JOBS,
|
||||
shouldRunPrChecks
|
||||
} from './pr-code-change-scope.mjs'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
|
||||
const gatedIf = "needs.code_paths.outputs.should_run == 'true'"
|
||||
const expensiveJobs = [
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
@@ -15,12 +20,34 @@ const expensiveJobs = [
|
||||
'xterm_patch_sync',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'orcad_browser',
|
||||
'cross-version-wire',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
]
|
||||
|
||||
const ALWAYS_ON = ['static_analysis', 'typecheck', 'test']
|
||||
|
||||
function expectedJobs(overrides, { alwaysOn = true } = {}) {
|
||||
return Object.fromEntries(
|
||||
PR_CHECK_JOBS.map((job) => [
|
||||
job,
|
||||
(alwaysOn && ALWAYS_ON.includes(job)) || Boolean(overrides[job])
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function expectClassification(files, overrides) {
|
||||
const result = classifyPrJobs(files)
|
||||
const shouldRun = shouldRunPrChecks(files)
|
||||
expect(result.should_run).toBe(shouldRun)
|
||||
expect(result).toMatchObject({
|
||||
should_run: shouldRun,
|
||||
...expectedJobs(overrides, { alwaysOn: shouldRun })
|
||||
})
|
||||
}
|
||||
|
||||
describe('docs-only path classification', () => {
|
||||
it('treats the WeChat README PR files as docs-only', () => {
|
||||
expect(
|
||||
@@ -54,9 +81,154 @@ describe('docs-only path classification', () => {
|
||||
it('runs PR Checks when the diff is empty rather than skipping by accident', () => {
|
||||
expect(shouldRunPrChecks([])).toBe(true)
|
||||
})
|
||||
|
||||
it('does not start desktop PR Checks for mobile-only diffs', () => {
|
||||
expect(shouldRunPrChecks(['mobile/src/App.tsx', 'mobile/package.json'])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PR Checks docs-only skip wiring', () => {
|
||||
describe('per-job path classification', () => {
|
||||
it('runs every expensive job on an empty diff rather than skipping by accident', () => {
|
||||
const result = classifyPrJobs([])
|
||||
expect(result.should_run).toBe(true)
|
||||
for (const job of PR_CHECK_JOBS) {
|
||||
expect(result[job], job).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('skips every expensive job for docs-only diffs', () => {
|
||||
expectClassification(['README.md', 'docs/readme/README.zh-CN.md'], {})
|
||||
})
|
||||
|
||||
it('runs packaging and always-on jobs for product source, not git/xterm/shell lanes', () => {
|
||||
expectClassification(['src/renderer/src/components/tab-bar/TabBar.tsx'], {
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs Git compatibility only when git capability inputs change', () => {
|
||||
expectClassification(['src/shared/git-capability-cache.ts'], {
|
||||
git_compatibility: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
expectClassification(['src/shared/git-binary-compatibility.test.ts'], {
|
||||
git_compatibility: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs xterm patch sync only when xterm inputs change', () => {
|
||||
expectClassification(['config/patches/xterm-upstream.json'], {
|
||||
xterm_patch_sync: true
|
||||
})
|
||||
expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], {
|
||||
xterm_patch_sync: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs native package jobs only for the platform that ships the changed native', () => {
|
||||
expectClassification(['native/windows-cli-launcher/OrcaCliLauncher.cs'], {
|
||||
package_windows: true
|
||||
})
|
||||
expectClassification(['native/computer-use-linux/runtime.py'], {
|
||||
package: true
|
||||
})
|
||||
expectClassification(['native/computer-use-macos/Package.swift'], {})
|
||||
})
|
||||
|
||||
it('runs shell contracts when live-shell inputs change', () => {
|
||||
expectClassification(['src/main/daemon/shell-ready.ts'], {
|
||||
shell_contracts: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs shell contracts when wrapper templates or live-shell fixtures change', () => {
|
||||
expectClassification(['src/main/shell-templates.ts'], {
|
||||
shell_contracts: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
expectClassification(['src/main/shell-startup-launch-intent-fixtures.ts'], {
|
||||
shell_contracts: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs orcad browser when Chrome launch, session, or tab modules change', () => {
|
||||
for (const file of [
|
||||
'src/main/orcad/external-chromium-browser-session.ts',
|
||||
'src/main/orcad/external-chromium-command-arguments.ts',
|
||||
'src/main/orcad/external-chromium-tab-registry.ts',
|
||||
'src/main/orcad/external-chromium-tab-projection.ts'
|
||||
]) {
|
||||
expectClassification([file], {
|
||||
orcad_browser: true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
}
|
||||
expectClassification(['src/main/orcad/orcad-native-preflight.ts'], {
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
})
|
||||
|
||||
it('runs cross-version wire checks for every working-tree wire module', () => {
|
||||
for (const file of [
|
||||
'src/shared/protocol-version.ts',
|
||||
'src/shared/terminal-stream-protocol.ts',
|
||||
'src/main/runtime/rpc/dispatcher.ts',
|
||||
'src/main/runtime/rpc/methods/browser-tab-create-schema.ts',
|
||||
'src/main/runtime/rpc/methods/terminal.ts',
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
|
||||
]) {
|
||||
expectClassification([file], {
|
||||
'cross-version-wire': true,
|
||||
package: true,
|
||||
package_windows: true
|
||||
})
|
||||
}
|
||||
expectClassification(
|
||||
['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'],
|
||||
{ 'cross-version-wire': true }
|
||||
)
|
||||
})
|
||||
|
||||
it('runs workflow-self-change and lockfile diffs as force-all', () => {
|
||||
const result = classifyPrJobs(['.github/workflows/pr.yml'])
|
||||
expect(result.should_run).toBe(true)
|
||||
for (const job of PR_CHECK_JOBS) {
|
||||
expect(result[job], job).toBe(true)
|
||||
}
|
||||
expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps unit-test-only diffs out of packaging', () => {
|
||||
expectClassification(['src/main/git/git-status.test.ts'], {
|
||||
git_compatibility: true
|
||||
})
|
||||
})
|
||||
|
||||
it('emits GitHub output pairs from the shipped CLI', () => {
|
||||
const result = spawnSync(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
input: 'config/patches/xterm-upstream.json\n'
|
||||
})
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('should_run=true\n')
|
||||
expect(result.stdout).toContain('xterm_patch_sync=true\n')
|
||||
expect(result.stdout).toContain('git_compatibility=false\n')
|
||||
expect(result.stdout).toContain('package=false\n')
|
||||
expect(result.stdout).toContain('test=true\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PR Checks skip wiring', () => {
|
||||
it('classifies the PR range with a tested script and expands renames', () => {
|
||||
const classify = prWorkflow.jobs.code_paths.steps.find(
|
||||
(step) => step.name === 'Classify changed paths'
|
||||
@@ -65,9 +237,12 @@ describe('PR Checks docs-only skip wiring', () => {
|
||||
expect(classify.run).toContain('--no-renames')
|
||||
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
|
||||
expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
|
||||
'${{ steps.filter.outputs.should_run }}'
|
||||
)
|
||||
expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
|
||||
for (const jobName of ['should_run', ...expensiveJobs]) {
|
||||
expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
|
||||
`\${{ steps.filter.outputs.${jobName} }}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the cheap root-directory guard on docs-only PRs', () => {
|
||||
@@ -75,10 +250,12 @@ describe('PR Checks docs-only skip wiring', () => {
|
||||
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips expensive jobs unless the detector says the PR has code', () => {
|
||||
it('gates each expensive job on its own classifier output', () => {
|
||||
for (const jobName of expensiveJobs) {
|
||||
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf)
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
|
||||
`needs.code_paths.outputs.${jobName} == 'true'`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -89,19 +266,23 @@ describe('PR Checks docs-only skip wiring', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => {
|
||||
it('lets verify pass skipped jobs the classifier turned off', () => {
|
||||
const verifyStep = prWorkflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths')
|
||||
expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}')
|
||||
expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"')
|
||||
const docsOnlyBranch = verifyStep.run.slice(
|
||||
0,
|
||||
verifyStep.run.indexOf('# Require success when the PR has code-relevant changes')
|
||||
)
|
||||
expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]')
|
||||
expect(docsOnlyBranch).not.toContain('"$result" != "success"')
|
||||
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
|
||||
expect(verifyStep.run).toContain('# Require success when the PR has code-relevant changes')
|
||||
expect(verifyStep.run).toContain('expected skipped')
|
||||
expect(verifyStep.run).toContain('expected success')
|
||||
for (const job of prWorkflow.jobs.verify.needs) {
|
||||
if (job === 'code_paths' || job === 'root_directory_guard') {
|
||||
continue
|
||||
}
|
||||
const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
|
||||
expect(verifyStep.env[envVar]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
|
||||
expect(verifyStep.run).toContain(`"$${envVar}"`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,17 +100,19 @@ describe('PR E2E gate contract', () => {
|
||||
// job without adding it to the strict loop fails here instead of silently
|
||||
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
|
||||
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
|
||||
// Why lastIndexOf: the docs-only branch has its own loop that allows skipped.
|
||||
const successMarker = '# Require success when the PR has code-relevant changes'
|
||||
const successLoop = verifyStep.run.slice(
|
||||
verifyStep.run.indexOf(successMarker),
|
||||
verifyStep.run.lastIndexOf('done')
|
||||
)
|
||||
const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker))
|
||||
expect(successLoop.length).toBeGreaterThan(0)
|
||||
expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"')
|
||||
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
|
||||
for (const job of prWorkflow.jobs.verify.needs) {
|
||||
const envVar = job.toUpperCase()
|
||||
const envVar = job.replaceAll('-', '_').toUpperCase()
|
||||
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
|
||||
if (job === 'code_paths' || job === 'root_directory_guard') {
|
||||
continue
|
||||
}
|
||||
expect(successLoop).toContain(`"$${envVar}"`)
|
||||
expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -157,6 +159,18 @@ describe('PR E2E gate contract', () => {
|
||||
expect(sshDockerRunner).toContain("'electron-headful'")
|
||||
})
|
||||
|
||||
it('reuses the composite install action instead of duplicating pnpm setup', () => {
|
||||
const installFor = (jobName) =>
|
||||
e2eWorkflow.jobs[jobName].steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(installFor('build').with).toBeUndefined()
|
||||
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
|
||||
expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
|
||||
}
|
||||
})
|
||||
|
||||
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
|
||||
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
|
||||
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
|
||||
|
||||
@@ -50,9 +50,9 @@ describe('PR workflow parallelism', () => {
|
||||
it('shards the general test suite across Node 24 and Node 26', () => {
|
||||
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
|
||||
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 16 }, (_, index) => index + 1)
|
||||
Array.from({ length: 8 }, (_, index) => index + 1)
|
||||
)
|
||||
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
|
||||
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([8])
|
||||
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
|
||||
const installStep = workflow.jobs.test.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
@@ -233,21 +233,20 @@ describe('PR workflow parallelism', () => {
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
for (const jobName of [
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'xterm_patch_sync'
|
||||
]) {
|
||||
for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
|
||||
expect(installFor(jobName).with, jobName).toBeUndefined()
|
||||
}
|
||||
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
|
||||
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
|
||||
expect(installFor('test').with['native-runtime']).toBe('node')
|
||||
expect(installFor('package').with['native-runtime']).toBe('electron')
|
||||
expect(installFor('package_windows').with['native-runtime']).toBe('node')
|
||||
expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
|
||||
|
||||
expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
|
||||
expect(
|
||||
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
|
||||
).toBe("inputs.native-runtime != 'none'")
|
||||
).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'")
|
||||
const dependencyInstall = dependencyAction.runs.steps.find(
|
||||
(step) => step.name === 'Install dependencies'
|
||||
)
|
||||
@@ -295,14 +294,39 @@ describe('PR workflow parallelism', () => {
|
||||
// overwritten and one after the rebuild would never save a hit.
|
||||
expect(installIndex).toBeLessThan(cacheIndex)
|
||||
expect(cacheIndex).toBeLessThan(prepareIndex)
|
||||
expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
|
||||
expect(steps[cacheIndex].if).toBe(
|
||||
"inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
|
||||
)
|
||||
const restoreOnly = steps.find(
|
||||
(step) => step.name === 'Restore compiled native modules without saving'
|
||||
)
|
||||
expect(restoreOnly.if).toBe(
|
||||
"inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'"
|
||||
)
|
||||
expect(restoreOnly.uses).toBe('actions/cache/restore@v5')
|
||||
// Native artifacts are ABI-bound: a key missing either dimension serves a build
|
||||
// that cannot load, and ensure-native-runtime would recompile it anyway.
|
||||
expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
|
||||
expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
|
||||
expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
|
||||
// No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
|
||||
expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
|
||||
for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
|
||||
expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
|
||||
expect(cacheStep.with.key).toContain('${{ runner.os }}')
|
||||
expect(cacheStep.with.key).toContain('${{ runner.arch }}')
|
||||
expect(cacheStep.with.key).toContain('steps.requested-node.outputs.node-version')
|
||||
expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
|
||||
expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
|
||||
expect(cacheStep.with.key).toContain(
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
|
||||
)
|
||||
expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
|
||||
expect(cacheStep.with.path).toContain('windows-native-registry@')
|
||||
expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@')
|
||||
expect(cacheStep.with['restore-keys']).toBeUndefined()
|
||||
}
|
||||
const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
|
||||
expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
|
||||
expect(cacheScope.run).toContain('/etc/os-release')
|
||||
expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
|
||||
'${{ steps.native-cache-scope.outputs.scope }}'
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses TypeScript incremental state across typecheck runs', () => {
|
||||
@@ -346,6 +370,7 @@ describe('PR workflow parallelism', () => {
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'orcad_browser',
|
||||
'cross-version-wire',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
@@ -359,5 +384,7 @@ describe('PR workflow parallelism', () => {
|
||||
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
|
||||
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
|
||||
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
|
||||
expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
|
||||
expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -401,14 +401,23 @@ function getPatchedNodePtyRebuildReason() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
|
||||
// load successfully in Electron while missing the patched fd/error handling.
|
||||
// Why: Orca patches node-pty's native Unix spawn path and Windows job-object
|
||||
// exports; upstream prebuilds can load while missing those patches.
|
||||
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
|
||||
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
|
||||
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
|
||||
if (process.platform === 'darwin') {
|
||||
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
|
||||
}
|
||||
const artifactPaths =
|
||||
rebuildPlatform === 'win32'
|
||||
? [
|
||||
resolve(nodePtyDir, 'build', 'Release', 'conpty.node'),
|
||||
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) =>
|
||||
resolve(nodePtyDir, 'build', 'Release', 'conpty', filename)
|
||||
)
|
||||
]
|
||||
: [
|
||||
resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
|
||||
...(osPlatform() === 'darwin'
|
||||
? [resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')]
|
||||
: [])
|
||||
]
|
||||
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
|
||||
|
||||
if (!missingArtifact) {
|
||||
@@ -422,9 +431,6 @@ function requiresPatchedNodePtySourceBuild() {
|
||||
if (!onlyModules.includes('node-pty')) {
|
||||
return false
|
||||
}
|
||||
if (rebuildPlatform === 'win32') {
|
||||
return false
|
||||
}
|
||||
if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -586,6 +586,13 @@ function writeNodePtyPatchFile(projectDir) {
|
||||
function writePatchedNodePtyBuildArtifacts(projectDir) {
|
||||
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
|
||||
mkdirSync(buildDir, { recursive: true })
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(join(buildDir, 'conpty.node'), '')
|
||||
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
|
||||
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
|
||||
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
|
||||
return
|
||||
}
|
||||
writeFileSync(join(buildDir, 'pty.node'), '')
|
||||
if (process.platform === 'darwin') {
|
||||
writeFileSync(join(buildDir, 'spawn-helper'), '')
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('packaged skills CLI PR gates', () => {
|
||||
|
||||
expect(job['runs-on']).toBe('windows-2022')
|
||||
expect(buildStep.run).toBe('pnpm run build:release:parallel')
|
||||
expect(buildStep.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
|
||||
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
|
||||
expect(packageStep.run).toContain('electron-builder')
|
||||
expect(packageStep.run).toContain('--dir')
|
||||
|
||||
@@ -28,8 +28,14 @@ describe('packaged Windows PTY native capability routing', () => {
|
||||
|
||||
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
|
||||
const job = workflow.jobs.package_windows
|
||||
const sourceRebuild = job.steps.find(
|
||||
(step) => step.name === 'Rebuild node-pty from patched source'
|
||||
const install = job.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const nodeCacheSave = job.steps.find(
|
||||
(step) => step.name === 'Save compiled Node native modules'
|
||||
)
|
||||
const electronCache = job.steps.find(
|
||||
(step) => step.name === 'Restore compiled Electron native modules'
|
||||
)
|
||||
const build = job.steps.find((step) => step.name === 'Build package inputs')
|
||||
const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
|
||||
@@ -37,10 +43,17 @@ describe('packaged Windows PTY native capability routing', () => {
|
||||
const verify = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8')
|
||||
|
||||
expect(sourceRebuild.env.npm_config_build_from_source).toBe('true')
|
||||
expect(sourceRebuild.run).toBe('pnpm rebuild node-pty')
|
||||
expect(install.with['native-runtime']).toBe('node')
|
||||
expect(install.with['persist-native-cache']).toBe('false')
|
||||
expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
|
||||
expect(nodeCacheSave.with.key).toContain('-node-node')
|
||||
expect(electronCache.with.key).toContain('-electron-node')
|
||||
expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']")
|
||||
expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')")
|
||||
expect(build.run).toBe('pnpm run build:release:parallel')
|
||||
expect(build.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
|
||||
expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
|
||||
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
|
||||
expect(workflow.jobs.verify.needs).toContain('package_windows')
|
||||
|
||||
@@ -326,8 +326,6 @@ the Windows CI job rebuilds from source before running the win32 suites.
|
||||
it is true before asserting anything else, so an unpatched binary fails loudly
|
||||
instead of passing every case vacuously. That guard is what caught this.
|
||||
|
||||
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still
|
||||
exempts win32, on the premise that the patch is Unix-only. That premise is now
|
||||
false, but lifting the exemption also needs `pnpm rebuild` to force a source
|
||||
build — otherwise the assertion fires and the remedy does not fix it. Left as a
|
||||
follow-up rather than changed blind.
|
||||
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` now covers
|
||||
win32 as well, and `pnpm rebuild node-pty` sets `npm_config_build_from_source`
|
||||
so the patched source build actually replaces the upstream prebuild.
|
||||
|
||||
@@ -209,7 +209,6 @@ export const electronViteConfig: UserConfig = {
|
||||
index: resolve('src/main/index.ts'),
|
||||
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
|
||||
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
|
||||
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
|
||||
@@ -75,54 +75,18 @@
|
||||
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/components/mobile-rich-markdown-editor-html.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 648, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/components/CustomKeyModal.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 645, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/pair-scan.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/mock-server.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/terminal/terminal-accessory-keys.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 388, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/troubleshoot.tsx"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/repro-worktree-startup-stream.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 326, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/repro-terminal-colors.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 319, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["app/h/*/files/*.tsx"],
|
||||
"rules": {
|
||||
|
||||
+2
-185
@@ -2,7 +2,6 @@ import { useState, useRef, useCallback } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
@@ -19,13 +18,14 @@ import {
|
||||
} from '../src/transport/pre-profile-pairing-coordinator'
|
||||
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
|
||||
import { useRefreshHostClient } from '../src/transport/client-context'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { colors, spacing } from '../src/theme/mobile-theme'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import {
|
||||
loadMobileOnboardingSteps,
|
||||
mobileOnboardingDestination
|
||||
} from '../src/onboarding/mobile-onboarding-plan'
|
||||
import { pairScanStyles as styles } from '../src/pair-scan-styles'
|
||||
|
||||
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
|
||||
// route surfaces as a real error with the log visible instead of a
|
||||
@@ -356,186 +356,3 @@ export default function PairScanScreen() {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
steps: {
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
marginLeft: 7
|
||||
},
|
||||
step: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
stepBadge: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
stepNumber: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
stepText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
cameraWrap: {
|
||||
flex: 1,
|
||||
borderRadius: radii.camera,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
// Why: holds the layout slot while the camera is unmounted during
|
||||
// paste, so the bottom action button doesn't snap up to fill the
|
||||
// empty space.
|
||||
cameraPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.camera
|
||||
},
|
||||
camera: {
|
||||
...StyleSheet.absoluteFillObject
|
||||
},
|
||||
reticle: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
reticleFrame: {
|
||||
position: 'relative'
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderColor: 'rgba(255,255,255,0.7)'
|
||||
},
|
||||
cornerTL: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderTopWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderTopLeftRadius: 6
|
||||
},
|
||||
cornerTR: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
borderTopWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderTopRightRadius: 6
|
||||
},
|
||||
cornerBL: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderBottomWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderBottomLeftRadius: 6
|
||||
},
|
||||
cornerBR: {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
borderBottomWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderBottomRightRadius: 6
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
subtitle: {
|
||||
maxWidth: 310,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
connectingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
logSlot: {
|
||||
width: '100%',
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.sm
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
pasteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
pasteButtonPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
pasteButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
errorActions: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
secondaryButton: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
export type TerminalColorSnapshot = {
|
||||
label: string
|
||||
handle: string
|
||||
cols: number | null
|
||||
rows: number | null
|
||||
serialized: string
|
||||
lines: string
|
||||
}
|
||||
|
||||
function countMatches(value: string, pattern: RegExp): number {
|
||||
return value.match(pattern)?.length ?? 0
|
||||
}
|
||||
|
||||
export function summarizeTerminalColorSnapshot(
|
||||
snapshot: TerminalColorSnapshot
|
||||
): Record<string, string | number | null> {
|
||||
const data = snapshot.serialized
|
||||
return {
|
||||
label: snapshot.label,
|
||||
handle: snapshot.handle,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
serializedBytes: Buffer.byteLength(data),
|
||||
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
|
||||
sgrColor: countMatches(
|
||||
data,
|
||||
new RegExp(
|
||||
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
|
||||
'g'
|
||||
)
|
||||
),
|
||||
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
|
||||
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
|
||||
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
|
||||
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
|
||||
}
|
||||
}
|
||||
|
||||
export function saveTerminalColorSnapshots(snapshots: TerminalColorSnapshot[]): string {
|
||||
const dir = join(process.cwd(), 'terminal-color-repro')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const snapshot of snapshots) {
|
||||
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
|
||||
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
|
||||
writeFileSync(
|
||||
join(dir, `${base}.escaped.txt`),
|
||||
JSON.stringify(snapshot.serialized || snapshot.lines)
|
||||
)
|
||||
}
|
||||
writeFileSync(
|
||||
join(dir, 'summary.json'),
|
||||
JSON.stringify(snapshots.map(summarizeTerminalColorSnapshot), null, 2)
|
||||
)
|
||||
return dir
|
||||
}
|
||||
@@ -10,10 +10,13 @@
|
||||
* ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \
|
||||
* pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import nacl from 'tweetnacl'
|
||||
import WebSocket from 'ws'
|
||||
import {
|
||||
saveTerminalColorSnapshots,
|
||||
summarizeTerminalColorSnapshot,
|
||||
type TerminalColorSnapshot
|
||||
} from './repro-terminal-color-output'
|
||||
|
||||
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
|
||||
const token = process.argv[2]
|
||||
@@ -21,7 +24,6 @@ const serverPublicKeyB64 = process.argv[3]
|
||||
const worktreeSelector = process.argv[4]
|
||||
const explicitHandleA = process.argv[5]
|
||||
const explicitHandleB = process.argv[6]
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
type RpcResponse = {
|
||||
id: string
|
||||
@@ -37,15 +39,6 @@ type PendingRequest = {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
type Snapshot = {
|
||||
label: string
|
||||
handle: string
|
||||
cols: number | null
|
||||
rows: number | null
|
||||
serialized: string
|
||||
lines: string
|
||||
}
|
||||
|
||||
if (!token || !serverPublicKeyB64 || !worktreeSelector) {
|
||||
console.error(
|
||||
'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]'
|
||||
@@ -158,9 +151,13 @@ async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise<strin
|
||||
return handle
|
||||
}
|
||||
|
||||
async function captureSnapshot(ws: WebSocket, label: string, handle: string): Promise<Snapshot> {
|
||||
async function captureSnapshot(
|
||||
ws: WebSocket,
|
||||
label: string,
|
||||
handle: string
|
||||
): Promise<TerminalColorSnapshot> {
|
||||
const id = nextId()
|
||||
const snapshot = await new Promise<Snapshot>((resolve, reject) => {
|
||||
const snapshot = await new Promise<TerminalColorSnapshot>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
streamListeners.delete(id)
|
||||
@@ -207,48 +204,6 @@ async function captureSnapshot(ws: WebSocket, label: string, handle: string): Pr
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function countMatches(value: string, pattern: RegExp): number {
|
||||
return value.match(pattern)?.length ?? 0
|
||||
}
|
||||
|
||||
function summarize(snapshot: Snapshot): Record<string, string | number | null> {
|
||||
const data = snapshot.serialized
|
||||
return {
|
||||
label: snapshot.label,
|
||||
handle: snapshot.handle,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
serializedBytes: Buffer.byteLength(data),
|
||||
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
|
||||
sgrColor: countMatches(
|
||||
data,
|
||||
new RegExp(
|
||||
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
|
||||
'g'
|
||||
)
|
||||
),
|
||||
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
|
||||
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
|
||||
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
|
||||
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
|
||||
}
|
||||
}
|
||||
|
||||
function saveSnapshots(snapshots: Snapshot[]): string {
|
||||
const dir = join(process.cwd(), 'terminal-color-repro')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const snapshot of snapshots) {
|
||||
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
|
||||
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
|
||||
writeFileSync(
|
||||
join(dir, `${base}.escaped.txt`),
|
||||
JSON.stringify(snapshot.serialized || snapshot.lines)
|
||||
)
|
||||
}
|
||||
writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function run(ws: WebSocket): Promise<void> {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -303,11 +258,14 @@ async function run(ws: WebSocket): Promise<void> {
|
||||
const firstB = await captureSnapshot(ws, 'b-during-switch', handleB)
|
||||
const secondA = await captureSnapshot(ws, 'a-after-switch', handleA)
|
||||
const snapshots = [firstA, firstB, secondA]
|
||||
const dir = saveSnapshots(snapshots)
|
||||
const dir = saveTerminalColorSnapshots(snapshots)
|
||||
|
||||
console.table(snapshots.map(summarize))
|
||||
console.table(snapshots.map(summarizeTerminalColorSnapshot))
|
||||
console.log(`saved: ${dir}`)
|
||||
if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) {
|
||||
if (
|
||||
summarizeTerminalColorSnapshot(firstA).sgrColor !==
|
||||
summarizeTerminalColorSnapshot(secondA).sgrColor
|
||||
) {
|
||||
console.log(
|
||||
'color SGR count changed between A snapshots: serialization/state changed on desktop'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
export type StartupTerminalCapture = {
|
||||
handle: string
|
||||
title: string | null
|
||||
scrollback: Record<string, unknown> | null
|
||||
chunks: string[]
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return (
|
||||
value
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\r/g, '\n')
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePreview(value: string): string {
|
||||
return stripAnsi(value)
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function summarizeStartupCapture(capture: StartupTerminalCapture): Record<string, unknown> {
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
const live = capture.chunks.join('')
|
||||
const serializedPreview = normalizePreview(serialized)
|
||||
const livePreview = normalizePreview(live)
|
||||
return {
|
||||
handle: capture.handle,
|
||||
title: capture.title,
|
||||
cols: capture.scrollback?.cols ?? null,
|
||||
rows: capture.scrollback?.rows ?? null,
|
||||
serializedBytes: Buffer.byteLength(serialized),
|
||||
liveBytes: Buffer.byteLength(live),
|
||||
liveChunks: capture.chunks.length,
|
||||
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
livePreviewContainedInSerialized:
|
||||
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
|
||||
serializedPreview,
|
||||
livePreview
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStartupCaptures(
|
||||
captures: StartupTerminalCapture[],
|
||||
worktreeName: string
|
||||
): string {
|
||||
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const capture of captures) {
|
||||
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
|
||||
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
|
||||
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
|
||||
}
|
||||
writeFileSync(
|
||||
join(dir, 'summary.json'),
|
||||
JSON.stringify(captures.map(summarizeStartupCapture), null, 2)
|
||||
)
|
||||
return dir
|
||||
}
|
||||
@@ -4,10 +4,15 @@
|
||||
* Usage:
|
||||
* pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import nacl from 'tweetnacl'
|
||||
import WebSocket from 'ws'
|
||||
import {
|
||||
saveStartupCaptures,
|
||||
summarizeStartupCapture,
|
||||
type StartupTerminalCapture
|
||||
} from './repro-worktree-startup-output'
|
||||
|
||||
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
|
||||
const USER_DATA =
|
||||
@@ -15,7 +20,6 @@ const USER_DATA =
|
||||
const repoSelector = process.argv[2]
|
||||
const worktreeName = process.argv[3]
|
||||
const startupCommand = process.argv[4] || 'claude'
|
||||
const ESC = String.fromCharCode(27)
|
||||
|
||||
type RpcResponse = {
|
||||
id: string
|
||||
@@ -36,13 +40,6 @@ type TerminalInfo = {
|
||||
title: string | null
|
||||
}
|
||||
|
||||
type Capture = {
|
||||
handle: string
|
||||
title: string | null
|
||||
scrollback: Record<string, unknown> | null
|
||||
chunks: string[]
|
||||
}
|
||||
|
||||
if (!repoSelector || !worktreeName) {
|
||||
console.error(
|
||||
'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]'
|
||||
@@ -141,26 +138,6 @@ function formatError(response: RpcResponse): string {
|
||||
return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000)
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return (
|
||||
value
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
|
||||
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
|
||||
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\r/g, '\n')
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePreview(value: string): string {
|
||||
return stripAnsi(value)
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<TerminalInfo[]> {
|
||||
const deadline = Date.now() + 60_000
|
||||
while (Date.now() < deadline) {
|
||||
@@ -185,7 +162,7 @@ async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<Term
|
||||
throw new Error('Timed out waiting for startup terminals')
|
||||
}
|
||||
|
||||
async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
|
||||
async function subscribe(ws: WebSocket, capture: StartupTerminalCapture): Promise<void> {
|
||||
const id = nextId()
|
||||
streamListeners.set(id, (result) => {
|
||||
if (result.type === 'scrollback') {
|
||||
@@ -204,44 +181,6 @@ async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function summarize(capture: Capture): Record<string, unknown> {
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
const live = capture.chunks.join('')
|
||||
const serializedPreview = normalizePreview(serialized)
|
||||
const livePreview = normalizePreview(live)
|
||||
return {
|
||||
handle: capture.handle,
|
||||
title: capture.title,
|
||||
cols: capture.scrollback?.cols ?? null,
|
||||
rows: capture.scrollback?.rows ?? null,
|
||||
serializedBytes: Buffer.byteLength(serialized),
|
||||
liveBytes: Buffer.byteLength(live),
|
||||
liveChunks: capture.chunks.length,
|
||||
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
|
||||
livePreviewContainedInSerialized:
|
||||
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
|
||||
serializedPreview,
|
||||
livePreview
|
||||
}
|
||||
}
|
||||
|
||||
function save(captures: Capture[], worktreeName: string): string {
|
||||
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (const capture of captures) {
|
||||
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
|
||||
const serialized =
|
||||
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
|
||||
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
|
||||
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
|
||||
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
|
||||
}
|
||||
writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function run(ws: WebSocket): Promise<void> {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -312,8 +251,8 @@ async function run(ws: WebSocket): Promise<void> {
|
||||
for (const capture of captures) {
|
||||
await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null)
|
||||
}
|
||||
const dir = save(captures, worktreeName)
|
||||
console.table(captures.map(summarize))
|
||||
const dir = saveStartupCaptures(captures, worktreeName)
|
||||
console.table(captures.map(summarizeStartupCapture))
|
||||
console.log(`saved: ${dir}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const customKeyModalStyles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
backButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
backSpacer: {
|
||||
width: 30
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center'
|
||||
},
|
||||
group: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
row: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 1
|
||||
},
|
||||
rowHint: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted
|
||||
},
|
||||
shortcutForm: {
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
preview: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.lg + spacing.xs,
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
previewKeycapRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
previewPlus: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16
|
||||
},
|
||||
keycap: {
|
||||
minWidth: 48,
|
||||
height: 48,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keycapModifier: {
|
||||
minWidth: 0
|
||||
},
|
||||
keycapWarn: {
|
||||
borderColor: colors.statusAmber
|
||||
},
|
||||
keycapText: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 17,
|
||||
fontWeight: '600'
|
||||
},
|
||||
keycapTextWarn: {
|
||||
color: colors.statusAmber
|
||||
},
|
||||
keycapModifierText: {
|
||||
color: colors.textSecondary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: '600'
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.md
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: spacing.sm,
|
||||
paddingLeft: 2
|
||||
},
|
||||
mods: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
chip: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4
|
||||
},
|
||||
chipSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
chipPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
chipText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: '500'
|
||||
},
|
||||
chipTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
chipGlyph: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
chipGlyphSelected: {
|
||||
color: 'rgba(10,10,10,0.5)'
|
||||
},
|
||||
keyInput: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center'
|
||||
},
|
||||
moreLink: {
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center'
|
||||
},
|
||||
moreLinkPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
moreLinkText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13,
|
||||
textDecorationLine: 'underline'
|
||||
},
|
||||
specialKeysForm: {
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.md,
|
||||
gap: spacing.md
|
||||
},
|
||||
specialGroup: {
|
||||
gap: spacing.xs
|
||||
},
|
||||
specialGroupTitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
paddingLeft: 2,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
keyGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginHorizontal: -spacing.xs / 2
|
||||
},
|
||||
keyCellWrap: {
|
||||
paddingHorizontal: spacing.xs / 2,
|
||||
paddingVertical: spacing.xs / 2
|
||||
},
|
||||
keyCell: {
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keyCellPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
keyCellSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
keyCellText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
keyCellTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
macroForm: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
fieldInput: {
|
||||
backgroundColor: colors.bgBase,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
switchLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
saveButton: {
|
||||
marginTop: spacing.md,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: 15,
|
||||
fontWeight: '600'
|
||||
},
|
||||
saveButtonTextDisabled: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
|
||||
import { View, Text, Pressable, TextInput, Switch } from 'react-native'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import {
|
||||
buildTerminalShortcutKey,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type TerminalShortcutModifier,
|
||||
type TerminalShortcutSpecialKey
|
||||
} from '../terminal/terminal-accessory-keys'
|
||||
import { customKeyModalStyles as styles } from './CustomKeyModal.styles'
|
||||
|
||||
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
|
||||
|
||||
@@ -409,277 +410,3 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
backButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
backSpacer: {
|
||||
width: 30
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center'
|
||||
},
|
||||
group: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
row: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 1
|
||||
},
|
||||
rowHint: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted
|
||||
},
|
||||
shortcutForm: {
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
preview: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.lg + spacing.xs,
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
previewKeycapRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
previewPlus: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16
|
||||
},
|
||||
keycap: {
|
||||
minWidth: 48,
|
||||
height: 48,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keycapModifier: {
|
||||
minWidth: 0
|
||||
},
|
||||
keycapWarn: {
|
||||
borderColor: colors.statusAmber
|
||||
},
|
||||
keycapText: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 17,
|
||||
fontWeight: '600'
|
||||
},
|
||||
keycapTextWarn: {
|
||||
color: colors.statusAmber
|
||||
},
|
||||
keycapModifierText: {
|
||||
color: colors.textSecondary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: '600'
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.md
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: spacing.sm,
|
||||
paddingLeft: 2
|
||||
},
|
||||
mods: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
chip: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4
|
||||
},
|
||||
chipSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
chipPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
chipText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: '500'
|
||||
},
|
||||
chipTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
chipGlyph: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
chipGlyphSelected: {
|
||||
color: 'rgba(10,10,10,0.5)'
|
||||
},
|
||||
keyInput: {
|
||||
width: '100%',
|
||||
height: 56,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center'
|
||||
},
|
||||
moreLink: {
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center'
|
||||
},
|
||||
moreLinkPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
moreLinkText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13,
|
||||
textDecorationLine: 'underline'
|
||||
},
|
||||
specialKeysForm: {
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.md,
|
||||
gap: spacing.md
|
||||
},
|
||||
specialGroup: {
|
||||
gap: spacing.xs
|
||||
},
|
||||
specialGroupTitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
paddingLeft: 2,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
keyGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginHorizontal: -spacing.xs / 2
|
||||
},
|
||||
keyCellWrap: {
|
||||
paddingHorizontal: spacing.xs / 2,
|
||||
paddingVertical: spacing.xs / 2
|
||||
},
|
||||
keyCell: {
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgPanel,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
keyCellPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
keyCellSelected: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
keyCellText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
keyCellTextSelected: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
macroForm: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
fieldInput: {
|
||||
backgroundColor: colors.bgBase,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
switchLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
saveButton: {
|
||||
marginTop: spacing.md,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: 15,
|
||||
fontWeight: '600'
|
||||
},
|
||||
saveButtonTextDisabled: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [
|
||||
';',
|
||||
' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;',
|
||||
' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
|
||||
' }',
|
||||
' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }',
|
||||
' html, body {',
|
||||
' width: 100%;',
|
||||
' min-height: 100%;',
|
||||
' margin: 0;',
|
||||
' background: var(--editor-surface);',
|
||||
' color: var(--foreground);',
|
||||
' font-family: var(--font-sans);',
|
||||
' overscroll-behavior: contain;',
|
||||
' }',
|
||||
' body { overflow: auto; }',
|
||||
' #editor {',
|
||||
' min-height: 100vh;',
|
||||
' padding: 18px 16px 112px;',
|
||||
' outline: none;',
|
||||
' font-size: 14px;',
|
||||
' line-height: 1.7;',
|
||||
' word-wrap: break-word;',
|
||||
' overflow-wrap: anywhere;',
|
||||
' caret-color: var(--foreground);',
|
||||
' }',
|
||||
' #editor[contenteditable="false"] {',
|
||||
' opacity: 0.78;',
|
||||
' }',
|
||||
' #editor:empty::before,',
|
||||
' #editor p.is-empty:first-child::before {',
|
||||
' content: attr(data-placeholder);',
|
||||
' color: var(--muted-foreground);',
|
||||
' pointer-events: none;',
|
||||
' }',
|
||||
' #editor > :first-child { margin-top: 0; }',
|
||||
' h1, h2, h3, h4, h5, h6 {',
|
||||
' margin: 1.5em 0 0.5em;',
|
||||
' font-weight: 600;',
|
||||
' line-height: 1.3;',
|
||||
' letter-spacing: 0;',
|
||||
' }',
|
||||
' h1 { font-size: 1.85em; font-weight: 700; }',
|
||||
' h2 { font-size: 1.4em; }',
|
||||
' h3 { font-size: 1.15em; }',
|
||||
' p, ul, ol, blockquote { margin: 0.75em 0; }',
|
||||
' ul, ol { padding-left: 1.5em; }',
|
||||
' ul { list-style: disc; }',
|
||||
' ol { list-style: decimal; }',
|
||||
' li { margin: 0.15em 0; }',
|
||||
' li > p { margin: 0; }',
|
||||
' ul[data-type="taskList"] {',
|
||||
' padding-left: 0;',
|
||||
' list-style: none;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li {',
|
||||
' display: flex;',
|
||||
' align-items: flex-start;',
|
||||
' gap: 6px;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li > label {',
|
||||
' flex-shrink: 0;',
|
||||
' display: flex;',
|
||||
' align-items: center;',
|
||||
' height: 1.55em;',
|
||||
' user-select: none;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"] {',
|
||||
' appearance: none;',
|
||||
' width: 16px;',
|
||||
' height: 16px;',
|
||||
' margin: 0;',
|
||||
' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);',
|
||||
' border-radius: 4px;',
|
||||
' background: transparent;',
|
||||
' position: relative;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"]:checked {',
|
||||
' background: var(--primary);',
|
||||
' border-color: var(--primary);',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"]:checked::after {',
|
||||
' content: "";',
|
||||
' position: absolute;',
|
||||
' left: 4px;',
|
||||
' top: 1px;',
|
||||
' width: 5px;',
|
||||
' height: 9px;',
|
||||
' border: solid var(--primary-foreground);',
|
||||
' border-width: 0 2px 2px 0;',
|
||||
' transform: rotate(45deg);',
|
||||
' }',
|
||||
' ul[data-type="taskList"] input[type="checkbox"]:disabled {',
|
||||
' opacity: 0.65;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li > div {',
|
||||
' flex: 1;',
|
||||
' min-width: 0;',
|
||||
' }',
|
||||
' ul[data-type="taskList"] > li[data-checked="true"] > div {',
|
||||
' text-decoration: line-through;',
|
||||
' color: var(--muted-foreground);',
|
||||
' }',
|
||||
' blockquote {',
|
||||
' padding: 0.5em 1em;',
|
||||
' border-left: 3px solid var(--border);',
|
||||
' border-radius: 0 6px 6px 0;',
|
||||
' color: var(--muted-foreground);',
|
||||
' background: color-mix(in srgb, var(--foreground) 2%, transparent);',
|
||||
' }',
|
||||
' table {',
|
||||
' width: 100%;',
|
||||
' margin: 1em 0;',
|
||||
' border-collapse: collapse;',
|
||||
' font-size: 0.95em;',
|
||||
' }',
|
||||
' th, td {',
|
||||
' padding: 8px 14px;',
|
||||
' border: 1px solid var(--border);',
|
||||
' text-align: left;',
|
||||
' vertical-align: top;',
|
||||
' }',
|
||||
' th {',
|
||||
' font-weight: 600;',
|
||||
' background: color-mix(in srgb, var(--foreground) 4%, transparent);',
|
||||
' }',
|
||||
' tr:nth-child(odd) td {',
|
||||
' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);',
|
||||
' }',
|
||||
' code {',
|
||||
' padding: 0.2em 0.4em;',
|
||||
' border-radius: 5px;',
|
||||
' background: color-mix(in srgb, var(--foreground) 8%, transparent);',
|
||||
' font-size: 0.88em;',
|
||||
' font-family: var(--font-mono);',
|
||||
' }',
|
||||
' pre {',
|
||||
' margin: 0.75em 0;',
|
||||
' padding: 14px 18px;',
|
||||
' border-radius: 8px;',
|
||||
' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);',
|
||||
' overflow-x: auto;',
|
||||
' line-height: 1.55;',
|
||||
' background: color-mix(in srgb, var(--foreground) 6%, transparent);',
|
||||
' font-family: var(--font-mono);',
|
||||
' white-space: pre-wrap;',
|
||||
' }',
|
||||
' pre::before {',
|
||||
' content: attr(data-language);',
|
||||
' display: block;',
|
||||
' min-height: 13px;',
|
||||
' margin-bottom: 4px;',
|
||||
' color: var(--muted-foreground);',
|
||||
' font-size: 11px;',
|
||||
' text-transform: uppercase;',
|
||||
' }',
|
||||
' pre code {',
|
||||
' padding: 0;',
|
||||
' border-radius: 0;',
|
||||
' background: transparent;',
|
||||
' font-size: 0.92em;',
|
||||
' white-space: pre-wrap;',
|
||||
' }',
|
||||
' hr {',
|
||||
' margin: 1.5em 0;',
|
||||
' border: none;',
|
||||
' border-top: 1px solid var(--border);',
|
||||
' }',
|
||||
' a {',
|
||||
' color: var(--accent-link);',
|
||||
' text-decoration: underline;',
|
||||
' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);',
|
||||
' text-underline-offset: 2px;',
|
||||
' }',
|
||||
' img {',
|
||||
' display: block;',
|
||||
' max-width: 100%;',
|
||||
' margin: 0.75em 0;',
|
||||
' border-radius: 8px;',
|
||||
' }',
|
||||
' </style>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
' <main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>',
|
||||
' <script>',
|
||||
' (function () {',
|
||||
" var editor = document.getElementById('editor');",
|
||||
" var lastMarkdown = '';",
|
||||
' var inputTimer = null;',
|
||||
' var documentGeneration = 0;',
|
||||
' var editable = true;',
|
||||
' var suppressInput = false;',
|
||||
'',
|
||||
' function post(message) {',
|
||||
' window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));',
|
||||
' }',
|
||||
'',
|
||||
' function decodeMarkdownEntities(value) {',
|
||||
' return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {',
|
||||
' var lower = String(entity).toLowerCase();',
|
||||
" if (lower === 'amp') return '&';",
|
||||
" if (lower === 'lt') return '<';",
|
||||
" if (lower === 'gt') return '>';",
|
||||
" if (lower === 'quot') return '\"';",
|
||||
" if (lower === 'apos') return \"'\";",
|
||||
" if (lower.indexOf('#x') === 0) {",
|
||||
' var hex = Number.parseInt(lower.slice(2), 16);',
|
||||
' return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;',
|
||||
' }',
|
||||
" if (lower.indexOf('#') === 0) {",
|
||||
' var code = Number.parseInt(lower.slice(1), 10);',
|
||||
' return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;',
|
||||
' }',
|
||||
' return match;',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function escapeHtml(value) {',
|
||||
' return decodeMarkdownEntities(value).replace(/[&<>"\']/g, function (char) {',
|
||||
" return ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[char];",
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function escapeAttr(value) {',
|
||||
" return escapeHtml(value).replace(/\\n/g, ' ');",
|
||||
' }',
|
||||
'',
|
||||
' function isSafeUrl(value) {',
|
||||
" var trimmed = String(value || '').trim();",
|
||||
' return !/^javascript:/i.test(trimmed);',
|
||||
' }',
|
||||
'',
|
||||
' function splitTableRow(line) {',
|
||||
" return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {",
|
||||
' return cell.trim();',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function isTableSeparator(line) {',
|
||||
' var cells = splitTableRow(line);',
|
||||
' return cells.length > 0 && cells.every(function (cell) {',
|
||||
' return /^:?-{3,}:?$/.test(cell);',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function renderInline(text) {',
|
||||
" var output = '';",
|
||||
' var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|`[^`]+`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;',
|
||||
' var lastIndex = 0;',
|
||||
' var match;',
|
||||
' while ((match = pattern.exec(text))) {',
|
||||
' output += escapeHtml(text.slice(lastIndex, match.index));',
|
||||
' var token = match[0];',
|
||||
' var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);',
|
||||
' var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);',
|
||||
' if (image && isSafeUrl(image[2])) {',
|
||||
" output += '<img src=\"' + escapeAttr(image[2]) + '\" alt=\"' + escapeAttr(image[1] || '') + '\" />';",
|
||||
' } else if (link && isSafeUrl(link[2])) {',
|
||||
" output += '<a href=\"' + escapeAttr(link[2]) + '\">' + renderInline(link[1]) + '</a>';",
|
||||
' } else if (/^https?:\\/\\//i.test(token)) {',
|
||||
" output += '<a href=\"' + escapeAttr(token) + '\">' + escapeHtml(token) + '</a>';",
|
||||
" } else if (token.indexOf('`') === 0) {",
|
||||
" output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';",
|
||||
" } else if (token.indexOf('~~') === 0) {",
|
||||
" output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';",
|
||||
" } else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {",
|
||||
" output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';",
|
||||
' } else {',
|
||||
" output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';",
|
||||
' }',
|
||||
' lastIndex = pattern.lastIndex;',
|
||||
' }',
|
||||
' output += escapeHtml(text.slice(lastIndex));',
|
||||
' return output;',
|
||||
' }',
|
||||
'',
|
||||
' function isBlockStart(line) {',
|
||||
' return /^(```|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);'
|
||||
].join('\n')
|
||||
@@ -0,0 +1,278 @@
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY = [
|
||||
' }',
|
||||
'',
|
||||
' function indentationWidth(value) {',
|
||||
" return String(value || '').replace(/\\t/g, ' ').length;",
|
||||
' }',
|
||||
'',
|
||||
' function parseListLine(line) {',
|
||||
" var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);",
|
||||
' if (!match) return null;',
|
||||
" var rawText = match[3] || '';",
|
||||
' var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);',
|
||||
' return {',
|
||||
" indent: indentationWidth(match[1] || ''),",
|
||||
" ordered: /^\\d/.test(match[2] || ''),",
|
||||
" orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,",
|
||||
" task: task ? task[1].toLowerCase() === 'x' : null,",
|
||||
' text: task ? task[2] : rawText,',
|
||||
' children: []',
|
||||
' };',
|
||||
' }',
|
||||
'',
|
||||
' function listKind(item) {',
|
||||
" if (item.task !== null) return 'task';",
|
||||
" return item.ordered ? 'ol' : 'ul';",
|
||||
' }',
|
||||
'',
|
||||
' function parseListTree(lines, startIndex) {',
|
||||
' var root = { indent: -1, children: [] };',
|
||||
' var stack = [root];',
|
||||
' var index = startIndex;',
|
||||
' while (index < lines.length) {',
|
||||
" var item = parseListLine(lines[index] || '');",
|
||||
' if (!item) break;',
|
||||
' while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {',
|
||||
' stack.pop();',
|
||||
' }',
|
||||
' stack[stack.length - 1].children.push(item);',
|
||||
' stack.push(item);',
|
||||
' index += 1;',
|
||||
' }',
|
||||
' return { items: root.children, nextIndex: index };',
|
||||
' }',
|
||||
'',
|
||||
' function renderListItems(items) {',
|
||||
' var html = [];',
|
||||
' var index = 0;',
|
||||
' while (index < items.length) {',
|
||||
' var kind = listKind(items[index]);',
|
||||
' var group = [];',
|
||||
' while (index < items.length && listKind(items[index]) === kind) {',
|
||||
' group.push(items[index]);',
|
||||
' index += 1;',
|
||||
' }',
|
||||
" var tag = kind === 'ol' ? 'ol' : 'ul';",
|
||||
" var attrs = kind === 'task' ? ' data-type=\"taskList\"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start=\"' + group[0].orderedNumber + '\"' : '';",
|
||||
" html.push('<' + tag + attrs + '>' + group.map(function (item) {",
|
||||
" var children = item.children.length ? renderListItems(item.children) : '';",
|
||||
" if (kind === 'task') {",
|
||||
' var checked = item.task === true;',
|
||||
" return '<li data-checked=\"' + String(checked) + '\"><label contenteditable=\"false\"><input type=\"checkbox\" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';",
|
||||
' }',
|
||||
" var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value=\"' + item.orderedNumber + '\" data-list-number=\"' + item.orderedNumber + '\"' : '';",
|
||||
" return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';",
|
||||
" }).join('') + '</' + tag + '>');",
|
||||
' }',
|
||||
" return html.join('');",
|
||||
' }',
|
||||
'',
|
||||
' function markdownToHtml(markdown) {',
|
||||
" var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');",
|
||||
' var html = [];',
|
||||
' var index = 0;',
|
||||
' while (index < lines.length) {',
|
||||
" var line = lines[index] || '';",
|
||||
' if (!line.trim()) {',
|
||||
' index += 1;',
|
||||
' continue;',
|
||||
' }',
|
||||
' var fence = line.match(/^\\```([^\\s`]*)\\s*$/);',
|
||||
' if (fence) {',
|
||||
' index += 1;',
|
||||
' var code = [];',
|
||||
" while (index < lines.length && !/^\\```\\s*$/.test(lines[index] || '')) {",
|
||||
" code.push(lines[index] || '');",
|
||||
' index += 1;',
|
||||
' }',
|
||||
' if (index < lines.length) index += 1;',
|
||||
" html.push('<pre data-language=\"' + escapeAttr(fence[1] || '') + '\"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');",
|
||||
' continue;',
|
||||
' }',
|
||||
' if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {',
|
||||
" html.push('<hr />');",
|
||||
' index += 1;',
|
||||
' continue;',
|
||||
' }',
|
||||
" if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {",
|
||||
' var headers = splitTableRow(line);',
|
||||
' index += 2;',
|
||||
' var rows = [];',
|
||||
" while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {",
|
||||
" rows.push(splitTableRow(lines[index] || ''));",
|
||||
' index += 1;',
|
||||
' }',
|
||||
" html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {",
|
||||
" return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';",
|
||||
" }).join('') + '</tbody></table>');",
|
||||
' continue;',
|
||||
' }',
|
||||
' var heading = line.match(/^(#{1,6})\\s+(.+)$/);',
|
||||
' if (heading) {',
|
||||
" html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');",
|
||||
' index += 1;',
|
||||
' continue;',
|
||||
' }',
|
||||
' if (/^>\\s?/.test(line)) {',
|
||||
' var quote = [];',
|
||||
" while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {",
|
||||
" quote.push((lines[index] || '').replace(/^>\\s?/, ''));",
|
||||
' index += 1;',
|
||||
' }',
|
||||
" html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');",
|
||||
' continue;',
|
||||
' }',
|
||||
' if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {',
|
||||
' var list = parseListTree(lines, index);',
|
||||
' html.push(renderListItems(list.items));',
|
||||
' index = list.nextIndex;',
|
||||
' continue;',
|
||||
' }',
|
||||
' var paragraph = [];',
|
||||
" while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {",
|
||||
" paragraph.push(lines[index] || '');",
|
||||
' index += 1;',
|
||||
' }',
|
||||
" html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');",
|
||||
' }',
|
||||
" return html.join('\\n') || '<p class=\"is-empty\"><br /></p>';",
|
||||
' }',
|
||||
'',
|
||||
' function textContent(node) {',
|
||||
" return (node.textContent || '').replace(/\\u00a0/g, ' ');",
|
||||
' }',
|
||||
'',
|
||||
' function inlineMarkdown(node) {',
|
||||
" if (!node) return '';",
|
||||
' if (node.nodeType === Node.TEXT_NODE) return textContent(node);',
|
||||
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
|
||||
' var el = node;',
|
||||
' var tag = el.tagName.toLowerCase();',
|
||||
" if (tag === 'br') return '\\n';",
|
||||
" if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';",
|
||||
" if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';",
|
||||
" if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';",
|
||||
" if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '`' + textContent(el) + '`';",
|
||||
" if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';",
|
||||
" if (tag === 'img') return ' || '') + ')';",
|
||||
" if (tag === 'label') return '';",
|
||||
' return inlineChildren(el);',
|
||||
' }',
|
||||
'',
|
||||
' function inlineChildren(el) {',
|
||||
" return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');",
|
||||
' }',
|
||||
'',
|
||||
' function listItemText(li) {',
|
||||
' var clone = li.cloneNode(true);',
|
||||
" Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });",
|
||||
" Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });",
|
||||
' return inlineChildren(clone).trim();',
|
||||
' }',
|
||||
'',
|
||||
' function directNestedLists(li) {',
|
||||
" return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {",
|
||||
" return list.closest('li') === li;",
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function listMarkdown(el, depth) {',
|
||||
' var tag = el.tagName.toLowerCase();',
|
||||
" var isTask = el.getAttribute('data-type') === 'taskList';",
|
||||
" var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;",
|
||||
' if (!Number.isFinite(orderedStart)) orderedStart = 1;',
|
||||
" var indent = ' '.repeat(depth);",
|
||||
' return Array.prototype.map.call(el.children, function (li, index) {',
|
||||
" if (li.tagName.toLowerCase() !== 'li') return '';",
|
||||
' var marker;',
|
||||
' if (isTask) {',
|
||||
' var input = li.querySelector(\'input[type="checkbox"]\');',
|
||||
" marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';",
|
||||
' } else {',
|
||||
' var listNumber =',
|
||||
' li.getAttribute &&',
|
||||
" (li.getAttribute('data-list-number') || li.getAttribute('value'));",
|
||||
" marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';",
|
||||
' }',
|
||||
' var line = indent + marker + listItemText(li);',
|
||||
' var nested = directNestedLists(li).map(function (list) {',
|
||||
' return listMarkdown(list, depth + 1);',
|
||||
" }).filter(Boolean).join('\\n');",
|
||||
" return nested ? line + '\\n' + nested : line;",
|
||||
" }).filter(Boolean).join('\\n');",
|
||||
' }',
|
||||
'',
|
||||
' function blockMarkdown(node) {',
|
||||
' if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();',
|
||||
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
|
||||
' var el = node;',
|
||||
' var tag = el.tagName.toLowerCase();',
|
||||
" if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();",
|
||||
" if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();",
|
||||
" if (tag === 'blockquote') {",
|
||||
" return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');",
|
||||
' }',
|
||||
" if (tag === 'pre') {",
|
||||
" var lang = el.getAttribute('data-language') || '';",
|
||||
" var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');",
|
||||
" return '```' + lang + '\\n' + code + '\\n```';",
|
||||
' }',
|
||||
" if (tag === 'ul' || tag === 'ol') {",
|
||||
' return listMarkdown(el, 0);',
|
||||
' }',
|
||||
" if (tag === 'table') {",
|
||||
" var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));",
|
||||
" if (rows.length === 0) return '';",
|
||||
' var cellsFor = function (row) {',
|
||||
' return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });',
|
||||
' };',
|
||||
' var headers = cellsFor(rows[0]);',
|
||||
' var bodyRows = rows.slice(1).map(cellsFor);',
|
||||
" return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');",
|
||||
' }',
|
||||
" if (tag === 'hr') return '---';",
|
||||
" if (tag === 'img') return inlineMarkdown(el);",
|
||||
' return inlineChildren(el).trim();',
|
||||
' }',
|
||||
'',
|
||||
' function currentMarkdown() {',
|
||||
' return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {',
|
||||
' return block.trim().length > 0;',
|
||||
" }).join('\\n\\n').trimEnd();",
|
||||
' }',
|
||||
'',
|
||||
' function syncTaskCheckboxesDisabled() {',
|
||||
' Array.prototype.forEach.call(editor.querySelectorAll(\'input[type="checkbox"]\'), function (input) {',
|
||||
' input.disabled = !editable;',
|
||||
' });',
|
||||
' }',
|
||||
'',
|
||||
' function emitChange() {',
|
||||
' if (suppressInput || !editable) return;',
|
||||
' window.clearTimeout(inputTimer);',
|
||||
' var pendingGeneration = documentGeneration;',
|
||||
' lastMarkdown = currentMarkdown();',
|
||||
" post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });",
|
||||
' }',
|
||||
'',
|
||||
' function setMarkdown(markdown, generation) {',
|
||||
' window.clearTimeout(inputTimer);',
|
||||
' documentGeneration = Number(generation) || 0;',
|
||||
' suppressInput = true;',
|
||||
" // Why: replacing innerHTML detaches the remembered caret's nodes.",
|
||||
' savedSelectionRange = null;',
|
||||
' selectionDroppedOnBlur = false;',
|
||||
" lastMarkdown = String(markdown || '');",
|
||||
' editor.innerHTML = markdownToHtml(lastMarkdown);',
|
||||
' syncTaskCheckboxesDisabled();',
|
||||
' suppressInput = false;',
|
||||
' }',
|
||||
'',
|
||||
' function setEditable(nextEditable) {',
|
||||
' editable = Boolean(nextEditable);',
|
||||
" editor.setAttribute('contenteditable', editable ? 'true' : 'false');",
|
||||
' syncTaskCheckboxesDisabled();',
|
||||
' }',
|
||||
'',
|
||||
''
|
||||
].join('\n')
|
||||
@@ -0,0 +1,93 @@
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS = [
|
||||
'',
|
||||
' function runCommand(command) {',
|
||||
" if (!editable || editor.getAttribute('contenteditable') !== 'true') return;",
|
||||
' restoreSelectionOrEnd();',
|
||||
" if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');",
|
||||
" else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');",
|
||||
" else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');",
|
||||
" else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');",
|
||||
" else if (command === 'bold') document.execCommand('bold');",
|
||||
" else if (command === 'italic') document.execCommand('italic');",
|
||||
" else if (command === 'strike') document.execCommand('strikeThrough');",
|
||||
" else if (command === 'bulletList') document.execCommand('insertUnorderedList');",
|
||||
" else if (command === 'orderedList') document.execCommand('insertOrderedList');",
|
||||
" else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');",
|
||||
" else if (command === 'inlineCode') wrapSelection('code');",
|
||||
" else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=\"\"><code>code</code></pre><p><br></p>');",
|
||||
' else if (command === \'taskList\') document.execCommand(\'insertHTML\', false, \'<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>\');',
|
||||
" else if (command === 'link') {",
|
||||
" var href = window.prompt('Link URL');",
|
||||
" if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);",
|
||||
" } else if (command === 'image') {",
|
||||
" var src = window.prompt('Image URL');",
|
||||
" if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);",
|
||||
' }',
|
||||
' syncTaskCheckboxesDisabled();',
|
||||
' emitChange();',
|
||||
' }',
|
||||
'',
|
||||
" editor.addEventListener('input', function () {",
|
||||
' selectionDroppedOnBlur = false;',
|
||||
' if (editable) emitChange();',
|
||||
' });',
|
||||
" editor.addEventListener('change', function (event) {",
|
||||
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
|
||||
' if (input) {',
|
||||
' if (!editable) {',
|
||||
' event.preventDefault();',
|
||||
' return;',
|
||||
' }',
|
||||
" var li = input.closest('li');",
|
||||
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
|
||||
' }',
|
||||
' if (editable) emitChange();',
|
||||
' });',
|
||||
" editor.addEventListener('click', function (event) {",
|
||||
" var link = event.target && event.target.closest && event.target.closest('a[href]');",
|
||||
' if (link) {',
|
||||
' event.preventDefault();',
|
||||
" post({ type: 'openLink', url: link.getAttribute('href') || '' });",
|
||||
' return;',
|
||||
' }',
|
||||
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
|
||||
' if (!input) {',
|
||||
' if (!editable) return;',
|
||||
' // Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.',
|
||||
' var uneditable = event.target && event.target.closest && event.target.closest(\'[contenteditable="false"]\');',
|
||||
' if (uneditable && uneditable !== editor) return;',
|
||||
' selectionDroppedOnBlur = false;',
|
||||
' if (document.activeElement === editor) return;',
|
||||
' // Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.',
|
||||
' var caret = caretRangeAtPoint(event.clientX, event.clientY);',
|
||||
' focusEditor();',
|
||||
' if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);',
|
||||
' return;',
|
||||
' }',
|
||||
' if (!editable) {',
|
||||
' event.preventDefault();',
|
||||
' return;',
|
||||
' }',
|
||||
" var li = input.closest('li');",
|
||||
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
|
||||
' emitChange();',
|
||||
' });',
|
||||
" editor.addEventListener('keydown', function (event) {",
|
||||
" if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {",
|
||||
' event.preventDefault();',
|
||||
" runCommand('bold');",
|
||||
' }',
|
||||
' });',
|
||||
'',
|
||||
' window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
export const MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END = [
|
||||
'',
|
||||
" post({ type: 'ready' });",
|
||||
' })();',
|
||||
' </script>',
|
||||
'</body>',
|
||||
'</html>'
|
||||
].join('\n')
|
||||
@@ -2,6 +2,12 @@ import { colors } from '../theme/mobile-theme'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
|
||||
import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script'
|
||||
import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY } from './mobile-rich-markdown-editor-body-primary'
|
||||
import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY } from './mobile-rich-markdown-editor-body-secondary'
|
||||
import {
|
||||
MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS,
|
||||
MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END
|
||||
} from './mobile-rich-markdown-editor-document-suffix'
|
||||
|
||||
export function escapeInjectedJavaScriptString(value: string): string {
|
||||
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
|
||||
@@ -24,644 +30,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
--border: ${colors.borderSubtle};
|
||||
--primary: ${colors.textPrimary};
|
||||
--primary-foreground: ${colors.bgBase};
|
||||
--accent-link: ${colors.accentBlue};
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
background: var(--editor-surface);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
body { overflow: auto; }
|
||||
#editor {
|
||||
min-height: 100vh;
|
||||
padding: 18px 16px 112px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
#editor[contenteditable="false"] {
|
||||
opacity: 0.78;
|
||||
}
|
||||
#editor:empty::before,
|
||||
#editor p.is-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
#editor > :first-child { margin-top: 0; }
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 1.5em 0 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
h1 { font-size: 1.85em; font-weight: 700; }
|
||||
h2 { font-size: 1.4em; }
|
||||
h3 { font-size: 1.15em; }
|
||||
p, ul, ol, blockquote { margin: 0.75em 0; }
|
||||
ul, ol { padding-left: 1.5em; }
|
||||
ul { list-style: disc; }
|
||||
ol { list-style: decimal; }
|
||||
li { margin: 0.15em 0; }
|
||||
li > p { margin: 0; }
|
||||
ul[data-type="taskList"] {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
ul[data-type="taskList"] > li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
ul[data-type="taskList"] > li > label {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.55em;
|
||||
user-select: none;
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"]:checked {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"]:checked::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid var(--primary-foreground);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
ul[data-type="taskList"] input[type="checkbox"]:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
ul[data-type="taskList"] > li > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
ul[data-type="taskList"] > li[data-checked="true"] > div {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
blockquote {
|
||||
padding: 0.5em 1em;
|
||||
border-left: 3px solid var(--border);
|
||||
border-radius: 0 6px 6px 0;
|
||||
color: var(--muted-foreground);
|
||||
background: color-mix(in srgb, var(--foreground) 2%, transparent);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
}
|
||||
tr:nth-child(odd) td {
|
||||
background: color-mix(in srgb, var(--foreground) 1.5%, transparent);
|
||||
}
|
||||
code {
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
font-size: 0.88em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
pre {
|
||||
margin: 0.75em 0;
|
||||
padding: 14px 18px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
overflow-x: auto;
|
||||
line-height: 1.55;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
pre::before {
|
||||
content: attr(data-language);
|
||||
display: block;
|
||||
min-height: 13px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
pre code {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: 0.92em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
hr {
|
||||
margin: 1.5em 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-link);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0.75em 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>
|
||||
<script>
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
var lastMarkdown = '';
|
||||
var inputTimer = null;
|
||||
var documentGeneration = 0;
|
||||
var editable = true;
|
||||
var suppressInput = false;
|
||||
|
||||
function post(message) {
|
||||
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function decodeMarkdownEntities(value) {
|
||||
return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {
|
||||
var lower = String(entity).toLowerCase();
|
||||
if (lower === 'amp') return '&';
|
||||
if (lower === 'lt') return '<';
|
||||
if (lower === 'gt') return '>';
|
||||
if (lower === 'quot') return '"';
|
||||
if (lower === 'apos') return "'";
|
||||
if (lower.indexOf('#x') === 0) {
|
||||
var hex = Number.parseInt(lower.slice(2), 16);
|
||||
return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;
|
||||
}
|
||||
if (lower.indexOf('#') === 0) {
|
||||
var code = Number.parseInt(lower.slice(1), 10);
|
||||
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return decodeMarkdownEntities(value).replace(/[&<>"']/g, function (char) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char];
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/\\n/g, ' ');
|
||||
}
|
||||
|
||||
function isSafeUrl(value) {
|
||||
var trimmed = String(value || '').trim();
|
||||
return !/^javascript:/i.test(trimmed);
|
||||
}
|
||||
|
||||
function splitTableRow(line) {
|
||||
return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {
|
||||
return cell.trim();
|
||||
});
|
||||
}
|
||||
|
||||
function isTableSeparator(line) {
|
||||
var cells = splitTableRow(line);
|
||||
return cells.length > 0 && cells.every(function (cell) {
|
||||
return /^:?-{3,}:?$/.test(cell);
|
||||
});
|
||||
}
|
||||
|
||||
function renderInline(text) {
|
||||
var output = '';
|
||||
var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|\`[^\`]+\`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;
|
||||
var lastIndex = 0;
|
||||
var match;
|
||||
while ((match = pattern.exec(text))) {
|
||||
output += escapeHtml(text.slice(lastIndex, match.index));
|
||||
var token = match[0];
|
||||
var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);
|
||||
var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);
|
||||
if (image && isSafeUrl(image[2])) {
|
||||
output += '<img src="' + escapeAttr(image[2]) + '" alt="' + escapeAttr(image[1] || '') + '" />';
|
||||
} else if (link && isSafeUrl(link[2])) {
|
||||
output += '<a href="' + escapeAttr(link[2]) + '">' + renderInline(link[1]) + '</a>';
|
||||
} else if (/^https?:\\/\\//i.test(token)) {
|
||||
output += '<a href="' + escapeAttr(token) + '">' + escapeHtml(token) + '</a>';
|
||||
} else if (token.indexOf('\`') === 0) {
|
||||
output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';
|
||||
} else if (token.indexOf('~~') === 0) {
|
||||
output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';
|
||||
} else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {
|
||||
output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';
|
||||
} else {
|
||||
output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';
|
||||
}
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
output += escapeHtml(text.slice(lastIndex));
|
||||
return output;
|
||||
}
|
||||
|
||||
function isBlockStart(line) {
|
||||
return /^(\`\`\`|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);
|
||||
}
|
||||
|
||||
function indentationWidth(value) {
|
||||
return String(value || '').replace(/\\t/g, ' ').length;
|
||||
}
|
||||
|
||||
function parseListLine(line) {
|
||||
var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);
|
||||
if (!match) return null;
|
||||
var rawText = match[3] || '';
|
||||
var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);
|
||||
return {
|
||||
indent: indentationWidth(match[1] || ''),
|
||||
ordered: /^\\d/.test(match[2] || ''),
|
||||
orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,
|
||||
task: task ? task[1].toLowerCase() === 'x' : null,
|
||||
text: task ? task[2] : rawText,
|
||||
children: []
|
||||
};
|
||||
}
|
||||
|
||||
function listKind(item) {
|
||||
if (item.task !== null) return 'task';
|
||||
return item.ordered ? 'ol' : 'ul';
|
||||
}
|
||||
|
||||
function parseListTree(lines, startIndex) {
|
||||
var root = { indent: -1, children: [] };
|
||||
var stack = [root];
|
||||
var index = startIndex;
|
||||
while (index < lines.length) {
|
||||
var item = parseListLine(lines[index] || '');
|
||||
if (!item) break;
|
||||
while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {
|
||||
stack.pop();
|
||||
}
|
||||
stack[stack.length - 1].children.push(item);
|
||||
stack.push(item);
|
||||
index += 1;
|
||||
}
|
||||
return { items: root.children, nextIndex: index };
|
||||
}
|
||||
|
||||
function renderListItems(items) {
|
||||
var html = [];
|
||||
var index = 0;
|
||||
while (index < items.length) {
|
||||
var kind = listKind(items[index]);
|
||||
var group = [];
|
||||
while (index < items.length && listKind(items[index]) === kind) {
|
||||
group.push(items[index]);
|
||||
index += 1;
|
||||
}
|
||||
var tag = kind === 'ol' ? 'ol' : 'ul';
|
||||
var attrs = kind === 'task' ? ' data-type="taskList"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start="' + group[0].orderedNumber + '"' : '';
|
||||
html.push('<' + tag + attrs + '>' + group.map(function (item) {
|
||||
var children = item.children.length ? renderListItems(item.children) : '';
|
||||
if (kind === 'task') {
|
||||
var checked = item.task === true;
|
||||
return '<li data-checked="' + String(checked) + '"><label contenteditable="false"><input type="checkbox" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';
|
||||
}
|
||||
var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value="' + item.orderedNumber + '" data-list-number="' + item.orderedNumber + '"' : '';
|
||||
return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';
|
||||
}).join('') + '</' + tag + '>');
|
||||
}
|
||||
return html.join('');
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown) {
|
||||
var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');
|
||||
var html = [];
|
||||
var index = 0;
|
||||
while (index < lines.length) {
|
||||
var line = lines[index] || '';
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
var fence = line.match(/^\\\`\`\`([^\\s\`]*)\\s*$/);
|
||||
if (fence) {
|
||||
index += 1;
|
||||
var code = [];
|
||||
while (index < lines.length && !/^\\\`\`\`\\s*$/.test(lines[index] || '')) {
|
||||
code.push(lines[index] || '');
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) index += 1;
|
||||
html.push('<pre data-language="' + escapeAttr(fence[1] || '') + '"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {
|
||||
html.push('<hr />');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {
|
||||
var headers = splitTableRow(line);
|
||||
index += 2;
|
||||
var rows = [];
|
||||
while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {
|
||||
rows.push(splitTableRow(lines[index] || ''));
|
||||
index += 1;
|
||||
}
|
||||
html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {
|
||||
return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';
|
||||
}).join('') + '</tbody></table>');
|
||||
continue;
|
||||
}
|
||||
var heading = line.match(/^(#{1,6})\\s+(.+)$/);
|
||||
if (heading) {
|
||||
html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^>\\s?/.test(line)) {
|
||||
var quote = [];
|
||||
while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {
|
||||
quote.push((lines[index] || '').replace(/^>\\s?/, ''));
|
||||
index += 1;
|
||||
}
|
||||
html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');
|
||||
continue;
|
||||
}
|
||||
if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {
|
||||
var list = parseListTree(lines, index);
|
||||
html.push(renderListItems(list.items));
|
||||
index = list.nextIndex;
|
||||
continue;
|
||||
}
|
||||
var paragraph = [];
|
||||
while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {
|
||||
paragraph.push(lines[index] || '');
|
||||
index += 1;
|
||||
}
|
||||
html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');
|
||||
}
|
||||
return html.join('\\n') || '<p class="is-empty"><br /></p>';
|
||||
}
|
||||
|
||||
function textContent(node) {
|
||||
return (node.textContent || '').replace(/\\u00a0/g, ' ');
|
||||
}
|
||||
|
||||
function inlineMarkdown(node) {
|
||||
if (!node) return '';
|
||||
if (node.nodeType === Node.TEXT_NODE) return textContent(node);
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
var el = node;
|
||||
var tag = el.tagName.toLowerCase();
|
||||
if (tag === 'br') return '\\n';
|
||||
if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';
|
||||
if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';
|
||||
if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';
|
||||
if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '\`' + textContent(el) + '\`';
|
||||
if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';
|
||||
if (tag === 'img') return ' || '') + ')';
|
||||
if (tag === 'label') return '';
|
||||
return inlineChildren(el);
|
||||
}
|
||||
|
||||
function inlineChildren(el) {
|
||||
return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');
|
||||
}
|
||||
|
||||
function listItemText(li) {
|
||||
var clone = li.cloneNode(true);
|
||||
Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });
|
||||
Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });
|
||||
return inlineChildren(clone).trim();
|
||||
}
|
||||
|
||||
function directNestedLists(li) {
|
||||
return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {
|
||||
return list.closest('li') === li;
|
||||
});
|
||||
}
|
||||
|
||||
function listMarkdown(el, depth) {
|
||||
var tag = el.tagName.toLowerCase();
|
||||
var isTask = el.getAttribute('data-type') === 'taskList';
|
||||
var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;
|
||||
if (!Number.isFinite(orderedStart)) orderedStart = 1;
|
||||
var indent = ' '.repeat(depth);
|
||||
return Array.prototype.map.call(el.children, function (li, index) {
|
||||
if (li.tagName.toLowerCase() !== 'li') return '';
|
||||
var marker;
|
||||
if (isTask) {
|
||||
var input = li.querySelector('input[type="checkbox"]');
|
||||
marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';
|
||||
} else {
|
||||
var listNumber =
|
||||
li.getAttribute &&
|
||||
(li.getAttribute('data-list-number') || li.getAttribute('value'));
|
||||
marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';
|
||||
}
|
||||
var line = indent + marker + listItemText(li);
|
||||
var nested = directNestedLists(li).map(function (list) {
|
||||
return listMarkdown(list, depth + 1);
|
||||
}).filter(Boolean).join('\\n');
|
||||
return nested ? line + '\\n' + nested : line;
|
||||
}).filter(Boolean).join('\\n');
|
||||
}
|
||||
|
||||
function blockMarkdown(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
var el = node;
|
||||
var tag = el.tagName.toLowerCase();
|
||||
if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();
|
||||
if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();
|
||||
if (tag === 'blockquote') {
|
||||
return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');
|
||||
}
|
||||
if (tag === 'pre') {
|
||||
var lang = el.getAttribute('data-language') || '';
|
||||
var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');
|
||||
return '\`\`\`' + lang + '\\n' + code + '\\n\`\`\`';
|
||||
}
|
||||
if (tag === 'ul' || tag === 'ol') {
|
||||
return listMarkdown(el, 0);
|
||||
}
|
||||
if (tag === 'table') {
|
||||
var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));
|
||||
if (rows.length === 0) return '';
|
||||
var cellsFor = function (row) {
|
||||
return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });
|
||||
};
|
||||
var headers = cellsFor(rows[0]);
|
||||
var bodyRows = rows.slice(1).map(cellsFor);
|
||||
return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');
|
||||
}
|
||||
if (tag === 'hr') return '---';
|
||||
if (tag === 'img') return inlineMarkdown(el);
|
||||
return inlineChildren(el).trim();
|
||||
}
|
||||
|
||||
function currentMarkdown() {
|
||||
return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {
|
||||
return block.trim().length > 0;
|
||||
}).join('\\n\\n').trimEnd();
|
||||
}
|
||||
|
||||
function syncTaskCheckboxesDisabled() {
|
||||
Array.prototype.forEach.call(editor.querySelectorAll('input[type="checkbox"]'), function (input) {
|
||||
input.disabled = !editable;
|
||||
});
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
if (suppressInput || !editable) return;
|
||||
window.clearTimeout(inputTimer);
|
||||
var pendingGeneration = documentGeneration;
|
||||
lastMarkdown = currentMarkdown();
|
||||
post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });
|
||||
}
|
||||
|
||||
function setMarkdown(markdown, generation) {
|
||||
window.clearTimeout(inputTimer);
|
||||
documentGeneration = Number(generation) || 0;
|
||||
suppressInput = true;
|
||||
// Why: replacing innerHTML detaches the remembered caret's nodes.
|
||||
savedSelectionRange = null;
|
||||
selectionDroppedOnBlur = false;
|
||||
lastMarkdown = String(markdown || '');
|
||||
editor.innerHTML = markdownToHtml(lastMarkdown);
|
||||
syncTaskCheckboxesDisabled();
|
||||
suppressInput = false;
|
||||
}
|
||||
|
||||
function setEditable(nextEditable) {
|
||||
editable = Boolean(nextEditable);
|
||||
editor.setAttribute('contenteditable', editable ? 'true' : 'false');
|
||||
syncTaskCheckboxesDisabled();
|
||||
}
|
||||
|
||||
${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}
|
||||
function runCommand(command) {
|
||||
if (!editable || editor.getAttribute('contenteditable') !== 'true') return;
|
||||
restoreSelectionOrEnd();
|
||||
if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');
|
||||
else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');
|
||||
else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');
|
||||
else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');
|
||||
else if (command === 'bold') document.execCommand('bold');
|
||||
else if (command === 'italic') document.execCommand('italic');
|
||||
else if (command === 'strike') document.execCommand('strikeThrough');
|
||||
else if (command === 'bulletList') document.execCommand('insertUnorderedList');
|
||||
else if (command === 'orderedList') document.execCommand('insertOrderedList');
|
||||
else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');
|
||||
else if (command === 'inlineCode') wrapSelection('code');
|
||||
else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=""><code>code</code></pre><p><br></p>');
|
||||
else if (command === 'taskList') document.execCommand('insertHTML', false, '<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>');
|
||||
else if (command === 'link') {
|
||||
var href = window.prompt('Link URL');
|
||||
if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);
|
||||
} else if (command === 'image') {
|
||||
var src = window.prompt('Image URL');
|
||||
if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);
|
||||
}
|
||||
syncTaskCheckboxesDisabled();
|
||||
emitChange();
|
||||
}
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
selectionDroppedOnBlur = false;
|
||||
if (editable) emitChange();
|
||||
});
|
||||
editor.addEventListener('change', function (event) {
|
||||
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
|
||||
if (input) {
|
||||
if (!editable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
var li = input.closest('li');
|
||||
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
|
||||
}
|
||||
if (editable) emitChange();
|
||||
});
|
||||
editor.addEventListener('click', function (event) {
|
||||
var link = event.target && event.target.closest && event.target.closest('a[href]');
|
||||
if (link) {
|
||||
event.preventDefault();
|
||||
post({ type: 'openLink', url: link.getAttribute('href') || '' });
|
||||
return;
|
||||
}
|
||||
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
|
||||
if (!input) {
|
||||
if (!editable) return;
|
||||
// Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.
|
||||
var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]');
|
||||
if (uneditable && uneditable !== editor) return;
|
||||
selectionDroppedOnBlur = false;
|
||||
if (document.activeElement === editor) return;
|
||||
// Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.
|
||||
var caret = caretRangeAtPoint(event.clientX, event.clientY);
|
||||
focusEditor();
|
||||
if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);
|
||||
return;
|
||||
}
|
||||
if (!editable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
var li = input.closest('li');
|
||||
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
|
||||
emitChange();
|
||||
});
|
||||
editor.addEventListener('keydown', function (event) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {
|
||||
event.preventDefault();
|
||||
runCommand('bold');
|
||||
}
|
||||
});
|
||||
|
||||
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}
|
||||
post({ type: 'ready' });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
--accent-link: ${colors.accentBlue}${MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY}
|
||||
${MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY}${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS}${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END}`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
|
||||
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
|
||||
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
|
||||
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import type { WorktreeCreateIdempotencySupport } from '../tasks/worktree-create-idempotency-policy'
|
||||
import {
|
||||
pickPreferredNewWorktreeAgent,
|
||||
type NewWorktreeAgentOption,
|
||||
@@ -54,7 +55,7 @@ export function useNewWorkspaceCreateSubmit(args: {
|
||||
runSetup: boolean
|
||||
trustedOrcaHooks: PersistedTrustedOrcaHooks
|
||||
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
|
||||
getWorktreeCreateCutoverSupport: () => Promise<boolean>
|
||||
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
|
||||
transitionDrawer: (view: Exclude<NewWorktreeDrawerView, 'transition'>) => void
|
||||
setError: Dispatch<SetStateAction<string>>
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
@@ -163,7 +164,7 @@ export function useNewWorkspaceCreateSubmit(args: {
|
||||
workspaceName: trimmedName || undefined,
|
||||
note: trimmedNote,
|
||||
nameIsAutoManaged: args.composer.isNameAutoManaged,
|
||||
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
|
||||
worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport()
|
||||
})
|
||||
: await createBlankWorkspace({
|
||||
client,
|
||||
@@ -173,7 +174,7 @@ export function useNewWorkspaceCreateSubmit(args: {
|
||||
createdWithAgentId,
|
||||
comment: trimmedNote,
|
||||
setupDecision,
|
||||
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
|
||||
worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport()
|
||||
})
|
||||
if ('error' in result) {
|
||||
args.setError(result.error)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
export const pairScanStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
steps: {
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
marginLeft: 7
|
||||
},
|
||||
step: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
stepBadge: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
stepNumber: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
stepText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
cameraWrap: {
|
||||
flex: 1,
|
||||
borderRadius: radii.camera,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
// Why: holds the layout slot while the camera is unmounted during
|
||||
// paste, so the bottom action button doesn't snap up to fill the
|
||||
// empty space.
|
||||
cameraPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.camera
|
||||
},
|
||||
camera: {
|
||||
...StyleSheet.absoluteFillObject
|
||||
},
|
||||
reticle: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
reticleFrame: {
|
||||
position: 'relative'
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderColor: 'rgba(255,255,255,0.7)'
|
||||
},
|
||||
cornerTL: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderTopWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderTopLeftRadius: 6
|
||||
},
|
||||
cornerTR: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
borderTopWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderTopRightRadius: 6
|
||||
},
|
||||
cornerBL: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderBottomWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderBottomLeftRadius: 6
|
||||
},
|
||||
cornerBR: {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
borderBottomWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderBottomRightRadius: 6
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
subtitle: {
|
||||
maxWidth: 310,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
connectingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
logSlot: {
|
||||
width: '100%',
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.sm
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
pasteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
pasteButtonPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
pasteButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
errorActions: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
secondaryButton: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createBlankWorkspace } from './blank-workspace-create'
|
||||
import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
|
||||
|
||||
type Call = { method: string; params: unknown }
|
||||
|
||||
const IDEMPOTENT_CREATE_SUPPORT = {
|
||||
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
||||
}
|
||||
|
||||
function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
|
||||
return {
|
||||
sendRequest: async (method: string, params?: unknown) => {
|
||||
@@ -35,7 +40,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' })
|
||||
@@ -70,7 +75,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit',
|
||||
nameWasGenerated: true,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(calls[0]?.params).toMatchObject({ nameWasGenerated: true })
|
||||
@@ -90,7 +95,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: 'spike',
|
||||
setupDecision: 'run',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
const params = calls[0]?.params as Record<string, unknown>
|
||||
@@ -122,7 +127,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' })
|
||||
@@ -148,7 +153,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'inherit',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' })
|
||||
@@ -167,7 +172,7 @@ describe('createBlankWorkspace', () => {
|
||||
comment: undefined,
|
||||
setupDecision: 'skip',
|
||||
nameWasGenerated: false,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
})
|
||||
|
||||
expect(result).toEqual({ error: 'SSH connection is not available' })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
|
||||
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
|
||||
import {
|
||||
agentLaunchCreateFields,
|
||||
type WorkspaceCreateSetupDecision
|
||||
@@ -19,13 +20,13 @@ export async function createBlankWorkspace(args: {
|
||||
/** True when `baseName` is a generated creature name rather than one the user typed; only then
|
||||
* may the host retire it. */
|
||||
nameWasGenerated: boolean
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
return createWorktreeWithNameRetry({
|
||||
client: args.client,
|
||||
baseName: args.baseName,
|
||||
nameWasGenerated: args.nameWasGenerated,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
buildParams: (name) => {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${args.repoId}`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createWorkspaceFromComposerSource } from './source-workspace-create'
|
||||
import type { MobileComposerCreateSelection } from './mobile-composer-source-types'
|
||||
import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
|
||||
|
||||
type Call = { method: string; params: Record<string, unknown> }
|
||||
|
||||
@@ -24,6 +25,9 @@ function fakeClient(handle: (method: string, call: number) => unknown, calls: Ca
|
||||
}
|
||||
|
||||
const agent = { choice: 'blank' as const }
|
||||
const IDEMPOTENT_CREATE_SUPPORT = {
|
||||
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
targetRepoId: 'repo-1',
|
||||
@@ -31,7 +35,7 @@ const baseArgs = {
|
||||
agent,
|
||||
workspaceName: undefined,
|
||||
note: undefined,
|
||||
supportsIdempotentCutoverRetry: true
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
|
||||
}
|
||||
|
||||
describe('createWorkspaceFromComposerSource', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type WorkspaceCreateTaskItem
|
||||
} from './workspace-create-params'
|
||||
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
|
||||
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
|
||||
|
||||
// The agent bundle the modal resolved: `choice` drives launch resolution — the
|
||||
// host applies the agent's launch args (permission flags) and shell quoting.
|
||||
@@ -29,7 +30,7 @@ export type CreateWorkspaceFromComposerArgs = {
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}
|
||||
|
||||
export async function createWorkspaceFromComposerSource(
|
||||
@@ -92,7 +93,7 @@ async function createWorkItemWorkspace(args: {
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
|
||||
const item = selection.item
|
||||
@@ -137,7 +138,7 @@ async function createWorkItemWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
buildParams: (name) => ({ ...params, name })
|
||||
})
|
||||
}
|
||||
@@ -150,7 +151,7 @@ async function createBranchWorkspace(args: {
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
|
||||
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
|
||||
@@ -174,7 +175,7 @@ async function createBranchWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
maxAttempts: 1,
|
||||
buildParams: (name) =>
|
||||
applyCommon({
|
||||
@@ -196,7 +197,7 @@ async function createBranchWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
buildParams: (candidate) => {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${targetRepoId}`,
|
||||
@@ -220,7 +221,7 @@ async function createNewBranchWorkspace(args: {
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, note } = args
|
||||
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
|
||||
@@ -232,7 +233,7 @@ async function createNewBranchWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: selection.branchName,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
|
||||
buildParams: (candidate) => {
|
||||
const params: Record<string, unknown> = {
|
||||
repo: `id:${targetRepoId}`,
|
||||
|
||||
@@ -2,8 +2,21 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
|
||||
import {
|
||||
WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS,
|
||||
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
||||
} from './worktree-create-idempotency-policy'
|
||||
|
||||
function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClient {
|
||||
type StatusOutcome =
|
||||
| 'cutover'
|
||||
| 'error'
|
||||
| string[]
|
||||
| {
|
||||
capabilities?: string[]
|
||||
worktreeCreateIdempotency?: unknown
|
||||
}
|
||||
|
||||
function statusClient(outcomes: StatusOutcome[]): RpcClient {
|
||||
let call = 0
|
||||
return {
|
||||
sendRequest: async () => {
|
||||
@@ -15,10 +28,13 @@ function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClien
|
||||
if (outcome === 'error') {
|
||||
throw new Error('offline')
|
||||
}
|
||||
const result = Array.isArray(outcome)
|
||||
? { capabilities: outcome, hostPlatform: 'darwin' }
|
||||
: { ...outcome, hostPlatform: 'darwin' }
|
||||
return {
|
||||
id: '1',
|
||||
ok: true,
|
||||
result: { capabilities: outcome, hostPlatform: 'darwin' },
|
||||
result,
|
||||
_meta: { runtimeId: 'r' }
|
||||
}
|
||||
}
|
||||
@@ -29,23 +45,123 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
|
||||
it('reads task and idempotent-create support from status.get', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([['mobile.tasks.v1', 'worktree.create-idempotency.v1']])
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 }
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: true,
|
||||
idempotentWorktreeCreateSupported: true,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it('retries the safe status probe after a connection cutover', async () => {
|
||||
it('uses the bounded fallback for an old idempotent host without an advertisement', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(statusClient([['worktree.create-idempotency.v1']]))
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when a valid idempotency container omits dedupeTtlMs', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient(['cutover', ['worktree.create-idempotency.v1']])
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: {}
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
idempotentWorktreeCreateSupported: true,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'null', value: null },
|
||||
{ label: 'string', value: 'nonsense' },
|
||||
{ label: 'number', value: 20_000 },
|
||||
{ label: 'empty array', value: [] }
|
||||
])(
|
||||
'fails closed for a malformed idempotency container ($label)',
|
||||
async ({ value: worktreeCreateIdempotency }) => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('clamps an over-large host advertisement to the client fallback ceiling', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 600_000 }
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['60000', -1, Number.NaN, 60_000.5, {}])(
|
||||
'fails closed for malformed host advertisement %j',
|
||||
async (advertisedDedupeTtlMs) => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: advertisedDedupeTtlMs }
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('retries the safe status probe after a connection cutover', async () => {
|
||||
await expect(
|
||||
readNewWorktreeRuntimeCapabilities(
|
||||
statusClient([
|
||||
'cutover',
|
||||
{
|
||||
capabilities: ['worktree.create-idempotency.v1'],
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 }
|
||||
}
|
||||
])
|
||||
)
|
||||
).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 },
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
})
|
||||
@@ -53,7 +169,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
|
||||
it('fails closed when capability detection is unavailable', async () => {
|
||||
await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({
|
||||
tasksSupported: false,
|
||||
idempotentWorktreeCreateSupported: false,
|
||||
worktreeCreateIdempotency: false,
|
||||
hostPlatform: null
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,11 @@ import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-cli
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
|
||||
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
|
||||
import {
|
||||
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
|
||||
resolveWorktreeCreateIdempotencySupport,
|
||||
type WorktreeCreateIdempotencySupport
|
||||
} from './worktree-create-idempotency-policy'
|
||||
|
||||
// Why: older hosts strip worktree.create's clientMutationId, so mobile must not
|
||||
// replay an ambiguous create unless the host advertises idempotency support.
|
||||
@@ -14,13 +19,13 @@ const STATUS_CUTOVER_MAX_RETRIES = 5
|
||||
|
||||
export type NewWorktreeRuntimeCapabilities = {
|
||||
tasksSupported: boolean
|
||||
idempotentWorktreeCreateSupported: boolean
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
|
||||
tasksSupported: false,
|
||||
idempotentWorktreeCreateSupported: false,
|
||||
worktreeCreateIdempotency: false,
|
||||
hostPlatform: null
|
||||
}
|
||||
|
||||
@@ -35,13 +40,28 @@ export async function readNewWorktreeRuntimeCapabilities(
|
||||
if (!response.ok) {
|
||||
return UNSUPPORTED_CAPABILITIES
|
||||
}
|
||||
const result = (response as RpcSuccess).result as { capabilities?: string[] }
|
||||
const result = (response as RpcSuccess).result as {
|
||||
capabilities?: string[]
|
||||
worktreeCreateIdempotency?: unknown
|
||||
}
|
||||
const capabilities = result.capabilities ?? []
|
||||
const supportsIdempotency = capabilities.includes(
|
||||
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
|
||||
)
|
||||
const advertisedIdempotency = result.worktreeCreateIdempotency
|
||||
return {
|
||||
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
|
||||
idempotentWorktreeCreateSupported: capabilities.includes(
|
||||
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
|
||||
),
|
||||
worktreeCreateIdempotency: supportsIdempotency
|
||||
? advertisedIdempotency === undefined
|
||||
? { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }
|
||||
: advertisedIdempotency !== null &&
|
||||
typeof advertisedIdempotency === 'object' &&
|
||||
!Array.isArray(advertisedIdempotency)
|
||||
? resolveWorktreeCreateIdempotencySupport(
|
||||
(advertisedIdempotency as { dedupeTtlMs?: unknown }).dedupeTtlMs
|
||||
)
|
||||
: { dedupeTtlMs: 0 }
|
||||
: false,
|
||||
hostPlatform: readMobileRuntimeHostPlatform(result)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -58,7 +78,7 @@ export function useNewWorktreeRuntimeCapabilities(
|
||||
): {
|
||||
tasksSupported: boolean
|
||||
hostPlatform: NodeJS.Platform | null
|
||||
getWorktreeCreateCutoverSupport: () => Promise<boolean>
|
||||
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
|
||||
} {
|
||||
const [tasksSupported, setTasksSupported] = useState(false)
|
||||
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
|
||||
@@ -97,7 +117,7 @@ export function useNewWorktreeRuntimeCapabilities(
|
||||
}, [client, enabled, getCapabilities, setTasksSupported])
|
||||
|
||||
const getWorktreeCreateCutoverSupport = useCallback(
|
||||
() => getCapabilities().then((capabilities) => capabilities.idempotentWorktreeCreateSupported),
|
||||
() => getCapabilities().then((capabilities) => capabilities.worktreeCreateIdempotency),
|
||||
[getCapabilities]
|
||||
)
|
||||
return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Why: old idempotent hosts omit policy, so preserve today's effective replay window.
|
||||
export const WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS = 60_000
|
||||
|
||||
// Why: a client must not trust a host advertisement beyond its tested ceiling.
|
||||
export const WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS = 60_000
|
||||
|
||||
// Why: the replay still has to reach the host before its dedupe record expires.
|
||||
const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
|
||||
|
||||
export type WorktreeCreateIdempotencySupport = {
|
||||
dedupeTtlMs: number
|
||||
}
|
||||
|
||||
export type WorktreeCreateIdempotencyProbe =
|
||||
| WorktreeCreateIdempotencySupport
|
||||
| false
|
||||
| Promise<WorktreeCreateIdempotencySupport | false>
|
||||
|
||||
export function resolveWorktreeCreateIdempotencySupport(
|
||||
advertisedDedupeTtlMs: unknown
|
||||
): WorktreeCreateIdempotencySupport {
|
||||
if (advertisedDedupeTtlMs === undefined) {
|
||||
return { dedupeTtlMs: 0 }
|
||||
}
|
||||
if (
|
||||
typeof advertisedDedupeTtlMs !== 'number' ||
|
||||
!Number.isSafeInteger(advertisedDedupeTtlMs) ||
|
||||
advertisedDedupeTtlMs < 0
|
||||
) {
|
||||
// Truthy support preserves immediate cutover retries while disabling ambiguous replay.
|
||||
return { dedupeTtlMs: 0 }
|
||||
}
|
||||
return {
|
||||
dedupeTtlMs: Math.min(advertisedDedupeTtlMs, WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS)
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorktreeCreateReplayWindowMs(support: WorktreeCreateIdempotencySupport): number {
|
||||
return Math.max(0, support.dedupeTtlMs - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS)
|
||||
}
|
||||
@@ -3,16 +3,24 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../../src/shared/new-workspace/worktree-create-retry-policy'
|
||||
import {
|
||||
getWorktreeCreateReplayWindowMs,
|
||||
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
|
||||
type WorktreeCreateIdempotencySupport
|
||||
} from './worktree-create-idempotency-policy'
|
||||
import {
|
||||
createWorktreeWithNameRetry,
|
||||
WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS,
|
||||
WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
|
||||
WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS
|
||||
} from './worktree-create-retry'
|
||||
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
|
||||
|
||||
type Attempt = { method: string; params: Record<string, unknown> }
|
||||
|
||||
const IDEMPOTENT_CREATE_SUPPORT = {
|
||||
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
|
||||
}
|
||||
const LEGACY_HOST_REPLAY_WINDOW_MS = getWorktreeCreateReplayWindowMs(IDEMPOTENT_CREATE_SUPPORT)
|
||||
|
||||
// Drives the transport state a replay has to wait on. Production couples the two:
|
||||
// a socket close rejects the pending frame AND drops the client off 'connected'.
|
||||
function connectionController(): {
|
||||
@@ -106,21 +114,21 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
it('waits for capability detection before sending a create', async () => {
|
||||
const attempts: Attempt[] = []
|
||||
const client = scriptedClient([{ id: 'wt-ready' }], attempts)
|
||||
let resolveSupport!: (supported: boolean) => void
|
||||
const support = new Promise<boolean>((resolve) => {
|
||||
let resolveSupport!: (supported: WorktreeCreateIdempotencySupport) => void
|
||||
const support = new Promise<WorktreeCreateIdempotencySupport>((resolve) => {
|
||||
resolveSupport = resolve
|
||||
})
|
||||
const pending = createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: 'puffin',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: support,
|
||||
worktreeCreateIdempotency: support,
|
||||
mintMutationId: () => 'key-ready'
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(attempts).toHaveLength(0)
|
||||
resolveSupport(true)
|
||||
resolveSupport(IDEMPOTENT_CREATE_SUPPORT)
|
||||
|
||||
await expect(pending).resolves.toEqual({ worktreeId: 'wt-ready', name: 'puffin' })
|
||||
expect(attempts).toHaveLength(1)
|
||||
@@ -134,7 +142,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'otter',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-1'
|
||||
})
|
||||
expect(result).toEqual({ worktreeId: 'wt-1', name: 'otter' })
|
||||
@@ -152,7 +160,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'seal',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-mig'
|
||||
})
|
||||
expect(result).toEqual({ worktreeId: 'wt-2', name: 'seal' })
|
||||
@@ -172,7 +180,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'crab',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-x'
|
||||
})
|
||||
).rejects.toBeInstanceOf(LogicalClientCutoverError)
|
||||
@@ -190,7 +198,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'eel',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-t'
|
||||
})
|
||||
).rejects.toThrow('Request timed out')
|
||||
@@ -208,7 +216,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'topic',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => `key-${(n += 1)}`
|
||||
})
|
||||
expect(result).toEqual({ worktreeId: 'wt-3', name: 'topic-2' })
|
||||
@@ -231,7 +239,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
baseName: 'nautilus-2',
|
||||
nameWasGenerated: true,
|
||||
buildParams: (name) => ({ repo: 'id:r', name, nameWasGenerated: true }),
|
||||
supportsIdempotentCutoverRetry: false
|
||||
worktreeCreateIdempotency: false
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-generated', name: 'nautilus-3' })
|
||||
@@ -246,7 +254,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'ray',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: false,
|
||||
worktreeCreateIdempotency: false,
|
||||
mintMutationId: () => 'must-not-be-used'
|
||||
})
|
||||
).rejects.toBeInstanceOf(LogicalClientCutoverError)
|
||||
@@ -273,7 +281,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'urchin',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-drop'
|
||||
})
|
||||
|
||||
@@ -301,7 +309,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'limpet',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: false,
|
||||
worktreeCreateIdempotency: false,
|
||||
mintMutationId: () => 'must-not-be-used'
|
||||
})
|
||||
).rejects.toThrow('Connection interrupted')
|
||||
@@ -309,6 +317,32 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
expect(attempts[0]!.params.clientMutationId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables ambiguous replay for a malformed advertisement', async () => {
|
||||
const attempts: Attempt[] = []
|
||||
const connection = connectionController()
|
||||
const client = scriptedClient(
|
||||
[
|
||||
{
|
||||
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
|
||||
dropsConnection: true
|
||||
}
|
||||
],
|
||||
attempts,
|
||||
connection
|
||||
)
|
||||
await expect(
|
||||
createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: 'limpet',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
|
||||
mintMutationId: () => 'key-malformed'
|
||||
})
|
||||
).rejects.toThrow('Connection interrupted')
|
||||
expect(attempts).toHaveLength(1)
|
||||
expect(attempts[0]!.params.clientMutationId).toBe('key-malformed')
|
||||
})
|
||||
|
||||
it('gives up after the delivery-ambiguity replay budget, still inside the dedupe TTL', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -331,21 +365,21 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'barnacle',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-budget'
|
||||
}).catch((error: unknown) => {
|
||||
settledAt = Date.now()
|
||||
throw error
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_MS)
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS)
|
||||
await settled
|
||||
// Initial attempt + 2 replays.
|
||||
expect(attempts).toHaveLength(3)
|
||||
// The budget is a count, but what keeps a replay reconciling instead of building
|
||||
// a second worktree is wall clock: every attempt has to land while the host still
|
||||
// holds the record, with the watchdog's detection latency already deducted.
|
||||
expect(settledAt - startedAt).toBeLessThanOrEqual(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
|
||||
expect(settledAt - startedAt).toBeLessThanOrEqual(LEGACY_HOST_REPLAY_WINDOW_MS)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -373,7 +407,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'anemone',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-stuck'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
@@ -388,6 +422,41 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
// so vitest's default 5s real-time budget is reachable on a loaded runner.
|
||||
}, 30_000)
|
||||
|
||||
it('stops replaying when the host-advertised dedupe record has expired', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const attempts: Attempt[] = []
|
||||
const connection = connectionController()
|
||||
const client = scriptedClient(
|
||||
[
|
||||
{
|
||||
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
|
||||
dropsConnection: true,
|
||||
takesMs: 12_000,
|
||||
reconnectsAfterMs: 1_000
|
||||
},
|
||||
{ id: 'wt-duplicate' }
|
||||
],
|
||||
attempts,
|
||||
connection
|
||||
)
|
||||
|
||||
const pending = createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: 'mussel',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
|
||||
mintMutationId: () => 'key-short-ttl'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await settled
|
||||
expect(attempts).toHaveLength(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not replay an ambiguity that surfaced while the transport stayed connected', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -415,7 +484,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'cuttlefish',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-expired'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Request timed out')
|
||||
@@ -464,19 +533,19 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'nautilus',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-clamped'
|
||||
}).catch((error: unknown) => {
|
||||
settledAt = Date.now()
|
||||
throw error
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
|
||||
await vi.advanceTimersByTimeAsync(LEGACY_HOST_REPLAY_WINDOW_MS)
|
||||
await settled
|
||||
expect(attempts).toHaveLength(2)
|
||||
// The window is anchored at the FIRST ambiguity, so the second wait gets only the
|
||||
// remainder — it must not restart a full wait and carry the replay past the record.
|
||||
expect(settledAt - startedAt).toBe(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
|
||||
expect(settledAt - startedAt).toBe(LEGACY_HOST_REPLAY_WINDOW_MS)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -509,7 +578,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'limpet',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-stale'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection interrupted')
|
||||
@@ -551,7 +620,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'kelp',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-long'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(250_000)
|
||||
@@ -606,7 +675,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'urchin',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-anchored'
|
||||
})
|
||||
const settled = expect(pending).rejects.toThrow('Connection lost')
|
||||
@@ -638,7 +707,7 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
client,
|
||||
baseName: 'urchin',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
supportsIdempotentCutoverRetry: true,
|
||||
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
|
||||
mintMutationId: () => 'key-unmarked'
|
||||
})
|
||||
).rejects.toThrow('Socket closed before send')
|
||||
@@ -649,11 +718,10 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
// The window is measured from a lower bound on when the host could have resolved, so
|
||||
// it has to leave the record room for the replay to still be in flight. Widening it
|
||||
// to the whole TTL would let the last replay land on a record that just expired.
|
||||
expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeGreaterThan(0)
|
||||
expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_MS)
|
||||
// A single wait must not be able to consume the window on its own.
|
||||
expect(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS).toBeLessThan(
|
||||
WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
|
||||
)
|
||||
expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeGreaterThan(0)
|
||||
expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS)
|
||||
// A reconnect wait is clamped when a short advertisement leaves less time.
|
||||
const shortReplayWindowMs = getWorktreeCreateReplayWindowMs({ dedupeTtlMs: 20_000 })
|
||||
expect(Math.min(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS, shortReplayWindowMs)).toBe(10_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
|
||||
getClientWorktreeCreateCandidate,
|
||||
getGeneratedWorktreeCreateRetryCandidate,
|
||||
isRetryableWorktreeCreateConflict,
|
||||
WORKTREE_CREATE_DEDUPE_TTL_MS
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from '../../../src/shared/new-workspace/worktree-create-retry-policy'
|
||||
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
|
||||
import {
|
||||
getWorktreeCreateReplayWindowMs,
|
||||
type WorktreeCreateIdempotencyProbe,
|
||||
type WorktreeCreateIdempotencySupport
|
||||
} from './worktree-create-idempotency-policy'
|
||||
|
||||
// Why: server-side collision checks (branch already exists locally / on a remote
|
||||
// / already has PR #N) can fire even after a pre-flight basename dedupe —
|
||||
@@ -36,17 +40,6 @@ const WORKTREE_CREATE_CUTOVER_MAX_RETRIES = 5
|
||||
// finished the worktree. Replay on the same clientMutationId instead.
|
||||
const WORKTREE_CREATE_AMBIGUOUS_MAX_RETRIES = 2
|
||||
|
||||
// Why: the host keeps a settled create's dedupe record for WORKTREE_CREATE_DEDUPE_TTL_MS
|
||||
// after it resolves; a replay that lands later misses it and the host's suffix loop
|
||||
// builds a SECOND worktree — and for folder workspaces a second one with the SAME name
|
||||
// and no collision check at all. So a replay is bounded by how stale our knowledge of the
|
||||
// host is, and that has to be measured in WALL CLOCK: iOS/Android suspend JS timers while
|
||||
// the app is backgrounded, so any ceiling derived from timer intervals — a watchdog probe
|
||||
// budget, a request timeout — can be arbitrarily wrong across a background cycle, which is
|
||||
// exactly when a create sits ambiguous for minutes.
|
||||
const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
|
||||
export const WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS =
|
||||
WORKTREE_CREATE_DEDUPE_TTL_MS - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS
|
||||
// Bounded so the Create spinner doesn't sit for the whole window; the deadline still caps it.
|
||||
export const WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS = 20_000
|
||||
|
||||
@@ -55,7 +48,7 @@ export type CreateWorktreeWithNameRetryArgs = {
|
||||
baseName: string
|
||||
nameWasGenerated?: boolean
|
||||
buildParams: (name: string) => Record<string, unknown>
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
maxAttempts?: number
|
||||
// Injected in tests; production mints a fresh idempotency key per candidate.
|
||||
mintMutationId?: () => string
|
||||
@@ -72,7 +65,7 @@ export async function createWorktreeWithNameRetry(
|
||||
const { client, baseName, buildParams } = args
|
||||
// Why: creating before status.get settles would silently disable safe replay
|
||||
// during the exact slow-network window this path is meant to recover from.
|
||||
const supportsIdempotentCutoverRetry = await args.supportsIdempotentCutoverRetry
|
||||
const worktreeCreateIdempotency = await args.worktreeCreateIdempotency
|
||||
const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS
|
||||
const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId
|
||||
let lastError: string | null = null
|
||||
@@ -84,14 +77,10 @@ export async function createWorktreeWithNameRetry(
|
||||
// Why: older hosts strip unknown fields, so only stamp and replay when the
|
||||
// host advertises idempotency. One key per candidate makes cutover retries
|
||||
// safe while a name-collision bump remains a genuinely new create.
|
||||
const params = supportsIdempotentCutoverRetry
|
||||
const params = worktreeCreateIdempotency
|
||||
? { ...candidateParams, clientMutationId: mintMutationId() }
|
||||
: candidateParams
|
||||
const response = await sendWorktreeCreateResilient(
|
||||
client,
|
||||
params,
|
||||
supportsIdempotentCutoverRetry
|
||||
)
|
||||
const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency)
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { worktree: { id: string } }
|
||||
return { worktreeId: result.worktree.id, name: candidateName }
|
||||
@@ -112,7 +101,7 @@ export async function createWorktreeWithNameRetry(
|
||||
async function sendWorktreeCreateResilient(
|
||||
client: RpcClient,
|
||||
params: Record<string, unknown>,
|
||||
supportsIdempotentCutoverRetry: boolean
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
|
||||
): Promise<RpcResponse> {
|
||||
let migrationRetry = 0
|
||||
let ambiguousRetry = 0
|
||||
@@ -124,7 +113,7 @@ async function sendWorktreeCreateResilient(
|
||||
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
|
||||
})
|
||||
} catch (error) {
|
||||
if (!supportsIdempotentCutoverRetry) {
|
||||
if (!worktreeCreateIdempotency) {
|
||||
throw error
|
||||
}
|
||||
if (isLogicalClientCutoverError(error)) {
|
||||
@@ -152,7 +141,7 @@ async function sendWorktreeCreateResilient(
|
||||
}
|
||||
// Computed once: a later ambiguity reads a fresher lastInboundAt from the
|
||||
// replacement session, which would push the deadline past the record it respects.
|
||||
replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt)
|
||||
replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt, worktreeCreateIdempotency)
|
||||
const remainingWindowMs = replayDeadlineAt - Date.now()
|
||||
if (remainingWindowMs <= 0) {
|
||||
throw error
|
||||
@@ -179,10 +168,14 @@ async function sendWorktreeCreateResilient(
|
||||
// so it stays honest across a suspension in a way a timer budget cannot; the send is the
|
||||
// fallback, and a sound floor either way, because the host cannot resolve a create it has
|
||||
// not yet received.
|
||||
function resolveReplayDeadline(client: RpcClient, firstSentAt: number): number {
|
||||
function resolveReplayDeadline(
|
||||
client: RpcClient,
|
||||
firstSentAt: number,
|
||||
support: WorktreeCreateIdempotencySupport
|
||||
): number {
|
||||
const lastInboundAt = client.getLastInboundAt?.() ?? null
|
||||
const anchor = lastInboundAt !== null && lastInboundAt > firstSentAt ? lastInboundAt : firstSentAt
|
||||
return anchor + WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
|
||||
return anchor + getWorktreeCreateReplayWindowMs(support)
|
||||
}
|
||||
|
||||
function defaultWorktreeCreateMutationId(): string {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
SPECIAL_KEY_LABELS,
|
||||
TERMINAL_ACCESSORY_KEY_DEFINITIONS,
|
||||
TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS
|
||||
} from './terminal-key-definitions'
|
||||
|
||||
export type TerminalAccessoryKey = {
|
||||
id: string
|
||||
label: string
|
||||
@@ -75,66 +81,6 @@ const CTRL_PRINTABLE_BYTES: Record<string, string> = {
|
||||
'?': '\x7f'
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
escape: 'Esc',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: '⌫',
|
||||
delete: 'Del',
|
||||
insert: 'Ins',
|
||||
arrowUp: '↑',
|
||||
arrowDown: '↓',
|
||||
arrowLeft: '←',
|
||||
arrowRight: '→',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'PgUp',
|
||||
pageDown: 'PgDn',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
|
||||
escape: 'Escape',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: 'Backspace',
|
||||
delete: 'Forward delete',
|
||||
insert: 'Insert',
|
||||
arrowUp: 'Arrow up',
|
||||
arrowDown: 'Arrow down',
|
||||
arrowLeft: 'Arrow left',
|
||||
arrowRight: 'Arrow right',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'Page up',
|
||||
pageDown: 'Page down',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
const CSI_FINAL_SPECIAL_KEYS: Record<string, string> = {
|
||||
arrowUp: 'A',
|
||||
arrowDown: 'B',
|
||||
@@ -165,87 +111,9 @@ const CSI_TILDE_SPECIAL_KEYS: Record<string, number> = {
|
||||
f12: 24
|
||||
}
|
||||
|
||||
export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [
|
||||
'escape',
|
||||
'tab',
|
||||
'enter',
|
||||
'backspace',
|
||||
'delete',
|
||||
'insert',
|
||||
'arrowUp',
|
||||
'arrowDown',
|
||||
'arrowLeft',
|
||||
'arrowRight',
|
||||
'home',
|
||||
'end',
|
||||
'pageUp',
|
||||
'pageDown',
|
||||
'space',
|
||||
'f1',
|
||||
'f2',
|
||||
'f3',
|
||||
'f4',
|
||||
'f5',
|
||||
'f6',
|
||||
'f7',
|
||||
'f8',
|
||||
'f9',
|
||||
'f10',
|
||||
'f11',
|
||||
'f12'
|
||||
].map((id) => ({
|
||||
id,
|
||||
label: SPECIAL_KEY_LABELS[id]!,
|
||||
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
|
||||
}))
|
||||
export const TERMINAL_SHORTCUT_SPECIAL_KEYS = TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS
|
||||
|
||||
export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
|
||||
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
|
||||
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
|
||||
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
|
||||
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
|
||||
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
|
||||
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' },
|
||||
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Del',
|
||||
bytes: '\x1b[3~',
|
||||
accessibilityLabel: 'Forward delete',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
|
||||
{
|
||||
id: 'arrowDown',
|
||||
label: '↓',
|
||||
bytes: '\x1b[B',
|
||||
accessibilityLabel: 'Arrow Down',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowLeft',
|
||||
label: '←',
|
||||
bytes: '\x1b[D',
|
||||
accessibilityLabel: 'Arrow Left',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowRight',
|
||||
label: '→',
|
||||
bytes: '\x1b[C',
|
||||
accessibilityLabel: 'Arrow Right',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
|
||||
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
|
||||
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
|
||||
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
|
||||
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
|
||||
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
|
||||
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
|
||||
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
|
||||
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
|
||||
]
|
||||
export const TERMINAL_ACCESSORY_KEYS = TERMINAL_ACCESSORY_KEY_DEFINITIONS
|
||||
|
||||
export function buildTerminalShortcutKey(
|
||||
binding: TerminalShortcutBinding
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { TerminalAccessoryKey, TerminalShortcutSpecialKey } from './terminal-accessory-keys'
|
||||
|
||||
export const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
escape: 'Esc',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: '⌫',
|
||||
delete: 'Del',
|
||||
insert: 'Ins',
|
||||
arrowUp: '↑',
|
||||
arrowDown: '↓',
|
||||
arrowLeft: '←',
|
||||
arrowRight: '→',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'PgUp',
|
||||
pageDown: 'PgDn',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
|
||||
escape: 'Escape',
|
||||
tab: 'Tab',
|
||||
enter: 'Enter',
|
||||
backspace: 'Backspace',
|
||||
delete: 'Forward delete',
|
||||
insert: 'Insert',
|
||||
arrowUp: 'Arrow up',
|
||||
arrowDown: 'Arrow down',
|
||||
arrowLeft: 'Arrow left',
|
||||
arrowRight: 'Arrow right',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageUp: 'Page up',
|
||||
pageDown: 'Page down',
|
||||
space: 'Space',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12'
|
||||
}
|
||||
|
||||
export const TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS: TerminalShortcutSpecialKey[] = [
|
||||
'escape',
|
||||
'tab',
|
||||
'enter',
|
||||
'backspace',
|
||||
'delete',
|
||||
'insert',
|
||||
'arrowUp',
|
||||
'arrowDown',
|
||||
'arrowLeft',
|
||||
'arrowRight',
|
||||
'home',
|
||||
'end',
|
||||
'pageUp',
|
||||
'pageDown',
|
||||
'space',
|
||||
'f1',
|
||||
'f2',
|
||||
'f3',
|
||||
'f4',
|
||||
'f5',
|
||||
'f6',
|
||||
'f7',
|
||||
'f8',
|
||||
'f9',
|
||||
'f10',
|
||||
'f11',
|
||||
'f12'
|
||||
].map((id) => ({
|
||||
id,
|
||||
label: SPECIAL_KEY_LABELS[id]!,
|
||||
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
|
||||
}))
|
||||
|
||||
export const TERMINAL_ACCESSORY_KEY_DEFINITIONS: TerminalAccessoryKey[] = [
|
||||
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
|
||||
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
|
||||
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
|
||||
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
|
||||
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
|
||||
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' },
|
||||
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Del',
|
||||
bytes: '\x1b[3~',
|
||||
accessibilityLabel: 'Forward delete',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
|
||||
{
|
||||
id: 'arrowDown',
|
||||
label: '↓',
|
||||
bytes: '\x1b[B',
|
||||
accessibilityLabel: 'Arrow Down',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowLeft',
|
||||
label: '←',
|
||||
bytes: '\x1b[D',
|
||||
accessibilityLabel: 'Arrow Left',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowRight',
|
||||
label: '→',
|
||||
bytes: '\x1b[C',
|
||||
accessibilityLabel: 'Arrow Right',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
|
||||
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
|
||||
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
|
||||
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
|
||||
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
|
||||
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
|
||||
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
|
||||
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
|
||||
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
|
||||
]
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { AutomationPrecheck, AutomationSchedulePreset } from '../../shared/automations-types'
|
||||
import {
|
||||
normalizeTaskSourceContext,
|
||||
type TaskSourceContext
|
||||
} from '../../shared/task-source-context'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import {
|
||||
DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
|
||||
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
|
||||
} from '../../shared/automation-precheck'
|
||||
import { buildAutomationRrule } from '../../shared/automation-schedule-occurrences'
|
||||
import { isValidAutomationSchedule } from '../../shared/automation-schedule-parsing'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import {
|
||||
getOptionalPositiveIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRequiredStringFlag
|
||||
} from '../flags'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
|
||||
const PRESET_TRIGGERS = new Set<AutomationSchedulePreset>(['hourly', 'daily', 'weekdays', 'weekly'])
|
||||
const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const
|
||||
|
||||
function getScheduleModifierFlag(flags: Map<string, string | boolean>): string | undefined {
|
||||
return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag))
|
||||
}
|
||||
|
||||
function validateScheduleModifierApplicability(
|
||||
flags: Map<string, string | boolean>,
|
||||
raw: string
|
||||
): void {
|
||||
const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
|
||||
if (!isPreset) {
|
||||
if (flags.has('time')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--time can only be used with preset automation triggers'
|
||||
)
|
||||
}
|
||||
if (flags.has('day')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--day can only be used with the weekly automation preset'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (raw === 'hourly' && flags.has('time')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute'
|
||||
)
|
||||
}
|
||||
if (raw !== 'weekly' && flags.has('day')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--day can only be used with the weekly automation preset'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionalDayFlag(flags: Map<string, string | boolean>): number | undefined {
|
||||
const raw = flags.get('day')
|
||||
if (raw === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN
|
||||
if (!Number.isInteger(day) || day < 0 || day > 6) {
|
||||
throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6')
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
export function getProviderFlag(flags: Map<string, string | boolean>): TuiAgent {
|
||||
const provider = getRequiredStringFlag(flags, 'provider')
|
||||
if (!isTuiAgent(provider)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
export function getOptionalProviderFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): TuiAgent | undefined {
|
||||
const provider = getOptionalStringFlag(flags, 'provider')
|
||||
if (provider === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!isTuiAgent(provider)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
function getTimeFlag(flags: Map<string, string | boolean>): { hour: number; minute: number } {
|
||||
const value = flags.get('time')
|
||||
if (value === undefined) {
|
||||
return { hour: 9, minute: 0 }
|
||||
}
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
|
||||
}
|
||||
return parseTimeFlag(value)
|
||||
}
|
||||
|
||||
function parseTimeFlag(value: string): { hour: number; minute: number } {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(value)
|
||||
if (!match) {
|
||||
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
|
||||
}
|
||||
const hour = Number(match[1])
|
||||
const minute = Number(match[2])
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time')
|
||||
}
|
||||
return { hour, minute }
|
||||
}
|
||||
|
||||
export function getScheduleFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
required: boolean
|
||||
): { rrule: string; dtstart: number } | undefined {
|
||||
const trigger = getOptionalStringFlag(flags, 'trigger')
|
||||
const schedule = getOptionalStringFlag(flags, 'schedule')
|
||||
if (trigger && schedule) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --trigger or --schedule, not both.'
|
||||
)
|
||||
}
|
||||
const raw = trigger ?? schedule
|
||||
if (!raw) {
|
||||
const modifier = getScheduleModifierFlag(flags)
|
||||
if (modifier) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--${modifier} requires --trigger or --schedule`
|
||||
)
|
||||
}
|
||||
if (required) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (raw === 'manual') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run <id>` when needed.'
|
||||
)
|
||||
}
|
||||
validateScheduleModifierApplicability(flags, raw)
|
||||
const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags)
|
||||
const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1
|
||||
const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
|
||||
? buildAutomationRrule({
|
||||
preset: raw as Exclude<AutomationSchedulePreset, 'custom'>,
|
||||
hour,
|
||||
minute,
|
||||
dayOfWeek
|
||||
})
|
||||
: raw
|
||||
if (!isValidAutomationSchedule(rrule)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`)
|
||||
}
|
||||
return { rrule, dtstart: Date.now() }
|
||||
}
|
||||
|
||||
export function getEnabledFlag(flags: Map<string, string | boolean>): boolean | undefined {
|
||||
const enabledFlag = flags.get('enabled')
|
||||
const disabledFlag = flags.get('disabled')
|
||||
if (typeof enabledFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--enabled does not take a value')
|
||||
}
|
||||
if (typeof disabledFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--disabled does not take a value')
|
||||
}
|
||||
const enabled = enabledFlag === true
|
||||
const disabled = disabledFlag === true
|
||||
if (enabled && disabled) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --enabled or --disabled, not both.'
|
||||
)
|
||||
}
|
||||
if (enabled) {
|
||||
return true
|
||||
}
|
||||
if (disabled) {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getReuseSessionFlag(flags: Map<string, string | boolean>): boolean | undefined {
|
||||
const reuseFlag = flags.get('reuse-session')
|
||||
const freshFlag = flags.get('fresh-session')
|
||||
if (typeof reuseFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--reuse-session does not take a value')
|
||||
}
|
||||
if (typeof freshFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--fresh-session does not take a value')
|
||||
}
|
||||
const reuse = reuseFlag === true
|
||||
const fresh = freshFlag === true
|
||||
if (reuse && fresh) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --reuse-session or --fresh-session, not both.'
|
||||
)
|
||||
}
|
||||
if (reuse) {
|
||||
return true
|
||||
}
|
||||
if (fresh) {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getPrecheckFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): AutomationPrecheck | null | undefined {
|
||||
const hasPrecheck = flags.has('precheck')
|
||||
const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout')
|
||||
if (!hasPrecheck) {
|
||||
if (timeoutSeconds !== undefined) {
|
||||
throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get('precheck')
|
||||
if (typeof value !== 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--precheck requires a command')
|
||||
}
|
||||
const command = value.trim()
|
||||
if (!command) {
|
||||
return null
|
||||
}
|
||||
if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds`
|
||||
)
|
||||
}
|
||||
return {
|
||||
command,
|
||||
timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
export function getSourceContextFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): TaskSourceContext | null | undefined {
|
||||
if (!flags.has('source-context')) {
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get('source-context')
|
||||
if (typeof value !== 'string') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context requires a JSON TaskSourceContext or null'
|
||||
)
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON')
|
||||
}
|
||||
if (parsed === null) {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context must be a JSON TaskSourceContext or null'
|
||||
)
|
||||
}
|
||||
const sourceContext = normalizeTaskSourceContext(
|
||||
parsed as Parameters<typeof normalizeTaskSourceContext>[0]
|
||||
)
|
||||
if (!sourceContext) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context is not a valid TaskSourceContext'
|
||||
)
|
||||
}
|
||||
return sourceContext
|
||||
}
|
||||
|
||||
export function getWorkspaceModeFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): 'existing' | 'new_per_run' | undefined {
|
||||
const value = getOptionalStringFlag(flags, 'workspace-mode')
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (value === 'existing') {
|
||||
return 'existing'
|
||||
}
|
||||
if (value === 'new-per-run' || value === 'new_per_run') {
|
||||
return 'new_per_run'
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--workspace-mode must be existing or new-per-run'
|
||||
)
|
||||
}
|
||||
+10
-299
@@ -1,16 +1,11 @@
|
||||
/* eslint-disable max-lines -- Why: automation handlers share schedule parsing, target resolution, and RPC payload shaping for one command family. */
|
||||
import type {
|
||||
Automation,
|
||||
AutomationCreateInput,
|
||||
AutomationPrecheck,
|
||||
AutomationRun,
|
||||
AutomationSchedulePreset,
|
||||
AutomationUpdateInput
|
||||
} from '../../shared/automations-types'
|
||||
import {
|
||||
buildWorkspaceRunContext,
|
||||
normalizeTaskSourceContext,
|
||||
type TaskSourceContext,
|
||||
type WorkspaceRunContext
|
||||
} from '../../shared/task-source-context'
|
||||
import type {
|
||||
@@ -18,13 +13,6 @@ import type {
|
||||
AutomationOwnerPrecondition
|
||||
} from '../../shared/automation-owner-precondition'
|
||||
import type { ProjectHostSetup } from '../../shared/project-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import {
|
||||
DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
|
||||
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
|
||||
} from '../../shared/automation-precheck'
|
||||
import { buildAutomationRrule, isValidAutomationSchedule } from '../../shared/automation-schedules'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import {
|
||||
formatAutomationList,
|
||||
@@ -48,6 +36,16 @@ import {
|
||||
hasWorkspaceProjectTarget,
|
||||
resolveProjectCreateTarget
|
||||
} from '../worktree-project-target'
|
||||
import {
|
||||
getEnabledFlag,
|
||||
getOptionalProviderFlag,
|
||||
getPrecheckFlag,
|
||||
getProviderFlag,
|
||||
getReuseSessionFlag,
|
||||
getScheduleFlag,
|
||||
getSourceContextFlag,
|
||||
getWorkspaceModeFlag
|
||||
} from './automation-handler-flags'
|
||||
|
||||
type AutomationCreateParams = Omit<AutomationCreateInput, 'projectId' | 'timezone'> & {
|
||||
destination?: AutomationDestination
|
||||
@@ -61,293 +59,6 @@ type AutomationUpdateParams = AutomationUpdateInput & {
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
const PRESET_TRIGGERS = new Set<AutomationSchedulePreset>(['hourly', 'daily', 'weekdays', 'weekly'])
|
||||
const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const
|
||||
|
||||
function getScheduleModifierFlag(flags: Map<string, string | boolean>): string | undefined {
|
||||
return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag))
|
||||
}
|
||||
|
||||
function validateScheduleModifierApplicability(
|
||||
flags: Map<string, string | boolean>,
|
||||
raw: string
|
||||
): void {
|
||||
const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
|
||||
if (!isPreset) {
|
||||
if (flags.has('time')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--time can only be used with preset automation triggers'
|
||||
)
|
||||
}
|
||||
if (flags.has('day')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--day can only be used with the weekly automation preset'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (raw === 'hourly' && flags.has('time')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute'
|
||||
)
|
||||
}
|
||||
if (raw !== 'weekly' && flags.has('day')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--day can only be used with the weekly automation preset'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionalDayFlag(flags: Map<string, string | boolean>): number | undefined {
|
||||
const raw = flags.get('day')
|
||||
if (raw === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN
|
||||
if (!Number.isInteger(day) || day < 0 || day > 6) {
|
||||
throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6')
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
function getProviderFlag(flags: Map<string, string | boolean>): TuiAgent {
|
||||
const provider = getRequiredStringFlag(flags, 'provider')
|
||||
if (!isTuiAgent(provider)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
function getOptionalProviderFlag(flags: Map<string, string | boolean>): TuiAgent | undefined {
|
||||
const provider = getOptionalStringFlag(flags, 'provider')
|
||||
if (provider === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!isTuiAgent(provider)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
function getTimeFlag(flags: Map<string, string | boolean>): { hour: number; minute: number } {
|
||||
const value = flags.get('time')
|
||||
if (value === undefined) {
|
||||
return { hour: 9, minute: 0 }
|
||||
}
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
|
||||
}
|
||||
return parseTimeFlag(value)
|
||||
}
|
||||
|
||||
function parseTimeFlag(value: string): { hour: number; minute: number } {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(value)
|
||||
if (!match) {
|
||||
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
|
||||
}
|
||||
const hour = Number(match[1])
|
||||
const minute = Number(match[2])
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time')
|
||||
}
|
||||
return { hour, minute }
|
||||
}
|
||||
|
||||
function getScheduleFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
required: boolean
|
||||
): { rrule: string; dtstart: number } | undefined {
|
||||
const trigger = getOptionalStringFlag(flags, 'trigger')
|
||||
const schedule = getOptionalStringFlag(flags, 'schedule')
|
||||
if (trigger && schedule) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --trigger or --schedule, not both.'
|
||||
)
|
||||
}
|
||||
const raw = trigger ?? schedule
|
||||
if (!raw) {
|
||||
const modifier = getScheduleModifierFlag(flags)
|
||||
if (modifier) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--${modifier} requires --trigger or --schedule`
|
||||
)
|
||||
}
|
||||
if (required) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (raw === 'manual') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run <id>` when needed.'
|
||||
)
|
||||
}
|
||||
validateScheduleModifierApplicability(flags, raw)
|
||||
const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags)
|
||||
const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1
|
||||
const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
|
||||
? buildAutomationRrule({
|
||||
preset: raw as Exclude<AutomationSchedulePreset, 'custom'>,
|
||||
hour,
|
||||
minute,
|
||||
dayOfWeek
|
||||
})
|
||||
: raw
|
||||
if (!isValidAutomationSchedule(rrule)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`)
|
||||
}
|
||||
return { rrule, dtstart: Date.now() }
|
||||
}
|
||||
|
||||
function getEnabledFlag(flags: Map<string, string | boolean>): boolean | undefined {
|
||||
const enabledFlag = flags.get('enabled')
|
||||
const disabledFlag = flags.get('disabled')
|
||||
if (typeof enabledFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--enabled does not take a value')
|
||||
}
|
||||
if (typeof disabledFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--disabled does not take a value')
|
||||
}
|
||||
const enabled = enabledFlag === true
|
||||
const disabled = disabledFlag === true
|
||||
if (enabled && disabled) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --enabled or --disabled, not both.'
|
||||
)
|
||||
}
|
||||
if (enabled) {
|
||||
return true
|
||||
}
|
||||
if (disabled) {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getReuseSessionFlag(flags: Map<string, string | boolean>): boolean | undefined {
|
||||
const reuseFlag = flags.get('reuse-session')
|
||||
const freshFlag = flags.get('fresh-session')
|
||||
if (typeof reuseFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--reuse-session does not take a value')
|
||||
}
|
||||
if (typeof freshFlag === 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--fresh-session does not take a value')
|
||||
}
|
||||
const reuse = reuseFlag === true
|
||||
const fresh = freshFlag === true
|
||||
if (reuse && fresh) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --reuse-session or --fresh-session, not both.'
|
||||
)
|
||||
}
|
||||
if (reuse) {
|
||||
return true
|
||||
}
|
||||
if (fresh) {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getPrecheckFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): AutomationPrecheck | null | undefined {
|
||||
const hasPrecheck = flags.has('precheck')
|
||||
const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout')
|
||||
if (!hasPrecheck) {
|
||||
if (timeoutSeconds !== undefined) {
|
||||
throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get('precheck')
|
||||
if (typeof value !== 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', '--precheck requires a command')
|
||||
}
|
||||
const command = value.trim()
|
||||
if (!command) {
|
||||
return null
|
||||
}
|
||||
if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds`
|
||||
)
|
||||
}
|
||||
return {
|
||||
command,
|
||||
timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceContextFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): TaskSourceContext | null | undefined {
|
||||
if (!flags.has('source-context')) {
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get('source-context')
|
||||
if (typeof value !== 'string') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context requires a JSON TaskSourceContext or null'
|
||||
)
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON')
|
||||
}
|
||||
if (parsed === null) {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context must be a JSON TaskSourceContext or null'
|
||||
)
|
||||
}
|
||||
const sourceContext = normalizeTaskSourceContext(
|
||||
parsed as Parameters<typeof normalizeTaskSourceContext>[0]
|
||||
)
|
||||
if (!sourceContext) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context is not a valid TaskSourceContext'
|
||||
)
|
||||
}
|
||||
return sourceContext
|
||||
}
|
||||
|
||||
function getWorkspaceModeFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): 'existing' | 'new_per_run' | undefined {
|
||||
const value = getOptionalStringFlag(flags, 'workspace-mode')
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (value === 'existing') {
|
||||
return 'existing'
|
||||
}
|
||||
if (value === 'new-per-run' || value === 'new_per_run') {
|
||||
return 'new_per_run'
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--workspace-mode must be existing or new-per-run'
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveDefaultTarget(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
|
||||
+3
-364
@@ -1,371 +1,10 @@
|
||||
/* eslint-disable max-lines -- Why: root and generated command help text live together so CLI discovery stays greppable. */
|
||||
import type { CommandSpec } from './args'
|
||||
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
|
||||
import { unknownCommandData } from './command-suggestion'
|
||||
import { ROOT_HELP_TEXT_PRIMARY } from './root-help-text-primary'
|
||||
import { ROOT_HELP_TEXT_SECONDARY } from './root-help-text-secondary'
|
||||
|
||||
const ROOT_HELP_TEXT = `orca
|
||||
|
||||
Usage: orca <command> [options]
|
||||
|
||||
Startup:
|
||||
open Launch Orca and wait for the runtime to be reachable
|
||||
serve Start a headless Orca runtime server
|
||||
status Show app/runtime/graph readiness
|
||||
|
||||
Diagnostics:
|
||||
diagnostics memory Collect a memory snapshot for Orca and managed terminals
|
||||
|
||||
Agent Discovery:
|
||||
agent-context Print the machine-readable command schema for agents
|
||||
|
||||
Accounts:
|
||||
account add Add a managed Claude or Codex account on this Orca host
|
||||
account list List managed Claude and Codex accounts on this Orca host
|
||||
|
||||
Skills:
|
||||
skills installed List installed skill selectors
|
||||
skills share Publish selected skills behind one unlisted link
|
||||
skills list List version-matched skill guides bundled with this Orca CLI
|
||||
skills get Print a version-matched skill guide as Markdown
|
||||
skills install Install bundled Orca skills globally via the community skills CLI
|
||||
skills update Update already-installed Orca skills via the community skills CLI
|
||||
|
||||
Hosts:
|
||||
host list List targetable machines and how to name each one
|
||||
|
||||
Environments:
|
||||
environment add Save a remote Orca runtime from a pairing code
|
||||
environment list List saved remote Orca runtimes
|
||||
environment show Show one saved remote Orca runtime
|
||||
environment rm Remove a saved remote Orca runtime
|
||||
|
||||
Environment Recipes:
|
||||
vm recipe doctor Validate a per-workspace environment recipe
|
||||
|
||||
Automations:
|
||||
automations list List scheduled Orca automations
|
||||
automations show Show one Orca automation
|
||||
automations create Create a scheduled Orca automation
|
||||
automations edit Edit an Orca automation
|
||||
automations remove Remove an Orca automation and its run history
|
||||
automations run Run an Orca automation now
|
||||
automations runs List automation run history
|
||||
|
||||
Projects:
|
||||
project list List durable projects known to Orca
|
||||
project setups List project host setups
|
||||
project setup-existing-folder Make a project available on a host by importing an existing folder
|
||||
project setup-clone Make a project available on a host by cloning a repository
|
||||
project setup-create Create independent project host setup metadata
|
||||
project setup-update Update project host setup metadata
|
||||
project setup-delete Remove a project host setup
|
||||
|
||||
Repos:
|
||||
repo list List repos registered in Orca
|
||||
repo add Add a project to Orca by filesystem path
|
||||
repo show Show one registered repo
|
||||
repo set-base-ref Set the repo's default base ref for future worktrees
|
||||
repo search-refs Search branch/tag refs within a repo
|
||||
|
||||
Worktrees:
|
||||
worktree list List Orca-managed worktrees
|
||||
worktree show Show one worktree
|
||||
worktree current Show the Orca-managed worktree for the current directory
|
||||
worktree create Create a new Orca-managed worktree
|
||||
worktree set Update Orca metadata for a worktree
|
||||
worktree rm Remove a worktree from Orca and git
|
||||
worktree ps Show a compact orchestration summary across worktrees
|
||||
|
||||
Files:
|
||||
file open Open a workspace file in the Orca editor
|
||||
file diff Open a workspace file diff in the Orca editor
|
||||
file open-changed Open all git-changed files for a workspace
|
||||
|
||||
Terminals:
|
||||
terminal list List live Orca-managed terminals
|
||||
terminal show Show terminal metadata and preview
|
||||
terminal read Read bounded terminal output
|
||||
terminal send Send input to a live terminal
|
||||
terminal wait Wait for a terminal condition (exit, tui-idle)
|
||||
terminal stop Stop terminals for a worktree
|
||||
terminal create Create a terminal session in a worktree
|
||||
terminal rename Set or clear the title of a terminal tab
|
||||
terminal split Split an existing terminal pane
|
||||
terminal switch Bring a terminal tab to the foreground
|
||||
terminal focus Alias for terminal switch
|
||||
terminal close Close a terminal pane/session, or its whole tab with --tab
|
||||
|
||||
Orchestration:
|
||||
orchestration run-create Create and bind a lightweight orchestration Run
|
||||
orchestration run-use Bind this coordinator terminal to an existing Run
|
||||
orchestration run-current Show this terminal's bound Run
|
||||
orchestration run-list List lightweight orchestration Runs
|
||||
orchestration run-show Show one lightweight orchestration Run
|
||||
orchestration send Send an inter-agent message
|
||||
orchestration check Check the bound Run mailbox
|
||||
orchestration ask Ask the coordinator a blocking question
|
||||
orchestration reply Reply to a message
|
||||
orchestration inbox Show all messages across recipients
|
||||
orchestration task-create Create an orchestration task
|
||||
orchestration task-list List orchestration tasks
|
||||
orchestration task-update Update a task status
|
||||
orchestration dispatch Dispatch a task to a terminal
|
||||
orchestration dispatch-show Show dispatch context for a task
|
||||
orchestration worker-start Start a supervised worker locally or on a connected Orca server
|
||||
orchestration worker-show Inspect one supervised worker
|
||||
orchestration worker-read Read bounded output from one supervised worker
|
||||
orchestration worker-stop Fence one Dispatch; stop only its supervised worker
|
||||
orchestration worker-abandon Fence an uncertain worker without claiming it stopped
|
||||
orchestration worker-release Release a settled worker's terminal after archiving its output
|
||||
orchestration worker-retain Keep a worker terminal live for debugging
|
||||
orchestration worker-list Report worker terminal resource accounting
|
||||
orchestration coordinator-start Start the legacy automatic coordinator loop
|
||||
orchestration coordinator-stop Stop the legacy automatic coordinator loop
|
||||
orchestration gate-create Create a decision gate blocking a task
|
||||
orchestration gate-resolve Resolve a pending decision gate
|
||||
orchestration gate-list List decision gates
|
||||
orchestration reset Reset orchestration state
|
||||
|
||||
Computer Use:
|
||||
computer capabilities Show computer-use provider capabilities
|
||||
computer permissions Show or open computer-use permission setup
|
||||
computer list-apps List running apps available to computer-use
|
||||
computer list-windows List visible windows for a target app
|
||||
computer get-app-state Capture a compact accessibility snapshot of an app
|
||||
computer click Click an app element or window coordinate
|
||||
computer perform-secondary-action Run an advertised accessibility action
|
||||
computer scroll Scroll an app element
|
||||
computer drag Drag between app elements or window coordinates
|
||||
computer type-text Type literal text at the current app focus
|
||||
computer press-key Press a single key such as Return or Escape
|
||||
computer hotkey Press a shortcut combination such as CmdOrCtrl+A
|
||||
computer paste-text Paste text through the native clipboard path
|
||||
computer set-value Set the value of a settable app element
|
||||
|
||||
Linear:
|
||||
linear Read Linear ticket context for agents
|
||||
|
||||
Mobile Emulator (iOS Simulator):
|
||||
emulator list List available/running emulators (Orca-managed + raw serve-sim)
|
||||
emulator attach <device> Attach/start helper and make active for the worktree
|
||||
emulator tap <x> <y> Tap at normalized 0..1 coords (preferred for single taps)
|
||||
emulator type <text> Type text (US ASCII only)
|
||||
emulator gesture <json> Send begin/move/end touch points
|
||||
emulator button <name> Hardware button (home, side_button, etc.)
|
||||
emulator rotate <o> Rotate device (portrait|landscape_left|...)
|
||||
emulator exec --command Raw serve-sim subcommand passthrough (no "serve-sim" prefix)
|
||||
emulator kill Stop helper for device
|
||||
|
||||
Browser Automation:
|
||||
tab create Create a new browser tab (navigates to --url)
|
||||
tab list List open browser tabs
|
||||
tab show Show one browser tab by page id
|
||||
tab current Show the current browser tab
|
||||
tab profile list List browser session profiles
|
||||
tab profile create Create a browser session profile
|
||||
tab profile delete Delete a browser session profile
|
||||
tab profile set Switch a browser tab to a different profile
|
||||
tab profile show Show the profile bound to a browser tab
|
||||
tab profile use-default Switch a browser tab back to the default profile
|
||||
tab profile clone Clone a browser tab into another profile
|
||||
tab switch Switch the active browser tab by --index or --page
|
||||
tab close Close a browser tab by --index/--page or the current tab
|
||||
snapshot Accessibility snapshot with element refs (e.g. @e1, @e2)
|
||||
goto Navigate the active tab to --url
|
||||
click Click element by --element ref
|
||||
fill Clear and fill input by --element ref with --value
|
||||
type Type --input text at the current focus (no element needed)
|
||||
select Select dropdown option by --element ref and --value
|
||||
hover Hover element by --element ref
|
||||
keypress Press a key (e.g. --key Enter, --key Tab)
|
||||
scroll Scroll --direction (up/down) by --amount pixels
|
||||
back Navigate back in browser history
|
||||
reload Reload the active browser tab
|
||||
screenshot Capture viewport screenshot (--format png|jpeg)
|
||||
eval Evaluate --expression JavaScript in the page context
|
||||
wait Wait for page idle or --timeout ms
|
||||
check Check a checkbox by --element ref
|
||||
uncheck Uncheck a checkbox by --element ref
|
||||
focus Focus an element by --element ref
|
||||
clear Clear an input by --element ref
|
||||
drag Drag --from ref to --to ref
|
||||
upload Upload --files to a file input by --element ref
|
||||
dblclick Double-click element by --element ref
|
||||
forward Navigate forward in browser history
|
||||
scrollintoview Scroll --element into view
|
||||
get Get element property (--what: text, html, value, url, title)
|
||||
is Check element state (--what: visible, enabled, checked)
|
||||
inserttext Insert text without key events
|
||||
mouse move Move mouse to --x --y coordinates
|
||||
mouse down Press mouse button
|
||||
mouse up Release mouse button
|
||||
mouse wheel Scroll wheel --dy [--dx]
|
||||
find Find element by locator (--locator role|text|label --value <v>)
|
||||
set device Emulate device (--name "iPhone 12")
|
||||
set offline Toggle offline mode (--state on|off)
|
||||
set headers Set HTTP headers (--headers '{"key":"val"}')
|
||||
set credentials Set HTTP auth (--user <u> --pass <p>)
|
||||
set media Set color scheme (--color-scheme dark|light)
|
||||
clipboard read Read clipboard contents
|
||||
clipboard write Write --text to clipboard
|
||||
dialog accept Accept browser dialog (--text for prompt response)
|
||||
dialog dismiss Dismiss browser dialog
|
||||
storage local get Get localStorage value by --key
|
||||
storage local set Set localStorage --key --value
|
||||
storage local clear Clear localStorage
|
||||
storage session get Get sessionStorage value by --key
|
||||
storage session set Set sessionStorage --key --value
|
||||
storage session clear Clear sessionStorage
|
||||
download Download file via --selector to --path
|
||||
highlight Highlight --selector on page
|
||||
exec Run any agent-browser command (--command "...")
|
||||
|
||||
Common Commands:
|
||||
orca open [--json]
|
||||
orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--project-root <path>] [--recipe-json] [--json]
|
||||
orca status [--json]
|
||||
orca diagnostics memory [--json]
|
||||
orca agent-context [--json]
|
||||
orca account add [--agent claude|codex] [--json]
|
||||
orca account list [--json]
|
||||
orca host list [--json]
|
||||
orca environment add --name <name> --pairing-code <code> [--json]
|
||||
orca environment list [--json]
|
||||
orca environment show --environment <selector> [--json]
|
||||
orca environment rm --environment <selector> [--json]
|
||||
orca worktree list [--repo <selector>] [--limit <n>] [--json]
|
||||
orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
|
||||
orca worktree show --worktree <selector> [--json]
|
||||
orca worktree current [--json]
|
||||
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
|
||||
orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]
|
||||
orca worktree ps [--limit <n>] [--json]
|
||||
orca file open <path> [--worktree <selector>] [--json]
|
||||
orca file diff <path> [--staged] [--worktree <selector>] [--json]
|
||||
orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]
|
||||
orca terminal list [--worktree <selector>] [--limit <n>] [--include-visual-layouts] [--json]
|
||||
orca terminal show [--terminal <handle>] [--json]
|
||||
orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]
|
||||
orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]
|
||||
orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]
|
||||
orca terminal stop --worktree <selector> [--json]
|
||||
orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]
|
||||
orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]
|
||||
orca terminal switch [--terminal <handle>] [--json]
|
||||
orca terminal close [--terminal <handle>] [--tab] [--json]
|
||||
orca project list [--json]
|
||||
orca project setups [--project <id>] [--host <host-id>] [--json]
|
||||
orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]
|
||||
orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]
|
||||
orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]
|
||||
orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]
|
||||
orca project setup-delete --setup <setup-id> [--json]
|
||||
orca repo list [--json]
|
||||
orca repo add --path <path> [--json]
|
||||
orca repo show --repo <selector> [--json]
|
||||
orca repo set-base-ref --repo <selector> --ref <ref> [--json]
|
||||
orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]
|
||||
|
||||
Selectors:
|
||||
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
|
||||
--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
|
||||
--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--no-parent Force no parent lineage for unrelated worktree creation/update
|
||||
|
||||
Terminal Send Options:
|
||||
--text <text> Text to send to the terminal
|
||||
--enter Append Enter after sending text
|
||||
--interrupt Send as an interrupt-style input when supported
|
||||
|
||||
Terminal List Options:
|
||||
--include-visual-layouts Include tab and pane topology in JSON output
|
||||
|
||||
Wait Options:
|
||||
--for exit Wait until the target terminal exits
|
||||
--timeout-ms <ms> Maximum wait time before timing out
|
||||
|
||||
Output Options:
|
||||
--json Emit machine-readable JSON instead of human text
|
||||
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair?... code
|
||||
--environment <selector> Connect using a saved environment id or name
|
||||
--help Show this help message
|
||||
|
||||
Behavior:
|
||||
Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first.
|
||||
Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.
|
||||
Use selectors for discovery and handles for repeated live terminal operations.
|
||||
|
||||
Agent Sessions And Worktrees:
|
||||
\`worktree create --agent\` creates a new checkout with an agent.
|
||||
To start a fresh agent in the current worktree, use:
|
||||
orca terminal create --worktree active --command "codex"
|
||||
|
||||
Browser Workflow:
|
||||
1. Create or navigate: orca tab create --url https://example.com
|
||||
orca goto --url https://example.com
|
||||
2. Inspect the page: orca snapshot
|
||||
(Returns an accessibility tree with element refs like e1, e2, e3)
|
||||
For concurrent workflows, prefer: orca tab list --json
|
||||
then reuse tabs[].browserPageId with --page <id> on later commands.
|
||||
3. Interact: orca click --element e2
|
||||
orca fill --element e5 --value "search query"
|
||||
orca keypress --key Enter
|
||||
4. Re-inspect: orca snapshot
|
||||
(Element refs change after navigation — always re-snapshot before interacting)
|
||||
|
||||
Browser Options:
|
||||
--element <ref> Element ref from snapshot (e.g. @e3)
|
||||
--url <url> URL to navigate to
|
||||
--value <text> Value to fill or select
|
||||
--input <text> Text to type at current focus (no element needed)
|
||||
--expression <js> JavaScript expression to evaluate
|
||||
--key <key> Key to press (Enter, Tab, Escape, Control+a, etc.)
|
||||
--direction <dir> Scroll direction: up or down
|
||||
--amount <pixels> Scroll distance in pixels (default: viewport height)
|
||||
--index <n> Tab index (from \`tab list\`)
|
||||
--page <id> Stable browser page id (preferred for concurrent workflows)
|
||||
--profile <id> Browser profile id
|
||||
--show-profile Include the tab's browser profile in text output
|
||||
--no-ua-spoof Keep Electron's native user agent for a new profile
|
||||
--format <png|jpeg> Screenshot image format
|
||||
--from <ref> Drag source element ref
|
||||
--to <ref> Drag target element ref
|
||||
--files <path,...> Comma-separated file paths for upload
|
||||
--timeout <ms> Wait timeout in milliseconds
|
||||
--worktree <selector> Scope commands to a specific worktree's browser tabs
|
||||
|
||||
Examples:
|
||||
$ orca open
|
||||
$ orca status --json
|
||||
$ orca diagnostics memory --json
|
||||
$ orca repo list
|
||||
$ orca worktree create --name agent-task --agent codex --prompt "hi"
|
||||
$ orca worktree create --repo name:orca --name cli-test-1 --issue 273
|
||||
$ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue
|
||||
$ orca worktree create --name linear-task --linear-issue STA-335
|
||||
$ orca worktree show --worktree branch:Jinwoo-H/cli
|
||||
$ orca worktree current
|
||||
$ orca worktree set --worktree active --comment "waiting on review"
|
||||
$ orca worktree set --worktree active --linear-issue null
|
||||
$ orca worktree ps --limit 10
|
||||
$ orca file open-changed --mode diff
|
||||
$ orca file open src/App.tsx
|
||||
$ orca terminal create --worktree active --command "codex"
|
||||
$ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json
|
||||
$ orca terminal send --terminal term_123 --text "hi" --enter
|
||||
$ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json
|
||||
$ orca tab current --json
|
||||
$ orca tab show --page page_123 --json
|
||||
$ orca tab create --url https://example.com --profile work
|
||||
$ orca tab profile clone --page page_123 --profile work --json
|
||||
$ orca snapshot
|
||||
$ orca click --element e3
|
||||
$ orca fill --element e5 --value "hello"
|
||||
$ orca goto --url https://example.com/login
|
||||
$ orca keypress --key Enter
|
||||
$ orca eval --expression "document.title"
|
||||
$ orca tab list --json`
|
||||
const ROOT_HELP_TEXT = [ROOT_HELP_TEXT_PRIMARY, ROOT_HELP_TEXT_SECONDARY].join('\n')
|
||||
|
||||
export function printHelp(specs: CommandSpec[], commandPath: string[] = []): void {
|
||||
const exactSpec = findCommandSpec(specs, commandPath)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
export const ROOT_HELP_TEXT_PRIMARY = [
|
||||
'orca',
|
||||
'',
|
||||
'Usage: orca <command> [options]',
|
||||
'',
|
||||
'Startup:',
|
||||
' open Launch Orca and wait for the runtime to be reachable',
|
||||
' serve Start a headless Orca runtime server',
|
||||
' status Show app/runtime/graph readiness',
|
||||
'',
|
||||
'Diagnostics:',
|
||||
' diagnostics memory Collect a memory snapshot for Orca and managed terminals',
|
||||
'',
|
||||
'Agent Discovery:',
|
||||
' agent-context Print the machine-readable command schema for agents',
|
||||
'',
|
||||
'Accounts:',
|
||||
' account add Add a managed Claude or Codex account on this Orca host',
|
||||
' account list List managed Claude and Codex accounts on this Orca host',
|
||||
'',
|
||||
'Skills:',
|
||||
' skills installed List installed skill selectors',
|
||||
' skills share Publish selected skills behind one unlisted link',
|
||||
' skills list List version-matched skill guides bundled with this Orca CLI',
|
||||
' skills get Print a version-matched skill guide as Markdown',
|
||||
' skills install Install bundled Orca skills globally via the community skills CLI',
|
||||
' skills update Update already-installed Orca skills via the community skills CLI',
|
||||
'',
|
||||
'Hosts:',
|
||||
' host list List targetable machines and how to name each one',
|
||||
'',
|
||||
'Environments:',
|
||||
' environment add Save a remote Orca runtime from a pairing code',
|
||||
' environment list List saved remote Orca runtimes',
|
||||
' environment show Show one saved remote Orca runtime',
|
||||
' environment rm Remove a saved remote Orca runtime',
|
||||
'',
|
||||
'Environment Recipes:',
|
||||
' vm recipe doctor Validate a per-workspace environment recipe',
|
||||
'',
|
||||
'Automations:',
|
||||
' automations list List scheduled Orca automations',
|
||||
' automations show Show one Orca automation',
|
||||
' automations create Create a scheduled Orca automation',
|
||||
' automations edit Edit an Orca automation',
|
||||
' automations remove Remove an Orca automation and its run history',
|
||||
' automations run Run an Orca automation now',
|
||||
' automations runs List automation run history',
|
||||
'',
|
||||
'Projects:',
|
||||
' project list List durable projects known to Orca',
|
||||
' project setups List project host setups',
|
||||
' project setup-existing-folder Make a project available on a host by importing an existing folder',
|
||||
' project setup-clone Make a project available on a host by cloning a repository',
|
||||
' project setup-create Create independent project host setup metadata',
|
||||
' project setup-update Update project host setup metadata',
|
||||
' project setup-delete Remove a project host setup',
|
||||
'',
|
||||
'Repos:',
|
||||
' repo list List repos registered in Orca',
|
||||
' repo add Add a project to Orca by filesystem path',
|
||||
' repo show Show one registered repo',
|
||||
" repo set-base-ref Set the repo's default base ref for future worktrees",
|
||||
' repo search-refs Search branch/tag refs within a repo',
|
||||
'',
|
||||
'Worktrees:',
|
||||
' worktree list List Orca-managed worktrees',
|
||||
' worktree show Show one worktree',
|
||||
' worktree current Show the Orca-managed worktree for the current directory',
|
||||
' worktree create Create a new Orca-managed worktree',
|
||||
' worktree set Update Orca metadata for a worktree',
|
||||
' worktree rm Remove a worktree from Orca and git',
|
||||
' worktree ps Show a compact orchestration summary across worktrees',
|
||||
'',
|
||||
'Files:',
|
||||
' file open Open a workspace file in the Orca editor',
|
||||
' file diff Open a workspace file diff in the Orca editor',
|
||||
' file open-changed Open all git-changed files for a workspace',
|
||||
'',
|
||||
'Terminals:',
|
||||
' terminal list List live Orca-managed terminals',
|
||||
' terminal show Show terminal metadata and preview',
|
||||
' terminal read Read bounded terminal output',
|
||||
' terminal send Send input to a live terminal',
|
||||
' terminal wait Wait for a terminal condition (exit, tui-idle)',
|
||||
' terminal stop Stop terminals for a worktree',
|
||||
' terminal create Create a terminal session in a worktree',
|
||||
' terminal rename Set or clear the title of a terminal tab',
|
||||
' terminal split Split an existing terminal pane',
|
||||
' terminal switch Bring a terminal tab to the foreground',
|
||||
' terminal focus Alias for terminal switch',
|
||||
' terminal close Close a terminal pane/session, or its whole tab with --tab',
|
||||
'',
|
||||
'Orchestration:',
|
||||
' orchestration run-create Create and bind a lightweight orchestration Run',
|
||||
' orchestration run-use Bind this coordinator terminal to an existing Run',
|
||||
" orchestration run-current Show this terminal's bound Run",
|
||||
' orchestration run-list List lightweight orchestration Runs',
|
||||
' orchestration run-show Show one lightweight orchestration Run',
|
||||
' orchestration send Send an inter-agent message',
|
||||
' orchestration check Check the bound Run mailbox',
|
||||
' orchestration ask Ask the coordinator a blocking question',
|
||||
' orchestration reply Reply to a message',
|
||||
' orchestration inbox Show all messages across recipients',
|
||||
' orchestration task-create Create an orchestration task',
|
||||
' orchestration task-list List orchestration tasks',
|
||||
' orchestration task-update Update a task status',
|
||||
' orchestration dispatch Dispatch a task to a terminal',
|
||||
' orchestration dispatch-show Show dispatch context for a task',
|
||||
' orchestration worker-start Start a supervised worker locally or on a connected Orca server',
|
||||
' orchestration worker-show Inspect one supervised worker',
|
||||
' orchestration worker-read Read bounded output from one supervised worker',
|
||||
' orchestration worker-stop Fence one Dispatch; stop only its supervised worker',
|
||||
' orchestration worker-abandon Fence an uncertain worker without claiming it stopped',
|
||||
" orchestration worker-release Release a settled worker's terminal after archiving its output",
|
||||
' orchestration worker-retain Keep a worker terminal live for debugging',
|
||||
' orchestration worker-list Report worker terminal resource accounting',
|
||||
' orchestration coordinator-start Start the legacy automatic coordinator loop',
|
||||
' orchestration coordinator-stop Stop the legacy automatic coordinator loop',
|
||||
' orchestration gate-create Create a decision gate blocking a task',
|
||||
' orchestration gate-resolve Resolve a pending decision gate',
|
||||
' orchestration gate-list List decision gates',
|
||||
' orchestration reset Reset orchestration state',
|
||||
'',
|
||||
'Computer Use:',
|
||||
' computer capabilities Show computer-use provider capabilities',
|
||||
' computer permissions Show or open computer-use permission setup',
|
||||
' computer list-apps List running apps available to computer-use',
|
||||
' computer list-windows List visible windows for a target app',
|
||||
' computer get-app-state Capture a compact accessibility snapshot of an app',
|
||||
' computer click Click an app element or window coordinate',
|
||||
' computer perform-secondary-action Run an advertised accessibility action',
|
||||
' computer scroll Scroll an app element',
|
||||
' computer drag Drag between app elements or window coordinates',
|
||||
' computer type-text Type literal text at the current app focus',
|
||||
' computer press-key Press a single key such as Return or Escape',
|
||||
' computer hotkey Press a shortcut combination such as CmdOrCtrl+A',
|
||||
' computer paste-text Paste text through the native clipboard path',
|
||||
' computer set-value Set the value of a settable app element',
|
||||
'',
|
||||
'Linear:',
|
||||
' linear Read Linear ticket context for agents',
|
||||
'',
|
||||
'Mobile Emulator (iOS Simulator):',
|
||||
' emulator list List available/running emulators (Orca-managed + raw serve-sim)',
|
||||
' emulator attach <device> Attach/start helper and make active for the worktree',
|
||||
' emulator tap <x> <y> Tap at normalized 0..1 coords (preferred for single taps)',
|
||||
' emulator type <text> Type text (US ASCII only)',
|
||||
' emulator gesture <json> Send begin/move/end touch points',
|
||||
' emulator button <name> Hardware button (home, side_button, etc.)',
|
||||
' emulator rotate <o> Rotate device (portrait|landscape_left|...)',
|
||||
' emulator exec --command Raw serve-sim subcommand passthrough (no "serve-sim" prefix)',
|
||||
' emulator kill Stop helper for device',
|
||||
'',
|
||||
'Browser Automation:',
|
||||
' tab create Create a new browser tab (navigates to --url)',
|
||||
' tab list List open browser tabs',
|
||||
' tab show Show one browser tab by page id',
|
||||
' tab current Show the current browser tab',
|
||||
' tab profile list List browser session profiles',
|
||||
' tab profile create Create a browser session profile',
|
||||
' tab profile delete Delete a browser session profile',
|
||||
' tab profile set Switch a browser tab to a different profile',
|
||||
' tab profile show Show the profile bound to a browser tab',
|
||||
' tab profile use-default Switch a browser tab back to the default profile',
|
||||
' tab profile clone Clone a browser tab into another profile',
|
||||
' tab switch Switch the active browser tab by --index or --page',
|
||||
' tab close Close a browser tab by --index/--page or the current tab',
|
||||
' snapshot Accessibility snapshot with element refs (e.g. @e1, @e2)',
|
||||
' goto Navigate the active tab to --url',
|
||||
' click Click element by --element ref',
|
||||
' fill Clear and fill input by --element ref with --value',
|
||||
' type Type --input text at the current focus (no element needed)',
|
||||
' select Select dropdown option by --element ref and --value',
|
||||
' hover Hover element by --element ref',
|
||||
' keypress Press a key (e.g. --key Enter, --key Tab)',
|
||||
' scroll Scroll --direction (up/down) by --amount pixels',
|
||||
' back Navigate back in browser history',
|
||||
' reload Reload the active browser tab',
|
||||
' screenshot Capture viewport screenshot (--format png|jpeg)',
|
||||
' eval Evaluate --expression JavaScript in the page context',
|
||||
' wait Wait for page idle or --timeout ms',
|
||||
' check Check a checkbox by --element ref'
|
||||
].join('\n')
|
||||
@@ -0,0 +1,183 @@
|
||||
export const ROOT_HELP_TEXT_SECONDARY = [
|
||||
' uncheck Uncheck a checkbox by --element ref',
|
||||
' focus Focus an element by --element ref',
|
||||
' clear Clear an input by --element ref',
|
||||
' drag Drag --from ref to --to ref',
|
||||
' upload Upload --files to a file input by --element ref',
|
||||
' dblclick Double-click element by --element ref',
|
||||
' forward Navigate forward in browser history',
|
||||
' scrollintoview Scroll --element into view',
|
||||
' get Get element property (--what: text, html, value, url, title)',
|
||||
' is Check element state (--what: visible, enabled, checked)',
|
||||
' inserttext Insert text without key events',
|
||||
' mouse move Move mouse to --x --y coordinates',
|
||||
' mouse down Press mouse button',
|
||||
' mouse up Release mouse button',
|
||||
' mouse wheel Scroll wheel --dy [--dx]',
|
||||
' find Find element by locator (--locator role|text|label --value <v>)',
|
||||
' set device Emulate device (--name "iPhone 12")',
|
||||
' set offline Toggle offline mode (--state on|off)',
|
||||
' set headers Set HTTP headers (--headers \'{"key":"val"}\')',
|
||||
' set credentials Set HTTP auth (--user <u> --pass <p>)',
|
||||
' set media Set color scheme (--color-scheme dark|light)',
|
||||
' clipboard read Read clipboard contents',
|
||||
' clipboard write Write --text to clipboard',
|
||||
' dialog accept Accept browser dialog (--text for prompt response)',
|
||||
' dialog dismiss Dismiss browser dialog',
|
||||
' storage local get Get localStorage value by --key',
|
||||
' storage local set Set localStorage --key --value',
|
||||
' storage local clear Clear localStorage',
|
||||
' storage session get Get sessionStorage value by --key',
|
||||
' storage session set Set sessionStorage --key --value',
|
||||
' storage session clear Clear sessionStorage',
|
||||
' download Download file via --selector to --path',
|
||||
' highlight Highlight --selector on page',
|
||||
' exec Run any agent-browser command (--command "...")',
|
||||
'',
|
||||
'Common Commands:',
|
||||
' orca open [--json]',
|
||||
' orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--project-root <path>] [--recipe-json] [--json]',
|
||||
' orca status [--json]',
|
||||
' orca diagnostics memory [--json]',
|
||||
' orca agent-context [--json]',
|
||||
' orca account add [--agent claude|codex] [--json]',
|
||||
' orca account list [--json]',
|
||||
' orca host list [--json]',
|
||||
' orca environment add --name <name> --pairing-code <code> [--json]',
|
||||
' orca environment list [--json]',
|
||||
' orca environment show --environment <selector> [--json]',
|
||||
' orca environment rm --environment <selector> [--json]',
|
||||
' orca worktree list [--repo <selector>] [--limit <n>] [--json]',
|
||||
' orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
|
||||
' orca worktree show --worktree <selector> [--json]',
|
||||
' orca worktree current [--json]',
|
||||
' orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
|
||||
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]',
|
||||
' orca worktree ps [--limit <n>] [--json]',
|
||||
' orca file open <path> [--worktree <selector>] [--json]',
|
||||
' orca file diff <path> [--staged] [--worktree <selector>] [--json]',
|
||||
' orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]',
|
||||
' orca terminal list [--worktree <selector>] [--limit <n>] [--include-visual-layouts] [--json]',
|
||||
' orca terminal show [--terminal <handle>] [--json]',
|
||||
' orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]',
|
||||
' orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]',
|
||||
' orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]',
|
||||
' orca terminal stop --worktree <selector> [--json]',
|
||||
' orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]',
|
||||
' orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]',
|
||||
' orca terminal switch [--terminal <handle>] [--json]',
|
||||
' orca terminal close [--terminal <handle>] [--tab] [--json]',
|
||||
' orca project list [--json]',
|
||||
' orca project setups [--project <id>] [--host <host-id>] [--json]',
|
||||
' orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]',
|
||||
' orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]',
|
||||
' orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]',
|
||||
' orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]',
|
||||
' orca project setup-delete --setup <setup-id> [--json]',
|
||||
' orca repo list [--json]',
|
||||
' orca repo add --path <path> [--json]',
|
||||
' orca repo show --repo <selector> [--json]',
|
||||
' orca repo set-base-ref --repo <selector> --ref <ref> [--json]',
|
||||
' orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]',
|
||||
'',
|
||||
'Selectors:',
|
||||
' --repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>',
|
||||
' --worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
|
||||
' --terminal <handle> Runtime-issued terminal handle returned by `orca terminal list --json`',
|
||||
' --parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
|
||||
' --no-parent Force no parent lineage for unrelated worktree creation/update',
|
||||
'',
|
||||
'Terminal Send Options:',
|
||||
' --text <text> Text to send to the terminal',
|
||||
' --enter Append Enter after sending text',
|
||||
' --interrupt Send as an interrupt-style input when supported',
|
||||
'',
|
||||
'Terminal List Options:',
|
||||
' --include-visual-layouts Include tab and pane topology in JSON output',
|
||||
'',
|
||||
'Wait Options:',
|
||||
' --for exit Wait until the target terminal exits',
|
||||
' --timeout-ms <ms> Maximum wait time before timing out',
|
||||
'',
|
||||
'Output Options:',
|
||||
' --json Emit machine-readable JSON instead of human text',
|
||||
' --pairing-code <code> Connect to a remote Orca runtime using an orca://pair?... code',
|
||||
' --environment <selector> Connect using a saved environment id or name',
|
||||
' --help Show this help message',
|
||||
'',
|
||||
'Behavior:',
|
||||
' Most commands require a running Orca runtime. If Orca is not open yet, run `orca open` first.',
|
||||
' Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.',
|
||||
' Use selectors for discovery and handles for repeated live terminal operations.',
|
||||
'',
|
||||
'Agent Sessions And Worktrees:',
|
||||
' `worktree create --agent` creates a new checkout with an agent.',
|
||||
' To start a fresh agent in the current worktree, use:',
|
||||
' orca terminal create --worktree active --command "codex"',
|
||||
'',
|
||||
'Browser Workflow:',
|
||||
' 1. Create or navigate: orca tab create --url https://example.com',
|
||||
' orca goto --url https://example.com',
|
||||
' 2. Inspect the page: orca snapshot',
|
||||
' (Returns an accessibility tree with element refs like e1, e2, e3)',
|
||||
' For concurrent workflows, prefer: orca tab list --json',
|
||||
' then reuse tabs[].browserPageId with --page <id> on later commands.',
|
||||
' 3. Interact: orca click --element e2',
|
||||
' orca fill --element e5 --value "search query"',
|
||||
' orca keypress --key Enter',
|
||||
' 4. Re-inspect: orca snapshot',
|
||||
' (Element refs change after navigation — always re-snapshot before interacting)',
|
||||
'',
|
||||
'Browser Options:',
|
||||
' --element <ref> Element ref from snapshot (e.g. @e3)',
|
||||
' --url <url> URL to navigate to',
|
||||
' --value <text> Value to fill or select',
|
||||
' --input <text> Text to type at current focus (no element needed)',
|
||||
' --expression <js> JavaScript expression to evaluate',
|
||||
' --key <key> Key to press (Enter, Tab, Escape, Control+a, etc.)',
|
||||
' --direction <dir> Scroll direction: up or down',
|
||||
' --amount <pixels> Scroll distance in pixels (default: viewport height)',
|
||||
' --index <n> Tab index (from `tab list`)',
|
||||
' --page <id> Stable browser page id (preferred for concurrent workflows)',
|
||||
' --profile <id> Browser profile id',
|
||||
" --show-profile Include the tab's browser profile in text output",
|
||||
" --no-ua-spoof Keep Electron's native user agent for a new profile",
|
||||
' --format <png|jpeg> Screenshot image format',
|
||||
' --from <ref> Drag source element ref',
|
||||
' --to <ref> Drag target element ref',
|
||||
' --files <path,...> Comma-separated file paths for upload',
|
||||
' --timeout <ms> Wait timeout in milliseconds',
|
||||
" --worktree <selector> Scope commands to a specific worktree's browser tabs",
|
||||
'',
|
||||
'Examples:',
|
||||
' $ orca open',
|
||||
' $ orca status --json',
|
||||
' $ orca diagnostics memory --json',
|
||||
' $ orca repo list',
|
||||
' $ orca worktree create --name agent-task --agent codex --prompt "hi"',
|
||||
' $ orca worktree create --repo name:orca --name cli-test-1 --issue 273',
|
||||
' $ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue',
|
||||
' $ orca worktree create --name linear-task --linear-issue STA-335',
|
||||
' $ orca worktree show --worktree branch:Jinwoo-H/cli',
|
||||
' $ orca worktree current',
|
||||
' $ orca worktree set --worktree active --comment "waiting on review"',
|
||||
' $ orca worktree set --worktree active --linear-issue null',
|
||||
' $ orca worktree ps --limit 10',
|
||||
' $ orca file open-changed --mode diff',
|
||||
' $ orca file open src/App.tsx',
|
||||
' $ orca terminal create --worktree active --command "codex"',
|
||||
' $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json',
|
||||
' $ orca terminal send --terminal term_123 --text "hi" --enter',
|
||||
' $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json',
|
||||
' $ orca tab current --json',
|
||||
' $ orca tab show --page page_123 --json',
|
||||
' $ orca tab create --url https://example.com --profile work',
|
||||
' $ orca tab profile clone --page page_123 --profile work --json',
|
||||
' $ orca snapshot',
|
||||
' $ orca click --element e3',
|
||||
' $ orca fill --element e5 --value "hello"',
|
||||
' $ orca goto --url https://example.com/login',
|
||||
' $ orca keypress --key Enter',
|
||||
' $ orca eval --expression "document.title"',
|
||||
' $ orca tab list --json'
|
||||
].join('\n')
|
||||
@@ -1,210 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const browserMocks = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(() => '/downloads'),
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestOpenDevToolsMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
openPopupWithOriginBarMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: browserMocks.appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: browserMocks.shellOpenExternalMock },
|
||||
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
|
||||
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
|
||||
webContents: { fromId: browserMocks.webContentsFromIdMock }
|
||||
}))
|
||||
|
||||
vi.mock('./popup-origin-bar-window', () => ({
|
||||
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
|
||||
}))
|
||||
|
||||
import { browserManager } from './browser-manager'
|
||||
import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge'
|
||||
import {
|
||||
rendererWebContentsId,
|
||||
resetBrowserManagerMocks,
|
||||
resetBrowserManagerState
|
||||
} from './browser-manager-test-harness'
|
||||
import {
|
||||
createViewportGuestFactory,
|
||||
flushViewportOps
|
||||
} from './browser-manager-viewport-test-fixtures'
|
||||
|
||||
const { webContentsFromIdMock } = browserMocks
|
||||
const makeGuest = createViewportGuestFactory(browserMocks)
|
||||
|
||||
const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
|
||||
emitViewport: false,
|
||||
enabled: true,
|
||||
markers: [],
|
||||
token: 'annotationviewporttoken'
|
||||
}
|
||||
|
||||
function registerPage(pageId: string, guest: Record<string, unknown>): void {
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: pageId,
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
}
|
||||
|
||||
/** The production resolver's shape: read the registry now, not when the request was made. */
|
||||
function resolveFromRegistry(pageId: string): () => Electron.WebContents | null {
|
||||
return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId)
|
||||
}
|
||||
|
||||
describe('browserManager.setAnnotationViewportBridge', () => {
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('installs the bridge in an isolated world', async () => {
|
||||
const { guest } = makeGuest(4646)
|
||||
registerPage('tab-annotations', guest)
|
||||
|
||||
const ok = await browserManager.setAnnotationViewportBridge(
|
||||
'tab-annotations',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveFromRegistry('tab-annotations')
|
||||
)
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
[
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
|
||||
})
|
||||
],
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
// Why this is the case that matters: a cross-process navigation re-registers the same page id
|
||||
// with a new WebContents and leaves the retired one alive, so a bridge op that resolved its
|
||||
// guest when the request arrived would inject into a page nobody is looking at — and the
|
||||
// annotation badges would stop tracking the document on screen.
|
||||
it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => {
|
||||
const { guest: firstGuest } = makeGuest(5101)
|
||||
let releaseFirstInjection = (): void => {}
|
||||
// One shared gate for every call, so a wrongly-routed second injection still settles and the
|
||||
// test fails on where it landed rather than on a timeout.
|
||||
const firstInjectionGate = new Promise<void>((resolve) => {
|
||||
releaseFirstInjection = () => resolve()
|
||||
})
|
||||
firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate)
|
||||
registerPage('tab-swap', firstGuest)
|
||||
|
||||
const firstDone = browserManager.setAnnotationViewportBridge(
|
||||
'tab-swap',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveFromRegistry('tab-swap')
|
||||
)
|
||||
await flushViewportOps()
|
||||
|
||||
// A second request arrives while the first still holds the chain — at this moment the page is
|
||||
// still on the first guest, which is the guest a request-time resolution would capture.
|
||||
const secondDone = browserManager.setAnnotationViewportBridge(
|
||||
'tab-swap',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveFromRegistry('tab-swap')
|
||||
)
|
||||
await flushViewportOps()
|
||||
|
||||
// Only now does the page swap renderer processes, while the second op is still queued.
|
||||
const { guest: secondGuest } = makeGuest(5102)
|
||||
registerPage('tab-swap', secondGuest)
|
||||
|
||||
releaseFirstInjection()
|
||||
await expect(firstDone).resolves.toBe(true)
|
||||
await expect(secondDone).resolves.toBe(true)
|
||||
|
||||
expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
|
||||
expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry
|
||||
// is the one that can see the guest is gone. The bridge only reports the refusal.
|
||||
it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => {
|
||||
const { guest } = makeGuest(5103)
|
||||
registerPage('tab-dies', guest)
|
||||
expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103)
|
||||
|
||||
webContentsFromIdMock.mockReturnValue(null)
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge(
|
||||
'tab-dies',
|
||||
BRIDGE_OPTIONS,
|
||||
resolveFromRegistry('tab-dies')
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
|
||||
// Why assert the registry and not just the answer: a stale guest has to clear every per-tab
|
||||
// entry, or the page keeps a dead WebContents id that later ops resolve against.
|
||||
expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull()
|
||||
})
|
||||
|
||||
// Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by
|
||||
// the wrong renderer names a page that is alive and on screen, and answering it with teardown
|
||||
// would cancel that page's in-flight downloads and grabs over a misaddressed message.
|
||||
it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => {
|
||||
const { guest } = makeGuest(5105)
|
||||
registerPage('tab-mismatch', guest)
|
||||
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
|
||||
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () =>
|
||||
browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1)
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(unregisterGuest).not.toHaveBeenCalled()
|
||||
expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a destroyed guest without injecting into it', async () => {
|
||||
const { guest } = makeGuest(5104)
|
||||
registerPage('tab-destroyed', guest)
|
||||
;(guest.isDestroyed as ReturnType<typeof vi.fn>).mockReturnValue(true)
|
||||
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge(
|
||||
'tab-destroyed',
|
||||
BRIDGE_OPTIONS,
|
||||
() => guest as never
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: a document page owns no browsing state, so treating its refusal as a stale page would run
|
||||
// teardown against an id the browsing registry never held.
|
||||
it('does not run page teardown when a document page resolves to nothing', async () => {
|
||||
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
|
||||
|
||||
await expect(
|
||||
browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null)
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(unregisterGuest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -45,32 +45,6 @@ import {
|
||||
resetBrowserManagerMocks,
|
||||
resetBrowserManagerState
|
||||
} from './browser-manager-test-harness'
|
||||
import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy'
|
||||
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
/**
|
||||
* A page the document half of the registry really holds. Built rather than named: membership is
|
||||
* what both doors refuse on now, so an id that merely looks like a preview's would be admitted.
|
||||
*/
|
||||
function registerWorkspaceDocPage(browserPageId: string): void {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
const guest = {
|
||||
isFocused: () => false,
|
||||
isDestroyed: () => false,
|
||||
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() })
|
||||
}
|
||||
|
||||
const {
|
||||
guestOffMock,
|
||||
@@ -86,7 +60,6 @@ describe('browserManager', () => {
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -119,77 +92,6 @@ describe('browserManager', () => {
|
||||
expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work')
|
||||
})
|
||||
|
||||
// Why both doors: one id in both halves of the registry would make the tool door answer with a
|
||||
// document guest for a page the reader is browsing in.
|
||||
it.each(['registerGuest', 'registerOffscreenGuest'] as const)(
|
||||
'refuses %s for a page the document registry already holds',
|
||||
(entryPoint) => {
|
||||
const guest = {
|
||||
id: 129,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
const browserPageId = 'doc-page-1'
|
||||
registerWorkspaceDocPage(browserPageId)
|
||||
|
||||
if (entryPoint === 'registerGuest') {
|
||||
expect(
|
||||
browserManager.registerGuest({
|
||||
browserPageId,
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
).toBe(false)
|
||||
} else {
|
||||
expect(
|
||||
browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id })
|
||||
).toBe(false)
|
||||
}
|
||||
|
||||
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
// Why this answer is load-bearing: the headless backend destroys its window on false, so a true
|
||||
// for a guest that is already gone would leave a page id registered onto nothing.
|
||||
it.each(['missing', 'destroyed'] as const)(
|
||||
'refuses registerOffscreenGuest when the named guest is %s',
|
||||
(guestState) => {
|
||||
webContentsFromIdMock.mockReturnValue(
|
||||
guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) }
|
||||
)
|
||||
|
||||
expect(
|
||||
browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 })
|
||||
).toBe(false)
|
||||
|
||||
expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
// Why the exit door needs the same check: a document page withdraws by revoking its grant, so its
|
||||
// id here is misaddressed — and unregistering opens by evicting whatever grab that id names.
|
||||
it('refuses unregisterGuest for a page the document registry holds', () => {
|
||||
registerWorkspaceDocPage('doc-page-2')
|
||||
const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp')
|
||||
|
||||
browserManager.unregisterGuest('doc-page-2')
|
||||
|
||||
expect(cancelGrabOp).not.toHaveBeenCalled()
|
||||
|
||||
// The presence half: the same door does evict a browsing page's grab.
|
||||
browserManager.unregisterGuest('browser-page-1')
|
||||
expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted')
|
||||
cancelGrabOp.mockRestore()
|
||||
})
|
||||
|
||||
it('blocks non-web guest navigations after attach', () => {
|
||||
const guest = {
|
||||
isDestroyed: vi.fn(() => false),
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const browserMocks = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(() => '/downloads'),
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestOpenDevToolsMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
openPopupWithOriginBarMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: browserMocks.appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: browserMocks.shellOpenExternalMock },
|
||||
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
|
||||
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
|
||||
webContents: { fromId: browserMocks.webContentsFromIdMock }
|
||||
}))
|
||||
vi.mock('./popup-origin-bar-window', () => ({
|
||||
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
|
||||
}))
|
||||
|
||||
import { browserManager } from './browser-manager'
|
||||
import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness'
|
||||
import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy'
|
||||
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
type GuestFake = {
|
||||
id: number
|
||||
url: string
|
||||
listeners: Map<string, ((...args: never[]) => void)[]>
|
||||
windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null
|
||||
webRtcPolicy: string | null
|
||||
isDestroyed: () => boolean
|
||||
isFocused: () => boolean
|
||||
getURL: () => string
|
||||
getType: () => string
|
||||
setBackgroundThrottling: (value: boolean) => void
|
||||
setWebRTCIPHandlingPolicy: (policy: string) => void
|
||||
setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void
|
||||
executeJavaScriptInIsolatedWorld: ReturnType<typeof vi.fn>
|
||||
debugger: {
|
||||
isAttached: () => boolean
|
||||
attach: ReturnType<typeof vi.fn>
|
||||
sendCommand: ReturnType<typeof vi.fn>
|
||||
}
|
||||
on: (event: string, listener: (...args: never[]) => void) => void
|
||||
once: (event: string, listener: (...args: never[]) => void) => void
|
||||
off: (event: string, listener: (...args: never[]) => void) => void
|
||||
}
|
||||
|
||||
function createGuest(id: number, url: string): GuestFake {
|
||||
const listeners = new Map<string, ((...args: never[]) => void)[]>()
|
||||
const guest: GuestFake = {
|
||||
id,
|
||||
url,
|
||||
listeners,
|
||||
windowOpenHandler: null,
|
||||
webRtcPolicy: null,
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => true,
|
||||
getURL: () => guest.url,
|
||||
getType: () => 'webview',
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: (policy: string) => {
|
||||
guest.webRtcPolicy = policy
|
||||
},
|
||||
setWindowOpenHandler: (handler) => {
|
||||
guest.windowOpenHandler = handler
|
||||
},
|
||||
executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined),
|
||||
debugger: {
|
||||
isAttached: () => true,
|
||||
attach: vi.fn(),
|
||||
sendCommand: vi.fn(async () => undefined)
|
||||
},
|
||||
on: (event, listener) => {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), listener])
|
||||
},
|
||||
once: (event, listener) => {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), listener])
|
||||
},
|
||||
off: (event, listener) => {
|
||||
listeners.set(
|
||||
event,
|
||||
(listeners.get(event) ?? []).filter((entry) => entry !== listener)
|
||||
)
|
||||
}
|
||||
}
|
||||
return guest
|
||||
}
|
||||
|
||||
function listenerCount(guest: GuestFake, event: string): number {
|
||||
return guest.listeners.get(event)?.length ?? 0
|
||||
}
|
||||
|
||||
/** Drives the guest's own will-navigate listeners and reports whether they refused. */
|
||||
function navigateTo(guest: GuestFake, url: string): boolean {
|
||||
let prevented = false
|
||||
const event = { preventDefault: () => (prevented = true) } as never
|
||||
for (const listener of guest.listeners.get('will-navigate') ?? []) {
|
||||
;(listener as (event: unknown, url: string) => void)(event, url)
|
||||
}
|
||||
return prevented
|
||||
}
|
||||
|
||||
const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents
|
||||
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
revokeAllDocPreviewGrants()
|
||||
browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
describe('guest policy profiles', () => {
|
||||
function attachPreviewGuest(id = 301): {
|
||||
guest: GuestFake
|
||||
grantId: string
|
||||
browserPageId: string
|
||||
} {
|
||||
const browserPageId = `doc-page-${id}`
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host })
|
||||
return { guest, grantId: grant.id, browserPageId }
|
||||
}
|
||||
|
||||
// The presence half of every absence below: a browsing guest observably takes all of it through
|
||||
// the same method, so a profile that fenced nothing — or an attach path that stopped installing
|
||||
// anything at all — cannot pass these by being uniformly empty.
|
||||
it('gives a browsing guest link routing, popups and anti-detection', () => {
|
||||
const guest = createGuest(300, 'https://example.com/')
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(1)
|
||||
expect(listenerCount(guest, 'frame-created')).toBe(1)
|
||||
expect(listenerCount(guest, 'did-create-window')).toBe(1)
|
||||
expect(guest.debugger.sendCommand).toHaveBeenCalled()
|
||||
expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false)
|
||||
})
|
||||
|
||||
it('gives a workspace-document guest none of it', () => {
|
||||
const { guest } = attachPreviewGuest()
|
||||
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(0)
|
||||
expect(listenerCount(guest, 'frame-created')).toBe(0)
|
||||
expect(listenerCount(guest, 'did-create-window')).toBe(0)
|
||||
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('holds a workspace-document guest inside the grant it is showing', () => {
|
||||
const { guest, grantId } = attachPreviewGuest()
|
||||
|
||||
expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false)
|
||||
expect(navigateTo(guest, 'https://example.com/')).toBe(true)
|
||||
expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp')
|
||||
})
|
||||
|
||||
it('denies every window a workspace-document guest asks for', () => {
|
||||
const { guest } = attachPreviewGuest()
|
||||
|
||||
expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({
|
||||
action: 'deny'
|
||||
})
|
||||
})
|
||||
|
||||
// Why teardown and not just install: registration and teardown both key on the guest having been
|
||||
// policy-attached, so a profile that installs outside that bookkeeping leaves the id marked
|
||||
// attached forever and stays answerable to tools after the surface is gone.
|
||||
it('tears a workspace-document guest down through the same policy cleanup', () => {
|
||||
const { guest, browserPageId } = attachPreviewGuest(302)
|
||||
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never)
|
||||
|
||||
for (const listener of guest.listeners.get('destroyed') ?? []) {
|
||||
listener()
|
||||
}
|
||||
|
||||
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull()
|
||||
// The manager's own bookkeeping was reached too: a second attach is refused while the id is
|
||||
// still marked policy-attached, and accepted once its teardown has run.
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(1)
|
||||
})
|
||||
|
||||
// The seam the whole split rests on: one public door answers for both halves, and each page id
|
||||
// resolves in exactly one of them. Asserted against the real manager, because the IPC census
|
||||
// test has to mock it.
|
||||
describe('the one door across both halves of the registry', () => {
|
||||
const BROWSING_PAGE_ID = 'browser-page-1'
|
||||
|
||||
function registerBrowsingGuest(id = 400): GuestFake {
|
||||
const guest = createGuest(id, 'https://example.com/')
|
||||
browserMocks.webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: BROWSING_PAGE_ID,
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
webContentsId: id,
|
||||
rendererWebContentsId: host.id
|
||||
})
|
||||
return guest
|
||||
}
|
||||
|
||||
it('answers each page with the guest of its own half', () => {
|
||||
const browsing = registerBrowsingGuest()
|
||||
const { guest: document, browserPageId } = attachPreviewGuest()
|
||||
|
||||
expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never)
|
||||
expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never)
|
||||
})
|
||||
|
||||
// Why this is the containment claim and not a lookup detail: page management, agent commands,
|
||||
// download routing and certificate attribution all read the browsing map directly, so a
|
||||
// document page being absent from it is what fences them without a guard of their own.
|
||||
it('keeps a document page out of the browsing map entirely', () => {
|
||||
registerBrowsingGuest()
|
||||
const { browserPageId } = attachPreviewGuest()
|
||||
|
||||
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
|
||||
expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID])
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches a guest once whatever profile it was asked for', () => {
|
||||
const { guest } = attachPreviewGuest(303)
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
|
||||
expect(listenerCount(guest, 'dom-ready')).toBe(0)
|
||||
expect(listenerCount(guest, 'will-navigate')).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -882,5 +882,34 @@ describe('browserManager', () => {
|
||||
expect(debuggerAttach).toHaveBeenCalledWith('1.3')
|
||||
expect(debuggerSendCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installs annotation viewport bridge in an isolated world', async () => {
|
||||
const { guest } = makeGuest(4646)
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-annotations',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', {
|
||||
emitViewport: false,
|
||||
enabled: true,
|
||||
markers: [],
|
||||
token: 'annotationviewporttoken'
|
||||
})
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
[
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
|
||||
})
|
||||
],
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,11 +63,6 @@ import {
|
||||
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
|
||||
buildBrowserAnnotationViewportBridgeScript
|
||||
} from '../../shared/browser-annotation-viewport-bridge'
|
||||
import {
|
||||
getWorkspaceDocPageGuest,
|
||||
installDocPreviewGuestPolicy,
|
||||
isWorkspaceDocPageId
|
||||
} from './doc-preview-guest-policy'
|
||||
import type { KeybindingOverrides } from '../../shared/keybindings'
|
||||
import {
|
||||
BrowserCertificateTrustController,
|
||||
@@ -162,15 +157,6 @@ type PopupOwnerContext = {
|
||||
browserTabId: string
|
||||
rootGuestWebContentsId: number
|
||||
}
|
||||
/**
|
||||
* What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and
|
||||
* anti-detection all apply. A workspace-document guest renders one granted document and gets none
|
||||
* of that; `host` is the renderer that minted its grant, and the only sink for what it reports.
|
||||
*/
|
||||
export type BrowserGuestPolicy =
|
||||
| { profile: 'browsing' }
|
||||
| { profile: 'workspace-doc'; host: Electron.WebContents }
|
||||
const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' }
|
||||
type PendingMainFrameNavigation = {
|
||||
currentUrl: string
|
||||
supersededUrls: string[]
|
||||
@@ -685,20 +671,12 @@ export class BrowserManager {
|
||||
|
||||
attachGuestPolicies(
|
||||
guest: Electron.WebContents,
|
||||
inheritedOwnerContext: PopupOwnerContext | null = null,
|
||||
policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY
|
||||
inheritedOwnerContext: PopupOwnerContext | null = null
|
||||
): void {
|
||||
if (this.policyAttachedGuestIds.has(guest.id)) {
|
||||
return
|
||||
}
|
||||
this.policyAttachedGuestIds.add(guest.id)
|
||||
// Why one door with a profile rather than a second installer beside it: whether a guest was
|
||||
// policy-attached at all is what registration and teardown both key on, so a guest that took
|
||||
// another path into the app is invisible to both.
|
||||
if (policy.profile === 'workspace-doc') {
|
||||
this.attachWorkspaceDocGuestPolicies(guest, policy.host)
|
||||
return
|
||||
}
|
||||
if (inheritedOwnerContext) {
|
||||
this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext)
|
||||
}
|
||||
@@ -1038,30 +1016,6 @@ export class BrowserManager {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace document is not the web: no popups, no link routing, no anti-detection, and no
|
||||
* navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is
|
||||
* this method's teardown, so a retired preview drops its listeners on the same path.
|
||||
*/
|
||||
private attachWorkspaceDocGuestPolicies(
|
||||
guest: Electron.WebContents,
|
||||
host: Electron.WebContents
|
||||
): void {
|
||||
const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host)
|
||||
const handleDestroyed = (): void => {
|
||||
this.cleanupGuestPolicyAttachment(guest.id)
|
||||
}
|
||||
guest.on('destroyed', handleDestroyed)
|
||||
this.policyCleanupByGuestId.set(guest.id, () => {
|
||||
disposeDocPolicy()
|
||||
try {
|
||||
guest.off('destroyed', handleDestroyed)
|
||||
} catch {
|
||||
// guest may already be destroyed
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
|
||||
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
|
||||
// must be matched here per navigation or the two layers disagree — itself a bot tell.
|
||||
@@ -1364,9 +1318,7 @@ export class BrowserManager {
|
||||
rendererWebContentsId
|
||||
}: BrowserGuestRegistration): boolean {
|
||||
const browserTabId = browserPageId ?? legacyBrowserTabId
|
||||
// Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one
|
||||
// id resolves in both and the tool door silently prefers the document guest over the page.
|
||||
if (!browserTabId || isWorkspaceDocPageId(browserTabId)) {
|
||||
if (!browserTabId) {
|
||||
return false
|
||||
}
|
||||
// Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents.
|
||||
@@ -1425,12 +1377,6 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
unregisterGuest(browserTabId: string): void {
|
||||
// Why the check on the exit door too: a document page withdraws by revoking its grant, never
|
||||
// through here, so its id arriving is misaddressed — and the cancel below would evict that
|
||||
// preview's live grab on the strength of it.
|
||||
if (isWorkspaceDocPageId(browserTabId)) {
|
||||
return
|
||||
}
|
||||
// Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise.
|
||||
this.cancelGrabOp(browserTabId, 'evicted')
|
||||
|
||||
@@ -1498,15 +1444,10 @@ export class BrowserManager {
|
||||
sessionProfileId?: string | null
|
||||
userAgentMode?: BrowserSessionUserAgentMode
|
||||
webContentsId: number
|
||||
}): boolean {
|
||||
// Why the same check on both registration doors: one id resolving in both halves is the exact
|
||||
// confusion the split registries exist to prevent.
|
||||
if (isWorkspaceDocPageId(browserPageId)) {
|
||||
return false
|
||||
}
|
||||
}): void {
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
return false
|
||||
return
|
||||
}
|
||||
// Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
|
||||
this.offscreenGuestIds.add(webContentsId)
|
||||
@@ -1527,7 +1468,6 @@ export class BrowserManager {
|
||||
this.worktreeIdByTabId.set(browserPageId, worktreeId)
|
||||
}
|
||||
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
|
||||
return true
|
||||
}
|
||||
|
||||
unregisterAll(): void {
|
||||
@@ -1903,13 +1843,12 @@ export class BrowserManager {
|
||||
|
||||
async setAnnotationViewportBridge(
|
||||
browserTabId: string,
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
options: BrowserAnnotationViewportBridgeOptions
|
||||
): Promise<boolean> {
|
||||
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.catch(() => {})
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
|
||||
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
|
||||
try {
|
||||
return await next
|
||||
@@ -1920,22 +1859,18 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Why the caller resolves the guest: the same bridge serves browsing pages and workspace
|
||||
// documents, which live in different halves of the page registry.
|
||||
// Why a resolver and not the guest itself: this op may have waited behind another one, and a
|
||||
// cross-process navigation meanwhile swaps the tab's contents without destroying the old one —
|
||||
// injecting into the guest the request named would bridge a page nobody is looking at.
|
||||
// Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and
|
||||
// taking an id it cannot act on would invite the next reader to act on it.
|
||||
private async doSetAnnotationViewportBridgeImpl(
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
browserTabId: string,
|
||||
options: BrowserAnnotationViewportBridgeOptions
|
||||
): Promise<boolean> {
|
||||
// Why no teardown here: the resolver already unregisters a page whose guest died, and the only
|
||||
// case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would
|
||||
// cancel that page's in-flight downloads and grabs over a request that was merely misaddressed.
|
||||
const guest = resolveGuest()
|
||||
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (!webContentsId) {
|
||||
return false
|
||||
}
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
|
||||
this.unregisterGuest(browserTabId)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -2042,21 +1977,10 @@ export class BrowserManager {
|
||||
// --- Browser Context Grab — main-owned operations ---
|
||||
|
||||
/** Validate that the sender owns browserTabId; returns the guest WebContents or null. */
|
||||
/**
|
||||
* The guest a request from `senderWebContentsId` may act on, across both halves of the page
|
||||
* registry. This is the only door taught about workspace-document guests: they are kept out of
|
||||
* the browsing maps entirely, so page management, agent commands, download routing and
|
||||
* certificate attribution all miss them without a guard of their own — and a reader who opens a
|
||||
* tool on the document in front of them still gets an answer.
|
||||
*/
|
||||
getAuthorizedGuest(
|
||||
browserTabId: string,
|
||||
senderWebContentsId: number
|
||||
): Electron.WebContents | null {
|
||||
const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId)
|
||||
if (docGuest) {
|
||||
return docGuest
|
||||
}
|
||||
const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId)
|
||||
if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) {
|
||||
return null
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserSessionProfile } from '../../shared/browser-workspace-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handleGuestWillDownload: vi.fn(),
|
||||
noticeDocPreviewDownloadBlocked: vi.fn()
|
||||
}))
|
||||
|
||||
type WillDownloadListener = (
|
||||
event: { preventDefault: () => void },
|
||||
item: { id: string },
|
||||
webContents: { id: number }
|
||||
) => void
|
||||
|
||||
type FakeSession = {
|
||||
listeners: WillDownloadListener[]
|
||||
on: ReturnType<typeof vi.fn>
|
||||
removeListener: ReturnType<typeof vi.fn>
|
||||
getUserAgent: () => string
|
||||
setUserAgent: ReturnType<typeof vi.fn>
|
||||
setPermissionRequestHandler: ReturnType<typeof vi.fn>
|
||||
setPermissionCheckHandler: ReturnType<typeof vi.fn>
|
||||
setDisplayMediaRequestHandler: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const sessionsByPartition = new Map<string, FakeSession>()
|
||||
|
||||
function fakeSession(): FakeSession {
|
||||
const listeners: WillDownloadListener[] = []
|
||||
return {
|
||||
listeners,
|
||||
on: vi.fn((event: string, listener: WillDownloadListener) => {
|
||||
if (event === 'will-download') {
|
||||
listeners.push(listener)
|
||||
}
|
||||
}),
|
||||
removeListener: vi.fn((event: string, listener: WillDownloadListener) => {
|
||||
if (event !== 'will-download') {
|
||||
return
|
||||
}
|
||||
const index = listeners.indexOf(listener)
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}),
|
||||
getUserAgent: () => 'Mozilla/5.0 Orca',
|
||||
setUserAgent: vi.fn(),
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setDisplayMediaRequestHandler: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
session: {
|
||||
fromPartition: (partition: string) => {
|
||||
const existing = sessionsByPartition.get(partition)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created = fakeSession()
|
||||
sessionsByPartition.set(partition, created)
|
||||
return created
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./browser-manager', () => ({
|
||||
browserManager: {
|
||||
handleGuestWillDownload: mocks.handleGuestWillDownload,
|
||||
installCertificateRequestGuard: vi.fn(),
|
||||
removeCertificateRequestGuard: vi.fn(),
|
||||
notifyPermissionDenied: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('./doc-preview-download-block-notice', () => ({
|
||||
noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked
|
||||
}))
|
||||
vi.mock('./browser-media-access', () => ({
|
||||
hasSystemMediaAccess: () => false,
|
||||
requestSystemMediaAccess: async () => false
|
||||
}))
|
||||
vi.mock('./browser-session-ua', () => ({
|
||||
cleanElectronUserAgent: (userAgent: string) => userAgent,
|
||||
setupClientHintsOverride: vi.fn()
|
||||
}))
|
||||
vi.mock('./browser-session-user-agent-mode', () => ({
|
||||
setBrowserSessionUserAgentMode: vi.fn()
|
||||
}))
|
||||
vi.mock('./browser-webauthn-access', () => ({
|
||||
allowsBrowserWebAuthnPermission: () => false,
|
||||
clearBrowserWebAuthnAccessHandlers: vi.fn(),
|
||||
installBrowserWebAuthnAccessHandlers: vi.fn()
|
||||
}))
|
||||
|
||||
type PartitionPolicyInstaller = (
|
||||
profile: BrowserSessionProfile,
|
||||
options?: { downloads?: 'route' | 'deny' }
|
||||
) => void
|
||||
|
||||
// Why imported per test rather than at the top: the installer remembers which partitions it has
|
||||
// already configured in module state, so a shared import would make the second test's install a
|
||||
// no-op and leave it reading the first test's listener.
|
||||
async function loadInstaller(): Promise<PartitionPolicyInstaller> {
|
||||
const module = await import('./browser-session-partition-policies')
|
||||
return module.installBrowserSessionPartitionPolicies
|
||||
}
|
||||
|
||||
function profileFor(partition: string): BrowserSessionProfile {
|
||||
return {
|
||||
id: partition,
|
||||
scope: 'isolated',
|
||||
partition,
|
||||
label: partition,
|
||||
source: null,
|
||||
userAgentMode: 'clean'
|
||||
}
|
||||
}
|
||||
|
||||
/** Fires the partition's real `will-download` listener and reports what it decided. */
|
||||
function fireWillDownload(partition: string): { cancelled: boolean } {
|
||||
const sess = sessionsByPartition.get(partition)
|
||||
if (!sess || sess.listeners.length !== 1) {
|
||||
throw new Error(`expected exactly one will-download listener on ${partition}`)
|
||||
}
|
||||
let cancelled = false
|
||||
sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 })
|
||||
return { cancelled }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
sessionsByPartition.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('partition download policy', () => {
|
||||
// The presence half: without it, a deny assertion passes for a partition that installed no
|
||||
// listener at all, and would keep passing if the whole download path were removed.
|
||||
it('routes a download on a partition that did not ask for the deny', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
|
||||
expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guestWebContentsId: 42 })
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels a download on a partition that asked for the deny, routing nothing', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
|
||||
|
||||
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
|
||||
expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why in the same run as the routing test above: a refusal the reader cannot see is a pressed
|
||||
// button that does nothing, and a notice on the routing partition would announce a download that
|
||||
// is about to arrive normally.
|
||||
it('tells the reader about the refusal, and only on the partition that refused', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
fireWillDownload('persist:browsing-1')
|
||||
expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled()
|
||||
|
||||
fireWillDownload('orca-doc-preview')
|
||||
expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 42 })
|
||||
)
|
||||
})
|
||||
|
||||
// Why both partitions in one run: the listener is module state shared across sessions, so a deny
|
||||
// installed for one partition must not follow the next partition that installs after it.
|
||||
it('keeps each partition on its own decision', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
|
||||
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
|
||||
expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
clearBrowserWebAuthnAccessHandlers,
|
||||
installBrowserWebAuthnAccessHandlers
|
||||
} from './browser-webauthn-access'
|
||||
import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice'
|
||||
|
||||
// Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart.
|
||||
const configuredPartitions = new Set<string>()
|
||||
@@ -23,23 +22,6 @@ const handleWillDownload = (
|
||||
browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item })
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a second listener instead of a branch inside the shared one: `will-download` is a session
|
||||
* event that names no partition, so the only place the decision can be keyed by partition is which
|
||||
* listener that partition's session got. A workspace-document guest has no page of its own to
|
||||
* attribute a download to, so routing one lands it in this desktop's Downloads folder under a
|
||||
* remote-authored name that nothing in the UI accounts for.
|
||||
*/
|
||||
const handleDeniedWillDownload = (
|
||||
event: Electron.Event,
|
||||
_item: Electron.DownloadItem,
|
||||
webContents: Electron.WebContents
|
||||
): void => {
|
||||
event.preventDefault()
|
||||
// The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing.
|
||||
noticeDocPreviewDownloadBlocked(webContents)
|
||||
}
|
||||
|
||||
function resolvePermissionNoticeUrl(
|
||||
webContents: Electron.WebContents,
|
||||
details: Electron.PermissionRequest | undefined
|
||||
@@ -55,13 +37,7 @@ function resolvePermissionNoticeUrl(
|
||||
}
|
||||
}
|
||||
|
||||
/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */
|
||||
export type BrowserPartitionDownloadPolicy = 'route' | 'deny'
|
||||
|
||||
export function installBrowserSessionPartitionPolicies(
|
||||
profile: BrowserSessionProfile,
|
||||
options?: { downloads?: BrowserPartitionDownloadPolicy }
|
||||
): void {
|
||||
export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void {
|
||||
const { partition } = profile
|
||||
const sess = session.fromPartition(partition)
|
||||
setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean')
|
||||
@@ -130,11 +106,7 @@ export function installBrowserSessionPartitionPolicies(
|
||||
callback({ video: undefined, audio: undefined })
|
||||
})
|
||||
sess.removeListener('will-download', handleWillDownload)
|
||||
sess.removeListener('will-download', handleDeniedWillDownload)
|
||||
sess.on(
|
||||
'will-download',
|
||||
options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload
|
||||
)
|
||||
sess.on('will-download', handleWillDownload)
|
||||
configuredPartitions.add(partition)
|
||||
}
|
||||
|
||||
@@ -143,7 +115,6 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se
|
||||
configuredPartitions.delete(partition)
|
||||
browserManager.removeCertificateRequestGuard(sess)
|
||||
sess.removeListener('will-download', handleWillDownload)
|
||||
sess.removeListener('will-download', handleDeniedWillDownload)
|
||||
clearBrowserWebAuthnAccessHandlers(sess)
|
||||
sess.setPermissionRequestHandler(null)
|
||||
sess.setPermissionCheckHandler(null)
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
publishDocPreviewFailure: vi.fn(),
|
||||
boundGrantIdByGuest: new Map<object, string>()
|
||||
}))
|
||||
|
||||
vi.mock('./doc-preview-failure-notice', () => ({
|
||||
publishDocPreviewFailure: mocks.publishDocPreviewFailure
|
||||
}))
|
||||
vi.mock('./doc-preview-guest-policy', () => ({
|
||||
readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null
|
||||
}))
|
||||
|
||||
const GRANT_ID = 'a'.repeat(32)
|
||||
const OTHER_GRANT_ID = 'b'.repeat(32)
|
||||
|
||||
/** Only the identity matters: the module asks the guest registry what grant this contents holds. */
|
||||
function guestBoundTo(grantId: string | null): Electron.WebContents {
|
||||
const guest = {} as Electron.WebContents
|
||||
if (grantId !== null) {
|
||||
mocks.boundGrantIdByGuest.set(guest, grantId)
|
||||
}
|
||||
return guest
|
||||
}
|
||||
|
||||
async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> {
|
||||
const module = await import('./doc-preview-download-block-notice')
|
||||
return module.noticeDocPreviewDownloadBlocked
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.boundGrantIdByGuest.clear()
|
||||
// Why per test: the module remembers which grants it has already told the reader about.
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('noticeDocPreviewDownloadBlocked', () => {
|
||||
it('tells the shell which preview had a download refused', async () => {
|
||||
const notice = await loadNotifier()
|
||||
|
||||
notice(guestBoundTo(GRANT_ID))
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({
|
||||
grantId: GRANT_ID,
|
||||
reason: 'download-blocked'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: a document can ask in a loop. The reader learns nothing from the second notice, and every
|
||||
// attempt would otherwise cross the IPC boundary and re-render the strip.
|
||||
it('says it once however often the document asks', async () => {
|
||||
const notice = await loadNotifier()
|
||||
const guest = guestBoundTo(GRANT_ID)
|
||||
|
||||
notice(guest)
|
||||
notice(guest)
|
||||
vi.advanceTimersByTime(1_500)
|
||||
notice(guest)
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('says it again for an attempt long after the last one', async () => {
|
||||
const notice = await loadNotifier()
|
||||
const guest = guestBoundTo(GRANT_ID)
|
||||
|
||||
notice(guest)
|
||||
vi.advanceTimersByTime(2_500)
|
||||
notice(guest)
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
// Why not one throttle for the whole app: two previews are two readers, and silencing the second
|
||||
// because the first just refused something leaves that press unexplained.
|
||||
it('throttles each preview on its own', async () => {
|
||||
const notice = await loadNotifier()
|
||||
|
||||
notice(guestBoundTo(GRANT_ID))
|
||||
notice(guestBoundTo(OTHER_GRANT_ID))
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({
|
||||
grantId: OTHER_GRANT_ID,
|
||||
reason: 'download-blocked'
|
||||
})
|
||||
})
|
||||
|
||||
// The absence half of the first test: no shell is showing this contents, so there is no preview
|
||||
// to put a notice on. Without the presence tests above, this would pass on a module that never
|
||||
// published anything at all.
|
||||
it('says nothing for a contents no preview is bound to', async () => {
|
||||
const notice = await loadNotifier()
|
||||
|
||||
notice(guestBoundTo(null))
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
|
||||
import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy'
|
||||
|
||||
/**
|
||||
* Why the reader is told at all, when the point of the fence is that nothing happens: a press that
|
||||
* produces no file and no explanation reads as Orca being broken. The notice is chrome the document
|
||||
* cannot see or read back, so the refusal stays as silent to the page as it was.
|
||||
*
|
||||
* Why a floor between notices: a document can ask in a loop, and every attempt would otherwise
|
||||
* cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth.
|
||||
*/
|
||||
const NOTICE_MIN_INTERVAL_MS = 2_000
|
||||
const noticedAtByGrantId = new Map<string, number>()
|
||||
|
||||
export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void {
|
||||
const grantId = readDocPreviewGuestBoundGrantId(guest)
|
||||
// Nothing to route a notice to: no shell is showing this contents as a preview.
|
||||
if (grantId === null) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
// Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a
|
||||
// grant that stops downloading stops being remembered.
|
||||
for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) {
|
||||
if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) {
|
||||
noticedAtByGrantId.delete(noticedGrantId)
|
||||
}
|
||||
}
|
||||
if (noticedAtByGrantId.has(grantId)) {
|
||||
return
|
||||
}
|
||||
noticedAtByGrantId.set(grantId, now)
|
||||
publishDocPreviewFailure({ grantId, reason: 'download-blocked' })
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DOC_PREVIEW_LOAD_FAILURE_CHANNEL } from '../../shared/doc-preview-scheme'
|
||||
import { publishDocPreviewFailure, setDocPreviewFailureSink } from './doc-preview-failure-notice'
|
||||
|
||||
afterEach(() => {
|
||||
setDocPreviewFailureSink(null)
|
||||
})
|
||||
|
||||
describe('publishDocPreviewFailure', () => {
|
||||
it('sends the grant, path, and reason on the failure channel', () => {
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send })
|
||||
|
||||
publishDocPreviewFailure({
|
||||
grantId: 'a'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'too-large'
|
||||
})
|
||||
|
||||
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
|
||||
grantId: 'a'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'too-large'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: reads can outlive the window that asked for them; a missing sink must not throw inside
|
||||
// the protocol handler.
|
||||
it('is a no-op with no sink registered', () => {
|
||||
expect(() =>
|
||||
publishDocPreviewFailure({
|
||||
grantId: 'b'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('sends nothing into a destroyed window', () => {
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send, isDestroyed: () => true })
|
||||
|
||||
publishDocPreviewFailure({
|
||||
grantId: 'c'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable'
|
||||
})
|
||||
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: a WebContents can be torn down between the liveness check and the send.
|
||||
it('swallows a throwing sink and stops using it', () => {
|
||||
const send = vi.fn(() => {
|
||||
throw new Error('Object has been destroyed')
|
||||
})
|
||||
setDocPreviewFailureSink({ send })
|
||||
const failure = {
|
||||
grantId: 'd'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable' as const
|
||||
}
|
||||
|
||||
expect(() => publishDocPreviewFailure(failure)).not.toThrow()
|
||||
publishDocPreviewFailure(failure)
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
DOC_PREVIEW_LOAD_FAILURE_CHANNEL,
|
||||
type DocPreviewFailure
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
|
||||
type DocPreviewFailureSink = {
|
||||
send: (channel: string, payload: DocPreviewFailure) => void
|
||||
isDestroyed?: () => boolean
|
||||
}
|
||||
|
||||
let failureSink: DocPreviewFailureSink | null = null
|
||||
|
||||
export function setDocPreviewFailureSink(sink: DocPreviewFailureSink | null): void {
|
||||
failureSink = sink
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview shell cannot read the guest's HTTP status, and a 4xx body renders as
|
||||
* if it were the document. Pushing the reason lets the shell replace that with a
|
||||
* localized notice for the failure the user actually hit.
|
||||
*/
|
||||
export function publishDocPreviewFailure(failure: DocPreviewFailure): void {
|
||||
const sink = failureSink
|
||||
// Why: a read can outlive the window that asked for it, and sending into torn-down
|
||||
// WebContents throws — an unreadable asset must not take the protocol handler with it.
|
||||
if (!sink || sink.isDestroyed?.()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
sink.send(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, failure)
|
||||
} catch {
|
||||
failureSink = null
|
||||
}
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeEnvironment: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
requireSshFilesystemProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: mocks.callRuntimeEnvironment
|
||||
}))
|
||||
vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' }))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
requireSshFilesystemProvider: mocks.requireSshFilesystemProvider
|
||||
}))
|
||||
|
||||
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
|
||||
import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader'
|
||||
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
|
||||
|
||||
function sshGrant(): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
owner: {
|
||||
kind: 'runtime',
|
||||
environmentId: 'env-1',
|
||||
worktreeSelector: 'id:wt-1',
|
||||
worktreeRoot: '/srv/repo'
|
||||
},
|
||||
root,
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
// Why: an unsymlinked host canonicalizes to the path it was given.
|
||||
mocks.realpath.mockImplementation((path: string) => Promise.resolve(path))
|
||||
mocks.requireSshFilesystemProvider.mockReturnValue({
|
||||
readFile: mocks.readFile,
|
||||
realpath: mocks.realpath
|
||||
})
|
||||
})
|
||||
|
||||
describe('docPreviewContentType', () => {
|
||||
it('maps document and asset extensions, defaulting to octet-stream', () => {
|
||||
expect(docPreviewContentType('index.html')).toBe('text/html; charset=utf-8')
|
||||
expect(docPreviewContentType('assets/app.CSS')).toBe('text/css; charset=utf-8')
|
||||
expect(docPreviewContentType('assets/logo.png')).toBe('image/png')
|
||||
expect(docPreviewContentType('data.bin')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readDocPreviewFile — ssh owner', () => {
|
||||
it('reads text through the SSH filesystem provider', async () => {
|
||||
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'index.html')
|
||||
|
||||
expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1')
|
||||
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/index.html')
|
||||
expect(outcome).toEqual({
|
||||
ok: true,
|
||||
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes a base64 binary asset', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
|
||||
mocks.readFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
|
||||
|
||||
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
|
||||
})
|
||||
|
||||
// Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is
|
||||
// always whole and needs no truncation flag.
|
||||
it('serves a whole SSH read that carries no truncation flag', async () => {
|
||||
mocks.readFile.mockResolvedValue({ content: '<h1>whole</h1>', isBinary: false })
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
// Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by
|
||||
// design — the failure must name the file type, not a stale server.
|
||||
it('reports a file type the host will not send as unsupported-asset', async () => {
|
||||
mocks.readFile.mockResolvedValue({ content: '', isBinary: true })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' })
|
||||
})
|
||||
|
||||
// Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer —
|
||||
// reporting it as a refused format would be a failure the workspace never reported.
|
||||
it('serves an empty file the host still typed instead of calling it unsupported', async () => {
|
||||
mocks.readFile.mockResolvedValue({ content: '', isBinary: true, mimeType: 'image/png' })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
|
||||
|
||||
expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' })
|
||||
})
|
||||
|
||||
// Why: containment above is lexical, and the SSH read RPC enforces no root of its own, so a
|
||||
// symlink inside the grant would otherwise read anything the account can reach.
|
||||
it('404s a path that canonicalizes outside the grant root', async () => {
|
||||
mocks.realpath.mockImplementation((path: string) =>
|
||||
Promise.resolve(path === '/home/alice/docs/escape.html' ? '/etc/shadow' : path)
|
||||
)
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'escape.html')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 404 })
|
||||
expect(mocks.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads the canonical path once containment holds', async () => {
|
||||
mocks.realpath.mockImplementation((path: string) =>
|
||||
Promise.resolve(path === '/home/alice/docs/link.html' ? '/home/alice/docs/real.html' : path)
|
||||
)
|
||||
mocks.readFile.mockResolvedValue({ content: '<h1>real</h1>', isBinary: false })
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'link.html')).toMatchObject({ ok: true })
|
||||
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/real.html')
|
||||
})
|
||||
|
||||
// Why: a symlinked root is legitimate; containment must be judged on what both sides resolve to.
|
||||
it('keeps serving a grant whose own root is a symlink', async () => {
|
||||
mocks.realpath.mockImplementation((path: string) =>
|
||||
Promise.resolve(path.replace('/home/alice/docs', '/mnt/data/docs'))
|
||||
)
|
||||
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
|
||||
expect(mocks.readFile).toHaveBeenCalledWith('/mnt/data/docs/index.html')
|
||||
})
|
||||
|
||||
it('404s when the host cannot canonicalize the path at all', async () => {
|
||||
mocks.realpath.mockRejectedValue(new Error('no such file'))
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
expect(mocks.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s a path outside the grant root without touching the provider', async () => {
|
||||
const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 404 })
|
||||
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an over-cap SSH file as too large rather than unreadable', async () => {
|
||||
mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap'))
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 413
|
||||
})
|
||||
})
|
||||
|
||||
it('404s when the provider read fails', async () => {
|
||||
mocks.readFile.mockRejectedValue(new Error('no such file'))
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('readDocPreviewFile — paired runtime owner', () => {
|
||||
it('reads text over worktree-relative files.read', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { content: '<h1>remote</h1>', truncated: false, byteLength: 15 }
|
||||
})
|
||||
|
||||
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
|
||||
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
'/user-data',
|
||||
'env-1',
|
||||
'files.read',
|
||||
{ worktree: 'id:wt-1', relativePath: 'docs/index.html' },
|
||||
15_000
|
||||
)
|
||||
expect(outcome).toEqual({
|
||||
ok: true,
|
||||
bytes: Buffer.from('<h1>remote</h1>', 'utf8'),
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the base64 preview RPC for a binary asset', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
content: png.toString('base64'),
|
||||
isBinary: true,
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
}
|
||||
})
|
||||
|
||||
const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')
|
||||
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/user-data',
|
||||
'env-1',
|
||||
'files.readPreview',
|
||||
{ worktree: 'id:wt-1', relativePath: 'docs/assets/logo.png' },
|
||||
15_000
|
||||
)
|
||||
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
|
||||
})
|
||||
|
||||
it('degrades on an old server whose empty binary preview carries no metadata', async () => {
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' }
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, result: { content: '', isBinary: true } })
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({
|
||||
ok: false,
|
||||
status: 415,
|
||||
reason: 'unsupported-asset'
|
||||
})
|
||||
})
|
||||
|
||||
it('serves an empty paired asset the host typed rather than reporting a refusal', async () => {
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }
|
||||
})
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({
|
||||
ok: true,
|
||||
bytes: Buffer.alloc(0),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: files.read clamps text at the host cap and only says so in `truncated`; serving the
|
||||
// clamped bytes renders a document that silently stops halfway.
|
||||
it('refuses a truncated text read instead of serving the clamped bytes', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { content: '<h1>half of', truncated: true, byteLength: 40_000_000 }
|
||||
})
|
||||
|
||||
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 413 })
|
||||
expect(outcome).not.toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('serves a read the host reports as complete', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { content: '<h1>all</h1>', truncated: false, byteLength: 12 }
|
||||
})
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
// Why: the binary RPC has no `truncated` field — it rejects an over-cap asset with this error.
|
||||
it('reports the host rejecting an over-cap binary as too large', async () => {
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'file_too_large' }
|
||||
})
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'assets/huge.png')).toMatchObject({
|
||||
ok: false,
|
||||
status: 413
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat an unrelated RPC failure as a binary fallback', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'permission_denied' }
|
||||
})
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('404s a document outside the worktree, which files.read cannot address', async () => {
|
||||
const outcome = await readDocPreviewFile(runtimeGrant('/tmp/agent-docs'), 'index.html')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 404 })
|
||||
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,198 +0,0 @@
|
||||
import { extname } from 'node:path'
|
||||
import type {
|
||||
RuntimeFilePreviewResult,
|
||||
RuntimeFileReadResult
|
||||
} from '../../shared/runtime-file-contracts'
|
||||
import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
|
||||
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
|
||||
import { getCanonicalUserDataPath } from '../persistence'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
resolveCanonicalDocPreviewPath,
|
||||
resolveDocPreviewTargetPath,
|
||||
toRuntimeWorktreeRelativePath,
|
||||
type DocPreviewGrant
|
||||
} from './doc-preview-grant-registry'
|
||||
|
||||
const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000
|
||||
|
||||
/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so
|
||||
* a font is refused there by design, not by version. Name the file type, not the host's age. */
|
||||
const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.'
|
||||
|
||||
/** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would
|
||||
* render a silently half-finished document. */
|
||||
const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to send in full.'
|
||||
|
||||
/** The paired host rejects an over-cap asset outright instead of clamping it. */
|
||||
const RUNTIME_TOO_LARGE_ERROR = 'file_too_large'
|
||||
|
||||
/** Both owners refuse an over-cap file; only their error shapes differ. */
|
||||
function isTooLargeReadError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof FileReadCapExceededError ||
|
||||
(error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR)
|
||||
)
|
||||
}
|
||||
|
||||
export type DocPreviewReadOutcome =
|
||||
| { ok: true; bytes: Buffer; contentType: string }
|
||||
| { ok: false; status: number; reason: DocPreviewFileFailureReason; message: string }
|
||||
|
||||
const DOC_PREVIEW_CONTENT_TYPES: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.htm': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.md': 'text/plain; charset=utf-8',
|
||||
'.csv': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.pdf': 'application/pdf',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.otf': 'font/otf'
|
||||
}
|
||||
|
||||
export function docPreviewContentType(relativePath: string): string {
|
||||
return (
|
||||
DOC_PREVIEW_CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? 'application/octet-stream'
|
||||
)
|
||||
}
|
||||
|
||||
type PreviewFileBytes = {
|
||||
content: string
|
||||
isBinary: boolean
|
||||
truncated?: boolean
|
||||
/** Set by every owner that agreed to serve the bytes, so it also survives a 0-byte file. */
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
function toOutcome(source: PreviewFileBytes, contentType: string): DocPreviewReadOutcome {
|
||||
if (source.truncated) {
|
||||
return { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
|
||||
}
|
||||
if (!source.isBinary) {
|
||||
return { ok: true, bytes: Buffer.from(source.content, 'utf8'), contentType }
|
||||
}
|
||||
if (source.content) {
|
||||
return { ok: true, bytes: Buffer.from(source.content, 'base64'), contentType }
|
||||
}
|
||||
// Why: an empty binary body is two different answers. A host that still named the file's type
|
||||
// read a 0-byte file, and 0 bytes is what it should serve; a host that named no type declined
|
||||
// the format outright and has nothing to send.
|
||||
return source.mimeType
|
||||
? { ok: true, bytes: Buffer.alloc(0), contentType }
|
||||
: {
|
||||
ok: false,
|
||||
status: 415,
|
||||
reason: 'unsupported-asset',
|
||||
message: UNSERVABLE_ASSET_PREVIEW_MESSAGE
|
||||
}
|
||||
}
|
||||
|
||||
async function readRuntimeDocPreviewFile(
|
||||
environmentId: string,
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<PreviewFileBytes> {
|
||||
const userDataPath = getCanonicalUserDataPath()
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'files.read',
|
||||
{ worktree: worktreeSelector, relativePath },
|
||||
DOC_PREVIEW_READ_TIMEOUT_MS
|
||||
)
|
||||
if (response.ok) {
|
||||
const result = response.result as RuntimeFileReadResult
|
||||
return { content: result.content, isBinary: false, truncated: result.truncated === true }
|
||||
}
|
||||
// Why: files.read rejects binaries with a typed error; the base64 preview RPC serves
|
||||
// images and fonts the same way it does for markdown previews. Match the exact
|
||||
// message so an unrelated failure can't spoof the fallback.
|
||||
if (response.error.message !== 'binary_file') {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const previewResponse = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'files.readPreview',
|
||||
{ worktree: worktreeSelector, relativePath },
|
||||
DOC_PREVIEW_READ_TIMEOUT_MS
|
||||
)
|
||||
if (!previewResponse.ok) {
|
||||
throw new Error(previewResponse.error.message)
|
||||
}
|
||||
const preview = previewResponse.result as RuntimeFilePreviewResult
|
||||
// Why: readPreview never clamps — it rejects an over-cap asset — so its body is whole or absent.
|
||||
return {
|
||||
content: preview.content,
|
||||
isBinary: preview.isBinary,
|
||||
...(preview.mimeType ? { mimeType: preview.mimeType } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function notFoundOutcome(message = 'Not found'): DocPreviewReadOutcome {
|
||||
return { ok: false, status: 404, reason: 'unreadable', message }
|
||||
}
|
||||
|
||||
/** Reads one in-grant path over the same channel the editor uses for that owner. */
|
||||
export async function readDocPreviewFile(
|
||||
grant: DocPreviewGrant,
|
||||
relativePath: string
|
||||
): Promise<DocPreviewReadOutcome> {
|
||||
const absolutePath = resolveDocPreviewTargetPath(grant, relativePath)
|
||||
if (!absolutePath) {
|
||||
return notFoundOutcome()
|
||||
}
|
||||
const contentType = docPreviewContentType(relativePath)
|
||||
try {
|
||||
if (grant.owner.kind === 'ssh') {
|
||||
const provider = requireSshFilesystemProvider(grant.owner.connectionId)
|
||||
// Why: the SSH read RPC enforces no root of its own, so containment has to survive a symlink
|
||||
// before the read — the lexical check above only proves the requested path looked contained.
|
||||
const canonicalPath = await resolveCanonicalDocPreviewPath(grant, absolutePath, (path) =>
|
||||
provider.realpath(path)
|
||||
)
|
||||
if (!canonicalPath) {
|
||||
return notFoundOutcome()
|
||||
}
|
||||
// Why: the SSH reader rejects an over-cap file outright, so its result is never partial.
|
||||
return toOutcome(await provider.readFile(canonicalPath), contentType)
|
||||
}
|
||||
const worktreeRelativePath = toRuntimeWorktreeRelativePath(
|
||||
grant.owner.worktreeRoot,
|
||||
absolutePath
|
||||
)
|
||||
if (!worktreeRelativePath) {
|
||||
// Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel.
|
||||
return notFoundOutcome()
|
||||
}
|
||||
// Why no realpath pass here: the host resolves this path through resolveAuthorizedPath, which
|
||||
// canonicalizes and re-checks the worktree root server-side before reading.
|
||||
return toOutcome(
|
||||
await readRuntimeDocPreviewFile(
|
||||
grant.owner.environmentId,
|
||||
grant.owner.worktreeSelector,
|
||||
worktreeRelativePath
|
||||
),
|
||||
contentType
|
||||
)
|
||||
} catch (error) {
|
||||
return isTooLargeReadError(error)
|
||||
? { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
|
||||
: notFoundOutcome(error instanceof Error ? error.message : undefined)
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getDocPreviewGrant,
|
||||
mintDocPreviewGrant,
|
||||
resolveCanonicalDocPreviewPath,
|
||||
resolveDocPreviewTargetPath,
|
||||
revokeAllDocPreviewGrants,
|
||||
revokeDocPreviewGrant,
|
||||
toRuntimeWorktreeRelativePath,
|
||||
type DocPreviewGrant
|
||||
} from './doc-preview-grant-registry'
|
||||
|
||||
const sshOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const
|
||||
|
||||
function mintPosixGrant(root = '/srv/repo/docs'): DocPreviewGrant {
|
||||
return mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
root,
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
describe('doc preview grants', () => {
|
||||
it('mints unguessable ids and looks them up', () => {
|
||||
const first = mintPosixGrant()
|
||||
const second = mintPosixGrant()
|
||||
|
||||
expect(first.id).toMatch(/^[0-9a-f]{32}$/)
|
||||
expect(first.id).not.toBe(second.id)
|
||||
expect(getDocPreviewGrant(first.id)).toBe(first)
|
||||
})
|
||||
|
||||
it('returns nothing for an unknown or revoked grant', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(getDocPreviewGrant('0'.repeat(32))).toBeNull()
|
||||
expect(revokeDocPreviewGrant(grant.id)).toBe(true)
|
||||
expect(getDocPreviewGrant(grant.id)).toBeNull()
|
||||
expect(revokeDocPreviewGrant(grant.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDocPreviewTargetPath', () => {
|
||||
it('resolves paths inside the grant root', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
|
||||
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe(
|
||||
'/srv/repo/docs/assets/logo.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses parent traversal, absolute escapes and empty paths', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, '')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses backslash and NUL segments that could re-split on the owning host', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is a traversal test and not a containment test: every request path that names a
|
||||
// sibling directory has to climb out of the root first, so the `..` segment guard answers it
|
||||
// before the prefix check runs. Sibling containment is exercised where it is reachable —
|
||||
// against a canonicalized path, below.
|
||||
it('refuses a sibling directory by refusing the traversal that reaches it', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a Windows drive root addressable instead of turning it drive-relative', () => {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
root: 'C:\\',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html')
|
||||
})
|
||||
|
||||
it('follows the owning host path flavor rather than this process platform', () => {
|
||||
const windowsGrant = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
root: 'C:\\srv\\repo\\docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe(
|
||||
'C:\\srv\\repo\\docs\\assets\\logo.png'
|
||||
)
|
||||
expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes a trailing separator on the root', () => {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
root: '/srv/repo/docs/',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
|
||||
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCanonicalDocPreviewPath', () => {
|
||||
// Why here and not above: a canonical path is the one input that can name a sibling directory
|
||||
// without traversing — the host resolved a symlink to it — so this is where the prefix check
|
||||
// is the only thing standing between the grant and `/srv/repo/docs-private`.
|
||||
it('refuses a canonical path in a sibling directory that shares the root prefix', async () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
await expect(
|
||||
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) =>
|
||||
path === grant.root ? path : '/srv/repo/docs-private/secret.html'
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('answers the canonical path when it stays inside the canonical root', async () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
await expect(
|
||||
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path)
|
||||
).resolves.toBe('/srv/repo/docs/report.html')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toRuntimeWorktreeRelativePath', () => {
|
||||
it('produces a worktree-relative path for files inside the worktree', () => {
|
||||
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe(
|
||||
'docs/index.html'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects paths outside the worktree, which files.read cannot address', () => {
|
||||
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull()
|
||||
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses Windows semantics for a Windows worktree root', () => {
|
||||
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe(
|
||||
'docs/index.html'
|
||||
)
|
||||
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,208 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { posix, win32 } from 'node:path'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
|
||||
/**
|
||||
* A preview grant is the only authority that turns an `orca-preview://` request
|
||||
* into bytes: it names the host that owns the file and the single directory
|
||||
* subtree requests may resolve inside. No grant, no bytes.
|
||||
*/
|
||||
export type DocPreviewOwner =
|
||||
| { kind: 'ssh'; connectionId: string }
|
||||
| {
|
||||
kind: 'runtime'
|
||||
environmentId: string
|
||||
/** Selector the runtime resolves `files.read` against. */
|
||||
worktreeSelector: string
|
||||
/** Worktree root on the runtime host; `files.read` only accepts paths inside it. */
|
||||
worktreeRoot: string
|
||||
}
|
||||
|
||||
export type DocPreviewGrant = {
|
||||
id: string
|
||||
owner: DocPreviewOwner
|
||||
/** Containing directory of the opened document, on the owning host. */
|
||||
root: string
|
||||
/** Path of the opened document relative to `root`. */
|
||||
entryRelativePath: string
|
||||
/**
|
||||
* Browser page the reader opened this document in. Main registers the guest under it once the
|
||||
* guest commits to the grant, so the surface a tool names is the page the reader is looking at
|
||||
* and not the grant, which a re-mint replaces underneath the same page.
|
||||
*/
|
||||
browserPageId: string
|
||||
}
|
||||
|
||||
const grantsById = new Map<string, DocPreviewGrant>()
|
||||
|
||||
function pathFlavorFor(root: string): typeof posix | typeof win32 {
|
||||
return isWindowsAbsolutePathLike(root) ? win32 : posix
|
||||
}
|
||||
|
||||
function normalizeRootPath(root: string): string {
|
||||
const flavor = pathFlavorFor(root)
|
||||
const normalized =
|
||||
flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root)
|
||||
// Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the
|
||||
// drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant.
|
||||
if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
// Why: a trailing separator would make the containment prefix check accept a sibling directory.
|
||||
return normalized.length > 1 && normalized.endsWith(flavor.sep)
|
||||
? normalized.slice(0, -1)
|
||||
: normalized
|
||||
}
|
||||
|
||||
export function mintDocPreviewGrant(params: {
|
||||
owner: DocPreviewOwner
|
||||
root: string
|
||||
entryRelativePath: string
|
||||
browserPageId: string
|
||||
}): DocPreviewGrant {
|
||||
const grant: DocPreviewGrant = {
|
||||
id: randomBytes(16).toString('hex'),
|
||||
owner: params.owner,
|
||||
root: normalizeRootPath(params.root),
|
||||
entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'),
|
||||
browserPageId: params.browserPageId
|
||||
}
|
||||
grantsById.set(grant.id, grant)
|
||||
return grant
|
||||
}
|
||||
|
||||
export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null {
|
||||
return grantsById.get(grantId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why anything listens at all: a grant is the only thing that names a preview's lifetime. State
|
||||
* elsewhere in main keyed by a preview's tool target — grab intent, a queued grab chain — has no
|
||||
* other signal telling it the surface is gone, and would otherwise accrete one entry per grant
|
||||
* for the life of the process.
|
||||
*/
|
||||
const revocationListeners = new Set<(grant: DocPreviewGrant) => void>()
|
||||
|
||||
/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */
|
||||
export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void {
|
||||
revocationListeners.add(listener)
|
||||
return () => revocationListeners.delete(listener)
|
||||
}
|
||||
|
||||
function notifyRevoked(grant: DocPreviewGrant): void {
|
||||
for (const listener of revocationListeners) {
|
||||
listener(grant)
|
||||
}
|
||||
}
|
||||
|
||||
export function revokeDocPreviewGrant(grantId: string): boolean {
|
||||
canonicalRootByGrantId.delete(grantId)
|
||||
const grant = grantsById.get(grantId)
|
||||
if (!grant) {
|
||||
return false
|
||||
}
|
||||
grantsById.delete(grantId)
|
||||
notifyRevoked(grant)
|
||||
return true
|
||||
}
|
||||
|
||||
export function revokeAllDocPreviewGrants(): void {
|
||||
canonicalRootByGrantId.clear()
|
||||
const revoked = [...grantsById.values()]
|
||||
grantsById.clear()
|
||||
for (const grant of revoked) {
|
||||
notifyRevoked(grant)
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnsafeSegment(segments: string[]): boolean {
|
||||
return segments.some(
|
||||
(segment) =>
|
||||
segment.length === 0 ||
|
||||
segment === '.' ||
|
||||
segment === '..' ||
|
||||
segment.includes('\0') ||
|
||||
segment.includes('\\')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a request path to an absolute path on the owning host, or null when
|
||||
* it would escape the grant's root. Path flavor follows the root (the owning
|
||||
* host may be Windows while this client is not), never `process.platform`.
|
||||
*/
|
||||
export function resolveDocPreviewTargetPath(
|
||||
grant: DocPreviewGrant,
|
||||
relativePath: string
|
||||
): string | null {
|
||||
const segments = relativePath.split('/').filter((segment, index, all) => {
|
||||
// Why: keep empty segments visible to the safety check except a single trailing one from `dir/`.
|
||||
return !(segment === '' && index === all.length - 1)
|
||||
})
|
||||
if (segments.length === 0 || hasUnsafeSegment(segments)) {
|
||||
return null
|
||||
}
|
||||
const flavor = pathFlavorFor(grant.root)
|
||||
const resolved = flavor.normalize(flavor.join(grant.root, ...segments))
|
||||
return isInsideRoot(grant.root, resolved, flavor) ? resolved : null
|
||||
}
|
||||
|
||||
function isInsideRoot(
|
||||
root: string,
|
||||
candidate: string,
|
||||
flavor: typeof posix | typeof win32
|
||||
): boolean {
|
||||
const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}`
|
||||
return candidate.startsWith(rootPrefix)
|
||||
}
|
||||
|
||||
/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */
|
||||
const canonicalRootByGrantId = new Map<string, Promise<string>>()
|
||||
|
||||
/**
|
||||
* Second containment pass for hosts where the lexical one is not enough: a symlink
|
||||
* inside the root can point anywhere, and the SSH read RPC applies no root of its
|
||||
* own. Both sides are canonicalized on the owning host before the prefix re-check;
|
||||
* a host that cannot canonicalize a path answers nothing.
|
||||
*/
|
||||
export async function resolveCanonicalDocPreviewPath(
|
||||
grant: DocPreviewGrant,
|
||||
absolutePath: string,
|
||||
realpath: (path: string) => Promise<string>
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
let canonicalRoot = canonicalRootByGrantId.get(grant.id)
|
||||
if (!canonicalRoot) {
|
||||
canonicalRoot = realpath(grant.root).then(normalizeRootPath)
|
||||
canonicalRootByGrantId.set(grant.id, canonicalRoot)
|
||||
}
|
||||
const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)])
|
||||
const flavor = pathFlavorFor(root)
|
||||
return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null
|
||||
} catch {
|
||||
// Why: a root that no longer canonicalizes must not fall back to the lexical answer.
|
||||
canonicalRootByGrantId.delete(grant.id)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path a runtime `files.read` can address, i.e. relative to the worktree root.
|
||||
* Returns null when the grant root sits outside the worktree — the runtime file
|
||||
* RPCs are worktree-scoped, so those documents are unreadable client-side.
|
||||
*/
|
||||
export function toRuntimeWorktreeRelativePath(
|
||||
worktreeRoot: string,
|
||||
absolutePath: string
|
||||
): string | null {
|
||||
const flavor = pathFlavorFor(worktreeRoot)
|
||||
const normalizedRoot = normalizeRootPath(worktreeRoot)
|
||||
const relative = flavor.relative(normalizedRoot, absolutePath)
|
||||
if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) {
|
||||
return null
|
||||
}
|
||||
if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) {
|
||||
return null
|
||||
}
|
||||
return relative.replace(/\\/g, '/')
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getWorkspaceDocPageGuest,
|
||||
installDocPreviewGuestPolicy,
|
||||
readDocPreviewGuestBoundGrantId,
|
||||
reportDocPreviewLinkClick
|
||||
} from './doc-preview-guest-policy'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants,
|
||||
revokeDocPreviewGrant
|
||||
} from './doc-preview-grant-registry'
|
||||
|
||||
type GuestHandlers = Record<string, (...args: never[]) => void>
|
||||
|
||||
const HOST_RENDERER_ID = 42
|
||||
|
||||
function installOnFakeGuest(
|
||||
hostId: number = HOST_RENDERER_ID,
|
||||
/** What the guest is already showing when the embedder hands it over, as a real one usually is. */
|
||||
initialUrl = ''
|
||||
): {
|
||||
contents: object
|
||||
handlers: GuestHandlers
|
||||
hostId: number
|
||||
isFocused: ReturnType<typeof vi.fn>
|
||||
send: ReturnType<typeof vi.fn>
|
||||
setWebRTCIPHandlingPolicy: ReturnType<typeof vi.fn>
|
||||
windowOpenHandler: (details: { url: string }) => { action: string }
|
||||
/** Why without the `destroyed` event: Chromium tears the contents down before main runs it. */
|
||||
markContentsDestroyed: () => void
|
||||
} {
|
||||
const handlers: GuestHandlers = {}
|
||||
let contentsDestroyed = false
|
||||
const send = vi.fn()
|
||||
const isFocused = vi.fn(() => true)
|
||||
const setWebRTCIPHandlingPolicy = vi.fn()
|
||||
let windowOpenHandler: (details: { url: string }) => { action: string } = () => ({
|
||||
action: 'deny'
|
||||
})
|
||||
const register = (event: string, handler: (...args: never[]) => void): void => {
|
||||
handlers[event] = handler
|
||||
}
|
||||
const guest = {
|
||||
isFocused,
|
||||
isDestroyed: () => contentsDestroyed,
|
||||
getURL: () => initialUrl,
|
||||
on: vi.fn(register),
|
||||
once: vi.fn(register),
|
||||
setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => {
|
||||
windowOpenHandler = handler
|
||||
}),
|
||||
setWebRTCIPHandlingPolicy
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: hostId, send })
|
||||
return {
|
||||
contents: guest,
|
||||
handlers,
|
||||
hostId,
|
||||
isFocused,
|
||||
send,
|
||||
setWebRTCIPHandlingPolicy,
|
||||
windowOpenHandler: (details) => windowOpenHandler(details),
|
||||
markContentsDestroyed: () => {
|
||||
contentsDestroyed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FakeGuest = ReturnType<typeof installOnFakeGuest>
|
||||
|
||||
function startMainFrameNavigation(guest: FakeGuest, url: string): void {
|
||||
guest.handlers['did-start-navigation']?.({ url, isMainFrame: true } as never)
|
||||
}
|
||||
|
||||
/** A guest already showing a document, which is the only state a link can be pressed in. */
|
||||
function boundGuest(): { grant: ReturnType<typeof mintGrant>; guest: FakeGuest } {
|
||||
const grant = mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
return { grant, guest }
|
||||
}
|
||||
|
||||
function reportClick(guest: FakeGuest, url: string): void {
|
||||
reportDocPreviewLinkClick(guest.contents as never, url)
|
||||
}
|
||||
|
||||
// Why a fresh page per grant: the registry is keyed by the page now, so two grants sharing one
|
||||
// would have the second silently replace the first rather than stand beside it.
|
||||
let nextDocPageOrdinal = 0
|
||||
|
||||
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
|
||||
nextDocPageOrdinal += 1
|
||||
return mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: `doc-page-${nextDocPageOrdinal}`
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
describe('doc preview guest policy', () => {
|
||||
it('allows relative navigation within the bound grant', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(grant.id, 'guide.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why an unlatched guest is refused rather than trusted: the renderer-set src is
|
||||
// browser-initiated and never reaches will-navigate, so a navigation arriving before the latch is
|
||||
// one the guest started for itself.
|
||||
it('blocks a navigation the guest starts before a document has bound it', () => {
|
||||
const grant = mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(grant.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks navigation into a different live grant once bound', () => {
|
||||
const { guest } = boundGuest()
|
||||
const otherGrant = mintGrant()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks a revoked grant even when it matches the bound id', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
revokeAllDocPreviewGrants()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(grant.id, 'guide.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks an external navigation without offering it to the renderer', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.({ preventDefault } as never, 'https://example.com/' as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks a file: navigation', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.({ preventDefault } as never, 'file:///etc/passwd' as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies the same rule to redirects', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-redirect']?.({ preventDefault } as never, 'https://evil.test/' as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies every popup and routes none of them', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
expect(guest.windowOpenHandler({ url: 'https://example.com/docs' })).toEqual({
|
||||
action: 'deny'
|
||||
})
|
||||
expect(guest.windowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: will-navigate never fires for a subframe, so an <iframe src="https://…"> would load
|
||||
// off-machine even though the top frame cannot.
|
||||
it('blocks a subframe navigating outside the grant, without opening a tab for it', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-frame-navigate']?.({
|
||||
preventDefault,
|
||||
url: 'https://tracker.test/pixel',
|
||||
isMainFrame: false
|
||||
} as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a subframe load an in-grant asset', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-frame-navigate']?.({
|
||||
preventDefault,
|
||||
url: buildDocPreviewUrl(grant.id, 'chart.html'),
|
||||
isMainFrame: false
|
||||
} as never)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why this group exists: the external-link route ends in a real browser tab with full network, so
|
||||
// a document that can read its grant and reach that route can exfiltrate it. Only a reader's own
|
||||
// press on a link may, and only the guest's preload can tell that a press was one.
|
||||
describe('the trusted-click route out of the preview', () => {
|
||||
// The headline. The earlier design read a recent-input timestamp, so a script that navigated
|
||||
// shortly after any genuine press was routed out as if the press had asked for it. Here the
|
||||
// navigation sinks route nothing at all, so when the input happened cannot matter.
|
||||
it('routes nothing for a scripted location change or window.open, whenever input happened', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
// Genuine input the guest would report, delivered immediately before the document acts.
|
||||
guest.handlers['input-event']?.({} as never, { type: 'mouseDown' } as never)
|
||||
guest.handlers['before-input-event']?.({} as never, { type: 'keyDown' } as never)
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
'https://attacker.test/?d=secret' as never
|
||||
)
|
||||
const popup = guest.windowOpenHandler({ url: 'https://attacker.test/?d=secret' })
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(popup).toEqual({ action: 'deny' })
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
// Why assert the absence of the listeners too: with them gone there is no timing a document
|
||||
// could hit, rather than a window it merely failed to hit in this test.
|
||||
expect(guest.handlers['input-event']).toBeUndefined()
|
||||
expect(guest.handlers['before-input-event']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes a reported click on an external link exactly once', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).toHaveBeenCalledExactlyOnceWith('docPreview:externalLink', {
|
||||
url: 'https://example.com/docs'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a click reported while the guest is not the contents the reader is looking at', () => {
|
||||
const { guest } = boundGuest()
|
||||
guest.isFocused.mockReturnValue(false)
|
||||
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a click reported by a sender that is not a preview guest', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
reportDocPreviewLinkClick({ isFocused: () => true } as never, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a click reported by a guest that never bound a grant', () => {
|
||||
mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("drops a click once the guest's bound grant is revoked", () => {
|
||||
const { guest } = boundGuest()
|
||||
revokeAllDocPreviewGrants()
|
||||
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'file:///etc/passwd',
|
||||
'javascript:fetch("https://attacker.test")',
|
||||
'orca-preview://a/b',
|
||||
'/Users/alice/secrets.txt',
|
||||
''
|
||||
])('drops a reported click on %s, which is not the web', (url) => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
reportClick(guest, url)
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops routing for a guest that has been destroyed', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
guest.handlers['destroyed']?.()
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Why: a peer connection is UDP straight off the network stack — the response CSP and the
|
||||
// session's request filter both miss it, so this is the only place it can be refused.
|
||||
it('denies the guest non-proxied WebRTC UDP at attach', () => {
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
expect(guest.setWebRTCIPHandlingPolicy).toHaveBeenCalledWith('disable_non_proxied_udp')
|
||||
})
|
||||
|
||||
// Why: latching from a subframe would let an in-document iframe decide which grant the guest
|
||||
// belongs to, and every later main-frame check would be measured against that.
|
||||
it('binds the guest from the main frame only', () => {
|
||||
const grant = mintGrant()
|
||||
const otherGrant = mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
guest.handlers['did-start-navigation']?.({
|
||||
url: buildDocPreviewUrl(otherGrant.id, 'index.html'),
|
||||
isMainFrame: false
|
||||
} as never)
|
||||
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
describe('tool authorization', () => {
|
||||
it('answers the hosting renderer for a guest bound to a live grant', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
|
||||
})
|
||||
|
||||
it('refuses a renderer that does not host the preview', () => {
|
||||
const { grant } = boundGuest()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID + 1)).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is the ordinary case and not an edge one: the embedder hands the guest over after
|
||||
// it has already started loading its src, so the navigation that binds most previews to their
|
||||
// grant happens before any listener here exists. Missing it leaves the tools unable to name
|
||||
// the guest for as long as the reader stays on the page they opened.
|
||||
it('binds a guest that is already showing its document when the policy installs', () => {
|
||||
const grant = mintGrant()
|
||||
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
|
||||
})
|
||||
|
||||
// The same miss, one event later: the load started before the listener and commits after it.
|
||||
it('binds a guest whose first navigation only reaches the commit', () => {
|
||||
const grant = mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
guest.handlers['did-navigate']?.(
|
||||
{} as never,
|
||||
buildDocPreviewUrl(grant.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
|
||||
})
|
||||
|
||||
// Why: the src commit is what proves this guest is showing that grant. Before it, the id names
|
||||
// a document nothing has been asked to render.
|
||||
it('refuses a guest that has not committed a document yet', () => {
|
||||
const grant = mintGrant()
|
||||
installOnFakeGuest()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
// Why: revoking is how a closed tab withdraws its preview, and it happens while the guest is
|
||||
// still being torn down — a tool must stop answering at revoke, not at destroy.
|
||||
it('stops answering once the grant is revoked', () => {
|
||||
const { grant } = boundGuest()
|
||||
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
it('drops the guest when it is destroyed', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
|
||||
guest.handlers['destroyed']?.()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is separate from the destroy event: the contents die before main runs that listener,
|
||||
// so in that window the registration still looks live and only this check refuses it.
|
||||
it('refuses a guest whose contents died before the destroy event arrived', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
|
||||
guest.markContentsDestroyed()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
it('answers nothing for a grant no preview ever rendered', () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is what makes three latch call sites safe: install, did-start-navigation and
|
||||
// did-navigate all feed the same latch, so without the no-rebind guard the second and third
|
||||
// would let a later navigation move a bound guest onto another grant — and a tool asking for
|
||||
// that grant would be handed a guest showing someone else's document.
|
||||
it('never rebinds a bound guest onto a second grant', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
const other = mintGrant()
|
||||
|
||||
startMainFrameNavigation(guest, buildDocPreviewUrl(other.id, 'index.html'))
|
||||
guest.handlers['did-navigate']?.(
|
||||
{} as never,
|
||||
buildDocPreviewUrl(other.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(getWorkspaceDocPageGuest(other.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
|
||||
})
|
||||
|
||||
// Why the replacement is registered before the old guest's teardown runs: a re-mint attaches
|
||||
// the new guest first, and Chromium runs the outgoing guest's destroyed listener afterwards. A
|
||||
// teardown that deleted by page alone would unregister the guest the reader is now looking at.
|
||||
it('leaves a re-minted preview registered when the guest it replaced tears down', () => {
|
||||
const first = mintGrant()
|
||||
const outgoing = installOnFakeGuest(
|
||||
HOST_RENDERER_ID,
|
||||
buildDocPreviewUrl(first.id, 'index.html')
|
||||
)
|
||||
const remint = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: first.browserPageId
|
||||
})
|
||||
const incoming = installOnFakeGuest(
|
||||
HOST_RENDERER_ID,
|
||||
buildDocPreviewUrl(remint.id, 'index.html')
|
||||
)
|
||||
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
|
||||
incoming.contents
|
||||
)
|
||||
|
||||
outgoing.handlers['destroyed']?.()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
|
||||
incoming.contents
|
||||
)
|
||||
})
|
||||
|
||||
// Why the grant has to be live at the latch and not only at each later request: registering the
|
||||
// page a dead grant names would put a guest that can read nothing into the registry the tool
|
||||
// door answers from, under a page a live grant may later want.
|
||||
it('registers nothing for a guest showing a grant that is already revoked', () => {
|
||||
const grant = mintGrant()
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
|
||||
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
// The presence half: the same install does register when the grant is still live.
|
||||
const live = mintGrant()
|
||||
installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(live.id, 'index.html'))
|
||||
expect(getWorkspaceDocPageGuest(live.browserPageId, HOST_RENDERER_ID)).not.toBeNull()
|
||||
expect(guest.contents).toBeDefined()
|
||||
})
|
||||
|
||||
// Why both directions: two previews open at once must not be able to drive each other's guest.
|
||||
it('keeps two live previews on their own guests', () => {
|
||||
const first = boundGuest()
|
||||
const second = boundGuest()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(first.grant.browserPageId, HOST_RENDERER_ID)).toBe(
|
||||
first.guest.contents
|
||||
)
|
||||
expect(getWorkspaceDocPageGuest(second.grant.browserPageId, HOST_RENDERER_ID)).toBe(
|
||||
second.guest.contents
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Session events name the contents and nothing else, so this is how a fence firing on the whole
|
||||
// partition works out which preview — if any — the reader should be told about.
|
||||
describe('naming the grant a contents is showing', () => {
|
||||
it('answers the bound grant for a live preview guest', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
|
||||
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBe(grant.id)
|
||||
})
|
||||
|
||||
it('answers nothing for a contents that is not a preview guest', () => {
|
||||
boundGuest()
|
||||
|
||||
expect(readDocPreviewGuestBoundGrantId({} as never)).toBeNull()
|
||||
})
|
||||
|
||||
it('answers nothing for a preview guest that has committed no document', () => {
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
|
||||
})
|
||||
|
||||
it('answers nothing once the guest is gone', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
guest.handlers['destroyed']?.()
|
||||
|
||||
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,247 +0,0 @@
|
||||
import {
|
||||
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
|
||||
parseDocPreviewUrl
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
import { normalizeExternalBrowserUrl } from '../../shared/browser-url'
|
||||
import { enforceBrowserRouteWebRtcPolicy } from './browser-route-webrtc-policy'
|
||||
import { getDocPreviewGrant } from './doc-preview-grant-registry'
|
||||
|
||||
/** The trusted renderer hosting the preview: the sink for link reports and the only sender allowed to drive tools on it. */
|
||||
type PreviewHostRenderer = {
|
||||
id: number
|
||||
send: (channel: string, payload: { url: string }) => void
|
||||
}
|
||||
|
||||
type PreviewGuestRegistration = {
|
||||
host: PreviewHostRenderer
|
||||
readBoundGrantId: () => string | null
|
||||
isFocused: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a registry: the click report arrives on an IPC channel, where the only thing main holds is
|
||||
* the sender. Without this, "is this a live preview guest, and which grant is it bound to" has no
|
||||
* answer, and any WebContents that learned the channel name would be routed out.
|
||||
*/
|
||||
const previewGuests = new WeakMap<object, PreviewGuestRegistration>()
|
||||
|
||||
/**
|
||||
* The workspace-document half of the browser page registry, keyed by the page the reader opened
|
||||
* the document in. It is deliberately a separate map from the browsing one: page management,
|
||||
* agent commands, download routing and certificate attribution all read that map directly, in
|
||||
* more places than a guard could be remembered in, so a document guest is simply not in it. The
|
||||
* one door taught about both is `browserManager.getAuthorizedGuest`, because acting on the guest
|
||||
* the reader is looking at is the single operation that legitimately spans them.
|
||||
*/
|
||||
const docGuestsByPageId = new Map<
|
||||
string,
|
||||
{ guest: Electron.WebContents; hostId: number; grantId: string }
|
||||
>()
|
||||
|
||||
const registrationListeners = new Set<(browserPageId: string) => void>()
|
||||
|
||||
/** Why anything listens: a tool request can beat the guest's own attach, and the wait it parks in lives in the IPC layer. */
|
||||
export function onWorkspaceDocGuestRegistered(
|
||||
listener: (browserPageId: string) => void
|
||||
): () => void {
|
||||
registrationListeners.add(listener)
|
||||
return () => registrationListeners.delete(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the document guest a browser tool should act on. Grants the guest nothing — it answers
|
||||
* only whether the trusted renderer asking is the one hosting a live, grant-bound preview on that
|
||||
* page. Reached only through `browserManager.getAuthorizedGuest`.
|
||||
*/
|
||||
export function getWorkspaceDocPageGuest(
|
||||
browserPageId: string,
|
||||
senderWebContentsId: number
|
||||
): Electron.WebContents | null {
|
||||
const registration = docGuestsByPageId.get(browserPageId)
|
||||
if (!registration || registration.hostId !== senderWebContentsId) {
|
||||
return null
|
||||
}
|
||||
// Why: revocation is how a closed tab withdraws its preview, and it happens before the guest is torn down.
|
||||
if (!getDocPreviewGrant(registration.grantId)) {
|
||||
return null
|
||||
}
|
||||
if (registration.guest.isDestroyed()) {
|
||||
docGuestsByPageId.delete(browserPageId)
|
||||
return null
|
||||
}
|
||||
return registration.guest
|
||||
}
|
||||
|
||||
/** True when the page renders a workspace document, so the browsing registry must never hold it. */
|
||||
export function isWorkspaceDocPageId(browserPageId: string): boolean {
|
||||
return docGuestsByPageId.has(browserPageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The grant a live preview guest is bound to, or null for any other WebContents — the same identity
|
||||
* question `reportDocPreviewLinkClick` asks, for a session event that hands over only the contents.
|
||||
*/
|
||||
export function readDocPreviewGuestBoundGrantId(guest: Electron.WebContents): string | null {
|
||||
return previewGuests.get(guest)?.readBoundGrantId() ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview guest renders a workspace document, not the web: it may only move within its own
|
||||
* grant, and nothing it does by itself leaves the preview. The one route out is
|
||||
* `reportDocPreviewLinkClick`, which answers for a click the reader really made.
|
||||
*
|
||||
* Reached only through `browserManager.attachGuestPolicies` under the workspace-doc profile, so
|
||||
* every guest in the app is policy-attached by one door whatever it renders. Returns the disposal
|
||||
* that door stores: without it a retired guest keeps its listeners and stays answerable through the
|
||||
* grant-keyed authority until the WebContents itself is collected.
|
||||
*/
|
||||
export function installDocPreviewGuestPolicy(
|
||||
guest: Electron.WebContents,
|
||||
host: PreviewHostRenderer
|
||||
): () => void {
|
||||
// Why: the first commit is the renderer-set src, already admitted by will-attach-webview;
|
||||
// latching it pins every later navigation to that one grant.
|
||||
let boundGrantId: string | null = null
|
||||
let boundPageId: string | null = null
|
||||
|
||||
const latchGrantFromUrl = (rawUrl: string): void => {
|
||||
if (boundGrantId !== null) {
|
||||
return
|
||||
}
|
||||
const grantId = parseDocPreviewUrl(rawUrl)?.grantId ?? null
|
||||
// Why the grant must still be live: registering under the page a dead grant names would put a
|
||||
// guest nothing can read into the registry the tool door answers from.
|
||||
const grant = grantId === null ? null : getDocPreviewGrant(grantId)
|
||||
if (!grant) {
|
||||
return
|
||||
}
|
||||
boundGrantId = grant.id
|
||||
boundPageId = grant.browserPageId
|
||||
docGuestsByPageId.set(boundPageId, { guest, hostId: host.id, grantId: grant.id })
|
||||
for (const listener of registrationListeners) {
|
||||
listener(boundPageId)
|
||||
}
|
||||
}
|
||||
|
||||
previewGuests.set(guest, {
|
||||
host,
|
||||
readBoundGrantId: () => boundGrantId,
|
||||
isFocused: () => guest.isFocused()
|
||||
})
|
||||
const forgetGuest = (): void => {
|
||||
previewGuests.delete(guest)
|
||||
// Why the identity check: a re-mint registers the replacement under the same page before this
|
||||
// guest's own teardown runs, and deleting by key alone would unregister the live one.
|
||||
if (boundPageId !== null && docGuestsByPageId.get(boundPageId)?.guest === guest) {
|
||||
docGuestsByPageId.delete(boundPageId)
|
||||
}
|
||||
}
|
||||
guest.once('destroyed', forgetGuest)
|
||||
|
||||
const isAllowedPreviewNavigation = (rawUrl: string): boolean => {
|
||||
const target = parseDocPreviewUrl(rawUrl)
|
||||
if (!target || !getDocPreviewGrant(target.grantId)) {
|
||||
return false
|
||||
}
|
||||
// Why the latch is required and not just consistent: the renderer-set src is browser-initiated,
|
||||
// so will-navigate never fires for it. Anything reaching here before the latch is the guest
|
||||
// moving itself, which no grant has admitted yet.
|
||||
return boundGrantId !== null && target.grantId === boundGrantId
|
||||
}
|
||||
|
||||
/**
|
||||
* Why deny-only, with nothing routed: a navigation the guest starts cannot be attributed to the
|
||||
* reader. The document may read its whole grant over `connect-src 'self'`, so routing an
|
||||
* unattributable URL to a real browser tab with full network is how those bytes would get out.
|
||||
*/
|
||||
const navigationGuard = (event: Electron.Event, url: string): void => {
|
||||
if (isAllowedPreviewNavigation(url)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// Why here and not only on navigation events: the guest is already loading its src by the time
|
||||
// the embedder hands it to us, so the only navigation most previews ever make has already
|
||||
// started. Latching what it is on now is what binds the usual preview to its grant at all.
|
||||
latchGrantFromUrl(guest.getURL())
|
||||
const latchFromMainFrameNavigation = (details: { isMainFrame: boolean; url: string }): void => {
|
||||
// Why: only the top document defines which grant this guest belongs to. Latching from a
|
||||
// subframe would let an in-document iframe rebind the guest to another grant.
|
||||
if (!details.isMainFrame) {
|
||||
return
|
||||
}
|
||||
latchGrantFromUrl(details.url)
|
||||
}
|
||||
// Why a committed URL too: a navigation that started before this listener existed still commits
|
||||
// after it, and a preview that never navigates again would otherwise stay bound to nothing.
|
||||
const latchFromCommittedUrl = (_event: Electron.Event, url: string): void =>
|
||||
latchGrantFromUrl(url)
|
||||
// Why: will-navigate never fires for a subframe, so without this an <iframe src="https://…">
|
||||
// inside a previewed document would load off-machine even though the top frame cannot.
|
||||
const frameNavigationGuard = (details: {
|
||||
isMainFrame: boolean
|
||||
url: string
|
||||
preventDefault: () => void
|
||||
}): void => {
|
||||
if (details.isMainFrame || isAllowedPreviewNavigation(details.url)) {
|
||||
return
|
||||
}
|
||||
details.preventDefault()
|
||||
}
|
||||
guest.on('did-start-navigation', latchFromMainFrameNavigation)
|
||||
guest.on('did-navigate', latchFromCommittedUrl)
|
||||
guest.on('will-navigate', navigationGuard)
|
||||
guest.on('will-redirect', navigationGuard)
|
||||
guest.on('will-frame-navigate', frameNavigationGuard)
|
||||
// Why deny with nothing routed: previews own no native child windows, and a popup the document
|
||||
// asked for is the document asking, not the reader. A link the reader presses is intercepted
|
||||
// before Chromium ever considers a popup.
|
||||
guest.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
// Why a second fence for one API: WebRTC opens UDP straight from the network stack, so neither the
|
||||
// response CSP nor the session's request filter ever sees it. This is the only layer that can.
|
||||
enforceBrowserRouteWebRtcPolicy(guest, () => {})
|
||||
|
||||
return () => {
|
||||
forgetGuest()
|
||||
if (guest.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
guest.off('destroyed', forgetGuest)
|
||||
guest.off('did-start-navigation', latchFromMainFrameNavigation)
|
||||
guest.off('did-navigate', latchFromCommittedUrl)
|
||||
guest.off('will-navigate', navigationGuard)
|
||||
guest.off('will-redirect', navigationGuard)
|
||||
guest.off('will-frame-navigate', frameNavigationGuard)
|
||||
}
|
||||
}
|
||||
|
||||
function isWebUrl(url: string): boolean {
|
||||
return url.startsWith('http://') || url.startsWith('https://')
|
||||
}
|
||||
|
||||
/**
|
||||
* The only way a URL leaves a preview. Every condition is load-bearing: the sender must be a live
|
||||
* preview guest still bound to a grant, it must be the contents the reader is looking at, and the
|
||||
* target must be the web. Anything else is dropped without a trace the document could observe.
|
||||
*/
|
||||
export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl: string): void {
|
||||
const registration = previewGuests.get(sender)
|
||||
if (!registration) {
|
||||
return
|
||||
}
|
||||
const boundGrantId = registration.readBoundGrantId()
|
||||
if (boundGrantId === null || !getDocPreviewGrant(boundGrantId)) {
|
||||
return
|
||||
}
|
||||
// Why focus and not just the preload's trusted-click check: that check runs inside the guest, so
|
||||
// it holds only while the guest renderer does. Focus is the half main can verify for itself.
|
||||
if (!registration.isFocused()) {
|
||||
return
|
||||
}
|
||||
const externalUrl = normalizeExternalBrowserUrl(rawUrl)
|
||||
if (!externalUrl || !isWebUrl(externalUrl)) {
|
||||
return
|
||||
}
|
||||
registration.host.send(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, { url: externalUrl })
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readDocPreviewFile: vi.fn(),
|
||||
installBrowserSessionPartitionPolicies: vi.fn()
|
||||
}))
|
||||
|
||||
function createFakeSession(): {
|
||||
protocol: { isProtocolHandled: () => boolean; handle: ReturnType<typeof vi.fn> }
|
||||
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> }
|
||||
} {
|
||||
return {
|
||||
protocol: { isProtocolHandled: () => false, handle: vi.fn() },
|
||||
webRequest: { onBeforeRequest: vi.fn() }
|
||||
}
|
||||
}
|
||||
|
||||
// Why one session per partition and a distinct default: installing the handler on the default
|
||||
// session instead of the preview session is otherwise invisible — every read would come back
|
||||
// from the same object.
|
||||
const previewSession = createFakeSession()
|
||||
const defaultSession = createFakeSession()
|
||||
vi.mock('electron', () => ({
|
||||
protocol: { registerSchemesAsPrivileged: vi.fn() },
|
||||
session: {
|
||||
get defaultSession() {
|
||||
return defaultSession
|
||||
},
|
||||
fromPartition: (partition: string) => {
|
||||
if (partition !== 'orca-doc-preview') {
|
||||
throw new Error(`unexpected partition ${partition}`)
|
||||
}
|
||||
return previewSession
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./doc-preview-file-reader', () => ({ readDocPreviewFile: mocks.readDocPreviewFile }))
|
||||
vi.mock('./browser-session-partition-policies', () => ({
|
||||
installBrowserSessionPartitionPolicies: mocks.installBrowserSessionPartitionPolicies
|
||||
}))
|
||||
|
||||
import { protocol } from 'electron'
|
||||
import {
|
||||
getDocPreviewSession,
|
||||
handleDocPreviewRequest,
|
||||
installDocPreviewProtocolHandler,
|
||||
isAllowedDocPreviewRequestUrl,
|
||||
isDocPreviewSession,
|
||||
registerDocPreviewSchemePrivileges
|
||||
} from './doc-preview-protocol'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants,
|
||||
revokeDocPreviewGrant
|
||||
} from './doc-preview-grant-registry'
|
||||
import {
|
||||
buildDocPreviewUrl,
|
||||
DOC_PREVIEW_LOAD_FAILURE_CHANNEL
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
import { setDocPreviewFailureSink } from './doc-preview-failure-notice'
|
||||
|
||||
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
setDocPreviewFailureSink(null)
|
||||
mocks.readDocPreviewFile.mockResolvedValue({
|
||||
ok: true,
|
||||
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDocPreviewRequest', () => {
|
||||
it('serves an in-grant document with no-store so reload re-reads the workspace', async () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store')
|
||||
expect(await response.text()).toBe('<h1>hi</h1>')
|
||||
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
|
||||
})
|
||||
|
||||
it('falls back to the granted entry document for a root request', async () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
await handleDocPreviewRequest(new Request(`orca-preview://${grant.id}/`))
|
||||
|
||||
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
|
||||
})
|
||||
|
||||
it('decodes percent-encoded segments before resolving', async () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'a b/c#d.html')))
|
||||
|
||||
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'a b/c#d.html')
|
||||
})
|
||||
|
||||
it('404s an unknown grant without reading anything', async () => {
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(`orca-preview://${'0'.repeat(32)}/index.html`)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s a revoked grant', async () => {
|
||||
const grant = mintGrant()
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s a malformed grant id', async () => {
|
||||
const response = await handleDocPreviewRequest(new Request('orca-preview://not-a-grant/x.html'))
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates the reader status for an unservable asset', async () => {
|
||||
const grant = mintGrant()
|
||||
mocks.readDocPreviewFile.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 415,
|
||||
reason: 'unsupported-asset',
|
||||
message: 'cannot send this file type'
|
||||
})
|
||||
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(buildDocPreviewUrl(grant.id, 'a.woff2'))
|
||||
)
|
||||
|
||||
expect(response.status).toBe(415)
|
||||
expect(await response.text()).toBe('cannot send this file type')
|
||||
})
|
||||
|
||||
// Why: previewed documents are agent-authored, so an outbound request is an exfiltration channel
|
||||
// for everything else the grant can read.
|
||||
it('serves documents under a self-only content security policy', async () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
)
|
||||
|
||||
const policy = response.headers.get('Content-Security-Policy') ?? ''
|
||||
expect(policy).toContain("default-src 'self'")
|
||||
expect(policy).toContain("connect-src 'self'")
|
||||
expect(policy).toContain("object-src 'none'")
|
||||
expect(policy).toContain("img-src 'self' data:")
|
||||
expect(policy).not.toContain('https:')
|
||||
// Why assert an absence: `webrtc 'block'` is the obvious directive to reach for here and this
|
||||
// Chromium does not implement it — it logs "Unrecognized Content-Security-Policy directive
|
||||
// 'webrtc'" and gathers candidates regardless. Adding it back would document a fence that is
|
||||
// not there; the guest's IP-handling policy is what actually refuses.
|
||||
expect(policy).not.toContain('webrtc')
|
||||
})
|
||||
|
||||
// Why: the guest paints a 4xx body as if it were the document, so the shell only learns the
|
||||
// reason from this push.
|
||||
it('pushes the failure reason for the requested path', async () => {
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send })
|
||||
const grant = mintGrant()
|
||||
mocks.readDocPreviewFile.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 413,
|
||||
reason: 'too-large',
|
||||
message: 'too large'
|
||||
})
|
||||
|
||||
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
|
||||
|
||||
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
|
||||
grantId: grant.id,
|
||||
relativePath: 'index.html',
|
||||
reason: 'too-large'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes nothing when the document is served', async () => {
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send })
|
||||
const grant = mintGrant()
|
||||
|
||||
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
|
||||
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: a grant revoked underneath a live guest 404s like a missing file, and without this push
|
||||
// the shell leaves the handler's "Not found" body on screen as if it were the document.
|
||||
it('pushes a failure for a revoked grant so the shell can replace the 404 body', async () => {
|
||||
const send = vi.fn()
|
||||
const grant = mintGrant()
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
setDocPreviewFailureSink({ send })
|
||||
|
||||
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
|
||||
|
||||
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
|
||||
grantId: grant.id,
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('installDocPreviewProtocolHandler', () => {
|
||||
// Why the default session is named here: it is the session every other Electron API reaches for
|
||||
// by default, and handling the scheme there would serve preview bytes to ordinary browsing.
|
||||
it('handles the scheme on the preview session and nowhere else', () => {
|
||||
installDocPreviewProtocolHandler()
|
||||
|
||||
expect(previewSession.protocol.handle).toHaveBeenCalledWith(
|
||||
'orca-preview',
|
||||
handleDocPreviewRequest
|
||||
)
|
||||
expect(previewSession.webRequest.onBeforeRequest).toHaveBeenCalled()
|
||||
expect(defaultSession.protocol.handle).not.toHaveBeenCalled()
|
||||
expect(defaultSession.webRequest.onBeforeRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels every request the preview session should never carry', () => {
|
||||
installDocPreviewProtocolHandler()
|
||||
|
||||
const filter = previewSession.webRequest.onBeforeRequest.mock.calls.at(-1)?.[0] as (
|
||||
details: { url: string },
|
||||
callback: (response: { cancel: boolean }) => void
|
||||
) => void
|
||||
const cancelled = (url: string): boolean => {
|
||||
let response: { cancel: boolean } | null = null
|
||||
filter({ url }, (value) => {
|
||||
response = value
|
||||
})
|
||||
return (response as { cancel: boolean } | null)?.cancel === true
|
||||
}
|
||||
|
||||
expect(cancelled('https://cdn.example.com/tracker.js')).toBe(true)
|
||||
expect(cancelled(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(false)
|
||||
})
|
||||
|
||||
// Why: preview guests are webviews like any other, so they must not skip the deny-by-default
|
||||
// permission and display-media policy every browser partition gets.
|
||||
it('applies the shared browser partition policies to the preview session', () => {
|
||||
installDocPreviewProtocolHandler()
|
||||
|
||||
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ partition: 'orca-doc-preview', userAgentMode: 'clean' }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
// Why downloads and nothing else: the browser download flow attributes a file to the page that
|
||||
// asked for it, and a previewed document is no page. Routed, it would write remote-authored bytes
|
||||
// into this desktop's Downloads folder with no prompt and no tab to name as the source.
|
||||
it('asks for downloads to be denied on the preview partition', () => {
|
||||
installDocPreviewProtocolHandler()
|
||||
|
||||
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(expect.anything(), {
|
||||
downloads: 'deny'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerDocPreviewSchemePrivileges', () => {
|
||||
// Why the whole literal and not a subset: every privilege this scheme does not claim is one the
|
||||
// document cannot use to escape it. `allowServiceWorkers` would outlive the tab that was granted
|
||||
// the read, and `bypassCSP` would undo the self-only policy the handler serves with.
|
||||
it('claims exactly the privileges the preview document needs', () => {
|
||||
registerDocPreviewSchemePrivileges()
|
||||
|
||||
expect(protocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([
|
||||
{
|
||||
scheme: 'orca-preview',
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
stream: true
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedDocPreviewRequestUrl', () => {
|
||||
// Why: the session refuses to carry the request at all, so a CSP bypass in one element type
|
||||
// still reaches nothing off-machine.
|
||||
it('admits in-document schemes and refuses everything that leaves the machine', () => {
|
||||
expect(isAllowedDocPreviewRequestUrl(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(true)
|
||||
expect(isAllowedDocPreviewRequestUrl('devtools://devtools/bundled/inspector.html')).toBe(true)
|
||||
expect(isAllowedDocPreviewRequestUrl('data:image/png;base64,AAA')).toBe(true)
|
||||
expect(isAllowedDocPreviewRequestUrl('blob:orca-preview://abc/123')).toBe(true)
|
||||
expect(isAllowedDocPreviewRequestUrl('https://cdn.example.com/app.css')).toBe(false)
|
||||
expect(isAllowedDocPreviewRequestUrl('http://127.0.0.1:9999/exfil')).toBe(false)
|
||||
expect(isAllowedDocPreviewRequestUrl('ws://evil.example.com/socket')).toBe(false)
|
||||
expect(isAllowedDocPreviewRequestUrl('file:///etc/passwd')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDocPreviewSession', () => {
|
||||
it('claims no session until the preview session has been materialized', () => {
|
||||
expect(isDocPreviewSession({} as never)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches only the memoized preview session', () => {
|
||||
const created = getDocPreviewSession()
|
||||
|
||||
expect(isDocPreviewSession(created)).toBe(true)
|
||||
expect(isDocPreviewSession({} as never)).toBe(false)
|
||||
expect(getDocPreviewSession()).toBe(created)
|
||||
})
|
||||
})
|
||||
@@ -1,144 +0,0 @@
|
||||
import { protocol, session } from 'electron'
|
||||
import {
|
||||
DOC_PREVIEW_PARTITION,
|
||||
DOC_PREVIEW_SCHEME,
|
||||
parseDocPreviewUrl
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
import { installBrowserSessionPartitionPolicies } from './browser-session-partition-policies'
|
||||
import { readDocPreviewFile } from './doc-preview-file-reader'
|
||||
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
|
||||
import { getDocPreviewGrant } from './doc-preview-grant-registry'
|
||||
|
||||
/** Must run before `app.whenReady()`; Electron freezes the privileged scheme table at ready. */
|
||||
export function registerDocPreviewSchemePrivileges(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: DOC_PREVIEW_SCHEME,
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
stream: true
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
let docPreviewSession: Electron.Session | null = null
|
||||
|
||||
/** Non-persistent session, so preview bytes never land in a browsing profile's storage. */
|
||||
export function getDocPreviewSession(): Electron.Session {
|
||||
docPreviewSession ??= session.fromPartition(DOC_PREVIEW_PARTITION)
|
||||
return docPreviewSession
|
||||
}
|
||||
|
||||
/** Pure identity check: every guest attach consults it, and none should materialize a session. */
|
||||
export function isDocPreviewSession(candidate: Electron.Session): boolean {
|
||||
return docPreviewSession !== null && candidate === docPreviewSession
|
||||
}
|
||||
|
||||
/**
|
||||
* Product decision, not a hardening default: previewed documents are agent-authored, so any
|
||||
* outbound request they can make is an exfiltration channel for whatever else the page can read.
|
||||
* Self-contained documents — inline CSS/JS/SVG and in-grant assets — render in full; a CDN
|
||||
* stylesheet, font, script, or analytics beacon deliberately does not load.
|
||||
*/
|
||||
const DOC_PREVIEW_CONTENT_SECURITY_POLICY = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'"
|
||||
// Why no `webrtc 'block'` here, though a peer connection is exactly the outbound channel this
|
||||
// policy is meant to close: Chromium 43-era answers that directive with "Unrecognized
|
||||
// Content-Security-Policy directive 'webrtc'" and gathers candidates anyway, so listing it would
|
||||
// read as a fence while fencing nothing. The guest's IP-handling policy is the one that holds —
|
||||
// see `installDocPreviewGuestPolicy`.
|
||||
].join('; ')
|
||||
|
||||
function notFound(message: string): Response {
|
||||
return new Response(message, {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleDocPreviewRequest(request: Request): Promise<Response> {
|
||||
const target = parseDocPreviewUrl(request.url)
|
||||
if (!target) {
|
||||
return notFound('Not found')
|
||||
}
|
||||
const grant = getDocPreviewGrant(target.grantId)
|
||||
if (!grant) {
|
||||
// Why: a revoked or unknown grant is indistinguishable from a missing file by design — but the
|
||||
// shell still needs to know, or the guest paints this body where the document should be.
|
||||
publishDocPreviewFailure({
|
||||
grantId: target.grantId,
|
||||
relativePath: target.relativePath,
|
||||
reason: 'unreadable'
|
||||
})
|
||||
return notFound('Not found')
|
||||
}
|
||||
const relativePath = target.relativePath || grant.entryRelativePath
|
||||
const outcome = await readDocPreviewFile(grant, relativePath)
|
||||
if (!outcome.ok) {
|
||||
publishDocPreviewFailure({ grantId: target.grantId, relativePath, reason: outcome.reason })
|
||||
return new Response(outcome.message, {
|
||||
status: outcome.status,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||
})
|
||||
}
|
||||
return new Response(new Uint8Array(outcome.bytes), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': outcome.contentType,
|
||||
'Content-Security-Policy': DOC_PREVIEW_CONTENT_SECURITY_POLICY,
|
||||
// Why: reload must re-read the workspace disk, so nothing may be cached.
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** `data:`/`blob:` never leave the document; every other scheme would reach off-machine. */
|
||||
export function isAllowedDocPreviewRequestUrl(url: string): boolean {
|
||||
return (
|
||||
url.startsWith(`${DOC_PREVIEW_SCHEME}://`) ||
|
||||
url.startsWith('devtools://') ||
|
||||
url.startsWith('data:') ||
|
||||
url.startsWith('blob:')
|
||||
)
|
||||
}
|
||||
|
||||
export function installDocPreviewProtocolHandler(): void {
|
||||
const previewSession = getDocPreviewSession()
|
||||
if (previewSession.protocol.isProtocolHandled(DOC_PREVIEW_SCHEME)) {
|
||||
return
|
||||
}
|
||||
previewSession.protocol.handle(DOC_PREVIEW_SCHEME, handleDocPreviewRequest)
|
||||
// Why: the response CSP is the document's own promise to obey; this is the session refusing to
|
||||
// carry the request at all, so a CSP bypass in one element type still reaches nothing.
|
||||
previewSession.webRequest.onBeforeRequest((details, callback) => {
|
||||
callback({ cancel: !isAllowedDocPreviewRequestUrl(details.url) })
|
||||
})
|
||||
// Why: preview guests are webviews like any other, so they inherit the same deny-by-default
|
||||
// permission, display-media and user-agent policy every browser partition gets.
|
||||
installBrowserSessionPartitionPolicies(
|
||||
{
|
||||
id: DOC_PREVIEW_PARTITION,
|
||||
scope: 'isolated',
|
||||
partition: DOC_PREVIEW_PARTITION,
|
||||
label: 'Document preview',
|
||||
source: null,
|
||||
userAgentMode: 'clean'
|
||||
},
|
||||
// Why downloads are the one policy that does not carry over: the browser download flow needs a
|
||||
// page to attribute the file to, and a previewed document is not one. Routed here it would
|
||||
// reserve a name in this desktop's Downloads folder and write remote-authored bytes into it
|
||||
// with nothing in the UI naming the tab that asked, and no prompt in front of it.
|
||||
{ downloads: 'deny' }
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
windows: [] as MockBrowserWindow[],
|
||||
windows: [] as { webContents: MockWebContents }[],
|
||||
BrowserWindow: vi.fn(),
|
||||
finishLoads: true
|
||||
}))
|
||||
@@ -50,41 +50,6 @@ vi.mock('./browser-session-registry', () => ({
|
||||
}))
|
||||
|
||||
import { OffscreenBrowserBackend } from './offscreen-browser-backend'
|
||||
import { installDocPreviewGuestPolicy, isWorkspaceDocPageId } from './doc-preview-guest-policy'
|
||||
import { mintDocPreviewGrant } from './doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
/** The real door's answer, so the backend is tested against the refusal it will actually get. */
|
||||
function registerOffscreenGuestLikeBrowserManager({
|
||||
browserPageId
|
||||
}: {
|
||||
browserPageId: string
|
||||
}): boolean {
|
||||
return !isWorkspaceDocPageId(browserPageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* A page the document half of the registry really owns. Built rather than named, because the door
|
||||
* refuses on registry membership now — a made-up id would be admitted and prove nothing.
|
||||
*/
|
||||
function registerWorkspaceDocPage(browserPageId: string): void {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
const guest = {
|
||||
isFocused: () => false,
|
||||
isDestroyed: () => false,
|
||||
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: 1, send: vi.fn() })
|
||||
}
|
||||
|
||||
describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
@@ -108,7 +73,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.finishLoads = false
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never)
|
||||
@@ -129,36 +94,9 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Why the unregister assertion and not just the destroy: a refused id is one the document half
|
||||
// of the registry owns, and the teardown hook would cancel that preview's work on the way out.
|
||||
it('destroys a window whose registration was refused without unregistering the id', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never)
|
||||
registerWorkspaceDocPage('doc-page-1')
|
||||
|
||||
await expect(
|
||||
backend.createTab({
|
||||
browserPageId: 'doc-page-1',
|
||||
url: 'https://example.com',
|
||||
worktreeId: 'wt'
|
||||
})
|
||||
).rejects.toThrow('was refused')
|
||||
|
||||
expect(mocks.windows[0].isDestroyed()).toBe(true)
|
||||
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
|
||||
|
||||
// Why shutdown and not the map: a refused id left behind is invisible until teardown walks it
|
||||
// and hands that id to the other authority after all.
|
||||
await backend.destroyAll()
|
||||
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unregisters a closing page before awaiting owner retirement', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
@@ -188,7 +126,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('preserves a replacement page when the old window finishes closing', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
@@ -214,7 +152,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('retires the helper when an offscreen renderer is destroyed unexpectedly', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const onPageClosed = vi.fn(async () => {})
|
||||
@@ -229,7 +167,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('cleans every helper owner during backend shutdown', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const onPageClosed = vi.fn(async () => {})
|
||||
@@ -248,7 +186,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('rejects a concurrent create while shutdown is draining owned pages', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
@@ -277,7 +215,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('joins owner retirement started by an unexpected renderer destroy', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
@@ -306,7 +244,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('bounds concurrent helper retirements during shutdown', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let activeRetirements = 0
|
||||
@@ -346,10 +284,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
})
|
||||
|
||||
it('closes the page even when daemon retirement throws', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const browserManager = { registerOffscreenGuest: vi.fn(), unregisterGuest: vi.fn() }
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never, {
|
||||
getAgentBrowserBridge: () => ({
|
||||
onPageClosed: vi.fn(async () => {
|
||||
|
||||
@@ -66,33 +66,9 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
|
||||
this.windowsByPageId.set(browserPageId, win)
|
||||
|
||||
// Why: register the guest and return immediately so the new tab appears
|
||||
// without waiting for the page to finish loading. Previously createTab
|
||||
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
|
||||
// up to a second on real URLs. The page loads asynchronously and streams
|
||||
// once it paints; a failed load leaves the (usable) tab open, matching how a
|
||||
// normal browser tab survives a failed navigation.
|
||||
const registered = this.browserManager.registerOffscreenGuest({
|
||||
browserPageId,
|
||||
worktreeId: params.worktreeId,
|
||||
sessionProfileId: profile?.id ?? null,
|
||||
userAgentMode: profile?.userAgentMode,
|
||||
webContentsId: win.webContents.id
|
||||
})
|
||||
if (!registered) {
|
||||
// Why destroy rather than carry on: the window already exists but carries none of the guest
|
||||
// policies registration installs, so leaving it would navigate an unvalidated URL with no
|
||||
// policy on it and hand back a page id nothing can drive. The renderer door aborts its mount
|
||||
// the same way; this is that abort.
|
||||
this.windowsByPageId.delete(browserPageId)
|
||||
win.destroy()
|
||||
throw new Error(`Browser page ${browserPageId} was refused`)
|
||||
}
|
||||
|
||||
// Why only once registration took: this teardown unregisters the page id, and a refused id is
|
||||
// one the other authority may own — cancelling its work would be the confusion we just refused.
|
||||
// Why at all: if the window dies out from under us (crash, app teardown), drop the registry
|
||||
// entry so commands fail cleanly instead of resolving a dead WebContents.
|
||||
// Why: if the offscreen window is destroyed out from under us (crash, app
|
||||
// teardown), drop the registry entry so commands fail cleanly instead of
|
||||
// resolving a dead WebContents.
|
||||
win.webContents.once('destroyed', () => {
|
||||
// Explicit close removes the page first and performs awaited cleanup;
|
||||
// only an unexpected destruction still owns the bridge retirement here.
|
||||
@@ -104,6 +80,20 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
this.browserManager.unregisterGuest(browserPageId)
|
||||
})
|
||||
|
||||
// Why: register the guest and return immediately so the new tab appears
|
||||
// without waiting for the page to finish loading. Previously createTab
|
||||
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
|
||||
// up to a second on real URLs. The page loads asynchronously and streams
|
||||
// once it paints; a failed load leaves the (usable) tab open, matching how a
|
||||
// normal browser tab survives a failed navigation.
|
||||
this.browserManager.registerOffscreenGuest({
|
||||
browserPageId,
|
||||
worktreeId: params.worktreeId,
|
||||
sessionProfileId: profile?.id ?? null,
|
||||
userAgentMode: profile?.userAgentMode,
|
||||
webContentsId: win.webContents.id
|
||||
})
|
||||
|
||||
const url = params.url || 'about:blank'
|
||||
void this.loadUrl(win, url).catch((error) => {
|
||||
console.warn(
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
/* eslint-disable max-lines -- Why: WSL CLI status/install/remove share one state machine;
|
||||
splitting the installer would separate conflict checks from the operations they guard. */
|
||||
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { getDefaultWslDistro } from '../wsl'
|
||||
import { runWslProcess } from '../wsl/wsl-runner'
|
||||
import { CliInstaller } from './cli-installer'
|
||||
import {
|
||||
buildManagedLegacyRemoveCommand,
|
||||
buildRegistrationLockPrelude,
|
||||
buildSafeRemoveCommand,
|
||||
buildSafeReplaceGuard,
|
||||
buildWslBridgeScript,
|
||||
buildWslLauncher,
|
||||
getBridgePathFromCommandPath,
|
||||
@@ -18,10 +14,11 @@ import {
|
||||
parseManagedLauncherTarget,
|
||||
quoteShell
|
||||
} from './wsl-cli-scripts'
|
||||
import { buildWslCliInstallCommand } from './wsl-cli-registration-command'
|
||||
import { buildWslCliStatus, readWslCliCommandFile, resolveReadyWslCliState } from './wsl-cli-status'
|
||||
|
||||
const MANAGED_MARKER = getWslLauncherMarker()
|
||||
const BRIDGE_MANAGED_MARKER = getWslBridgeMarker()
|
||||
const WSL_COMMAND_NAME = 'orca-ide'
|
||||
const LEGACY_WSL_COMMAND_NAME = 'orca'
|
||||
const WSL_COMMAND_TIMEOUT_MS = 10_000
|
||||
|
||||
@@ -60,7 +57,12 @@ export class WslCliInstaller {
|
||||
}
|
||||
|
||||
async getStatus(): Promise<CliInstallStatus> {
|
||||
const ready = await this.resolveReadyState()
|
||||
const ready = await resolveReadyWslCliState({
|
||||
platform: this.platform,
|
||||
distro: this.distro,
|
||||
getHostStatus: () => this.hostInstaller.getStatus(),
|
||||
run: (distro, command) => this.run(distro, command)
|
||||
})
|
||||
if ('status' in ready) {
|
||||
return ready.status
|
||||
}
|
||||
@@ -208,67 +210,13 @@ export class WslCliInstaller {
|
||||
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`)
|
||||
}
|
||||
|
||||
// Why: the launcher and PowerShell bridge are one registration; the
|
||||
// command replacement stays a single atomic rename (never missing for a
|
||||
// concurrent shell) while a bridge copy enables rollback of the pair.
|
||||
await this.run(
|
||||
this.distro as string,
|
||||
[
|
||||
// Why -eu not -euo pipefail: transported via runWslProcess's `sh -s`,
|
||||
// and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option.
|
||||
'set -eu',
|
||||
`mkdir -p ${quoteShell(status.pathDirectory as string)}`,
|
||||
`mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`,
|
||||
buildRegistrationLockPrelude(status.commandPath),
|
||||
`command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`,
|
||||
`bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
|
||||
`legacy_command_path=${quoteShell(
|
||||
`${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
|
||||
)}`,
|
||||
'bridge_tmp="${bridge_path}.tmp.$$"',
|
||||
'bridge_backup="${bridge_tmp}.backup"',
|
||||
'bridge_had_original=0',
|
||||
'bridge_touched=0',
|
||||
'committed=0',
|
||||
'rollback() {',
|
||||
' result=$?',
|
||||
' set +e',
|
||||
' if [ "$committed" -ne 1 ]; then',
|
||||
` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; fi`,
|
||||
' fi',
|
||||
' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"',
|
||||
' exit "$result"',
|
||||
'}',
|
||||
'trap rollback EXIT',
|
||||
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
|
||||
buildSafeReplaceGuard(
|
||||
getBridgePathFromCommandPath(status.commandPath),
|
||||
BRIDGE_MANAGED_MARKER
|
||||
),
|
||||
`cat > "$command_tmp" <<'ORCA_WSL_CLI'`,
|
||||
buildWslLauncher(status.launcherPath, getBridgePathFromCommandPath(status.commandPath)),
|
||||
'ORCA_WSL_CLI',
|
||||
`cat > "$bridge_tmp" <<'ORCA_WSL_BRIDGE'`,
|
||||
buildWslBridgeScript(),
|
||||
'ORCA_WSL_BRIDGE',
|
||||
'chmod 755 "$command_tmp"',
|
||||
'chmod 644 "$bridge_tmp"',
|
||||
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
|
||||
buildSafeReplaceGuard(
|
||||
getBridgePathFromCommandPath(status.commandPath),
|
||||
BRIDGE_MANAGED_MARKER
|
||||
),
|
||||
`if [ -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} ]; then cp -p ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} "$bridge_backup"; bridge_had_original=1; fi`,
|
||||
`mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
|
||||
'bridge_touched=1',
|
||||
`mv -f "$command_tmp" ${quoteShell(status.commandPath)}`,
|
||||
'committed=1',
|
||||
'rm -f "$bridge_backup"',
|
||||
// Why: the command was renamed to avoid GNOME Orca; remove only the
|
||||
// old Orca-managed WSL wrapper after the replacement has committed.
|
||||
buildManagedLegacyRemoveCommand('"$legacy_command_path"'),
|
||||
'trap - EXIT'
|
||||
].join('\n')
|
||||
buildWslCliInstallCommand({
|
||||
...status,
|
||||
commandPath: status.commandPath,
|
||||
launcherPath: status.launcherPath
|
||||
})
|
||||
)
|
||||
return this.getStatus()
|
||||
}
|
||||
@@ -299,108 +247,15 @@ export class WslCliInstaller {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
private async resolveReadyState(): Promise<
|
||||
| { status: CliInstallStatus }
|
||||
| {
|
||||
distro: string
|
||||
commandPath: string
|
||||
bridgePath: string
|
||||
launcherPath: string
|
||||
pathConfigured: boolean
|
||||
}
|
||||
> {
|
||||
if (this.platform !== 'win32') {
|
||||
return {
|
||||
status: this.unsupported(
|
||||
'platform_not_supported',
|
||||
'WSL CLI registration is only available on Windows.'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!this.distro) {
|
||||
return {
|
||||
status: this.unsupported('platform_not_supported', 'No WSL distribution is available.')
|
||||
}
|
||||
}
|
||||
|
||||
const hostStatus = await this.hostInstaller.getStatus()
|
||||
if (!hostStatus.launcherPath) {
|
||||
return {
|
||||
status: this.unsupported(
|
||||
hostStatus.unsupportedReason ?? 'launcher_missing',
|
||||
hostStatus.detail ?? 'The Windows Orca CLI launcher is missing.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const home = (await this.run(this.distro, 'printf %s "$HOME"')).trim()
|
||||
if (!home.startsWith('/')) {
|
||||
return {
|
||||
status: this.unsupported('launcher_missing', 'Unable to resolve the WSL home directory.')
|
||||
}
|
||||
}
|
||||
|
||||
const interopReady =
|
||||
(
|
||||
await this.run(
|
||||
this.distro,
|
||||
'{ command -v powershell.exe >/dev/null 2>&1 || [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; } && command -v wslpath >/dev/null 2>&1 && printf yes || printf no'
|
||||
)
|
||||
).trim() === 'yes'
|
||||
if (!interopReady) {
|
||||
return {
|
||||
status: this.unsupported(
|
||||
'launcher_missing',
|
||||
'WSL Windows interop is unavailable; Orca cannot launch the Windows CLI from WSL.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const pathDirectory = `${home}/.local/bin`
|
||||
// Why: matches the Linux CLI rename to `orca-ide` (avoids GNOME Orca conflict).
|
||||
const commandPath = `${pathDirectory}/${WSL_COMMAND_NAME}`
|
||||
const pathConfigured =
|
||||
(
|
||||
await this.run(
|
||||
this.distro,
|
||||
`case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac`
|
||||
)
|
||||
).trim() === 'yes'
|
||||
|
||||
return {
|
||||
distro: this.distro,
|
||||
commandPath,
|
||||
bridgePath: getBridgePathFromCommandPath(commandPath),
|
||||
launcherPath: hostStatus.launcherPath,
|
||||
pathConfigured
|
||||
}
|
||||
}
|
||||
|
||||
private async readCommandFile(
|
||||
distro: string,
|
||||
commandPath: string
|
||||
): Promise<(string & {}) | 'not_file' | null> {
|
||||
const output = await this.run(
|
||||
return readWslCliCommandFile(
|
||||
(targetDistro, command) => this.run(targetDistro, command),
|
||||
distro,
|
||||
[
|
||||
`if [ -L ${quoteShell(commandPath)} ]; then`,
|
||||
' printf __ORCA_NOT_FILE__',
|
||||
`elif [ ! -e ${quoteShell(commandPath)} ]; then`,
|
||||
' printf __ORCA_MISSING__',
|
||||
`elif [ ! -f ${quoteShell(commandPath)} ]; then`,
|
||||
' printf __ORCA_NOT_FILE__',
|
||||
'else',
|
||||
` cat ${quoteShell(commandPath)}`,
|
||||
'fi'
|
||||
].join('\n')
|
||||
commandPath
|
||||
)
|
||||
if (output === '__ORCA_MISSING__') {
|
||||
return null
|
||||
}
|
||||
if (output === '__ORCA_NOT_FILE__') {
|
||||
return 'not_file'
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private buildStatus(args: {
|
||||
@@ -412,43 +267,7 @@ export class WslCliInstaller {
|
||||
pathConfigured: boolean
|
||||
detail: string
|
||||
}): CliInstallStatus {
|
||||
return {
|
||||
platform: 'linux',
|
||||
commandName: WSL_COMMAND_NAME,
|
||||
commandPath: args.commandPath,
|
||||
pathDirectory: getPosixDirname(args.commandPath),
|
||||
pathConfigured: args.pathConfigured,
|
||||
launcherPath: args.launcherPath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: args.state,
|
||||
currentTarget: args.currentTarget,
|
||||
unsupportedReason: null,
|
||||
detail:
|
||||
args.state === 'installed' && !args.pathConfigured
|
||||
? `${args.commandPath} is registered, but ${getPosixDirname(args.commandPath)} is not on PATH in ${args.distro}.`
|
||||
: args.detail
|
||||
}
|
||||
}
|
||||
|
||||
private unsupported(
|
||||
unsupportedReason: NonNullable<CliInstallStatus['unsupportedReason']>,
|
||||
detail: string
|
||||
): CliInstallStatus {
|
||||
return {
|
||||
platform: 'linux',
|
||||
commandName: WSL_COMMAND_NAME,
|
||||
commandPath: null,
|
||||
pathDirectory: null,
|
||||
pathConfigured: false,
|
||||
launcherPath: null,
|
||||
installMethod: null,
|
||||
supported: false,
|
||||
state: 'unsupported',
|
||||
currentTarget: null,
|
||||
unsupportedReason,
|
||||
detail
|
||||
}
|
||||
return buildWslCliStatus(args)
|
||||
}
|
||||
|
||||
private async run(distro: string, command: string): Promise<string> {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import {
|
||||
buildManagedLegacyRemoveCommand,
|
||||
buildRegistrationLockPrelude,
|
||||
buildSafeReplaceGuard,
|
||||
buildWslBridgeScript,
|
||||
buildWslLauncher,
|
||||
getBridgePathFromCommandPath,
|
||||
getPosixDirname,
|
||||
getWslBridgeMarker,
|
||||
getWslLauncherMarker,
|
||||
quoteShell
|
||||
} from './wsl-cli-scripts'
|
||||
|
||||
const MANAGED_MARKER = getWslLauncherMarker()
|
||||
const BRIDGE_MANAGED_MARKER = getWslBridgeMarker()
|
||||
const LEGACY_WSL_COMMAND_NAME = 'orca'
|
||||
|
||||
export function buildWslCliInstallCommand(
|
||||
status: CliInstallStatus & { commandPath: string; launcherPath: string }
|
||||
): string {
|
||||
const bridgePath = getBridgePathFromCommandPath(status.commandPath)
|
||||
const legacyCommandPath = `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
|
||||
return [
|
||||
// Why -eu not -euo pipefail: transported via runWslProcess's `sh -s`,
|
||||
// and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option.
|
||||
'set -eu',
|
||||
`mkdir -p ${quoteShell(status.pathDirectory as string)}`,
|
||||
`mkdir -p ${quoteShell(getPosixDirname(bridgePath))}`,
|
||||
buildRegistrationLockPrelude(status.commandPath),
|
||||
`command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`,
|
||||
`bridge_path=${quoteShell(bridgePath)}`,
|
||||
`legacy_command_path=${quoteShell(legacyCommandPath)}`,
|
||||
'bridge_tmp="${bridge_path}.tmp.$$"',
|
||||
'bridge_backup="${bridge_tmp}.backup"',
|
||||
'bridge_had_original=0',
|
||||
'bridge_touched=0',
|
||||
'committed=0',
|
||||
'rollback() {',
|
||||
' result=$?',
|
||||
' set +e',
|
||||
' if [ "$committed" -ne 1 ]; then',
|
||||
` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(bridgePath)}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(bridgePath)}; fi`,
|
||||
' fi',
|
||||
' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"',
|
||||
' exit "$result"',
|
||||
'}',
|
||||
'trap rollback EXIT',
|
||||
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
|
||||
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
|
||||
`cat > "$command_tmp" <<'ORCA_WSL_CLI'`,
|
||||
buildWslLauncher(status.launcherPath, bridgePath),
|
||||
'ORCA_WSL_CLI',
|
||||
`cat > "$bridge_tmp" <<'ORCA_WSL_BRIDGE'`,
|
||||
buildWslBridgeScript(),
|
||||
'ORCA_WSL_BRIDGE',
|
||||
'chmod 755 "$command_tmp"',
|
||||
'chmod 644 "$bridge_tmp"',
|
||||
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
|
||||
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
|
||||
`if [ -f ${quoteShell(bridgePath)} ]; then cp -p ${quoteShell(bridgePath)} "$bridge_backup"; bridge_had_original=1; fi`,
|
||||
`mv -f "$bridge_tmp" ${quoteShell(bridgePath)}`,
|
||||
'bridge_touched=1',
|
||||
`mv -f "$command_tmp" ${quoteShell(status.commandPath)}`,
|
||||
'committed=1',
|
||||
'rm -f "$bridge_backup"',
|
||||
buildManagedLegacyRemoveCommand('"$legacy_command_path"'),
|
||||
'trap - EXIT'
|
||||
].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { getBridgePathFromCommandPath, getPosixDirname, quoteShell } from './wsl-cli-scripts'
|
||||
|
||||
const WSL_COMMAND_NAME = 'orca-ide'
|
||||
|
||||
export type ReadyWslCliState = {
|
||||
distro: string
|
||||
commandPath: string
|
||||
bridgePath: string
|
||||
launcherPath: string
|
||||
pathConfigured: boolean
|
||||
}
|
||||
|
||||
export async function readWslCliCommandFile(
|
||||
run: (distro: string, command: string) => Promise<string>,
|
||||
distro: string,
|
||||
commandPath: string
|
||||
): Promise<(string & {}) | 'not_file' | null> {
|
||||
const output = await run(
|
||||
distro,
|
||||
[
|
||||
`if [ -L ${quoteShell(commandPath)} ]; then`,
|
||||
' printf __ORCA_NOT_FILE__',
|
||||
`elif [ ! -e ${quoteShell(commandPath)} ]; then`,
|
||||
' printf __ORCA_MISSING__',
|
||||
`elif [ ! -f ${quoteShell(commandPath)} ]; then`,
|
||||
' printf __ORCA_NOT_FILE__',
|
||||
'else',
|
||||
` cat ${quoteShell(commandPath)}`,
|
||||
'fi'
|
||||
].join('\n')
|
||||
)
|
||||
if (output === '__ORCA_MISSING__') {
|
||||
return null
|
||||
}
|
||||
if (output === '__ORCA_NOT_FILE__') {
|
||||
return 'not_file'
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export function buildWslCliStatus(args: {
|
||||
distro: string
|
||||
commandPath: string
|
||||
launcherPath: string
|
||||
state: CliInstallStatus['state']
|
||||
currentTarget: string | null
|
||||
pathConfigured: boolean
|
||||
detail: string
|
||||
}): CliInstallStatus {
|
||||
return {
|
||||
platform: 'linux',
|
||||
commandName: WSL_COMMAND_NAME,
|
||||
commandPath: args.commandPath,
|
||||
pathDirectory: getPosixDirname(args.commandPath),
|
||||
pathConfigured: args.pathConfigured,
|
||||
launcherPath: args.launcherPath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: args.state,
|
||||
currentTarget: args.currentTarget,
|
||||
unsupportedReason: null,
|
||||
detail:
|
||||
args.state === 'installed' && !args.pathConfigured
|
||||
? `${args.commandPath} is registered, but ${getPosixDirname(args.commandPath)} is not on PATH in ${args.distro}.`
|
||||
: args.detail
|
||||
}
|
||||
}
|
||||
|
||||
export function unsupportedWslCliStatus(
|
||||
unsupportedReason: NonNullable<CliInstallStatus['unsupportedReason']>,
|
||||
detail: string
|
||||
): CliInstallStatus {
|
||||
return {
|
||||
platform: 'linux',
|
||||
commandName: WSL_COMMAND_NAME,
|
||||
commandPath: null,
|
||||
pathDirectory: null,
|
||||
pathConfigured: false,
|
||||
launcherPath: null,
|
||||
installMethod: null,
|
||||
supported: false,
|
||||
state: 'unsupported',
|
||||
currentTarget: null,
|
||||
unsupportedReason,
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveReadyWslCliState(args: {
|
||||
platform: NodeJS.Platform
|
||||
distro: string | null
|
||||
getHostStatus: () => Promise<CliInstallStatus>
|
||||
run: (distro: string, command: string) => Promise<string>
|
||||
}): Promise<{ status: CliInstallStatus } | ReadyWslCliState> {
|
||||
if (args.platform !== 'win32') {
|
||||
return {
|
||||
status: unsupportedWslCliStatus(
|
||||
'platform_not_supported',
|
||||
'WSL CLI registration is only available on Windows.'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!args.distro) {
|
||||
return {
|
||||
status: unsupportedWslCliStatus('platform_not_supported', 'No WSL distribution is available.')
|
||||
}
|
||||
}
|
||||
|
||||
const hostStatus = await args.getHostStatus()
|
||||
if (!hostStatus.launcherPath) {
|
||||
return {
|
||||
status: unsupportedWslCliStatus(
|
||||
hostStatus.unsupportedReason ?? 'launcher_missing',
|
||||
hostStatus.detail ?? 'The Windows Orca CLI launcher is missing.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const home = (await args.run(args.distro, 'printf %s "$HOME"')).trim()
|
||||
if (!home.startsWith('/')) {
|
||||
return {
|
||||
status: unsupportedWslCliStatus(
|
||||
'launcher_missing',
|
||||
'Unable to resolve the WSL home directory.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const interopReady =
|
||||
(
|
||||
await args.run(
|
||||
args.distro,
|
||||
'{ command -v powershell.exe >/dev/null 2>&1 || [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; } && command -v wslpath >/dev/null 2>&1 && printf yes || printf no'
|
||||
)
|
||||
).trim() === 'yes'
|
||||
if (!interopReady) {
|
||||
return {
|
||||
status: unsupportedWslCliStatus(
|
||||
'launcher_missing',
|
||||
'WSL Windows interop is unavailable; Orca cannot launch the Windows CLI from WSL.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const pathDirectory = `${home}/.local/bin`
|
||||
const commandPath = `${pathDirectory}/${WSL_COMMAND_NAME}`
|
||||
const pathConfigured =
|
||||
(
|
||||
await args.run(
|
||||
args.distro,
|
||||
`case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac`
|
||||
)
|
||||
).trim() === 'yes'
|
||||
|
||||
return {
|
||||
distro: args.distro,
|
||||
commandPath,
|
||||
bridgePath: getBridgePathFromCommandPath(commandPath),
|
||||
launcherPath: hostStatus.launcherPath,
|
||||
pathConfigured
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* #15192, activation-order hypothesis: "the Orca unicode provider is not reached
|
||||
* in production, so Hangul lays out at the wrong width."
|
||||
*
|
||||
* These pin the measurements that close it. Every precomposed Hangul syllable is
|
||||
* two cells under xterm's Unicode 6 tables, its Unicode 11 tables, and Orca's
|
||||
* provider alike, so no activation order — provider, v11 fallback, or the
|
||||
* untouched v6 default — can change how a syllable is budgeted. Whatever moves
|
||||
* Korean text off its cells is not the unicode version.
|
||||
*
|
||||
* The version-sensitive and oracle-sensitive code points are pinned too, as the
|
||||
* complete list of Hangul-block characters where a width disagreement is even
|
||||
* available. None of them occur in modern Korean prose.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider'
|
||||
import { isWideGlyph } from './__fixtures__/terminal-wide-cell-grid'
|
||||
|
||||
const ORCA_UNICODE_VERSION = 'orca-11-zwj'
|
||||
const HANGUL_SYLLABLES_FIRST = 0xac00
|
||||
const HANGUL_SYLLABLES_LAST = 0xd7a3
|
||||
|
||||
/** Jamo, Compatibility Jamo, Jamo Extended-A, Syllables + Extended-B, halfwidth jamo. */
|
||||
const HANGUL_BLOCKS: [number, number][] = [
|
||||
[0x1100, 0x11ff],
|
||||
[0x3130, 0x318f],
|
||||
[0xa960, 0xa97f],
|
||||
[0xac00, 0xd7ff],
|
||||
[0xffa0, 0xffdc]
|
||||
]
|
||||
|
||||
function* hangulCodePoints(): Generator<number> {
|
||||
for (const [first, last] of HANGUL_BLOCKS) {
|
||||
for (let cp = first; cp <= last; cp += 1) {
|
||||
yield cp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type UnicodeServiceInternals = {
|
||||
versions: string[]
|
||||
activeVersion: string
|
||||
wcwidth(codepoint: number): number
|
||||
charProperties(codepoint: number, preceding: number): number
|
||||
}
|
||||
|
||||
/**
|
||||
* Addon first, activation after — the order every call site uses. This mirrors that
|
||||
* order rather than importing it, so it does not guard the call sites themselves;
|
||||
* `pane-lifecycle.test.ts` covers that the activation runs and runs after the addon.
|
||||
* What is unguarded, and what these cover, is that the real `_core.unicodeService`
|
||||
* shape still lets the activation reach its non-fallback branch on a live terminal.
|
||||
*/
|
||||
function openWithUnicode11AddonLoaded(): {
|
||||
terminal: Terminal
|
||||
unicode: UnicodeServiceInternals
|
||||
} {
|
||||
const terminal = new Terminal({ cols: 40, rows: 10, allowProposedApi: true })
|
||||
terminal.loadAddon(new Unicode11Addon())
|
||||
activateOrcaTerminalUnicodeProvider(terminal as never)
|
||||
const unicode = (
|
||||
terminal as unknown as {
|
||||
_core: { unicodeService: UnicodeServiceInternals }
|
||||
}
|
||||
)._core.unicodeService
|
||||
return { terminal, unicode }
|
||||
}
|
||||
|
||||
/** Width bits of xterm's packed char properties — what the buffer actually budgets. */
|
||||
function propertyWidth(properties: number): number {
|
||||
return (properties >> 1) & 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Contiguous code points carrying the same detail collapse into one line. The detail
|
||||
* is part of the run key so a changed width re-partitions the output instead of
|
||||
* silently reusing a range that only ever asserted "these two disagree somehow".
|
||||
*/
|
||||
function summarize(entries: { codepoint: number; detail: string }[]): string[] {
|
||||
const runs: { start: number; end: number; detail: string }[] = []
|
||||
for (const { codepoint, detail } of entries) {
|
||||
const last = runs.at(-1)
|
||||
if (last && last.end === codepoint - 1 && last.detail === detail) {
|
||||
last.end = codepoint
|
||||
continue
|
||||
}
|
||||
runs.push({ start: codepoint, end: codepoint, detail })
|
||||
}
|
||||
const hex = (value: number): string => `U+${value.toString(16).toUpperCase().padStart(4, '0')}`
|
||||
return runs.map(
|
||||
({ start, end, detail }) =>
|
||||
`${start === end ? hex(start) : `${hex(start)}..${hex(end)}`} ${detail}`
|
||||
)
|
||||
}
|
||||
|
||||
describe('Hangul cell width agreement (#15192)', () => {
|
||||
it('reaches the Orca provider, not the v11 fallback, on a live terminal', () => {
|
||||
const { terminal, unicode } = openWithUnicode11AddonLoaded()
|
||||
expect(unicode.versions).toContain(ORCA_UNICODE_VERSION)
|
||||
expect(unicode.activeVersion).toBe(ORCA_UNICODE_VERSION)
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('budgets every precomposed syllable at two cells under v6, v11 and Orca', () => {
|
||||
const { terminal, unicode } = openWithUnicode11AddonLoaded()
|
||||
const disagreeing: { codepoint: number; detail: string }[] = []
|
||||
for (const version of ['6', '11', ORCA_UNICODE_VERSION]) {
|
||||
unicode.activeVersion = version
|
||||
for (let cp = HANGUL_SYLLABLES_FIRST; cp <= HANGUL_SYLLABLES_LAST; cp += 1) {
|
||||
const wcwidth = unicode.wcwidth(cp)
|
||||
const packed = propertyWidth(unicode.charProperties(cp, 0))
|
||||
if (wcwidth !== 2 || packed !== 2) {
|
||||
disagreeing.push({
|
||||
codepoint: cp,
|
||||
detail: `v${version} wcwidth=${wcwidth} packed=${packed}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(summarize(disagreeing)).toEqual([])
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('records where the wide-cell oracle diverges from xterm on conjoining jamo', () => {
|
||||
const { terminal, unicode } = openWithUnicode11AddonLoaded()
|
||||
const divergent: { codepoint: number; detail: string }[] = []
|
||||
for (const cp of hangulCodePoints()) {
|
||||
const oracle = isWideGlyph(String.fromCodePoint(cp)) ? 2 : 1
|
||||
const xterm = unicode.wcwidth(cp)
|
||||
if (xterm !== oracle) {
|
||||
divergent.push({ codepoint: cp, detail: `oracle=${oracle} xterm=${xterm}` })
|
||||
}
|
||||
}
|
||||
// Why pinned rather than fixed: xterm treats medial/final jamo as zero-width
|
||||
// combining marks, the oracle as one cell — and `isWideGlyph` returns a boolean,
|
||||
// so it cannot express zero-width without changing shape. Only decomposed (NFD)
|
||||
// Korean reaches them and no fixture writes NFD today, so nothing mis-asserts;
|
||||
// this is the tripwire for the fixture that adds it. U+3130/U+318F are unassigned
|
||||
// Compatibility Jamo edges. U+A960..U+A97C is assigned Jamo Extended-A (Old Korean
|
||||
// initials) that xterm makes wide and the oracle narrow — the dangerous direction,
|
||||
// but no fixture uses it. Both widths are asserted so changing either trips this.
|
||||
expect(summarize(divergent)).toEqual([
|
||||
'U+1160..U+11FF oracle=1 xterm=0',
|
||||
'U+3130 oracle=2 xterm=1',
|
||||
'U+318F oracle=2 xterm=1',
|
||||
'U+A960..U+A97C oracle=1 xterm=2'
|
||||
])
|
||||
terminal.dispose()
|
||||
})
|
||||
})
|
||||
@@ -50,6 +50,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LocalPtyUtils>()
|
||||
return {
|
||||
...actual,
|
||||
getNodePtySpawnHelperCandidates: () => [import.meta.filename],
|
||||
resolveUnixShellPath: resolveUnixShellPathMock,
|
||||
validateWorkingDirectory: validateWorkingDirectoryMock,
|
||||
validateWorkingDirectoryAsync: validateWorkingDirectoryMock
|
||||
|
||||
@@ -10,6 +10,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import type * as LocalPtyUtils from '../providers/local-pty-utils'
|
||||
const { spawnMock, isPwshAvailableMock, resolveAgentForegroundProcessMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
isPwshAvailableMock: vi.fn(),
|
||||
@@ -39,6 +40,14 @@ vi.mock('../providers/windows-powershell-executable', () => ({
|
||||
getWindowsCmdPath: () => CMD_ABS
|
||||
}))
|
||||
|
||||
vi.mock('../providers/local-pty-utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LocalPtyUtils>()
|
||||
return {
|
||||
...actual,
|
||||
getNodePtySpawnHelperCandidates: () => [import.meta.filename]
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../providers/agent-foreground-process', () => ({
|
||||
resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => {
|
||||
const value = await resolveAgentForegroundProcessMock(...args)
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LocalPtyUtils>()
|
||||
return {
|
||||
...actual,
|
||||
getNodePtySpawnHelperCandidates: () => [import.meta.filename],
|
||||
resolveUnixShellPath: resolveUnixShellPathMock,
|
||||
validateWorkingDirectory: validateWorkingDirectoryMock,
|
||||
validateWorkingDirectoryAsync: validateWorkingDirectoryMock
|
||||
|
||||
@@ -319,11 +319,6 @@ import { browserCertificateTrustController, browserManager } from './browser/bro
|
||||
import { RpcDispatcher } from './runtime/rpc/dispatcher'
|
||||
import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend'
|
||||
import { initializeBrowserSessionsForApp } from './browser/browser-session-startup'
|
||||
import {
|
||||
installDocPreviewProtocolHandler,
|
||||
registerDocPreviewSchemePrivileges
|
||||
} from './browser/doc-preview-protocol'
|
||||
import { registerDocPreviewGrantHandlers } from './ipc/doc-preview-grant-ipc'
|
||||
import { initializeBrowserClientHostId } from './browser/browser-client-host-id'
|
||||
import { setUnreadDockBadgeCount } from './dock/unread-badge'
|
||||
import { AutomationService } from './automations/service'
|
||||
@@ -967,9 +962,6 @@ if (hasSingleInstanceLock) {
|
||||
if (shouldApplyPreReadyAppName(devInstanceIdentity)) {
|
||||
app.setName(devInstanceIdentity.appName)
|
||||
}
|
||||
// Why: Electron freezes the privileged scheme table at ready, so the doc-preview
|
||||
// scheme must be declared here or its webview loses fetch/secure-origin privileges.
|
||||
registerDocPreviewSchemePrivileges()
|
||||
// Why: must precede app.whenReady() so Crashpad is installed before the
|
||||
// first renderer spawns; a CHECK before this point is still exit-code-only.
|
||||
startCrashpadCapture()
|
||||
@@ -2445,9 +2437,6 @@ void app.whenReady().then(async () => {
|
||||
} catch {
|
||||
console.warn('[proxy] Failed to apply network proxy settings')
|
||||
}
|
||||
// Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches.
|
||||
installDocPreviewProtocolHandler()
|
||||
registerDocPreviewGrantHandlers()
|
||||
// Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path.
|
||||
initializeBrowserSessionsForApp({
|
||||
orcaProfileId: activeOrcaProfile.profile.id,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import { onDocPreviewGrantRevoked } from '../browser/doc-preview-grant-registry'
|
||||
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
|
||||
import { waitForNextTabRegistration } from './browser-tab-registration-wait'
|
||||
import type {
|
||||
@@ -49,25 +48,7 @@ export function disposeGrabModeStateForPage(browserPageId: string): void {
|
||||
grabModeOperationByPageId.delete(browserPageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why previews need their own disposal path: a browser page announces its own death on
|
||||
* `browser:unregisterGuest`, and a preview never does — it withdraws by revoking its grant, which
|
||||
* is also what a re-mint does. Without this, every grant leaves an intent entry behind forever.
|
||||
*/
|
||||
let disposePreviewGrantSubscription: (() => void) | null = null
|
||||
|
||||
function subscribeToPreviewGrantRevocation(): void {
|
||||
disposePreviewGrantSubscription?.()
|
||||
disposePreviewGrantSubscription = onDocPreviewGrantRevoked((grant) => {
|
||||
// Why cancel first: a grab still armed on that guest would otherwise leave the renderer's
|
||||
// await hanging on a surface the reader has already closed.
|
||||
browserManager.cancelGrabOp(grant.browserPageId, 'evicted')
|
||||
disposeGrabModeStateForPage(grant.browserPageId)
|
||||
})
|
||||
}
|
||||
|
||||
export function registerBrowserGrabHandlers(): void {
|
||||
subscribeToPreviewGrantRevocation()
|
||||
ipcMain.removeHandler('browser:setGrabMode')
|
||||
ipcMain.removeHandler('browser:awaitGrabSelection')
|
||||
ipcMain.removeHandler('browser:cancelGrab')
|
||||
|
||||
@@ -85,26 +85,12 @@ export function registerBrowserGuestViewHandlers(): void {
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Why resolve here: this is a tool acting on a guest the reader is looking at, so it answers
|
||||
// for a workspace document too — and routing through the authority also pins the request to
|
||||
// the renderer that owns the target, which page-id-only resolution never checked.
|
||||
const resolveGuest = (): Electron.WebContents | null =>
|
||||
browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id)
|
||||
if (!resolveGuest()) {
|
||||
return false
|
||||
}
|
||||
// Why hand over the resolver rather than that guest: the op is serialized per tab, and the
|
||||
// one it finally runs against must be the one on screen then, not the one this request saw.
|
||||
return browserManager.setAnnotationViewportBridge(
|
||||
args.browserPageId,
|
||||
{
|
||||
enabled: args.enabled,
|
||||
emitViewport: args.emitViewport,
|
||||
markers: args.markers,
|
||||
token: args.token
|
||||
},
|
||||
resolveGuest
|
||||
)
|
||||
return browserManager.setAnnotationViewportBridge(args.browserPageId, {
|
||||
enabled: args.enabled,
|
||||
emitViewport: args.emitViewport,
|
||||
markers: args.markers,
|
||||
token: args.token
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,510 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as DocPreviewGuestPolicyModule from '../browser/doc-preview-guest-policy'
|
||||
import type * as TabRegistrationWaitModule from './browser-tab-registration-wait'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
removeHandlerMock,
|
||||
getAuthorizedGuestMock,
|
||||
setGrabModeMock,
|
||||
awaitGrabSelectionMock,
|
||||
cancelGrabOpMock,
|
||||
captureSelectionScreenshotMock,
|
||||
extractHoverPayloadMock,
|
||||
setAnnotationViewportBridgeMock,
|
||||
openDevToolsMock,
|
||||
setViewportOverrideMock,
|
||||
previewAuthoritySpy,
|
||||
registrationWaitSpy
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
removeHandlerMock: vi.fn(),
|
||||
getAuthorizedGuestMock: vi.fn(),
|
||||
setGrabModeMock: vi.fn().mockResolvedValue(true),
|
||||
awaitGrabSelectionMock: vi.fn().mockResolvedValue({ opId: 'op', kind: 'cancelled' }),
|
||||
cancelGrabOpMock: vi.fn(),
|
||||
captureSelectionScreenshotMock: vi.fn().mockResolvedValue(null),
|
||||
extractHoverPayloadMock: vi.fn().mockResolvedValue(null),
|
||||
setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true),
|
||||
openDevToolsMock: vi.fn().mockResolvedValue(true),
|
||||
setViewportOverrideMock: vi.fn().mockResolvedValue(true),
|
||||
previewAuthoritySpy: vi.fn(),
|
||||
registrationWaitSpy: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromWebContents: vi.fn() },
|
||||
ipcMain: { removeHandler: removeHandlerMock, handle: handleMock },
|
||||
webContents: { fromId: vi.fn(() => ({ isDestroyed: () => false })) }
|
||||
}))
|
||||
|
||||
vi.mock('../browser/browser-manager', () => ({
|
||||
browserCertificateTrustController: { proceed: vi.fn(() => ({ ok: true })) },
|
||||
browserManager: {
|
||||
registerGuest: vi.fn(() => true),
|
||||
attachGuestPolicies: vi.fn(),
|
||||
unregisterGuest: vi.fn(),
|
||||
getGuestWebContentsId: vi.fn(),
|
||||
getWebContentsIdByTabId: vi.fn(() => new Map()),
|
||||
getWorktreeIdForTab: vi.fn(),
|
||||
getAuthorizedGuest: getAuthorizedGuestMock,
|
||||
setGrabMode: setGrabModeMock,
|
||||
awaitGrabSelection: awaitGrabSelectionMock,
|
||||
cancelGrabOp: cancelGrabOpMock,
|
||||
captureSelectionScreenshot: captureSelectionScreenshotMock,
|
||||
extractHoverPayload: extractHoverPayloadMock,
|
||||
setAnnotationViewportBridge: setAnnotationViewportBridgeMock,
|
||||
openDevTools: openDevToolsMock,
|
||||
setViewportOverride: setViewportOverrideMock,
|
||||
cancelDownload: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Why the real policy module behind a spy: the point of these tests is which half of the page
|
||||
// registry a channel reads, so a stub of it would prove nothing about what was consulted.
|
||||
vi.mock('../browser/doc-preview-guest-policy', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof DocPreviewGuestPolicyModule>()
|
||||
return {
|
||||
...actual,
|
||||
getWorkspaceDocPageGuest: (browserPageId: string, senderWebContentsId: number) => {
|
||||
previewAuthoritySpy(browserPageId, senderWebContentsId)
|
||||
return actual.getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Why spy rather than stub: the wait is real machinery the browser-page path still depends on;
|
||||
// only whether a preview target enters it is under test.
|
||||
vi.mock('./browser-tab-registration-wait', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof TabRegistrationWaitModule>()
|
||||
return {
|
||||
...actual,
|
||||
waitForNextTabRegistration: (...args: Parameters<typeof actual.waitForNextTabRegistration>) => {
|
||||
registrationWaitSpy(args[0])
|
||||
return actual.waitForNextTabRegistration(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { registerBrowserHandlers } from './browser'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import {
|
||||
getWorkspaceDocPageGuest,
|
||||
installDocPreviewGuestPolicy
|
||||
} from '../browser/doc-preview-guest-policy'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants,
|
||||
revokeDocPreviewGrant
|
||||
} from '../browser/doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
const HOST_RENDERER_ID = 91
|
||||
const OTHER_RENDERER_ID = 92
|
||||
/** Mirrors browser-grab-ipc's own wait, so elapsing it here settles the same parked request. */
|
||||
const GRAB_REGISTRATION_WAIT_MS = 1_000
|
||||
|
||||
/**
|
||||
* Every `browser:*` invoke channel, split by whether it acts on a guest the reader is looking at
|
||||
* (a tool) or manages browser-page, session and profile state. The split is asserted to be total,
|
||||
* so a new channel cannot be added without deciding which side of the preview seam it belongs on.
|
||||
*/
|
||||
const TOOL_CHANNELS = [
|
||||
'browser:setGrabMode',
|
||||
'browser:awaitGrabSelection',
|
||||
'browser:cancelGrab',
|
||||
'browser:captureSelectionScreenshot',
|
||||
'browser:extractHoverPayload',
|
||||
'browser:setAnnotationViewportBridge'
|
||||
]
|
||||
|
||||
const BROWSER_PAGE_CHANNELS = [
|
||||
'browser:registerGuest',
|
||||
'browser:prepareSshWorkspacePartition',
|
||||
'browser:repairGuestRegistration',
|
||||
'browser:isGuestRegistered',
|
||||
'browser:unregisterGuest',
|
||||
'browser:respondWebAuthnAccount',
|
||||
'browser:proceedCertificate',
|
||||
'browser:activeTabChanged',
|
||||
'browser:openDevTools',
|
||||
'browser:setViewportOverride',
|
||||
'browser:publishClientPageMetadata',
|
||||
'browser:cancelDownload',
|
||||
'browser:session:listProfiles',
|
||||
'browser:session:createProfile',
|
||||
'browser:session:deleteProfile',
|
||||
'browser:session:importCookies',
|
||||
'browser:session:resolvePartition',
|
||||
'browser:session:clearDefaultCookies',
|
||||
'browser:session:importFromBrowserForClientHost',
|
||||
'browser:session:clientRouteImportSources',
|
||||
'browser:session:detectBrowsers',
|
||||
'browser:session:detectBrowsersForClientHost',
|
||||
'browser:session:importFromBrowser'
|
||||
]
|
||||
|
||||
type Handler = (event: { sender: Electron.WebContents }, args: unknown) => unknown
|
||||
|
||||
function registeredHandlers(): Map<string, Handler> {
|
||||
const handlers = new Map<string, Handler>()
|
||||
for (const [channel, handler] of handleMock.mock.calls as [string, Handler][]) {
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
function trustedSender(id: number): { sender: Electron.WebContents } {
|
||||
return {
|
||||
sender: {
|
||||
id,
|
||||
isDestroyed: () => false,
|
||||
getType: () => 'window',
|
||||
getURL: () => 'file:///renderer/index.html'
|
||||
} as unknown as Electron.WebContents
|
||||
}
|
||||
}
|
||||
|
||||
let nextDocPageOrdinal = 0
|
||||
|
||||
function grantForNewDocPage(): { id: string; browserPageId: string } {
|
||||
nextDocPageOrdinal += 1
|
||||
const browserPageId = `doc-page-${nextDocPageOrdinal}`
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
return { id: grant.id, browserPageId }
|
||||
}
|
||||
|
||||
/** A preview guest already showing its document, which is the only state a tool can act in. */
|
||||
function renderPreviewForGrant(
|
||||
grant: { id: string; browserPageId: string },
|
||||
hostId: number = HOST_RENDERER_ID
|
||||
): {
|
||||
grantId: string
|
||||
browserPageId: string
|
||||
contents: object
|
||||
markContentsDestroyed: () => void
|
||||
} {
|
||||
const browserPageId = grant.browserPageId
|
||||
const handlers: Record<string, (...args: never[]) => void> = {}
|
||||
const register = (event: string, handler: (...args: never[]) => void): void => {
|
||||
handlers[event] = handler
|
||||
}
|
||||
// Why the guest already reports its URL: the embedder hands a preview over mid-load, so this is
|
||||
// the state the policy really installs into.
|
||||
const documentUrl = buildDocPreviewUrl(grant.id, 'index.html')
|
||||
let contentsDestroyed = false
|
||||
const guest = {
|
||||
isFocused: () => true,
|
||||
isDestroyed: () => contentsDestroyed,
|
||||
getURL: () => documentUrl,
|
||||
on: vi.fn(register),
|
||||
once: vi.fn(register),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: hostId, send: vi.fn() })
|
||||
handlers['did-start-navigation']?.({ url: documentUrl, isMainFrame: true } as never)
|
||||
return {
|
||||
grantId: grant.id,
|
||||
browserPageId,
|
||||
contents: guest,
|
||||
// Why without the `destroyed` event: Chromium tears the contents down before main runs that
|
||||
// listener, so this is the window the authority has to answer for on its own.
|
||||
markContentsDestroyed: () => {
|
||||
contentsDestroyed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function liveRenderedPreview(
|
||||
hostId: number = HOST_RENDERER_ID
|
||||
): ReturnType<typeof renderPreviewForGrant> {
|
||||
return renderPreviewForGrant(grantForNewDocPage(), hostId)
|
||||
}
|
||||
|
||||
/** Minimal well-formed args per channel, so a refusal is authorization and not shape validation. */
|
||||
function toolArgs(channel: string, browserPageId: string): Record<string, unknown> {
|
||||
switch (channel) {
|
||||
case 'browser:setGrabMode':
|
||||
return { browserPageId, enabled: true }
|
||||
case 'browser:awaitGrabSelection':
|
||||
return { browserPageId, opId: 'op-1' }
|
||||
case 'browser:captureSelectionScreenshot':
|
||||
return { browserPageId, rect: { x: 0, y: 0, width: 10, height: 10 } }
|
||||
case 'browser:setAnnotationViewportBridge':
|
||||
return {
|
||||
browserPageId,
|
||||
enabled: true,
|
||||
emitViewport: true,
|
||||
markers: [],
|
||||
token: 'annotation-bridge-token-1'
|
||||
}
|
||||
default:
|
||||
return { browserPageId }
|
||||
}
|
||||
}
|
||||
|
||||
/** The viewport bridge is handed a resolver rather than the contents, so unwrap one call argument. */
|
||||
function resolvesToGuest(argument: unknown, guest: object): boolean {
|
||||
return argument === guest || (typeof argument === 'function' && argument() === guest)
|
||||
}
|
||||
|
||||
const GUEST_RECEIVING_MOCKS = [
|
||||
setGrabModeMock,
|
||||
awaitGrabSelectionMock,
|
||||
captureSelectionScreenshotMock,
|
||||
extractHoverPayloadMock,
|
||||
setAnnotationViewportBridgeMock
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('ELECTRON_RENDERER_URL', '')
|
||||
// Why before clearing: revoking the previous test's grants disposes their page state through the
|
||||
// mocked manager, and those calls belong to that test, not this one.
|
||||
revokeAllDocPreviewGrants()
|
||||
vi.clearAllMocks()
|
||||
// Why the mocked manager still answers for documents: this file's subject is which channels may
|
||||
// reach a document guest, not how the manager splits its registry — that lives in
|
||||
// browser-manager-guest-policy-profile.test.ts, against the real manager.
|
||||
getAuthorizedGuestMock.mockImplementation((browserPageId: string, senderWebContentsId: number) =>
|
||||
getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
|
||||
)
|
||||
setGrabModeMock.mockResolvedValue(true)
|
||||
awaitGrabSelectionMock.mockResolvedValue({ opId: 'op-1', kind: 'cancelled' })
|
||||
captureSelectionScreenshotMock.mockResolvedValue(null)
|
||||
extractHoverPayloadMock.mockResolvedValue(null)
|
||||
setAnnotationViewportBridgeMock.mockResolvedValue(true)
|
||||
registerBrowserHandlers()
|
||||
// Why fake timers for the whole file: a tool asking for a page whose guest has not attached parks
|
||||
// in the registration wait, and several cases here are exactly the ones that never attach. Real
|
||||
// time would spend that wait for each of them.
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** Settles a tool request, elapsing the registration wait it may be parked in. */
|
||||
async function settle<T>(pending: Promise<T> | T | undefined): Promise<T | undefined> {
|
||||
await vi.advanceTimersByTimeAsync(GRAB_REGISTRATION_WAIT_MS)
|
||||
return pending
|
||||
}
|
||||
|
||||
describe('doc preview tool authorization', () => {
|
||||
it('classifies every registered browser channel as a tool or a browser-page channel', () => {
|
||||
const registered = [...registeredHandlers().keys()].sort()
|
||||
const classified = [...TOOL_CHANNELS, ...BROWSER_PAGE_CHANNELS].sort()
|
||||
|
||||
expect(registered).toEqual(classified)
|
||||
})
|
||||
|
||||
it.each(TOOL_CHANNELS)('drives the preview guest from %s', async (channel) => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
|
||||
|
||||
// Why assert on the guest and not on a return value: the whole point of the seam is which
|
||||
// WebContents the tool ends up acting on.
|
||||
const receivedGuest = GUEST_RECEIVING_MOCKS.some((mock) =>
|
||||
mock.mock.calls.some((args) => args.some((arg) => resolvesToGuest(arg, preview.contents)))
|
||||
)
|
||||
expect(receivedGuest || cancelGrabOpMock.mock.calls.length > 0).toBe(true)
|
||||
})
|
||||
|
||||
// The load-bearing containment claim: page and session management never reads the document half
|
||||
// of the registry, so a document page can never be resolved as, or managed like, a browsing one.
|
||||
// Nothing here is a per-channel guard — a document guest is simply not in the map these read.
|
||||
it.each(BROWSER_PAGE_CHANNELS)('never reads the document registry from %s', async (channel) => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
try {
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: preview.browserPageId,
|
||||
profileId: preview.browserPageId,
|
||||
environmentId: preview.browserPageId
|
||||
})
|
||||
} catch {
|
||||
// A malformed-for-this-channel payload may throw; the claim is about which registry was read.
|
||||
}
|
||||
|
||||
expect(previewAuthoritySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(TOOL_CHANNELS)(
|
||||
'refuses %s from a renderer that does not host the preview',
|
||||
async (channel) => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
await settle(
|
||||
handler?.(trustedSender(OTHER_RENDERER_ID), toolArgs(channel, preview.browserPageId))
|
||||
)
|
||||
|
||||
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
|
||||
expect(mock).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(TOOL_CHANNELS)('refuses %s for a page no preview rendered', async (channel) => {
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
await settle(
|
||||
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, 'doc-page-unrendered'))
|
||||
)
|
||||
|
||||
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
|
||||
expect(mock).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
// Why the contents check has to be its own condition: the grant is still live and the sender is
|
||||
// still the host, so nothing else in the authority notices that the guest is gone.
|
||||
it.each(TOOL_CHANNELS)('refuses %s once the preview contents are destroyed', async (channel) => {
|
||||
const preview = liveRenderedPreview()
|
||||
preview.markContentsDestroyed()
|
||||
const handler = registeredHandlers().get(channel)
|
||||
|
||||
await settle(
|
||||
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
|
||||
)
|
||||
|
||||
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
|
||||
expect(mock).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
// Why this inverts what the split registries used to assert: a document page now registers under
|
||||
// its own id, so the wait is reachable and useful — a tool opened while the guest is still
|
||||
// attaching parks here instead of answering not-ready at the reader.
|
||||
it('waits for a document page whose guest has not attached yet, and arms when it does', async () => {
|
||||
const grant = grantForNewDocPage()
|
||||
const handler = registeredHandlers().get('browser:setGrabMode')
|
||||
|
||||
const pending = handler?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: grant.browserPageId,
|
||||
enabled: true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(registrationWaitSpy).toHaveBeenCalledWith(grant.browserPageId)
|
||||
|
||||
const preview = renderPreviewForGrant(grant)
|
||||
|
||||
await expect(pending).resolves.toEqual({ ok: true })
|
||||
expect(setGrabModeMock).toHaveBeenCalledWith(grant.browserPageId, true, preview.contents)
|
||||
})
|
||||
|
||||
// The absence half, with the same presence precondition: a page nothing ever renders still gives
|
||||
// the reader an answer rather than hanging on the full timeout.
|
||||
it('answers not-ready once the wait for an unrendered page elapses', async () => {
|
||||
const handler = registeredHandlers().get('browser:setGrabMode')
|
||||
|
||||
await expect(
|
||||
settle(
|
||||
handler?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: 'doc-page-never-rendered',
|
||||
enabled: true
|
||||
})
|
||||
)
|
||||
).resolves.toEqual({ ok: false, reason: 'not-ready' })
|
||||
})
|
||||
|
||||
it('still waits for a browser page whose registration may be in flight', async () => {
|
||||
const handler = registeredHandlers().get('browser:setGrabMode')
|
||||
|
||||
await settle(
|
||||
handler?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: 'browser-page-1',
|
||||
enabled: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(registrationWaitSpy).toHaveBeenCalledWith('browser-page-1')
|
||||
})
|
||||
|
||||
// Why the grant and not the guest: a preview withdraws by revoking, which is also what a
|
||||
// re-mint does, and nothing else tells main that this tool target will never be used again.
|
||||
it('disposes the grab state a preview target accumulated when its grant is revoked', async () => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get('browser:setGrabMode')
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: preview.browserPageId,
|
||||
enabled: true
|
||||
})
|
||||
cancelGrabOpMock.mockClear()
|
||||
|
||||
revokeDocPreviewGrant(preview.grantId)
|
||||
|
||||
expect(cancelGrabOpMock).toHaveBeenCalledWith(preview.browserPageId, 'evicted')
|
||||
})
|
||||
|
||||
// Why both halves of this door: the manager refuses a preview id on its own, but the grab
|
||||
// disposal beside it takes a renderer-supplied id, and the intent it drops is compared by
|
||||
// identity — dropping it makes the grab settle ok without ever arming the guest.
|
||||
it('leaves an in-flight preview grab armed when unregisterGuest names its target', async () => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handlers = registeredHandlers()
|
||||
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: preview.browserPageId,
|
||||
enabled: true
|
||||
})
|
||||
|
||||
// Why synchronously here: the intent is recorded before the queued operation runs, so this is
|
||||
// the exact window in which a disposal at this door would be invisible to the caller.
|
||||
const unregistered = handlers.get('browser:unregisterGuest')?.(
|
||||
trustedSender(HOST_RENDERER_ID),
|
||||
{ browserPageId: preview.browserPageId }
|
||||
)
|
||||
|
||||
// Why the guest and not the result: a dropped intent settles as ok either way, so only the
|
||||
// guest actually being driven separates an armed grab from a silent no-op.
|
||||
await expect(pending).resolves.toEqual({ ok: true })
|
||||
expect(setGrabModeMock).toHaveBeenCalledWith(preview.browserPageId, true, preview.contents)
|
||||
expect(unregistered).toBe(false)
|
||||
expect(vi.mocked(browserManager.unregisterGuest)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The converse, so the guard above cannot be widened into a door that stops closing tabs.
|
||||
it('still disposes a browser page grab through the same door', async () => {
|
||||
getAuthorizedGuestMock.mockReturnValue({ isDestroyed: () => false })
|
||||
const handlers = registeredHandlers()
|
||||
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
|
||||
browserPageId: 'browser-page-1',
|
||||
enabled: true
|
||||
})
|
||||
|
||||
const unregistered = handlers.get('browser:unregisterGuest')?.(
|
||||
trustedSender(HOST_RENDERER_ID),
|
||||
{ browserPageId: 'browser-page-1' }
|
||||
)
|
||||
|
||||
expect(unregistered).toBe(true)
|
||||
await expect(pending).resolves.toEqual({ ok: true })
|
||||
expect(setGrabModeMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(browserManager.unregisterGuest)).toHaveBeenCalledWith('browser-page-1')
|
||||
})
|
||||
|
||||
// Why with a live preview standing beside it: the halves are looked up in one door now, so the
|
||||
// thing to prove is that a browsing id is not answered by whatever document happens to be open.
|
||||
it('never hands a browsing page id the guest of a document that is open', async () => {
|
||||
const preview = liveRenderedPreview()
|
||||
const handler = registeredHandlers().get('browser:extractHoverPayload')
|
||||
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: 'browser-page-1' })
|
||||
|
||||
expect(getAuthorizedGuestMock).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
|
||||
expect(previewAuthoritySpy).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
|
||||
expect(extractHoverPayloadMock).not.toHaveBeenCalled()
|
||||
// The presence half: the same channel does reach that guest under the page it really renders.
|
||||
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: preview.browserPageId })
|
||||
expect(extractHoverPayloadMock).toHaveBeenCalledWith(preview.browserPageId, preview.contents)
|
||||
})
|
||||
})
|
||||
@@ -1,108 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getWebContentsIdByTabId: vi.fn(() => new Map<string, number>()),
|
||||
getWorktreeIdForTab: vi.fn(() => undefined as string | undefined),
|
||||
getGuestWebContentsId: vi.fn(() => null as number | null),
|
||||
webContentsFromId: vi.fn(() => null as unknown)
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ webContents: { fromId: mocks.webContentsFromId } }))
|
||||
vi.mock('../browser/browser-manager', () => ({
|
||||
browserManager: {
|
||||
getWebContentsIdByTabId: mocks.getWebContentsIdByTabId,
|
||||
getWorktreeIdForTab: mocks.getWorktreeIdForTab,
|
||||
getGuestWebContentsId: mocks.getGuestWebContentsId
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
waitForAnyTabRegistration,
|
||||
waitForNextTabRegistration,
|
||||
waitForWorktreeTabRegistration
|
||||
} from './browser-tab-registration-wait'
|
||||
import { installDocPreviewGuestPolicy } from '../browser/doc-preview-guest-policy'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants
|
||||
} from '../browser/doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
const WAIT_MS = 1_000
|
||||
|
||||
/** Registers a document guest the way the attach door does, which is what notifies the waiters. */
|
||||
function attachDocumentGuest(browserPageId: string): void {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
const guest = {
|
||||
isFocused: () => false,
|
||||
isDestroyed: () => false,
|
||||
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: 91, send: vi.fn() })
|
||||
}
|
||||
|
||||
/** Whether a wait has settled, without letting its rejection escape as an unhandled one. */
|
||||
function track(pending: Promise<void>): () => 'pending' | 'resolved' | 'rejected' {
|
||||
let state: 'pending' | 'resolved' | 'rejected' = 'pending'
|
||||
pending.then(
|
||||
() => {
|
||||
state = 'resolved'
|
||||
},
|
||||
() => {
|
||||
state = 'rejected'
|
||||
}
|
||||
)
|
||||
return () => state
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
revokeAllDocPreviewGrants()
|
||||
vi.clearAllMocks()
|
||||
mocks.getWebContentsIdByTabId.mockReturnValue(new Map())
|
||||
mocks.getGuestWebContentsId.mockReturnValue(null)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('a workspace document registering', () => {
|
||||
it('settles a wait that already names its page', async () => {
|
||||
const settled = track(waitForNextTabRegistration('doc-page-1', WAIT_MS))
|
||||
|
||||
attachDocumentGuest('doc-page-1')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(settled()).toBe('resolved')
|
||||
})
|
||||
|
||||
// Why these two stay pending: they are how the CLI and agents ask for a browser tab to drive, and
|
||||
// a preview is neither drivable by them nor visible to them. Satisfying either would hand the
|
||||
// caller a surface that answers nothing, instead of letting it keep waiting for a real tab.
|
||||
it('leaves the waits that ask for any browser tab still waiting', async () => {
|
||||
const worktreeWait = track(waitForWorktreeTabRegistration('wt-1', WAIT_MS))
|
||||
const anyWait = track(waitForAnyTabRegistration(WAIT_MS))
|
||||
|
||||
attachDocumentGuest('doc-page-2')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(worktreeWait()).toBe('pending')
|
||||
expect(anyWait()).toBe('pending')
|
||||
|
||||
// The presence half: those waits do settle, so a mutant that never resolves them would pass the
|
||||
// assertions above by being uniformly stuck.
|
||||
await vi.advanceTimersByTimeAsync(WAIT_MS)
|
||||
expect(worktreeWait()).toBe('rejected')
|
||||
expect(anyWait()).toBe('rejected')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { webContents } from 'electron'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import { onWorkspaceDocGuestRegistered } from '../browser/doc-preview-guest-policy'
|
||||
|
||||
// Why: CLI-driven tab creation must wait until the renderer mounts the webview
|
||||
// and calls registerGuest, so the tab has a webContentsId and is operable by
|
||||
@@ -110,18 +109,6 @@ export function waitForAnyTabRegistration(timeoutMs = 8_000): Promise<void> {
|
||||
return waitForRegistrationSet(pendingAnyTabRegistrations, timeoutMs, () => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a document page resolves only its own waiters: the worktree-wide and any-tab waits are how
|
||||
* the CLI and agents ask for a browser tab to drive, and a preview is neither drivable by them nor
|
||||
* visible to them. Only a request already naming this page — a tool the reader opened on the
|
||||
* document — is waiting for this.
|
||||
*/
|
||||
onWorkspaceDocGuestRegistered((browserPageId) => {
|
||||
const pendingResolves = pendingTabRegistrations.get(browserPageId)
|
||||
pendingTabRegistrations.delete(browserPageId)
|
||||
resolvePendingRegistrations(pendingResolves)
|
||||
})
|
||||
|
||||
export function resolveTabRegistrationWaiters(browserPageId: string, worktreeId: string): void {
|
||||
const pendingResolves = pendingTabRegistrations.get(browserPageId)
|
||||
pendingTabRegistrations.delete(browserPageId)
|
||||
|
||||
@@ -794,8 +794,6 @@ describe('registerBrowserHandlers', () => {
|
||||
|
||||
it('validates annotation viewport bridge requests before syncing to the guest', async () => {
|
||||
registerBrowserHandlers()
|
||||
const guest = { isDestroyed: () => false } as Electron.WebContents
|
||||
getAuthorizedGuestMock.mockReturnValue(guest)
|
||||
|
||||
const syncHandler = handleMock.mock.calls.find(
|
||||
([channel]) => channel === 'browser:setAnnotationViewportBridge'
|
||||
@@ -820,52 +818,12 @@ describe('registerBrowserHandlers', () => {
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
{
|
||||
emitViewport: false,
|
||||
enabled: true,
|
||||
markers: [],
|
||||
token: 'annotationviewporttoken'
|
||||
},
|
||||
// Why a resolver and not the guest: the op is serialized per page, so it has to read the
|
||||
// registry when it runs — a navigation while it waited may have swapped the contents.
|
||||
// Which guest it then resolves is pinned in browser-manager-annotation-bridge.test.ts.
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
// Why this is new: the channel used to hand a page id straight to the manager, so any trusted
|
||||
// renderer could drive any page's guest. It now resolves through the same authority the grab
|
||||
// channels use, which pins the request to the renderer that registered the page.
|
||||
it('refuses an annotation viewport bridge request from a renderer that does not own the page', async () => {
|
||||
registerBrowserHandlers()
|
||||
getAuthorizedGuestMock.mockReturnValue(null)
|
||||
|
||||
const syncHandler = handleMock.mock.calls.find(
|
||||
([channel]) => channel === 'browser:setAnnotationViewportBridge'
|
||||
)?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => Promise<boolean> | boolean
|
||||
|
||||
const result = await syncHandler(
|
||||
{
|
||||
sender: {
|
||||
id: 91,
|
||||
isDestroyed: () => false,
|
||||
getType: () => 'window',
|
||||
getURL: () => 'file:///renderer/index.html'
|
||||
} as Electron.WebContents
|
||||
},
|
||||
{
|
||||
browserPageId: 'page-1',
|
||||
emitViewport: false,
|
||||
enabled: true,
|
||||
markers: [],
|
||||
token: 'annotationviewporttoken'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(setAnnotationViewportBridgeMock).not.toHaveBeenCalled()
|
||||
expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith('page-1', {
|
||||
emitViewport: false,
|
||||
enabled: true,
|
||||
markers: [],
|
||||
token: 'annotationviewporttoken'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid annotation viewport bridge requests', async () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ipcMain, webContents } from 'electron'
|
||||
import { browserCertificateTrustController, browserManager } from '../browser/browser-manager'
|
||||
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
|
||||
import { browserSessionRegistry } from '../browser/browser-session-registry'
|
||||
import { isWorkspaceDocPageId } from '../browser/doc-preview-guest-policy'
|
||||
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
|
||||
import {
|
||||
isLiveBrowserWebContentsId,
|
||||
@@ -161,13 +160,6 @@ export function registerBrowserHandlers(): void {
|
||||
if (!isTrustedBrowserRenderer(event.sender)) {
|
||||
return false
|
||||
}
|
||||
// Why the whole door and not just the manager call: a document page shares this renderer, and
|
||||
// the grab disposal below drops the intent an in-flight preview grab compares by identity —
|
||||
// that grab would then answer ok without ever arming. A document page withdraws by revoking
|
||||
// its grant, so its id arriving here is misaddressed however it got here.
|
||||
if (typeof args?.browserPageId !== 'string' || isWorkspaceDocPageId(args.browserPageId)) {
|
||||
return false
|
||||
}
|
||||
// Why: notify bridge before unregistering so it can destroy the session
|
||||
// process and proxy. Must happen before unregisterGuest clears the mapping.
|
||||
const wcId = browserManager.getGuestWebContentsId(args.browserPageId)
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
|
||||
listeners: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
|
||||
isTrustedBrowserRenderer: vi.fn(),
|
||||
reportDocPreviewLinkClick: vi.fn(),
|
||||
getGuestWebContentsId: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
mocks.handlers.set(channel, handler)
|
||||
},
|
||||
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
mocks.listeners.set(channel, listener)
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./browser-renderer-trust', () => ({
|
||||
isTrustedBrowserRenderer: mocks.isTrustedBrowserRenderer
|
||||
}))
|
||||
vi.mock('../browser/doc-preview-guest-policy', () => ({
|
||||
reportDocPreviewLinkClick: mocks.reportDocPreviewLinkClick
|
||||
}))
|
||||
vi.mock('../browser/browser-manager', () => ({
|
||||
browserManager: { getGuestWebContentsId: mocks.getGuestWebContentsId }
|
||||
}))
|
||||
|
||||
import {
|
||||
registerDocPreviewGrantHandlers,
|
||||
type DocPreviewGrantRequest
|
||||
} from './doc-preview-grant-ipc'
|
||||
import {
|
||||
getDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants
|
||||
} from '../browser/doc-preview-grant-registry'
|
||||
import {
|
||||
DOC_PREVIEW_LINK_CLICK_CHANNEL,
|
||||
DOC_PREVIEW_MINT_GRANT_CHANNEL,
|
||||
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
|
||||
parseDocPreviewUrl
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
|
||||
const REQUEST: DocPreviewGrantRequest = {
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'doc-page-1'
|
||||
}
|
||||
|
||||
const sender = { id: 7 }
|
||||
|
||||
function mint(request: DocPreviewGrantRequest = REQUEST): { grantId: string; url: string } {
|
||||
const handler = mocks.handlers.get(DOC_PREVIEW_MINT_GRANT_CHANNEL)
|
||||
if (!handler) {
|
||||
throw new Error('mint handler not registered')
|
||||
}
|
||||
return handler({ sender }, request) as { grantId: string; url: string }
|
||||
}
|
||||
|
||||
function revoke(grantId: string): boolean {
|
||||
const handler = mocks.handlers.get(DOC_PREVIEW_REVOKE_GRANT_CHANNEL)
|
||||
if (!handler) {
|
||||
throw new Error('revoke handler not registered')
|
||||
}
|
||||
return handler({ sender }, grantId) as boolean
|
||||
}
|
||||
|
||||
function reportLinkClick(url: unknown): void {
|
||||
const listener = mocks.listeners.get(DOC_PREVIEW_LINK_CLICK_CHANNEL)
|
||||
if (!listener) {
|
||||
throw new Error('link click listener not registered')
|
||||
}
|
||||
listener({ sender }, url)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.handlers.clear()
|
||||
mocks.listeners.clear()
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
mocks.isTrustedBrowserRenderer.mockReturnValue(true)
|
||||
mocks.getGuestWebContentsId.mockReturnValue(null)
|
||||
registerDocPreviewGrantHandlers()
|
||||
})
|
||||
|
||||
describe('document preview grant handlers', () => {
|
||||
it('mints a grant addressable by the URL it returns', () => {
|
||||
const result = mint()
|
||||
|
||||
expect(parseDocPreviewUrl(result.url)).toEqual({
|
||||
grantId: result.grantId,
|
||||
relativePath: 'index.html'
|
||||
})
|
||||
expect(getDocPreviewGrant(result.grantId)?.root).toBe('/home/alice/docs')
|
||||
expect(mocks.isTrustedBrowserRenderer).toHaveBeenCalledWith(sender)
|
||||
})
|
||||
|
||||
it('revokes a grant it minted', () => {
|
||||
const result = mint()
|
||||
|
||||
expect(revoke(result.grantId)).toBe(true)
|
||||
expect(getDocPreviewGrant(result.grantId)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a request that names no root, entry document or page', () => {
|
||||
expect(() => mint({ ...REQUEST, root: ' ' })).toThrow(/Invalid/)
|
||||
expect(() => mint({ ...REQUEST, entryRelativePath: '' })).toThrow(/Invalid/)
|
||||
expect(() => mint({ ...REQUEST, browserPageId: ' ' })).toThrow(/Invalid/)
|
||||
})
|
||||
|
||||
// Why this is refused here and not left to registration: this is where a page becomes a document
|
||||
// page, and the two halves of the page registry have to stay disjoint. A page already hosting a
|
||||
// browsing guest would otherwise resolve in both, and the tool door prefers the document one.
|
||||
it('refuses to make a page that already hosts a browsing guest into a document page', () => {
|
||||
mocks.getGuestWebContentsId.mockReturnValue(42)
|
||||
|
||||
expect(() => mint()).toThrow(/browsing page/)
|
||||
expect(mocks.getGuestWebContentsId).toHaveBeenCalledWith('doc-page-1')
|
||||
})
|
||||
|
||||
// Why: this channel hands out filesystem-read authority, so an untrusted sender must leave with
|
||||
// nothing rather than with a grant id the scheme handler would honor.
|
||||
it('mints nothing for a sender that is not the trusted renderer', () => {
|
||||
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
|
||||
|
||||
expect(() => mint()).toThrow(/Untrusted/)
|
||||
})
|
||||
|
||||
it('refuses to revoke on behalf of a sender that is not the trusted renderer', () => {
|
||||
const result = mint()
|
||||
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
|
||||
|
||||
expect(revoke(result.grantId)).toBe(false)
|
||||
expect(getDocPreviewGrant(result.grantId)).not.toBeNull()
|
||||
})
|
||||
|
||||
// Why this channel skips the trusted-renderer check: its sender is a preview guest rendering a
|
||||
// workspace document, which is the untrusted side by construction. The guest policy holds the
|
||||
// gate, so all this listener owes is the sender and a string.
|
||||
it('hands a reported link click to the guest policy with the sender that reported it', () => {
|
||||
reportLinkClick('https://example.com/docs')
|
||||
|
||||
expect(mocks.reportDocPreviewLinkClick).toHaveBeenCalledExactlyOnceWith(
|
||||
sender,
|
||||
'https://example.com/docs'
|
||||
)
|
||||
expect(mocks.isTrustedBrowserRenderer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a reported click that is not a string at all', () => {
|
||||
reportLinkClick({ url: 'https://example.com/docs' })
|
||||
reportLinkClick(undefined)
|
||||
|
||||
expect(mocks.reportDocPreviewLinkClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
buildDocPreviewUrl,
|
||||
DOC_PREVIEW_LINK_CLICK_CHANNEL,
|
||||
DOC_PREVIEW_MINT_GRANT_CHANNEL,
|
||||
DOC_PREVIEW_REVOKE_GRANT_CHANNEL
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import { reportDocPreviewLinkClick } from '../browser/doc-preview-guest-policy'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeDocPreviewGrant,
|
||||
type DocPreviewOwner
|
||||
} from '../browser/doc-preview-grant-registry'
|
||||
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
|
||||
|
||||
export type DocPreviewGrantRequest = {
|
||||
owner: DocPreviewOwner
|
||||
/** Containing directory of the opened document, on the owning host. */
|
||||
root: string
|
||||
/** Opened document, relative to `root`. */
|
||||
entryRelativePath: string
|
||||
/** Browser page the reader is opening the document in; main registers the guest under it. */
|
||||
browserPageId: string
|
||||
}
|
||||
|
||||
export type DocPreviewGrantResult = { grantId: string; url: string }
|
||||
|
||||
function isValidGrantRequest(request: DocPreviewGrantRequest): boolean {
|
||||
if (!request.root.trim() || !request.entryRelativePath.trim()) {
|
||||
return false
|
||||
}
|
||||
if (typeof request.browserPageId !== 'string' || !request.browserPageId.trim()) {
|
||||
return false
|
||||
}
|
||||
if (request.owner.kind === 'ssh') {
|
||||
return Boolean(request.owner.connectionId.trim())
|
||||
}
|
||||
return Boolean(
|
||||
request.owner.environmentId.trim() &&
|
||||
request.owner.worktreeSelector.trim() &&
|
||||
request.owner.worktreeRoot.trim()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Minting never widens what the renderer can already read: an SSH grant reads
|
||||
* through the same provider as `fs:readFile`, and a runtime grant through the
|
||||
* same worktree-scoped `files.read` RPC the renderer can call directly.
|
||||
*/
|
||||
export function registerDocPreviewGrantHandlers(): void {
|
||||
ipcMain.handle(
|
||||
DOC_PREVIEW_MINT_GRANT_CHANNEL,
|
||||
(event, request: DocPreviewGrantRequest): DocPreviewGrantResult => {
|
||||
// Why gate a channel guests cannot reach today: this one hands out filesystem-read
|
||||
// authority, so it holds the same sender check its sibling browser channels do rather than
|
||||
// relying on guests never gaining an ipcRenderer.
|
||||
if (!isTrustedBrowserRenderer(event.sender)) {
|
||||
throw new Error('Untrusted document preview grant request')
|
||||
}
|
||||
if (!isValidGrantRequest(request)) {
|
||||
throw new Error('Invalid document preview grant request')
|
||||
}
|
||||
// Why the other half of the registry is consulted here: this is where a page first becomes a
|
||||
// document page, and the two halves must stay disjoint. Naming a page that already hosts a
|
||||
// browsing guest would make one id resolve in both.
|
||||
if (browserManager.getGuestWebContentsId(request.browserPageId) !== null) {
|
||||
throw new Error('Document preview grant names a browsing page')
|
||||
}
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: request.owner,
|
||||
root: request.root,
|
||||
entryRelativePath: request.entryRelativePath,
|
||||
browserPageId: request.browserPageId
|
||||
})
|
||||
return {
|
||||
grantId: grant.id,
|
||||
url: buildDocPreviewUrl(grant.id, grant.entryRelativePath)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, (event, grantId: string): boolean =>
|
||||
isTrustedBrowserRenderer(event.sender) ? revokeDocPreviewGrant(grantId) : false
|
||||
)
|
||||
|
||||
// Why no trusted-renderer check here: the sender is a preview guest rendering a workspace
|
||||
// document, which is exactly the untrusted side. `reportDocPreviewLinkClick` is the gate — a
|
||||
// live bound grant, a focused guest, a web URL — and it drops everything else silently.
|
||||
ipcMain.on(DOC_PREVIEW_LINK_CLICK_CHANNEL, (event, url: unknown) => {
|
||||
if (typeof url === 'string') {
|
||||
reportDocPreviewLinkClick(event.sender, url)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -148,6 +148,58 @@ describe('registerPtyHandlers', () => {
|
||||
false
|
||||
)
|
||||
})
|
||||
it('restores daemon launch identity for a runtime-created reattach', async () => {
|
||||
const incarnationId = 'runtime-reattach-incarnation'
|
||||
const leafId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
daemonSpawn.mockResolvedValueOnce({
|
||||
id: 'runtime-reattach',
|
||||
incarnationId,
|
||||
isReattach: true,
|
||||
launchAgent: 'codex'
|
||||
} as never)
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
spawn(args: {
|
||||
cols: number
|
||||
rows: number
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
sessionId: string
|
||||
}): Promise<{ id: string }>
|
||||
}
|
||||
|
||||
await controller.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'wt-runtime-reattach',
|
||||
tabId: 'tab-runtime-reattach',
|
||||
leafId,
|
||||
sessionId: 'runtime-reattach'
|
||||
})
|
||||
|
||||
expect(runtime.registerPty).toHaveBeenCalledWith(
|
||||
'runtime-reattach',
|
||||
'wt-runtime-reattach',
|
||||
null,
|
||||
{
|
||||
tabId: 'tab-runtime-reattach',
|
||||
leafId,
|
||||
incarnationId,
|
||||
providerReattachLaunchIdentity: { incarnationId, launchAgent: 'codex' }
|
||||
},
|
||||
false
|
||||
)
|
||||
})
|
||||
it('uses the owning project WSL runtime for runtime-created daemon PTYs', async () => {
|
||||
await withWin32Platform(async () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
|
||||
|
||||
@@ -87,6 +87,7 @@ export const fsModuleMock = () => ({
|
||||
chmodSync: chmodSyncMock,
|
||||
renameSync: renameSyncMock,
|
||||
rmSync: rmSyncMock,
|
||||
mkdtempSync: () => '/tmp/orca-watcher-canary-test',
|
||||
constants: {
|
||||
X_OK: 1,
|
||||
R_OK: 4
|
||||
|
||||
@@ -416,8 +416,14 @@ describe('registerPtyHandlers', () => {
|
||||
baseEnv: expect.any(Object)
|
||||
})
|
||||
})
|
||||
it('does not echo launch config for provider reattach results', async () => {
|
||||
const spawn = vi.fn(async () => ({ id: 'ssh-reattach', isReattach: true }))
|
||||
it('restores daemon launch identity without minting renderer authority on reattach', async () => {
|
||||
const incarnationId = 'ssh-reattach-incarnation'
|
||||
const spawn = vi.fn(async () => ({
|
||||
id: 'ssh-reattach',
|
||||
incarnationId,
|
||||
isReattach: true as const,
|
||||
launchAgent: 'codex' as const
|
||||
}))
|
||||
registerSshPtyProvider('ssh-reattach-1', {
|
||||
spawn,
|
||||
write: vi.fn(),
|
||||
@@ -439,17 +445,30 @@ describe('registerPtyHandlers', () => {
|
||||
getProfiles: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn()
|
||||
} as never)
|
||||
const registerPty = vi.fn()
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
|
||||
registerPreAllocatedHandleForPty: vi.fn()
|
||||
registerPreAllocatedHandleForPty: vi.fn(),
|
||||
registerPty
|
||||
}
|
||||
const tabId = 'tab-reattach'
|
||||
const leafId = '77777777-7777-4777-8777-777777777777'
|
||||
const worktreeId = 'repo-1::/tmp/reattach'
|
||||
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
const result = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
connectionId: 'ssh-reattach-1',
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId,
|
||||
env: {
|
||||
ORCA_PANE_KEY: makePaneKey(tabId, leafId),
|
||||
ORCA_TAB_ID: tabId,
|
||||
ORCA_WORKTREE_ID: worktreeId
|
||||
},
|
||||
launchConfig: {
|
||||
agentCommand: 'codex --model gpt-5',
|
||||
agentArgs: '--model gpt-5',
|
||||
@@ -459,6 +478,18 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
expect(result).toMatchObject({ id: 'ssh-reattach', isReattach: true })
|
||||
expect(result.launchConfig).toBeUndefined()
|
||||
expect(registerPty).toHaveBeenCalledWith(
|
||||
'ssh-reattach',
|
||||
worktreeId,
|
||||
'ssh-reattach-1',
|
||||
expect.objectContaining({
|
||||
tabId,
|
||||
leafId,
|
||||
incarnationId,
|
||||
providerReattachLaunchIdentity: { incarnationId, launchAgent: 'codex' }
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
it('reuses the runtime background handle in local PTY spawn env', async () => {
|
||||
type RuntimeSpawnController = {
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
import { rememberPaneKeyForPty } from '../pane/key-state'
|
||||
import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
|
||||
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
|
||||
import { admitRendererAgentLaunchAuthority } from '../pane/launch-authority'
|
||||
import {
|
||||
admitProviderReattachLaunchIdentity,
|
||||
admitRendererAgentLaunchAuthority
|
||||
} from '../pane/launch-authority'
|
||||
import type { PtyIpcSpawnState } from './spawn-state'
|
||||
import { persistPtyIpcSpawnCommit } from './spawn-commit-persist'
|
||||
|
||||
@@ -82,6 +85,11 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
|
||||
hasStablePaneOwner: ctx.stablePaneOwner !== null,
|
||||
incarnationId: ctx.result.incarnationId
|
||||
})
|
||||
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity({
|
||||
isReattach: ctx.result.isReattach === true,
|
||||
launchAgent: ctx.result.launchAgent,
|
||||
incarnationId: ctx.result.incarnationId
|
||||
})
|
||||
ctx.deps.runtime?.registerPty(
|
||||
ctx.result.id,
|
||||
args.worktreeId,
|
||||
@@ -95,7 +103,8 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
|
||||
tabId: args.tabId,
|
||||
leafId: ctx.metadataLeafId,
|
||||
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
||||
...(agentLaunchAuthority ? { agentLaunchAuthority } : {})
|
||||
...(agentLaunchAuthority ? { agentLaunchAuthority } : {}),
|
||||
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
|
||||
}
|
||||
: undefined,
|
||||
!args.connectionId
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { admitProviderReattachLaunchIdentity } from './launch-authority'
|
||||
|
||||
describe('admitProviderReattachLaunchIdentity', () => {
|
||||
it('binds provider launch identity to a valid reattach incarnation', () => {
|
||||
expect(
|
||||
admitProviderReattachLaunchIdentity({
|
||||
isReattach: true,
|
||||
launchAgent: 'codex',
|
||||
incarnationId: 'provider-incarnation'
|
||||
})
|
||||
).toEqual({ launchAgent: 'codex', incarnationId: 'provider-incarnation' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'fresh spawn', isReattach: false, launchAgent: 'codex', incarnationId: 'incarnation' },
|
||||
{
|
||||
label: 'invalid agent',
|
||||
isReattach: true,
|
||||
launchAgent: 'unknown',
|
||||
incarnationId: 'incarnation'
|
||||
},
|
||||
{
|
||||
label: 'missing incarnation',
|
||||
isReattach: true,
|
||||
launchAgent: 'codex',
|
||||
incarnationId: undefined
|
||||
},
|
||||
{
|
||||
label: 'oversized incarnation',
|
||||
isReattach: true,
|
||||
launchAgent: 'codex',
|
||||
incarnationId: 'x'.repeat(129)
|
||||
}
|
||||
])('rejects $label metadata', ({ isReattach, launchAgent, incarnationId }) => {
|
||||
expect(
|
||||
admitProviderReattachLaunchIdentity({ isReattach, launchAgent, incarnationId })
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,21 @@ export function admitRendererAgentLaunchAuthority(args: {
|
||||
return { launchToken: args.launchToken, launchAgent: args.launchAgent }
|
||||
}
|
||||
|
||||
export function admitProviderReattachLaunchIdentity(args: {
|
||||
isReattach?: boolean
|
||||
launchAgent?: unknown
|
||||
incarnationId?: unknown
|
||||
}): { incarnationId: string; launchAgent: TuiAgent } | null {
|
||||
if (
|
||||
!args.isReattach ||
|
||||
!isTuiAgent(args.launchAgent) ||
|
||||
!isPtyIncarnationId(args.incarnationId)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { incarnationId: args.incarnationId, launchAgent: args.launchAgent }
|
||||
}
|
||||
|
||||
export function shouldRefreshNativeClaudeAgentTeamsEnv(args: {
|
||||
command?: string
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
|
||||
@@ -33,10 +33,12 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
|
||||
import { clearProviderPtyState } from '../provider/state-cleanup'
|
||||
import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
|
||||
import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority'
|
||||
import type { RuntimePtySpawnState } from './spawn-state'
|
||||
|
||||
export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
const args = ctx.args
|
||||
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result)
|
||||
try {
|
||||
ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({
|
||||
store: ctx.hostSessionBinding?.store,
|
||||
@@ -69,7 +71,8 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
{
|
||||
tabId: owner.surface.tabId,
|
||||
leafId: owner.surface.leafId,
|
||||
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {})
|
||||
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
||||
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
|
||||
}
|
||||
)
|
||||
if (!args.connectionId) {
|
||||
@@ -204,7 +207,8 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
? {
|
||||
tabId: args.tabId,
|
||||
leafId: ctx.metadataLeafId,
|
||||
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {})
|
||||
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
|
||||
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
|
||||
}
|
||||
: undefined,
|
||||
!args.connectionId
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import {
|
||||
findKeybindingConflicts,
|
||||
getKeybindingDefinition,
|
||||
isKeybindingActionId,
|
||||
normalizeKeybindingArrayForAction,
|
||||
normalizeKeybindingListForAction,
|
||||
type KeybindingActionId,
|
||||
type KeybindingFileDiagnostic,
|
||||
type KeybindingOverrides,
|
||||
type KeybindingPlatform
|
||||
} from '../../shared/keybindings'
|
||||
|
||||
export type JsonObject = Record<string, unknown>
|
||||
|
||||
export const FILE_VERSION = 1
|
||||
const PLATFORM_KEYS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32']
|
||||
const ROOT_KEYS = new Set(['$schema', 'version', 'keybindings', 'platforms'])
|
||||
|
||||
export function isJsonObject(value: unknown): value is JsonObject {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function createEmptyDocument(): JsonObject {
|
||||
return {
|
||||
version: FILE_VERSION,
|
||||
keybindings: {},
|
||||
platforms: {
|
||||
darwin: {},
|
||||
linux: {},
|
||||
win32: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readJsonDocument(path: string): {
|
||||
exists: boolean
|
||||
document: JsonObject | null
|
||||
error?: string
|
||||
} {
|
||||
if (!existsSync(path)) {
|
||||
return { exists: false, document: createEmptyDocument() }
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
||||
if (!isJsonObject(parsed)) {
|
||||
return { exists: true, document: null, error: 'Keybindings file must contain a JSON object.' }
|
||||
}
|
||||
return { exists: true, document: parsed }
|
||||
} catch (error) {
|
||||
return {
|
||||
exists: true,
|
||||
document: null,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonDocument(path: string, document: JsonObject): void {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
const tempPath = `${path}.tmp`
|
||||
try {
|
||||
writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8')
|
||||
renameSync(tempPath, path)
|
||||
} catch (error) {
|
||||
try {
|
||||
if (existsSync(tempPath)) {
|
||||
unlinkSync(tempPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup failure; the original write error is more actionable.
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBindingValue(
|
||||
actionId: KeybindingActionId,
|
||||
value: unknown
|
||||
): { ok: true; value: string[] } | { ok: false; error: string } {
|
||||
if (value === null || value === false) {
|
||||
return { ok: true, value: [] }
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = normalizeKeybindingListForAction(actionId, value)
|
||||
return Array.isArray(normalized)
|
||||
? { ok: true, value: normalized }
|
||||
: normalized.ok
|
||||
? { ok: true, value: [normalized.value] }
|
||||
: normalized
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.every((item) => typeof item === 'string')) {
|
||||
return { ok: false, error: 'Use a string, string array, null, or false.' }
|
||||
}
|
||||
const normalized = normalizeKeybindingArrayForAction(actionId, value)
|
||||
return Array.isArray(normalized)
|
||||
? { ok: true, value: normalized }
|
||||
: normalized.ok
|
||||
? { ok: true, value: [normalized.value] }
|
||||
: normalized
|
||||
}
|
||||
return { ok: false, error: 'Use a string, string array, null, or false.' }
|
||||
}
|
||||
|
||||
export function normalizeWriteBindingValue(
|
||||
actionId: KeybindingActionId,
|
||||
value: unknown
|
||||
): string[] | null {
|
||||
if (value === null) {
|
||||
return null
|
||||
}
|
||||
if (!Array.isArray(value) || !value.every((binding) => typeof binding === 'string')) {
|
||||
throw new Error('Use a string array or null.')
|
||||
}
|
||||
const normalized = normalizeKeybindingArrayForAction(actionId, value)
|
||||
if (!Array.isArray(normalized)) {
|
||||
throw new Error(normalized.ok ? 'Unable to parse shortcut.' : normalized.error)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function parseBindingSection(
|
||||
value: unknown,
|
||||
section: string,
|
||||
diagnostics: KeybindingFileDiagnostic[],
|
||||
options: { skipRootKeys?: boolean } = {}
|
||||
): KeybindingOverrides {
|
||||
if (value === undefined) {
|
||||
return {}
|
||||
}
|
||||
if (!isJsonObject(value)) {
|
||||
diagnostics.push({
|
||||
severity: 'error',
|
||||
section,
|
||||
message: `${section} must be an object.`
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
const overrides: KeybindingOverrides = {}
|
||||
for (const [actionId, rawBinding] of Object.entries(value)) {
|
||||
if (options.skipRootKeys && ROOT_KEYS.has(actionId)) {
|
||||
continue
|
||||
}
|
||||
if (!isKeybindingActionId(actionId)) {
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
section,
|
||||
actionId,
|
||||
message: `Unknown keybinding action "${actionId}" was ignored.`
|
||||
})
|
||||
continue
|
||||
}
|
||||
const normalized = normalizeBindingValue(actionId, rawBinding)
|
||||
if (!normalized.ok) {
|
||||
diagnostics.push({
|
||||
severity: 'error',
|
||||
section,
|
||||
actionId,
|
||||
message: `Shortcut for "${actionId}" was ignored: ${normalized.error}`
|
||||
})
|
||||
continue
|
||||
}
|
||||
overrides[actionId] = normalized.value
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
export function parsePlatformOverrides(
|
||||
document: JsonObject,
|
||||
diagnostics: KeybindingFileDiagnostic[]
|
||||
): Partial<Record<KeybindingPlatform, KeybindingOverrides>> {
|
||||
const rawPlatforms = document.platforms
|
||||
if (rawPlatforms === undefined) {
|
||||
return {}
|
||||
}
|
||||
if (!isJsonObject(rawPlatforms)) {
|
||||
diagnostics.push({
|
||||
severity: 'error',
|
||||
section: 'platforms',
|
||||
message: 'platforms must be an object with darwin, linux, or win32 sections.'
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
const result: Partial<Record<KeybindingPlatform, KeybindingOverrides>> = {}
|
||||
for (const [platform, value] of Object.entries(rawPlatforms)) {
|
||||
if (!PLATFORM_KEYS.includes(platform as KeybindingPlatform)) {
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
section: `platforms.${platform}`,
|
||||
message: `Unknown platform "${platform}" was ignored.`
|
||||
})
|
||||
continue
|
||||
}
|
||||
result[platform as KeybindingPlatform] = parseBindingSection(
|
||||
value,
|
||||
`platforms.${platform}`,
|
||||
diagnostics
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function removeConflictingOverrides(
|
||||
platform: KeybindingPlatform,
|
||||
overrides: KeybindingOverrides,
|
||||
diagnostics: KeybindingFileDiagnostic[]
|
||||
): KeybindingOverrides {
|
||||
let next = { ...overrides }
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const conflicts = findKeybindingConflicts(platform, next)
|
||||
const conflictingOverrides = new Set<KeybindingActionId>()
|
||||
|
||||
for (const conflict of conflicts) {
|
||||
for (const actionId of conflict.actionIds) {
|
||||
if (Object.hasOwn(next, actionId)) {
|
||||
conflictingOverrides.add(actionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conflictingOverrides.size === 0) {
|
||||
return next
|
||||
}
|
||||
|
||||
for (const actionId of conflictingOverrides) {
|
||||
delete next[actionId]
|
||||
}
|
||||
|
||||
diagnostics.push({
|
||||
severity: 'error',
|
||||
message: `Conflicting custom shortcuts were ignored: ${Array.from(conflictingOverrides)
|
||||
.map((actionId) => getKeybindingDefinition(actionId)?.title ?? actionId)
|
||||
.join(', ')}.`
|
||||
})
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -1,250 +1,33 @@
|
||||
/* eslint-disable max-lines -- Why: parsing, sanitizing, migrating, and writing the keybindings file must stay together so file-format edge cases share one validation path. */
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
findKeybindingConflicts,
|
||||
formatKeybindingList,
|
||||
getKeybindingDefinition,
|
||||
getKeybindingPlatform,
|
||||
isKeybindingActionId,
|
||||
normalizeKeybindingArrayForAction,
|
||||
normalizeKeybindingListForAction,
|
||||
type KeybindingActionId,
|
||||
type KeybindingFileDiagnostic,
|
||||
type KeybindingFileSnapshot,
|
||||
type KeybindingOverrides,
|
||||
type KeybindingPlatform
|
||||
type KeybindingOverrides
|
||||
} from '../../shared/keybindings'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
const FILE_VERSION = 1
|
||||
const PLATFORM_KEYS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32']
|
||||
const ROOT_KEYS = new Set(['$schema', 'version', 'keybindings', 'platforms'])
|
||||
import {
|
||||
createEmptyDocument,
|
||||
FILE_VERSION,
|
||||
isJsonObject,
|
||||
normalizeWriteBindingValue,
|
||||
parseBindingSection,
|
||||
parsePlatformOverrides,
|
||||
readJsonDocument,
|
||||
removeConflictingOverrides,
|
||||
writeJsonDocument,
|
||||
type JsonObject
|
||||
} from './keybinding-file-parser'
|
||||
|
||||
export function getUserKeybindingsPath(homePath: string): string {
|
||||
return join(homePath, '.orca', 'keybindings.json')
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is JsonObject {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function createEmptyDocument(): JsonObject {
|
||||
return {
|
||||
version: FILE_VERSION,
|
||||
keybindings: {},
|
||||
platforms: {
|
||||
darwin: {},
|
||||
linux: {},
|
||||
win32: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonDocument(path: string): {
|
||||
exists: boolean
|
||||
document: JsonObject | null
|
||||
error?: string
|
||||
} {
|
||||
if (!existsSync(path)) {
|
||||
return { exists: false, document: createEmptyDocument() }
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
||||
if (!isJsonObject(parsed)) {
|
||||
return { exists: true, document: null, error: 'Keybindings file must contain a JSON object.' }
|
||||
}
|
||||
return { exists: true, document: parsed }
|
||||
} catch (error) {
|
||||
return {
|
||||
exists: true,
|
||||
document: null,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonDocument(path: string, document: JsonObject): void {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
const tempPath = `${path}.tmp`
|
||||
try {
|
||||
writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8')
|
||||
renameSync(tempPath, path)
|
||||
} catch (error) {
|
||||
try {
|
||||
if (existsSync(tempPath)) {
|
||||
unlinkSync(tempPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup failure; the original write error is more actionable.
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBindingValue(
|
||||
actionId: KeybindingActionId,
|
||||
value: unknown
|
||||
): { ok: true; value: string[] } | { ok: false; error: string } {
|
||||
if (value === null || value === false) {
|
||||
return { ok: true, value: [] }
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = normalizeKeybindingListForAction(actionId, value)
|
||||
return Array.isArray(normalized)
|
||||
? { ok: true, value: normalized }
|
||||
: normalized.ok
|
||||
? { ok: true, value: [normalized.value] }
|
||||
: normalized
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.every((item) => typeof item === 'string')) {
|
||||
return { ok: false, error: 'Use a string, string array, null, or false.' }
|
||||
}
|
||||
const normalized = normalizeKeybindingArrayForAction(actionId, value)
|
||||
return Array.isArray(normalized)
|
||||
? { ok: true, value: normalized }
|
||||
: normalized.ok
|
||||
? { ok: true, value: [normalized.value] }
|
||||
: normalized
|
||||
}
|
||||
return { ok: false, error: 'Use a string, string array, null, or false.' }
|
||||
}
|
||||
|
||||
function normalizeWriteBindingValue(actionId: KeybindingActionId, value: unknown): string[] | null {
|
||||
if (value === null) {
|
||||
return null
|
||||
}
|
||||
if (!Array.isArray(value) || !value.every((binding) => typeof binding === 'string')) {
|
||||
throw new Error('Use a string array or null.')
|
||||
}
|
||||
const normalized = normalizeKeybindingArrayForAction(actionId, value)
|
||||
if (!Array.isArray(normalized)) {
|
||||
throw new Error(normalized.ok ? 'Unable to parse shortcut.' : normalized.error)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function parseBindingSection(
|
||||
value: unknown,
|
||||
section: string,
|
||||
diagnostics: KeybindingFileDiagnostic[],
|
||||
options: { skipRootKeys?: boolean } = {}
|
||||
): KeybindingOverrides {
|
||||
if (value === undefined) {
|
||||
return {}
|
||||
}
|
||||
if (!isJsonObject(value)) {
|
||||
diagnostics.push({
|
||||
severity: 'error',
|
||||
section,
|
||||
message: `${section} must be an object.`
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
const overrides: KeybindingOverrides = {}
|
||||
for (const [actionId, rawBinding] of Object.entries(value)) {
|
||||
if (options.skipRootKeys && ROOT_KEYS.has(actionId)) {
|
||||
continue
|
||||
}
|
||||
if (!isKeybindingActionId(actionId)) {
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
section,
|
||||
actionId,
|
||||
message: `Unknown keybinding action "${actionId}" was ignored.`
|
||||
})
|
||||
continue
|
||||
}
|
||||
const normalized = normalizeBindingValue(actionId, rawBinding)
|
||||
if (!normalized.ok) {
|
||||
diagnostics.push({
|
||||
severity: 'error',
|
||||
section,
|
||||
actionId,
|
||||
message: `Shortcut for "${actionId}" was ignored: ${normalized.error}`
|
||||
})
|
||||
continue
|
||||
}
|
||||
overrides[actionId] = normalized.value
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
function parsePlatformOverrides(
|
||||
document: JsonObject,
|
||||
diagnostics: KeybindingFileDiagnostic[]
|
||||
): Partial<Record<KeybindingPlatform, KeybindingOverrides>> {
|
||||
const rawPlatforms = document.platforms
|
||||
if (rawPlatforms === undefined) {
|
||||
return {}
|
||||
}
|
||||
if (!isJsonObject(rawPlatforms)) {
|
||||
diagnostics.push({
|
||||
severity: 'error',
|
||||
section: 'platforms',
|
||||
message: 'platforms must be an object with darwin, linux, or win32 sections.'
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
const result: Partial<Record<KeybindingPlatform, KeybindingOverrides>> = {}
|
||||
for (const [platform, value] of Object.entries(rawPlatforms)) {
|
||||
if (!PLATFORM_KEYS.includes(platform as KeybindingPlatform)) {
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
section: `platforms.${platform}`,
|
||||
message: `Unknown platform "${platform}" was ignored.`
|
||||
})
|
||||
continue
|
||||
}
|
||||
result[platform as KeybindingPlatform] = parseBindingSection(
|
||||
value,
|
||||
`platforms.${platform}`,
|
||||
diagnostics
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function removeConflictingOverrides(
|
||||
platform: KeybindingPlatform,
|
||||
overrides: KeybindingOverrides,
|
||||
diagnostics: KeybindingFileDiagnostic[]
|
||||
): KeybindingOverrides {
|
||||
let next = { ...overrides }
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const conflicts = findKeybindingConflicts(platform, next)
|
||||
const conflictingOverrides = new Set<KeybindingActionId>()
|
||||
|
||||
for (const conflict of conflicts) {
|
||||
for (const actionId of conflict.actionIds) {
|
||||
if (Object.hasOwn(next, actionId)) {
|
||||
conflictingOverrides.add(actionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conflictingOverrides.size === 0) {
|
||||
return next
|
||||
}
|
||||
|
||||
for (const actionId of conflictingOverrides) {
|
||||
delete next[actionId]
|
||||
}
|
||||
|
||||
diagnostics.push({
|
||||
severity: 'error',
|
||||
message: `Conflicting custom shortcuts were ignored: ${Array.from(conflictingOverrides)
|
||||
.map((actionId) => getKeybindingDefinition(actionId)?.title ?? actionId)
|
||||
.join(', ')}.`
|
||||
})
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function readKeybindingFile(
|
||||
path: string,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
|
||||
+46
-168
@@ -1,6 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: the collector's host-process index,
|
||||
history ring buffer, Electron bucketing, and worktree attribution live
|
||||
together to keep one snapshot's orchestration in one place. */
|
||||
/**
|
||||
* Memory dashboard collector.
|
||||
*
|
||||
@@ -17,11 +14,9 @@
|
||||
* renderer polls never produces overlapping child processes.
|
||||
*/
|
||||
|
||||
import { basename } from 'node:path'
|
||||
import { exec } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import os from 'node:os'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
|
||||
import {
|
||||
getProcessOutputFields,
|
||||
iterateProcessOutputLines
|
||||
@@ -30,7 +25,6 @@ import { getAppEnvironment, type AppEnvironment } from '../../shared/app-environ
|
||||
import type {
|
||||
AppMemory,
|
||||
MemorySnapshot,
|
||||
ProcessCommitMetric,
|
||||
SessionMemory,
|
||||
UsageValues,
|
||||
WorktreeMemory
|
||||
@@ -39,8 +33,24 @@ import type { Store } from '../persistence'
|
||||
import { ORPHAN_WORKTREE_ID } from '../../shared/constants'
|
||||
import { listRegisteredPtys } from './pty-registry'
|
||||
import { enumerateWindowsProcessResources } from './windows-process-resource-collector'
|
||||
import { collectHostMemory, fallbackHostMemory } from './host-memory'
|
||||
import { collectHostMemory } from './host-memory'
|
||||
import { getProcessMemoryMetric } from './process-memory-metric'
|
||||
import {
|
||||
createEmptyWorktreeMemoryBucket,
|
||||
pushAppMemoryHistory,
|
||||
pushMemoryHistorySample,
|
||||
readAppMemoryHistory,
|
||||
readMemoryHistory,
|
||||
resolveWorktreeMemoryNames,
|
||||
sweepStaleMemoryHistory,
|
||||
type WorktreeMemoryBucket
|
||||
} from './memory-snapshot-buckets'
|
||||
import {
|
||||
clampMemoryMetric,
|
||||
emptyMemorySnapshot,
|
||||
optionalCommitField,
|
||||
snapshotCommitFields
|
||||
} from './memory-snapshot-values'
|
||||
|
||||
export type MemorySnapshotStore = Pick<Store, 'getRepo' | 'getWorktreeMeta'>
|
||||
|
||||
@@ -61,7 +71,7 @@ export async function collectMemorySnapshot(store: MemorySnapshotStore): Promise
|
||||
inflight = runSnapshot(store)
|
||||
.catch((err) => {
|
||||
console.warn('[memory] snapshot failed; returning empty', err)
|
||||
return emptySnapshot()
|
||||
return emptyMemorySnapshot()
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null
|
||||
@@ -99,88 +109,6 @@ type ProcIndex = {
|
||||
hasPrivateMemory: boolean
|
||||
}
|
||||
|
||||
const PROCESS_COMMIT_METRIC: ProcessCommitMetric = 'private-bytes'
|
||||
|
||||
/**
|
||||
* The one rule for every committed-bytes key: present only when the sweep could
|
||||
* measure it, because a 0 would read as "these processes commit nothing".
|
||||
*/
|
||||
function commitField(hasPrivateMemory: boolean, privateMemory: number): { privateMemory?: number } {
|
||||
return hasPrivateMemory ? { privateMemory: clampNumber(privateMemory) } : {}
|
||||
}
|
||||
|
||||
/** The snapshot-level pair, which names the unit alongside the total. */
|
||||
function snapshotCommitFields(
|
||||
hasPrivateMemory: boolean,
|
||||
totalPrivateMemory: number
|
||||
): Pick<MemorySnapshot, 'processCommitMetric' | 'totalPrivateMemory'> {
|
||||
return hasPrivateMemory
|
||||
? {
|
||||
processCommitMetric: PROCESS_COMMIT_METRIC,
|
||||
totalPrivateMemory: clampNumber(totalPrivateMemory)
|
||||
}
|
||||
: {}
|
||||
}
|
||||
|
||||
function clampNumber(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(0, value)
|
||||
}
|
||||
|
||||
function emptySnapshot(): MemorySnapshot {
|
||||
const zero = { cpu: 0, memory: 0 }
|
||||
return {
|
||||
app: { ...zero, main: zero, renderer: zero, other: zero, history: [] },
|
||||
worktrees: [],
|
||||
host: fallbackHostMemory(),
|
||||
processMemoryMetric: getProcessMemoryMetric(),
|
||||
totalCpu: 0,
|
||||
totalMemory: 0,
|
||||
collectedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── History ring buffers ───────────────────────────────────────────
|
||||
|
||||
const APP_HISTORY_KEY = '__app__'
|
||||
const HISTORY_CAPACITY = 60
|
||||
const HISTORY_STALE_MS = 10 * 60 * 1000
|
||||
|
||||
type HistoryRing = {
|
||||
samples: number[]
|
||||
touchedAt: number
|
||||
}
|
||||
|
||||
const historyByKey = new Map<string, HistoryRing>()
|
||||
|
||||
function pushHistorySample(key: string, memoryBytes: number, now: number): void {
|
||||
let ring = historyByKey.get(key)
|
||||
if (!ring) {
|
||||
ring = { samples: [], touchedAt: now }
|
||||
historyByKey.set(key, ring)
|
||||
}
|
||||
ring.samples.push(memoryBytes)
|
||||
if (ring.samples.length > HISTORY_CAPACITY) {
|
||||
ring.samples.shift()
|
||||
}
|
||||
ring.touchedAt = now
|
||||
}
|
||||
|
||||
function readHistory(key: string): number[] {
|
||||
const ring = historyByKey.get(key)
|
||||
return ring ? [...ring.samples] : []
|
||||
}
|
||||
|
||||
function sweepStaleHistory(now: number): void {
|
||||
for (const [key, ring] of historyByKey) {
|
||||
if (now - ring.touchedAt > HISTORY_STALE_MS) {
|
||||
historyByKey.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Host process enumeration ───────────────────────────────────────
|
||||
|
||||
async function enumerateProcesses(): Promise<ProcIndex> {
|
||||
@@ -292,7 +220,7 @@ function electronMetricMemoryBytes(
|
||||
// Why: on macOS, getAppEnvironment().getAppMetrics().workingSetSize can include large shared
|
||||
// Chromium/Electron mappings. Prefer the host RSS sweep used elsewhere, but
|
||||
// keep workingSetSize as a fallback when the process disappears mid-snapshot.
|
||||
return clampNumber(proc.memory?.workingSetSize) * 1024
|
||||
return clampMemoryMetric(proc.memory?.workingSetSize) * 1024
|
||||
}
|
||||
|
||||
function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
|
||||
@@ -301,11 +229,11 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
|
||||
const other = { cpu: 0, memory: 0, privateMemory: 0 }
|
||||
|
||||
for (const proc of getAppEnvironment().getAppMetrics()) {
|
||||
const cpu = clampNumber(proc.cpu?.percentCPUUsage)
|
||||
const cpu = clampMemoryMetric(proc.cpu?.percentCPUUsage)
|
||||
const memoryBytes = electronMetricMemoryBytes(proc, processIndex)
|
||||
// Why the host row rather than Electron's own metric: getAppMetrics has no
|
||||
// commit figure for helper processes, and the sweep already indexed them.
|
||||
const privateBytes = clampNumber(processIndex.byPid.get(proc.pid)?.privateMemory)
|
||||
const privateBytes = clampMemoryMetric(processIndex.byPid.get(proc.pid)?.privateMemory)
|
||||
|
||||
// Why: lowercase once so future Electron versions emitting different
|
||||
// casing ('browser' vs 'Browser') still bucket correctly.
|
||||
@@ -325,7 +253,7 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
|
||||
const usage = (bucket: typeof main): UsageValues => ({
|
||||
cpu: bucket.cpu,
|
||||
memory: bucket.memory,
|
||||
...commitField(processIndex.hasPrivateMemory, bucket.privateMemory)
|
||||
...optionalCommitField(processIndex.hasPrivateMemory, bucket.privateMemory)
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -340,61 +268,6 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Worktree attribution ───────────────────────────────────────────
|
||||
|
||||
type WorktreeBucket = {
|
||||
worktreeId: string
|
||||
worktreeName: string
|
||||
repoId: string
|
||||
repoName: string
|
||||
cpu: number
|
||||
memory: number
|
||||
privateMemory: number
|
||||
sessions: SessionMemory[]
|
||||
}
|
||||
|
||||
function resolveWorktreeNames(
|
||||
worktreeId: string,
|
||||
store: MemorySnapshotStore
|
||||
): {
|
||||
worktreeName: string
|
||||
repoId: string
|
||||
repoName: string
|
||||
} {
|
||||
// Orca worktree ids look like `${repoId}::${absolutePath}`.
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
const repoId = parsed?.repoId ?? worktreeId
|
||||
const worktreePath = parsed?.worktreePath ?? ''
|
||||
const fallbackName = worktreePath ? basename(worktreePath) : worktreeId
|
||||
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
const repo = store.getRepo(repoId)
|
||||
|
||||
return {
|
||||
worktreeName: meta?.displayName?.trim() || fallbackName,
|
||||
repoId,
|
||||
repoName: repo?.displayName?.trim() || repoId || 'Unknown Repo'
|
||||
}
|
||||
}
|
||||
|
||||
function makeEmptyBucket(
|
||||
worktreeId: string,
|
||||
worktreeName: string,
|
||||
repoId: string,
|
||||
repoName: string
|
||||
): WorktreeBucket {
|
||||
return {
|
||||
worktreeId,
|
||||
worktreeName,
|
||||
repoId,
|
||||
repoName,
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
privateMemory: 0,
|
||||
sessions: []
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main collection path ───────────────────────────────────────────
|
||||
|
||||
async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot> {
|
||||
@@ -408,13 +281,13 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
|
||||
// attribute to the first PTY (registration order) to see each pid.
|
||||
const claimed = new Set<number>()
|
||||
|
||||
const orphan = makeEmptyBucket(
|
||||
const orphan = createEmptyWorktreeMemoryBucket(
|
||||
ORPHAN_WORKTREE_ID,
|
||||
'Unattributed terminals',
|
||||
ORPHAN_WORKTREE_ID,
|
||||
'Other'
|
||||
)
|
||||
const worktreeBuckets = new Map<string, WorktreeBucket>()
|
||||
const worktreeBuckets = new Map<string, WorktreeMemoryBucket>()
|
||||
|
||||
for (const pty of ptys) {
|
||||
let sessionCpu = 0
|
||||
@@ -435,7 +308,7 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
|
||||
sessionMemory += row.memory
|
||||
// Why the whole subtree: an agent's committed bytes live in the
|
||||
// children it spawned (codex.exe, MCP servers), not in the shell.
|
||||
sessionPrivateMemory += clampNumber(row.privateMemory)
|
||||
sessionPrivateMemory += clampMemoryMetric(row.privateMemory)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,19 +316,24 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
|
||||
sessionId: pty.sessionId ?? pty.ptyId,
|
||||
paneKey: pty.paneKey,
|
||||
pid: pty.pid ?? 0,
|
||||
cpu: clampNumber(sessionCpu),
|
||||
memory: clampNumber(sessionMemory),
|
||||
...commitField(processIndex.hasPrivateMemory, sessionPrivateMemory)
|
||||
cpu: clampMemoryMetric(sessionCpu),
|
||||
memory: clampMemoryMetric(sessionMemory),
|
||||
...optionalCommitField(processIndex.hasPrivateMemory, sessionPrivateMemory)
|
||||
}
|
||||
|
||||
let bucket: WorktreeBucket
|
||||
let bucket: WorktreeMemoryBucket
|
||||
if (pty.worktreeId) {
|
||||
const existing = worktreeBuckets.get(pty.worktreeId)
|
||||
if (existing) {
|
||||
bucket = existing
|
||||
} else {
|
||||
const names = resolveWorktreeNames(pty.worktreeId, store)
|
||||
bucket = makeEmptyBucket(pty.worktreeId, names.worktreeName, names.repoId, names.repoName)
|
||||
const names = resolveWorktreeMemoryNames(pty.worktreeId, store)
|
||||
bucket = createEmptyWorktreeMemoryBucket(
|
||||
pty.worktreeId,
|
||||
names.worktreeName,
|
||||
names.repoId,
|
||||
names.repoName
|
||||
)
|
||||
worktreeBuckets.set(pty.worktreeId, bucket)
|
||||
}
|
||||
} else {
|
||||
@@ -464,11 +342,11 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
|
||||
|
||||
bucket.cpu += session.cpu
|
||||
bucket.memory += session.memory
|
||||
bucket.privateMemory += clampNumber(session.privateMemory)
|
||||
bucket.privateMemory += clampMemoryMetric(session.privateMemory)
|
||||
bucket.sessions.push(session)
|
||||
}
|
||||
|
||||
const bucketList: WorktreeBucket[] = [...worktreeBuckets.values()]
|
||||
const bucketList: WorktreeMemoryBucket[] = [...worktreeBuckets.values()]
|
||||
if (orphan.sessions.length > 0) {
|
||||
bucketList.push(orphan)
|
||||
}
|
||||
@@ -477,16 +355,16 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
|
||||
// returned arrays end with the freshly-collected value. Each write also
|
||||
// acts as a keep-alive so active worktrees survive the staleness sweep.
|
||||
const now = Date.now()
|
||||
pushHistorySample(APP_HISTORY_KEY, appBuckets.memory, now)
|
||||
pushAppMemoryHistory(appBuckets.memory, now)
|
||||
for (const bucket of bucketList) {
|
||||
pushHistorySample(bucket.worktreeId, bucket.memory, now)
|
||||
pushMemoryHistorySample(bucket.worktreeId, bucket.memory, now)
|
||||
}
|
||||
sweepStaleHistory(now)
|
||||
sweepStaleMemoryHistory(now)
|
||||
|
||||
const worktrees: WorktreeMemory[] = bucketList.map(({ privateMemory, ...b }) => ({
|
||||
...b,
|
||||
...commitField(processIndex.hasPrivateMemory, privateMemory),
|
||||
history: readHistory(b.worktreeId)
|
||||
...optionalCommitField(processIndex.hasPrivateMemory, privateMemory),
|
||||
history: readMemoryHistory(b.worktreeId)
|
||||
}))
|
||||
|
||||
let sessionCpuTotal = 0
|
||||
@@ -495,17 +373,17 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
|
||||
for (const wt of worktrees) {
|
||||
sessionCpuTotal += wt.cpu
|
||||
sessionMemoryTotal += wt.memory
|
||||
sessionPrivateTotal += clampNumber(wt.privateMemory)
|
||||
sessionPrivateTotal += clampMemoryMetric(wt.privateMemory)
|
||||
}
|
||||
|
||||
return {
|
||||
app: { ...appBuckets, history: readHistory(APP_HISTORY_KEY) },
|
||||
app: { ...appBuckets, history: readAppMemoryHistory() },
|
||||
worktrees,
|
||||
host,
|
||||
processMemoryMetric: getProcessMemoryMetric(),
|
||||
...snapshotCommitFields(
|
||||
processIndex.hasPrivateMemory,
|
||||
clampNumber(appBuckets.privateMemory) + sessionPrivateTotal
|
||||
clampMemoryMetric(appBuckets.privateMemory) + sessionPrivateTotal
|
||||
),
|
||||
totalCpu: appBuckets.cpu + sessionCpuTotal,
|
||||
totalMemory: appBuckets.memory + sessionMemoryTotal,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { basename } from 'node:path'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
|
||||
import type { SessionMemory } from '../../shared/process-stats-types'
|
||||
import type { MemorySnapshotStore } from './collector'
|
||||
|
||||
const APP_HISTORY_KEY = '__app__'
|
||||
const HISTORY_CAPACITY = 60
|
||||
const HISTORY_STALE_MS = 10 * 60 * 1000
|
||||
|
||||
type HistoryRing = {
|
||||
samples: number[]
|
||||
touchedAt: number
|
||||
}
|
||||
|
||||
const historyByKey = new Map<string, HistoryRing>()
|
||||
|
||||
export function pushMemoryHistorySample(key: string, memoryBytes: number, now: number): void {
|
||||
let ring = historyByKey.get(key)
|
||||
if (!ring) {
|
||||
ring = { samples: [], touchedAt: now }
|
||||
historyByKey.set(key, ring)
|
||||
}
|
||||
ring.samples.push(memoryBytes)
|
||||
if (ring.samples.length > HISTORY_CAPACITY) {
|
||||
ring.samples.shift()
|
||||
}
|
||||
ring.touchedAt = now
|
||||
}
|
||||
|
||||
export function readMemoryHistory(key: string): number[] {
|
||||
const ring = historyByKey.get(key)
|
||||
return ring ? [...ring.samples] : []
|
||||
}
|
||||
|
||||
export function sweepStaleMemoryHistory(now: number): void {
|
||||
for (const [key, ring] of historyByKey) {
|
||||
if (now - ring.touchedAt > HISTORY_STALE_MS) {
|
||||
historyByKey.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pushAppMemoryHistory(memoryBytes: number, now: number): void {
|
||||
pushMemoryHistorySample(APP_HISTORY_KEY, memoryBytes, now)
|
||||
}
|
||||
|
||||
export function readAppMemoryHistory(): number[] {
|
||||
return readMemoryHistory(APP_HISTORY_KEY)
|
||||
}
|
||||
|
||||
export type WorktreeMemoryBucket = {
|
||||
worktreeId: string
|
||||
worktreeName: string
|
||||
repoId: string
|
||||
repoName: string
|
||||
cpu: number
|
||||
memory: number
|
||||
privateMemory: number
|
||||
sessions: SessionMemory[]
|
||||
}
|
||||
|
||||
export function resolveWorktreeMemoryNames(
|
||||
worktreeId: string,
|
||||
store: MemorySnapshotStore
|
||||
): {
|
||||
worktreeName: string
|
||||
repoId: string
|
||||
repoName: string
|
||||
} {
|
||||
// Orca worktree ids look like `${repoId}::${absolutePath}`.
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
const repoId = parsed?.repoId ?? worktreeId
|
||||
const worktreePath = parsed?.worktreePath ?? ''
|
||||
const fallbackName = worktreePath ? basename(worktreePath) : worktreeId
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
const repo = store.getRepo(repoId)
|
||||
|
||||
return {
|
||||
worktreeName: meta?.displayName?.trim() || fallbackName,
|
||||
repoId,
|
||||
repoName: repo?.displayName?.trim() || repoId || 'Unknown Repo'
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyWorktreeMemoryBucket(
|
||||
worktreeId: string,
|
||||
worktreeName: string,
|
||||
repoId: string,
|
||||
repoName: string
|
||||
): WorktreeMemoryBucket {
|
||||
return {
|
||||
worktreeId,
|
||||
worktreeName,
|
||||
repoId,
|
||||
repoName,
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
privateMemory: 0,
|
||||
sessions: []
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user