Merge remote-tracking branch 'origin/main' into brennanb2025/review-8706

This commit is contained in:
Brennan Benson
2026-07-14 23:10:12 -07:00
467 changed files with 38416 additions and 2536 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
@@ -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
+2 -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)
---
+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
+66 -17
View File
@@ -2161,7 +2161,7 @@
"id": "terminal-provider.ssh-remote-reattach-contract",
"title": "SSH and remote terminal restore never turn unknown liveness into destructive cleanup",
"maturity": "experimental",
"protection": "none",
"protection": "partial",
"owner": "terminal-provider",
"layer": "provider-contract",
"surfaces": [
@@ -2181,28 +2181,79 @@
"remote-runtime",
"wsl"
],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The SSH unknown-liveness hardening and deferred-reattach contracts exist only on the pending reliability stack. It registers here with its owning split PR.",
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"ssh"
],
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, remote or unresolved hosts cannot enter local-daemon cold activation deferral, and stale cleanup cannot unregister a replacement runtime terminal. A macOS Electron journey connects to a real Linux SSH relay in Docker, persists six terminals, reloads the renderer, and verifies all six original relay PTYs remount eagerly and still execute remote input. Linux and Windows desktop clients, WSL, and remote-runtime restore remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
"https://github.com/stablyai/orca/pull/6979",
"https://github.com/stablyai/orca/pull/7009"
"https://github.com/stablyai/orca/pull/7009",
"https://github.com/stablyai/orca/pull/8597"
],
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Snapshot-backed cold activation deferral is local-daemon-only; every restored remote terminal must mount a manager eagerly and preserve its provider PTY identity.",
"oracle": "Deterministic tests assert startup publishes ssh.connect's authoritative state before terminal reconnect, only an explicit local execution host can defer cold activation, host ownership changes clear old restrictions, and replacement runtime registrations survive stale cleanup. The live Docker SSH journey commits six terminal records to the relay, reloads Electron, requires the exact six manager ids with no parked tabs, compares all restored PTY ids to their pre-reload identities, and sends a marker through the restored terminal to a proof file on the Linux host.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"pnpm exec electron-vite build --mode e2e",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/renderer/src/startup/ssh-startup-reconnect.test.ts",
"src/renderer/src/lib/resolved-worktree-execution-host.test.ts",
"src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts",
"src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts",
"src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"tests/e2e/terminal-cold-activation-deferral.spec.ts",
"tests/e2e/ssh-cold-activation-restore.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/startup/ssh-startup-reconnect.test.ts",
"assertions": [
"the state returned by ssh.connect is published before persisted terminals reconnect"
]
},
{
"file": "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts",
"assertions": [
"only an explicit local execution host can defer cold activation",
"SSH, remote-runtime, and unresolved owners remain eager"
]
},
{
"file": "tests/e2e/ssh-cold-activation-restore.spec.ts",
"assertions": [
"six relay-backed tabs retain the same six PTY ids across renderer reload",
"all six SSH managers mount eagerly and none is parked",
"restored terminal input reaches a proof file on the Linux SSH host"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-14",
"runner": "local",
"platform": "macos",
"command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 128,
"summary": "Six focused files and 475 tests passed; the local cold-activation journey passed; the Docker/Linux SSH journey restored six of six original relay PTYs, mounted six of six managers without parking, and executed remote input through the restored terminal."
}
],
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once.",
"oracle": "The current executable slice asserts saved SSH sessions use relay attach, expired relay attach does not silently fresh-spawn inside the provider, provider listing and targeted-liveness failures are unknown rather than dead, deferred passphrase cancellation does not auto-reconnect, deferred SSH attach uses saved leaf/tab session ids once connected, transient deferred reattach failure preserves the saved restore id without fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without consuming the saved restore id, disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. WSL and remote-runtime mirror polling require separate follow-up contracts.",
"commands": [],
"testFiles": [],
"assertionRefs": [],
"evidenceRuns": [],
"runtimeBudget": {
"p95Seconds": 45,
"scope": "provider contract plus optional SSH soak"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Focused provider/main/renderer SSH contract tests now run locally; needs soak history before promotion."
"evidence": "Focused renderer contracts and one clean Docker/Linux SSH Electron run pass locally; the live journey needs CI soak history before promotion."
},
"redGreenEvidence": {
"status": "partial",
@@ -2210,7 +2261,7 @@
},
"performanceBudget": {
"required": true,
"evidence": "Provider listing and polling must be bounded, targeted where possible, and absent from typing/focus hot paths."
"evidence": "Host eligibility is resolved from existing store metadata during activation and adds no provider listing or polling. The local journey preserves cold deferral, while SSH eagerly mounts six managers as required; no work is added to typing or terminal-output hot paths."
},
"promotionCriteria": [
"Use deterministic fake providers for failure and unknown-liveness cases.",
@@ -2218,12 +2269,10 @@
"Include reconnect/polling artifacts that identify provider, connection id, and lease state."
],
"knownGaps": [
"No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.",
"Current command covers store wake-hint metadata, main-process SSH provider failure semantics, provider attach/expired-attach behavior, and renderer deferred SSH reconnect/transient-failure/expired-relay fallback with mocked transports.",
"The store coverage asserts recoverability metadata, not a live attached PTY or user-visible connected state.",
"Live SSH/remote coverage remains environment-dependent.",
"The live SSH journey is environment-dependent and currently runs from a macOS Electron client against a Linux Docker host.",
"WSL and remote-runtime mirror polling contracts are not wired yet.",
"The deferred SSH coverage does not boot Electron against a real SSH relay or exercise real network reconnect timing."
"Linux and Windows desktop-client journeys are not yet collected."
],
"demotionRule": "Cannot promote if provider failure can close panes or if the oracle is screenshot-only."
},
@@ -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')
+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.4m">
<title>downloads: 5.4m</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.4m</text>
<text x="93.5" y="14">5.4m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+2 -2
View File
@@ -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)
---
+2 -2
View File
@@ -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)
---
+2 -2
View File
@@ -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)
---
+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)
---
+2 -2
View File
@@ -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
+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'
+5 -3
View File
@@ -34,11 +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
+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'
+32 -97
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,
@@ -35,12 +34,13 @@ 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'
@@ -318,12 +318,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 +397,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 +430,7 @@ export default function HomeScreen() {
return () => {
stale = true
}
}, [])
}, [router])
)
const sortedHosts = useMemo(
@@ -824,7 +838,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 +845,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={
@@ -1081,16 +1070,16 @@ export default function HomeScreen() {
items.push({
label: 'Rename',
icon: Edit3,
closeBeforePress: true,
onPress: () => {
setActionTarget(null)
setRenameTarget(host)
}
})
items.push({
label: 'Remove',
destructive: true,
closeBeforePress: true,
onPress: () => {
setActionTarget(null)
setConfirmRemove(host)
}
})
@@ -1244,63 +1233,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}
+24 -61
View File
@@ -5,14 +5,13 @@ 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 { 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'
@@ -35,7 +34,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 +78,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 +91,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 +104,15 @@ export default function PairConfirmScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
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 +128,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)}`
)
}
}
+24 -66
View File
@@ -14,15 +14,14 @@ 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 { 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
@@ -54,7 +53,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 +122,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 +135,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 +148,15 @@ export default function PairScanScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
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 +172,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)
+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
@@ -1,6 +1,8 @@
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'
@@ -18,7 +20,7 @@ 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
@@ -35,6 +37,32 @@ 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()
@@ -82,6 +82,7 @@ export type NotificationPermissionState = {
granted: boolean
status: string
canAskAgain: boolean
authorizationReflectsUserChoice: boolean
}
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
@@ -89,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)
}
}
@@ -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
}
}
@@ -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', () => {
@@ -72,5 +72,9 @@ export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string
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'
}
@@ -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
}
+66 -1
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,19 @@ vi.mock('./host-credential-cleanup', () => ({
retryPendingHostCredentialCleanups: vi.fn()
}))
import { removeHost, renameHost, resetHostStoreForTests, updateLastConnected } from './host-store'
import {
loadHosts,
MobileRelayUpgradeHostRemovedError,
removeHost,
renameHost,
resetHostStoreForTests,
saveExistingHostRelayUpgrade,
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,17 +63,23 @@ 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) => {
@@ -70,6 +87,9 @@ describe('host-store list mutations', () => {
storedHostsRaw = raw
}
})
secureStoreMock.getItemAsync.mockImplementation(async (key: string) =>
key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null
)
})
it('commits the removal when credential cleanup scheduling rejects', async () => {
@@ -81,6 +101,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(
+65 -3
View File
@@ -12,6 +12,14 @@ import {
retryPendingHostCredentialCleanups,
scheduleHostCredentialCleanup
} from './host-credential-cleanup'
import {
loadMobileRelayHostOverlayState,
removeMobileRelayHostOverlay,
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 +69,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 +134,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,7 +166,18 @@ 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
}
@@ -196,7 +229,17 @@ 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)
await mutateStoredHosts((hosts) => {
@@ -206,6 +249,10 @@ export async function saveHost(host: HostProfile): Promise<void> {
next[index] = stored
return next
}
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, stored]
})
// Why: write metadata BEFORE the keychain token so a crash between the two
@@ -215,15 +262,30 @@ 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
})
}
}
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,7 +296,7 @@ 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> {
@@ -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')
}
}
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
describe('mobile relay credential hash', () => {
it('hashes the base64url wire token text rather than its decoded bytes', () => {
expect(hashMobileRelayCredential('BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc')).toBe(
'3Ev4DHdHPRMPoN6GukAY_pi7IUAF5qWJHRK6kURvnoE'
)
})
})
@@ -0,0 +1,15 @@
import { sha256 } from '@noble/hashes/sha256'
// Why: the relay stores a digest of the serialized base64url bearer, not the
// random bytes it encodes, so every installer must hash the wire token text.
export function hashMobileRelayCredential(token: string): string {
return encodeBase64Url(sha256(new TextEncoder().encode(token)))
}
function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
@@ -0,0 +1,191 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from './rpc-client'
import {
applyResumeConfirmation,
mobileRelayCredentialNeedsRotation,
rotateMobileRelayCredential
} from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
import type { 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) }))
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 bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: 'host-1',
deviceToken: 'device-token',
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 2,
expiresAt: 50_000
}
}
function success(result: unknown): RpcResponse {
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function install(reqId: string) {
return {
v: 1 as const,
reqId,
authorizationMode: 'authenticated-direct' as const,
currentVersion: 3,
resumeExpiresAt: 100_000,
graceExpiresAt: 70_000
}
}
function fakeClient(responses: RpcResponse[]): RpcClient {
return {
sendRequest: vi.fn(async () => responses.shift()!),
subscribe: vi.fn(() => () => {}),
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => 1,
onStateChange: () => () => {},
notifyForeground: vi.fn(),
close: vi.fn()
}
}
describe('mobile relay credential rotation', () => {
it('persists pending material before install and promotes only committed status', async () => {
const installed = install('rotate-CAgICAgICAgICAgICAgICA')
const client = fakeClient([
success({ v: 1, relay, installStatus: { v: 1, reqId: installed.reqId, state: 'not-found' } }),
success(installed),
success({
v: 1,
relay,
installStatus: { v: 1, reqId: installed.reqId, state: 'committed', result: installed }
})
])
const writes: MobileRelayCredentialBundle[] = []
const result = await rotateMobileRelayCredential({
client,
bundle,
writeBundle: async (value) => {
writes.push(value)
},
randomBytes: (length) => new Uint8Array(length).fill(length === 32 ? 7 : 8)
})
expect(writes).toHaveLength(2)
expect(writes[0]!.pending).toMatchObject({ reqId: installed.reqId })
expect(writes[0]!.pending!.hash).toBe('3Ev4DHdHPRMPoN6GukAY_pi7IUAF5qWJHRK6kURvnoE')
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', {
reqId: installed.reqId,
newResumeTokenHash: writes[0]!.pending!.hash,
expectedCurrentHash: bundle.current.hash
})
expect(result.bundle).toMatchObject({
current: { version: 3, expiresAt: 100_000 },
grace: { version: 2, expiresAt: 70_000 }
})
expect(result.bundle.pending).toBeUndefined()
})
it('repairs legacy decoded-byte hashes before the normal rotation window', () => {
const now = 1_000
const valid = {
...bundle,
current: {
...bundle.current,
hash: hashMobileRelayCredential(bundle.current.token),
expiresAt: now + 30 * 24 * 60 * 60 * 1000
}
}
expect(mobileRelayCredentialNeedsRotation(valid, now)).toBe(false)
expect(mobileRelayCredentialNeedsRotation(bundle, now)).toBe(true)
expect(
mobileRelayCredentialNeedsRotation(
{ ...valid, pending: { token: 'C'.repeat(43), hash: 'D'.repeat(43), reqId: 'pending' } },
now
)
).toBe(true)
})
it('reconciles a committed lost response without issuing a second install', async () => {
const pendingBundle: MobileRelayCredentialBundle = {
...bundle,
pending: { token: 'C'.repeat(43), hash: 'D'.repeat(43), reqId: 'rotate-existing' }
}
const installed = install('rotate-existing')
const client = fakeClient([
success({
v: 1,
relay,
installStatus: { v: 1, reqId: installed.reqId, state: 'committed', result: installed }
})
])
const writeBundle = vi.fn(async () => {})
await rotateMobileRelayCredential({ client, bundle: pendingBundle, writeBundle })
expect(client.sendRequest).toHaveBeenCalledOnce()
expect(client.sendRequest).toHaveBeenCalledWith('pairing.getEndpoints', {
installReqId: 'rotate-existing'
})
expect(writeBundle).toHaveBeenCalledOnce()
})
it('applies only authoritative current or grace confirmation expiries', () => {
const withGrace: MobileRelayCredentialBundle = {
...bundle,
grace: { token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1, expiresAt: 40_000 }
}
const renewed = applyResumeConfirmation(withGrace, 2, {
v: 1,
reqId: 'confirm-current',
currentVersion: 2,
acceptedAs: 'current',
renewed: true,
resumeExpiresAt: 120_000,
graceExpiresAt: 40_000
})
const grace = applyResumeConfirmation(renewed, 1, {
v: 1,
reqId: 'confirm-grace',
currentVersion: 2,
acceptedAs: 'grace',
renewed: false,
resumeExpiresAt: 120_000,
graceExpiresAt: 45_000
})
expect(renewed.current.expiresAt).toBe(120_000)
expect(grace.grace?.expiresAt).toBe(45_000)
expect(applyResumeConfirmation(grace, 99, { ...graceConfirmation(), reqId: 'other' })).toBe(
grace
)
})
})
function graceConfirmation() {
return {
v: 1 as const,
reqId: 'confirm',
currentVersion: 2,
acceptedAs: 'grace' as const,
renewed: false,
resumeExpiresAt: 120_000,
graceExpiresAt: 45_000
}
}
@@ -0,0 +1,147 @@
import * as ExpoCrypto from 'expo-crypto'
import {
DeviceCredentialInstalledSchema,
PairingGetEndpointsResultSchema,
type DeviceResumeConfirmed,
type MobileRelayEndpoint
} from '../../../src/shared/mobile-relay-credential-contract'
import {
MobileRelayCredentialBundleSchema,
type MobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
import type { RpcClient } from './rpc-client'
const CREDENTIAL_ROTATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
type RotationResult = {
bundle: MobileRelayCredentialBundle
relay: MobileRelayEndpoint
}
export async function rotateMobileRelayCredential(args: {
client: RpcClient
bundle: MobileRelayCredentialBundle
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
randomBytes?: (length: number) => Uint8Array
}): Promise<RotationResult> {
let bundle = args.bundle
if (!bundle.pending) {
const randomBytes = args.randomBytes ?? ExpoCrypto.getRandomBytes
const token = encodeBase64Url(randomBytes(32))
bundle = MobileRelayCredentialBundleSchema.parse({
...bundle,
pending: {
token,
hash: hashMobileRelayCredential(token),
reqId: `rotate-${encodeBase64Url(randomBytes(16))}`
}
})
// Why: a crash or lost response must leave enough material to query the
// one global install key before any second authorization attempt.
await args.writeBundle(bundle)
}
const pending = bundle.pending
if (!pending) {
throw new Error('relay credential rotation pending state missing')
}
let endpoints = await getEndpoints(args.client, pending.reqId)
if (endpoints.installStatus?.state !== 'committed') {
const response = await args.client.sendRequest('pairing.provisionRelay', {
reqId: pending.reqId,
newResumeTokenHash: pending.hash,
expectedCurrentHash: bundle.current.hash
})
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
const installed = DeviceCredentialInstalledSchema.parse(response.result)
endpoints = await getEndpoints(args.client, pending.reqId)
if (
endpoints.installStatus?.state !== 'committed' ||
JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed)
) {
throw new Error('relay credential rotation was not authoritatively committed')
}
}
if (!endpoints.relay || endpoints.installStatus?.state !== 'committed') {
throw new Error('relay credential rotation endpoint state missing')
}
const installed = endpoints.installStatus.result
const next = MobileRelayCredentialBundleSchema.parse({
...bundle,
current: {
token: pending.token,
hash: pending.hash,
version: installed.currentVersion,
expiresAt: installed.resumeExpiresAt
},
...(installed.graceExpiresAt
? { grace: { ...bundle.current, expiresAt: installed.graceExpiresAt } }
: { grace: undefined }),
pending: undefined
})
await args.writeBundle(next)
return { bundle: next, relay: endpoints.relay }
}
export function mobileRelayCredentialNeedsRotation(
bundle: MobileRelayCredentialBundle,
now: number
): boolean {
// Why: pre-release clients hashed decoded random bytes. Direct connectivity
// can safely replace that unusable cloud credential using its stored hash.
const malformedCurrentHash =
bundle.current.hash !== hashMobileRelayCredential(bundle.current.token)
return (
Boolean(bundle.pending) ||
malformedCurrentHash ||
bundle.current.expiresAt - now <= CREDENTIAL_ROTATION_WINDOW_MS
)
}
export function applyResumeConfirmation(
bundle: MobileRelayCredentialBundle,
usedCredentialVersion: number,
confirmation: DeviceResumeConfirmed
): MobileRelayCredentialBundle {
if (
confirmation.acceptedAs === 'current' &&
confirmation.renewed &&
bundle.current.version === usedCredentialVersion &&
confirmation.currentVersion === usedCredentialVersion
) {
return MobileRelayCredentialBundleSchema.parse({
...bundle,
current: { ...bundle.current, expiresAt: confirmation.resumeExpiresAt }
})
}
if (
confirmation.acceptedAs === 'grace' &&
bundle.grace?.version === usedCredentialVersion &&
confirmation.graceExpiresAt
) {
return MobileRelayCredentialBundleSchema.parse({
...bundle,
grace: { ...bundle.grace, expiresAt: confirmation.graceExpiresAt }
})
}
return bundle
}
async function getEndpoints(client: RpcClient, installReqId: string) {
const response = await client.sendRequest('pairing.getEndpoints', { installReqId })
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return PairingGetEndpointsResultSchema.parse(response.result)
}
function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest'
import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller'
import type { MobileRelayDirectUpgradeResult } from './mobile-relay-direct-upgrade'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
const directHost: HostProfile = {
id: 'host-direct',
name: 'Host 4',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1
}
const upgraded = {
host: {
...directHost,
relayHostId: 'AbCdEf0123_-xyZ9',
relay: {
v: 1 as const,
directorUrl: 'https://relay-staging.onorca.dev',
cellUrl: 'https://c1.relay-staging.onorca.dev',
assignmentEpoch: 4,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
},
bundle: {
v: 1 as const,
hostId: directHost.id,
deviceToken: directHost.deviceToken,
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 1,
expiresAt: 99_999_999
}
}
} satisfies MobileRelayDirectUpgradeResult
function logicalClient(initial: ConnectionState) {
let state = initial
const listeners = new Set<(state: ConnectionState) => void>()
return {
client: {
getState: () => state,
onStateChange: (listener: (next: ConnectionState) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
} as unknown as StableLogicalRpcClient,
setState(next: ConnectionState) {
state = next
for (const listener of listeners) {
listener(next)
}
}
}
}
describe('direct pairing upgrade controller', () => {
it('upgrades immediately after an authenticated direct connection', async () => {
const logical = logicalClient('connected')
const upgrade = vi.fn(async () => upgraded)
const onUpgraded = vi.fn(async () => {})
const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, {
upgrade,
onUpgraded
})
await controller.start()
expect(upgrade).toHaveBeenCalledWith(logical.client, directHost)
expect(onUpgraded).toHaveBeenCalledWith(upgraded)
})
it('retries a deferred upgrade on the next foreground transition', async () => {
const logical = logicalClient('connected')
const upgrade = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(upgraded)
const onUpgraded = vi.fn(async () => {})
const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, {
upgrade,
onUpgraded
})
await controller.start()
controller.setForeground(false)
controller.setForeground(true)
await vi.waitFor(() => expect(onUpgraded).toHaveBeenCalledOnce())
expect(upgrade).toHaveBeenCalledTimes(2)
})
it('fences a completed request after the host client closes', async () => {
const logical = logicalClient('connecting')
let resolveUpgrade!: (result: MobileRelayDirectUpgradeResult) => void
const upgrade = vi.fn(
() =>
new Promise<MobileRelayDirectUpgradeResult>((resolve) => {
resolveUpgrade = resolve
})
)
const onUpgraded = vi.fn(async () => {})
const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, {
upgrade,
onUpgraded
})
await controller.start()
logical.setState('connected')
controller.stop()
resolveUpgrade(upgraded)
await Promise.resolve()
expect(onUpgraded).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,69 @@
import type { MobileRelayDirectUpgradeResult } from './mobile-relay-direct-upgrade'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { HostProfile } from './types'
type Dependencies = {
upgrade: (
client: StableLogicalRpcClient,
host: HostProfile
) => Promise<MobileRelayDirectUpgradeResult | null>
onUpgraded: (result: MobileRelayDirectUpgradeResult) => Promise<void>
}
export class MobileRelayDirectUpgradeController {
private foreground = true
private stopped = false
private inFlight = false
private unsubscribe: (() => void) | null = null
constructor(
private readonly logical: StableLogicalRpcClient,
private readonly host: HostProfile,
private readonly dependencies: Dependencies
) {}
async start(): Promise<void> {
this.unsubscribe = this.logical.onStateChange((state) => {
if (state === 'connected') {
void this.tryUpgrade()
}
})
if (this.logical.getState() === 'connected') {
await this.tryUpgrade()
}
}
setForeground(foreground: boolean): void {
this.foreground = foreground
if (foreground && this.logical.getState() === 'connected') {
void this.tryUpgrade()
}
}
stop(): void {
this.stopped = true
this.unsubscribe?.()
this.unsubscribe = null
}
private async tryUpgrade(): Promise<void> {
if (this.stopped || !this.foreground || this.inFlight) {
return
}
this.inFlight = true
try {
const result = await this.dependencies.upgrade(this.logical, this.host)
if (!result || this.stopped) {
return
}
this.unsubscribe?.()
this.unsubscribe = null
await this.dependencies.onUpgraded(result)
} catch {
// Why: the journal survives transient auth/control failure; retry only on
// the next authenticated reconnect or foreground transition.
} finally {
this.inFlight = false
}
}
}
@@ -0,0 +1,89 @@
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import { z } from 'zod'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
export const MobileRelayDirectUpgradeJournalSchema = z
.object({
v: z.literal(1),
hostId: z.string().min(1),
reqId: z.string().min(1).max(128),
pendingResumeToken: Base64Url32ByteSchema,
pendingResumeTokenHash: Base64Url32ByteSchema
})
.strict()
export type MobileRelayDirectUpgradeJournal = z.infer<typeof MobileRelayDirectUpgradeJournalSchema>
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
function journalKey(hostId: string): string {
return `orca.mobile-relay.direct-upgrade.${hostId}`
}
export function createMobileRelayDirectUpgradeJournal(
hostId: string,
randomBytes: (length: number) => Uint8Array
): MobileRelayDirectUpgradeJournal {
const pendingResumeToken = encodeBase64Url(randomBytes(32))
return MobileRelayDirectUpgradeJournalSchema.parse({
v: 1,
hostId,
reqId: `upgrade-${encodeBase64Url(randomBytes(16))}`,
pendingResumeToken,
pendingResumeTokenHash: hashMobileRelayCredential(pendingResumeToken)
})
}
export async function readMobileRelayDirectUpgradeJournal(
hostId: string
): Promise<MobileRelayDirectUpgradeJournal | null> {
requireNativeSecretStore()
const raw = await SecureStore.getItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS)
if (!raw) {
return null
}
try {
const parsed = MobileRelayDirectUpgradeJournalSchema.safeParse(JSON.parse(raw))
return parsed.success && parsed.data.hostId === hostId ? parsed.data : null
} catch {
return null
}
}
export async function writeMobileRelayDirectUpgradeJournal(
journal: MobileRelayDirectUpgradeJournal
): Promise<void> {
requireNativeSecretStore()
const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal)
await SecureStore.setItemAsync(
journalKey(parsed.hostId),
JSON.stringify(parsed),
KEYCHAIN_OPTIONS
)
}
export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Promise<void> {
if (Platform.OS === 'web') {
return
}
await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS)
}
function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function requireNativeSecretStore(): void {
if (Platform.OS === 'web') {
throw new Error('Orca Relay upgrade state requires a native secret store')
}
}
@@ -0,0 +1,193 @@
import { describe, expect, it, vi } from 'vitest'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import { MobileRelayUpgradeHostRemovedError } from './host-store'
import {
createMobileRelayDirectUpgradeJournal,
type MobileRelayDirectUpgradeJournal
} from './mobile-relay-direct-upgrade-journal'
import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade'
import type { RpcClient } from './rpc-client'
import type { 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) }))
const relay: MobileRelayEndpoint = {
v: 1,
directorUrl: 'https://relay-staging.onorca.dev',
cellUrl: 'https://c1.relay-staging.onorca.dev',
assignmentEpoch: 4,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2
}
const host: HostProfile = {
id: 'host-direct',
name: 'Host 4',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1
}
function success(result: unknown): RpcResponse {
return { id: 'rpc', ok: true, result, _meta: { runtimeId: 'runtime' } }
}
function clientWith(responses: RpcResponse[]) {
return {
sendRequest: vi.fn(async () => responses.shift()!),
getState: () => 'connected'
} as unknown as RpcClient
}
function installed(journal: MobileRelayDirectUpgradeJournal) {
return {
v: 1 as const,
reqId: journal.reqId,
authorizationMode: 'authenticated-direct' as const,
currentVersion: 1,
resumeExpiresAt: 9_999_999
}
}
function dependencies(journal: MobileRelayDirectUpgradeJournal | null = null) {
let stored = journal
return {
readJournal: vi.fn(async () => stored),
writeJournal: vi.fn(async (next: MobileRelayDirectUpgradeJournal) => {
stored = next
}),
clearJournal: vi.fn(async () => {
stored = null
}),
writeBundle: vi.fn(async () => {}),
deleteBundle: vi.fn(async () => {}),
saveHost: vi.fn(async () => {}),
randomBytes: (length: number) => new Uint8Array(length).fill(7)
}
}
describe('existing direct pairing relay upgrade', () => {
it('persists pending material before install and publishes only after committed status', async () => {
const deps = dependencies()
let journal: MobileRelayDirectUpgradeJournal | null = null
deps.writeJournal.mockImplementation(async (next) => {
journal = next
})
const client = clientWith([
success({ v: 1, relay }),
success({
v: 1,
reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw',
authorizationMode: 'authenticated-direct',
currentVersion: 1,
resumeExpiresAt: 9_999_999
}),
success({
v: 1,
relay,
installStatus: {
v: 1,
reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw',
state: 'committed',
result: {
v: 1,
reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw',
authorizationMode: 'authenticated-direct',
currentVersion: 1,
resumeExpiresAt: 9_999_999
}
}
})
])
const result = await upgradeDirectMobileRelay({ client, host, dependencies: deps })
expect(journal).not.toBeNull()
expect(deps.writeJournal.mock.invocationCallOrder[0]).toBeLessThan(
client.sendRequest.mock.invocationCallOrder[0]!
)
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', {
reqId: journal!.reqId,
newResumeTokenHash: journal!.pendingResumeTokenHash
})
expect(deps.writeBundle).toHaveBeenCalledBefore(deps.saveHost)
expect(result?.host.relay).toEqual(relay)
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
})
it('recovers an already committed install without authorizing a second one', async () => {
const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) =>
new Uint8Array(length).fill(3)
)
const committed = installed(journal)
const deps = dependencies(journal)
const client = clientWith([
success({
v: 1,
relay,
installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed }
})
])
const result = await upgradeDirectMobileRelay({ client, host, dependencies: deps })
expect(client.sendRequest).toHaveBeenCalledOnce()
expect(result?.bundle.current.version).toBe(1)
expect(deps.writeBundle).toHaveBeenCalledOnce()
})
it('cleans pending state and leaves direct access unchanged for an old desktop', async () => {
const deps = dependencies()
const client = clientWith([
{
id: 'rpc',
ok: false,
error: { code: 'method_not_found', message: 'unsupported' },
_meta: { runtimeId: 'runtime' }
}
])
await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull()
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
expect(deps.writeBundle).not.toHaveBeenCalled()
expect(deps.saveHost).not.toHaveBeenCalled()
})
it('retains the durable journal when relay registration is temporarily unavailable', async () => {
const deps = dependencies()
const client = clientWith([success({ v: 1, relay: null })])
await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).rejects.toThrow(
'relay endpoint unavailable'
)
expect(deps.writeJournal).toHaveBeenCalledOnce()
expect(deps.clearJournal).not.toHaveBeenCalled()
})
it('cleans newly installed secrets instead of resurrecting a removed host', async () => {
const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) =>
new Uint8Array(length).fill(5)
)
const committed = installed(journal)
const deps = dependencies(journal)
deps.saveHost.mockRejectedValue(
new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
)
const client = clientWith([
success({
v: 1,
relay,
installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed }
})
])
await expect(
upgradeDirectMobileRelay({ client, host, dependencies: deps })
).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError)
expect(deps.deleteBundle).toHaveBeenCalledWith(host.id)
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
})
})
@@ -0,0 +1,175 @@
import * as ExpoCrypto from 'expo-crypto'
import {
DeviceCredentialInstalledSchema,
PairingGetEndpointsResultSchema,
type DeviceCredentialInstalled,
type PairingGetEndpointsResult
} from '../../../src/shared/mobile-relay-credential-contract'
import { MobileRelayUpgradeHostRemovedError, saveExistingHostRelayUpgrade } from './host-store'
import { persistRelayHost } from './mobile-endpoint-supervisor-support'
import {
MobileRelayCredentialBundleSchema,
deleteMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle,
type MobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import {
createMobileRelayDirectUpgradeJournal,
deleteMobileRelayDirectUpgradeJournal,
readMobileRelayDirectUpgradeJournal,
writeMobileRelayDirectUpgradeJournal,
type MobileRelayDirectUpgradeJournal
} from './mobile-relay-direct-upgrade-journal'
import type { RpcClient } from './rpc-client'
import type { HostProfile, RpcResponse } from './types'
export type MobileRelayDirectUpgradeResult = {
host: HostProfile
bundle: MobileRelayCredentialBundle
}
type Dependencies = {
readJournal: typeof readMobileRelayDirectUpgradeJournal
writeJournal: typeof writeMobileRelayDirectUpgradeJournal
clearJournal: typeof deleteMobileRelayDirectUpgradeJournal
writeBundle: typeof writeMobileRelayCredentialBundle
saveHost: typeof saveExistingHostRelayUpgrade
deleteBundle: typeof deleteMobileRelayCredentialBundle
randomBytes: (length: number) => Uint8Array
}
export async function upgradeDirectMobileRelay(args: {
client: RpcClient
host: HostProfile
dependencies?: Partial<Dependencies>
}): Promise<MobileRelayDirectUpgradeResult | null> {
if (args.host.relay) {
return null
}
const dependencies: Dependencies = {
readJournal: readMobileRelayDirectUpgradeJournal,
writeJournal: writeMobileRelayDirectUpgradeJournal,
clearJournal: deleteMobileRelayDirectUpgradeJournal,
writeBundle: writeMobileRelayCredentialBundle,
saveHost: saveExistingHostRelayUpgrade,
deleteBundle: deleteMobileRelayCredentialBundle,
randomBytes: ExpoCrypto.getRandomBytes,
...args.dependencies
}
let journal = await dependencies.readJournal(args.host.id)
if (!journal) {
journal = createMobileRelayDirectUpgradeJournal(args.host.id, dependencies.randomBytes)
// Why: the stable reqId and pending secret must survive a lost install response.
await dependencies.writeJournal(journal)
}
const initial = await getEndpoints(args.client, journal.reqId)
if (initial === 'method-not-found') {
await dependencies.clearJournal(args.host.id)
return null
}
if (initial.installStatus?.state === 'committed') {
return publishCommitted(args.host, journal, initial, dependencies)
}
if (!initial.relay) {
throw new Error('relay endpoint unavailable for direct pairing upgrade')
}
const provisionResponse = await args.client.sendRequest('pairing.provisionRelay', {
reqId: journal.reqId,
newResumeTokenHash: journal.pendingResumeTokenHash
})
if (isMethodNotFound(provisionResponse)) {
await dependencies.clearJournal(args.host.id)
return null
}
const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provisionResponse))
assertDirectInstall(journal, installed)
const reconciled = await getEndpoints(args.client, journal.reqId)
if (reconciled === 'method-not-found') {
throw new Error('relay endpoint reconciliation became unavailable')
}
assertCommitted(reconciled, installed)
return publishCommitted(args.host, journal, reconciled, dependencies)
}
async function publishCommitted(
host: HostProfile,
journal: MobileRelayDirectUpgradeJournal,
endpoints: PairingGetEndpointsResult,
dependencies: Dependencies
): Promise<MobileRelayDirectUpgradeResult> {
if (endpoints.installStatus?.state !== 'committed' || !endpoints.relay) {
throw new Error('direct pairing upgrade was not authoritatively committed')
}
const installed = endpoints.installStatus.result
assertDirectInstall(journal, installed)
const bundle = MobileRelayCredentialBundleSchema.parse({
v: 1,
hostId: host.id,
deviceToken: host.deviceToken,
current: {
token: journal.pendingResumeToken,
hash: journal.pendingResumeTokenHash,
version: installed.currentVersion,
expiresAt: installed.resumeExpiresAt
}
})
// Why: the overlay must never advertise relay without its matching credential.
await dependencies.writeBundle(bundle)
let updatedHost: HostProfile
try {
updatedHost = await persistRelayHost(host, endpoints.relay, dependencies.saveHost)
} catch (error) {
if (error instanceof MobileRelayUpgradeHostRemovedError) {
await dependencies.deleteBundle(host.id)
await dependencies.clearJournal(host.id)
}
throw error
}
await dependencies.clearJournal(host.id)
return { host: updatedHost, bundle }
}
async function getEndpoints(
client: RpcClient,
installReqId: string
): Promise<PairingGetEndpointsResult | 'method-not-found'> {
const response = await client.sendRequest('pairing.getEndpoints', { installReqId })
if (isMethodNotFound(response)) {
return 'method-not-found'
}
return PairingGetEndpointsResultSchema.parse(requireSuccess(response))
}
function assertDirectInstall(
journal: MobileRelayDirectUpgradeJournal,
installed: DeviceCredentialInstalled
): void {
if (installed.reqId !== journal.reqId || installed.authorizationMode !== 'authenticated-direct') {
throw new Error('relay credential install does not match direct upgrade journal')
}
}
function assertCommitted(
endpoints: PairingGetEndpointsResult,
installed: DeviceCredentialInstalled
): void {
if (
endpoints.installStatus?.state !== 'committed' ||
JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed)
) {
throw new Error('relay credential install was not authoritatively reconciled')
}
}
function requireSuccess(response: RpcResponse): unknown {
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
}
function isMethodNotFound(response: RpcResponse): boolean {
return !response.ok && response.error.code === 'method_not_found'
}
@@ -0,0 +1,151 @@
import {
RelayPhoneHelloSchema,
type RelayPhoneHello
} from '../../../src/shared/mobile-relay-phone-protocol'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
export class RelayOuterError extends Error {
constructor(readonly code: number) {
super(`relay_outer_${code}`)
}
}
type MobileRelayE2eeLinkOptions = {
endpoint: { cellUrl: string; relayHostId: string }
credential: string
expectedCredentialKind: 'invite' | 'resume'
deviceToken: string
desktopPublicKeyB64: string
onAuthenticated: () => void
onText: (plaintext: string) => void
onBinary: (plaintext: Uint8Array) => void
onHello?: (hello: Extract<RelayPhoneHello, { ok: true }>) => void
onError: (error: Error) => void
createSocket?: (url: string) => WebSocket
}
export class MobileRelayE2eeLink {
private readonly options: MobileRelayE2eeLinkOptions
private readonly socket: WebSocket
private readonly channel: MobileE2EEV2PhysicalChannel
private outerReady = false
private closed = false
private inboundChain: Promise<void> = Promise.resolve()
constructor(options: MobileRelayE2eeLinkOptions) {
this.options = options
this.socket = (options.createSocket ?? ((url) => new WebSocket(url)))(
relaySocketUrl(options.endpoint)
)
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: options.desktopPublicKeyB64,
transport: 'relay',
relayHostId: options.endpoint.relayHostId
})
this.channel = new MobileE2EEV2PhysicalChannel({
session,
socket: this.socket,
deviceToken: options.deviceToken,
decodeBinary: websocketPayloadToUint8,
onAuthenticated: options.onAuthenticated,
onText: options.onText,
onBinary: options.onBinary,
onError: (error) => this.fail(error)
})
this.bindSocket()
}
sendText(plaintext: string): boolean {
return !this.closed && this.channel.sendText(plaintext)
}
sendBinary(plaintext: Uint8Array): boolean {
return !this.closed && this.channel.sendBinary(plaintext)
}
close(): void {
if (this.closed) {
return
}
this.closed = true
this.channel.dispose()
this.socket.close()
}
private bindSocket(): void {
this.socket.onopen = () => {
this.socket.send(
JSON.stringify({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: this.options.credential
})
)
}
this.socket.onmessage = (event) => {
this.inboundChain = this.inboundChain
.then(async () => {
if (this.closed) {
return
}
if (!this.outerReady) {
this.acceptHello(event.data)
} else {
await this.channel.handleMessage(event.data)
}
})
.catch((error: unknown) => this.fail(asError(error)))
}
this.socket.onerror = () => this.fail(new Error('relay transport error'))
this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006))
}
private acceptHello(raw: unknown): void {
if (typeof raw !== 'string') {
throw new Error('expected plaintext relay hello')
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
throw new Error('invalid relay hello JSON')
}
const parsed = RelayPhoneHelloSchema.safeParse(value)
if (!parsed.success) {
throw new Error('invalid relay hello')
}
if (!parsed.data.ok) {
throw new RelayOuterError(parsed.data.code)
}
if (parsed.data.credentialKind !== this.options.expectedCredentialKind) {
throw new Error('relay credential resolved as an unexpected credential kind')
}
this.outerReady = true
this.options.onHello?.(parsed.data)
this.channel.start()
}
private fail(error: Error): void {
if (this.closed) {
return
}
this.closed = true
this.channel.dispose()
this.options.onError(error)
this.socket.close()
}
}
function relaySocketUrl(endpoint: { cellUrl: string; relayHostId: string }): string {
const url = new URL(endpoint.cellUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(endpoint.relayHostId)}`
return url.toString()
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
import {
loadMobileRelayHostOverlays,
resetMobileRelayHostOverlayStoreForTests,
saveMobileRelayHostOverlay
} from './mobile-relay-host-overlay-store'
import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay'
const STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
const OVERLAY: MobileRelayHostOverlay = {
v: 2,
hostId: 'host-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/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
}
}
describe('mobile relay host overlay store', () => {
let stored: string | null
beforeEach(() => {
vi.clearAllMocks()
resetMobileRelayHostOverlayStoreForTests()
stored = null
asyncStorage.getItem.mockImplementation(async (key: string) =>
key === STORAGE_KEY ? stored : null
)
asyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
if (key === STORAGE_KEY) {
stored = value
}
})
})
it('round-trips v2 metadata in a namespace legacy builds do not rewrite', async () => {
await saveMobileRelayHostOverlay(OVERLAY)
await expect(loadMobileRelayHostOverlays(new Set(['host-1']))).resolves.toEqual(
new Map([['host-1', OVERLAY]])
)
expect(asyncStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, expect.any(String))
})
it('never overlays or resurrects a host whose legacy base was removed', async () => {
stored = JSON.stringify([OVERLAY])
await expect(loadMobileRelayHostOverlays(new Set())).resolves.toEqual(new Map())
expect(asyncStorage.setItem).not.toHaveBeenCalled()
expect(JSON.parse(stored)).toEqual([OVERLAY])
})
it('refuses to overwrite unreadable overlay storage', async () => {
stored = '{'
await expect(saveMobileRelayHostOverlay(OVERLAY)).rejects.toThrow(/unreadable/)
expect(asyncStorage.setItem).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,94 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
MobileRelayHostOverlaySchema,
type MobileRelayHostOverlay
} from './mobile-relay-host-overlay'
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
let overlayMutation: Promise<void> = Promise.resolve()
function parseOverlays(raw: string | null): MobileRelayHostOverlay[] | null {
if (raw === null) {
return []
}
try {
const value = JSON.parse(raw) as unknown
if (!Array.isArray(value)) {
return null
}
return value.flatMap((item) => {
const result = MobileRelayHostOverlaySchema.safeParse(item)
return result.success ? [result.data] : []
})
} catch {
return null
}
}
async function readOverlaysForMutation(): Promise<MobileRelayHostOverlay[]> {
const overlays = parseOverlays(await AsyncStorage.getItem(OVERLAY_STORAGE_KEY))
if (!overlays) {
// Why: never rewrite an unreadable v2 namespace as an empty list; doing so
// would destroy relay recovery data during an unrelated host mutation.
throw new Error('mobile relay host overlay storage unreadable')
}
return overlays
}
async function mutateOverlays(
update: (overlays: MobileRelayHostOverlay[]) => MobileRelayHostOverlay[]
): Promise<void> {
const mutation = overlayMutation.then(async () => {
const current = await readOverlaysForMutation()
await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(update(current)))
})
overlayMutation = mutation.catch(() => {})
return mutation
}
export async function loadMobileRelayHostOverlays(
existingHostIds: ReadonlySet<string>
): Promise<Map<string, MobileRelayHostOverlay>> {
return (await loadMobileRelayHostOverlayState(existingHostIds)).overlays
}
export async function loadMobileRelayHostOverlayState(
existingHostIds: ReadonlySet<string>
): Promise<{ overlays: Map<string, MobileRelayHostOverlay>; orphanHostIds: string[] }> {
await overlayMutation
const overlays = parseOverlays(await AsyncStorage.getItem(OVERLAY_STORAGE_KEY)) ?? []
const active = new Map<string, MobileRelayHostOverlay>()
const orphanHostIds: string[] = []
for (const overlay of overlays) {
// Why: an older app can remove the legacy base without knowing this
// namespace; never let the retained overlay resurrect that host later.
if (existingHostIds.has(overlay.hostId)) {
active.set(overlay.hostId, overlay)
} else {
orphanHostIds.push(overlay.hostId)
}
}
return { overlays: active, orphanHostIds }
}
export async function saveMobileRelayHostOverlay(overlay: MobileRelayHostOverlay): Promise<void> {
const validated = MobileRelayHostOverlaySchema.parse(overlay)
return mutateOverlays((overlays) => {
const index = overlays.findIndex(({ hostId }) => hostId === validated.hostId)
if (index < 0) {
return [...overlays, validated]
}
const next = overlays.slice()
next[index] = validated
return next
})
}
export function removeMobileRelayHostOverlay(hostId: string): Promise<void> {
return mutateOverlays((overlays) => overlays.filter((overlay) => overlay.hostId !== hostId))
}
/** Test-only: drain the module mutation chain between cases. */
export function resetMobileRelayHostOverlayStoreForTests(): void {
overlayMutation = Promise.resolve()
}
@@ -0,0 +1,47 @@
import { z } from 'zod'
import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract'
export const MobileAccessEndpointSchema = z
.object({
id: z.string().min(1).max(128),
kind: z.enum(['lan', 'tailscale', 'relay']),
url: z.string().min(1).max(2048)
})
.strict()
export const MobileRelayHostOverlaySchema = z
.object({
v: z.literal(2),
hostId: z.string().min(1),
endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16),
relayHostId: z
.string()
.regex(/^[A-Za-z0-9_-]{16}$/)
.optional(),
relay: MobileRelayEndpointSchema.optional()
})
.strict()
.superRefine((overlay, context) => {
if ((overlay.relayHostId === undefined) !== (overlay.relay === undefined)) {
context.addIssue({ code: 'custom', message: 'Relay identity and endpoint must coexist' })
return
}
if (overlay.relay && overlay.relay.relayHostId !== overlay.relayHostId) {
context.addIssue({
code: 'custom',
path: ['relayHostId'],
message: 'Relay host identity mismatch'
})
}
const relayEndpointCount = overlay.endpoints.filter(({ kind }) => kind === 'relay').length
if (relayEndpointCount !== (overlay.relay ? 1 : 0)) {
context.addIssue({
code: 'custom',
path: ['endpoints'],
message: 'Expected exactly one endpoint for configured relay metadata'
})
}
})
export type MobileAccessEndpoint = z.infer<typeof MobileAccessEndpointSchema>
export type MobileRelayHostOverlay = z.infer<typeof MobileRelayHostOverlaySchema>
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
class FakeSocket {
sent: string[] = []
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number }) => void) | null = null
send(value: string): void {
this.sent.push(value)
}
close(): void {}
}
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
inviteExpiresAt: Date.now() + 300_000,
e2eeFraming: 2 as const
}
describe('pairing invite director resolution', () => {
it('authenticates only to the configured director and accepts a strictly newer move', async () => {
const socket = new FakeSocket()
let url = ''
const resolving = resolvePairingInviteThroughDirector({
relay,
createSocket: (value) => {
url = value
return socket as unknown as WebSocket
}
})
socket.onopen?.()
expect(url).toBe('wss://relay.onorca.dev/v1/connect/AbCdEf0123_-xyZ9')
expect(url).not.toContain('?')
expect(JSON.parse(socket.sent[0]!)).toEqual({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: relay.inviteToken
})
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-moved',
v: 1,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
})
})
await expect(resolving).resolves.toMatchObject({
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
})
})
it('rejects same/older epochs and untrusted extra fields', async () => {
const socket = new FakeSocket()
const resolving = resolvePairingInviteThroughDirector({
relay,
createSocket: () => socket as unknown as WebSocket
})
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-moved',
v: 1,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 7,
targetFromCell: true
})
})
await expect(resolving).rejects.toThrow(/not strictly newer/)
})
})
@@ -0,0 +1,78 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol'
export function resolvePairingInviteThroughDirector(args: {
relay: PairingRelay
timeoutMs?: number
createSocket?: (url: string) => WebSocket
}): Promise<PairingRelay> {
const socket = (args.createSocket ?? ((url) => new WebSocket(url)))(
directorWebSocketUrl(args.relay)
)
return new Promise((resolve, reject) => {
let settled = false
const timeout = setTimeout(
() => finish(new Error('relay director resolution timed out')),
args.timeoutMs ?? 5_000
)
socket.onopen = () => {
socket.send(
JSON.stringify({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: args.relay.inviteToken
})
)
}
socket.onmessage = (event) => {
if (typeof event.data !== 'string') {
finish(new Error('invalid relay director move'))
return
}
let value: unknown
try {
value = JSON.parse(event.data)
} catch {
finish(new Error('invalid relay director move'))
return
}
const moved = RelayMovedSchema.safeParse(value)
if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
finish(new Error('relay director move was not strictly newer'))
return
}
settled = true
clearTimeout(timeout)
socket.close()
resolve({
...args.relay,
cellUrl: moved.data.cellUrl,
assignmentEpoch: moved.data.assignmentEpoch
})
}
socket.onerror = () => finish(new Error('relay director transport error'))
socket.onclose = (event) => {
if (!settled) {
finish(new Error(`relay director closed before move: ${event.code || 1006}`))
}
}
function finish(error: Error): void {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
socket.close()
reject(error)
}
})
}
export function directorWebSocketUrl(relay: PairingRelay): string {
const url = new URL(relay.directorUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return url.toString()
}
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
describe('mobile relay orphan cleanup', () => {
it('durably schedules credential deletion before removing an orphan overlay pointer', async () => {
const order: string[] = []
const deleteCredential = vi.fn(async () => {})
const scheduleCleanup = vi.fn(async (hostId: string) => {
order.push(`schedule:${hostId}`)
})
const removeOverlay = vi.fn(async (hostId: string) => {
order.push(`overlay:${hostId}`)
})
await scheduleOrphanedMobileRelayCleanup({
hostIds: ['host-1', 'host-1'],
deleteCredential,
scheduleCleanup,
removeOverlay
})
expect(order).toEqual(['schedule:host-1', 'overlay:host-1'])
expect(scheduleCleanup).toHaveBeenCalledWith('host-1', deleteCredential)
})
it('retains the overlay pointer when durable cleanup scheduling fails', async () => {
const removeOverlay = vi.fn(async () => {})
await scheduleOrphanedMobileRelayCleanup({
hostIds: ['host-1'],
deleteCredential: vi.fn(async () => {}),
scheduleCleanup: vi.fn(async () => {
throw new Error('storage unavailable')
}),
removeOverlay
})
expect(removeOverlay).not.toHaveBeenCalled()
})
})

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