mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into battery-git-status-backoff
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -75,8 +75,12 @@ jobs:
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
|
||||
# is no root config) and falls back to the 5s default timeout with no
|
||||
# Windows worker cap, so the real csc.exe launcher-compile tests time out
|
||||
# on hosted Windows. Use the shared config so this job matches pnpm test.
|
||||
- run: >-
|
||||
pnpm vitest run
|
||||
pnpm vitest run --config config/vitest.config.ts
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
- 'tests/e2e/fixtures/terminal-emoji-table.md'
|
||||
- 'tests/e2e/helpers/**'
|
||||
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
|
||||
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
|
||||
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
|
||||
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
|
||||
- 'src/renderer/src/lib/pane-manager/**'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
@@ -77,6 +77,11 @@ jobs:
|
||||
- name: Enforce max-lines ratchet (no new bypasses)
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
# Why: the CLI embeds guide content while the skills CLI installs generated
|
||||
# projections from the repository, so stale output would split those two truths.
|
||||
- name: Verify bundled skill guides
|
||||
run: pnpm run verify:bundled-skill-guides
|
||||
|
||||
# Why: project-owned type declarations must live in .ts so tsc
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
# from @electron-toolkit/tsconfig) silently widens unresolved names
|
||||
|
||||
+4
-4
@@ -100,9 +100,9 @@ docs/reference/react-performance-audit.md
|
||||
validation-screenshots/
|
||||
.stably-browser
|
||||
|
||||
# PR verification evidence screenshots are referenced from notes but should not
|
||||
# be committed.
|
||||
notes/artifacts/
|
||||
# Local scratch notes and PR evidence screenshots (not part of the product).
|
||||
/notes/
|
||||
/pr-evidence/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
@@ -123,5 +123,5 @@ src/renderer/src/i18n/locales/.ko-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.ja-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.es-catalog-cache.json
|
||||
|
||||
# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md
|
||||
# Bench result JSONs are working artifacts
|
||||
tools/benchmarks/results/terminal-pipeline-*.json
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -261,6 +261,9 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
|
||||
<img src="docs/assets/star-history.png" alt="GitHub star history chart for stablyai/orca" width="880" />
|
||||
</p>
|
||||
|
||||
## Signed Builds
|
||||
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
|
||||
|
||||
## License
|
||||
|
||||
Orca is free and open source under the [MIT License](LICENSE).
|
||||
|
||||
@@ -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
+801
-47
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,14 @@ import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const itWindows = process.platform === 'win32' ? it : it.skip
|
||||
const itCrossHost = process.platform === 'win32' ? it.skip : it
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
|
||||
// keep the larger allowance scoped to the real compiler integration test.
|
||||
function itWindows(name, test) {
|
||||
const runner = process.platform === 'win32' ? it : it.skip
|
||||
runner(name, { timeout: 15_000 }, test)
|
||||
}
|
||||
|
||||
describe('Windows CLI launcher', () => {
|
||||
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('electron-builder config', () => {
|
||||
'!mobile{,/**/*}',
|
||||
'!native{,/**/*}',
|
||||
'!skills{,/**/*}',
|
||||
'!skill-guides{,/**/*}',
|
||||
'!tests{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { constants } from 'node:fs'
|
||||
import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const SCRIPT_DIR = import.meta.dirname
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
|
||||
|
||||
const CANONICAL_GUIDE_NAMES = [
|
||||
'computer-use',
|
||||
'linear-tickets',
|
||||
'orca-cli',
|
||||
'orca-emulator',
|
||||
'orca-emulator-android',
|
||||
'orca-linear',
|
||||
'orca-per-workspace-env',
|
||||
'orchestration'
|
||||
]
|
||||
|
||||
// Why: old discovery stubs can outlive a rename indefinitely, so aliases are
|
||||
// a compatibility ledger: add entries for renames, but never remove them.
|
||||
const GUIDE_ALIASES = {
|
||||
'computer-use': [],
|
||||
'linear-tickets': [],
|
||||
'orca-cli': [],
|
||||
'orca-emulator': [],
|
||||
'orca-emulator-android': [],
|
||||
'orca-linear': [],
|
||||
'orca-per-workspace-env': [],
|
||||
orchestration: []
|
||||
}
|
||||
|
||||
function normalizeMarkdown(markdown) {
|
||||
return markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
}
|
||||
|
||||
function parseFrontmatter(markdown, sourcePath) {
|
||||
const normalized = normalizeMarkdown(markdown)
|
||||
const match = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/.exec(normalized)
|
||||
if (!match) {
|
||||
throw new Error(`Guide source has no YAML frontmatter: ${sourcePath}`)
|
||||
}
|
||||
let values
|
||||
try {
|
||||
values = parse(match[1])
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Guide source has invalid YAML frontmatter: ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
!values ||
|
||||
typeof values !== 'object' ||
|
||||
typeof values.name !== 'string' ||
|
||||
typeof values.description !== 'string'
|
||||
) {
|
||||
throw new Error(`Guide source must declare name and description: ${sourcePath}`)
|
||||
}
|
||||
return {
|
||||
name: values.name,
|
||||
description: values.description.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
}
|
||||
|
||||
function constantName(name) {
|
||||
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
|
||||
}
|
||||
|
||||
function serializeEmbeddedModule(guides) {
|
||||
const markdownConstants = guides
|
||||
.map(
|
||||
(guide) =>
|
||||
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
|
||||
)
|
||||
.join('\n\n')
|
||||
const guideEntries = guides
|
||||
.map((guide) => {
|
||||
const markdownConstant = constantName(guide.name)
|
||||
return [
|
||||
' {',
|
||||
` name: ${JSON.stringify(guide.name)},`,
|
||||
` description: ${JSON.stringify(guide.description)},`,
|
||||
` markdown: ${markdownConstant},`,
|
||||
` fullMarkdown: ${markdownConstant},`,
|
||||
` aliases: ${JSON.stringify(guide.aliases)}`,
|
||||
' }'
|
||||
].join('\n')
|
||||
})
|
||||
.join(',\n')
|
||||
|
||||
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n}\n\n${markdownConstants}\n\n// Why: no current guide has bundled reference documents, so --full is byte-identical for now.\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
|
||||
}
|
||||
|
||||
function assertAliasContract(guides) {
|
||||
const canonicalNames = new Set(guides.map((guide) => guide.name))
|
||||
const seenAliases = new Set()
|
||||
for (const guide of guides) {
|
||||
for (const alias of guide.aliases) {
|
||||
if (canonicalNames.has(alias)) {
|
||||
throw new Error(`Guide alias collides with canonical name: ${alias}`)
|
||||
}
|
||||
if (seenAliases.has(alias)) {
|
||||
throw new Error(`Guide alias is assigned more than once: ${alias}`)
|
||||
}
|
||||
seenAliases.add(alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildArtifacts(repoRoot = REPO_ROOT) {
|
||||
const guideRoot = path.join(repoRoot, 'skill-guides')
|
||||
const sourceFiles = (await readdir(guideRoot, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
||||
.map((entry) => entry.name.slice(0, -3))
|
||||
.sort((left, right) => left.localeCompare(right, 'en'))
|
||||
const expectedNames = [...CANONICAL_GUIDE_NAMES].sort((left, right) =>
|
||||
left.localeCompare(right, 'en')
|
||||
)
|
||||
if (JSON.stringify(sourceFiles) !== JSON.stringify(expectedNames)) {
|
||||
throw new Error(
|
||||
`Guide sources must match the canonical topic list.\nExpected: ${expectedNames.join(', ')}\nFound: ${sourceFiles.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const guides = []
|
||||
const projections = []
|
||||
for (const name of expectedNames) {
|
||||
const sourcePath = path.join(guideRoot, `${name}.md`)
|
||||
// Why: Git may render text with native EOLs despite repository policy; the
|
||||
// embedded guide and generated projection must have one platform-neutral identity.
|
||||
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
|
||||
const frontmatter = parseFrontmatter(markdown, path.relative(repoRoot, sourcePath))
|
||||
if (frontmatter.name !== name) {
|
||||
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
|
||||
}
|
||||
const aliases = GUIDE_ALIASES[name]
|
||||
guides.push({ name, description: frontmatter.description, markdown, aliases })
|
||||
projections.push({
|
||||
path: path.join(repoRoot, 'skills', name, 'SKILL.md'),
|
||||
content: markdown
|
||||
})
|
||||
}
|
||||
assertAliasContract(guides)
|
||||
|
||||
return [
|
||||
{
|
||||
path: path.join(repoRoot, 'src', 'cli', 'bundled-skill-guides.ts'),
|
||||
content: serializeEmbeddedModule(guides)
|
||||
},
|
||||
...projections
|
||||
]
|
||||
}
|
||||
|
||||
async function writeArtifacts(artifacts) {
|
||||
for (const artifact of artifacts) {
|
||||
await mkdir(path.dirname(artifact.path), { recursive: true })
|
||||
await writeFile(artifact.path, artifact.content, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyArtifacts(artifacts, repoRoot = REPO_ROOT) {
|
||||
const stale = []
|
||||
for (const artifact of artifacts) {
|
||||
try {
|
||||
await access(artifact.path, constants.R_OK)
|
||||
if ((await readFile(artifact.path, 'utf8')) !== artifact.content) {
|
||||
stale.push(artifact.path)
|
||||
}
|
||||
} catch {
|
||||
stale.push(artifact.path)
|
||||
}
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
throw new Error(
|
||||
`Generated bundled skill guides are stale:\n${stale
|
||||
.map((filePath) => path.relative(repoRoot, filePath))
|
||||
.join('\n')}\nRun node config/scripts/generate-bundled-skill-guides.mjs --write.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const artifacts = await buildArtifacts()
|
||||
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
CANONICAL_GUIDE_NAMES,
|
||||
GUIDE_ALIASES,
|
||||
assertAliasContract,
|
||||
buildArtifacts,
|
||||
normalizeMarkdown,
|
||||
parseFrontmatter,
|
||||
serializeEmbeddedModule,
|
||||
verifyArtifacts,
|
||||
writeArtifacts
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
|
||||
import {
|
||||
CANONICAL_GUIDE_NAMES,
|
||||
GUIDE_ALIASES,
|
||||
assertAliasContract,
|
||||
buildArtifacts,
|
||||
normalizeMarkdown,
|
||||
parseFrontmatter,
|
||||
verifyArtifacts,
|
||||
writeArtifacts
|
||||
} from './generate-bundled-skill-guides.mjs'
|
||||
|
||||
const projectDir = path.resolve(import.meta.dirname, '..', '..')
|
||||
const temporaryDirectories = []
|
||||
|
||||
async function createFixture() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
|
||||
temporaryDirectories.push(root)
|
||||
await Promise.all([
|
||||
cp(path.join(projectDir, 'skill-guides'), path.join(root, 'skill-guides'), {
|
||||
recursive: true
|
||||
}),
|
||||
cp(path.join(projectDir, 'skills'), path.join(root, 'skills'), { recursive: true }),
|
||||
mkdir(path.join(root, 'src', 'cli'), { recursive: true })
|
||||
])
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('bundled skill guide generator', () => {
|
||||
it('keeps every first-phase fat projection byte-identical to its authoritative source', async () => {
|
||||
for (const name of CANONICAL_GUIDE_NAMES) {
|
||||
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`))
|
||||
const projection = await readFile(path.join(projectDir, 'skills', name, 'SKILL.md'))
|
||||
expect(projection, name).toEqual(source)
|
||||
}
|
||||
})
|
||||
|
||||
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
|
||||
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
|
||||
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
|
||||
)
|
||||
|
||||
for (const guide of BUNDLED_SKILL_GUIDES) {
|
||||
const source = await readFile(
|
||||
path.join(projectDir, 'skill-guides', `${guide.name}.md`),
|
||||
'utf8'
|
||||
)
|
||||
const frontmatter = parseFrontmatter(source, `${guide.name}.md`)
|
||||
expect(guide.description).toBe(frontmatter.description)
|
||||
expect(guide.markdown).toBe(source)
|
||||
expect(guide.fullMarkdown).toBe(source)
|
||||
expect(guide.aliases).toEqual(GUIDE_ALIASES[guide.name])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps CLI guide examples safe across shells and Linux command names', async () => {
|
||||
for (const name of ['orca-cli', 'computer-use', 'orca-emulator', 'orca-emulator-android']) {
|
||||
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`), 'utf8')
|
||||
|
||||
expect(source).toContain('ORCA_CLI_COMMAND')
|
||||
expect(source).toContain('orca-dev')
|
||||
expect(source).toContain('orca-ide')
|
||||
expect(source).toContain('PowerShell')
|
||||
expect(source).toContain('cmd.exe')
|
||||
expect(source).toMatch(/^ORCA .+--json$/mu)
|
||||
// Why: bare command lines can launch GNOME Orca, while shell variables make
|
||||
// the same guide unusable from PowerShell and cmd.exe.
|
||||
expect(source).not.toMatch(/^orca /mu)
|
||||
expect(source).not.toMatch(/\$ORCA(?:_|\b)/u)
|
||||
}
|
||||
})
|
||||
|
||||
it('builds deterministic artifacts and verifies the checked-in outputs', async () => {
|
||||
const first = await buildArtifacts(projectDir)
|
||||
const second = await buildArtifacts(projectDir)
|
||||
|
||||
expect(second).toEqual(first)
|
||||
await expect(verifyArtifacts(first, projectDir)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('generates platform-identical output from CRLF guide sources', async () => {
|
||||
const expected = await buildArtifacts(projectDir)
|
||||
const root = await createFixture()
|
||||
for (const name of CANONICAL_GUIDE_NAMES) {
|
||||
const sourcePath = path.join(root, 'skill-guides', `${name}.md`)
|
||||
const source = await readFile(sourcePath, 'utf8')
|
||||
await writeFile(sourcePath, source.replaceAll('\n', '\r\n'))
|
||||
}
|
||||
|
||||
const actual = await buildArtifacts(root)
|
||||
expect(actual.map((artifact) => artifact.content)).toEqual(
|
||||
expected.map((artifact) => artifact.content)
|
||||
)
|
||||
})
|
||||
|
||||
it('pins guide sources, projections, and embedded output to LF in Git', async () => {
|
||||
const attributes = await readFile(path.join(projectDir, '.gitattributes'), 'utf8')
|
||||
expect(normalizeMarkdown(attributes)).toContain('/skill-guides/*.md text eol=lf\n')
|
||||
expect(normalizeMarkdown(attributes)).toContain('/skills/*/SKILL.md text eol=lf\n')
|
||||
expect(normalizeMarkdown(attributes)).toContain(
|
||||
'/src/cli/bundled-skill-guides.ts text eol=lf\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports stale outputs and write mode repairs all projections', async () => {
|
||||
const root = await createFixture()
|
||||
const artifacts = await buildArtifacts(root)
|
||||
|
||||
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow(
|
||||
'src/cli/bundled-skill-guides.ts'
|
||||
)
|
||||
await writeArtifacts(artifacts)
|
||||
await expect(verifyArtifacts(artifacts, root)).resolves.toBeUndefined()
|
||||
|
||||
await writeFile(path.join(root, 'skills', 'computer-use', 'SKILL.md'), 'stale\n')
|
||||
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow('skills/computer-use/SKILL.md')
|
||||
})
|
||||
|
||||
it('rejects mismatched source names and ambiguous aliases', async () => {
|
||||
const root = await createFixture()
|
||||
await writeFile(
|
||||
path.join(root, 'skill-guides', 'computer-use.md'),
|
||||
'---\nname: wrong\ndescription: present\n---\n'
|
||||
)
|
||||
await expect(buildArtifacts(root)).rejects.toThrow('declares mismatched name wrong')
|
||||
|
||||
expect(() =>
|
||||
assertAliasContract([
|
||||
{ name: 'first', aliases: ['legacy'] },
|
||||
{ name: 'second', aliases: ['legacy'] }
|
||||
])
|
||||
).toThrow('assigned more than once')
|
||||
expect(() =>
|
||||
assertAliasContract([
|
||||
{ name: 'first', aliases: ['second'] },
|
||||
{ name: 'second', aliases: [] }
|
||||
])
|
||||
).toThrow('collides with canonical name')
|
||||
})
|
||||
})
|
||||
@@ -40,7 +40,7 @@ describe('orca CLI skill guidance', () => {
|
||||
'`task-create` is also forbidden because it records coordinator-owned tracking state'
|
||||
)
|
||||
expect(skill).toContain(
|
||||
'orca worktree create --name <task-name> --no-parent --agent codex --prompt'
|
||||
'ORCA worktree create --name <task-name> --no-parent --agent codex --prompt'
|
||||
)
|
||||
expect(skill).toContain('codex --model gpt-5.5 -c model_reasoning_effort="xhigh"')
|
||||
expect(skill).toContain('wait only for TUI readiness if needed to avoid losing input')
|
||||
|
||||
@@ -7,6 +7,19 @@ const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'))
|
||||
|
||||
describe('Electron runtime package contract', () => {
|
||||
it('keeps shared WebGL atlas invalidation reproducible from vendored source', () => {
|
||||
const patch = readFileSync(
|
||||
join(projectDir, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(patch).toContain('diff --git a/src/Types.ts b/src/Types.ts')
|
||||
expect(patch).toContain('readonly clearModelGeneration: number')
|
||||
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
|
||||
expect(patch).toContain('this.clearModelGeneration++')
|
||||
expect(patch).toContain('this._atlas._clearModelGeneration||0')
|
||||
})
|
||||
|
||||
it('keeps root postinstall as the single Electron binary install owner', () => {
|
||||
expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs')
|
||||
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('electron')
|
||||
@@ -535,6 +548,9 @@ describe('Electron runtime package contract', () => {
|
||||
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
|
||||
'terminal-raw-emoji-table-scroll-restore.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
|
||||
'terminal-webgl-atlas-budget.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain(
|
||||
'terminal-long-table-scroll-restore.spec.ts'
|
||||
)
|
||||
@@ -548,6 +564,8 @@ describe('Electron runtime package contract', () => {
|
||||
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
}
|
||||
expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts')
|
||||
expect(pullRequestPaths).toContain('tests/e2e/terminal-webgl-atlas-budget.spec.ts')
|
||||
expect(pullRequestPaths).toContain('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
|
||||
expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md')
|
||||
expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**')
|
||||
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, realpath, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const ENTRY_PATH = resolve('out/main/parcel-watcher-process-entry.js')
|
||||
const POOL_SOURCE = resolve('src/main/ipc/runtime-watcher-process-pool.ts')
|
||||
const FAILURE_SOURCE = resolve('src/main/ipc/parcel-watcher-process-failure.ts')
|
||||
const REGISTRY_SOURCE = resolve('src/main/ipc/parcel-watcher-child-registry.ts')
|
||||
const MAX_CHILD_RSS_KIB = 128 * 1024
|
||||
const MAX_CHILD_CPU_PERCENT = 50
|
||||
const MAX_QUARANTINE_RSS_KIB = 512 * 1024
|
||||
const MAX_QUARANTINE_CPU_PERCENT = 100
|
||||
const PHYSICAL_CHILD_CAP = 8
|
||||
const WAIT_TIMEOUT_MS = 15_000
|
||||
const execFileAsync = promisify(execFile)
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(ENTRY_PATH)) {
|
||||
throw new Error(`Missing ${ENTRY_PATH}; run pnpm run build:electron-vite first`)
|
||||
}
|
||||
|
||||
const createdRoots = []
|
||||
const exactPids = new Set()
|
||||
let bundleDir
|
||||
let pool
|
||||
let result
|
||||
try {
|
||||
bundleDir = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-resource-'))
|
||||
const { RuntimeWatcherProcessPool, WatcherProcessFailure, reserveWatcherChild } =
|
||||
await loadProbe(bundleDir)
|
||||
const roots = []
|
||||
for (let index = 0; index < 5; index++) {
|
||||
const createdRoot = await mkdtemp(join(tmpdir(), `orca-watcher-resource-${index}-`))
|
||||
createdRoots.push(createdRoot)
|
||||
roots.push(await realpath(createdRoot))
|
||||
}
|
||||
|
||||
pool = new RuntimeWatcherProcessPool()
|
||||
await Promise.all(roots.map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {})))
|
||||
const healthyPids = activeChildPids(pool)
|
||||
assertPidCount(healthyPids, 1, 'five healthy roots')
|
||||
healthyPids.forEach((pid) => exactPids.add(pid))
|
||||
const healthyResources = await sampleResources(healthyPids)
|
||||
assertResourceBudget(healthyResources, 'healthy pool', {
|
||||
maxTotalRssKiB: MAX_CHILD_RSS_KIB,
|
||||
maxTotalCpuPercent: MAX_CHILD_CPU_PERCENT
|
||||
})
|
||||
assertRegistryCapacity(reserveWatcherChild, 7)
|
||||
|
||||
const sharedSlot = [...pool.activeSlots][0]
|
||||
const failure = new WatcherProcessFailure(
|
||||
'resource probe synthetic shared-shard fault',
|
||||
'supervisor',
|
||||
'supervisor_crash_fuse'
|
||||
)
|
||||
for (const record of sharedSlot.supervisor.records.values()) {
|
||||
record.hooks.onTerminalError?.(failure)
|
||||
}
|
||||
await waitFor(() => !isPidAlive(healthyPids[0]), 'healthy child exit')
|
||||
|
||||
await Promise.all(
|
||||
roots.slice(0, 4).map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {}))
|
||||
)
|
||||
const quarantinePids = activeChildPids(pool)
|
||||
assertPidCount(quarantinePids, 4, 'four fault-quarantine roots')
|
||||
quarantinePids.forEach((pid) => exactPids.add(pid))
|
||||
const quarantineResources = await sampleResources(quarantinePids)
|
||||
assertResourceBudget(quarantineResources, 'quarantine pool', {
|
||||
maxTotalRssKiB: MAX_QUARANTINE_RSS_KIB,
|
||||
maxTotalCpuPercent: MAX_QUARANTINE_CPU_PERCENT
|
||||
})
|
||||
// Four live quarantine children leave four reservations before the global
|
||||
// eight-physical-child ceiling rejects the next launch.
|
||||
assertRegistryCapacity(reserveWatcherChild, 4)
|
||||
|
||||
result = {
|
||||
hostPid: process.pid,
|
||||
healthy: summarizeResources(healthyResources),
|
||||
quarantine: summarizeResources(quarantineResources),
|
||||
physicalChildCap: PHYSICAL_CHILD_CAP,
|
||||
budgets: {
|
||||
maxChildRssKiB: MAX_CHILD_RSS_KIB,
|
||||
maxChildCpuPercent: MAX_CHILD_CPU_PERCENT,
|
||||
maxQuarantineRssKiB: MAX_QUARANTINE_RSS_KIB,
|
||||
maxQuarantineCpuPercent: MAX_QUARANTINE_CPU_PERCENT,
|
||||
maxPhysicalRssKiB: MAX_CHILD_RSS_KIB * PHYSICAL_CHILD_CAP,
|
||||
maxPhysicalCpuPercent: MAX_CHILD_CPU_PERCENT * PHYSICAL_CHILD_CAP
|
||||
},
|
||||
exactPidCleanup: true
|
||||
}
|
||||
} finally {
|
||||
pool?.dispose()
|
||||
await Promise.all(
|
||||
[...exactPids].map((pid) => waitFor(() => !isPidAlive(pid), `watcher PID ${pid} cleanup`))
|
||||
)
|
||||
await Promise.all([
|
||||
...createdRoots.map((rootPath) => rm(rootPath, { recursive: true, force: true })),
|
||||
bundleDir ? rm(bundleDir, { recursive: true, force: true }) : Promise.resolve()
|
||||
])
|
||||
}
|
||||
console.log(JSON.stringify(result))
|
||||
}
|
||||
|
||||
async function loadProbe(bundleDir) {
|
||||
const outfile = join(bundleDir, 'watcher-resource-probe.cjs')
|
||||
await build({
|
||||
stdin: {
|
||||
contents: [
|
||||
`export { RuntimeWatcherProcessPool } from ${JSON.stringify(POOL_SOURCE)}`,
|
||||
`export { WatcherProcessFailure } from ${JSON.stringify(FAILURE_SOURCE)}`,
|
||||
`export { reserveWatcherChild } from ${JSON.stringify(REGISTRY_SOURCE)}`
|
||||
].join('\n'),
|
||||
resolveDir: process.cwd(),
|
||||
sourcefile: 'runtime-file-watcher-resource-probe-entry.ts',
|
||||
loader: 'ts'
|
||||
},
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
outfile,
|
||||
external: ['@parcel/watcher', 'electron'],
|
||||
logLevel: 'silent'
|
||||
})
|
||||
return require(outfile)
|
||||
}
|
||||
|
||||
function activeChildPids(pool) {
|
||||
return [...pool.activeSlots]
|
||||
.map((slot) => slot.supervisor.child?.pid)
|
||||
.filter((pid) => Number.isInteger(pid))
|
||||
}
|
||||
|
||||
function assertPidCount(pids, expected, label) {
|
||||
if (new Set(pids).size !== expected || pids.some((pid) => !isPidAlive(pid))) {
|
||||
throw new Error(
|
||||
`${label} used ${new Set(pids).size} live watcher children; expected ${expected}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRegistryCapacity(reserveWatcherChild, availableReservations) {
|
||||
const releases = []
|
||||
try {
|
||||
for (let index = 0; index < availableReservations; index++) {
|
||||
const release = reserveWatcherChild()
|
||||
if (!release) {
|
||||
throw new Error(`Watcher child registry rejected reservation ${index + 1}`)
|
||||
}
|
||||
releases.push(release)
|
||||
}
|
||||
const unexpectedReservation = reserveWatcherChild()
|
||||
if (unexpectedReservation) {
|
||||
unexpectedReservation()
|
||||
throw new Error('Watcher child registry exceeded its global physical-process cap')
|
||||
}
|
||||
} finally {
|
||||
releases.forEach((release) => release())
|
||||
}
|
||||
}
|
||||
|
||||
async function sampleResources(pids) {
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
|
||||
return Promise.all(pids.map((pid) => sampleProcess(pid)))
|
||||
}
|
||||
|
||||
async function sampleProcess(pid) {
|
||||
if (process.platform === 'win32') {
|
||||
const command =
|
||||
`$p=Get-Process -Id ${pid};` +
|
||||
`[pscustomobject]@{rssKiB=[math]::Round($p.WorkingSet64/1KB);cpuSeconds=$p.CPU}|ConvertTo-Json -Compress`
|
||||
const first = JSON.parse(
|
||||
(await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim()
|
||||
)
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
|
||||
const second = JSON.parse(
|
||||
(await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim()
|
||||
)
|
||||
return {
|
||||
pid,
|
||||
rssKiB: second.rssKiB,
|
||||
cpuPercent: Math.max(0, (second.cpuSeconds - first.cpuSeconds) * 400)
|
||||
}
|
||||
}
|
||||
const { stdout } = await execFileAsync('ps', ['-o', 'rss=', '-o', '%cpu=', '-p', String(pid)], {
|
||||
env: { ...process.env, LC_ALL: 'C' }
|
||||
})
|
||||
const [rssKiB, cpuPercent] = stdout.trim().split(/\s+/).map(Number)
|
||||
return { pid, rssKiB, cpuPercent }
|
||||
}
|
||||
|
||||
function assertResourceBudget(resources, label, { maxTotalRssKiB, maxTotalCpuPercent }) {
|
||||
for (const resource of resources) {
|
||||
if (!Number.isFinite(resource.rssKiB) || resource.rssKiB <= 0) {
|
||||
throw new Error(`Missing RSS evidence for watcher PID ${resource.pid}`)
|
||||
}
|
||||
if (resource.rssKiB > MAX_CHILD_RSS_KIB) {
|
||||
throw new Error(
|
||||
`Watcher PID ${resource.pid} RSS ${resource.rssKiB} KiB exceeded ${MAX_CHILD_RSS_KIB} KiB`
|
||||
)
|
||||
}
|
||||
if (!Number.isFinite(resource.cpuPercent) || resource.cpuPercent < 0) {
|
||||
throw new Error(`Missing CPU evidence for watcher PID ${resource.pid}`)
|
||||
}
|
||||
if (resource.cpuPercent > MAX_CHILD_CPU_PERCENT) {
|
||||
throw new Error(
|
||||
`Watcher PID ${resource.pid} CPU ${resource.cpuPercent}% exceeded ${MAX_CHILD_CPU_PERCENT}%`
|
||||
)
|
||||
}
|
||||
}
|
||||
const summary = summarizeResources(resources)
|
||||
if (summary.totalRssKiB > maxTotalRssKiB) {
|
||||
throw new Error(
|
||||
`${label} RSS ${summary.totalRssKiB} KiB exceeded aggregate budget ${maxTotalRssKiB} KiB`
|
||||
)
|
||||
}
|
||||
if (summary.sampledCpuPercent > maxTotalCpuPercent) {
|
||||
throw new Error(
|
||||
`${label} CPU ${summary.sampledCpuPercent}% exceeded aggregate budget ${maxTotalCpuPercent}%`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeResources(resources) {
|
||||
return {
|
||||
childCount: resources.length,
|
||||
pids: resources.map(({ pid }) => pid),
|
||||
totalRssKiB: resources.reduce((total, { rssKiB }) => total + rssKiB, 0),
|
||||
sampledCpuPercent: resources.reduce((total, { cpuPercent }) => total + cpuPercent, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function isPidAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(predicate, label) {
|
||||
const deadline = Date.now() + WAIT_TIMEOUT_MS
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Timed out waiting for ${label}`)
|
||||
}
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25))
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.0m">
|
||||
<title>downloads: 5.0m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.7m">
|
||||
<title>downloads: 5.7m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.0m</text>
|
||||
<text x="93.5" y="14">5.0m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.7m</text>
|
||||
<text x="93.5" y="14">5.7m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -17,7 +17,7 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>El orquestador de IA para desarrolladores 100x.</strong><br/>
|
||||
Ejecuta Claude Code, OpenClaude, Codex u OpenCode en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar.
|
||||
Ejecuta Codex, Claude Code, OpenCode u Pi en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar.
|
||||
</p>
|
||||
|
||||
<h3 align="center"><a href="https://onorca.dev/download"><ins>Descargar Orca</ins></a></h3>
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>100x ビルダーのための AI オーケストレーター。</strong><br/>
|
||||
Claude Code、OpenClaude、Codex、OpenCode を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
|
||||
Codex、Claude Code、OpenCode、Pi を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
|
||||
</p>
|
||||
|
||||
<h3 align="center"><a href="https://onorca.dev/download"><ins>Orca をダウンロード</ins></a></h3>
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>100x 빌더를 위한 AI 오케스트레이터.</strong><br/>
|
||||
Claude Code, OpenClaude, Codex, OpenCode를 나란히 실행하세요 — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
|
||||
Codex, Claude Code, OpenCode, Pi를 나란히 실행하세요. — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
|
||||
</p>
|
||||
|
||||
<h3 align="center"><a href="https://onorca.dev/download"><ins>Orca 다운로드</ins></a></h3>
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -164,7 +164,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
|
||||
- **[풍부한 리포지토리 미리보기](https://www.onorca.dev/docs/editing/markdown)** — Markdown, 이미지, PDF, 리포지토리 문서를 워크스페이스에서 미리 볼 수 있습니다.
|
||||
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 워크플로에 실제 상호작용이 필요할 때 에이전트가 데스크톱 앱과 화면에 보이는 UI를 직접 조작하게 하세요.
|
||||
- **[알림과 읽지 않음 상태](https://www.onorca.dev/docs/notifications)** — 에이전트가 완료되거나 주의가 필요할 때 알림을 받고, 스레드를 읽지 않음으로 표시해 나중에 다시 확인하세요.
|
||||
- **그리고 훨씬 더 많은 기능** — 매일 출시하기 때문에 이 목록은 항상 뒤처져 있습니다. 진짜 기능 목록은 [체인지로그](https://github.com/stablyai/orca/releases)입니다.
|
||||
- **그리고 훨씬 더 많은 기능** — 새로운 기능이 매일 출시되므로 이 목록은 늘 한 발 늦습니다. 진짜 기능은 [체인지로그](https://github.com/stablyai/orca/releases)에서 확인하세요.
|
||||
|
||||
---
|
||||
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -237,7 +237,7 @@ yay -S stably-orca-bin
|
||||
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
|
||||
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
|
||||
- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
|
||||
- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일의 릴리스를 따라와 주세요.
|
||||
- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>面向 100x 构建者的 AI 编排器。</strong><br/>
|
||||
并排运行 Claude Code、OpenClaude、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
|
||||
并排运行 Codex、Claude Code、OpenCode 或 Pi — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
|
||||
</p>
|
||||
|
||||
<h3 align="center"><a href="https://onorca.dev/download"><ins>下载 Orca</ins></a></h3>
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,7 +35,7 @@ authority.
|
||||
|
||||
| Capability | Preferred behavior | Compatibility behavior |
|
||||
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `worktree-list-z` | NUL-delimited worktree paths | Line-block parser for Git before `worktree list -z` |
|
||||
| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 |
|
||||
| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
|
||||
| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
|
||||
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Kerberos / GSSAPI SSH Authentication
|
||||
|
||||
Orca's ssh2-based SSH stack cannot speak `gssapi-with-mic` — the ssh2 library
|
||||
has no GSSAPI userauth support, and adding it would mean forking ssh2's
|
||||
protocol layer plus shipping the `kerberos` native module (MIT krb5 / Heimdal /
|
||||
Windows SSPI) as a prebuilt Electron dependency on three platforms. Instead,
|
||||
hosts that need Kerberos ride the existing **system OpenSSH transport** — the
|
||||
same parallel transport already used for `ProxyCommand`/`ProxyJump` hosts —
|
||||
which delegates GSSAPI, ticket lookup (`kinit` cache, Windows domain logon),
|
||||
and credential delegation to the platform's own `ssh` binary on macOS, Linux,
|
||||
and Windows (Win32-OpenSSH uses SSPI).
|
||||
|
||||
## Transport selection
|
||||
|
||||
Two tiers, deliberately asymmetric because RHEL-family distros ship
|
||||
`GSSAPIAuthentication yes` in the global `/etc/ssh/ssh_config`, which makes
|
||||
`ssh -G` report GSSAPI enabled for *every* host:
|
||||
|
||||
1. **Proactive** — a target with `gssapiAuthentication: true` (parsed from an
|
||||
explicit `GSSAPIAuthentication yes` in the host's `~/.ssh/config` block, or
|
||||
set on the target directly) tries the system-ssh probe first. If that fails
|
||||
(e.g. no ticket), the connect falls through to the normal ssh2 key/agent
|
||||
path where passphrase/password prompts remain available — OpenSSH semantics
|
||||
allow other auth methods alongside GSSAPI.
|
||||
2. **Auth-failure fallback** — when ssh2 exhausts key/agent auth and the
|
||||
`ssh -G`-resolved config enables GSSAPI (`isGssapiSystemSshFallbackCandidate`
|
||||
in `ssh-connection-utils.ts`), the connection retries over system ssh
|
||||
*before* prompting the user for credentials. Kerberos-only hosts on
|
||||
distro-default configs connect this way; hosts where keys work never leave
|
||||
the ssh2 path.
|
||||
|
||||
Manual (non-ssh-config) targets flagged for GSSAPI get an explicit
|
||||
`-o GSSAPIAuthentication=yes` in `system-ssh-args.ts`; config-backed targets
|
||||
inherit the option from their own `Host` block since the system binary re-reads
|
||||
ssh_config.
|
||||
|
||||
Both tiers work headless (`orca serve`): the system-ssh probe needs no
|
||||
credential callbacks, and GSSAPI itself is non-interactive once a ticket
|
||||
exists.
|
||||
@@ -0,0 +1,564 @@
|
||||
# Terminal Session Ownership and Teardown
|
||||
|
||||
**Status:** Incident fix and pane-authority transfer implemented
|
||||
**Date:** 2026-07-13
|
||||
**Incident:** [`../../pty-exhaustion-agent-session-leak.md`](../../pty-exhaustion-agent-session-leak.md)
|
||||
**Related contracts:** [`terminal-model-view-contract.md`](./terminal-model-view-contract.md),
|
||||
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Terminal process lifetime must be owned by explicit product intent, never by
|
||||
whether a React view happens to be mounted.
|
||||
|
||||
Orca currently preserves a PTY when a terminal view detaches, which is correct
|
||||
for view parking, tab moves, renderer reload, and warm reattach. It also relies
|
||||
on `TerminalPane` unmount to destroy a PTY after a tab is closed. Hidden-view
|
||||
parking invalidated that implicit invariant: a parked tab has no mounted pane,
|
||||
so closing it removes renderer state and observers without terminating the PTY.
|
||||
Agent resume records survive the same close and later interpret the missing pane
|
||||
as failed recovery, launching the deliberately closed session again.
|
||||
|
||||
This design establishes one authoritative lifecycle contract:
|
||||
|
||||
- **Close tab or pane:** permanently retire the surface, terminate every PTY it
|
||||
owns, and revoke every resume record for its panes.
|
||||
- **Detach or park:** remove only the view connection; preserve the PTY and its
|
||||
durable identity.
|
||||
- **Sleep:** intentionally checkpoint resumable agents, terminate the PTYs, and
|
||||
preserve only the checkpoint needed for wake.
|
||||
- **Quit or reload:** follow the configured persistence policy; warm detach is
|
||||
allowed, but it is not a tab close.
|
||||
|
||||
The immediate implementation makes the existing terminal-tab state close the
|
||||
authoritative compatibility boundary for explicit retirement while adding an
|
||||
explicit natural-exit reason for callers that are only reconciling an already
|
||||
dead PTY. A follow-up separates the command and reducer APIs once all direct
|
||||
store callers have migrated.
|
||||
|
||||
## Implementation status
|
||||
|
||||
The incident patch ships the retirement planner, provider-aware close routing,
|
||||
unified-only retirement, parked watcher/candidate disposal, resume-authority
|
||||
revocation, explicit natural-exit handling, late-binding rejection, and direct
|
||||
background-launch ownership checks. It also adds exact pane retirement and a
|
||||
persisted physical-to-owner pane-key alias for detach, plus a live Electron
|
||||
parked-close gate. Focused tests cover local, SSH, split, parked, shared,
|
||||
paired-host, ordinary runtime, pane-transfer, and late-spawn paths.
|
||||
|
||||
Origin-aware defensive expiry remains a follow-up. Immediate deletion on
|
||||
explicit close fixes the incident without applying a wall-clock policy that
|
||||
could invalidate intentional long-lived `worktree-sleep` checkpoints.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Closing a terminal tab submits retirement for all exclusively owned local,
|
||||
daemon, WSL, SSH, or runtime PTYs, including every split pane, whether its
|
||||
views are mounted or parked.
|
||||
2. Closing a terminal pane terminates only that pane's PTY; detaching a pane to
|
||||
another tab preserves its PTY, hook identity, and resume authority under the
|
||||
new owning pane.
|
||||
3. Closing a tab permanently removes its agent resume authority. App restart,
|
||||
worktree activation, mobile wake, and periodic capture must not resurrect it.
|
||||
4. Parking, tab-group moves, renderer reload, and warm reattach keep their
|
||||
existing process-preservation behavior.
|
||||
5. Remote-runtime and web-session surfaces are terminated by their owning host,
|
||||
not by sending a host-scoped ID to the local PTY provider.
|
||||
6. Teardown is idempotent and safe under duplicate close requests, late PTY
|
||||
exits, late agent-hook events, in-flight spawns, and provider disconnects.
|
||||
7. A future bookkeeping regression cannot grow without bound: unowned defensive
|
||||
resume records have an origin-aware expiry, and provider-owned detached
|
||||
sessions gain a bounded orphan policy in the daemon-lease phase.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing terminal output, snapshot, replay, query-response, or hidden-delivery
|
||||
behavior.
|
||||
- Killing processes that deliberately daemonize away from a plain user
|
||||
terminal's process group (nohup-style survivors remain user intent there).
|
||||
For **agent sessions**, close/kill additionally terminates the snapshotted
|
||||
descendant tree — including detached-pgid children the PTY's SIGHUP cannot
|
||||
reach — via `pty-descendant-termination.ts` (bounded fresh snapshot with
|
||||
same-turn coalescing, SIGTERM, grace window, then identity-safe SIGKILL).
|
||||
Completed process tables are never reused as signal targets, and identity
|
||||
checks use C-locale timestamps from the source scan. Later requests start a
|
||||
fresh same-turn-coalesced successor inside their own deadline instead of
|
||||
waiting behind older scans. A session is marked as terminating before capture
|
||||
and keeps request ownership through natural exit, so reattach, duplicate kill,
|
||||
and graceful-to-immediate upgrade cannot race the snapshot; descendant signals
|
||||
still require the exact root session/handle to be live. Windows and SSH-hosted
|
||||
PTYs keep the previous foreground-tree contract for now.
|
||||
- Changing agent-provider resume commands or permission flags.
|
||||
- Making a UI close wait for a remote process to exit before the tab disappears.
|
||||
- Replacing worktree sleep with tab close. Sleep remains resumable by design.
|
||||
- Claiming that a disconnected SSH relay process is already dead when main can
|
||||
only tombstone its app-scoped ID. Durable relay-side kill-on-reconnect is a
|
||||
follow-up.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Surface:** a terminal tab or a pane within a split terminal tab.
|
||||
- **View:** a renderer xterm or pane-less parked watcher observing a surface.
|
||||
- **Session:** the provider-owned PTY identified by a PTY ID.
|
||||
- **Resume authority:** a `sleepingAgentSessionsByPaneKey` record that permits
|
||||
Orca to launch an agent-provider resume command.
|
||||
- **Retire:** permanently close a surface and revoke its process and resume
|
||||
ownership.
|
||||
- **Detach:** disconnect a view while preserving the session for reattachment.
|
||||
|
||||
## Open-source prior art
|
||||
|
||||
The lifecycle distinction is established in mature terminal implementations:
|
||||
|
||||
- VS Code's terminal instance disposal calls process-manager disposal, and the
|
||||
process manager marks the process `KilledByUser` before shutdown. Its separate
|
||||
`detachFromProcess` path deliberately clears the client reference without
|
||||
shutting down the process. It also shuts down a process whose asynchronous
|
||||
creation completes after the manager was disposed. See
|
||||
[`terminalInstance.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts#L1287-L1325)
|
||||
and
|
||||
[`terminalProcessManager.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts#L207-L239).
|
||||
- tmux exposes the same contract as separate commands: `detach-client` removes
|
||||
a client while leaving the session intact, whereas `kill-session` destroys
|
||||
the session and its windows. See
|
||||
[`tmux.1`](https://github.com/tmux/tmux/blob/42b3ea0d7411acb4cd0357a3c2829d986b455918/tmux.1#L1177-L1218).
|
||||
|
||||
Orca needs the same semantic split, extended across local, WSL, SSH, persistent
|
||||
daemon, and remote-runtime ownership. The important precedent is the explicit
|
||||
intent boundary, not a particular framework or process API.
|
||||
|
||||
## Non-negotiable invariants
|
||||
|
||||
1. View lifetime and process lifetime are independent. Mount and unmount may
|
||||
attach or detach observers; they do not decide whether a session should live.
|
||||
2. Every live PTY has an owning surface or an explicit bounded detached state.
|
||||
3. A user close is terminal: once accepted, no status event, session snapshot,
|
||||
or runtime replay may recreate the closed surface or resume its agent.
|
||||
4. A tab's retirement candidates are the union of its live index, tab row,
|
||||
persisted split layout, last-known relay ID, deferred SSH ID, and pending
|
||||
reconnect ID. A candidate is killable only when no other live surface claims
|
||||
it.
|
||||
5. Provider routing follows the execution owner:
|
||||
- local/daemon/WSL and app-scoped SSH IDs use `pty.kill`;
|
||||
- `remote:` runtime mirror IDs are never passed to the local PTY provider;
|
||||
- paired web tabs are closed through the host session RPC.
|
||||
6. Teardown requests are idempotent. Repeated kill, exit-after-kill, and
|
||||
close-after-exit must converge on the same empty state.
|
||||
7. Resume-record validity is origin-aware. Intentional `worktree-sleep` records
|
||||
are not expired by agent-status freshness; unowned defensive `live` and
|
||||
`quit` records use a documented decision-time horizon.
|
||||
8. Natural process exit and explicit user retirement are distinct intents.
|
||||
Reconciliation after an exit must not silently adopt destructive user-close
|
||||
semantics.
|
||||
|
||||
## Lifecycle matrix
|
||||
|
||||
| Intent | View | PTY | Resume record | Persistent tab state |
|
||||
| --------------------- | ------------------------------------------ | ---------------------------------- | --------------------------------------------------- | -------------------- |
|
||||
| Switch tabs/worktrees | hidden or parked | keep | keep/update | keep |
|
||||
| Move pane/tab group | detach, then remount | keep | keep | move |
|
||||
| Renderer reload | detach | keep when warm persistence applies | keep | keep |
|
||||
| App quit | detach or terminate per persistence policy | policy-dependent | capture live agents | keep |
|
||||
| Sleep worktree | unmount | terminate | capture intentional sleep checkpoint | keep identifiers |
|
||||
| Close split pane | destroy | terminate that pane | delete that pane record | remove pane |
|
||||
| Close terminal tab | destroy or already absent | terminate all tab PTYs | delete all tab records | remove tab |
|
||||
| Remove worktree | destroy | terminate all worktree PTYs | delete all worktree records | remove worktree |
|
||||
| PTY exits naturally | disconnect | already dead | clear or retain completed evidence per agent policy | reconcile surface |
|
||||
|
||||
## Current failure
|
||||
|
||||
The current close path deletes `ptyIdsByTabId[tabId]` and the tab layout, then
|
||||
relies on a mounted `TerminalPane` cleanup to call `transport.destroy()`. A
|
||||
parked tab has already run the detach branch and has no mounted cleanup left.
|
||||
`disposeParkedTabWatchers` only unregisters observers. The provider session is
|
||||
therefore alive after Orca discards its last renderer-side owner.
|
||||
|
||||
The same state close drops live agent status but not
|
||||
`sleepingAgentSessionsByPaneKey`. Worktree activation intentionally fresh-resumes
|
||||
records that no preserved pane can own. A deliberately closed pane therefore
|
||||
looks like a failed restore. The existing age check compares `capturedAt` with
|
||||
`updatedAt`; that detects a status that was stale at capture time but never
|
||||
expires a record as wall time advances.
|
||||
|
||||
## Proposed architecture
|
||||
|
||||
### 1. Authoritative tab retirement plan
|
||||
|
||||
Before deleting any tab-scoped state, build a `TerminalTabRetirementPlan` from
|
||||
one store snapshot:
|
||||
|
||||
```ts
|
||||
type TerminalTabRetirementPlan = {
|
||||
tabId: string
|
||||
worktreeId: string | null
|
||||
ptyIds: string[]
|
||||
localOrSshPtyIds: string[]
|
||||
runtimeTerminals: Array<{ environmentId: string; handle: string }>
|
||||
sharedPtyIds: string[]
|
||||
paneKeys: string[]
|
||||
}
|
||||
```
|
||||
|
||||
`ptyIds` is the deduplicated union of:
|
||||
|
||||
- `ptyIdsByTabId[tabId]`;
|
||||
- the tab row's legacy `ptyId`;
|
||||
- every value in `terminalLayoutsByTabId[tabId].ptyIdsByLeafId`;
|
||||
- `lastKnownRelayPtyIdByTabId[tabId]`;
|
||||
- `deferredSshSessionIdsByTabId[tabId]`;
|
||||
- `pendingReconnectPtyIdByTabId[tabId]`.
|
||||
|
||||
This snapshot must be built before any of those maps are pruned. PTY IDs are
|
||||
classified structurally with `parseRemoteRuntimePtyId`, not by assuming a host
|
||||
or platform from the current active workspace. Before shutdown, the planner
|
||||
subtracts IDs referenced by another tab row, live index, split layout, or relay
|
||||
and reconnect map. A partially completed pane move must not let closing the
|
||||
source tab kill the target tab's session.
|
||||
|
||||
The immediate compatibility boundary is `closeTab`, because several production
|
||||
paths still call the store action directly. Its options carry an explicit
|
||||
`reason: 'user' | 'pty-exit' | 'cleanup'`; `user` and `cleanup` retire, while
|
||||
`pty-exit` reconciles an already-dead session without killing siblings or
|
||||
revoking crash-recovery policy by accident. Making only the rendered tab-bar
|
||||
action destructive would leave background launch cleanup, floating terminal,
|
||||
onboarding, runtime notifications, and future direct callers vulnerable.
|
||||
|
||||
### 2. Teardown ordering
|
||||
|
||||
A local tab retirement executes in this order:
|
||||
|
||||
1. Snapshot the retirement plan and its execution-owner classifications.
|
||||
2. Mark the tab recently closed so late agent-hook events cannot reintroduce
|
||||
status or resume authority.
|
||||
3. Dispose the whole tab's parked registry entry and captured candidates, and
|
||||
silence teardown side-effect handlers while preserving deterministic exit
|
||||
observation.
|
||||
4. Submit `pty.kill` for exclusive local or SSH IDs and `terminal.close` for
|
||||
exclusive ordinary runtime handles. A paired host-session close remains
|
||||
owned by `session.tabs.close` and is not duplicated locally.
|
||||
5. Atomically remove the tab, layout/binding maps, agent status, and resume
|
||||
records from renderer state.
|
||||
6. Let subsequent React unmount cleanup run idempotently. It is no longer the
|
||||
process-lifetime authority.
|
||||
|
||||
Steps 3 and 4 are issued before the state loses its IDs. The UI state mutation
|
||||
remains synchronous and does not await provider exit. Completion is observed
|
||||
with `Promise.allSettled` so rejections cannot become unhandled promises. The
|
||||
current Phase 1 fallback for a rejected retirement is provider `listSessions`
|
||||
plus Resource Manager orphan cleanup; durable retry inventory belongs to the
|
||||
main-owned retirement phase.
|
||||
|
||||
Remote-runtime mirrors are different: their `remote:` IDs are excluded from
|
||||
local `pty.kill`. Ordinary runtime terminals retire through `terminal.close`.
|
||||
Paired host-session tabs retire through `session.tabs.close`, which owns the
|
||||
entire host tab graph. The local mirror may be pruned optimistically only after
|
||||
the host close intent is recorded, as it is today.
|
||||
|
||||
### 3. Resume-authority revocation
|
||||
|
||||
Tab retirement removes every sleeping record whose:
|
||||
|
||||
- map key begins with `${tabId}:`; or
|
||||
- record has `tabId === tabId`.
|
||||
|
||||
The explicit `record.tabId` check covers migrated or legacy keys whose key no
|
||||
longer encodes the current tab identity. Sibling tabs and other worktrees retain
|
||||
their records by reference when unchanged.
|
||||
|
||||
The existing recently-closed tab registry remains the short-lived race guard
|
||||
for hook events already in flight. It must prevent those events from creating a
|
||||
new `origin: 'live'` resume record after retirement. Tests must cover both event
|
||||
orders: close then late status, and status queued in the same turn as close.
|
||||
|
||||
Periodic capture iterates only current live status entries. Once close removes
|
||||
the live entry and resume record, the periodic pass has nothing to persist.
|
||||
|
||||
### 4. Origin-aware defensive record expiry
|
||||
|
||||
An unowned defensive `live` or `quit` record may expire when:
|
||||
|
||||
```ts
|
||||
Date.now() - record.capturedAt > DEFENSIVE_AGENT_RESUME_MAX_AGE_MS
|
||||
```
|
||||
|
||||
Intentional `worktree-sleep` records are exempt from the agent-status freshness
|
||||
window. Originless legacy behavior remains unchanged until its migration policy
|
||||
is explicitly chosen. The same validator must run before desktop activation and
|
||||
mobile/background wake so one entry point cannot bypass it.
|
||||
|
||||
Expiry is defense in depth, not the primary close mechanism. A correctly closed
|
||||
record is deleted immediately; it is not retained for 30 minutes.
|
||||
|
||||
### 5. Pane close remains pane-scoped
|
||||
|
||||
`PaneManager.closePane` already distinguishes `reason: 'close'` from
|
||||
`reason: 'detach'`. Preserve that distinction with explicit pane authority:
|
||||
|
||||
- `close` must clear the exact pane's resume authority, add a pane-scoped late
|
||||
event tombstone, and destroy the pane transport;
|
||||
- `detach` must atomically transfer or alias resume ownership because the agent
|
||||
process retains its immutable source `ORCA_PANE_KEY`.
|
||||
|
||||
The last-pane path routes to terminal-tab retirement so it receives the same
|
||||
parked, split, SSH, and resume cleanup behavior as a tab-bar close.
|
||||
|
||||
### 6. Provider-side safety net
|
||||
|
||||
Client correctness is necessary but not sufficient for a daemon deliberately
|
||||
designed to outlive renderer and app processes. Add a follow-up provider-owned
|
||||
orphan policy:
|
||||
|
||||
- track attachments/ownership leases rather than treating `detach` as a no-op;
|
||||
- distinguish warm-reattach grace from ownerless retirement;
|
||||
- reap sessions that have no owner after a bounded grace period;
|
||||
- never idle-reap a session merely because it produces no output;
|
||||
- scope leases to native, WSL, SSH provider, relay connection, or runtime host;
|
||||
- expose orphan count and oldest orphan age in Resource Manager diagnostics.
|
||||
|
||||
This hardening is not required to land the immediate close fix, because a lease
|
||||
protocol changes daemon compatibility and needs its own migration. It is the
|
||||
defense against a future client bookkeeping regression.
|
||||
|
||||
## Provider behavior
|
||||
|
||||
| Provider/session kind | Close operation | Notes |
|
||||
| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Local native | `pty.kill(id)` | Main routes to the owning local/daemon provider. |
|
||||
| Persistent daemon | `pty.kill(id)` | Explicit close overrides warm-reattach persistence. |
|
||||
| Windows ConPTY | `pty.kill(id)` | Provider shutdown owns Windows process-tree semantics. |
|
||||
| WSL | `pty.kill(id)` | Route by PTY ownership; do not infer from path separators. |
|
||||
| SSH | `pty.kill(appScopedId)` | Main resolves the connection-specific SSH provider. |
|
||||
| Disconnected SSH | `pty.kill(appScopedId)` | Tombstones locally and prevents fallback; relay-side death requires reconnect-aware follow-up. |
|
||||
| Ordinary remote runtime | host `terminal.close` | Classify environment and handle from the `remote:` ID. |
|
||||
| Paired web host tab | host `session.tabs.close` | Host owns the complete tab and pane graph. |
|
||||
| Web client local mirror | optimistic prune after close intent | Host snapshot confirms final removal. |
|
||||
|
||||
## Failure and race handling
|
||||
|
||||
### Duplicate close
|
||||
|
||||
The first close snapshots and retires the tab. Later closes find no target and
|
||||
no-op. Provider shutdown is idempotent for an already exited session.
|
||||
|
||||
### PTY exit races close
|
||||
|
||||
Observers are unregistered before shutdown. If an exit event was already
|
||||
queued, the recently-closed tab guard and missing tab make it a no-op. It must
|
||||
not recreate a tab, completion row, resume record, or notification.
|
||||
|
||||
### Spawn completes after close
|
||||
|
||||
The transport already kills a PTY when its spawn resolves after `destroyed` is
|
||||
set, but the two direct background launchers do not use that transport. Each
|
||||
direct launcher must revalidate tab ownership immediately after `pty.spawn` or
|
||||
`terminal.create` resolves and before writing any binding, layout, eager buffer,
|
||||
subscription, or mount request. A retired local or SSH result is killed; a
|
||||
retired runtime result is closed through `terminal.close`.
|
||||
|
||||
### Provider shutdown fails
|
||||
|
||||
The tab remains closed; reappearing would violate user intent. Report the
|
||||
provider class through structured diagnostics and leave the session visible in
|
||||
provider inventory so existing Resource Manager orphan cleanup can retry. A
|
||||
durable pending-retirement inventory is a later main-process API and is not
|
||||
claimed by Phase 1. Do not restore resume authority after a failed terminal
|
||||
kill.
|
||||
|
||||
### Remote host is unavailable
|
||||
|
||||
Keep the local close intent until either a host snapshot confirms removal or the
|
||||
existing intent TTL expires. If it expires, the host remains authoritative and
|
||||
may republish the tab; log the failed close. Never pretend a local mirror prune
|
||||
terminated the host PTY.
|
||||
|
||||
### App exits during close
|
||||
|
||||
Submit shutdown IPC before discarding the only local PTY identifiers. The main
|
||||
process/provider owns completion after accepting the request. A future batched
|
||||
shutdown IPC may return per-ID acceptance, but UI close does not wait for it.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### Phase 1: correctness patch (implemented)
|
||||
|
||||
1. Add a small store-independent terminal-tab retirement planner that collects
|
||||
all six ID sources, classifies execution owners, and excludes IDs shared by
|
||||
another live surface.
|
||||
2. Add an explicit close reason, update natural-exit callers, and make explicit
|
||||
`closeTab` retirement dispose the whole parked-tab registry, submit local,
|
||||
SSH, or runtime shutdown, atomically clear all reconnect maps, and remove
|
||||
tab-owned resume records.
|
||||
3. Add post-await owner checks to both direct background launchers so a late
|
||||
local, SSH, or runtime spawn is terminated before any renderer binding.
|
||||
4. Defer decision-time expiry until unowned defensive `live` or `quit` records
|
||||
can be distinguished without changing intentional `worktree-sleep` or legacy
|
||||
recovery behavior.
|
||||
5. Add focused planner, store, launcher-race, and resume-policy tests. Keep one
|
||||
local Electron parked-close proof as an RC release gate.
|
||||
|
||||
### Phase 2: remaining API cleanup
|
||||
|
||||
1. Rename the state-only reducer primitive so `closeTab` cannot ambiguously mean
|
||||
either UI cleanup or lifecycle teardown.
|
||||
2. Route all user and runtime close call sites through one domain command.
|
||||
3. Keep explicit `detach` and `sleep` APIs; do not encode them as close options.
|
||||
4. Add a development assertion when tab state is removed with provider PTY IDs
|
||||
but no retirement plan.
|
||||
|
||||
Exact pane retirement, persisted detach authority transfer, and unified-only
|
||||
terminal routing landed with Phase 1 because exact-head review found they were
|
||||
required to avoid introducing adjacent close regressions.
|
||||
|
||||
### Phase 3: daemon ownership leases
|
||||
|
||||
1. Define versioned attach/detach/retire lease messages.
|
||||
2. Preserve warm reattach across renderer/app restarts within a bounded grace.
|
||||
3. Reap ownerless sessions and expose pressure telemetry.
|
||||
4. Add compatibility tests across the supported daemon protocol versions and
|
||||
native/WSL/SSH host isolation.
|
||||
|
||||
## Test plan
|
||||
|
||||
### Unit/store tests
|
||||
|
||||
- Closing an active single-pane tab submits exactly its PTY for shutdown and
|
||||
removes all tab state.
|
||||
- Closing a parked tab with no mounted transport still submits its PTY.
|
||||
- Closing a split tab submits every unique PTY found across tab row, index, and
|
||||
layout plus relay/deferred/reconnect maps, including IDs present in only one
|
||||
source.
|
||||
- Duplicate IDs are submitted once by the authoritative retirement plan.
|
||||
- An ID referenced by another live tab is not killed when the source tab closes.
|
||||
- `remote:` IDs are excluded from local PTY shutdown.
|
||||
- App-scoped SSH IDs are included and are not mistaken for remote runtime IDs.
|
||||
- All sleeping records for the tab are removed by key prefix or `record.tabId`;
|
||||
sibling records retain identity.
|
||||
- Periodic capture after close does not recreate the record.
|
||||
- Late agent status after close cannot recreate live status or resume authority.
|
||||
- Closing twice and close-after-exit are no-ops after the first retirement.
|
||||
- A spawn resolving after close kills the newly returned PTY.
|
||||
- A defensive live/quit record at the expiry boundary is retained; one
|
||||
millisecond past it is cleared and never launched, while an older intentional
|
||||
worktree-sleep record still resumes.
|
||||
|
||||
### Renderer integration tests
|
||||
|
||||
- Mounted tab close still destroys its xterm and terminates the PTY.
|
||||
- Parked watcher disposal occurs before or with PTY shutdown and emits no final
|
||||
completion/bell notification.
|
||||
- Closing one split pane preserves the sibling; closing the last pane retires
|
||||
the tab.
|
||||
- Pane-to-tab detach preserves the PTY and resume record.
|
||||
- Pinned-tab cancellation performs no shutdown; confirmed close does.
|
||||
- Bulk close (`close others`, `close right`, kill-all) uses the same semantics.
|
||||
|
||||
### Provider/main tests
|
||||
|
||||
- Local, daemon, WSL, ConPTY, and SSH ownership route explicit close to the
|
||||
correct provider.
|
||||
- Disconnected SSH close tombstones the app-scoped ID and never falls through to
|
||||
a local provider.
|
||||
- Repeated `pty.kill` for the same ID is benign.
|
||||
- Remote-runtime IDs cannot reach the local kill handler from renderer close.
|
||||
|
||||
### Electron end-to-end regression
|
||||
|
||||
1. Create a worktree terminal and start a deterministic long-lived child.
|
||||
2. Switch worktrees and wait until the source worktree is cold-parked.
|
||||
3. Close the parked tab.
|
||||
4. Assert its exact PTY disappears from `pty:listSessions` and the child exits.
|
||||
5. Restart/reload Orca and activate the worktree.
|
||||
6. Assert no terminal or agent resume command is recreated for the closed pane.
|
||||
7. Repeat with two split panes and with an SSH fixture where CI supports it.
|
||||
|
||||
### Soak and pressure gate
|
||||
|
||||
Run repeated create, park, close, reload, and reopen cycles. App-owned PTY count
|
||||
must return to baseline after each cycle and remain bounded over the run. Record
|
||||
the peak and final provider session count in CI artifacts.
|
||||
|
||||
## Observability
|
||||
|
||||
Emit a synchronous scheduled summary and an asynchronous completion summary:
|
||||
|
||||
- tab/worktree identifiers in hashed or existing diagnostic form;
|
||||
- number of PTY IDs discovered by each source;
|
||||
- provider classification counts;
|
||||
- submitted count, then fulfilled/rejected counts after `Promise.allSettled`;
|
||||
- number of resume records removed;
|
||||
- whether the tab was parked or mounted when retired.
|
||||
|
||||
After the daemon-lease phase, Resource Manager should distinguish:
|
||||
|
||||
- attached sessions;
|
||||
- warm-detached sessions eligible for reattach;
|
||||
- ownerless/orphan sessions;
|
||||
- pending/failed explicit retirement.
|
||||
|
||||
Alert locally before app-owned PTYs approach platform pressure. The warning is a
|
||||
defense, not a substitute for lifecycle correctness.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Land the correctness patch without a feature flag. User close semantics are
|
||||
restorative behavior, not an experiment.
|
||||
2. Keep hidden-view parking enabled; disabling it masks the ownership bug and
|
||||
forfeits its memory benefit.
|
||||
3. Run focused unit/integration tests plus the live parked-close Electron gate.
|
||||
4. Cut an RC and soak create/close/restart cycles while monitoring provider and
|
||||
OS PTY counts.
|
||||
5. Ship daemon leases separately behind protocol compatibility and telemetry.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Disable hidden-view parking
|
||||
|
||||
This restores the old accidental unmount behavior at significant memory cost
|
||||
and leaves every other state-only close path fragile. It does not repair resume
|
||||
records or daemon ownership.
|
||||
|
||||
### Kill only from `TerminalPane` cleanup
|
||||
|
||||
There is intentionally no pane during parking and some headless/runtime flows.
|
||||
View cleanup cannot be process authority.
|
||||
|
||||
### Clear only resume records
|
||||
|
||||
This prevents resurrection but still leaks the live PTY until app/daemon exit.
|
||||
|
||||
### Kill only the tab row's `ptyId`
|
||||
|
||||
Split panes and partially reconciled layouts can own multiple IDs. The full
|
||||
union is required.
|
||||
|
||||
### Rely only on a daemon idle timeout
|
||||
|
||||
Interactive agents can be legitimately idle for hours. Reaping by output
|
||||
idleness loses user work; ownership, not activity, is the safe signal.
|
||||
|
||||
### Await every provider exit before hiding the tab
|
||||
|
||||
Remote and disconnected providers can take seconds or fail. UI retirement must
|
||||
be immediate once the user confirms; provider completion is asynchronous and
|
||||
observable.
|
||||
|
||||
## Phase 1 acceptance criteria
|
||||
|
||||
- A confirmed terminal-tab close submits retirement for every exclusively owned
|
||||
session Orca can currently reach and reports rejected requests without an
|
||||
unhandled promise.
|
||||
- A closed agent session is never resumed by restart, activation, or mobile wake.
|
||||
- Parking, move/detach, sleep/wake, and warm app reattach retain their documented
|
||||
behavior.
|
||||
- Split, local, SSH, and remote-runtime ownership are covered by deterministic
|
||||
tests; Windows/WSL process-tree behavior remains a live-platform follow-up.
|
||||
- PTY counts remain bounded across the soak scenario.
|
||||
- No process-lifetime decision depends on React component mount state.
|
||||
|
||||
## Full-contract follow-ups
|
||||
|
||||
- Durable main-owned retirement intent and bounded retry inventory.
|
||||
- Disconnected SSH relay kill-on-reconnect.
|
||||
- Serializable daemon attachment leases with protocol-version fallback.
|
||||
- Positive ownership validation for late hook events beyond the bounded recent-
|
||||
close registry.
|
||||
+2
-2
@@ -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,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 }} />
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store'
|
||||
import {
|
||||
displayHostEndpoint,
|
||||
endpointPort,
|
||||
endpointScheme,
|
||||
normalizeHostEndpoint
|
||||
} from '../../../src/transport/host-endpoint'
|
||||
import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context'
|
||||
import type { HostProfile } from '../../../src/transport/types'
|
||||
|
||||
export default function EditHostScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { hostId } = useLocalSearchParams<{ hostId: string }>()
|
||||
const primeHosts = usePrimeHosts()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
|
||||
const [host, setHost] = useState<HostProfile | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [address, setAddress] = useState('')
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Why: setSaving is async, so a second trigger before the re-render could
|
||||
// still read stale state and re-enter handleSave; the ref closes that race.
|
||||
const savingRef = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!hostId) {
|
||||
setLoadError('Missing host.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const hosts = await loadHosts()
|
||||
const found = hosts.find((h) => h.id === hostId) ?? null
|
||||
if (!found) {
|
||||
setLoadError('This host was removed from this phone.')
|
||||
setHost(null)
|
||||
return
|
||||
}
|
||||
setHost(found)
|
||||
setName(found.name)
|
||||
setAddress(displayHostEndpoint(found.endpoint))
|
||||
setLoadError(null)
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : 'Failed to load host.')
|
||||
setHost(null)
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const fallbackPort = host ? endpointPort(host.endpoint) : undefined
|
||||
const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws'
|
||||
|
||||
const normalizedEndpoint = useMemo(
|
||||
() => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }),
|
||||
[address, fallbackPort, fallbackScheme]
|
||||
)
|
||||
|
||||
const nameTrimmed = name.trim()
|
||||
const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name
|
||||
const endpointChanged =
|
||||
host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint
|
||||
const canSave =
|
||||
host != null &&
|
||||
nameTrimmed.length > 0 &&
|
||||
normalizedEndpoint.ok &&
|
||||
(nameChanged || endpointChanged) &&
|
||||
!saving
|
||||
|
||||
async function handleSave() {
|
||||
if (!host || !hostId || savingRef.current) {
|
||||
return
|
||||
}
|
||||
const nextName = name.trim()
|
||||
if (!nextName) {
|
||||
setSaveError('Enter a name.')
|
||||
return
|
||||
}
|
||||
if (!normalizedEndpoint.ok) {
|
||||
setSaveError(normalizedEndpoint.error)
|
||||
return
|
||||
}
|
||||
|
||||
const willRename = nextName !== host.name
|
||||
const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint
|
||||
if (!willRename && !willUpdateEndpoint) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
|
||||
savingRef.current = true
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
// Why: a single mutateStoredHosts pass so name + endpoint commit
|
||||
// atomically — a mid-save failure can never persist one without the
|
||||
// other, and a host removed mid-edit throws instead of no-oping.
|
||||
await updateHostNameAndEndpoint(host.id, {
|
||||
...(willRename ? { name: nextName } : {}),
|
||||
...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {})
|
||||
})
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save host.')
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: the write already committed above; a re-prime failure here
|
||||
// must not be reported as a save failure — the next loadHosts() call
|
||||
// elsewhere in the app picks up the fresh state regardless.
|
||||
const hosts = await loadHosts()
|
||||
primeHosts(hosts)
|
||||
} catch {
|
||||
// best-effort re-prime; persisted data is unaffected
|
||||
}
|
||||
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
router.back()
|
||||
|
||||
if (willUpdateEndpoint) {
|
||||
// Why: reconnect is a follow-on side effect of a save that already
|
||||
// committed — its failure or a hang must not be reported as a save
|
||||
// failure or block navigating back.
|
||||
void forceReconnectHost(host.id).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Edit host</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.saveButton,
|
||||
(!canSave || pressed) && styles.saveButtonDisabled
|
||||
]}
|
||||
onPress={() => void handleSave()}
|
||||
disabled={!canSave}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Save host"
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator size="small" color={colors.bgBase} />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>Save</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{loadError ? (
|
||||
<View style={styles.errorState}>
|
||||
<Text style={styles.errorText}>{loadError}</Text>
|
||||
<Pressable style={styles.secondaryButton} onPress={() => router.back()}>
|
||||
<Text style={styles.secondaryButtonText}>Go back</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : !host ? (
|
||||
<View style={styles.loadingState}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
</View>
|
||||
) : (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.form, { paddingBottom: insets.bottom + spacing.xl }]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.help}>
|
||||
Change the display name or connection address. Address edits only switch where this
|
||||
phone connects — they do not re-pair. Use this when the same desktop is reachable at a
|
||||
different IP (for example home LAN vs Tailscale).
|
||||
</Text>
|
||||
|
||||
<Text style={styles.label}>Name</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
accessibilityLabel="Name"
|
||||
value={name}
|
||||
onChangeText={(value) => {
|
||||
setName(value)
|
||||
setSaveError(null)
|
||||
}}
|
||||
placeholder="Host name"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="words"
|
||||
autoCorrect={false}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Address</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
accessibilityLabel="Address"
|
||||
value={address}
|
||||
onChangeText={(value) => {
|
||||
setAddress(value)
|
||||
setSaveError(null)
|
||||
}}
|
||||
placeholder="192.168.1.10:6768"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => {
|
||||
if (canSave) {
|
||||
void handleSave()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
Accepts IP, host:port, or ws:// / wss://. Missing port defaults to the current port
|
||||
(or 6768).
|
||||
</Text>
|
||||
|
||||
{normalizedEndpoint.ok ? (
|
||||
<Text style={styles.preview} numberOfLines={2}>
|
||||
Connects to {normalizedEndpoint.endpoint}
|
||||
</Text>
|
||||
) : address.trim().length > 0 ? (
|
||||
<Text style={styles.previewError}>{normalizedEndpoint.error}</Text>
|
||||
) : null}
|
||||
|
||||
{saveError ? <Text style={styles.errorText}>{saveError}</Text> : null}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
flex: {
|
||||
flex: 1
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
gap: spacing.sm
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
heading: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontSize: 20,
|
||||
fontWeight: '700'
|
||||
},
|
||||
saveButton: {
|
||||
minWidth: 64,
|
||||
height: 34,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.surfaceBright,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.4
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
form: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
gap: spacing.sm
|
||||
},
|
||||
help: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
label: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '500',
|
||||
marginTop: spacing.sm,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.row,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? 12 : 10
|
||||
},
|
||||
hint: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: 16
|
||||
},
|
||||
preview: {
|
||||
marginTop: spacing.sm,
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : typography.monoFamily
|
||||
},
|
||||
previewError: {
|
||||
marginTop: spacing.sm,
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.md
|
||||
},
|
||||
errorState: {
|
||||
flex: 1,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xl,
|
||||
gap: spacing.md
|
||||
},
|
||||
loadingState: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
secondaryButton: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
}
|
||||
})
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
SectionList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert
|
||||
Alert,
|
||||
RefreshControl
|
||||
} from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
@@ -33,10 +34,13 @@ import { removeHostAndCloseClient } from '../../../src/transport/host-removal-li
|
||||
import {
|
||||
useHostClient,
|
||||
useCloseHost,
|
||||
useForceReconnect,
|
||||
useReconnectAttempt,
|
||||
useLastConnectedAt
|
||||
useForceReconnect
|
||||
} from '../../../src/transport/client-context'
|
||||
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
useReconnectAttempt
|
||||
} from '../../../src/transport/client-context-connection-metrics'
|
||||
import {
|
||||
classifyConnection,
|
||||
type ConnectionVerdict
|
||||
@@ -59,7 +63,7 @@ import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
import { setCachedRepos } from '../../../src/cache/repo-cache'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
@@ -459,6 +463,14 @@ export function HostScreen({
|
||||
areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees
|
||||
)
|
||||
setWorktreesLoaded(true)
|
||||
// Why (#8498): the host detail screen seeds its list from the
|
||||
// home-written cache, so a partial home fetch could poison it until a
|
||||
// focus poll corrected it. Write the confirmed snapshot back through
|
||||
// the same cache so a reconnect refetch (or a remount) can't serve a
|
||||
// stale worktree list.
|
||||
if (hostId) {
|
||||
setCachedWorktrees(hostId, result.worktrees)
|
||||
}
|
||||
// Drop the optimistic active override once the host confirms it (the
|
||||
// activate RPC has landed and worktree.ps now reports it active), so we
|
||||
// stop overriding and respect any later desktop-driven change.
|
||||
@@ -613,6 +625,17 @@ export function HostScreen({
|
||||
return () => clearInterval(interval)
|
||||
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
|
||||
// Why (#8498): reconnect refetch + manual pull-to-refresh, extracted to
|
||||
// useWorktreeResync so this screen stays under its max-lines budget. The
|
||||
// steady-state focus/embedded polls don't cover the transition INTO
|
||||
// 'connected' after a background/sleep, which is when the cache is stalest.
|
||||
const { refreshing, onRefresh } = useWorktreeResync({
|
||||
client,
|
||||
connState,
|
||||
fetchWorktrees,
|
||||
fetchRepoMetadata
|
||||
})
|
||||
|
||||
const updateLocalPins = useCallback(
|
||||
(worktreeId: string, pinned: boolean) => {
|
||||
setPinnedIds((prev) => {
|
||||
@@ -1229,6 +1252,16 @@ export function HostScreen({
|
||||
)
|
||||
}}
|
||||
ItemSeparatorComponent={ListSeparator}
|
||||
// Why (#8498): manual pull-to-refresh forces a fresh worktree
|
||||
// snapshot after a reconnect or whenever the cache looks stale.
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colors.textSecondary}
|
||||
colors={[colors.textSecondary]}
|
||||
/>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<WorktreeListRow
|
||||
item={item}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -39,6 +39,7 @@ function HostStack({ animation }: { animation: 'none' | 'default' }) {
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
|
||||
<Stack.Screen name="[hostId]/edit" options={{ title: 'Edit host' }} />
|
||||
<Stack.Screen name="[hostId]/accounts" options={{ title: 'Accounts' }} />
|
||||
<Stack.Screen name="[hostId]/tasks" options={{ title: 'Tasks' }} />
|
||||
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
|
||||
|
||||
+35
-124
@@ -3,7 +3,6 @@ import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter, useFocusEffect } from 'expo-router'
|
||||
import {
|
||||
Monitor,
|
||||
QrCode,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
UsageBar
|
||||
} from '../src/components/AccountUsage'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { loadHosts, renameHost } from '../src/transport/host-store'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
|
||||
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
@@ -35,14 +34,14 @@ import {
|
||||
useForceReconnect,
|
||||
usePrimeHosts
|
||||
} from '../src/transport/client-context'
|
||||
import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health'
|
||||
import { classifyConnection } from '../src/transport/connection-health'
|
||||
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
|
||||
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
|
||||
import type { ConnectionState, HostProfile } from '../src/transport/types'
|
||||
import { triggerMediumImpact } from '../src/platform/haptics'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { StatusDot } from '../src/components/StatusDot'
|
||||
import { MobileHostCard } from '../src/components/MobileHostCard'
|
||||
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal'
|
||||
import { ConfirmModal } from '../src/components/ConfirmModal'
|
||||
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
|
||||
@@ -306,7 +305,6 @@ export default function HomeScreen() {
|
||||
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<HostProfile | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
|
||||
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
|
||||
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
|
||||
@@ -318,12 +316,17 @@ export default function HomeScreen() {
|
||||
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
|
||||
null
|
||||
)
|
||||
const notificationOptInCheckedRef = useRef(false)
|
||||
|
||||
// Why: read shared clients from the per-host store. Replaces the prior
|
||||
// pattern of opening N independent WebSockets here. See
|
||||
// docs/mobile-shared-client-per-host.md.
|
||||
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
|
||||
const allClients = useAllHostClients(hostIds)
|
||||
const hostPaths = useMemo(
|
||||
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
|
||||
[allClients]
|
||||
)
|
||||
const closeHostClient = useCloseHost()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const primeHosts = usePrimeHosts()
|
||||
@@ -392,9 +395,18 @@ export default function HomeScreen() {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let stale = false
|
||||
void loadHosts().then((h) => {
|
||||
if (!stale) {
|
||||
setHosts(h)
|
||||
void loadHosts().then(async (h) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
setHosts(h)
|
||||
if (h.length === 0 || notificationOptInCheckedRef.current) {
|
||||
return
|
||||
}
|
||||
notificationOptInCheckedRef.current = true
|
||||
const showNotificationOptIn = await shouldPresentNotificationOptIn()
|
||||
if (!stale && showNotificationOptIn) {
|
||||
router.replace('/notification-opt-in')
|
||||
}
|
||||
})
|
||||
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
|
||||
@@ -416,7 +428,7 @@ export default function HomeScreen() {
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [])
|
||||
}, [router])
|
||||
)
|
||||
|
||||
const sortedHosts = useMemo(
|
||||
@@ -694,19 +706,6 @@ export default function HomeScreen() {
|
||||
</Pressable>
|
||||
)
|
||||
|
||||
async function handleRename(newName: string) {
|
||||
if (!renameTarget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await renameHost(renameTarget.id, newName)
|
||||
setRenameTarget(null)
|
||||
setHosts(await loadHosts())
|
||||
} catch {
|
||||
setRenameTarget(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirmRemove) {
|
||||
return
|
||||
@@ -824,7 +823,6 @@ export default function HomeScreen() {
|
||||
const state = hostStates[item.id] ?? 'connecting'
|
||||
const attempts = hostAttempts[item.id] ?? 0
|
||||
const lastConnectedAt = hostLastConnected[item.id] ?? null
|
||||
const connected = state === 'connected'
|
||||
const info = worktreeInfo[item.id]
|
||||
const verdict = classifyConnection({
|
||||
state,
|
||||
@@ -832,45 +830,21 @@ export default function HomeScreen() {
|
||||
lastConnectedAt,
|
||||
endpoint: item.endpoint
|
||||
})
|
||||
const isError =
|
||||
verdict.kind === 'warning' ||
|
||||
verdict.kind === 'unreachable' ||
|
||||
verdict.kind === 'auth-failed'
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
|
||||
<MobileHostCard
|
||||
host={item}
|
||||
state={state}
|
||||
verdict={verdict}
|
||||
path={hostPaths[item.id] ?? 'lan'}
|
||||
worktreeCounts={
|
||||
info ? { total: info.totalWorktrees, active: info.activeCount } : undefined
|
||||
}
|
||||
onPress={() => router.push(`/h/${item.id}`)}
|
||||
onLongPress={() => {
|
||||
triggerMediumImpact()
|
||||
setActionTarget(item)
|
||||
}}
|
||||
delayLongPress={400}
|
||||
>
|
||||
<View style={styles.hostIcon}>
|
||||
<Monitor
|
||||
size={20}
|
||||
color={connected ? colors.textPrimary : colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.hostMain}>
|
||||
<Text
|
||||
style={[styles.hostName, !connected && { color: colors.textSecondary }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
<View style={styles.hostMeta}>
|
||||
<StatusDot state={state} verdict={verdict} />
|
||||
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
|
||||
{verdictDisplayLabel(verdict)}
|
||||
{connected && info
|
||||
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
|
||||
: ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
/>
|
||||
)
|
||||
}}
|
||||
ListFooterComponent={
|
||||
@@ -1079,18 +1053,19 @@ export default function HomeScreen() {
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
label: 'Rename',
|
||||
label: 'Edit host',
|
||||
icon: Edit3,
|
||||
closeBeforePress: true,
|
||||
onPress: () => {
|
||||
setActionTarget(null)
|
||||
setRenameTarget(host)
|
||||
router.push(`/h/${host.id}/edit`)
|
||||
}
|
||||
})
|
||||
items.push({
|
||||
label: 'Remove',
|
||||
destructive: true,
|
||||
closeBeforePress: true,
|
||||
onPress: () => {
|
||||
setActionTarget(null)
|
||||
setConfirmRemove(host)
|
||||
}
|
||||
})
|
||||
@@ -1099,16 +1074,6 @@ export default function HomeScreen() {
|
||||
onClose={() => setActionTarget(null)}
|
||||
/>
|
||||
|
||||
<TextInputModal
|
||||
visible={renameTarget != null}
|
||||
title="Rename Host"
|
||||
message="Enter a new name for this host."
|
||||
defaultValue={renameTarget?.name ?? ''}
|
||||
placeholder="Host name"
|
||||
onSubmit={(name) => void handleRename(name)}
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
visible={confirmRemove != null}
|
||||
title="Remove Host"
|
||||
@@ -1244,63 +1209,9 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
/* ─── Host cards ─── */
|
||||
hostCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.md,
|
||||
paddingVertical: 12,
|
||||
borderRadius: radii.card,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
hostCardPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
hostIcon: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 13,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised,
|
||||
marginRight: 14,
|
||||
position: 'relative'
|
||||
},
|
||||
hostMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
hostName: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
lineHeight: 20
|
||||
},
|
||||
hostMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginTop: 3
|
||||
},
|
||||
hostMetaItem: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
hostMetaDot: {
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: colors.textMuted,
|
||||
marginHorizontal: 8
|
||||
},
|
||||
statusDot: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 3.5
|
||||
},
|
||||
|
||||
/* ─── Resume card ─── */
|
||||
resumeCard: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
const DEFAULT_PERMISSION_STATE: NotificationPermissionState = {
|
||||
granted: false,
|
||||
status: 'undetermined',
|
||||
canAskAgain: true
|
||||
canAskAgain: true,
|
||||
authorizationReflectsUserChoice: false
|
||||
}
|
||||
|
||||
export default function NotificationsScreen() {
|
||||
@@ -69,7 +70,7 @@ export default function NotificationsScreen() {
|
||||
const notificationsBlocked = permissionState.status === 'denied'
|
||||
const hint = notificationsBlocked
|
||||
? 'Notifications are disabled in system settings.'
|
||||
: 'Receive notifications when an agent task completes on your desktop.'
|
||||
: 'Get notified on this device when an agent needs your input or finishes a task.'
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
@@ -82,7 +83,7 @@ export default function NotificationsScreen() {
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Push Notifications</Text>
|
||||
<Text style={styles.rowLabel}>Agent notifications</Text>
|
||||
<Switch
|
||||
value={switchEnabled}
|
||||
disabled={notificationsBlocked}
|
||||
|
||||
+33
-61
@@ -5,14 +5,14 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state'
|
||||
import {
|
||||
startPairingConnectionAttempt,
|
||||
type PairingConnectionAttempt
|
||||
} from '../src/transport/pairing-connection-attempt'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
||||
import type { ConnectionLogEntry, RpcResponse } from '../src/transport/types'
|
||||
startPreProfilePairing,
|
||||
type PreProfilePairingAttempt
|
||||
} from '../src/transport/pre-profile-pairing-coordinator'
|
||||
import type { ConnectionLogEntry } from '../src/transport/types'
|
||||
import { useCloseHost } from '../src/transport/client-context'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
|
||||
|
||||
type Status = 'awaiting-confirm' | 'connecting' | 'error'
|
||||
|
||||
@@ -25,6 +25,7 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000
|
||||
|
||||
export default function PairConfirmScreen() {
|
||||
const router = useRouter()
|
||||
const closeHost = useCloseHost()
|
||||
const insets = useSafeAreaInsets()
|
||||
const params = useLocalSearchParams<{ code?: string }>()
|
||||
const [status, setStatus] = useState<Status>('awaiting-confirm')
|
||||
@@ -35,7 +36,7 @@ export default function PairConfirmScreen() {
|
||||
// batch fewer setState calls when entries arrive in bursts.
|
||||
const logsRef = useRef<ConnectionLogEntry[]>([])
|
||||
const mountedRef = useRef(true)
|
||||
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
|
||||
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
|
||||
|
||||
const routeState = resolvePairConfirmRouteState(params.code)
|
||||
const offer = routeState.offer
|
||||
@@ -79,20 +80,12 @@ export default function PairConfirmScreen() {
|
||||
setStatus('connecting')
|
||||
logsRef.current = []
|
||||
setLogs([])
|
||||
let client: ReturnType<typeof connect> | null = null
|
||||
activePairingAttemptRef.current?.dispose()
|
||||
|
||||
// Why: split the try/catch around the network call vs the local save
|
||||
// so a Keychain or AsyncStorage failure doesn't masquerade as a
|
||||
// "Cannot connect" error.
|
||||
let response: RpcResponse
|
||||
const attempt = startPairingConnectionAttempt({
|
||||
const attempt = startPreProfilePairing({
|
||||
offer,
|
||||
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
|
||||
closeClient: () => client?.close()
|
||||
})
|
||||
activePairingAttemptRef.current = attempt
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
|
||||
connectOptions: {
|
||||
onLog: (entry) => {
|
||||
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
|
||||
return
|
||||
@@ -100,8 +93,11 @@ export default function PairConfirmScreen() {
|
||||
logsRef.current = [...logsRef.current, entry]
|
||||
setLogs(logsRef.current)
|
||||
}
|
||||
})
|
||||
response = await client.sendRequest('status.get')
|
||||
}
|
||||
})
|
||||
activePairingAttemptRef.current = attempt
|
||||
try {
|
||||
const { hostId } = await attempt.result
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
attempt.dispose()
|
||||
if (activePairingAttemptRef.current === attempt) {
|
||||
@@ -110,6 +106,22 @@ export default function PairConfirmScreen() {
|
||||
if (!mountedRef.current || !attemptIsCurrent) {
|
||||
return
|
||||
}
|
||||
// Why: re-pairing the same desktop now reuses its existing host id
|
||||
// (STA-1840 dedup), so a client cached under that id from an earlier
|
||||
// pairing would keep the stale endpoint/relay. Close it so the
|
||||
// destination screen opens a fresh client with the newly-paired
|
||||
// profile — the removeHost() path already refreshes on re-pair, and a
|
||||
// brand-new host has no cached entry so this is a no-op.
|
||||
closeHost(hostId)
|
||||
const showNotificationOptIn = await shouldPresentNotificationOptIn()
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
router.replace(
|
||||
showNotificationOptIn
|
||||
? { pathname: '/notification-opt-in', params: { hostId } }
|
||||
: `/h/${hostId}`
|
||||
)
|
||||
} catch (err) {
|
||||
const timedOut = attempt.timedOut
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
@@ -125,47 +137,7 @@ export default function PairConfirmScreen() {
|
||||
setErrorMessage(
|
||||
timedOut
|
||||
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
|
||||
: 'Cannot connect — check that your computer is on the same network'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
response.error.code === 'unauthorized'
|
||||
? 'Authentication failed — token may be expired'
|
||||
: `Server error: ${response.error.message}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const hostId = `host-${Date.now()}`
|
||||
const hostName = await getNextHostName()
|
||||
await saveHost({
|
||||
id: hostId,
|
||||
name: hostName,
|
||||
endpoint: offer.endpoint,
|
||||
deviceToken: offer.deviceToken,
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
console.warn('[pair-confirm] save failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
|
||||
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+33
-66
@@ -14,15 +14,15 @@ import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
|
||||
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
|
||||
import {
|
||||
startPairingConnectionAttempt,
|
||||
type PairingConnectionAttempt
|
||||
} from '../src/transport/pairing-connection-attempt'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
||||
import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types'
|
||||
startPreProfilePairing,
|
||||
type PreProfilePairingAttempt
|
||||
} from '../src/transport/pre-profile-pairing-coordinator'
|
||||
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
|
||||
import { useCloseHost } from '../src/transport/client-context'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
|
||||
|
||||
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
|
||||
// route surfaces as a real error with the log visible instead of a
|
||||
@@ -44,6 +44,7 @@ function Step({ number, text }: { number: number; text: string }) {
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const router = useRouter()
|
||||
const closeHost = useCloseHost()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [permission, requestPermission] = useCameraPermissions()
|
||||
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
|
||||
@@ -54,7 +55,7 @@ export default function PairScanScreen() {
|
||||
const logsRef = useRef<ConnectionLogEntry[]>([])
|
||||
const processingRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
|
||||
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
|
||||
|
||||
const setPairScanRootRef = useCallback((node: View | null): void => {
|
||||
if (node !== null) {
|
||||
@@ -123,21 +124,12 @@ export default function PairScanScreen() {
|
||||
setStatus('connecting')
|
||||
logsRef.current = []
|
||||
setLogs([])
|
||||
let client: ReturnType<typeof connect> | null = null
|
||||
activePairingAttemptRef.current?.dispose()
|
||||
|
||||
// Why: split the try/catch around the network call vs the local save
|
||||
// so a Keychain or AsyncStorage failure doesn't masquerade as a
|
||||
// "Cannot connect — same network?" error. Pairing reached the
|
||||
// desktop fine; the failure is local persistence.
|
||||
let response: RpcResponse
|
||||
const attempt = startPairingConnectionAttempt({
|
||||
const attempt = startPreProfilePairing({
|
||||
offer,
|
||||
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
|
||||
closeClient: () => client?.close()
|
||||
})
|
||||
activePairingAttemptRef.current = attempt
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
|
||||
connectOptions: {
|
||||
onLog: (entry) => {
|
||||
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
|
||||
return
|
||||
@@ -145,8 +137,11 @@ export default function PairScanScreen() {
|
||||
logsRef.current = [...logsRef.current, entry]
|
||||
setLogs(logsRef.current)
|
||||
}
|
||||
})
|
||||
response = await client.sendRequest('status.get')
|
||||
}
|
||||
})
|
||||
activePairingAttemptRef.current = attempt
|
||||
try {
|
||||
const { hostId } = await attempt.result
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
attempt.dispose()
|
||||
if (activePairingAttemptRef.current === attempt) {
|
||||
@@ -155,6 +150,22 @@ export default function PairScanScreen() {
|
||||
if (!mountedRef.current || !attemptIsCurrent) {
|
||||
return
|
||||
}
|
||||
// Why: re-pairing the same desktop now reuses its existing host id
|
||||
// (STA-1840 dedup), so a client cached under that id from an earlier
|
||||
// pairing would keep the stale endpoint/relay. Close it so the
|
||||
// destination screen opens a fresh client with the newly-paired
|
||||
// profile — the removeHost() path already refreshes on re-pair, and a
|
||||
// brand-new host has no cached entry so this is a no-op.
|
||||
closeHost(hostId)
|
||||
const showNotificationOptIn = await shouldPresentNotificationOptIn()
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
router.replace(
|
||||
showNotificationOptIn
|
||||
? { pathname: '/notification-opt-in', params: { hostId } }
|
||||
: `/h/${hostId}`
|
||||
)
|
||||
} catch (err) {
|
||||
const timedOut = attempt.timedOut
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
@@ -170,51 +181,7 @@ export default function PairScanScreen() {
|
||||
setErrorMessage(
|
||||
timedOut
|
||||
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
|
||||
: 'Cannot connect — check that your computer is on the same network'
|
||||
)
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (response.error.code === 'unauthorized') {
|
||||
setStatus('error')
|
||||
setErrorMessage('Authentication failed — token may be expired')
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
setStatus('error')
|
||||
setErrorMessage(`Server error: ${response.error.message}`)
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const hostId = `host-${Date.now()}`
|
||||
const hostName = await getNextHostName()
|
||||
await saveHost({
|
||||
id: hostId,
|
||||
name: hostName,
|
||||
endpoint: offer.endpoint,
|
||||
deviceToken: offer.deviceToken,
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
console.warn('[pair] save failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
|
||||
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
processingRef.current = false
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+9
@@ -11,6 +11,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@noble/hashes':
|
||||
specifier: 1.8.0
|
||||
version: 1.8.0
|
||||
'@orca/expo-two-way-audio':
|
||||
specifier: file:./packages/expo-two-way-audio
|
||||
version: file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
|
||||
@@ -1878,6 +1881,10 @@ packages:
|
||||
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3':
|
||||
resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==}
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio':
|
||||
resolution: {directory: packages/expo-two-way-audio, type: directory}
|
||||
peerDependencies:
|
||||
@@ -8835,6 +8842,8 @@ snapshots:
|
||||
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache'
|
||||
|
||||
// Why: AC #8498 guarantees a reconnect refetch writes through the
|
||||
// same cache path the host detail screen seeds from, so a reconnect can't
|
||||
// serve a stale snapshot. This unit pins the write-through contract.
|
||||
describe('worktree-cache write-through', () => {
|
||||
it('returns the most-recently written snapshot, not a stale one', () => {
|
||||
const hostId = 'host-write-through'
|
||||
const stale = [{ worktreeId: 'a', name: 'stale' }]
|
||||
const fresh = [
|
||||
{ worktreeId: 'a', name: 'fresh' },
|
||||
{ worktreeId: 'b', name: 'added' }
|
||||
]
|
||||
|
||||
setCachedWorktrees(hostId, stale)
|
||||
expect(getCachedWorktrees(hostId)).toEqual(stale)
|
||||
|
||||
// Why: simulates the reconnect refetch write-through — the fresh
|
||||
// worktree.ps snapshot must fully replace the poisoned cache entry.
|
||||
setCachedWorktrees(hostId, fresh)
|
||||
expect(getCachedWorktrees(hostId)).toEqual(fresh)
|
||||
expect(getCachedWorktrees(hostId)).not.toEqual(stale)
|
||||
})
|
||||
|
||||
it('exposes a fresh snapshot to a remounting screen after reconnect', () => {
|
||||
// Why: the host detail screen reads getCachedWorktrees(hostId)
|
||||
// on (re)mount as its initialCache. A reconnect that writes
|
||||
// through must therefore surface here instead of the pre-reconnect data.
|
||||
const hostId = 'host-remount'
|
||||
setCachedWorktrees(hostId, [{ worktreeId: 'old', name: 'pre-reconnect' }])
|
||||
|
||||
// Reconnect refetch lands a fresh snapshot and writes it through.
|
||||
const reconnected = [
|
||||
{ worktreeId: 'old', name: 'post-reconnect' },
|
||||
{ worktreeId: 'new', name: 'now-visible' }
|
||||
]
|
||||
setCachedWorktrees(hostId, reconnected)
|
||||
|
||||
// A fresh screen mount reads the cache — must see the connected set.
|
||||
expect(getCachedWorktrees(hostId)).toEqual(reconnected)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
|
||||
export type MobileSpeechSetup = RuntimeSpeechSetupState
|
||||
@@ -31,7 +32,7 @@ export function isDictationSetupRequiredError(message: string): boolean {
|
||||
export async function fetchDictationSetup(
|
||||
client: Pick<RpcClient, 'sendRequest'>
|
||||
): Promise<MobileSpeechSetup> {
|
||||
const response = await client.sendRequest('speech.models.list', null)
|
||||
const response = await fetchDictationSetupResponse(client)
|
||||
if (!response.ok) {
|
||||
if (isLegacyDesktopSpeechSetupError(response.error)) {
|
||||
throw new Error(LEGACY_DESKTOP_SPEECH_SETUP_MESSAGE)
|
||||
@@ -41,6 +42,19 @@ export async function fetchDictationSetup(
|
||||
return (response as RpcSuccess).result as MobileSpeechSetup
|
||||
}
|
||||
|
||||
async function fetchDictationSetupResponse(client: Pick<RpcClient, 'sendRequest'>) {
|
||||
try {
|
||||
return await client.sendRequest('speech.models.list', null)
|
||||
} catch (error) {
|
||||
if (!(error instanceof LogicalClientCutoverError)) {
|
||||
throw error
|
||||
}
|
||||
// Why: this read can safely repeat on the authenticated replacement; mutation
|
||||
// RPCs must still surface cutover so callers never replay unknown commits.
|
||||
return client.sendRequest('speech.models.list', null)
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadDictationModel(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
modelId: string
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import EditHostScreen from '../app/h/[hostId]/edit'
|
||||
|
||||
const dependencies = vi.hoisted(() => ({
|
||||
back: vi.fn(),
|
||||
forceReconnectHost: vi.fn(),
|
||||
loadHosts: vi.fn(),
|
||||
primeHosts: vi.fn(),
|
||||
updateHostNameAndEndpoint: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
KeyboardAvoidingView: 'KeyboardAvoidingView',
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
Text: 'Text',
|
||||
TextInput: 'TextInput',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => ({ hostId: 'host-1' }),
|
||||
useRouter: () => ({ back: dependencies.back })
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ChevronLeft: 'ChevronLeft'
|
||||
}))
|
||||
|
||||
vi.mock('./transport/host-store', () => ({
|
||||
loadHosts: dependencies.loadHosts,
|
||||
updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint
|
||||
}))
|
||||
|
||||
vi.mock('./transport/client-context', () => ({
|
||||
useForceReconnect: () => dependencies.forceReconnectHost,
|
||||
usePrimeHosts: () => dependencies.primeHosts
|
||||
}))
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(EditHostScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('Edit host route did not render')
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
describe('edit host route accessibility', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
dependencies.loadHosts.mockReset().mockResolvedValue([
|
||||
{
|
||||
id: 'host-1',
|
||||
name: 'Desk',
|
||||
endpoint: 'ws://192.168.1.10:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'public-key',
|
||||
lastConnected: 1
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('exposes stable accessible names for both editable fields', async () => {
|
||||
const renderer = await renderEditHostRoute()
|
||||
|
||||
const inputs = renderer.root.findAllByType('TextInput')
|
||||
expect(inputs).toHaveLength(2)
|
||||
expect(inputs.map((input) => input.props.accessibilityLabel)).toEqual(['Name', 'Address'])
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import EditHostScreen from '../app/h/[hostId]/edit'
|
||||
|
||||
const dependencies = vi.hoisted(() => ({
|
||||
back: vi.fn(),
|
||||
forceReconnectHost: vi.fn(),
|
||||
loadHosts: vi.fn(),
|
||||
primeHosts: vi.fn(),
|
||||
updateHostNameAndEndpoint: vi.fn(),
|
||||
hostId: 'host-1' as string | undefined
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
KeyboardAvoidingView: 'KeyboardAvoidingView',
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
Text: 'Text',
|
||||
TextInput: 'TextInput',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => ({ hostId: dependencies.hostId }),
|
||||
useRouter: () => ({ back: dependencies.back })
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ChevronLeft: 'ChevronLeft'
|
||||
}))
|
||||
|
||||
vi.mock('./transport/host-store', () => ({
|
||||
loadHosts: dependencies.loadHosts,
|
||||
updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint
|
||||
}))
|
||||
|
||||
vi.mock('./transport/client-context', () => ({
|
||||
useForceReconnect: () => dependencies.forceReconnectHost,
|
||||
usePrimeHosts: () => dependencies.primeHosts
|
||||
}))
|
||||
|
||||
const HOST_FIXTURE = {
|
||||
id: 'host-1',
|
||||
name: 'Desk',
|
||||
endpoint: 'ws://192.168.1.10:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'public-key',
|
||||
lastConnected: 1
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(EditHostScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('Edit host route did not render')
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
function setFieldValue(
|
||||
renderer: ReactTestRenderer,
|
||||
accessibilityLabel: 'Name' | 'Address',
|
||||
value: string
|
||||
): void {
|
||||
const input = renderer.root
|
||||
.findAllByType('TextInput')
|
||||
.find((node) => node.props.accessibilityLabel === accessibilityLabel)
|
||||
if (!input) {
|
||||
throw new Error(`${accessibilityLabel} input not found`)
|
||||
}
|
||||
act(() => {
|
||||
input.props.onChangeText(value)
|
||||
})
|
||||
}
|
||||
|
||||
function findSaveButton(renderer: ReactTestRenderer) {
|
||||
const button = renderer.root
|
||||
.findAllByType('Pressable')
|
||||
.find((node) => node.props.accessibilityLabel === 'Save host')
|
||||
if (!button) {
|
||||
throw new Error('Save button not found')
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
async function pressSave(renderer: ReactTestRenderer): Promise<void> {
|
||||
const button = findSaveButton(renderer)
|
||||
await act(async () => {
|
||||
button.props.onPress()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
function findText(renderer: ReactTestRenderer, match: string): boolean {
|
||||
return renderer.root.findAllByType('Text').some((node) => {
|
||||
const children = node.props.children
|
||||
if (typeof children === 'string') {
|
||||
return children.includes(match)
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.join('').includes(match)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
describe('edit host handleSave', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
dependencies.hostId = 'host-1'
|
||||
dependencies.back.mockReset()
|
||||
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
|
||||
dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE])
|
||||
dependencies.primeHosts.mockReset()
|
||||
dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('rename-only save updates only the name and does not reconnect', async () => {
|
||||
const renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Name', 'Home Desk')
|
||||
await pressSave(renderer)
|
||||
|
||||
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
|
||||
name: 'Home Desk'
|
||||
})
|
||||
expect(dependencies.forceReconnectHost).not.toHaveBeenCalled()
|
||||
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('endpoint-only save updates only the endpoint and reconnects', async () => {
|
||||
const renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
|
||||
await pressSave(renderer)
|
||||
|
||||
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
|
||||
endpoint: 'ws://192.168.1.20:6768'
|
||||
})
|
||||
expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1')
|
||||
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('saves name and endpoint together in one call, then reconnects', async () => {
|
||||
const renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Name', 'Home Desk')
|
||||
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
|
||||
await pressSave(renderer)
|
||||
|
||||
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledTimes(1)
|
||||
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
|
||||
name: 'Home Desk',
|
||||
endpoint: 'ws://192.168.1.20:6768'
|
||||
})
|
||||
expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1')
|
||||
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('navigates back without saving or reconnecting when nothing changed', async () => {
|
||||
const renderer = await renderEditHostRoute()
|
||||
await pressSave(renderer)
|
||||
|
||||
expect(dependencies.updateHostNameAndEndpoint).not.toHaveBeenCalled()
|
||||
expect(dependencies.forceReconnectHost).not.toHaveBeenCalled()
|
||||
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('shows the error and does not navigate back or reconnect when the save rejects', async () => {
|
||||
dependencies.updateHostNameAndEndpoint.mockRejectedValueOnce(new Error('Host not found'))
|
||||
const renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Name', 'Home Desk')
|
||||
await pressSave(renderer)
|
||||
|
||||
expect(findText(renderer, 'Host not found')).toBe(true)
|
||||
expect(dependencies.forceReconnectHost).not.toHaveBeenCalled()
|
||||
expect(dependencies.back).not.toHaveBeenCalled()
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('still navigates back when the post-save re-prime fails', async () => {
|
||||
dependencies.loadHosts
|
||||
.mockResolvedValueOnce([HOST_FIXTURE])
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
const renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Name', 'Home Desk')
|
||||
await pressSave(renderer)
|
||||
|
||||
expect(dependencies.primeHosts).not.toHaveBeenCalled()
|
||||
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('still navigates back and shows no error when the post-save reconnect rejects', async () => {
|
||||
dependencies.forceReconnectHost.mockRejectedValueOnce(new Error('connect failed'))
|
||||
const renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
|
||||
await pressSave(renderer)
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1')
|
||||
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
||||
expect(findText(renderer, 'connect failed')).toBe(false)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('ignores a second Save trigger while a save is already in flight', async () => {
|
||||
let resolveSave: () => void = () => {}
|
||||
dependencies.updateHostNameAndEndpoint.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
)
|
||||
const renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Name', 'Home Desk')
|
||||
const button = findSaveButton(renderer)
|
||||
|
||||
await act(async () => {
|
||||
button.props.onPress()
|
||||
button.props.onPress()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveSave()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit host load() error states', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
dependencies.hostId = 'host-1'
|
||||
dependencies.back.mockReset()
|
||||
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
|
||||
dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE])
|
||||
dependencies.primeHosts.mockReset()
|
||||
dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('shows "Missing host." when hostId is absent', async () => {
|
||||
dependencies.hostId = undefined
|
||||
const renderer = await renderEditHostRoute()
|
||||
|
||||
expect(findText(renderer, 'Missing host.')).toBe(true)
|
||||
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('shows a not-saved message when the host is not in the loaded list', async () => {
|
||||
dependencies.loadHosts.mockReset().mockResolvedValue([])
|
||||
const renderer = await renderEditHostRoute()
|
||||
|
||||
expect(findText(renderer, 'This host was removed from this phone.')).toBe(true)
|
||||
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('surfaces the error message when loadHosts rejects', async () => {
|
||||
dependencies.loadHosts.mockReset().mockRejectedValue(new Error('storage unreadable'))
|
||||
const renderer = await renderEditHostRoute()
|
||||
|
||||
expect(findText(renderer, 'storage unreadable')).toBe(true)
|
||||
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { Platform } from 'react-native'
|
||||
import {
|
||||
getNotificationPermissionState,
|
||||
setScheduledNotificationsMaxForTests,
|
||||
subscribeToDesktopNotifications
|
||||
} from './mobile-notifications'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
|
||||
@@ -17,13 +20,49 @@ vi.mock('expo-notifications', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: { OS: 'ios' }
|
||||
Platform: { OS: 'ios', Version: 18 }
|
||||
}))
|
||||
|
||||
// Why: mobile-notifications now persists the catch-up watermark to
|
||||
// AsyncStorage. The package isn't resolvable in the node test env (other
|
||||
// mobile tests mock it the same way), so we provide a no-op mock.
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(Platform, { OS: 'ios', Version: 18 })
|
||||
})
|
||||
|
||||
describe('getNotificationPermissionState', () => {
|
||||
it.each([
|
||||
{ os: 'android', version: 32, expected: false },
|
||||
{ os: 'android', version: 33, expected: true },
|
||||
{ os: 'ios', version: 18, expected: true }
|
||||
])(
|
||||
'reports whether a granted $os $version authorization reflects user choice',
|
||||
async ({ os, version, expected }) => {
|
||||
Object.assign(Platform, { OS: os, Version: version })
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
|
||||
await expect(getNotificationPermissionState()).resolves.toMatchObject({
|
||||
granted: true,
|
||||
authorizationReflectsUserChoice: expected
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('subscribeToDesktopNotifications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -316,3 +355,288 @@ describe('subscribeToDesktopNotifications', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the
|
||||
// client must fetch missed notifications from its watermark and push exactly
|
||||
// the ones it had not yet delivered — never re-pushing an already-delivered id.
|
||||
describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
|
||||
const AsyncStorageMock = vi.mocked(AsyncStorage)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
AsyncStorageMock.getItem.mockResolvedValue(null)
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 10)
|
||||
})
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const sentRequests: { method: string; params: unknown }[] = []
|
||||
const client = {
|
||||
subscribe: vi.fn((_method: string, _params: unknown, cb: (data: unknown) => void) => {
|
||||
onData = cb
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(
|
||||
async (method: string, _params: unknown = {}) =>
|
||||
({
|
||||
ok: true,
|
||||
result: method === 'notifications.getMissedSince' ? { notifications: [] } : undefined
|
||||
}) as never
|
||||
)
|
||||
}
|
||||
// Why: onData is captured live via a getter (not destructured) because the
|
||||
// subscribe mock assigns it asynchronously as a side effect of
|
||||
// subscribeToDesktopNotifications calling client.subscribe.
|
||||
return {
|
||||
client: client as unknown as RpcClient,
|
||||
get onData() {
|
||||
return onData
|
||||
},
|
||||
sentRequests
|
||||
}
|
||||
}
|
||||
|
||||
it('does not fetch missed notifications on the first (cold-open) ready', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
// First ready = cold open.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
|
||||
expect(sub.client.sendRequest).not.toHaveBeenCalledWith(
|
||||
'notifications.getMissedSince',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches only notifications after the delivered watermark (idempotent catch-up)', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
|
||||
const sub = makeClient()
|
||||
// The desktop honours the watermark: only seq 10 (agent:missed) is returned
|
||||
// because seq 11 (agent:dup) was already delivered on the live stream and
|
||||
// advanced lastDeliveredSeq to 11. So the replay never re-includes it.
|
||||
sub.client.sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'missed',
|
||||
body: 'b',
|
||||
notificationId: 'agent:missed',
|
||||
notificationSeq: 10
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
// First ready = cold open (no fetch).
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
// Live stream already delivered agent:dup (seq 11) before reap.
|
||||
sub.onData?.({
|
||||
type: 'notification',
|
||||
title: 'dup',
|
||||
body: 'b',
|
||||
notificationId: 'agent:dup',
|
||||
notificationSeq: 11
|
||||
})
|
||||
await flushAsync()
|
||||
// Reconnect ready → fetchMissed sends the watermark (11).
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
// The watermark passed to getMissedSince is the delivered seq.
|
||||
const missedCall = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 11 })
|
||||
// Only agent:missed was pushed; agent:dup appears exactly once (live only).
|
||||
const scheduledIds = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map(
|
||||
(call) =>
|
||||
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
|
||||
)
|
||||
expect(scheduledIds).toEqual(['agent:dup', 'agent:missed'])
|
||||
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
// Simulate the bounded-buffer edge: the desktop returns seq 11 again
|
||||
// (already delivered live) alongside a new seq 12.
|
||||
sub.client.sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'dup',
|
||||
body: 'b',
|
||||
notificationId: 'agent:dup',
|
||||
notificationSeq: 11
|
||||
},
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'new',
|
||||
body: 'b',
|
||||
notificationId: 'agent:new',
|
||||
notificationSeq: 12
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
// Live stream delivered agent:dup (seq 11) before reap.
|
||||
sub.onData?.({
|
||||
type: 'notification',
|
||||
title: 'dup',
|
||||
body: 'b',
|
||||
notificationId: 'agent:dup',
|
||||
notificationSeq: 11
|
||||
})
|
||||
await flushAsync()
|
||||
// Reconnect replay re-includes seq 11 (must be dropped) + new seq 12.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const scheduledIds = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map(
|
||||
(call) =>
|
||||
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
|
||||
)
|
||||
expect(scheduledIds).toEqual(['agent:dup', 'agent:new'])
|
||||
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('persists the highest delivered seq so a later reconnect resumes from it', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
// Live stream delivers seq 5.
|
||||
sub.onData?.({
|
||||
type: 'notification',
|
||||
title: 't',
|
||||
body: 'b',
|
||||
notificationId: 'agent:live',
|
||||
notificationSeq: 5
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'orca:mobileNotificationsLastSeq:host-1',
|
||||
'5'
|
||||
)
|
||||
})
|
||||
|
||||
// Why: a replay-ONLY delivery (nothing arrived live first) must still advance
|
||||
// and persist the watermark. This is the exact case the seq/notificationSeq
|
||||
// field mismatch broke — the desktop replay path returns `notificationSeq`
|
||||
// (matching the live fan-out), so the client watermark moves and the next
|
||||
// reconnect resumes from it instead of re-fetching from 0.
|
||||
it('advances + persists the watermark from a replay-only delivery (#8129 field-mismatch regression)', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
// Desktop replay returns events keyed by notificationSeq (the fixed shape).
|
||||
sub.client.sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'missed',
|
||||
body: 'b',
|
||||
notificationId: 'agent:missed',
|
||||
notificationSeq: 8
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
// First reconnect → replay delivers seq 8 (no prior live delivery).
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
// Watermark advanced to the replayed seq and was persisted.
|
||||
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'orca:mobileNotificationsLastSeq:host-1',
|
||||
'8'
|
||||
)
|
||||
|
||||
// Second reconnect resumes from the advanced watermark, not 0.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
const missedCalls = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 8 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,12 @@ import { Platform } from 'react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
|
||||
import {
|
||||
createSeenNotificationGuard,
|
||||
loadLastSeenSeq,
|
||||
saveLastSeenSeq,
|
||||
seenKeyForEvent
|
||||
} from './notification-reconnect-catchup'
|
||||
|
||||
type NotificationEvent = {
|
||||
type: 'notification'
|
||||
@@ -11,11 +17,16 @@ type NotificationEvent = {
|
||||
body: string
|
||||
worktreeId?: string
|
||||
notificationId?: string
|
||||
// Mirrors the desktop-assigned MobileNotificationEvent.notificationSeq used
|
||||
// for reconnect catch-up (#8129). Optional because older runtimes / non-
|
||||
// replay events may omit it.
|
||||
notificationSeq?: number
|
||||
}
|
||||
|
||||
type DismissNotificationEvent = {
|
||||
type: 'dismiss'
|
||||
notificationId: string
|
||||
notificationSeq?: number
|
||||
}
|
||||
|
||||
type SubscribeResult = {
|
||||
@@ -71,6 +82,7 @@ export type NotificationPermissionState = {
|
||||
granted: boolean
|
||||
status: string
|
||||
canAskAgain: boolean
|
||||
authorizationReflectsUserChoice: boolean
|
||||
}
|
||||
|
||||
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
|
||||
@@ -78,7 +90,11 @@ export async function getNotificationPermissionState(): Promise<NotificationPerm
|
||||
return {
|
||||
granted: status === 'granted',
|
||||
status,
|
||||
canAskAgain
|
||||
canAskAgain,
|
||||
// Why: Android before API 33 has no runtime notification permission, so
|
||||
// Expo's default "granted" state is capability evidence, not user consent.
|
||||
authorizationReflectsUserChoice:
|
||||
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,18 +239,105 @@ async function dismissLocalNotification(
|
||||
|
||||
// Why: each host connection gets its own notification subscription. When the
|
||||
// connection drops, the unsubscribe function cleans up the streaming RPC.
|
||||
// On reconnect the same subscribe stream is re-established by the RPC client;
|
||||
// we use its `ready` event to trigger catch-up (#8129): fetch notifications
|
||||
// dispatched while the socket was reaped, watermarked by the last seq we
|
||||
// already delivered so the desktop never re-sends an already-pushed one.
|
||||
// Returns an unsubscribe function.
|
||||
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
|
||||
configureNotificationChannel()
|
||||
|
||||
let subscriptionId: string | null = null
|
||||
let disposed = false
|
||||
// Highest seq delivered on the live stream or replay for this connection.
|
||||
// Persisted per-host so a cold app start still resumes from the right cut.
|
||||
let lastDeliveredSeq = 0
|
||||
// Why: per-connection dedup guard applied ONLY to the replay path
|
||||
// (fetchMissed), never the live stream. The desktop already guarantees the
|
||||
// replay cannot contain an event with seq <= lastDeliveredSeq (both live and
|
||||
// replay advance the same watermark), so live + replay never overlap there.
|
||||
// This set is defense-in-depth: if the desktop's bounded buffer evicted an
|
||||
// old entry and a reconnect re-fetches across a boundary, an id delivered in
|
||||
// the same connection isn't pushed twice. Bounded (RECENTLY_SEEN_CAP) so a
|
||||
// long-lived session can't grow without limit.
|
||||
const seenReplay = createSeenNotificationGuard()
|
||||
|
||||
function deliverLive(
|
||||
type: 'notification' | 'dismiss',
|
||||
event: NotificationEvent | DismissNotificationEvent
|
||||
): Promise<void> {
|
||||
if (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) {
|
||||
lastDeliveredSeq = event.notificationSeq
|
||||
void saveLastSeenSeq(hostId, lastDeliveredSeq)
|
||||
}
|
||||
// Why (#8129 dedup): mark the event seen on EVERY delivery path (live AND
|
||||
// replay) so a replay that re-includes an id already pushed live in this
|
||||
// connection is dropped instead of double-pushed. fetchMissed also
|
||||
// pre-checks seenReplay, but without this the live path never populated it.
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key) {
|
||||
seenReplay.add(key)
|
||||
}
|
||||
if (type === 'notification') {
|
||||
return showLocalNotification(event as NotificationEvent, hostId)
|
||||
}
|
||||
return dismissLocalNotification(event as DismissNotificationEvent, hostId)
|
||||
}
|
||||
|
||||
// Why: on a reconnect `ready` the desktop has already dispatched whatever we
|
||||
// missed; ask for it from our persisted watermark. Because the desktop cuts
|
||||
// by seq > lastSeenSeq this is idempotent — we only ever get events we have
|
||||
// not delivered before. The seenReplay guard is a second layer so a replay
|
||||
// that somehow re-includes an id already delivered this connection is
|
||||
// dropped instead of double-pushed.
|
||||
async function fetchMissed(): Promise<void> {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const missed = await client
|
||||
.sendRequest('notifications.getMissedSince', { lastSeenSeq: lastDeliveredSeq })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return []
|
||||
}
|
||||
const result = response.result as { notifications?: unknown[] } | undefined
|
||||
return Array.isArray(result?.notifications) ? result.notifications : []
|
||||
})
|
||||
.catch(() => [])
|
||||
for (const raw of missed) {
|
||||
const event = raw as NotificationEvent | DismissNotificationEvent
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key && seenReplay.has(key)) {
|
||||
continue
|
||||
}
|
||||
if (key) {
|
||||
seenReplay.add(key)
|
||||
}
|
||||
if (event.type === 'notification') {
|
||||
await deliverLive('notification', event)
|
||||
} else if (event.type === 'dismiss') {
|
||||
await deliverLive('dismiss', event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: lazily seed the watermark from durable storage on first use so we
|
||||
// don't block subscribe() on an AsyncStorage read. The first `ready` (cold
|
||||
// open) does NOT need catch-up — the live stream starts fresh; only
|
||||
// subsequent reconnect `ready` events fetch missed notifications.
|
||||
let watermarkLoaded = false
|
||||
void loadLastSeenSeq(hostId).then((seq) => {
|
||||
lastDeliveredSeq = Math.max(lastDeliveredSeq, seq)
|
||||
watermarkLoaded = true
|
||||
})
|
||||
|
||||
function unsubscribeServer(id: string) {
|
||||
if (client.getState() === 'connected') {
|
||||
client.sendRequest('notifications.unsubscribe', { subscriptionId: id }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
let reconnectReadyCount = 0
|
||||
const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
|
||||
const event = data as
|
||||
| NotificationEvent
|
||||
@@ -243,9 +346,18 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
| { type: 'end' }
|
||||
if (event.type === 'ready') {
|
||||
subscriptionId = (event as SubscribeResult).subscriptionId
|
||||
reconnectReadyCount += 1
|
||||
if (disposed) {
|
||||
unsubscribeServer(subscriptionId)
|
||||
unsubscribeStream()
|
||||
return
|
||||
}
|
||||
// Why: first ready is the cold-open live stream — no catch-up needed.
|
||||
// Every later ready is a reconnect; fetch what we missed from the
|
||||
// watermark. Guard on watermarkLoaded so a fast reconnect doesn't
|
||||
// fetch from a stale 0 watermark (which would re-push everything).
|
||||
if (reconnectReadyCount > 1 && watermarkLoaded) {
|
||||
void fetchMissed()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -259,9 +371,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
return
|
||||
}
|
||||
if (event.type === 'notification') {
|
||||
void showLocalNotification(event as NotificationEvent, hostId)
|
||||
void deliverLive('notification', event as NotificationEvent)
|
||||
} else if (event.type === 'dismiss') {
|
||||
void dismissLocalNotification(event as DismissNotificationEvent, hostId)
|
||||
void deliverLive('dismiss', event as DismissNotificationEvent)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
readPushNotificationsPreference,
|
||||
savePushNotificationsEnabled
|
||||
} from '../storage/preferences'
|
||||
import { getNotificationPermissionState } from './mobile-notifications'
|
||||
import { shouldPresentNotificationOptIn } from './notification-opt-in-gate'
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
readPushNotificationsPreference: vi.fn(),
|
||||
savePushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-notifications', () => ({
|
||||
getNotificationPermissionState: vi.fn()
|
||||
}))
|
||||
|
||||
describe('notification opt-in gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(readPushNotificationsPreference).mockReset()
|
||||
vi.mocked(savePushNotificationsEnabled).mockReset()
|
||||
vi.mocked(getNotificationPermissionState).mockReset()
|
||||
})
|
||||
|
||||
it('presents only when the local preference and system decision are both unset', async () => {
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
|
||||
vi.mocked(getNotificationPermissionState).mockResolvedValue({
|
||||
granted: false,
|
||||
status: 'undetermined',
|
||||
canAskAgain: true,
|
||||
authorizationReflectsUserChoice: false
|
||||
})
|
||||
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
|
||||
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([true, false])('preserves an existing %s mobile preference', async (value) => {
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value, loaded: true })
|
||||
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
|
||||
expect(getNotificationPermissionState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adopts existing system authorization without prompting', async () => {
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
|
||||
vi.mocked(getNotificationPermissionState).mockResolvedValue({
|
||||
granted: true,
|
||||
status: 'granted',
|
||||
canAskAgain: true,
|
||||
authorizationReflectsUserChoice: true
|
||||
})
|
||||
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
|
||||
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('still presents when a pre-Android 13 default grant is not an opt-in decision', async () => {
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
|
||||
vi.mocked(getNotificationPermissionState).mockResolvedValue({
|
||||
granted: true,
|
||||
status: 'granted',
|
||||
canAskAgain: true,
|
||||
authorizationReflectsUserChoice: false
|
||||
})
|
||||
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
|
||||
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the gate when iOS has already denied permission', async () => {
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
|
||||
vi.mocked(getNotificationPermissionState).mockResolvedValue({
|
||||
granted: false,
|
||||
status: 'denied',
|
||||
canAskAgain: false,
|
||||
authorizationReflectsUserChoice: false
|
||||
})
|
||||
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
|
||||
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('does not block startup when storage or permission checks fail', async () => {
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: false })
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
|
||||
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
|
||||
vi.mocked(getNotificationPermissionState).mockRejectedValue(new Error('unavailable'))
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
readPushNotificationsPreference,
|
||||
savePushNotificationsEnabled
|
||||
} from '../storage/preferences'
|
||||
import { getNotificationPermissionState } from './mobile-notifications'
|
||||
|
||||
export async function shouldPresentNotificationOptIn(): Promise<boolean> {
|
||||
const preference = await readPushNotificationsPreference()
|
||||
if (!preference.loaded || preference.value !== null) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await getNotificationPermissionState()
|
||||
if (permission.granted) {
|
||||
if (!permission.authorizationReflectsUserChoice) {
|
||||
return true
|
||||
}
|
||||
// Why: an already-authorized device should inherit the useful default
|
||||
// without seeing an onboarding decision it has effectively made.
|
||||
await savePushNotificationsEnabled(true)
|
||||
return false
|
||||
}
|
||||
if (permission.status === 'denied' || !permission.canAskAgain) {
|
||||
// Why: iOS cannot show its authorization prompt again, so a blocking
|
||||
// onboarding screen would be a dead end; Settings remains the recovery.
|
||||
await savePushNotificationsEnabled(false)
|
||||
return false
|
||||
}
|
||||
return permission.status === 'undetermined'
|
||||
} catch {
|
||||
// Why: permission or persistence failures must not trap startup behind a
|
||||
// decision screen whose result cannot be applied reliably.
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted
|
||||
// from mobile-notifications.ts so that file stays under its max-lines budget.
|
||||
// The highest desktop notification seq this device has delivered is persisted
|
||||
// per-host so it survives app restarts. On reconnect we send it to
|
||||
// notifications.getMissedSince as the catch-up watermark — the desktop then
|
||||
// returns only notifications dispatched after it, so we never re-push a
|
||||
// notification we already delivered. The in-memory seen-set is a second guard
|
||||
// against double-delivery for events that arrive on both the live stream and a
|
||||
// replay (e.g. a brief liveness spell before a reap).
|
||||
const LAST_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:'
|
||||
|
||||
function lastSeqStorageKey(hostId: string): string {
|
||||
return LAST_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
|
||||
}
|
||||
|
||||
export async function loadLastSeenSeq(hostId: string): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(lastSeqStorageKey(hostId))
|
||||
const parsed = raw == null ? 0 : Number(raw)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLastSeenSeq(hostId: string, seq: number): Promise<void> {
|
||||
if (!Number.isFinite(seq) || seq <= 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await AsyncStorage.setItem(lastSeqStorageKey(hostId), String(seq))
|
||||
} catch {
|
||||
// Why: persisting the watermark is best-effort. If it fails (or lags), the
|
||||
// stored value stays BELOW what we delivered, so a later cold start can
|
||||
// re-fetch — and, once the in-memory seen-set is gone, re-show — an already
|
||||
// delivered notification. That's the accepted at-least-once trade-off;
|
||||
// within a live session the in-memory watermark is authoritative, so only
|
||||
// post-restart reconnects are affected.
|
||||
}
|
||||
}
|
||||
|
||||
// Why: bounded in-memory dedup window for notificationIds/dismiss ids observed
|
||||
// on the current connection. The desktop already dedupes by seq on replay, but
|
||||
// a socket that flickers background→foreground→background can deliver an event
|
||||
// on the live stream and again in a replay; the seen-set guarantees each
|
||||
// notificationId maps to at most one local push for the connection lifetime.
|
||||
// Bounded so a long-lived session can't grow without limit — a 2x superset of
|
||||
// the desktop's 256-entry replay buffer and the 256 scheduled-notification cap.
|
||||
const RECENTLY_SEEN_CAP = 512
|
||||
|
||||
export function createSeenNotificationGuard(): {
|
||||
has: (id: string) => boolean
|
||||
add: (id: string) => void
|
||||
} {
|
||||
const seen = new Set<string>()
|
||||
return {
|
||||
has(id: string): boolean {
|
||||
return seen.has(id)
|
||||
},
|
||||
add(id: string): void {
|
||||
seen.add(id)
|
||||
if (seen.size > RECENTLY_SEEN_CAP) {
|
||||
// Why: insertion order; the oldest entries are first. Drop one to stay
|
||||
// bounded without disturbing the more-recently-relevant keys.
|
||||
const first = seen.values().next().value
|
||||
if (first !== undefined) {
|
||||
seen.delete(first)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: key for the replay dedup guard. Uses notificationId when present, but
|
||||
// disambiguates by seq so a legitimate live re-delivery of the same id at a
|
||||
// NEW seq (content refresh, allowed by the existing behaviour) is NOT treated
|
||||
// as a duplicate, while a replay re-returning the SAME id+seq already delivered
|
||||
// live is suppressed. Replay events always carry a seq (the desktop assigns
|
||||
// one), so the guard is effective on the reconnect path.
|
||||
export function seenKeyForEvent(event: {
|
||||
notificationId?: string
|
||||
notificationSeq?: number
|
||||
}): string | null {
|
||||
const id = event.notificationId
|
||||
if (id != null && event.notificationSeq != null) {
|
||||
return `id:${id}#${event.notificationSeq}`
|
||||
}
|
||||
if (id != null) {
|
||||
return `id:${id}`
|
||||
}
|
||||
if (event.notificationSeq != null) {
|
||||
return `seq:${event.notificationSeq}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -33,3 +33,31 @@ export function isGestureMouseTrackingMode(
|
||||
): boolean {
|
||||
return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'
|
||||
}
|
||||
|
||||
export function getActiveTabIdForHandle(
|
||||
tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>,
|
||||
terminalHandle: string | null
|
||||
): string | null {
|
||||
if (!terminalHandle) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
tabs.find((tab) => tab.type === 'terminal' && tab.terminal === terminalHandle)?.id ??
|
||||
terminalHandle
|
||||
)
|
||||
}
|
||||
|
||||
export function updateTerminalCwdFromStreamEvent(
|
||||
handle: string,
|
||||
data: Readonly<Record<string, unknown>>,
|
||||
terminalCwd: Map<string, string>
|
||||
): void {
|
||||
if (!('cwd' in data)) {
|
||||
return
|
||||
}
|
||||
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
|
||||
terminalCwd.set(handle, data.cwd)
|
||||
return
|
||||
}
|
||||
terminalCwd.delete(handle)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('mobile session startup', () => {
|
||||
expect(pendingActivationEffect).toContain(
|
||||
'pendingTerminalActivationAttemptRef.current === activationKey'
|
||||
)
|
||||
expect(pendingActivationEffect).toContain("sendRequest('session.tabs.activate'")
|
||||
expect(pendingActivationEffect).toContain('activateMobileSessionTab(client,')
|
||||
expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id')
|
||||
expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId')
|
||||
expect(pendingActivationEffect).toContain('notifyClients: false')
|
||||
@@ -68,8 +68,19 @@ describe('mobile session startup', () => {
|
||||
expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()')
|
||||
})
|
||||
|
||||
it('keeps mobile session tab activation local to the phone', () => {
|
||||
const activationRequests = source.split("sendRequest('session.tabs.activate'").slice(1)
|
||||
it('mirrors ready terminal taps while persisting them for headless hosts', () => {
|
||||
const readyTerminalSwitch = sliceBetween(
|
||||
'const switchTab = useCallback(',
|
||||
'const switchSessionTab = useCallback('
|
||||
)
|
||||
|
||||
expect(readyTerminalSwitch).toContain('focusMobileTerminal(client, handle)')
|
||||
expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,')
|
||||
expect(readyTerminalSwitch).toContain('notifyClients: false')
|
||||
})
|
||||
|
||||
it('keeps background and pending session-tab activation local to the phone', () => {
|
||||
const activationRequests = source.split('activateMobileSessionTab(client,').slice(1)
|
||||
|
||||
expect(activationRequests).toHaveLength(4)
|
||||
for (const request of activationRequests) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { activateMobileSessionTab, focusMobileTerminal } from './mobile-session-tab-activation'
|
||||
|
||||
function success(): RpcResponse {
|
||||
return { id: 'rpc-1', ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function clientWith(sendRequest: RpcClient['sendRequest']): Pick<RpcClient, 'sendRequest'> {
|
||||
return { sendRequest }
|
||||
}
|
||||
|
||||
describe('mobile session tab activation', () => {
|
||||
it('retries terminal focus once on the authenticated replacement after cutover', async () => {
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockRejectedValueOnce(new LogicalClientCutoverError())
|
||||
.mockResolvedValueOnce(success())
|
||||
|
||||
await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).resolves.toMatchObject(
|
||||
{
|
||||
ok: true
|
||||
}
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { terminal: 'terminal-1' })
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { terminal: 'terminal-1' })
|
||||
})
|
||||
|
||||
it('retries session-tab activation with the same target after cutover', async () => {
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockRejectedValueOnce(new LogicalClientCutoverError())
|
||||
.mockResolvedValueOnce(success())
|
||||
const params = {
|
||||
worktree: 'id:worktree-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
notifyClients: false as const
|
||||
}
|
||||
|
||||
await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.activate', params)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', params)
|
||||
})
|
||||
|
||||
it('does not retry unrelated transport failures', async () => {
|
||||
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockRejectedValue(new Error('offline'))
|
||||
|
||||
await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).rejects.toThrow(
|
||||
'offline'
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries at most once when consecutive cutovers interrupt activation', async () => {
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockRejectedValue(new LogicalClientCutoverError())
|
||||
|
||||
await expect(
|
||||
activateMobileSessionTab(clientWith(sendRequest), {
|
||||
worktree: 'id:worktree-1',
|
||||
tabId: 'tab-1',
|
||||
notifyClients: false
|
||||
})
|
||||
).rejects.toBeInstanceOf(LogicalClientCutoverError)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import {
|
||||
getMobileTerminalDiagnosticErrorName,
|
||||
logMobileTerminalDiagnostic,
|
||||
shortenMobileTerminalDiagnosticId
|
||||
} from './mobile-terminal-diagnostics'
|
||||
|
||||
type ActivationClient = Pick<RpcClient, 'sendRequest'>
|
||||
|
||||
type MobileSessionTabActivationParams = {
|
||||
worktree: string
|
||||
tabId: string
|
||||
leafId?: string
|
||||
notifyClients: false
|
||||
}
|
||||
|
||||
async function retryIdempotentActivationAfterCutover(
|
||||
request: () => Promise<RpcResponse>,
|
||||
operation: 'terminal.focus' | 'session.tabs.activate',
|
||||
target: string
|
||||
): Promise<RpcResponse> {
|
||||
const diagnosticTarget = shortenMobileTerminalDiagnosticId(target)
|
||||
logMobileTerminalDiagnostic('activation-request', { operation, target: diagnosticTarget })
|
||||
try {
|
||||
const response = await request()
|
||||
logMobileTerminalDiagnostic('activation-result', {
|
||||
operation,
|
||||
target: diagnosticTarget,
|
||||
ok: response.ok,
|
||||
rpcCode: response.ok ? null : response.error.code
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
if (!(error instanceof LogicalClientCutoverError)) {
|
||||
logMobileTerminalDiagnostic('activation-error', {
|
||||
operation,
|
||||
target: diagnosticTarget,
|
||||
errorName: getMobileTerminalDiagnosticErrorName(error)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
logMobileTerminalDiagnostic('activation-cutover-retry', {
|
||||
operation,
|
||||
target: diagnosticTarget
|
||||
})
|
||||
// Why: cutover rejects ambiguous in-flight work after the replacement is
|
||||
// active; these state-setting requests are idempotent and safe to repeat once.
|
||||
try {
|
||||
const response = await request()
|
||||
logMobileTerminalDiagnostic('activation-result', {
|
||||
operation,
|
||||
target: diagnosticTarget,
|
||||
ok: response.ok,
|
||||
rpcCode: response.ok ? null : response.error.code
|
||||
})
|
||||
return response
|
||||
} catch (retryError) {
|
||||
logMobileTerminalDiagnostic('activation-error', {
|
||||
operation,
|
||||
target: diagnosticTarget,
|
||||
errorName: getMobileTerminalDiagnosticErrorName(retryError)
|
||||
})
|
||||
throw retryError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function focusMobileTerminal(
|
||||
client: ActivationClient,
|
||||
terminal: string
|
||||
): Promise<RpcResponse> {
|
||||
return retryIdempotentActivationAfterCutover(
|
||||
() => client.sendRequest('terminal.focus', { terminal }),
|
||||
'terminal.focus',
|
||||
terminal
|
||||
)
|
||||
}
|
||||
|
||||
export function activateMobileSessionTab(
|
||||
client: ActivationClient,
|
||||
params: MobileSessionTabActivationParams
|
||||
): Promise<RpcResponse> {
|
||||
return retryIdempotentActivationAfterCutover(
|
||||
() => client.sendRequest('session.tabs.activate', params),
|
||||
'session.tabs.activate',
|
||||
params.tabId
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getMobileTerminalDiagnosticErrorName,
|
||||
logMobileTerminalDiagnostic,
|
||||
MobileTerminalDiagnostics,
|
||||
shortenMobileTerminalDiagnosticId
|
||||
} from './mobile-terminal-diagnostics'
|
||||
|
||||
describe('mobile terminal diagnostics', () => {
|
||||
it('keeps only the correlatable suffix of identifiers', () => {
|
||||
expect(shortenMobileTerminalDiagnosticId('terminal-secret-prefix-12345678')).toBe('12345678')
|
||||
expect(shortenMobileTerminalDiagnosticId('short')).toBe('short')
|
||||
expect(shortenMobileTerminalDiagnosticId(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports thrown error types without copying potentially sensitive messages', () => {
|
||||
expect(getMobileTerminalDiagnosticErrorName(new TypeError('/private/worktree failed'))).toBe(
|
||||
'TypeError'
|
||||
)
|
||||
expect(getMobileTerminalDiagnosticErrorName('raw failure')).toBe('string')
|
||||
})
|
||||
|
||||
it('uses one filterable structured log tag', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
logMobileTerminalDiagnostic('stream-armed', { handle: '12345678', seq: 2 })
|
||||
|
||||
expect(log).toHaveBeenCalledWith('[terminal-diagnostic]', 'stream-armed', {
|
||||
handle: '12345678',
|
||||
seq: 2
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it('forgets first-event state when a terminal unsubscribes', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const diagnostics = new MobileTerminalDiagnostics()
|
||||
|
||||
diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed')
|
||||
diagnostics.terminalUnsubscribed('terminal-1')
|
||||
diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed')
|
||||
|
||||
expect(log).toHaveBeenCalledTimes(2)
|
||||
log.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]'
|
||||
|
||||
type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined
|
||||
|
||||
export type MobileTerminalDiagnosticDetails = Readonly<
|
||||
Record<string, MobileTerminalDiagnosticValue>
|
||||
>
|
||||
|
||||
type DiagnosticTab = {
|
||||
readonly id: string
|
||||
readonly type: string
|
||||
readonly isActive: boolean
|
||||
readonly terminal?: string | null
|
||||
}
|
||||
|
||||
type DiagnosticTabsSnapshot = {
|
||||
readonly publicationEpoch?: string
|
||||
readonly snapshotVersion: number
|
||||
readonly tabs: readonly DiagnosticTab[]
|
||||
}
|
||||
|
||||
type DiagnosticDimensions = { readonly cols: number; readonly rows: number } | null | undefined
|
||||
|
||||
// Why: full runtime identifiers make shared logs unnecessarily sensitive; the
|
||||
// suffix is enough to correlate lifecycle events within one reproduction.
|
||||
export function shortenMobileTerminalDiagnosticId(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
return value.slice(-8)
|
||||
}
|
||||
|
||||
export function getMobileTerminalDiagnosticErrorName(error: unknown): string {
|
||||
if (error instanceof Error && error.name) {
|
||||
return error.name
|
||||
}
|
||||
return typeof error
|
||||
}
|
||||
|
||||
export function logMobileTerminalDiagnostic(
|
||||
event: string,
|
||||
details: MobileTerminalDiagnosticDetails = {}
|
||||
): void {
|
||||
// Why: lifecycle diagnostics are intentionally available for HMR repros,
|
||||
// but high-frequency WebView events must not add production log overhead.
|
||||
if (typeof __DEV__ !== 'undefined' && !__DEV__) {
|
||||
return
|
||||
}
|
||||
// Keep this structured and content-free so users can safely share a filtered log.
|
||||
console.log(MOBILE_TERMINAL_DIAGNOSTIC_TAG, event, details)
|
||||
}
|
||||
|
||||
export class MobileTerminalDiagnostics {
|
||||
private readonly streamGateByHandle = new Map<string, string>()
|
||||
private readonly firstStreamEventSeqByHandle = new Map<string, number>()
|
||||
private lastFetchedTabsSignature: string | null = null
|
||||
private lastAppliedTabsSignature: string | null = null
|
||||
private lastTabsFetchStartAt = 0
|
||||
private tabsFetchSkipLogged = false
|
||||
|
||||
clearTerminalCache(): void {
|
||||
this.streamGateByHandle.clear()
|
||||
this.firstStreamEventSeqByHandle.clear()
|
||||
}
|
||||
|
||||
resetRoute(): void {
|
||||
this.clearTerminalCache()
|
||||
this.lastFetchedTabsSignature = null
|
||||
this.lastAppliedTabsSignature = null
|
||||
this.lastTabsFetchStartAt = 0
|
||||
this.tabsFetchSkipLogged = false
|
||||
}
|
||||
|
||||
terminalUnsubscribed(handle: string): void {
|
||||
this.streamGateByHandle.delete(handle)
|
||||
this.firstStreamEventSeqByHandle.delete(handle)
|
||||
}
|
||||
|
||||
viewportMeasured(handle: string, dims: DiagnosticDimensions, frameHeight: number): void {
|
||||
logMobileTerminalDiagnostic('viewport-measure', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
ok: dims != null,
|
||||
cols: dims?.cols,
|
||||
rows: dims?.rows,
|
||||
frameHeight: Math.round(frameHeight)
|
||||
})
|
||||
}
|
||||
|
||||
streamSkipped(handle: string, reason: string, isActive: boolean): void {
|
||||
if (this.streamGateByHandle.get(handle) === reason) {
|
||||
return
|
||||
}
|
||||
this.streamGateByHandle.set(handle, reason)
|
||||
logMobileTerminalDiagnostic('stream-skipped', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
reason,
|
||||
isActive
|
||||
})
|
||||
}
|
||||
|
||||
streamArmed(handle: string, seq: number, viewport: DiagnosticDimensions): void {
|
||||
this.streamGateByHandle.delete(handle)
|
||||
logMobileTerminalDiagnostic('stream-armed', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
hasViewport: viewport != null,
|
||||
viewportCols: viewport?.cols,
|
||||
viewportRows: viewport?.rows
|
||||
})
|
||||
}
|
||||
|
||||
firstStreamEvent(handle: string, seq: number, type: unknown): void {
|
||||
if (this.firstStreamEventSeqByHandle.get(handle) === seq) {
|
||||
return
|
||||
}
|
||||
this.firstStreamEventSeqByHandle.set(handle, seq)
|
||||
logMobileTerminalDiagnostic('stream-first-event', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
type: typeof type === 'string' ? type : 'unknown'
|
||||
})
|
||||
}
|
||||
|
||||
streamScrollback(
|
||||
handle: string,
|
||||
seq: number,
|
||||
eventSeq: number | null,
|
||||
data: Readonly<Record<string, unknown>>
|
||||
): void {
|
||||
logMobileTerminalDiagnostic('stream-scrollback', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
eventSeq,
|
||||
cols: typeof data.cols === 'number' ? data.cols : null,
|
||||
rows: typeof data.rows === 'number' ? data.rows : null,
|
||||
serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0,
|
||||
displayMode: typeof data.displayMode === 'string' ? data.displayMode : null,
|
||||
source: typeof data.source === 'string' ? data.source : null,
|
||||
scrollbackRows: typeof data.scrollbackRows === 'number' ? data.scrollbackRows : null,
|
||||
truncated: data.truncated === true || data.truncatedByByteBudget === true
|
||||
})
|
||||
}
|
||||
|
||||
streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void {
|
||||
logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
cols: dims.cols,
|
||||
rows: dims.rows
|
||||
})
|
||||
}
|
||||
|
||||
streamResized(
|
||||
handle: string,
|
||||
seq: number,
|
||||
eventSeq: number | null,
|
||||
data: Readonly<Record<string, unknown>>,
|
||||
hasRef: boolean
|
||||
): void {
|
||||
logMobileTerminalDiagnostic('stream-resized', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
eventSeq,
|
||||
cols: typeof data.cols === 'number' ? data.cols : null,
|
||||
rows: typeof data.rows === 'number' ? data.rows : null,
|
||||
serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0,
|
||||
hasRef
|
||||
})
|
||||
}
|
||||
|
||||
tabsApplied(
|
||||
snapshot: DiagnosticTabsSnapshot,
|
||||
tabs: readonly DiagnosticTab[],
|
||||
activeTab: DiagnosticTab | null,
|
||||
selectionSource: string
|
||||
): void {
|
||||
const activeHandle =
|
||||
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
|
||||
? activeTab.terminal
|
||||
: null
|
||||
const appliedSnapshot = { ...snapshot, tabs }
|
||||
const signature = [
|
||||
appliedSnapshot.publicationEpoch ?? '',
|
||||
appliedSnapshot.snapshotVersion,
|
||||
activeTab?.id ?? '',
|
||||
activeHandle ?? '',
|
||||
selectionSource
|
||||
].join(':')
|
||||
if (this.lastAppliedTabsSignature === signature) {
|
||||
return
|
||||
}
|
||||
this.lastAppliedTabsSignature = signature
|
||||
this.logTabs('tabs-applied', appliedSnapshot, activeTab, activeHandle, { selectionSource })
|
||||
}
|
||||
|
||||
tabsFetchSkipped(reason: string): void {
|
||||
if (reason === 'already-in-flight' && this.tabsFetchSkipLogged) {
|
||||
return
|
||||
}
|
||||
this.tabsFetchSkipLogged = reason === 'already-in-flight'
|
||||
logMobileTerminalDiagnostic('tabs-fetch-skipped', { reason })
|
||||
}
|
||||
|
||||
tabsFetchStarted(worktreeId: string): void {
|
||||
this.tabsFetchSkipLogged = false
|
||||
const now = Date.now()
|
||||
if (this.lastFetchedTabsSignature != null && now - this.lastTabsFetchStartAt < 10_000) {
|
||||
return
|
||||
}
|
||||
this.lastTabsFetchStartAt = now
|
||||
logMobileTerminalDiagnostic('tabs-fetch-start', {
|
||||
worktree: shortenMobileTerminalDiagnosticId(worktreeId)
|
||||
})
|
||||
}
|
||||
|
||||
tabsFetchFailed(rpcCode: string): void {
|
||||
logMobileTerminalDiagnostic('tabs-fetch-rpc-failure', { rpcCode })
|
||||
}
|
||||
|
||||
tabsFetchErrored(error: unknown): void {
|
||||
logMobileTerminalDiagnostic('tabs-fetch-error', {
|
||||
errorName: getMobileTerminalDiagnosticErrorName(error)
|
||||
})
|
||||
}
|
||||
|
||||
tabsFetchSucceeded(snapshot: DiagnosticTabsSnapshot): void {
|
||||
const activeTab = snapshot.tabs.find((tab) => tab.isActive) ?? null
|
||||
const signature = [
|
||||
snapshot.publicationEpoch ?? '',
|
||||
snapshot.snapshotVersion,
|
||||
snapshot.tabs.length,
|
||||
activeTab?.id ?? '',
|
||||
activeTab?.type ?? ''
|
||||
].join(':')
|
||||
if (this.lastFetchedTabsSignature === signature) {
|
||||
return
|
||||
}
|
||||
this.lastFetchedTabsSignature = signature
|
||||
const activeHandle =
|
||||
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
|
||||
? activeTab.terminal
|
||||
: null
|
||||
this.logTabs('tabs-fetch-success', snapshot, activeTab, activeHandle)
|
||||
}
|
||||
|
||||
tabSwitch(tabType: string, tabId: string, pending: boolean, handle?: string): void {
|
||||
logMobileTerminalDiagnostic('tab-switch', {
|
||||
tabType,
|
||||
tab: shortenMobileTerminalDiagnosticId(tabId),
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
pending
|
||||
})
|
||||
}
|
||||
|
||||
webViewRef(handle: string, attached: boolean): void {
|
||||
logMobileTerminalDiagnostic('webview-ref', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
attached
|
||||
})
|
||||
}
|
||||
|
||||
webViewReady(handle: string, reload: boolean, isActive: boolean): void {
|
||||
logMobileTerminalDiagnostic('webview-ready', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
reload,
|
||||
isActive
|
||||
})
|
||||
}
|
||||
|
||||
private logTabs(
|
||||
event: 'tabs-applied' | 'tabs-fetch-success',
|
||||
snapshot: DiagnosticTabsSnapshot,
|
||||
activeTab: DiagnosticTab | null,
|
||||
activeHandle: string | null,
|
||||
extra: MobileTerminalDiagnosticDetails = {}
|
||||
): void {
|
||||
logMobileTerminalDiagnostic(event, {
|
||||
publication: shortenMobileTerminalDiagnosticId(snapshot.publicationEpoch),
|
||||
snapshotVersion: snapshot.snapshotVersion,
|
||||
tabCount: snapshot.tabs.length,
|
||||
terminalTabCount: snapshot.tabs.filter((tab) => tab.type === 'terminal').length,
|
||||
pendingTerminalCount: snapshot.tabs.filter(
|
||||
(tab) => tab.type === 'terminal' && typeof tab.terminal !== 'string'
|
||||
).length,
|
||||
activeTab: shortenMobileTerminalDiagnosticId(activeTab?.id),
|
||||
activeType: activeTab?.type ?? null,
|
||||
activeHandle: shortenMobileTerminalDiagnosticId(activeHandle),
|
||||
...extra
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
acceptSessionSnapshot,
|
||||
applyClosedTabTombstones,
|
||||
confirmsMirroredTabSelection,
|
||||
type AppliedSnapshotMarker
|
||||
} from './session-tab-snapshot-gate'
|
||||
|
||||
@@ -50,6 +51,18 @@ describe('acceptSessionSnapshot', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmsMirroredTabSelection', () => {
|
||||
it('does not treat phone-local persistence as desktop focus confirmation', () => {
|
||||
expect(confirmsMirroredTabSelection('mobile-local:abc')).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts renderer, headless, and legacy publications as confirmation', () => {
|
||||
expect(confirmsMirroredTabSelection('renderer:abc')).toBe(true)
|
||||
expect(confirmsMirroredTabSelection('headless:abc')).toBe(true)
|
||||
expect(confirmsMirroredTabSelection()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyClosedTabTombstones', () => {
|
||||
const tab = (id: string): { id: string } => ({ id })
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ export function acceptSessionSnapshot(
|
||||
return true
|
||||
}
|
||||
|
||||
export function confirmsMirroredTabSelection(publicationEpoch?: string): boolean {
|
||||
// Why: phone-local persistence acknowledges the mutation, not host focus;
|
||||
// keep the pending guard until any host publication confirms the selection.
|
||||
return !publicationEpoch?.startsWith('mobile-local:')
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops tabs the user just closed locally (tombstoned) until the publisher's
|
||||
* snapshot also drops them or the tombstone expires. Mutates `tombstones`,
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
getMobilePrCreateBlockMessage,
|
||||
mobileRepoSelectorFromWorktreeId,
|
||||
resolveMobilePrPrefill,
|
||||
shouldPushBeforeMobilePrCreate
|
||||
shouldPushBeforeMobilePrCreate,
|
||||
type MobilePrPrefill
|
||||
} from './mobile-pr-create'
|
||||
|
||||
function ok(result: unknown): RpcSuccess {
|
||||
@@ -122,6 +123,19 @@ describe('mobile create form gating parity', () => {
|
||||
|
||||
expect(mobileAllowsComposer).toBe(desktopAllowsComposer)
|
||||
})
|
||||
|
||||
it('stays safely blocked for a reason added by a newer desktop contract', () => {
|
||||
expect(
|
||||
getMobilePrCreateBlockMessage({
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
title: 'Add feature',
|
||||
body: '',
|
||||
canCreate: false,
|
||||
blockedReason: 'future_desktop_reason' as unknown as MobilePrPrefill['blockedReason']
|
||||
})
|
||||
).toBe('This branch is not ready for a pull request yet.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveMobilePrPrefill', () => {
|
||||
|
||||
@@ -66,9 +66,15 @@ export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string
|
||||
return `A ${copy.reviewLabel} already exists for this branch.`
|
||||
case 'fork_head_unsupported':
|
||||
return `Creating a ${copy.reviewLabel} from this fork is not supported.`
|
||||
case 'base_not_on_remote':
|
||||
return `Push the base branch before creating a ${copy.reviewLabel}.`
|
||||
case 'needs_push':
|
||||
case null:
|
||||
case undefined:
|
||||
return `This branch is not ready for a ${copy.reviewLabel} yet.`
|
||||
default:
|
||||
// Why: desktop can add blocked reasons before a long-lived mobile branch
|
||||
// catches up; remain safely blocked while preserving merge-ref typechecks.
|
||||
return `This branch is not ready for a ${copy.reviewLabel} yet.`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,257 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
displayHostEndpoint,
|
||||
endpointPort,
|
||||
endpointScheme,
|
||||
normalizeHostEndpoint
|
||||
} from './host-endpoint'
|
||||
|
||||
describe('displayHostEndpoint', () => {
|
||||
it('shows host:port for websocket URLs', () => {
|
||||
expect(displayHostEndpoint('ws://192.168.1.10:6768')).toBe('192.168.1.10:6768')
|
||||
// Why: URL omits default scheme ports (443 for wss); use a non-default port.
|
||||
expect(displayHostEndpoint('wss://desk.example:8443')).toBe('desk.example:8443')
|
||||
})
|
||||
|
||||
it('returns the raw string when not a URL', () => {
|
||||
expect(displayHostEndpoint('not-a-url')).toBe('not-a-url')
|
||||
})
|
||||
|
||||
it('brackets IPv6 hostnames for round-trip safety', () => {
|
||||
expect(displayHostEndpoint('ws://[fd7a:115c:a1e0::1]:6768')).toBe('[fd7a:115c:a1e0::1]:6768')
|
||||
})
|
||||
})
|
||||
|
||||
describe('endpointPort', () => {
|
||||
it('reads the port when present', () => {
|
||||
expect(endpointPort('ws://192.168.1.10:6768')).toBe('6768')
|
||||
})
|
||||
|
||||
it('returns undefined when the port is omitted', () => {
|
||||
expect(endpointPort('ws://192.168.1.10')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves scheme-default ports that URL.port hides', () => {
|
||||
expect(endpointPort('ws://192.168.1.10:80')).toBe('80')
|
||||
expect(endpointPort('wss://desk.example:443')).toBe('443')
|
||||
})
|
||||
})
|
||||
|
||||
describe('endpointScheme', () => {
|
||||
it('returns wss for a wss:// endpoint', () => {
|
||||
expect(endpointScheme('wss://desk.example:8443')).toBe('wss')
|
||||
})
|
||||
|
||||
it('returns ws for a ws:// endpoint', () => {
|
||||
expect(endpointScheme('ws://192.168.1.10:6768')).toBe('ws')
|
||||
})
|
||||
|
||||
it('falls back to ws for a non-URL endpoint', () => {
|
||||
expect(endpointScheme('not-a-url')).toBe('ws')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeHostEndpoint', () => {
|
||||
it('accepts a full ws URL', () => {
|
||||
expect(normalizeHostEndpoint('ws://100.64.0.5:6768')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://100.64.0.5:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts wss and preserves scheme', () => {
|
||||
expect(normalizeHostEndpoint('wss://desk.example:8443')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'wss://desk.example:8443'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts bare host:port', () => {
|
||||
expect(normalizeHostEndpoint('192.168.1.10:6768')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://192.168.1.10:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults missing port to 6768', () => {
|
||||
expect(normalizeHostEndpoint('192.168.1.10')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://192.168.1.10:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses fallbackPort when the user omits the port', () => {
|
||||
expect(normalizeHostEndpoint('mac-mini.local', { fallbackPort: '7777' })).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://mac-mini.local:7777'
|
||||
})
|
||||
})
|
||||
|
||||
it('fills missing port on scheme URLs from fallbackPort', () => {
|
||||
expect(normalizeHostEndpoint('ws://192.168.1.10', { fallbackPort: '9000' })).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://192.168.1.10:9000'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves explicit ws :80 and wss :443 instead of rewriting to fallback', () => {
|
||||
expect(normalizeHostEndpoint('ws://192.168.1.10:80', { fallbackPort: '6768' })).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://192.168.1.10:80'
|
||||
})
|
||||
expect(normalizeHostEndpoint('wss://desk.example:443', { fallbackPort: '6768' })).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'wss://desk.example:443'
|
||||
})
|
||||
expect(displayHostEndpoint('ws://192.168.1.10:80')).toBe('192.168.1.10:80')
|
||||
expect(displayHostEndpoint('wss://desk.example:443')).toBe('desk.example:443')
|
||||
})
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(normalizeHostEndpoint(' 10.0.0.2:6768 ')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://10.0.0.2:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty input', () => {
|
||||
expect(normalizeHostEndpoint(' ')).toEqual({
|
||||
ok: false,
|
||||
error: 'Enter a host address.'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-websocket schemes', () => {
|
||||
expect(normalizeHostEndpoint('http://192.168.1.10:6768')).toEqual({
|
||||
ok: false,
|
||||
error: 'Use ws:// or wss:// (or host:port).'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects explicit invalid ports without falling back', () => {
|
||||
for (const input of [
|
||||
'192.168.1.10:0',
|
||||
'192.168.1.10:99999',
|
||||
'ws://192.168.1.10:0',
|
||||
'ws://192.168.1.10:99999'
|
||||
]) {
|
||||
expect(normalizeHostEndpoint(input, { fallbackPort: '6768' })).toEqual({
|
||||
ok: false,
|
||||
error: 'Port must be 1–65535.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects bare hosts with path, query, or spaces', () => {
|
||||
expect(normalizeHostEndpoint('desk/path')).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
expect(normalizeHostEndpoint('desk?route')).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
expect(normalizeHostEndpoint('desk name')).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects scheme URLs with path or query', () => {
|
||||
expect(normalizeHostEndpoint('ws://desk.example/path')).toEqual({
|
||||
ok: false,
|
||||
error: 'Host must not include a path or query.'
|
||||
})
|
||||
expect(normalizeHostEndpoint('ws://desk.example?route=1')).toEqual({
|
||||
ok: false,
|
||||
error: 'Host must not include a path or query.'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts bracketed IPv6 with port', () => {
|
||||
expect(normalizeHostEndpoint('[fd7a:115c:a1e0::1]:6768')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://[fd7a:115c:a1e0::1]:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed IPv6 that a WebSocket URL cannot parse', () => {
|
||||
expect(normalizeHostEndpoint('[1::2::3]:6768')).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-canonical numeric IPv4 forms in bare addresses', () => {
|
||||
for (const host of ['999.999.999.999', '010.0.0.1', '127.1', '0x7f000001']) {
|
||||
expect(normalizeHostEndpoint(`${host}:6768`)).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects non-canonical numeric IPv4 forms before URL normalization', () => {
|
||||
for (const host of ['999.999.999.999', '010.0.0.1', '127.1', '0x7f000001']) {
|
||||
expect(normalizeHostEndpoint(`ws://${host}:6768`)).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects encoded and trailing-dot numeric IPv4 aliases', () => {
|
||||
for (const input of ['ws://127.1.:6768', 'wss://127.1.:6768', 'ws://%31%32%37.1:6768']) {
|
||||
expect(normalizeHostEndpoint(input)).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty userinfo before URL normalization', () => {
|
||||
expect(normalizeHostEndpoint('ws://@127.1:6768')).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid address.'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a path or query before validating a numeric hostname alias', () => {
|
||||
for (const input of ['ws://127.1/path', 'ws://127.1?route=1']) {
|
||||
expect(normalizeHostEndpoint(input)).toEqual({
|
||||
ok: false,
|
||||
error: 'Host must not include a path or query.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps ordinary DNS names that contain numeric labels', () => {
|
||||
expect(normalizeHostEndpoint('desk123.local:6768')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://desk123.local:6768'
|
||||
})
|
||||
expect(normalizeHostEndpoint('ws://123.example:6768')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://123.example:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves wss when re-normalizing bare host:port via fallbackScheme', () => {
|
||||
expect(
|
||||
normalizeHostEndpoint('desk.example:8443', { fallbackScheme: 'wss', fallbackPort: '8443' })
|
||||
).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'wss://desk.example:8443'
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips displayed IPv6 endpoints without port corruption', () => {
|
||||
const original = 'ws://[fd7a:115c:a1e0::1]:6768'
|
||||
const displayed = displayHostEndpoint(original)
|
||||
expect(displayed).toBe('[fd7a:115c:a1e0::1]:6768')
|
||||
expect(normalizeHostEndpoint(displayed, { fallbackPort: '6768' })).toEqual({
|
||||
ok: true,
|
||||
endpoint: original
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,365 @@
|
||||
// Why: mobile host profiles store a single websocket endpoint fixed at pair
|
||||
// time. Edit-host lets the user rewrite host/port without re-pairing; this
|
||||
// helper accepts phone-friendly input (bare IP, host:port, or full ws URL)
|
||||
// and normalizes to the ws(s):// form RpcClient expects.
|
||||
|
||||
export type NormalizeHostEndpointResult =
|
||||
| { ok: true; endpoint: string }
|
||||
| { ok: false; error: string }
|
||||
|
||||
type WebsocketUrlPortResolution =
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'valid'; port: string }
|
||||
| { kind: 'invalid' }
|
||||
|
||||
type RawSchemeAuthority = {
|
||||
hostname: string | null
|
||||
hasUserInfo: boolean
|
||||
hasPathOrQuery: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = '6768'
|
||||
const NUMERIC_IPV4_CANDIDATE = /^(?:0[xX][0-9a-fA-F]+|\d+)(?:\.(?:0[xX][0-9a-fA-F]+|\d+))*$/
|
||||
|
||||
export function displayHostEndpoint(endpoint: string): string {
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
// Why: some URL parsers leave IPv6 brackets on hostname, others strip them.
|
||||
// Normalize once so round-trip through normalizeHostEndpoint stays stable.
|
||||
const host = formatHostForUrl(unwrapHostname(url.hostname))
|
||||
const port = resolveWebsocketUrlPort(endpoint, url)
|
||||
if (port.kind === 'invalid') {
|
||||
return endpoint
|
||||
}
|
||||
return port.kind === 'valid' ? `${host}:${port.port}` : host
|
||||
} catch {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapHostname(hostname: string): string {
|
||||
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover an explicitly written port from a ws(s) URL authority.
|
||||
* Why: `new URL('ws://host:80').port` and `wss://host:443` are empty — the
|
||||
* URL parser hides scheme-default ports, so callers that need the user's
|
||||
* literal :80/:443 must re-parse the original string.
|
||||
*/
|
||||
function extractExplicitPortFromWebsocketUrl(input: string): string | null {
|
||||
const withoutScheme = input.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '')
|
||||
if (withoutScheme.startsWith('[')) {
|
||||
const close = withoutScheme.indexOf(']')
|
||||
if (close <= 1) {
|
||||
return null
|
||||
}
|
||||
const rest = withoutScheme.slice(close + 1)
|
||||
const match = /^:(\d+)(?=[/?#]|$)/.exec(rest)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
const end = withoutScheme.search(/[/?#]/)
|
||||
const authority = end === -1 ? withoutScheme : withoutScheme.slice(0, end)
|
||||
const at = authority.lastIndexOf('@')
|
||||
const hostPort = at === -1 ? authority : authority.slice(at + 1)
|
||||
const match = /:(\d+)$/.exec(hostPort)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function resolveWebsocketUrlPort(input: string, url?: URL): WebsocketUrlPortResolution {
|
||||
const explicit = extractExplicitPortFromWebsocketUrl(input)
|
||||
// Why: missing and invalid are different states. Treating both as null lets
|
||||
// an explicit :0/:99999 silently inherit fallbackPort on permissive parsers.
|
||||
if (explicit !== null && !isValidPort(explicit)) {
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
if (url?.port) {
|
||||
return isValidPort(url.port) ? { kind: 'valid', port: url.port } : { kind: 'invalid' }
|
||||
}
|
||||
if (explicit !== null) {
|
||||
return { kind: 'valid', port: explicit }
|
||||
}
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
|
||||
export function endpointPort(endpoint: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
const port = resolveWebsocketUrlPort(endpoint, url)
|
||||
return port.kind === 'valid' ? port.port : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function endpointScheme(endpoint: string): 'ws' | 'wss' {
|
||||
try {
|
||||
const protocol = new URL(endpoint).protocol.replace(':', '')
|
||||
return protocol === 'wss' ? 'wss' : 'ws'
|
||||
} catch {
|
||||
return 'ws'
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeHostEndpoint(
|
||||
input: string,
|
||||
options?: { fallbackPort?: string | number; fallbackScheme?: 'ws' | 'wss' }
|
||||
): NormalizeHostEndpointResult {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: 'Enter a host address.' }
|
||||
}
|
||||
|
||||
const fallbackPort = resolveFallbackPort(options?.fallbackPort)
|
||||
const fallbackScheme = options?.fallbackScheme === 'wss' ? 'wss' : 'ws'
|
||||
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
|
||||
return normalizeSchemeUrl(trimmed, fallbackPort)
|
||||
}
|
||||
|
||||
return normalizeHostPort(trimmed, fallbackPort, fallbackScheme)
|
||||
}
|
||||
|
||||
function resolveFallbackPort(value: string | number | undefined): string {
|
||||
if (value == null) {
|
||||
return DEFAULT_PORT
|
||||
}
|
||||
const asString = String(value).trim()
|
||||
if (!asString || !isValidPort(asString)) {
|
||||
return DEFAULT_PORT
|
||||
}
|
||||
return asString
|
||||
}
|
||||
|
||||
function normalizeSchemeUrl(input: string, fallbackPort: string): NormalizeHostEndpointResult {
|
||||
const explicitPort = resolveWebsocketUrlPort(input)
|
||||
if (explicitPort.kind === 'invalid') {
|
||||
return { ok: false, error: 'Port must be 1–65535.' }
|
||||
}
|
||||
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(input)?.[1]?.toLowerCase()
|
||||
if (scheme !== 'ws' && scheme !== 'wss') {
|
||||
return { ok: false, error: 'Use ws:// or wss:// (or host:port).' }
|
||||
}
|
||||
|
||||
const rawAuthority = parseRawSchemeAuthority(input)
|
||||
if (rawAuthority.hasUserInfo) {
|
||||
return { ok: false, error: 'Not a valid address.' }
|
||||
}
|
||||
if (rawAuthority.hasPathOrQuery) {
|
||||
return { ok: false, error: 'Host must not include a path or query.' }
|
||||
}
|
||||
if (
|
||||
rawAuthority.hostname &&
|
||||
validateNumericIpv4Candidate(normalizeRawNumericIpv4Candidate(rawAuthority.hostname))
|
||||
) {
|
||||
return { ok: false, error: 'Not a valid hostname.' }
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(input)
|
||||
} catch {
|
||||
return { ok: false, error: 'Not a valid address.' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
|
||||
return { ok: false, error: 'Use ws:// or wss:// (or host:port).' }
|
||||
}
|
||||
if (!url.hostname) {
|
||||
return { ok: false, error: 'Missing hostname.' }
|
||||
}
|
||||
|
||||
// Why: edit-host persists a bare host:port WebSocket endpoint. Path/query/
|
||||
// userinfo are not part of the pairing contract — reject rather than strip
|
||||
// so typos like desk/path or desk?route cannot be saved silently.
|
||||
if (url.username || url.password) {
|
||||
return { ok: false, error: 'Not a valid address.' }
|
||||
}
|
||||
if ((url.pathname && url.pathname !== '/') || url.search || url.hash) {
|
||||
return { ok: false, error: 'Host must not include a path or query.' }
|
||||
}
|
||||
|
||||
const hostname = unwrapHostname(url.hostname)
|
||||
// Why: WHATWG URL accepts legacy aliases and rewrites them to a different
|
||||
// IPv4 address. Only an already-canonical raw dotted quad may become IPv4.
|
||||
if (rawAuthority.hostname && isCanonicalIpv4(hostname) && rawAuthority.hostname !== hostname) {
|
||||
return { ok: false, error: 'Not a valid hostname.' }
|
||||
}
|
||||
const hostError = validateHostname(hostname)
|
||||
if (hostError) {
|
||||
return { ok: false, error: hostError }
|
||||
}
|
||||
|
||||
// Why: keep explicit :80/:443 (URL.port is empty for scheme defaults) instead
|
||||
// of rewriting them to fallbackPort (usually 6768).
|
||||
const resolvedPort = resolveWebsocketUrlPort(input, url)
|
||||
if (resolvedPort.kind === 'invalid') {
|
||||
return { ok: false, error: 'Port must be 1–65535.' }
|
||||
}
|
||||
const port = resolvedPort.kind === 'valid' ? resolvedPort.port : fallbackPort
|
||||
|
||||
// Why: rebuild so accidental whitespace never reaches the WebSocket constructor.
|
||||
return { ok: true, endpoint: `${url.protocol}//${formatHostForUrl(hostname)}:${port}` }
|
||||
}
|
||||
|
||||
function parseRawSchemeAuthority(input: string): RawSchemeAuthority {
|
||||
const schemeEnd = input.indexOf('://')
|
||||
const remainder = input.slice(schemeEnd + 3)
|
||||
const authorityEnd = remainder.search(/[/?#]/)
|
||||
const authority = authorityEnd === -1 ? remainder : remainder.slice(0, authorityEnd)
|
||||
const suffix = authorityEnd === -1 ? '' : remainder.slice(authorityEnd)
|
||||
const hasUserInfo = authority.includes('@')
|
||||
const hostPort = hasUserInfo ? authority.slice(authority.lastIndexOf('@') + 1) : authority
|
||||
if (!hostPort) {
|
||||
return { hostname: null, hasUserInfo, hasPathOrQuery: suffix !== '' && suffix !== '/' }
|
||||
}
|
||||
if (hostPort.startsWith('[')) {
|
||||
const close = hostPort.indexOf(']')
|
||||
return {
|
||||
hostname: close > 1 ? hostPort.slice(1, close) : null,
|
||||
hasUserInfo,
|
||||
hasPathOrQuery: suffix !== '' && suffix !== '/'
|
||||
}
|
||||
}
|
||||
const lastColon = hostPort.lastIndexOf(':')
|
||||
return {
|
||||
hostname: lastColon === -1 ? hostPort : hostPort.slice(0, lastColon),
|
||||
hasUserInfo,
|
||||
hasPathOrQuery: suffix !== '' && suffix !== '/'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHostPort(
|
||||
input: string,
|
||||
fallbackPort: string,
|
||||
fallbackScheme: 'ws' | 'wss'
|
||||
): NormalizeHostEndpointResult {
|
||||
let host: string
|
||||
let port: string | undefined
|
||||
|
||||
if (input.startsWith('[')) {
|
||||
const close = input.indexOf(']')
|
||||
if (close <= 1) {
|
||||
return { ok: false, error: 'Not a valid address.' }
|
||||
}
|
||||
host = input.slice(1, close)
|
||||
const rest = input.slice(close + 1)
|
||||
if (rest.startsWith(':')) {
|
||||
port = rest.slice(1)
|
||||
} else if (rest.length > 0) {
|
||||
return { ok: false, error: 'Not a valid address.' }
|
||||
}
|
||||
} else {
|
||||
const firstColon = input.indexOf(':')
|
||||
const lastColon = input.lastIndexOf(':')
|
||||
if (firstColon !== -1 && firstColon === lastColon) {
|
||||
host = input.slice(0, firstColon)
|
||||
port = input.slice(firstColon + 1)
|
||||
} else {
|
||||
// No port, or bare IPv6 (multiple colons, no brackets).
|
||||
host = input
|
||||
}
|
||||
}
|
||||
|
||||
host = host.trim()
|
||||
if (!host) {
|
||||
return { ok: false, error: 'Missing hostname.' }
|
||||
}
|
||||
|
||||
// Why: bare input is not a URL, so characters that only make sense in a URL
|
||||
// (path, query, fragment, whitespace) must not be treated as hostname bytes.
|
||||
const hostError = validateHostname(host)
|
||||
if (hostError) {
|
||||
return { ok: false, error: hostError }
|
||||
}
|
||||
|
||||
if (port !== undefined) {
|
||||
port = port.trim()
|
||||
if (!isValidPort(port)) {
|
||||
return { ok: false, error: 'Port must be 1–65535.' }
|
||||
}
|
||||
}
|
||||
|
||||
const finalPort = port ?? fallbackPort
|
||||
return { ok: true, endpoint: `${fallbackScheme}://${formatHostForUrl(host)}:${finalPort}` }
|
||||
}
|
||||
|
||||
function formatHostForUrl(host: string): string {
|
||||
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject hostnames that would be illegal or ambiguous in a websocket URL.
|
||||
* Allows DNS labels, `.local` mDNS, IPv4, and IPv6 hex forms.
|
||||
*/
|
||||
function validateHostname(host: string): string | null {
|
||||
if (!host) {
|
||||
return 'Missing hostname.'
|
||||
}
|
||||
// Spaces, path/query/fragment separators, userinfo separators, brackets.
|
||||
if (/[\s/?#@[\]]/.test(host)) {
|
||||
return 'Not a valid hostname.'
|
||||
}
|
||||
const numericIpv4Error = validateNumericIpv4Candidate(host)
|
||||
if (numericIpv4Error) {
|
||||
return numericIpv4Error
|
||||
}
|
||||
if (host.includes(':')) {
|
||||
// Why: a hex/colon regex accepts malformed forms such as two `::` runs.
|
||||
// Reuse the URL parser that WebSocket will ultimately use.
|
||||
if (!/^[0-9a-fA-F:]+$/.test(host)) {
|
||||
return 'Not a valid hostname.'
|
||||
}
|
||||
try {
|
||||
new URL(`ws://[${host}]:${DEFAULT_PORT}`)
|
||||
} catch {
|
||||
return 'Not a valid hostname.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
// DNS / IPv4 / mDNS: labels of alnum and hyphen, dots between, no empty labels.
|
||||
if (
|
||||
!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(
|
||||
host
|
||||
)
|
||||
) {
|
||||
return 'Not a valid hostname.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function validateNumericIpv4Candidate(host: string): string | null {
|
||||
if (!NUMERIC_IPV4_CANDIDATE.test(host)) {
|
||||
return null
|
||||
}
|
||||
if (!isCanonicalIpv4(host)) {
|
||||
return 'Not a valid hostname.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeRawNumericIpv4Candidate(host: string): string {
|
||||
let decoded = host
|
||||
try {
|
||||
decoded = decodeURIComponent(host)
|
||||
} catch {
|
||||
// The URL parser will reject malformed escapes; keep them untouched here.
|
||||
}
|
||||
return decoded.endsWith('.') ? decoded.slice(0, -1) : decoded
|
||||
}
|
||||
|
||||
function isCanonicalIpv4(host: string): boolean {
|
||||
const octets = host.split('.')
|
||||
return (
|
||||
octets.length === 4 &&
|
||||
octets.every((octet) => /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255)
|
||||
)
|
||||
}
|
||||
|
||||
function isValidPort(port: string): boolean {
|
||||
if (!/^\d+$/.test(port)) {
|
||||
return false
|
||||
}
|
||||
const n = Number(port)
|
||||
return n >= 1 && n <= 65535
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AppState, Platform } from 'react-native'
|
||||
import { connect, type RpcClient } from './rpc-client'
|
||||
import { createStableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ConnectionLogSink, HostProfile } from './types'
|
||||
import { directPathForEndpoint } from './mobile-direct-endpoint-probe'
|
||||
import { startMobileEndpointLifecycle } from './mobile-endpoint-lifecycle'
|
||||
|
||||
export function openHostLogicalClient(host: HostProfile, onLog: ConnectionLogSink): RpcClient {
|
||||
// Why: the stable facade owns app-visible RPC/subscription state while the
|
||||
// direct socket remains a replaceable first physical generation.
|
||||
const logical = createStableLogicalRpcClient(
|
||||
connect(host.endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
|
||||
directPathForEndpoint(host, host.endpoint)
|
||||
)
|
||||
if (Platform.OS === 'web') {
|
||||
return logical
|
||||
}
|
||||
|
||||
const endpointLifecycle = startMobileEndpointLifecycle(logical, host, onLog)
|
||||
endpointLifecycle.setForeground(AppState.currentState === 'active')
|
||||
const appStateSubscription = AppState.addEventListener('change', (state) => {
|
||||
endpointLifecycle.setForeground(state === 'active')
|
||||
})
|
||||
const closeLogical = logical.close
|
||||
logical.close = () => {
|
||||
appStateSubscription.remove()
|
||||
endpointLifecycle.stop()
|
||||
closeLogical()
|
||||
}
|
||||
const notifyLogicalForeground = logical.notifyForeground
|
||||
logical.notifyForeground = () => {
|
||||
endpointLifecycle.setForeground(true)
|
||||
notifyLogicalForeground()
|
||||
}
|
||||
return logical
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { updateHostNameAndEndpoint } from './host-store'
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('expo-secure-store', () => ({
|
||||
getItemAsync: vi.fn(),
|
||||
setItemAsync: vi.fn(),
|
||||
deleteItemAsync: vi.fn(),
|
||||
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY'
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: { OS: 'ios' }
|
||||
}))
|
||||
|
||||
describe('updateHostNameAndEndpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockReset()
|
||||
})
|
||||
|
||||
const stored = [
|
||||
{
|
||||
id: 'host-1',
|
||||
name: 'Desk',
|
||||
endpoint: 'ws://100.64.0.5:6768',
|
||||
publicKeyB64: 'pk',
|
||||
lastConnected: 1
|
||||
},
|
||||
{
|
||||
id: 'host-2',
|
||||
name: 'Laptop',
|
||||
endpoint: 'wss://laptop.example:8443',
|
||||
publicKeyB64: 'pk-2',
|
||||
lastConnected: 2
|
||||
}
|
||||
]
|
||||
|
||||
it('commits name and endpoint together in a single write', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
|
||||
|
||||
await updateHostNameAndEndpoint('host-1', {
|
||||
name: 'Home Desk',
|
||||
endpoint: 'ws://192.168.1.10:6768'
|
||||
})
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'orca:hosts',
|
||||
JSON.stringify([
|
||||
{ ...stored[0], name: 'Home Desk', endpoint: 'ws://192.168.1.10:6768' },
|
||||
stored[1]
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('updates only the provided field', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
|
||||
|
||||
await updateHostNameAndEndpoint('host-1', { name: 'Home Desk' })
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'orca:hosts',
|
||||
JSON.stringify([{ ...stored[0], name: 'Home Desk' }, stored[1]])
|
||||
)
|
||||
})
|
||||
|
||||
it('rewrites only the endpoint when name is omitted', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
|
||||
|
||||
await updateHostNameAndEndpoint('host-1', { endpoint: 'ws://192.168.1.10:6768' })
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'orca:hosts',
|
||||
JSON.stringify([{ ...stored[0], endpoint: 'ws://192.168.1.10:6768' }, stored[1]])
|
||||
)
|
||||
})
|
||||
|
||||
it('throws and writes nothing when the host is missing', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue('[]')
|
||||
|
||||
await expect(updateHostNameAndEndpoint('missing', { name: 'Renamed' })).rejects.toThrow(
|
||||
'Host not found'
|
||||
)
|
||||
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay'
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn(),
|
||||
@@ -32,9 +33,21 @@ vi.mock('./host-credential-cleanup', () => ({
|
||||
retryPendingHostCredentialCleanups: vi.fn()
|
||||
}))
|
||||
|
||||
import { removeHost, renameHost, resetHostStoreForTests, updateLastConnected } from './host-store'
|
||||
import {
|
||||
loadHosts,
|
||||
MobileRelayUpgradeHostRemovedError,
|
||||
removeHost,
|
||||
resolvePairingHostIdentity,
|
||||
resetHostStoreForTests,
|
||||
saveHost,
|
||||
saveExistingHostRelayUpgrade,
|
||||
updateHostNameAndEndpoint,
|
||||
updateLastConnected
|
||||
} from './host-store'
|
||||
import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store'
|
||||
|
||||
const HOSTS_STORAGE_KEY = 'orca:hosts'
|
||||
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
|
||||
const HOST_ONE = {
|
||||
id: 'host-1',
|
||||
name: 'Host 1',
|
||||
@@ -52,24 +65,118 @@ const HOST_TWO = {
|
||||
|
||||
describe('host-store list mutations', () => {
|
||||
let storedHostsRaw: string
|
||||
let storedOverlayRaw: string | null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetHostStoreForTests()
|
||||
resetMobileRelayHostOverlayStoreForTests()
|
||||
scheduleCleanupMock.mockReset()
|
||||
scheduleCleanupMock.mockResolvedValue(undefined)
|
||||
storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO])
|
||||
storedOverlayRaw = null
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === HOSTS_STORAGE_KEY) {
|
||||
return storedHostsRaw
|
||||
}
|
||||
if (key === OVERLAY_STORAGE_KEY) {
|
||||
return storedOverlayRaw
|
||||
}
|
||||
return null
|
||||
})
|
||||
asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => {
|
||||
if (key === HOSTS_STORAGE_KEY) {
|
||||
storedHostsRaw = raw
|
||||
} else if (key === OVERLAY_STORAGE_KEY) {
|
||||
storedOverlayRaw = raw
|
||||
}
|
||||
})
|
||||
secureStoreMock.getItemAsync.mockImplementation(async (key: string) =>
|
||||
key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves an existing host by pinned key with one durable read', async () => {
|
||||
await expect(resolvePairingHostIdentity(HOST_TWO.publicKeyB64, 'host-new')).resolves.toEqual({
|
||||
id: HOST_TWO.id,
|
||||
name: HOST_TWO.name
|
||||
})
|
||||
expect(asyncStorageMock.getItem).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('names a new host from the same durable read used for identity lookup', async () => {
|
||||
await expect(resolvePairingHostIdentity('unpaired-key', 'host-new')).resolves.toEqual({
|
||||
id: 'host-new',
|
||||
name: 'Host 3'
|
||||
})
|
||||
expect(asyncStorageMock.getItem).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails closed when durable host identity storage is unreadable', async () => {
|
||||
asyncStorageMock.getItem.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
|
||||
await expect(resolvePairingHostIdentity('key-new', 'host-new')).rejects.toThrow(/unreadable/)
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collapses already-persisted duplicates when the desktop is re-paired', async () => {
|
||||
storedHostsRaw = JSON.stringify([
|
||||
HOST_ONE,
|
||||
{ ...HOST_TWO, id: 'host-duplicate', publicKeyB64: HOST_ONE.publicKeyB64 }
|
||||
])
|
||||
|
||||
await saveHost({
|
||||
...HOST_ONE,
|
||||
endpoint: 'ws://127.0.0.1:3',
|
||||
deviceToken: 'replacement-token'
|
||||
})
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([{ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3' }])
|
||||
expect(scheduleCleanupMock).toHaveBeenCalledWith('host-duplicate', expect.any(Function))
|
||||
})
|
||||
|
||||
it('clears stale relay state when an existing host is re-paired direct-only', async () => {
|
||||
const overlay: MobileRelayHostOverlay = {
|
||||
v: 2,
|
||||
hostId: HOST_ONE.id,
|
||||
endpoints: [
|
||||
{ id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint },
|
||||
{
|
||||
id: 'relay-primary',
|
||||
kind: 'relay',
|
||||
url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9'
|
||||
}
|
||||
],
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.onorca.dev',
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
e2eeFraming: 2
|
||||
}
|
||||
}
|
||||
storedOverlayRaw = JSON.stringify([overlay])
|
||||
|
||||
await saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' })
|
||||
|
||||
expect(JSON.parse(storedOverlayRaw!)).toEqual([])
|
||||
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not touch relay storage when saving a new direct-only host', async () => {
|
||||
await saveHost({
|
||||
id: 'host-new',
|
||||
name: 'New Host',
|
||||
endpoint: 'ws://127.0.0.1:3',
|
||||
publicKeyB64: 'key-new',
|
||||
deviceToken: 'new-token',
|
||||
lastConnected: 0
|
||||
})
|
||||
|
||||
expect(asyncStorageMock.getItem).not.toHaveBeenCalledWith(OVERLAY_STORAGE_KEY)
|
||||
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commits the removal when credential cleanup scheduling rejects', async () => {
|
||||
@@ -81,6 +188,51 @@ describe('host-store list mutations', () => {
|
||||
expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function))
|
||||
})
|
||||
|
||||
it('merges v2 endpoints only onto an existing legacy base host', async () => {
|
||||
const overlay: MobileRelayHostOverlay = {
|
||||
v: 2,
|
||||
hostId: HOST_ONE.id,
|
||||
endpoints: [
|
||||
{ id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint },
|
||||
{
|
||||
id: 'relay-primary',
|
||||
kind: 'relay',
|
||||
url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9'
|
||||
}
|
||||
],
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.onorca.dev',
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
e2eeFraming: 2
|
||||
}
|
||||
}
|
||||
storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }])
|
||||
|
||||
const hosts = await loadHosts()
|
||||
|
||||
expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({
|
||||
endpoints: overlay.endpoints,
|
||||
relayHostId: overlay.relayHostId,
|
||||
relay: overlay.relay
|
||||
})
|
||||
expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to resurrect a removed host during relay upgrade publication', async () => {
|
||||
storedHostsRaw = JSON.stringify([HOST_TWO])
|
||||
|
||||
await expect(
|
||||
saveExistingHostRelayUpgrade({ ...HOST_ONE, deviceToken: 'token-1' })
|
||||
).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError)
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO])
|
||||
expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('awaits cleanup scheduling after metadata commit', async () => {
|
||||
let resolveSchedule: (() => void) | null = null
|
||||
scheduleCleanupMock.mockReturnValue(
|
||||
@@ -121,7 +273,7 @@ describe('host-store list mutations', () => {
|
||||
return storedHostsRaw
|
||||
})
|
||||
|
||||
const rename = renameHost(HOST_ONE.id, 'Renamed Host')
|
||||
const rename = updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Renamed Host' })
|
||||
const remove = removeHost(HOST_TWO.id)
|
||||
// Both writers have started their RMW and are blocked on the shared read
|
||||
// gate; without a mutation queue the second would clobber the first.
|
||||
@@ -142,9 +294,9 @@ describe('host-store list mutations', () => {
|
||||
it('preserves a rename when lastConnected updates race it', async () => {
|
||||
const before = Date.now()
|
||||
await Promise.all([
|
||||
renameHost(HOST_ONE.id, 'Alpha'),
|
||||
updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Alpha' }),
|
||||
updateLastConnected(HOST_ONE.id),
|
||||
renameHost(HOST_TWO.id, 'Beta')
|
||||
updateHostNameAndEndpoint(HOST_TWO.id, { name: 'Beta' })
|
||||
])
|
||||
|
||||
const stored = JSON.parse(storedHostsRaw) as Array<typeof HOST_ONE>
|
||||
@@ -160,7 +312,9 @@ describe('host-store list mutations', () => {
|
||||
|
||||
it('does not wipe the host list when storage is unreadable during mutation', async () => {
|
||||
storedHostsRaw = '{'
|
||||
await expect(renameHost(HOST_ONE.id, 'Nope')).rejects.toThrow(/unreadable/)
|
||||
await expect(updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Nope' })).rejects.toThrow(
|
||||
/unreadable/
|
||||
)
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
|
||||
expect(storedHostsRaw).toBe('{')
|
||||
})
|
||||
|
||||
@@ -12,6 +12,15 @@ import {
|
||||
retryPendingHostCredentialCleanups,
|
||||
scheduleHostCredentialCleanup
|
||||
} from './host-credential-cleanup'
|
||||
import {
|
||||
loadMobileRelayHostOverlayState,
|
||||
removeMobileRelayHostOverlay,
|
||||
removeMobileRelayHostOverlays,
|
||||
saveMobileRelayHostOverlay
|
||||
} from './mobile-relay-host-overlay-store'
|
||||
import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
|
||||
import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal'
|
||||
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
|
||||
|
||||
const STORAGE_KEY = 'orca:hosts'
|
||||
// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected.
|
||||
@@ -61,6 +70,12 @@ async function deleteDeviceToken(hostId: string): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
|
||||
}
|
||||
|
||||
async function deleteHostCredentials(hostId: string): Promise<void> {
|
||||
await deleteDeviceToken(hostId)
|
||||
await deleteMobileRelayCredentialBundle(hostId)
|
||||
await deleteMobileRelayDirectUpgradeJournal(hostId)
|
||||
}
|
||||
|
||||
// Why: SecureStore reads on Android Keystore can take 50-200ms each, and
|
||||
// loadHosts() is called from every screen mount + every useFocusEffect.
|
||||
// Stack with N hosts and you get N*200ms blocking every navigation, which
|
||||
@@ -120,6 +135,14 @@ async function doLoadHosts(): Promise<HostProfile[]> {
|
||||
if (!storedHosts) {
|
||||
return []
|
||||
}
|
||||
const overlayState = await loadMobileRelayHostOverlayState(
|
||||
new Set(storedHosts.map(({ id }) => id))
|
||||
)
|
||||
await scheduleOrphanedMobileRelayCleanup({
|
||||
hostIds: overlayState.orphanHostIds,
|
||||
deleteCredential: deleteHostCredentials
|
||||
})
|
||||
const overlays = overlayState.overlays
|
||||
|
||||
const out: HostProfile[] = []
|
||||
for (const stored of storedHosts) {
|
||||
@@ -144,17 +167,34 @@ async function doLoadHosts(): Promise<HostProfile[]> {
|
||||
token = fetched
|
||||
tokenCache.set(stored.id, token)
|
||||
}
|
||||
out.push({ ...stored, deviceToken: token })
|
||||
const overlay = overlays.get(stored.id)
|
||||
out.push({
|
||||
...stored,
|
||||
deviceToken: token,
|
||||
...(overlay
|
||||
? {
|
||||
endpoints: overlay.endpoints,
|
||||
relayHostId: overlay.relayHostId,
|
||||
relay: overlay.relay
|
||||
}
|
||||
: {})
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function loadStoredHosts(): Promise<StoredHostProfile[]> {
|
||||
try {
|
||||
return parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
export async function resolvePairingHostIdentity(
|
||||
publicKeyB64: string,
|
||||
newHostId: string
|
||||
): Promise<{ id: string; name: string }> {
|
||||
// Why: one durable read both preserves an existing identity and names a new host,
|
||||
// avoiding duplicate cards and a second serial storage read before connecting.
|
||||
await hostListMutation
|
||||
const hosts = await readStoredHostsForMutation()
|
||||
const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64)
|
||||
return match
|
||||
? { id: match.id, name: match.name }
|
||||
: { id: newHostId, name: getNextHostNameFromHosts(hosts) }
|
||||
}
|
||||
|
||||
async function readStoredHostsForMutation(): Promise<StoredHostProfile[]> {
|
||||
@@ -196,17 +236,41 @@ function toStored(host: HostProfile): StoredHostProfile {
|
||||
}
|
||||
}
|
||||
|
||||
export class MobileRelayUpgradeHostRemovedError extends Error {}
|
||||
|
||||
export async function saveHost(host: HostProfile): Promise<void> {
|
||||
await persistHost(host, false)
|
||||
}
|
||||
|
||||
export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise<void> {
|
||||
await persistHost(host, true)
|
||||
}
|
||||
|
||||
async function persistHost(host: HostProfile, requireExisting: boolean): Promise<void> {
|
||||
const validated = HostProfileSchema.parse(host)
|
||||
const stored = toStored(validated)
|
||||
const duplicateHostIds = new Set<string>()
|
||||
let updatedExistingHost = false
|
||||
await mutateStoredHosts((hosts) => {
|
||||
const index = hosts.findIndex((h) => h.id === stored.id)
|
||||
if (index >= 0) {
|
||||
const next = hosts.slice()
|
||||
next[index] = stored
|
||||
return next
|
||||
for (const candidate of hosts) {
|
||||
if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) {
|
||||
duplicateHostIds.add(candidate.id)
|
||||
}
|
||||
}
|
||||
return [...hosts, stored]
|
||||
if (index >= 0) {
|
||||
updatedExistingHost = true
|
||||
// Why: affected installs may already contain duplicate rows; an authoritative
|
||||
// save is the safe point to collapse them to the preserved host id.
|
||||
return hosts
|
||||
.filter(({ id }) => !duplicateHostIds.has(id))
|
||||
.map((candidate) => (candidate.id === stored.id ? stored : candidate))
|
||||
}
|
||||
if (requireExisting) {
|
||||
// Why: an in-flight relay upgrade must not resurrect a host the user removed.
|
||||
throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
|
||||
}
|
||||
return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored]
|
||||
})
|
||||
// Why: write metadata BEFORE the keychain token so a crash between the two
|
||||
// leaves orphaned metadata (which loadHosts skips and removeHost can clean
|
||||
@@ -215,15 +279,47 @@ export async function saveHost(host: HostProfile): Promise<void> {
|
||||
// from current metadata.
|
||||
await writeDeviceToken(stored.id, validated.deviceToken)
|
||||
tokenCache.set(stored.id, validated.deviceToken)
|
||||
if (validated.endpoints) {
|
||||
await saveMobileRelayHostOverlay({
|
||||
v: 2,
|
||||
hostId: stored.id,
|
||||
endpoints: validated.endpoints,
|
||||
relayHostId: validated.relayHostId,
|
||||
relay: validated.relay
|
||||
})
|
||||
}
|
||||
const overlayRemovalIds = [...duplicateHostIds]
|
||||
if (!validated.endpoints && updatedExistingHost) {
|
||||
overlayRemovalIds.push(stored.id)
|
||||
}
|
||||
if (overlayRemovalIds.length > 0) {
|
||||
// Why: reusing an id for direct-only re-pairing must not retain routing
|
||||
// metadata from the host's previous transport state.
|
||||
await removeMobileRelayHostOverlays(overlayRemovalIds)
|
||||
}
|
||||
for (const duplicateHostId of duplicateHostIds) {
|
||||
tokenCache.delete(duplicateHostId)
|
||||
try {
|
||||
await scheduleHostCredentialCleanup(duplicateHostId, deleteHostCredentials)
|
||||
} catch {
|
||||
// Metadata is already deduplicated; orphan-token recovery is best-effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeHost(hostId: string): Promise<void> {
|
||||
await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId))
|
||||
tokenCache.delete(hostId)
|
||||
try {
|
||||
await removeMobileRelayHostOverlay(hostId)
|
||||
} catch {
|
||||
// The missing legacy base is authoritative, so a retained overlay cannot
|
||||
// resurrect this host and can be cleaned on a later explicit retry.
|
||||
}
|
||||
// Why: await only the durable cleanup intent (AsyncStorage). Native keychain
|
||||
// delete can reject or stall and must not freeze removeHost / the UI.
|
||||
try {
|
||||
await scheduleHostCredentialCleanup(hostId, deleteDeviceToken)
|
||||
await scheduleHostCredentialCleanup(hostId, deleteHostCredentials)
|
||||
} catch {
|
||||
// Metadata is already committed; orphan-token recovery is best-effort.
|
||||
}
|
||||
@@ -234,27 +330,32 @@ export async function retryPendingHostCredentialCleanup(): Promise<{
|
||||
remainingIds: string[]
|
||||
storageUnreadable: boolean
|
||||
}> {
|
||||
return retryPendingHostCredentialCleanups(deleteDeviceToken)
|
||||
return retryPendingHostCredentialCleanups(deleteHostCredentials)
|
||||
}
|
||||
|
||||
export async function renameHost(hostId: string, newName: string): Promise<void> {
|
||||
// Why: Edit host can change name and endpoint together; a single
|
||||
// mutateStoredHosts pass keeps both fields committed atomically so a
|
||||
// mid-save failure can never persist one change without the other, and a
|
||||
// host removed mid-edit throws consistently instead of silently no-oping.
|
||||
export async function updateHostNameAndEndpoint(
|
||||
hostId: string,
|
||||
updates: { name?: string; endpoint?: string }
|
||||
): Promise<void> {
|
||||
await mutateStoredHosts((hosts) => {
|
||||
const index = hosts.findIndex((h) => h.id === hostId)
|
||||
const index = hosts.findIndex((host) => host.id === hostId)
|
||||
if (index < 0) {
|
||||
return hosts
|
||||
throw new Error('Host not found')
|
||||
}
|
||||
const next = hosts.slice()
|
||||
next[index] = { ...next[index]!, name: newName }
|
||||
next[index] = {
|
||||
...next[index]!,
|
||||
...(updates.name !== undefined ? { name: updates.name } : {}),
|
||||
...(updates.endpoint !== undefined ? { endpoint: updates.endpoint } : {})
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export async function getNextHostName(): Promise<string> {
|
||||
await hostListMutation
|
||||
const hosts = await loadStoredHosts()
|
||||
return getNextHostNameFromHosts(hosts)
|
||||
}
|
||||
|
||||
export async function updateLastConnected(hostId: string): Promise<void> {
|
||||
try {
|
||||
await mutateStoredHosts((hosts) => {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileConnectionPathLabel } from './mobile-connection-path-label'
|
||||
|
||||
describe('mobile connection path label', () => {
|
||||
it('distinguishes LAN, Tailscale, and the relay without exposing transport errors', () => {
|
||||
expect(mobileConnectionPathLabel('lan')).toBe('Direct · LAN')
|
||||
expect(mobileConnectionPathLabel('tailscale')).toBe('Direct · Tailscale')
|
||||
expect(mobileConnectionPathLabel('relay')).toBe('Orca Relay')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
|
||||
export function mobileConnectionPathLabel(path: MobileConnectionPath): string {
|
||||
if (path === 'relay') {
|
||||
return 'Orca Relay'
|
||||
}
|
||||
return path === 'tailscale' ? 'Direct · Tailscale' : 'Direct · LAN'
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
import type { HostProfile } from './types'
|
||||
|
||||
function directEndpointUrls(host: HostProfile): string[] {
|
||||
const endpoints =
|
||||
host.endpoints?.filter(({ kind }) => kind !== 'relay').map(({ url }) => url) ?? []
|
||||
return [...new Set([host.endpoint, ...endpoints])]
|
||||
}
|
||||
|
||||
export function directPathForEndpoint(
|
||||
host: HostProfile,
|
||||
endpoint: string
|
||||
): Exclude<MobileConnectionPath, 'relay'> {
|
||||
const configured = host.endpoints?.find((candidate) => candidate.url === endpoint)
|
||||
if (configured?.kind === 'tailscale') {
|
||||
return 'tailscale'
|
||||
}
|
||||
try {
|
||||
const hostname = new URL(endpoint).hostname
|
||||
if (hostname.endsWith('.ts.net') || /^100\.(?:\d{1,3}\.){2}\d{1,3}$/.test(hostname)) {
|
||||
return 'tailscale'
|
||||
}
|
||||
} catch {}
|
||||
return 'lan'
|
||||
}
|
||||
|
||||
function waitForAuthenticatedSession(session: RpcClient, timeoutMs: number): Promise<void> {
|
||||
if (session.getState() === 'connected') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const unsubscribe = session.onStateChange((state) => {
|
||||
if (state === 'connected') {
|
||||
finish()
|
||||
resolve()
|
||||
} else if (state === 'disconnected' || state === 'auth-failed') {
|
||||
finish()
|
||||
reject(new Error(`probe session ${state}`))
|
||||
}
|
||||
})
|
||||
timer = setTimeout(() => {
|
||||
finish()
|
||||
reject(new Error('probe session authentication timed out'))
|
||||
}, timeoutMs)
|
||||
function finish(): void {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function openAuthenticatedDirectEndpoint(
|
||||
host: HostProfile,
|
||||
openDirect: (endpoint: string) => RpcClient,
|
||||
timeoutMs: number
|
||||
): Promise<{ client: RpcClient; path: Exclude<MobileConnectionPath, 'relay'> } | null> {
|
||||
for (const endpoint of directEndpointUrls(host)) {
|
||||
const client = openDirect(endpoint)
|
||||
try {
|
||||
await waitForAuthenticatedSession(client, timeoutMs)
|
||||
return { client, path: directPathForEndpoint(host, endpoint) }
|
||||
} catch {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import nacl from 'tweetnacl'
|
||||
import { MOBILE_E2EE_LEGACY_FIXTURE } from '../../../src/shared/mobile-e2ee-legacy-fixtures'
|
||||
|
||||
vi.mock('expo-crypto', () => ({
|
||||
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
|
||||
}))
|
||||
|
||||
import { decrypt, decryptBytes, deriveSharedKey } from './e2ee'
|
||||
|
||||
describe('mobile legacy E2EE fixtures', () => {
|
||||
it('matches the captured desktop key and text/binary frames', () => {
|
||||
const fixture = MOBILE_E2EE_LEGACY_FIXTURE
|
||||
const server = nacl.box.keyPair.fromSecretKey(fixture.serverSecretKey)
|
||||
const client = nacl.box.keyPair.fromSecretKey(fixture.clientSecretKey)
|
||||
const shared = deriveSharedKey(client.secretKey, server.publicKey)
|
||||
|
||||
expect(hex(shared)).toBe(fixture.sharedKeyHex)
|
||||
expect(decrypt(fixture.authFrameB64, shared)).toBe(fixture.authPlaintext)
|
||||
expect(decryptBytes(fromHex(fixture.binaryFrameHex), shared)).toEqual(fixture.binaryPlaintext)
|
||||
})
|
||||
})
|
||||
|
||||
function hex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
function fromHex(value: string): Uint8Array {
|
||||
return Uint8Array.from(value.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import nacl from 'tweetnacl'
|
||||
import {
|
||||
encodeMobileE2EEV2Transcript,
|
||||
validateMobileE2EEV2Handshake,
|
||||
type MobileE2EEV2Ready
|
||||
} from '../../../src/shared/mobile-e2ee-v2-contract'
|
||||
import { sealMobileE2EEV2Frame } from '../../../src/shared/mobile-e2ee-v2-framing'
|
||||
|
||||
vi.mock('expo-crypto', () => ({
|
||||
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
|
||||
}))
|
||||
|
||||
import { deriveSharedKey } from './e2ee'
|
||||
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
|
||||
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
|
||||
|
||||
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
|
||||
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
|
||||
|
||||
function setup() {
|
||||
const session = MobileE2EEV2ClientSession.create({
|
||||
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
|
||||
transport: 'relay',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
clientNonce: new Uint8Array(32).fill(3),
|
||||
clientKeyPair: client
|
||||
})
|
||||
const ready: MobileE2EEV2Ready = {
|
||||
type: 'e2ee_ready',
|
||||
v: 2,
|
||||
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
|
||||
clientNonceB64: session.hello.clientNonceB64,
|
||||
desktopNonceB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64'),
|
||||
selection: { framing: 2, payloadKinds: ['text', 'binary'] },
|
||||
context: session.hello.context
|
||||
}
|
||||
return { session, ready }
|
||||
}
|
||||
|
||||
describe('mobile E2EE v2 client session', () => {
|
||||
it('pins the desktop key and accepts the exact transcript', () => {
|
||||
const { session, ready } = setup()
|
||||
expect(session.acceptReady(ready)).toBe(true)
|
||||
expect(session.transcriptHashB64).toHaveLength(44)
|
||||
expect(
|
||||
session.acceptReady({
|
||||
...ready,
|
||||
desktopPublicKeyB64: Buffer.from(new Uint8Array(32).fill(8)).toString('base64')
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('seals auth at counter zero and rejects replayed desktop frames', () => {
|
||||
const { session, ready } = setup()
|
||||
expect(session.acceptReady(ready)).toBe(true)
|
||||
const auth = JSON.stringify({
|
||||
type: 'e2ee_auth',
|
||||
v: 2,
|
||||
transcriptHashB64: session.transcriptHashB64,
|
||||
deviceToken: 'token'
|
||||
})
|
||||
const authFrame = Buffer.from(session.sealText(auth), 'base64')
|
||||
expect(authFrame.subarray(16, 24)).toEqual(Buffer.alloc(8, 0))
|
||||
|
||||
const handshake = validateMobileE2EEV2Handshake(session.hello, ready)!
|
||||
const schedule = deriveMobileE2EEV2KeySchedule({
|
||||
sharedSecret: deriveSharedKey(desktop.secretKey, client.publicKey),
|
||||
transcript: encodeMobileE2EEV2Transcript(handshake),
|
||||
clientNonce: handshake.clientNonce,
|
||||
desktopNonce: handshake.desktopNonce
|
||||
})
|
||||
const response = sealMobileE2EEV2Frame({
|
||||
payload: new TextEncoder().encode('authenticated'),
|
||||
key: schedule.desktopToMobileKey,
|
||||
sessionId: schedule.sessionId,
|
||||
direction: 'desktop-to-mobile',
|
||||
payloadKind: 'text',
|
||||
counter: 0n
|
||||
})
|
||||
const encoded = Buffer.from(response).toString('base64')
|
||||
expect(session.openText(encoded)).toBe('authenticated')
|
||||
expect(session.openText(encoded)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import * as ExpoCrypto from 'expo-crypto'
|
||||
import {
|
||||
encodeMobileE2EEV2Transcript,
|
||||
validateMobileE2EEV2Handshake,
|
||||
type MobileE2EETransport,
|
||||
type MobileE2EEV2Hello
|
||||
} from '../../../src/shared/mobile-e2ee-v2-contract'
|
||||
import {
|
||||
openMobileE2EEV2Frame,
|
||||
sealMobileE2EEV2Frame
|
||||
} from '../../../src/shared/mobile-e2ee-v2-framing'
|
||||
import { deriveSharedKey, generateKeyPair, publicKeyFromBase64, publicKeyToBase64 } from './e2ee'
|
||||
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
|
||||
|
||||
export class MobileE2EEV2ClientSession {
|
||||
readonly hello: MobileE2EEV2Hello
|
||||
private inboundCounter = 0n
|
||||
private outboundCounter = 0n
|
||||
private schedule: ReturnType<typeof deriveMobileE2EEV2KeySchedule> | null = null
|
||||
private transcriptHashB64Value: string | null = null
|
||||
|
||||
private constructor(
|
||||
private readonly clientSecretKey: Uint8Array,
|
||||
private readonly pinnedDesktopPublicKey: Uint8Array,
|
||||
hello: MobileE2EEV2Hello
|
||||
) {
|
||||
this.hello = hello
|
||||
}
|
||||
|
||||
static create(args: {
|
||||
desktopPublicKeyB64: string
|
||||
transport: MobileE2EETransport
|
||||
relayHostId?: string
|
||||
clientNonce?: Uint8Array
|
||||
clientKeyPair?: { publicKey: Uint8Array; secretKey: Uint8Array }
|
||||
}): MobileE2EEV2ClientSession {
|
||||
const keyPair = args.clientKeyPair ?? generateKeyPair()
|
||||
const clientNonce = args.clientNonce ?? ExpoCrypto.getRandomBytes(32)
|
||||
if (clientNonce.length !== 32) {
|
||||
throw new Error(`Invalid client nonce length: ${clientNonce.length}`)
|
||||
}
|
||||
return new MobileE2EEV2ClientSession(
|
||||
keyPair.secretKey,
|
||||
publicKeyFromBase64(args.desktopPublicKeyB64),
|
||||
{
|
||||
type: 'e2ee_hello',
|
||||
v: 2,
|
||||
clientPublicKeyB64: publicKeyToBase64(keyPair.publicKey),
|
||||
clientNonceB64: encodeBase64(clientNonce),
|
||||
capabilities: { framing: [2], payloadKinds: ['text', 'binary'] },
|
||||
context: {
|
||||
protocol: 'orca-mobile-e2ee',
|
||||
initiator: 'mobile',
|
||||
responder: 'desktop',
|
||||
transport: args.transport,
|
||||
...(args.relayHostId ? { relayHostId: args.relayHostId } : {})
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
acceptReady(ready: unknown): boolean {
|
||||
const handshake = validateMobileE2EEV2Handshake(this.hello, ready)
|
||||
if (!handshake || !equalBytes(handshake.desktopPublicKey, this.pinnedDesktopPublicKey)) {
|
||||
return false
|
||||
}
|
||||
this.schedule = deriveMobileE2EEV2KeySchedule({
|
||||
sharedSecret: deriveSharedKey(this.clientSecretKey, this.pinnedDesktopPublicKey),
|
||||
transcript: encodeMobileE2EEV2Transcript(handshake),
|
||||
clientNonce: handshake.clientNonce,
|
||||
desktopNonce: handshake.desktopNonce
|
||||
})
|
||||
this.transcriptHashB64Value = encodeBase64(this.schedule.transcriptHash)
|
||||
return true
|
||||
}
|
||||
|
||||
get transcriptHashB64(): string {
|
||||
if (!this.transcriptHashB64Value) {
|
||||
throw new Error('E2EE v2 ready has not been accepted')
|
||||
}
|
||||
return this.transcriptHashB64Value
|
||||
}
|
||||
|
||||
openText(frameB64: string): string | null {
|
||||
const frame = decodeCanonicalBase64(frameB64)
|
||||
if (!frame) {
|
||||
return null
|
||||
}
|
||||
const plaintext = this.open(frame, 'text')
|
||||
return plaintext ? new TextDecoder().decode(plaintext) : null
|
||||
}
|
||||
|
||||
openBinary(frame: Uint8Array): Uint8Array | null {
|
||||
return this.open(frame, 'binary')
|
||||
}
|
||||
|
||||
sealText(plaintext: string): string {
|
||||
return encodeBase64(this.seal(new TextEncoder().encode(plaintext), 'text'))
|
||||
}
|
||||
|
||||
sealBinary(plaintext: Uint8Array): Uint8Array {
|
||||
return this.seal(plaintext, 'binary')
|
||||
}
|
||||
|
||||
private open(frame: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array | null {
|
||||
if (!this.schedule) {
|
||||
return null
|
||||
}
|
||||
const plaintext = openMobileE2EEV2Frame({
|
||||
frame,
|
||||
key: this.schedule.desktopToMobileKey,
|
||||
sessionId: this.schedule.sessionId,
|
||||
direction: 'desktop-to-mobile',
|
||||
payloadKind,
|
||||
expectedCounter: this.inboundCounter
|
||||
})
|
||||
if (plaintext) {
|
||||
this.inboundCounter++
|
||||
}
|
||||
return plaintext
|
||||
}
|
||||
|
||||
private seal(plaintext: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array {
|
||||
if (!this.schedule) {
|
||||
throw new Error('E2EE v2 ready has not been accepted')
|
||||
}
|
||||
const frame = sealMobileE2EEV2Frame({
|
||||
payload: plaintext,
|
||||
key: this.schedule.mobileToDesktopKey,
|
||||
sessionId: this.schedule.sessionId,
|
||||
direction: 'mobile-to-desktop',
|
||||
payloadKind,
|
||||
counter: this.outboundCounter
|
||||
})
|
||||
this.outboundCounter++
|
||||
return frame
|
||||
}
|
||||
}
|
||||
|
||||
function encodeBase64(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte)
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function decodeCanonicalBase64(value: string): Uint8Array | null {
|
||||
try {
|
||||
const binary = atob(value)
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
return encodeBase64(bytes) === value ? bytes : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
let difference = 0
|
||||
for (let index = 0; index < left.length; index++) {
|
||||
difference |= left[index]! ^ right[index]!
|
||||
}
|
||||
return difference === 0
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
encodeMobileE2EEV2Transcript,
|
||||
validateMobileE2EEV2Handshake
|
||||
} from '../../../src/shared/mobile-e2ee-v2-contract'
|
||||
import {
|
||||
createMobileE2EEV2Fixture,
|
||||
MOBILE_E2EE_V2_VECTOR
|
||||
} from '../../../src/shared/mobile-e2ee-v2-fixtures'
|
||||
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
|
||||
|
||||
function hex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
describe('mobile E2EE v2 key schedule', () => {
|
||||
it('matches the desktop normative HKDF vector', () => {
|
||||
const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture()
|
||||
const handshake = validateMobileE2EEV2Handshake(hello, ready)!
|
||||
const schedule = deriveMobileE2EEV2KeySchedule({
|
||||
sharedSecret,
|
||||
transcript: encodeMobileE2EEV2Transcript(handshake),
|
||||
clientNonce: handshake.clientNonce,
|
||||
desktopNonce: handshake.desktopNonce
|
||||
})
|
||||
|
||||
expect(hex(schedule.mobileToDesktopKey)).toBe(MOBILE_E2EE_V2_VECTOR.mobileToDesktopKeyHex)
|
||||
expect(hex(schedule.desktopToMobileKey)).toBe(MOBILE_E2EE_V2_VECTOR.desktopToMobileKeyHex)
|
||||
expect(hex(schedule.sessionId)).toBe(MOBILE_E2EE_V2_VECTOR.sessionIdHex)
|
||||
expect(hex(schedule.transcriptHash)).toBe(MOBILE_E2EE_V2_VECTOR.transcriptHashHex)
|
||||
})
|
||||
|
||||
it('derives unique direction keys and session IDs across fresh client nonces', () => {
|
||||
const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture()
|
||||
const fingerprints = new Set<string>()
|
||||
for (let index = 0; index < 128; index++) {
|
||||
const nonce = new Uint8Array(32)
|
||||
new DataView(nonce.buffer).setUint32(28, index, false)
|
||||
const clientNonceB64 = Buffer.from(nonce).toString('base64')
|
||||
const handshake = validateMobileE2EEV2Handshake(
|
||||
{ ...hello, clientNonceB64 },
|
||||
{ ...ready, clientNonceB64 }
|
||||
)!
|
||||
const schedule = deriveMobileE2EEV2KeySchedule({
|
||||
sharedSecret,
|
||||
transcript: encodeMobileE2EEV2Transcript(handshake),
|
||||
clientNonce: handshake.clientNonce,
|
||||
desktopNonce: handshake.desktopNonce
|
||||
})
|
||||
fingerprints.add(
|
||||
[schedule.mobileToDesktopKey, schedule.desktopToMobileKey, schedule.sessionId]
|
||||
.map(hex)
|
||||
.join(':')
|
||||
)
|
||||
}
|
||||
expect(fingerprints.size).toBe(128)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { hkdf } from '@noble/hashes/hkdf'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
|
||||
const SALT_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/salt\0')
|
||||
const INFO_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/session\0')
|
||||
|
||||
export function deriveMobileE2EEV2KeySchedule(args: {
|
||||
sharedSecret: Uint8Array
|
||||
transcript: Uint8Array
|
||||
clientNonce: Uint8Array
|
||||
desktopNonce: Uint8Array
|
||||
}): {
|
||||
mobileToDesktopKey: Uint8Array
|
||||
desktopToMobileKey: Uint8Array
|
||||
sessionId: Uint8Array
|
||||
transcriptHash: Uint8Array
|
||||
} {
|
||||
requireLength(args.sharedSecret, 32, 'shared secret')
|
||||
requireLength(args.clientNonce, 32, 'client nonce')
|
||||
requireLength(args.desktopNonce, 32, 'desktop nonce')
|
||||
|
||||
const transcriptHash = sha256(args.transcript)
|
||||
const salt = sha256(concatBytes([SALT_LABEL, args.clientNonce, args.desktopNonce]))
|
||||
const info = concatBytes([INFO_LABEL, transcriptHash])
|
||||
const expanded = hkdf(sha256, args.sharedSecret, salt, info, 96)
|
||||
return {
|
||||
mobileToDesktopKey: expanded.slice(0, 32),
|
||||
desktopToMobileKey: expanded.slice(32, 64),
|
||||
sessionId: expanded.slice(64, 96),
|
||||
transcriptHash
|
||||
}
|
||||
}
|
||||
|
||||
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
|
||||
const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
|
||||
let offset = 0
|
||||
for (const part of parts) {
|
||||
result.set(part, offset)
|
||||
offset += part.length
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function requireLength(bytes: Uint8Array, expected: number, label: string): void {
|
||||
if (bytes.length !== expected) {
|
||||
throw new Error(`Invalid ${label}: expected ${expected} bytes, got ${bytes.length}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import nacl from 'tweetnacl'
|
||||
import {
|
||||
encodeMobileE2EEV2Transcript,
|
||||
validateMobileE2EEV2Handshake,
|
||||
type MobileE2EEV2Ready
|
||||
} from '../../../src/shared/mobile-e2ee-v2-contract'
|
||||
import {
|
||||
openMobileE2EEV2Frame,
|
||||
sealMobileE2EEV2Frame
|
||||
} from '../../../src/shared/mobile-e2ee-v2-framing'
|
||||
|
||||
vi.mock('expo-crypto', () => ({
|
||||
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
|
||||
}))
|
||||
|
||||
import { deriveSharedKey } from './e2ee'
|
||||
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
|
||||
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
|
||||
import {
|
||||
MobileE2EEAuthenticationError,
|
||||
MobileE2EEV2PhysicalChannel,
|
||||
type MobileE2EEV2Socket
|
||||
} from './mobile-e2ee-v2-physical-channel'
|
||||
|
||||
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
|
||||
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
|
||||
|
||||
function setup(decodeBinary: (raw: unknown) => Promise<Uint8Array | null>) {
|
||||
const session = MobileE2EEV2ClientSession.create({
|
||||
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
|
||||
transport: 'relay',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
clientNonce: new Uint8Array(32).fill(3),
|
||||
clientKeyPair: client
|
||||
})
|
||||
const sent: (string | Uint8Array)[] = []
|
||||
const socket = {
|
||||
OPEN: 1,
|
||||
readyState: 1,
|
||||
bufferedAmount: 0,
|
||||
send: (frame: string | Uint8Array) => sent.push(frame)
|
||||
} satisfies MobileE2EEV2Socket
|
||||
const events: string[] = []
|
||||
const onAuthenticated = vi.fn(() => events.push('authenticated'))
|
||||
const onError = vi.fn()
|
||||
const channel = new MobileE2EEV2PhysicalChannel({
|
||||
session,
|
||||
socket,
|
||||
deviceToken: 'valid-token',
|
||||
decodeBinary,
|
||||
onAuthenticated,
|
||||
onText: (plaintext) => events.push(`text:${plaintext}`),
|
||||
onBinary: (plaintext) => events.push(`binary:${plaintext[0]}`),
|
||||
onError
|
||||
})
|
||||
channel.start()
|
||||
|
||||
const ready: MobileE2EEV2Ready = {
|
||||
type: 'e2ee_ready',
|
||||
v: 2,
|
||||
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
|
||||
clientNonceB64: session.hello.clientNonceB64,
|
||||
desktopNonceB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64'),
|
||||
selection: { framing: 2, payloadKinds: ['text', 'binary'] },
|
||||
context: session.hello.context
|
||||
}
|
||||
const handshake = validateMobileE2EEV2Handshake(session.hello, ready)!
|
||||
const schedule = deriveMobileE2EEV2KeySchedule({
|
||||
sharedSecret: deriveSharedKey(desktop.secretKey, client.publicKey),
|
||||
transcript: encodeMobileE2EEV2Transcript(handshake),
|
||||
clientNonce: handshake.clientNonce,
|
||||
desktopNonce: handshake.desktopNonce
|
||||
})
|
||||
return { channel, session, socket, sent, events, onAuthenticated, onError, ready, schedule }
|
||||
}
|
||||
|
||||
function serverFrame(
|
||||
payload: Uint8Array,
|
||||
kind: 'text' | 'binary',
|
||||
counter: bigint,
|
||||
schedule: ReturnType<typeof setup>['schedule']
|
||||
): Uint8Array {
|
||||
return sealMobileE2EEV2Frame({
|
||||
payload,
|
||||
key: schedule.desktopToMobileKey,
|
||||
sessionId: schedule.sessionId,
|
||||
direction: 'desktop-to-mobile',
|
||||
payloadKind: kind,
|
||||
counter
|
||||
})
|
||||
}
|
||||
|
||||
async function authenticate(ctx: ReturnType<typeof setup>): Promise<void> {
|
||||
await ctx.channel.handleMessage(JSON.stringify(ctx.ready))
|
||||
const response = serverFrame(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
type: 'e2ee_authenticated',
|
||||
v: 2,
|
||||
transcriptHashB64: ctx.session.transcriptHashB64
|
||||
})
|
||||
),
|
||||
'text',
|
||||
0n,
|
||||
ctx.schedule
|
||||
)
|
||||
await ctx.channel.handleMessage(Buffer.from(response).toString('base64'))
|
||||
}
|
||||
|
||||
describe('mobile E2EE v2 physical channel', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('sends hello/auth and confirms the exact transcript', async () => {
|
||||
const ctx = setup(async () => null)
|
||||
await authenticate(ctx)
|
||||
|
||||
expect(JSON.parse(ctx.sent[0] as string)).toEqual(ctx.session.hello)
|
||||
expect(typeof ctx.sent[1]).toBe('string')
|
||||
expect(ctx.onAuthenticated).toHaveBeenCalledOnce()
|
||||
expect(ctx.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies the encrypted desktop device-token rejection as global auth failure', async () => {
|
||||
const ctx = setup(async () => null)
|
||||
await ctx.channel.handleMessage(JSON.stringify(ctx.ready))
|
||||
const rejection = serverFrame(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'e2ee_error', error: { code: 'unauthorized' } })
|
||||
),
|
||||
'text',
|
||||
0n,
|
||||
ctx.schedule
|
||||
)
|
||||
|
||||
await ctx.channel.handleMessage(Buffer.from(rejection).toString('base64'))
|
||||
|
||||
expect(ctx.onError.mock.calls[0]![0]).toBeInstanceOf(MobileE2EEAuthenticationError)
|
||||
expect(ctx.onAuthenticated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serializes delayed binary conversion before a later text counter', async () => {
|
||||
let releaseBinary!: (bytes: Uint8Array) => void
|
||||
const pendingBinary = new Promise<Uint8Array>((resolve) => (releaseBinary = resolve))
|
||||
const ctx = setup(async () => pendingBinary)
|
||||
await authenticate(ctx)
|
||||
ctx.events.length = 0
|
||||
|
||||
const binary = serverFrame(new Uint8Array([7]), 'binary', 1n, ctx.schedule)
|
||||
const text = serverFrame(new TextEncoder().encode('later'), 'text', 2n, ctx.schedule)
|
||||
const first = ctx.channel.handleMessage({ delayedBlob: true })
|
||||
const second = ctx.channel.handleMessage(Buffer.from(text).toString('base64'))
|
||||
await Promise.resolve()
|
||||
expect(ctx.events).toEqual([])
|
||||
|
||||
releaseBinary(binary)
|
||||
await Promise.all([first, second])
|
||||
expect(ctx.events).toEqual(['binary:7', 'text:later'])
|
||||
expect(ctx.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues outbound text and binary in one counter order', async () => {
|
||||
const ctx = setup(async () => null)
|
||||
await authenticate(ctx)
|
||||
ctx.socket.bufferedAmount = 9 * 1024 * 1024
|
||||
expect(ctx.channel.sendText('one')).toBe(true)
|
||||
expect(ctx.channel.sendBinary(new Uint8Array([2]))).toBe(true)
|
||||
expect(ctx.sent).toHaveLength(2)
|
||||
|
||||
ctx.socket.bufferedAmount = 0
|
||||
vi.runOnlyPendingTimers()
|
||||
expect(typeof ctx.sent[2]).toBe('string')
|
||||
expect(ctx.sent[3]).toBeInstanceOf(Uint8Array)
|
||||
expect(
|
||||
openMobileE2EEV2Frame({
|
||||
frame: Buffer.from(ctx.sent[2] as string, 'base64'),
|
||||
key: ctx.schedule.mobileToDesktopKey,
|
||||
sessionId: ctx.schedule.sessionId,
|
||||
direction: 'mobile-to-desktop',
|
||||
payloadKind: 'text',
|
||||
expectedCounter: 1n
|
||||
})
|
||||
).toEqual(new TextEncoder().encode('one'))
|
||||
expect(
|
||||
openMobileE2EEV2Frame({
|
||||
frame: ctx.sent[3] as Uint8Array,
|
||||
key: ctx.schedule.mobileToDesktopKey,
|
||||
sessionId: ctx.schedule.sessionId,
|
||||
direction: 'mobile-to-desktop',
|
||||
payloadKind: 'binary',
|
||||
expectedCounter: 2n
|
||||
})
|
||||
).toEqual(new Uint8Array([2]))
|
||||
})
|
||||
|
||||
it('bounds the unified outbound queue and reports a wedged link', async () => {
|
||||
const ctx = setup(async () => null)
|
||||
await authenticate(ctx)
|
||||
ctx.socket.bufferedAmount = 9 * 1024 * 1024
|
||||
const megabyte = new Uint8Array(1024 * 1024)
|
||||
for (let index = 0; index < 65; index++) {
|
||||
expect(ctx.channel.sendBinary(megabyte)).toBe(true)
|
||||
}
|
||||
expect(ctx.onError).toHaveBeenCalledOnce()
|
||||
expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 outbound buffer overflow')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
createWsOutboundBackpressureQueue,
|
||||
type WsOutboundBackpressureQueue
|
||||
} from '../../../src/shared/ws-outbound-backpressure-queue'
|
||||
import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
|
||||
|
||||
type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready'
|
||||
type OutboundItem = { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array }
|
||||
|
||||
export class MobileE2EEAuthenticationError extends Error {
|
||||
constructor() {
|
||||
super('E2EE device authentication rejected')
|
||||
}
|
||||
}
|
||||
|
||||
export type MobileE2EEV2Socket = {
|
||||
readonly OPEN: number
|
||||
readonly readyState: number
|
||||
readonly bufferedAmount: number
|
||||
send: (frame: string | Uint8Array) => void
|
||||
}
|
||||
|
||||
export class MobileE2EEV2PhysicalChannel {
|
||||
private state: ChannelState = 'awaiting-ready'
|
||||
private generation = 0
|
||||
private inboundChain: Promise<void> = Promise.resolve()
|
||||
private readonly outboundQueue: WsOutboundBackpressureQueue<OutboundItem>
|
||||
|
||||
constructor(
|
||||
private readonly args: {
|
||||
session: MobileE2EEV2ClientSession
|
||||
socket: MobileE2EEV2Socket
|
||||
deviceToken: string
|
||||
decodeBinary: (raw: unknown) => Promise<Uint8Array | null>
|
||||
onAuthenticated: () => void
|
||||
onText: (plaintext: string) => void
|
||||
onBinary: (plaintext: Uint8Array) => void
|
||||
onError: (error: Error) => void
|
||||
}
|
||||
) {
|
||||
this.outboundQueue = createWsOutboundBackpressureQueue<OutboundItem>({
|
||||
// Why: encryption happens only when an admitted item reaches the wire,
|
||||
// so a bounded-queue rejection cannot burn an ordered v2 counter.
|
||||
send: (item) => {
|
||||
args.socket.send(
|
||||
item.kind === 'text'
|
||||
? args.session.sealText(item.plaintext)
|
||||
: args.session.sealBinary(item.plaintext)
|
||||
)
|
||||
},
|
||||
byteLengthOf: (item) =>
|
||||
(item.kind === 'text'
|
||||
? new TextEncoder().encode(item.plaintext).length
|
||||
: item.plaintext.length) + 82,
|
||||
getBufferedAmount: () => args.socket.bufferedAmount,
|
||||
isWritable: () => args.socket.readyState === args.socket.OPEN,
|
||||
onOverflow: () => args.onError(new Error('E2EE v2 outbound buffer overflow'))
|
||||
})
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.args.socket.send(JSON.stringify(this.args.session.hello))
|
||||
}
|
||||
|
||||
handleMessage(raw: unknown): Promise<void> {
|
||||
const generation = this.generation
|
||||
this.inboundChain = this.inboundChain
|
||||
.then(() => this.processMessage(raw, generation))
|
||||
.catch((error: unknown) => {
|
||||
if (generation === this.generation) {
|
||||
this.args.onError(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
return this.inboundChain
|
||||
}
|
||||
|
||||
sendText(plaintext: string): boolean {
|
||||
return this.enqueueReady({ kind: 'text', plaintext })
|
||||
}
|
||||
|
||||
sendBinary(plaintext: Uint8Array): boolean {
|
||||
return this.enqueueReady({ kind: 'binary', plaintext })
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.generation++
|
||||
this.outboundQueue.dispose()
|
||||
}
|
||||
|
||||
private async processMessage(raw: unknown, generation: number): Promise<void> {
|
||||
if (generation !== this.generation) {
|
||||
return
|
||||
}
|
||||
if (this.state === 'awaiting-ready') {
|
||||
this.acceptReady(raw)
|
||||
return
|
||||
}
|
||||
|
||||
const plaintext =
|
||||
typeof raw === 'string'
|
||||
? this.args.session.openText(raw)
|
||||
: await this.openBinary(raw, generation)
|
||||
if (generation !== this.generation || plaintext === null) {
|
||||
return
|
||||
}
|
||||
if (this.state === 'awaiting-authenticated') {
|
||||
if (typeof plaintext === 'string' && isAuthenticationRejection(plaintext)) {
|
||||
throw new MobileE2EEAuthenticationError()
|
||||
}
|
||||
if (typeof plaintext !== 'string' || !this.isAuthenticated(plaintext)) {
|
||||
throw new Error('Invalid E2EE v2 authenticated response')
|
||||
}
|
||||
this.state = 'ready'
|
||||
this.args.onAuthenticated()
|
||||
} else if (typeof plaintext === 'string') {
|
||||
this.args.onText(plaintext)
|
||||
} else {
|
||||
this.args.onBinary(plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
private acceptReady(raw: unknown): void {
|
||||
if (typeof raw !== 'string') {
|
||||
throw new Error('Expected plaintext E2EE v2 ready')
|
||||
}
|
||||
let ready: unknown
|
||||
try {
|
||||
ready = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error('Invalid E2EE v2 ready JSON')
|
||||
}
|
||||
if (!this.args.session.acceptReady(ready)) {
|
||||
throw new Error('Invalid E2EE v2 ready')
|
||||
}
|
||||
this.state = 'awaiting-authenticated'
|
||||
this.outboundQueue.enqueue({
|
||||
kind: 'text',
|
||||
plaintext: JSON.stringify({
|
||||
type: 'e2ee_auth',
|
||||
v: 2,
|
||||
transcriptHashB64: this.args.session.transcriptHashB64,
|
||||
deviceToken: this.args.deviceToken
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async openBinary(raw: unknown, generation: number): Promise<Uint8Array | null> {
|
||||
const bytes = await this.args.decodeBinary(raw)
|
||||
if (!bytes || generation !== this.generation) {
|
||||
return null
|
||||
}
|
||||
return this.args.session.openBinary(bytes)
|
||||
}
|
||||
|
||||
private isAuthenticated(plaintext: string): boolean {
|
||||
try {
|
||||
const message = JSON.parse(plaintext) as Record<string, unknown>
|
||||
return (
|
||||
Object.keys(message).sort().join(',') === 'transcriptHashB64,type,v' &&
|
||||
message.type === 'e2ee_authenticated' &&
|
||||
message.v === 2 &&
|
||||
message.transcriptHashB64 === this.args.session.transcriptHashB64
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private enqueueReady(item: OutboundItem): boolean {
|
||||
if (this.state !== 'ready') {
|
||||
return false
|
||||
}
|
||||
this.outboundQueue.enqueue(item)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthenticationRejection(plaintext: string): boolean {
|
||||
try {
|
||||
const message = JSON.parse(plaintext) as Record<string, unknown>
|
||||
return message.type === 'e2ee_error'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
|
||||
|
||||
const options = {
|
||||
directSuccessesRequired: 3,
|
||||
directObservationMs: 30_000,
|
||||
failureCooldownMs: 60_000,
|
||||
minimumDwellMs: 60_000
|
||||
}
|
||||
|
||||
describe('mobile endpoint hysteresis', () => {
|
||||
it('requires three authenticated direct successes across the observation and dwell windows', () => {
|
||||
const policy = new MobileEndpointHysteresis(0, options)
|
||||
|
||||
expect(policy.recordDirectSuccess(60_000)).toBe(false)
|
||||
expect(policy.recordDirectSuccess(75_000)).toBe(false)
|
||||
expect(policy.recordDirectSuccess(90_000)).toBe(true)
|
||||
})
|
||||
|
||||
it('resets progress and observes cooldown after a failure', () => {
|
||||
const policy = new MobileEndpointHysteresis(0, options)
|
||||
policy.recordDirectSuccess(60_000)
|
||||
policy.recordDirectFailure(61_000)
|
||||
|
||||
expect(policy.canProbe(120_999)).toBe(false)
|
||||
expect(policy.canProbe(121_000)).toBe(true)
|
||||
expect(policy.recordDirectSuccess(121_000)).toBe(false)
|
||||
expect(policy.recordDirectSuccess(136_000)).toBe(false)
|
||||
expect(policy.recordDirectSuccess(151_000)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
export type EndpointHysteresisOptions = {
|
||||
directSuccessesRequired: number
|
||||
directObservationMs: number
|
||||
failureCooldownMs: number
|
||||
minimumDwellMs: number
|
||||
}
|
||||
|
||||
export class MobileEndpointHysteresis {
|
||||
private consecutiveDirectSuccesses = 0
|
||||
private directObservationStartedAt: number | null = null
|
||||
private cooldownUntil = 0
|
||||
private lastMigrationAt: number
|
||||
|
||||
constructor(
|
||||
startedAt: number,
|
||||
private readonly options: EndpointHysteresisOptions
|
||||
) {
|
||||
this.lastMigrationAt = startedAt
|
||||
}
|
||||
|
||||
recordDirectSuccess(now: number): boolean {
|
||||
if (now < this.cooldownUntil) {
|
||||
return false
|
||||
}
|
||||
if (this.consecutiveDirectSuccesses === 0) {
|
||||
this.directObservationStartedAt = now
|
||||
}
|
||||
this.consecutiveDirectSuccesses += 1
|
||||
return (
|
||||
this.consecutiveDirectSuccesses >= this.options.directSuccessesRequired &&
|
||||
this.directObservationStartedAt !== null &&
|
||||
now - this.directObservationStartedAt >= this.options.directObservationMs &&
|
||||
now - this.lastMigrationAt >= this.options.minimumDwellMs
|
||||
)
|
||||
}
|
||||
|
||||
recordDirectFailure(now: number): void {
|
||||
this.consecutiveDirectSuccesses = 0
|
||||
this.directObservationStartedAt = null
|
||||
this.cooldownUntil = now + this.options.failureCooldownMs
|
||||
}
|
||||
|
||||
recordMigration(now: number): void {
|
||||
this.lastMigrationAt = now
|
||||
this.consecutiveDirectSuccesses = 0
|
||||
this.directObservationStartedAt = null
|
||||
}
|
||||
|
||||
canProbe(now: number): boolean {
|
||||
return now >= this.cooldownUntil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as ExpoCrypto from 'expo-crypto'
|
||||
import type { ConnectionLogSink, HostProfile } from './types'
|
||||
import { connect } from './rpc-client'
|
||||
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
|
||||
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
|
||||
import {
|
||||
readMobileRelayCredentialBundle,
|
||||
writeMobileRelayCredentialBundle
|
||||
} from './mobile-relay-credential-bundle'
|
||||
import { saveHost } from './host-store'
|
||||
import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade'
|
||||
import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
|
||||
type EndpointLifecycle = {
|
||||
setForeground(foreground: boolean): void
|
||||
stop(): void
|
||||
}
|
||||
|
||||
type EndpointOwner = EndpointLifecycle & {
|
||||
start(): Promise<void>
|
||||
}
|
||||
|
||||
export function startMobileEndpointLifecycle(
|
||||
logical: StableLogicalRpcClient,
|
||||
initialHost: HostProfile,
|
||||
onLog: ConnectionLogSink
|
||||
): EndpointLifecycle {
|
||||
let stopped = false
|
||||
let foreground = true
|
||||
let owner: EndpointOwner
|
||||
|
||||
const startSupervisor = async (host: HostProfile): Promise<void> => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
const supervisor = createSupervisor(logical, host, onLog)
|
||||
owner.stop()
|
||||
owner = supervisor
|
||||
supervisor.setForeground(foreground)
|
||||
await supervisor.start()
|
||||
}
|
||||
|
||||
if (initialHost.relay) {
|
||||
owner = createSupervisor(logical, initialHost, onLog)
|
||||
void owner.start()
|
||||
} else {
|
||||
owner = new MobileRelayDirectUpgradeController(logical, initialHost, {
|
||||
upgrade: (client, host) =>
|
||||
upgradeDirectMobileRelay({
|
||||
client,
|
||||
host,
|
||||
dependencies: { randomBytes: ExpoCrypto.getRandomBytes }
|
||||
}),
|
||||
onUpgraded: ({ host }) => startSupervisor(host)
|
||||
})
|
||||
void owner.start()
|
||||
}
|
||||
|
||||
return {
|
||||
setForeground(next) {
|
||||
foreground = next
|
||||
owner.setForeground(next)
|
||||
},
|
||||
stop() {
|
||||
stopped = true
|
||||
owner.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createSupervisor(
|
||||
logical: StableLogicalRpcClient,
|
||||
host: HostProfile,
|
||||
onLog: ConnectionLogSink
|
||||
): MobileEndpointSupervisor {
|
||||
return new MobileEndpointSupervisor(logical, host, {
|
||||
openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
|
||||
openRelay: (relay, credential, confirmReqId) =>
|
||||
connectMobileRelayRpcSession({
|
||||
relay,
|
||||
resumeToken: credential.token,
|
||||
resumeCredentialVersion: credential.version,
|
||||
resumeConfirmReqId: confirmReqId,
|
||||
deviceToken: host.deviceToken,
|
||||
desktopPublicKeyB64: host.publicKeyB64
|
||||
}),
|
||||
resolveRelay: resolveMobileRelayEndpoint,
|
||||
readBundle: readMobileRelayCredentialBundle,
|
||||
writeBundle: writeMobileRelayCredentialBundle,
|
||||
saveHost,
|
||||
now: Date.now,
|
||||
randomBytes: ExpoCrypto.getRandomBytes,
|
||||
setTimer: setTimeout,
|
||||
clearTimer: clearTimeout
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
|
||||
import type { HostProfile } from './types'
|
||||
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
|
||||
|
||||
export function isDirectorResolutionFailure(error: Error): boolean {
|
||||
return (
|
||||
!(error instanceof MobileE2EEAuthenticationError) &&
|
||||
(!(error instanceof RelayOuterError) || [4409, 4503, 1006].includes(error.code))
|
||||
)
|
||||
}
|
||||
|
||||
export function relayWebSocketUrl(relay: { cellUrl: string; relayHostId: string }): string {
|
||||
const url = new URL(relay.cellUrl)
|
||||
url.protocol = 'wss:'
|
||||
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export async function persistRelayHost(
|
||||
host: HostProfile,
|
||||
relay: MobileRelayEndpoint,
|
||||
saveHost: (host: HostProfile) => Promise<void>
|
||||
): Promise<HostProfile> {
|
||||
const endpoints = [
|
||||
...(host.endpoints ?? [{ id: 'direct-primary', kind: 'lan' as const, url: host.endpoint }])
|
||||
].filter(({ kind }) => kind !== 'relay')
|
||||
endpoints.push({ id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) })
|
||||
const updated = { ...host, endpoints, relayHostId: relay.relayHostId, relay }
|
||||
await saveHost(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
export function encodeBase64Url(value: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of value) {
|
||||
binary += String.fromCharCode(byte)
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
|
||||
import { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import {
|
||||
MobileEndpointSupervisor,
|
||||
type MobileEndpointSupervisorDependencies
|
||||
} from './mobile-endpoint-supervisor'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostProfile, RpcResponse } from './types'
|
||||
|
||||
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
|
||||
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
|
||||
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
|
||||
|
||||
class FakeSession implements RpcClient {
|
||||
readonly sendRequest = vi.fn(
|
||||
async (): Promise<RpcResponse> => ({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: {},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
readonly subscribe = vi.fn(() => () => {})
|
||||
readonly updateTerminalSubscriptionViewport = vi.fn()
|
||||
readonly notifyForeground = vi.fn()
|
||||
readonly close = vi.fn()
|
||||
private readonly listeners = new Set<(state: ConnectionState) => void>()
|
||||
|
||||
constructor(private state: ConnectionState) {}
|
||||
|
||||
getState = () => this.state
|
||||
getReconnectAttempt = () => 0
|
||||
getLastConnectedAt = () => null
|
||||
onStateChange = (listener: (state: ConnectionState) => void) => {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
publishState(state: ConnectionState): void {
|
||||
this.state = state
|
||||
for (const listener of this.listeners) {
|
||||
listener(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
|
||||
constructor(
|
||||
state: ConnectionState,
|
||||
private readonly failure: Error | null = null,
|
||||
private readonly lease = Date.now() + 120_000
|
||||
) {
|
||||
super(state)
|
||||
}
|
||||
getLeaseExpiresAt = () => this.lease
|
||||
getResumeConfirmation = () => ({
|
||||
v: 1 as const,
|
||||
reqId: 'confirm-1',
|
||||
currentVersion: 2,
|
||||
acceptedAs: 'current' as const,
|
||||
renewed: true,
|
||||
resumeExpiresAt: Date.now() + 300_000
|
||||
})
|
||||
getFailure = () => this.failure
|
||||
}
|
||||
|
||||
class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
|
||||
private path: MobileConnectionPath
|
||||
private generation = 1
|
||||
|
||||
constructor(state: ConnectionState, path: MobileConnectionPath) {
|
||||
super(state)
|
||||
this.path = path
|
||||
}
|
||||
|
||||
migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => {
|
||||
if (session.getState() !== 'connected') {
|
||||
session.close()
|
||||
throw new Error(`replacement session ${session.getState()}`)
|
||||
}
|
||||
this.path = path
|
||||
this.generation += 1
|
||||
})
|
||||
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
|
||||
getActivePath = () => this.path
|
||||
getGeneration = () => this.generation
|
||||
}
|
||||
|
||||
const relay = {
|
||||
v: 1 as const,
|
||||
directorUrl: 'https://relay.onorca.dev',
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
e2eeFraming: 2 as const
|
||||
}
|
||||
const host: HostProfile = {
|
||||
id: 'host-1',
|
||||
name: 'Blue Whale',
|
||||
endpoint: 'ws://192.168.1.10:6768',
|
||||
deviceToken: 'device-token',
|
||||
publicKeyB64: 'A'.repeat(44),
|
||||
lastConnected: 1,
|
||||
endpoints: [
|
||||
{ id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' },
|
||||
{ id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' }
|
||||
],
|
||||
relayHostId: relay.relayHostId,
|
||||
relay
|
||||
}
|
||||
const bundle: MobileRelayCredentialBundle = {
|
||||
v: 1,
|
||||
hostId: host.id,
|
||||
deviceToken: host.deviceToken,
|
||||
current: {
|
||||
token: 'A'.repeat(43),
|
||||
hash: 'B'.repeat(43),
|
||||
version: 2,
|
||||
expiresAt: Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
overrides: Partial<MobileEndpointSupervisorDependencies> = {}
|
||||
): MobileEndpointSupervisorDependencies {
|
||||
return {
|
||||
openDirect: vi.fn(() => new FakeSession('connected')),
|
||||
openRelay: vi.fn(() => new FakeRelaySession('connected')),
|
||||
resolveRelay: vi.fn(async ({ relay }) => relay),
|
||||
readBundle: vi.fn(async () => bundle),
|
||||
writeBundle: vi.fn(async () => {}),
|
||||
saveHost: vi.fn(async () => {}),
|
||||
now: Date.now,
|
||||
randomBytes: (length) => new Uint8Array(length).fill(1),
|
||||
setTimer: setTimeout,
|
||||
clearTimer: clearTimeout,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile endpoint supervisor', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('fails over to a confirmed relay session and persists its renewed expiry', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const deps = dependencies()
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
|
||||
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
|
||||
expect(logical.getActivePath()).toBe('relay')
|
||||
expect(deps.writeBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ current: expect.objectContaining({ version: 2 }) })
|
||||
)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('fails over when the direct retry loop publishes reconnecting', async () => {
|
||||
const logical = new FakeLogicalClient('connecting', 'lan')
|
||||
const deps = dependencies()
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
await supervisor.start()
|
||||
|
||||
logical.publishState('reconnecting')
|
||||
await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay'))
|
||||
|
||||
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('fails over when direct is already reconnecting before startup completes', async () => {
|
||||
const logical = new FakeLogicalClient('reconnecting', 'lan')
|
||||
const deps = dependencies()
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
|
||||
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
|
||||
expect(logical.getActivePath()).toBe('relay')
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('uses POST resolve for wrong-cell recovery and persists the authoritative target', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4409)))
|
||||
.mockReturnValueOnce(new FakeRelaySession('connected'))
|
||||
const resolved = { ...relay, cellUrl: 'https://relay-c2.onorca.dev', assignmentEpoch: 8 }
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
resolveRelay: vi.fn(async () => resolved)
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
|
||||
expect(deps.resolveRelay).toHaveBeenCalledOnce()
|
||||
expect(openRelay).toHaveBeenLastCalledWith(resolved, expect.any(Object), expect.any(String))
|
||||
expect(deps.saveHost).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ relay: resolved, endpoint: host.endpoint })
|
||||
)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('promotes direct only after repeated foreground authenticated probes and dwell', async () => {
|
||||
const logical = new FakeLogicalClient('connected', 'relay')
|
||||
const deps = dependencies()
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
await supervisor.start()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(45_000)
|
||||
expect(logical.getActivePath()).toBe('relay')
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
expect(logical.getActivePath()).toBe('lan')
|
||||
expect(deps.openDirect).toHaveBeenCalledTimes(4)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('releases a background relay session and reconnects it on foreground', async () => {
|
||||
const logical = new FakeLogicalClient('connected', 'relay')
|
||||
const deps = dependencies()
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
await supervisor.start()
|
||||
|
||||
supervisor.setForeground(false)
|
||||
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
|
||||
expect(logical.getState()).toBe('disconnected')
|
||||
|
||||
supervisor.setForeground(true)
|
||||
await vi.waitFor(() => expect(logical.migrateTo).toHaveBeenCalled())
|
||||
expect(logical.getActivePath()).toBe('relay')
|
||||
supervisor.stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
|
||||
import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe'
|
||||
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
|
||||
import {
|
||||
encodeBase64Url,
|
||||
isDirectorResolutionFailure,
|
||||
persistRelayHost,
|
||||
toError
|
||||
} from './mobile-endpoint-supervisor-support'
|
||||
import {
|
||||
applyResumeConfirmation,
|
||||
mobileRelayCredentialNeedsRotation,
|
||||
rotateMobileRelayCredential
|
||||
} from './mobile-relay-credential-rotation'
|
||||
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
|
||||
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { HostProfile } from './types'
|
||||
|
||||
const DIRECT_PROBE_INTERVAL_MS = 15_000
|
||||
const DIRECT_OBSERVATION_MS = 30_000
|
||||
const MINIMUM_DWELL_MS = 60_000
|
||||
const FAILURE_COOLDOWN_MS = 60_000
|
||||
const LEASE_ROTATION_MARGIN_MS = 30_000
|
||||
|
||||
export type MobileEndpointSupervisorDependencies = {
|
||||
openDirect: (endpoint: string) => RpcClient
|
||||
openRelay: (
|
||||
relay: MobileRelayEndpoint,
|
||||
credential: { token: string; version: number },
|
||||
confirmReqId: string
|
||||
) => MobileRelayRpcSession
|
||||
resolveRelay: typeof resolveMobileRelayEndpoint
|
||||
readBundle: (hostId: string) => Promise<MobileRelayCredentialBundle | null>
|
||||
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
|
||||
saveHost: (host: HostProfile) => Promise<void>
|
||||
now: () => number
|
||||
randomBytes: (length: number) => Uint8Array
|
||||
setTimer: typeof setTimeout
|
||||
clearTimer: typeof clearTimeout
|
||||
}
|
||||
|
||||
export class MobileEndpointSupervisor {
|
||||
private host: HostProfile
|
||||
private bundle: MobileRelayCredentialBundle | null = null
|
||||
private stopped = false
|
||||
private foreground = true
|
||||
private operationInFlight = false
|
||||
private credentialRotationInFlight = false
|
||||
private relayRotationPending = false
|
||||
private probeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private leaseTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private unsubscribeState: (() => void) | null = null
|
||||
private readonly hysteresis: MobileEndpointHysteresis
|
||||
|
||||
constructor(
|
||||
private readonly logical: StableLogicalRpcClient,
|
||||
host: HostProfile,
|
||||
private readonly dependencies: MobileEndpointSupervisorDependencies
|
||||
) {
|
||||
this.host = host
|
||||
this.hysteresis = new MobileEndpointHysteresis(dependencies.now(), {
|
||||
directSuccessesRequired: 3,
|
||||
directObservationMs: DIRECT_OBSERVATION_MS,
|
||||
failureCooldownMs: FAILURE_COOLDOWN_MS,
|
||||
minimumDwellMs: MINIMUM_DWELL_MS
|
||||
})
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.bundle = await this.dependencies.readBundle(this.host.id).catch(() => null)
|
||||
if (this.stopped || !this.bundle || !this.host.relay) {
|
||||
return
|
||||
}
|
||||
this.unsubscribeState = this.logical.onStateChange((state) => {
|
||||
if (state === 'connected') {
|
||||
if (this.logical.getActivePath() !== 'relay') {
|
||||
void this.rotateCredentialIfNeeded()
|
||||
}
|
||||
this.scheduleDirectProbe()
|
||||
} else if (state === 'reconnecting' || state === 'disconnected' || state === 'auth-failed') {
|
||||
// Why: the direct client enters reconnecting after its first failed
|
||||
// dial and may never publish disconnected while its retry loop lives.
|
||||
void this.recoverRelay()
|
||||
}
|
||||
})
|
||||
const initialState = this.logical.getState()
|
||||
if (
|
||||
initialState === 'reconnecting' ||
|
||||
initialState === 'disconnected' ||
|
||||
initialState === 'auth-failed'
|
||||
) {
|
||||
// Why: the first direct dial can fail while encrypted relay credentials
|
||||
// are still loading, before the supervisor subscribes to state changes.
|
||||
await this.recoverRelay()
|
||||
} else {
|
||||
this.scheduleDirectProbe()
|
||||
}
|
||||
}
|
||||
|
||||
setForeground(foreground: boolean): void {
|
||||
this.foreground = foreground
|
||||
if (foreground) {
|
||||
void this.recoverRelay(this.relayRotationPending)
|
||||
this.scheduleDirectProbe(0)
|
||||
} else {
|
||||
if (this.logical.getActivePath() === 'relay') {
|
||||
// Why: background phones must not hold billed relay data splices; the
|
||||
// stable client keeps subscriptions for authenticated foreground replay.
|
||||
this.logical.suspendActiveSession()
|
||||
}
|
||||
if (this.probeTimer) {
|
||||
this.dependencies.clearTimer(this.probeTimer)
|
||||
this.probeTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true
|
||||
this.unsubscribeState?.()
|
||||
this.unsubscribeState = null
|
||||
if (this.probeTimer) {
|
||||
this.dependencies.clearTimer(this.probeTimer)
|
||||
this.probeTimer = null
|
||||
}
|
||||
this.clearLeaseTimer()
|
||||
}
|
||||
|
||||
private async recoverRelay(forceReplacement = false): Promise<void> {
|
||||
if (
|
||||
this.stopped ||
|
||||
!this.foreground ||
|
||||
this.operationInFlight ||
|
||||
!this.bundle ||
|
||||
!this.host.relay ||
|
||||
(!forceReplacement && this.logical.getState() === 'connected')
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.operationInFlight = true
|
||||
try {
|
||||
const credentials = [this.bundle.current, this.bundle.grace].filter(
|
||||
(credential): credential is NonNullable<typeof credential> =>
|
||||
Boolean(credential && credential.expiresAt > this.dependencies.now())
|
||||
)
|
||||
for (const credential of credentials) {
|
||||
if (await this.tryRelayCredential(credential)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.operationInFlight = false
|
||||
if (forceReplacement && this.relayRotationPending && !this.stopped && !this.leaseTimer) {
|
||||
this.leaseTimer = this.dependencies.setTimer(() => {
|
||||
this.leaseTimer = null
|
||||
void this.recoverRelay(true)
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async tryRelayCredential(credential: {
|
||||
token: string
|
||||
version: number
|
||||
}): Promise<boolean> {
|
||||
const first = await this.openAndMigrateRelay(credential)
|
||||
if (first.ok) {
|
||||
return true
|
||||
}
|
||||
if (!isDirectorResolutionFailure(first.error) || !this.host.relay) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const resolved = await this.dependencies.resolveRelay({
|
||||
relay: this.host.relay,
|
||||
resumeToken: credential.token
|
||||
})
|
||||
this.host = await persistRelayHost(this.host, resolved, this.dependencies.saveHost)
|
||||
return (await this.openAndMigrateRelay(credential)).ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async openAndMigrateRelay(credential: {
|
||||
token: string
|
||||
version: number
|
||||
}): Promise<{ ok: true } | { ok: false; error: Error }> {
|
||||
if (!this.host.relay || !this.bundle) {
|
||||
return { ok: false, error: new Error('relay state missing') }
|
||||
}
|
||||
const session = this.dependencies.openRelay(
|
||||
this.host.relay,
|
||||
credential,
|
||||
`confirm-${encodeBase64Url(this.dependencies.randomBytes(16))}`
|
||||
)
|
||||
try {
|
||||
await this.logical.migrateTo(session, 'relay')
|
||||
if (!this.foreground) {
|
||||
this.logical.suspendActiveSession()
|
||||
}
|
||||
this.relayRotationPending = false
|
||||
this.hysteresis.recordMigration(this.dependencies.now())
|
||||
const confirmation = session.getResumeConfirmation()
|
||||
if (confirmation) {
|
||||
this.bundle = applyResumeConfirmation(this.bundle, credential.version, confirmation)
|
||||
await this.dependencies.writeBundle(this.bundle)
|
||||
}
|
||||
this.scheduleLeaseRotation(session)
|
||||
this.scheduleDirectProbe()
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, error: session.getFailure() ?? toError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleDirectProbe(delayMs = DIRECT_PROBE_INTERVAL_MS): void {
|
||||
if (
|
||||
this.stopped ||
|
||||
!this.foreground ||
|
||||
this.logical.getActivePath() !== 'relay' ||
|
||||
this.probeTimer
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.probeTimer = this.dependencies.setTimer(() => {
|
||||
this.probeTimer = null
|
||||
void this.probeDirect()
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
private async probeDirect(): Promise<void> {
|
||||
if (
|
||||
this.stopped ||
|
||||
!this.foreground ||
|
||||
this.operationInFlight ||
|
||||
!this.hysteresis.canProbe(this.dependencies.now())
|
||||
) {
|
||||
this.scheduleDirectProbe()
|
||||
return
|
||||
}
|
||||
this.operationInFlight = true
|
||||
let successful: Awaited<ReturnType<typeof openAuthenticatedDirectEndpoint>> = null
|
||||
try {
|
||||
const openDirect = this.dependencies.openDirect
|
||||
successful = await openAuthenticatedDirectEndpoint(this.host, openDirect, 12_000)
|
||||
if (!successful) {
|
||||
this.hysteresis.recordDirectFailure(this.dependencies.now())
|
||||
return
|
||||
}
|
||||
if (!this.hysteresis.recordDirectSuccess(this.dependencies.now())) {
|
||||
successful.client.close()
|
||||
return
|
||||
}
|
||||
await this.logical.migrateTo(successful.client, successful.path)
|
||||
successful = null
|
||||
this.hysteresis.recordMigration(this.dependencies.now())
|
||||
this.clearLeaseTimer()
|
||||
this.relayRotationPending = false
|
||||
await this.rotateCredentialIfNeeded()
|
||||
} finally {
|
||||
successful?.client.close()
|
||||
this.operationInFlight = false
|
||||
if (this.relayRotationPending) {
|
||||
void this.recoverRelay(true)
|
||||
}
|
||||
this.scheduleDirectProbe()
|
||||
}
|
||||
}
|
||||
|
||||
private async rotateCredentialIfNeeded(): Promise<void> {
|
||||
if (
|
||||
this.stopped ||
|
||||
this.credentialRotationInFlight ||
|
||||
!this.bundle ||
|
||||
this.logical.getActivePath() === 'relay' ||
|
||||
!mobileRelayCredentialNeedsRotation(this.bundle, this.dependencies.now())
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.credentialRotationInFlight = true
|
||||
try {
|
||||
const result = await rotateMobileRelayCredential({
|
||||
client: this.logical,
|
||||
bundle: this.bundle,
|
||||
writeBundle: this.dependencies.writeBundle,
|
||||
randomBytes: this.dependencies.randomBytes
|
||||
})
|
||||
this.bundle = result.bundle
|
||||
this.host = await persistRelayHost(this.host, result.relay, this.dependencies.saveHost)
|
||||
} catch {
|
||||
// Why: pending material remains durable; the next authenticated direct
|
||||
// opportunity must reconcile it before creating another install key.
|
||||
} finally {
|
||||
this.credentialRotationInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleLeaseRotation(session: MobileRelayRpcSession): void {
|
||||
this.clearLeaseTimer()
|
||||
const deadline = session.getLeaseExpiresAt()
|
||||
if (!deadline) {
|
||||
return
|
||||
}
|
||||
const delay = Math.max(1000, deadline - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS)
|
||||
this.leaseTimer = this.dependencies.setTimer(() => {
|
||||
this.leaseTimer = null
|
||||
this.relayRotationPending = true
|
||||
void this.recoverRelay(true)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private clearLeaseTimer(): void {
|
||||
if (this.leaseTimer) {
|
||||
this.dependencies.clearTimer(this.leaseTimer)
|
||||
this.leaseTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const secureStore = vi.hoisted(() => ({
|
||||
getItemAsync: vi.fn(),
|
||||
setItemAsync: vi.fn(),
|
||||
deleteItemAsync: vi.fn()
|
||||
}))
|
||||
const platform = vi.hoisted(() => ({ OS: 'ios' }))
|
||||
|
||||
vi.mock('expo-secure-store', () => ({
|
||||
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
|
||||
...secureStore
|
||||
}))
|
||||
vi.mock('react-native', () => ({ Platform: platform }))
|
||||
|
||||
import {
|
||||
deleteMobileRelayCredentialBundle,
|
||||
promotePairingJournalCredential,
|
||||
readMobileRelayCredentialBundle,
|
||||
writeMobileRelayCredentialBundle
|
||||
} from './mobile-relay-credential-bundle'
|
||||
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
|
||||
|
||||
const journal = {
|
||||
metadata: {
|
||||
v: 1,
|
||||
journalId: 'pair-1',
|
||||
offerFingerprint: 'A'.repeat(43),
|
||||
host: {
|
||||
id: 'host-1',
|
||||
name: 'Blue Whale',
|
||||
endpoint: 'ws://192.168.1.10:6768',
|
||||
publicKeyB64: 'A'.repeat(44),
|
||||
lastConnected: 1
|
||||
},
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.onorca.dev',
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
inviteExpiresAt: 10_000,
|
||||
e2eeFraming: 2
|
||||
},
|
||||
installReqId: 'install-1',
|
||||
resumeConfirmReqId: 'confirm-1',
|
||||
pendingResumeTokenHash: 'B'.repeat(43),
|
||||
winner: 'direct',
|
||||
authorizationMode: 'authenticated-direct'
|
||||
},
|
||||
secrets: {
|
||||
v: 1,
|
||||
journalId: 'pair-1',
|
||||
deviceToken: 'device-token',
|
||||
inviteToken: 'C'.repeat(43),
|
||||
pendingResumeToken: 'D'.repeat(43)
|
||||
}
|
||||
} satisfies MobileRelayPairingJournal
|
||||
|
||||
describe('mobile relay credential bundle', () => {
|
||||
let stored: string | null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
platform.OS = 'ios'
|
||||
stored = null
|
||||
secureStore.getItemAsync.mockImplementation(async () => stored)
|
||||
secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => {
|
||||
stored = value
|
||||
})
|
||||
secureStore.deleteItemAsync.mockImplementation(async () => {
|
||||
stored = null
|
||||
})
|
||||
})
|
||||
|
||||
it('promotes only a matching committed install result to current', async () => {
|
||||
const bundle = promotePairingJournalCredential({
|
||||
journal,
|
||||
installed: {
|
||||
v: 1,
|
||||
reqId: 'install-1',
|
||||
authorizationMode: 'authenticated-direct',
|
||||
currentVersion: 3,
|
||||
resumeExpiresAt: 50_000
|
||||
}
|
||||
})
|
||||
await writeMobileRelayCredentialBundle(bundle)
|
||||
|
||||
await expect(readMobileRelayCredentialBundle('host-1')).resolves.toEqual(bundle)
|
||||
expect(stored).toContain(journal.secrets.pendingResumeToken)
|
||||
expect(stored).not.toContain(journal.secrets.inviteToken)
|
||||
})
|
||||
|
||||
it('rejects a result from another request or authorization mode', () => {
|
||||
expect(() =>
|
||||
promotePairingJournalCredential({
|
||||
journal,
|
||||
installed: {
|
||||
v: 1,
|
||||
reqId: 'other-request',
|
||||
authorizationMode: 'relay-basis',
|
||||
currentVersion: 1,
|
||||
resumeExpiresAt: 50_000
|
||||
}
|
||||
})
|
||||
).toThrow(/does not match/)
|
||||
})
|
||||
|
||||
it('deletes the namespaced bundle and never enables it on web', async () => {
|
||||
await deleteMobileRelayCredentialBundle('host-1')
|
||||
expect(secureStore.deleteItemAsync).toHaveBeenCalledWith(
|
||||
'orca.mobile-relay.credentials.host-1',
|
||||
expect.any(Object)
|
||||
)
|
||||
platform.OS = 'web'
|
||||
await expect(readMobileRelayCredentialBundle('host-1')).rejects.toThrow(/native secret store/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
import { Platform } from 'react-native'
|
||||
import { z } from 'zod'
|
||||
import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract'
|
||||
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
|
||||
|
||||
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
|
||||
const ResumeCredentialSchema = z
|
||||
.object({
|
||||
token: Base64Url32ByteSchema,
|
||||
hash: Base64Url32ByteSchema,
|
||||
version: z.number().int().positive(),
|
||||
expiresAt: z.number().int().nonnegative()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileRelayCredentialBundleSchema = z
|
||||
.object({
|
||||
v: z.literal(1),
|
||||
hostId: z.string().min(1),
|
||||
deviceToken: z.string().min(1),
|
||||
current: ResumeCredentialSchema,
|
||||
grace: ResumeCredentialSchema.optional(),
|
||||
pending: z
|
||||
.object({
|
||||
token: Base64Url32ByteSchema,
|
||||
hash: Base64Url32ByteSchema,
|
||||
reqId: z.string().min(1)
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
invite: z
|
||||
.object({ token: Base64Url32ByteSchema, expiresAt: z.number().int().positive() })
|
||||
.strict()
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type MobileRelayCredentialBundle = z.infer<typeof MobileRelayCredentialBundleSchema>
|
||||
|
||||
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
|
||||
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
|
||||
}
|
||||
|
||||
function credentialKey(hostId: string): string {
|
||||
return `orca.mobile-relay.credentials.${hostId}`
|
||||
}
|
||||
|
||||
export function promotePairingJournalCredential(args: {
|
||||
journal: MobileRelayPairingJournal
|
||||
installed: DeviceCredentialInstalled
|
||||
}): MobileRelayCredentialBundle {
|
||||
const { journal, installed } = args
|
||||
if (
|
||||
installed.reqId !== journal.metadata.installReqId ||
|
||||
installed.authorizationMode !== journal.metadata.authorizationMode
|
||||
) {
|
||||
throw new Error('relay credential install result does not match pairing journal')
|
||||
}
|
||||
return MobileRelayCredentialBundleSchema.parse({
|
||||
v: 1,
|
||||
hostId: journal.metadata.host.id,
|
||||
deviceToken: journal.secrets.deviceToken,
|
||||
current: {
|
||||
token: journal.secrets.pendingResumeToken,
|
||||
hash: journal.metadata.pendingResumeTokenHash,
|
||||
version: installed.currentVersion,
|
||||
expiresAt: installed.resumeExpiresAt
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function readMobileRelayCredentialBundle(
|
||||
hostId: string
|
||||
): Promise<MobileRelayCredentialBundle | null> {
|
||||
requireNativeSecretStore()
|
||||
const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
|
||||
if (raw === null) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const result = MobileRelayCredentialBundleSchema.safeParse(JSON.parse(raw))
|
||||
return result.success && result.data.hostId === hostId ? result.data : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeMobileRelayCredentialBundle(
|
||||
bundle: MobileRelayCredentialBundle
|
||||
): Promise<void> {
|
||||
requireNativeSecretStore()
|
||||
const validated = MobileRelayCredentialBundleSchema.parse(bundle)
|
||||
await SecureStore.setItemAsync(
|
||||
credentialKey(validated.hostId),
|
||||
JSON.stringify(validated),
|
||||
KEYCHAIN_OPTIONS
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteMobileRelayCredentialBundle(hostId: string): Promise<void> {
|
||||
if (Platform.OS === 'web') {
|
||||
return
|
||||
}
|
||||
await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
|
||||
}
|
||||
|
||||
function requireNativeSecretStore(): void {
|
||||
if (Platform.OS === 'web') {
|
||||
throw new Error('Orca Relay credentials require a native secret store')
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user