Merge branch 'main' into brennanb2025/sta-5329-plan

This commit is contained in:
Brennan Benson
2026-08-28 00:20:56 -07:00
committed by GitHub
457 changed files with 33831 additions and 4687 deletions
@@ -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'
+22 -3
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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()
+59
View File
@@ -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
+9 -1
View File
@@ -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': {
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}`)
+5 -1
View File
@@ -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'
)
+22 -1
View File
@@ -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)
}
+32 -3
View File
@@ -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', () => {
+157 -2
View File
@@ -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')
+39 -3
View File
@@ -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')
+95
View File
@@ -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')
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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'
}
})
+43 -30
View File
@@ -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={() => {
+1 -1
View File
@@ -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 && (
+23 -372
View File
@@ -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()
})
})
+40 -20
View File
@@ -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', () => {
+1 -1
View File
@@ -55,7 +55,7 @@ export function formatEndpoint(endpoint: string): string {
const url = new URL(endpoint)
return url.host
} catch {
return endpoint
return 'invalid endpoint'
}
}
+2
View File
@@ -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()
})
+12 -3
View File
@@ -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): {
+3 -3
View File
@@ -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)
})
})
+98 -14
View File
@@ -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' }
)
}
}
+16 -23
View File
@@ -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}`
}
+6 -1
View File
@@ -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()
}
+11
View File
@@ -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)
}
+28
View File
@@ -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'
+1 -1
View File
@@ -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;
+1
View File
@@ -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",
+9 -9
View File
@@ -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
)
})
})
})
+91 -15
View File
@@ -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)
+213
View File
@@ -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