Merge branch 'main' of https://github.com/stablyai/orca into battery-git-status-backoff

This commit is contained in:
Jinjing
2026-07-15 18:22:44 -07:00
806 changed files with 72311 additions and 9391 deletions
+3
View File
@@ -6,3 +6,6 @@
/config/scripts/run-internal-dev-setup.mjs text eol=lf
/config/scripts/verify-cli-bin.mjs text eol=lf
/config/scripts/verify-release-required-assets.mjs text eol=lf
/skill-guides/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
/src/cli/bundled-skill-guides.ts text eol=lf
+5 -1
View File
@@ -75,8 +75,12 @@ jobs:
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
# is no root config) and falls back to the 5s default timeout with no
# Windows worker cap, so the real csc.exe launcher-compile tests time out
# on hosted Windows. Use the shared config so this job matches pnpm test.
- run: >-
pnpm vitest run
pnpm vitest run --config config/vitest.config.ts
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
@@ -9,6 +9,8 @@ on:
- 'tests/e2e/fixtures/terminal-emoji-table.md'
- 'tests/e2e/helpers/**'
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
- 'src/renderer/src/lib/pane-manager/**'
workflow_dispatch:
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Typecheck
run: npx tsc --noEmit
run: pnpm typecheck
- name: Test
run: pnpm test
+5
View File
@@ -77,6 +77,11 @@ jobs:
- name: Enforce max-lines ratchet (no new bypasses)
run: pnpm run check:max-lines-ratchet
# Why: the CLI embeds guide content while the skills CLI installs generated
# projections from the repository, so stale output would split those two truths.
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
+4 -4
View File
@@ -100,9 +100,9 @@ docs/reference/react-performance-audit.md
validation-screenshots/
.stably-browser
# PR verification evidence screenshots are referenced from notes but should not
# be committed.
notes/artifacts/
# Local scratch notes and PR evidence screenshots (not part of the product).
/notes/
/pr-evidence/
# Playwright
test-results/
@@ -123,5 +123,5 @@ src/renderer/src/i18n/locales/.ko-catalog-cache.json
src/renderer/src/i18n/locales/.ja-catalog-cache.json
src/renderer/src/i18n/locales/.es-catalog-cache.json
# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md
# Bench result JSONs are working artifacts
tools/benchmarks/results/terminal-pipeline-*.json
+5 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [Download APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
@@ -261,6 +261,9 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
<img src="docs/assets/star-history.png" alt="GitHub star history chart for stablyai/orca" width="880" />
</p>
## Signed Builds
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
## License
Orca is free and open source under the [MIT License](LICENSE).
+3
View File
@@ -62,6 +62,9 @@ module.exports = {
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
// Why: authoritative guide markdown is compiled into out/cli; shipping the
// authoring sources too would duplicate content without a runtime consumer.
'!skill-guides{,/**/*}',
'!tests{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -4,9 +4,14 @@ import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
const itWindows = process.platform === 'win32' ? it : it.skip
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
// keep the larger allowance scoped to the real compiler integration test.
function itWindows(name, test) {
const runner = process.platform === 'win32' ? it : it.skip
runner(name, { timeout: 15_000 }, test)
}
describe('Windows CLI launcher', () => {
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
@@ -28,6 +28,7 @@ describe('electron-builder config', () => {
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
'!skill-guides{,/**/*}',
'!tests{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
@@ -0,0 +1,205 @@
import { constants } from 'node:fs'
import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { parse } from 'yaml'
const SCRIPT_DIR = import.meta.dirname
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
const CANONICAL_GUIDE_NAMES = [
'computer-use',
'linear-tickets',
'orca-cli',
'orca-emulator',
'orca-emulator-android',
'orca-linear',
'orca-per-workspace-env',
'orchestration'
]
// Why: old discovery stubs can outlive a rename indefinitely, so aliases are
// a compatibility ledger: add entries for renames, but never remove them.
const GUIDE_ALIASES = {
'computer-use': [],
'linear-tickets': [],
'orca-cli': [],
'orca-emulator': [],
'orca-emulator-android': [],
'orca-linear': [],
'orca-per-workspace-env': [],
orchestration: []
}
function normalizeMarkdown(markdown) {
return markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
function parseFrontmatter(markdown, sourcePath) {
const normalized = normalizeMarkdown(markdown)
const match = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/.exec(normalized)
if (!match) {
throw new Error(`Guide source has no YAML frontmatter: ${sourcePath}`)
}
let values
try {
values = parse(match[1])
} catch (error) {
throw new Error(
`Guide source has invalid YAML frontmatter: ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`
)
}
if (
!values ||
typeof values !== 'object' ||
typeof values.name !== 'string' ||
typeof values.description !== 'string'
) {
throw new Error(`Guide source must declare name and description: ${sourcePath}`)
}
return {
name: values.name,
description: values.description.replace(/\s+/g, ' ').trim()
}
}
function constantName(name) {
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
}
function serializeEmbeddedModule(guides) {
const markdownConstants = guides
.map(
(guide) =>
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
)
.join('\n\n')
const guideEntries = guides
.map((guide) => {
const markdownConstant = constantName(guide.name)
return [
' {',
` name: ${JSON.stringify(guide.name)},`,
` description: ${JSON.stringify(guide.description)},`,
` markdown: ${markdownConstant},`,
` fullMarkdown: ${markdownConstant},`,
` aliases: ${JSON.stringify(guide.aliases)}`,
' }'
].join('\n')
})
.join(',\n')
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n}\n\n${markdownConstants}\n\n// Why: no current guide has bundled reference documents, so --full is byte-identical for now.\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
}
function assertAliasContract(guides) {
const canonicalNames = new Set(guides.map((guide) => guide.name))
const seenAliases = new Set()
for (const guide of guides) {
for (const alias of guide.aliases) {
if (canonicalNames.has(alias)) {
throw new Error(`Guide alias collides with canonical name: ${alias}`)
}
if (seenAliases.has(alias)) {
throw new Error(`Guide alias is assigned more than once: ${alias}`)
}
seenAliases.add(alias)
}
}
}
async function buildArtifacts(repoRoot = REPO_ROOT) {
const guideRoot = path.join(repoRoot, 'skill-guides')
const sourceFiles = (await readdir(guideRoot, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => entry.name.slice(0, -3))
.sort((left, right) => left.localeCompare(right, 'en'))
const expectedNames = [...CANONICAL_GUIDE_NAMES].sort((left, right) =>
left.localeCompare(right, 'en')
)
if (JSON.stringify(sourceFiles) !== JSON.stringify(expectedNames)) {
throw new Error(
`Guide sources must match the canonical topic list.\nExpected: ${expectedNames.join(', ')}\nFound: ${sourceFiles.join(', ')}`
)
}
const guides = []
const projections = []
for (const name of expectedNames) {
const sourcePath = path.join(guideRoot, `${name}.md`)
// Why: Git may render text with native EOLs despite repository policy; the
// embedded guide and generated projection must have one platform-neutral identity.
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
const frontmatter = parseFrontmatter(markdown, path.relative(repoRoot, sourcePath))
if (frontmatter.name !== name) {
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
}
const aliases = GUIDE_ALIASES[name]
guides.push({ name, description: frontmatter.description, markdown, aliases })
projections.push({
path: path.join(repoRoot, 'skills', name, 'SKILL.md'),
content: markdown
})
}
assertAliasContract(guides)
return [
{
path: path.join(repoRoot, 'src', 'cli', 'bundled-skill-guides.ts'),
content: serializeEmbeddedModule(guides)
},
...projections
]
}
async function writeArtifacts(artifacts) {
for (const artifact of artifacts) {
await mkdir(path.dirname(artifact.path), { recursive: true })
await writeFile(artifact.path, artifact.content, 'utf8')
}
}
async function verifyArtifacts(artifacts, repoRoot = REPO_ROOT) {
const stale = []
for (const artifact of artifacts) {
try {
await access(artifact.path, constants.R_OK)
if ((await readFile(artifact.path, 'utf8')) !== artifact.content) {
stale.push(artifact.path)
}
} catch {
stale.push(artifact.path)
}
}
if (stale.length > 0) {
throw new Error(
`Generated bundled skill guides are stale:\n${stale
.map((filePath) => path.relative(repoRoot, filePath))
.join('\n')}\nRun node config/scripts/generate-bundled-skill-guides.mjs --write.`
)
}
}
async function main() {
const artifacts = await buildArtifacts()
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}
export {
CANONICAL_GUIDE_NAMES,
GUIDE_ALIASES,
assertAliasContract,
buildArtifacts,
normalizeMarkdown,
parseFrontmatter,
serializeEmbeddedModule,
verifyArtifacts,
writeArtifacts
}
@@ -0,0 +1,150 @@
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
import {
CANONICAL_GUIDE_NAMES,
GUIDE_ALIASES,
assertAliasContract,
buildArtifacts,
normalizeMarkdown,
parseFrontmatter,
verifyArtifacts,
writeArtifacts
} from './generate-bundled-skill-guides.mjs'
const projectDir = path.resolve(import.meta.dirname, '..', '..')
const temporaryDirectories = []
async function createFixture() {
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
temporaryDirectories.push(root)
await Promise.all([
cp(path.join(projectDir, 'skill-guides'), path.join(root, 'skill-guides'), {
recursive: true
}),
cp(path.join(projectDir, 'skills'), path.join(root, 'skills'), { recursive: true }),
mkdir(path.join(root, 'src', 'cli'), { recursive: true })
])
return root
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('bundled skill guide generator', () => {
it('keeps every first-phase fat projection byte-identical to its authoritative source', async () => {
for (const name of CANONICAL_GUIDE_NAMES) {
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`))
const projection = await readFile(path.join(projectDir, 'skills', name, 'SKILL.md'))
expect(projection, name).toEqual(source)
}
})
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
)
for (const guide of BUNDLED_SKILL_GUIDES) {
const source = await readFile(
path.join(projectDir, 'skill-guides', `${guide.name}.md`),
'utf8'
)
const frontmatter = parseFrontmatter(source, `${guide.name}.md`)
expect(guide.description).toBe(frontmatter.description)
expect(guide.markdown).toBe(source)
expect(guide.fullMarkdown).toBe(source)
expect(guide.aliases).toEqual(GUIDE_ALIASES[guide.name])
}
})
it('keeps CLI guide examples safe across shells and Linux command names', async () => {
for (const name of ['orca-cli', 'computer-use', 'orca-emulator', 'orca-emulator-android']) {
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`), 'utf8')
expect(source).toContain('ORCA_CLI_COMMAND')
expect(source).toContain('orca-dev')
expect(source).toContain('orca-ide')
expect(source).toContain('PowerShell')
expect(source).toContain('cmd.exe')
expect(source).toMatch(/^ORCA .+--json$/mu)
// Why: bare command lines can launch GNOME Orca, while shell variables make
// the same guide unusable from PowerShell and cmd.exe.
expect(source).not.toMatch(/^orca /mu)
expect(source).not.toMatch(/\$ORCA(?:_|\b)/u)
}
})
it('builds deterministic artifacts and verifies the checked-in outputs', async () => {
const first = await buildArtifacts(projectDir)
const second = await buildArtifacts(projectDir)
expect(second).toEqual(first)
await expect(verifyArtifacts(first, projectDir)).resolves.toBeUndefined()
})
it('generates platform-identical output from CRLF guide sources', async () => {
const expected = await buildArtifacts(projectDir)
const root = await createFixture()
for (const name of CANONICAL_GUIDE_NAMES) {
const sourcePath = path.join(root, 'skill-guides', `${name}.md`)
const source = await readFile(sourcePath, 'utf8')
await writeFile(sourcePath, source.replaceAll('\n', '\r\n'))
}
const actual = await buildArtifacts(root)
expect(actual.map((artifact) => artifact.content)).toEqual(
expected.map((artifact) => artifact.content)
)
})
it('pins guide sources, projections, and embedded output to LF in Git', async () => {
const attributes = await readFile(path.join(projectDir, '.gitattributes'), 'utf8')
expect(normalizeMarkdown(attributes)).toContain('/skill-guides/*.md text eol=lf\n')
expect(normalizeMarkdown(attributes)).toContain('/skills/*/SKILL.md text eol=lf\n')
expect(normalizeMarkdown(attributes)).toContain(
'/src/cli/bundled-skill-guides.ts text eol=lf\n'
)
})
it('reports stale outputs and write mode repairs all projections', async () => {
const root = await createFixture()
const artifacts = await buildArtifacts(root)
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow(
'src/cli/bundled-skill-guides.ts'
)
await writeArtifacts(artifacts)
await expect(verifyArtifacts(artifacts, root)).resolves.toBeUndefined()
await writeFile(path.join(root, 'skills', 'computer-use', 'SKILL.md'), 'stale\n')
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow('skills/computer-use/SKILL.md')
})
it('rejects mismatched source names and ambiguous aliases', async () => {
const root = await createFixture()
await writeFile(
path.join(root, 'skill-guides', 'computer-use.md'),
'---\nname: wrong\ndescription: present\n---\n'
)
await expect(buildArtifacts(root)).rejects.toThrow('declares mismatched name wrong')
expect(() =>
assertAliasContract([
{ name: 'first', aliases: ['legacy'] },
{ name: 'second', aliases: ['legacy'] }
])
).toThrow('assigned more than once')
expect(() =>
assertAliasContract([
{ name: 'first', aliases: ['second'] },
{ name: 'second', aliases: [] }
])
).toThrow('collides with canonical name')
})
})
@@ -40,7 +40,7 @@ describe('orca CLI skill guidance', () => {
'`task-create` is also forbidden because it records coordinator-owned tracking state'
)
expect(skill).toContain(
'orca worktree create --name <task-name> --no-parent --agent codex --prompt'
'ORCA worktree create --name <task-name> --no-parent --agent codex --prompt'
)
expect(skill).toContain('codex --model gpt-5.5 -c model_reasoning_effort="xhigh"')
expect(skill).toContain('wait only for TUI readiness if needed to avoid losing input')
@@ -7,6 +7,19 @@ const projectDir = resolve(import.meta.dirname, '../..')
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'))
describe('Electron runtime package contract', () => {
it('keeps shared WebGL atlas invalidation reproducible from vendored source', () => {
const patch = readFileSync(
join(projectDir, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
'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')
})
it('keeps root postinstall as the single Electron binary install owner', () => {
expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs')
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('electron')
@@ -535,6 +548,9 @@ describe('Electron runtime package contract', () => {
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
'terminal-raw-emoji-table-scroll-restore.spec.ts'
)
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
'terminal-webgl-atlas-budget.spec.ts'
)
expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain(
'terminal-long-table-scroll-restore.spec.ts'
)
@@ -548,6 +564,8 @@ describe('Electron runtime package contract', () => {
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
}
expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts')
expect(pullRequestPaths).toContain('tests/e2e/terminal-webgl-atlas-budget.spec.ts')
expect(pullRequestPaths).toContain('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md')
expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**')
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
@@ -0,0 +1,257 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, realpath, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { build } from 'esbuild'
const ENTRY_PATH = resolve('out/main/parcel-watcher-process-entry.js')
const POOL_SOURCE = resolve('src/main/ipc/runtime-watcher-process-pool.ts')
const FAILURE_SOURCE = resolve('src/main/ipc/parcel-watcher-process-failure.ts')
const REGISTRY_SOURCE = resolve('src/main/ipc/parcel-watcher-child-registry.ts')
const MAX_CHILD_RSS_KIB = 128 * 1024
const MAX_CHILD_CPU_PERCENT = 50
const MAX_QUARANTINE_RSS_KIB = 512 * 1024
const MAX_QUARANTINE_CPU_PERCENT = 100
const PHYSICAL_CHILD_CAP = 8
const WAIT_TIMEOUT_MS = 15_000
const execFileAsync = promisify(execFile)
const require = createRequire(import.meta.url)
async function main() {
if (!existsSync(ENTRY_PATH)) {
throw new Error(`Missing ${ENTRY_PATH}; run pnpm run build:electron-vite first`)
}
const createdRoots = []
const exactPids = new Set()
let bundleDir
let pool
let result
try {
bundleDir = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-resource-'))
const { RuntimeWatcherProcessPool, WatcherProcessFailure, reserveWatcherChild } =
await loadProbe(bundleDir)
const roots = []
for (let index = 0; index < 5; index++) {
const createdRoot = await mkdtemp(join(tmpdir(), `orca-watcher-resource-${index}-`))
createdRoots.push(createdRoot)
roots.push(await realpath(createdRoot))
}
pool = new RuntimeWatcherProcessPool()
await Promise.all(roots.map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {})))
const healthyPids = activeChildPids(pool)
assertPidCount(healthyPids, 1, 'five healthy roots')
healthyPids.forEach((pid) => exactPids.add(pid))
const healthyResources = await sampleResources(healthyPids)
assertResourceBudget(healthyResources, 'healthy pool', {
maxTotalRssKiB: MAX_CHILD_RSS_KIB,
maxTotalCpuPercent: MAX_CHILD_CPU_PERCENT
})
assertRegistryCapacity(reserveWatcherChild, 7)
const sharedSlot = [...pool.activeSlots][0]
const failure = new WatcherProcessFailure(
'resource probe synthetic shared-shard fault',
'supervisor',
'supervisor_crash_fuse'
)
for (const record of sharedSlot.supervisor.records.values()) {
record.hooks.onTerminalError?.(failure)
}
await waitFor(() => !isPidAlive(healthyPids[0]), 'healthy child exit')
await Promise.all(
roots.slice(0, 4).map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {}))
)
const quarantinePids = activeChildPids(pool)
assertPidCount(quarantinePids, 4, 'four fault-quarantine roots')
quarantinePids.forEach((pid) => exactPids.add(pid))
const quarantineResources = await sampleResources(quarantinePids)
assertResourceBudget(quarantineResources, 'quarantine pool', {
maxTotalRssKiB: MAX_QUARANTINE_RSS_KIB,
maxTotalCpuPercent: MAX_QUARANTINE_CPU_PERCENT
})
// Four live quarantine children leave four reservations before the global
// eight-physical-child ceiling rejects the next launch.
assertRegistryCapacity(reserveWatcherChild, 4)
result = {
hostPid: process.pid,
healthy: summarizeResources(healthyResources),
quarantine: summarizeResources(quarantineResources),
physicalChildCap: PHYSICAL_CHILD_CAP,
budgets: {
maxChildRssKiB: MAX_CHILD_RSS_KIB,
maxChildCpuPercent: MAX_CHILD_CPU_PERCENT,
maxQuarantineRssKiB: MAX_QUARANTINE_RSS_KIB,
maxQuarantineCpuPercent: MAX_QUARANTINE_CPU_PERCENT,
maxPhysicalRssKiB: MAX_CHILD_RSS_KIB * PHYSICAL_CHILD_CAP,
maxPhysicalCpuPercent: MAX_CHILD_CPU_PERCENT * PHYSICAL_CHILD_CAP
},
exactPidCleanup: true
}
} finally {
pool?.dispose()
await Promise.all(
[...exactPids].map((pid) => waitFor(() => !isPidAlive(pid), `watcher PID ${pid} cleanup`))
)
await Promise.all([
...createdRoots.map((rootPath) => rm(rootPath, { recursive: true, force: true })),
bundleDir ? rm(bundleDir, { recursive: true, force: true }) : Promise.resolve()
])
}
console.log(JSON.stringify(result))
}
async function loadProbe(bundleDir) {
const outfile = join(bundleDir, 'watcher-resource-probe.cjs')
await build({
stdin: {
contents: [
`export { RuntimeWatcherProcessPool } from ${JSON.stringify(POOL_SOURCE)}`,
`export { WatcherProcessFailure } from ${JSON.stringify(FAILURE_SOURCE)}`,
`export { reserveWatcherChild } from ${JSON.stringify(REGISTRY_SOURCE)}`
].join('\n'),
resolveDir: process.cwd(),
sourcefile: 'runtime-file-watcher-resource-probe-entry.ts',
loader: 'ts'
},
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
external: ['@parcel/watcher', 'electron'],
logLevel: 'silent'
})
return require(outfile)
}
function activeChildPids(pool) {
return [...pool.activeSlots]
.map((slot) => slot.supervisor.child?.pid)
.filter((pid) => Number.isInteger(pid))
}
function assertPidCount(pids, expected, label) {
if (new Set(pids).size !== expected || pids.some((pid) => !isPidAlive(pid))) {
throw new Error(
`${label} used ${new Set(pids).size} live watcher children; expected ${expected}`
)
}
}
function assertRegistryCapacity(reserveWatcherChild, availableReservations) {
const releases = []
try {
for (let index = 0; index < availableReservations; index++) {
const release = reserveWatcherChild()
if (!release) {
throw new Error(`Watcher child registry rejected reservation ${index + 1}`)
}
releases.push(release)
}
const unexpectedReservation = reserveWatcherChild()
if (unexpectedReservation) {
unexpectedReservation()
throw new Error('Watcher child registry exceeded its global physical-process cap')
}
} finally {
releases.forEach((release) => release())
}
}
async function sampleResources(pids) {
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
return Promise.all(pids.map((pid) => sampleProcess(pid)))
}
async function sampleProcess(pid) {
if (process.platform === 'win32') {
const command =
`$p=Get-Process -Id ${pid};` +
`[pscustomobject]@{rssKiB=[math]::Round($p.WorkingSet64/1KB);cpuSeconds=$p.CPU}|ConvertTo-Json -Compress`
const first = JSON.parse(
(await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim()
)
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
const second = JSON.parse(
(await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim()
)
return {
pid,
rssKiB: second.rssKiB,
cpuPercent: Math.max(0, (second.cpuSeconds - first.cpuSeconds) * 400)
}
}
const { stdout } = await execFileAsync('ps', ['-o', 'rss=', '-o', '%cpu=', '-p', String(pid)], {
env: { ...process.env, LC_ALL: 'C' }
})
const [rssKiB, cpuPercent] = stdout.trim().split(/\s+/).map(Number)
return { pid, rssKiB, cpuPercent }
}
function assertResourceBudget(resources, label, { maxTotalRssKiB, maxTotalCpuPercent }) {
for (const resource of resources) {
if (!Number.isFinite(resource.rssKiB) || resource.rssKiB <= 0) {
throw new Error(`Missing RSS evidence for watcher PID ${resource.pid}`)
}
if (resource.rssKiB > MAX_CHILD_RSS_KIB) {
throw new Error(
`Watcher PID ${resource.pid} RSS ${resource.rssKiB} KiB exceeded ${MAX_CHILD_RSS_KIB} KiB`
)
}
if (!Number.isFinite(resource.cpuPercent) || resource.cpuPercent < 0) {
throw new Error(`Missing CPU evidence for watcher PID ${resource.pid}`)
}
if (resource.cpuPercent > MAX_CHILD_CPU_PERCENT) {
throw new Error(
`Watcher PID ${resource.pid} CPU ${resource.cpuPercent}% exceeded ${MAX_CHILD_CPU_PERCENT}%`
)
}
}
const summary = summarizeResources(resources)
if (summary.totalRssKiB > maxTotalRssKiB) {
throw new Error(
`${label} RSS ${summary.totalRssKiB} KiB exceeded aggregate budget ${maxTotalRssKiB} KiB`
)
}
if (summary.sampledCpuPercent > maxTotalCpuPercent) {
throw new Error(
`${label} CPU ${summary.sampledCpuPercent}% exceeded aggregate budget ${maxTotalCpuPercent}%`
)
}
}
function summarizeResources(resources) {
return {
childCount: resources.length,
pids: resources.map(({ pid }) => pid),
totalRssKiB: resources.reduce((total, { rssKiB }) => total + rssKiB, 0),
sampledCpuPercent: resources.reduce((total, { cpuPercent }) => total + cpuPercent, 0)
}
}
function isPidAlive(pid) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function waitFor(predicate, label) {
const deadline = Date.now() + WAIT_TIMEOUT_MS
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for ${label}`)
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25))
}
}
await main()
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.0m">
<title>downloads: 5.0m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.7m">
<title>downloads: 5.7m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.0m</text>
<text x="93.5" y="14">5.0m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.7m</text>
<text x="93.5" y="14">5.7m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+3 -3
View File
@@ -17,7 +17,7 @@
<p align="center">
<strong>El orquestador de IA para desarrolladores 100x.</strong><br/>
Ejecuta Claude Code, OpenClaude, Codex u OpenCode en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar.
Ejecuta Codex, Claude Code, OpenCode u Pi en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Descargar Orca</ins></a></h3>
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
+3 -3
View File
@@ -17,7 +17,7 @@
<p align="center">
<strong>100x ビルダーのための AI オーケストレーター。</strong><br/>
Claude Code、OpenClaude、Codex、OpenCode を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
Codex、Claude Code、OpenCode、Pi を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Orca をダウンロード</ins></a></h3>
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
+5 -5
View File
@@ -17,7 +17,7 @@
<p align="center">
<strong>100x 빌더를 위한 AI 오케스트레이터.</strong><br/>
Claude Code, OpenClaude, Codex, OpenCode를 나란히 실행하세요 — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
Codex, Claude Code, OpenCode, Pi를 나란히 실행하세요. — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Orca 다운로드</ins></a></h3>
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -164,7 +164,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
- **[풍부한 리포지토리 미리보기](https://www.onorca.dev/docs/editing/markdown)** — Markdown, 이미지, PDF, 리포지토리 문서를 워크스페이스에서 미리 볼 수 있습니다.
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 워크플로에 실제 상호작용이 필요할 때 에이전트가 데스크톱 앱과 화면에 보이는 UI를 직접 조작하게 하세요.
- **[알림과 읽지 않음 상태](https://www.onorca.dev/docs/notifications)** — 에이전트가 완료되거나 주의가 필요할 때 알림을 받고, 스레드를 읽지 않음으로 표시해 나중에 다시 확인하세요.
- **그리고 훨씬 더 많은 기능** — 매일 출시하기 때문에 이 목록은 항상 뒤처져 있습니다. 진짜 기능 목록은 [체인지로그](https://github.com/stablyai/orca/releases)입니다.
- **그리고 훨씬 더 많은 기능** — 새로운 기능이 매일 출시되므로 이 목록은 늘 한 발 늦습니다. 진짜 기능은 [체인지로그](https://github.com/stablyai/orca/releases)에서 확인하세요.
---
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
@@ -237,7 +237,7 @@ yay -S stably-orca-bin
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일의 릴리스를 따라와 주세요.
- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요.
---
+2 -2
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [Baixar APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
+3 -3
View File
@@ -17,7 +17,7 @@
<p align="center">
<strong>面向 100x 构建者的 AI 编排器。</strong><br/>
并排运行 Claude Code、OpenClaude、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
并排运行 Codex、Claude Code、OpenCode 或 Pi — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>下载 Orca</ins></a></h3>
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
+8 -2
View File
@@ -112,8 +112,14 @@ path. This mirrors POSIX ownership without starting an additional process.
- Encoded PowerShell launchers use `Test-Path -PathType Leaf`. A missing script
calls `[Console]::In.ReadToEnd()` and exits zero; an existing script preserves
`$LASTEXITCODE`.
- Codex's cmd.exe fast path remains PowerShell-free. It rejects missing paths
and directories, using the same qualified `more.com` drain for both.
- Codex's (and Antigravity's/Devin's) cmd fast path is the bare, directly
spawnable `.cmd` path. These agents launch the hook command as a program
(argv[0]), not through cmd.exe, so the launcher cannot lead with a cmd builtin
such as `if` — that argv[0] is unspawnable and fails every hook. A missing
script therefore surfaces a normal launch failure on this fast path; only
launchers that already require a real interpreter (encoded PowerShell, Git
Bash) drain a missing script. Paths cmd.exe cannot invoke verbatim fall back to
the encoded PowerShell launcher, which owns stdin for the missing case.
- Claude's Git Bash fast path uses a POSIX file guard and drain while continuing
to execute the `.cmd` directly rather than interpreting it as shell source.
- Agent-specific direct launchers, including Antigravity event wrappers and
+1 -1
View File
@@ -35,7 +35,7 @@ authority.
| Capability | Preferred behavior | Compatibility behavior |
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `worktree-list-z` | NUL-delimited worktree paths | Line-block parser for Git before `worktree list -z` |
| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 |
| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
+39
View File
@@ -0,0 +1,39 @@
# Kerberos / GSSAPI SSH Authentication
Orca's ssh2-based SSH stack cannot speak `gssapi-with-mic` — the ssh2 library
has no GSSAPI userauth support, and adding it would mean forking ssh2's
protocol layer plus shipping the `kerberos` native module (MIT krb5 / Heimdal /
Windows SSPI) as a prebuilt Electron dependency on three platforms. Instead,
hosts that need Kerberos ride the existing **system OpenSSH transport** — the
same parallel transport already used for `ProxyCommand`/`ProxyJump` hosts —
which delegates GSSAPI, ticket lookup (`kinit` cache, Windows domain logon),
and credential delegation to the platform's own `ssh` binary on macOS, Linux,
and Windows (Win32-OpenSSH uses SSPI).
## Transport selection
Two tiers, deliberately asymmetric because RHEL-family distros ship
`GSSAPIAuthentication yes` in the global `/etc/ssh/ssh_config`, which makes
`ssh -G` report GSSAPI enabled for *every* host:
1. **Proactive** — a target with `gssapiAuthentication: true` (parsed from an
explicit `GSSAPIAuthentication yes` in the host's `~/.ssh/config` block, or
set on the target directly) tries the system-ssh probe first. If that fails
(e.g. no ticket), the connect falls through to the normal ssh2 key/agent
path where passphrase/password prompts remain available — OpenSSH semantics
allow other auth methods alongside GSSAPI.
2. **Auth-failure fallback** — when ssh2 exhausts key/agent auth and the
`ssh -G`-resolved config enables GSSAPI (`isGssapiSystemSshFallbackCandidate`
in `ssh-connection-utils.ts`), the connection retries over system ssh
*before* prompting the user for credentials. Kerberos-only hosts on
distro-default configs connect this way; hosts where keys work never leave
the ssh2 path.
Manual (non-ssh-config) targets flagged for GSSAPI get an explicit
`-o GSSAPIAuthentication=yes` in `system-ssh-args.ts`; config-backed targets
inherit the option from their own `Host` block since the system binary re-reads
ssh_config.
Both tiers work headless (`orca serve`): the system-ssh probe needs no
credential callbacks, and GSSAPI itself is non-interactive once a ticket
exists.
@@ -0,0 +1,564 @@
# Terminal Session Ownership and Teardown
**Status:** Incident fix and pane-authority transfer implemented
**Date:** 2026-07-13
**Incident:** [`../../pty-exhaustion-agent-session-leak.md`](../../pty-exhaustion-agent-session-leak.md)
**Related contracts:** [`terminal-model-view-contract.md`](./terminal-model-view-contract.md),
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md)
## Summary
Terminal process lifetime must be owned by explicit product intent, never by
whether a React view happens to be mounted.
Orca currently preserves a PTY when a terminal view detaches, which is correct
for view parking, tab moves, renderer reload, and warm reattach. It also relies
on `TerminalPane` unmount to destroy a PTY after a tab is closed. Hidden-view
parking invalidated that implicit invariant: a parked tab has no mounted pane,
so closing it removes renderer state and observers without terminating the PTY.
Agent resume records survive the same close and later interpret the missing pane
as failed recovery, launching the deliberately closed session again.
This design establishes one authoritative lifecycle contract:
- **Close tab or pane:** permanently retire the surface, terminate every PTY it
owns, and revoke every resume record for its panes.
- **Detach or park:** remove only the view connection; preserve the PTY and its
durable identity.
- **Sleep:** intentionally checkpoint resumable agents, terminate the PTYs, and
preserve only the checkpoint needed for wake.
- **Quit or reload:** follow the configured persistence policy; warm detach is
allowed, but it is not a tab close.
The immediate implementation makes the existing terminal-tab state close the
authoritative compatibility boundary for explicit retirement while adding an
explicit natural-exit reason for callers that are only reconciling an already
dead PTY. A follow-up separates the command and reducer APIs once all direct
store callers have migrated.
## Implementation status
The incident patch ships the retirement planner, provider-aware close routing,
unified-only retirement, parked watcher/candidate disposal, resume-authority
revocation, explicit natural-exit handling, late-binding rejection, and direct
background-launch ownership checks. It also adds exact pane retirement and a
persisted physical-to-owner pane-key alias for detach, plus a live Electron
parked-close gate. Focused tests cover local, SSH, split, parked, shared,
paired-host, ordinary runtime, pane-transfer, and late-spawn paths.
Origin-aware defensive expiry remains a follow-up. Immediate deletion on
explicit close fixes the incident without applying a wall-clock policy that
could invalidate intentional long-lived `worktree-sleep` checkpoints.
## Goals
1. Closing a terminal tab submits retirement for all exclusively owned local,
daemon, WSL, SSH, or runtime PTYs, including every split pane, whether its
views are mounted or parked.
2. Closing a terminal pane terminates only that pane's PTY; detaching a pane to
another tab preserves its PTY, hook identity, and resume authority under the
new owning pane.
3. Closing a tab permanently removes its agent resume authority. App restart,
worktree activation, mobile wake, and periodic capture must not resurrect it.
4. Parking, tab-group moves, renderer reload, and warm reattach keep their
existing process-preservation behavior.
5. Remote-runtime and web-session surfaces are terminated by their owning host,
not by sending a host-scoped ID to the local PTY provider.
6. Teardown is idempotent and safe under duplicate close requests, late PTY
exits, late agent-hook events, in-flight spawns, and provider disconnects.
7. A future bookkeeping regression cannot grow without bound: unowned defensive
resume records have an origin-aware expiry, and provider-owned detached
sessions gain a bounded orphan policy in the daemon-lease phase.
## Non-goals
- Changing terminal output, snapshot, replay, query-response, or hidden-delivery
behavior.
- Killing processes that deliberately daemonize away from a plain user
terminal's process group (nohup-style survivors remain user intent there).
For **agent sessions**, close/kill additionally terminates the snapshotted
descendant tree — including detached-pgid children the PTY's SIGHUP cannot
reach — via `pty-descendant-termination.ts` (bounded fresh snapshot with
same-turn coalescing, SIGTERM, grace window, then identity-safe SIGKILL).
Completed process tables are never reused as signal targets, and identity
checks use C-locale timestamps from the source scan. Later requests start a
fresh same-turn-coalesced successor inside their own deadline instead of
waiting behind older scans. A session is marked as terminating before capture
and keeps request ownership through natural exit, so reattach, duplicate kill,
and graceful-to-immediate upgrade cannot race the snapshot; descendant signals
still require the exact root session/handle to be live. Windows and SSH-hosted
PTYs keep the previous foreground-tree contract for now.
- Changing agent-provider resume commands or permission flags.
- Making a UI close wait for a remote process to exit before the tab disappears.
- Replacing worktree sleep with tab close. Sleep remains resumable by design.
- Claiming that a disconnected SSH relay process is already dead when main can
only tombstone its app-scoped ID. Durable relay-side kill-on-reconnect is a
follow-up.
## Terminology
- **Surface:** a terminal tab or a pane within a split terminal tab.
- **View:** a renderer xterm or pane-less parked watcher observing a surface.
- **Session:** the provider-owned PTY identified by a PTY ID.
- **Resume authority:** a `sleepingAgentSessionsByPaneKey` record that permits
Orca to launch an agent-provider resume command.
- **Retire:** permanently close a surface and revoke its process and resume
ownership.
- **Detach:** disconnect a view while preserving the session for reattachment.
## Open-source prior art
The lifecycle distinction is established in mature terminal implementations:
- VS Code's terminal instance disposal calls process-manager disposal, and the
process manager marks the process `KilledByUser` before shutdown. Its separate
`detachFromProcess` path deliberately clears the client reference without
shutting down the process. It also shuts down a process whose asynchronous
creation completes after the manager was disposed. See
[`terminalInstance.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts#L1287-L1325)
and
[`terminalProcessManager.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts#L207-L239).
- tmux exposes the same contract as separate commands: `detach-client` removes
a client while leaving the session intact, whereas `kill-session` destroys
the session and its windows. See
[`tmux.1`](https://github.com/tmux/tmux/blob/42b3ea0d7411acb4cd0357a3c2829d986b455918/tmux.1#L1177-L1218).
Orca needs the same semantic split, extended across local, WSL, SSH, persistent
daemon, and remote-runtime ownership. The important precedent is the explicit
intent boundary, not a particular framework or process API.
## Non-negotiable invariants
1. View lifetime and process lifetime are independent. Mount and unmount may
attach or detach observers; they do not decide whether a session should live.
2. Every live PTY has an owning surface or an explicit bounded detached state.
3. A user close is terminal: once accepted, no status event, session snapshot,
or runtime replay may recreate the closed surface or resume its agent.
4. A tab's retirement candidates are the union of its live index, tab row,
persisted split layout, last-known relay ID, deferred SSH ID, and pending
reconnect ID. A candidate is killable only when no other live surface claims
it.
5. Provider routing follows the execution owner:
- local/daemon/WSL and app-scoped SSH IDs use `pty.kill`;
- `remote:` runtime mirror IDs are never passed to the local PTY provider;
- paired web tabs are closed through the host session RPC.
6. Teardown requests are idempotent. Repeated kill, exit-after-kill, and
close-after-exit must converge on the same empty state.
7. Resume-record validity is origin-aware. Intentional `worktree-sleep` records
are not expired by agent-status freshness; unowned defensive `live` and
`quit` records use a documented decision-time horizon.
8. Natural process exit and explicit user retirement are distinct intents.
Reconciliation after an exit must not silently adopt destructive user-close
semantics.
## Lifecycle matrix
| Intent | View | PTY | Resume record | Persistent tab state |
| --------------------- | ------------------------------------------ | ---------------------------------- | --------------------------------------------------- | -------------------- |
| Switch tabs/worktrees | hidden or parked | keep | keep/update | keep |
| Move pane/tab group | detach, then remount | keep | keep | move |
| Renderer reload | detach | keep when warm persistence applies | keep | keep |
| App quit | detach or terminate per persistence policy | policy-dependent | capture live agents | keep |
| Sleep worktree | unmount | terminate | capture intentional sleep checkpoint | keep identifiers |
| Close split pane | destroy | terminate that pane | delete that pane record | remove pane |
| Close terminal tab | destroy or already absent | terminate all tab PTYs | delete all tab records | remove tab |
| Remove worktree | destroy | terminate all worktree PTYs | delete all worktree records | remove worktree |
| PTY exits naturally | disconnect | already dead | clear or retain completed evidence per agent policy | reconcile surface |
## Current failure
The current close path deletes `ptyIdsByTabId[tabId]` and the tab layout, then
relies on a mounted `TerminalPane` cleanup to call `transport.destroy()`. A
parked tab has already run the detach branch and has no mounted cleanup left.
`disposeParkedTabWatchers` only unregisters observers. The provider session is
therefore alive after Orca discards its last renderer-side owner.
The same state close drops live agent status but not
`sleepingAgentSessionsByPaneKey`. Worktree activation intentionally fresh-resumes
records that no preserved pane can own. A deliberately closed pane therefore
looks like a failed restore. The existing age check compares `capturedAt` with
`updatedAt`; that detects a status that was stale at capture time but never
expires a record as wall time advances.
## Proposed architecture
### 1. Authoritative tab retirement plan
Before deleting any tab-scoped state, build a `TerminalTabRetirementPlan` from
one store snapshot:
```ts
type TerminalTabRetirementPlan = {
tabId: string
worktreeId: string | null
ptyIds: string[]
localOrSshPtyIds: string[]
runtimeTerminals: Array<{ environmentId: string; handle: string }>
sharedPtyIds: string[]
paneKeys: string[]
}
```
`ptyIds` is the deduplicated union of:
- `ptyIdsByTabId[tabId]`;
- the tab row's legacy `ptyId`;
- every value in `terminalLayoutsByTabId[tabId].ptyIdsByLeafId`;
- `lastKnownRelayPtyIdByTabId[tabId]`;
- `deferredSshSessionIdsByTabId[tabId]`;
- `pendingReconnectPtyIdByTabId[tabId]`.
This snapshot must be built before any of those maps are pruned. PTY IDs are
classified structurally with `parseRemoteRuntimePtyId`, not by assuming a host
or platform from the current active workspace. Before shutdown, the planner
subtracts IDs referenced by another tab row, live index, split layout, or relay
and reconnect map. A partially completed pane move must not let closing the
source tab kill the target tab's session.
The immediate compatibility boundary is `closeTab`, because several production
paths still call the store action directly. Its options carry an explicit
`reason: 'user' | 'pty-exit' | 'cleanup'`; `user` and `cleanup` retire, while
`pty-exit` reconciles an already-dead session without killing siblings or
revoking crash-recovery policy by accident. Making only the rendered tab-bar
action destructive would leave background launch cleanup, floating terminal,
onboarding, runtime notifications, and future direct callers vulnerable.
### 2. Teardown ordering
A local tab retirement executes in this order:
1. Snapshot the retirement plan and its execution-owner classifications.
2. Mark the tab recently closed so late agent-hook events cannot reintroduce
status or resume authority.
3. Dispose the whole tab's parked registry entry and captured candidates, and
silence teardown side-effect handlers while preserving deterministic exit
observation.
4. Submit `pty.kill` for exclusive local or SSH IDs and `terminal.close` for
exclusive ordinary runtime handles. A paired host-session close remains
owned by `session.tabs.close` and is not duplicated locally.
5. Atomically remove the tab, layout/binding maps, agent status, and resume
records from renderer state.
6. Let subsequent React unmount cleanup run idempotently. It is no longer the
process-lifetime authority.
Steps 3 and 4 are issued before the state loses its IDs. The UI state mutation
remains synchronous and does not await provider exit. Completion is observed
with `Promise.allSettled` so rejections cannot become unhandled promises. The
current Phase 1 fallback for a rejected retirement is provider `listSessions`
plus Resource Manager orphan cleanup; durable retry inventory belongs to the
main-owned retirement phase.
Remote-runtime mirrors are different: their `remote:` IDs are excluded from
local `pty.kill`. Ordinary runtime terminals retire through `terminal.close`.
Paired host-session tabs retire through `session.tabs.close`, which owns the
entire host tab graph. The local mirror may be pruned optimistically only after
the host close intent is recorded, as it is today.
### 3. Resume-authority revocation
Tab retirement removes every sleeping record whose:
- map key begins with `${tabId}:`; or
- record has `tabId === tabId`.
The explicit `record.tabId` check covers migrated or legacy keys whose key no
longer encodes the current tab identity. Sibling tabs and other worktrees retain
their records by reference when unchanged.
The existing recently-closed tab registry remains the short-lived race guard
for hook events already in flight. It must prevent those events from creating a
new `origin: 'live'` resume record after retirement. Tests must cover both event
orders: close then late status, and status queued in the same turn as close.
Periodic capture iterates only current live status entries. Once close removes
the live entry and resume record, the periodic pass has nothing to persist.
### 4. Origin-aware defensive record expiry
An unowned defensive `live` or `quit` record may expire when:
```ts
Date.now() - record.capturedAt > DEFENSIVE_AGENT_RESUME_MAX_AGE_MS
```
Intentional `worktree-sleep` records are exempt from the agent-status freshness
window. Originless legacy behavior remains unchanged until its migration policy
is explicitly chosen. The same validator must run before desktop activation and
mobile/background wake so one entry point cannot bypass it.
Expiry is defense in depth, not the primary close mechanism. A correctly closed
record is deleted immediately; it is not retained for 30 minutes.
### 5. Pane close remains pane-scoped
`PaneManager.closePane` already distinguishes `reason: 'close'` from
`reason: 'detach'`. Preserve that distinction with explicit pane authority:
- `close` must clear the exact pane's resume authority, add a pane-scoped late
event tombstone, and destroy the pane transport;
- `detach` must atomically transfer or alias resume ownership because the agent
process retains its immutable source `ORCA_PANE_KEY`.
The last-pane path routes to terminal-tab retirement so it receives the same
parked, split, SSH, and resume cleanup behavior as a tab-bar close.
### 6. Provider-side safety net
Client correctness is necessary but not sufficient for a daemon deliberately
designed to outlive renderer and app processes. Add a follow-up provider-owned
orphan policy:
- track attachments/ownership leases rather than treating `detach` as a no-op;
- distinguish warm-reattach grace from ownerless retirement;
- reap sessions that have no owner after a bounded grace period;
- never idle-reap a session merely because it produces no output;
- scope leases to native, WSL, SSH provider, relay connection, or runtime host;
- expose orphan count and oldest orphan age in Resource Manager diagnostics.
This hardening is not required to land the immediate close fix, because a lease
protocol changes daemon compatibility and needs its own migration. It is the
defense against a future client bookkeeping regression.
## Provider behavior
| Provider/session kind | Close operation | Notes |
| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
| Local native | `pty.kill(id)` | Main routes to the owning local/daemon provider. |
| Persistent daemon | `pty.kill(id)` | Explicit close overrides warm-reattach persistence. |
| Windows ConPTY | `pty.kill(id)` | Provider shutdown owns Windows process-tree semantics. |
| WSL | `pty.kill(id)` | Route by PTY ownership; do not infer from path separators. |
| SSH | `pty.kill(appScopedId)` | Main resolves the connection-specific SSH provider. |
| Disconnected SSH | `pty.kill(appScopedId)` | Tombstones locally and prevents fallback; relay-side death requires reconnect-aware follow-up. |
| Ordinary remote runtime | host `terminal.close` | Classify environment and handle from the `remote:` ID. |
| Paired web host tab | host `session.tabs.close` | Host owns the complete tab and pane graph. |
| Web client local mirror | optimistic prune after close intent | Host snapshot confirms final removal. |
## Failure and race handling
### Duplicate close
The first close snapshots and retires the tab. Later closes find no target and
no-op. Provider shutdown is idempotent for an already exited session.
### PTY exit races close
Observers are unregistered before shutdown. If an exit event was already
queued, the recently-closed tab guard and missing tab make it a no-op. It must
not recreate a tab, completion row, resume record, or notification.
### Spawn completes after close
The transport already kills a PTY when its spawn resolves after `destroyed` is
set, but the two direct background launchers do not use that transport. Each
direct launcher must revalidate tab ownership immediately after `pty.spawn` or
`terminal.create` resolves and before writing any binding, layout, eager buffer,
subscription, or mount request. A retired local or SSH result is killed; a
retired runtime result is closed through `terminal.close`.
### Provider shutdown fails
The tab remains closed; reappearing would violate user intent. Report the
provider class through structured diagnostics and leave the session visible in
provider inventory so existing Resource Manager orphan cleanup can retry. A
durable pending-retirement inventory is a later main-process API and is not
claimed by Phase 1. Do not restore resume authority after a failed terminal
kill.
### Remote host is unavailable
Keep the local close intent until either a host snapshot confirms removal or the
existing intent TTL expires. If it expires, the host remains authoritative and
may republish the tab; log the failed close. Never pretend a local mirror prune
terminated the host PTY.
### App exits during close
Submit shutdown IPC before discarding the only local PTY identifiers. The main
process/provider owns completion after accepting the request. A future batched
shutdown IPC may return per-ID acceptance, but UI close does not wait for it.
## Implementation plan
### Phase 1: correctness patch (implemented)
1. Add a small store-independent terminal-tab retirement planner that collects
all six ID sources, classifies execution owners, and excludes IDs shared by
another live surface.
2. Add an explicit close reason, update natural-exit callers, and make explicit
`closeTab` retirement dispose the whole parked-tab registry, submit local,
SSH, or runtime shutdown, atomically clear all reconnect maps, and remove
tab-owned resume records.
3. Add post-await owner checks to both direct background launchers so a late
local, SSH, or runtime spawn is terminated before any renderer binding.
4. Defer decision-time expiry until unowned defensive `live` or `quit` records
can be distinguished without changing intentional `worktree-sleep` or legacy
recovery behavior.
5. Add focused planner, store, launcher-race, and resume-policy tests. Keep one
local Electron parked-close proof as an RC release gate.
### Phase 2: remaining API cleanup
1. Rename the state-only reducer primitive so `closeTab` cannot ambiguously mean
either UI cleanup or lifecycle teardown.
2. Route all user and runtime close call sites through one domain command.
3. Keep explicit `detach` and `sleep` APIs; do not encode them as close options.
4. Add a development assertion when tab state is removed with provider PTY IDs
but no retirement plan.
Exact pane retirement, persisted detach authority transfer, and unified-only
terminal routing landed with Phase 1 because exact-head review found they were
required to avoid introducing adjacent close regressions.
### Phase 3: daemon ownership leases
1. Define versioned attach/detach/retire lease messages.
2. Preserve warm reattach across renderer/app restarts within a bounded grace.
3. Reap ownerless sessions and expose pressure telemetry.
4. Add compatibility tests across the supported daemon protocol versions and
native/WSL/SSH host isolation.
## Test plan
### Unit/store tests
- Closing an active single-pane tab submits exactly its PTY for shutdown and
removes all tab state.
- Closing a parked tab with no mounted transport still submits its PTY.
- Closing a split tab submits every unique PTY found across tab row, index, and
layout plus relay/deferred/reconnect maps, including IDs present in only one
source.
- Duplicate IDs are submitted once by the authoritative retirement plan.
- An ID referenced by another live tab is not killed when the source tab closes.
- `remote:` IDs are excluded from local PTY shutdown.
- App-scoped SSH IDs are included and are not mistaken for remote runtime IDs.
- All sleeping records for the tab are removed by key prefix or `record.tabId`;
sibling records retain identity.
- Periodic capture after close does not recreate the record.
- Late agent status after close cannot recreate live status or resume authority.
- Closing twice and close-after-exit are no-ops after the first retirement.
- A spawn resolving after close kills the newly returned PTY.
- A defensive live/quit record at the expiry boundary is retained; one
millisecond past it is cleared and never launched, while an older intentional
worktree-sleep record still resumes.
### Renderer integration tests
- Mounted tab close still destroys its xterm and terminates the PTY.
- Parked watcher disposal occurs before or with PTY shutdown and emits no final
completion/bell notification.
- Closing one split pane preserves the sibling; closing the last pane retires
the tab.
- Pane-to-tab detach preserves the PTY and resume record.
- Pinned-tab cancellation performs no shutdown; confirmed close does.
- Bulk close (`close others`, `close right`, kill-all) uses the same semantics.
### Provider/main tests
- Local, daemon, WSL, ConPTY, and SSH ownership route explicit close to the
correct provider.
- Disconnected SSH close tombstones the app-scoped ID and never falls through to
a local provider.
- Repeated `pty.kill` for the same ID is benign.
- Remote-runtime IDs cannot reach the local kill handler from renderer close.
### Electron end-to-end regression
1. Create a worktree terminal and start a deterministic long-lived child.
2. Switch worktrees and wait until the source worktree is cold-parked.
3. Close the parked tab.
4. Assert its exact PTY disappears from `pty:listSessions` and the child exits.
5. Restart/reload Orca and activate the worktree.
6. Assert no terminal or agent resume command is recreated for the closed pane.
7. Repeat with two split panes and with an SSH fixture where CI supports it.
### Soak and pressure gate
Run repeated create, park, close, reload, and reopen cycles. App-owned PTY count
must return to baseline after each cycle and remain bounded over the run. Record
the peak and final provider session count in CI artifacts.
## Observability
Emit a synchronous scheduled summary and an asynchronous completion summary:
- tab/worktree identifiers in hashed or existing diagnostic form;
- number of PTY IDs discovered by each source;
- provider classification counts;
- submitted count, then fulfilled/rejected counts after `Promise.allSettled`;
- number of resume records removed;
- whether the tab was parked or mounted when retired.
After the daemon-lease phase, Resource Manager should distinguish:
- attached sessions;
- warm-detached sessions eligible for reattach;
- ownerless/orphan sessions;
- pending/failed explicit retirement.
Alert locally before app-owned PTYs approach platform pressure. The warning is a
defense, not a substitute for lifecycle correctness.
## Rollout
1. Land the correctness patch without a feature flag. User close semantics are
restorative behavior, not an experiment.
2. Keep hidden-view parking enabled; disabling it masks the ownership bug and
forfeits its memory benefit.
3. Run focused unit/integration tests plus the live parked-close Electron gate.
4. Cut an RC and soak create/close/restart cycles while monitoring provider and
OS PTY counts.
5. Ship daemon leases separately behind protocol compatibility and telemetry.
## Alternatives rejected
### Disable hidden-view parking
This restores the old accidental unmount behavior at significant memory cost
and leaves every other state-only close path fragile. It does not repair resume
records or daemon ownership.
### Kill only from `TerminalPane` cleanup
There is intentionally no pane during parking and some headless/runtime flows.
View cleanup cannot be process authority.
### Clear only resume records
This prevents resurrection but still leaks the live PTY until app/daemon exit.
### Kill only the tab row's `ptyId`
Split panes and partially reconciled layouts can own multiple IDs. The full
union is required.
### Rely only on a daemon idle timeout
Interactive agents can be legitimately idle for hours. Reaping by output
idleness loses user work; ownership, not activity, is the safe signal.
### Await every provider exit before hiding the tab
Remote and disconnected providers can take seconds or fail. UI retirement must
be immediate once the user confirms; provider completion is asynchronous and
observable.
## Phase 1 acceptance criteria
- A confirmed terminal-tab close submits retirement for every exclusively owned
session Orca can currently reach and reports rejected requests without an
unhandled promise.
- A closed agent session is never resumed by restart, activation, or mobile wake.
- Parking, move/detach, sleep/wake, and warm app reattach retain their documented
behavior.
- Split, local, SSH, and remote-runtime ownership are covered by deterministic
tests; Windows/WSL process-tree behavior remains a live-platform follow-up.
- PTY counts remain bounded across the soak scenario.
- No process-lifetime decision depends on React component mount state.
## Full-contract follow-ups
- Durable main-owned retirement intent and bounded retry inventory.
- Disconnected SSH relay kill-on-reconnect.
- Serializable daemon attachment leases with protocol-version fallback.
- Positive ownership validation for late hook events beyond the bounded recent-
close registry.
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.27",
"version": "0.0.31",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 7
"versionCode": 8
},
"plugins": [
"expo-router",
+11
View File
@@ -11,6 +11,7 @@ import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationPath } from '../src/notifications/notification-routing'
import { loadHosts } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
// Why: keeps the native splash screen visible until the React tree is mounted
// and ready to render. Without this the user sees a blank white/black frame
@@ -35,6 +36,12 @@ export default function RootLayout() {
const router = useRouter()
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
// Why: pairing publication is journaled across process death; startup must
// reconcile the server result before another scan can replace that journal.
void recoverMobileRelayPairing()
}, [])
// Why: route `orca://pair?...` deep links to the confirm screen so
// the same pairing flow runs whether the link arrived via QR scan,
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
@@ -171,6 +178,10 @@ export default function RootLayout() {
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen
name="notification-opt-in"
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
+2 -2
View File
@@ -9,11 +9,11 @@ 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 {
useHostClient,
useLastConnectedAt,
useReconnectAttempt
} from '../src/transport/client-context'
} from '../src/transport/client-context-connection-metrics'
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
+382
View File
@@ -0,0 +1,382 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
View,
Text,
TextInput,
StyleSheet,
Pressable,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store'
import {
displayHostEndpoint,
endpointPort,
endpointScheme,
normalizeHostEndpoint
} from '../../../src/transport/host-endpoint'
import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context'
import type { HostProfile } from '../../../src/transport/types'
export default function EditHostScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { hostId } = useLocalSearchParams<{ hostId: string }>()
const primeHosts = usePrimeHosts()
const forceReconnectHost = useForceReconnect()
const [host, setHost] = useState<HostProfile | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [name, setName] = useState('')
const [address, setAddress] = useState('')
const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
// Why: setSaving is async, so a second trigger before the re-render could
// still read stale state and re-enter handleSave; the ref closes that race.
const savingRef = useRef(false)
const load = useCallback(async () => {
if (!hostId) {
setLoadError('Missing host.')
return
}
try {
const hosts = await loadHosts()
const found = hosts.find((h) => h.id === hostId) ?? null
if (!found) {
setLoadError('This host was removed from this phone.')
setHost(null)
return
}
setHost(found)
setName(found.name)
setAddress(displayHostEndpoint(found.endpoint))
setLoadError(null)
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Failed to load host.')
setHost(null)
}
}, [hostId])
useEffect(() => {
void load()
}, [load])
const fallbackPort = host ? endpointPort(host.endpoint) : undefined
const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws'
const normalizedEndpoint = useMemo(
() => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }),
[address, fallbackPort, fallbackScheme]
)
const nameTrimmed = name.trim()
const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name
const endpointChanged =
host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint
const canSave =
host != null &&
nameTrimmed.length > 0 &&
normalizedEndpoint.ok &&
(nameChanged || endpointChanged) &&
!saving
async function handleSave() {
if (!host || !hostId || savingRef.current) {
return
}
const nextName = name.trim()
if (!nextName) {
setSaveError('Enter a name.')
return
}
if (!normalizedEndpoint.ok) {
setSaveError(normalizedEndpoint.error)
return
}
const willRename = nextName !== host.name
const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint
if (!willRename && !willUpdateEndpoint) {
router.back()
return
}
savingRef.current = true
setSaving(true)
setSaveError(null)
try {
// Why: a single mutateStoredHosts pass so name + endpoint commit
// atomically — a mid-save failure can never persist one without the
// other, and a host removed mid-edit throws instead of no-oping.
await updateHostNameAndEndpoint(host.id, {
...(willRename ? { name: nextName } : {}),
...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {})
})
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save host.')
savingRef.current = false
setSaving(false)
return
}
try {
// Why: the write already committed above; a re-prime failure here
// must not be reported as a save failure — the next loadHosts() call
// elsewhere in the app picks up the fresh state regardless.
const hosts = await loadHosts()
primeHosts(hosts)
} catch {
// best-effort re-prime; persisted data is unaffected
}
savingRef.current = false
setSaving(false)
router.back()
if (willUpdateEndpoint) {
// Why: reconnect is a follow-on side effect of a save that already
// committed — its failure or a hang must not be reported as a save
// failure or block navigating back.
void forceReconnectHost(host.id).catch(() => {})
}
}
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
style={styles.backButton}
onPress={() => router.back()}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Edit host</Text>
<Pressable
style={({ pressed }) => [
styles.saveButton,
(!canSave || pressed) && styles.saveButtonDisabled
]}
onPress={() => void handleSave()}
disabled={!canSave}
accessibilityRole="button"
accessibilityLabel="Save host"
>
{saving ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.saveButtonText}>Save</Text>
)}
</Pressable>
</View>
{loadError ? (
<View style={styles.errorState}>
<Text style={styles.errorText}>{loadError}</Text>
<Pressable style={styles.secondaryButton} onPress={() => router.back()}>
<Text style={styles.secondaryButtonText}>Go back</Text>
</Pressable>
</View>
) : !host ? (
<View style={styles.loadingState}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={[styles.form, { paddingBottom: insets.bottom + spacing.xl }]}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.help}>
Change the display name or connection address. Address edits only switch where this
phone connects — they do not re-pair. Use this when the same desktop is reachable at a
different IP (for example home LAN vs Tailscale).
</Text>
<Text style={styles.label}>Name</Text>
<TextInput
style={styles.input}
accessibilityLabel="Name"
value={name}
onChangeText={(value) => {
setName(value)
setSaveError(null)
}}
placeholder="Host name"
placeholderTextColor={colors.textMuted}
autoCapitalize="words"
autoCorrect={false}
returnKeyType="next"
/>
<Text style={styles.label}>Address</Text>
<TextInput
style={styles.input}
accessibilityLabel="Address"
value={address}
onChangeText={(value) => {
setAddress(value)
setSaveError(null)
}}
placeholder="192.168.1.10:6768"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
keyboardType="url"
returnKeyType="done"
onSubmitEditing={() => {
if (canSave) {
void handleSave()
}
}}
/>
<Text style={styles.hint}>
Accepts IP, host:port, or ws:// / wss://. Missing port defaults to the current port
(or 6768).
</Text>
{normalizedEndpoint.ok ? (
<Text style={styles.preview} numberOfLines={2}>
Connects to {normalizedEndpoint.endpoint}
</Text>
) : address.trim().length > 0 ? (
<Text style={styles.previewError}>{normalizedEndpoint.error}</Text>
) : null}
{saveError ? <Text style={styles.errorText}>{saveError}</Text> : null}
</ScrollView>
</KeyboardAvoidingView>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
flex: {
flex: 1
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
gap: spacing.sm
},
backButton: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center'
},
heading: {
flex: 1,
color: colors.textPrimary,
fontSize: 20,
fontWeight: '700'
},
saveButton: {
minWidth: 64,
height: 34,
paddingHorizontal: spacing.md,
borderRadius: radii.button,
backgroundColor: colors.surfaceBright,
alignItems: 'center',
justifyContent: 'center'
},
saveButtonDisabled: {
opacity: 0.4
},
saveButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
form: {
paddingHorizontal: spacing.lg,
gap: spacing.sm
},
help: {
color: colors.textSecondary,
fontSize: typography.bodySize,
lineHeight: 20,
marginBottom: spacing.sm
},
label: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '500',
marginTop: spacing.sm,
textTransform: 'uppercase',
letterSpacing: 0.4
},
input: {
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.row,
color: colors.textPrimary,
fontSize: typography.bodySize,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? 12 : 10
},
hint: {
color: colors.textMuted,
fontSize: typography.metaSize,
lineHeight: 16
},
preview: {
marginTop: spacing.sm,
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : typography.monoFamily
},
previewError: {
marginTop: spacing.sm,
color: colors.statusRed,
fontSize: typography.bodySize
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
marginTop: spacing.md
},
errorState: {
flex: 1,
paddingHorizontal: spacing.lg,
paddingTop: spacing.xl,
gap: spacing.md
},
loadingState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
secondaryButton: {
alignSelf: 'flex-start',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgRaised
},
secondaryButtonText: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '500'
}
})
+38 -5
View File
@@ -6,7 +6,8 @@ import {
SectionList,
Pressable,
ActivityIndicator,
Alert
Alert,
RefreshControl
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
@@ -33,10 +34,13 @@ import { removeHostAndCloseClient } from '../../../src/transport/host-removal-li
import {
useHostClient,
useCloseHost,
useForceReconnect,
useReconnectAttempt,
useLastConnectedAt
useForceReconnect
} from '../../../src/transport/client-context'
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import {
useLastConnectedAt,
useReconnectAttempt
} from '../../../src/transport/client-context-connection-metrics'
import {
classifyConnection,
type ConnectionVerdict
@@ -59,7 +63,7 @@ import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
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 { useResponsiveLayout } from '../../../src/layout/responsive-layout'
@@ -459,6 +463,14 @@ export function HostScreen({
areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees
)
setWorktreesLoaded(true)
// Why (#8498): the host detail screen seeds its list from the
// home-written cache, so a partial home fetch could poison it until a
// focus poll corrected it. Write the confirmed snapshot back through
// the same cache so a reconnect refetch (or a remount) can't serve a
// stale worktree list.
if (hostId) {
setCachedWorktrees(hostId, result.worktrees)
}
// Drop the optimistic active override once the host confirms it (the
// activate RPC has landed and worktree.ps now reports it active), so we
// stop overriding and respect any later desktop-driven change.
@@ -613,6 +625,17 @@ export function HostScreen({
return () => clearInterval(interval)
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
// Why (#8498): reconnect refetch + manual pull-to-refresh, extracted to
// useWorktreeResync so this screen stays under its max-lines budget. The
// steady-state focus/embedded polls don't cover the transition INTO
// 'connected' after a background/sleep, which is when the cache is stalest.
const { refreshing, onRefresh } = useWorktreeResync({
client,
connState,
fetchWorktrees,
fetchRepoMetadata
})
const updateLocalPins = useCallback(
(worktreeId: string, pinned: boolean) => {
setPinnedIds((prev) => {
@@ -1229,6 +1252,16 @@ export function HostScreen({
)
}}
ItemSeparatorComponent={ListSeparator}
// Why (#8498): manual pull-to-refresh forces a fresh worktree
// snapshot after a reconnect or whenever the cache looks stale.
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.textSecondary}
colors={[colors.textSecondary]}
/>
}
renderItem={({ item }) => (
<WorktreeListRow
item={item}
+95 -74
View File
@@ -56,12 +56,11 @@ import {
saveTerminalTextScale,
type MobileTerminalLinkOpenMode
} from '../../../../src/storage/preferences'
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import {
useHostClient,
useForceReconnect,
useReconnectAttempt,
useLastConnectedAt
} from '../../../../src/transport/client-context'
useLastConnectedAt,
useReconnectAttempt
} from '../../../../src/transport/client-context-connection-metrics'
import {
classifyConnection,
verdictDisplayLabel
@@ -185,6 +184,7 @@ import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-n
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from '../../../../src/session/session-tab-snapshot-gate'
import {
@@ -198,8 +198,14 @@ import {
isDictationSetupRequiredError
} from '../../../../src/dictation/mobile-dictation-setup'
import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
import {
activateMobileSessionTab,
focusMobileTerminal
} from '../../../../src/session/mobile-session-tab-activation'
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
import {
getRepoIdFromMobileWorktreeId,
getActiveTabIdForHandle,
isFileExistsErrorMessage,
isGestureMouseTrackingMode,
MOBILE_SESSION_STATUS_LABELS,
@@ -207,7 +213,8 @@ import {
TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS,
TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES,
TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS,
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
updateTerminalCwdFromStreamEvent
} from '../../../../src/session/mobile-session-route-helpers'
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
@@ -246,21 +253,6 @@ type TerminalLiveAccessoryInput = ReturnType<typeof createTerminalLiveAccessoryI
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function getActiveTabIdForHandle(
tabs: MobileSessionTab[],
terminalHandle: string | null
): string | null {
if (!terminalHandle) {
return null
}
return (
tabs.find(
(tab): tab is Extract<MobileSessionTab, { type: 'terminal' }> =>
tab.type === 'terminal' && tab.terminal === terminalHandle
)?.id ?? terminalHandle
)
}
function MarkdownReader({
documentId,
doc,
@@ -801,21 +793,6 @@ function FileReader({
return renderSourceText(doc.content)
}
function updateTerminalCwdFromStreamEvent(
handle: string,
data: Record<string, unknown>,
terminalCwd: Map<string, string>
): void {
if (!('cwd' in data)) {
return
}
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
terminalCwd.set(handle, data.cwd)
return
}
terminalCwd.delete(handle)
}
export default function SessionScreen() {
const {
hostId,
@@ -1032,6 +1009,7 @@ export default function SessionScreen() {
const terminalUnsubsRef = useRef<Map<string, () => void>>(new Map())
const subscribingHandlesRef = useRef<Set<string>>(new Set())
const initializedHandlesRef = useRef<Set<string>>(new Set())
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
// Why: WebViews load xterm.js from CDN asynchronously. Hidden WebViews
// (opacity:0) may have delayed JS execution on iOS. We must not subscribe
// until the WebView has fired web-ready, otherwise init() messages queue
@@ -1080,7 +1058,6 @@ export default function SessionScreen() {
// the row) without any window-dim change. Tracking the measured width lets the
// refit hook re-fit the PTY on those resizes — see terminal-viewport-refit.ts.
const [terminalFrameWidth, setTerminalFrameWidth] = useState(0)
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
const {
clearPendingLiveInputCommit,
@@ -1317,6 +1294,7 @@ export default function SessionScreen() {
terminalUnsubsRef.current.get(handle)?.()
terminalUnsubsRef.current.delete(handle)
subscribingHandlesRef.current.delete(handle)
terminalDiagnosticsRef.current.terminalUnsubscribed(handle)
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
// Why: a fresh subscription will land on a new server-side state machine
// run (or the same one with a higher seq); reset the high-water mark so
@@ -1331,6 +1309,7 @@ export default function SessionScreen() {
terminalUnsubsRef.current.clear()
subscribingHandlesRef.current.clear()
initializedHandlesRef.current.clear()
terminalDiagnosticsRef.current.clearTerminalCache()
webReadyHandlesRef.current.clear()
subscribeSeqRef.current.clear()
layoutSeqRef.current.clear()
@@ -1352,6 +1331,7 @@ export default function SessionScreen() {
const dims = await getTerminalRef(handle)?.measureFitDimensions(
terminalFrameHeightRef.current || undefined
)
terminalDiagnosticsRef.current.viewportMeasured(handle, dims, terminalFrameHeightRef.current)
if (dims) {
viewportRef.current = dims
viewportMeasuredRef.current = true
@@ -1362,25 +1342,34 @@ export default function SessionScreen() {
const subscribeToTerminal = useCallback(
(handle: string) => {
const diagnostics = terminalDiagnosticsRef.current
const logSkippedGate = (reason: string) =>
diagnostics.streamSkipped(handle, reason, handle === activeHandleRef.current)
if (!client) {
logSkippedGate('no-client')
return
}
if (terminalUnsubsRef.current.has(handle)) {
logSkippedGate('already-subscribed')
return
}
if (subscribingHandlesRef.current.has(handle)) {
logSkippedGate('subscribe-in-flight')
return
}
if (!getTerminalRef(handle)) {
logSkippedGate('no-webview-ref')
return
}
if (!webReadyHandlesRef.current.has(handle)) {
logSkippedGate('webview-not-ready')
return
}
subscribingHandlesRef.current.add(handle)
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
subscribeSeqRef.current.set(handle, seq)
diagnostics.streamArmed(handle, seq, viewportRef.current)
// Why: server handles auto-fit on subscribe — no terminal.focus call needed.
// The viewport is embedded in the subscribe params so the server resizes
@@ -1399,6 +1388,7 @@ export default function SessionScreen() {
return
}
const data = result as Record<string, unknown>
diagnostics.firstStreamEvent(handle, seq, data.type)
// Why: stale-event filter. Server-side state machine bumps a
// monotonic seq on every applyLayout. Drop `resized` events
// whose seq is strictly older than what we've already observed
@@ -1431,6 +1421,7 @@ export default function SessionScreen() {
return
}
if (data.type === 'scrollback') {
diagnostics.streamScrollback(handle, seq, eventSeq, data)
if (initializedHandlesRef.current.has(handle)) {
return
}
@@ -1522,6 +1513,7 @@ export default function SessionScreen() {
// server still has a null viewport for THIS subscriber
// record — we MUST resubscribe so the server stores it.
if (dims) {
diagnostics.streamResubscribing(handle, seq, dims)
viewportRef.current = dims
viewportMeasuredRef.current = true
unsubscribeTerminal(handle)
@@ -1566,6 +1558,7 @@ export default function SessionScreen() {
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const serialized = typeof data.serialized === 'string' ? data.serialized : null
diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null)
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
if (serialized != null) {
getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks)
@@ -1735,6 +1728,7 @@ export default function SessionScreen() {
const applySessionTabs = useCallback(
(result: SessionTabsResult) => {
const diagnostics = terminalDiagnosticsRef.current
// Reject out-of-order snapshots, then suppress just-closed tabs until the
// publisher confirms their absence. See session-tab-snapshot-gate.
if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) {
@@ -1791,15 +1785,21 @@ export default function SessionScreen() {
const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current
const pendingActiveTerminalHandle = pendingActiveTerminalHandleRef.current
let active = snapshotActive
let selectionSource = 'snapshot'
if (pendingActiveSessionTabId) {
if (snapshotActive?.id === pendingActiveSessionTabId) {
pendingActiveSessionTabIdRef.current = null
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
pendingActiveSessionTabIdRef.current = null
} else {
selectionSource = 'pending-tab-local-ack'
}
} else {
const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId)
if (pendingTab) {
// Why: desktop tab snapshots can lag a mobile tap while activate RPC
// is in flight. Keep the locally selected tab to avoid snapping back.
active = pendingTab
selectionSource = 'pending-tab'
} else {
pendingActiveSessionTabIdRef.current = null
}
@@ -1817,12 +1817,17 @@ export default function SessionScreen() {
snapshotActive?.type === 'terminal' &&
snapshotActive.terminal === pendingActiveTerminalHandle
) {
pendingActiveTerminalHandleRef.current = null
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
pendingActiveTerminalHandleRef.current = null
} else {
selectionSource = 'pending-handle-local-ack'
}
} else if (pendingTerminalTab) {
// Why: desktop active flags can lag a mobile terminal tap. Key by
// terminal handle too, because fallback PTY tabs may not yet have a
// stable session tab id during new-worktree startup.
active = pendingTerminalTab
selectionSource = 'pending-handle-tab'
} else if (pendingTerminalExists) {
const nextActiveTabId = getActiveTabIdForHandle(nextTabs, pendingActiveTerminalHandle)
activeSessionTabIdRef.current = nextActiveTabId
@@ -1835,6 +1840,7 @@ export default function SessionScreen() {
pendingActiveTerminalHandleRef.current = null
}
}
diagnostics.tabsApplied(result, nextTabs, active, selectionSource)
activeSessionTabTypeRef.current = active?.type ?? null
activeSessionTabIdRef.current = active?.id ?? null
setActiveSessionTabId(active?.id ?? null)
@@ -2379,20 +2385,25 @@ export default function SessionScreen() {
const fetchSessionTabs = useCallback(async () => {
if (!client) {
terminalDiagnosticsRef.current.tabsFetchSkipped('no-client')
return
}
if (fetchSessionTabsInFlightRef.current) {
terminalDiagnosticsRef.current.tabsFetchSkipped('already-in-flight')
return
}
fetchSessionTabsInFlightRef.current = true
terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId)
try {
const response = await client.sendRequest('session.tabs.list', {
worktree: `id:${worktreeId}`
})
if (!response.ok) {
terminalDiagnosticsRef.current.tabsFetchFailed((response as RpcFailure).error.code)
return
}
const result = (response as RpcSuccess).result as SessionTabsResult
terminalDiagnosticsRef.current.tabsFetchSucceeded(result)
applySessionTabs(result)
// Focus a just-opened browser tab once it appears in the snapshot, via the
// normal activate path so it sticks and the user can still switch away.
@@ -2406,7 +2417,8 @@ export default function SessionScreen() {
switchSessionTabRef.current?.(browserTab)
}
}
} catch {
} catch (error) {
terminalDiagnosticsRef.current.tabsFetchErrored(error)
// Keep the last tab snapshot visible during reconnect/backoff.
} finally {
fetchSessionTabsInFlightRef.current = false
@@ -2589,7 +2601,7 @@ export default function SessionScreen() {
// Why: viewport refits for layout changes outside the subscribe path
// (tab strip toggling, fold/unfold, rotation) live in a dedicated hook —
// see terminal-viewport-refit.ts for the full rationale.
useTerminalViewportRefit({
const { notifyTerminalFrameHeight, notifyKeyboardVisibility } = useTerminalViewportRefit({
activeHandleRef,
terminalRefs,
terminalFrameHeightRef,
@@ -2608,9 +2620,11 @@ export default function SessionScreen() {
useEffect(() => {
const onShow = (e: KeyboardEvent) => {
notifyKeyboardVisibility(true)
setKeyboardHeight(e.endCoordinates?.height ?? 0)
}
const onHide = () => {
notifyKeyboardVisibility(false)
setKeyboardHeight(0)
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
@@ -2621,7 +2635,7 @@ export default function SessionScreen() {
showSub.remove()
hideSub.remove()
}
}, [])
}, [notifyKeyboardVisibility])
const scrollActiveTabIntoView = useCallback((tabId: string | null, animated: boolean) => {
if (!tabId) {
@@ -2689,6 +2703,7 @@ export default function SessionScreen() {
pendingBrowserFocusPageIdRef.current = null
pendingTerminalActivationAttemptRef.current = null
initialEmptySessionAutoCreateRef.current = null
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
for (const queued of terminalGestureInputQueuesRef.current.values()) {
@@ -2912,6 +2927,7 @@ export default function SessionScreen() {
(tab): tab is Extract<MobileSessionTab, { type: 'terminal' }> =>
tab.type === 'terminal' && tab.terminal === handle
)
terminalDiagnosticsRef.current.tabSwitch('terminal', matchingTab?.id ?? '', false, handle)
pendingActiveSessionTabIdRef.current = matchingTab?.id ?? null
pendingActiveTerminalHandleRef.current = handle
activeSessionTabTypeRef.current = 'terminal'
@@ -2931,15 +2947,15 @@ export default function SessionScreen() {
}
subscribeToTerminal(handle)
if (client) {
void client.sendRequest('terminal.focus', { terminal: handle }).catch(() => {})
void focusMobileTerminal(client, handle).catch(() => {})
if (matchingTab) {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
notifyClients: false
})
.catch(() => {})
// Why: persist selection for headless hosts; the snapshot gate keeps
// this phone-local acknowledgement from impersonating desktop focus.
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
notifyClients: false
}).catch(() => {})
}
}
},
@@ -2960,6 +2976,7 @@ export default function SessionScreen() {
switchTab(tab.terminal)
return
}
terminalDiagnosticsRef.current.tabSwitch('terminal', tab.id, true)
triggerSelection()
pendingActiveSessionTabIdRef.current = tab.id
pendingActiveTerminalHandleRef.current = null
@@ -2973,18 +2990,17 @@ export default function SessionScreen() {
activeHandleRef.current = null
setActiveHandle(null)
if (client) {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
})
.catch(() => {})
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
}).catch(() => {})
}
return
}
triggerSelection()
terminalDiagnosticsRef.current.tabSwitch(tab.type, tab.id, false)
pendingActiveSessionTabIdRef.current = tab.id
pendingActiveTerminalHandleRef.current = null
activeSessionTabTypeRef.current = tab.type
@@ -2997,13 +3013,11 @@ export default function SessionScreen() {
activeHandleRef.current = null
setActiveHandle(null)
if (client) {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
})
.catch(() => {})
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
}).catch(() => {})
}
if (tab.type === 'browser') {
return
@@ -3031,6 +3045,7 @@ export default function SessionScreen() {
// init messages. This prevents the blank terminal race where init() was
// queued before the WebView loaded.
const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => {
terminalDiagnosticsRef.current.webViewRef(handle, ref != null)
if (ref) {
terminalRefs.current.set(handle, ref)
} else {
@@ -3049,6 +3064,11 @@ export default function SessionScreen() {
(handle: string) => {
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
webReadyHandlesRef.current.add(handle)
terminalDiagnosticsRef.current.webViewReady(
handle,
wasAlreadyReady,
handle === activeHandleRef.current
)
if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) {
// Why: the native WebView reloaded (Metro hot reload or Android
// process churn). The old xterm buffer is gone, so force a fresh
@@ -3085,7 +3105,7 @@ export default function SessionScreen() {
})()
}
},
[measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
[getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
)
useEffect(() => {
@@ -4302,13 +4322,12 @@ export default function SessionScreen() {
// Why: a hydrated headless/server-owned tab can already be active but still
// pending; activation is the RPC that materializes or focuses its PTY handle.
pendingTerminalActivationAttemptRef.current = activationKey
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId,
notifyClients: false
})
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId,
notifyClients: false
})
.then((response) => {
if (!response.ok) {
if (pendingTerminalActivationAttemptRef.current === activationKey) {
@@ -4831,10 +4850,12 @@ export default function SessionScreen() {
style={styles.terminalFrame}
onLayout={(e) => {
terminalFrameHeightRef.current = e.nativeEvent.layout.height
// Trigger a refit only when the width actually changes (sidebar
// resize, fold, rotation) — avoids churn on height-only changes.
// Why: notify height imperatively so dock settling re-fits the
// PTY without rerendering SessionScreen for layout callbacks.
const nextWidth = Math.round(e.nativeEvent.layout.width)
const nextHeight = Math.round(e.nativeEvent.layout.height)
setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth))
notifyTerminalFrameHeight(nextHeight)
}}
>
{terminals.map((terminal) => (
+2 -2
View File
@@ -34,11 +34,11 @@ import {
} from 'lucide-react-native'
import type { RpcClient } from '../../../src/transport/rpc-client'
import type { RpcSuccess } from '../../../src/transport/types'
import { useHostClient } from '../../../src/transport/client-context'
import {
useHostClient,
useLastConnectedAt,
useReconnectAttempt
} from '../../../src/transport/client-context'
} from '../../../src/transport/client-context-connection-metrics'
import { classifyConnection } from '../../../src/transport/connection-health'
import { StatusDot } from '../../../src/components/StatusDot'
import { ActionSheetModal } from '../../../src/components/ActionSheetModal'
+1
View File
@@ -39,6 +39,7 @@ function HostStack({ animation }: { animation: 'none' | 'default' }) {
}}
>
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
<Stack.Screen name="[hostId]/edit" options={{ title: 'Edit host' }} />
<Stack.Screen name="[hostId]/accounts" options={{ title: 'Accounts' }} />
<Stack.Screen name="[hostId]/tasks" options={{ title: 'Tasks' }} />
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
+35 -124
View File
@@ -3,7 +3,6 @@ import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'
import {
Monitor,
QrCode,
Settings,
ChevronRight,
@@ -25,7 +24,7 @@ import {
UsageBar
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts, renameHost } from '../src/transport/host-store'
import { loadHosts } from '../src/transport/host-store'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
import type { RpcClient } from '../src/transport/rpc-client'
@@ -35,14 +34,14 @@ import {
useForceReconnect,
usePrimeHosts
} from '../src/transport/client-context'
import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { StatusDot } from '../src/components/StatusDot'
import { MobileHostCard } from '../src/components/MobileHostCard'
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
import { TextInputModal } from '../src/components/TextInputModal'
import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal'
import { ConfirmModal } from '../src/components/ConfirmModal'
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
@@ -306,7 +305,6 @@ export default function HomeScreen() {
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
const [hosts, setHosts] = useState<HostProfile[]>([])
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
const [renameTarget, setRenameTarget] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
@@ -318,12 +316,17 @@ export default function HomeScreen() {
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
null
)
const notificationOptInCheckedRef = useRef(false)
// Why: read shared clients from the per-host store. Replaces the prior
// pattern of opening N independent WebSockets here. See
// docs/mobile-shared-client-per-host.md.
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const allClients = useAllHostClients(hostIds)
const hostPaths = useMemo(
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
[allClients]
)
const closeHostClient = useCloseHost()
const forceReconnectHost = useForceReconnect()
const primeHosts = usePrimeHosts()
@@ -392,9 +395,18 @@ export default function HomeScreen() {
useFocusEffect(
useCallback(() => {
let stale = false
void loadHosts().then((h) => {
if (!stale) {
setHosts(h)
void loadHosts().then(async (h) => {
if (stale) {
return
}
setHosts(h)
if (h.length === 0 || notificationOptInCheckedRef.current) {
return
}
notificationOptInCheckedRef.current = true
const showNotificationOptIn = await shouldPresentNotificationOptIn()
if (!stale && showNotificationOptIn) {
router.replace('/notification-opt-in')
}
})
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
@@ -416,7 +428,7 @@ export default function HomeScreen() {
return () => {
stale = true
}
}, [])
}, [router])
)
const sortedHosts = useMemo(
@@ -694,19 +706,6 @@ export default function HomeScreen() {
</Pressable>
)
async function handleRename(newName: string) {
if (!renameTarget) {
return
}
try {
await renameHost(renameTarget.id, newName)
setRenameTarget(null)
setHosts(await loadHosts())
} catch {
setRenameTarget(null)
}
}
async function handleRemove() {
if (!confirmRemove) {
return
@@ -824,7 +823,6 @@ export default function HomeScreen() {
const state = hostStates[item.id] ?? 'connecting'
const attempts = hostAttempts[item.id] ?? 0
const lastConnectedAt = hostLastConnected[item.id] ?? null
const connected = state === 'connected'
const info = worktreeInfo[item.id]
const verdict = classifyConnection({
state,
@@ -832,45 +830,21 @@ export default function HomeScreen() {
lastConnectedAt,
endpoint: item.endpoint
})
const isError =
verdict.kind === 'warning' ||
verdict.kind === 'unreachable' ||
verdict.kind === 'auth-failed'
return (
<Pressable
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
<MobileHostCard
host={item}
state={state}
verdict={verdict}
path={hostPaths[item.id] ?? 'lan'}
worktreeCounts={
info ? { total: info.totalWorktrees, active: info.activeCount } : undefined
}
onPress={() => router.push(`/h/${item.id}`)}
onLongPress={() => {
triggerMediumImpact()
setActionTarget(item)
}}
delayLongPress={400}
>
<View style={styles.hostIcon}>
<Monitor
size={20}
color={connected ? colors.textPrimary : colors.textSecondary}
/>
</View>
<View style={styles.hostMain}>
<Text
style={[styles.hostName, !connected && { color: colors.textSecondary }]}
numberOfLines={1}
>
{item.name}
</Text>
<View style={styles.hostMeta}>
<StatusDot state={state} verdict={verdict} />
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
{verdictDisplayLabel(verdict)}
{connected && info
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
: ''}
</Text>
</View>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
/>
)
}}
ListFooterComponent={
@@ -1079,18 +1053,19 @@ export default function HomeScreen() {
})
}
items.push({
label: 'Rename',
label: 'Edit host',
icon: Edit3,
closeBeforePress: true,
onPress: () => {
setActionTarget(null)
setRenameTarget(host)
router.push(`/h/${host.id}/edit`)
}
})
items.push({
label: 'Remove',
destructive: true,
closeBeforePress: true,
onPress: () => {
setActionTarget(null)
setConfirmRemove(host)
}
})
@@ -1099,16 +1074,6 @@ export default function HomeScreen() {
onClose={() => setActionTarget(null)}
/>
<TextInputModal
visible={renameTarget != null}
title="Rename Host"
message="Enter a new name for this host."
defaultValue={renameTarget?.name ?? ''}
placeholder="Host name"
onSubmit={(name) => void handleRename(name)}
onCancel={() => setRenameTarget(null)}
/>
<ConfirmModal
visible={confirmRemove != null}
title="Remove Host"
@@ -1244,63 +1209,9 @@ const styles = StyleSheet.create({
},
/* ─── Host cards ─── */
hostCard: {
flexDirection: 'row',
alignItems: 'center',
paddingLeft: spacing.md,
paddingRight: spacing.md,
paddingVertical: 12,
borderRadius: radii.card,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
hostCardPressed: {
backgroundColor: colors.bgRaised
},
hostIcon: {
width: 46,
height: 46,
borderRadius: 13,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
marginRight: 14,
position: 'relative'
},
hostMain: {
flex: 1,
minWidth: 0,
marginRight: spacing.sm
},
hostName: {
color: colors.textPrimary,
fontSize: 15,
fontWeight: '600',
lineHeight: 20
},
hostMeta: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginTop: 3
},
hostMetaItem: {
fontSize: 12,
color: colors.textSecondary
},
hostMetaDot: {
width: 3,
height: 3,
borderRadius: 1.5,
backgroundColor: colors.textMuted,
marginHorizontal: 8
},
statusDot: {
width: 7,
height: 7,
borderRadius: 3.5
},
/* ─── Resume card ─── */
resumeCard: {
+236
View File
@@ -0,0 +1,236 @@
import { useCallback, useState } from 'react'
import {
ActivityIndicator,
BackHandler,
Pressable,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { SafeAreaView } from 'react-native-safe-area-context'
import { BellRing } from 'lucide-react-native'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications'
import { savePushNotificationsEnabled } from '../src/storage/preferences'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
export default function NotificationOptInScreen() {
const router = useRouter()
const params = useLocalSearchParams<{ hostId?: string | string[] }>()
const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId
const [busyChoice, setBusyChoice] = useState<'enable' | 'skip' | null>(null)
const [error, setError] = useState<string | null>(null)
// Why: this one-time screen requires an explicit Enable or Not now choice;
// disabling back gestures alone would still leave Android hardware back open.
useFocusEffect(
useCallback(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', () => true)
return () => subscription.remove()
}, [])
)
const continueToApp = useCallback(() => {
router.replace(hostId ? `/h/${hostId}` : '/')
}, [hostId, router])
const choose = useCallback(
async (choice: 'enable' | 'skip') => {
if (busyChoice) {
return
}
setBusyChoice(choice)
setError(null)
try {
const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false
await savePushNotificationsEnabled(enabled)
continueToApp()
} catch {
setError('Notification settings could not be updated. Try again.')
setBusyChoice(null)
}
},
[busyChoice, continueToApp]
)
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<View style={styles.brandRow}>
<OrcaLogo size={22} />
<Text style={styles.brandName}>Orca</Text>
</View>
<View style={styles.content}>
<View style={styles.iconSurface}>
<BellRing size={30} color={colors.textPrimary} />
</View>
<Text style={styles.eyebrow}>Notifications</Text>
<Text style={styles.title}>Stay updated while away</Text>
<Text style={styles.body}>
Get notified on this device when an agent needs your input or finishes a task.
</Text>
</View>
<View style={styles.footer}>
{error ? (
<Text style={styles.error} accessibilityRole="alert">
{error}
</Text>
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel="Enable agent notifications"
disabled={busyChoice !== null}
style={({ pressed }) => [
styles.primaryButton,
pressed && styles.buttonPressed,
busyChoice !== null && styles.buttonDisabled
]}
onPress={() => void choose('enable')}
>
{busyChoice === 'enable' ? (
<ActivityIndicator color={colors.bgBase} />
) : (
<Text style={styles.primaryButtonText}>Enable notifications</Text>
)}
</Pressable>
<Pressable
accessibilityRole="button"
disabled={busyChoice !== null}
style={({ pressed }) => [
styles.secondaryButton,
pressed && styles.buttonPressed,
busyChoice !== null && styles.buttonDisabled
]}
onPress={() => void choose('skip')}
>
{busyChoice === 'skip' ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<Text style={styles.secondaryButtonText}>Not now</Text>
)}
</Pressable>
<Text style={styles.footerNote}>You can change this any time in Settings.</Text>
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
paddingHorizontal: spacing.xl
},
// Why: this decision screen cannot be dismissed with Back, so every action
// must remain reachable in landscape and with accessibility text scaling.
scrollContent: {
flexGrow: 1
},
brandRow: {
minHeight: 52,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
brandName: {
color: colors.textPrimary,
fontSize: 17,
fontWeight: '700'
},
content: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xl
},
iconSurface: {
width: 64,
height: 64,
borderRadius: radii.card,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
marginBottom: spacing.xl
},
eyebrow: {
color: colors.textMuted,
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.55,
textTransform: 'uppercase',
marginBottom: spacing.sm
},
title: {
maxWidth: 420,
color: colors.textPrimary,
fontSize: 26,
fontWeight: '700',
letterSpacing: -0.3,
textAlign: 'center'
},
body: {
maxWidth: 420,
color: colors.textSecondary,
fontSize: typography.bodySize,
lineHeight: 21,
textAlign: 'center',
marginTop: spacing.md
},
footer: {
width: '100%',
maxWidth: 420,
alignSelf: 'center',
paddingBottom: spacing.lg
},
primaryButton: {
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.surfaceBright,
paddingVertical: spacing.sm
},
primaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
secondaryButton: {
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
marginTop: spacing.xs,
paddingVertical: spacing.sm
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
buttonPressed: {
opacity: 0.72
},
buttonDisabled: {
opacity: 0.58
},
footerNote: {
color: colors.textMuted,
fontSize: typography.metaSize,
lineHeight: 18,
textAlign: 'center',
marginTop: spacing.sm
},
error: {
color: colors.statusRed,
fontSize: typography.metaSize,
lineHeight: 18,
textAlign: 'center',
marginBottom: spacing.sm
}
})
+4 -3
View File
@@ -17,7 +17,8 @@ import {
const DEFAULT_PERMISSION_STATE: NotificationPermissionState = {
granted: false,
status: 'undetermined',
canAskAgain: true
canAskAgain: true,
authorizationReflectsUserChoice: false
}
export default function NotificationsScreen() {
@@ -69,7 +70,7 @@ export default function NotificationsScreen() {
const notificationsBlocked = permissionState.status === 'denied'
const hint = notificationsBlocked
? 'Notifications are disabled in system settings.'
: 'Receive notifications when an agent task completes on your desktop.'
: 'Get notified on this device when an agent needs your input or finishes a task.'
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
@@ -82,7 +83,7 @@ export default function NotificationsScreen() {
<View style={styles.section}>
<View style={styles.row}>
<Text style={styles.rowLabel}>Push Notifications</Text>
<Text style={styles.rowLabel}>Agent notifications</Text>
<Switch
value={switchEnabled}
disabled={notificationsBlocked}
+33 -61
View File
@@ -5,14 +5,14 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state'
import {
startPairingConnectionAttempt,
type PairingConnectionAttempt
} from '../src/transport/pairing-connection-attempt'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
import type { ConnectionLogEntry, RpcResponse } from '../src/transport/types'
startPreProfilePairing,
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry } from '../src/transport/types'
import { useCloseHost } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
type Status = 'awaiting-confirm' | 'connecting' | 'error'
@@ -25,6 +25,7 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000
export default function PairConfirmScreen() {
const router = useRouter()
const closeHost = useCloseHost()
const insets = useSafeAreaInsets()
const params = useLocalSearchParams<{ code?: string }>()
const [status, setStatus] = useState<Status>('awaiting-confirm')
@@ -35,7 +36,7 @@ export default function PairConfirmScreen() {
// batch fewer setState calls when entries arrive in bursts.
const logsRef = useRef<ConnectionLogEntry[]>([])
const mountedRef = useRef(true)
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
const routeState = resolvePairConfirmRouteState(params.code)
const offer = routeState.offer
@@ -79,20 +80,12 @@ export default function PairConfirmScreen() {
setStatus('connecting')
logsRef.current = []
setLogs([])
let client: ReturnType<typeof connect> | null = null
activePairingAttemptRef.current?.dispose()
// Why: split the try/catch around the network call vs the local save
// so a Keychain or AsyncStorage failure doesn't masquerade as a
// "Cannot connect" error.
let response: RpcResponse
const attempt = startPairingConnectionAttempt({
const attempt = startPreProfilePairing({
offer,
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
closeClient: () => client?.close()
})
activePairingAttemptRef.current = attempt
try {
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
connectOptions: {
onLog: (entry) => {
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
return
@@ -100,8 +93,11 @@ export default function PairConfirmScreen() {
logsRef.current = [...logsRef.current, entry]
setLogs(logsRef.current)
}
})
response = await client.sendRequest('status.get')
}
})
activePairingAttemptRef.current = attempt
try {
const { hostId } = await attempt.result
const attemptIsCurrent = activePairingAttemptRef.current === attempt
attempt.dispose()
if (activePairingAttemptRef.current === attempt) {
@@ -110,6 +106,22 @@ export default function PairConfirmScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
// Why: re-pairing the same desktop now reuses its existing host id
// (STA-1840 dedup), so a client cached under that id from an earlier
// pairing would keep the stale endpoint/relay. Close it so the
// destination screen opens a fresh client with the newly-paired
// profile — the removeHost() path already refreshes on re-pair, and a
// brand-new host has no cached entry so this is a no-op.
closeHost(hostId)
const showNotificationOptIn = await shouldPresentNotificationOptIn()
if (!mountedRef.current) {
return
}
router.replace(
showNotificationOptIn
? { pathname: '/notification-opt-in', params: { hostId } }
: `/h/${hostId}`
)
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
@@ -125,47 +137,7 @@ export default function PairConfirmScreen() {
setErrorMessage(
timedOut
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
: 'Cannot connect — check that your computer is on the same network'
)
return
}
if (!response.ok) {
if (!mountedRef.current) {
return
}
setStatus('error')
setErrorMessage(
response.error.code === 'unauthorized'
? 'Authentication failed — token may be expired'
: `Server error: ${response.error.message}`
)
return
}
try {
const hostId = `host-${Date.now()}`
const hostName = await getNextHostName()
await saveHost({
id: hostId,
name: hostName,
endpoint: offer.endpoint,
deviceToken: offer.deviceToken,
publicKeyB64: offer.publicKeyB64,
lastConnected: Date.now()
})
if (!mountedRef.current) {
return
}
router.replace(`/h/${hostId}`)
} catch (err) {
if (!mountedRef.current) {
return
}
console.warn('[pair-confirm] save failed', err)
setStatus('error')
setErrorMessage(
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
)
}
}
+33 -66
View File
@@ -14,15 +14,15 @@ import { useRouter } from 'expo-router'
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
import {
startPairingConnectionAttempt,
type PairingConnectionAttempt
} from '../src/transport/pairing-connection-attempt'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types'
startPreProfilePairing,
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { useCloseHost } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
// route surfaces as a real error with the log visible instead of a
@@ -44,6 +44,7 @@ function Step({ number, text }: { number: number; text: string }) {
export default function PairScanScreen() {
const router = useRouter()
const closeHost = useCloseHost()
const insets = useSafeAreaInsets()
const [permission, requestPermission] = useCameraPermissions()
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
@@ -54,7 +55,7 @@ export default function PairScanScreen() {
const logsRef = useRef<ConnectionLogEntry[]>([])
const processingRef = useRef(false)
const mountedRef = useRef(true)
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
const setPairScanRootRef = useCallback((node: View | null): void => {
if (node !== null) {
@@ -123,21 +124,12 @@ export default function PairScanScreen() {
setStatus('connecting')
logsRef.current = []
setLogs([])
let client: ReturnType<typeof connect> | null = null
activePairingAttemptRef.current?.dispose()
// Why: split the try/catch around the network call vs the local save
// so a Keychain or AsyncStorage failure doesn't masquerade as a
// "Cannot connect — same network?" error. Pairing reached the
// desktop fine; the failure is local persistence.
let response: RpcResponse
const attempt = startPairingConnectionAttempt({
const attempt = startPreProfilePairing({
offer,
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
closeClient: () => client?.close()
})
activePairingAttemptRef.current = attempt
try {
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
connectOptions: {
onLog: (entry) => {
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
return
@@ -145,8 +137,11 @@ export default function PairScanScreen() {
logsRef.current = [...logsRef.current, entry]
setLogs(logsRef.current)
}
})
response = await client.sendRequest('status.get')
}
})
activePairingAttemptRef.current = attempt
try {
const { hostId } = await attempt.result
const attemptIsCurrent = activePairingAttemptRef.current === attempt
attempt.dispose()
if (activePairingAttemptRef.current === attempt) {
@@ -155,6 +150,22 @@ export default function PairScanScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
// Why: re-pairing the same desktop now reuses its existing host id
// (STA-1840 dedup), so a client cached under that id from an earlier
// pairing would keep the stale endpoint/relay. Close it so the
// destination screen opens a fresh client with the newly-paired
// profile — the removeHost() path already refreshes on re-pair, and a
// brand-new host has no cached entry so this is a no-op.
closeHost(hostId)
const showNotificationOptIn = await shouldPresentNotificationOptIn()
if (!mountedRef.current) {
return
}
router.replace(
showNotificationOptIn
? { pathname: '/notification-opt-in', params: { hostId } }
: `/h/${hostId}`
)
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
@@ -170,51 +181,7 @@ export default function PairScanScreen() {
setErrorMessage(
timedOut
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
: 'Cannot connect — check that your computer is on the same network'
)
processingRef.current = false
return
}
if (!response.ok) {
if (!mountedRef.current) {
return
}
if (response.error.code === 'unauthorized') {
setStatus('error')
setErrorMessage('Authentication failed — token may be expired')
processingRef.current = false
return
}
setStatus('error')
setErrorMessage(`Server error: ${response.error.message}`)
processingRef.current = false
return
}
try {
const hostId = `host-${Date.now()}`
const hostName = await getNextHostName()
await saveHost({
id: hostId,
name: hostName,
endpoint: offer.endpoint,
deviceToken: offer.deviceToken,
publicKeyB64: offer.publicKeyB64,
lastConnected: Date.now()
})
if (!mountedRef.current) {
return
}
router.replace(`/h/${hostId}`)
} catch (err) {
if (!mountedRef.current) {
return
}
console.warn('[pair] save failed', err)
setStatus('error')
setErrorMessage(
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
)
processingRef.current = false
}
+2
View File
@@ -9,6 +9,7 @@
"ios": "expo run:ios",
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "oxlint",
"format": "oxfmt --write .",
"format:check": "oxfmt --check .",
@@ -17,6 +18,7 @@
"start:emulator": "node scripts/start-emulator.mjs"
},
"dependencies": {
"@noble/hashes": "1.8.0",
"@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio",
"@react-native-async-storage/async-storage": "^2.2.0",
"@xterm/addon-unicode11": "0.10.0-beta.285",
+9
View File
@@ -11,6 +11,9 @@ importers:
.:
dependencies:
'@noble/hashes':
specifier: 1.8.0
version: 1.8.0
'@orca/expo-two-way-audio':
specifier: file:./packages/expo-two-way-audio
version: file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -1878,6 +1881,10 @@ packages:
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3':
resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==}
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio':
resolution: {directory: packages/expo-two-way-audio, type: directory}
peerDependencies:
@@ -8835,6 +8842,8 @@ snapshots:
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3':
optional: true
'@noble/hashes@1.8.0': {}
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
dependencies:
expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache'
// Why: AC #8498 guarantees a reconnect refetch writes through the
// same cache path the host detail screen seeds from, so a reconnect can't
// serve a stale snapshot. This unit pins the write-through contract.
describe('worktree-cache write-through', () => {
it('returns the most-recently written snapshot, not a stale one', () => {
const hostId = 'host-write-through'
const stale = [{ worktreeId: 'a', name: 'stale' }]
const fresh = [
{ worktreeId: 'a', name: 'fresh' },
{ worktreeId: 'b', name: 'added' }
]
setCachedWorktrees(hostId, stale)
expect(getCachedWorktrees(hostId)).toEqual(stale)
// Why: simulates the reconnect refetch write-through — the fresh
// worktree.ps snapshot must fully replace the poisoned cache entry.
setCachedWorktrees(hostId, fresh)
expect(getCachedWorktrees(hostId)).toEqual(fresh)
expect(getCachedWorktrees(hostId)).not.toEqual(stale)
})
it('exposes a fresh snapshot to a remounting screen after reconnect', () => {
// Why: the host detail screen reads getCachedWorktrees(hostId)
// on (re)mount as its initialCache. A reconnect that writes
// through must therefore surface here instead of the pre-reconnect data.
const hostId = 'host-remount'
setCachedWorktrees(hostId, [{ worktreeId: 'old', name: 'pre-reconnect' }])
// Reconnect refetch lands a fresh snapshot and writes it through.
const reconnected = [
{ worktreeId: 'old', name: 'post-reconnect' },
{ worktreeId: 'new', name: 'now-visible' }
]
setCachedWorktrees(hostId, reconnected)
// A fresh screen mount reads the cache — must see the connected set.
expect(getCachedWorktrees(hostId)).toEqual(reconnected)
})
})
+32 -3
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'
import { useRef, type ReactNode } from 'react'
import { ActivityIndicator, View, Text, Pressable, StyleSheet } from 'react-native'
import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
@@ -13,6 +13,7 @@ export type ActionSheetAction = {
hint?: string
loading?: boolean
skipAutoClose?: boolean
closeBeforePress?: boolean
onPress: () => void
}
@@ -107,9 +108,37 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content
}
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
const pendingActionRef = useRef<(() => void) | null>(null)
const sequencedActions = actions.map((action) =>
action.closeBeforePress
? {
...action,
onPress: () => {
pendingActionRef.current = action.onPress
}
}
: action
)
return (
<BottomDrawer visible={visible} onClose={onClose} dragContentToDismiss>
<ActionSheetContent title={title} message={message} actions={actions} onClose={onClose} />
<BottomDrawer
visible={visible}
onClose={onClose}
onAfterClose={() => {
// Why: iOS cannot present a second native modal until the action
// sheet's native window has fully unmounted.
const pendingAction = pendingActionRef.current
pendingActionRef.current = null
pendingAction?.()
}}
dragContentToDismiss
>
<ActionSheetContent
title={title}
message={message}
actions={sequencedActions}
onClose={onClose}
/>
</BottomDrawer>
)
}
+6 -1
View File
@@ -40,6 +40,7 @@ const TOP_SCROLL_EPSILON = 1
type Props = {
visible: boolean
onClose: () => void
onAfterClose?: () => void
children: ReactNode
dragContentToDismiss?: boolean
contentScrollable?: boolean
@@ -49,6 +50,7 @@ type Props = {
export function BottomDrawer({
visible,
onClose,
onAfterClose,
children,
dragContentToDismiss = true,
contentScrollable = true,
@@ -73,7 +75,10 @@ export function BottomDrawer({
<MountedBottomDrawer
visible={visible}
onClose={onClose}
onHidden={() => setMounted(false)}
onHidden={() => {
setMounted(false)
onAfterClose?.()
}}
dragContentToDismiss={dragContentToDismiss}
contentScrollable={contentScrollable}
zIndex={zIndex}
+102
View File
@@ -0,0 +1,102 @@
import { ChevronRight, Monitor } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { ConnectionVerdict } from '../transport/connection-health'
import { verdictDisplayLabel } from '../transport/connection-health'
import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label'
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from '../transport/types'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { StatusDot } from './StatusDot'
export function MobileHostCard(props: {
host: HostProfile
state: ConnectionState
verdict: ConnectionVerdict
path: MobileConnectionPath
worktreeCounts?: { total: number; active: number }
onPress: () => void
onLongPress: () => void
}) {
const connected = props.state === 'connected'
const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
const worktreeSummary = props.worktreeCounts
? `${props.worktreeCounts.total} worktree${props.worktreeCounts.total === 1 ? '' : 's'}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}`
: null
return (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
onPress={props.onPress}
onLongPress={props.onLongPress}
delayLongPress={400}
>
<View style={styles.icon}>
<Monitor size={20} color={connected ? colors.textPrimary : colors.textSecondary} />
</View>
<View style={styles.main}>
<Text
style={[styles.name, !connected && { color: colors.textSecondary }]}
numberOfLines={1}
>
{props.host.name}
</Text>
<View style={styles.meta}>
<StatusDot state={props.state} verdict={props.verdict} />
<Text style={[styles.metaText, isError && { color: colors.statusRed }]} numberOfLines={1}>
{verdictDisplayLabel(props.verdict)}
{connected ? ` · ${mobileConnectionPathLabel(props.path)}` : ''}
</Text>
</View>
{connected && worktreeSummary ? (
<Text style={styles.worktreeMetaText} numberOfLines={1}>
{worktreeSummary}
</Text>
) : null}
{props.verdict.kind === 'unreachable' && !props.host.relay ? (
<Text style={styles.discoveryHint} numberOfLines={2}>
Update desktop Orca and sign in to connect from anywhere
</Text>
) : null}
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
)
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: 12,
borderRadius: radii.card,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
cardPressed: { backgroundColor: colors.bgRaised },
icon: {
width: 46,
height: 46,
borderRadius: 13,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
marginRight: 14
},
main: { flex: 1, minWidth: 0, marginRight: spacing.sm },
name: { color: colors.textPrimary, fontSize: 15, fontWeight: '600', lineHeight: 20 },
meta: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3, minWidth: 0 },
metaText: { flex: 1, fontSize: 12, color: colors.textSecondary },
worktreeMetaText: {
marginTop: 2,
marginLeft: spacing.xl,
fontSize: 12,
color: colors.textMuted
},
discoveryHint: {
marginTop: spacing.xs,
fontSize: 11,
lineHeight: 15,
color: colors.textMuted
}
})
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import {
@@ -67,6 +68,26 @@ describe('rpc wrappers', () => {
expect(client.calls[0]).toEqual({ method: 'speech.models.list', params: null })
})
it('retries the idempotent setup read once after logical-client cutover', async () => {
const setup: MobileSpeechSetup = { enabled: false, selectedModelId: '', models: [] }
const sendRequest = vi
.fn()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(ok(setup))
await expect(fetchDictationSetup({ sendRequest })).resolves.toEqual(setup)
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'speech.models.list', null)
expect(sendRequest).toHaveBeenNthCalledWith(2, 'speech.models.list', null)
})
it('does not retry unrelated setup-read failures', async () => {
const sendRequest = vi.fn().mockRejectedValue(new Error('offline'))
await expect(fetchDictationSetup({ sendRequest })).rejects.toThrow('offline')
expect(sendRequest).toHaveBeenCalledOnce()
})
it('starts a download', async () => {
const client = clientWith([ok({ started: true })])
await downloadDictationModel(client, 'm1')
+15 -1
View File
@@ -1,5 +1,6 @@
import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcSuccess } from '../transport/types'
export type MobileSpeechSetup = RuntimeSpeechSetupState
@@ -31,7 +32,7 @@ export function isDictationSetupRequiredError(message: string): boolean {
export async function fetchDictationSetup(
client: Pick<RpcClient, 'sendRequest'>
): Promise<MobileSpeechSetup> {
const response = await client.sendRequest('speech.models.list', null)
const response = await fetchDictationSetupResponse(client)
if (!response.ok) {
if (isLegacyDesktopSpeechSetupError(response.error)) {
throw new Error(LEGACY_DESKTOP_SPEECH_SETUP_MESSAGE)
@@ -41,6 +42,19 @@ export async function fetchDictationSetup(
return (response as RpcSuccess).result as MobileSpeechSetup
}
async function fetchDictationSetupResponse(client: Pick<RpcClient, 'sendRequest'>) {
try {
return await client.sendRequest('speech.models.list', null)
} catch (error) {
if (!(error instanceof LogicalClientCutoverError)) {
throw error
}
// Why: this read can safely repeat on the authenticated replacement; mutation
// RPCs must still surface cutover so callers never replay unknown commits.
return client.sendRequest('speech.models.list', null)
}
}
export async function downloadDictationModel(
client: Pick<RpcClient, 'sendRequest'>,
modelId: string
@@ -0,0 +1,106 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import EditHostScreen from '../app/h/[hostId]/edit'
const dependencies = vi.hoisted(() => ({
back: vi.fn(),
forceReconnectHost: vi.fn(),
loadHosts: vi.fn(),
primeHosts: vi.fn(),
updateHostNameAndEndpoint: vi.fn()
}))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
KeyboardAvoidingView: 'KeyboardAvoidingView',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
}))
vi.mock('expo-router', () => ({
useLocalSearchParams: () => ({ hostId: 'host-1' }),
useRouter: () => ({ back: dependencies.back })
}))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'ChevronLeft'
}))
vi.mock('./transport/host-store', () => ({
loadHosts: dependencies.loadHosts,
updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint
}))
vi.mock('./transport/client-context', () => ({
useForceReconnect: () => dependencies.forceReconnectHost,
usePrimeHosts: () => dependencies.primeHosts
}))
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(EditHostScreen))
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('Edit host route did not render')
}
return renderer
}
describe('edit host route accessibility', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
dependencies.loadHosts.mockReset().mockResolvedValue([
{
id: 'host-1',
name: 'Desk',
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'token',
publicKeyB64: 'public-key',
lastConnected: 1
}
])
})
afterEach(() => {
vi.restoreAllMocks()
})
it('exposes stable accessible names for both editable fields', async () => {
const renderer = await renderEditHostRoute()
const inputs = renderer.root.findAllByType('TextInput')
expect(inputs).toHaveLength(2)
expect(inputs.map((input) => input.props.accessibilityLabel)).toEqual(['Name', 'Address'])
act(() => renderer.unmount())
})
})
+325
View File
@@ -0,0 +1,325 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import EditHostScreen from '../app/h/[hostId]/edit'
const dependencies = vi.hoisted(() => ({
back: vi.fn(),
forceReconnectHost: vi.fn(),
loadHosts: vi.fn(),
primeHosts: vi.fn(),
updateHostNameAndEndpoint: vi.fn(),
hostId: 'host-1' as string | undefined
}))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
KeyboardAvoidingView: 'KeyboardAvoidingView',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
}))
vi.mock('expo-router', () => ({
useLocalSearchParams: () => ({ hostId: dependencies.hostId }),
useRouter: () => ({ back: dependencies.back })
}))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'ChevronLeft'
}))
vi.mock('./transport/host-store', () => ({
loadHosts: dependencies.loadHosts,
updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint
}))
vi.mock('./transport/client-context', () => ({
useForceReconnect: () => dependencies.forceReconnectHost,
usePrimeHosts: () => dependencies.primeHosts
}))
const HOST_FIXTURE = {
id: 'host-1',
name: 'Desk',
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'token',
publicKeyB64: 'public-key',
lastConnected: 1
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(EditHostScreen))
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('Edit host route did not render')
}
return renderer
}
function setFieldValue(
renderer: ReactTestRenderer,
accessibilityLabel: 'Name' | 'Address',
value: string
): void {
const input = renderer.root
.findAllByType('TextInput')
.find((node) => node.props.accessibilityLabel === accessibilityLabel)
if (!input) {
throw new Error(`${accessibilityLabel} input not found`)
}
act(() => {
input.props.onChangeText(value)
})
}
function findSaveButton(renderer: ReactTestRenderer) {
const button = renderer.root
.findAllByType('Pressable')
.find((node) => node.props.accessibilityLabel === 'Save host')
if (!button) {
throw new Error('Save button not found')
}
return button
}
async function pressSave(renderer: ReactTestRenderer): Promise<void> {
const button = findSaveButton(renderer)
await act(async () => {
button.props.onPress()
await Promise.resolve()
await Promise.resolve()
})
}
function findText(renderer: ReactTestRenderer, match: string): boolean {
return renderer.root.findAllByType('Text').some((node) => {
const children = node.props.children
if (typeof children === 'string') {
return children.includes(match)
}
if (Array.isArray(children)) {
return children.join('').includes(match)
}
return false
})
}
describe('edit host handleSave', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
dependencies.hostId = 'host-1'
dependencies.back.mockReset()
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE])
dependencies.primeHosts.mockReset()
dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('rename-only save updates only the name and does not reconnect', async () => {
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Name', 'Home Desk')
await pressSave(renderer)
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
name: 'Home Desk'
})
expect(dependencies.forceReconnectHost).not.toHaveBeenCalled()
expect(dependencies.back).toHaveBeenCalledTimes(1)
act(() => renderer.unmount())
})
it('endpoint-only save updates only the endpoint and reconnects', async () => {
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
await pressSave(renderer)
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
endpoint: 'ws://192.168.1.20:6768'
})
expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1')
expect(dependencies.back).toHaveBeenCalledTimes(1)
act(() => renderer.unmount())
})
it('saves name and endpoint together in one call, then reconnects', async () => {
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Name', 'Home Desk')
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
await pressSave(renderer)
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledTimes(1)
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
name: 'Home Desk',
endpoint: 'ws://192.168.1.20:6768'
})
expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1')
expect(dependencies.back).toHaveBeenCalledTimes(1)
act(() => renderer.unmount())
})
it('navigates back without saving or reconnecting when nothing changed', async () => {
const renderer = await renderEditHostRoute()
await pressSave(renderer)
expect(dependencies.updateHostNameAndEndpoint).not.toHaveBeenCalled()
expect(dependencies.forceReconnectHost).not.toHaveBeenCalled()
expect(dependencies.back).toHaveBeenCalledTimes(1)
act(() => renderer.unmount())
})
it('shows the error and does not navigate back or reconnect when the save rejects', async () => {
dependencies.updateHostNameAndEndpoint.mockRejectedValueOnce(new Error('Host not found'))
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Name', 'Home Desk')
await pressSave(renderer)
expect(findText(renderer, 'Host not found')).toBe(true)
expect(dependencies.forceReconnectHost).not.toHaveBeenCalled()
expect(dependencies.back).not.toHaveBeenCalled()
act(() => renderer.unmount())
})
it('still navigates back when the post-save re-prime fails', async () => {
dependencies.loadHosts
.mockResolvedValueOnce([HOST_FIXTURE])
.mockRejectedValueOnce(new Error('boom'))
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Name', 'Home Desk')
await pressSave(renderer)
expect(dependencies.primeHosts).not.toHaveBeenCalled()
expect(dependencies.back).toHaveBeenCalledTimes(1)
act(() => renderer.unmount())
})
it('still navigates back and shows no error when the post-save reconnect rejects', async () => {
dependencies.forceReconnectHost.mockRejectedValueOnce(new Error('connect failed'))
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
await pressSave(renderer)
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1')
expect(dependencies.back).toHaveBeenCalledTimes(1)
expect(findText(renderer, 'connect failed')).toBe(false)
act(() => renderer.unmount())
})
it('ignores a second Save trigger while a save is already in flight', async () => {
let resolveSave: () => void = () => {}
dependencies.updateHostNameAndEndpoint.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveSave = resolve
})
)
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Name', 'Home Desk')
const button = findSaveButton(renderer)
await act(async () => {
button.props.onPress()
button.props.onPress()
await Promise.resolve()
})
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledTimes(1)
await act(async () => {
resolveSave()
await Promise.resolve()
await Promise.resolve()
})
act(() => renderer.unmount())
})
})
describe('edit host load() error states', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
dependencies.hostId = 'host-1'
dependencies.back.mockReset()
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE])
dependencies.primeHosts.mockReset()
dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('shows "Missing host." when hostId is absent', async () => {
dependencies.hostId = undefined
const renderer = await renderEditHostRoute()
expect(findText(renderer, 'Missing host.')).toBe(true)
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
act(() => renderer.unmount())
})
it('shows a not-saved message when the host is not in the loaded list', async () => {
dependencies.loadHosts.mockReset().mockResolvedValue([])
const renderer = await renderEditHostRoute()
expect(findText(renderer, 'This host was removed from this phone.')).toBe(true)
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
act(() => renderer.unmount())
})
it('surfaces the error message when loadHosts rejects', async () => {
dependencies.loadHosts.mockReset().mockRejectedValue(new Error('storage unreadable'))
const renderer = await renderEditHostRoute()
expect(findText(renderer, 'storage unreadable')).toBe(true)
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
act(() => renderer.unmount())
})
})
@@ -1,9 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import {
getNotificationPermissionState,
setScheduledNotificationsMaxForTests,
subscribeToDesktopNotifications
} from './mobile-notifications'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
@@ -17,13 +20,49 @@ vi.mock('expo-notifications', () => ({
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios' }
Platform: { OS: 'ios', Version: 18 }
}))
// Why: mobile-notifications now persists the catch-up watermark to
// AsyncStorage. The package isn't resolvable in the node test env (other
// mobile tests mock it the same way), so we provide a no-op mock.
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined)
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
beforeEach(() => {
Object.assign(Platform, { OS: 'ios', Version: 18 })
})
describe('getNotificationPermissionState', () => {
it.each([
{ os: 'android', version: 32, expected: false },
{ os: 'android', version: 33, expected: true },
{ os: 'ios', version: 18, expected: true }
])(
'reports whether a granted $os $version authorization reflects user choice',
async ({ os, version, expected }) => {
Object.assign(Platform, { OS: os, Version: version })
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
await expect(getNotificationPermissionState()).resolves.toMatchObject({
granted: true,
authorizationReflectsUserChoice: expected
})
}
)
})
describe('subscribeToDesktopNotifications', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -316,3 +355,288 @@ describe('subscribeToDesktopNotifications', () => {
}
})
})
// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the
// client must fetch missed notifications from its watermark and push exactly
// the ones it had not yet delivered — never re-pushing an already-delivered id.
describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
const AsyncStorageMock = vi.mocked(AsyncStorage)
beforeEach(() => {
vi.clearAllMocks()
AsyncStorageMock.getItem.mockResolvedValue(null)
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function makeClient() {
let onData: ((data: unknown) => void) | null = null
const sentRequests: { method: string; params: unknown }[] = []
const client = {
subscribe: vi.fn((_method: string, _params: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(
async (method: string, _params: unknown = {}) =>
({
ok: true,
result: method === 'notifications.getMissedSince' ? { notifications: [] } : undefined
}) as never
)
}
// Why: onData is captured live via a getter (not destructured) because the
// subscribe mock assigns it asynchronously as a side effect of
// subscribeToDesktopNotifications calling client.subscribe.
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
sentRequests
}
}
it('does not fetch missed notifications on the first (cold-open) ready', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
// First ready = cold open.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
expect(sub.client.sendRequest).not.toHaveBeenCalledWith(
'notifications.getMissedSince',
expect.anything()
)
})
it('fetches only notifications after the delivered watermark (idempotent catch-up)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
const sub = makeClient()
// The desktop honours the watermark: only seq 10 (agent:missed) is returned
// because seq 11 (agent:dup) was already delivered on the live stream and
// advanced lastDeliveredSeq to 11. So the replay never re-includes it.
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'missed',
body: 'b',
notificationId: 'agent:missed',
notificationSeq: 10
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
// First ready = cold open (no fetch).
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// Live stream already delivered agent:dup (seq 11) before reap.
sub.onData?.({
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 11
})
await flushAsync()
// Reconnect ready → fetchMissed sends the watermark (11).
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
await flushAsync()
// The watermark passed to getMissedSince is the delivered seq.
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 11 })
// Only agent:missed was pushed; agent:dup appears exactly once (live only).
const scheduledIds = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map(
(call) =>
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
)
expect(scheduledIds).toEqual(['agent:dup', 'agent:missed'])
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
})
it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
// Simulate the bounded-buffer edge: the desktop returns seq 11 again
// (already delivered live) alongside a new seq 12.
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 11
},
{
type: 'notification',
title: 'new',
body: 'b',
notificationId: 'agent:new',
notificationSeq: 12
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// Live stream delivered agent:dup (seq 11) before reap.
sub.onData?.({
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 11
})
await flushAsync()
// Reconnect replay re-includes seq 11 (must be dropped) + new seq 12.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
await flushAsync()
const scheduledIds = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map(
(call) =>
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
)
expect(scheduledIds).toEqual(['agent:dup', 'agent:new'])
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
})
it('persists the highest delivered seq so a later reconnect resumes from it', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// Live stream delivers seq 5.
sub.onData?.({
type: 'notification',
title: 't',
body: 'b',
notificationId: 'agent:live',
notificationSeq: 5
})
await flushAsync()
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:mobileNotificationsLastSeq:host-1',
'5'
)
})
// Why: a replay-ONLY delivery (nothing arrived live first) must still advance
// and persist the watermark. This is the exact case the seq/notificationSeq
// field mismatch broke — the desktop replay path returns `notificationSeq`
// (matching the live fan-out), so the client watermark moves and the next
// reconnect resumes from it instead of re-fetching from 0.
it('advances + persists the watermark from a replay-only delivery (#8129 field-mismatch regression)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
// Desktop replay returns events keyed by notificationSeq (the fixed shape).
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'missed',
body: 'b',
notificationId: 'agent:missed',
notificationSeq: 8
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// First reconnect → replay delivers seq 8 (no prior live delivery).
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
await flushAsync()
// Watermark advanced to the replayed seq and was persisted.
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:mobileNotificationsLastSeq:host-1',
'8'
)
// Second reconnect resumes from the advanced watermark, not 0.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
const missedCalls = vi
.mocked(sub.client.sendRequest)
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 8 })
})
})
@@ -3,6 +3,12 @@ import { Platform } from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
import {
createSeenNotificationGuard,
loadLastSeenSeq,
saveLastSeenSeq,
seenKeyForEvent
} from './notification-reconnect-catchup'
type NotificationEvent = {
type: 'notification'
@@ -11,11 +17,16 @@ type NotificationEvent = {
body: string
worktreeId?: string
notificationId?: string
// Mirrors the desktop-assigned MobileNotificationEvent.notificationSeq used
// for reconnect catch-up (#8129). Optional because older runtimes / non-
// replay events may omit it.
notificationSeq?: number
}
type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
}
type SubscribeResult = {
@@ -71,6 +82,7 @@ export type NotificationPermissionState = {
granted: boolean
status: string
canAskAgain: boolean
authorizationReflectsUserChoice: boolean
}
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
@@ -78,7 +90,11 @@ export async function getNotificationPermissionState(): Promise<NotificationPerm
return {
granted: status === 'granted',
status,
canAskAgain
canAskAgain,
// Why: Android before API 33 has no runtime notification permission, so
// Expo's default "granted" state is capability evidence, not user consent.
authorizationReflectsUserChoice:
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
}
}
@@ -223,18 +239,105 @@ async function dismissLocalNotification(
// Why: each host connection gets its own notification subscription. When the
// connection drops, the unsubscribe function cleans up the streaming RPC.
// On reconnect the same subscribe stream is re-established by the RPC client;
// we use its `ready` event to trigger catch-up (#8129): fetch notifications
// dispatched while the socket was reaped, watermarked by the last seq we
// already delivered so the desktop never re-sends an already-pushed one.
// Returns an unsubscribe function.
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
configureNotificationChannel()
let subscriptionId: string | null = null
let disposed = false
// Highest seq delivered on the live stream or replay for this connection.
// Persisted per-host so a cold app start still resumes from the right cut.
let lastDeliveredSeq = 0
// Why: per-connection dedup guard applied ONLY to the replay path
// (fetchMissed), never the live stream. The desktop already guarantees the
// replay cannot contain an event with seq <= lastDeliveredSeq (both live and
// replay advance the same watermark), so live + replay never overlap there.
// This set is defense-in-depth: if the desktop's bounded buffer evicted an
// old entry and a reconnect re-fetches across a boundary, an id delivered in
// the same connection isn't pushed twice. Bounded (RECENTLY_SEEN_CAP) so a
// long-lived session can't grow without limit.
const seenReplay = createSeenNotificationGuard()
function deliverLive(
type: 'notification' | 'dismiss',
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
if (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) {
lastDeliveredSeq = event.notificationSeq
void saveLastSeenSeq(hostId, lastDeliveredSeq)
}
// Why (#8129 dedup): mark the event seen on EVERY delivery path (live AND
// replay) so a replay that re-includes an id already pushed live in this
// connection is dropped instead of double-pushed. fetchMissed also
// pre-checks seenReplay, but without this the live path never populated it.
const key = seenKeyForEvent(event)
if (key) {
seenReplay.add(key)
}
if (type === 'notification') {
return showLocalNotification(event as NotificationEvent, hostId)
}
return dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
// Why: on a reconnect `ready` the desktop has already dispatched whatever we
// missed; ask for it from our persisted watermark. Because the desktop cuts
// by seq > lastSeenSeq this is idempotent — we only ever get events we have
// not delivered before. The seenReplay guard is a second layer so a replay
// that somehow re-includes an id already delivered this connection is
// dropped instead of double-pushed.
async function fetchMissed(): Promise<void> {
if (disposed) {
return
}
const missed = await client
.sendRequest('notifications.getMissedSince', { lastSeenSeq: lastDeliveredSeq })
.then((response) => {
if (!response.ok) {
return []
}
const result = response.result as { notifications?: unknown[] } | undefined
return Array.isArray(result?.notifications) ? result.notifications : []
})
.catch(() => [])
for (const raw of missed) {
const event = raw as NotificationEvent | DismissNotificationEvent
const key = seenKeyForEvent(event)
if (key && seenReplay.has(key)) {
continue
}
if (key) {
seenReplay.add(key)
}
if (event.type === 'notification') {
await deliverLive('notification', event)
} else if (event.type === 'dismiss') {
await deliverLive('dismiss', event)
}
}
}
// Why: lazily seed the watermark from durable storage on first use so we
// don't block subscribe() on an AsyncStorage read. The first `ready` (cold
// open) does NOT need catch-up — the live stream starts fresh; only
// subsequent reconnect `ready` events fetch missed notifications.
let watermarkLoaded = false
void loadLastSeenSeq(hostId).then((seq) => {
lastDeliveredSeq = Math.max(lastDeliveredSeq, seq)
watermarkLoaded = true
})
function unsubscribeServer(id: string) {
if (client.getState() === 'connected') {
client.sendRequest('notifications.unsubscribe', { subscriptionId: id }).catch(() => {})
}
}
let reconnectReadyCount = 0
const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
const event = data as
| NotificationEvent
@@ -243,9 +346,18 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
| { type: 'end' }
if (event.type === 'ready') {
subscriptionId = (event as SubscribeResult).subscriptionId
reconnectReadyCount += 1
if (disposed) {
unsubscribeServer(subscriptionId)
unsubscribeStream()
return
}
// Why: first ready is the cold-open live stream — no catch-up needed.
// Every later ready is a reconnect; fetch what we missed from the
// watermark. Guard on watermarkLoaded so a fast reconnect doesn't
// fetch from a stale 0 watermark (which would re-push everything).
if (reconnectReadyCount > 1 && watermarkLoaded) {
void fetchMissed()
}
return
}
@@ -259,9 +371,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
return
}
if (event.type === 'notification') {
void showLocalNotification(event as NotificationEvent, hostId)
void deliverLive('notification', event as NotificationEvent)
} else if (event.type === 'dismiss') {
void dismissLocalNotification(event as DismissNotificationEvent, hostId)
void deliverLive('dismiss', event as DismissNotificationEvent)
}
})
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
readPushNotificationsPreference,
savePushNotificationsEnabled
} from '../storage/preferences'
import { getNotificationPermissionState } from './mobile-notifications'
import { shouldPresentNotificationOptIn } from './notification-opt-in-gate'
vi.mock('../storage/preferences', () => ({
readPushNotificationsPreference: vi.fn(),
savePushNotificationsEnabled: vi.fn()
}))
vi.mock('./mobile-notifications', () => ({
getNotificationPermissionState: vi.fn()
}))
describe('notification opt-in gate', () => {
beforeEach(() => {
vi.mocked(readPushNotificationsPreference).mockReset()
vi.mocked(savePushNotificationsEnabled).mockReset()
vi.mocked(getNotificationPermissionState).mockReset()
})
it('presents only when the local preference and system decision are both unset', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: false,
status: 'undetermined',
canAskAgain: true,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
})
it.each([true, false])('preserves an existing %s mobile preference', async (value) => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value, loaded: true })
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(getNotificationPermissionState).not.toHaveBeenCalled()
})
it('adopts existing system authorization without prompting', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: true
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true)
})
it('still presents when a pre-Android 13 default grant is not an opt-in decision', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
})
it('skips the gate when iOS has already denied permission', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: false,
status: 'denied',
canAskAgain: false,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(false)
})
it('does not block startup when storage or permission checks fail', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: false })
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockRejectedValue(new Error('unavailable'))
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
})
})
@@ -0,0 +1,36 @@
import {
readPushNotificationsPreference,
savePushNotificationsEnabled
} from '../storage/preferences'
import { getNotificationPermissionState } from './mobile-notifications'
export async function shouldPresentNotificationOptIn(): Promise<boolean> {
const preference = await readPushNotificationsPreference()
if (!preference.loaded || preference.value !== null) {
return false
}
try {
const permission = await getNotificationPermissionState()
if (permission.granted) {
if (!permission.authorizationReflectsUserChoice) {
return true
}
// Why: an already-authorized device should inherit the useful default
// without seeing an onboarding decision it has effectively made.
await savePushNotificationsEnabled(true)
return false
}
if (permission.status === 'denied' || !permission.canAskAgain) {
// Why: iOS cannot show its authorization prompt again, so a blocking
// onboarding screen would be a dead end; Settings remains the recovery.
await savePushNotificationsEnabled(false)
return false
}
return permission.status === 'undetermined'
} catch {
// Why: permission or persistence failures must not trap startup behind a
// decision screen whose result cannot be applied reliably.
return false
}
}
@@ -0,0 +1,97 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted
// from mobile-notifications.ts so that file stays under its max-lines budget.
// The highest desktop notification seq this device has delivered is persisted
// per-host so it survives app restarts. On reconnect we send it to
// notifications.getMissedSince as the catch-up watermark — the desktop then
// returns only notifications dispatched after it, so we never re-push a
// notification we already delivered. The in-memory seen-set is a second guard
// against double-delivery for events that arrive on both the live stream and a
// replay (e.g. a brief liveness spell before a reap).
const LAST_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:'
function lastSeqStorageKey(hostId: string): string {
return LAST_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
}
export async function loadLastSeenSeq(hostId: string): Promise<number> {
try {
const raw = await AsyncStorage.getItem(lastSeqStorageKey(hostId))
const parsed = raw == null ? 0 : Number(raw)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
} catch {
return 0
}
}
export async function saveLastSeenSeq(hostId: string, seq: number): Promise<void> {
if (!Number.isFinite(seq) || seq <= 0) {
return
}
try {
await AsyncStorage.setItem(lastSeqStorageKey(hostId), String(seq))
} catch {
// Why: persisting the watermark is best-effort. If it fails (or lags), the
// stored value stays BELOW what we delivered, so a later cold start can
// re-fetch — and, once the in-memory seen-set is gone, re-show — an already
// delivered notification. That's the accepted at-least-once trade-off;
// within a live session the in-memory watermark is authoritative, so only
// post-restart reconnects are affected.
}
}
// Why: bounded in-memory dedup window for notificationIds/dismiss ids observed
// on the current connection. The desktop already dedupes by seq on replay, but
// a socket that flickers background→foreground→background can deliver an event
// on the live stream and again in a replay; the seen-set guarantees each
// notificationId maps to at most one local push for the connection lifetime.
// Bounded so a long-lived session can't grow without limit — a 2x superset of
// the desktop's 256-entry replay buffer and the 256 scheduled-notification cap.
const RECENTLY_SEEN_CAP = 512
export function createSeenNotificationGuard(): {
has: (id: string) => boolean
add: (id: string) => void
} {
const seen = new Set<string>()
return {
has(id: string): boolean {
return seen.has(id)
},
add(id: string): void {
seen.add(id)
if (seen.size > RECENTLY_SEEN_CAP) {
// Why: insertion order; the oldest entries are first. Drop one to stay
// bounded without disturbing the more-recently-relevant keys.
const first = seen.values().next().value
if (first !== undefined) {
seen.delete(first)
}
}
}
}
}
// Why: key for the replay dedup guard. Uses notificationId when present, but
// disambiguates by seq so a legitimate live re-delivery of the same id at a
// NEW seq (content refresh, allowed by the existing behaviour) is NOT treated
// as a duplicate, while a replay re-returning the SAME id+seq already delivered
// live is suppressed. Replay events always carry a seq (the desktop assigns
// one), so the guard is effective on the reconnect path.
export function seenKeyForEvent(event: {
notificationId?: string
notificationSeq?: number
}): string | null {
const id = event.notificationId
if (id != null && event.notificationSeq != null) {
return `id:${id}#${event.notificationSeq}`
}
if (id != null) {
return `id:${id}`
}
if (event.notificationSeq != null) {
return `seq:${event.notificationSeq}`
}
return null
}
@@ -33,3 +33,31 @@ export function isGestureMouseTrackingMode(
): boolean {
return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'
}
export function getActiveTabIdForHandle(
tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>,
terminalHandle: string | null
): string | null {
if (!terminalHandle) {
return null
}
return (
tabs.find((tab) => tab.type === 'terminal' && tab.terminal === terminalHandle)?.id ??
terminalHandle
)
}
export function updateTerminalCwdFromStreamEvent(
handle: string,
data: Readonly<Record<string, unknown>>,
terminalCwd: Map<string, string>
): void {
if (!('cwd' in data)) {
return
}
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
terminalCwd.set(handle, data.cwd)
return
}
terminalCwd.delete(handle)
}
@@ -58,7 +58,7 @@ describe('mobile session startup', () => {
expect(pendingActivationEffect).toContain(
'pendingTerminalActivationAttemptRef.current === activationKey'
)
expect(pendingActivationEffect).toContain("sendRequest('session.tabs.activate'")
expect(pendingActivationEffect).toContain('activateMobileSessionTab(client,')
expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id')
expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId')
expect(pendingActivationEffect).toContain('notifyClients: false')
@@ -68,8 +68,19 @@ describe('mobile session startup', () => {
expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()')
})
it('keeps mobile session tab activation local to the phone', () => {
const activationRequests = source.split("sendRequest('session.tabs.activate'").slice(1)
it('mirrors ready terminal taps while persisting them for headless hosts', () => {
const readyTerminalSwitch = sliceBetween(
'const switchTab = useCallback(',
'const switchSessionTab = useCallback('
)
expect(readyTerminalSwitch).toContain('focusMobileTerminal(client, handle)')
expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,')
expect(readyTerminalSwitch).toContain('notifyClients: false')
})
it('keeps background and pending session-tab activation local to the phone', () => {
const activationRequests = source.split('activateMobileSessionTab(client,').slice(1)
expect(activationRequests).toHaveLength(4)
for (const request of activationRequests) {
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import { activateMobileSessionTab, focusMobileTerminal } from './mobile-session-tab-activation'
function success(): RpcResponse {
return { id: 'rpc-1', ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } }
}
function clientWith(sendRequest: RpcClient['sendRequest']): Pick<RpcClient, 'sendRequest'> {
return { sendRequest }
}
describe('mobile session tab activation', () => {
it('retries terminal focus once on the authenticated replacement after cutover', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(success())
await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).resolves.toMatchObject(
{
ok: true
}
)
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { terminal: 'terminal-1' })
expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { terminal: 'terminal-1' })
})
it('retries session-tab activation with the same target after cutover', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(success())
const params = {
worktree: 'id:worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
notifyClients: false as const
}
await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({
ok: true
})
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.activate', params)
expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', params)
})
it('does not retry unrelated transport failures', async () => {
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockRejectedValue(new Error('offline'))
await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).rejects.toThrow(
'offline'
)
expect(sendRequest).toHaveBeenCalledOnce()
})
it('retries at most once when consecutive cutovers interrupt activation', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValue(new LogicalClientCutoverError())
await expect(
activateMobileSessionTab(clientWith(sendRequest), {
worktree: 'id:worktree-1',
tabId: 'tab-1',
notifyClients: false
})
).rejects.toBeInstanceOf(LogicalClientCutoverError)
expect(sendRequest).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,90 @@
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcResponse } from '../transport/types'
import {
getMobileTerminalDiagnosticErrorName,
logMobileTerminalDiagnostic,
shortenMobileTerminalDiagnosticId
} from './mobile-terminal-diagnostics'
type ActivationClient = Pick<RpcClient, 'sendRequest'>
type MobileSessionTabActivationParams = {
worktree: string
tabId: string
leafId?: string
notifyClients: false
}
async function retryIdempotentActivationAfterCutover(
request: () => Promise<RpcResponse>,
operation: 'terminal.focus' | 'session.tabs.activate',
target: string
): Promise<RpcResponse> {
const diagnosticTarget = shortenMobileTerminalDiagnosticId(target)
logMobileTerminalDiagnostic('activation-request', { operation, target: diagnosticTarget })
try {
const response = await request()
logMobileTerminalDiagnostic('activation-result', {
operation,
target: diagnosticTarget,
ok: response.ok,
rpcCode: response.ok ? null : response.error.code
})
return response
} catch (error) {
if (!(error instanceof LogicalClientCutoverError)) {
logMobileTerminalDiagnostic('activation-error', {
operation,
target: diagnosticTarget,
errorName: getMobileTerminalDiagnosticErrorName(error)
})
throw error
}
logMobileTerminalDiagnostic('activation-cutover-retry', {
operation,
target: diagnosticTarget
})
// Why: cutover rejects ambiguous in-flight work after the replacement is
// active; these state-setting requests are idempotent and safe to repeat once.
try {
const response = await request()
logMobileTerminalDiagnostic('activation-result', {
operation,
target: diagnosticTarget,
ok: response.ok,
rpcCode: response.ok ? null : response.error.code
})
return response
} catch (retryError) {
logMobileTerminalDiagnostic('activation-error', {
operation,
target: diagnosticTarget,
errorName: getMobileTerminalDiagnosticErrorName(retryError)
})
throw retryError
}
}
}
export function focusMobileTerminal(
client: ActivationClient,
terminal: string
): Promise<RpcResponse> {
return retryIdempotentActivationAfterCutover(
() => client.sendRequest('terminal.focus', { terminal }),
'terminal.focus',
terminal
)
}
export function activateMobileSessionTab(
client: ActivationClient,
params: MobileSessionTabActivationParams
): Promise<RpcResponse> {
return retryIdempotentActivationAfterCutover(
() => client.sendRequest('session.tabs.activate', params),
'session.tabs.activate',
params.tabId
)
}
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import {
getMobileTerminalDiagnosticErrorName,
logMobileTerminalDiagnostic,
MobileTerminalDiagnostics,
shortenMobileTerminalDiagnosticId
} from './mobile-terminal-diagnostics'
describe('mobile terminal diagnostics', () => {
it('keeps only the correlatable suffix of identifiers', () => {
expect(shortenMobileTerminalDiagnosticId('terminal-secret-prefix-12345678')).toBe('12345678')
expect(shortenMobileTerminalDiagnosticId('short')).toBe('short')
expect(shortenMobileTerminalDiagnosticId(null)).toBeNull()
})
it('reports thrown error types without copying potentially sensitive messages', () => {
expect(getMobileTerminalDiagnosticErrorName(new TypeError('/private/worktree failed'))).toBe(
'TypeError'
)
expect(getMobileTerminalDiagnosticErrorName('raw failure')).toBe('string')
})
it('uses one filterable structured log tag', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
logMobileTerminalDiagnostic('stream-armed', { handle: '12345678', seq: 2 })
expect(log).toHaveBeenCalledWith('[terminal-diagnostic]', 'stream-armed', {
handle: '12345678',
seq: 2
})
log.mockRestore()
})
it('forgets first-event state when a terminal unsubscribes', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const diagnostics = new MobileTerminalDiagnostics()
diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed')
diagnostics.terminalUnsubscribed('terminal-1')
diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed')
expect(log).toHaveBeenCalledTimes(2)
log.mockRestore()
})
})
@@ -0,0 +1,291 @@
const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]'
type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined
export type MobileTerminalDiagnosticDetails = Readonly<
Record<string, MobileTerminalDiagnosticValue>
>
type DiagnosticTab = {
readonly id: string
readonly type: string
readonly isActive: boolean
readonly terminal?: string | null
}
type DiagnosticTabsSnapshot = {
readonly publicationEpoch?: string
readonly snapshotVersion: number
readonly tabs: readonly DiagnosticTab[]
}
type DiagnosticDimensions = { readonly cols: number; readonly rows: number } | null | undefined
// Why: full runtime identifiers make shared logs unnecessarily sensitive; the
// suffix is enough to correlate lifecycle events within one reproduction.
export function shortenMobileTerminalDiagnosticId(value: string | null | undefined): string | null {
if (!value) {
return null
}
return value.slice(-8)
}
export function getMobileTerminalDiagnosticErrorName(error: unknown): string {
if (error instanceof Error && error.name) {
return error.name
}
return typeof error
}
export function logMobileTerminalDiagnostic(
event: string,
details: MobileTerminalDiagnosticDetails = {}
): void {
// Why: lifecycle diagnostics are intentionally available for HMR repros,
// but high-frequency WebView events must not add production log overhead.
if (typeof __DEV__ !== 'undefined' && !__DEV__) {
return
}
// Keep this structured and content-free so users can safely share a filtered log.
console.log(MOBILE_TERMINAL_DIAGNOSTIC_TAG, event, details)
}
export class MobileTerminalDiagnostics {
private readonly streamGateByHandle = new Map<string, string>()
private readonly firstStreamEventSeqByHandle = new Map<string, number>()
private lastFetchedTabsSignature: string | null = null
private lastAppliedTabsSignature: string | null = null
private lastTabsFetchStartAt = 0
private tabsFetchSkipLogged = false
clearTerminalCache(): void {
this.streamGateByHandle.clear()
this.firstStreamEventSeqByHandle.clear()
}
resetRoute(): void {
this.clearTerminalCache()
this.lastFetchedTabsSignature = null
this.lastAppliedTabsSignature = null
this.lastTabsFetchStartAt = 0
this.tabsFetchSkipLogged = false
}
terminalUnsubscribed(handle: string): void {
this.streamGateByHandle.delete(handle)
this.firstStreamEventSeqByHandle.delete(handle)
}
viewportMeasured(handle: string, dims: DiagnosticDimensions, frameHeight: number): void {
logMobileTerminalDiagnostic('viewport-measure', {
handle: shortenMobileTerminalDiagnosticId(handle),
ok: dims != null,
cols: dims?.cols,
rows: dims?.rows,
frameHeight: Math.round(frameHeight)
})
}
streamSkipped(handle: string, reason: string, isActive: boolean): void {
if (this.streamGateByHandle.get(handle) === reason) {
return
}
this.streamGateByHandle.set(handle, reason)
logMobileTerminalDiagnostic('stream-skipped', {
handle: shortenMobileTerminalDiagnosticId(handle),
reason,
isActive
})
}
streamArmed(handle: string, seq: number, viewport: DiagnosticDimensions): void {
this.streamGateByHandle.delete(handle)
logMobileTerminalDiagnostic('stream-armed', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
hasViewport: viewport != null,
viewportCols: viewport?.cols,
viewportRows: viewport?.rows
})
}
firstStreamEvent(handle: string, seq: number, type: unknown): void {
if (this.firstStreamEventSeqByHandle.get(handle) === seq) {
return
}
this.firstStreamEventSeqByHandle.set(handle, seq)
logMobileTerminalDiagnostic('stream-first-event', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
type: typeof type === 'string' ? type : 'unknown'
})
}
streamScrollback(
handle: string,
seq: number,
eventSeq: number | null,
data: Readonly<Record<string, unknown>>
): void {
logMobileTerminalDiagnostic('stream-scrollback', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
eventSeq,
cols: typeof data.cols === 'number' ? data.cols : null,
rows: typeof data.rows === 'number' ? data.rows : null,
serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0,
displayMode: typeof data.displayMode === 'string' ? data.displayMode : null,
source: typeof data.source === 'string' ? data.source : null,
scrollbackRows: typeof data.scrollbackRows === 'number' ? data.scrollbackRows : null,
truncated: data.truncated === true || data.truncatedByByteBudget === true
})
}
streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void {
logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
cols: dims.cols,
rows: dims.rows
})
}
streamResized(
handle: string,
seq: number,
eventSeq: number | null,
data: Readonly<Record<string, unknown>>,
hasRef: boolean
): void {
logMobileTerminalDiagnostic('stream-resized', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
eventSeq,
cols: typeof data.cols === 'number' ? data.cols : null,
rows: typeof data.rows === 'number' ? data.rows : null,
serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0,
hasRef
})
}
tabsApplied(
snapshot: DiagnosticTabsSnapshot,
tabs: readonly DiagnosticTab[],
activeTab: DiagnosticTab | null,
selectionSource: string
): void {
const activeHandle =
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
? activeTab.terminal
: null
const appliedSnapshot = { ...snapshot, tabs }
const signature = [
appliedSnapshot.publicationEpoch ?? '',
appliedSnapshot.snapshotVersion,
activeTab?.id ?? '',
activeHandle ?? '',
selectionSource
].join(':')
if (this.lastAppliedTabsSignature === signature) {
return
}
this.lastAppliedTabsSignature = signature
this.logTabs('tabs-applied', appliedSnapshot, activeTab, activeHandle, { selectionSource })
}
tabsFetchSkipped(reason: string): void {
if (reason === 'already-in-flight' && this.tabsFetchSkipLogged) {
return
}
this.tabsFetchSkipLogged = reason === 'already-in-flight'
logMobileTerminalDiagnostic('tabs-fetch-skipped', { reason })
}
tabsFetchStarted(worktreeId: string): void {
this.tabsFetchSkipLogged = false
const now = Date.now()
if (this.lastFetchedTabsSignature != null && now - this.lastTabsFetchStartAt < 10_000) {
return
}
this.lastTabsFetchStartAt = now
logMobileTerminalDiagnostic('tabs-fetch-start', {
worktree: shortenMobileTerminalDiagnosticId(worktreeId)
})
}
tabsFetchFailed(rpcCode: string): void {
logMobileTerminalDiagnostic('tabs-fetch-rpc-failure', { rpcCode })
}
tabsFetchErrored(error: unknown): void {
logMobileTerminalDiagnostic('tabs-fetch-error', {
errorName: getMobileTerminalDiagnosticErrorName(error)
})
}
tabsFetchSucceeded(snapshot: DiagnosticTabsSnapshot): void {
const activeTab = snapshot.tabs.find((tab) => tab.isActive) ?? null
const signature = [
snapshot.publicationEpoch ?? '',
snapshot.snapshotVersion,
snapshot.tabs.length,
activeTab?.id ?? '',
activeTab?.type ?? ''
].join(':')
if (this.lastFetchedTabsSignature === signature) {
return
}
this.lastFetchedTabsSignature = signature
const activeHandle =
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
? activeTab.terminal
: null
this.logTabs('tabs-fetch-success', snapshot, activeTab, activeHandle)
}
tabSwitch(tabType: string, tabId: string, pending: boolean, handle?: string): void {
logMobileTerminalDiagnostic('tab-switch', {
tabType,
tab: shortenMobileTerminalDiagnosticId(tabId),
handle: shortenMobileTerminalDiagnosticId(handle),
pending
})
}
webViewRef(handle: string, attached: boolean): void {
logMobileTerminalDiagnostic('webview-ref', {
handle: shortenMobileTerminalDiagnosticId(handle),
attached
})
}
webViewReady(handle: string, reload: boolean, isActive: boolean): void {
logMobileTerminalDiagnostic('webview-ready', {
handle: shortenMobileTerminalDiagnosticId(handle),
reload,
isActive
})
}
private logTabs(
event: 'tabs-applied' | 'tabs-fetch-success',
snapshot: DiagnosticTabsSnapshot,
activeTab: DiagnosticTab | null,
activeHandle: string | null,
extra: MobileTerminalDiagnosticDetails = {}
): void {
logMobileTerminalDiagnostic(event, {
publication: shortenMobileTerminalDiagnosticId(snapshot.publicationEpoch),
snapshotVersion: snapshot.snapshotVersion,
tabCount: snapshot.tabs.length,
terminalTabCount: snapshot.tabs.filter((tab) => tab.type === 'terminal').length,
pendingTerminalCount: snapshot.tabs.filter(
(tab) => tab.type === 'terminal' && typeof tab.terminal !== 'string'
).length,
activeTab: shortenMobileTerminalDiagnosticId(activeTab?.id),
activeType: activeTab?.type ?? null,
activeHandle: shortenMobileTerminalDiagnosticId(activeHandle),
...extra
})
}
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from './session-tab-snapshot-gate'
@@ -50,6 +51,18 @@ describe('acceptSessionSnapshot', () => {
})
})
describe('confirmsMirroredTabSelection', () => {
it('does not treat phone-local persistence as desktop focus confirmation', () => {
expect(confirmsMirroredTabSelection('mobile-local:abc')).toBe(false)
})
it('accepts renderer, headless, and legacy publications as confirmation', () => {
expect(confirmsMirroredTabSelection('renderer:abc')).toBe(true)
expect(confirmsMirroredTabSelection('headless:abc')).toBe(true)
expect(confirmsMirroredTabSelection()).toBe(true)
})
})
describe('applyClosedTabTombstones', () => {
const tab = (id: string): { id: string } => ({ id })
@@ -33,6 +33,12 @@ export function acceptSessionSnapshot(
return true
}
export function confirmsMirroredTabSelection(publicationEpoch?: string): boolean {
// Why: phone-local persistence acknowledges the mutation, not host focus;
// keep the pending guard until any host publication confirms the selection.
return !publicationEpoch?.startsWith('mobile-local:')
}
/**
* Drops tabs the user just closed locally (tombstoned) until the publisher's
* snapshot also drops them or the tombstone expires. Mutates `tombstones`,
@@ -8,7 +8,8 @@ import {
getMobilePrCreateBlockMessage,
mobileRepoSelectorFromWorktreeId,
resolveMobilePrPrefill,
shouldPushBeforeMobilePrCreate
shouldPushBeforeMobilePrCreate,
type MobilePrPrefill
} from './mobile-pr-create'
function ok(result: unknown): RpcSuccess {
@@ -122,6 +123,19 @@ describe('mobile create form gating parity', () => {
expect(mobileAllowsComposer).toBe(desktopAllowsComposer)
})
it('stays safely blocked for a reason added by a newer desktop contract', () => {
expect(
getMobilePrCreateBlockMessage({
provider: 'github',
base: 'main',
title: 'Add feature',
body: '',
canCreate: false,
blockedReason: 'future_desktop_reason' as unknown as MobilePrPrefill['blockedReason']
})
).toBe('This branch is not ready for a pull request yet.')
})
})
describe('resolveMobilePrPrefill', () => {
@@ -66,9 +66,15 @@ export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string
return `A ${copy.reviewLabel} already exists for this branch.`
case 'fork_head_unsupported':
return `Creating a ${copy.reviewLabel} from this fork is not supported.`
case 'base_not_on_remote':
return `Push the base branch before creating a ${copy.reviewLabel}.`
case 'needs_push':
case null:
case undefined:
return `This branch is not ready for a ${copy.reviewLabel} yet.`
default:
// Why: desktop can add blocked reasons before a long-lived mobile branch
// catches up; remain safely blocked while preserving merge-ref typechecks.
return `This branch is not ready for a ${copy.reviewLabel} yet.`
}
}
+43
View File
@@ -10,11 +10,14 @@ import {
clampHostSidebarWidth,
loadDisabledTerminalLiveInputHandles,
loadHostSidebarWidth,
loadPushNotificationsEnabled,
loadTerminalAutocompleteEnabled,
loadTerminalLinkOpenMode,
readPushNotificationsPreference,
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles,
saveHostSidebarWidth,
savePushNotificationsEnabled,
saveTerminalAutocompleteEnabled,
saveTerminalLinkOpenMode
} from './preferences'
@@ -26,6 +29,46 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}
}))
describe('push notification preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
it('distinguishes an unset preference from an explicit disabled choice', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
await expect(readPushNotificationsPreference()).resolves.toEqual({
value: null,
loaded: true
})
await expect(loadPushNotificationsEnabled()).resolves.toBe(false)
vi.mocked(AsyncStorage.getItem).mockResolvedValue('false')
await expect(readPushNotificationsPreference()).resolves.toEqual({
value: false,
loaded: true
})
})
it('reports storage failures without enabling notifications', async () => {
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
await expect(readPushNotificationsPreference()).resolves.toEqual({
value: null,
loaded: false
})
await expect(loadPushNotificationsEnabled()).resolves.toBe(false)
})
it('persists the onboarding decision in the existing mobile toggle', async () => {
await savePushNotificationsEnabled(true)
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'true')
await savePushNotificationsEnabled(false)
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'false')
})
})
describe('terminal autocomplete preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
+17 -11
View File
@@ -3,23 +3,29 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
const PINS_PREFIX = 'orca:pins:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
// Why: default-off so the iOS notification permission prompt never
// fires until the user explicitly opts in via Settings → Notifications.
// Apple's review guideline 4.5.4 and HIG both prefer user-initiated
// permission prompts; default-on would fire the prompt the moment the
// desktop sent its first notification, which can read as unsolicited.
export async function loadPushNotificationsEnabled(): Promise<boolean> {
export type PushNotificationsPreference = {
readonly value: boolean | null
readonly loaded: boolean
}
// Why: null distinguishes people who have never made the one-time onboarding
// decision from people who explicitly chose Not now or disabled notifications.
export async function readPushNotificationsPreference(): Promise<PushNotificationsPreference> {
try {
const raw = await AsyncStorage.getItem(NOTIF_KEY)
if (raw === null) {
return false
}
return raw === 'true'
return { value: raw === null ? null : raw === 'true', loaded: true }
} catch {
return false
return { value: null, loaded: false }
}
}
// Why: default-off prevents background notification events from opening the
// system prompt; only the onboarding CTA or Settings switch requests permission.
export async function loadPushNotificationsEnabled(): Promise<boolean> {
const preference = await readPushNotificationsPreference()
return preference.value ?? false
}
export async function savePushNotificationsEnabled(enabled: boolean): Promise<void> {
await AsyncStorage.setItem(NOTIF_KEY, String(enabled))
}
@@ -1,5 +1,7 @@
import type { RpcResponse } from '../transport/types'
export type TerminalUpdateViewportCapability = 'unknown' | 'supported' | 'unsupported'
export type TerminalViewportRefitTargetState = {
activeHandle: string | null
expectedHandle: string
@@ -24,6 +26,70 @@ export function isTerminalUpdateViewportApplied(response: RpcResponse): boolean
return (response.result as { applied?: unknown }).applied === true
}
export function resolveTerminalUpdateViewportCapability(
response: RpcResponse
): TerminalUpdateViewportCapability {
if (response.ok) {
return 'supported'
}
return response.error.code === 'method_not_found' ? 'unsupported' : 'unknown'
}
// Why: defer height refits while typing, then coalesce every skipped layout
// change into one correction after the keyboard closes.
export type TerminalFrameHeightRefitState = {
frameHeight: number
keyboardVisible: boolean
pending: boolean
}
export type TerminalFrameHeightRefitEvent =
| { type: 'frame-height'; height: number }
| { type: 'keyboard-visibility'; visible: boolean }
| { type: 'refit-committed' }
export function reduceTerminalFrameHeightRefit(
state: TerminalFrameHeightRefitState,
event: TerminalFrameHeightRefitEvent
): { state: TerminalFrameHeightRefitState; shouldRefit: boolean } {
if (event.type === 'refit-committed') {
// Why: the debounced height refit is firing. The keyboard can reopen during
// the debounce window, so re-check here and re-defer rather than reflow the
// PTY mid-keystroke; it runs on the next keyboard close.
if (state.keyboardVisible) {
return { state: { ...state, pending: true }, shouldRefit: false }
}
return { state: { ...state, pending: false }, shouldRefit: true }
}
if (event.type === 'keyboard-visibility') {
if (event.visible === state.keyboardVisible) {
return { state, shouldRefit: false }
}
if (event.visible) {
return { state: { ...state, keyboardVisible: true }, shouldRefit: false }
}
return {
state: { ...state, keyboardVisible: false, pending: false },
shouldRefit: state.pending
}
}
if (event.height === state.frameHeight) {
return { state, shouldRefit: false }
}
if (state.keyboardVisible) {
return {
state: { ...state, frameHeight: event.height, pending: true },
shouldRefit: false
}
}
return {
state: { ...state, frameHeight: event.height, pending: false },
shouldRefit: true
}
}
export function isTerminalViewportRefitTargetCurrent(
state: TerminalViewportRefitTargetState
): boolean {
@@ -4,7 +4,12 @@ import type { RpcResponse } from '../transport/types'
import {
isTerminalUpdateViewportApplied,
isTerminalUpdateViewportUpdated,
isTerminalViewportRefitTargetCurrent
isTerminalViewportRefitTargetCurrent,
reduceTerminalFrameHeightRefit,
resolveTerminalUpdateViewportCapability,
type TerminalFrameHeightRefitEvent,
type TerminalFrameHeightRefitState,
type TerminalUpdateViewportCapability
} from './terminal-viewport-refit-state'
const hookSource = readFileSync(new URL('./terminal-viewport-refit.ts', import.meta.url), 'utf8')
@@ -48,11 +53,110 @@ describe('terminal viewport refit', () => {
expect(textScaleEffect).toContain('[textScale, viewportMeasuredRef, scheduleViewportRefit]')
})
it('coalesces keyboard-visible frame-height churn into one refit after close', () => {
let state: TerminalFrameHeightRefitState = {
frameHeight: 600,
keyboardVisible: false,
pending: false
}
let refitCount = 0
const dispatch = (event: TerminalFrameHeightRefitEvent) => {
const transition = reduceTerminalFrameHeightRefit(state, event)
state = transition.state
refitCount += Number(transition.shouldRefit)
}
dispatch({ type: 'keyboard-visibility', visible: true })
dispatch({ type: 'frame-height', height: 540 })
dispatch({ type: 'frame-height', height: 520 })
dispatch({ type: 'frame-height', height: 520 })
expect(refitCount).toBe(0)
expect(state.pending).toBe(true)
dispatch({ type: 'keyboard-visibility', visible: false })
dispatch({ type: 'keyboard-visibility', visible: false })
dispatch({ type: 'frame-height', height: 520 })
expect(refitCount).toBe(1)
expect(state.pending).toBe(false)
dispatch({ type: 'frame-height', height: 500 })
expect(refitCount).toBe(2)
})
it('routes imperative height notifications through the keyboard-aware reducer', () => {
const start = hookSource.indexOf('const notifyFrameHeightRefitEvent = useCallback(')
expect(start).toBeGreaterThanOrEqual(0)
const notifier = hookSource.slice(start, start + 1_300)
expect(notifier).toContain('reduceTerminalFrameHeightRefit(')
expect(notifier).toContain("{ type: 'frame-height', height }")
expect(notifier).toContain("{ type: 'keyboard-visibility', visible }")
expect(notifier).toContain('viewportMeasuredRef.current = false')
expect(notifier).toContain('scheduleViewportRefit({ heightOriginated: true })')
})
it('re-defers a height refit if the keyboard reopens before the debounce fires', () => {
// Settle while the keyboard is up -> deferred (pending), no refit.
let r = reduceTerminalFrameHeightRefit(
{ frameHeight: 600, keyboardVisible: true, pending: false },
{ type: 'frame-height', height: 520 }
)
expect(r.shouldRefit).toBe(false)
expect(r.state.pending).toBe(true)
// Keyboard closes -> refit scheduled (the hook arms a 150ms timer here).
r = reduceTerminalFrameHeightRefit(r.state, { type: 'keyboard-visibility', visible: false })
expect(r.shouldRefit).toBe(true)
// Keyboard reopens inside the debounce window, then the timer fires:
// the committed refit must NOT reflow while typing, and stays owed.
r = reduceTerminalFrameHeightRefit(r.state, { type: 'keyboard-visibility', visible: true })
const committed = reduceTerminalFrameHeightRefit(r.state, { type: 'refit-committed' })
expect(committed.shouldRefit).toBe(false)
expect(committed.state.pending).toBe(true)
// Keyboard closes again -> rescheduled -> now the committed refit runs.
const rescheduled = reduceTerminalFrameHeightRefit(committed.state, {
type: 'keyboard-visibility',
visible: false
})
expect(rescheduled.shouldRefit).toBe(true)
const ran = reduceTerminalFrameHeightRefit(rescheduled.state, { type: 'refit-committed' })
expect(ran.shouldRefit).toBe(true)
expect(ran.state.pending).toBe(false)
})
it('re-checks the keyboard at fire time only for height-originated refits', () => {
const start = hookSource.indexOf('refitTimerRef.current = setTimeout(')
expect(start).toBeGreaterThanOrEqual(0)
const timerBody = hookSource.slice(start, start + 900)
// The height flag scopes the guard; forced/width refits stay unguarded.
expect(timerBody).toContain('if (heightOriginatedRefitRef.current)')
expect(timerBody).toContain("type: 'refit-committed'")
expect(timerBody).toContain('if (!decision.shouldRefit)')
})
it('is wired into the session screen', () => {
expect(sessionSource).toContain('useTerminalViewportRefit({')
expect(sessionSource).toContain('tabStripVisible: terminals.length > 1')
expect(sessionSource).toContain('textScale: terminalTextScale')
expect(sessionSource).toContain('connState,')
expect(sessionSource).toContain('notifyTerminalFrameHeight(nextHeight)')
expect(sessionSource).toContain('notifyKeyboardVisibility(true)')
expect(sessionSource).toContain('notifyKeyboardVisibility(false)')
})
it('does not rerender SessionScreen for frame-height-only layout changes', () => {
// The imperative notifier keeps a dock-settling burst off React's render path.
expect(sessionSource).not.toContain('setTerminalFrameHeight')
expect(sessionSource).not.toContain('const [terminalFrameHeight,')
})
it('defers height-only window resizes while the keyboard is visible', () => {
const start = hookSource.indexOf('const prevWindowDimsRef')
const windowEffect = hookSource.slice(start, start + 1_100)
expect(windowEffect).toContain(
'prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible'
)
})
it('forces a refit on iOS foreground and connection recovery', () => {
@@ -93,6 +197,42 @@ describe('terminal viewport refit', () => {
expect(resubscribeIndex).toBeGreaterThan(rpcIndex)
})
it('falls back to legacy resubscribe when an older desktop lacks updateViewport', () => {
const unsupported = {
id: 'old-host',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method: terminal.updateViewport' },
_meta: { runtimeId: 'runtime' }
} satisfies RpcResponse
expect(isTerminalUpdateViewportUpdated(unsupported)).toBe(false)
expect(
resolveTerminalUpdateViewportCapability({
...unsupported,
error: { code: 'temporary_failure', message: 'retryable' }
})
).toBe('unknown')
let capability: TerminalUpdateViewportCapability = 'unknown'
let probeCount = 0
for (let refit = 0; refit < 10; refit += 1) {
if (capability === 'unsupported') {
continue
}
probeCount += 1
capability = resolveTerminalUpdateViewportCapability(unsupported)
}
expect(probeCount).toBe(1)
const responseCheckIndex = hookSource.indexOf('isTerminalUpdateViewportUpdated(response)')
const unsubscribeIndex = hookSource.indexOf('unsubscribeTerminal(handle)', responseCheckIndex)
const subscribeIndex = hookSource.indexOf('subscribeToTerminal(handle)', unsubscribeIndex)
expect(responseCheckIndex).toBeGreaterThanOrEqual(0)
expect(unsubscribeIndex).toBeGreaterThan(responseCheckIndex)
expect(subscribeIndex).toBeGreaterThan(unsubscribeIndex)
expect(hookSource).toContain("updateViewportCapabilityRef.current !== 'unsupported'")
expect(hookSource).toContain("updateViewportCapabilityRef.current = 'unknown'")
})
it('reflows the local xterm scrollback after a successful updateViewport', () => {
// Why: updateViewport may only record an informational mobile viewport in
// desktop mode. Reflow local scrollback only after the server says it
+166 -92
View File
@@ -7,7 +7,12 @@ import { shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-rec
import {
isTerminalUpdateViewportApplied,
isTerminalUpdateViewportUpdated,
isTerminalViewportRefitTargetCurrent
isTerminalViewportRefitTargetCurrent,
reduceTerminalFrameHeightRefit,
resolveTerminalUpdateViewportCapability,
type TerminalFrameHeightRefitEvent,
type TerminalFrameHeightRefitState,
type TerminalUpdateViewportCapability
} from './terminal-viewport-refit-state'
export type TerminalViewportDims = { cols: number; rows: number }
@@ -36,13 +41,20 @@ type TerminalViewportRefitOptions = {
subscribeToTerminal: (handle: string) => void
}
type TerminalViewportRefitNotifications = {
notifyTerminalFrameHeight: (height: number) => void
notifyKeyboardVisibility: (visible: boolean) => void
}
// Why: re-measure the phone viewport when layout-affecting state changes
// outside the subscribe path — the tab strip toggling visibility, and the
// window itself resizing (fold/unfold on foldables, orientation rotation,
// split-screen). Without the resize trigger, a PTY fitted on the folded
// cover screen stays at cover-screen cols after unfolding and the terminal
// renders in only part of the display (#4579's "cut in half" symptom).
export function useTerminalViewportRefit(options: TerminalViewportRefitOptions): void {
export function useTerminalViewportRefit(
options: TerminalViewportRefitOptions
): TerminalViewportRefitNotifications {
const {
activeHandleRef,
terminalRefs,
@@ -64,101 +76,130 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
const refitRunSeqRef = useRef(0)
const forceNextRefitRef = useRef(false)
const disposedRef = useRef(false)
const scheduleViewportRefit = useCallback(() => {
if (refitTimerRef.current) {
clearTimeout(refitTimerRef.current)
}
refitTimerRef.current = setTimeout(() => {
refitTimerRef.current = null
const runSeq = refitRunSeqRef.current + 1
refitRunSeqRef.current = runSeq
const handle = activeHandleRef.current
if (!handle) {
return
const updateViewportCapabilityRef = useRef<TerminalUpdateViewportCapability>('unknown')
const frameHeightRefitStateRef = useRef<TerminalFrameHeightRefitState>({
frameHeight: 0,
keyboardVisible: false,
pending: false
})
// Why: marks the currently-armed timer as a height refit so its callback can
// re-check the keyboard at fire time. Non-height refits (width/rotation and
// the forced reconnect/foreground re-asserts) stay unguarded so they always run.
const heightOriginatedRefitRef = useRef(false)
const scheduleViewportRefit = useCallback(
(options?: { heightOriginated?: boolean }) => {
if (refitTimerRef.current) {
clearTimeout(refitTimerRef.current)
}
const ref = terminalRefs.current.get(handle)
if (!ref) {
return
}
const isCurrentTarget = () =>
isTerminalViewportRefitTargetCurrent({
activeHandle: activeHandleRef.current,
expectedHandle: handle,
currentRef: terminalRefs.current.get(handle),
expectedRef: ref,
disposed: disposedRef.current,
runSeq,
currentRunSeq: refitRunSeqRef.current
})
void (async () => {
const dims = await ref.measureFitDimensions(terminalFrameHeightRef.current || undefined)
if (!isCurrentTarget()) {
return
}
if (!dims) {
return
}
const forceRefit = forceNextRefitRef.current
forceNextRefitRef.current = false
const prev = viewportRef.current
if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows) {
return
}
viewportRef.current = dims
viewportMeasuredRef.current = true
// Why: prefer the in-place viewport update RPC over the legacy
// unsubscribe → subscribe cycle. This keeps the server-side
// mobile subscriber record alive (no driver=idle blip on the
// desktop banner; no false phone-fit baseline capture on the
// re-subscribe). See docs/mobile-presence-lock.md.
const rpc = clientRef.current
const deviceToken = deviceTokenRef.current
if (rpc && deviceToken) {
try {
const response = await rpc.sendRequest('terminal.updateViewport', {
terminal: handle,
client: { id: deviceToken, type: 'mobile' as const },
viewport: dims
})
if (!isCurrentTarget()) {
return
}
if (isTerminalUpdateViewportUpdated(response)) {
rpc.updateTerminalSubscriptionViewport(handle, dims)
if (isTerminalUpdateViewportApplied(response)) {
// Why: updateViewport reflows the server PTY and re-streams only
// the visible screen, so the WebView's local xterm scrollback
// stays wrapped at the old width. Reflow it locally only when
// the server actually applied phone-fit; desktop mode records
// the viewport but leaves the PTY at desktop dims.
ref.reflow(dims.cols, dims.rows)
}
return
}
} catch {
// Fall through to legacy resubscribe.
heightOriginatedRefitRef.current = options?.heightOriginated ?? false
refitTimerRef.current = setTimeout(() => {
refitTimerRef.current = null
// Why: a height refit deferred at keyboard-close can fire after the keyboard
// reopened within the 150ms debounce; re-check and re-defer so we never
// reflow the PTY mid-keystroke. Scoped via the height-originated flag.
if (heightOriginatedRefitRef.current) {
heightOriginatedRefitRef.current = false
const decision = reduceTerminalFrameHeightRefit(frameHeightRefitStateRef.current, {
type: 'refit-committed'
})
frameHeightRefitStateRef.current = decision.state
if (!decision.shouldRefit) {
return
}
}
if (!isCurrentTarget()) {
const runSeq = refitRunSeqRef.current + 1
refitRunSeqRef.current = runSeq
const handle = activeHandleRef.current
if (!handle) {
return
}
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
})()
}, 150)
}, [
activeHandleRef,
terminalRefs,
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,
unsubscribeTerminal,
subscribeToTerminal
])
const ref = terminalRefs.current.get(handle)
if (!ref) {
return
}
const isCurrentTarget = () =>
isTerminalViewportRefitTargetCurrent({
activeHandle: activeHandleRef.current,
expectedHandle: handle,
currentRef: terminalRefs.current.get(handle),
expectedRef: ref,
disposed: disposedRef.current,
runSeq,
currentRunSeq: refitRunSeqRef.current
})
void (async () => {
const dims = await ref.measureFitDimensions(terminalFrameHeightRef.current || undefined)
if (!isCurrentTarget()) {
return
}
if (!dims) {
return
}
const forceRefit = forceNextRefitRef.current
forceNextRefitRef.current = false
const prev = viewportRef.current
if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows) {
return
}
viewportRef.current = dims
viewportMeasuredRef.current = true
// Why: prefer the in-place viewport update RPC over the legacy
// unsubscribe → subscribe cycle. This keeps the server-side
// mobile subscriber record alive (no driver=idle blip on the
// desktop banner; no false phone-fit baseline capture on the
// re-subscribe). See docs/mobile-presence-lock.md.
const rpc = clientRef.current
const deviceToken = deviceTokenRef.current
if (rpc && deviceToken && updateViewportCapabilityRef.current !== 'unsupported') {
try {
const response = await rpc.sendRequest('terminal.updateViewport', {
terminal: handle,
client: { id: deviceToken, type: 'mobile' as const },
viewport: dims
})
if (!isCurrentTarget()) {
return
}
updateViewportCapabilityRef.current =
resolveTerminalUpdateViewportCapability(response)
if (isTerminalUpdateViewportUpdated(response)) {
rpc.updateTerminalSubscriptionViewport(handle, dims)
if (isTerminalUpdateViewportApplied(response)) {
// Why: updateViewport reflows the server PTY and re-streams only
// the visible screen, so the WebView's local xterm scrollback
// stays wrapped at the old width. Reflow it locally only when
// the server actually applied phone-fit; desktop mode records
// the viewport but leaves the PTY at desktop dims.
ref.reflow(dims.cols, dims.rows)
}
return
}
} catch {
// Fall through to legacy resubscribe.
}
}
if (!isCurrentTarget()) {
return
}
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
})()
}, 150)
},
[
activeHandleRef,
terminalRefs,
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,
unsubscribeTerminal,
subscribeToTerminal
]
)
const scheduleForcedViewportRefit = useCallback(() => {
forceNextRefitRef.current = true
scheduleViewportRefit()
@@ -194,6 +235,11 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
return
}
prevWindowDimsRef.current = { width: windowWidth, height: windowHeight }
// Why: adjustResize can change only window height while the IME is open;
// the frame-height notifier schedules one correction after it closes.
if (prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible) {
return
}
viewportMeasuredRef.current = false
scheduleViewportRefit()
}, [windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit])
@@ -226,6 +272,29 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
scheduleViewportRefit()
}, [terminalFrameWidth, viewportMeasuredRef, scheduleViewportRefit])
const notifyFrameHeightRefitEvent = useCallback(
(event: TerminalFrameHeightRefitEvent) => {
const transition = reduceTerminalFrameHeightRefit(frameHeightRefitStateRef.current, event)
frameHeightRefitStateRef.current = transition.state
if (!transition.shouldRefit) {
return
}
viewportMeasuredRef.current = false
scheduleViewportRefit({ heightOriginated: true })
},
[viewportMeasuredRef, scheduleViewportRefit]
)
// Why: notify imperatively so layout churn does not rerender the full session;
// a height change during typing is coalesced into one refit after keyboard close.
const notifyTerminalFrameHeight = useCallback(
(height: number) => notifyFrameHeightRefitEvent({ type: 'frame-height', height }),
[notifyFrameHeightRefitEvent]
)
const notifyKeyboardVisibility = useCallback(
(visible: boolean) => notifyFrameHeightRefitEvent({ type: 'keyboard-visibility', visible }),
[notifyFrameHeightRefitEvent]
)
useEffect(() => {
if (Platform.OS !== 'ios') {
return
@@ -256,6 +325,9 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
if (previous === 'connected' || connState !== 'connected') {
return
}
// Why: an in-place desktop upgrade may add updateViewport; reconnect is the
// narrow boundary where an old-host method_not_found cache becomes stale.
updateViewportCapabilityRef.current = 'unknown'
// Why: reconnect can restore a PTY whose host-side size changed while the
// socket was down, so equal cached dimensions still need reassertion.
viewportMeasuredRef.current = false
@@ -272,4 +344,6 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
}
}
}, [])
return { notifyTerminalFrameHeight, notifyKeyboardVisibility }
}
+7 -24
View File
@@ -6,6 +6,7 @@ import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
import { TERMINAL_SURFACE_SWAP_JS } from './terminal-webview-surface-swap-injected'
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
@@ -217,6 +218,7 @@ window.onerror = function(msg) {
var statusDotPendingSelector = false;
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null; ${TERMINAL_QUERY_REPLY_JS}
${TERMINAL_SURFACE_SWAP_JS}
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
@@ -713,22 +715,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : [];
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = false;
var oldTerm = term;
var oldSurface = surface;
var nextSurface = null;
disposeTermObservers();
if (oldTerm) {
nextSurface = document.createElement('div');
nextSurface.id = 'terminal-surface';
nextSurface.style.visibility = 'hidden';
nextSurface.style.position = 'absolute';
nextSurface.style.left = '0';
nextSurface.style.top = '0';
document.getElementById('terminal-container').appendChild(nextSurface);
surface = nextSurface;
attachSurfaceEventHandlers(surface);
oldSurface.removeAttribute('id');
}
var surfaceSwap = beginTerminalSurfaceSwap();
var nextSurface = surfaceSwap.nextSurface;
applyTerminalTheme(nextTheme);
term = new Terminal({
@@ -749,6 +737,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
convertEol: false,
allowProposedApi: true
});
var nextTerm = term;
pendingTerm = nextTerm;
term.open(surface);
attachWebglAddon(true);
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
@@ -768,14 +758,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
everReady = true;
afterWritesDrained(function() {
if (gen !== terminalGeneration) return;
if (nextSurface && oldSurface) {
nextSurface.style.visibility = 'visible';
nextSurface.style.position = '';
nextSurface.style.left = '';
nextSurface.style.top = '';
oldSurface.remove();
if (oldTerm) oldTerm.dispose();
}
commitTerminalSurfaceSwap(surfaceSwap, nextTerm);
// Why: restore the reader's place after the rewrapped buffer replays.
// Replay lands at bottom, so only act when they were scrolled up (rows>0).
if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) {
@@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
function iifeSource(): string {
const start = XTERM_HTML.indexOf('(function() {')
const end = XTERM_HTML.lastIndexOf('})();')
return XTERM_HTML.slice(start, end + '})();'.length)
}
function bodyMarkup(): string {
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
const end = XTERM_HTML.indexOf('<script>', start)
return XTERM_HTML.slice(start, end)
}
type TerminalStub = ReturnType<typeof makeTerminal>
function makeTerminal(writeCallbacks: Array<() => void>) {
const terminal = {
cols: 80,
rows: 24,
options: { fontSize: 13 },
modes: {},
element: null as HTMLElement | null,
disposed: false,
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } },
buffer: {
active: {
viewportY: 0,
baseY: 0,
length: 1,
cursorY: 0,
type: 'normal' as const,
getLine: () => null
}
},
write(_data: string, callback?: () => void) {
if (callback) {
writeCallbacks.push(callback)
}
},
open(surface: HTMLElement) {
terminal.element = surface
},
loadAddon() {},
resize(cols: number, rows: number) {
terminal.cols = cols
terminal.rows = rows
},
clear() {},
reset() {},
refresh() {},
selectAll() {},
clearSelection() {},
select() {},
scrollLines() {},
scrollToBottom() {},
scrollToLine() {},
getSelection: () => '',
onData: () => ({ dispose() {} }),
onLineFeed: () => ({ dispose() {} }),
onScroll: () => ({ dispose() {} }),
onWriteParsed: () => ({ dispose() {} }),
dispose() {
terminal.disposed = true
}
}
return terminal
}
function dispatchInit(cols: number, initialData: string): void {
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'init', cols, rows: 40, initialData })
})
)
}
describe('terminal WebView init surface replacement', () => {
let animationFrames: Array<() => void>
let terminals: TerminalStub[]
let writeCallbacks: Array<() => void>
beforeEach(() => {
animationFrames = []
terminals = []
writeCallbacks = []
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
animationFrames.push(callback)
return animationFrames.length
})
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
const webWindow = window as unknown as {
Terminal: new () => TerminalStub
ReactNativeWebView: { postMessage: (data: string) => void }
}
webWindow.Terminal = function () {
const terminal = makeTerminal(writeCallbacks)
terminals.push(terminal)
return terminal
} as unknown as new () => TerminalStub
webWindow.ReactNativeWebView = { postMessage: vi.fn() }
document.body.innerHTML = bodyMarkup()
// eslint-disable-next-line no-new-func
new Function(iifeSource())()
})
it('commits only the newest surface when phone-fit init calls overlap', () => {
// Why: restored terminals can receive desktop scrollback, a phone resize,
// and phone scrollback before any xterm replay callback has completed.
dispatchInit(120, 'desktop')
animationFrames.shift()?.()
dispatchInit(51, 'phone-resize')
animationFrames.shift()?.()
dispatchInit(51, 'phone-scrollback')
animationFrames.shift()?.()
expect(terminals).toHaveLength(3)
expect(writeCallbacks).toHaveLength(3)
writeCallbacks[2]?.()
writeCallbacks[0]?.()
writeCallbacks[1]?.()
const surfaces = document.querySelectorAll('#terminal-container > div')
expect(surfaces).toHaveLength(1)
expect(surfaces[0]?.id).toBe('terminal-surface')
expect((surfaces[0] as HTMLElement).style.visibility).toBe('visible')
expect(terminals.map((terminal) => terminal.disposed)).toEqual([true, true, false])
})
})
@@ -0,0 +1,49 @@
export const TERMINAL_SURFACE_SWAP_JS = String.raw`
// Why: phone-fit startup can issue several init() calls before xterm finishes
// replaying. Track the last painted surface separately from its replacement.
var committedTerm = null;
var committedSurface = surface;
var pendingTerm = null;
var pendingSurface = null;
function beginTerminalSurfaceSwap() {
// Why: a superseded hidden replacement must not remain between the last
// painted surface and the newest one, or the newest commits below the viewport.
if (pendingSurface) {
try { pendingSurface.remove(); } catch (e) {}
if (pendingTerm) try { pendingTerm.dispose(); } catch (e) {}
pendingSurface = null;
pendingTerm = null;
}
var swap = {
oldTerm: committedTerm,
oldSurface: committedSurface,
nextSurface: document.createElement('div')
};
disposeTermObservers();
swap.nextSurface.id = 'terminal-surface';
swap.nextSurface.style.visibility = 'hidden';
swap.nextSurface.style.position = 'absolute';
swap.nextSurface.style.left = '0';
swap.nextSurface.style.top = '0';
document.getElementById('terminal-container').appendChild(swap.nextSurface);
surface = swap.nextSurface;
pendingSurface = swap.nextSurface;
attachSurfaceEventHandlers(surface);
swap.oldSurface.removeAttribute('id');
return swap;
}
function commitTerminalSurfaceSwap(swap, nextTerm) {
swap.nextSurface.style.visibility = 'visible';
swap.nextSurface.style.position = '';
swap.nextSurface.style.left = '';
swap.nextSurface.style.top = '';
swap.oldSurface.remove();
if (swap.oldTerm) swap.oldTerm.dispose();
committedTerm = nextTerm;
committedSurface = swap.nextSurface;
pendingTerm = null;
pendingSurface = null;
}
`
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import { useRpcClientContext, type RpcClientContextValue } from './client-context'
export function useReconnectAttempt(hostId: string | undefined): number {
return useHostMetric(hostId, (context, id) => context.getReconnectAttempt(id), 0)
}
export function useLastConnectedAt(hostId: string | undefined): number | null {
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
}
function useHostMetric<T>(
hostId: string | undefined,
read: (context: RpcClientContextValue, hostId: string) => T,
fallback: T
): T {
const context = useRpcClientContext()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) {
return
}
return context.subscribeHostState(hostId, () => force((count) => count + 1))
}, [context, hostId])
return hostId ? read(context, hostId) : fallback
}
+4 -1
View File
@@ -10,6 +10,9 @@ const loadHostsMock = vi.fn()
vi.mock('./rpc-client', () => ({
connect: (...args: unknown[]) => connectMock(...args)
}))
vi.mock('./host-logical-client', () => ({
openHostLogicalClient: (...args: unknown[]) => connectMock(...args)
}))
vi.mock('./host-store', () => ({
loadHosts: () => loadHostsMock()
}))
@@ -131,7 +134,7 @@ describe('useHostClient', () => {
loadHostsMock.mockResolvedValue([HOST])
const harness = await renderHarness(HOST.id)
expect(harness.hook.client).toBe(fake)
expect(harness.hook.client).not.toBeNull()
expect(harness.hook.state).toBe('connected')
// Regression (STA-1511): closeHost deletes the entry; before the fix the
+36 -51
View File
@@ -19,11 +19,13 @@ import {
useState,
type ReactNode
} from 'react'
import { connect, type RpcClient } from './rpc-client'
import type { RpcClient } from './rpc-client'
import { connectionLogStore } from './connection-log-buffer'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
import { HostClientOpenRegistry } from './host-client-open-registry'
import { loadHosts } from './host-store'
import { openHostLogicalClient } from './host-logical-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
type StoreEntry = {
@@ -33,7 +35,7 @@ type StoreEntry = {
unsubState: () => void
}
type ContextValue = {
export type RpcClientContextValue = {
acquire: (hostId: string, host?: HostProfile) => RpcClient | null
release: (hostId: string) => void
forceReconnect: (hostId: string) => Promise<void>
@@ -45,6 +47,7 @@ type ContextValue = {
// Used by the UI to escalate "Reconnecting…" into a "host appears
// unreachable, re-pair?" prompt.
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => Array<{ hostId: string; client: RpcClient }>
subscribeAllHosts: (listener: () => void) => () => void
@@ -54,7 +57,7 @@ type ContextValue = {
primeHosts: (hosts: HostProfile[]) => void
}
const Ctx = createContext<ContextValue | null>(null)
const Ctx = createContext<RpcClientContextValue | null>(null)
export function RpcClientProvider({ children }: { children: ReactNode }) {
// Why: entries live in a ref so updates don't force re-renders of the
@@ -155,12 +158,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
let client: RpcClient
try {
client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, {
// Why: retain reconnect lifecycle events for the Connection Log
// screen — without this the reasons a host is stuck live only in
// console.log, which users can't see or share.
onLog: (entry) => connectionLogStore.append(hostId, entry)
})
client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry))
} catch {
// Why: connect() can throw synchronously if the public key is
// malformed or the endpoint URL is invalid. Notify so the UI
@@ -279,6 +277,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
return storeRef.current.get(hostId)?.client.getLastConnectedAt() ?? null
}, [])
const getActivePath = useCallback((hostId: string): MobileConnectionPath => {
return clientActivePath(storeRef.current.get(hostId)?.client)
}, [])
const subscribeHostState = useCallback(
(hostId: string, listener: (state: ConnectionState) => void) => {
let set = stateListenersRef.current.get(hostId)
@@ -348,7 +350,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
})
}, [])
const value = useMemo<ContextValue>(
const value = useMemo<RpcClientContextValue>(
() => ({
acquire,
release,
@@ -357,6 +359,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
getState,
getReconnectAttempt,
getLastConnectedAt,
getActivePath,
subscribeHostState,
getAllClients,
subscribeAllHosts,
@@ -370,6 +373,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
getState,
getReconnectAttempt,
getLastConnectedAt,
getActivePath,
subscribeHostState,
getAllClients,
subscribeAllHosts,
@@ -380,7 +384,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
function useCtx(): ContextValue {
export function useRpcClientContext(): RpcClientContextValue {
const ctx = useContext(Ctx)
if (!ctx) {
throw new Error('useHostClient must be used inside <RpcClientProvider>')
@@ -395,7 +399,7 @@ export function useHostClient(hostId: string | undefined): {
client: RpcClient | null
state: ConnectionState
} {
const ctx = useCtx()
const ctx = useRpcClientContext()
const [, force] = useState(0)
const [state, setState] = useState<ConnectionState>(() =>
hostId ? ctx.getState(hostId) : 'disconnected'
@@ -453,8 +457,9 @@ export function useAllHostClients(hostIds: string[]): Array<{
hostId: string
client: RpcClient
state: ConnectionState
path: MobileConnectionPath
}> {
const ctx = useCtx()
const ctx = useRpcClientContext()
// Stable key so we don't tear down on every render of the array.
const key = useMemo(() => [...hostIds].sort().join(','), [hostIds])
const [tick, setTick] = useState(0)
@@ -483,11 +488,21 @@ export function useAllHostClients(hostIds: string[]): Array<{
}, [key])
return useMemo(() => {
const out: Array<{ hostId: string; client: RpcClient; state: ConnectionState }> = []
const out: Array<{
hostId: string
client: RpcClient
state: ConnectionState
path: MobileConnectionPath
}> = []
for (const id of hostIds) {
const all = ctx.getAllClients().find((entry) => entry.hostId === id)
if (all) {
out.push({ hostId: id, client: all.client, state: ctx.getState(id) })
out.push({
hostId: id,
client: all.client,
state: ctx.getState(id),
path: ctx.getActivePath(id)
})
}
}
return out
@@ -499,13 +514,13 @@ export function useAllHostClients(hostIds: string[]): Array<{
// host-store has no React-side handle. Expose a hook that lets callers
// close a host after removal.
export function useCloseHost(): (hostId: string) => void {
const ctx = useCtx()
const ctx = useRpcClientContext()
return ctx.closeHost
}
// Why: future-proof "Connection issues — try again" affordance.
export function useForceReconnect(): (hostId: string) => Promise<void> {
const ctx = useCtx()
const ctx = useRpcClientContext()
return ctx.forceReconnect
}
@@ -513,41 +528,11 @@ export function useForceReconnect(): (hostId: string) => Promise<void> {
// provider can skip its own loadHosts() pass when it eventually opens
// each host — collapses two serial Keychain reads on cold-start into one.
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
const ctx = useCtx()
const ctx = useRpcClientContext()
return ctx.primeHosts
}
// Why: lets the home/host-detail UI escalate "Reconnecting…" to a more
// alarming "Can't connect" once the rpc-client has cycled enough times to
// indicate something's actually wrong (wrong port, server down, network
// loss). Reads through the context so it stays in sync with the live
// rpc-client instance even after forceReconnect swaps the underlying
// client.
export function useReconnectAttempt(hostId: string | undefined): number {
const ctx = useCtx()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) {
return
}
return ctx.subscribeHostState(hostId, () => force((n) => n + 1))
}, [ctx, hostId])
return hostId ? ctx.getReconnectAttempt(hostId) : 0
}
// Why: timestamp of last successful connect for this host, or null if
// the client has never connected. Combined with reconnectAttempt this
// distinguishes "transient blip" (recently connected) from "host
// appears unreachable" (never connected, or hasn't connected in N
// seconds despite many retry attempts).
export function useLastConnectedAt(hostId: string | undefined): number | null {
const ctx = useCtx()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) {
return
}
return ctx.subscribeHostState(hostId, () => force((n) => n + 1))
}, [ctx, hostId])
return hostId ? ctx.getLastConnectedAt(hostId) : null
function clientActivePath(client: RpcClient | undefined): MobileConnectionPath {
const logical = client as Partial<StableLogicalRpcClient> | undefined
return typeof logical?.getActivePath === 'function' ? logical.getActivePath() : 'lan'
}
+257
View File
@@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest'
import {
displayHostEndpoint,
endpointPort,
endpointScheme,
normalizeHostEndpoint
} from './host-endpoint'
describe('displayHostEndpoint', () => {
it('shows host:port for websocket URLs', () => {
expect(displayHostEndpoint('ws://192.168.1.10:6768')).toBe('192.168.1.10:6768')
// Why: URL omits default scheme ports (443 for wss); use a non-default port.
expect(displayHostEndpoint('wss://desk.example:8443')).toBe('desk.example:8443')
})
it('returns the raw string when not a URL', () => {
expect(displayHostEndpoint('not-a-url')).toBe('not-a-url')
})
it('brackets IPv6 hostnames for round-trip safety', () => {
expect(displayHostEndpoint('ws://[fd7a:115c:a1e0::1]:6768')).toBe('[fd7a:115c:a1e0::1]:6768')
})
})
describe('endpointPort', () => {
it('reads the port when present', () => {
expect(endpointPort('ws://192.168.1.10:6768')).toBe('6768')
})
it('returns undefined when the port is omitted', () => {
expect(endpointPort('ws://192.168.1.10')).toBeUndefined()
})
it('preserves scheme-default ports that URL.port hides', () => {
expect(endpointPort('ws://192.168.1.10:80')).toBe('80')
expect(endpointPort('wss://desk.example:443')).toBe('443')
})
})
describe('endpointScheme', () => {
it('returns wss for a wss:// endpoint', () => {
expect(endpointScheme('wss://desk.example:8443')).toBe('wss')
})
it('returns ws for a ws:// endpoint', () => {
expect(endpointScheme('ws://192.168.1.10:6768')).toBe('ws')
})
it('falls back to ws for a non-URL endpoint', () => {
expect(endpointScheme('not-a-url')).toBe('ws')
})
})
describe('normalizeHostEndpoint', () => {
it('accepts a full ws URL', () => {
expect(normalizeHostEndpoint('ws://100.64.0.5:6768')).toEqual({
ok: true,
endpoint: 'ws://100.64.0.5:6768'
})
})
it('accepts wss and preserves scheme', () => {
expect(normalizeHostEndpoint('wss://desk.example:8443')).toEqual({
ok: true,
endpoint: 'wss://desk.example:8443'
})
})
it('accepts bare host:port', () => {
expect(normalizeHostEndpoint('192.168.1.10:6768')).toEqual({
ok: true,
endpoint: 'ws://192.168.1.10:6768'
})
})
it('defaults missing port to 6768', () => {
expect(normalizeHostEndpoint('192.168.1.10')).toEqual({
ok: true,
endpoint: 'ws://192.168.1.10:6768'
})
})
it('uses fallbackPort when the user omits the port', () => {
expect(normalizeHostEndpoint('mac-mini.local', { fallbackPort: '7777' })).toEqual({
ok: true,
endpoint: 'ws://mac-mini.local:7777'
})
})
it('fills missing port on scheme URLs from fallbackPort', () => {
expect(normalizeHostEndpoint('ws://192.168.1.10', { fallbackPort: '9000' })).toEqual({
ok: true,
endpoint: 'ws://192.168.1.10:9000'
})
})
it('preserves explicit ws :80 and wss :443 instead of rewriting to fallback', () => {
expect(normalizeHostEndpoint('ws://192.168.1.10:80', { fallbackPort: '6768' })).toEqual({
ok: true,
endpoint: 'ws://192.168.1.10:80'
})
expect(normalizeHostEndpoint('wss://desk.example:443', { fallbackPort: '6768' })).toEqual({
ok: true,
endpoint: 'wss://desk.example:443'
})
expect(displayHostEndpoint('ws://192.168.1.10:80')).toBe('192.168.1.10:80')
expect(displayHostEndpoint('wss://desk.example:443')).toBe('desk.example:443')
})
it('trims whitespace', () => {
expect(normalizeHostEndpoint(' 10.0.0.2:6768 ')).toEqual({
ok: true,
endpoint: 'ws://10.0.0.2:6768'
})
})
it('rejects empty input', () => {
expect(normalizeHostEndpoint(' ')).toEqual({
ok: false,
error: 'Enter a host address.'
})
})
it('rejects non-websocket schemes', () => {
expect(normalizeHostEndpoint('http://192.168.1.10:6768')).toEqual({
ok: false,
error: 'Use ws:// or wss:// (or host:port).'
})
})
it('rejects explicit invalid ports without falling back', () => {
for (const input of [
'192.168.1.10:0',
'192.168.1.10:99999',
'ws://192.168.1.10:0',
'ws://192.168.1.10:99999'
]) {
expect(normalizeHostEndpoint(input, { fallbackPort: '6768' })).toEqual({
ok: false,
error: 'Port must be 1–65535.'
})
}
})
it('rejects bare hosts with path, query, or spaces', () => {
expect(normalizeHostEndpoint('desk/path')).toEqual({
ok: false,
error: 'Not a valid hostname.'
})
expect(normalizeHostEndpoint('desk?route')).toEqual({
ok: false,
error: 'Not a valid hostname.'
})
expect(normalizeHostEndpoint('desk name')).toEqual({
ok: false,
error: 'Not a valid hostname.'
})
})
it('rejects scheme URLs with path or query', () => {
expect(normalizeHostEndpoint('ws://desk.example/path')).toEqual({
ok: false,
error: 'Host must not include a path or query.'
})
expect(normalizeHostEndpoint('ws://desk.example?route=1')).toEqual({
ok: false,
error: 'Host must not include a path or query.'
})
})
it('accepts bracketed IPv6 with port', () => {
expect(normalizeHostEndpoint('[fd7a:115c:a1e0::1]:6768')).toEqual({
ok: true,
endpoint: 'ws://[fd7a:115c:a1e0::1]:6768'
})
})
it('rejects malformed IPv6 that a WebSocket URL cannot parse', () => {
expect(normalizeHostEndpoint('[1::2::3]:6768')).toEqual({
ok: false,
error: 'Not a valid hostname.'
})
})
it('rejects non-canonical numeric IPv4 forms in bare addresses', () => {
for (const host of ['999.999.999.999', '010.0.0.1', '127.1', '0x7f000001']) {
expect(normalizeHostEndpoint(`${host}:6768`)).toEqual({
ok: false,
error: 'Not a valid hostname.'
})
}
})
it('rejects non-canonical numeric IPv4 forms before URL normalization', () => {
for (const host of ['999.999.999.999', '010.0.0.1', '127.1', '0x7f000001']) {
expect(normalizeHostEndpoint(`ws://${host}:6768`)).toEqual({
ok: false,
error: 'Not a valid hostname.'
})
}
})
it('rejects encoded and trailing-dot numeric IPv4 aliases', () => {
for (const input of ['ws://127.1.:6768', 'wss://127.1.:6768', 'ws://%31%32%37.1:6768']) {
expect(normalizeHostEndpoint(input)).toEqual({
ok: false,
error: 'Not a valid hostname.'
})
}
})
it('rejects empty userinfo before URL normalization', () => {
expect(normalizeHostEndpoint('ws://@127.1:6768')).toEqual({
ok: false,
error: 'Not a valid address.'
})
})
it('reports a path or query before validating a numeric hostname alias', () => {
for (const input of ['ws://127.1/path', 'ws://127.1?route=1']) {
expect(normalizeHostEndpoint(input)).toEqual({
ok: false,
error: 'Host must not include a path or query.'
})
}
})
it('keeps ordinary DNS names that contain numeric labels', () => {
expect(normalizeHostEndpoint('desk123.local:6768')).toEqual({
ok: true,
endpoint: 'ws://desk123.local:6768'
})
expect(normalizeHostEndpoint('ws://123.example:6768')).toEqual({
ok: true,
endpoint: 'ws://123.example:6768'
})
})
it('preserves wss when re-normalizing bare host:port via fallbackScheme', () => {
expect(
normalizeHostEndpoint('desk.example:8443', { fallbackScheme: 'wss', fallbackPort: '8443' })
).toEqual({
ok: true,
endpoint: 'wss://desk.example:8443'
})
})
it('round-trips displayed IPv6 endpoints without port corruption', () => {
const original = 'ws://[fd7a:115c:a1e0::1]:6768'
const displayed = displayHostEndpoint(original)
expect(displayed).toBe('[fd7a:115c:a1e0::1]:6768')
expect(normalizeHostEndpoint(displayed, { fallbackPort: '6768' })).toEqual({
ok: true,
endpoint: original
})
})
})
+365
View File
@@ -0,0 +1,365 @@
// Why: mobile host profiles store a single websocket endpoint fixed at pair
// time. Edit-host lets the user rewrite host/port without re-pairing; this
// helper accepts phone-friendly input (bare IP, host:port, or full ws URL)
// and normalizes to the ws(s):// form RpcClient expects.
export type NormalizeHostEndpointResult =
| { ok: true; endpoint: string }
| { ok: false; error: string }
type WebsocketUrlPortResolution =
| { kind: 'missing' }
| { kind: 'valid'; port: string }
| { kind: 'invalid' }
type RawSchemeAuthority = {
hostname: string | null
hasUserInfo: boolean
hasPathOrQuery: boolean
}
const DEFAULT_PORT = '6768'
const NUMERIC_IPV4_CANDIDATE = /^(?:0[xX][0-9a-fA-F]+|\d+)(?:\.(?:0[xX][0-9a-fA-F]+|\d+))*$/
export function displayHostEndpoint(endpoint: string): string {
try {
const url = new URL(endpoint)
// Why: some URL parsers leave IPv6 brackets on hostname, others strip them.
// Normalize once so round-trip through normalizeHostEndpoint stays stable.
const host = formatHostForUrl(unwrapHostname(url.hostname))
const port = resolveWebsocketUrlPort(endpoint, url)
if (port.kind === 'invalid') {
return endpoint
}
return port.kind === 'valid' ? `${host}:${port.port}` : host
} catch {
return endpoint
}
}
function unwrapHostname(hostname: string): string {
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
}
/**
* Recover an explicitly written port from a ws(s) URL authority.
* Why: `new URL('ws://host:80').port` and `wss://host:443` are empty — the
* URL parser hides scheme-default ports, so callers that need the user's
* literal :80/:443 must re-parse the original string.
*/
function extractExplicitPortFromWebsocketUrl(input: string): string | null {
const withoutScheme = input.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '')
if (withoutScheme.startsWith('[')) {
const close = withoutScheme.indexOf(']')
if (close <= 1) {
return null
}
const rest = withoutScheme.slice(close + 1)
const match = /^:(\d+)(?=[/?#]|$)/.exec(rest)
return match?.[1] ?? null
}
const end = withoutScheme.search(/[/?#]/)
const authority = end === -1 ? withoutScheme : withoutScheme.slice(0, end)
const at = authority.lastIndexOf('@')
const hostPort = at === -1 ? authority : authority.slice(at + 1)
const match = /:(\d+)$/.exec(hostPort)
return match?.[1] ?? null
}
function resolveWebsocketUrlPort(input: string, url?: URL): WebsocketUrlPortResolution {
const explicit = extractExplicitPortFromWebsocketUrl(input)
// Why: missing and invalid are different states. Treating both as null lets
// an explicit :0/:99999 silently inherit fallbackPort on permissive parsers.
if (explicit !== null && !isValidPort(explicit)) {
return { kind: 'invalid' }
}
if (url?.port) {
return isValidPort(url.port) ? { kind: 'valid', port: url.port } : { kind: 'invalid' }
}
if (explicit !== null) {
return { kind: 'valid', port: explicit }
}
return { kind: 'missing' }
}
export function endpointPort(endpoint: string): string | undefined {
try {
const url = new URL(endpoint)
const port = resolveWebsocketUrlPort(endpoint, url)
return port.kind === 'valid' ? port.port : undefined
} catch {
return undefined
}
}
export function endpointScheme(endpoint: string): 'ws' | 'wss' {
try {
const protocol = new URL(endpoint).protocol.replace(':', '')
return protocol === 'wss' ? 'wss' : 'ws'
} catch {
return 'ws'
}
}
export function normalizeHostEndpoint(
input: string,
options?: { fallbackPort?: string | number; fallbackScheme?: 'ws' | 'wss' }
): NormalizeHostEndpointResult {
const trimmed = input.trim()
if (!trimmed) {
return { ok: false, error: 'Enter a host address.' }
}
const fallbackPort = resolveFallbackPort(options?.fallbackPort)
const fallbackScheme = options?.fallbackScheme === 'wss' ? 'wss' : 'ws'
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
return normalizeSchemeUrl(trimmed, fallbackPort)
}
return normalizeHostPort(trimmed, fallbackPort, fallbackScheme)
}
function resolveFallbackPort(value: string | number | undefined): string {
if (value == null) {
return DEFAULT_PORT
}
const asString = String(value).trim()
if (!asString || !isValidPort(asString)) {
return DEFAULT_PORT
}
return asString
}
function normalizeSchemeUrl(input: string, fallbackPort: string): NormalizeHostEndpointResult {
const explicitPort = resolveWebsocketUrlPort(input)
if (explicitPort.kind === 'invalid') {
return { ok: false, error: 'Port must be 1–65535.' }
}
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(input)?.[1]?.toLowerCase()
if (scheme !== 'ws' && scheme !== 'wss') {
return { ok: false, error: 'Use ws:// or wss:// (or host:port).' }
}
const rawAuthority = parseRawSchemeAuthority(input)
if (rawAuthority.hasUserInfo) {
return { ok: false, error: 'Not a valid address.' }
}
if (rawAuthority.hasPathOrQuery) {
return { ok: false, error: 'Host must not include a path or query.' }
}
if (
rawAuthority.hostname &&
validateNumericIpv4Candidate(normalizeRawNumericIpv4Candidate(rawAuthority.hostname))
) {
return { ok: false, error: 'Not a valid hostname.' }
}
let url: URL
try {
url = new URL(input)
} catch {
return { ok: false, error: 'Not a valid address.' }
}
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
return { ok: false, error: 'Use ws:// or wss:// (or host:port).' }
}
if (!url.hostname) {
return { ok: false, error: 'Missing hostname.' }
}
// Why: edit-host persists a bare host:port WebSocket endpoint. Path/query/
// userinfo are not part of the pairing contract — reject rather than strip
// so typos like desk/path or desk?route cannot be saved silently.
if (url.username || url.password) {
return { ok: false, error: 'Not a valid address.' }
}
if ((url.pathname && url.pathname !== '/') || url.search || url.hash) {
return { ok: false, error: 'Host must not include a path or query.' }
}
const hostname = unwrapHostname(url.hostname)
// Why: WHATWG URL accepts legacy aliases and rewrites them to a different
// IPv4 address. Only an already-canonical raw dotted quad may become IPv4.
if (rawAuthority.hostname && isCanonicalIpv4(hostname) && rawAuthority.hostname !== hostname) {
return { ok: false, error: 'Not a valid hostname.' }
}
const hostError = validateHostname(hostname)
if (hostError) {
return { ok: false, error: hostError }
}
// Why: keep explicit :80/:443 (URL.port is empty for scheme defaults) instead
// of rewriting them to fallbackPort (usually 6768).
const resolvedPort = resolveWebsocketUrlPort(input, url)
if (resolvedPort.kind === 'invalid') {
return { ok: false, error: 'Port must be 1–65535.' }
}
const port = resolvedPort.kind === 'valid' ? resolvedPort.port : fallbackPort
// Why: rebuild so accidental whitespace never reaches the WebSocket constructor.
return { ok: true, endpoint: `${url.protocol}//${formatHostForUrl(hostname)}:${port}` }
}
function parseRawSchemeAuthority(input: string): RawSchemeAuthority {
const schemeEnd = input.indexOf('://')
const remainder = input.slice(schemeEnd + 3)
const authorityEnd = remainder.search(/[/?#]/)
const authority = authorityEnd === -1 ? remainder : remainder.slice(0, authorityEnd)
const suffix = authorityEnd === -1 ? '' : remainder.slice(authorityEnd)
const hasUserInfo = authority.includes('@')
const hostPort = hasUserInfo ? authority.slice(authority.lastIndexOf('@') + 1) : authority
if (!hostPort) {
return { hostname: null, hasUserInfo, hasPathOrQuery: suffix !== '' && suffix !== '/' }
}
if (hostPort.startsWith('[')) {
const close = hostPort.indexOf(']')
return {
hostname: close > 1 ? hostPort.slice(1, close) : null,
hasUserInfo,
hasPathOrQuery: suffix !== '' && suffix !== '/'
}
}
const lastColon = hostPort.lastIndexOf(':')
return {
hostname: lastColon === -1 ? hostPort : hostPort.slice(0, lastColon),
hasUserInfo,
hasPathOrQuery: suffix !== '' && suffix !== '/'
}
}
function normalizeHostPort(
input: string,
fallbackPort: string,
fallbackScheme: 'ws' | 'wss'
): NormalizeHostEndpointResult {
let host: string
let port: string | undefined
if (input.startsWith('[')) {
const close = input.indexOf(']')
if (close <= 1) {
return { ok: false, error: 'Not a valid address.' }
}
host = input.slice(1, close)
const rest = input.slice(close + 1)
if (rest.startsWith(':')) {
port = rest.slice(1)
} else if (rest.length > 0) {
return { ok: false, error: 'Not a valid address.' }
}
} else {
const firstColon = input.indexOf(':')
const lastColon = input.lastIndexOf(':')
if (firstColon !== -1 && firstColon === lastColon) {
host = input.slice(0, firstColon)
port = input.slice(firstColon + 1)
} else {
// No port, or bare IPv6 (multiple colons, no brackets).
host = input
}
}
host = host.trim()
if (!host) {
return { ok: false, error: 'Missing hostname.' }
}
// Why: bare input is not a URL, so characters that only make sense in a URL
// (path, query, fragment, whitespace) must not be treated as hostname bytes.
const hostError = validateHostname(host)
if (hostError) {
return { ok: false, error: hostError }
}
if (port !== undefined) {
port = port.trim()
if (!isValidPort(port)) {
return { ok: false, error: 'Port must be 1–65535.' }
}
}
const finalPort = port ?? fallbackPort
return { ok: true, endpoint: `${fallbackScheme}://${formatHostForUrl(host)}:${finalPort}` }
}
function formatHostForUrl(host: string): string {
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
}
/**
* Reject hostnames that would be illegal or ambiguous in a websocket URL.
* Allows DNS labels, `.local` mDNS, IPv4, and IPv6 hex forms.
*/
function validateHostname(host: string): string | null {
if (!host) {
return 'Missing hostname.'
}
// Spaces, path/query/fragment separators, userinfo separators, brackets.
if (/[\s/?#@[\]]/.test(host)) {
return 'Not a valid hostname.'
}
const numericIpv4Error = validateNumericIpv4Candidate(host)
if (numericIpv4Error) {
return numericIpv4Error
}
if (host.includes(':')) {
// Why: a hex/colon regex accepts malformed forms such as two `::` runs.
// Reuse the URL parser that WebSocket will ultimately use.
if (!/^[0-9a-fA-F:]+$/.test(host)) {
return 'Not a valid hostname.'
}
try {
new URL(`ws://[${host}]:${DEFAULT_PORT}`)
} catch {
return 'Not a valid hostname.'
}
return null
}
// DNS / IPv4 / mDNS: labels of alnum and hyphen, dots between, no empty labels.
if (
!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(
host
)
) {
return 'Not a valid hostname.'
}
return null
}
function validateNumericIpv4Candidate(host: string): string | null {
if (!NUMERIC_IPV4_CANDIDATE.test(host)) {
return null
}
if (!isCanonicalIpv4(host)) {
return 'Not a valid hostname.'
}
return null
}
function normalizeRawNumericIpv4Candidate(host: string): string {
let decoded = host
try {
decoded = decodeURIComponent(host)
} catch {
// The URL parser will reject malformed escapes; keep them untouched here.
}
return decoded.endsWith('.') ? decoded.slice(0, -1) : decoded
}
function isCanonicalIpv4(host: string): boolean {
const octets = host.split('.')
return (
octets.length === 4 &&
octets.every((octet) => /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255)
)
}
function isValidPort(port: string): boolean {
if (!/^\d+$/.test(port)) {
return false
}
const n = Number(port)
return n >= 1 && n <= 65535
}
@@ -0,0 +1,36 @@
import { AppState, Platform } from 'react-native'
import { connect, type RpcClient } from './rpc-client'
import { createStableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionLogSink, HostProfile } from './types'
import { directPathForEndpoint } from './mobile-direct-endpoint-probe'
import { startMobileEndpointLifecycle } from './mobile-endpoint-lifecycle'
export function openHostLogicalClient(host: HostProfile, onLog: ConnectionLogSink): RpcClient {
// Why: the stable facade owns app-visible RPC/subscription state while the
// direct socket remains a replaceable first physical generation.
const logical = createStableLogicalRpcClient(
connect(host.endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
directPathForEndpoint(host, host.endpoint)
)
if (Platform.OS === 'web') {
return logical
}
const endpointLifecycle = startMobileEndpointLifecycle(logical, host, onLog)
endpointLifecycle.setForeground(AppState.currentState === 'active')
const appStateSubscription = AppState.addEventListener('change', (state) => {
endpointLifecycle.setForeground(state === 'active')
})
const closeLogical = logical.close
logical.close = () => {
appStateSubscription.remove()
endpointLifecycle.stop()
closeLogical()
}
const notifyLogicalForeground = logical.notifyForeground
logical.notifyForeground = () => {
endpointLifecycle.setForeground(true)
notifyLogicalForeground()
}
return logical
}
@@ -0,0 +1,95 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { updateHostNameAndEndpoint } from './host-store'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
}
}))
vi.mock('expo-secure-store', () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn(),
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY'
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios' }
}))
describe('updateHostNameAndEndpoint', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
const stored = [
{
id: 'host-1',
name: 'Desk',
endpoint: 'ws://100.64.0.5:6768',
publicKeyB64: 'pk',
lastConnected: 1
},
{
id: 'host-2',
name: 'Laptop',
endpoint: 'wss://laptop.example:8443',
publicKeyB64: 'pk-2',
lastConnected: 2
}
]
it('commits name and endpoint together in a single write', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
await updateHostNameAndEndpoint('host-1', {
name: 'Home Desk',
endpoint: 'ws://192.168.1.10:6768'
})
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:hosts',
JSON.stringify([
{ ...stored[0], name: 'Home Desk', endpoint: 'ws://192.168.1.10:6768' },
stored[1]
])
)
})
it('updates only the provided field', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
await updateHostNameAndEndpoint('host-1', { name: 'Home Desk' })
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:hosts',
JSON.stringify([{ ...stored[0], name: 'Home Desk' }, stored[1]])
)
})
it('rewrites only the endpoint when name is omitted', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
await updateHostNameAndEndpoint('host-1', { endpoint: 'ws://192.168.1.10:6768' })
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:hosts',
JSON.stringify([{ ...stored[0], endpoint: 'ws://192.168.1.10:6768' }, stored[1]])
)
})
it('throws and writes nothing when the host is missing', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue('[]')
await expect(updateHostNameAndEndpoint('missing', { name: 'Renamed' })).rejects.toThrow(
'Host not found'
)
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
})
})
+159 -5
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay'
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn(),
@@ -32,9 +33,21 @@ vi.mock('./host-credential-cleanup', () => ({
retryPendingHostCredentialCleanups: vi.fn()
}))
import { removeHost, renameHost, resetHostStoreForTests, updateLastConnected } from './host-store'
import {
loadHosts,
MobileRelayUpgradeHostRemovedError,
removeHost,
resolvePairingHostIdentity,
resetHostStoreForTests,
saveHost,
saveExistingHostRelayUpgrade,
updateHostNameAndEndpoint,
updateLastConnected
} from './host-store'
import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store'
const HOSTS_STORAGE_KEY = 'orca:hosts'
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
const HOST_ONE = {
id: 'host-1',
name: 'Host 1',
@@ -52,24 +65,118 @@ const HOST_TWO = {
describe('host-store list mutations', () => {
let storedHostsRaw: string
let storedOverlayRaw: string | null
beforeEach(() => {
vi.clearAllMocks()
resetHostStoreForTests()
resetMobileRelayHostOverlayStoreForTests()
scheduleCleanupMock.mockReset()
scheduleCleanupMock.mockResolvedValue(undefined)
storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO])
storedOverlayRaw = null
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === HOSTS_STORAGE_KEY) {
return storedHostsRaw
}
if (key === OVERLAY_STORAGE_KEY) {
return storedOverlayRaw
}
return null
})
asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => {
if (key === HOSTS_STORAGE_KEY) {
storedHostsRaw = raw
} else if (key === OVERLAY_STORAGE_KEY) {
storedOverlayRaw = raw
}
})
secureStoreMock.getItemAsync.mockImplementation(async (key: string) =>
key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null
)
})
it('resolves an existing host by pinned key with one durable read', async () => {
await expect(resolvePairingHostIdentity(HOST_TWO.publicKeyB64, 'host-new')).resolves.toEqual({
id: HOST_TWO.id,
name: HOST_TWO.name
})
expect(asyncStorageMock.getItem).toHaveBeenCalledOnce()
})
it('names a new host from the same durable read used for identity lookup', async () => {
await expect(resolvePairingHostIdentity('unpaired-key', 'host-new')).resolves.toEqual({
id: 'host-new',
name: 'Host 3'
})
expect(asyncStorageMock.getItem).toHaveBeenCalledOnce()
})
it('fails closed when durable host identity storage is unreadable', async () => {
asyncStorageMock.getItem.mockRejectedValueOnce(new Error('storage unavailable'))
await expect(resolvePairingHostIdentity('key-new', 'host-new')).rejects.toThrow(/unreadable/)
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
})
it('collapses already-persisted duplicates when the desktop is re-paired', async () => {
storedHostsRaw = JSON.stringify([
HOST_ONE,
{ ...HOST_TWO, id: 'host-duplicate', publicKeyB64: HOST_ONE.publicKeyB64 }
])
await saveHost({
...HOST_ONE,
endpoint: 'ws://127.0.0.1:3',
deviceToken: 'replacement-token'
})
expect(JSON.parse(storedHostsRaw)).toEqual([{ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3' }])
expect(scheduleCleanupMock).toHaveBeenCalledWith('host-duplicate', expect.any(Function))
})
it('clears stale relay state when an existing host is re-paired direct-only', async () => {
const overlay: MobileRelayHostOverlay = {
v: 2,
hostId: HOST_ONE.id,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint },
{
id: 'relay-primary',
kind: 'relay',
url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9'
}
],
relayHostId: 'AbCdEf0123_-xyZ9',
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2
}
}
storedOverlayRaw = JSON.stringify([overlay])
await saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' })
expect(JSON.parse(storedOverlayRaw!)).toEqual([])
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
})
it('does not touch relay storage when saving a new direct-only host', async () => {
await saveHost({
id: 'host-new',
name: 'New Host',
endpoint: 'ws://127.0.0.1:3',
publicKeyB64: 'key-new',
deviceToken: 'new-token',
lastConnected: 0
})
expect(asyncStorageMock.getItem).not.toHaveBeenCalledWith(OVERLAY_STORAGE_KEY)
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
})
it('commits the removal when credential cleanup scheduling rejects', async () => {
@@ -81,6 +188,51 @@ describe('host-store list mutations', () => {
expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function))
})
it('merges v2 endpoints only onto an existing legacy base host', async () => {
const overlay: MobileRelayHostOverlay = {
v: 2,
hostId: HOST_ONE.id,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint },
{
id: 'relay-primary',
kind: 'relay',
url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9'
}
],
relayHostId: 'AbCdEf0123_-xyZ9',
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2
}
}
storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }])
const hosts = await loadHosts()
expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({
endpoints: overlay.endpoints,
relayHostId: overlay.relayHostId,
relay: overlay.relay
})
expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false)
})
it('refuses to resurrect a removed host during relay upgrade publication', async () => {
storedHostsRaw = JSON.stringify([HOST_TWO])
await expect(
saveExistingHostRelayUpgrade({ ...HOST_ONE, deviceToken: 'token-1' })
).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError)
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO])
expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled()
})
it('awaits cleanup scheduling after metadata commit', async () => {
let resolveSchedule: (() => void) | null = null
scheduleCleanupMock.mockReturnValue(
@@ -121,7 +273,7 @@ describe('host-store list mutations', () => {
return storedHostsRaw
})
const rename = renameHost(HOST_ONE.id, 'Renamed Host')
const rename = updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Renamed Host' })
const remove = removeHost(HOST_TWO.id)
// Both writers have started their RMW and are blocked on the shared read
// gate; without a mutation queue the second would clobber the first.
@@ -142,9 +294,9 @@ describe('host-store list mutations', () => {
it('preserves a rename when lastConnected updates race it', async () => {
const before = Date.now()
await Promise.all([
renameHost(HOST_ONE.id, 'Alpha'),
updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Alpha' }),
updateLastConnected(HOST_ONE.id),
renameHost(HOST_TWO.id, 'Beta')
updateHostNameAndEndpoint(HOST_TWO.id, { name: 'Beta' })
])
const stored = JSON.parse(storedHostsRaw) as Array<typeof HOST_ONE>
@@ -160,7 +312,9 @@ describe('host-store list mutations', () => {
it('does not wipe the host list when storage is unreadable during mutation', async () => {
storedHostsRaw = '{'
await expect(renameHost(HOST_ONE.id, 'Nope')).rejects.toThrow(/unreadable/)
await expect(updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Nope' })).rejects.toThrow(
/unreadable/
)
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
expect(storedHostsRaw).toBe('{')
})
+125 -24
View File
@@ -12,6 +12,15 @@ import {
retryPendingHostCredentialCleanups,
scheduleHostCredentialCleanup
} from './host-credential-cleanup'
import {
loadMobileRelayHostOverlayState,
removeMobileRelayHostOverlay,
removeMobileRelayHostOverlays,
saveMobileRelayHostOverlay
} from './mobile-relay-host-overlay-store'
import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal'
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
const STORAGE_KEY = 'orca:hosts'
// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected.
@@ -61,6 +70,12 @@ async function deleteDeviceToken(hostId: string): Promise<void> {
await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
}
async function deleteHostCredentials(hostId: string): Promise<void> {
await deleteDeviceToken(hostId)
await deleteMobileRelayCredentialBundle(hostId)
await deleteMobileRelayDirectUpgradeJournal(hostId)
}
// Why: SecureStore reads on Android Keystore can take 50-200ms each, and
// loadHosts() is called from every screen mount + every useFocusEffect.
// Stack with N hosts and you get N*200ms blocking every navigation, which
@@ -120,6 +135,14 @@ async function doLoadHosts(): Promise<HostProfile[]> {
if (!storedHosts) {
return []
}
const overlayState = await loadMobileRelayHostOverlayState(
new Set(storedHosts.map(({ id }) => id))
)
await scheduleOrphanedMobileRelayCleanup({
hostIds: overlayState.orphanHostIds,
deleteCredential: deleteHostCredentials
})
const overlays = overlayState.overlays
const out: HostProfile[] = []
for (const stored of storedHosts) {
@@ -144,17 +167,34 @@ async function doLoadHosts(): Promise<HostProfile[]> {
token = fetched
tokenCache.set(stored.id, token)
}
out.push({ ...stored, deviceToken: token })
const overlay = overlays.get(stored.id)
out.push({
...stored,
deviceToken: token,
...(overlay
? {
endpoints: overlay.endpoints,
relayHostId: overlay.relayHostId,
relay: overlay.relay
}
: {})
})
}
return out
}
async function loadStoredHosts(): Promise<StoredHostProfile[]> {
try {
return parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) ?? []
} catch {
return []
}
export async function resolvePairingHostIdentity(
publicKeyB64: string,
newHostId: string
): Promise<{ id: string; name: string }> {
// Why: one durable read both preserves an existing identity and names a new host,
// avoiding duplicate cards and a second serial storage read before connecting.
await hostListMutation
const hosts = await readStoredHostsForMutation()
const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64)
return match
? { id: match.id, name: match.name }
: { id: newHostId, name: getNextHostNameFromHosts(hosts) }
}
async function readStoredHostsForMutation(): Promise<StoredHostProfile[]> {
@@ -196,17 +236,41 @@ function toStored(host: HostProfile): StoredHostProfile {
}
}
export class MobileRelayUpgradeHostRemovedError extends Error {}
export async function saveHost(host: HostProfile): Promise<void> {
await persistHost(host, false)
}
export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise<void> {
await persistHost(host, true)
}
async function persistHost(host: HostProfile, requireExisting: boolean): Promise<void> {
const validated = HostProfileSchema.parse(host)
const stored = toStored(validated)
const duplicateHostIds = new Set<string>()
let updatedExistingHost = false
await mutateStoredHosts((hosts) => {
const index = hosts.findIndex((h) => h.id === stored.id)
if (index >= 0) {
const next = hosts.slice()
next[index] = stored
return next
for (const candidate of hosts) {
if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) {
duplicateHostIds.add(candidate.id)
}
}
return [...hosts, stored]
if (index >= 0) {
updatedExistingHost = true
// Why: affected installs may already contain duplicate rows; an authoritative
// save is the safe point to collapse them to the preserved host id.
return hosts
.filter(({ id }) => !duplicateHostIds.has(id))
.map((candidate) => (candidate.id === stored.id ? stored : candidate))
}
if (requireExisting) {
// Why: an in-flight relay upgrade must not resurrect a host the user removed.
throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
}
return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored]
})
// Why: write metadata BEFORE the keychain token so a crash between the two
// leaves orphaned metadata (which loadHosts skips and removeHost can clean
@@ -215,15 +279,47 @@ export async function saveHost(host: HostProfile): Promise<void> {
// from current metadata.
await writeDeviceToken(stored.id, validated.deviceToken)
tokenCache.set(stored.id, validated.deviceToken)
if (validated.endpoints) {
await saveMobileRelayHostOverlay({
v: 2,
hostId: stored.id,
endpoints: validated.endpoints,
relayHostId: validated.relayHostId,
relay: validated.relay
})
}
const overlayRemovalIds = [...duplicateHostIds]
if (!validated.endpoints && updatedExistingHost) {
overlayRemovalIds.push(stored.id)
}
if (overlayRemovalIds.length > 0) {
// Why: reusing an id for direct-only re-pairing must not retain routing
// metadata from the host's previous transport state.
await removeMobileRelayHostOverlays(overlayRemovalIds)
}
for (const duplicateHostId of duplicateHostIds) {
tokenCache.delete(duplicateHostId)
try {
await scheduleHostCredentialCleanup(duplicateHostId, deleteHostCredentials)
} catch {
// Metadata is already deduplicated; orphan-token recovery is best-effort.
}
}
}
export async function removeHost(hostId: string): Promise<void> {
await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId))
tokenCache.delete(hostId)
try {
await removeMobileRelayHostOverlay(hostId)
} catch {
// The missing legacy base is authoritative, so a retained overlay cannot
// resurrect this host and can be cleaned on a later explicit retry.
}
// Why: await only the durable cleanup intent (AsyncStorage). Native keychain
// delete can reject or stall and must not freeze removeHost / the UI.
try {
await scheduleHostCredentialCleanup(hostId, deleteDeviceToken)
await scheduleHostCredentialCleanup(hostId, deleteHostCredentials)
} catch {
// Metadata is already committed; orphan-token recovery is best-effort.
}
@@ -234,27 +330,32 @@ export async function retryPendingHostCredentialCleanup(): Promise<{
remainingIds: string[]
storageUnreadable: boolean
}> {
return retryPendingHostCredentialCleanups(deleteDeviceToken)
return retryPendingHostCredentialCleanups(deleteHostCredentials)
}
export async function renameHost(hostId: string, newName: string): Promise<void> {
// Why: Edit host can change name and endpoint together; a single
// mutateStoredHosts pass keeps both fields committed atomically so a
// mid-save failure can never persist one change without the other, and a
// host removed mid-edit throws consistently instead of silently no-oping.
export async function updateHostNameAndEndpoint(
hostId: string,
updates: { name?: string; endpoint?: string }
): Promise<void> {
await mutateStoredHosts((hosts) => {
const index = hosts.findIndex((h) => h.id === hostId)
const index = hosts.findIndex((host) => host.id === hostId)
if (index < 0) {
return hosts
throw new Error('Host not found')
}
const next = hosts.slice()
next[index] = { ...next[index]!, name: newName }
next[index] = {
...next[index]!,
...(updates.name !== undefined ? { name: updates.name } : {}),
...(updates.endpoint !== undefined ? { endpoint: updates.endpoint } : {})
}
return next
})
}
export async function getNextHostName(): Promise<string> {
await hostListMutation
const hosts = await loadStoredHosts()
return getNextHostNameFromHosts(hosts)
}
export async function updateLastConnected(hostId: string): Promise<void> {
try {
await mutateStoredHosts((hosts) => {
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { mobileConnectionPathLabel } from './mobile-connection-path-label'
describe('mobile connection path label', () => {
it('distinguishes LAN, Tailscale, and the relay without exposing transport errors', () => {
expect(mobileConnectionPathLabel('lan')).toBe('Direct · LAN')
expect(mobileConnectionPathLabel('tailscale')).toBe('Direct · Tailscale')
expect(mobileConnectionPathLabel('relay')).toBe('Orca Relay')
})
})
@@ -0,0 +1,8 @@
import type { MobileConnectionPath } from './stable-logical-rpc-client'
export function mobileConnectionPathLabel(path: MobileConnectionPath): string {
if (path === 'relay') {
return 'Orca Relay'
}
return path === 'tailscale' ? 'Direct · Tailscale' : 'Direct · LAN'
}
@@ -0,0 +1,71 @@
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { HostProfile } from './types'
function directEndpointUrls(host: HostProfile): string[] {
const endpoints =
host.endpoints?.filter(({ kind }) => kind !== 'relay').map(({ url }) => url) ?? []
return [...new Set([host.endpoint, ...endpoints])]
}
export function directPathForEndpoint(
host: HostProfile,
endpoint: string
): Exclude<MobileConnectionPath, 'relay'> {
const configured = host.endpoints?.find((candidate) => candidate.url === endpoint)
if (configured?.kind === 'tailscale') {
return 'tailscale'
}
try {
const hostname = new URL(endpoint).hostname
if (hostname.endsWith('.ts.net') || /^100\.(?:\d{1,3}\.){2}\d{1,3}$/.test(hostname)) {
return 'tailscale'
}
} catch {}
return 'lan'
}
function waitForAuthenticatedSession(session: RpcClient, timeoutMs: number): Promise<void> {
if (session.getState() === 'connected') {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null
const unsubscribe = session.onStateChange((state) => {
if (state === 'connected') {
finish()
resolve()
} else if (state === 'disconnected' || state === 'auth-failed') {
finish()
reject(new Error(`probe session ${state}`))
}
})
timer = setTimeout(() => {
finish()
reject(new Error('probe session authentication timed out'))
}, timeoutMs)
function finish(): void {
if (timer) {
clearTimeout(timer)
}
unsubscribe()
}
})
}
export async function openAuthenticatedDirectEndpoint(
host: HostProfile,
openDirect: (endpoint: string) => RpcClient,
timeoutMs: number
): Promise<{ client: RpcClient; path: Exclude<MobileConnectionPath, 'relay'> } | null> {
for (const endpoint of directEndpointUrls(host)) {
const client = openDirect(endpoint)
try {
await waitForAuthenticatedSession(client, timeoutMs)
return { client, path: directPathForEndpoint(host, endpoint) }
} catch {
client.close()
}
}
return null
}
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import { MOBILE_E2EE_LEGACY_FIXTURE } from '../../../src/shared/mobile-e2ee-legacy-fixtures'
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
}))
import { decrypt, decryptBytes, deriveSharedKey } from './e2ee'
describe('mobile legacy E2EE fixtures', () => {
it('matches the captured desktop key and text/binary frames', () => {
const fixture = MOBILE_E2EE_LEGACY_FIXTURE
const server = nacl.box.keyPair.fromSecretKey(fixture.serverSecretKey)
const client = nacl.box.keyPair.fromSecretKey(fixture.clientSecretKey)
const shared = deriveSharedKey(client.secretKey, server.publicKey)
expect(hex(shared)).toBe(fixture.sharedKeyHex)
expect(decrypt(fixture.authFrameB64, shared)).toBe(fixture.authPlaintext)
expect(decryptBytes(fromHex(fixture.binaryFrameHex), shared)).toEqual(fixture.binaryPlaintext)
})
})
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function fromHex(value: string): Uint8Array {
return Uint8Array.from(value.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16))
}
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake,
type MobileE2EEV2Ready
} from '../../../src/shared/mobile-e2ee-v2-contract'
import { sealMobileE2EEV2Frame } from '../../../src/shared/mobile-e2ee-v2-framing'
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
}))
import { deriveSharedKey } from './e2ee'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
function setup() {
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
transport: 'relay',
relayHostId: 'AbCdEf0123_-xyZ9',
clientNonce: new Uint8Array(32).fill(3),
clientKeyPair: client
})
const ready: MobileE2EEV2Ready = {
type: 'e2ee_ready',
v: 2,
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
clientNonceB64: session.hello.clientNonceB64,
desktopNonceB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64'),
selection: { framing: 2, payloadKinds: ['text', 'binary'] },
context: session.hello.context
}
return { session, ready }
}
describe('mobile E2EE v2 client session', () => {
it('pins the desktop key and accepts the exact transcript', () => {
const { session, ready } = setup()
expect(session.acceptReady(ready)).toBe(true)
expect(session.transcriptHashB64).toHaveLength(44)
expect(
session.acceptReady({
...ready,
desktopPublicKeyB64: Buffer.from(new Uint8Array(32).fill(8)).toString('base64')
})
).toBe(false)
})
it('seals auth at counter zero and rejects replayed desktop frames', () => {
const { session, ready } = setup()
expect(session.acceptReady(ready)).toBe(true)
const auth = JSON.stringify({
type: 'e2ee_auth',
v: 2,
transcriptHashB64: session.transcriptHashB64,
deviceToken: 'token'
})
const authFrame = Buffer.from(session.sealText(auth), 'base64')
expect(authFrame.subarray(16, 24)).toEqual(Buffer.alloc(8, 0))
const handshake = validateMobileE2EEV2Handshake(session.hello, ready)!
const schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret: deriveSharedKey(desktop.secretKey, client.publicKey),
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
const response = sealMobileE2EEV2Frame({
payload: new TextEncoder().encode('authenticated'),
key: schedule.desktopToMobileKey,
sessionId: schedule.sessionId,
direction: 'desktop-to-mobile',
payloadKind: 'text',
counter: 0n
})
const encoded = Buffer.from(response).toString('base64')
expect(session.openText(encoded)).toBe('authenticated')
expect(session.openText(encoded)).toBeNull()
})
})
@@ -0,0 +1,167 @@
import * as ExpoCrypto from 'expo-crypto'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake,
type MobileE2EETransport,
type MobileE2EEV2Hello
} from '../../../src/shared/mobile-e2ee-v2-contract'
import {
openMobileE2EEV2Frame,
sealMobileE2EEV2Frame
} from '../../../src/shared/mobile-e2ee-v2-framing'
import { deriveSharedKey, generateKeyPair, publicKeyFromBase64, publicKeyToBase64 } from './e2ee'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
export class MobileE2EEV2ClientSession {
readonly hello: MobileE2EEV2Hello
private inboundCounter = 0n
private outboundCounter = 0n
private schedule: ReturnType<typeof deriveMobileE2EEV2KeySchedule> | null = null
private transcriptHashB64Value: string | null = null
private constructor(
private readonly clientSecretKey: Uint8Array,
private readonly pinnedDesktopPublicKey: Uint8Array,
hello: MobileE2EEV2Hello
) {
this.hello = hello
}
static create(args: {
desktopPublicKeyB64: string
transport: MobileE2EETransport
relayHostId?: string
clientNonce?: Uint8Array
clientKeyPair?: { publicKey: Uint8Array; secretKey: Uint8Array }
}): MobileE2EEV2ClientSession {
const keyPair = args.clientKeyPair ?? generateKeyPair()
const clientNonce = args.clientNonce ?? ExpoCrypto.getRandomBytes(32)
if (clientNonce.length !== 32) {
throw new Error(`Invalid client nonce length: ${clientNonce.length}`)
}
return new MobileE2EEV2ClientSession(
keyPair.secretKey,
publicKeyFromBase64(args.desktopPublicKeyB64),
{
type: 'e2ee_hello',
v: 2,
clientPublicKeyB64: publicKeyToBase64(keyPair.publicKey),
clientNonceB64: encodeBase64(clientNonce),
capabilities: { framing: [2], payloadKinds: ['text', 'binary'] },
context: {
protocol: 'orca-mobile-e2ee',
initiator: 'mobile',
responder: 'desktop',
transport: args.transport,
...(args.relayHostId ? { relayHostId: args.relayHostId } : {})
}
}
)
}
acceptReady(ready: unknown): boolean {
const handshake = validateMobileE2EEV2Handshake(this.hello, ready)
if (!handshake || !equalBytes(handshake.desktopPublicKey, this.pinnedDesktopPublicKey)) {
return false
}
this.schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret: deriveSharedKey(this.clientSecretKey, this.pinnedDesktopPublicKey),
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
this.transcriptHashB64Value = encodeBase64(this.schedule.transcriptHash)
return true
}
get transcriptHashB64(): string {
if (!this.transcriptHashB64Value) {
throw new Error('E2EE v2 ready has not been accepted')
}
return this.transcriptHashB64Value
}
openText(frameB64: string): string | null {
const frame = decodeCanonicalBase64(frameB64)
if (!frame) {
return null
}
const plaintext = this.open(frame, 'text')
return plaintext ? new TextDecoder().decode(plaintext) : null
}
openBinary(frame: Uint8Array): Uint8Array | null {
return this.open(frame, 'binary')
}
sealText(plaintext: string): string {
return encodeBase64(this.seal(new TextEncoder().encode(plaintext), 'text'))
}
sealBinary(plaintext: Uint8Array): Uint8Array {
return this.seal(plaintext, 'binary')
}
private open(frame: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array | null {
if (!this.schedule) {
return null
}
const plaintext = openMobileE2EEV2Frame({
frame,
key: this.schedule.desktopToMobileKey,
sessionId: this.schedule.sessionId,
direction: 'desktop-to-mobile',
payloadKind,
expectedCounter: this.inboundCounter
})
if (plaintext) {
this.inboundCounter++
}
return plaintext
}
private seal(plaintext: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array {
if (!this.schedule) {
throw new Error('E2EE v2 ready has not been accepted')
}
const frame = sealMobileE2EEV2Frame({
payload: plaintext,
key: this.schedule.mobileToDesktopKey,
sessionId: this.schedule.sessionId,
direction: 'mobile-to-desktop',
payloadKind,
counter: this.outboundCounter
})
this.outboundCounter++
return frame
}
}
function encodeBase64(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
function decodeCanonicalBase64(value: string): Uint8Array | null {
try {
const binary = atob(value)
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
return encodeBase64(bytes) === value ? bytes : null
} catch {
return null
}
}
function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
if (left.length !== right.length) {
return false
}
let difference = 0
for (let index = 0; index < left.length; index++) {
difference |= left[index]! ^ right[index]!
}
return difference === 0
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake
} from '../../../src/shared/mobile-e2ee-v2-contract'
import {
createMobileE2EEV2Fixture,
MOBILE_E2EE_V2_VECTOR
} from '../../../src/shared/mobile-e2ee-v2-fixtures'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
describe('mobile E2EE v2 key schedule', () => {
it('matches the desktop normative HKDF vector', () => {
const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture()
const handshake = validateMobileE2EEV2Handshake(hello, ready)!
const schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret,
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
expect(hex(schedule.mobileToDesktopKey)).toBe(MOBILE_E2EE_V2_VECTOR.mobileToDesktopKeyHex)
expect(hex(schedule.desktopToMobileKey)).toBe(MOBILE_E2EE_V2_VECTOR.desktopToMobileKeyHex)
expect(hex(schedule.sessionId)).toBe(MOBILE_E2EE_V2_VECTOR.sessionIdHex)
expect(hex(schedule.transcriptHash)).toBe(MOBILE_E2EE_V2_VECTOR.transcriptHashHex)
})
it('derives unique direction keys and session IDs across fresh client nonces', () => {
const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture()
const fingerprints = new Set<string>()
for (let index = 0; index < 128; index++) {
const nonce = new Uint8Array(32)
new DataView(nonce.buffer).setUint32(28, index, false)
const clientNonceB64 = Buffer.from(nonce).toString('base64')
const handshake = validateMobileE2EEV2Handshake(
{ ...hello, clientNonceB64 },
{ ...ready, clientNonceB64 }
)!
const schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret,
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
fingerprints.add(
[schedule.mobileToDesktopKey, schedule.desktopToMobileKey, schedule.sessionId]
.map(hex)
.join(':')
)
}
expect(fingerprints.size).toBe(128)
})
})
@@ -0,0 +1,48 @@
import { hkdf } from '@noble/hashes/hkdf'
import { sha256 } from '@noble/hashes/sha256'
const SALT_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/salt\0')
const INFO_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/session\0')
export function deriveMobileE2EEV2KeySchedule(args: {
sharedSecret: Uint8Array
transcript: Uint8Array
clientNonce: Uint8Array
desktopNonce: Uint8Array
}): {
mobileToDesktopKey: Uint8Array
desktopToMobileKey: Uint8Array
sessionId: Uint8Array
transcriptHash: Uint8Array
} {
requireLength(args.sharedSecret, 32, 'shared secret')
requireLength(args.clientNonce, 32, 'client nonce')
requireLength(args.desktopNonce, 32, 'desktop nonce')
const transcriptHash = sha256(args.transcript)
const salt = sha256(concatBytes([SALT_LABEL, args.clientNonce, args.desktopNonce]))
const info = concatBytes([INFO_LABEL, transcriptHash])
const expanded = hkdf(sha256, args.sharedSecret, salt, info, 96)
return {
mobileToDesktopKey: expanded.slice(0, 32),
desktopToMobileKey: expanded.slice(32, 64),
sessionId: expanded.slice(64, 96),
transcriptHash
}
}
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
let offset = 0
for (const part of parts) {
result.set(part, offset)
offset += part.length
}
return result
}
function requireLength(bytes: Uint8Array, expected: number, label: string): void {
if (bytes.length !== expected) {
throw new Error(`Invalid ${label}: expected ${expected} bytes, got ${bytes.length}`)
}
}
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake,
type MobileE2EEV2Ready
} from '../../../src/shared/mobile-e2ee-v2-contract'
import {
openMobileE2EEV2Frame,
sealMobileE2EEV2Frame
} from '../../../src/shared/mobile-e2ee-v2-framing'
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
}))
import { deriveSharedKey } from './e2ee'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
import {
MobileE2EEAuthenticationError,
MobileE2EEV2PhysicalChannel,
type MobileE2EEV2Socket
} from './mobile-e2ee-v2-physical-channel'
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
function setup(decodeBinary: (raw: unknown) => Promise<Uint8Array | null>) {
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
transport: 'relay',
relayHostId: 'AbCdEf0123_-xyZ9',
clientNonce: new Uint8Array(32).fill(3),
clientKeyPair: client
})
const sent: (string | Uint8Array)[] = []
const socket = {
OPEN: 1,
readyState: 1,
bufferedAmount: 0,
send: (frame: string | Uint8Array) => sent.push(frame)
} satisfies MobileE2EEV2Socket
const events: string[] = []
const onAuthenticated = vi.fn(() => events.push('authenticated'))
const onError = vi.fn()
const channel = new MobileE2EEV2PhysicalChannel({
session,
socket,
deviceToken: 'valid-token',
decodeBinary,
onAuthenticated,
onText: (plaintext) => events.push(`text:${plaintext}`),
onBinary: (plaintext) => events.push(`binary:${plaintext[0]}`),
onError
})
channel.start()
const ready: MobileE2EEV2Ready = {
type: 'e2ee_ready',
v: 2,
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
clientNonceB64: session.hello.clientNonceB64,
desktopNonceB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64'),
selection: { framing: 2, payloadKinds: ['text', 'binary'] },
context: session.hello.context
}
const handshake = validateMobileE2EEV2Handshake(session.hello, ready)!
const schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret: deriveSharedKey(desktop.secretKey, client.publicKey),
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
return { channel, session, socket, sent, events, onAuthenticated, onError, ready, schedule }
}
function serverFrame(
payload: Uint8Array,
kind: 'text' | 'binary',
counter: bigint,
schedule: ReturnType<typeof setup>['schedule']
): Uint8Array {
return sealMobileE2EEV2Frame({
payload,
key: schedule.desktopToMobileKey,
sessionId: schedule.sessionId,
direction: 'desktop-to-mobile',
payloadKind: kind,
counter
})
}
async function authenticate(ctx: ReturnType<typeof setup>): Promise<void> {
await ctx.channel.handleMessage(JSON.stringify(ctx.ready))
const response = serverFrame(
new TextEncoder().encode(
JSON.stringify({
type: 'e2ee_authenticated',
v: 2,
transcriptHashB64: ctx.session.transcriptHashB64
})
),
'text',
0n,
ctx.schedule
)
await ctx.channel.handleMessage(Buffer.from(response).toString('base64'))
}
describe('mobile E2EE v2 physical channel', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('sends hello/auth and confirms the exact transcript', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
expect(JSON.parse(ctx.sent[0] as string)).toEqual(ctx.session.hello)
expect(typeof ctx.sent[1]).toBe('string')
expect(ctx.onAuthenticated).toHaveBeenCalledOnce()
expect(ctx.onError).not.toHaveBeenCalled()
})
it('classifies the encrypted desktop device-token rejection as global auth failure', async () => {
const ctx = setup(async () => null)
await ctx.channel.handleMessage(JSON.stringify(ctx.ready))
const rejection = serverFrame(
new TextEncoder().encode(
JSON.stringify({ type: 'e2ee_error', error: { code: 'unauthorized' } })
),
'text',
0n,
ctx.schedule
)
await ctx.channel.handleMessage(Buffer.from(rejection).toString('base64'))
expect(ctx.onError.mock.calls[0]![0]).toBeInstanceOf(MobileE2EEAuthenticationError)
expect(ctx.onAuthenticated).not.toHaveBeenCalled()
})
it('serializes delayed binary conversion before a later text counter', async () => {
let releaseBinary!: (bytes: Uint8Array) => void
const pendingBinary = new Promise<Uint8Array>((resolve) => (releaseBinary = resolve))
const ctx = setup(async () => pendingBinary)
await authenticate(ctx)
ctx.events.length = 0
const binary = serverFrame(new Uint8Array([7]), 'binary', 1n, ctx.schedule)
const text = serverFrame(new TextEncoder().encode('later'), 'text', 2n, ctx.schedule)
const first = ctx.channel.handleMessage({ delayedBlob: true })
const second = ctx.channel.handleMessage(Buffer.from(text).toString('base64'))
await Promise.resolve()
expect(ctx.events).toEqual([])
releaseBinary(binary)
await Promise.all([first, second])
expect(ctx.events).toEqual(['binary:7', 'text:later'])
expect(ctx.onError).not.toHaveBeenCalled()
})
it('queues outbound text and binary in one counter order', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
ctx.socket.bufferedAmount = 9 * 1024 * 1024
expect(ctx.channel.sendText('one')).toBe(true)
expect(ctx.channel.sendBinary(new Uint8Array([2]))).toBe(true)
expect(ctx.sent).toHaveLength(2)
ctx.socket.bufferedAmount = 0
vi.runOnlyPendingTimers()
expect(typeof ctx.sent[2]).toBe('string')
expect(ctx.sent[3]).toBeInstanceOf(Uint8Array)
expect(
openMobileE2EEV2Frame({
frame: Buffer.from(ctx.sent[2] as string, 'base64'),
key: ctx.schedule.mobileToDesktopKey,
sessionId: ctx.schedule.sessionId,
direction: 'mobile-to-desktop',
payloadKind: 'text',
expectedCounter: 1n
})
).toEqual(new TextEncoder().encode('one'))
expect(
openMobileE2EEV2Frame({
frame: ctx.sent[3] as Uint8Array,
key: ctx.schedule.mobileToDesktopKey,
sessionId: ctx.schedule.sessionId,
direction: 'mobile-to-desktop',
payloadKind: 'binary',
expectedCounter: 2n
})
).toEqual(new Uint8Array([2]))
})
it('bounds the unified outbound queue and reports a wedged link', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
ctx.socket.bufferedAmount = 9 * 1024 * 1024
const megabyte = new Uint8Array(1024 * 1024)
for (let index = 0; index < 65; index++) {
expect(ctx.channel.sendBinary(megabyte)).toBe(true)
}
expect(ctx.onError).toHaveBeenCalledOnce()
expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 outbound buffer overflow')
})
})
@@ -0,0 +1,185 @@
import {
createWsOutboundBackpressureQueue,
type WsOutboundBackpressureQueue
} from '../../../src/shared/ws-outbound-backpressure-queue'
import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready'
type OutboundItem = { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array }
export class MobileE2EEAuthenticationError extends Error {
constructor() {
super('E2EE device authentication rejected')
}
}
export type MobileE2EEV2Socket = {
readonly OPEN: number
readonly readyState: number
readonly bufferedAmount: number
send: (frame: string | Uint8Array) => void
}
export class MobileE2EEV2PhysicalChannel {
private state: ChannelState = 'awaiting-ready'
private generation = 0
private inboundChain: Promise<void> = Promise.resolve()
private readonly outboundQueue: WsOutboundBackpressureQueue<OutboundItem>
constructor(
private readonly args: {
session: MobileE2EEV2ClientSession
socket: MobileE2EEV2Socket
deviceToken: string
decodeBinary: (raw: unknown) => Promise<Uint8Array | null>
onAuthenticated: () => void
onText: (plaintext: string) => void
onBinary: (plaintext: Uint8Array) => void
onError: (error: Error) => void
}
) {
this.outboundQueue = createWsOutboundBackpressureQueue<OutboundItem>({
// Why: encryption happens only when an admitted item reaches the wire,
// so a bounded-queue rejection cannot burn an ordered v2 counter.
send: (item) => {
args.socket.send(
item.kind === 'text'
? args.session.sealText(item.plaintext)
: args.session.sealBinary(item.plaintext)
)
},
byteLengthOf: (item) =>
(item.kind === 'text'
? new TextEncoder().encode(item.plaintext).length
: item.plaintext.length) + 82,
getBufferedAmount: () => args.socket.bufferedAmount,
isWritable: () => args.socket.readyState === args.socket.OPEN,
onOverflow: () => args.onError(new Error('E2EE v2 outbound buffer overflow'))
})
}
start(): void {
this.args.socket.send(JSON.stringify(this.args.session.hello))
}
handleMessage(raw: unknown): Promise<void> {
const generation = this.generation
this.inboundChain = this.inboundChain
.then(() => this.processMessage(raw, generation))
.catch((error: unknown) => {
if (generation === this.generation) {
this.args.onError(error instanceof Error ? error : new Error(String(error)))
}
})
return this.inboundChain
}
sendText(plaintext: string): boolean {
return this.enqueueReady({ kind: 'text', plaintext })
}
sendBinary(plaintext: Uint8Array): boolean {
return this.enqueueReady({ kind: 'binary', plaintext })
}
dispose(): void {
this.generation++
this.outboundQueue.dispose()
}
private async processMessage(raw: unknown, generation: number): Promise<void> {
if (generation !== this.generation) {
return
}
if (this.state === 'awaiting-ready') {
this.acceptReady(raw)
return
}
const plaintext =
typeof raw === 'string'
? this.args.session.openText(raw)
: await this.openBinary(raw, generation)
if (generation !== this.generation || plaintext === null) {
return
}
if (this.state === 'awaiting-authenticated') {
if (typeof plaintext === 'string' && isAuthenticationRejection(plaintext)) {
throw new MobileE2EEAuthenticationError()
}
if (typeof plaintext !== 'string' || !this.isAuthenticated(plaintext)) {
throw new Error('Invalid E2EE v2 authenticated response')
}
this.state = 'ready'
this.args.onAuthenticated()
} else if (typeof plaintext === 'string') {
this.args.onText(plaintext)
} else {
this.args.onBinary(plaintext)
}
}
private acceptReady(raw: unknown): void {
if (typeof raw !== 'string') {
throw new Error('Expected plaintext E2EE v2 ready')
}
let ready: unknown
try {
ready = JSON.parse(raw)
} catch {
throw new Error('Invalid E2EE v2 ready JSON')
}
if (!this.args.session.acceptReady(ready)) {
throw new Error('Invalid E2EE v2 ready')
}
this.state = 'awaiting-authenticated'
this.outboundQueue.enqueue({
kind: 'text',
plaintext: JSON.stringify({
type: 'e2ee_auth',
v: 2,
transcriptHashB64: this.args.session.transcriptHashB64,
deviceToken: this.args.deviceToken
})
})
}
private async openBinary(raw: unknown, generation: number): Promise<Uint8Array | null> {
const bytes = await this.args.decodeBinary(raw)
if (!bytes || generation !== this.generation) {
return null
}
return this.args.session.openBinary(bytes)
}
private isAuthenticated(plaintext: string): boolean {
try {
const message = JSON.parse(plaintext) as Record<string, unknown>
return (
Object.keys(message).sort().join(',') === 'transcriptHashB64,type,v' &&
message.type === 'e2ee_authenticated' &&
message.v === 2 &&
message.transcriptHashB64 === this.args.session.transcriptHashB64
)
} catch {
return false
}
}
private enqueueReady(item: OutboundItem): boolean {
if (this.state !== 'ready') {
return false
}
this.outboundQueue.enqueue(item)
return true
}
}
function isAuthenticationRejection(plaintext: string): boolean {
try {
const message = JSON.parse(plaintext) as Record<string, unknown>
return message.type === 'e2ee_error'
} catch {
return false
}
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
const options = {
directSuccessesRequired: 3,
directObservationMs: 30_000,
failureCooldownMs: 60_000,
minimumDwellMs: 60_000
}
describe('mobile endpoint hysteresis', () => {
it('requires three authenticated direct successes across the observation and dwell windows', () => {
const policy = new MobileEndpointHysteresis(0, options)
expect(policy.recordDirectSuccess(60_000)).toBe(false)
expect(policy.recordDirectSuccess(75_000)).toBe(false)
expect(policy.recordDirectSuccess(90_000)).toBe(true)
})
it('resets progress and observes cooldown after a failure', () => {
const policy = new MobileEndpointHysteresis(0, options)
policy.recordDirectSuccess(60_000)
policy.recordDirectFailure(61_000)
expect(policy.canProbe(120_999)).toBe(false)
expect(policy.canProbe(121_000)).toBe(true)
expect(policy.recordDirectSuccess(121_000)).toBe(false)
expect(policy.recordDirectSuccess(136_000)).toBe(false)
expect(policy.recordDirectSuccess(151_000)).toBe(true)
})
})
@@ -0,0 +1,52 @@
export type EndpointHysteresisOptions = {
directSuccessesRequired: number
directObservationMs: number
failureCooldownMs: number
minimumDwellMs: number
}
export class MobileEndpointHysteresis {
private consecutiveDirectSuccesses = 0
private directObservationStartedAt: number | null = null
private cooldownUntil = 0
private lastMigrationAt: number
constructor(
startedAt: number,
private readonly options: EndpointHysteresisOptions
) {
this.lastMigrationAt = startedAt
}
recordDirectSuccess(now: number): boolean {
if (now < this.cooldownUntil) {
return false
}
if (this.consecutiveDirectSuccesses === 0) {
this.directObservationStartedAt = now
}
this.consecutiveDirectSuccesses += 1
return (
this.consecutiveDirectSuccesses >= this.options.directSuccessesRequired &&
this.directObservationStartedAt !== null &&
now - this.directObservationStartedAt >= this.options.directObservationMs &&
now - this.lastMigrationAt >= this.options.minimumDwellMs
)
}
recordDirectFailure(now: number): void {
this.consecutiveDirectSuccesses = 0
this.directObservationStartedAt = null
this.cooldownUntil = now + this.options.failureCooldownMs
}
recordMigration(now: number): void {
this.lastMigrationAt = now
this.consecutiveDirectSuccesses = 0
this.directObservationStartedAt = null
}
canProbe(now: number): boolean {
return now >= this.cooldownUntil
}
}
@@ -0,0 +1,98 @@
import * as ExpoCrypto from 'expo-crypto'
import type { ConnectionLogSink, HostProfile } from './types'
import { connect } from './rpc-client'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
import {
readMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import { saveHost } from './host-store'
import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade'
import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
type EndpointLifecycle = {
setForeground(foreground: boolean): void
stop(): void
}
type EndpointOwner = EndpointLifecycle & {
start(): Promise<void>
}
export function startMobileEndpointLifecycle(
logical: StableLogicalRpcClient,
initialHost: HostProfile,
onLog: ConnectionLogSink
): EndpointLifecycle {
let stopped = false
let foreground = true
let owner: EndpointOwner
const startSupervisor = async (host: HostProfile): Promise<void> => {
if (stopped) {
return
}
const supervisor = createSupervisor(logical, host, onLog)
owner.stop()
owner = supervisor
supervisor.setForeground(foreground)
await supervisor.start()
}
if (initialHost.relay) {
owner = createSupervisor(logical, initialHost, onLog)
void owner.start()
} else {
owner = new MobileRelayDirectUpgradeController(logical, initialHost, {
upgrade: (client, host) =>
upgradeDirectMobileRelay({
client,
host,
dependencies: { randomBytes: ExpoCrypto.getRandomBytes }
}),
onUpgraded: ({ host }) => startSupervisor(host)
})
void owner.start()
}
return {
setForeground(next) {
foreground = next
owner.setForeground(next)
},
stop() {
stopped = true
owner.stop()
}
}
}
function createSupervisor(
logical: StableLogicalRpcClient,
host: HostProfile,
onLog: ConnectionLogSink
): MobileEndpointSupervisor {
return new MobileEndpointSupervisor(logical, host, {
openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
openRelay: (relay, credential, confirmReqId) =>
connectMobileRelayRpcSession({
relay,
resumeToken: credential.token,
resumeCredentialVersion: credential.version,
resumeConfirmReqId: confirmReqId,
deviceToken: host.deviceToken,
desktopPublicKeyB64: host.publicKeyB64
}),
resolveRelay: resolveMobileRelayEndpoint,
readBundle: readMobileRelayCredentialBundle,
writeBundle: writeMobileRelayCredentialBundle,
saveHost,
now: Date.now,
randomBytes: ExpoCrypto.getRandomBytes,
setTimer: setTimeout,
clearTimer: clearTimeout
})
}
@@ -0,0 +1,44 @@
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import type { HostProfile } from './types'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
export function isDirectorResolutionFailure(error: Error): boolean {
return (
!(error instanceof MobileE2EEAuthenticationError) &&
(!(error instanceof RelayOuterError) || [4409, 4503, 1006].includes(error.code))
)
}
export function relayWebSocketUrl(relay: { cellUrl: string; relayHostId: string }): string {
const url = new URL(relay.cellUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return url.toString()
}
export async function persistRelayHost(
host: HostProfile,
relay: MobileRelayEndpoint,
saveHost: (host: HostProfile) => Promise<void>
): Promise<HostProfile> {
const endpoints = [
...(host.endpoints ?? [{ id: 'direct-primary', kind: 'lan' as const, url: host.endpoint }])
].filter(({ kind }) => kind !== 'relay')
endpoints.push({ id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) })
const updated = { ...host, endpoints, relayHostId: relay.relayHostId, relay }
await saveHost(updated)
return updated
}
export function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
@@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import {
MobileEndpointSupervisor,
type MobileEndpointSupervisorDependencies
} from './mobile-endpoint-supervisor'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile, RpcResponse } from './types'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
class FakeSession implements RpcClient {
readonly sendRequest = vi.fn(
async (): Promise<RpcResponse> => ({
id: 'rpc-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
)
readonly subscribe = vi.fn(() => () => {})
readonly updateTerminalSubscriptionViewport = vi.fn()
readonly notifyForeground = vi.fn()
readonly close = vi.fn()
private readonly listeners = new Set<(state: ConnectionState) => void>()
constructor(private state: ConnectionState) {}
getState = () => this.state
getReconnectAttempt = () => 0
getLastConnectedAt = () => null
onStateChange = (listener: (state: ConnectionState) => void) => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
publishState(state: ConnectionState): void {
this.state = state
for (const listener of this.listeners) {
listener(state)
}
}
}
class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
constructor(
state: ConnectionState,
private readonly failure: Error | null = null,
private readonly lease = Date.now() + 120_000
) {
super(state)
}
getLeaseExpiresAt = () => this.lease
getResumeConfirmation = () => ({
v: 1 as const,
reqId: 'confirm-1',
currentVersion: 2,
acceptedAs: 'current' as const,
renewed: true,
resumeExpiresAt: Date.now() + 300_000
})
getFailure = () => this.failure
}
class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private generation = 1
constructor(state: ConnectionState, path: MobileConnectionPath) {
super(state)
this.path = path
}
migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => {
if (session.getState() !== 'connected') {
session.close()
throw new Error(`replacement session ${session.getState()}`)
}
this.path = path
this.generation += 1
})
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
getGeneration = () => this.generation
}
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
const host: HostProfile = {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' },
{ id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' }
],
relayHostId: relay.relayHostId,
relay
}
const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: host.id,
deviceToken: host.deviceToken,
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 2,
expiresAt: Number.MAX_SAFE_INTEGER
}
}
function dependencies(
overrides: Partial<MobileEndpointSupervisorDependencies> = {}
): MobileEndpointSupervisorDependencies {
return {
openDirect: vi.fn(() => new FakeSession('connected')),
openRelay: vi.fn(() => new FakeRelaySession('connected')),
resolveRelay: vi.fn(async ({ relay }) => relay),
readBundle: vi.fn(async () => bundle),
writeBundle: vi.fn(async () => {}),
saveHost: vi.fn(async () => {}),
now: Date.now,
randomBytes: (length) => new Uint8Array(length).fill(1),
setTimer: setTimeout,
clearTimer: clearTimeout,
...overrides
}
}
describe('mobile endpoint supervisor', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('fails over to a confirmed relay session and persists its renewed expiry', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
expect(logical.getActivePath()).toBe('relay')
expect(deps.writeBundle).toHaveBeenCalledWith(
expect.objectContaining({ current: expect.objectContaining({ version: 2 }) })
)
supervisor.stop()
})
it('fails over when the direct retry loop publishes reconnecting', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
logical.publishState('reconnecting')
await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay'))
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
supervisor.stop()
})
it('fails over when direct is already reconnecting before startup completes', async () => {
const logical = new FakeLogicalClient('reconnecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('uses POST resolve for wrong-cell recovery and persists the authoritative target', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi
.fn()
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4409)))
.mockReturnValueOnce(new FakeRelaySession('connected'))
const resolved = { ...relay, cellUrl: 'https://relay-c2.onorca.dev', assignmentEpoch: 8 }
const deps = dependencies({
openRelay,
resolveRelay: vi.fn(async () => resolved)
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(deps.resolveRelay).toHaveBeenCalledOnce()
expect(openRelay).toHaveBeenLastCalledWith(resolved, expect.any(Object), expect.any(String))
expect(deps.saveHost).toHaveBeenCalledWith(
expect.objectContaining({ relay: resolved, endpoint: host.endpoint })
)
supervisor.stop()
})
it('promotes direct only after repeated foreground authenticated probes and dwell', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(45_000)
expect(logical.getActivePath()).toBe('relay')
await vi.advanceTimersByTimeAsync(15_000)
expect(logical.getActivePath()).toBe('lan')
expect(deps.openDirect).toHaveBeenCalledTimes(4)
supervisor.stop()
})
it('releases a background relay session and reconnects it on foreground', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('disconnected')
supervisor.setForeground(true)
await vi.waitFor(() => expect(logical.migrateTo).toHaveBeenCalled())
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
})
@@ -0,0 +1,322 @@
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import {
encodeBase64Url,
isDirectorResolutionFailure,
persistRelayHost,
toError
} from './mobile-endpoint-supervisor-support'
import {
applyResumeConfirmation,
mobileRelayCredentialNeedsRotation,
rotateMobileRelayCredential
} from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
import type { RpcClient } from './rpc-client'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { HostProfile } from './types'
const DIRECT_PROBE_INTERVAL_MS = 15_000
const DIRECT_OBSERVATION_MS = 30_000
const MINIMUM_DWELL_MS = 60_000
const FAILURE_COOLDOWN_MS = 60_000
const LEASE_ROTATION_MARGIN_MS = 30_000
export type MobileEndpointSupervisorDependencies = {
openDirect: (endpoint: string) => RpcClient
openRelay: (
relay: MobileRelayEndpoint,
credential: { token: string; version: number },
confirmReqId: string
) => MobileRelayRpcSession
resolveRelay: typeof resolveMobileRelayEndpoint
readBundle: (hostId: string) => Promise<MobileRelayCredentialBundle | null>
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
saveHost: (host: HostProfile) => Promise<void>
now: () => number
randomBytes: (length: number) => Uint8Array
setTimer: typeof setTimeout
clearTimer: typeof clearTimeout
}
export class MobileEndpointSupervisor {
private host: HostProfile
private bundle: MobileRelayCredentialBundle | null = null
private stopped = false
private foreground = true
private operationInFlight = false
private credentialRotationInFlight = false
private relayRotationPending = false
private probeTimer: ReturnType<typeof setTimeout> | null = null
private leaseTimer: ReturnType<typeof setTimeout> | null = null
private unsubscribeState: (() => void) | null = null
private readonly hysteresis: MobileEndpointHysteresis
constructor(
private readonly logical: StableLogicalRpcClient,
host: HostProfile,
private readonly dependencies: MobileEndpointSupervisorDependencies
) {
this.host = host
this.hysteresis = new MobileEndpointHysteresis(dependencies.now(), {
directSuccessesRequired: 3,
directObservationMs: DIRECT_OBSERVATION_MS,
failureCooldownMs: FAILURE_COOLDOWN_MS,
minimumDwellMs: MINIMUM_DWELL_MS
})
}
async start(): Promise<void> {
this.bundle = await this.dependencies.readBundle(this.host.id).catch(() => null)
if (this.stopped || !this.bundle || !this.host.relay) {
return
}
this.unsubscribeState = this.logical.onStateChange((state) => {
if (state === 'connected') {
if (this.logical.getActivePath() !== 'relay') {
void this.rotateCredentialIfNeeded()
}
this.scheduleDirectProbe()
} else if (state === 'reconnecting' || state === 'disconnected' || state === 'auth-failed') {
// Why: the direct client enters reconnecting after its first failed
// dial and may never publish disconnected while its retry loop lives.
void this.recoverRelay()
}
})
const initialState = this.logical.getState()
if (
initialState === 'reconnecting' ||
initialState === 'disconnected' ||
initialState === 'auth-failed'
) {
// Why: the first direct dial can fail while encrypted relay credentials
// are still loading, before the supervisor subscribes to state changes.
await this.recoverRelay()
} else {
this.scheduleDirectProbe()
}
}
setForeground(foreground: boolean): void {
this.foreground = foreground
if (foreground) {
void this.recoverRelay(this.relayRotationPending)
this.scheduleDirectProbe(0)
} else {
if (this.logical.getActivePath() === 'relay') {
// Why: background phones must not hold billed relay data splices; the
// stable client keeps subscriptions for authenticated foreground replay.
this.logical.suspendActiveSession()
}
if (this.probeTimer) {
this.dependencies.clearTimer(this.probeTimer)
this.probeTimer = null
}
}
}
stop(): void {
this.stopped = true
this.unsubscribeState?.()
this.unsubscribeState = null
if (this.probeTimer) {
this.dependencies.clearTimer(this.probeTimer)
this.probeTimer = null
}
this.clearLeaseTimer()
}
private async recoverRelay(forceReplacement = false): Promise<void> {
if (
this.stopped ||
!this.foreground ||
this.operationInFlight ||
!this.bundle ||
!this.host.relay ||
(!forceReplacement && this.logical.getState() === 'connected')
) {
return
}
this.operationInFlight = true
try {
const credentials = [this.bundle.current, this.bundle.grace].filter(
(credential): credential is NonNullable<typeof credential> =>
Boolean(credential && credential.expiresAt > this.dependencies.now())
)
for (const credential of credentials) {
if (await this.tryRelayCredential(credential)) {
return
}
}
} finally {
this.operationInFlight = false
if (forceReplacement && this.relayRotationPending && !this.stopped && !this.leaseTimer) {
this.leaseTimer = this.dependencies.setTimer(() => {
this.leaseTimer = null
void this.recoverRelay(true)
}, 5000)
}
}
}
private async tryRelayCredential(credential: {
token: string
version: number
}): Promise<boolean> {
const first = await this.openAndMigrateRelay(credential)
if (first.ok) {
return true
}
if (!isDirectorResolutionFailure(first.error) || !this.host.relay) {
return false
}
try {
const resolved = await this.dependencies.resolveRelay({
relay: this.host.relay,
resumeToken: credential.token
})
this.host = await persistRelayHost(this.host, resolved, this.dependencies.saveHost)
return (await this.openAndMigrateRelay(credential)).ok
} catch {
return false
}
}
private async openAndMigrateRelay(credential: {
token: string
version: number
}): Promise<{ ok: true } | { ok: false; error: Error }> {
if (!this.host.relay || !this.bundle) {
return { ok: false, error: new Error('relay state missing') }
}
const session = this.dependencies.openRelay(
this.host.relay,
credential,
`confirm-${encodeBase64Url(this.dependencies.randomBytes(16))}`
)
try {
await this.logical.migrateTo(session, 'relay')
if (!this.foreground) {
this.logical.suspendActiveSession()
}
this.relayRotationPending = false
this.hysteresis.recordMigration(this.dependencies.now())
const confirmation = session.getResumeConfirmation()
if (confirmation) {
this.bundle = applyResumeConfirmation(this.bundle, credential.version, confirmation)
await this.dependencies.writeBundle(this.bundle)
}
this.scheduleLeaseRotation(session)
this.scheduleDirectProbe()
return { ok: true }
} catch (error) {
return { ok: false, error: session.getFailure() ?? toError(error) }
}
}
private scheduleDirectProbe(delayMs = DIRECT_PROBE_INTERVAL_MS): void {
if (
this.stopped ||
!this.foreground ||
this.logical.getActivePath() !== 'relay' ||
this.probeTimer
) {
return
}
this.probeTimer = this.dependencies.setTimer(() => {
this.probeTimer = null
void this.probeDirect()
}, delayMs)
}
private async probeDirect(): Promise<void> {
if (
this.stopped ||
!this.foreground ||
this.operationInFlight ||
!this.hysteresis.canProbe(this.dependencies.now())
) {
this.scheduleDirectProbe()
return
}
this.operationInFlight = true
let successful: Awaited<ReturnType<typeof openAuthenticatedDirectEndpoint>> = null
try {
const openDirect = this.dependencies.openDirect
successful = await openAuthenticatedDirectEndpoint(this.host, openDirect, 12_000)
if (!successful) {
this.hysteresis.recordDirectFailure(this.dependencies.now())
return
}
if (!this.hysteresis.recordDirectSuccess(this.dependencies.now())) {
successful.client.close()
return
}
await this.logical.migrateTo(successful.client, successful.path)
successful = null
this.hysteresis.recordMigration(this.dependencies.now())
this.clearLeaseTimer()
this.relayRotationPending = false
await this.rotateCredentialIfNeeded()
} finally {
successful?.client.close()
this.operationInFlight = false
if (this.relayRotationPending) {
void this.recoverRelay(true)
}
this.scheduleDirectProbe()
}
}
private async rotateCredentialIfNeeded(): Promise<void> {
if (
this.stopped ||
this.credentialRotationInFlight ||
!this.bundle ||
this.logical.getActivePath() === 'relay' ||
!mobileRelayCredentialNeedsRotation(this.bundle, this.dependencies.now())
) {
return
}
this.credentialRotationInFlight = true
try {
const result = await rotateMobileRelayCredential({
client: this.logical,
bundle: this.bundle,
writeBundle: this.dependencies.writeBundle,
randomBytes: this.dependencies.randomBytes
})
this.bundle = result.bundle
this.host = await persistRelayHost(this.host, result.relay, this.dependencies.saveHost)
} catch {
// Why: pending material remains durable; the next authenticated direct
// opportunity must reconcile it before creating another install key.
} finally {
this.credentialRotationInFlight = false
}
}
private scheduleLeaseRotation(session: MobileRelayRpcSession): void {
this.clearLeaseTimer()
const deadline = session.getLeaseExpiresAt()
if (!deadline) {
return
}
const delay = Math.max(1000, deadline - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS)
this.leaseTimer = this.dependencies.setTimer(() => {
this.leaseTimer = null
this.relayRotationPending = true
void this.recoverRelay(true)
}, delay)
}
private clearLeaseTimer(): void {
if (this.leaseTimer) {
this.dependencies.clearTimer(this.leaseTimer)
this.leaseTimer = null
}
}
}
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const secureStore = vi.hoisted(() => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn()
}))
const platform = vi.hoisted(() => ({ OS: 'ios' }))
vi.mock('expo-secure-store', () => ({
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
...secureStore
}))
vi.mock('react-native', () => ({ Platform: platform }))
import {
deleteMobileRelayCredentialBundle,
promotePairingJournalCredential,
readMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
const journal = {
metadata: {
v: 1,
journalId: 'pair-1',
offerFingerprint: 'A'.repeat(43),
host: {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1
},
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteExpiresAt: 10_000,
e2eeFraming: 2
},
installReqId: 'install-1',
resumeConfirmReqId: 'confirm-1',
pendingResumeTokenHash: 'B'.repeat(43),
winner: 'direct',
authorizationMode: 'authenticated-direct'
},
secrets: {
v: 1,
journalId: 'pair-1',
deviceToken: 'device-token',
inviteToken: 'C'.repeat(43),
pendingResumeToken: 'D'.repeat(43)
}
} satisfies MobileRelayPairingJournal
describe('mobile relay credential bundle', () => {
let stored: string | null
beforeEach(() => {
vi.clearAllMocks()
platform.OS = 'ios'
stored = null
secureStore.getItemAsync.mockImplementation(async () => stored)
secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => {
stored = value
})
secureStore.deleteItemAsync.mockImplementation(async () => {
stored = null
})
})
it('promotes only a matching committed install result to current', async () => {
const bundle = promotePairingJournalCredential({
journal,
installed: {
v: 1,
reqId: 'install-1',
authorizationMode: 'authenticated-direct',
currentVersion: 3,
resumeExpiresAt: 50_000
}
})
await writeMobileRelayCredentialBundle(bundle)
await expect(readMobileRelayCredentialBundle('host-1')).resolves.toEqual(bundle)
expect(stored).toContain(journal.secrets.pendingResumeToken)
expect(stored).not.toContain(journal.secrets.inviteToken)
})
it('rejects a result from another request or authorization mode', () => {
expect(() =>
promotePairingJournalCredential({
journal,
installed: {
v: 1,
reqId: 'other-request',
authorizationMode: 'relay-basis',
currentVersion: 1,
resumeExpiresAt: 50_000
}
})
).toThrow(/does not match/)
})
it('deletes the namespaced bundle and never enables it on web', async () => {
await deleteMobileRelayCredentialBundle('host-1')
expect(secureStore.deleteItemAsync).toHaveBeenCalledWith(
'orca.mobile-relay.credentials.host-1',
expect.any(Object)
)
platform.OS = 'web'
await expect(readMobileRelayCredentialBundle('host-1')).rejects.toThrow(/native secret store/)
})
})
@@ -0,0 +1,112 @@
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import { z } from 'zod'
import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
const ResumeCredentialSchema = z
.object({
token: Base64Url32ByteSchema,
hash: Base64Url32ByteSchema,
version: z.number().int().positive(),
expiresAt: z.number().int().nonnegative()
})
.strict()
export const MobileRelayCredentialBundleSchema = z
.object({
v: z.literal(1),
hostId: z.string().min(1),
deviceToken: z.string().min(1),
current: ResumeCredentialSchema,
grace: ResumeCredentialSchema.optional(),
pending: z
.object({
token: Base64Url32ByteSchema,
hash: Base64Url32ByteSchema,
reqId: z.string().min(1)
})
.strict()
.optional(),
invite: z
.object({ token: Base64Url32ByteSchema, expiresAt: z.number().int().positive() })
.strict()
.optional()
})
.strict()
export type MobileRelayCredentialBundle = z.infer<typeof MobileRelayCredentialBundleSchema>
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
function credentialKey(hostId: string): string {
return `orca.mobile-relay.credentials.${hostId}`
}
export function promotePairingJournalCredential(args: {
journal: MobileRelayPairingJournal
installed: DeviceCredentialInstalled
}): MobileRelayCredentialBundle {
const { journal, installed } = args
if (
installed.reqId !== journal.metadata.installReqId ||
installed.authorizationMode !== journal.metadata.authorizationMode
) {
throw new Error('relay credential install result does not match pairing journal')
}
return MobileRelayCredentialBundleSchema.parse({
v: 1,
hostId: journal.metadata.host.id,
deviceToken: journal.secrets.deviceToken,
current: {
token: journal.secrets.pendingResumeToken,
hash: journal.metadata.pendingResumeTokenHash,
version: installed.currentVersion,
expiresAt: installed.resumeExpiresAt
}
})
}
export async function readMobileRelayCredentialBundle(
hostId: string
): Promise<MobileRelayCredentialBundle | null> {
requireNativeSecretStore()
const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
if (raw === null) {
return null
}
try {
const result = MobileRelayCredentialBundleSchema.safeParse(JSON.parse(raw))
return result.success && result.data.hostId === hostId ? result.data : null
} catch {
return null
}
}
export async function writeMobileRelayCredentialBundle(
bundle: MobileRelayCredentialBundle
): Promise<void> {
requireNativeSecretStore()
const validated = MobileRelayCredentialBundleSchema.parse(bundle)
await SecureStore.setItemAsync(
credentialKey(validated.hostId),
JSON.stringify(validated),
KEYCHAIN_OPTIONS
)
}
export async function deleteMobileRelayCredentialBundle(hostId: string): Promise<void> {
if (Platform.OS === 'web') {
return
}
await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
}
function requireNativeSecretStore(): void {
if (Platform.OS === 'web') {
throw new Error('Orca Relay credentials require a native secret store')
}
}

Some files were not shown because too many files have changed in this diff Show More