mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
Merge branch 'main' into brennanb2025/sta-5329-plan
This commit is contained in:
@@ -30,6 +30,7 @@ runs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.24.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -86,7 +87,9 @@ runs:
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
# Job containers can run composite steps from a source mirror without .git.
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
|
||||
if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
|
||||
fi
|
||||
|
||||
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
|
||||
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
|
||||
@@ -117,7 +120,7 @@ runs:
|
||||
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') }}
|
||||
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', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', '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'
|
||||
@@ -127,7 +130,7 @@ runs:
|
||||
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') }}
|
||||
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', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', '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'
|
||||
|
||||
@@ -17,6 +17,10 @@ on:
|
||||
description: JSON array of changed specs; empty runs the full suite
|
||||
required: false
|
||||
type: string
|
||||
ssh_source_changed:
|
||||
description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane'
|
||||
required: false
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -221,14 +225,21 @@ jobs:
|
||||
ssh-docker-watcher-isolation:
|
||||
name: ssh docker watcher isolation
|
||||
needs: build
|
||||
# Why ssh_source_changed first: this lane used to trigger on SSH source only as a side
|
||||
# effect of one route listing a startup-readiness spec — pruning that spec would have
|
||||
# silently retired the whole lane. The signal is now derived from the SSH routes directly.
|
||||
# The two spec clauses stay for their honest purpose: changed-e2e hands these specs to this
|
||||
# lane, so editing one must still run it here.
|
||||
if: >-
|
||||
inputs.test_files == '' ||
|
||||
inputs.ssh_source_changed == 'true' ||
|
||||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
|
||||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
|
||||
runs-on: ubuntu-latest
|
||||
# Why 35: the parking, retention, startup-exec, and paired parity specs run
|
||||
# serially on isolated Electron/SSH fixtures after watcher isolation.
|
||||
timeout-minutes: 35
|
||||
# Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average
|
||||
# ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35
|
||||
# — and the sharded lanes already show that a lane which times out is a lane nobody trusts.
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -260,6 +271,14 @@ jobs:
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
|
||||
|
||||
# Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every
|
||||
# spec below skipped itself while the shard still reported green. Running them on this one
|
||||
# VM pays the fixture image build once instead of ten times, and keeps an SSH regression
|
||||
# legible as an SSH-named failure.
|
||||
- name: Run remaining Docker SSH E2E
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
|
||||
|
||||
- name: Upload watcher isolation traces
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Node next compatibility
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Full future-runtime coverage is useful, but not worth doubling every PR matrix.
|
||||
- cron: '0 10 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: node-next-compat
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["26"]'
|
||||
+25
-36
@@ -27,6 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
|
||||
static_analysis: ${{ steps.filter.outputs.static_analysis }}
|
||||
typecheck: ${{ steps.filter.outputs.typecheck }}
|
||||
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
|
||||
@@ -442,17 +443,13 @@ jobs:
|
||||
src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
# Cache-key input changes would otherwise make every shard compile the same
|
||||
# native addon concurrently. Prime the supported Node ABI before the matrix fans out.
|
||||
test_native_cache:
|
||||
name: prepare test native cache node 24
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.test == 'true'
|
||||
if: needs.code_paths.outputs.native_cache_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['24', '26']
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard_total: [8]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -463,32 +460,17 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: '24'
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/main/shell-startup-feature-channel.test.ts \
|
||||
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
test:
|
||||
needs: [code_paths, test_native_cache]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.code_paths.outputs.test == 'true' &&
|
||||
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["24"]'
|
||||
|
||||
# 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.
|
||||
@@ -695,7 +677,7 @@ jobs:
|
||||
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') }}
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
@@ -748,7 +730,7 @@ jobs:
|
||||
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') }}
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', '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
|
||||
@@ -777,6 +759,7 @@ jobs:
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
test_files: ${{ steps.filter.outputs.test_files }}
|
||||
ssh_source_changed: ${{ steps.filter.outputs.ssh_source_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -799,6 +782,11 @@ jobs:
|
||||
# authorities, exclusions, and sentinels without evaluating workflow shell.
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
# Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a
|
||||
# spec name surviving in a route's list. Same routes, so the two cannot drift.
|
||||
SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)"
|
||||
echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
|
||||
echo "SSH source changed: $SSH_SOURCE_CHANGED"
|
||||
if [ "$TEST_FILES_JSON" != '[]' ]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Changed E2E specs: $TEST_FILES_JSON"
|
||||
@@ -817,6 +805,7 @@ jobs:
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
test_files: ${{ needs.e2e-paths.outputs.test_files }}
|
||||
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
|
||||
|
||||
verify:
|
||||
if: always()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Unit tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node_versions:
|
||||
description: JSON array of Node.js major versions to test.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ${{ fromJSON(inputs.node_versions) }}
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard_total: [8]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/main/shell-startup-feature-channel.test.ts \
|
||||
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
@@ -1,26 +1,31 @@
|
||||
diff --git a/binding.gyp b/binding.gyp
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e773638bf4 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -3,7 +3,7 @@
|
||||
@@ -3,7 +3,6 @@
|
||||
{
|
||||
"target_name": "windows_process_tree",
|
||||
"dependencies": [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
"conditions": [
|
||||
['OS=="win"', {
|
||||
@@ -16,9 +16,6 @@
|
||||
@@ -15,14 +14,13 @@
|
||||
"src/process_commandline.cc"
|
||||
],
|
||||
"include_dirs": [],
|
||||
- "include_dirs": [],
|
||||
+ "include_dirs": ["deps/node-addon-api"],
|
||||
+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],
|
||||
"libraries": [ 'psapi.lib' ],
|
||||
- "msvs_configuration_attributes": {
|
||||
- "SpectreMitigation": "Spectre"
|
||||
- },
|
||||
"msvs_settings": {
|
||||
"VCCLCompilerTool": {
|
||||
+ "ExceptionHandling": 1,
|
||||
"AdditionalOptions": [
|
||||
"/guard:cf",
|
||||
"/sdl",
|
||||
diff --git a/src/process.cc b/src/process.cc
|
||||
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
|
||||
--- a/src/process.cc
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,13 +2,21 @@ diff --git a/binding.gyp b/binding.gyp
|
||||
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -1,13 +1,10 @@
|
||||
@@ -1,13 +1,18 @@
|
||||
{
|
||||
'target_defaults': {
|
||||
'dependencies': [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
+ # Orca: GCC 9 (Ubuntu 20.04 floor) accepts C++20 as gnu++2a, but rejects
|
||||
+ # the newer gnu++20 spelling emitted by Node 24's gyp flags.
|
||||
+ 'cflags_cc!': [
|
||||
+ '-std=gnu++20'
|
||||
+ ],
|
||||
+ 'cflags_cc': [
|
||||
+ '-std=gnu++2a'
|
||||
+ ],
|
||||
'conditions': [
|
||||
['OS=="win"', {
|
||||
- 'msvs_configuration_attributes': {
|
||||
|
||||
+506
-110
File diff suppressed because one or more lines are too long
@@ -26,9 +26,11 @@ import {
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync
|
||||
readSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..', '..')
|
||||
@@ -67,13 +69,16 @@ function assertPatchApplied() {
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (!bindingGyp.includes("require.resolve('node-addon-api/node_addon_api.gyp')")) {
|
||||
if (bindingGyp.includes('node_addon_api.gyp')) {
|
||||
throw new Error(
|
||||
'binding.gyp still uses require("node-addon-api").targets. That path is ' +
|
||||
'cwd-relative and misses node_addon_api.gyp under pnpm on Windows. ' +
|
||||
'binding.gyp still depends on node_addon_api.gyp. pnpm and node-gyp rewrite that ' +
|
||||
'project path incorrectly on Windows. ' +
|
||||
'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (!bindingGyp.includes('"include_dirs": ["deps/node-addon-api"]')) {
|
||||
throw new Error('binding.gyp does not use the staged node-addon-api headers.')
|
||||
}
|
||||
const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8')
|
||||
if (processCc.includes('process_count < 1024')) {
|
||||
throw new Error(
|
||||
@@ -83,6 +88,58 @@ function assertPatchApplied() {
|
||||
}
|
||||
}
|
||||
|
||||
// pnpm can materialize this CRLF package without applying its patch. Repair the
|
||||
// load-bearing build settings before node-gyp so the release build stays safe.
|
||||
function applyWindowsProcessTreeBuildFixes() {
|
||||
const bindingPath = join(PACKAGE_DIR, 'binding.gyp')
|
||||
const processPath = join(PACKAGE_DIR, 'src', 'process.cc')
|
||||
const nodeAddonApiDir = dirname(
|
||||
createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')
|
||||
)
|
||||
const stagedHeaderDir = join(PACKAGE_DIR, 'deps', 'node-addon-api')
|
||||
let bindingGyp = readFileSync(bindingPath, 'utf8')
|
||||
let processCc = readFileSync(processPath, 'utf8')
|
||||
const originalBinding = bindingGyp
|
||||
const originalProcess = processCc
|
||||
|
||||
for (const dynamicDependency of [
|
||||
String.raw`<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except`,
|
||||
String.raw`<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except`,
|
||||
'../../node-addon-api/node_addon_api.gyp:node_addon_api_except'
|
||||
]) {
|
||||
bindingGyp = bindingGyp.replace(`"${dynamicDependency}",`, '')
|
||||
}
|
||||
bindingGyp = bindingGyp.replace(
|
||||
'"include_dirs": []',
|
||||
'"include_dirs": ["deps/node-addon-api"],\n "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"]'
|
||||
)
|
||||
if (!bindingGyp.includes('"ExceptionHandling": 1')) {
|
||||
bindingGyp = bindingGyp.replace(
|
||||
'"VCCLCompilerTool": {',
|
||||
'"VCCLCompilerTool": {\n "ExceptionHandling": 1,'
|
||||
)
|
||||
}
|
||||
bindingGyp = bindingGyp.replace(
|
||||
/\r?\n\s*"msvs_configuration_attributes": \{\s*"SpectreMitigation": "Spectre"\s*\},?/s,
|
||||
''
|
||||
)
|
||||
processCc = processCc.replace(/process_count < 1024 && /, '')
|
||||
|
||||
if (bindingGyp !== originalBinding) {
|
||||
writeFileSync(bindingPath, bindingGyp)
|
||||
}
|
||||
if (processCc !== originalProcess) {
|
||||
writeFileSync(processPath, processCc)
|
||||
}
|
||||
mkdirSync(stagedHeaderDir, { recursive: true })
|
||||
for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h']) {
|
||||
copyFileSync(join(nodeAddonApiDir, header), join(stagedHeaderDir, header))
|
||||
}
|
||||
if (bindingGyp !== originalBinding || processCc !== originalProcess) {
|
||||
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the PE machine field, so an arm64 request cannot ship an x64 binary. */
|
||||
function readPeMachine(binaryPath) {
|
||||
const fd = openSync(binaryPath, 'r')
|
||||
@@ -109,6 +166,7 @@ function main() {
|
||||
if (!existsSync(PACKAGE_DIR)) {
|
||||
throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`)
|
||||
}
|
||||
applyWindowsProcessTreeBuildFixes()
|
||||
assertPatchApplied()
|
||||
|
||||
console.log(`[windows-process-tree] building ${arch} from ${PACKAGE_DIR}`)
|
||||
|
||||
@@ -378,11 +378,15 @@ function rebuildNodeRuntimeModules(moduleNames) {
|
||||
|
||||
function runPnpm(args, { cwd = projectDir } = {}) {
|
||||
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env =
|
||||
process.platform === 'linux' && args.includes('node-gyp')
|
||||
? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
|
||||
: process.env
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
env: process.env
|
||||
env
|
||||
})
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('ensure-native-runtime', () => {
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeFakeNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
@@ -44,6 +45,9 @@ describe('ensure-native-runtime', () => {
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log).toContain('pnpm exec node-gyp rebuild\n')
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
if (process.platform === 'linux') {
|
||||
expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m)
|
||||
}
|
||||
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$/)
|
||||
@@ -239,6 +243,14 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
if (!markerExists) {
|
||||
throw new Error('ABI mismatch sentinel')
|
||||
}
|
||||
return {
|
||||
dir: '../build/Release/',
|
||||
module: {
|
||||
listJobProcessIds() {},
|
||||
terminateJob() {},
|
||||
assignCurrentProcessToJob() {}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -337,6 +349,10 @@ appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
|
||||
)
|
||||
appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`
|
||||
)
|
||||
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
|
||||
`
|
||||
)
|
||||
|
||||
@@ -47,6 +47,33 @@ function executeInstallScript(fixture) {
|
||||
}
|
||||
|
||||
describe('install-node-dependencies action', () => {
|
||||
it('skips the lockfile diff when a job container has no Git metadata', () => {
|
||||
const fixture = createFixture()
|
||||
try {
|
||||
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
|
||||
|
||||
const result = executeInstallScript(fixture)
|
||||
expect(result.status, result.stderr || result.stdout).toBe(0)
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips the lockfile diff for a bare repository', () => {
|
||||
const fixture = createFixture()
|
||||
try {
|
||||
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
|
||||
expect(run('git', ['init', '--bare', '-q'], { cwd: fixture.workspace }).status).toBe(0)
|
||||
|
||||
const result = executeInstallScript(fixture)
|
||||
expect(result.status, result.stderr || result.stdout).toBe(0)
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['package.json', '{"name":"changed"}\n'],
|
||||
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
|
||||
|
||||
@@ -17,11 +17,11 @@ describe('Electron runtime package contract', () => {
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(patch).toContain('diff --git a/src/Types.ts b/src/Types.ts')
|
||||
expect(patch).toContain('readonly clearModelGeneration: number')
|
||||
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
|
||||
expect(patch).toContain('this.clearModelGeneration++')
|
||||
expect(patch).toContain('this._atlas._clearModelGeneration||0')
|
||||
expect(patch.match(/\^\(\?:\[1-8\]\\d\{2\}\|900\)\$/g)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps root postinstall as the single Electron binary install owner', () => {
|
||||
@@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => {
|
||||
expect(copyStep.run).toContain('git add "$CASK_PATH"')
|
||||
})
|
||||
|
||||
it('installs the Electron package binary in PR checks without changing native module ABI', () => {
|
||||
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
|
||||
const parsedWorkflow = parse(prWorkflow)
|
||||
const installStep = parsedWorkflow.jobs.test.steps.find(
|
||||
it('installs the Electron package binary in the shared unit-test workflow', () => {
|
||||
const unitTestWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8')
|
||||
)
|
||||
const installStep = unitTestWorkflow.jobs.test.steps.find(
|
||||
(step) => step.name === 'Install Electron package binary for tests'
|
||||
)
|
||||
|
||||
|
||||
@@ -116,6 +116,19 @@ const NATIVE_RUNTIME_PREFIXES = [
|
||||
'config/patches/@vscode__windows-process-tree'
|
||||
]
|
||||
|
||||
const NATIVE_CACHE_FILES = new Set([
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'config/scripts/ensure-native-runtime.mjs',
|
||||
'config/scripts/rebuild-native-deps.mjs'
|
||||
])
|
||||
|
||||
const NATIVE_CACHE_PREFIXES = [
|
||||
'config/patches/node-pty@',
|
||||
'config/patches/@vscode__windows-process-tree'
|
||||
]
|
||||
|
||||
const SHARED_PACKAGE_PREFIXES = [
|
||||
'electron.vite.config.ts',
|
||||
'config/electron-builder',
|
||||
@@ -222,7 +235,11 @@ export function classifyPrJobs(changedFiles) {
|
||||
shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
|
||||
])
|
||||
)
|
||||
return { should_run: shouldRun, ...jobs }
|
||||
return {
|
||||
should_run: shouldRun,
|
||||
native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
|
||||
...jobs
|
||||
}
|
||||
}
|
||||
|
||||
function jobDetector(job) {
|
||||
@@ -274,6 +291,10 @@ function isDesktopIrrelevantPath(file) {
|
||||
return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
|
||||
}
|
||||
|
||||
function isNativeCacheInputPath(file) {
|
||||
return NATIVE_CACHE_FILES.has(file) || matchesPrefix(file, NATIVE_CACHE_PREFIXES)
|
||||
}
|
||||
|
||||
function isGlobalForcePath(file) {
|
||||
return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
|
||||
}
|
||||
|
||||
@@ -207,6 +207,22 @@ describe('per-job path classification', () => {
|
||||
expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
|
||||
})
|
||||
|
||||
it('primes native caches only when their immutable inputs change', () => {
|
||||
expect(classifyPrJobs([]).native_cache_changed).toBe(true)
|
||||
expect(classifyPrJobs(['README.md']).native_cache_changed).toBe(false)
|
||||
expect(classifyPrJobs(['src/main/index.ts']).native_cache_changed).toBe(false)
|
||||
for (const file of [
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
'config/scripts/ensure-native-runtime.mjs',
|
||||
'config/scripts/rebuild-native-deps.mjs',
|
||||
'config/patches/node-pty@1.1.0.patch'
|
||||
]) {
|
||||
expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps unit-test-only diffs out of packaging', () => {
|
||||
expectClassification(['src/main/git/git-status.test.ts'], {
|
||||
git_compatibility: true
|
||||
@@ -238,7 +254,7 @@ describe('PR Checks skip wiring', () => {
|
||||
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
|
||||
expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
|
||||
for (const jobName of ['should_run', ...expensiveJobs]) {
|
||||
for (const jobName of ['should_run', 'native_cache_changed', ...expensiveJobs]) {
|
||||
expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
|
||||
`\${{ steps.filter.outputs.${jobName} }}`
|
||||
)
|
||||
@@ -250,13 +266,26 @@ describe('PR Checks skip wiring', () => {
|
||||
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('gates each expensive job on its own classifier output', () => {
|
||||
for (const jobName of expensiveJobs) {
|
||||
it('gates each expensive job on its classifier and cache prerequisite', () => {
|
||||
for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) {
|
||||
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
|
||||
`needs.code_paths.outputs.${jobName} == 'true'`
|
||||
)
|
||||
}
|
||||
expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache'])
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'")
|
||||
expect(prWorkflow.jobs.test_native_cache.needs).toEqual(['code_paths'])
|
||||
expect(prWorkflow.jobs.test_native_cache.if).toBe(
|
||||
"needs.code_paths.outputs.native_cache_changed == 'true'"
|
||||
)
|
||||
expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined()
|
||||
const primerInstall = prWorkflow.jobs.test_native_cache.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
expect(primerInstall.with['node-version']).toBe('24')
|
||||
})
|
||||
|
||||
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parse as parseJsonc } from 'jsonc-parser'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
|
||||
import {
|
||||
hasSshSourceChange,
|
||||
PR_E2E_SOURCE_ROUTES,
|
||||
selectPrE2eSpecs,
|
||||
SSH_SOURCE_ROUTE_IDS
|
||||
} from './pr-e2e-source-routing.mjs'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
@@ -209,6 +214,8 @@ describe('PR E2E gate contract', () => {
|
||||
'src/main/providers/ssh-',
|
||||
'src/main/ipc/pty',
|
||||
'src/relay/',
|
||||
'src/shared/ssh-',
|
||||
'src/renderer/src/store/slices/direct-ssh-',
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-'
|
||||
]
|
||||
for (const authority of sshSourceAuthorities) {
|
||||
@@ -217,10 +224,24 @@ describe('PR E2E gate contract', () => {
|
||||
)
|
||||
}
|
||||
|
||||
// Why named files rather than prefixes: these seams are single modules, and a prefix
|
||||
// here would route their unrelated neighbours.
|
||||
for (const file of [
|
||||
'src/main/runtime/public-ssh-state.ts',
|
||||
'src/renderer/src/startup/ssh-startup-reconnect.ts',
|
||||
'src/renderer/src/store/slices/ssh.ts'
|
||||
]) {
|
||||
expect(selectPrE2eSpecs([file]), file).toContain(
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
|
||||
)
|
||||
}
|
||||
|
||||
const mappedSpecs = [
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
|
||||
]
|
||||
@@ -249,6 +270,140 @@ describe('PR E2E gate contract', () => {
|
||||
expect(changedInstall.run).toContain('openssh-client')
|
||||
})
|
||||
|
||||
it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => {
|
||||
// Why by name: none of these carry "ssh", so the SSH authorities above never reach them
|
||||
// — a closed-tab tombstone and a dropped default-tabs marker both shipped through it.
|
||||
for (const file of [
|
||||
'src/renderer/src/hooks/remote-workspace-session-merge.ts',
|
||||
'src/main/ipc/remote-workspace-snapshot-normalization.ts',
|
||||
'src/renderer/src/lib/worktree-initial-terminal-seeding.ts',
|
||||
'src/renderer/src/lib/worktree-default-terminal-tabs.ts',
|
||||
'src/shared/remote-workspace-session-projection.ts',
|
||||
'src/renderer/src/components/terminal/initial-terminal.ts'
|
||||
]) {
|
||||
const specs = selectPrE2eSpecs([file])
|
||||
expect(specs, file).toContain('tests/e2e/ssh-cold-activation-restore.spec.ts')
|
||||
expect(specs, file).toContain('tests/e2e/ssh-reconnect-tab-destruction.spec.ts')
|
||||
}
|
||||
|
||||
expect(
|
||||
selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts'])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => {
|
||||
// The behavioural half of the invariant, and the part that actually matters: an SSH source
|
||||
// edit is recognised as one, through the same routes that select the specs.
|
||||
for (const file of [
|
||||
'src/main/ssh/connection.ts',
|
||||
'src/relay/pty-handler.ts',
|
||||
'src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts',
|
||||
'src/renderer/src/hooks/remote-workspace-session-merge.ts',
|
||||
'src/main/ipc/remote-workspace-snapshot-normalization.ts'
|
||||
]) {
|
||||
expect(hasSshSourceChange([file]), file).toBe(true)
|
||||
}
|
||||
for (const file of [
|
||||
'src/main/git/git-status.ts',
|
||||
'src/renderer/src/components/tab-bar/BrowserTab.tsx',
|
||||
'src/main/ssh/connection.test.ts'
|
||||
]) {
|
||||
expect(hasSshSourceChange([file]), file).toBe(false)
|
||||
}
|
||||
|
||||
// Why: the signal must stay derived from the routes. A route id that no longer exists would
|
||||
// silently narrow it to nothing.
|
||||
for (const id of SSH_SOURCE_ROUTE_IDS) {
|
||||
expect(
|
||||
PR_E2E_SOURCE_ROUTES.map((route) => route.id),
|
||||
id
|
||||
).toContain(id)
|
||||
}
|
||||
|
||||
// Why text and not structure: a job `if:` is only ever available as a string. The strongest
|
||||
// available assertion is that the source signal is its own disjunct, so the lane no longer
|
||||
// depends on a spec name surviving in a route's spec list.
|
||||
const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
|
||||
expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||")
|
||||
|
||||
expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string')
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.ssh_source_changed).toBe(
|
||||
'${{ steps.filter.outputs.ssh_source_changed }}'
|
||||
)
|
||||
expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe(
|
||||
'${{ needs.e2e-paths.outputs.ssh_source_changed }}'
|
||||
)
|
||||
expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source')
|
||||
expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED')
|
||||
})
|
||||
|
||||
it('gives every Docker-gated SSH spec a lane that runs it', () => {
|
||||
// Why this shape: the sharded lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec
|
||||
// that no runner names runs nowhere and still reports green — the silent skip this file
|
||||
// exists to prevent. Asserting reachability rather than a literal keeps that true when
|
||||
// the lanes move.
|
||||
// Why these two are exempt: each needs something CI cannot give it, recorded in
|
||||
// run-ssh-docker-e2e.mjs so the gap stays legible rather than looking like coverage.
|
||||
const unreachableSpecs = new Set([
|
||||
'tests/e2e/ssh-docker-relay-perf.spec.ts',
|
||||
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
|
||||
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts'
|
||||
])
|
||||
// Why comments are stripped: this file's own runner lists the two exempt specs by name in a
|
||||
// prose comment. A substring scan over raw text would count any spec merely *discussed* in a
|
||||
// runner as claimed by it -- the silent skip this assertion exists to catch, re-entering
|
||||
// through the documentation.
|
||||
const stripComments = (text) =>
|
||||
text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
||||
const laneRunners = [
|
||||
'run-ssh-docker-e2e.mjs',
|
||||
'run-ssh-docker-watcher-isolation-e2e.mjs',
|
||||
'run-ssh-docker-terminal-parking-e2e.mjs'
|
||||
].map((file) => stripComments(readFileSync(join(projectDir, 'config/scripts', file), 'utf8')))
|
||||
|
||||
// Why a comparison and not the bare name: preview and demo specs cite the flag in a
|
||||
// "how to run me" comment without gating on it. Why a regex rather than one literal: an
|
||||
// equally-valid spelling (double quotes, or a `!==` guard) would escape a fixed-string scan
|
||||
// and the spec would silently leave the contract.
|
||||
const dockerGateExpression = /ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/
|
||||
const dockerGatedSpecs = readdirSync(join(projectDir, 'tests/e2e'))
|
||||
.filter((file) => file.endsWith('.spec.ts'))
|
||||
.map((file) => `tests/e2e/${file}`)
|
||||
.filter((spec) => dockerGateExpression.test(readFileSync(join(projectDir, spec), 'utf8')))
|
||||
expect(dockerGatedSpecs.length).toBeGreaterThan(0)
|
||||
|
||||
const unclaimed = dockerGatedSpecs.filter(
|
||||
(spec) => !unreachableSpecs.has(spec) && !laneRunners.some((runner) => runner.includes(spec))
|
||||
)
|
||||
expect(
|
||||
unclaimed,
|
||||
`Docker-gated specs claimed by no lane runner: ${unclaimed.join(', ')}`
|
||||
).toEqual([])
|
||||
|
||||
// Why: an exemption that outlives its spec would quietly excuse a real gap.
|
||||
for (const spec of unreachableSpecs) {
|
||||
expect(dockerGatedSpecs, spec).toContain(spec)
|
||||
// Why also assert absence from every runner: `unreachableSpecs` short-circuits the
|
||||
// unclaimed check above, so a spec could be documented as exempt while a runner still
|
||||
// invokes it -- an exemption that reads as coverage removal but changes nothing, and a
|
||||
// lane that stays red for a reason the file says it excluded.
|
||||
for (const runner of laneRunners) {
|
||||
expect(runner.includes(spec), `${spec} is exempt but still invoked by a lane runner`).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const laneStep = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].steps.find(
|
||||
(step) => step.name === 'Run remaining Docker SSH E2E'
|
||||
)
|
||||
expect(laneStep.run).toContain('test:e2e:ssh-docker')
|
||||
// Why: the added serial tests, several budgeting 4-10 minutes each, do not fit the old 35.
|
||||
expect(
|
||||
e2eWorkflow.jobs['ssh-docker-watcher-isolation']['timeout-minutes']
|
||||
).toBeGreaterThanOrEqual(60)
|
||||
})
|
||||
|
||||
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
|
||||
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
|
||||
|
||||
@@ -18,12 +18,32 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
|
||||
],
|
||||
// Why the store/startup/shared additions: the SSH-named authorities stop at the main
|
||||
// process and the pane component, but the reconnect ledgers and retained-payload
|
||||
// admission that decide whether a pane rebinds live in the renderer store.
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
|
||||
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/main\/runtime\/(?:public-ssh-state|ssh-file-explorer-chunk-read)\.ts|src\/relay\/|src\/shared\/(?:ssh-|skill-ssh-relay-contract)|src\/renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts|src\/renderer\/src\/store\/slices\/(?:ssh|direct-ssh-)|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
// Why a sibling route rather than more paths on ssh-terminal-source: these modules carry
|
||||
// no "ssh" in their names, and only the two restore specs gate them. Folding them in
|
||||
// would run the whole SSH terminal list for a tab-tombstone edit.
|
||||
id: 'ssh-workspace-session-restore',
|
||||
specs: [
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts'
|
||||
],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
@@ -131,6 +151,18 @@ export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
|
||||
return [...specs].sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
/** Routes whose authorities are SSH execution source, and so require the Docker-SSH lane. */
|
||||
export const SSH_SOURCE_ROUTE_IDS = ['ssh-terminal-source', 'ssh-workspace-session-restore']
|
||||
|
||||
// Why derive this from the routes instead of a second path list: the Docker-SSH lane used to
|
||||
// trigger only because one route happened to list a startup-readiness spec, so pruning that
|
||||
// spec would have silently retired the lane. Two lists that must agree is how that drifted.
|
||||
export function hasSshSourceChange(changedPaths) {
|
||||
return PR_E2E_SOURCE_ROUTES.filter((route) => SSH_SOURCE_ROUTE_IDS.includes(route.id)).some(
|
||||
(route) => changedPaths.some(route.matches)
|
||||
)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
let input = ''
|
||||
process.stdin.setEncoding('utf8')
|
||||
@@ -138,6 +170,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
input += chunk
|
||||
}
|
||||
const changedPaths = input.split(/\r?\n/).filter(Boolean)
|
||||
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
|
||||
process.stdout.write(`${JSON.stringify(specs)}\n`)
|
||||
if (process.argv.includes('--ssh-source')) {
|
||||
process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
|
||||
} else {
|
||||
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
|
||||
process.stdout.write(`${JSON.stringify(specs)}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
|
||||
const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
|
||||
const dependencyAction = parse(
|
||||
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
|
||||
)
|
||||
@@ -47,22 +49,35 @@ describe('PR workflow parallelism', () => {
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
})
|
||||
|
||||
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: 8 }, (_, index) => index + 1)
|
||||
it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => {
|
||||
const sharedTest = unitTestWorkflow.jobs.test
|
||||
const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
|
||||
const installStep = sharedTest.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
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(
|
||||
const primerInstall = workflow.jobs.test_native_cache.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
|
||||
expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
|
||||
expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
|
||||
expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26'])
|
||||
expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
|
||||
expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
|
||||
expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
|
||||
expect(sharedTest.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 8 }, (_, index) => index + 1)
|
||||
)
|
||||
expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
|
||||
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(testStep.run).toContain(`--exclude=${testFile}`)
|
||||
}
|
||||
expect(primerInstall.with['native-runtime']).toBe('node')
|
||||
expect(primerInstall.with['node-version']).toBe('24')
|
||||
expect(workflow.jobs.test.needs).toContain('test_native_cache')
|
||||
})
|
||||
|
||||
it('runs real-shell coverage once outside the general shards', () => {
|
||||
@@ -208,6 +223,8 @@ describe('PR workflow parallelism', () => {
|
||||
|
||||
expect(pnpmIndex).toBeLessThan(nodeIndex)
|
||||
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
|
||||
const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
|
||||
expect(steps[pnpmIndex].with.version).toBe(packageManagerVersion)
|
||||
expect(steps[nodeIndex].with.cache).toBe('pnpm')
|
||||
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
|
||||
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
|
||||
@@ -232,13 +249,16 @@ describe('PR workflow parallelism', () => {
|
||||
workflow.jobs[jobName].steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
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(sharedTestInstall.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')
|
||||
@@ -316,6 +336,9 @@ describe('PR workflow parallelism', () => {
|
||||
expect(cacheStep.with.key).toContain(
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
|
||||
)
|
||||
expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
|
||||
expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
|
||||
expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
|
||||
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@')
|
||||
|
||||
@@ -434,6 +434,20 @@ describe('check-mode reporting', () => {
|
||||
// These run without network or a build, so ordinary `pnpm test` catches the two
|
||||
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
|
||||
describe('committed xterm patch artifacts', () => {
|
||||
it('normalizes Chromium font-weight serialization in every WebGL runtime copy', async () => {
|
||||
const patch = await readFile(
|
||||
path.join(REPO_ROOT, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
|
||||
'utf8'
|
||||
)
|
||||
expect(patch).not.toMatch(/desiredWeight !== '400'|orcaProbeWeight!=="400"/)
|
||||
expect(
|
||||
patch.match(/(?:actualWeightToken === 'normal'|orcaActualWeightToken==="normal")/g)
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
patch.match(/(?:actualWeight !== desiredWeight|orcaActualWeight!==orcaProbeWeight)/g)
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('records the lockfile hash pnpm derives from the patch file', async () => {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
|
||||
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const rawExtraArgs = process.argv.slice(2)
|
||||
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_E2E_SSH_DOCKER: '1',
|
||||
ORCA_E2E_WEB_CLIENT: '1'
|
||||
}
|
||||
|
||||
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
|
||||
const spawnOptions = {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32'
|
||||
}
|
||||
|
||||
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
|
||||
|
||||
if (runtime.status !== 0) {
|
||||
process.exit(runtime.status ?? 1)
|
||||
}
|
||||
|
||||
// Why one explicit list: these specs self-skip without ORCA_E2E_SSH_DOCKER and no sharded lane
|
||||
// sets it, so a spec in no runner runs nowhere. The gate contract proves every flag-reading
|
||||
// spec is claimed here, by the watcher-isolation or parking runner, or by a listed exclusion.
|
||||
//
|
||||
// Deliberately absent, and therefore still covered by no CI trigger:
|
||||
// ssh-docker-relay-perf.spec.ts — wall-clock latency thresholds; flaky budgets here would
|
||||
// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in
|
||||
// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at
|
||||
// all. Recorded as a real gap, not as coverage living somewhere else.
|
||||
// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
|
||||
// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
|
||||
// ssh-docker-bulk-open-freeze-repro.spec.ts — two reasons, both disqualifying:
|
||||
// (a) it is a perf oracle, not a correctness one: SOFT_FREEZE_LAG_MS=2500 /
|
||||
// HARD_FREEZE_LAG_MS=5000 measured by a renderer lag probe under a deliberate
|
||||
// 5-pane output flood on a 420s budget. Same rule as ssh-docker-relay-perf above.
|
||||
// (b) it is ROTTED: four call sites are out of date against terminal.ts's current
|
||||
// helpers — execInTerminal gained a ptyId parameter and splitActiveTerminalPane
|
||||
// gained a direction, so it cannot compile, let alone pass. Repairing it needs two
|
||||
// semantic decisions (which ptyId to capture, which split direction) that change
|
||||
// what the repro measures. Tracked in stablyai/orca#16764.
|
||||
//
|
||||
// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
|
||||
// grep-inverts away.
|
||||
//
|
||||
// Known gaps in SSH e2e coverage, recorded here because nothing else names them:
|
||||
// - The job that runs this is still called `ssh-docker-watcher-isolation`, though watcher
|
||||
// isolation is now one spec of many. Renaming it changes the GitHub check name and can
|
||||
// break required-check config, so the name understates the job on purpose.
|
||||
// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on
|
||||
// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment
|
||||
// has the exact wiring to flip it, and the gate contract asserts the current state.
|
||||
// - Five specs and one unit test are gated on env vars no workflow sets, so they run nowhere
|
||||
// and are not Docker-gated, which puts them outside this file's contract:
|
||||
// local-ssh-browser-routing (ORCA_E2E_LOCAL_SSH_BROWSER)
|
||||
// ssh-client-hosted-browser-drop-reconnect (ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER)
|
||||
// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH)
|
||||
// ssh-localhost (ORCA_E2E_SSH_LOCALHOST)
|
||||
// ssh-browser-network-execution-route.docker.unit.test.ts (ORCA_RUN_DOCKER_SSH_BROWSER_E2E)
|
||||
// Runner scripts for the first four sit unused in package.json; no workflow calls them.
|
||||
const result = spawnSync(
|
||||
pnpm,
|
||||
[
|
||||
'exec',
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-external-image-preview.spec.ts',
|
||||
'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts',
|
||||
'tests/e2e/ssh-pi-compatible-agent-title.spec.ts',
|
||||
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
|
||||
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
|
||||
'tests/e2e/ssh-restart-tab-accumulation.spec.ts',
|
||||
'tests/e2e/ssh-skill-installation.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--project',
|
||||
'electron-headful',
|
||||
'--workers=1',
|
||||
...extraArgs
|
||||
],
|
||||
spawnOptions
|
||||
)
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -9,26 +9,30 @@ const PATCH = readFileSync(
|
||||
'utf8'
|
||||
)
|
||||
const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
const ABSOLUTE_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
|
||||
const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
|
||||
|
||||
describe('windows-process-tree node-addon-api gyp path', () => {
|
||||
it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => {
|
||||
expect(PATCH).toContain(
|
||||
`+ "<!(node -p \\"require.resolve('node-addon-api/node_addon_api.gyp')\\"):node_addon_api_except"`
|
||||
it('stages headers without a pnpm-sensitive gyp dependency', () => {
|
||||
expect(PATCH).not.toContain('+ "../../node-addon-api')
|
||||
expect(PATCH).toContain('+ "include_dirs": ["deps/node-addon-api"],')
|
||||
expect(PATCH).toContain('+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],')
|
||||
const buildScript = readFileSync(
|
||||
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
|
||||
expect(bindingGyp).toContain(ABSOLUTE_GYP)
|
||||
expect(bindingGyp).not.toContain("require('node-addon-api').targets")
|
||||
expect(
|
||||
readFileSync(
|
||||
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
).toContain(ABSOLUTE_GYP)
|
||||
expect(buildScript).toContain(
|
||||
"for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h'])"
|
||||
)
|
||||
expect(buildScript).toContain("import { createRequire } from 'node:module'")
|
||||
expect(buildScript).toContain("import { dirname, join, resolve } from 'node:path'")
|
||||
expect(buildScript).toContain(
|
||||
"createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')"
|
||||
)
|
||||
expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.')
|
||||
})
|
||||
|
||||
it('resolves node_addon_api.gyp to a real file from the package directory', () => {
|
||||
const resolved = execFileSync(process.execPath, ['-p', ABSOLUTE_GYP], {
|
||||
const resolved = execFileSync(process.execPath, ['-p', RESOLVED_GYP], {
|
||||
cwd: PACKAGE_DIR,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
|
||||
@@ -50,6 +50,11 @@ describe('packaged Windows PTY native capability routing', () => {
|
||||
expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
|
||||
expect(nodeCacheSave.with.key).toContain('-node-node')
|
||||
expect(electronCache.with.key).toContain('-electron-node')
|
||||
for (const cache of [nodeCacheSave, electronCache]) {
|
||||
expect(cache.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
|
||||
expect(cache.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
|
||||
expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
|
||||
}
|
||||
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')
|
||||
|
||||
@@ -209,6 +209,7 @@ 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'),
|
||||
|
||||
+209
-23
@@ -1,20 +1,35 @@
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import Constants from 'expo-constants'
|
||||
import { ChevronLeft, Copy, Check } from 'lucide-react-native'
|
||||
import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { connectionLogStore } from '../src/transport/connection-log-buffer'
|
||||
import { useHostClient } from '../src/transport/client-context'
|
||||
import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
|
||||
import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
useConnectionPathStatus,
|
||||
useReconnectAttempt
|
||||
} from '../src/transport/client-context-connection-metrics'
|
||||
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
|
||||
import {
|
||||
diagnoseConnection,
|
||||
getReportableConnectionIncidentId
|
||||
} from '../src/diagnostics/connection-diagnostics-analysis'
|
||||
import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission'
|
||||
import {
|
||||
readHydratedConnectionLog,
|
||||
readConnectionDiagnosticsSnapshot,
|
||||
resolveDiagnosticsHostId,
|
||||
getDiagnosticsSubmissionState,
|
||||
updateDiagnosticsSubmissionState,
|
||||
type DiagnosticsSubmissionStates
|
||||
} from '../src/diagnostics/connection-diagnostics-screen-data'
|
||||
import { useHostStatusGates } from '../src/transport/host-status-gates'
|
||||
import { loadHostAppVersion } from '../src/transport/host-app-version-store'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
|
||||
|
||||
// Why: getSnapshot must be referentially stable when there's no data —
|
||||
@@ -25,11 +40,19 @@ const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
|
||||
// screen also *acquires* the host client — opening it kicks a dial and the
|
||||
// log fills live instead of showing a stale tail.
|
||||
export default function ConnectionLogScreen() {
|
||||
const clientContext = useRpcClientContext()
|
||||
const router = useRouter()
|
||||
const params = useLocalSearchParams<{ hostId?: string }>()
|
||||
const insets = useSafeAreaInsets()
|
||||
const routeKey = useMemo(() => ({}), [params.hostId])
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [manualSelection, setManualSelection] = useState<{
|
||||
hostId: string
|
||||
requestedHostId: string | undefined
|
||||
routeKey: object
|
||||
} | null>(null)
|
||||
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
|
||||
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
@@ -38,17 +61,28 @@ export default function ConnectionLogScreen() {
|
||||
return
|
||||
}
|
||||
setHosts(loaded)
|
||||
setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey)
|
||||
const selected = hosts.find((h) => h.id === selectedId) ?? null
|
||||
const { state } = useHostClient(selected?.id)
|
||||
const { client, state } = useHostClient(selected?.id)
|
||||
const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({
|
||||
hostId: selected?.id,
|
||||
client,
|
||||
connState: state
|
||||
})
|
||||
const reconnectAttempts = useReconnectAttempt(selected?.id)
|
||||
const lastConnectedAt = useLastConnectedAt(selected?.id)
|
||||
const { activePath, pendingPath } = useConnectionPathStatus(selected?.id)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) {
|
||||
void readHydratedConnectionLog(connectionLogStore, selectedId)
|
||||
}
|
||||
}, [selectedId])
|
||||
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) =>
|
||||
@@ -60,25 +94,93 @@ export default function ConnectionLogScreen() {
|
||||
[selectedId]
|
||||
)
|
||||
const entries = useSyncExternalStore(subscribe, getSnapshot)
|
||||
const diagnosis = selected
|
||||
? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries })
|
||||
: null
|
||||
const incidentId = selected
|
||||
? getReportableConnectionIncidentId({
|
||||
endpoint: selected.endpoint,
|
||||
state,
|
||||
activePath,
|
||||
pendingPath,
|
||||
entries
|
||||
})
|
||||
: null
|
||||
const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null
|
||||
const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey)
|
||||
const copied = copiedHostId === selectedId
|
||||
|
||||
const copyDiagnostics = useCallback(async () => {
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
|
||||
const snapshot = await readConnectionDiagnosticsSnapshot(
|
||||
clientContext,
|
||||
connectionLogStore,
|
||||
selected.id
|
||||
)
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: selected.name,
|
||||
endpoint: selected.endpoint,
|
||||
state,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt,
|
||||
state: snapshot.state,
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
lastConnectedAt: snapshot.lastConnectedAt,
|
||||
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
|
||||
appVersion: Constants.expoConfig?.version ?? 'unknown',
|
||||
entries
|
||||
desktopAppVersion,
|
||||
entries: snapshot.entries,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath
|
||||
})
|
||||
await Clipboard.setStringAsync(report)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [selected, state, reconnectAttempts, lastConnectedAt, entries])
|
||||
setCopiedHostId(selected.id)
|
||||
setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000)
|
||||
}, [selected, liveDesktopAppVersion, clientContext])
|
||||
|
||||
const sendDiagnostics = useCallback(async () => {
|
||||
if (!selected || !submissionKey || submissionState === 'sending') {
|
||||
return
|
||||
}
|
||||
const startedKey = submissionKey
|
||||
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending'))
|
||||
const appVersion = Constants.expoConfig?.version ?? 'unknown'
|
||||
const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim()
|
||||
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
|
||||
const snapshot = await readConnectionDiagnosticsSnapshot(
|
||||
clientContext,
|
||||
connectionLogStore,
|
||||
selected.id
|
||||
)
|
||||
const currentIncidentId = getReportableConnectionIncidentId({
|
||||
endpoint: selected.endpoint,
|
||||
state: snapshot.state,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath,
|
||||
entries: snapshot.entries
|
||||
})
|
||||
if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) {
|
||||
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null))
|
||||
return
|
||||
}
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: selected.name,
|
||||
endpoint: selected.endpoint,
|
||||
state: snapshot.state,
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
lastConnectedAt: snapshot.lastConnectedAt,
|
||||
platform,
|
||||
appVersion,
|
||||
desktopAppVersion,
|
||||
entries: snapshot.entries,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath
|
||||
})
|
||||
const result = await submitConnectionDiagnostics({ report, appVersion, platform })
|
||||
setSubmissionStates((states) =>
|
||||
updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed')
|
||||
)
|
||||
}, [selected, submissionKey, submissionState, liveDesktopAppVersion, clientContext])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
@@ -86,7 +188,7 @@ export default function ConnectionLogScreen() {
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Connection log</Text>
|
||||
<Text style={styles.heading}>Network diagnostics</Text>
|
||||
</View>
|
||||
|
||||
{hosts.length > 1 && (
|
||||
@@ -95,7 +197,9 @@ export default function ConnectionLogScreen() {
|
||||
<Pressable
|
||||
key={host.id}
|
||||
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
|
||||
onPress={() => setSelectedId(host.id)}
|
||||
onPress={() =>
|
||||
setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey })
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
|
||||
@@ -121,14 +225,49 @@ export default function ConnectionLogScreen() {
|
||||
) : (
|
||||
<Copy size={14} color={colors.textSecondary} />
|
||||
)}
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy diagnostics'}</Text>
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy report'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{diagnosis && (
|
||||
<View style={styles.diagnosisCard}>
|
||||
<Text style={styles.diagnosisHeading}>What this suggests</Text>
|
||||
<Text style={styles.diagnosisText}>{diagnosis.likelyCause}</Text>
|
||||
<Text style={styles.diagnosisNext}>{diagnosis.nextStep}</Text>
|
||||
{diagnosis.reportability === 'orca-relay' && (
|
||||
<>
|
||||
<Text style={styles.privacyHint}>
|
||||
Sends a size-limited redacted report including host name, endpoint, versions,
|
||||
connection state, and events—never terminal contents or credentials.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.sendButton}
|
||||
onPress={() => void sendDiagnostics()}
|
||||
disabled={submissionState === 'sending'}
|
||||
>
|
||||
{submissionState === 'sent' ? (
|
||||
<Check size={14} color={colors.statusGreen} />
|
||||
) : (
|
||||
<Send size={14} color={colors.textPrimary} />
|
||||
)}
|
||||
<Text style={styles.sendButtonText}>
|
||||
{submissionState === 'sending'
|
||||
? 'Sending…'
|
||||
: submissionState === 'sent'
|
||||
? 'Diagnostics sent'
|
||||
: submissionState === 'failed'
|
||||
? 'Retry sending'
|
||||
: 'Send diagnostics to Orca'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{entries.length > 0 ? (
|
||||
<ConnectionLog entries={[...entries]} title={selected.name} />
|
||||
<ConnectionLog entries={[...entries]} title={selected.name} fillAvailableHeight />
|
||||
) : (
|
||||
<Text style={styles.emptyText}>
|
||||
No connection events yet this session. Events appear as the app dials this host.
|
||||
No connection events yet. Events appear as the app dials this host.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
@@ -199,6 +338,53 @@ const styles = StyleSheet.create({
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
diagnosisCard: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: 10,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
diagnosisHeading: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
diagnosisText: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textPrimary,
|
||||
lineHeight: 18
|
||||
},
|
||||
diagnosisNext: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 18,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
privacyHint: {
|
||||
marginTop: spacing.sm,
|
||||
fontSize: 11,
|
||||
lineHeight: 15,
|
||||
color: colors.textMuted
|
||||
},
|
||||
sendButton: {
|
||||
marginTop: spacing.md,
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
sendButtonText: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
+15
-309
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
|
||||
import { View, Text, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
import {
|
||||
@@ -63,14 +63,16 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { HostDiagnosticsLink } from '../../../src/components/HostDiagnosticsLink'
|
||||
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
|
||||
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
import { setCachedRepos } from '../../../src/cache/repo-cache'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { colors, spacing } from '../../../src/theme/mobile-theme'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
import { hostScreenStyles as styles } from '../../../src/host-screen/host-screen-styles'
|
||||
import { leaveHostRoute } from '../../../src/host-route-exit'
|
||||
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
|
||||
import {
|
||||
@@ -1090,6 +1092,17 @@ export function HostScreen({
|
||||
/>
|
||||
)}
|
||||
|
||||
{connState !== 'connected' &&
|
||||
!relayRecovery.pairingRejected &&
|
||||
reconnectAttempts >= 3 &&
|
||||
hostId ? (
|
||||
<HostDiagnosticsLink
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/connection-log', params: { hostId: String(hostId) } })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
|
||||
{routeNotice && (
|
||||
<HostRouteNoticeBanner
|
||||
@@ -1410,310 +1423,3 @@ export default function HostWorktreeRoute() {
|
||||
function ListSeparator() {
|
||||
return <View style={styles.separator} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
topChrome: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
statusBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 34,
|
||||
paddingTop: spacing.xs,
|
||||
paddingHorizontal: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sidebarCollapseButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
hostIdentity: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
marginRight: spacing.md
|
||||
},
|
||||
hostNameText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
reconnectButton: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
reconnectButtonText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbar: {
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
embeddedFilterChip: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
embeddedModeButton: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
filterChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
filterChipActive: {
|
||||
borderColor: colors.textSecondary,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
filterChipText: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterChipTextActive: {
|
||||
color: colors.textPrimary
|
||||
},
|
||||
modeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
sortLabel: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
toolbarSpacer: {
|
||||
flex: 1
|
||||
},
|
||||
floatingWorkspaceHeaderButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
embeddedToolbarIconButton: {
|
||||
flex: 1,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
toolbarIconDisabled: {
|
||||
opacity: 0.6
|
||||
},
|
||||
searchToggle: {
|
||||
padding: spacing.xs
|
||||
},
|
||||
searchBar: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
list: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs
|
||||
},
|
||||
sectionIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionRepoIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
sectionCount: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginLeft: spacing.lg + 24,
|
||||
marginRight: spacing.lg
|
||||
},
|
||||
filterModalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterModalTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterSectionLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
filterGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
gap: spacing.sm
|
||||
},
|
||||
filterRowText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
filterSeparator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
filterRepoDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4
|
||||
},
|
||||
confirmContent: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
confirmTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
confirmMessage: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
lineHeight: 20
|
||||
},
|
||||
confirmButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
confirmBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
confirmBtnCancel: {
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
confirmBtnDestructive: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
confirmBtnPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
confirmBtnCancelText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
confirmBtnDestructiveText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -117,6 +117,10 @@ import {
|
||||
} from '../../../../src/terminal/terminal-accessory-layout'
|
||||
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
|
||||
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
|
||||
import {
|
||||
createTerminalAccessoryRepeatController,
|
||||
createTerminalAccessoryRepeatSender
|
||||
} from '../../../../src/terminal/terminal-accessory-repeat'
|
||||
import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
@@ -2993,16 +2997,22 @@ export default function SessionScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccessoryKey(input: ReturnType<typeof createTerminalLiveAccessoryInput>) {
|
||||
if (!client || !activeHandle || !canSend) {
|
||||
return
|
||||
async function handleAccessoryKey(
|
||||
input: ReturnType<typeof createTerminalLiveAccessoryInput>,
|
||||
targetHandle = activeHandleRef.current,
|
||||
isDeliveryTargetCurrent: () => boolean = () => targetHandle === activeHandleRef.current
|
||||
) {
|
||||
if (!client || !targetHandle || !isDeliveryTargetCurrent() || !canSend) {
|
||||
return false
|
||||
}
|
||||
const targetHandle = activeHandle
|
||||
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
|
||||
if (accessoryCommit.kind !== 'allow-raw') {
|
||||
return
|
||||
if (!isDeliveryTargetCurrent()) {
|
||||
return false
|
||||
}
|
||||
await sendTerminalLiveAccessoryRawBytes({
|
||||
if (accessoryCommit.kind !== 'allow-raw') {
|
||||
return accessoryCommit.kind === 'handled'
|
||||
}
|
||||
return sendTerminalLiveAccessoryRawBytes({
|
||||
client: clientRef.current,
|
||||
targetHandle,
|
||||
activeHandle: activeHandleRef.current,
|
||||
@@ -3399,32 +3409,37 @@ export default function SessionScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive).
|
||||
const repeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const repeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
// Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal.
|
||||
const accessoryRepeatRef = useRef(
|
||||
createTerminalAccessoryRepeatController<ReturnType<typeof createTerminalLiveAccessoryInput>>()
|
||||
)
|
||||
// Why: the current callback observes reconnect state while the sender pins the press to its original terminal.
|
||||
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
|
||||
handleAccessoryKeyRef.current = handleAccessoryKey
|
||||
const stopAccessoryRepeat = useCallback(() => {
|
||||
if (repeatTimeoutRef.current) {
|
||||
clearTimeout(repeatTimeoutRef.current)
|
||||
repeatTimeoutRef.current = null
|
||||
}
|
||||
if (repeatIntervalRef.current) {
|
||||
clearInterval(repeatIntervalRef.current)
|
||||
repeatIntervalRef.current = null
|
||||
}
|
||||
accessoryRepeatRef.current.stop()
|
||||
}, [])
|
||||
const startAccessoryRepeat = useCallback(
|
||||
(input: ReturnType<typeof createTerminalLiveAccessoryInput>) => {
|
||||
stopAccessoryRepeat()
|
||||
repeatTimeoutRef.current = setTimeout(() => {
|
||||
repeatIntervalRef.current = setInterval(() => {
|
||||
void handleAccessoryKeyRef.current(input)
|
||||
}, 45)
|
||||
}, 400)
|
||||
// Why: a held repeat must not resume through a replacement client or connection.
|
||||
const targetClient = clientRef.current
|
||||
const targetConnectedAt = targetClient?.getLastConnectedAt() ?? null
|
||||
const isDeliveryTargetCurrent = (targetHandle: string) =>
|
||||
activeHandleRef.current === targetHandle &&
|
||||
targetClient !== null &&
|
||||
clientRef.current === targetClient &&
|
||||
connStateRef.current === 'connected' &&
|
||||
targetClient.getLastConnectedAt() === targetConnectedAt
|
||||
accessoryRepeatRef.current.start(
|
||||
input,
|
||||
createTerminalAccessoryRepeatSender(
|
||||
activeHandleRef.current,
|
||||
isDeliveryTargetCurrent,
|
||||
(nextInput, targetHandle, isTargetCurrent) =>
|
||||
handleAccessoryKeyRef.current(nextInput, targetHandle, isTargetCurrent)
|
||||
)
|
||||
)
|
||||
},
|
||||
[stopAccessoryRepeat]
|
||||
[]
|
||||
)
|
||||
const setMobileSessionRootRef = useCallback(
|
||||
(node: View | null): void => {
|
||||
@@ -3440,15 +3455,14 @@ export default function SessionScreen() {
|
||||
clearPendingLiveInputCommit()
|
||||
sessionTabActionSheetRequestSeqRef.current += 1
|
||||
clearSessionTabActionSheetKeyboardListener()
|
||||
stopAccessoryRepeat()
|
||||
accessoryRepeatRef.current.cancel()
|
||||
},
|
||||
[
|
||||
clearPendingLiveInputCommit,
|
||||
clearDelayedActionTimers,
|
||||
clearSessionTabActionSheetKeyboardListener,
|
||||
clearTerminalCache,
|
||||
clearToastHideTimer,
|
||||
stopAccessoryRepeat
|
||||
clearToastHideTimer
|
||||
]
|
||||
)
|
||||
|
||||
@@ -4848,7 +4862,6 @@ export default function SessionScreen() {
|
||||
return
|
||||
}
|
||||
const input = createTerminalLiveAccessoryInput(key)
|
||||
void handleAccessoryKey(input)
|
||||
startAccessoryRepeat(input)
|
||||
}}
|
||||
onPressOut={() => {
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function TroubleshootScreen() {
|
||||
onPress={() => router.push('/connection-log')}
|
||||
>
|
||||
<ScrollText size={16} color={colors.textPrimary} />
|
||||
<Text style={styles.diagnosticButtonLabel}>View connection log</Text>
|
||||
<Text style={styles.diagnosticButtonLabel}>View network diagnostics</Text>
|
||||
</Pressable>
|
||||
|
||||
{checks.length > 0 && (
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
|
||||
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
|
||||
// can't resolve Node's builtin in a React Native bundle.
|
||||
import { Buffer } from 'buffer'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -10,7 +7,6 @@ import {
|
||||
PanResponder,
|
||||
PixelRatio,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
@@ -24,7 +20,7 @@ import type {
|
||||
BrowserScreencastFrame,
|
||||
BrowserScreencastFrameMetadata
|
||||
} from '../transport/browser-screencast-protocol'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
|
||||
buildMobileBrowserScreencastRequest,
|
||||
@@ -47,11 +43,32 @@ import {
|
||||
computeBrowserTouchClickRadiusCss,
|
||||
mapScreenToBrowserPoint,
|
||||
readLocalTouchPoint,
|
||||
type BrowserFrameGeometry,
|
||||
type BrowserPoint,
|
||||
type BrowserTouchLayout,
|
||||
type BrowserZoomState
|
||||
} from './browser-touch-geometry'
|
||||
import {
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
assertRpcOk,
|
||||
browserErrorMessage,
|
||||
browserFrameMetadataEqual,
|
||||
buttonColor,
|
||||
cacheBrowserFrame,
|
||||
clearCachedBrowserFramesForWorktree,
|
||||
createBrowserFrameDataUri,
|
||||
createPinchGesture,
|
||||
getCachedBrowserFrame,
|
||||
makeBrowserFrameCacheKey,
|
||||
peekCachedBrowserFrame,
|
||||
shouldSurfaceBrowserError,
|
||||
updateBrowserImageSource,
|
||||
updateBrowserLayerVisibility,
|
||||
updatePinchZoom,
|
||||
type FrameLayer,
|
||||
type PinchGesture
|
||||
} from './mobile-browser-frame-state'
|
||||
import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles'
|
||||
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
|
||||
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
|
||||
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
|
||||
@@ -79,15 +96,6 @@ type MobileBrowserPaneProps = {
|
||||
onToast: (message: string, durationMs?: number) => void
|
||||
}
|
||||
|
||||
type FrameLayer = 0 | 1
|
||||
|
||||
type PinchGesture = {
|
||||
distance: number
|
||||
scale: number
|
||||
anchorX: number
|
||||
anchorY: number
|
||||
}
|
||||
|
||||
type PanGesture = {
|
||||
x: number
|
||||
y: number
|
||||
@@ -105,17 +113,7 @@ const SCROLL_START_SLOP = 22
|
||||
const LONG_PRESS_MS = 550
|
||||
const WHEEL_INTERVAL_MS = 70
|
||||
const TOUCH_CLICK_RADIUS_DIP = 14
|
||||
const MIN_ZOOM = 1
|
||||
const MAX_ZOOM = 3.5
|
||||
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
|
||||
const BROWSER_FRAME_CACHE_LIMIT = 4
|
||||
|
||||
type BrowserFrameCacheEntry = {
|
||||
uri: string
|
||||
metadata: BrowserScreencastFrameMetadata
|
||||
}
|
||||
|
||||
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
|
||||
|
||||
type BrowserPageParams = {
|
||||
worktree: string
|
||||
@@ -1268,350 +1266,3 @@ export function MobileBrowserPane({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function buttonColor(enabled: boolean): string {
|
||||
return enabled ? colors.textSecondary : colors.textMuted
|
||||
}
|
||||
|
||||
function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
|
||||
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
|
||||
}
|
||||
|
||||
function makeBrowserFrameCacheKey(
|
||||
worktreeId: string,
|
||||
browserPageId: string | null,
|
||||
viewMode: MobileBrowserViewMode
|
||||
): string | null {
|
||||
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
|
||||
}
|
||||
|
||||
function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
|
||||
const prefix = `${worktreeId}:`
|
||||
for (const key of browserFrameCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
browserFrameCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
if (!cacheKey) {
|
||||
return null
|
||||
}
|
||||
const cached = browserFrameCache.get(cacheKey)
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
|
||||
}
|
||||
|
||||
function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
|
||||
if (!cacheKey) {
|
||||
return
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, entry)
|
||||
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
|
||||
const oldestKey = browserFrameCache.keys().next().value
|
||||
if (typeof oldestKey !== 'string') {
|
||||
break
|
||||
}
|
||||
browserFrameCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
function updateBrowserLayerVisibility(
|
||||
layers: [View | null, View | null],
|
||||
visible: FrameLayer
|
||||
): void {
|
||||
for (const [index, layer] of layers.entries()) {
|
||||
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
|
||||
}
|
||||
}
|
||||
|
||||
function updateBrowserImageSource(image: Image | null, uri: string): void {
|
||||
// Why: browser frames are large strings; mutating only the native Image
|
||||
// source avoids re-rendering the whole tab view for every streamed frame.
|
||||
const source = [{ uri }]
|
||||
image?.setNativeProps({ source, src: source })
|
||||
}
|
||||
|
||||
function assertRpcOk(
|
||||
response: RpcSuccess | RpcFailure,
|
||||
fallbackMessage: string
|
||||
): asserts response is RpcSuccess {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message || fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
function browserFrameMetadataEqual(
|
||||
a: BrowserScreencastFrameMetadata | null,
|
||||
b: BrowserScreencastFrameMetadata
|
||||
): boolean {
|
||||
return (
|
||||
a?.deviceWidth === b.deviceWidth &&
|
||||
a?.deviceHeight === b.deviceHeight &&
|
||||
a?.pageScaleFactor === b.pageScaleFactor
|
||||
)
|
||||
}
|
||||
|
||||
function browserErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
function shouldSurfaceBrowserError(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
// Why: selector_not_found can be emitted by in-flight page automation while
|
||||
// the browser is still usable; replacing the frame with it feels like a crash.
|
||||
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
|
||||
}
|
||||
|
||||
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
|
||||
const touches = event.nativeEvent.touches
|
||||
if (!touches || touches.length < 2) {
|
||||
return null
|
||||
}
|
||||
const a = readLocalTouchPoint(touches[0])
|
||||
const b = readLocalTouchPoint(touches[1])
|
||||
return a && b ? { a, b } : null
|
||||
}
|
||||
|
||||
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
function createPinchGesture(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
zoom: BrowserZoomState
|
||||
): PinchGesture | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const distance = pointDistance(pair.a, pair.b)
|
||||
if (distance < 8) {
|
||||
return null
|
||||
}
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
|
||||
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
|
||||
return {
|
||||
distance,
|
||||
scale: zoom.scale,
|
||||
anchorX: (centerX - frameCenterX) / zoom.scale,
|
||||
anchorY: (centerY - frameCenterY) / zoom.scale
|
||||
}
|
||||
}
|
||||
|
||||
function updatePinchZoom(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
pinch: PinchGesture
|
||||
): BrowserZoomState | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const nextScale = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
|
||||
)
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
|
||||
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
|
||||
return clampBrowserZoomState(
|
||||
{
|
||||
scale: nextScale,
|
||||
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
|
||||
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
|
||||
},
|
||||
geometry,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
toolbar: {
|
||||
minHeight: 32,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
viewport: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
browserImageHost: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImageFill: {
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
},
|
||||
browserImageLayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserImageLayerHidden: {
|
||||
opacity: 0
|
||||
},
|
||||
browserZoomOffset: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserFrameBox: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImage: {
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.2)'
|
||||
},
|
||||
errorText: {
|
||||
color: colors.textPrimary,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
dialogOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.5)'
|
||||
},
|
||||
dialogCard: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel,
|
||||
padding: spacing.lg
|
||||
},
|
||||
dialogTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogMessage: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
dialogActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
dialogButton: {
|
||||
minHeight: 34,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
dialogButtonPrimary: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
dialogButtonPressed: {
|
||||
opacity: 0.75
|
||||
},
|
||||
dialogButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogButtonPrimaryText: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
keyboardDock: {
|
||||
zIndex: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.xs + 2
|
||||
},
|
||||
keyboardInput: {
|
||||
flex: 1,
|
||||
height: 34,
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
sendButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.35
|
||||
},
|
||||
disabledText: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Buffer } from 'buffer'
|
||||
import type { GestureResponderEvent, Image, View } from 'react-native'
|
||||
import type { RpcFailure, RpcSuccess } from '../transport/types'
|
||||
import type {
|
||||
BrowserScreencastFrame,
|
||||
BrowserScreencastFrameMetadata
|
||||
} from '../transport/browser-screencast-protocol'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
clampBrowserZoomState,
|
||||
readLocalTouchPoint,
|
||||
type BrowserFrameGeometry,
|
||||
type BrowserPoint,
|
||||
type BrowserZoomState
|
||||
} from './browser-touch-geometry'
|
||||
import type { MobileBrowserViewMode } from './browser-screencast-request'
|
||||
|
||||
export type FrameLayer = 0 | 1
|
||||
export type PinchGesture = {
|
||||
distance: number
|
||||
scale: number
|
||||
anchorX: number
|
||||
anchorY: number
|
||||
}
|
||||
export type BrowserFrameCacheEntry = {
|
||||
uri: string
|
||||
metadata: BrowserScreencastFrameMetadata
|
||||
}
|
||||
export const MIN_ZOOM = 1
|
||||
export const MAX_ZOOM = 3.5
|
||||
const BROWSER_FRAME_CACHE_LIMIT = 4
|
||||
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
|
||||
|
||||
export function buttonColor(enabled: boolean): string {
|
||||
return enabled ? colors.textSecondary : colors.textMuted
|
||||
}
|
||||
|
||||
export function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
|
||||
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
|
||||
}
|
||||
|
||||
export function makeBrowserFrameCacheKey(
|
||||
worktreeId: string,
|
||||
browserPageId: string | null,
|
||||
viewMode: MobileBrowserViewMode
|
||||
): string | null {
|
||||
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
|
||||
}
|
||||
|
||||
export function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
|
||||
const prefix = `${worktreeId}:`
|
||||
for (const key of browserFrameCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
browserFrameCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
if (!cacheKey) {
|
||||
return null
|
||||
}
|
||||
const cached = browserFrameCache.get(cacheKey)
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
export function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
|
||||
}
|
||||
|
||||
export function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
|
||||
if (!cacheKey) {
|
||||
return
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, entry)
|
||||
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
|
||||
const oldestKey = browserFrameCache.keys().next().value
|
||||
if (typeof oldestKey !== 'string') {
|
||||
break
|
||||
}
|
||||
browserFrameCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function updateBrowserLayerVisibility(
|
||||
layers: [View | null, View | null],
|
||||
visible: FrameLayer
|
||||
): void {
|
||||
for (const [index, layer] of layers.entries()) {
|
||||
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
|
||||
}
|
||||
}
|
||||
|
||||
export function updateBrowserImageSource(image: Image | null, uri: string): void {
|
||||
// Why: browser frames are large strings; mutating only the native Image
|
||||
// source avoids re-rendering the whole tab view for every streamed frame.
|
||||
const source = [{ uri }]
|
||||
image?.setNativeProps({ source, src: source })
|
||||
}
|
||||
|
||||
export function assertRpcOk(
|
||||
response: RpcSuccess | RpcFailure,
|
||||
fallbackMessage: string
|
||||
): asserts response is RpcSuccess {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message || fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
export function browserFrameMetadataEqual(
|
||||
a: BrowserScreencastFrameMetadata | null,
|
||||
b: BrowserScreencastFrameMetadata
|
||||
): boolean {
|
||||
return (
|
||||
a?.deviceWidth === b.deviceWidth &&
|
||||
a?.deviceHeight === b.deviceHeight &&
|
||||
a?.pageScaleFactor === b.pageScaleFactor
|
||||
)
|
||||
}
|
||||
|
||||
export function browserErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
export function shouldSurfaceBrowserError(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
// Why: selector_not_found can be emitted by in-flight page automation while
|
||||
// the browser is still usable; replacing the frame with it feels like a crash.
|
||||
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
|
||||
}
|
||||
|
||||
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
|
||||
const touches = event.nativeEvent.touches
|
||||
if (!touches || touches.length < 2) {
|
||||
return null
|
||||
}
|
||||
const a = readLocalTouchPoint(touches[0])
|
||||
const b = readLocalTouchPoint(touches[1])
|
||||
return a && b ? { a, b } : null
|
||||
}
|
||||
|
||||
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
export function createPinchGesture(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
zoom: BrowserZoomState
|
||||
): PinchGesture | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const distance = pointDistance(pair.a, pair.b)
|
||||
if (distance < 8) {
|
||||
return null
|
||||
}
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
|
||||
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
|
||||
return {
|
||||
distance,
|
||||
scale: zoom.scale,
|
||||
anchorX: (centerX - frameCenterX) / zoom.scale,
|
||||
anchorY: (centerY - frameCenterY) / zoom.scale
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePinchZoom(
|
||||
event: GestureResponderEvent,
|
||||
geometry: BrowserFrameGeometry | null,
|
||||
pinch: PinchGesture
|
||||
): BrowserZoomState | null {
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
const pair = touchPair(event)
|
||||
if (!pair) {
|
||||
return null
|
||||
}
|
||||
const nextScale = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
|
||||
)
|
||||
const centerX = (pair.a.x + pair.b.x) / 2
|
||||
const centerY = (pair.a.y + pair.b.y) / 2
|
||||
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
|
||||
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
|
||||
return clampBrowserZoomState(
|
||||
{
|
||||
scale: nextScale,
|
||||
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
|
||||
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
|
||||
},
|
||||
geometry,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const mobileBrowserPaneStyles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
toolbar: {
|
||||
minHeight: 32,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
viewport: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
browserImageHost: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImageFill: {
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
},
|
||||
browserImageLayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserImageLayerHidden: {
|
||||
opacity: 0
|
||||
},
|
||||
browserZoomOffset: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
browserFrameBox: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
browserImage: {
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.2)'
|
||||
},
|
||||
errorText: {
|
||||
color: colors.textPrimary,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
dialogOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
backgroundColor: 'rgba(13, 15, 24, 0.5)'
|
||||
},
|
||||
dialogCard: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel,
|
||||
padding: spacing.lg
|
||||
},
|
||||
dialogTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogMessage: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
dialogActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
dialogButton: {
|
||||
minHeight: 34,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
dialogButtonPrimary: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
dialogButtonPressed: {
|
||||
opacity: 0.75
|
||||
},
|
||||
dialogButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
dialogButtonPrimaryText: {
|
||||
color: colors.bgBase
|
||||
},
|
||||
keyboardDock: {
|
||||
zIndex: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.xs + 2
|
||||
},
|
||||
keyboardInput: {
|
||||
flex: 1,
|
||||
height: 34,
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
sendButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.35
|
||||
},
|
||||
disabledText: {
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionLogEntry } from '../transport/types'
|
||||
import { ConnectionLog } from './ConnectionLog'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
const entries: ConnectionLogEntry[] = [
|
||||
{ id: 'event-1', ts: 1, level: 'info', message: 'Opening WebSocket' }
|
||||
]
|
||||
|
||||
const duplicateEntries: ConnectionLogEntry[] = [
|
||||
{ id: 'relay-1', ts: 1, level: 'info', message: 'Relay recovery started' },
|
||||
{ id: 'relay-1', ts: 2, level: 'warn', message: 'Relay retry scheduled' }
|
||||
]
|
||||
|
||||
describe('ConnectionLog', () => {
|
||||
type RenderedNode = { props: { style?: unknown } }
|
||||
type Renderer = {
|
||||
root: {
|
||||
findAllByType: (type: unknown) => RenderedNode[]
|
||||
findByType: (type: unknown) => RenderedNode
|
||||
}
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
let renderer: Renderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function renderLog(fillAvailableHeight = false): Renderer {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(ConnectionLog, { entries, fillAvailableHeight })
|
||||
) as unknown as Renderer
|
||||
})
|
||||
return renderer as Renderer
|
||||
}
|
||||
|
||||
it('keeps the compact height in pairing flows', () => {
|
||||
const instance = renderLog()
|
||||
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
|
||||
|
||||
expect(containerStyles).toContainEqual({ maxHeight: 240 })
|
||||
})
|
||||
|
||||
it('fills the available diagnostics viewport', () => {
|
||||
const instance = renderLog(true)
|
||||
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
|
||||
const scroll = instance.root.findByType('ScrollView')
|
||||
|
||||
expect(containerStyles).toContainEqual({ flex: 1 })
|
||||
expect(scroll.props.style).toEqual({ flex: 1 })
|
||||
})
|
||||
|
||||
it('does not emit duplicate-key warnings for repeated persisted event IDs', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(ConnectionLog, { entries: duplicateEntries })
|
||||
) as unknown as Renderer
|
||||
})
|
||||
|
||||
expect(
|
||||
consoleError.mock.calls.some(([message]) =>
|
||||
String(message).includes('Encountered two children with the same key')
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
// Tag printed before the first entry so it's clear what's being logged
|
||||
// (e.g. 'Pairing' vs 'Reconnect').
|
||||
title?: string
|
||||
fillAvailableHeight?: boolean
|
||||
}
|
||||
|
||||
const LEVEL_COLOR: Record<ConnectionLogEntry['level'], string> = {
|
||||
@@ -37,42 +38,53 @@ function formatTime(ts: number, baseTs: number): string {
|
||||
return `+${Math.round(elapsed)}s`
|
||||
}
|
||||
|
||||
export function ConnectionLog({ entries, title }: Props) {
|
||||
export function ConnectionLog({ entries, title, fillAvailableHeight = false }: Props) {
|
||||
const scrollRef = useRef<ScrollView | null>(null)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
const baseTs = entries[0]!.ts
|
||||
const keyOccurrences = new Map<string, number>()
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
fillAvailableHeight ? styles.fillContainer : styles.boundedContainer
|
||||
]}
|
||||
>
|
||||
{title && <Text style={styles.title}>{title}</Text>}
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.scroll}
|
||||
style={fillAvailableHeight ? styles.fillScroll : styles.boundedScroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<View key={entry.id} style={styles.row}>
|
||||
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
|
||||
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{LEVEL_GLYPH[entry.level]}
|
||||
</Text>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{entry.message}
|
||||
{entries.map((entry) => {
|
||||
const occurrence = keyOccurrences.get(entry.id) ?? 0
|
||||
keyOccurrences.set(entry.id, occurrence + 1)
|
||||
const renderKey = occurrence === 0 ? entry.id : `${entry.id}:${occurrence}`
|
||||
return (
|
||||
<View key={renderKey} style={styles.row}>
|
||||
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
|
||||
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{LEVEL_GLYPH[entry.level]}
|
||||
</Text>
|
||||
{entry.detail && (
|
||||
<Text style={styles.detail} numberOfLines={2}>
|
||||
{entry.detail}
|
||||
<View style={styles.rowText}>
|
||||
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{entry.message}
|
||||
</Text>
|
||||
)}
|
||||
{entry.detail && (
|
||||
<Text style={styles.detail} numberOfLines={2}>
|
||||
{entry.detail}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
@@ -81,7 +93,6 @@ export function ConnectionLog({ entries, title }: Props) {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
maxHeight: 240,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
@@ -89,6 +100,12 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
boundedContainer: {
|
||||
maxHeight: 240
|
||||
},
|
||||
fillContainer: {
|
||||
flex: 1
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily,
|
||||
@@ -97,9 +114,12 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 1,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
scroll: {
|
||||
boundedScroll: {
|
||||
maxHeight: 200
|
||||
},
|
||||
fillScroll: {
|
||||
flex: 1
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 6
|
||||
},
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ChevronRight } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export function HostDiagnosticsLink({ onPress }: { onPress: () => void }): React.JSX.Element {
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.link}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="View network diagnostics"
|
||||
>
|
||||
<Text style={styles.text}>View network diagnostics</Text>
|
||||
<ChevronRight size={16} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
link: {
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
text: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
diagnoseConnection,
|
||||
getReportableConnectionIncidentId
|
||||
} from './connection-diagnostics-analysis'
|
||||
import type { ConnectionLogEntry } from '../transport/types'
|
||||
|
||||
function event(message: string, detail?: string): ConnectionLogEntry {
|
||||
return { id: message, ts: 1, level: 'error', message, detail }
|
||||
}
|
||||
|
||||
describe('diagnoseConnection', () => {
|
||||
it('reports the current healthy path instead of a historical failure', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'connected',
|
||||
activePath: 'relay',
|
||||
entries: [event('WebSocket connect timeout')]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'Connection is healthy via Relay.',
|
||||
nextStep: 'No action needed.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('distinguishes an invalid Relay credential from a transient outage', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [event('Relay: relay dial failed', 'relay director resolve failed (401)')]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'Relay rejected the saved resume credential.',
|
||||
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies the direct Tailscale timeout while Relay recovery is pending', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'tailscale',
|
||||
pendingPath: 'relay',
|
||||
entries: [event('WebSocket connect timeout')]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'The saved Tailscale endpoint did not answer before the connection timeout.',
|
||||
nextStep: 'Relay recovery is in progress; keep Orca open while it retries.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies an authenticated Relay liveness failure', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'Relay stopped answering authenticated health checks.',
|
||||
nextStep: 'Orca closed the stale session and started recovery.',
|
||||
reportability: 'orca-relay'
|
||||
})
|
||||
})
|
||||
|
||||
it('separates an active Relay close from a failed Relay dial', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay: active relay session failed', 'RelayOuterError: close code 4408'),
|
||||
code: 'relay-session-failed',
|
||||
path: 'relay'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'The active Relay session closed unexpectedly.',
|
||||
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
|
||||
reportability: 'orca-relay'
|
||||
})
|
||||
})
|
||||
|
||||
it('marks authenticated Relay liveness failures as safe to send', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay'
|
||||
}
|
||||
]
|
||||
}).reportability
|
||||
).toBe('orca-relay')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['direct timeout', 'connect-timeout', 'tailscale'],
|
||||
['handshake timeout', 'handshake-timeout', 'relay'],
|
||||
['invalid credential', 'relay director resolve failed (401)', 'relay'],
|
||||
['ambiguous recovery', 'retry scheduled', 'relay']
|
||||
] as const)('does not offer submission for %s', (_name, message, path) => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [{ ...event(message), path }]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('does not offer submission for a bounded Relay director outage', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [event('Relay: relay dial failed', 'relay director resolve failed (503)')]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('classifies the newest failure instead of an older persisted 401', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [
|
||||
event('Relay: relay dial failed', 'relay director resolve failed (401)'),
|
||||
{ ...event('WebSocket connect timeout'), id: 'newer', ts: 2, code: 'connect-timeout' }
|
||||
]
|
||||
}).likelyCause
|
||||
).toBe('The saved Tailscale endpoint did not answer before the connection timeout.')
|
||||
})
|
||||
|
||||
it('requires structured Relay evidence before offering submission', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [event('active relay session failed')]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('ignores failures from before the current app resume boundary', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'connecting',
|
||||
activePath: 'lan',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay'
|
||||
},
|
||||
{
|
||||
...event('App returned to foreground'),
|
||||
id: 'resume',
|
||||
ts: 2,
|
||||
code: 'app-resumed'
|
||||
},
|
||||
{ ...event('WebSocket closed'), id: 'closed', ts: 3, code: 'socket-closed' }
|
||||
]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('uses a structured direct liveness path ahead of the current active path', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Connection health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'lan'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'The connected host stopped answering authenticated health checks.',
|
||||
nextStep: 'Orca closed the stale session and started recovery.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('keys reportability to the current structured incident', () => {
|
||||
const args = {
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting' as const,
|
||||
activePath: 'relay' as const,
|
||||
entries: [
|
||||
{ ...event('Authenticated'), code: 'direct-connected' as const, path: 'lan' as const },
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
id: 'current-relay-failure',
|
||||
ts: 2,
|
||||
code: 'liveness-timeout' as const,
|
||||
path: 'relay' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expect(getReportableConnectionIncidentId(args)).toBe('current-relay-failure')
|
||||
expect(
|
||||
getReportableConnectionIncidentId({
|
||||
...args,
|
||||
entries: [...args.entries, { ...event('Network changed'), ts: 3, code: 'network-changed' }]
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
|
||||
import type {
|
||||
ConnectionLogEntry,
|
||||
ConnectionState,
|
||||
MobileConnectionDiagnosticPath
|
||||
} from '../transport/types'
|
||||
|
||||
export type ConnectionDiagnosis = {
|
||||
likelyCause: string
|
||||
nextStep: string
|
||||
reportability: 'none' | 'orca-relay'
|
||||
}
|
||||
|
||||
type DiagnoseConnectionArgs = {
|
||||
endpoint: string
|
||||
state: ConnectionState
|
||||
activePath?: MobileConnectionDiagnosticPath
|
||||
pendingPath?: MobileConnectionDiagnosticPath | null
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
}
|
||||
|
||||
export function diagnoseConnection(args: DiagnoseConnectionArgs): ConnectionDiagnosis {
|
||||
if (args.state === 'connected') {
|
||||
return {
|
||||
likelyCause: `Connection is healthy${args.activePath ? ` via ${formatPath(args.activePath)}` : ''}.`,
|
||||
nextStep: 'No action needed.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
const failure = findCurrentDiagnosticFailure(args.entries)
|
||||
const evidence = failure ? `${failure.code ?? ''} ${failure.message} ${failure.detail ?? ''}` : ''
|
||||
|
||||
if (/relay director resolve failed \(401\)/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'Relay rejected the saved resume credential.',
|
||||
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/relay director resolve failed \(503\)/i.test(evidence)) {
|
||||
const retryMs = parseRetryDelayMs(evidence)
|
||||
return {
|
||||
likelyCause: `Relay service was temporarily unavailable${retryMs == null ? '.' : ` and asked Orca to retry in ${formatDelay(retryMs)}.`}`,
|
||||
nextStep: 'Keep Orca open; recovery should retry automatically.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/liveness-timeout|liveness timeout|connection health check failed/i.test(evidence)) {
|
||||
const relayLiveness = failure?.code === 'liveness-timeout' && failure.path === 'relay'
|
||||
const structuredDirectLiveness =
|
||||
failure?.code === 'liveness-timeout' &&
|
||||
(failure.path === 'lan' || failure.path === 'tailscale')
|
||||
const path =
|
||||
relayLiveness || (!structuredDirectLiveness && args.activePath === 'relay')
|
||||
? 'Relay'
|
||||
: 'The connected host'
|
||||
return {
|
||||
likelyCause: `${path} stopped answering authenticated health checks.`,
|
||||
nextStep: 'Orca closed the stale session and started recovery.',
|
||||
reportability: relayLiveness ? 'orca-relay' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/relay-session-failed|active relay session failed/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'The active Relay session closed unexpectedly.',
|
||||
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
|
||||
reportability:
|
||||
failure?.code === 'relay-session-failed' && failure.path === 'relay' ? 'orca-relay' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/authentication-rejected|unauthorized|pairing may be revoked/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'The desktop rejected this device during authentication.',
|
||||
nextStep: 'Confirm the device is still paired; pair it again if the rejection repeats.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/connect-timeout|websocket connect timeout/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: isTailscaleEndpoint(args.endpoint)
|
||||
? 'The saved Tailscale endpoint did not answer before the connection timeout.'
|
||||
: 'The saved direct endpoint did not answer before the connection timeout.',
|
||||
nextStep:
|
||||
args.pendingPath === 'relay'
|
||||
? 'Relay recovery is in progress; keep Orca open while it retries.'
|
||||
: 'Check the local/VPN network and confirm the desktop is awake.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/handshake-timeout|handshake timeout/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'The endpoint opened, but the encrypted Orca handshake did not finish.',
|
||||
nextStep: 'Confirm the desktop is running a compatible Orca version and retry.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (args.pendingPath === 'relay') {
|
||||
return {
|
||||
likelyCause: 'Relay recovery is selected, but no more specific failure is recorded yet.',
|
||||
nextStep: 'Keep this page open while the next recovery event is recorded.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
likelyCause: 'No single failure cause can be determined from the recorded events.',
|
||||
nextStep: 'Run diagnostics and copy the report again after the next connection attempt.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
export function getReportableConnectionIncidentId(args: DiagnoseConnectionArgs): string | null {
|
||||
if (diagnoseConnection(args).reportability !== 'orca-relay') {
|
||||
return null
|
||||
}
|
||||
return findCurrentDiagnosticFailure(args.entries)?.id ?? null
|
||||
}
|
||||
|
||||
function findCurrentDiagnosticFailure(
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
): ConnectionLogEntry | undefined {
|
||||
const boundaryIndex = entries.findLastIndex(isDiagnosticBoundary)
|
||||
return entries
|
||||
.slice(boundaryIndex + 1)
|
||||
.toReversed()
|
||||
.find(isDiagnosticFailure)
|
||||
}
|
||||
|
||||
function isDiagnosticBoundary(entry: ConnectionLogEntry): boolean {
|
||||
return (
|
||||
entry.code === 'client-session-started' ||
|
||||
entry.code === 'app-resumed' ||
|
||||
entry.code === 'network-changed' ||
|
||||
entry.code === 'relay-connected' ||
|
||||
entry.code === 'direct-connected' ||
|
||||
entry.message === 'Authenticated'
|
||||
)
|
||||
}
|
||||
|
||||
function isDiagnosticFailure(entry: ConnectionLogEntry): boolean {
|
||||
const evidence = `${entry.code ?? ''} ${entry.message} ${entry.detail ?? ''}`
|
||||
return /relay director resolve failed \((?:401|503)\)|liveness-timeout|liveness timeout|connection health check failed|relay-session-failed|active relay session failed|authentication-rejected|unauthorized|pairing may be revoked|connect-timeout|websocket connect timeout|handshake-timeout|handshake timeout/i.test(
|
||||
evidence
|
||||
)
|
||||
}
|
||||
|
||||
function parseRetryDelayMs(evidence: string): number | null {
|
||||
const match = /retry(?:-|\s)?after(?:=|\s)(\d+)ms/i.exec(evidence)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function formatDelay(ms: number): string {
|
||||
return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`
|
||||
}
|
||||
|
||||
function formatPath(path: MobileConnectionDiagnosticPath): string {
|
||||
if (path === 'relay') {
|
||||
return 'Relay'
|
||||
}
|
||||
return path === 'tailscale' ? 'Tailscale/direct' : 'LAN/direct'
|
||||
}
|
||||
@@ -13,6 +13,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
lastConnectedAt: NOW - 5 * 60_000,
|
||||
platform: 'ios 26.5.1',
|
||||
appVersion: '0.0.29',
|
||||
desktopAppVersion: '1.4.191',
|
||||
entries: [
|
||||
{
|
||||
id: 'log-1',
|
||||
@@ -26,6 +27,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
})
|
||||
|
||||
expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1')
|
||||
expect(report).toContain('Host Orca version: 1.4.191')
|
||||
expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)')
|
||||
expect(report).toContain('State: reconnecting (reconnect attempts: 12)')
|
||||
expect(report).toContain('(5m 0s ago)')
|
||||
@@ -48,8 +50,96 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
})
|
||||
|
||||
expect(report).toContain('Endpoint: 192.168.1.50:6768')
|
||||
expect(report).toContain('Host Orca version: unknown')
|
||||
expect(report).not.toContain('(Tailscale)')
|
||||
expect(report).toContain('Last connected: never this session')
|
||||
expect(report).toContain('No connection events recorded this session.')
|
||||
expect(report).toContain('No connection events recorded.')
|
||||
})
|
||||
|
||||
it('explains the most likely cause and redacts credentials before copying', () => {
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 3',
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 4,
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.46',
|
||||
activePath: 'tailscale',
|
||||
pendingPath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
id: 'relay-failure',
|
||||
ts: NOW - 5_000,
|
||||
level: 'error',
|
||||
message: 'Relay: relay dial failed',
|
||||
detail:
|
||||
'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token'
|
||||
}
|
||||
],
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
expect(report).toContain(
|
||||
'Likely cause: Relay service was temporarily unavailable and asked Orca to retry in 30s.'
|
||||
)
|
||||
expect(report).toContain('Path: active=tailscale; recovery=relay')
|
||||
expect(report).toContain('Next step: Keep Orca open; recovery should retry automatically.')
|
||||
expect(report).toContain('resumeToken=[redacted]')
|
||||
expect(report).not.toContain('secret-resume-token')
|
||||
})
|
||||
|
||||
it('redacts quoted JSON credentials and never echoes an invalid endpoint', () => {
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 4',
|
||||
endpoint: 'not-a-url?token=endpoint-secret',
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 1,
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.47',
|
||||
entries: [
|
||||
{
|
||||
id: 'json-secret',
|
||||
ts: NOW,
|
||||
level: 'error',
|
||||
message: 'Relay failed',
|
||||
detail: '{"resumeToken":"json-secret","authorization":"Bearer bearer-secret"}'
|
||||
}
|
||||
],
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
expect(report).toContain('Endpoint: invalid endpoint')
|
||||
expect(report).not.toContain('endpoint-secret')
|
||||
expect(report).not.toContain('json-secret')
|
||||
expect(report).not.toContain('bearer-secret')
|
||||
})
|
||||
|
||||
it('bounds a single event line before submission while preserving its identity', () => {
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 5',
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 1,
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.47',
|
||||
entries: [
|
||||
{
|
||||
id: 'oversized',
|
||||
ts: NOW,
|
||||
level: 'error',
|
||||
message: `newest oversized ${'😀'.repeat(2_000)}`
|
||||
}
|
||||
],
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
expect(report).toContain('newest oversized')
|
||||
expect(report).toContain('[truncated]')
|
||||
expect(new TextEncoder().encode(report.split('\n').at(-1)!).byteLength).toBeLessThanOrEqual(
|
||||
2048
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
|
||||
import type { ConnectionLogEntry, ConnectionState } from '../transport/types'
|
||||
import type {
|
||||
ConnectionLogEntry,
|
||||
ConnectionState,
|
||||
MobileConnectionDiagnosticPath
|
||||
} from '../transport/types'
|
||||
import { normalizeHostAppVersion } from '../transport/host-app-version-store'
|
||||
import { formatEndpoint } from './host-reachability'
|
||||
import { diagnoseConnection } from './connection-diagnostics-analysis'
|
||||
import { redactConnectionLogEntry, redactConnectionLogText } from './connection-log-redaction'
|
||||
|
||||
const MAX_EVENT_LINE_BYTES = 2 * 1024
|
||||
const EVENT_TRUNCATION_MARKER = ' … [truncated]'
|
||||
|
||||
// Why: one shareable text blob answering everything we historically had to
|
||||
// ask reporters one message at a time (endpoint type, state, attempt count,
|
||||
@@ -13,37 +23,83 @@ export function buildConnectionDiagnosticsReport(args: {
|
||||
lastConnectedAt: number | null
|
||||
platform: string
|
||||
appVersion: string
|
||||
desktopAppVersion?: string | null
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
activePath?: MobileConnectionDiagnosticPath
|
||||
pendingPath?: MobileConnectionDiagnosticPath | null
|
||||
nowMs?: number
|
||||
}): string {
|
||||
const now = args.nowMs ?? Date.now()
|
||||
const entries = args.entries.map(redactConnectionLogEntry)
|
||||
const diagnosis = diagnoseConnection({
|
||||
endpoint: args.endpoint,
|
||||
state: args.state,
|
||||
activePath: args.activePath,
|
||||
pendingPath: args.pendingPath,
|
||||
entries
|
||||
})
|
||||
const lines: string[] = []
|
||||
lines.push('Orca Mobile connection diagnostics')
|
||||
lines.push(`Generated: ${new Date(now).toISOString()}`)
|
||||
lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`)
|
||||
lines.push(`Host: ${args.hostName}`)
|
||||
const desktopAppVersion = normalizeHostAppVersion(args.desktopAppVersion)
|
||||
lines.push(`Host Orca version: ${desktopAppVersion ?? 'unknown'}`)
|
||||
lines.push(`Host: ${redactConnectionLogText(args.hostName)}`)
|
||||
lines.push(
|
||||
`Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}`
|
||||
)
|
||||
lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`)
|
||||
if (args.activePath) {
|
||||
lines.push(
|
||||
`Path: active=${args.activePath}${args.pendingPath ? `; recovery=${args.pendingPath}` : ''}`
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
args.lastConnectedAt == null
|
||||
? 'Last connected: never this session'
|
||||
: `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
|
||||
)
|
||||
lines.push('')
|
||||
if (args.entries.length === 0) {
|
||||
lines.push('No connection events recorded this session.')
|
||||
lines.push(`Likely cause: ${diagnosis.likelyCause}`)
|
||||
lines.push(`Next step: ${diagnosis.nextStep}`)
|
||||
lines.push('')
|
||||
if (entries.length === 0) {
|
||||
lines.push('No connection events recorded.')
|
||||
} else {
|
||||
lines.push(`Connection log (${args.entries.length} events, oldest first):`)
|
||||
for (const entry of args.entries) {
|
||||
lines.push(`Recent connection history (${entries.length} events, oldest first):`)
|
||||
for (const entry of entries) {
|
||||
const detail = entry.detail ? ` — ${entry.detail}` : ''
|
||||
lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`)
|
||||
const evidence = [entry.code, entry.path].filter(Boolean).join(' · ')
|
||||
lines.push(
|
||||
truncateUtf8WithMarker(
|
||||
`${new Date(entry.ts).toISOString()} [${entry.level}]${evidence ? ` [${evidence}]` : ''} ${entry.message}${detail}`,
|
||||
MAX_EVENT_LINE_BYTES,
|
||||
EVENT_TRUNCATION_MARKER
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function truncateUtf8WithMarker(value: string, maxBytes: number, marker: string): string {
|
||||
if (new TextEncoder().encode(value).byteLength <= maxBytes) {
|
||||
return value
|
||||
}
|
||||
const markerBytes = new TextEncoder().encode(marker).byteLength
|
||||
const characters: string[] = []
|
||||
let bytes = 0
|
||||
for (const character of value) {
|
||||
const characterBytes = new TextEncoder().encode(character).byteLength
|
||||
if (bytes + characterBytes + markerBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
characters.push(character)
|
||||
bytes += characterBytes
|
||||
}
|
||||
return `${characters.join('')}${marker}`
|
||||
}
|
||||
|
||||
function formatAgo(ms: number): string {
|
||||
const seconds = Math.max(0, Math.round(ms / 1000))
|
||||
if (seconds < 60) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getDiagnosticsSubmissionState,
|
||||
readConnectionDiagnosticsSnapshot,
|
||||
readHydratedConnectionLog,
|
||||
resolveDiagnosticsHostId,
|
||||
selectDiagnosticsHostId,
|
||||
updateDiagnosticsSubmissionState
|
||||
} from './connection-diagnostics-screen-data'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
|
||||
|
||||
const host = (id: string): HostProfile => ({
|
||||
id,
|
||||
name: id,
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key',
|
||||
lastConnected: 0
|
||||
})
|
||||
|
||||
describe('connection diagnostics screen data', () => {
|
||||
it('prefers a valid route host when navigation changes', () => {
|
||||
expect(selectDiagnosticsHostId([host('a'), host('b')], 'b', 'a')).toBe('b')
|
||||
expect(selectDiagnosticsHostId([host('a')], 'missing', 'missing')).toBe('a')
|
||||
})
|
||||
|
||||
it('applies route changes synchronously without discarding a chip choice for the same route', () => {
|
||||
const hosts = [host('a'), host('b')]
|
||||
expect(resolveDiagnosticsHostId(hosts, undefined, null)).toBe('a')
|
||||
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'a' })).toBe('b')
|
||||
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'b' })).toBe('a')
|
||||
})
|
||||
|
||||
it('does not revive a stale manual choice when routing away and back', () => {
|
||||
const hosts = [host('a'), host('b'), host('c')]
|
||||
const firstRouteA = {}
|
||||
const routeC = {}
|
||||
const secondRouteA = {}
|
||||
const manualSelection = { hostId: 'b', requestedHostId: 'a', routeKey: firstRouteA }
|
||||
|
||||
expect(resolveDiagnosticsHostId(hosts, 'c', manualSelection, routeC)).toBe('c')
|
||||
expect(resolveDiagnosticsHostId(hosts, 'a', manualSelection, secondRouteA)).toBe('a')
|
||||
})
|
||||
|
||||
it('keeps async submission completion scoped to its host incident', () => {
|
||||
let states = updateDiagnosticsSubmissionState({}, 'host-a:incident-1', 'sending')
|
||||
states = updateDiagnosticsSubmissionState(states, 'host-b:incident-2', 'sending')
|
||||
states = updateDiagnosticsSubmissionState(states, 'host-a:incident-1', 'sent')
|
||||
|
||||
expect(getDiagnosticsSubmissionState(states, 'host-a:incident-1')).toBe('sent')
|
||||
expect(getDiagnosticsSubmissionState(states, 'host-b:incident-2')).toBe('sending')
|
||||
})
|
||||
|
||||
it('waits for hydration and then reads the refreshed log', async () => {
|
||||
const entries: ConnectionLogEntry[] = []
|
||||
const store = {
|
||||
hydrate: vi.fn(async () => {
|
||||
entries.push({ id: 'stored', ts: 1, level: 'info', message: 'stored event' })
|
||||
}),
|
||||
get: vi.fn(() => entries)
|
||||
}
|
||||
|
||||
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
|
||||
expect(store.get).toHaveBeenCalledAfter(store.hydrate)
|
||||
})
|
||||
|
||||
it('retries one transient hydration failure before reading', async () => {
|
||||
const entries = [{ id: 'stored', ts: 1, level: 'info' as const, message: 'stored event' }]
|
||||
const store = {
|
||||
hydrate: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
.mockResolvedValueOnce(undefined),
|
||||
get: vi.fn(() => entries)
|
||||
}
|
||||
|
||||
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
|
||||
expect(store.hydrate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reads connection metadata after hydration completes', async () => {
|
||||
let state: 'connecting' | 'reconnecting' = 'connecting'
|
||||
const store = {
|
||||
hydrate: vi.fn(async () => {
|
||||
state = 'reconnecting'
|
||||
}),
|
||||
get: vi.fn(() => [{ id: 'new', ts: 2, level: 'warn' as const, message: 'new event' }])
|
||||
}
|
||||
const context = {
|
||||
getState: vi.fn(() => state),
|
||||
getReconnectAttempt: vi.fn(() => 2),
|
||||
getLastConnectedAt: vi.fn(() => 1),
|
||||
getActivePath: vi.fn(() => 'relay' as const),
|
||||
getPendingPath: vi.fn(() => null)
|
||||
}
|
||||
|
||||
await expect(readConnectionDiagnosticsSnapshot(context, store, 'host-a')).resolves.toEqual({
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 2,
|
||||
lastConnectedAt: 1,
|
||||
activePath: 'relay',
|
||||
pendingPath: null,
|
||||
entries: [{ id: 'new', ts: 2, level: 'warn', message: 'new event' }]
|
||||
})
|
||||
expect(context.getState).toHaveBeenCalledAfter(store.hydrate)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ConnectionLogStore } from '../transport/connection-log-buffer'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
|
||||
import type { RpcClientContextValue } from '../transport/rpc-client-context-contract'
|
||||
|
||||
export type DiagnosticsHostSelection = {
|
||||
hostId: string
|
||||
requestedHostId: string | undefined
|
||||
routeKey?: object
|
||||
}
|
||||
|
||||
export type DiagnosticsSubmissionState = 'sending' | 'sent' | 'failed'
|
||||
export type DiagnosticsSubmissionStates = Readonly<Record<string, DiagnosticsSubmissionState>>
|
||||
|
||||
export function selectDiagnosticsHostId(
|
||||
hosts: readonly HostProfile[],
|
||||
requestedHostId: string | undefined,
|
||||
previousHostId: string | null
|
||||
): string | null {
|
||||
if (requestedHostId && hosts.some((host) => host.id === requestedHostId)) {
|
||||
return requestedHostId
|
||||
}
|
||||
if (previousHostId && hosts.some((host) => host.id === previousHostId)) {
|
||||
return previousHostId
|
||||
}
|
||||
return hosts[0]?.id ?? null
|
||||
}
|
||||
|
||||
export function resolveDiagnosticsHostId(
|
||||
hosts: readonly HostProfile[],
|
||||
requestedHostId: string | undefined,
|
||||
manualSelection: DiagnosticsHostSelection | null,
|
||||
routeKey?: object
|
||||
): string | null {
|
||||
const selected = manualSelection
|
||||
if (selected && selected.requestedHostId === requestedHostId && selected.routeKey === routeKey) {
|
||||
const manualHostId = selected.hostId
|
||||
if (hosts.some((host) => host.id === manualHostId)) {
|
||||
return manualHostId
|
||||
}
|
||||
}
|
||||
return selectDiagnosticsHostId(hosts, requestedHostId, null)
|
||||
}
|
||||
|
||||
export function getDiagnosticsSubmissionState(
|
||||
states: DiagnosticsSubmissionStates,
|
||||
key: string | null
|
||||
): DiagnosticsSubmissionState | 'idle' {
|
||||
return key ? (states[key] ?? 'idle') : 'idle'
|
||||
}
|
||||
|
||||
export function updateDiagnosticsSubmissionState(
|
||||
states: DiagnosticsSubmissionStates,
|
||||
key: string,
|
||||
state: DiagnosticsSubmissionState | null
|
||||
): DiagnosticsSubmissionStates {
|
||||
const next = { ...states }
|
||||
if (state) {
|
||||
next[key] = state
|
||||
} else {
|
||||
delete next[key]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export async function readHydratedConnectionLog(
|
||||
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
|
||||
hostId: string
|
||||
): Promise<readonly ConnectionLogEntry[]> {
|
||||
try {
|
||||
await store.hydrate(hostId)
|
||||
} catch {
|
||||
await store.hydrate(hostId).catch(() => {})
|
||||
}
|
||||
return store.get(hostId)
|
||||
}
|
||||
|
||||
export async function readConnectionDiagnosticsSnapshot(
|
||||
context: Pick<
|
||||
RpcClientContextValue,
|
||||
'getState' | 'getReconnectAttempt' | 'getLastConnectedAt' | 'getActivePath' | 'getPendingPath'
|
||||
>,
|
||||
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
|
||||
hostId: string
|
||||
) {
|
||||
const entries = await readHydratedConnectionLog(store, hostId)
|
||||
return {
|
||||
state: context.getState(hostId),
|
||||
reconnectAttempts: context.getReconnectAttempt(hostId),
|
||||
lastConnectedAt: context.getLastConnectedAt(hostId),
|
||||
activePath: context.getActivePath(hostId),
|
||||
pendingPath: context.getPendingPath(hostId),
|
||||
entries
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
boundConnectionDiagnosticsReport,
|
||||
submitConnectionDiagnostics
|
||||
} from './connection-diagnostics-submission'
|
||||
|
||||
describe('submitConnectionDiagnostics', () => {
|
||||
it('sends a bounded report through the diagnostics lane', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
const result = await submitConnectionDiagnostics(
|
||||
{ report: 'x'.repeat(100_000), appVersion: '0.0.47', platform: 'android 36' },
|
||||
fetchImpl
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://www.onorca.dev/v1/feedback',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
const request = fetchImpl.mock.calls[0]?.[1]
|
||||
const body = JSON.parse(String(request?.body)) as { feedback: string; submissionType: string }
|
||||
expect(body.submissionType).toBe('connection_diagnostics')
|
||||
expect(new TextEncoder().encode(body.feedback).byteLength).toBeLessThanOrEqual(64 * 1024)
|
||||
})
|
||||
|
||||
it('returns a safe failure for a rejected response', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 }))
|
||||
await expect(
|
||||
submitConnectionDiagnostics(
|
||||
{ report: 'report', appVersion: '0.0.47', platform: 'android' },
|
||||
fetchImpl
|
||||
)
|
||||
).resolves.toEqual({ ok: false, error: 'status 503' })
|
||||
})
|
||||
|
||||
it('preserves the newest complete events when bounding a UTF-8 report', () => {
|
||||
const report = [
|
||||
'Orca Mobile connection diagnostics',
|
||||
'State: reconnecting',
|
||||
'',
|
||||
'Recent connection history (3 events, oldest first):',
|
||||
`oldest ${'😀'.repeat(20)}`,
|
||||
`middle ${'😀'.repeat(20)}`,
|
||||
`newest ${'😀'.repeat(20)}`
|
||||
].join('\n')
|
||||
|
||||
const bounded = boundConnectionDiagnosticsReport(report, 180)
|
||||
|
||||
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(180)
|
||||
expect(bounded).toContain('newest')
|
||||
expect(bounded).not.toContain('oldest')
|
||||
expect(bounded).toMatch(/older omitted/)
|
||||
})
|
||||
|
||||
it('retains a bounded form of the newest event when that event exceeds the budget', () => {
|
||||
const report = [
|
||||
'Orca Mobile connection diagnostics',
|
||||
'State: reconnecting',
|
||||
'',
|
||||
'Recent connection history (2 events, oldest first):',
|
||||
'old event',
|
||||
`newest oversized ${'😀'.repeat(1_000)}`
|
||||
].join('\n')
|
||||
|
||||
const bounded = boundConnectionDiagnosticsReport(report, 220)
|
||||
|
||||
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(220)
|
||||
expect(bounded).toContain('newest oversized')
|
||||
expect(bounded).toContain('[truncated]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
const CONNECTION_DIAGNOSTICS_ENDPOINT = 'https://www.onorca.dev/v1/feedback'
|
||||
const SUBMISSION_TIMEOUT_MS = 10_000
|
||||
const MAX_SUBMISSION_BYTES = 64 * 1024
|
||||
|
||||
export type ConnectionDiagnosticsSubmission = {
|
||||
report: string
|
||||
appVersion: string
|
||||
platform: string
|
||||
}
|
||||
|
||||
export type ConnectionDiagnosticsSubmissionResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
/** Sends only the already-redacted, bounded report after an explicit user tap. */
|
||||
export async function submitConnectionDiagnostics(
|
||||
submission: ConnectionDiagnosticsSubmission,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ConnectionDiagnosticsSubmissionResult> {
|
||||
const report = boundConnectionDiagnosticsReport(submission.report)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), SUBMISSION_TIMEOUT_MS)
|
||||
try {
|
||||
const response = await fetchImpl(CONNECTION_DIAGNOSTICS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
feedback: report,
|
||||
submissionType: 'connection_diagnostics',
|
||||
githubLogin: null,
|
||||
githubEmail: null,
|
||||
appVersion: submission.appVersion,
|
||||
platform: `mobile-${submission.platform}`,
|
||||
osRelease: 'unknown',
|
||||
arch: 'unknown'
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
return response.ok ? { ok: true } : { ok: false, error: `status ${response.status}` }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: controller.signal.aborted
|
||||
? 'request timed out'
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'request failed'
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export function boundConnectionDiagnosticsReport(
|
||||
report: string,
|
||||
maxBytes: number = MAX_SUBMISSION_BYTES
|
||||
): string {
|
||||
if (utf8Bytes(report) <= maxBytes) {
|
||||
return report
|
||||
}
|
||||
const lines = report.split('\n')
|
||||
const historyIndex = lines.findIndex((line) => line.startsWith('Recent connection history ('))
|
||||
if (historyIndex === -1) {
|
||||
return truncateUtf8(report, maxBytes)
|
||||
}
|
||||
const header = lines.slice(0, historyIndex)
|
||||
const events = lines.slice(historyIndex + 1)
|
||||
let kept: string[] = []
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const candidateEvents = [events[index]!, ...kept]
|
||||
const candidate = formatBoundedReport(header, candidateEvents, events.length)
|
||||
if (utf8Bytes(candidate) > maxBytes) {
|
||||
break
|
||||
}
|
||||
kept = candidateEvents
|
||||
}
|
||||
if (kept.length === 0 && events.length > 0) {
|
||||
return formatReportWithTruncatedNewestEvent(header, events, maxBytes)
|
||||
}
|
||||
return truncateUtf8(formatBoundedReport(header, kept, events.length), maxBytes)
|
||||
}
|
||||
|
||||
function formatReportWithTruncatedNewestEvent(
|
||||
header: string[],
|
||||
events: string[],
|
||||
maxBytes: number
|
||||
): string {
|
||||
const history = `Recent connection history (1 newest event; ${events.length - 1} older omitted):`
|
||||
const prefix = [...header, history].join('\n') + '\n'
|
||||
const availableBytes = maxBytes - utf8Bytes(prefix)
|
||||
if (availableBytes <= 0) {
|
||||
return truncateUtf8(prefix, maxBytes)
|
||||
}
|
||||
const marker = ' … [truncated]'
|
||||
const markerBytes = utf8Bytes(marker)
|
||||
if (availableBytes <= markerBytes) {
|
||||
return truncateUtf8(prefix, maxBytes)
|
||||
}
|
||||
return `${prefix}${truncateUtf8(events.at(-1)!, availableBytes - markerBytes)}${marker}`
|
||||
}
|
||||
|
||||
function formatBoundedReport(header: string[], events: string[], totalEvents: number): string {
|
||||
const omitted = totalEvents - events.length
|
||||
return [
|
||||
...header,
|
||||
`Recent connection history (${events.length} newest events; ${omitted} older omitted):`,
|
||||
...events
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
const characters: string[] = []
|
||||
let bytes = 0
|
||||
for (const character of value) {
|
||||
const characterBytes = utf8Bytes(character)
|
||||
if (bytes + characterBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
characters.push(character)
|
||||
bytes += characterBytes
|
||||
}
|
||||
return characters.join('')
|
||||
}
|
||||
|
||||
function utf8Bytes(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ConnectionLogEntry } from '../transport/types'
|
||||
|
||||
const SECRET_JSON_ASSIGNMENT =
|
||||
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*\4/gi
|
||||
const SECRET_QUOTED_VALUE_ASSIGNMENT =
|
||||
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*\3/gi
|
||||
const SECRET_UNTERMINATED_JSON_ASSIGNMENT =
|
||||
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*(?=\r?\n|$)/gi
|
||||
const SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT =
|
||||
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*(?=\r?\n|$)/gi
|
||||
const SECRET_ASSIGNMENT =
|
||||
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(?:Bearer\s+)?([^\s;,}"']+)/gi
|
||||
const SECRET_QUERY = /([?&](?:token|credential|resumeToken|deviceToken)=)[^&#\s]+/gi
|
||||
const URL_USERINFO = /((?:wss?|https?):\/\/)[^\s/?#]*@/gi
|
||||
|
||||
export function redactConnectionLogText(value: string): string {
|
||||
return value
|
||||
.replace(
|
||||
SECRET_JSON_ASSIGNMENT,
|
||||
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
|
||||
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]${valueQuote}`
|
||||
)
|
||||
.replace(
|
||||
SECRET_QUOTED_VALUE_ASSIGNMENT,
|
||||
(_match, label: string, separator: string, valueQuote: string) =>
|
||||
`${label}${separator}${valueQuote}[redacted]${valueQuote}`
|
||||
)
|
||||
.replace(
|
||||
SECRET_UNTERMINATED_JSON_ASSIGNMENT,
|
||||
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
|
||||
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]`
|
||||
)
|
||||
.replace(
|
||||
SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT,
|
||||
(_match, label: string, separator: string, valueQuote: string) =>
|
||||
`${label}${separator}${valueQuote}[redacted]`
|
||||
)
|
||||
.replace(SECRET_ASSIGNMENT, (_match, label: string, separator: string) => {
|
||||
return `${label}${separator}[redacted]`
|
||||
})
|
||||
.replace(SECRET_QUERY, '$1[redacted]')
|
||||
.replace(URL_USERINFO, '$1[redacted]@')
|
||||
}
|
||||
|
||||
export function redactConnectionLogEntry(entry: ConnectionLogEntry): ConnectionLogEntry {
|
||||
return {
|
||||
...entry,
|
||||
message: redactConnectionLogText(entry.message),
|
||||
...(entry.detail ? { detail: redactConnectionLogText(entry.detail) } : {})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { testHostReachability, unreachableHostDetail } from './host-reachability'
|
||||
import { formatEndpoint, testHostReachability, unreachableHostDetail } from './host-reachability'
|
||||
|
||||
describe('unreachableHostDetail', () => {
|
||||
it('points at Tailscale for tailnet CGNAT endpoints', () => {
|
||||
@@ -21,6 +21,10 @@ describe('unreachableHostDetail', () => {
|
||||
it('does not treat non-CGNAT 100.x addresses as Tailscale', () => {
|
||||
expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768')
|
||||
})
|
||||
|
||||
it('does not echo malformed endpoints that may contain credentials', () => {
|
||||
expect(formatEndpoint('not-a-url?token=secret')).toBe('invalid endpoint')
|
||||
})
|
||||
})
|
||||
|
||||
describe('testHostReachability', () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ export function formatEndpoint(endpoint: string): string {
|
||||
const url = new URL(endpoint)
|
||||
return url.host
|
||||
} catch {
|
||||
return endpoint
|
||||
return 'invalid endpoint'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ export function MobileHomeScreen() {
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onReconnect: (hostId) => void forceReconnectHost(hostId),
|
||||
onDisconnect: disconnectHostClient,
|
||||
onDiagnostics: (hostId) =>
|
||||
data.router.push({ pathname: '/connection-log', params: { hostId } }),
|
||||
onEdit: openMobileHostEdit,
|
||||
onRemove: (host) => setConfirmRemove(host)
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getHostListActionSheetActions } from './host-list-action-sheet-actions'
|
||||
import type { ConnectionState, HostProfile } from './transport/types'
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Activity: vi.fn(),
|
||||
Edit3: vi.fn(),
|
||||
PowerOff: vi.fn(),
|
||||
RefreshCw: vi.fn()
|
||||
@@ -22,6 +23,7 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
|
||||
onDismiss: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
onDisconnect: vi.fn(),
|
||||
onDiagnostics: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
}
|
||||
@@ -35,14 +37,17 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
|
||||
}
|
||||
|
||||
describe('getHostListActionSheetActions', () => {
|
||||
// Why: both open a second drawer. Presenting one while this sheet's native
|
||||
// Why: these navigate or open a second drawer. Presenting while this sheet's native
|
||||
// Modal is still up freezes the whole screen on iOS — issue #8791.
|
||||
it.each(['Edit host', 'Remove'])('defers %s until the action sheet has closed', (label) => {
|
||||
const { actions } = build()
|
||||
expect(actions.find((action) => action.label === label)).toMatchObject({
|
||||
closeBeforePress: true
|
||||
})
|
||||
})
|
||||
it.each(['Network diagnostics', 'Edit host', 'Remove'])(
|
||||
'defers %s until the action sheet has closed',
|
||||
(label) => {
|
||||
const { actions } = build()
|
||||
expect(actions.find((action) => action.label === label)).toMatchObject({
|
||||
closeBeforePress: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves the in-place actions undeferred so they fire on tap', () => {
|
||||
const { actions, spies } = build()
|
||||
@@ -65,15 +70,23 @@ describe('getHostListActionSheetActions', () => {
|
||||
expect(spies.onEdit).toHaveBeenCalledWith(HOST.id)
|
||||
})
|
||||
|
||||
it('routes Network diagnostics with the selected host', () => {
|
||||
const { actions, spies } = build()
|
||||
actions.find((action) => action.label === 'Network diagnostics')?.onPress()
|
||||
expect(spies.onDiagnostics).toHaveBeenCalledWith(HOST.id)
|
||||
})
|
||||
|
||||
it('offers Disconnect only while the socket is live', () => {
|
||||
expect(build({ state: 'reconnecting' }).actions.map((action) => action.label)).toEqual([
|
||||
'Reconnect',
|
||||
'Disconnect',
|
||||
'Network diagnostics',
|
||||
'Edit host',
|
||||
'Remove'
|
||||
])
|
||||
expect(build({ state: 'disconnected' }).actions.map((action) => action.label)).toEqual([
|
||||
'Connect',
|
||||
'Network diagnostics',
|
||||
'Edit host',
|
||||
'Remove'
|
||||
])
|
||||
@@ -93,6 +106,7 @@ describe('getHostListActionSheetActions', () => {
|
||||
onDismiss: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
onDisconnect: vi.fn(),
|
||||
onDiagnostics: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
|
||||
import { Activity, Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
|
||||
import type { ActionSheetAction } from './components/ActionSheetModal'
|
||||
import type { ConnectionState, HostProfile } from './transport/types'
|
||||
|
||||
/** Builds the home-screen host long-press menu. Edit and Remove open a second
|
||||
* drawer, so both must defer until this sheet's native Modal has unmounted —
|
||||
/** Builds the home-screen host long-press menu. Navigation and second drawers
|
||||
* defer until this sheet's native Modal has unmounted —
|
||||
* presenting into a live one freezes the whole screen on iOS (issue #8791). */
|
||||
export function getHostListActionSheetActions(args: {
|
||||
host: HostProfile | null
|
||||
@@ -13,6 +13,7 @@ export function getHostListActionSheetActions(args: {
|
||||
onDismiss: () => void
|
||||
onReconnect: (hostId: string) => void
|
||||
onDisconnect: (hostId: string) => void
|
||||
onDiagnostics: (hostId: string) => void
|
||||
onEdit: (hostId: string) => void
|
||||
onRemove: (host: HostProfile) => void
|
||||
}): ActionSheetAction[] {
|
||||
@@ -47,6 +48,14 @@ export function getHostListActionSheetActions(args: {
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Network diagnostics',
|
||||
icon: Activity,
|
||||
closeBeforePress: true,
|
||||
onPress: () => {
|
||||
args.onDiagnostics(host.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Edit host',
|
||||
icon: Edit3,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const hostScreenPrimaryStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
topChrome: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
statusBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 34,
|
||||
paddingTop: spacing.xs,
|
||||
paddingHorizontal: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sidebarCollapseButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
hostIdentity: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
marginRight: spacing.md
|
||||
},
|
||||
hostNameText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
reconnectButton: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
reconnectButtonText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbar: {
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
embeddedToolbarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
embeddedFilterChip: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
embeddedModeButton: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 0
|
||||
},
|
||||
filterChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
filterChipActive: {
|
||||
borderColor: colors.textSecondary,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
filterChipText: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterChipTextActive: {
|
||||
color: colors.textPrimary
|
||||
},
|
||||
modeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
sortLabel: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
toolbarSpacer: {
|
||||
flex: 1
|
||||
},
|
||||
floatingWorkspaceHeaderButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
embeddedToolbarIconButton: {
|
||||
flex: 1,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
toolbarIconDisabled: {
|
||||
opacity: 0.6
|
||||
},
|
||||
searchToggle: {
|
||||
padding: spacing.xs
|
||||
},
|
||||
searchBar: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const hostScreenSecondaryStyles = StyleSheet.create({
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
list: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs
|
||||
},
|
||||
sectionIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionRepoIcon: {
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
sectionCount: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginLeft: spacing.xs
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginLeft: spacing.lg + 24,
|
||||
marginRight: spacing.lg
|
||||
},
|
||||
filterModalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterModalTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterSectionLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
filterGroup: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
gap: spacing.sm
|
||||
},
|
||||
filterRowText: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
filterSeparator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
filterRepoDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4
|
||||
},
|
||||
confirmContent: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
confirmTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
confirmMessage: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
lineHeight: 20
|
||||
},
|
||||
confirmButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
confirmBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
confirmBtnCancel: {
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
confirmBtnDestructive: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
confirmBtnPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
confirmBtnCancelText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
confirmBtnDestructiveText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { hostScreenPrimaryStyles } from './host-screen-primary-styles'
|
||||
import { hostScreenSecondaryStyles } from './host-screen-secondary-styles'
|
||||
|
||||
export const hostScreenStyles = {
|
||||
...hostScreenPrimaryStyles,
|
||||
...hostScreenSecondaryStyles
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createTerminalAccessoryRepeatController,
|
||||
createTerminalAccessoryRepeatSender,
|
||||
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
|
||||
TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS
|
||||
} from './terminal-accessory-repeat'
|
||||
|
||||
function deferred() {
|
||||
let resolve: () => void = () => {}
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('terminal accessory repeat', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('keeps one send in flight and preserves repeat order under host latency', async () => {
|
||||
vi.useFakeTimers()
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
const sent: string[] = []
|
||||
const send = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return (sent.length === 1 ? first.promise : second.promise).then(() => true)
|
||||
})
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
expect(sent).toEqual(['down'])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_DELAY_MS + 500)
|
||||
expect(sent).toEqual(['down'])
|
||||
|
||||
first.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(sent).toEqual(['down', 'down'])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS + 500)
|
||||
expect(sent).toEqual(['down', 'down'])
|
||||
|
||||
second.resolve()
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
|
||||
expect(sent).toEqual(['down', 'down', 'down'])
|
||||
})
|
||||
|
||||
it('does not schedule another repeat after release while a send is pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred()
|
||||
const send = vi.fn(() => pending.promise.then(() => true))
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
repeat.stop()
|
||||
pending.resolve()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dispatches distinct taps without waiting for prior acknowledgements', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = Array.from({ length: 5 }, () => deferred())
|
||||
const sent: string[] = []
|
||||
const send = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return pending[sent.length - 1]!.promise.then(() => true)
|
||||
})
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
for (let index = 0; index < pending.length; index += 1) {
|
||||
repeat.start(`tap-${index}`, send)
|
||||
repeat.stop()
|
||||
}
|
||||
|
||||
expect(sent).toEqual(['tap-0', 'tap-1', 'tap-2', 'tap-3', 'tap-4'])
|
||||
|
||||
pending.forEach(({ resolve }) => resolve())
|
||||
await vi.runAllTimersAsync()
|
||||
expect(send).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('cancels repeats without dropping a distinct tap already dispatched by the user', async () => {
|
||||
vi.useFakeTimers()
|
||||
const first = deferred()
|
||||
const sent: string[] = []
|
||||
const send = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true)
|
||||
})
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
repeat.stop()
|
||||
repeat.start('up', send)
|
||||
repeat.cancel()
|
||||
|
||||
first.resolve()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(sent).toEqual(['down', 'up'])
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stops repeating when the transport rejects a send', async () => {
|
||||
vi.useFakeTimers()
|
||||
const send = vi.fn(() => Promise.reject(new Error('disconnected')))
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops repeating when the send is dropped before reaching the transport', async () => {
|
||||
vi.useFakeTimers()
|
||||
const send = vi.fn(async () => false)
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', send)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops sending when the terminal active at press time is no longer active', async () => {
|
||||
let activeTerminal = 'terminal-a'
|
||||
const sendToTerminal = vi.fn(async () => true)
|
||||
const send = createTerminalAccessoryRepeatSender(
|
||||
activeTerminal,
|
||||
(targetHandle) => activeTerminal === targetHandle,
|
||||
sendToTerminal
|
||||
)
|
||||
|
||||
await expect(send('down')).resolves.toBe(true)
|
||||
activeTerminal = 'terminal-b'
|
||||
await expect(send('down')).resolves.toBe(false)
|
||||
|
||||
expect(sendToTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(sendToTerminal).toHaveBeenCalledWith('down', 'terminal-a', expect.any(Function))
|
||||
})
|
||||
|
||||
it('provides a live target guard for async send preflight', async () => {
|
||||
let targetCurrent = true
|
||||
const preflight = deferred()
|
||||
const send = createTerminalAccessoryRepeatSender(
|
||||
'terminal-a',
|
||||
() => targetCurrent,
|
||||
async (_input, _targetHandle, isDeliveryTargetCurrent) => {
|
||||
await preflight.promise
|
||||
return isDeliveryTargetCurrent()
|
||||
}
|
||||
)
|
||||
|
||||
const result = send('down')
|
||||
targetCurrent = false
|
||||
preflight.resolve()
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('stops held repeats when the press-time connection is no longer current', async () => {
|
||||
vi.useFakeTimers()
|
||||
let connectionGeneration = 1
|
||||
const first = deferred()
|
||||
const sent: string[] = []
|
||||
const sendToTerminal = vi.fn((input: string) => {
|
||||
sent.push(input)
|
||||
return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true)
|
||||
})
|
||||
const createSender = () => {
|
||||
const pressedConnectionGeneration = connectionGeneration
|
||||
return createTerminalAccessoryRepeatSender(
|
||||
'terminal-a',
|
||||
() => connectionGeneration === pressedConnectionGeneration,
|
||||
sendToTerminal
|
||||
)
|
||||
}
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
repeat.start('down', createSender())
|
||||
|
||||
connectionGeneration = 2
|
||||
first.resolve()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(sent).toEqual(['down'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
export const TERMINAL_ACCESSORY_REPEAT_DELAY_MS = 400
|
||||
export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45
|
||||
|
||||
type TerminalAccessoryRepeatSender<TInput> = (input: TInput) => Promise<boolean>
|
||||
type TerminalAccessoryRepeatSendToTerminal<TInput> = (
|
||||
input: TInput,
|
||||
targetHandle: string,
|
||||
isDeliveryTargetCurrent: () => boolean
|
||||
) => Promise<boolean>
|
||||
|
||||
export function createTerminalAccessoryRepeatSender<TInput>(
|
||||
targetHandle: string | null,
|
||||
isDeliveryTargetCurrent: (targetHandle: string) => boolean,
|
||||
sendToTerminal: TerminalAccessoryRepeatSendToTerminal<TInput>
|
||||
): TerminalAccessoryRepeatSender<TInput> {
|
||||
return (input) => {
|
||||
if (!targetHandle || !isDeliveryTargetCurrent(targetHandle)) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return sendToTerminal(input, targetHandle, () => isDeliveryTargetCurrent(targetHandle))
|
||||
}
|
||||
}
|
||||
|
||||
export function createTerminalAccessoryRepeatController<TInput>() {
|
||||
let generation = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const stop = () => {
|
||||
generation += 1
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = stop
|
||||
|
||||
const start = (input: TInput, send: TerminalAccessoryRepeatSender<TInput>) => {
|
||||
stop()
|
||||
const activeGeneration = generation
|
||||
const pressedAt = Date.now()
|
||||
|
||||
const schedule = (delayMs: number) => {
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
if (generation !== activeGeneration) {
|
||||
return
|
||||
}
|
||||
void send(input).then(
|
||||
(sent) => {
|
||||
if (sent && generation === activeGeneration) {
|
||||
schedule(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
|
||||
}
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
void send(input).then(
|
||||
(sent) => {
|
||||
if (sent && generation === activeGeneration) {
|
||||
schedule(
|
||||
Math.min(
|
||||
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
|
||||
Math.max(0, TERMINAL_ACCESSORY_REPEAT_DELAY_MS - (Date.now() - pressedAt))
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
|
||||
return { cancel, start, stop }
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
|
||||
function captureClient(result: Promise<unknown> = Promise.resolve({ ok: true })) {
|
||||
function captureClient(
|
||||
result: Promise<unknown> = Promise.resolve({
|
||||
id: 'send',
|
||||
ok: true,
|
||||
result: { send: { handle: 'terminal-a', accepted: true, bytesWritten: 1 } },
|
||||
_meta: { runtimeId: 'test-runtime' }
|
||||
})
|
||||
) {
|
||||
const sendRequest = vi.fn(() => result)
|
||||
return { client: { sendRequest } as unknown as Pick<RpcClient, 'sendRequest'>, sendRequest }
|
||||
}
|
||||
@@ -20,7 +28,7 @@ describe('terminal live accessory raw send', () => {
|
||||
it('sends raw bytes now-or-never with the device presence tag', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(true)
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
@@ -33,7 +41,9 @@ describe('terminal live accessory raw send', () => {
|
||||
it('drops the bytes instead of sending while disconnected', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
|
||||
await expect(
|
||||
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -41,16 +51,28 @@ describe('terminal live accessory raw send', () => {
|
||||
it('drops the bytes when the terminal selection went stale mid-flush', async () => {
|
||||
const { client, sendRequest } = captureClient()
|
||||
|
||||
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
|
||||
await expect(
|
||||
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
|
||||
).resolves.toBe(false)
|
||||
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows a rejected send so accessory taps never surface transport errors', async () => {
|
||||
it('reports a rejected send without surfacing transport errors', async () => {
|
||||
const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send')))
|
||||
|
||||
await expect(
|
||||
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
|
||||
).resolves.toBeUndefined()
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('reports a fulfilled RPC failure as a failed send', async () => {
|
||||
const response: RpcResponse = {
|
||||
id: 'send',
|
||||
ok: false,
|
||||
error: { code: 'terminal_error', message: 'failed' },
|
||||
_meta: { runtimeId: 'test-runtime' }
|
||||
}
|
||||
const { client } = captureClient(Promise.resolve(response))
|
||||
|
||||
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
|
||||
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
|
||||
import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
@@ -15,7 +16,7 @@ type TerminalLiveAccessoryRawSendArgs = {
|
||||
|
||||
export async function sendTerminalLiveAccessoryRawBytes(
|
||||
args: TerminalLiveAccessoryRawSendArgs
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
// Why: async IME flushing can outlive the original terminal selection.
|
||||
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
|
||||
targetHandle: args.targetHandle,
|
||||
@@ -23,9 +24,9 @@ export async function sendTerminalLiveAccessoryRawBytes(
|
||||
activeSessionTabType: args.activeSessionTabType
|
||||
})
|
||||
if (!args.client || !rawSendTarget || args.connState !== 'connected') {
|
||||
return
|
||||
return false
|
||||
}
|
||||
await args.client
|
||||
return args.client
|
||||
.sendRequest(
|
||||
'terminal.send',
|
||||
buildTerminalSendParams({
|
||||
@@ -36,8 +37,5 @@ export async function sendTerminalLiveAccessoryRawBytes(
|
||||
}),
|
||||
TERMINAL_INPUT_SEND_OPTIONS
|
||||
)
|
||||
.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
.then(isTerminalSendRpcAccepted, () => false)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ function createAccessoryInputCommitHarness({
|
||||
}
|
||||
}
|
||||
const applyLiveInputMirror = vi.fn(
|
||||
(_handle: string, _fieldText: string, _composing?: boolean) => {}
|
||||
async (_handle: string, _fieldText: string, _composing?: boolean) => true
|
||||
)
|
||||
const clearPendingLiveInputCommit = vi.fn(() => {})
|
||||
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
|
||||
@@ -171,6 +171,20 @@ describe('terminal live accessory input commit hook', () => {
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
})
|
||||
|
||||
it('Given held text with a failed control send When committed Then reports the accessory input as suppressed', async () => {
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '한',
|
||||
pendingHandle: 'terminal-a',
|
||||
sendResult: false
|
||||
})
|
||||
|
||||
const result = await harness.commit({ bytes: '\x1b' })
|
||||
|
||||
expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
|
||||
expect(harness.sent).toEqual(['\x1b'])
|
||||
expect(result).toEqual({ kind: 'suppress-raw' })
|
||||
})
|
||||
|
||||
it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({ pendingHandle: null })
|
||||
|
||||
@@ -21,7 +21,11 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
|
||||
type TerminalLiveAccessoryInputCommitOptions = {
|
||||
readonly activeHandle: string | null
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
|
||||
readonly applyLiveInputMirror: (
|
||||
handle: string,
|
||||
fieldText: string,
|
||||
composing?: boolean
|
||||
) => Promise<boolean>
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
@@ -84,15 +88,20 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
setLiveInputCapture(editedText)
|
||||
liveInputRef.current?.setNativeProps({ text: editedText })
|
||||
// Preserve undefined so Android's heuristic hold still settles on its timer.
|
||||
applyLiveInputMirror(activeHandle, editedText, liveInputComposingRef.current)
|
||||
return { kind: 'handled' }
|
||||
const sent = await applyLiveInputMirror(
|
||||
activeHandle,
|
||||
editedText,
|
||||
liveInputComposingRef.current
|
||||
)
|
||||
return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
|
||||
}
|
||||
case 'commit-held-then-send':
|
||||
await sendTerminalLiveControlAfterPendingFlush(
|
||||
case 'commit-held-then-send': {
|
||||
const sent = await sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
)
|
||||
return { kind: 'handled' }
|
||||
return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
|
||||
}
|
||||
default:
|
||||
decision satisfies never
|
||||
return { kind: 'handled' }
|
||||
|
||||
@@ -190,6 +190,20 @@ describe('terminal live input commit hook', () => {
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
})
|
||||
|
||||
it('Given a failed mirrored Backspace When accessory input commits Then reports failure', async () => {
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
changeLiveInput(handlers, 'a', false)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['a']))
|
||||
|
||||
const result = await handlers.handleLiveInputAccessoryBytes({
|
||||
bytes: '\x7f',
|
||||
localEdit: 'backspace'
|
||||
})
|
||||
|
||||
expect(sent).toEqual(['a', '\x7f'])
|
||||
expect(result).toEqual({ kind: 'suppress-raw' })
|
||||
})
|
||||
|
||||
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -133,7 +133,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
// that differs from the native field text, so the controlled capture must
|
||||
// echo the field verbatim; only the PTY mirror sees normalized text.
|
||||
setLiveInputCapture(nativeEvent.text)
|
||||
applyLiveInputMirror(
|
||||
void applyLiveInputMirror(
|
||||
activeHandle,
|
||||
normalizeTerminalTextInput(nativeEvent.text),
|
||||
nativeEvent.isComposing
|
||||
|
||||
@@ -30,7 +30,11 @@ type RunTerminalLiveMirrorStep = (
|
||||
) => Promise<boolean>
|
||||
|
||||
type TerminalLivePendingInputFlush = {
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
|
||||
readonly applyLiveInputMirror: (
|
||||
handle: string,
|
||||
fieldText: string,
|
||||
composing?: boolean
|
||||
) => Promise<boolean>
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
@@ -153,9 +157,8 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
}, [runMirrorStep])
|
||||
|
||||
const applyLiveInputMirror = useCallback(
|
||||
(handle: string, fieldText: string, composing?: boolean): void => {
|
||||
void runMirrorStep(handle, fieldText, false, composing)
|
||||
},
|
||||
(handle: string, fieldText: string, composing?: boolean): Promise<boolean> =>
|
||||
runMirrorStep(handle, fieldText, false, composing),
|
||||
[runMirrorStep]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRpcClientContext, type RpcClientContextValue } from './client-context'
|
||||
import { useRpcClientContext } from './client-context'
|
||||
import type { RpcClientContextValue } from './rpc-client-context-contract'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
|
||||
export function useReconnectAttempt(hostId: string | undefined): number {
|
||||
@@ -10,6 +11,20 @@ export function useLastConnectedAt(hostId: string | undefined): number | null {
|
||||
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
|
||||
}
|
||||
|
||||
export function useConnectionPathStatus(hostId: string | undefined): {
|
||||
activePath: MobileConnectionPath
|
||||
pendingPath: MobileConnectionPath | null
|
||||
} {
|
||||
return useHostMetric(
|
||||
hostId,
|
||||
(context, id) => ({
|
||||
activePath: context.getActivePath(id),
|
||||
pendingPath: context.getPendingPath(id)
|
||||
}),
|
||||
{ activePath: 'lan', pendingPath: null }
|
||||
)
|
||||
}
|
||||
|
||||
// Both inputs classifyConnection needs about a relay recovery, read from one
|
||||
// subscription so a screen cannot render half of the escalation.
|
||||
export function useRelayRecoveryStatus(hostId: string | undefined): {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { HostOpenRetryScheduler } from './host-open-retry-scheduler'
|
||||
import { openHostClientEntry, type HostClientStoreEntry } from './host-entry-opener'
|
||||
import { shouldPreserveActiveRelay } from './relay-reconnect-preservation'
|
||||
import { recordConnectionRevival } from './persisted-connection-log-store'
|
||||
import {
|
||||
createHostClientSelectors,
|
||||
listHostClients,
|
||||
@@ -35,8 +36,6 @@ import type { ConnectionState, HostProfile } from './types'
|
||||
import type { RpcClientContextValue } from './rpc-client-context-contract'
|
||||
|
||||
type StoreEntry = HostClientStoreEntry
|
||||
export type { HostClientAcquisition } from './host-client-acquisition-registry'
|
||||
export type { RpcClientContextValue } from './rpc-client-context-contract'
|
||||
|
||||
const Ctx = createContext<RpcClientContextValue | null>(null)
|
||||
|
||||
@@ -311,7 +310,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
for (const hostId of pendingAcquisitionsRef.current.keys()) {
|
||||
retrySchedulerRef.current?.expedite(hostId)
|
||||
}
|
||||
for (const entry of storeRef.current.values()) {
|
||||
for (const [hostId, entry] of storeRef.current) {
|
||||
recordConnectionRevival(hostId, reason)
|
||||
try {
|
||||
entry.client.notifyForeground(reason)
|
||||
} catch {
|
||||
|
||||
@@ -53,4 +53,154 @@ describe('connection log buffer', () => {
|
||||
store.append('host-a', entry(2))
|
||||
expect(onA).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hydrates persisted history without dropping events recorded during app startup', async () => {
|
||||
let finishLoad: (entries: readonly ConnectionLogEntry[]) => void = () => {}
|
||||
const load = vi.fn(
|
||||
() =>
|
||||
new Promise<readonly ConnectionLogEntry[]>((resolve) => {
|
||||
finishLoad = resolve
|
||||
})
|
||||
)
|
||||
const save = vi.fn(async () => {})
|
||||
const store = createConnectionLogStore(3, { load, save })
|
||||
|
||||
store.append('host-a', entry(3))
|
||||
finishLoad([entry(1), entry(2)])
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1', 'log-2', 'log-3'])
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalled())
|
||||
expect(save).toHaveBeenLastCalledWith('host-a', [entry(1), entry(2), entry(3)])
|
||||
})
|
||||
|
||||
it('redacts credentials before retaining or persisting an event', async () => {
|
||||
const save = vi.fn(async () => {})
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
detail: 'resumeToken=do-not-copy deviceToken:also-secret'
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]?.detail).toBe('resumeToken=[redacted] deviceToken:[redacted]')
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalled())
|
||||
expect(JSON.stringify(save.mock.calls)).not.toContain('do-not-copy')
|
||||
expect(JSON.stringify(save.mock.calls)).not.toContain('also-secret')
|
||||
})
|
||||
|
||||
it('redacts quoted credential values when the object key is unquoted', async () => {
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
detail: `resumeToken: 'secret-one' deviceToken="secret-two"`
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]?.detail).toBe(
|
||||
`resumeToken: '[redacted]' deviceToken="[redacted]"`
|
||||
)
|
||||
})
|
||||
|
||||
it('redacts the full quoted credential when its value contains an escaped quote', async () => {
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
detail: String.raw`{"token":"secret\"tail"} token: 'secret\'tail'`
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]?.detail).toBe(`{"token":"[redacted]"} token: '[redacted]'`)
|
||||
})
|
||||
|
||||
it('redacts unterminated quoted credentials', async () => {
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
message: 'token="secret',
|
||||
detail: '{"authorization":"Bearer also-secret'
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]).toMatchObject({
|
||||
message: 'token="[redacted]',
|
||||
detail: '{"authorization":"[redacted]'
|
||||
})
|
||||
})
|
||||
|
||||
it('redacts URL userinfo through the last authority separator', async () => {
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
message: 'wss://user:p@ss@example.com/x',
|
||||
detail: 'https://user:pass@example.com/x'
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]).toMatchObject({
|
||||
message: 'wss://[redacted]@example.com/x',
|
||||
detail: 'https://[redacted]@example.com/x'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not overwrite persisted history when hydration fails and retries later', async () => {
|
||||
const load = vi
|
||||
.fn<() => Promise<readonly ConnectionLogEntry[]>>()
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
.mockResolvedValueOnce([entry(1)])
|
||||
const save = vi.fn(async () => {})
|
||||
const store = createConnectionLogStore(3, { load, save })
|
||||
|
||||
store.append('host-a', entry(2))
|
||||
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
|
||||
await store.hydrate('host-a')
|
||||
expect(store.get('host-a').map((value) => value.id)).toEqual(['log-1', 'log-2'])
|
||||
})
|
||||
|
||||
it('preserves legacy entries that reused the same event id', async () => {
|
||||
const store = createConnectionLogStore(3, {
|
||||
load: async () => [entry(1), { ...entry(2), id: 'log-1' }],
|
||||
save: async () => {}
|
||||
})
|
||||
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a').map((value) => value.message)).toEqual(['event 1', 'event 2'])
|
||||
})
|
||||
|
||||
it('retries one transient persistence failure without requiring another append', async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save })
|
||||
|
||||
await store.hydrate('host-a')
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalled())
|
||||
save.mockReset()
|
||||
save.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(undefined)
|
||||
store.append('host-a', entry(1))
|
||||
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2))
|
||||
expect(save).toHaveBeenLastCalledWith('host-a', [entry(1)])
|
||||
})
|
||||
|
||||
it('does not retry failed automatic hydration for every appended event', async () => {
|
||||
const load = vi.fn().mockRejectedValue(new Error('storage unavailable'))
|
||||
const store = createConnectionLogStore(3, { load, save: async () => {} })
|
||||
|
||||
store.append('host-a', entry(1))
|
||||
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
|
||||
store.append('host-a', entry(2))
|
||||
store.append('host-a', entry(3))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
import { redactConnectionLogEntry } from '../diagnostics/connection-log-redaction'
|
||||
|
||||
// Why: the rpc-client's onLog entries were only wired during pairing; for
|
||||
// long-lived host connections everything went to console.log, invisible to
|
||||
@@ -12,20 +13,108 @@ const MAX_ENTRIES_PER_HOST = 200
|
||||
export type ConnectionLogStore = {
|
||||
append: (hostId: string, entry: ConnectionLogEntry) => void
|
||||
get: (hostId: string) => readonly ConnectionLogEntry[]
|
||||
hydrate: (hostId: string) => Promise<void>
|
||||
subscribe: (hostId: string, listener: () => void) => () => void
|
||||
}
|
||||
|
||||
export type ConnectionLogPersistence = {
|
||||
load: (hostId: string) => Promise<readonly ConnectionLogEntry[]>
|
||||
save: (hostId: string, entries: readonly ConnectionLogEntry[]) => Promise<void>
|
||||
}
|
||||
|
||||
export function createConnectionLogStore(
|
||||
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST
|
||||
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST,
|
||||
persistence?: ConnectionLogPersistence
|
||||
): ConnectionLogStore {
|
||||
const entriesByHost = new Map<string, ConnectionLogEntry[]>()
|
||||
const listenersByHost = new Map<string, Set<() => void>>()
|
||||
const hydratedHosts = new Set<string>()
|
||||
const hydrationFailedHosts = new Set<string>()
|
||||
const hydrationByHost = new Map<string, Promise<void>>()
|
||||
const saveByHost = new Map<string, Promise<void>>()
|
||||
// Why: useSyncExternalStore compares snapshots by reference — getSnapshot
|
||||
// must return the SAME array until the data actually changes, or React
|
||||
// loops re-rendering. Cache per host; invalidate on append.
|
||||
const snapshotByHost = new Map<string, readonly ConnectionLogEntry[]>()
|
||||
const EMPTY: readonly ConnectionLogEntry[] = []
|
||||
|
||||
const trim = (entries: ConnectionLogEntry[]): void => {
|
||||
if (entries.length > maxEntriesPerHost) {
|
||||
entries.splice(0, entries.length - maxEntriesPerHost)
|
||||
}
|
||||
}
|
||||
|
||||
const notify = (hostId: string): void => {
|
||||
snapshotByHost.delete(hostId)
|
||||
const listeners = listenersByHost.get(hostId)
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persist = (hostId: string): void => {
|
||||
if (!persistence || !hydratedHosts.has(hostId)) {
|
||||
return
|
||||
}
|
||||
const snapshot = [...(entriesByHost.get(hostId) ?? [])]
|
||||
const previous = saveByHost.get(hostId) ?? Promise.resolve()
|
||||
const pending = previous
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
try {
|
||||
await persistence.save(hostId, snapshot)
|
||||
} catch {
|
||||
await persistence.save(hostId, snapshot)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
saveByHost.set(hostId, pending)
|
||||
}
|
||||
|
||||
const hydrateHost = async (hostId: string, retryAfterFailure: boolean): Promise<void> => {
|
||||
if (!persistence || hydratedHosts.has(hostId)) {
|
||||
return
|
||||
}
|
||||
const existing = hydrationByHost.get(hostId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
if (!retryAfterFailure && hydrationFailedHosts.has(hostId)) {
|
||||
return
|
||||
}
|
||||
const pending = persistence
|
||||
.load(hostId)
|
||||
.then((stored) => {
|
||||
const live = entriesByHost.get(hostId) ?? []
|
||||
const seen = new Set<string>()
|
||||
const merged: ConnectionLogEntry[] = []
|
||||
for (const entry of [...stored, ...live]) {
|
||||
const redacted = redactConnectionLogEntry(entry)
|
||||
const fingerprint = JSON.stringify(redacted)
|
||||
if (!seen.has(fingerprint)) {
|
||||
seen.add(fingerprint)
|
||||
merged.push(redacted)
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => a.ts - b.ts)
|
||||
trim(merged)
|
||||
entriesByHost.set(hostId, merged)
|
||||
hydratedHosts.add(hostId)
|
||||
hydrationFailedHosts.delete(hostId)
|
||||
notify(hostId)
|
||||
persist(hostId)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
hydrationFailedHosts.add(hostId)
|
||||
throw error
|
||||
})
|
||||
.finally(() => hydrationByHost.delete(hostId))
|
||||
hydrationByHost.set(hostId, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
return {
|
||||
append(hostId, entry) {
|
||||
let entries = entriesByHost.get(hostId)
|
||||
@@ -33,17 +122,12 @@ export function createConnectionLogStore(
|
||||
entries = []
|
||||
entriesByHost.set(hostId, entries)
|
||||
}
|
||||
entries.push(entry)
|
||||
if (entries.length > maxEntriesPerHost) {
|
||||
entries.splice(0, entries.length - maxEntriesPerHost)
|
||||
}
|
||||
snapshotByHost.delete(hostId)
|
||||
const listeners = listenersByHost.get(hostId)
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
entries.push(redactConnectionLogEntry(entry))
|
||||
trim(entries)
|
||||
notify(hostId)
|
||||
void hydrateHost(hostId, false)
|
||||
.then(() => persist(hostId))
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
get(hostId) {
|
||||
@@ -60,6 +144,8 @@ export function createConnectionLogStore(
|
||||
return snapshot
|
||||
},
|
||||
|
||||
hydrate: (hostId) => hydrateHost(hostId, true),
|
||||
|
||||
subscribe(hostId, listener) {
|
||||
let listeners = listenersByHost.get(hostId)
|
||||
if (!listeners) {
|
||||
@@ -80,5 +166,3 @@ export function createConnectionLogStore(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionLogStore = createConnectionLogStore()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
|
||||
import type { LivenessTimeoutEvidence } from './rpc-session-liveness-watchdog'
|
||||
import type {
|
||||
ConnectionLogEntry,
|
||||
ConnectionLogLevel,
|
||||
ConnectionLogSink,
|
||||
MobileConnectionDiagnosticPath
|
||||
} from './types'
|
||||
|
||||
export class DirectConnectionLog {
|
||||
private sequence = 0
|
||||
private readonly path: MobileConnectionDiagnosticPath
|
||||
|
||||
constructor(
|
||||
endpoint: string,
|
||||
private readonly sink?: ConnectionLogSink
|
||||
) {
|
||||
this.path = isTailscaleEndpoint(endpoint) ? 'tailscale' : 'lan'
|
||||
}
|
||||
|
||||
emit = (
|
||||
level: ConnectionLogLevel,
|
||||
message: string,
|
||||
detail?: string,
|
||||
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
|
||||
): void => {
|
||||
this.sink?.({
|
||||
id: `log-${++this.sequence}-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
level,
|
||||
message,
|
||||
detail,
|
||||
...evidence,
|
||||
path: evidence?.path ?? this.path
|
||||
})
|
||||
}
|
||||
|
||||
livenessTimeout = (evidence: LivenessTimeoutEvidence): void => {
|
||||
this.emit(
|
||||
'error',
|
||||
'Connection health check failed',
|
||||
`${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`,
|
||||
{ code: 'liveness-timeout' }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConnectOptions, RpcClient, SendRequestOptions } from './rpc-client'
|
||||
import { DirectConnectionLog } from './direct-connection-log'
|
||||
import { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
|
||||
import { RpcClientConnectionState } from './rpc-client-connection-state'
|
||||
import {
|
||||
@@ -16,12 +17,7 @@ import {
|
||||
} from './rpc-client-stream-registry'
|
||||
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
|
||||
import { isStaleForegroundDial } from './rpc-stale-dial'
|
||||
import type {
|
||||
ConnectionLogLevel,
|
||||
ConnectionState,
|
||||
ForegroundNudgeReason,
|
||||
RpcResponse
|
||||
} from './types'
|
||||
import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types'
|
||||
|
||||
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
|
||||
|
||||
@@ -36,7 +32,7 @@ export class DirectRpcClient implements RpcClient {
|
||||
private readonly authenticationRetry: RpcClientAuthenticationRetry
|
||||
private readonly socketClose: RpcClientSocketCloseController
|
||||
private requestCounter = 0
|
||||
private logCounter = 0
|
||||
private readonly connectionLog: DirectConnectionLog
|
||||
private intentionallyClosed = false
|
||||
private authenticationGeneration = 0
|
||||
private livenessSession: RpcClientSocketSession | null = null
|
||||
@@ -47,10 +43,12 @@ export class DirectRpcClient implements RpcClient {
|
||||
serverPublicKeyB64: string,
|
||||
private readonly options: ConnectOptions
|
||||
) {
|
||||
this.connectionLog = new DirectConnectionLog(endpoint, options.onLog)
|
||||
this.reconnect = new RpcClientReconnectSchedule({
|
||||
openConnection: () => this.openConnection(),
|
||||
rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason),
|
||||
emitLog: (message, detail) => this.emitLog('info', message, detail)
|
||||
emitLog: (message, detail) =>
|
||||
this.connectionLog.emit('info', message, detail, { code: 'retry-scheduled' })
|
||||
})
|
||||
this.connectionState = new RpcClientConnectionState({
|
||||
endpoint,
|
||||
@@ -78,7 +76,8 @@ export class DirectRpcClient implements RpcClient {
|
||||
if (identity === this.livenessSession && this.socketSession === this.livenessSession) {
|
||||
this.socketClose.forceClose(this.livenessSession)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTimeout: this.connectionLog.livenessTimeout
|
||||
})
|
||||
this.socketFactory = new RpcClientSocketFactory({
|
||||
endpoint,
|
||||
@@ -89,7 +88,7 @@ export class DirectRpcClient implements RpcClient {
|
||||
getReconnectAttempt: () => this.getReconnectAttempt(),
|
||||
getLastConnectedAt: () => this.getLastConnectedAt(),
|
||||
isIntentionallyClosed: () => this.intentionallyClosed,
|
||||
emitLog: (level, message, detail) => this.emitLog(level, message, detail),
|
||||
emitLog: this.connectionLog.emit,
|
||||
onHandshakeStarted: () => this.connectionState.publish('handshaking'),
|
||||
onAuthenticated: (session) => this.handleAuthenticated(session),
|
||||
onAuthRejected: (reason) => this.authenticationRetry.reject(reason),
|
||||
@@ -102,7 +101,8 @@ export class DirectRpcClient implements RpcClient {
|
||||
this.authenticationRetry = new RpcClientAuthenticationRetry({
|
||||
endpoint,
|
||||
stopLiveness: () => this.stopLiveness(),
|
||||
emitWarning: (message, detail) => this.emitLog('warn', message, detail),
|
||||
emitWarning: (message, detail) =>
|
||||
this.connectionLog.emit('warn', message, detail, { code: 'authentication-rejected' }),
|
||||
retry: (reason) => this.retryAuthentication(reason),
|
||||
latchFailure: (reason) => this.latchAuthenticationFailure(reason)
|
||||
})
|
||||
@@ -122,7 +122,8 @@ export class DirectRpcClient implements RpcClient {
|
||||
this.stopLiveness()
|
||||
}
|
||||
},
|
||||
emitWarning: (message, detail) => this.emitLog('warn', message, detail)
|
||||
emitWarning: (message, detail, evidence) =>
|
||||
this.connectionLog.emit('warn', message, detail, evidence)
|
||||
})
|
||||
this.openConnection()
|
||||
}
|
||||
@@ -232,7 +233,9 @@ export class DirectRpcClient implements RpcClient {
|
||||
this.reconnect.authenticated()
|
||||
this.authenticationRetry.accepted()
|
||||
this.connectionState.publish('connected')
|
||||
this.emitLog('success', 'Authenticated', 'Channel ready for RPC')
|
||||
this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', {
|
||||
code: 'direct-connected'
|
||||
})
|
||||
this.streams.replayAfterAuthentication()
|
||||
}
|
||||
|
||||
@@ -311,14 +314,4 @@ export class DirectRpcClient implements RpcClient {
|
||||
private nextId(): string {
|
||||
return `rpc-${++this.requestCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
private emitLog(level: ConnectionLogLevel, message: string, detail?: string): void {
|
||||
this.options.onLog?.({
|
||||
id: `log-${++this.logCounter}-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
level,
|
||||
message,
|
||||
detail
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: asyncStorageMock
|
||||
}))
|
||||
|
||||
import {
|
||||
loadHostAppVersion,
|
||||
normalizeHostAppVersion,
|
||||
recordHostAppVersion
|
||||
} from './host-app-version-store'
|
||||
|
||||
describe('host app version store', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
asyncStorageMock.getItem.mockResolvedValue(null)
|
||||
asyncStorageMock.setItem.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('persists a bounded version once per host', async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValueOnce(null).mockResolvedValueOnce('1.4.191')
|
||||
|
||||
await recordHostAppVersion('host-1', ' 1.4.191 ')
|
||||
await recordHostAppVersion('host-1', '1.4.191')
|
||||
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledOnce()
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'orca:host-app-version:v1:host-1',
|
||||
'1.4.191'
|
||||
)
|
||||
})
|
||||
|
||||
it('loads a previously observed version without requiring a live host', async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue('1.4.188')
|
||||
|
||||
await expect(loadHostAppVersion('host-1')).resolves.toBe('1.4.188')
|
||||
})
|
||||
|
||||
it('rejects multiline, empty, and oversized host-provided values', async () => {
|
||||
expect(normalizeHostAppVersion('1.4.191\nInjected: value')).toBeNull()
|
||||
expect(normalizeHostAppVersion(' ')).toBeNull()
|
||||
expect(normalizeHostAppVersion('x'.repeat(65))).toBeNull()
|
||||
|
||||
await recordHostAppVersion('host-1', '1.4.191\nInjected: value')
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'orca:host-app-version:v1:'
|
||||
const MAX_VERSION_LENGTH = 64
|
||||
|
||||
export function normalizeHostAppVersion(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const normalized = value.trim()
|
||||
if (
|
||||
normalized.length === 0 ||
|
||||
normalized.length > MAX_VERSION_LENGTH ||
|
||||
normalized.includes('\n') ||
|
||||
normalized.includes('\r')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export async function loadHostAppVersion(hostId: string): Promise<string | null> {
|
||||
try {
|
||||
return normalizeHostAppVersion(await AsyncStorage.getItem(storageKey(hostId)))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordHostAppVersion(hostId: string, value: unknown): Promise<void> {
|
||||
const appVersion = normalizeHostAppVersion(value)
|
||||
if (!appVersion) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const key = storageKey(hostId)
|
||||
if (normalizeHostAppVersion(await AsyncStorage.getItem(key)) !== appVersion) {
|
||||
await AsyncStorage.setItem(key, appVersion)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort diagnostic metadata must never affect host connectivity.
|
||||
}
|
||||
}
|
||||
|
||||
function storageKey(hostId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${hostId}`
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { connectionLogStore } from './connection-log-buffer'
|
||||
import {
|
||||
connectionLogStore,
|
||||
recordConnectionClientSessionStart
|
||||
} from './persisted-connection-log-store'
|
||||
import { loadHosts } from './host-store'
|
||||
import { openHostLogicalClient } from './host-logical-client'
|
||||
import type { HostClientOpenRegistry } from './host-client-open-registry'
|
||||
@@ -65,6 +68,7 @@ export async function openHostClientEntry(
|
||||
id: `host-open-${ticket.generation}-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
level: 'error',
|
||||
code: 'host-open-failed',
|
||||
message: 'Host client open failed',
|
||||
detail: `${category}; retry ${retry.nextDelayMs}ms (failure ${retry.failureCount})`
|
||||
})
|
||||
@@ -98,6 +102,7 @@ export async function openHostClientEntry(
|
||||
|
||||
let client: RpcClient
|
||||
try {
|
||||
recordConnectionClientSessionStart(hostId)
|
||||
client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry))
|
||||
} catch {
|
||||
failCurrentOpen('client-construction')
|
||||
|
||||
@@ -4,6 +4,13 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import { useHostStatusGates, type HostStatusGates } from './host-status-gates'
|
||||
|
||||
const recordHostAppVersionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
|
||||
vi.mock('./host-app-version-store', () => ({
|
||||
normalizeHostAppVersion: (value: unknown) => (typeof value === 'string' ? value : null),
|
||||
recordHostAppVersion: (...args: unknown[]) => recordHostAppVersionMock(...args)
|
||||
}))
|
||||
|
||||
describe('useHostStatusGates', () => {
|
||||
it('clears every prior-host gate and ignores its late response while the client is replaced', async () => {
|
||||
let resolveOldStatus: ((response: unknown) => void) | null = null
|
||||
@@ -76,6 +83,7 @@ describe('useHostStatusGates', () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
appVersion: '1.4.191',
|
||||
capabilities: ['browser.screencast.v1'],
|
||||
floatingWorkspaceEnabled: true
|
||||
}
|
||||
@@ -95,11 +103,13 @@ describe('useHostStatusGates', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(gates).toMatchObject({
|
||||
desktopAppVersion: '1.4.191',
|
||||
hostCapabilities: ['browser.screencast.v1'],
|
||||
floatingWorkspaceEnabled: true
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledOnce()
|
||||
expect(recordHostAppVersionMock).toHaveBeenCalledWith('host-1', '1.4.191')
|
||||
} finally {
|
||||
renderer?.unmount()
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionState, RpcSuccess } from './types'
|
||||
import { evaluateCompat, type CompatVerdict } from './protocol-compat'
|
||||
import type { DesktopStatus } from '../worktree/host-worktree-rpc-types'
|
||||
import { normalizeHostAppVersion, recordHostAppVersion } from './host-app-version-store'
|
||||
|
||||
export type HostStatusGates = {
|
||||
hostCapabilities: string[]
|
||||
floatingWorkspaceEnabled: boolean
|
||||
desktopAppVersion: string | null
|
||||
compatVerdict: CompatVerdict
|
||||
statusPending: boolean
|
||||
}
|
||||
@@ -53,6 +55,7 @@ export function useHostStatusGates(args: {
|
||||
settle({
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
desktopAppVersion: null,
|
||||
compatVerdict: { kind: 'ok' }
|
||||
})
|
||||
return
|
||||
@@ -64,9 +67,14 @@ export function useHostStatusGates(args: {
|
||||
desktopProtocolVersion: status.protocolVersion,
|
||||
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
|
||||
})
|
||||
const desktopAppVersion = normalizeHostAppVersion(status.appVersion)
|
||||
if (hostId && desktopAppVersion) {
|
||||
void recordHostAppVersion(hostId, desktopAppVersion)
|
||||
}
|
||||
settle({
|
||||
hostCapabilities: status.capabilities ?? [],
|
||||
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
|
||||
desktopAppVersion,
|
||||
compatVerdict: verdict
|
||||
})
|
||||
if (verdict.kind === 'blocked') {
|
||||
@@ -84,6 +92,7 @@ export function useHostStatusGates(args: {
|
||||
settle({
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
desktopAppVersion: null,
|
||||
compatVerdict: { kind: 'ok' }
|
||||
})
|
||||
}
|
||||
@@ -100,6 +109,7 @@ export function useHostStatusGates(args: {
|
||||
return {
|
||||
hostCapabilities: EMPTY_HOST_CAPABILITIES,
|
||||
floatingWorkspaceEnabled: false,
|
||||
desktopAppVersion: null,
|
||||
compatVerdict: { kind: 'ok' },
|
||||
statusPending: connState === 'connected' && client !== null
|
||||
}
|
||||
@@ -107,6 +117,7 @@ export function useHostStatusGates(args: {
|
||||
return {
|
||||
hostCapabilities: proven.hostCapabilities,
|
||||
floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled,
|
||||
desktopAppVersion: proven.desktopAppVersion,
|
||||
compatVerdict: proven.compatVerdict,
|
||||
// Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no
|
||||
// longer blanks the capabilities this same host already proved.
|
||||
|
||||
@@ -93,7 +93,8 @@ function createSupervisor(
|
||||
resumeCredentialVersion: credential.version,
|
||||
resumeConfirmReqId: confirmReqId,
|
||||
deviceToken: host.deviceToken,
|
||||
desktopPublicKeyB64: host.publicKeyB64
|
||||
desktopPublicKeyB64: host.publicKeyB64,
|
||||
onLog
|
||||
}),
|
||||
resolveRelay: resolveMobileRelayEndpoint,
|
||||
readBundle: readMobileRelayCredentialBundle,
|
||||
|
||||
@@ -234,8 +234,10 @@ describe('mobile endpoint supervisor', () => {
|
||||
.fn()
|
||||
.mockReturnValueOnce(new FakeRelaySession('connected', new RelayOuterError(4429)))
|
||||
.mockImplementation(() => new FakeRelaySession('connected'))
|
||||
const onLog = vi.fn()
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
onLog,
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
@@ -245,6 +247,13 @@ describe('mobile endpoint supervisor', () => {
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
expect(logical.getPendingPath()).toBe('relay')
|
||||
expect(onLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 'relay-session-failed',
|
||||
path: 'relay',
|
||||
message: 'Relay: active relay session failed'
|
||||
})
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
@@ -22,6 +22,11 @@ import * as recoveryPresentation from './mobile-relay-recovery-presentation'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ForegroundNudgeReason, HostProfile } from './types'
|
||||
import { MobileRelayBackgroundGrace } from './mobile-relay-background-grace'
|
||||
import {
|
||||
logRelayConnected,
|
||||
logRelayCredentialUnavailable,
|
||||
logRelayDialFailure
|
||||
} from './mobile-relay-diagnostic-log'
|
||||
|
||||
export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract'
|
||||
|
||||
@@ -98,7 +103,7 @@ export class MobileEndpointSupervisor {
|
||||
recordMigration: () => {
|
||||
this.relayRotationPending = false
|
||||
this.hysteresis.recordMigration(dependencies.now())
|
||||
this.logRelay('runtime channel migrated to relay')
|
||||
logRelayConnected(this.logRelay)
|
||||
},
|
||||
scheduleLease: (expiry) =>
|
||||
this.leaseRotation.scheduleFromLease(
|
||||
@@ -107,8 +112,7 @@ export class MobileEndpointSupervisor {
|
||||
scheduleDirectProbe: () => this.directProbe.schedule(),
|
||||
onBookkeepingError: (error) =>
|
||||
this.logRelay('relay bookkeeping failed after migration', error.message.slice(0, 80)),
|
||||
onDialFailure: (error) =>
|
||||
this.logRelay('relay dial failed', `${error.name}: ${String(error.message).slice(0, 80)}`)
|
||||
onDialFailure: (error) => logRelayDialFailure(this.logRelay, error)
|
||||
})
|
||||
this.directProbe = new DirectReturnProbe(dependencies, {
|
||||
hysteresis: this.hysteresis,
|
||||
@@ -166,7 +170,8 @@ export class MobileEndpointSupervisor {
|
||||
// Why: the direct client enters reconnecting after its first failed
|
||||
// dial and may never publish disconnected while its retry loop lives.
|
||||
recoveryPresentation.onActiveFailure(this.logical, this.relayReconnect, state, this.bundle)
|
||||
this.relayReconnect.handleStateFailure(this.logical, state)
|
||||
const relayFailure = this.relayReconnect.handleStateFailure(this.logical, state)
|
||||
logRelayDialFailure(this.logRelay, relayFailure, 'active-session')
|
||||
}
|
||||
})
|
||||
if (this.relayReconnect.needsRecovery(this.logical.getState())) {
|
||||
@@ -249,11 +254,7 @@ export class MobileEndpointSupervisor {
|
||||
this.logical.setRecoveryPath(null)
|
||||
// Why: "expired" vs "missing" separates a sleep-past-expiry phone
|
||||
// (needs re-pair or LAN) from a Keychain failure in field reports.
|
||||
this.logRelay(
|
||||
selection.bundle
|
||||
? 'relay credential expired or rejected; slow reprobe armed'
|
||||
: 'no relay credential bundle; slow reprobe armed'
|
||||
)
|
||||
logRelayCredentialUnavailable(this.logRelay, selection.bundle !== null)
|
||||
this.relayReconnect.armCredentialReprobe()
|
||||
if (ownsRecovery) {
|
||||
// Why: no dial happened — keep the session and the intent; the reprobe
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { RelayRecoveryLog } from './mobile-relay-recovery-log'
|
||||
import { RelayDirectorHttpError } from './mobile-relay-resume-director'
|
||||
|
||||
export function logRelayConnected(log: RelayRecoveryLog): void {
|
||||
log('runtime channel migrated to relay', undefined, {
|
||||
level: 'success',
|
||||
code: 'relay-connected'
|
||||
})
|
||||
}
|
||||
|
||||
export function logRelayDialFailure(
|
||||
log: RelayRecoveryLog,
|
||||
error: Error | null,
|
||||
source: 'dial' | 'active-session' = 'dial'
|
||||
): void {
|
||||
if (!error) {
|
||||
return
|
||||
}
|
||||
const base = `${error.name}: ${String(error.message).slice(0, 80)}`
|
||||
const detail =
|
||||
error instanceof RelayDirectorHttpError && error.retryAfterMs != null
|
||||
? `${base}; retry-after=${error.retryAfterMs}ms`
|
||||
: base
|
||||
log(source === 'dial' ? 'relay dial failed' : 'active relay session failed', detail, {
|
||||
level: 'error',
|
||||
code: source === 'dial' ? 'relay-dial-failed' : 'relay-session-failed'
|
||||
})
|
||||
}
|
||||
|
||||
export function logRelayCredentialUnavailable(log: RelayRecoveryLog, hasBundle: boolean): void {
|
||||
log(
|
||||
hasBundle
|
||||
? 'relay credential expired or rejected; slow reprobe armed'
|
||||
: 'no relay credential bundle; slow reprobe armed',
|
||||
undefined,
|
||||
{ level: 'warn', code: 'relay-credential-unavailable' }
|
||||
)
|
||||
}
|
||||
@@ -46,9 +46,7 @@ export class RelayReconnectController {
|
||||
this.credentials = new RelayCredentialEligibility(dependencies.now)
|
||||
}
|
||||
|
||||
getFailureCount(): number {
|
||||
return this.failureCount.current()
|
||||
}
|
||||
getFailureCount = (): number => this.failureCount.current()
|
||||
|
||||
reportRecoveryTo(logical: StableLogicalRpcClient): void {
|
||||
this.failureCount.reportTo(logical.setRecoveryAttempt)
|
||||
@@ -97,17 +95,17 @@ export class RelayReconnectController {
|
||||
return 'recover'
|
||||
}
|
||||
|
||||
handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): void {
|
||||
handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): Error | null {
|
||||
if (!this.needsRecovery(state)) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
this.registerActiveFailure(logical)
|
||||
const failure = this.registerActiveFailure(logical)
|
||||
this.onRetry()
|
||||
return failure
|
||||
}
|
||||
|
||||
needsRecovery(state: ConnectionState): boolean {
|
||||
return state !== 'connected' && state !== 'connecting' && state !== 'handshaking'
|
||||
}
|
||||
needsRecovery = (state: ConnectionState): boolean =>
|
||||
state !== 'connected' && state !== 'connecting' && state !== 'handshaking'
|
||||
|
||||
suspendActiveRelay(logical: StableLogicalRpcClient): void {
|
||||
if (logical.getActivePath() !== 'relay') {
|
||||
@@ -207,9 +205,9 @@ export class RelayReconnectController {
|
||||
|
||||
recordRejectedCredential = (version: number): void => this.credentials.recordRejected(version)
|
||||
|
||||
registerActiveFailure(logical: StableLogicalRpcClient): void {
|
||||
registerActiveFailure(logical: StableLogicalRpcClient): Error | null {
|
||||
if (logical.getActivePath() !== 'relay') {
|
||||
return
|
||||
return null
|
||||
}
|
||||
const failure = this.activeSession?.getFailure()
|
||||
this.activeSession = null
|
||||
@@ -219,6 +217,7 @@ export class RelayReconnectController {
|
||||
} else {
|
||||
this.activeRelayConnectedAt = null
|
||||
}
|
||||
return failure ?? null
|
||||
}
|
||||
|
||||
// True when the caller is still inside the cooldown window and must not
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createRelayRecoveryLog } from './mobile-relay-recovery-log'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
describe('createRelayRecoveryLog', () => {
|
||||
it('uses distinct ids across logger instances', () => {
|
||||
const entries: ConnectionLogEntry[] = []
|
||||
const sink = vi.fn((entry: ConnectionLogEntry) => entries.push(entry))
|
||||
createRelayRecoveryLog(() => 1, sink)('first')
|
||||
createRelayRecoveryLog(() => 1, sink)('second')
|
||||
|
||||
expect(entries[0]?.id).not.toBe(entries[1]?.id)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ConnectionLogSink } from './types'
|
||||
import type { ConnectionDiagnosticCode, ConnectionLogLevel, ConnectionLogSink } from './types'
|
||||
|
||||
export type RelayRecoveryLog = (message: string, detail?: string) => void
|
||||
export type RelayRecoveryLog = (
|
||||
message: string,
|
||||
detail?: string,
|
||||
evidence?: { level?: ConnectionLogLevel; code?: ConnectionDiagnosticCode }
|
||||
) => void
|
||||
|
||||
let relayLoggerInstanceSequence = 0
|
||||
|
||||
// Why: relay recovery failed silently in production for weeks; every decision
|
||||
// must reach logcat and the in-app connection log.
|
||||
@@ -9,12 +15,15 @@ export function createRelayRecoveryLog(
|
||||
onLog?: ConnectionLogSink
|
||||
): RelayRecoveryLog {
|
||||
let sequence = 0
|
||||
return (message, detail) => {
|
||||
const instanceId = `${Date.now().toString(36)}-${(++relayLoggerInstanceSequence).toString(36)}`
|
||||
return (message, detail, evidence) => {
|
||||
console.log(`[relay] ${message}`, detail ?? '')
|
||||
onLog?.({
|
||||
id: `relay-${++sequence}`,
|
||||
id: `relay-${instanceId}-${++sequence}`,
|
||||
ts: now(),
|
||||
level: 'info',
|
||||
level: evidence?.level ?? 'info',
|
||||
path: 'relay',
|
||||
...(evidence?.code ? { code: evidence.code } : {}),
|
||||
message: `Relay: ${message}`,
|
||||
...(detail ? { detail } : {})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('./mobile-relay-e2ee-link', () => ({
|
||||
}))
|
||||
|
||||
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import type { ConnectionLogSink } from './types'
|
||||
|
||||
const relay = {
|
||||
v: 1 as const,
|
||||
@@ -31,7 +32,7 @@ const relay = {
|
||||
e2eeFraming: 2 as const
|
||||
}
|
||||
|
||||
async function authenticateSession() {
|
||||
async function authenticateSession(onLog?: ConnectionLogSink) {
|
||||
const session = connectMobileRelayRpcSession({
|
||||
relay,
|
||||
resumeToken: 'resume-secret',
|
||||
@@ -39,7 +40,8 @@ async function authenticateSession() {
|
||||
resumeConfirmReqId: 'confirm-1',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
requestTimeoutMs: 30_000
|
||||
requestTimeoutMs: 30_000,
|
||||
onLog
|
||||
})
|
||||
fakes.linkOptions!.onHello({
|
||||
type: 'relay-hello',
|
||||
@@ -103,7 +105,8 @@ describe('mobile relay RPC session liveness', () => {
|
||||
})
|
||||
|
||||
it('disconnects after two fair foreground misses', async () => {
|
||||
const session = await authenticateSession()
|
||||
const onLog = vi.fn<ConnectionLogSink>()
|
||||
const session = await authenticateSession(onLog)
|
||||
|
||||
session.notifyForeground('focus')
|
||||
expect(sentRequests().map(({ method }) => method)).toEqual(['status.get'])
|
||||
@@ -114,6 +117,38 @@ describe('mobile relay RPC session liveness', () => {
|
||||
|
||||
expect(session.getState()).toBe('disconnected')
|
||||
expect(fakes.close).toHaveBeenCalledOnce()
|
||||
expect(onLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay',
|
||||
message: 'Relay health check failed',
|
||||
detail: expect.stringMatching(
|
||||
/^probe-timeout; 2\/2 probes missed; last authenticated activity \d+ms ago$/
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses distinct liveness evidence IDs for sessions created in the same millisecond', async () => {
|
||||
vi.setSystemTime(1_000)
|
||||
const firstLog = vi.fn<ConnectionLogSink>()
|
||||
const first = await authenticateSession(firstLog)
|
||||
first.notifyForeground('focus')
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
const firstId = firstLog.mock.calls[0]?.[0].id
|
||||
|
||||
vi.clearAllMocks()
|
||||
fakes.sendText.mockReturnValue(true)
|
||||
vi.setSystemTime(1_000)
|
||||
const secondLog = vi.fn<ConnectionLogSink>()
|
||||
const second = await authenticateSession(secondLog)
|
||||
second.notifyForeground('focus')
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
const secondId = secondLog.mock.calls[0]?.[0].id
|
||||
|
||||
expect(firstId).toBeTruthy()
|
||||
expect(secondId).toBeTruthy()
|
||||
expect(secondId).not.toBe(firstId)
|
||||
})
|
||||
|
||||
it('rate-limits foreground sequences without suppressing a retry', async () => {
|
||||
|
||||
@@ -11,11 +11,12 @@ import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-re
|
||||
import { isRpcResponse } from './rpc-response-shape'
|
||||
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionState, RpcResponse } from './types'
|
||||
import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
|
||||
|
||||
const RELAY_PROBE_TIMEOUT_MS = 4_000
|
||||
const RELAY_MISSED_PROBE_LIMIT = 2
|
||||
const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000
|
||||
let relayRpcSessionSequence = 0
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (response: RpcResponse) => void
|
||||
@@ -41,6 +42,7 @@ export function connectMobileRelayRpcSession(args: {
|
||||
desktopPublicKeyB64: string
|
||||
requestTimeoutMs?: number
|
||||
createSocket?: (url: string) => WebSocket
|
||||
onLog?: ConnectionLogSink
|
||||
}): MobileRelayRpcSession {
|
||||
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
|
||||
const pending = new Map<string, PendingRequest>()
|
||||
@@ -53,6 +55,8 @@ export function connectMobileRelayRpcSession(args: {
|
||||
let resumeConfirmation: DeviceResumeConfirmed | null = null
|
||||
let failure: Error | null = null
|
||||
let closed = false
|
||||
let logSequence = 0
|
||||
const logSessionId = `${Date.now().toString(36)}-${(++relayRpcSessionSequence).toString(36)}`
|
||||
const livenessIdentity = {}
|
||||
const streams = new MobileRelayRpcStreams({
|
||||
nextId,
|
||||
@@ -145,6 +149,17 @@ export function connectMobileRelayRpcSession(args: {
|
||||
voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS,
|
||||
sendProbe: () =>
|
||||
state === 'connected' && sendFrame({ id: nextId(), method: 'status.get', params: undefined }),
|
||||
onTimeout: (evidence) => {
|
||||
args.onLog?.({
|
||||
id: `relay-liveness-${logSessionId}-${++logSequence}`,
|
||||
ts: Date.now(),
|
||||
level: 'error',
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay',
|
||||
message: 'Relay health check failed',
|
||||
detail: `${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`
|
||||
})
|
||||
},
|
||||
terminate: () => fail(new Error('relay session liveness timeout'))
|
||||
})
|
||||
return client
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('persisted connection log store', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('keeps a new client-session boundary when a restart shares the prior timestamp', async () => {
|
||||
const stored: ConnectionLogEntry[] = [
|
||||
{
|
||||
id: 'client-session-1000',
|
||||
ts: 1_000,
|
||||
level: 'info',
|
||||
code: 'client-session-started',
|
||||
message: 'Mobile client session started'
|
||||
},
|
||||
{
|
||||
id: 'relay-failure',
|
||||
ts: 1_000,
|
||||
level: 'error',
|
||||
code: 'relay-session-failed',
|
||||
message: 'Relay: active relay session failed'
|
||||
}
|
||||
]
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
|
||||
vi.resetModules()
|
||||
const { connectionLogStore, recordConnectionClientSessionStart } =
|
||||
await import('./persisted-connection-log-store')
|
||||
|
||||
recordConnectionClientSessionStart('host-a')
|
||||
await connectionLogStore.hydrate('host-a')
|
||||
|
||||
expect(connectionLogStore.get('host-a').map((entry) => entry.code)).toEqual([
|
||||
'client-session-started',
|
||||
'relay-session-failed',
|
||||
'client-session-started'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { createConnectionLogStore } from './connection-log-buffer'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
const STORAGE_PREFIX = 'orca.mobile.connection-log.v1.'
|
||||
const clientSessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
||||
const sessionStartedHosts = new Set<string>()
|
||||
|
||||
export const connectionLogStore = createConnectionLogStore(200, {
|
||||
async load(hostId) {
|
||||
const raw = await AsyncStorage.getItem(storageKey(hostId))
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter(isConnectionLogEntry) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
save(hostId, entries) {
|
||||
return AsyncStorage.setItem(storageKey(hostId), JSON.stringify(entries))
|
||||
}
|
||||
})
|
||||
|
||||
export function recordConnectionRevival(
|
||||
hostId: string,
|
||||
reason: 'app-resume' | 'network-change'
|
||||
): void {
|
||||
const now = Date.now()
|
||||
connectionLogStore.append(hostId, {
|
||||
id: `revival-${reason}-${now}`,
|
||||
ts: now,
|
||||
level: 'info',
|
||||
code: reason === 'app-resume' ? 'app-resumed' : 'network-changed',
|
||||
message: reason === 'app-resume' ? 'App returned to foreground' : 'Network changed',
|
||||
detail: 'Connection recovery notified'
|
||||
})
|
||||
}
|
||||
|
||||
export function recordConnectionClientSessionStart(hostId: string): void {
|
||||
if (sessionStartedHosts.has(hostId)) {
|
||||
return
|
||||
}
|
||||
sessionStartedHosts.add(hostId)
|
||||
const now = Date.now()
|
||||
connectionLogStore.append(hostId, {
|
||||
id: `client-session-${clientSessionId}-${now}`,
|
||||
ts: now,
|
||||
level: 'info',
|
||||
code: 'client-session-started',
|
||||
message: 'Mobile client session started'
|
||||
})
|
||||
}
|
||||
|
||||
function storageKey(hostId: string): string {
|
||||
return `${STORAGE_PREFIX}${encodeURIComponent(hostId)}`
|
||||
}
|
||||
|
||||
function isConnectionLogEntry(value: unknown): value is ConnectionLogEntry {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const entry = value as Partial<ConnectionLogEntry>
|
||||
return (
|
||||
typeof entry.id === 'string' &&
|
||||
typeof entry.ts === 'number' &&
|
||||
Number.isFinite(entry.ts) &&
|
||||
(entry.level === 'info' ||
|
||||
entry.level === 'success' ||
|
||||
entry.level === 'warn' ||
|
||||
entry.level === 'error') &&
|
||||
typeof entry.message === 'string' &&
|
||||
(entry.detail === undefined || typeof entry.detail === 'string')
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { RpcClientSocketFactory } from './rpc-client-socket-factory'
|
||||
import type { RpcClientSocketSession } from './rpc-client-socket-session'
|
||||
import type { RpcClientStreamRegistry } from './rpc-client-stream-registry'
|
||||
import { RpcSynthesizedCloseIndex } from './rpc-socket-close-evidence'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
const UNAUTHORIZED_CLOSE_CODE = 4001
|
||||
|
||||
@@ -21,7 +22,11 @@ type SocketCloseControllerOptions = {
|
||||
getAuthenticationGeneration: () => number
|
||||
isIntentionallyClosed: () => boolean
|
||||
stopLiveness: (session: RpcClientSocketSession) => void
|
||||
emitWarning: (message: string, detail: string) => void
|
||||
emitWarning: (
|
||||
message: string,
|
||||
detail: string,
|
||||
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
|
||||
) => void
|
||||
}
|
||||
|
||||
export class RpcClientSocketCloseController {
|
||||
@@ -80,7 +85,11 @@ export class RpcClientSocketCloseController {
|
||||
streamCount: this.options.streams.size(),
|
||||
attempt: this.options.reconnect.getAttempt()
|
||||
})
|
||||
this.options.emitWarning('WebSocket closed', 'Will attempt to reconnect')
|
||||
this.options.emitWarning(
|
||||
'WebSocket closed',
|
||||
`${closeCode == null ? 'Close code unavailable' : `Close code ${closeCode}`}; reconnect scheduled`,
|
||||
{ code: 'socket-closed' }
|
||||
)
|
||||
this.options.requests.rejectAll('Connection interrupted', { deliveryUnknown: true })
|
||||
this.options.connectionState.publish('reconnecting')
|
||||
this.options.reconnect.schedule()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { publicKeyFromBase64 } from './e2ee'
|
||||
import { RpcClientSocketSession } from './rpc-client-socket-session'
|
||||
import { redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
|
||||
import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
|
||||
|
||||
type SocketFactoryOptions = {
|
||||
endpoint: string
|
||||
@@ -12,7 +12,7 @@ type SocketFactoryOptions = {
|
||||
getReconnectAttempt: () => number
|
||||
getLastConnectedAt: () => number | null
|
||||
isIntentionallyClosed: () => boolean
|
||||
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
|
||||
emitLog: ConnectionLogEmitter
|
||||
onHandshakeStarted: () => void
|
||||
onAuthenticated: (session: RpcClientSocketSession) => void
|
||||
onAuthRejected: (reason: string) => void
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { isRpcResponse } from './rpc-response-shape'
|
||||
import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence'
|
||||
import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
|
||||
import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
|
||||
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 12_000
|
||||
@@ -24,7 +24,7 @@ type SocketSessionOptions = {
|
||||
getState: () => ConnectionState
|
||||
getReconnectAttempt: () => number
|
||||
isIntentionallyClosed: () => boolean
|
||||
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
|
||||
emitLog: ConnectionLogEmitter
|
||||
onHandshakeStarted: () => void
|
||||
onAuthenticated: (session: RpcClientSocketSession) => void
|
||||
onAuthRejected: (reason: string) => void
|
||||
@@ -261,7 +261,8 @@ export class RpcClientSocketSession {
|
||||
this.options.emitLog(
|
||||
'error',
|
||||
'WebSocket connect timeout',
|
||||
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`
|
||||
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`,
|
||||
{ code: 'connect-timeout' }
|
||||
)
|
||||
this.options.onForcedClose(this)
|
||||
}
|
||||
@@ -280,7 +281,8 @@ export class RpcClientSocketSession {
|
||||
this.options.emitLog(
|
||||
'error',
|
||||
'Handshake timeout',
|
||||
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`
|
||||
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`,
|
||||
{ code: 'handshake-timeout' }
|
||||
)
|
||||
this.options.onForcedClose(this)
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
|
||||
@@ -46,6 +46,37 @@ describe('RpcSessionLivenessWatchdog', () => {
|
||||
expect(terminate).toHaveBeenCalledWith(identity)
|
||||
})
|
||||
|
||||
it('reports causal evidence before terminating a stale session', async () => {
|
||||
const onTimeout = vi.fn()
|
||||
const terminate = vi.fn()
|
||||
const identity = {}
|
||||
const watchdog = new RpcSessionLivenessWatchdog({
|
||||
transport: 'relay',
|
||||
idleProbeMs: null,
|
||||
probeTimeoutMs: 4_000,
|
||||
missedProbeLimit: 2,
|
||||
sendProbe: () => true,
|
||||
terminate,
|
||||
onTimeout,
|
||||
now: Date.now
|
||||
})
|
||||
watchdog.start(identity)
|
||||
watchdog.probeNow(identity)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
|
||||
expect(onTimeout).toHaveBeenCalledWith({
|
||||
transport: 'relay',
|
||||
reason: 'probe-timeout',
|
||||
missedProbes: 2,
|
||||
missedProbeLimit: 2,
|
||||
lastInboundAgeMs: 8_000
|
||||
})
|
||||
expect(onTimeout.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
terminate.mock.invocationCallOrder[0]!
|
||||
)
|
||||
})
|
||||
|
||||
it('authenticated activity resets suspicion', async () => {
|
||||
const { identity, terminate, watchdog } = fixture()
|
||||
watchdog.probeNow(identity)
|
||||
|
||||
@@ -8,6 +8,7 @@ type WatchdogOptions = {
|
||||
transport: 'direct' | 'relay'
|
||||
sendProbe: (identity: RpcSessionIdentity) => boolean
|
||||
terminate: (identity: RpcSessionIdentity) => void
|
||||
onTimeout?: (evidence: LivenessTimeoutEvidence) => void
|
||||
idleProbeMs?: number | null
|
||||
probeTimeoutMs?: number
|
||||
missedProbeLimit?: number
|
||||
@@ -17,6 +18,14 @@ type WatchdogOptions = {
|
||||
clearTimer?: typeof clearTimeout
|
||||
}
|
||||
|
||||
export type LivenessTimeoutEvidence = {
|
||||
transport: 'direct' | 'relay'
|
||||
reason: 'probe-send-failed' | 'probe-timeout'
|
||||
missedProbes: number
|
||||
missedProbeLimit: number
|
||||
lastInboundAgeMs: number
|
||||
}
|
||||
|
||||
export class RpcSessionLivenessWatchdog {
|
||||
private identity: RpcSessionIdentity | null = null
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -138,7 +147,7 @@ export class RpcSessionLivenessWatchdog {
|
||||
sent = false
|
||||
}
|
||||
if (!sent) {
|
||||
this.terminateCurrent(identity)
|
||||
this.terminateCurrent(identity, 'probe-send-failed')
|
||||
return
|
||||
}
|
||||
this.timer = this.setTimer(() => this.handleProbeTimeout(identity, sentAt), this.probeTimeoutMs)
|
||||
@@ -161,7 +170,7 @@ export class RpcSessionLivenessWatchdog {
|
||||
}
|
||||
this.missedProbes += 1
|
||||
if (this.missedProbes >= this.missedProbeLimit) {
|
||||
this.terminateCurrent(identity)
|
||||
this.terminateCurrent(identity, 'probe-timeout')
|
||||
return
|
||||
}
|
||||
console.log('[net] activity-probe timeout tolerated', {
|
||||
@@ -172,7 +181,10 @@ export class RpcSessionLivenessWatchdog {
|
||||
this.startProbe(identity)
|
||||
}
|
||||
|
||||
private terminateCurrent(identity: RpcSessionIdentity): void {
|
||||
private terminateCurrent(
|
||||
identity: RpcSessionIdentity,
|
||||
reason: LivenessTimeoutEvidence['reason']
|
||||
): void {
|
||||
if (this.identity !== identity) {
|
||||
return
|
||||
}
|
||||
@@ -184,6 +196,13 @@ export class RpcSessionLivenessWatchdog {
|
||||
missedProbes: this.missedProbes,
|
||||
missedProbeLimit: this.missedProbeLimit
|
||||
})
|
||||
this.options.onTimeout?.({
|
||||
transport: this.options.transport,
|
||||
reason,
|
||||
missedProbes: this.missedProbes,
|
||||
missedProbeLimit: this.missedProbeLimit,
|
||||
lastInboundAgeMs: Math.max(0, this.now() - this.lastInboundAt)
|
||||
})
|
||||
this.options.terminate(identity)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,25 @@ export type RpcResponse = RpcSuccess | RpcFailure
|
||||
|
||||
export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error'
|
||||
|
||||
export type MobileConnectionDiagnosticPath = 'lan' | 'tailscale' | 'relay'
|
||||
|
||||
export type ConnectionDiagnosticCode =
|
||||
| 'client-session-started'
|
||||
| 'app-resumed'
|
||||
| 'network-changed'
|
||||
| 'connect-timeout'
|
||||
| 'handshake-timeout'
|
||||
| 'authentication-rejected'
|
||||
| 'socket-closed'
|
||||
| 'liveness-timeout'
|
||||
| 'retry-scheduled'
|
||||
| 'relay-dial-failed'
|
||||
| 'relay-session-failed'
|
||||
| 'relay-connected'
|
||||
| 'direct-connected'
|
||||
| 'relay-credential-unavailable'
|
||||
| 'host-open-failed'
|
||||
|
||||
export type ConnectionLogEntry = {
|
||||
id: string
|
||||
ts: number
|
||||
@@ -47,10 +66,19 @@ export type ConnectionLogEntry = {
|
||||
message: string
|
||||
// Optional second line for endpoint/error/elapsed detail.
|
||||
detail?: string
|
||||
code?: ConnectionDiagnosticCode
|
||||
path?: MobileConnectionDiagnosticPath
|
||||
}
|
||||
|
||||
export type ConnectionLogSink = (entry: ConnectionLogEntry) => void
|
||||
|
||||
export type ConnectionLogEmitter = (
|
||||
level: ConnectionLogLevel,
|
||||
message: string,
|
||||
detail?: string,
|
||||
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
|
||||
) => void
|
||||
|
||||
export type ConnectionState =
|
||||
| 'connecting'
|
||||
| 'handshaking'
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { RpcClient } from './rpc-client'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState } from './types'
|
||||
import { useRpcClientContext } from './client-context'
|
||||
import type { HostClientAcquisition } from './client-context'
|
||||
import type { HostClientAcquisition } from './host-client-acquisition-registry'
|
||||
|
||||
type UseAllHostClientsOptions = {
|
||||
autoConnectHostIds?: readonly string[]
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
|
||||
// Locally-typed subset of the desktop status payload read from status.get.
|
||||
export type DesktopStatus = {
|
||||
appVersion?: string
|
||||
protocolVersion?: number
|
||||
minCompatibleMobileVersion?: number
|
||||
// Why: absent on hosts that predate the mobile Floating Workspace entry;
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
|
||||
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
|
||||
"test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs",
|
||||
"test:e2e:ssh-docker": "node config/scripts/run-ssh-docker-e2e.mjs",
|
||||
"test:e2e:ssh-client-hosted-browser": "node config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs",
|
||||
"test:e2e:local-ssh-browser": "node config/scripts/run-local-ssh-browser-routing-e2e.mjs",
|
||||
"test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs",
|
||||
|
||||
Generated
+9
-9
@@ -9,7 +9,7 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
'@vscode/windows-process-tree@0.8.0':
|
||||
hash: 73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0
|
||||
hash: 946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988
|
||||
path: config/patches/@vscode__windows-process-tree@0.8.0.patch
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287':
|
||||
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
|
||||
@@ -18,7 +18,7 @@ patchedDependencies:
|
||||
hash: af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c
|
||||
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
|
||||
'@xterm/addon-webgl@0.20.0-beta.286':
|
||||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
hash: 118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7
|
||||
@@ -27,7 +27,7 @@ patchedDependencies:
|
||||
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
|
||||
path: config/patches/lint-staged@16.4.0.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1
|
||||
hash: 9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17
|
||||
path: config/patches/node-pty@1.1.0.patch
|
||||
|
||||
importers:
|
||||
@@ -69,7 +69,7 @@ importers:
|
||||
version: 3.3.1
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1)
|
||||
version: 1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17)
|
||||
posthog-node:
|
||||
specifier: ^5.33.3
|
||||
version: 5.33.3
|
||||
@@ -229,7 +229,7 @@ importers:
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
version: 0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
@@ -407,7 +407,7 @@ importers:
|
||||
optionalDependencies:
|
||||
'@vscode/windows-process-tree':
|
||||
specifier: 0.8.0
|
||||
version: 0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)
|
||||
version: 0.8.0(patch_hash=946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988)
|
||||
sherpa-onnx-darwin-arm64:
|
||||
specifier: 1.12.37
|
||||
version: 1.12.37
|
||||
@@ -9605,7 +9605,7 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vscode/windows-process-tree@0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)':
|
||||
'@vscode/windows-process-tree@0.8.0(patch_hash=946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988)':
|
||||
dependencies:
|
||||
node-addon-api: 7.1.0
|
||||
optional: true
|
||||
@@ -9638,7 +9638,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
|
||||
@@ -12046,7 +12046,7 @@ snapshots:
|
||||
|
||||
node-int64@0.4.0: {}
|
||||
|
||||
node-pty@1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1):
|
||||
node-pty@1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17):
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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,6 +45,32 @@ 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,
|
||||
@@ -60,6 +86,7 @@ describe('browserManager', () => {
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -92,6 +119,77 @@ 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),
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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,34 +882,5 @@ 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,6 +63,11 @@ 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,
|
||||
@@ -157,6 +162,15 @@ 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[]
|
||||
@@ -671,12 +685,20 @@ export class BrowserManager {
|
||||
|
||||
attachGuestPolicies(
|
||||
guest: Electron.WebContents,
|
||||
inheritedOwnerContext: PopupOwnerContext | null = null
|
||||
inheritedOwnerContext: PopupOwnerContext | null = null,
|
||||
policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY
|
||||
): 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)
|
||||
}
|
||||
@@ -1016,6 +1038,30 @@ 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.
|
||||
@@ -1318,7 +1364,9 @@ export class BrowserManager {
|
||||
rendererWebContentsId
|
||||
}: BrowserGuestRegistration): boolean {
|
||||
const browserTabId = browserPageId ?? legacyBrowserTabId
|
||||
if (!browserTabId) {
|
||||
// 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)) {
|
||||
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.
|
||||
@@ -1377,6 +1425,12 @@ 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')
|
||||
|
||||
@@ -1444,10 +1498,15 @@ export class BrowserManager {
|
||||
sessionProfileId?: string | null
|
||||
userAgentMode?: BrowserSessionUserAgentMode
|
||||
webContentsId: number
|
||||
}): void {
|
||||
}): 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
|
||||
}
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
// Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
|
||||
this.offscreenGuestIds.add(webContentsId)
|
||||
@@ -1468,6 +1527,7 @@ export class BrowserManager {
|
||||
this.worktreeIdByTabId.set(browserPageId, worktreeId)
|
||||
}
|
||||
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
|
||||
return true
|
||||
}
|
||||
|
||||
unregisterAll(): void {
|
||||
@@ -1843,12 +1903,13 @@ export class BrowserManager {
|
||||
|
||||
async setAnnotationViewportBridge(
|
||||
browserTabId: string,
|
||||
options: BrowserAnnotationViewportBridgeOptions
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
): Promise<boolean> {
|
||||
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.catch(() => {})
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
|
||||
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
|
||||
try {
|
||||
return await next
|
||||
@@ -1859,18 +1920,22 @@ 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(
|
||||
browserTabId: string,
|
||||
options: BrowserAnnotationViewportBridgeOptions
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
): Promise<boolean> {
|
||||
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (!webContentsId) {
|
||||
return false
|
||||
}
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
// 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()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1977,10 +2042,21 @@ 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
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
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(),
|
||||
clearBrowserWebAuthnAccessHandlers: vi.fn(),
|
||||
installBrowserWebAuthnAccessHandlers: 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: mocks.clearBrowserWebAuthnAccessHandlers,
|
||||
installBrowserWebAuthnAccessHandlers: mocks.installBrowserWebAuthnAccessHandlers
|
||||
}))
|
||||
|
||||
type PartitionPolicyInstaller = (
|
||||
profile: BrowserSessionProfile,
|
||||
options?: { downloads?: 'route' | 'deny'; permissions?: 'browser' | '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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('partition permission policy', () => {
|
||||
it('keeps ordinary browser partitions on the browser permission policy', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
expect(mocks.installBrowserWebAuthnAccessHandlers).toHaveBeenCalledWith(
|
||||
sessionsByPartition.get('persist:browsing-1')
|
||||
)
|
||||
expect(mocks.clearBrowserWebAuthnAccessHandlers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies every request and check on a strict partition without WebAuthn handlers', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { permissions: 'deny' })
|
||||
const sess = sessionsByPartition.get('orca-doc-preview')
|
||||
if (!sess) {
|
||||
throw new Error('Expected the preview session')
|
||||
}
|
||||
const requestHandler = sess.setPermissionRequestHandler.mock.calls[0]?.[0] as (
|
||||
webContents: Electron.WebContents,
|
||||
permission: string,
|
||||
callback: (allowed: boolean) => void
|
||||
) => void
|
||||
const checkHandler = sess.setPermissionCheckHandler.mock.calls[0]?.[0] as (
|
||||
webContents: Electron.WebContents,
|
||||
permission: string
|
||||
) => boolean
|
||||
const displayMediaHandler = sess.setDisplayMediaRequestHandler.mock.calls[0]?.[0] as (
|
||||
request: Electron.DisplayMediaRequestHandlerHandlerRequest,
|
||||
callback: (streams: { video?: Electron.WebFrameMain; audio?: 'loopback' }) => void
|
||||
) => void
|
||||
|
||||
for (const permission of ['media', 'clipboard-read', 'notifications', 'fullscreen']) {
|
||||
let decision: boolean | null = null
|
||||
requestHandler({} as Electron.WebContents, permission, (allowed) => (decision = allowed))
|
||||
expect(decision).toBe(false)
|
||||
expect(checkHandler({} as Electron.WebContents, permission)).toBe(false)
|
||||
}
|
||||
expect(mocks.installBrowserWebAuthnAccessHandlers).not.toHaveBeenCalled()
|
||||
expect(mocks.clearBrowserWebAuthnAccessHandlers).toHaveBeenCalledWith(sess)
|
||||
let displayMediaDecision: { video?: Electron.WebFrameMain; audio?: 'loopback' } | null = null
|
||||
displayMediaHandler({} as Electron.DisplayMediaRequestHandlerHandlerRequest, (decision) => {
|
||||
displayMediaDecision = decision
|
||||
})
|
||||
expect(displayMediaDecision).toEqual({ video: undefined, audio: undefined })
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ 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>()
|
||||
@@ -22,6 +23,23 @@ 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
|
||||
@@ -37,7 +55,17 @@ function resolvePermissionNoticeUrl(
|
||||
}
|
||||
}
|
||||
|
||||
export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void {
|
||||
/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */
|
||||
export type BrowserPartitionDownloadPolicy = 'route' | 'deny'
|
||||
export type BrowserPartitionPermissionPolicy = 'browser' | 'deny'
|
||||
|
||||
export function installBrowserSessionPartitionPolicies(
|
||||
profile: BrowserSessionProfile,
|
||||
options?: {
|
||||
downloads?: BrowserPartitionDownloadPolicy
|
||||
permissions?: BrowserPartitionPermissionPolicy
|
||||
}
|
||||
): void {
|
||||
const { partition } = profile
|
||||
const sess = session.fromPartition(partition)
|
||||
setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean')
|
||||
@@ -51,62 +79,72 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr
|
||||
sess.setUserAgent(cleanUA)
|
||||
setupClientHintsOverride(sess, cleanUA)
|
||||
}
|
||||
sess.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||
// Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS.
|
||||
if (permission === 'media') {
|
||||
// Capture before async handling; opaque frames cannot be attributed to a named site.
|
||||
const rawUrl = resolvePermissionNoticeUrl(webContents, details)
|
||||
void requestSystemMediaAccess(
|
||||
details as Electron.MediaAccessPermissionRequest | undefined
|
||||
).then(
|
||||
(granted) => {
|
||||
if (!granted) {
|
||||
if (options?.permissions === 'deny') {
|
||||
sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false))
|
||||
sess.setPermissionCheckHandler(() => false)
|
||||
clearBrowserWebAuthnAccessHandlers(sess)
|
||||
} else {
|
||||
sess.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||
// Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS.
|
||||
if (permission === 'media') {
|
||||
// Capture before async handling; opaque frames cannot be attributed to a named site.
|
||||
const rawUrl = resolvePermissionNoticeUrl(webContents, details)
|
||||
void requestSystemMediaAccess(
|
||||
details as Electron.MediaAccessPermissionRequest | undefined
|
||||
).then(
|
||||
(granted) => {
|
||||
if (!granted) {
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: webContents.id,
|
||||
permission,
|
||||
rawUrl
|
||||
})
|
||||
}
|
||||
callback(granted)
|
||||
},
|
||||
(error: unknown) => {
|
||||
console.error('[permissions] Browser media access failed:', error)
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: webContents.id,
|
||||
permission,
|
||||
rawUrl
|
||||
})
|
||||
callback(false)
|
||||
}
|
||||
callback(granted)
|
||||
},
|
||||
(error: unknown) => {
|
||||
console.error('[permissions] Browser media access failed:', error)
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: webContents.id,
|
||||
permission,
|
||||
rawUrl
|
||||
})
|
||||
callback(false)
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
const allowed = isAutoGrantedBrowserSessionPermission(permission)
|
||||
if (!allowed) {
|
||||
const rawUrl = resolvePermissionNoticeUrl(webContents, details)
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: webContents.id,
|
||||
permission,
|
||||
rawUrl
|
||||
})
|
||||
}
|
||||
callback(allowed)
|
||||
})
|
||||
sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
|
||||
if (permission === 'media') {
|
||||
return hasSystemMediaAccess(details?.mediaType)
|
||||
}
|
||||
if (allowsBrowserWebAuthnPermission(permission, details)) {
|
||||
return true
|
||||
}
|
||||
return isAutoGrantedBrowserSessionPermission(permission)
|
||||
})
|
||||
installBrowserWebAuthnAccessHandlers(sess)
|
||||
)
|
||||
return
|
||||
}
|
||||
const allowed = isAutoGrantedBrowserSessionPermission(permission)
|
||||
if (!allowed) {
|
||||
const rawUrl = resolvePermissionNoticeUrl(webContents, details)
|
||||
browserManager.notifyPermissionDenied({
|
||||
guestWebContentsId: webContents.id,
|
||||
permission,
|
||||
rawUrl
|
||||
})
|
||||
}
|
||||
callback(allowed)
|
||||
})
|
||||
sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
|
||||
if (permission === 'media') {
|
||||
return hasSystemMediaAccess(details?.mediaType)
|
||||
}
|
||||
if (allowsBrowserWebAuthnPermission(permission, details)) {
|
||||
return true
|
||||
}
|
||||
return isAutoGrantedBrowserSessionPermission(permission)
|
||||
})
|
||||
installBrowserWebAuthnAccessHandlers(sess)
|
||||
}
|
||||
sess.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
callback({ video: undefined, audio: undefined })
|
||||
})
|
||||
sess.removeListener('will-download', handleWillDownload)
|
||||
sess.on('will-download', handleWillDownload)
|
||||
sess.removeListener('will-download', handleDeniedWillDownload)
|
||||
sess.on(
|
||||
'will-download',
|
||||
options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload
|
||||
)
|
||||
configuredPartitions.add(partition)
|
||||
}
|
||||
|
||||
@@ -115,6 +153,7 @@ 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)
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { WebContents } from 'electron'
|
||||
import type {
|
||||
BrowserCaptureStartResult,
|
||||
BrowserCaptureStopResult,
|
||||
BrowserConsoleEntry,
|
||||
BrowserConsoleResult,
|
||||
BrowserCookie,
|
||||
BrowserCookieDeleteResult,
|
||||
BrowserCookieGetResult,
|
||||
BrowserCookieSetResult,
|
||||
BrowserGeolocationResult,
|
||||
BrowserInterceptDisableResult,
|
||||
BrowserInterceptEnableResult,
|
||||
BrowserInterceptedRequest,
|
||||
BrowserNetworkEntry,
|
||||
BrowserNetworkLogResult,
|
||||
BrowserViewportResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CdpCommandSender, SnapshotResult } from './snapshot-engine'
|
||||
|
||||
export type CdpTabState = {
|
||||
navigationId: string | null
|
||||
snapshotResult: SnapshotResult | null
|
||||
debuggerAttached: boolean
|
||||
debuggerDetachListener: (() => void) | null
|
||||
debuggerMessageListener: ((_event: unknown, method: string, params: unknown) => void) | null
|
||||
iframeSessions: Map<string, string>
|
||||
capturing: boolean
|
||||
consoleLog: BrowserConsoleEntry[]
|
||||
networkLog: BrowserNetworkEntry[]
|
||||
intercepting: boolean
|
||||
interceptPatterns: string[]
|
||||
pausedRequests: Map<string, BrowserInterceptedRequest>
|
||||
networkRequestMap: Map<string, BrowserNetworkEntry>
|
||||
}
|
||||
|
||||
type ActiveCdpContext = {
|
||||
guest: WebContents
|
||||
sender: CdpCommandSender
|
||||
state: CdpTabState
|
||||
}
|
||||
|
||||
export type CdpAuxiliaryCommandHost = {
|
||||
run: <T>(operation: (context: ActiveCdpContext) => Promise<T>) => Promise<T>
|
||||
// Why: captureStop must not attach the debugger — stopping a capture on a tab whose
|
||||
// debugger detached (DevTools opened) would otherwise throw before clearing state.
|
||||
runOnState: <T>(
|
||||
operation: (context: Pick<ActiveCdpContext, 'guest' | 'state'>) => Promise<T>
|
||||
) => Promise<T>
|
||||
current: () => Pick<ActiveCdpContext, 'guest' | 'state'>
|
||||
}
|
||||
|
||||
export class CdpAuxiliaryCommands {
|
||||
constructor(private readonly host: CdpAuxiliaryCommandHost) {}
|
||||
|
||||
cookieGet(url?: string): Promise<BrowserCookieGetResult> {
|
||||
return this.host.run(async ({ sender }) => {
|
||||
const params: Record<string, unknown> = {}
|
||||
if (url) {
|
||||
params.urls = [url]
|
||||
}
|
||||
const { cookies } = (await sender('Network.getCookies', params)) as {
|
||||
cookies: BrowserCookie[]
|
||||
}
|
||||
return { cookies }
|
||||
})
|
||||
}
|
||||
|
||||
cookieSet(cookie: {
|
||||
name: string
|
||||
value: string
|
||||
domain?: string
|
||||
path?: string
|
||||
secure?: boolean
|
||||
httpOnly?: boolean
|
||||
sameSite?: string
|
||||
expires?: number
|
||||
}): Promise<BrowserCookieSetResult> {
|
||||
return this.host.run(async ({ sender }) => {
|
||||
let domain = cookie.domain
|
||||
if (!domain) {
|
||||
const { result } = (await sender('Runtime.evaluate', {
|
||||
expression: 'location.hostname',
|
||||
returnByValue: true
|
||||
})) as { result: { value: string } }
|
||||
domain = result.value
|
||||
}
|
||||
const params: Record<string, unknown> = {
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain,
|
||||
path: cookie.path ?? '/',
|
||||
secure: cookie.secure ?? false,
|
||||
httpOnly: cookie.httpOnly ?? false,
|
||||
sameSite: cookie.sameSite ?? 'Lax'
|
||||
}
|
||||
if (cookie.expires !== undefined) {
|
||||
params.expires = cookie.expires
|
||||
}
|
||||
const { success } = (await sender('Network.setCookie', params)) as { success: boolean }
|
||||
return { success }
|
||||
})
|
||||
}
|
||||
|
||||
cookieDelete(name: string, domain?: string, url?: string): Promise<BrowserCookieDeleteResult> {
|
||||
return this.host.run(async ({ sender }) => {
|
||||
const params: Record<string, unknown> = { name }
|
||||
if (domain) {
|
||||
params.domain = domain
|
||||
}
|
||||
if (url) {
|
||||
params.url = url
|
||||
}
|
||||
if (!domain && !url) {
|
||||
const { result } = (await sender('Runtime.evaluate', {
|
||||
expression: 'location.href',
|
||||
returnByValue: true
|
||||
})) as { result: { value: string } }
|
||||
params.url = result.value
|
||||
}
|
||||
await sender('Network.deleteCookies', params)
|
||||
return { deleted: true }
|
||||
})
|
||||
}
|
||||
|
||||
setViewport(
|
||||
width: number,
|
||||
height: number,
|
||||
deviceScaleFactor = 1,
|
||||
mobile = false
|
||||
): Promise<BrowserViewportResult> {
|
||||
return this.host.run(async ({ sender }) => {
|
||||
await sender('Emulation.setDeviceMetricsOverride', {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor,
|
||||
mobile
|
||||
})
|
||||
await Promise.resolve(sender('Emulation.setVisibleSize', { width, height })).catch(() => {})
|
||||
return { width, height, deviceScaleFactor, mobile }
|
||||
})
|
||||
}
|
||||
|
||||
setGeolocation(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
accuracy = 1
|
||||
): Promise<BrowserGeolocationResult> {
|
||||
return this.host.run(async ({ sender }) => {
|
||||
await sender('Emulation.setGeolocationOverride', { latitude, longitude, accuracy })
|
||||
return { latitude, longitude, accuracy }
|
||||
})
|
||||
}
|
||||
|
||||
interceptEnable(patterns: string[] = ['*']): Promise<BrowserInterceptEnableResult> {
|
||||
return this.host.run(async ({ sender, state }) => {
|
||||
await sender('Fetch.enable', { patterns: patterns.map((urlPattern) => ({ urlPattern })) })
|
||||
state.intercepting = true
|
||||
state.interceptPatterns = patterns
|
||||
return { enabled: true, patterns }
|
||||
})
|
||||
}
|
||||
|
||||
interceptDisable(): Promise<BrowserInterceptDisableResult> {
|
||||
return this.host.run(async ({ sender, state }) => {
|
||||
await sender('Fetch.disable')
|
||||
state.intercepting = false
|
||||
state.interceptPatterns = []
|
||||
state.pausedRequests.clear()
|
||||
return { disabled: true }
|
||||
})
|
||||
}
|
||||
|
||||
interceptList(): { requests: BrowserInterceptedRequest[] } {
|
||||
const { state } = this.host.current()
|
||||
return { requests: [...state.pausedRequests.values()] }
|
||||
}
|
||||
|
||||
captureStart(): Promise<BrowserCaptureStartResult> {
|
||||
return this.host.run(async ({ sender, state }) => {
|
||||
await sender('Runtime.enable')
|
||||
state.capturing = true
|
||||
state.consoleLog = []
|
||||
state.networkLog = []
|
||||
state.networkRequestMap.clear()
|
||||
return { capturing: true }
|
||||
})
|
||||
}
|
||||
|
||||
captureStop(): Promise<BrowserCaptureStopResult> {
|
||||
return this.host.runOnState(async ({ state }) => {
|
||||
state.capturing = false
|
||||
state.networkRequestMap.clear()
|
||||
return { stopped: true }
|
||||
})
|
||||
}
|
||||
|
||||
consoleLog(limit = 100): BrowserConsoleResult {
|
||||
const { state } = this.host.current()
|
||||
return {
|
||||
entries: state.consoleLog.slice(-limit),
|
||||
truncated: state.consoleLog.length > limit
|
||||
}
|
||||
}
|
||||
|
||||
networkLog(limit = 100): BrowserNetworkLogResult {
|
||||
const { state } = this.host.current()
|
||||
return {
|
||||
entries: state.networkLog.slice(-limit),
|
||||
truncated: state.networkLog.length > limit
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user