mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
Merge remote-tracking branch 'origin/main' into issue-7652-oauth-popup-loop
# Conflicts: # src/main/ipc/repos-remote.test.ts # src/main/runtime/orca-runtime.ts
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
name: Bug report
|
||||
description: Report a problem with Orca
|
||||
title: '[Bug]: '
|
||||
type: Bug
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: Feature request
|
||||
description: Suggest an improvement for Orca
|
||||
title: '[Feature]: '
|
||||
type: Feature
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
|
||||
@@ -72,6 +72,7 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: >-
|
||||
pnpm vitest run
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/computer-use-skill-guidance.test.mjs
|
||||
config/scripts/computer-use-smoke.test.mjs
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: dist/orca-windows-setup.exe
|
||||
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
|
||||
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }}
|
||||
|
||||
- name: Build Windows installer (unsigned)
|
||||
if: steps.cache-installer.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -1,9 +1,2 @@
|
||||
shamefully-hoist=true
|
||||
minimum-release-age=4320
|
||||
minimum-release-age-exclude[]=electron@43.1.0
|
||||
minimum-release-age-exclude[]=node-abi@4.33.0
|
||||
minimum-release-age-exclude[]=typescript@7.0.2
|
||||
minimum-release-age-exclude[]=@typescript/typescript-*
|
||||
minimum-release-age-exclude[]=mermaid@11.15.0
|
||||
minimum-release-age-exclude[]=@mermaid-js/parser@1.1.1
|
||||
minimum-release-age-exclude[]=serve-sim@0.1.40
|
||||
|
||||
@@ -12,9 +12,7 @@ Keep comments short — one or two lines. Capture only the non-obvious reason (s
|
||||
|
||||
## Lint Rules: Do Not Disable Max Lines
|
||||
|
||||
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`. Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead.
|
||||
|
||||
This is enforced in CI by `pnpm check:max-lines-ratchet` (`config/scripts/check-max-lines-ratchet.mjs`). Files already over the limit are grandfathered in `config/max-lines-baseline.txt`; that list may only **shrink**. Adding a new suppression fails the build. If you remove a suppression (great — split it!), run `pnpm check:max-lines-ratchet --prune` to drop its baseline line.
|
||||
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`.
|
||||
|
||||
## File and Module Naming
|
||||
|
||||
@@ -55,6 +53,5 @@ Source-control and review changes must consider GitLab and other supported git p
|
||||
## GitHub CLI Usage
|
||||
|
||||
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
|
||||
Never commit PR evidence images; attach them to the PR conversation instead (but never use gh-attach).
|
||||
|
||||
## Type Declarations: Prefer `.ts` Over `.d.ts`
|
||||
|
||||
@@ -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.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/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.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/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.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -185,6 +185,10 @@ module.exports = {
|
||||
from: 'resources/win32/bin/orca.cmd',
|
||||
to: 'bin/orca.cmd'
|
||||
},
|
||||
{
|
||||
from: 'native/windows-cli-launcher/.build/orca.exe',
|
||||
to: 'bin/orca.exe'
|
||||
},
|
||||
{
|
||||
from: 'node_modules/agent-browser/bin/agent-browser-win32-x64.exe',
|
||||
to: 'agent-browser-win32-x64.exe'
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-09",
|
||||
"updatedAt": "2026-07-11",
|
||||
"policy": {
|
||||
"maturityLevels": [
|
||||
"experimental",
|
||||
@@ -390,6 +390,109 @@
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the gate flakes without a product bug, if an existing directory is ever remapped away from the requested cwd, or if a reattach/remote/API caller can engage the fallback."
|
||||
},
|
||||
{
|
||||
"id": "agent-status.pi-hook-liveness",
|
||||
"title": "Pi status hooks cannot stall a turn or complete a live runtime",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "agent-session",
|
||||
"layer": "main-provider-contract",
|
||||
"surfaces": [
|
||||
"Pi and OMP managed extensions",
|
||||
"agent status hooks",
|
||||
"runtime reload and session replacement",
|
||||
"loopback restart and stall recovery"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows"
|
||||
],
|
||||
"providers": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"wsl",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Local macOS execution of the generated Pi/OMP extension plus the shared hook normalizer. WSL fallback behavior is covered with mocked native-fetch failure and Windows curl handoff. Daemon PTYs use the same generated extension without a distinct delivery path. SSH/relay ingest uses the shared normalizer, but no live remote Pi process is exercised.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/7791",
|
||||
"https://github.com/stablyai/orca/pull/7802",
|
||||
"https://github.com/stablyai/orca/pull/7838"
|
||||
],
|
||||
"invariant": "Orca status reporting must return synchronously from every Pi/OMP extension handler, retain at most one active request and one latest pending snapshot, and abandon stalled loopback delivery within one second. A Pi session_shutdown event cannot mark a turn done because Pi also emits it for reload, new, resume, and fork while the PTY remains alive; only agent_end proves turn completion, while real process exit is cleared by PTY teardown.",
|
||||
"oracle": "Execute the generated extension with a fetch that remains pending and assert the Pi handler returns before delivery; emit three statuses during the stall and assert exactly one request is active and only the latest pending status is sent next; advance fake time by one second and assert the active signal aborts and the latest status proceeds. Through the shared normalizer, assert session_shutdown yields no status while agent_end still yields done.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/pi/agent-status-extension-source.test.ts src/main/agent-hooks/server.test.ts --maxWorkers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/pi/agent-status-extension-source.test.ts",
|
||||
"src/main/agent-hooks/server.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/pi/agent-status-extension-source.test.ts",
|
||||
"assertions": [
|
||||
"a pending loopback fetch does not keep the Pi event handler unresolved",
|
||||
"three events during a stall produce one active request and one request for only the latest pending status",
|
||||
"the one-second delivery deadline aborts the active request and advances the latest pending status",
|
||||
"the managed status extension does not register session_shutdown as a completion event",
|
||||
"WSL native-fetch failures still hand off to a detached Windows curl process"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/agent-hooks/server.test.ts",
|
||||
"assertions": [
|
||||
"session_shutdown normalizes to no status instead of done",
|
||||
"agent_end remains the authoritative Pi/OMP done event"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-11",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/pi/agent-status-extension-source.test.ts src/main/agent-hooks/server.test.ts --maxWorkers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.3,
|
||||
"summary": "2 files and 238 tests passed, including executed generated-extension liveness, latest-only queue bounds, timeout abort, WSL fallback, and Pi shutdown normalization."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 10,
|
||||
"scope": "generated-extension and shared-normalizer unit gate"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "New deterministic gate with local fake-receiver and fake-timer evidence; needs CI soak before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Before the fix, the pending-fetch handler assertion remained false and session_shutdown normalized to done (2 focused failures, 233 passes). With the fix, both pass; the bounded count and deadline assertions additionally fail if latest-only coalescing or the timeout is removed. Needs saved CI evidence before blocking promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Every Pi event does O(1) work and returns without awaiting I/O. Delivery retains at most one active request plus one latest pending object, uses one unref'd timer per active request, and creates no polling, provider scans, subprocesses outside the existing WSL failure fallback, or renderer work. The deterministic burst test proves three stalled events retain two delivery slots rather than an event-count-sized queue."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.",
|
||||
"Attach saved red/green evidence for both the unresolved-handler and false-done regressions.",
|
||||
"Add a live Pi Electron test that stalls or restarts the hook listener during a running turn and proves terminal output continues."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live model-backed Pi turn or Electron Running-view assertion is automated; the gate executes the exact generated extension and shared normalizer below those surfaces.",
|
||||
"An already-running Pi process keeps its previously loaded extension until Pi reloads or restarts.",
|
||||
"During receiver unavailability, intermediate status details may be coalesced to the latest snapshot; this is intentional and bounded, but no UI test measures the temporary detail loss.",
|
||||
"SSH/remote and Windows are not exercised with live providers; relay ingest shares normalization and WSL fallback has mocked contract coverage."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the gate flakes without a product or harness bug, if a Pi handler can again await receiver I/O, or if coalescing allows an older status to overwrite a newer one."
|
||||
},
|
||||
{
|
||||
"id": "agent-session.provider-ownership",
|
||||
"title": "Provider sessions are resumed once per workspace ownership claim",
|
||||
@@ -1221,6 +1324,9 @@
|
||||
"surfaces": [
|
||||
"daemon stream",
|
||||
"main PTY batching",
|
||||
"runtime path provenance",
|
||||
"runtime serve-sim detection",
|
||||
"runtime terminal wait detection",
|
||||
"renderer ACK",
|
||||
"xterm scheduler",
|
||||
"hidden output"
|
||||
@@ -1240,20 +1346,27 @@
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Local macOS evidence over the main-process pending-output caps that exist on main@1282f5c2d. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.",
|
||||
"coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse, idle-path serve-sim detection, and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6836",
|
||||
"https://github.com/stablyai/orca/pull/6858",
|
||||
"https://github.com/stablyai/orca/pull/7002",
|
||||
"https://github.com/stablyai/orca/pull/7054"
|
||||
],
|
||||
"invariant": "High-volume terminal output must stay bounded across daemon socket writes, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.",
|
||||
"oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.",
|
||||
"invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.",
|
||||
"oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/pty.test.ts"
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/main/runtime/orca-runtime-path-candidate-history.test.ts",
|
||||
"src/main/emulator/serve-sim-state-watcher.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/orca-runtime-tail-wait-memo.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -1263,6 +1376,37 @@
|
||||
"active PTY pending output is prioritized during renderer backpressure",
|
||||
"combined pending output exceeding the interactive size limit is batched"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime-path-candidate-history.test.ts",
|
||||
"assertions": [
|
||||
"reuses path-candidate history across repeated pathless PTY output",
|
||||
"copies history when new output adds a path candidate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/emulator/serve-sim-state-watcher.test.ts",
|
||||
"assertions": [
|
||||
"does not buffer repeated brace-free PTY output while waiting for metadata",
|
||||
"detects serve-sim metadata split across PTY chunks and releases the partial object"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime-tail-wait-memo.test.ts",
|
||||
"assertions": [
|
||||
"does not rebuild or repeatedly scan an ordinary saturated tail",
|
||||
"memoized stamping matches recompute reference: blocked prompt split across chunks",
|
||||
"stays equivalent across tail eviction beyond the retained cap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
"returns a blocked wait result for Codex update prompts",
|
||||
"returns a blocked wait result for Codex workspace trust prompts",
|
||||
"returns a blocked wait result for generic Codex interactive prompts",
|
||||
"resolves tui-idle when a stale Codex prompt is followed by the ready header"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
@@ -1274,6 +1418,33 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.5,
|
||||
"summary": "1 test file(s) passed, 209 tests passed on main@1282f5c2d in a clean checkout."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3.49,
|
||||
"summary": "1 test file passed, 2 tests passed on the exact rebased tree based on main@dc468f0ded."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.37,
|
||||
"summary": "1 test file passed, 6 tests passed on pushed commit f0e3a185593b."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 21.33,
|
||||
"summary": "2 test files passed, 609 tests passed on pushed commit 4fb14eac3897."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -1286,11 +1457,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Existing renderer tests cover replay/backlog slices. Needs intentional-break proof and broader hidden-output/input-latency perf artifacts before promotion."
|
||||
"evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios."
|
||||
"evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios. With 1,024 retained provenance candidates, 4,096 pathless chunks dropped from 78.84ms and 4,096 array replacements to 1.74ms and zero replacements. Brace-free 1 KiB output dropped from 20.32/79.12/318.23ms to 0.31/0.64/2.35ms across 4,096/16,384/65,536 chunks. Repeated ordinary 252,000-character tail checks dropped from 96.34/464.20/941.79/3,796.70ms to 3.21/15.68/31.34/129.72ms across 100/500/1,000/4,096 updates."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Split daemon stream backpressure into a deterministic provider/IPC contract if full E2E is flaky.",
|
||||
@@ -1299,6 +1470,9 @@
|
||||
],
|
||||
"knownGaps": [
|
||||
"Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.",
|
||||
"Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.",
|
||||
"Serve-sim detector coverage is deterministic and does not include a live external simulator helper.",
|
||||
"Terminal-wait scale evidence is deterministic and does not yet include a live saturated-tail event-loop artifact.",
|
||||
"Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.",
|
||||
"Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved."
|
||||
],
|
||||
@@ -1791,6 +1965,423 @@
|
||||
],
|
||||
"demotionRule": "Cannot promote if standard-key behavior is inferred without PTY byte evidence."
|
||||
},
|
||||
{
|
||||
"id": "terminal-input.windows-modified-enter-routing",
|
||||
"title": "Windows modified Enter routes agent-compatible bytes to the active pane",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-input",
|
||||
"layer": "main-provider-renderer-unit-and-windows-electron",
|
||||
"surfaces": [
|
||||
"terminal input",
|
||||
"Windows modified Enter",
|
||||
"agent identity",
|
||||
"split panes",
|
||||
"SSH",
|
||||
"WSL",
|
||||
"remote runtime"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows"
|
||||
],
|
||||
"providers": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"wsl",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, unchanged SSH/WSL/remote fallback, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. The Electron byte test runs only on Windows; live Windows and Linux PTY evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/7620",
|
||||
"https://github.com/stablyai/orca/pull/7668"
|
||||
],
|
||||
"invariant": "Shift+Enter sends CSI-u only when the active pane is a genuine local Windows ConPTY with allowlisted Droid ownership or fresh routing-trusted Droid process evidence. Every new command and PTY replacement revokes stale routing until current evidence settles. All other Windows panes preserve Esc+CR; non-Windows panes remain CSI-u; SSH/WSL/remote behavior is unchanged; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no agent or ConPTY lookup.",
|
||||
"oracle": "Main/renderer tests assert exact bytes for local and non-local Windows plus non-Windows policy cells. Fresh Windows process evidence authorizes bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-cascades.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/daemon/daemon-foreground-confirmation-protocol.test.ts",
|
||||
"src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"src/main/daemon/daemon-pty-router.test.ts",
|
||||
"src/main/daemon/daemon-server.test.ts",
|
||||
"src/main/daemon/degraded-daemon-pty-provider.test.ts",
|
||||
"src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts",
|
||||
"src/main/daemon/pty-subprocess.test.ts",
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/main/providers/agent-foreground-process.test.ts",
|
||||
"src/main/providers/local-pty-provider.test.ts",
|
||||
"src/main/providers/windows-conpty-process-membership.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts",
|
||||
"src/renderer/src/store/slices/store-cascades.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts",
|
||||
"src/shared/agent-process-recognition.test.ts",
|
||||
"src/shared/process-table-snapshot.test.ts",
|
||||
"tests/e2e/terminal-foreground-confirmation.unit.test.ts",
|
||||
"tests/e2e/terminal-shortcuts.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/shared/process-table-snapshot.test.ts",
|
||||
"assertions": [
|
||||
"a fresh request bypasses a same-tick TTL cache and any scan already in flight before the request",
|
||||
"same-turn fresh requests and ordinary cache misses share one later scan while requests arriving after it starts queue behind it"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/agent-foreground-process.test.ts",
|
||||
"assertions": [
|
||||
"successful root-present Windows enumeration and failed, blank, malformed, or root-missing enumeration remain distinct evidence states",
|
||||
"an observed shell root with no children is authoritative while a snapshot omitting that root is unavailable",
|
||||
"an attached Droid candidate is authorized only when exact ConPTY membership contains it, while a detached Start-Process descendant is rejected",
|
||||
"a Windows process-table scan with no recognized agent performs zero ConPTY membership helper forks",
|
||||
"fresh POSIX scan failure or a snapshot missing the PTY root is unavailable rather than shell proof",
|
||||
"the compatibility process-name API still preserves its existing fallback contract for other consumers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/windows-conpty-process-membership.test.ts",
|
||||
"assertions": [
|
||||
"the fixed node-pty helper returns validated integer console membership containing the PTY root",
|
||||
"root-only fallback, malformed/missing-root messages, spawn errors, and a silent helper timeout all fail closed",
|
||||
"the bounded timeout kills the child helper exactly once"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/local-pty-provider.test.ts",
|
||||
"assertions": [
|
||||
"a delayed foreground confirmation is discarded after the owning PTY exits"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"assertions": [
|
||||
"new daemon sessions and warm reattach results carry the same minimal allowlisted launchAgent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/daemon-server.test.ts",
|
||||
"assertions": [
|
||||
"daemon protocol v21 persists allowlisted launchAgent across reattach and drops unknown identity strings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/pty-subprocess.test.ts",
|
||||
"assertions": [
|
||||
"Windows daemon foreground lookup can return PowerShell synchronously before async process-tree enrichment resolves the agent",
|
||||
"the enriched agent result is served by a later foreground read",
|
||||
"fresh confirmation awaits a post-request scan instead of serving cached PowerShell and returns null when inspection is unavailable"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-foreground-confirmation.unit.test.ts",
|
||||
"assertions": [
|
||||
"the real daemon handle and pane tracker keep restored Droid identity when confirmation resolves after the old cache window",
|
||||
"one fresh command-finish scan confirms real shell exit, while unavailable inspection performs exactly three bounded reads and never claims shell"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts",
|
||||
"assertions": [
|
||||
"Windows defaults and non-Droid agents preserve Esc+CR while explicit Droid capability selects CSI-u",
|
||||
"SSH, WSL, and remote Windows-client panes preserve Esc+CR without consulting the Droid capability",
|
||||
"non-Windows Shift+Enter remains CSI-u and unrelated keys invoke neither agent nor ConPTY callbacks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts",
|
||||
"assertions": [
|
||||
"only routing-trusted current process identity overrides launch evidence and confirmed shell foreground clears stale identity",
|
||||
"a newer untrusted or neutral command generation blocks stale launch ownership until confirmation",
|
||||
"only allowlisted fresh process or pane-scoped launch ownership selects Droid's CSI-u capability",
|
||||
"forged hook or OSC-derived Droid status cannot change the Windows byte encoding",
|
||||
"after split and original-pane close, a sole surviving sibling cannot inherit the original pane's Droid launch identity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts",
|
||||
"assertions": [
|
||||
"manual and restored local agent identity is published from bounded foreground-process reads",
|
||||
"a delayed read cannot publish after the pane rebinds to a replacement PTY id",
|
||||
"every new command publishes a routing-neutral generation before confirmation",
|
||||
"typed agent text only widens the confirmation window and never becomes published or routable identity",
|
||||
"unavailable typed-command confirmation stays on legacy routing through the three-read ladder and then fails closed",
|
||||
"ordinary fast OSC 133;C-to-D command pairs publish shell without a fresh confirmation scan",
|
||||
"command-start and visible launch recovery use at most three reads across the bounded 350 ms, 1.2 s, and 6 s ladder",
|
||||
"null and rejected reads participate in the same bounded known-identity ladder",
|
||||
"command-finished accepts one fresh recognized shell scan when agent evidence exists, while unavailable final evidence retires routing without claiming shell"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"assertions": [
|
||||
"after detach remount, pane-key launch/process identity forces a leaked nested OSC 133;D through foreground confirmation",
|
||||
"a still-running Droid process republishes Droid instead of being overwritten with shell-foreground evidence",
|
||||
"typing droid does not route CSI-u until fresh process evidence confirms Droid",
|
||||
"null and rejected command-start reads never promote typed Droid text to process evidence",
|
||||
"typed Droid input cannot replace live Antigravity process or pane-scoped launch identity inside another TUI",
|
||||
"typed Droid input on SSH or remote-runtime panes never writes local process evidence",
|
||||
"Droid routing survives a fresh command-finish scan that still sees Droid and retires after one fresh shell result",
|
||||
"a D-to-C transition immediately routes legacy bytes while the new command generation is pending",
|
||||
"an identityless visible-read versus OSC 133;D race cancels stale sampling and fails closed to shell",
|
||||
"a manually typed Droid in a shell without OSC 133 gains routing only after accepted-input fresh confirmation",
|
||||
"accepted no-OSC exit or interrupt input immediately revokes trusted Droid bytes and confirms the returned shell",
|
||||
"unavailable command-finish evidence resolves deferred cleanup, returns legacy routing, and does not assert shell foreground",
|
||||
"daemon-returned allowlisted launchAgent requests warm-reattach confirmation without restoring stale byte authority or the original args/env payload, while missing identity fails closed",
|
||||
"an unknown persisted wrapper cannot spoof launch identity and confirmed shell state prevents repeat focus scans"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"assertions": [
|
||||
"allowlisted daemon launchAgent reaches PtyConnectResult on reattach",
|
||||
"unknown launch identity is dropped at the renderer transport boundary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts",
|
||||
"assertions": [
|
||||
"pane-to-tab detach moves only the detached leaf's launch and process identity to its new pane key",
|
||||
"pane-to-tab detach preserves the source PTY shell override across native PowerShell and WSL classifications",
|
||||
"the source hook snapshot is retired rather than copied to a key the running PTY cannot emit",
|
||||
"the detached Droid keeps CSI-u while the source sibling keeps its own legacy encoding"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/agent-process-recognition.test.ts",
|
||||
"assertions": [
|
||||
"Droid's process name resolves to the allowlisted Droid agent identity without substring matching"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-shortcuts.spec.ts",
|
||||
"assertions": [
|
||||
"on Windows the focused terminal sends CSI-u bytes for a Droid pane and Esc+CR bytes after switching that same pane to Antigravity identity",
|
||||
"the byte oracle observes the real renderer-to-main PTY write path rather than terminal DOM state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-cascades.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8.71,
|
||||
"summary": "Twenty-two focused test files passed (1206 tests) on current main, covering fresh-scan ordering/deduplication, exact ConPTY membership and detached-child rejection, stale PTY guards, daemon v21 launch ownership with current-process confirmation, no-OSC command/exit recovery, preserved detach shell classification, unavailable evidence, routing-neutral command generations, exact shortcut bytes, and the composed daemon-plus-tracker contract; the Windows Electron byte test remains platform-gated and is not counted as local macOS evidence."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 90,
|
||||
"scope": "focused renderer units plus the Windows Electron terminal-shortcut spec"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The deterministic units are stable locally; the Windows-only Electron byte path needs CI or nightly soak history before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Reverting to the previous unconditional Windows Esc+CR branch fails the Droid byte assertion, while resolving launch metadata from any key other than the active pane fails the split-survivor isolation assertion. Saved intentional-break artifacts and live Windows history are still missing."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Callback-count tests prove unrelated ordinary keys invoke neither ConPTY nor agent resolution. Windows Shift+Enter performs two store reads: one local-ConPTY classification with an O(tabs-in-worktree) tab lookup, then two pane-keyed O(1) ownership lookups. Typed text performs no routing write. Ordinary fast OSC 133;C-to-D shell commands perform no fresh confirmation RPC or process scan. An accepted submit/interrupt while trusted Droid is current, a typed allowlisted agent command, title exit, focus, visibility transition, or an idle burst after trusted Droid Shift+Enter can schedule the bounded confirmation ladder; each event cancels/deduplicates prior work and no timer recurs after settling. Windows confirmation performs one fresh process-table scan and forks exact ConPTY membership only after a recognized agent candidate appears; no recognized candidate makes zero helper calls. Same-turn fresh requests share one scan and later arrivals queue at most one successor. Recovery remains three bounded one-shot reads (350 ms, 1.2 s, 6 s). No polling loop, provider fanout, persistent listener, hidden-pane wake loop, or per-printable-key work is added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the exact PTY-byte Electron oracle on Windows without silent platform skips.",
|
||||
"Collect stable runtime and flake history for local and daemon Windows ConPTY while retaining deterministic unchanged-provider assertions for SSH, WSL, and remote runtime.",
|
||||
"Attach saved intentional-break evidence for Droid capability removal and split-sibling identity leakage."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No Windows runner or live Linux PTY evidence is attached; the Electron byte test skips on macOS and Linux, while Linux behavior is covered only by deterministic POSIX unit paths.",
|
||||
"Droid inside SSH, WSL, or remote-runtime intentionally keeps the pre-existing Windows-client Esc+CR behavior because local foreground confirmation is unavailable; this PR narrows the exception to issue #7620's local ConPTY surface.",
|
||||
"The Electron test injects allowlisted pane identity rather than launching the real Droid and Antigravity binaries.",
|
||||
"If both Windows process-enumeration backends remain unavailable through the bounded ladder, routing fails closed to the legacy Esc+CR path; no live Droid artifact covers that degraded host configuration.",
|
||||
"Protocol v21 restarts older daemons because the minimal persisted launchAgent is a new contract; sessions preserved by pre-v21 daemons cannot supply warm-reattach launch ownership and fail closed until current process confirmation or restart.",
|
||||
"No live daemon, SSH, WSL, remote-runtime, paired-web, or restore/replay byte artifact has been collected.",
|
||||
"A running PTY keeps its spawn-time ORCA_PANE_KEY after pane-to-tab detach. This change transfers launch/process routing evidence and retires the stale hook snapshot, but does not redesign future hook reattribution across tab IDs."
|
||||
],
|
||||
"demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak."
|
||||
},
|
||||
{
|
||||
"id": "terminal-input.guarded-agent-send-authorization",
|
||||
"title": "Guarded agent sends authorize only the confirmed exact PTY",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-input",
|
||||
"layer": "runtime-provider-contract",
|
||||
"surfaces": [
|
||||
"terminal input",
|
||||
"guarded note sends",
|
||||
"agent status",
|
||||
"PTY identity",
|
||||
"foreground process confirmation"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows"
|
||||
],
|
||||
"providers": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"wsl",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [
|
||||
"local",
|
||||
"daemon"
|
||||
],
|
||||
"coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal on a shell conflict.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8303"
|
||||
],
|
||||
"invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.",
|
||||
"oracle": "Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/rpc/terminal-send.test.ts",
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/main/providers/agent-foreground-process.test.ts",
|
||||
"src/main/providers/local-pty-provider.test.ts",
|
||||
"src/main/providers/windows-conpty-process-membership.test.ts",
|
||||
"src/main/daemon/daemon-foreground-confirmation-protocol.test.ts",
|
||||
"src/main/daemon/pty-subprocess.test.ts",
|
||||
"src/renderer/src/lib/active-agent-note-send.test.ts",
|
||||
"src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
"a recognized fresh provider confirmation authorizes explicit hook state over an ordinary shell result",
|
||||
"shell, non-agent, null, thrown, and missing confirmation remain fail-closed",
|
||||
"title blockers and ordinary recognized agent evidence add no confirmation call",
|
||||
"PTY exit or handle rebind during either foreground read rejects evidence bound to the original PTY",
|
||||
"controller-less yields preserve stale-handle errors and confirmation methods retain their controller receiver"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/terminal-send.test.ts",
|
||||
"assertions": [
|
||||
"a handle rebind during guarded status evaluation returns not writable and writes zero bytes",
|
||||
"the callback's actual PTY must equal the handle binding before status can authorize a write"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty.test.ts",
|
||||
"assertions": [
|
||||
"runtime confirmation reaches the provider owning the captured PTY",
|
||||
"unsupported or missing providers return unavailable confirmation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/agent-foreground-process.test.ts",
|
||||
"assertions": [
|
||||
"recognized Windows candidates require exact ConPTY membership and detached descendants are rejected",
|
||||
"unavailable process inspection is distinct from authoritative shell evidence"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/local-pty-provider.test.ts",
|
||||
"assertions": [
|
||||
"fresh confirmation is discarded when its owning local PTY exits"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/windows-conpty-process-membership.test.ts",
|
||||
"assertions": [
|
||||
"exact ConPTY console membership comes from the fixed node-pty helper",
|
||||
"malformed, incomplete, timed-out, and spawn-error membership reads fail closed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts",
|
||||
"assertions": [
|
||||
"daemons from before the fresh-confirmation RPC are rejected by protocol version"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/pty-subprocess.test.ts",
|
||||
"assertions": [
|
||||
"fresh confirmation bypasses cached PowerShell and waits for a post-request process scan"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/active-agent-note-send.test.ts",
|
||||
"assertions": [
|
||||
"selected active-agent note sends retain guarded paste and submit routing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx",
|
||||
"assertions": [
|
||||
"browser annotation send content routes through review-notes send so existing agent sessions remain selectable",
|
||||
"both browser annotation send surfaces wire the shared menu content"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-11",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx",
|
||||
"result": "passed",
|
||||
"durationSeconds": 9.58,
|
||||
"summary": "Ten focused test files passed (1183 tests), covering runtime confirmation and PTY revalidation, guarded RPC zero-write behavior, PTY controller routing, local/daemon fresh scans, exact ConPTY membership, and unchanged renderer note routing."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 45,
|
||||
"scope": "focused runtime, RPC, PTY provider, and renderer routing units"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Deterministic units have local coverage only; promotion requires repeated CI and platform history."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; saved intentional-break and physical Windows evidence remain missing."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove permission/title blockers and ordinary recognized-agent evidence add zero confirmations; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the browser annotation existing-agent and repeat-send path in Electron on Windows ConPTY without a recognition refusal.",
|
||||
"Collect stable CI and flake history across local and daemon providers on Windows plus representative macOS/Linux coverage.",
|
||||
"Attach saved intentional-break evidence for confirmation removal and exact-PTY revalidation removal."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Physical Windows validation is unavailable on the current macOS host; exact ConPTY membership is covered deterministically.",
|
||||
"Electron annotation golden-path, repeat-send, adjacent-menu evidence, and screenshots are left to coordinator validation.",
|
||||
"SSH, WSL, legacy daemon, and remote-runtime providers without confirmation remain intentionally fail-closed on an ordinary-shell conflict; no live artifacts cover those degraded paths.",
|
||||
"No live Linux PTY, paired-web, mobile/relay, restore/replay, or multi-window artifact is attached; those surfaces receive no renderer, persistence, or protocol change."
|
||||
],
|
||||
"demotionRule": "Keep non-blocking or demote to protection none if provider confirmation becomes unconditional, exact-PTY mismatch can write bytes, unsupported providers fail open, or the focused gate flakes without an actionable product or harness defect."
|
||||
},
|
||||
{
|
||||
"id": "terminal-input.agent-prompt-injection",
|
||||
"title": "Orchestration agent prompts arrive as bracketed paste before submit",
|
||||
@@ -2413,6 +3004,151 @@
|
||||
],
|
||||
"demotionRule": "Cannot promote if success is based only on absence of visible artifacts."
|
||||
},
|
||||
{
|
||||
"id": "terminal-query.mobile-view-authority",
|
||||
"title": "Mobile xterm answers live terminal queries exactly once without replay or floor side effects",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "mobile-runtime-rpc-contract",
|
||||
"surfaces": [
|
||||
"mobile terminal WebView",
|
||||
"terminal query authority",
|
||||
"runtime RPC",
|
||||
"mobile subscription replay",
|
||||
"multi-mobile input floor"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows",
|
||||
"mobile"
|
||||
],
|
||||
"providers": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Deterministic local tests execute the exact injected mobile replay/generation gate, the React Native query classifier, stale-subscription sender, server-side single-responder election, query-reply RPC semantics, and live-output capture during async mobile fit. The provider write path is shared, but no live iOS/Android, SSH, WSL, or multi-device run is registered yet.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8128",
|
||||
"https://github.com/stablyai/orca/pull/8206"
|
||||
],
|
||||
"invariant": "Snapshot and replacement-terminal replay answer no terminal queries; every live query delivered to subscribed mobile views has at most one current responder; a live query queued behind replay is answered after the replay boundary; terminal-generated replies never transfer the user-input floor.",
|
||||
"oracle": "The injected gate emits zero replies during replay, exactly one after its live boundary, and ignores a superseded generation; native routing rejects ordinary input; the sender rejects disconnected or unsubscribed handles; runtime election accepts only the earliest active mobile subscriber and promotes its survivor; terminal.send writes only validated query grammar without mobileTookFloor; legacy binary subscribe captures a query while phone-fit is still pending and emits it after the snapshot.",
|
||||
"commands": [
|
||||
"pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"mobile/src/terminal/terminal-webview-query-reply.test.ts",
|
||||
"mobile/src/terminal/terminal-webview-query-reply-routing.test.ts",
|
||||
"mobile/src/terminal/mobile-terminal-query-reply.test.ts",
|
||||
"src/main/runtime/rpc/terminal-send.test.ts",
|
||||
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
|
||||
"src/main/runtime/mobile-presence-lock.test.ts",
|
||||
"src/shared/terminal-query-reply.test.ts",
|
||||
"src/shared/terminal-reply-query-scan.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "mobile/src/terminal/terminal-webview-query-reply.test.ts",
|
||||
"assertions": [
|
||||
"snapshot replay emits no terminal-data message before the replay boundary",
|
||||
"a live query after the boundary emits exactly one terminal-data message",
|
||||
"a superseded terminal generation cannot regain reply authority",
|
||||
"xterm parser replies remain enabled while its DOM textarea and hardware-key path stay inert"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/terminal-send.test.ts",
|
||||
"assertions": [
|
||||
"only the elected mobile subscriber can write a claimed query reply",
|
||||
"valid replies skip mobileTookFloor",
|
||||
"ordinary bytes and reply payloads combined with enter, interrupt, or guarded-send semantics are rejected"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
|
||||
"assertions": [
|
||||
"live terminal data and view authority register before asynchronous mobile fit",
|
||||
"a query received during that await is retained and emitted after the snapshot"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"assertions": [
|
||||
"desktop xterm onData replies are dropped while mobile owns the terminal driver",
|
||||
"desktop capability-handler replies outside onData honor the same mobile authority lock"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 1,
|
||||
"summary": "10 mobile bridge, routing, replay/generation, and stale-subscription tests passed. Before implementation, the WebView listener, native route, and sender tests failed."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4,
|
||||
"summary": "69 shared/runtime tests passed. Before implementation, terminal.send took the floor for claimed replies and accepted ordinary claimed bytes."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 11,
|
||||
"summary": "421 renderer terminal connection tests passed, including desktop onData and capability-handler suppression while mobile owns query authority."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 20,
|
||||
"scope": "mobile and runtime deterministic unit gate"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "New deterministic gate; no soak history yet. Tests use no timing sleeps or external services."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Observed red before implementation for the missing WebView listener/native route/sender and for RPC floor-taking plus unvalidated claims; all are green after the fix. The later multi-mobile and pre-fit race assertions have green deterministic coverage but no saved intentional-break artifact yet."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "No polling, scans, timers, or output fanout were added. The WebView adds one disposable onData listener; native/RPC work occurs only for xterm data and is grammar-filtered; responder election is O(1) from Map insertion order; pre-fit output uses the existing capped pending-output queue."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Attach an intentional-break artifact for the pre-fit capture and multi-mobile election assertions.",
|
||||
"Run live iOS and Android WebViews against a real querying TUI and verify no software-keyboard regression.",
|
||||
"Exercise two physical/virtual mobile clients on one PTY and observe one reply per query.",
|
||||
"Accumulate the normal soak window with zero unexplained flakes."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live iOS or Android WebView run is registered.",
|
||||
"No real SSH, WSL, daemon, or two-device provider-contract run is registered.",
|
||||
"Mixed-version pairing is gated by the terminal.query-reply-input.v1 capability (mobile drops replies unless the host advertises it), but no live old-binary pairing run is registered.",
|
||||
"A desktop-to-mobile driver handoff has a bounded double-reply window: the server elects the mobile responder synchronously while the desktop renderer's cached driver map updates via an async event, so an in-flight query can be answered by both until the driver-change event lands."
|
||||
],
|
||||
"demotionRule": "Keep experimental or disable mobile forwarding if the gate flakes without a product bug, live WebView input isolation regresses, or duplicate replies appear under multi-mobile use."
|
||||
},
|
||||
{
|
||||
"id": "terminal-runtime.mobile-stream-budget",
|
||||
"title": "Runtime and mobile terminal streams keep snapshot and live-output bytes bounded",
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
runNodeScript('config/scripts/build-windows-cli-launcher.mjs')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
console.log(`[native-build] no macOS native computer build required on ${process.platform}`)
|
||||
process.exit(0)
|
||||
@@ -28,3 +33,13 @@ function runPnpmScript(scriptName) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
function runNodeScript(scriptPath) {
|
||||
const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' })
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.status !== 0 || result.error) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
|
||||
const compilerPath = findFrameworkCompiler(process.env)
|
||||
|
||||
if (!compilerPath) {
|
||||
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
const result = spawnSync(
|
||||
compilerPath,
|
||||
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
|
||||
{ cwd: repoRoot, stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
function defaultOutputPath(projectRoot) {
|
||||
return join(projectRoot, 'native', 'windows-cli-launcher', '.build', 'orca.exe')
|
||||
}
|
||||
|
||||
function findFrameworkCompiler(env) {
|
||||
const windowsDirectory = env.WINDIR ?? env.SystemRoot
|
||||
if (!windowsDirectory) {
|
||||
return null
|
||||
}
|
||||
const candidates = [
|
||||
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
|
||||
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
|
||||
]
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? null
|
||||
}
|
||||
|
||||
function readArg(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
return index >= 0 ? process.argv[index + 1] : undefined
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
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 projectRoot = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('Windows CLI launcher', () => {
|
||||
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
|
||||
try {
|
||||
const resourcesPath = join(appRoot, 'resources')
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
|
||||
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
|
||||
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
|
||||
mkdirSync(dirname(cliPath), { recursive: true })
|
||||
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
|
||||
writeFileSync(
|
||||
cliPath,
|
||||
`process.stdout.write(JSON.stringify({
|
||||
argv: process.argv.slice(2),
|
||||
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
|
||||
nodeOptions: process.env.NODE_OPTIONS ?? null,
|
||||
orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null
|
||||
}))\n`,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
||||
{ cwd: projectRoot, encoding: 'utf8' }
|
||||
)
|
||||
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
||||
|
||||
const body = 'paragraph one line one\nparagraph one line two\n\nparagraph two'
|
||||
const powershell = spawnSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
'& $env:ORCA_TEST_LAUNCHER orchestration send --body $env:ORCA_TEST_BODY --json'
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: '--no-warnings',
|
||||
ORCA_TEST_BODY: body,
|
||||
ORCA_TEST_LAUNCHER: launcherPath
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(powershell.status, powershell.stderr).toBe(0)
|
||||
expect(JSON.parse(powershell.stdout)).toEqual({
|
||||
argv: ['orchestration', 'send', '--body', body, '--json'],
|
||||
electronRunAsNode: '1',
|
||||
nodeOptions: null,
|
||||
orcaNodeOptions: '--no-warnings'
|
||||
})
|
||||
} finally {
|
||||
rmSync(appRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -33,4 +33,11 @@ describe('computer-use skill guidance', () => {
|
||||
expect(skill).toContain('use `--restore-window` so another window does not cover')
|
||||
expect(skill).toContain('trust the tree over potentially occluded pixels')
|
||||
})
|
||||
|
||||
it('points JSON users to the public accessibility-tree field', () => {
|
||||
const skill = readFileSync(skillPath, 'utf8')
|
||||
|
||||
expect(skill).toContain('`result.snapshot.treeText`')
|
||||
expect(skill).not.toContain('`result.elements`')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,6 +58,10 @@ describe('electron-builder config', () => {
|
||||
expect.objectContaining({
|
||||
from: 'native/computer-use-windows/runtime.ps1',
|
||||
to: 'computer-use-windows/runtime.ps1'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
from: 'native/windows-cli-launcher/.build/orca.exe',
|
||||
to: 'bin/orca.exe'
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
@@ -9,6 +9,12 @@ function electronBuilderNativeRebuild(context) {
|
||||
|
||||
function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
|
||||
const args = buildNativeRebuildArgs(context)
|
||||
if (readPlatformName(context?.platform) === 'win32') {
|
||||
runner(process.execPath, ['config/scripts/build-windows-cli-launcher.mjs'], {
|
||||
cwd: projectDir,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
}
|
||||
runner(process.execPath, args, {
|
||||
cwd: projectDir,
|
||||
stdio: 'inherit'
|
||||
|
||||
@@ -42,6 +42,31 @@ describe('electron-builder native rebuild hook', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('builds the native CLI launcher before packaging Windows resources', () => {
|
||||
const calls = []
|
||||
const result = runElectronBuilderNativeRebuild(
|
||||
{
|
||||
platform: { nodeName: 'win32' },
|
||||
arch: 'x64'
|
||||
},
|
||||
(...args) => calls.push(args)
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
process.execPath,
|
||||
['config/scripts/build-windows-cli-launcher.mjs'],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
],
|
||||
[
|
||||
process.execPath,
|
||||
['config/scripts/rebuild-native-deps.mjs', '--platform=win32', '--arch=x64', '--force'],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects incomplete electron-builder contexts', () => {
|
||||
expect(() => buildNativeRebuildArgs({ arch: 'x64' })).toThrow(/platform/)
|
||||
expect(() => buildNativeRebuildArgs({ platform: { nodeName: 'linux' } })).toThrow(/arch/)
|
||||
|
||||
@@ -4,9 +4,11 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const skillPath = join(projectDir, 'skills', 'orca-cli', 'SKILL.md')
|
||||
const orchestrationSkillPath = join(projectDir, 'skills', 'orchestration', 'SKILL.md')
|
||||
const emulatorSkillPath = join(projectDir, 'skills', 'orca-emulator', 'SKILL.md')
|
||||
|
||||
function readSkill() {
|
||||
return readFileSync(skillPath, 'utf8')
|
||||
function readSkill(path = skillPath) {
|
||||
return readFileSync(path, 'utf8')
|
||||
}
|
||||
|
||||
describe('orca CLI skill guidance', () => {
|
||||
@@ -45,6 +47,43 @@ describe('orca CLI skill guidance', () => {
|
||||
expect(skill).toContain('send the prompt, and stop')
|
||||
})
|
||||
|
||||
it('prefers agent-first workers without duplicating terminal delivery', () => {
|
||||
const skill = readSkill()
|
||||
|
||||
expect(skill).toContain('Prefer agent-first create for agent workers')
|
||||
expect(skill).toContain('fallback shell plus a later `terminal create')
|
||||
expect(skill).toContain('Repo setup or default-terminal settings may still add tabs or splits')
|
||||
expect(skill).toContain(
|
||||
'when no repo default-terminal configuration supplies a primary terminal'
|
||||
)
|
||||
expect(skill).toContain('Configured default tabs are materialized instead')
|
||||
expect(skill).toContain(
|
||||
'only after `terminal list` or `terminal show` confirms it is an unused shell'
|
||||
)
|
||||
expect(skill).not.toContain('bare `worktree create` (no `--agent`) still opens')
|
||||
expect(skill).not.toContain('ends with **one** tab')
|
||||
expect(skill).toContain('Use `startupTerminal.handle` as the sole agent handle')
|
||||
expect(skill).toContain('never dual-send to old and replacement handles')
|
||||
expect(skill).toContain(
|
||||
"this checks the caller's inbox and does not remotely deliver input to another terminal"
|
||||
)
|
||||
})
|
||||
|
||||
it('requires full worktree ids across bundled agent guidance', () => {
|
||||
const cliSkill = readSkill()
|
||||
const orchestrationSkill = readSkill(orchestrationSkillPath)
|
||||
const emulatorSkill = readSkill(emulatorSkillPath)
|
||||
|
||||
for (const skill of [cliSkill, orchestrationSkill, emulatorSkill]) {
|
||||
expect(skill).toContain('<repo-id>::<path>')
|
||||
expect(skill).toContain('bare repo id')
|
||||
}
|
||||
expect(cliSkill).toContain('id:<repoId>::<worktreePath>')
|
||||
expect(cliSkill).toContain('two-part address')
|
||||
expect(orchestrationSkill).toContain('id:<newFullWorktreeId>')
|
||||
expect(emulatorSkill).not.toContain('id:abc123')
|
||||
})
|
||||
|
||||
it('keeps browser injection guidance narrow and avoids literal secret examples', () => {
|
||||
const skill = readSkill()
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ function readSkill() {
|
||||
|
||||
function getSection(markdown, heading) {
|
||||
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = markdown.match(new RegExp(`## ${escapedHeading}\\n([\\s\\S]*?)(?=\\n## |$)`))
|
||||
const match = markdown.match(
|
||||
new RegExp(`## ${escapedHeading}\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |$)`)
|
||||
)
|
||||
|
||||
expect(match).not.toBeNull()
|
||||
|
||||
@@ -188,4 +190,41 @@ describe('orchestration skill guidance', () => {
|
||||
expect(skill).not.toContain('post-completion polling messages')
|
||||
expect(skill).not.toContain('every 2 minutes')
|
||||
})
|
||||
|
||||
it('documents @grok in the Messaging group address list', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
|
||||
expect(messaging).toContain('`@grok`')
|
||||
})
|
||||
|
||||
it('keeps agent-first launch, handle recovery, and inbox injection distinct', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
const workerTerminals = getSection(skill, 'Worker Terminals')
|
||||
const agentFirstExample = workerTerminals.match(
|
||||
/```bash\norca worktree create --name <task-name> --agent codex --json\n[\s\S]*?```/
|
||||
)?.[0]
|
||||
|
||||
expect(workerTerminals).toContain('Agent-first (required for ordinary agent workers)')
|
||||
expect(workerTerminals).toContain('fallback shell + agent pair')
|
||||
expect(workerTerminals).toContain(
|
||||
'Repo setup or default-terminal settings may still add tabs or splits'
|
||||
)
|
||||
expect(workerTerminals).toContain('without configured default tabs')
|
||||
expect(workerTerminals).toContain(
|
||||
'only after `terminal list` or `terminal show` confirms it is an unused shell'
|
||||
)
|
||||
expect(workerTerminals).not.toContain('bare create opens a default shell')
|
||||
expect(workerTerminals).not.toContain('ends with **one** agent tab')
|
||||
expect(agentFirstExample).toBeDefined()
|
||||
expect(agentFirstExample).not.toContain('orca terminal list')
|
||||
expect(agentFirstExample).toContain('startupTerminal.handle')
|
||||
expect(messaging).toContain(
|
||||
'Use `startupTerminal.handle` from the create response when present'
|
||||
)
|
||||
expect(messaging).toContain('continue with the replacement only')
|
||||
expect(messaging).toContain('it does not remotely wake another terminal')
|
||||
expect(messaging).toContain('Use `orchestration dispatch --inject` to deliver a tracked task')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ function getPackagedCliPath(appDir) {
|
||||
return join(appDir, 'Contents', 'Resources', 'bin', 'orca')
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return join(appDir, 'resources', 'bin', 'orca.cmd')
|
||||
return join(appDir, 'resources', 'bin', 'orca.exe')
|
||||
}
|
||||
return join(appDir, 'resources', 'bin', 'orca-ide')
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"../src/main/ipc/worktree-logic.ts",
|
||||
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
|
||||
"../src/main/ipc/worktree-metadata-merge.ts",
|
||||
"../src/main/ipc/worktree-path-comparison.ts",
|
||||
"../src/main/wsl.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.4m">
|
||||
<title>downloads: 4.4m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.8m">
|
||||
<title>downloads: 4.8m</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">4.4m</text>
|
||||
<text x="93.5" y="14">4.4m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.8m</text>
|
||||
<text x="93.5" y="14">4.8m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -0,0 +1,84 @@
|
||||
# Claude Scoped OAuth Usage Limits
|
||||
|
||||
## Problem
|
||||
|
||||
Anthropic's current OAuth usage response reports Fable in `limits` as a model-scoped weekly limit instead of one of the legacy top-level Fable fields. Orca ignores `limits`, maps `fableWeekly` to `null`, and then depends on a hidden Claude `/usage` PTY read that is disabled on Windows and can fail silently elsewhere.
|
||||
|
||||
- `src/main/rate-limits/claude-fetcher.ts:300` models only top-level OAuth windows.
|
||||
- `src/main/rate-limits/claude-fetcher.ts:393` maps only legacy Fable field names.
|
||||
- `src/main/rate-limits/service.ts:1203` disables the PTY supplement on Windows.
|
||||
- `src/renderer/src/components/status-bar/tooltip.tsx:172` renders Fable whenever `fableWeekly` is populated.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The OAuth response contract evolved from dedicated model fields to generic entries shaped like `kind: "weekly_scoped"`, `percent`, `resets_at`, and `scope.model.display_name`. Orca's response type and mapper were not updated for that shape.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not change polling, credentials, token refresh, account switching, renderer layout, or usage percentage semantics.
|
||||
- Do not remove the existing PTY supplement or legacy field compatibility.
|
||||
- Do not generalize shared renderer state to arbitrary model windows in this targeted bug fix.
|
||||
|
||||
## Design
|
||||
|
||||
1. Extend the private OAuth response type with an optional `limits` array containing only the fields needed for safe parsing.
|
||||
2. Select a Fable entry only when `kind` is `weekly_scoped`, the model display name is Fable (case-insensitive), and `percent` is finite.
|
||||
3. Map the scoped entry to the existing seven-day `fableWeekly` window, including its reset timestamp.
|
||||
4. Prefer the current scoped entry, then retain the three legacy top-level fields as fallbacks.
|
||||
5. Keep malformed, unrelated, inactive, or absent entries non-fatal. Treat `is_active: false` as unavailable so stale promotional limits disappear; accept a missing activity flag for compatibility.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- OAuth response
|
||||
- `limits[].weekly_scoped` Fable -> `fableWeekly`
|
||||
- otherwise legacy explicit Fable field -> `fableWeekly`
|
||||
- otherwise existing optional PTY supplement
|
||||
- Existing provider state -> existing status-bar and details rendering
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- `limits` is missing, null, malformed, or contains null entries.
|
||||
- A scoped entry names another model.
|
||||
- Fable percent is missing, non-numeric, or non-finite.
|
||||
- Fable is inactive and should not be rendered.
|
||||
- `is_active` is omitted by an older server response but the remaining scoped entry is valid.
|
||||
- Both current and legacy fields exist; the current scoped entry wins.
|
||||
- Reset timestamps may be ISO strings, epoch seconds, epoch milliseconds, or absent.
|
||||
- Windows, WSL, SSH, and remote runtimes use the same OAuth mapper and require no platform-specific execution.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit: reproduce a current real-response shape and assert Fable maps without a PTY attempt.
|
||||
- Unit: assert scoped data wins over a legacy field.
|
||||
- Unit: assert inactive, malformed, and unrelated scoped entries are ignored while legacy fallback remains available.
|
||||
- Regression: retain existing legacy-field and bare-`fable` behavior tests.
|
||||
- Verification: focused Claude fetcher tests, typecheck, lint, and max-lines ratchet.
|
||||
- Electron: refresh Claude usage and confirm Session, Weekly, and Fable remain visible in the existing status-bar details surface.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
No UI implementation changes. The existing Fable row must reappear with the same typography, spacing, progress bar, percentage semantics, and reset copy as adjacent Session and Weekly rows.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. Claude usage details showing Session, Weekly, and Fable from a live OAuth refresh.
|
||||
2. Adjacent status-bar context showing the Claude provider remains visually unchanged outside the restored row.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add the scoped OAuth response types and mapper.
|
||||
2. Add focused current-schema and compatibility regression tests.
|
||||
3. Run focused and repository checks.
|
||||
4. Validate the restored row in Electron and capture review screenshots.
|
||||
5. Commit, push, and open an unmerged PR.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: Kept to the private OAuth mapper and tests; no shared-state or renderer generalization is required to restore Fable.
|
||||
- Architecture/data flow: OAuth remains authoritative, with structured scoped data preferred over legacy fields and PTY used only as the existing final supplement.
|
||||
- Failure modes covered: malformed optional data, unrelated models, inactive limits, missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution.
|
||||
- Test coverage required: current-schema success without PTY, precedence, inactivity, malformed/unrelated entries, and legacy fallback.
|
||||
- Performance/blast radius: One bounded linear scan of the small response `limits` array per existing OAuth refresh; no new requests, polling, subprocesses, IPC, storage, or renderer work.
|
||||
- UI quality bar: Existing status-bar visuals must remain unchanged except for the restored Fable row.
|
||||
- Required review screenshots: Live Claude details with all three rows; surrounding status-bar context.
|
||||
- Residual risks: Anthropic may rename the scoped model display label; legacy and PTY fallbacks remain available.
|
||||
@@ -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.25/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.27/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.25/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.25/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.27/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.25/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.25/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.27/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.25/app-release.apk)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/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.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/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.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
|
||||
- **Android:** [Baixar APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.25/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.27/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.25/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# SSH Repo Host Reconciliation Design
|
||||
|
||||
Date: 2026-07-10
|
||||
|
||||
## Problem
|
||||
|
||||
The proven flow is **Settings -> SSH -> remove host -> re-add the same host**. Main matches the
|
||||
removal tombstone and `Store.reassignSshTargetId` moves the persisted repo from the old target ID to
|
||||
the new target ID without changing `Repo.id`. A renderer catalog merge can then retain the cached
|
||||
old-target row alongside the fetched new-target row because repo rows are keyed by execution host
|
||||
and repo ID.
|
||||
|
||||
The stale row can route a terminal or destructive action to a removed SSH target. PR #7997 made
|
||||
worktree deletion host-scoped and fail closed; this follow-up removes the superseded renderer row
|
||||
without weakening that boundary.
|
||||
|
||||
## Why UUID Inference Is Unsafe
|
||||
|
||||
The same `Repo.id` can legitimately exist on multiple hosts. For example, a local checkout and a
|
||||
checkout on an SSH server can share the repository UUID. Removing the SSH host without re-adding it
|
||||
must keep the SSH ghost visible so the user can forget it. A local or runtime row with the same UUID
|
||||
does not prove that the SSH row moved.
|
||||
|
||||
Main already has exact migration evidence. `readoptOrphanedWorkspacesForTarget` selects a removed
|
||||
target tombstone, and `Store.reassignSshTargetId` knows which repo IDs it moved from that old target
|
||||
to the new target. The renderer must consume this evidence instead of inferring migration from live
|
||||
siblings or SSH target metadata.
|
||||
|
||||
## Ordering Gap
|
||||
|
||||
Main sends `repos:changed` before `ssh:addTarget` or `ssh:importConfig` returns. Either order is
|
||||
possible in renderer state:
|
||||
|
||||
1. The catalog transaction can merge old and new rows before the add response supplies migration
|
||||
evidence.
|
||||
2. The add response can supply evidence while renderer state still contains only the old row.
|
||||
|
||||
Evidence must therefore remain pending until the corresponding new direct-SSH row arrives. At that
|
||||
point the renderer removes only the mapped `(repoId, oldTargetId)` row and its old host setup, and
|
||||
moves cached worktree ownership onto the new host.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not re-key repo or worktree UUIDs or introduce compound serialized IDs.
|
||||
- Do not infer host migration from labels, paths, target-list absence, or same-UUID siblings.
|
||||
- Do not remove PR #7997's execution-host context from destructive operations.
|
||||
- Do not change git-provider behavior or add provider-specific assumptions.
|
||||
|
||||
## Design
|
||||
|
||||
1. `Store.reassignSshTargetId` returns the exact repo IDs it moved instead of only a count.
|
||||
2. Main records a list of `{ oldTargetId, newTargetId, repoIds }` for each add/import operation.
|
||||
3. The SSH IPC add and import results return the changed targets plus this migration evidence. Main
|
||||
still sends `repos:changed` when at least one repo moved.
|
||||
4. Both desktop SSH management surfaces record the evidence in the repo slice immediately after the
|
||||
invoke resolves. The web preload returns an empty evidence list because target management is
|
||||
desktop-only.
|
||||
5. The repo slice merges duplicate evidence and keeps unresolved repo IDs pending. Every local,
|
||||
targeted-runtime, and all-host catalog transaction reconciles against the latest pending set.
|
||||
6. Reconciliation requires the exact new direct-SSH owner to exist before pruning the exact old
|
||||
direct-SSH owner. Local rows, runtime-owned rows, unrelated SSH hosts, and same-UUID siblings are
|
||||
not considered evidence.
|
||||
7. When a repo row is pruned, its matching `ProjectHostSetup` is removed. The desktop catalog owns
|
||||
local and direct-SSH setups; `runtime:<environmentId>` setups remain authoritative on the remote
|
||||
Orca server.
|
||||
8. Cached visible and detected worktree rows for the migrated repo are moved from the exact old SSH
|
||||
host to the new host. If a new-host worktree row already arrived, it wins and the stale duplicate
|
||||
is discarded.
|
||||
9. A host-scoped worktree response is ignored if its captured repo/host owner no longer exists.
|
||||
Repo catalog transactions also reapply pending worktree migration, covering responses that land
|
||||
after evidence arrives but before the new repo row.
|
||||
|
||||
```text
|
||||
main: remove target -> tombstone
|
||||
main: re-add matching identity -> reassign old target ID to new target ID
|
||||
main: return exact (old target, new target, moved repo IDs)
|
||||
renderer: retain evidence until catalog includes the exact new SSH owner
|
||||
renderer: remove only the exact old SSH row/setup and migrate cached worktree ownership
|
||||
```
|
||||
|
||||
## Safety Constraints
|
||||
|
||||
- A local or runtime sibling with the same UUID never supersedes an SSH ghost.
|
||||
- A mapping is not consumed until the exact new direct-SSH row exists.
|
||||
- Runtime-owned SSH rows and `runtime:<environmentId>` setup rows remain untouched.
|
||||
- Catalog transactions reconcile inside the Zustand updater so overlapping responses use the latest
|
||||
repos and pending evidence.
|
||||
- Missing, offline, or unhydrated target metadata is not deletion or migration evidence.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Persistence returns exact migrated repo IDs and leaves other hosts untouched.
|
||||
- Tombstone re-adoption returns exact old/new/repo mappings for manual add and multi-host import.
|
||||
- SSH IPC returns the evidence, sends `repos:changed`, and clears operation-local evidence.
|
||||
- Pure renderer tests prove exact pruning, pending evidence, unrelated-host preservation, runtime
|
||||
ownership preservation, and evidence deduplication.
|
||||
- Store tests cover both event orders: catalog first and evidence first.
|
||||
- Remove-without-re-add keeps a forgettable SSH row and setup when a local repo shares its UUID.
|
||||
- Old direct-SSH setups are removed only for proven migrations; runtime setups remain.
|
||||
- Old/new cached worktree duplicates collapse to the authoritative new-host row, and an old-only
|
||||
worktree row migrates to the exact new host.
|
||||
- A deferred old-host worktree response cannot restore old visible or detected ownership after the
|
||||
new repo catalog consumes the migration evidence.
|
||||
- Targeted runtime and all-host catalog transactions reconcile against the latest state.
|
||||
|
||||
Run focused tests, typecheck, lint, `pnpm check:max-lines-ratchet`, and the production build required
|
||||
by the repository.
|
||||
|
||||
## Electron Validation
|
||||
|
||||
Use an isolated profile and a throwaway Linux SSH target:
|
||||
|
||||
1. Add a repo through the throwaway SSH target.
|
||||
2. Remove the SSH target and re-add the same identity without restarting the renderer.
|
||||
3. Confirm exactly one SSH project row remains and its worktree opens.
|
||||
4. Confirm Explorer lists the remote files without an ambiguous-host routing error.
|
||||
5. Repeat the cycle and confirm the stale row does not return.
|
||||
6. Keep an adjacent local project visible and confirm it remains unchanged.
|
||||
|
||||
There is no new control or copy. The behavioral evidence is the stable row, successful workspace
|
||||
routing, and deterministic store assertions.
|
||||
|
||||
## Performance And Scope
|
||||
|
||||
Renderer reconciliation indexes direct SSH repo owners once, then processes each migrated repo ID
|
||||
once. Setup cleanup is linear in current repos and setups. The change adds no polling, listeners,
|
||||
subprocesses, or persistent renderer state. IPC payload growth is bounded by the repo IDs actually
|
||||
migrated during the user-triggered add/import operation.
|
||||
|
||||
Windows and POSIX paths do not participate in reconciliation. SSH identity matching remains the
|
||||
existing strict alias or host/user/port logic, and git-provider identity is not used.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Cold-park reveal cost — measured
|
||||
|
||||
Companion to [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md).
|
||||
Quantifies the reveal latency a user pays when returning to a terminal whose
|
||||
hidden view was cold-parked, so the keep-warm policy is tuned from data rather
|
||||
than guessed. Motivated by field reports that "terminal mounting is jumpy —
|
||||
switching to a worktree after a while takes ~1s".
|
||||
|
||||
## How to reproduce
|
||||
|
||||
```bash
|
||||
pnpm bench:cold-park-reveal -- --cycles=12 # empty shell
|
||||
pnpm bench:cold-park-reveal -- --cycles=10 --scrollback-lines=5000
|
||||
pnpm bench:cold-park-reveal -- --cycles=10 --scrollback-lines=20000
|
||||
```
|
||||
|
||||
The bench (`tools/benchmarks/terminal-cold-park-reveal-bench.mjs`) drives the
|
||||
real dev app over CDP. Each cycle times a **cold** reveal (tab confirmed parked
|
||||
via `window.__terminalParkingDebug.parkedTabIds()` before revealing) and a
|
||||
**warm** reveal (revealed inside the hot-retain window, confirmed not parked),
|
||||
so the cold−warm delta is the isolated cost of parking. It shrinks the 30s
|
||||
hysteresis with `ORCA_E2E_TERMINAL_PARKING_DELAY_MS` and roots userData under a
|
||||
short `~/.ocpb` path — the default `/var/folders` tmp path overruns the macOS
|
||||
104-char Unix-socket limit, the daemon fails to bind, and terminals fall back to
|
||||
non-snapshot PTYs that never park.
|
||||
|
||||
Phases per reveal (ms from the switch-back click): `activationMs` (store active
|
||||
worktree flips), `ptyBindMs` (revealed pane has a bound pty — xterm remounted
|
||||
and attached), `paintSettleMs` (two RAFs after ptyBind).
|
||||
|
||||
## Results (macOS, dev app, medians / p95)
|
||||
|
||||
| Scrollback | warm paintSettle | cold paintSettle | **cold-park penalty** |
|
||||
| ---------- | ---------------- | ---------------- | --------------------- |
|
||||
| empty shell | 104 ms | 264 / 298 ms | **+160 ms** |
|
||||
| 5 000 lines | 131 ms | 318 / 423 ms | **+187 ms** |
|
||||
| 20 000 lines | 102 ms | 266 / 286 ms | **+164 ms** |
|
||||
|
||||
All cold samples confirmed parked (`parkedConfirmed = N/N`); all warm samples
|
||||
confirmed not parked.
|
||||
|
||||
## Finding
|
||||
|
||||
**The cold-park reveal penalty is ~160–190 ms and effectively flat across buffer
|
||||
size.** Growing scrollback 0 → 20 000 lines did not grow the penalty (the 20k
|
||||
run is within noise of empty). So the cost is **fixed remount overhead** — React
|
||||
subtree teardown/rebuild, fresh xterm construction, WebGL attach, fit, and the
|
||||
reattach reset — **not** daemon snapshot replay, which scales fine.
|
||||
|
||||
Implications for tuning:
|
||||
|
||||
- The lever is **how often a remount happens**, not how much is replayed.
|
||||
Widening the keep-warm working set (worktree hot-retain limit is 4, tabs 12)
|
||||
and never parking the last-active tab per worktree remove remounts entirely
|
||||
for the common switch-away-and-back, which is worth more than shaving the
|
||||
~170 ms remount itself.
|
||||
- Pre-warming a predicted reveal (sidebar hover, jump-palette selection) hides
|
||||
the fixed cost behind the navigation instead of eliminating it.
|
||||
- This synthetic terminal is a plain shell. A live agent TUI in the alternate
|
||||
screen pays additional reattach-reset + mode-rehydrate work on reveal (see the
|
||||
Grok-scrollback reattach path), so real-world cold reveals of a busy agent
|
||||
pane can exceed these numbers — the ~170 ms here is a floor for the mount
|
||||
overhead, not a ceiling for the full experience.
|
||||
|
||||
## Is parking worth having? (benefit side)
|
||||
|
||||
`tools/benchmarks/terminal-cold-park-resource-bench.mjs`
|
||||
(`pnpm bench:cold-park-resource`) A/Bs the `terminalHiddenViewParking` setting
|
||||
in one session: mount N worktree terminals, background all but one, then read
|
||||
resources with parking off vs on. Measured at 8 backgrounded worktrees
|
||||
(5 000-line scrollback each):
|
||||
|
||||
| metric | parking off | parking on | released |
|
||||
| ------ | ----------- | ---------- | -------- |
|
||||
| mounted pane managers | 9 | 1 | **8** |
|
||||
| DOM nodes | 2 941 | 1 389 | **1 552** |
|
||||
| attached WebGL contexts | 1 | 1 | **0** |
|
||||
|
||||
So parking's unique win is **releasing every hidden terminal's mounted view** —
|
||||
8 xterm instances + their DOM subtrees (~1 550 nodes) here. At xterm's default
|
||||
5 000-row scrollback, each terminal buffer alone is ≥ ~4–5 MB of typed-array
|
||||
cell data (12 bytes/cell × 80 cols × 5 000 rows), so the renderer-memory floor
|
||||
is on the order of **several MB per hidden terminal** — tens of MB across a
|
||||
many-worktree session, before DOM and addon overhead.
|
||||
|
||||
Two honest caveats on the numbers:
|
||||
|
||||
- The **WebGL-context budget is NOT parking's win.** Even with parking off, only
|
||||
one context was attached across 9 mounted terminals — the separate
|
||||
WebGL-suspend-on-hide path (`suspendRendering`) already releases hidden panes'
|
||||
contexts. So the #6874 context-exhaustion protection stands with or without
|
||||
parking; parking's benefit is renderer memory + DOM, not contexts.
|
||||
- The measured JS-heap delta was small (~1.7 MB) because detached xterm
|
||||
typed-array buffers GC lazily and the first measurement ran before they were
|
||||
reclaimed. The bench was upgraded to force multi-pass GC and read whole-app
|
||||
process memory (`window.api.memory.getSnapshot`), but a machine under disk +
|
||||
memory pressure could not complete the upgraded run (esbuild dev-service
|
||||
crashes). Re-run the upgraded bench on a healthy machine to get the settled
|
||||
whole-app memory figure; the pane-manager / DOM release above is the robust,
|
||||
reproducible part.
|
||||
|
||||
### Verdict
|
||||
|
||||
Parking earns its keep for the **many-worktree power user** (the reporter's
|
||||
profile): it is the only mechanism that frees hidden terminals' view memory and
|
||||
DOM, which is the dominant renderer cost at scale. It does **not** provide the
|
||||
GPU-context protection (that is suspend-on-hide's job). The reveal price is a
|
||||
flat ~170 ms remount. Net: keep it, and tune the keep-warm caps so the common
|
||||
few-worktree rotation never pays the remount.
|
||||
|
||||
### Tuning applied
|
||||
|
||||
Because the reveal cost is a fixed remount, the levers are all about
|
||||
**frequency**, not replay size. Three changes to
|
||||
`terminal-hidden-view-parking.ts`:
|
||||
|
||||
- **Worktree hot-retain limit 4 → 8.** Eight covers the ordinary working set;
|
||||
at the ~4–5 MB renderer floor per hidden worktree that is ~16–20 MB worst
|
||||
case to eliminate remounts for the common rotation. Beyond 8, parking still
|
||||
reclaims for the heavy tail it was built for.
|
||||
- **Hot-retain TTL 5 min → 15 min** (worktree and tab). The cap, not the clock,
|
||||
is the primary evictor now; 5 min was aggressive enough that a meeting-length
|
||||
absence parked the whole warm set. The tab count cap stays 12 (already
|
||||
generous).
|
||||
- **Last-active exemption.** The single most-recently-hidden worktree/tab is
|
||||
never parked, regardless of TTL or cap, so returning to the view you just
|
||||
left is always instant. Implemented in `selectIdsBeyondHotRetain`; it holds
|
||||
one warm slot and counts against the cap.
|
||||
|
||||
Not done (deliberately): pre-warm on predicted reveal — it hides the cost
|
||||
behind navigation rather than removing it and adds hover/prediction complexity;
|
||||
and the 30 s cold-park delay is unchanged (it is the quick-flip guard).
|
||||
@@ -29,7 +29,11 @@ A pure policy module decides which hidden terminal tabs may park:
|
||||
|
||||
- Cold-park hysteresis: a tab must be hidden for 30s before parking.
|
||||
- Hot-retain working set: recently visible worktrees/tabs are retained
|
||||
(5 minutes, bounded count) so quick tab switches never pay a re-hydrate.
|
||||
(15 minutes; up to 8 worktrees / 12 tabs) so quick tab switches never pay a
|
||||
re-hydrate. The single most-recently-hidden (last-active) worktree/tab is
|
||||
exempt from both the retain TTL and the count cap, so switching back to the
|
||||
view you just left is always instant regardless of how long you were away.
|
||||
See `terminal-cold-park-reveal-cost.md` for the data behind these caps.
|
||||
- Eligibility excludes: visible panes, hidden-measuring startup probes,
|
||||
activity-portal panes, tabs with pending startup commands or pending
|
||||
activation spawns, floating-panel tabs, and any tab whose PTY is not
|
||||
|
||||
@@ -96,20 +96,40 @@ ingestion and an async write; the decision must not be re-read at reply time):
|
||||
stream (CLI `terminal.read`, automation observers) do not suppress — they
|
||||
also do not answer; that bounded no-reply case matches today's behavior.
|
||||
|
||||
Mobile streams preserve that singularity across snapshot startup and multiple
|
||||
views. The mobile WebView suppresses `onData` during snapshot replay, then
|
||||
enables replies at an explicit live-output boundary. Clearing the WebView drops
|
||||
that replay queue and resumes live reply authority immediately. If a live query
|
||||
arrived while the initial snapshot was being built and its output sequence is
|
||||
covered by that snapshot, runtime RPC re-emits only the bounded query sequence
|
||||
after the snapshot; ordinary covered output stays deduplicated. `terminal.send`
|
||||
accepts the resulting `inputKind: query-reply` only from the earliest active
|
||||
phone-fitted mobile subscriber while mobile owns the terminal driver. Earliest
|
||||
is determined by preserved `subscribedAt`, so a soft-leave resubscribe does not
|
||||
change reply ownership; passive desktop-mode watchers are excluded. Peer phones
|
||||
are rejected, the next subscriber is promoted on unsubscribe, and desktop
|
||||
parser and capability handlers stay silent until desktop retakes the driver.
|
||||
Mixed versions: hosts advertise `terminal.query-reply-input.v1`; a mobile
|
||||
client never forwards replies to a host that lacks it, because such hosts
|
||||
strip `inputKind` and would treat the bytes as floor-taking shell input.
|
||||
Residual: a desktop→mobile driver handoff has a bounded double-reply window
|
||||
until the async driver-change event reaches the desktop renderer's cached
|
||||
driver map (`isPtyLocked`).
|
||||
|
||||
Everything the emulator emits outside a forwarding window is discarded, which
|
||||
also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes
|
||||
triggered by option mutations).
|
||||
|
||||
## Reply classes
|
||||
|
||||
| Class | Queries | Answer source |
|
||||
| --- | --- | --- |
|
||||
| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
|
||||
| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
|
||||
| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
|
||||
| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
|
||||
| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
|
||||
| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
|
||||
| Class | Queries | Answer source |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
|
||||
| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
|
||||
| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
|
||||
| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
|
||||
| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
|
||||
| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
|
||||
|
||||
### View-attribute bridge
|
||||
|
||||
@@ -237,7 +257,8 @@ any spawn-window drops).
|
||||
the documented ConPTY DA1 variant and the view-attribute parser handlers
|
||||
the headless core cannot serve.
|
||||
6. Remote views keep view authority; main yields whenever a remote view
|
||||
subscriber is attached.
|
||||
subscriber is attached. When multiple desktop/mobile views coexist, the
|
||||
terminal driver and server-side mobile election keep one reply writer.
|
||||
|
||||
**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is
|
||||
replaced by:
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.26",
|
||||
"version": "0.0.27",
|
||||
"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": 6
|
||||
"versionCode": 7
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { View, Text, StyleSheet, SectionList, Pressable, ActivityIndicator } from 'react-native'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SectionList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert
|
||||
} from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
import {
|
||||
@@ -20,7 +28,8 @@ import {
|
||||
PanelLeftClose
|
||||
} from 'lucide-react-native'
|
||||
import type { RpcClient } from '../../../src/transport/rpc-client'
|
||||
import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store'
|
||||
import { loadHosts, updateLastConnected } from '../../../src/transport/host-store'
|
||||
import { removeHostAndCloseClient } from '../../../src/transport/host-removal-lifecycle'
|
||||
import {
|
||||
useHostClient,
|
||||
useCloseHost,
|
||||
@@ -684,13 +693,15 @@ export function HostScreen({
|
||||
if (!hostId) {
|
||||
return
|
||||
}
|
||||
// Why: close the shared client first so its WebSocket is gone before
|
||||
// the host record disappears; otherwise the next loadHosts() the
|
||||
// provider does (e.g. on remount) wouldn't find this host but the
|
||||
// socket would still be open, leaking state.
|
||||
closeHostClient(hostId)
|
||||
await removeHost(hostId)
|
||||
leaveHost()
|
||||
try {
|
||||
await removeHostAndCloseClient(hostId, closeHostClient)
|
||||
leaveHost()
|
||||
} catch {
|
||||
// Why: metadata commit can fail while the host is still paired; keep the
|
||||
// screen mounted and re-open confirm (ConfirmModal closes on confirm).
|
||||
setConfirmRemoveHost(true)
|
||||
Alert.alert('Could not remove host', 'Please try again.')
|
||||
}
|
||||
}, [hostId, leaveHost, closeHostClient])
|
||||
|
||||
const navigateFromHostList = useCallback(
|
||||
|
||||
@@ -112,6 +112,8 @@ import {
|
||||
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
|
||||
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
|
||||
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
|
||||
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
|
||||
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
|
||||
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
|
||||
import {
|
||||
getTerminalCommandKeyboardType,
|
||||
@@ -2424,10 +2426,13 @@ export default function SessionScreen() {
|
||||
terminalGestureInputInFlightRef.current.clear()
|
||||
}, [connState])
|
||||
|
||||
const hostQueryReplyInputSupportedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || connState !== 'connected') {
|
||||
setBrowserScreencastSupported(null)
|
||||
setAgentSessionHistorySupported(null)
|
||||
hostQueryReplyInputSupportedRef.current = false
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
@@ -2444,11 +2449,16 @@ export default function SessionScreen() {
|
||||
setAgentSessionHistorySupported(
|
||||
status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true
|
||||
)
|
||||
// Why: hosts without this capability strip inputKind from terminal.send,
|
||||
// so a forwarded xterm reply would become floor-stealing shell input.
|
||||
hostQueryReplyInputSupportedRef.current =
|
||||
status.capabilities?.includes(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) === true
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setBrowserScreencastSupported(false)
|
||||
setAgentSessionHistorySupported(false)
|
||||
hostQueryReplyInputSupportedRef.current = false
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
@@ -2518,6 +2528,7 @@ export default function SessionScreen() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pendingForegroundRecoveryRef = useRef(false)
|
||||
useEffect(() => {
|
||||
let previousAppState: AppStateStatus | null = AppState.currentState
|
||||
const sub = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
|
||||
@@ -2536,7 +2547,7 @@ export default function SessionScreen() {
|
||||
// Why: iOS can resume a live WKWebView with a blank xterm backing store
|
||||
// without firing web-ready/reconnect; invalidate the native readiness
|
||||
// latch before replay so init waits for the document's pong.
|
||||
recoverActiveTerminalAfterForeground({
|
||||
const outcome = recoverActiveTerminalAfterForeground({
|
||||
activeHandleRef,
|
||||
terminalRefs,
|
||||
initializedHandlesRef,
|
||||
@@ -2545,12 +2556,36 @@ export default function SessionScreen() {
|
||||
subscribeToTerminal,
|
||||
schedule: scheduleDelayedAction
|
||||
})
|
||||
pendingForegroundRecoveryRef.current = outcome === 'deferred'
|
||||
})
|
||||
return () => {
|
||||
sub.remove()
|
||||
}
|
||||
}, [scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal])
|
||||
|
||||
// Why: resume usually lands mid-reconnect (the socket dies after ~60-80s of
|
||||
// background), so the recovery above defers. Re-run it once the connection
|
||||
// is back; otherwise a blanked WKWebView whose socket was merely probed (no
|
||||
// stream replay) stays stale until a manual tab switch.
|
||||
useEffect(() => {
|
||||
if (connState !== 'connected' || !pendingForegroundRecoveryRef.current) {
|
||||
return
|
||||
}
|
||||
pendingForegroundRecoveryRef.current = false
|
||||
if (AppState.currentState !== 'active') {
|
||||
return
|
||||
}
|
||||
recoverActiveTerminalAfterForeground({
|
||||
activeHandleRef,
|
||||
terminalRefs,
|
||||
initializedHandlesRef,
|
||||
connStateRef,
|
||||
unsubscribeTerminal,
|
||||
subscribeToTerminal,
|
||||
schedule: scheduleDelayedAction
|
||||
})
|
||||
}, [connState, scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal])
|
||||
|
||||
// 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.
|
||||
@@ -3036,10 +3071,15 @@ export default function SessionScreen() {
|
||||
// this, subscribe(viewport=null) lands on the server first and the
|
||||
// post-scrollback measure path's resubscribe sees alreadyMeasured=true
|
||||
// (because measureViewportOnce won the race) and silently skips.
|
||||
if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) {
|
||||
// Why: a just-created tab can briefly lose activeHandleRef to a lagging
|
||||
// session-tab snapshot; honor the pending marker so its one web-ready
|
||||
// subscribe still fires (see handleCreateTerminal).
|
||||
const isIntendedActive = () =>
|
||||
handle === activeHandleRef.current || handle === pendingActiveTerminalHandleRef.current
|
||||
if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) {
|
||||
void (async () => {
|
||||
await measureViewportOnce(handle)
|
||||
if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) {
|
||||
if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) {
|
||||
subscribeToTerminal(handle)
|
||||
}
|
||||
})()
|
||||
@@ -3531,6 +3571,18 @@ export default function SessionScreen() {
|
||||
[allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput]
|
||||
)
|
||||
|
||||
const handleTerminalQueryReply = useCallback((handle: string, bytes: string) => {
|
||||
void sendMobileTerminalQueryReply({
|
||||
bytes,
|
||||
client: clientRef.current,
|
||||
clientId: deviceTokenRef.current,
|
||||
connected: connStateRef.current === 'connected',
|
||||
handle,
|
||||
hostSupportsQueryReplyInput: hostQueryReplyInputSupportedRef.current,
|
||||
subscribedTerminals: terminalUnsubsRef.current
|
||||
})
|
||||
}, [])
|
||||
|
||||
async function handleClearTerminal(target: Terminal) {
|
||||
if (!client) {
|
||||
return
|
||||
@@ -3915,6 +3967,11 @@ export default function SessionScreen() {
|
||||
if (typeof created.terminal === 'string') {
|
||||
const createdHandle = created.terminal
|
||||
defaultTerminalHandlesToLiveInput([createdHandle])
|
||||
// Why: session-tab snapshots can lag the create RPC. Without the
|
||||
// handle marker, applySessionTabs snaps activeHandleRef back to the
|
||||
// previous terminal, and the new pane's web-ready subscribe (gated
|
||||
// on the active handle) is skipped — blank tab until a manual switch.
|
||||
pendingActiveTerminalHandleRef.current = createdHandle
|
||||
activeHandleRef.current = createdHandle
|
||||
setActiveHandle(createdHandle)
|
||||
setTerminals((prev) => {
|
||||
@@ -3969,6 +4026,9 @@ export default function SessionScreen() {
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Why: a prior pending handle must not outlive a create that returned
|
||||
// no terminal; web-ready subscribe gates on this ref as active.
|
||||
pendingActiveTerminalHandleRef.current = null
|
||||
activeHandleRef.current = null
|
||||
setActiveHandle(null)
|
||||
}
|
||||
@@ -4800,6 +4860,7 @@ export default function SessionScreen() {
|
||||
onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics}
|
||||
onHaptic={handleHaptic}
|
||||
onTerminalInput={handleTerminalInput}
|
||||
onTerminalQueryReply={handleTerminalQueryReply}
|
||||
onTerminalTap={handleTerminalTap}
|
||||
onFileTap={handleFileTap}
|
||||
onOpenUrl={handleTerminalOpenUrl}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, FlatList } from 'react-native'
|
||||
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 {
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
UsageBar
|
||||
} from '../src/components/AccountUsage'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { loadHosts, removeHost, renameHost } from '../src/transport/host-store'
|
||||
import { loadHosts, renameHost } 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'
|
||||
import {
|
||||
@@ -710,15 +711,16 @@ export default function HomeScreen() {
|
||||
if (!confirmRemove) {
|
||||
return
|
||||
}
|
||||
const hostToRemove = confirmRemove
|
||||
try {
|
||||
// Why: close the shared client first so the WebSocket is gone
|
||||
// before the host record disappears from loadHosts().
|
||||
closeHostClient(confirmRemove.id)
|
||||
await removeHost(confirmRemove.id)
|
||||
await removeHostAndCloseClient(hostToRemove.id, closeHostClient)
|
||||
setConfirmRemove(null)
|
||||
setHosts(await loadHosts())
|
||||
} catch {
|
||||
setConfirmRemove(null)
|
||||
// Why: ConfirmModal closes on confirm; re-open for retry and surface the
|
||||
// failure instead of silently leaving the host listed.
|
||||
setConfirmRemove(hostToRemove)
|
||||
Alert.alert('Could not remove host', 'Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+227
-77
@@ -1,6 +1,15 @@
|
||||
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Linking,
|
||||
ActivityIndicator,
|
||||
ScrollView
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useFocusEffect, useRouter } from 'expo-router'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -11,13 +20,76 @@ import {
|
||||
LifeBuoy,
|
||||
Mic,
|
||||
Globe,
|
||||
Terminal as TerminalIcon
|
||||
Terminal as TerminalIcon,
|
||||
KeyRound
|
||||
} from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import {
|
||||
loadPendingHostCredentialCleanup,
|
||||
subscribePendingHostCredentialCleanup
|
||||
} from '../src/transport/host-credential-cleanup'
|
||||
import { retryPendingHostCredentialCleanup } from '../src/transport/host-store'
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [pendingCredentialIds, setPendingCredentialIds] = useState<string[]>([])
|
||||
const [credentialStorageUnreadable, setCredentialStorageUnreadable] = useState(false)
|
||||
const [retryingCredentialCleanup, setRetryingCredentialCleanup] = useState(false)
|
||||
const [credentialRetryFailed, setCredentialRetryFailed] = useState(false)
|
||||
const credentialRefreshGenerationRef = useRef(0)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let active = true
|
||||
setCredentialRetryFailed(false)
|
||||
const refresh = () => {
|
||||
const generation = ++credentialRefreshGenerationRef.current
|
||||
void loadPendingHostCredentialCleanup().then((state) => {
|
||||
if (active && generation === credentialRefreshGenerationRef.current) {
|
||||
setPendingCredentialIds(state.ids)
|
||||
setCredentialStorageUnreadable(state.storageUnreadable)
|
||||
// Why: neutral copy once the queue is confirmed empty so a later
|
||||
// pending set does not inherit a previous Retry failure message.
|
||||
if (state.ids.length === 0 && !state.storageUnreadable) {
|
||||
setCredentialRetryFailed(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const unsubscribe = subscribePendingHostCredentialCleanup(refresh)
|
||||
refresh()
|
||||
return () => {
|
||||
active = false
|
||||
credentialRefreshGenerationRef.current += 1
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
)
|
||||
|
||||
const retryCredentialCleanup = useCallback(async () => {
|
||||
if (retryingCredentialCleanup) {
|
||||
return
|
||||
}
|
||||
setCredentialRetryFailed(false)
|
||||
setRetryingCredentialCleanup(true)
|
||||
try {
|
||||
const result = await retryPendingHostCredentialCleanup()
|
||||
setPendingCredentialIds(result.remainingIds)
|
||||
setCredentialStorageUnreadable(result.storageUnreadable)
|
||||
setCredentialRetryFailed(result.remainingIds.length > 0 || result.storageUnreadable)
|
||||
} catch {
|
||||
setCredentialRetryFailed(true)
|
||||
} finally {
|
||||
setRetryingCredentialCleanup(false)
|
||||
}
|
||||
}, [retryingCredentialCleanup])
|
||||
|
||||
const pendingCredentialCount = pendingCredentialIds.length
|
||||
// Why: show the cleanup card whenever cleanup is pending OR the durable queue
|
||||
// is unreadable — an unreadable queue can hide an orphaned token, so keep a
|
||||
// retry affordance rather than a silently-empty (hidden) section.
|
||||
const showCredentialCleanup = pendingCredentialCount > 0 || credentialStorageUnreadable
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
@@ -28,79 +100,123 @@ export default function SettingsScreen() {
|
||||
<Text style={styles.heading}>Settings</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/terminal-settings')}
|
||||
>
|
||||
<TerminalIcon size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Terminal</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/browser-settings')}
|
||||
>
|
||||
<Globe size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Browser</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/voice-settings')}
|
||||
>
|
||||
<Mic size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Voice</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/notifications')}
|
||||
>
|
||||
<Bell size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Notifications</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/troubleshoot')}
|
||||
>
|
||||
<Wrench size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Troubleshooting</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/about')}
|
||||
>
|
||||
<Info size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>About</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + spacing.lg }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/terminal-settings')}
|
||||
>
|
||||
<TerminalIcon size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Terminal</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/browser-settings')}
|
||||
>
|
||||
<Globe size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Browser</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/voice-settings')}
|
||||
>
|
||||
<Mic size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Voice</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/notifications')}
|
||||
>
|
||||
<Bell size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Notifications</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/troubleshoot')}
|
||||
>
|
||||
<Wrench size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Troubleshooting</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/about')}
|
||||
>
|
||||
<Info size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>About</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, styles.sectionSpacer]}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://www.onorca.dev/privacy')}
|
||||
>
|
||||
<Shield size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Privacy Policy</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://github.com/stablyai/orca/issues')}
|
||||
>
|
||||
<LifeBuoy size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Support</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{showCredentialCleanup ? (
|
||||
<View style={[styles.section, styles.sectionSpacer]}>
|
||||
<View style={styles.credentialCleanupRow}>
|
||||
<KeyRound size={16} color={colors.statusAmber} />
|
||||
<View style={styles.credentialCleanupCopy}>
|
||||
<Text style={styles.credentialCleanupTitle}>Pairing credential cleanup</Text>
|
||||
<Text accessibilityLiveRegion="polite" style={styles.rowHint}>
|
||||
{credentialRetryFailed
|
||||
? "Cleanup still couldn't be confirmed. Try again later."
|
||||
: pendingCredentialCount > 0
|
||||
? `Couldn't confirm cleanup for ${pendingCredentialCount} credential${pendingCredentialCount === 1 ? '' : 's'} on this device.`
|
||||
: "Couldn't check cleanup status on this device. Retry to be safe."}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retry clearing pairing credentials"
|
||||
accessibilityState={{
|
||||
busy: retryingCredentialCleanup,
|
||||
disabled: retryingCredentialCleanup
|
||||
}}
|
||||
disabled={retryingCredentialCleanup}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => [
|
||||
styles.retryButton,
|
||||
pressed && !retryingCredentialCleanup && styles.rowPressed
|
||||
]}
|
||||
onPress={() => void retryCredentialCleanup()}
|
||||
>
|
||||
{retryingCredentialCleanup ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : (
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={[styles.section, styles.sectionSpacer]}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://www.onorca.dev/privacy')}
|
||||
>
|
||||
<Shield size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Privacy Policy</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://github.com/stablyai/orca/issues')}
|
||||
>
|
||||
<LifeBuoy size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Support</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -109,7 +225,7 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
paddingHorizontal: spacing.lg
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -153,6 +269,40 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
credentialCleanupRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
credentialCleanupCopy: {
|
||||
flex: 1,
|
||||
gap: spacing.xs
|
||||
},
|
||||
credentialCleanupTitle: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowHint: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 17
|
||||
},
|
||||
retryButton: {
|
||||
width: 72,
|
||||
height: 32,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
retryButtonText: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
|
||||
+2
-1
@@ -35,6 +35,7 @@
|
||||
"expo-haptics": "^55.0.14",
|
||||
"expo-image-manipulator": "^55.0.17",
|
||||
"expo-image-picker": "^55.0.20",
|
||||
"expo-keep-awake": "~55.0.8",
|
||||
"expo-linking": "^55.0.15",
|
||||
"expo-modules-core": "~55.0.25",
|
||||
"expo-network": "~55.0.14",
|
||||
@@ -73,7 +74,7 @@
|
||||
"oxlint": "^1.71.0",
|
||||
"react-test-renderer": "19.2.6",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^7.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
|
||||
Generated
+100
-298
@@ -31,7 +31,7 @@ importers:
|
||||
version: 6.0.3
|
||||
expo:
|
||||
specifier: ^55.0.27
|
||||
version: 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@7.0.2)
|
||||
version: 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)
|
||||
expo-build-properties:
|
||||
specifier: ^55.0.13
|
||||
version: 55.0.13(expo@55.0.27)
|
||||
@@ -65,6 +65,9 @@ importers:
|
||||
expo-image-picker:
|
||||
specifier: ^55.0.20
|
||||
version: 55.0.20(expo@55.0.27)
|
||||
expo-keep-awake:
|
||||
specifier: ~55.0.8
|
||||
version: 55.0.8(expo@55.0.27)(react@19.2.6)
|
||||
expo-linking:
|
||||
specifier: ^55.0.15
|
||||
version: 55.0.15(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)
|
||||
@@ -76,7 +79,7 @@ importers:
|
||||
version: 55.0.14(expo@55.0.27)(react@19.2.6)
|
||||
expo-notifications:
|
||||
specifier: ^55.0.22
|
||||
version: 55.0.22(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)(typescript@7.0.2)
|
||||
version: 55.0.22(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)(typescript@6.0.3)
|
||||
expo-router:
|
||||
specifier: ^55.0.14
|
||||
version: 55.0.14(7ffd97bbc113b875771c40f21f44415d)
|
||||
@@ -85,7 +88,7 @@ importers:
|
||||
version: 55.0.13(expo@55.0.27)
|
||||
expo-splash-screen:
|
||||
specifier: ^55.0.20
|
||||
version: 55.0.20(expo@55.0.27)(typescript@7.0.2)
|
||||
version: 55.0.20(expo@55.0.27)(typescript@6.0.3)
|
||||
expo-status-bar:
|
||||
specifier: ^55.0.6
|
||||
version: 55.0.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)
|
||||
@@ -175,8 +178,8 @@ importers:
|
||||
specifier: ^4.22.4
|
||||
version: 4.22.4
|
||||
typescript:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
specifier: 6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.0.16
|
||||
version: 8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
@@ -2793,126 +2796,6 @@ packages:
|
||||
resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@typescript/typescript-darwin-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/typescript-darwin-x64@7.0.2':
|
||||
resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/typescript-freebsd-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@typescript/typescript-freebsd-x64@7.0.2':
|
||||
resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@typescript/typescript-linux-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-arm@7.0.2':
|
||||
resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-loong64@7.0.2':
|
||||
resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-mips64el@7.0.2':
|
||||
resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-ppc64@7.0.2':
|
||||
resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-riscv64@7.0.2':
|
||||
resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-s390x@7.0.2':
|
||||
resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-x64@7.0.2':
|
||||
resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-netbsd-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@typescript/typescript-netbsd-x64@7.0.2':
|
||||
resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@typescript/typescript-openbsd-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@typescript/typescript-openbsd-x64@7.0.2':
|
||||
resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@typescript/typescript-sunos-x64@7.0.2':
|
||||
resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@typescript/typescript-win32-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/typescript-win32-x64@7.0.2':
|
||||
resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@ungap/structured-clone@1.3.1':
|
||||
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
|
||||
|
||||
@@ -6376,9 +6259,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
typescript@7.0.2:
|
||||
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
typescript@6.0.3:
|
||||
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
ua-parser-js@1.0.41:
|
||||
@@ -8230,23 +8113,23 @@ snapshots:
|
||||
|
||||
'@expo-google-fonts/material-symbols@0.4.34': {}
|
||||
|
||||
'@expo/cli@55.0.33(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo@55.0.27)(react-dom@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@7.0.2)':
|
||||
'@expo/cli@55.0.33(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo@55.0.27)(react-dom@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)':
|
||||
dependencies:
|
||||
'@expo/code-signing-certificates': 0.0.6
|
||||
'@expo/config': 55.0.18(typescript@7.0.2)
|
||||
'@expo/config': 55.0.18(typescript@6.0.3)
|
||||
'@expo/config-plugins': 55.0.10
|
||||
'@expo/devcert': 1.2.1
|
||||
'@expo/env': 2.1.2
|
||||
'@expo/image-utils': 0.8.14(typescript@7.0.2)
|
||||
'@expo/image-utils': 0.8.14(typescript@6.0.3)
|
||||
'@expo/json-file': 10.2.0
|
||||
'@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(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)
|
||||
'@expo/metro': 55.1.1
|
||||
'@expo/metro-config': 55.0.24(expo@55.0.27)(typescript@7.0.2)
|
||||
'@expo/metro-config': 55.0.24(expo@55.0.27)(typescript@6.0.3)
|
||||
'@expo/osascript': 2.7.0
|
||||
'@expo/package-manager': 1.13.0
|
||||
'@expo/plist': 0.5.4
|
||||
'@expo/prebuild-config': 55.0.19(expo@55.0.27)(typescript@7.0.2)
|
||||
'@expo/require-utils': 55.0.5(typescript@7.0.2)
|
||||
'@expo/prebuild-config': 55.0.19(expo@55.0.27)(typescript@6.0.3)
|
||||
'@expo/require-utils': 55.0.5(typescript@6.0.3)
|
||||
'@expo/router-server': 55.0.18(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo-server@55.0.11)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@expo/schema-utils': 55.0.4
|
||||
'@expo/spawn-async': 1.7.2
|
||||
@@ -8264,7 +8147,7 @@ snapshots:
|
||||
connect: 3.7.0
|
||||
debug: 4.4.3
|
||||
dnssd-advertise: 1.1.6
|
||||
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@7.0.2)
|
||||
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)
|
||||
expo-server: 55.0.11
|
||||
fetch-nodeshim: 0.4.10
|
||||
getenv: 2.0.0
|
||||
@@ -8364,12 +8247,12 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@expo/config@55.0.16(typescript@7.0.2)':
|
||||
'@expo/config@55.0.16(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@expo/config-plugins': 55.0.8
|
||||
'@expo/config-types': 55.0.5
|
||||
'@expo/json-file': 10.0.14
|
||||
'@expo/require-utils': 55.0.5(typescript@7.0.2)
|
||||
'@expo/require-utils': 55.0.5(typescript@6.0.3)
|
||||
deepmerge: 4.3.1
|
||||
getenv: 2.0.0
|
||||
glob: 13.0.6
|
||||
@@ -8380,12 +8263,12 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@expo/config@55.0.18(typescript@7.0.2)':
|
||||
'@expo/config@55.0.18(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@expo/config-plugins': 55.0.10
|
||||
'@expo/config-types': 55.0.5
|
||||
'@expo/json-file': 10.2.0
|
||||
'@expo/require-utils': 55.0.5(typescript@7.0.2)
|
||||
'@expo/require-utils': 55.0.5(typescript@6.0.3)
|
||||
deepmerge: 4.3.1
|
||||
getenv: 2.0.0
|
||||
glob: 13.0.6
|
||||
@@ -8412,7 +8295,7 @@ snapshots:
|
||||
|
||||
'@expo/dom-webview@55.0.5(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@7.0.2)
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -8440,9 +8323,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@expo/image-utils@0.8.14(typescript@7.0.2)':
|
||||
'@expo/image-utils@0.8.14(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@expo/require-utils': 55.0.5(typescript@7.0.2)
|
||||
'@expo/require-utils': 55.0.5(typescript@6.0.3)
|
||||
'@expo/spawn-async': 1.7.2
|
||||
chalk: 4.1.2
|
||||
getenv: 2.0.0
|
||||
@@ -8473,9 +8356,9 @@ snapshots:
|
||||
'@babel/code-frame': 7.29.7
|
||||
json5: 2.2.3
|
||||
|
||||
'@expo/local-build-cache-provider@55.0.14(typescript@7.0.2)':
|
||||
'@expo/local-build-cache-provider@55.0.14(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@expo/config': 55.0.18(typescript@7.0.2)
|
||||
'@expo/config': 55.0.18(typescript@6.0.3)
|
||||
chalk: 4.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -8485,7 +8368,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@expo/dom-webview': 55.0.5(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)
|
||||
anser: 1.4.10
|
||||
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@7.0.2)
|
||||
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)
|
||||
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)
|
||||
stacktrace-parser: 0.1.11
|
||||
@@ -8494,17 +8377,17 @@ snapshots:
|
||||
dependencies:
|
||||
'@expo/dom-webview': 55.0.5(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)
|
||||
anser: 1.4.10
|
||||
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@7.0.2)
|
||||
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)
|
||||
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)
|
||||
stacktrace-parser: 0.1.11
|
||||
|
||||
'@expo/metro-config@55.0.24(expo@55.0.27)(typescript@7.0.2)':
|
||||
'@expo/metro-config@55.0.24(expo@55.0.27)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/generator': 7.29.7
|
||||
'@expo/config': 55.0.18(typescript@7.0.2)
|
||||
'@expo/config': 55.0.18(typescript@6.0.3)
|
||||
'@expo/env': 2.1.2
|
||||
'@expo/json-file': 10.0.16
|
||||
'@expo/metro': 55.1.1
|
||||
@@ -8521,7 +8404,7 @@ snapshots:
|
||||
postcss: 8.5.15
|
||||
resolve-from: 5.0.0
|
||||
optionalDependencies:
|
||||
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@7.0.2)
|
||||
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)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
@@ -8532,7 +8415,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(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)
|
||||
anser: 1.4.10
|
||||
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@7.0.2)
|
||||
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)
|
||||
pretty-format: 29.7.0
|
||||
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)
|
||||
@@ -8593,16 +8476,16 @@ snapshots:
|
||||
base64-js: 1.5.1
|
||||
xmlbuilder: 15.1.1
|
||||
|
||||
'@expo/prebuild-config@55.0.17(expo@55.0.27)(typescript@7.0.2)':
|
||||
'@expo/prebuild-config@55.0.17(expo@55.0.27)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@expo/config': 55.0.16(typescript@7.0.2)
|
||||
'@expo/config': 55.0.16(typescript@6.0.3)
|
||||
'@expo/config-plugins': 55.0.8
|
||||
'@expo/config-types': 55.0.5
|
||||
'@expo/image-utils': 0.8.14(typescript@7.0.2)
|
||||
'@expo/image-utils': 0.8.14(typescript@6.0.3)
|
||||
'@expo/json-file': 10.0.14
|
||||
'@react-native/normalize-colors': 0.83.6
|
||||
debug: 4.4.3
|
||||
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@7.0.2)
|
||||
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)
|
||||
resolve-from: 5.0.0
|
||||
semver: 7.7.4
|
||||
xml2js: 0.6.0
|
||||
@@ -8610,16 +8493,16 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@expo/prebuild-config@55.0.19(expo@55.0.27)(typescript@7.0.2)':
|
||||
'@expo/prebuild-config@55.0.19(expo@55.0.27)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@expo/config': 55.0.18(typescript@7.0.2)
|
||||
'@expo/config': 55.0.18(typescript@6.0.3)
|
||||
'@expo/config-plugins': 55.0.10
|
||||
'@expo/config-types': 55.0.5
|
||||
'@expo/image-utils': 0.8.14(typescript@7.0.2)
|
||||
'@expo/image-utils': 0.8.14(typescript@6.0.3)
|
||||
'@expo/json-file': 10.2.0
|
||||
'@react-native/normalize-colors': 0.83.6
|
||||
debug: 4.4.3
|
||||
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@7.0.2)
|
||||
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)
|
||||
resolve-from: 5.0.0
|
||||
semver: 7.8.4
|
||||
xml2js: 0.6.0
|
||||
@@ -8637,20 +8520,20 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@expo/require-utils@55.0.5(typescript@7.0.2)':
|
||||
'@expo/require-utils@55.0.5(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7)
|
||||
optionalDependencies:
|
||||
typescript: 7.0.2
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@expo/router-server@55.0.18(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo-server@55.0.11)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
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@7.0.2)
|
||||
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)
|
||||
expo-constants: 55.0.16(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))
|
||||
expo-font: 55.0.8(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)
|
||||
expo-server: 55.0.11
|
||||
@@ -8928,7 +8811,7 @@ snapshots:
|
||||
|
||||
'@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@7.0.2)
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -9759,7 +9642,7 @@ snapshots:
|
||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@7.0.2)
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
|
||||
'@typescript-eslint/scope-manager': 8.59.2
|
||||
'@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3)
|
||||
@@ -9772,7 +9655,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@7.0.2)':
|
||||
'@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.59.2
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
@@ -9780,7 +9663,7 @@ snapshots:
|
||||
'@typescript-eslint/visitor-keys': 8.59.2
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.4
|
||||
typescript: 7.0.2
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -9847,66 +9730,6 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-darwin-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-darwin-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-freebsd-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-freebsd-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-arm@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-loong64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-mips64el@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-ppc64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-riscv64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-s390x@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-netbsd-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-netbsd-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-openbsd-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-openbsd-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-sunos-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-win32-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-win32-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.1': {}
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
@@ -10267,7 +10090,7 @@ snapshots:
|
||||
resolve-from: 5.0.0
|
||||
optionalDependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
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@7.0.2)
|
||||
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)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- supports-color
|
||||
@@ -10300,7 +10123,7 @@ snapshots:
|
||||
resolve-from: 5.0.0
|
||||
optionalDependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
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@7.0.2)
|
||||
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)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- supports-color
|
||||
@@ -10954,7 +10777,7 @@ snapshots:
|
||||
eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@7.0.2)
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
|
||||
eslint: 9.39.4
|
||||
eslint-config-prettier: 9.1.2(eslint@9.39.4)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
|
||||
@@ -10985,7 +10808,7 @@ snapshots:
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@7.0.2)
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
|
||||
eslint: 9.39.4
|
||||
eslint-import-resolver-node: 0.3.10
|
||||
transitivePeerDependencies:
|
||||
@@ -11027,7 +10850,7 @@ snapshots:
|
||||
string.prototype.trimend: 1.0.9
|
||||
tsconfig-paths: 3.15.0
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@7.0.2)
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
|
||||
transitivePeerDependencies:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
@@ -11203,12 +11026,12 @@ snapshots:
|
||||
|
||||
expo-application@55.0.14(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
|
||||
expo-asset@55.0.17(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)(typescript@7.0.2):
|
||||
expo-asset@55.0.17(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)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@expo/image-utils': 0.8.14(typescript@7.0.2)
|
||||
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@7.0.2)
|
||||
'@expo/image-utils': 0.8.14(typescript@6.0.3)
|
||||
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)
|
||||
expo-constants: 55.0.16(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
|
||||
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)
|
||||
@@ -11219,14 +11042,14 @@ snapshots:
|
||||
expo-build-properties@55.0.13(expo@55.0.27):
|
||||
dependencies:
|
||||
'@expo/schema-utils': 55.0.4
|
||||
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@7.0.2)
|
||||
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)
|
||||
resolve-from: 5.0.0
|
||||
semver: 7.7.4
|
||||
|
||||
expo-camera@55.0.18(@types/emscripten@1.41.5)(expo@55.0.27)(react-native-web@0.21.2(react-dom@19.2.6(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):
|
||||
dependencies:
|
||||
barcode-detector: 3.1.3(@types/emscripten@1.41.5)
|
||||
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@7.0.2)
|
||||
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)
|
||||
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)
|
||||
optionalDependencies:
|
||||
@@ -11236,25 +11059,25 @@ snapshots:
|
||||
|
||||
expo-clipboard@55.0.13(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@7.0.2)
|
||||
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)
|
||||
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)
|
||||
|
||||
expo-constants@55.0.16(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)):
|
||||
dependencies:
|
||||
'@expo/env': 2.1.2
|
||||
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@7.0.2)
|
||||
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)
|
||||
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)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
expo-crypto@55.0.14(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
|
||||
expo-dev-client@55.0.35(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
expo-dev-launcher: 55.0.36(expo@55.0.27)
|
||||
expo-dev-menu: 55.0.30(expo@55.0.27)
|
||||
expo-dev-menu-interface: 55.0.2(expo@55.0.27)
|
||||
@@ -11264,67 +11087,67 @@ snapshots:
|
||||
expo-dev-launcher@55.0.36(expo@55.0.27):
|
||||
dependencies:
|
||||
'@expo/schema-utils': 55.0.4
|
||||
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@7.0.2)
|
||||
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)
|
||||
expo-dev-menu: 55.0.30(expo@55.0.27)
|
||||
expo-manifests: 55.0.17(expo@55.0.27)
|
||||
|
||||
expo-dev-menu-interface@55.0.2(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
|
||||
expo-dev-menu@55.0.30(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
expo-dev-menu-interface: 55.0.2(expo@55.0.27)
|
||||
|
||||
expo-document-picker@55.0.13(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
|
||||
expo-file-system@55.0.19(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)):
|
||||
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@7.0.2)
|
||||
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)
|
||||
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)
|
||||
|
||||
expo-file-system@55.0.23(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)):
|
||||
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@7.0.2)
|
||||
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)
|
||||
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)
|
||||
|
||||
expo-font@55.0.8(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@7.0.2)
|
||||
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)
|
||||
fontfaceobserver: 2.3.0
|
||||
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)
|
||||
|
||||
expo-glass-effect@55.0.11(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@7.0.2)
|
||||
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)
|
||||
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)
|
||||
|
||||
expo-haptics@55.0.14(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
|
||||
expo-image-loader@55.0.1(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
|
||||
expo-image-manipulator@55.0.17(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
expo-image-loader: 55.0.1(expo@55.0.27)
|
||||
|
||||
expo-image-picker@55.0.20(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
expo-image-loader: 55.0.1(expo@55.0.27)
|
||||
|
||||
expo-image@55.0.10(expo@55.0.27)(react-native-web@0.21.2(react-dom@19.2.6(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):
|
||||
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@7.0.2)
|
||||
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)
|
||||
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)
|
||||
sf-symbols-typescript: 2.2.0
|
||||
@@ -11335,7 +11158,7 @@ snapshots:
|
||||
|
||||
expo-keep-awake@55.0.8(expo@55.0.27)(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@7.0.2)
|
||||
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)
|
||||
react: 19.2.6
|
||||
|
||||
expo-linking@55.0.15(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):
|
||||
@@ -11350,7 +11173,7 @@ snapshots:
|
||||
|
||||
expo-manifests@55.0.17(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
expo-json-utils: 55.0.2
|
||||
|
||||
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.27)(jest@29.7.0(@types/node@26.1.1))(prettier@2.8.8)(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-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
@@ -11399,9 +11222,9 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
expo-modules-autolinking@55.0.24(typescript@7.0.2):
|
||||
expo-modules-autolinking@55.0.24(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@expo/require-utils': 55.0.5(typescript@7.0.2)
|
||||
'@expo/require-utils': 55.0.5(typescript@6.0.3)
|
||||
'@expo/spawn-async': 1.7.2
|
||||
chalk: 4.1.2
|
||||
commander: 7.2.0
|
||||
@@ -11419,15 +11242,15 @@ snapshots:
|
||||
|
||||
expo-network@55.0.14(expo@55.0.27)(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@7.0.2)
|
||||
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)
|
||||
react: 19.2.6
|
||||
|
||||
expo-notifications@55.0.22(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)(typescript@7.0.2):
|
||||
expo-notifications@55.0.22(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)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@expo/image-utils': 0.8.14(typescript@7.0.2)
|
||||
'@expo/image-utils': 0.8.14(typescript@6.0.3)
|
||||
abort-controller: 3.0.0
|
||||
badgin: 1.2.3
|
||||
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@7.0.2)
|
||||
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)
|
||||
expo-application: 55.0.14(expo@55.0.27)
|
||||
expo-constants: 55.0.16(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
|
||||
@@ -11449,7 +11272,7 @@ snapshots:
|
||||
client-only: 0.0.1
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
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@7.0.2)
|
||||
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)
|
||||
expo-constants: 55.0.16(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))
|
||||
expo-glass-effect: 55.0.11(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)
|
||||
expo-image: 55.0.10(expo@55.0.27)(react-native-web@0.21.2(react-dom@19.2.6(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)
|
||||
@@ -11487,16 +11310,16 @@ snapshots:
|
||||
|
||||
expo-secure-store@55.0.13(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
|
||||
expo-server@55.0.11: {}
|
||||
|
||||
expo-server@55.0.9: {}
|
||||
|
||||
expo-splash-screen@55.0.20(expo@55.0.27)(typescript@7.0.2):
|
||||
expo-splash-screen@55.0.20(expo@55.0.27)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@expo/prebuild-config': 55.0.17(expo@55.0.27)(typescript@7.0.2)
|
||||
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@7.0.2)
|
||||
'@expo/prebuild-config': 55.0.17(expo@55.0.27)(typescript@6.0.3)
|
||||
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)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
@@ -11510,7 +11333,7 @@ snapshots:
|
||||
expo-symbols@55.0.8(expo-font@55.0.8)(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-google-fonts/material-symbols': 0.4.34
|
||||
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@7.0.2)
|
||||
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)
|
||||
expo-font: 55.0.8(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)
|
||||
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)
|
||||
@@ -11518,29 +11341,29 @@ snapshots:
|
||||
|
||||
expo-updates-interface@55.1.6(expo@55.0.27):
|
||||
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@7.0.2)
|
||||
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)
|
||||
|
||||
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@7.0.2):
|
||||
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):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@expo/cli': 55.0.33(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo@55.0.27)(react-dom@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@7.0.2)
|
||||
'@expo/config': 55.0.18(typescript@7.0.2)
|
||||
'@expo/cli': 55.0.33(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo@55.0.27)(react-dom@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)
|
||||
'@expo/config': 55.0.18(typescript@6.0.3)
|
||||
'@expo/config-plugins': 55.0.10
|
||||
'@expo/devtools': 55.0.3(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)
|
||||
'@expo/fingerprint': 0.16.7
|
||||
'@expo/local-build-cache-provider': 55.0.14(typescript@7.0.2)
|
||||
'@expo/local-build-cache-provider': 55.0.14(typescript@6.0.3)
|
||||
'@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(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)
|
||||
'@expo/metro': 55.1.1
|
||||
'@expo/metro-config': 55.0.24(expo@55.0.27)(typescript@7.0.2)
|
||||
'@expo/metro-config': 55.0.24(expo@55.0.27)(typescript@6.0.3)
|
||||
'@expo/vector-icons': 15.1.1(expo-font@55.0.8)(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)
|
||||
'@ungap/structured-clone': 1.3.1
|
||||
babel-preset-expo: 55.0.23(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.27)(react-refresh@0.14.2)
|
||||
expo-asset: 55.0.17(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)(typescript@7.0.2)
|
||||
expo-asset: 55.0.17(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)(typescript@6.0.3)
|
||||
expo-constants: 55.0.16(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))
|
||||
expo-file-system: 55.0.23(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))
|
||||
expo-font: 55.0.8(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)
|
||||
expo-keep-awake: 55.0.8(expo@55.0.27)(react@19.2.6)
|
||||
expo-modules-autolinking: 55.0.24(typescript@7.0.2)
|
||||
expo-modules-autolinking: 55.0.24(typescript@6.0.3)
|
||||
expo-modules-core: 55.0.25(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)
|
||||
pretty-format: 29.7.0
|
||||
react: 19.2.6
|
||||
@@ -12237,7 +12060,7 @@ snapshots:
|
||||
'@jest/create-cache-key-function': 29.7.0
|
||||
'@jest/globals': 29.7.0
|
||||
babel-jest: 29.7.0(@babel/core@7.29.7)
|
||||
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@7.0.2)
|
||||
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)
|
||||
jest-environment-jsdom: 29.7.0
|
||||
jest-snapshot: 29.7.0
|
||||
jest-watch-select-projects: 2.0.0
|
||||
@@ -14274,28 +14097,7 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
typescript@7.0.2:
|
||||
optionalDependencies:
|
||||
'@typescript/typescript-aix-ppc64': 7.0.2
|
||||
'@typescript/typescript-darwin-arm64': 7.0.2
|
||||
'@typescript/typescript-darwin-x64': 7.0.2
|
||||
'@typescript/typescript-freebsd-arm64': 7.0.2
|
||||
'@typescript/typescript-freebsd-x64': 7.0.2
|
||||
'@typescript/typescript-linux-arm': 7.0.2
|
||||
'@typescript/typescript-linux-arm64': 7.0.2
|
||||
'@typescript/typescript-linux-loong64': 7.0.2
|
||||
'@typescript/typescript-linux-mips64el': 7.0.2
|
||||
'@typescript/typescript-linux-ppc64': 7.0.2
|
||||
'@typescript/typescript-linux-riscv64': 7.0.2
|
||||
'@typescript/typescript-linux-s390x': 7.0.2
|
||||
'@typescript/typescript-linux-x64': 7.0.2
|
||||
'@typescript/typescript-netbsd-arm64': 7.0.2
|
||||
'@typescript/typescript-netbsd-x64': 7.0.2
|
||||
'@typescript/typescript-openbsd-arm64': 7.0.2
|
||||
'@typescript/typescript-openbsd-x64': 7.0.2
|
||||
'@typescript/typescript-sunos-x64': 7.0.2
|
||||
'@typescript/typescript-win32-arm64': 7.0.2
|
||||
'@typescript/typescript-win32-x64': 7.0.2
|
||||
typescript@6.0.3: {}
|
||||
|
||||
ua-parser-js@1.0.41: {}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
useWindowDimensions,
|
||||
ScrollView,
|
||||
Keyboard,
|
||||
BackHandler
|
||||
BackHandler,
|
||||
Modal
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
@@ -264,96 +265,105 @@ function MountedBottomDrawer({
|
||||
return { opacity: progress.value * dragFade }
|
||||
})
|
||||
|
||||
// Why: rendering through a full-screen Modal lifts the sheet into its own
|
||||
// native window so it always covers the viewport — even when the drawer is
|
||||
// mounted deep inside a ScrollView, where a plain absolute overlay anchors to
|
||||
// the scrolled content and clips the sheet. The Modal stays mounted (visible)
|
||||
// for the whole life of MountedBottomDrawer so the reanimated exit animation
|
||||
// runs before the parent unmounts us; show/hide is driven by `progress`, so
|
||||
// animationType stays "none". onRequestClose handles the Android back button.
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents={visible ? 'auto' : 'none'}
|
||||
style={[styles.overlay, { zIndex, elevation: zIndex }]}
|
||||
accessibilityViewIsModal
|
||||
aria-modal
|
||||
>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<Animated.View style={[styles.backdrop, backdropStyle]}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
|
||||
</Animated.View>
|
||||
|
||||
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
{
|
||||
width: '100%',
|
||||
maxWidth: isWideLayout ? modalMaxWidth : undefined,
|
||||
maxHeight: screenHeight - insets.top - spacing.lg,
|
||||
paddingBottom: insets.bottom + spacing.lg
|
||||
},
|
||||
drawerStyle
|
||||
]}
|
||||
>
|
||||
{!contentScrollable ? (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<View style={styles.staticContent}>{children}</View>
|
||||
</>
|
||||
) : dragContentToDismiss ? (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<GestureDetector gesture={contentPanGesture}>
|
||||
<Animated.View collapsable={false}>
|
||||
<GestureDetector gesture={scrollGesture}>
|
||||
<Animated.ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</Animated.ScrollView>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</>
|
||||
)}
|
||||
<View style={styles.bottomExtension} />
|
||||
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
|
||||
<Animated.View
|
||||
pointerEvents={visible ? 'auto' : 'none'}
|
||||
style={[styles.overlay, { zIndex, elevation: zIndex }]}
|
||||
accessibilityViewIsModal
|
||||
aria-modal
|
||||
>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<Animated.View style={[styles.backdrop, backdropStyle]}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Animated.View>
|
||||
|
||||
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
{
|
||||
width: '100%',
|
||||
maxWidth: isWideLayout ? modalMaxWidth : undefined,
|
||||
maxHeight: screenHeight - insets.top - spacing.lg,
|
||||
paddingBottom: insets.bottom + spacing.lg
|
||||
},
|
||||
drawerStyle
|
||||
]}
|
||||
>
|
||||
{!contentScrollable ? (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<View style={styles.staticContent}>{children}</View>
|
||||
</>
|
||||
) : dragContentToDismiss ? (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<GestureDetector gesture={contentPanGesture}>
|
||||
<Animated.View collapsable={false}>
|
||||
<GestureDetector gesture={scrollGesture}>
|
||||
<Animated.ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</Animated.ScrollView>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</>
|
||||
)}
|
||||
<View style={styles.bottomExtension} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE,
|
||||
MOBILE_DICTATION_PCM_SAMPLE_RATE
|
||||
} from './mobile-dictation-pending-audio-budget'
|
||||
import { bytesToBase64 } from './mobile-dictation-session-state'
|
||||
import type { MicrophoneDataEvent } from '@orca/expo-two-way-audio'
|
||||
import type { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
type MobileDictationAudioChunkQueue = {
|
||||
pendingChunks: Set<Promise<void>>
|
||||
pendingAudioBudget: MobileDictationPendingAudioBudget
|
||||
shouldReleaseBudget: (dictationId: string) => boolean
|
||||
failActiveDictation: (dictationId: string, err: unknown) => void
|
||||
}
|
||||
|
||||
export function enqueueMobileDictationAudioChunk(
|
||||
client: RpcClient,
|
||||
dictationId: string,
|
||||
event: MicrophoneDataEvent,
|
||||
queue: MobileDictationAudioChunkQueue
|
||||
): void {
|
||||
const raw = event.data
|
||||
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
||||
const byteLength = bytes.byteLength
|
||||
if (!queue.pendingAudioBudget.tryReserve(byteLength)) {
|
||||
queue.failActiveDictation(
|
||||
dictationId,
|
||||
new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)
|
||||
)
|
||||
return
|
||||
}
|
||||
const sendChunk = client
|
||||
.sendRequest('speech.dictation.chunk', {
|
||||
dictationId,
|
||||
audioBase64: bytesToBase64(bytes),
|
||||
sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
})
|
||||
.catch((err) => queue.failActiveDictation(dictationId, err))
|
||||
.finally(() => {
|
||||
if (queue.shouldReleaseBudget(dictationId)) {
|
||||
queue.pendingAudioBudget.release(byteLength)
|
||||
}
|
||||
queue.pendingChunks.delete(sendChunk)
|
||||
})
|
||||
queue.pendingChunks.add(sendChunk)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS } from './mobile-dictation-session-state'
|
||||
import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start'
|
||||
import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
const OK_RESPONSE = { ok: true, result: {} } as const
|
||||
|
||||
type StartHarnessOptions = {
|
||||
sendRequest?: (method: string) => Promise<unknown>
|
||||
acquire?: () => Promise<void>
|
||||
commitRecordingStart?: () => boolean
|
||||
}
|
||||
|
||||
function createStartHarness(options: StartHarnessOptions = {}) {
|
||||
let generation = 1
|
||||
let enabled = true
|
||||
let activeId: string | null = 'dictation-a'
|
||||
const setIdle = vi.fn()
|
||||
const release = vi.fn().mockResolvedValue(undefined)
|
||||
const commitRecordingStart = vi.fn(options.commitRecordingStart ?? (() => true))
|
||||
const rollbackRecordingStart = vi.fn()
|
||||
const sendRequest = vi.fn(
|
||||
options.sendRequest ?? (async () => OK_RESPONSE)
|
||||
) as unknown as RpcClient['sendRequest']
|
||||
const client = { sendRequest } as RpcClient
|
||||
const keepAwakeOwner = {
|
||||
acquire: vi.fn(options.acquire ?? (async () => undefined)),
|
||||
release
|
||||
} as unknown as MobileDictationKeepAwakeOwner
|
||||
|
||||
return {
|
||||
options: {
|
||||
client,
|
||||
dictationId: 'dictation-a',
|
||||
generation: 1,
|
||||
getCurrentGeneration: () => generation,
|
||||
getEnabled: () => enabled,
|
||||
getActiveId: () => activeId,
|
||||
clearActiveId: (dictationId: string) => {
|
||||
if (activeId === dictationId) {
|
||||
activeId = null
|
||||
}
|
||||
},
|
||||
setIdle,
|
||||
keepAwakeOwner,
|
||||
commitRecordingStart,
|
||||
rollbackRecordingStart
|
||||
},
|
||||
setNewerStart: () => {
|
||||
generation = 2
|
||||
activeId = 'dictation-b'
|
||||
},
|
||||
setDisabled: () => {
|
||||
enabled = false
|
||||
},
|
||||
getActiveId: () => activeId,
|
||||
setIdle,
|
||||
release,
|
||||
sendRequest,
|
||||
commitRecordingStart,
|
||||
rollbackRecordingStart
|
||||
}
|
||||
}
|
||||
|
||||
describe('startMobileDictationDesktopSession', () => {
|
||||
it('does not reset UI state when a newer start supersedes keep-awake acquisition', async () => {
|
||||
let setNewerStart = () => undefined
|
||||
const harness = createStartHarness({
|
||||
acquire: async () => setNewerStart()
|
||||
})
|
||||
setNewerStart = harness.setNewerStart
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
|
||||
|
||||
expect(harness.setIdle).not.toHaveBeenCalled()
|
||||
expect(harness.getActiveId()).toBe('dictation-b')
|
||||
expect(harness.release).toHaveBeenCalledWith('dictation-a')
|
||||
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends desktop cancellation without waiting for a hung keep-awake release', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let goStale = () => undefined
|
||||
const harness = createStartHarness({
|
||||
acquire: () => {
|
||||
goStale()
|
||||
return new Promise<void>(() => undefined)
|
||||
}
|
||||
})
|
||||
goStale = harness.setNewerStart
|
||||
// Release queues behind the still-running acquisition, so it never settles.
|
||||
harness.release.mockReturnValue(new Promise<void>(() => undefined))
|
||||
|
||||
const startPromise = startMobileDictationDesktopSession(harness.options)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS)
|
||||
|
||||
// Cancellation is dispatched even though release is still pending, so the
|
||||
// native session tears down without waiting out its own timeout.
|
||||
expect(harness.release).toHaveBeenCalledWith('dictation-a')
|
||||
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
|
||||
dictationId: 'dictation-a'
|
||||
})
|
||||
|
||||
void startPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns to idle when disable makes keep-awake acquisition stale', async () => {
|
||||
let setDisabled = () => undefined
|
||||
const harness = createStartHarness({
|
||||
acquire: async () => setDisabled()
|
||||
})
|
||||
setDisabled = harness.setDisabled
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
|
||||
|
||||
expect(harness.setIdle).toHaveBeenCalledOnce()
|
||||
expect(harness.getActiveId()).toBeNull()
|
||||
expect(harness.release).toHaveBeenCalledWith('dictation-a')
|
||||
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not hold recording start on a hung keep-awake acquisition', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const harness = createStartHarness({
|
||||
acquire: () => new Promise<void>(() => undefined)
|
||||
})
|
||||
|
||||
const startPromise = startMobileDictationDesktopSession(harness.options)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS)
|
||||
|
||||
await expect(startPromise).resolves.toBe(true)
|
||||
expect(harness.commitRecordingStart).toHaveBeenCalledOnce()
|
||||
expect(harness.release).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('continues dictation when keep-awake acquisition fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const harness = createStartHarness({
|
||||
acquire: async () => {
|
||||
throw new Error('Unable to activate keep awake')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true)
|
||||
|
||||
expect(consoleError).toHaveBeenCalledOnce()
|
||||
consoleError.mockRestore()
|
||||
|
||||
expect(harness.commitRecordingStart).toHaveBeenCalledOnce()
|
||||
expect(harness.setIdle).not.toHaveBeenCalled()
|
||||
expect(harness.getActiveId()).toBe('dictation-a')
|
||||
expect(harness.release).not.toHaveBeenCalled()
|
||||
expect(harness.sendRequest).not.toHaveBeenCalledWith('speech.dictation.cancel', {
|
||||
dictationId: 'dictation-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not surface a desktop-start failure after the start became stale', async () => {
|
||||
let setNewerStart = () => undefined
|
||||
const harness = createStartHarness({
|
||||
sendRequest: async (method) => {
|
||||
if (method === 'speech.dictation.start') {
|
||||
setNewerStart()
|
||||
throw new Error('Desktop start failed')
|
||||
}
|
||||
return OK_RESPONSE
|
||||
}
|
||||
})
|
||||
setNewerStart = harness.setNewerStart
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
|
||||
|
||||
expect(harness.setIdle).not.toHaveBeenCalled()
|
||||
expect(harness.getActiveId()).toBe('dictation-b')
|
||||
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commits recording before returning a current start to the hook', async () => {
|
||||
const harness = createStartHarness()
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true)
|
||||
|
||||
expect(harness.commitRecordingStart).toHaveBeenCalledOnce()
|
||||
expect(harness.rollbackRecordingStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up the keep-awake tag and desktop session when native recording throws', async () => {
|
||||
const harness = createStartHarness({
|
||||
commitRecordingStart: () => {
|
||||
throw new Error('Audio focus request failed')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow(
|
||||
'Audio focus request failed'
|
||||
)
|
||||
|
||||
expect(harness.release).toHaveBeenCalledWith('dictation-a')
|
||||
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
|
||||
dictationId: 'dictation-a'
|
||||
})
|
||||
expect(harness.getActiveId()).toBeNull()
|
||||
expect(harness.setIdle).toHaveBeenCalledOnce()
|
||||
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects and cleans up when the native recorder does not start', async () => {
|
||||
const harness = createStartHarness({ commitRecordingStart: () => false })
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow(
|
||||
'Failed to start microphone recording'
|
||||
)
|
||||
|
||||
expect(harness.release).toHaveBeenCalledWith('dictation-a')
|
||||
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
|
||||
dictationId: 'dictation-a'
|
||||
})
|
||||
expect(harness.getActiveId()).toBeNull()
|
||||
expect(harness.setIdle).toHaveBeenCalledOnce()
|
||||
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS,
|
||||
isCurrentMobileDictationStart
|
||||
} from './mobile-dictation-session-state'
|
||||
import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
type StartMobileDictationDesktopSessionOptions = {
|
||||
client: RpcClient
|
||||
dictationId: string
|
||||
generation: number
|
||||
getCurrentGeneration: () => number
|
||||
getEnabled: () => boolean
|
||||
getActiveId: () => string | null
|
||||
clearActiveId: (dictationId: string) => void
|
||||
setIdle: () => void
|
||||
keepAwakeOwner: MobileDictationKeepAwakeOwner
|
||||
commitRecordingStart: () => boolean
|
||||
rollbackRecordingStart: () => void
|
||||
}
|
||||
|
||||
function isCurrentStart(options: StartMobileDictationDesktopSessionOptions): boolean {
|
||||
return isCurrentMobileDictationStart(
|
||||
options.getCurrentGeneration(),
|
||||
options.generation,
|
||||
options.getEnabled(),
|
||||
options.getActiveId(),
|
||||
options.dictationId
|
||||
)
|
||||
}
|
||||
|
||||
function canReportStartFailure(options: StartMobileDictationDesktopSessionOptions): boolean {
|
||||
return options.getCurrentGeneration() === options.generation && options.getEnabled()
|
||||
}
|
||||
|
||||
function setIdleIfGenerationCurrent(options: StartMobileDictationDesktopSessionOptions): void {
|
||||
if (options.getCurrentGeneration() === options.generation) {
|
||||
options.setIdle()
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel a start that went stale mid-startup. The wake-lock release and remote
|
||||
// cancel are independent, so run them concurrently: awaiting release first can
|
||||
// queue behind a still-running acquisition and delay cancel for the remainder
|
||||
// of the native timeout.
|
||||
async function cancelStaleStart(
|
||||
options: StartMobileDictationDesktopSessionOptions,
|
||||
{ releaseKeepAwake }: { releaseKeepAwake: boolean }
|
||||
): Promise<void> {
|
||||
const { client, dictationId, keepAwakeOwner } = options
|
||||
options.clearActiveId(dictationId)
|
||||
setIdleIfGenerationCurrent(options)
|
||||
const cleanups: Promise<unknown>[] = [
|
||||
client.sendRequest('speech.dictation.cancel', { dictationId })
|
||||
]
|
||||
if (releaseKeepAwake) {
|
||||
cleanups.push(keepAwakeOwner.release(dictationId))
|
||||
}
|
||||
await Promise.allSettled(cleanups)
|
||||
}
|
||||
|
||||
export async function startMobileDictationDesktopSession(
|
||||
options: StartMobileDictationDesktopSessionOptions
|
||||
): Promise<boolean> {
|
||||
const { client, dictationId, keepAwakeOwner } = options
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('speech.dictation.start', { dictationId })
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
} catch (err) {
|
||||
const wasCurrent = isCurrentStart(options)
|
||||
options.clearActiveId(dictationId)
|
||||
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
|
||||
// Awaited cleanup may overlap a newer start; stale work must not reset or
|
||||
// report over the replacement session.
|
||||
const shouldReport = wasCurrent && canReportStartFailure(options)
|
||||
setIdleIfGenerationCurrent(options)
|
||||
if (!shouldReport) {
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!isCurrentStart(options)) {
|
||||
await cancelStaleStart(options, { releaseKeepAwake: false })
|
||||
return false
|
||||
}
|
||||
|
||||
// Keep-awake is acquired only after the desktop session exists, so stale
|
||||
// mobile starts can be canceled without holding a screen-lock tag. It is
|
||||
// best-effort: Android throws with no current Activity, and a screen-lock
|
||||
// nicety must not abort an otherwise viable dictation — nor delay recording
|
||||
// past a small budget when native calls hang. A late acquisition finishes in
|
||||
// the background; the serialized keep-awake queue orders any later release
|
||||
// after it.
|
||||
await new Promise<void>((resolve) => {
|
||||
const budgetTimer = setTimeout(resolve, MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS)
|
||||
keepAwakeOwner
|
||||
.acquire(dictationId)
|
||||
.catch((err: unknown) => console.error('Keep-awake activation failed', err))
|
||||
.finally(() => {
|
||||
clearTimeout(budgetTimer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
if (!isCurrentStart(options)) {
|
||||
await cancelStaleStart(options, { releaseKeepAwake: true })
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Commit in the same continuation as the final stale check; returning first
|
||||
// would let a queued cancel resurrect microphone recording after cleanup.
|
||||
if (!options.commitRecordingStart()) {
|
||||
throw new Error('Failed to start microphone recording')
|
||||
}
|
||||
} catch (err) {
|
||||
const wasCurrent = isCurrentStart(options)
|
||||
// Native recording can partially start before throwing, so stop audio before
|
||||
// releasing the wake tag and remote session.
|
||||
try {
|
||||
options.rollbackRecordingStart()
|
||||
} catch {
|
||||
// Continue releasing independently owned resources after native audio failure.
|
||||
}
|
||||
options.clearActiveId(dictationId)
|
||||
await Promise.allSettled([
|
||||
keepAwakeOwner.release(dictationId),
|
||||
client.sendRequest('speech.dictation.cancel', { dictationId })
|
||||
])
|
||||
const shouldReport = wasCurrent && canReportStartFailure(options)
|
||||
setIdleIfGenerationCurrent(options)
|
||||
if (!shouldReport) {
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect } from 'react'
|
||||
import { AppState, Platform } from 'react-native'
|
||||
import { drainMobileDictationKeepAwakeCleanup } from './mobile-dictation-keep-awake'
|
||||
import type { RefObject } from 'react'
|
||||
import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake'
|
||||
|
||||
// A transient Activity gap can fail a foreground refresh; retry briefly while
|
||||
// the same dictation is live instead of waiting for the next foreground.
|
||||
const REACQUIRE_RETRY_DELAYS_MS = [1_000, 5_000]
|
||||
|
||||
let globalStaleTagDrainInstalled = false
|
||||
|
||||
// Failed final deactivations must be retried even after every session screen
|
||||
// unmounts, or a stale native tag keeps the screen awake until app restart.
|
||||
// Installed once for the app's lifetime; the drain spares still-wanted tags
|
||||
// and fast-paths to a no-op when nothing is pending.
|
||||
function installGlobalStaleTagForegroundDrain(): void {
|
||||
if (globalStaleTagDrainInstalled) {
|
||||
return
|
||||
}
|
||||
globalStaleTagDrainInstalled = true
|
||||
AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
void drainMobileDictationKeepAwakeCleanup().catch(() => undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useMobileDictationForegroundKeepAwake(
|
||||
keepAwakeOwner: MobileDictationKeepAwakeOwner,
|
||||
activeIdRef: RefObject<string | null>
|
||||
): void {
|
||||
useEffect(() => {
|
||||
installGlobalStaleTagForegroundDrain()
|
||||
// Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so Activity
|
||||
// recreation silently drops it mid-dictation; refresh on return to
|
||||
// active. iOS re-applies natively on foreground.
|
||||
if (Platform.OS !== 'android') {
|
||||
return
|
||||
}
|
||||
// A retry from an earlier foreground event can outlive a newer reacquire and
|
||||
// deactivate the recovered tag; a run token invalidated on each AppState
|
||||
// change and on unmount drops superseded retry chains.
|
||||
let reacquireRun = 0
|
||||
const reacquireWithRetry = (dictationId: string, attempt: number, run: number): void => {
|
||||
void keepAwakeOwner.reacquire(dictationId).catch(() => {
|
||||
const delay = REACQUIRE_RETRY_DELAYS_MS[attempt]
|
||||
if (delay === undefined) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (reacquireRun === run && activeIdRef.current === dictationId) {
|
||||
reacquireWithRetry(dictationId, attempt + 1, run)
|
||||
}
|
||||
}, delay)
|
||||
})
|
||||
}
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
const run = ++reacquireRun
|
||||
const dictationId = activeIdRef.current
|
||||
if (state === 'active' && dictationId) {
|
||||
reacquireWithRetry(dictationId, 0, run)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
reacquireRun += 1
|
||||
sub.remove()
|
||||
}
|
||||
}, [keepAwakeOwner, activeIdRef])
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const keepAwake = vi.hoisted(() => ({
|
||||
activate: vi.fn<(tag: string) => Promise<void>>(),
|
||||
deactivate: vi.fn<(tag: string) => Promise<void>>()
|
||||
}))
|
||||
|
||||
vi.mock('expo-keep-awake', () => ({
|
||||
activateKeepAwakeAsync: keepAwake.activate,
|
||||
deactivateKeepAwake: keepAwake.deactivate
|
||||
}))
|
||||
|
||||
import {
|
||||
MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS,
|
||||
MobileDictationKeepAwakeOwner,
|
||||
drainMobileDictationKeepAwakeCleanup
|
||||
} from './mobile-dictation-keep-awake'
|
||||
|
||||
function deferred(): {
|
||||
promise: Promise<void>
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
} {
|
||||
let resolvePromise: (() => void) | undefined
|
||||
let rejectPromise: ((error: Error) => void) | undefined
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
resolvePromise = resolve
|
||||
rejectPromise = reject
|
||||
})
|
||||
return {
|
||||
promise,
|
||||
resolve: () => resolvePromise?.(),
|
||||
reject: (error) => rejectPromise?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
describe('MobileDictationKeepAwakeOwner', () => {
|
||||
beforeEach(() => {
|
||||
keepAwake.activate.mockReset().mockResolvedValue(undefined)
|
||||
keepAwake.deactivate.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('retries a failed native deactivation after the hook owner is replaced', async () => {
|
||||
const firstOwner = new MobileDictationKeepAwakeOwner()
|
||||
|
||||
await firstOwner.acquire('first')
|
||||
const firstTag = keepAwake.activate.mock.calls[0]?.[0]
|
||||
expect(firstTag).toContain(':first')
|
||||
|
||||
keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable')
|
||||
|
||||
const replacementOwner = new MobileDictationKeepAwakeOwner()
|
||||
await replacementOwner.acquire('second')
|
||||
const secondTag = keepAwake.activate.mock.calls[1]?.[0]
|
||||
expect(secondTag).toContain(':second')
|
||||
expect(keepAwake.deactivate.mock.calls.slice(0, 2)).toEqual([[firstTag], [firstTag]])
|
||||
expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
keepAwake.activate.mock.invocationCallOrder[1] ?? 0
|
||||
)
|
||||
|
||||
await replacementOwner.release('second')
|
||||
})
|
||||
|
||||
it('serializes cancel and restart without letting a stale release deactivate the restart', async () => {
|
||||
const firstActivation = deferred()
|
||||
keepAwake.activate.mockImplementationOnce(() => firstActivation.promise)
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
|
||||
const acquireFirst = owner.acquire('first')
|
||||
const releaseFirst = owner.release('first')
|
||||
const acquireSecond = owner.acquire('second')
|
||||
firstActivation.resolve()
|
||||
await Promise.all([acquireFirst, releaseFirst, acquireSecond])
|
||||
|
||||
const secondTag = keepAwake.activate.mock.calls[1]?.[0]
|
||||
await owner.release('first')
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledTimes(1)
|
||||
|
||||
await owner.release('second')
|
||||
expect(keepAwake.deactivate).toHaveBeenLastCalledWith(secondTag)
|
||||
})
|
||||
|
||||
it('waits for an in-flight failed release before a replacement owner activates', async () => {
|
||||
const deactivation = deferred()
|
||||
const firstOwner = new MobileDictationKeepAwakeOwner()
|
||||
await firstOwner.acquire('first')
|
||||
keepAwake.deactivate.mockImplementationOnce(() => deactivation.promise)
|
||||
|
||||
const releaseFirst = firstOwner.release('first')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledOnce()
|
||||
|
||||
const replacementOwner = new MobileDictationKeepAwakeOwner()
|
||||
const acquireReplacement = replacementOwner.acquire('replacement')
|
||||
expect(keepAwake.activate).toHaveBeenCalledOnce()
|
||||
|
||||
deactivation.reject(new Error('Activity unavailable'))
|
||||
await expect(releaseFirst).rejects.toThrow('Activity unavailable')
|
||||
await acquireReplacement
|
||||
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledTimes(2)
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(2)
|
||||
expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
keepAwake.activate.mock.invocationCallOrder[1] ?? 0
|
||||
)
|
||||
await replacementOwner.release('replacement')
|
||||
})
|
||||
|
||||
it('does not fail a fresh acquire when stale-tag cleanup keeps failing', async () => {
|
||||
const firstOwner = new MobileDictationKeepAwakeOwner()
|
||||
await firstOwner.acquire('first')
|
||||
|
||||
// Both the release deactivate and its trailing drain retry fail.
|
||||
keepAwake.deactivate
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable')
|
||||
|
||||
keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
const replacementOwner = new MobileDictationKeepAwakeOwner()
|
||||
await expect(replacementOwner.acquire('second')).resolves.toBeUndefined()
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(2)
|
||||
|
||||
// The still-pending first tag drains once a deactivation finally succeeds.
|
||||
await replacementOwner.release('second')
|
||||
expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':first'))).toHaveLength(
|
||||
4
|
||||
)
|
||||
})
|
||||
|
||||
it('times out a never-settling native call instead of wedging the queue', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
keepAwake.activate.mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
const hungOwner = new MobileDictationKeepAwakeOwner()
|
||||
const hungAcquire = hungOwner.acquire('hung')
|
||||
// Drain microtasks to quiescence so the timeout timer is registered.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(hungAcquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
// The queue must advance, and another owner's drain must spare the
|
||||
// still-wanted maybe-late activation.
|
||||
const nextOwner = new MobileDictationKeepAwakeOwner()
|
||||
await nextOwner.acquire('next')
|
||||
expect(keepAwake.deactivate).not.toHaveBeenCalled()
|
||||
expect(keepAwake.activate.mock.calls[1]?.[0]).toContain(':next')
|
||||
|
||||
// Once its own dictation ends, the orphan gets cleaned.
|
||||
await hungOwner.release('hung')
|
||||
expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':hung'))).toHaveLength(
|
||||
1
|
||||
)
|
||||
await nextOwner.release('next')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('adopts a timed-out activation that lands late while the dictation is live', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const lateActivation = deferred()
|
||||
keepAwake.activate.mockImplementationOnce(() => lateActivation.promise)
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
const acquire = owner.acquire('late')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(acquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
lateActivation.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// Adopted, not deactivated: protection stays on for the live dictation.
|
||||
expect(keepAwake.deactivate).not.toHaveBeenCalled()
|
||||
|
||||
await owner.release('late')
|
||||
expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':late'))).toHaveLength(
|
||||
1
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let another owner drain a still-wanted timed-out activation', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const lateActivation = deferred()
|
||||
keepAwake.activate.mockImplementationOnce(() => lateActivation.promise)
|
||||
const ownerA = new MobileDictationKeepAwakeOwner()
|
||||
const acquireA = ownerA.acquire('wanted')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(acquireA).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
const ownerB = new MobileDictationKeepAwakeOwner()
|
||||
await ownerB.acquire('other')
|
||||
expect(keepAwake.deactivate).not.toHaveBeenCalled()
|
||||
|
||||
lateActivation.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// Adopted for owner A; released like a normal activation afterwards.
|
||||
await ownerA.release('wanted')
|
||||
expect(
|
||||
keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':wanted'))
|
||||
).toHaveLength(1)
|
||||
await ownerB.release('other')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('deactivates a late-landing activation once its dictation has ended', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const lateActivation = deferred()
|
||||
keepAwake.activate.mockImplementationOnce(() => lateActivation.promise)
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
const acquire = owner.acquire('ended')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(acquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
await owner.release('ended')
|
||||
lateActivation.resolve()
|
||||
await vi.waitFor(() => expect(keepAwake.deactivate).toHaveBeenCalledTimes(2))
|
||||
expect(keepAwake.deactivate.mock.calls.every(([tag]) => tag.includes(':ended'))).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a timed-out final deactivation via the foreground drain', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
await owner.acquire('final')
|
||||
const tag = keepAwake.activate.mock.calls[0]?.[0]
|
||||
// The release deactivate times out and its trailing drain retry fails.
|
||||
keepAwake.deactivate
|
||||
.mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
const release = owner.release('final')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(release).rejects.toThrow('Keep-awake native call timed out')
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledTimes(2)
|
||||
|
||||
await drainMobileDictationKeepAwakeCleanup()
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledTimes(3)
|
||||
expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drains orphaned tags on release, not only on the next acquire', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
keepAwake.activate.mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
const acquire = owner.acquire('orphan')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(acquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
// The dictation ends without another acquire; release must still clean.
|
||||
await owner.release('orphan')
|
||||
expect(
|
||||
keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':orphan'))
|
||||
).toHaveLength(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers on foreground reacquire after a failed initial acquisition', async () => {
|
||||
keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake'))
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
await expect(owner.acquire('current')).rejects.toThrow('Unable to activate keep awake')
|
||||
expect(keepAwake.deactivate).not.toHaveBeenCalled()
|
||||
|
||||
await owner.reacquire('current')
|
||||
const tag = keepAwake.activate.mock.calls[1]?.[0]
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(2)
|
||||
expect(tag).toContain(':current')
|
||||
// A definite rejection activated nothing, so recovery must not deactivate.
|
||||
expect(keepAwake.deactivate).not.toHaveBeenCalled()
|
||||
|
||||
await owner.release('current')
|
||||
expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag)
|
||||
})
|
||||
|
||||
it('recovers keep-awake on a later reacquire after a failed refresh', async () => {
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
await owner.acquire('current')
|
||||
const tag = keepAwake.activate.mock.calls[0]?.[0]
|
||||
|
||||
// Refresh loses the activation: deactivate succeeds, activate rejects.
|
||||
keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake'))
|
||||
await expect(owner.reacquire('current')).rejects.toThrow('Unable to activate keep awake')
|
||||
|
||||
await owner.reacquire('current')
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(3)
|
||||
expect(keepAwake.activate.mock.calls[2]?.[0]).toBe(tag)
|
||||
// Only the pre-refresh deactivate ran; recovery must not deactivate again.
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledTimes(1)
|
||||
|
||||
await owner.release('current')
|
||||
expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag)
|
||||
})
|
||||
|
||||
it('records new-dictation intent even when previous-tag cleanup fails', async () => {
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
await owner.acquire('first')
|
||||
|
||||
// Release and its trailing drain both fail; the owner keeps stale intent.
|
||||
keepAwake.deactivate
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
await expect(owner.release('first')).rejects.toThrow('Activity unavailable')
|
||||
|
||||
// The next acquire's drain and previous-tag cleanup fail too, and the new
|
||||
// activation itself fails — intent must still be recorded for the heal.
|
||||
keepAwake.deactivate
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake'))
|
||||
await expect(owner.acquire('second')).rejects.toThrow('Unable to activate keep awake')
|
||||
|
||||
await owner.reacquire('second')
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(3)
|
||||
expect(keepAwake.activate.mock.calls.at(-1)?.[0]).toContain(':second')
|
||||
|
||||
await owner.release('second')
|
||||
})
|
||||
|
||||
it('keeps tracking a newer timed-out activation when an older one settles late', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const first = deferred()
|
||||
keepAwake.activate.mockImplementationOnce(() => first.promise)
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
const acquire = owner.acquire('stacked')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(acquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
const second = deferred()
|
||||
keepAwake.activate.mockImplementationOnce(() => second.promise)
|
||||
const reacquire = owner.reacquire('stacked')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(reacquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
// The older activation settles late with a rejection; the newer
|
||||
// activation's tracking must survive it.
|
||||
first.reject(new Error('Activity unavailable'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
await owner.release('stacked')
|
||||
// Exactly two: the reacquire's deactivate-first pass, plus the release
|
||||
// drain cleaning the newer entry the stale settle must not have deleted.
|
||||
expect(
|
||||
keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':stacked'))
|
||||
).toHaveLength(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('reacquires a maybe-active timed-out activation by deactivating first', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
keepAwake.activate.mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
const acquire = owner.acquire('maybe')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(acquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
await owner.reacquire('maybe')
|
||||
// The ambiguous activation may be natively live; it must be deactivated
|
||||
// before the fresh activate so Android re-applies the window flag.
|
||||
const tag = keepAwake.activate.mock.calls[0]?.[0]
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledWith(tag)
|
||||
expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
keepAwake.activate.mock.invocationCallOrder[1] ?? 0
|
||||
)
|
||||
|
||||
await owner.release('maybe')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a live tag out of the orphan pool when a refresh deactivation fails', async () => {
|
||||
const ownerA = new MobileDictationKeepAwakeOwner()
|
||||
await ownerA.acquire('live')
|
||||
const liveTag = keepAwake.activate.mock.calls[0]?.[0]
|
||||
|
||||
// Foreground refresh: the deactivate leg fails; the tag stays native-on
|
||||
// and the failure surfaces so the caller's bounded retry can kick in.
|
||||
keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
await expect(ownerA.reacquire('live')).rejects.toThrow('Activity unavailable')
|
||||
expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1)
|
||||
|
||||
// Another owner's drain must spare the still-wanted live tag.
|
||||
const ownerB = new MobileDictationKeepAwakeOwner()
|
||||
await ownerB.acquire('other')
|
||||
expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1)
|
||||
|
||||
// The next foreground retries the full deactivate-then-activate refresh.
|
||||
await ownerA.reacquire('live')
|
||||
expect(keepAwake.activate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(2)
|
||||
|
||||
await ownerA.release('live')
|
||||
await ownerB.release('other')
|
||||
})
|
||||
|
||||
it('reacquires by deactivating before activating so Android re-applies the window flag', async () => {
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
await owner.acquire('current')
|
||||
const tag = keepAwake.activate.mock.calls[0]?.[0]
|
||||
|
||||
await owner.reacquire('current')
|
||||
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledWith(tag)
|
||||
expect(keepAwake.activate.mock.calls).toEqual([[tag], [tag]])
|
||||
expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
keepAwake.activate.mock.invocationCallOrder[1] ?? 0
|
||||
)
|
||||
|
||||
await owner.reacquire('other')
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(2)
|
||||
|
||||
await owner.release('current')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
|
||||
|
||||
const MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX = 'orca-mobile-dictation'
|
||||
|
||||
// Native keep-awake promises can be lost during Activity teardown; a bounded
|
||||
// wait keeps the serialized queue below from wedging dictation until restart.
|
||||
export const MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS = 10_000
|
||||
|
||||
let nextOwnerId = 0
|
||||
let keepAwakeOperation: Promise<void> = Promise.resolve()
|
||||
const activeTags = new Set<string>()
|
||||
const pendingCleanupTags = new Set<string>()
|
||||
// Timed-out activations that may still land natively, keyed to a predicate
|
||||
// saying whether their dictation still wants the tag.
|
||||
const pendingActivations = new Map<string, () => boolean>()
|
||||
|
||||
function createOwnerId(): string {
|
||||
nextOwnerId += 1
|
||||
return `${Date.now()}-${nextOwnerId}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
function enqueueKeepAwakeOperation(action: () => Promise<void>): Promise<void> {
|
||||
const operation = keepAwakeOperation.then(action)
|
||||
keepAwakeOperation = operation.catch(() => undefined)
|
||||
return operation
|
||||
}
|
||||
|
||||
const KEEP_AWAKE_TIMEOUT_ERROR_NAME = 'KeepAwakeNativeTimeoutError'
|
||||
|
||||
function isNativeCallTimeout(err: unknown): boolean {
|
||||
return err instanceof Error && err.name === KEEP_AWAKE_TIMEOUT_ERROR_NAME
|
||||
}
|
||||
|
||||
function withNativeCallTimeout(nativeCall: Promise<void>): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const timeoutError = new Error('Keep-awake native call timed out')
|
||||
timeoutError.name = KEEP_AWAKE_TIMEOUT_ERROR_NAME
|
||||
reject(timeoutError)
|
||||
}, MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
nativeCall.then(
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
},
|
||||
(err: unknown) => {
|
||||
clearTimeout(timer)
|
||||
reject(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function activateTrackedTag(tag: string, isStillWanted: () => boolean): Promise<void> {
|
||||
const nativeActivation = activateKeepAwakeAsync(tag)
|
||||
try {
|
||||
await withNativeCallTimeout(nativeActivation)
|
||||
} catch (err) {
|
||||
// Only a timeout is ambiguous: the activation can still take effect late.
|
||||
// A definite native rejection activated nothing and needs no cleanup.
|
||||
if (isNativeCallTimeout(err)) {
|
||||
// Tracked apart from deactivation retries so drains (including another
|
||||
// owner's) never turn off an activation its dictation still wants.
|
||||
pendingActivations.set(tag, isStillWanted)
|
||||
nativeActivation.then(
|
||||
() =>
|
||||
void enqueueKeepAwakeOperation(async () => {
|
||||
// Delete only this activation's own entry — a newer timed-out
|
||||
// activation of the same tag may have replaced it.
|
||||
if (pendingActivations.get(tag) === isStillWanted) {
|
||||
pendingActivations.delete(tag)
|
||||
}
|
||||
if (activeTags.has(tag)) {
|
||||
return
|
||||
}
|
||||
if (isStillWanted()) {
|
||||
// The activation landed late but its dictation is still live;
|
||||
// adopt it rather than turning off screen-lock protection.
|
||||
activeTags.add(tag)
|
||||
return
|
||||
}
|
||||
// No owner wants it anymore — the screen must not stay awake.
|
||||
await deactivateTrackedTag(tag).catch(() => undefined)
|
||||
}),
|
||||
() => {
|
||||
// A late definite rejection means nothing activated after all, but
|
||||
// spare a newer activation's entry keyed to the same tag.
|
||||
if (pendingActivations.get(tag) === isStillWanted) {
|
||||
pendingActivations.delete(tag)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
activeTags.add(tag)
|
||||
pendingCleanupTags.delete(tag)
|
||||
pendingActivations.delete(tag)
|
||||
}
|
||||
|
||||
async function deactivateTrackedTag(tag: string): Promise<void> {
|
||||
try {
|
||||
await withNativeCallTimeout(deactivateKeepAwake(tag))
|
||||
} catch (err) {
|
||||
// A replacement hook must be able to retry cleanup after Android replaces
|
||||
// an Activity and the owner that acquired this tag has unmounted.
|
||||
pendingCleanupTags.add(tag)
|
||||
throw err
|
||||
}
|
||||
activeTags.delete(tag)
|
||||
pendingCleanupTags.delete(tag)
|
||||
pendingActivations.delete(tag)
|
||||
}
|
||||
|
||||
async function cleanupPendingTags(): Promise<void> {
|
||||
const staleTags = new Set(pendingCleanupTags)
|
||||
for (const [tag, isStillWanted] of pendingActivations) {
|
||||
// A still-wanted timed-out activation is not an orphan: deactivating it
|
||||
// would turn off screen-lock protection for a live dictation.
|
||||
if (!isStillWanted()) {
|
||||
pendingActivations.delete(tag)
|
||||
staleTags.add(tag)
|
||||
}
|
||||
}
|
||||
if (staleTags.size === 0) {
|
||||
return
|
||||
}
|
||||
// Retry concurrently so N stale tags cost one timeout window, not N, and
|
||||
// swallow failures: a stale tag that still cannot be deactivated must not
|
||||
// fail the fresh acquire that triggered this retry; it stays queued.
|
||||
await Promise.allSettled(
|
||||
Array.from(staleTags, (tag) => deactivateTrackedTag(tag).catch(() => undefined))
|
||||
)
|
||||
}
|
||||
|
||||
export class MobileDictationKeepAwakeOwner {
|
||||
private readonly ownerId = createOwnerId()
|
||||
private acquiredTag: string | null = null
|
||||
|
||||
acquire(dictationId: string): Promise<void> {
|
||||
const tag = this.createTag(dictationId)
|
||||
return enqueueKeepAwakeOperation(async () => {
|
||||
await cleanupPendingTags()
|
||||
if (this.acquiredTag && !activeTags.has(this.acquiredTag)) {
|
||||
this.acquiredTag = null
|
||||
}
|
||||
if (this.acquiredTag === tag) {
|
||||
return
|
||||
}
|
||||
if (this.acquiredTag) {
|
||||
const previousTag = this.acquiredTag
|
||||
this.acquiredTag = null
|
||||
// Best-effort: a failed previous-tag cleanup is queued for retry and
|
||||
// must not block recording intent for the new dictation below.
|
||||
await deactivateTrackedTag(previousTag).catch(() => undefined)
|
||||
}
|
||||
// Record ownership before the native call: acquiredTag is intent while
|
||||
// activeTags is native state, so a failed initial activation can still
|
||||
// be healed by a later foreground reacquire.
|
||||
this.acquiredTag = tag
|
||||
await activateTrackedTag(tag, () => this.acquiredTag === tag)
|
||||
})
|
||||
}
|
||||
|
||||
// Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so a recreated
|
||||
// Activity silently loses it while native tags persist — and native activate
|
||||
// skips re-applying the flag while any tag remains, so deactivate first.
|
||||
reacquire(dictationId: string): Promise<void> {
|
||||
const tag = this.createTag(dictationId)
|
||||
return enqueueKeepAwakeOperation(async () => {
|
||||
await cleanupPendingTags()
|
||||
if (this.acquiredTag !== tag) {
|
||||
return
|
||||
}
|
||||
// A timed-out activation may be natively active too, and Android only
|
||||
// re-applies the window flag from an empty tag set — deactivate both.
|
||||
if (activeTags.has(tag) || pendingActivations.has(tag)) {
|
||||
try {
|
||||
await deactivateTrackedTag(tag)
|
||||
} catch (err) {
|
||||
// A still-live tag must not sit in the orphan pool where another
|
||||
// owner's drain would turn it off without reactivating; keep it in
|
||||
// the wanted pool and surface the failure so the caller can retry
|
||||
// before the next foreground event.
|
||||
pendingCleanupTags.delete(tag)
|
||||
pendingActivations.set(tag, () => this.acquiredTag === tag)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
// Also recovers an activation lost to an earlier native failure, so a
|
||||
// later foreground event can restore keep-awake instead of no-oping.
|
||||
// Known gap: if another expo-keep-awake owner exists (e.g. dev-build
|
||||
// dev tools), the native module never empties its tag set, so the
|
||||
// deactivate/activate cycle cannot re-apply the Android window flag.
|
||||
await activateTrackedTag(tag, () => this.acquiredTag === tag)
|
||||
})
|
||||
}
|
||||
|
||||
release(dictationId?: string): Promise<void> {
|
||||
const targetTag = dictationId ? this.createTag(dictationId) : null
|
||||
return enqueueKeepAwakeOperation(async () => {
|
||||
try {
|
||||
const tag = this.acquiredTag
|
||||
if (!tag || (targetTag && tag !== targetTag)) {
|
||||
return
|
||||
}
|
||||
if (!activeTags.has(tag)) {
|
||||
this.acquiredTag = null
|
||||
return
|
||||
}
|
||||
await deactivateTrackedTag(tag)
|
||||
this.acquiredTag = null
|
||||
} finally {
|
||||
// Drain after the owner-local unset so this owner's own timed-out
|
||||
// activation is no longer wanted and gets cleaned here — an acquire
|
||||
// may never happen again this session. Still-wanted tags of other
|
||||
// live dictations are spared by the drain itself.
|
||||
await cleanupPendingTags()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private createTag(dictationId: string): string {
|
||||
return `${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}`
|
||||
}
|
||||
}
|
||||
|
||||
export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeOwner {
|
||||
return new MobileDictationKeepAwakeOwner()
|
||||
}
|
||||
|
||||
// Foreground is the retry point for wake tags whose final deactivation timed
|
||||
// out after a dictation ended — otherwise nothing runs until the next one.
|
||||
export function drainMobileDictationKeepAwakeCleanup(): Promise<void> {
|
||||
return enqueueKeepAwakeOperation(cleanupPendingTags)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' because
|
||||
// Metro cannot resolve Node builtins in a React Native bundle.
|
||||
import { Buffer } from 'buffer'
|
||||
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
export type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error'
|
||||
|
||||
export type UseMobileDictationOptions = {
|
||||
client: RpcClient | null
|
||||
enabled: boolean
|
||||
onTranscript: (text: string) => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export type UseMobileDictationResult = {
|
||||
status: DictationStatus
|
||||
isStarting: boolean
|
||||
isRecording: boolean
|
||||
isProcessing: boolean
|
||||
error: string | null
|
||||
start: () => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
cancel: () => Promise<void>
|
||||
}
|
||||
|
||||
export const DICTATION_FINISH_TIMEOUT_MS = 75_000
|
||||
|
||||
// Recording start waits at most this long for the best-effort wake tag; a
|
||||
// slow or hung native keep-awake module must not hold the mic in 'starting'.
|
||||
export const MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS = 500
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
export function createMobileDictationId(): string {
|
||||
return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
export function isCurrentMobileDictationStart(
|
||||
currentGeneration: number,
|
||||
generation: number,
|
||||
enabled: boolean,
|
||||
activeId: string | null,
|
||||
dictationId: string
|
||||
): boolean {
|
||||
return currentGeneration === generation && enabled && activeId === dictationId
|
||||
}
|
||||
|
||||
export function isCurrentMobileDictationFinish(
|
||||
currentGeneration: number,
|
||||
generation: number,
|
||||
enabled: boolean,
|
||||
activeId: string | null,
|
||||
finishingId: string | null,
|
||||
dictationId: string
|
||||
): boolean {
|
||||
return (
|
||||
currentGeneration === generation &&
|
||||
enabled &&
|
||||
activeId === dictationId &&
|
||||
finishingId === dictationId
|
||||
)
|
||||
}
|
||||
@@ -2,13 +2,41 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./use-mobile-dictation.ts', import.meta.url), 'utf8')
|
||||
const audioChunkSource = readFileSync(
|
||||
new URL('./mobile-dictation-audio-chunk.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const keepAwakeSource = readFileSync(
|
||||
new URL('./mobile-dictation-keep-awake.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const desktopStartSource = readFileSync(
|
||||
new URL('./mobile-dictation-desktop-start.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const sessionStateSource = readFileSync(
|
||||
new URL('./mobile-dictation-session-state.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const foregroundKeepAwakeSource = readFileSync(
|
||||
new URL('./mobile-dictation-foreground-keep-awake.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function sliceSource(sourceText: string, startPattern: string, endPattern: string): string {
|
||||
const start = sourceText.indexOf(startPattern)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = sourceText.indexOf(endPattern, start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return sourceText.slice(start, end)
|
||||
}
|
||||
|
||||
function sliceBetween(startPattern: string, endPattern: string): string {
|
||||
const start = source.indexOf(startPattern)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = source.indexOf(endPattern, start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
return sliceSource(source, startPattern, endPattern)
|
||||
}
|
||||
|
||||
function sliceDesktopStartBetween(startPattern: string, endPattern: string): string {
|
||||
return sliceSource(desktopStartSource, startPattern, endPattern)
|
||||
}
|
||||
|
||||
describe('useMobileDictation source invariants', () => {
|
||||
@@ -28,9 +56,10 @@ describe('useMobileDictation source invariants', () => {
|
||||
})
|
||||
|
||||
it('reserves pending audio bytes before encoding microphone chunks', () => {
|
||||
const microphoneEffect = sliceBetween(
|
||||
"addExpoTwoWayAudioEventListener('onMicrophoneData'",
|
||||
'return () => sub.remove()'
|
||||
const microphoneEffect = sliceSource(
|
||||
audioChunkSource,
|
||||
'export function enqueueMobileDictationAudioChunk',
|
||||
' queue.pendingChunks.add(sendChunk)'
|
||||
)
|
||||
|
||||
const reserveIndex = microphoneEffect.indexOf('tryReserve(byteLength)')
|
||||
@@ -39,7 +68,19 @@ describe('useMobileDictation source invariants', () => {
|
||||
expect(encodeIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(reserveIndex).toBeLessThan(encodeIndex)
|
||||
expect(microphoneEffect).toContain('MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE')
|
||||
expect(microphoneEffect).toContain('pendingAudioBudgetRef.current.release(byteLength)')
|
||||
expect(microphoneEffect).toContain('queue.pendingAudioBudget.release(byteLength)')
|
||||
expect(source).toContain('enqueueMobileDictationAudioChunk(client, dictationId, event')
|
||||
})
|
||||
|
||||
it('reuses audio chunk queue wiring across microphone events', () => {
|
||||
const queueIndex = source.indexOf('const audioChunkQueue =')
|
||||
const listenerIndex = source.indexOf("addExpoTwoWayAudioEventListener('onMicrophoneData'")
|
||||
|
||||
expect(queueIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(queueIndex).toBeLessThan(listenerIndex)
|
||||
expect(source).toContain(
|
||||
'enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue)'
|
||||
)
|
||||
})
|
||||
|
||||
it('resets pending audio bytes whenever pending chunk tracking is cleared', () => {
|
||||
@@ -48,4 +89,198 @@ describe('useMobileDictation source invariants', () => {
|
||||
|
||||
expect(pendingAudioResets).toHaveLength(pendingChunkClears.length)
|
||||
})
|
||||
|
||||
it('keeps mobile dictation keep-awake ownership beside the hook', () => {
|
||||
expect(source).toMatch(
|
||||
/import \{[^}]*createMobileDictationKeepAwakeOwner[^}]*\} from '\.\/mobile-dictation-keep-awake'/
|
||||
)
|
||||
expect(source).toContain(
|
||||
'const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])'
|
||||
)
|
||||
expect(keepAwakeSource).toContain('activateKeepAwakeAsync')
|
||||
expect(keepAwakeSource).toContain('deactivateKeepAwake')
|
||||
expect(keepAwakeSource).not.toMatch(/\bactivateKeepAwake\s*\(/)
|
||||
})
|
||||
|
||||
it('acquires keep-awake only after desktop start and stale-start guards', () => {
|
||||
const hookStartBody = sliceBetween('const start = useCallback(async () => {', 'const stop =')
|
||||
const startBody = sliceDesktopStartBetween(
|
||||
'export async function startMobileDictationDesktopSession',
|
||||
' return true'
|
||||
)
|
||||
const desktopStartIndex = startBody.indexOf(
|
||||
"client.sendRequest('speech.dictation.start', { dictationId })"
|
||||
)
|
||||
const acquireIndex = startBody.indexOf('.acquire(dictationId)')
|
||||
const desktopSessionIndex = hookStartBody.indexOf('await startMobileDictationDesktopSession')
|
||||
const toggleRecordingIndex = hookStartBody.indexOf('toggleRecording(true)')
|
||||
|
||||
expect(desktopStartIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(acquireIndex).toBeGreaterThan(desktopStartIndex)
|
||||
expect(desktopSessionIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(toggleRecordingIndex).toBeGreaterThan(desktopSessionIndex)
|
||||
expect(hookStartBody).toContain('commitRecordingStart: () => {')
|
||||
expect(startBody).toContain('options.commitRecordingStart()')
|
||||
|
||||
const beforeAcquire = startBody.slice(desktopStartIndex, acquireIndex)
|
||||
expect(beforeAcquire).toContain('isCurrentStart(options)')
|
||||
expect(desktopStartSource).toContain('options.getCurrentGeneration()')
|
||||
expect(desktopStartSource).toContain('options.getEnabled()')
|
||||
expect(desktopStartSource).toContain('options.getActiveId()')
|
||||
expect(sessionStateSource).toContain(
|
||||
'currentGeneration === generation && enabled && activeId === dictationId'
|
||||
)
|
||||
})
|
||||
|
||||
it('re-checks stale-start guards after awaited keep-awake acquisition', () => {
|
||||
const startBody = sliceDesktopStartBetween(
|
||||
'export async function startMobileDictationDesktopSession',
|
||||
' return true'
|
||||
)
|
||||
const acquireIndex = startBody.indexOf('.acquire(dictationId)')
|
||||
const returnStartedIndex = startBody.indexOf('return true')
|
||||
const afterAcquire = startBody.slice(acquireIndex, returnStartedIndex)
|
||||
|
||||
expect(afterAcquire).toContain('isCurrentStart(options)')
|
||||
expect(desktopStartSource).toContain('options.getCurrentGeneration()')
|
||||
expect(desktopStartSource).toContain('options.getEnabled()')
|
||||
expect(desktopStartSource).toContain('options.getActiveId()')
|
||||
expect(afterAcquire).toContain('await cancelStaleStart(options, { releaseKeepAwake: true })')
|
||||
|
||||
// The stale-start cleanup must release the wake tag and cancel the desktop
|
||||
// session (concurrently, so a hung acquisition can't delay the cancel).
|
||||
const cancelStaleStartBody = sliceDesktopStartBetween(
|
||||
'async function cancelStaleStart',
|
||||
'export async function startMobileDictationDesktopSession'
|
||||
)
|
||||
expect(cancelStaleStartBody).toContain(
|
||||
"client.sendRequest('speech.dictation.cancel', { dictationId })"
|
||||
)
|
||||
expect(cancelStaleStartBody).toContain('cleanups.push(keepAwakeOwner.release(dictationId))')
|
||||
expect(cancelStaleStartBody).toContain('await Promise.allSettled(cleanups)')
|
||||
})
|
||||
|
||||
it('treats keep-awake acquisition as best-effort for the desktop session', () => {
|
||||
const startBody = sliceDesktopStartBetween(
|
||||
'export async function startMobileDictationDesktopSession',
|
||||
' return true'
|
||||
)
|
||||
const acquireIndex = startBody.indexOf('.acquire(dictationId)')
|
||||
expect(acquireIndex).toBeGreaterThanOrEqual(0)
|
||||
const staleCheckIndex = startBody.indexOf('isCurrentStart(options)', acquireIndex)
|
||||
expect(staleCheckIndex).toBeGreaterThan(acquireIndex)
|
||||
|
||||
// An acquisition failure must not cancel the dictation or surface an error.
|
||||
const acquireChain = startBody.slice(acquireIndex, staleCheckIndex)
|
||||
expect(acquireChain).toContain('.catch(')
|
||||
expect(acquireChain).not.toContain('throw')
|
||||
})
|
||||
|
||||
it('releases keep-awake on all dictation cleanup paths without delaying recording shutdown', () => {
|
||||
const closeAudio = sliceBetween(
|
||||
'const closeDictationAudio = useCallback(',
|
||||
'const failActiveDictation ='
|
||||
)
|
||||
expect(closeAudio.indexOf('toggleRecording(false)')).toBeLessThan(
|
||||
closeAudio.indexOf('void keepAwakeOwner.release')
|
||||
)
|
||||
expect(closeAudio).toContain('.catch(() => undefined)')
|
||||
|
||||
const cleanupSlices = [
|
||||
sliceBetween('const failActiveDictation = useCallback(', 'useEffect(() => {'),
|
||||
sliceBetween('const cancel = useCallback(async () => {', 'useEffect(() => {\n const sub'),
|
||||
sliceBetween('return () => {\n const dictationId = activeIdRef.current', ' return {')
|
||||
]
|
||||
|
||||
for (const cleanupSlice of cleanupSlices) {
|
||||
expect(cleanupSlice).toContain('closeDictationAudio(dictationId)')
|
||||
}
|
||||
|
||||
const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =')
|
||||
expect(stopBody.indexOf('toggleRecording(false)')).toBeLessThan(
|
||||
stopBody.indexOf('await Promise.allSettled')
|
||||
)
|
||||
// The wake tag must be held through chunk drain and the finish RPC so a
|
||||
// screen lock cannot suspend the app before the transcript arrives.
|
||||
expect(stopBody.indexOf('speech.dictation.finish')).toBeLessThan(
|
||||
stopBody.indexOf('void keepAwakeOwner.release')
|
||||
)
|
||||
expect(stopBody.indexOf('} finally {')).toBeLessThan(
|
||||
stopBody.indexOf('void keepAwakeOwner.release')
|
||||
)
|
||||
})
|
||||
|
||||
it('reacquires the wake tag when Android returns to the foreground mid-dictation', () => {
|
||||
expect(source).toContain('useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef)')
|
||||
expect(foregroundKeepAwakeSource).toContain("Platform.OS !== 'android'")
|
||||
expect(foregroundKeepAwakeSource).toContain('keepAwakeOwner.reacquire(dictationId)')
|
||||
// A transiently failing refresh retries while the dictation is live.
|
||||
expect(foregroundKeepAwakeSource).toContain('REACQUIRE_RETRY_DELAYS_MS[attempt]')
|
||||
expect(foregroundKeepAwakeSource).toContain('activeIdRef.current === dictationId')
|
||||
// Stale-tag retries survive hook unmount via a module-level listener.
|
||||
expect(foregroundKeepAwakeSource).toContain('installGlobalStaleTagForegroundDrain()')
|
||||
expect(foregroundKeepAwakeSource).toContain('drainMobileDictationKeepAwakeCleanup()')
|
||||
|
||||
// Native activate skips re-applying the window flag while any tag remains,
|
||||
// so reacquire must deactivate before activating.
|
||||
const reacquireBody = sliceSource(
|
||||
keepAwakeSource,
|
||||
'reacquire(dictationId: string)',
|
||||
'release(dictationId?: string)'
|
||||
)
|
||||
expect(reacquireBody.indexOf('await activateTrackedTag(tag,')).toBeGreaterThanOrEqual(0)
|
||||
expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeGreaterThanOrEqual(0)
|
||||
expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeLessThan(
|
||||
reacquireBody.indexOf('await activateTrackedTag(tag,')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps cleanup going when native recording shutdown throws', () => {
|
||||
const closeAudio = sliceBetween(
|
||||
'const closeDictationAudio = useCallback(',
|
||||
'const failActiveDictation ='
|
||||
)
|
||||
const toggleIndex = closeAudio.indexOf('toggleRecording(false)')
|
||||
const catchIndex = closeAudio.indexOf('} catch', toggleIndex)
|
||||
const releaseIndex = closeAudio.indexOf('void keepAwakeOwner.release')
|
||||
expect(toggleIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(catchIndex).toBeGreaterThan(toggleIndex)
|
||||
expect(catchIndex).toBeLessThan(releaseIndex)
|
||||
|
||||
// stop()'s recording shutdown sits inside the try so a native throw still
|
||||
// runs the finally release and error cleanup.
|
||||
const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =')
|
||||
expect(stopBody.indexOf('try {')).toBeGreaterThanOrEqual(0)
|
||||
expect(stopBody.indexOf('try {')).toBeLessThan(stopBody.indexOf('toggleRecording(false)'))
|
||||
})
|
||||
|
||||
it('routes disabled state and audio interruptions through cancel cleanup', () => {
|
||||
const interruptionEffect = sliceBetween(
|
||||
"addExpoTwoWayAudioEventListener('onAudioInterruption'",
|
||||
'return () => sub.remove()'
|
||||
)
|
||||
const disabledEffect = sliceBetween(
|
||||
'useEffect(() => {\n if (!enabled) {',
|
||||
' }, [cancel, enabled])'
|
||||
)
|
||||
|
||||
expect(interruptionEffect).toContain("event.data === 'began' || event.data === 'blocked'")
|
||||
expect(interruptionEffect).toContain('void cancel()')
|
||||
expect(disabledEffect).toContain('void cancel()')
|
||||
})
|
||||
|
||||
it('uses per-owner dictation keep-awake tags and serializes async ownership changes', () => {
|
||||
expect(keepAwakeSource).toContain('private readonly ownerId = createOwnerId()')
|
||||
expect(keepAwakeSource).toContain(
|
||||
'`${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}`'
|
||||
)
|
||||
expect(keepAwakeSource).toContain('let keepAwakeOperation: Promise<void> = Promise.resolve()')
|
||||
expect(keepAwakeSource).toContain('const pendingCleanupTags = new Set<string>()')
|
||||
expect(keepAwakeSource.match(/enqueueKeepAwakeOperation/g)?.length).toBeGreaterThanOrEqual(3)
|
||||
expect(keepAwakeSource).toContain(
|
||||
'const targetTag = dictationId ? this.createTag(dictationId) : null'
|
||||
)
|
||||
expect(keepAwakeSource).toContain('if (!tag || (targetTag && tag !== targetTag))')
|
||||
expect(keepAwakeSource).toContain('await cleanupPendingTags()')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
|
||||
// can't resolve Node's builtin in a React Native bundle.
|
||||
import { Buffer } from 'buffer'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
addExpoTwoWayAudioEventListener,
|
||||
initialize,
|
||||
@@ -9,45 +6,27 @@ import {
|
||||
tearDown,
|
||||
toggleRecording
|
||||
} from '@orca/expo-two-way-audio'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget'
|
||||
import { enqueueMobileDictationAudioChunk } from './mobile-dictation-audio-chunk'
|
||||
import { createMobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake'
|
||||
import { useMobileDictationForegroundKeepAwake } from './mobile-dictation-foreground-keep-awake'
|
||||
import {
|
||||
MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE,
|
||||
MOBILE_DICTATION_PCM_SAMPLE_RATE,
|
||||
MobileDictationPendingAudioBudget
|
||||
} from './mobile-dictation-pending-audio-budget'
|
||||
DICTATION_FINISH_TIMEOUT_MS,
|
||||
createMobileDictationId,
|
||||
isCurrentMobileDictationFinish
|
||||
} from './mobile-dictation-session-state'
|
||||
import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start'
|
||||
import type {
|
||||
DictationStatus,
|
||||
UseMobileDictationOptions,
|
||||
UseMobileDictationResult
|
||||
} from './mobile-dictation-session-state'
|
||||
|
||||
type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error'
|
||||
|
||||
type UseMobileDictationOptions = {
|
||||
client: RpcClient | null
|
||||
enabled: boolean
|
||||
onTranscript: (text: string) => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export type UseMobileDictationResult = {
|
||||
status: DictationStatus
|
||||
isStarting: boolean
|
||||
isRecording: boolean
|
||||
isProcessing: boolean
|
||||
error: string | null
|
||||
start: () => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
cancel: () => Promise<void>
|
||||
}
|
||||
|
||||
const DICTATION_FINISH_TIMEOUT_MS = 75_000
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
function createDictationId(): string {
|
||||
return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
export type { UseMobileDictationResult } from './mobile-dictation-session-state'
|
||||
|
||||
export function useMobileDictation(options: UseMobileDictationOptions): UseMobileDictationResult {
|
||||
const { client, enabled, onTranscript, onError } = options
|
||||
const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])
|
||||
const [status, setStatus] = useState<DictationStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const activeIdRef = useRef<string | null>(null)
|
||||
@@ -77,6 +56,23 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
onErrorRef.current?.(normalized)
|
||||
}, [])
|
||||
|
||||
const closeDictationAudio = useCallback(
|
||||
(dictationId?: string | null) => {
|
||||
acceptingChunksRef.current = false
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
try {
|
||||
toggleRecording(false)
|
||||
} catch (err) {
|
||||
// Cleanup must keep going when native recording shutdown throws, or
|
||||
// the wake tag and dictation state would leak.
|
||||
console.error('Failed to stop microphone recording', err)
|
||||
}
|
||||
void keepAwakeOwner.release(dictationId ?? undefined).catch(() => undefined)
|
||||
},
|
||||
[keepAwakeOwner]
|
||||
)
|
||||
|
||||
const failActiveDictation = useCallback(
|
||||
(dictationId: string, err: unknown) => {
|
||||
const client = clientRef.current
|
||||
@@ -84,51 +80,32 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
return
|
||||
}
|
||||
activeIdRef.current = null
|
||||
acceptingChunksRef.current = false
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
toggleRecording(false)
|
||||
closeDictationAudio(dictationId)
|
||||
if (client && dictationId) {
|
||||
void client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
|
||||
}
|
||||
reportError(err)
|
||||
},
|
||||
[reportError]
|
||||
[closeDictationAudio, reportError]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Microphone events are a hot path; reuse this wiring instead of allocating
|
||||
// a queue object and release predicate for every audio chunk.
|
||||
const audioChunkQueue = {
|
||||
pendingChunks: pendingChunksRef.current,
|
||||
pendingAudioBudget: pendingAudioBudgetRef.current,
|
||||
shouldReleaseBudget: (id: string) =>
|
||||
activeIdRef.current === id || finishingIdRef.current === id,
|
||||
failActiveDictation
|
||||
}
|
||||
const sub = addExpoTwoWayAudioEventListener('onMicrophoneData', (event) => {
|
||||
const client = clientRef.current
|
||||
const dictationId = activeIdRef.current
|
||||
if (!client || !dictationId || !enabledRef.current || !acceptingChunksRef.current) {
|
||||
return
|
||||
}
|
||||
const raw = event.data
|
||||
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
||||
const byteLength = bytes.byteLength
|
||||
if (!pendingAudioBudgetRef.current.tryReserve(byteLength)) {
|
||||
failActiveDictation(dictationId, new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE))
|
||||
return
|
||||
}
|
||||
const sendChunk = client
|
||||
.sendRequest('speech.dictation.chunk', {
|
||||
dictationId,
|
||||
audioBase64: bytesToBase64(bytes),
|
||||
sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
})
|
||||
.catch((err) => failActiveDictation(dictationId, err))
|
||||
.finally(() => {
|
||||
if (activeIdRef.current === dictationId || finishingIdRef.current === dictationId) {
|
||||
pendingAudioBudgetRef.current.release(byteLength)
|
||||
}
|
||||
pendingChunksRef.current.delete(sendChunk)
|
||||
})
|
||||
pendingChunksRef.current.add(sendChunk)
|
||||
enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue)
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [failActiveDictation, reportError])
|
||||
@@ -168,40 +145,41 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
throw new Error('Failed to initialize microphone')
|
||||
}
|
||||
|
||||
const dictationId = createDictationId()
|
||||
const dictationId = createMobileDictationId()
|
||||
activeIdRef.current = dictationId
|
||||
try {
|
||||
const response = await client.sendRequest('speech.dictation.start', { dictationId })
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
} catch (err) {
|
||||
if (activeIdRef.current === dictationId) {
|
||||
activeIdRef.current = null
|
||||
}
|
||||
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
|
||||
setStatus('idle')
|
||||
throw err
|
||||
}
|
||||
if (
|
||||
generationRef.current !== generation ||
|
||||
!enabledRef.current ||
|
||||
activeIdRef.current !== dictationId
|
||||
) {
|
||||
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
|
||||
if (activeIdRef.current === dictationId) {
|
||||
activeIdRef.current = null
|
||||
}
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
acceptingChunksRef.current = true
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
toggleRecording(true)
|
||||
setStatus('recording')
|
||||
}, [])
|
||||
await startMobileDictationDesktopSession({
|
||||
client,
|
||||
dictationId,
|
||||
generation,
|
||||
getCurrentGeneration: () => generationRef.current,
|
||||
getEnabled: () => enabledRef.current,
|
||||
getActiveId: () => activeIdRef.current,
|
||||
clearActiveId: (id) => {
|
||||
if (activeIdRef.current === id) {
|
||||
activeIdRef.current = null
|
||||
}
|
||||
},
|
||||
setIdle: () => setStatus('idle'),
|
||||
keepAwakeOwner,
|
||||
commitRecordingStart: () => {
|
||||
acceptingChunksRef.current = true
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
if (!toggleRecording(true)) {
|
||||
return false
|
||||
}
|
||||
setStatus('recording')
|
||||
return true
|
||||
},
|
||||
rollbackRecordingStart: () => {
|
||||
acceptingChunksRef.current = false
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
toggleRecording(false)
|
||||
}
|
||||
})
|
||||
}, [keepAwakeOwner])
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
const client = clientRef.current
|
||||
@@ -215,14 +193,20 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
finishingIdRef.current = dictationId
|
||||
setStatus('processing')
|
||||
acceptingChunksRef.current = false
|
||||
toggleRecording(false)
|
||||
try {
|
||||
// Inside the try so a throwing native shutdown still runs the finally
|
||||
// release and error cleanup.
|
||||
toggleRecording(false)
|
||||
await Promise.allSettled(Array.from(pendingChunksRef.current))
|
||||
if (
|
||||
generationRef.current !== generation ||
|
||||
activeIdRef.current !== dictationId ||
|
||||
finishingIdRef.current !== dictationId ||
|
||||
!enabledRef.current
|
||||
!isCurrentMobileDictationFinish(
|
||||
generationRef.current,
|
||||
generation,
|
||||
enabledRef.current,
|
||||
activeIdRef.current,
|
||||
finishingIdRef.current,
|
||||
dictationId
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -235,10 +219,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
if (
|
||||
generationRef.current !== generation ||
|
||||
activeIdRef.current !== dictationId ||
|
||||
finishingIdRef.current !== dictationId ||
|
||||
!enabledRef.current
|
||||
!isCurrentMobileDictationFinish(
|
||||
generationRef.current,
|
||||
generation,
|
||||
enabledRef.current,
|
||||
activeIdRef.current,
|
||||
finishingIdRef.current,
|
||||
dictationId
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -257,11 +245,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
} catch (err) {
|
||||
failActiveDictation(dictationId, err)
|
||||
} finally {
|
||||
// Hold the wake tag through chunk drain and the finish RPC: a screen
|
||||
// lock mid-processing suspends the app and loses the transcript.
|
||||
void keepAwakeOwner.release(dictationId).catch(() => undefined)
|
||||
if (finishingIdRef.current === dictationId) {
|
||||
finishingIdRef.current = null
|
||||
}
|
||||
}
|
||||
}, [failActiveDictation])
|
||||
}, [failActiveDictation, keepAwakeOwner])
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
const client = clientRef.current
|
||||
@@ -269,16 +260,15 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
generationRef.current += 1
|
||||
activeIdRef.current = null
|
||||
finishingIdRef.current = null
|
||||
acceptingChunksRef.current = false
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
toggleRecording(false)
|
||||
closeDictationAudio(dictationId)
|
||||
if (client && dictationId) {
|
||||
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
|
||||
}
|
||||
setStatus('idle')
|
||||
setError(null)
|
||||
}, [])
|
||||
}, [closeDictationAudio])
|
||||
|
||||
useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef)
|
||||
|
||||
useEffect(() => {
|
||||
const sub = addExpoTwoWayAudioEventListener('onAudioInterruption', (event) => {
|
||||
@@ -301,10 +291,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
generationRef.current += 1
|
||||
activeIdRef.current = null
|
||||
finishingIdRef.current = null
|
||||
acceptingChunksRef.current = false
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
toggleRecording(false)
|
||||
closeDictationAudio(dictationId)
|
||||
void tearDown()
|
||||
if (clientRef.current && dictationId) {
|
||||
void clientRef.current
|
||||
@@ -312,7 +299,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [closeDictationAudio])
|
||||
|
||||
return {
|
||||
status,
|
||||
|
||||
@@ -23,6 +23,7 @@ type TerminalPaneViewProps = {
|
||||
onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void
|
||||
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
|
||||
onTerminalInput: (handle: string, bytes: string) => void
|
||||
onTerminalQueryReply: (handle: string, bytes: string) => void
|
||||
onTerminalTap: (handle: string) => void
|
||||
onFileTap: (handle: string, pathText: string, line: number | null, column: number | null) => void
|
||||
onOpenUrl: (handle: string, url: string) => void
|
||||
@@ -44,6 +45,7 @@ export function TerminalPaneView({
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalQueryReply,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
@@ -80,6 +82,7 @@ export function TerminalPaneView({
|
||||
onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
|
||||
onHaptic={onHaptic}
|
||||
onTerminalInput={(bytes) => onTerminalInput(handle, bytes)}
|
||||
onTerminalQueryReply={(bytes) => onTerminalQueryReply(handle, bytes)}
|
||||
onTerminalTap={() => onTerminalTap(handle)}
|
||||
onFileTap={(pathText, line, column) => onFileTap(handle, pathText, line, column)}
|
||||
onOpenUrl={(url) => onOpenUrl(handle, url)}
|
||||
|
||||
@@ -76,7 +76,9 @@ describe('buildMobileAiVaultResumeCommand', () => {
|
||||
).toBe("cd '/Users/ada/repo' && omp --resume '/Users/ada/.omp/agent/sessions/repo/sess.jsonl'")
|
||||
})
|
||||
|
||||
it('uses cmd wrapping when the host Windows terminal is configured as cmd', () => {
|
||||
it('uses direct cmd syntax when the host Windows terminal is already cmd', () => {
|
||||
// Why: resume text is typed into the live host tab. Nested `cmd /d /s /c`
|
||||
// wrappers break interactive cmd quoting (see buildAiVaultResumeShellCommand).
|
||||
const command = buildMobileAiVaultResumeCommand({
|
||||
session: session({
|
||||
agent: 'codex',
|
||||
@@ -87,10 +89,9 @@ describe('buildMobileAiVaultResumeCommand', () => {
|
||||
hostPlatform: 'win32',
|
||||
hostTerminalWindowsShell: 'cmd.exe'
|
||||
})
|
||||
expect(command).toContain('cmd /d /s /c')
|
||||
expect(command).toContain('cd /d ""C:\\repo app""')
|
||||
expect(command).toContain('set ""CODEX_HOME=C:\\Users\\Ada\\.codex""')
|
||||
expect(command).toContain('codex resume ""codex-1""')
|
||||
expect(command).toBe(
|
||||
'cd /d "C:\\repo app" && set "CODEX_HOME=C:\\Users\\Ada\\.codex" && codex resume "codex-1"'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses POSIX command construction and converts Codex home for WSL targets', () => {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { useLiveWorktreeName } from './use-live-worktree-name'
|
||||
|
||||
vi.mock('expo-router', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
useFocusEffect(effect: () => void | (() => void)): void {
|
||||
React.useEffect(effect, [effect])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
describe('useLiveWorktreeName request volume', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let eventListener: ((payload: unknown) => void) | null = null
|
||||
const unsubscribeStream = vi.fn()
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
id: 'worktree-show',
|
||||
ok: true,
|
||||
result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
const subscribe = vi.fn(
|
||||
(_method: string, _params: unknown, listener: (payload: unknown) => void) => {
|
||||
eventListener = listener
|
||||
return unsubscribeStream
|
||||
}
|
||||
)
|
||||
const client = { sendRequest, subscribe } as unknown as RpcClient
|
||||
|
||||
async function mountHarness(): Promise<void> {
|
||||
function Harness(): null {
|
||||
useLiveWorktreeName({
|
||||
client,
|
||||
connState: 'connected',
|
||||
routeName: 'Route name',
|
||||
worktreeId: 'repo-1::/worktree'
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
}
|
||||
|
||||
async function emitEvent(payload: unknown): Promise<void> {
|
||||
await act(async () => {
|
||||
eventListener?.(payload)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
eventListener = null
|
||||
sendRequest.mockClear().mockResolvedValue({
|
||||
id: 'worktree-show',
|
||||
ok: true,
|
||||
result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
subscribe.mockClear()
|
||||
unsubscribeStream.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cuts thirty-second idle request volume from eleven calls to one', async () => {
|
||||
await mountHarness()
|
||||
expect(subscribe).toHaveBeenCalledWith(
|
||||
'runtime.clientEvents.subscribe',
|
||||
null,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
|
||||
await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
// The retired 3s interval made one initial request plus ten idle ticks here.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
|
||||
await emitEvent({ type: 'worktreesChanged', repoId: 'repo-2' })
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
|
||||
await emitEvent({ type: 'worktreesChanged', repoId: 'repo-1' })
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps polling when an older runtime rejects the event stream', async () => {
|
||||
await mountHarness()
|
||||
await emitEvent({ type: 'error', message: 'Unknown method' })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('retries a failed initial read even after the event stream is ready', async () => {
|
||||
sendRequest.mockRejectedValueOnce(new Error('transient')).mockResolvedValue({
|
||||
id: 'worktree-show',
|
||||
ok: true,
|
||||
result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
await mountHarness()
|
||||
await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('refreshes once when a reconnected stream becomes ready again', async () => {
|
||||
await mountHarness()
|
||||
await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' })
|
||||
await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-2' })
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('unsubscribes when the screen loses focus', async () => {
|
||||
await mountHarness()
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
expect(unsubscribeStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events'
|
||||
import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState, RpcSuccess } from '../transport/types'
|
||||
import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name'
|
||||
|
||||
const WORKTREE_NAME_FALLBACK_POLL_MS = 3000
|
||||
|
||||
type Params = {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
@@ -24,12 +28,27 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId }
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
const refreshWorktreeName = async () => {
|
||||
let eventStreamReady = false
|
||||
let hasSuccessfulRefresh = false
|
||||
let fallbackInterval: ReturnType<typeof setInterval> | null = null
|
||||
let refreshGeneration = 0
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
|
||||
const stopFallbackPoll = (): void => {
|
||||
if (fallbackInterval !== null) {
|
||||
clearInterval(fallbackInterval)
|
||||
fallbackInterval = null
|
||||
}
|
||||
}
|
||||
const refreshWorktreeName = async (): Promise<void> => {
|
||||
// Why: an event-driven refresh can overtake a slow fallback request;
|
||||
// only the newest read may publish or stop the retry poll.
|
||||
const generation = ++refreshGeneration
|
||||
try {
|
||||
const response = await client.sendRequest('worktree.show', {
|
||||
worktree: `id:${worktreeId}`
|
||||
})
|
||||
if (stale || !response.ok) {
|
||||
if (stale || generation !== refreshGeneration || !response.ok) {
|
||||
return
|
||||
}
|
||||
const result = (response as RpcSuccess).result as {
|
||||
@@ -41,17 +60,73 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId }
|
||||
if (liveName) {
|
||||
setWorktreeName((current) => (current === liveName ? current : liveName))
|
||||
}
|
||||
hasSuccessfulRefresh = true
|
||||
if (eventStreamReady) {
|
||||
stopFallbackPoll()
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: the route param remains a usable label until the next refresh.
|
||||
}
|
||||
}
|
||||
|
||||
const startFallbackPoll = (): void => {
|
||||
if (stale || fallbackInterval !== null) {
|
||||
return
|
||||
}
|
||||
fallbackInterval = setInterval(
|
||||
() => void refreshWorktreeName(),
|
||||
WORKTREE_NAME_FALLBACK_POLL_MS
|
||||
)
|
||||
}
|
||||
const invalidateAndRefresh = (): void => {
|
||||
hasSuccessfulRefresh = false
|
||||
startFallbackPoll()
|
||||
void refreshWorktreeName()
|
||||
}
|
||||
|
||||
startFallbackPoll()
|
||||
const unsubscribe = client.subscribe(
|
||||
'runtime.clientEvents.subscribe',
|
||||
null,
|
||||
(payload: unknown) => {
|
||||
if (stale || !payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const event = payload as RuntimeClientEventStreamMessage | { type: 'error' }
|
||||
if (event.type === 'ready') {
|
||||
const replayedAfterReconnect = eventStreamReady
|
||||
eventStreamReady = true
|
||||
if (hasSuccessfulRefresh) {
|
||||
stopFallbackPoll()
|
||||
}
|
||||
if (replayedAfterReconnect) {
|
||||
// Why: client events are not queued while disconnected, so replay
|
||||
// readiness must re-read the title once to close that event gap.
|
||||
invalidateAndRefresh()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'end' || event.type === 'error') {
|
||||
eventStreamReady = false
|
||||
startFallbackPoll()
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.type === 'reposChanged' ||
|
||||
(event.type === 'worktreesChanged' && event.repoId === repoId)
|
||||
) {
|
||||
invalidateAndRefresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
// Why: route params are only an entry hint. The desktop/runtime owns
|
||||
// displayName, including task-generated names that may settle after open.
|
||||
// displayName. Modern runtimes push invalidations; the poll remains only
|
||||
// until that stream proves available, preserving older-runtime behavior.
|
||||
void refreshWorktreeName()
|
||||
const interval = setInterval(() => void refreshWorktreeName(), 3000)
|
||||
return () => {
|
||||
stale = true
|
||||
clearInterval(interval)
|
||||
stopFallbackPoll()
|
||||
unsubscribe()
|
||||
}
|
||||
}, [client, connState, worktreeId])
|
||||
)
|
||||
|
||||
@@ -55,23 +55,28 @@ export function MobileSourceControlBranchCard({
|
||||
<Text style={styles.countText}>{unstagedCount} changed</Text>
|
||||
<Text style={styles.countText}>{stagedCount} staged</Text>
|
||||
{branchCount > 0 ? <Text style={styles.countText}>{branchCount} on branch</Text> : null}
|
||||
{showConflict ? (
|
||||
<View style={styles.conflictRow}>
|
||||
<Text style={styles.conflictText}>{conflictOperation}</Text>
|
||||
{conflictOperation === 'merge' || conflictOperation === 'rebase' ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.abortButton, pressed && styles.abortPressed]}
|
||||
disabled={conflictBusy}
|
||||
onPress={() => onAbortConflict(conflictOperation)}
|
||||
>
|
||||
<Text style={styles.abortText}>
|
||||
{mobileConflictAbortLabel(conflictOperation, conflictAborting)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{/* Own row so Abort never overflows past the card when counts are long. */}
|
||||
{showConflict ? (
|
||||
<View style={styles.conflictRow}>
|
||||
<Text style={styles.conflictText}>{conflictOperation}</Text>
|
||||
{conflictOperation === 'merge' || conflictOperation === 'rebase' ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.abortButton,
|
||||
conflictBusy && styles.abortButtonDisabled,
|
||||
pressed && !conflictBusy && styles.abortPressed
|
||||
]}
|
||||
disabled={conflictBusy}
|
||||
onPress={() => onAbortConflict(conflictOperation)}
|
||||
>
|
||||
<Text style={styles.abortText}>
|
||||
{mobileConflictAbortLabel(conflictOperation, conflictAborting)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{prChip ? <MobileSourceControlPrChip summary={prChip} onPress={onOpenPr} /> : null}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { ActivityIndicator, Pressable, SectionList, Text, TextInput, View } from 'react-native'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
SectionList,
|
||||
Text,
|
||||
TextInput,
|
||||
View
|
||||
} from 'react-native'
|
||||
import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry'
|
||||
@@ -13,7 +21,8 @@ type Props = {
|
||||
state: MobileSourceControlState
|
||||
}
|
||||
|
||||
// The ready-state Changes segment body: quick actions, changed-files list, and commit bar.
|
||||
// Changes tab: local file changes only — uncommitted (staged/unstaged) plus
|
||||
// committed-on-branch vs base. PR conflicts and push status live elsewhere.
|
||||
export function MobileSourceControlContent({ state }: Props) {
|
||||
const {
|
||||
insets,
|
||||
@@ -49,6 +58,21 @@ export function MobileSourceControlContent({ state }: Props) {
|
||||
const shouldShowGenerateButton = stagedCount > 0 || generatingMessage
|
||||
const createPrHeroActive =
|
||||
createPrAction.visible && !createPrAction.disabled && !createPrAction.pushFirst
|
||||
const branchCompareFooter = (
|
||||
<BranchCompareFooter
|
||||
state={{
|
||||
shouldShowBranchCompareSection: state.shouldShowBranchCompareSection,
|
||||
branchCompareSummaryText: state.branchCompareSummaryText,
|
||||
branchEntries: state.branchEntries,
|
||||
branchCompareState: state.branchCompareState,
|
||||
branchCompareResult: state.branchCompareResult,
|
||||
busyAction,
|
||||
openBranchDiff,
|
||||
openingBranchPath,
|
||||
openingPath
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -126,9 +150,15 @@ export function MobileSourceControlContent({ state }: Props) {
|
||||
|
||||
{!hasVisibleChanges ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateTitle}>No Changes</Text>
|
||||
<Text style={styles.stateTitle}>No local changes</Text>
|
||||
<Text style={styles.stateText}>Working tree is clean.</Text>
|
||||
</View>
|
||||
) : sections.length === 0 ? (
|
||||
// Why: RN SectionList with empty `sections` often skips ListFooterComponent,
|
||||
// which hid "Committed on Branch" when only branch files remain.
|
||||
<ScrollView style={hubStyles.tabBody} contentContainerStyle={styles.listContent}>
|
||||
{branchCompareFooter}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<SectionList
|
||||
style={hubStyles.tabBody}
|
||||
@@ -148,21 +178,7 @@ export function MobileSourceControlContent({ state }: Props) {
|
||||
<Text style={styles.sectionCount}>{section.data.length}</Text>
|
||||
</View>
|
||||
)}
|
||||
ListFooterComponent={
|
||||
<BranchCompareFooter
|
||||
state={{
|
||||
shouldShowBranchCompareSection: state.shouldShowBranchCompareSection,
|
||||
branchCompareSummaryText: state.branchCompareSummaryText,
|
||||
branchEntries: state.branchEntries,
|
||||
branchCompareState: state.branchCompareState,
|
||||
branchCompareResult: state.branchCompareResult,
|
||||
busyAction,
|
||||
openBranchDiff,
|
||||
openingBranchPath,
|
||||
openingPath
|
||||
}}
|
||||
/>
|
||||
}
|
||||
ListFooterComponent={branchCompareFooter}
|
||||
stickySectionHeadersEnabled={false}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
|
||||
@@ -92,6 +92,7 @@ const baseStyles = StyleSheet.create({
|
||||
},
|
||||
countRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
@@ -99,29 +100,43 @@ const baseStyles = StyleSheet.create({
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
// Separate line under counts — keeps Abort inside the card on narrow phones.
|
||||
conflictRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.sm,
|
||||
alignSelf: 'flex-start',
|
||||
maxWidth: '100%'
|
||||
},
|
||||
conflictText: {
|
||||
color: colors.statusAmber,
|
||||
fontSize: typography.metaSize,
|
||||
textTransform: 'capitalize'
|
||||
},
|
||||
// Match bulk-action hit target so Abort reads as a real control, not a chip.
|
||||
abortButton: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
minHeight: 32,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radii.button,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.statusAmber
|
||||
borderColor: colors.statusAmber,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
},
|
||||
abortPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
opacity: 0.75
|
||||
},
|
||||
abortButtonDisabled: {
|
||||
opacity: 0.45
|
||||
},
|
||||
abortText: {
|
||||
color: colors.statusAmber,
|
||||
fontSize: typography.metaSize,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
textTransform: 'capitalize'
|
||||
},
|
||||
|
||||
@@ -104,8 +104,18 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr
|
||||
return false
|
||||
}
|
||||
if (!baseRef) {
|
||||
setBranchCompareState({ kind: 'idle' })
|
||||
return true
|
||||
// Why: wiping a prior ready compare to idle makes Changes say "No
|
||||
// Changes" even when commits still exist (e.g. after abort-merge refresh).
|
||||
setBranchCompareState((prev) => {
|
||||
if (options?.preserveReadyOnFailure && prev.kind === 'ready') {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
kind: 'error',
|
||||
message: 'Unable to resolve the base branch for comparison.'
|
||||
}
|
||||
})
|
||||
return false
|
||||
}
|
||||
const response = await client.sendRequest('git.branchCompare', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
@@ -116,7 +126,12 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (isMobileGitUnavailable(response.error?.code, response.error?.message)) {
|
||||
setBranchCompareState({ kind: 'idle' })
|
||||
setBranchCompareState((prev) => {
|
||||
if (options?.preserveReadyOnFailure && prev.kind === 'ready') {
|
||||
return prev
|
||||
}
|
||||
return { kind: 'idle' }
|
||||
})
|
||||
return false
|
||||
}
|
||||
throw new Error(response.error?.message || 'Unable to load committed changes')
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
canOpenMobileBranchCompareDiff,
|
||||
formatMobileBranchCompareSummary
|
||||
} from './mobile-branch-compare'
|
||||
|
||||
import {
|
||||
buildMobileSourceControlSections,
|
||||
countStagedEntries,
|
||||
@@ -140,6 +141,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
|
||||
})),
|
||||
[branchCompareCanOpen, branchCompareSection]
|
||||
)
|
||||
// Local changes only: dirty files + committed file diffs vs base (not PR/push).
|
||||
const shouldShowBranchCompareSection =
|
||||
branchEntries.length > 0 ||
|
||||
branchCompareState.kind === 'loading' ||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
|
||||
import { Platform, View } from 'react-native'
|
||||
import { WebView } from 'react-native-webview'
|
||||
import type { WebViewMessageEvent } from 'react-native-webview'
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
|
||||
import {
|
||||
@@ -13,6 +12,7 @@ import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
|
||||
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
|
||||
import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing'
|
||||
|
||||
type Props = TerminalWebViewProps
|
||||
|
||||
@@ -32,6 +32,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalQueryReply,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
@@ -115,6 +116,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
routeTerminalQueryReply(msg, onTerminalQueryReply)
|
||||
|
||||
if (msg.type === 'web-ready') {
|
||||
confirmWebReady(true)
|
||||
@@ -228,6 +230,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalQueryReply,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { sendMobileTerminalQueryReply } from './mobile-terminal-query-reply'
|
||||
|
||||
function createClient() {
|
||||
return {
|
||||
sendRequest: vi.fn().mockResolvedValue({
|
||||
id: '1',
|
||||
ok: true,
|
||||
result: { send: { accepted: true, bytesWritten: 6 } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
} as unknown as Pick<RpcClient, 'sendRequest'>
|
||||
}
|
||||
|
||||
describe('sendMobileTerminalQueryReply', () => {
|
||||
it('sends a subscribed terminal reply immediately with additive RPC metadata', async () => {
|
||||
const client = createClient()
|
||||
|
||||
await expect(
|
||||
sendMobileTerminalQueryReply({
|
||||
bytes: '\x1b[3;4R',
|
||||
client,
|
||||
clientId: 'mobile-1',
|
||||
connected: true,
|
||||
handle: 'terminal-1',
|
||||
hostSupportsQueryReplyInput: true,
|
||||
subscribedTerminals: new Set(['terminal-1'])
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
|
||||
terminal: 'terminal-1',
|
||||
text: '\x1b[3;4R',
|
||||
enter: false,
|
||||
inputKind: 'query-reply',
|
||||
client: { id: 'mobile-1', type: 'mobile' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['disconnected', false, new Set(['terminal-1']), '\x1b[3;4R', 'terminal-1'],
|
||||
['unsubscribed', true, new Set(), '\x1b[3;4R', 'terminal-1'],
|
||||
['stale handle', true, new Set(['terminal-2']), '\x1b[3;4R', 'terminal-1'],
|
||||
['ordinary input', true, new Set(['terminal-1']), 'a', 'terminal-1']
|
||||
])('does not send %s data', async (_case, connected, subscribedTerminals, bytes, handle) => {
|
||||
const client = createClient()
|
||||
|
||||
await expect(
|
||||
sendMobileTerminalQueryReply({
|
||||
bytes,
|
||||
client,
|
||||
clientId: 'mobile-1',
|
||||
connected,
|
||||
handle,
|
||||
hostSupportsQueryReplyInput: true,
|
||||
subscribedTerminals
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: hosts without terminal.query-reply-input.v1 strip inputKind (zod drops
|
||||
// unknown keys) and would treat the reply as floor-taking shell input.
|
||||
it('does not send to a host that has not advertised query-reply input support', async () => {
|
||||
const client = createClient()
|
||||
|
||||
await expect(
|
||||
sendMobileTerminalQueryReply({
|
||||
bytes: '\x1b[3;4R',
|
||||
client,
|
||||
clientId: 'mobile-1',
|
||||
connected: true,
|
||||
handle: 'terminal-1',
|
||||
hostSupportsQueryReplyInput: false,
|
||||
subscribedTerminals: new Set(['terminal-1'])
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
|
||||
|
||||
type TerminalSubscriptionRegistry = {
|
||||
has: (handle: string) => boolean
|
||||
}
|
||||
|
||||
type MobileTerminalQueryReplyOptions = {
|
||||
bytes: string
|
||||
client: Pick<RpcClient, 'sendRequest'> | null
|
||||
clientId: string | null
|
||||
connected: boolean
|
||||
handle: string
|
||||
hostSupportsQueryReplyInput: boolean
|
||||
subscribedTerminals: TerminalSubscriptionRegistry
|
||||
}
|
||||
|
||||
export function sendMobileTerminalQueryReply({
|
||||
bytes,
|
||||
client,
|
||||
clientId,
|
||||
connected,
|
||||
handle,
|
||||
hostSupportsQueryReplyInput,
|
||||
subscribedTerminals
|
||||
}: MobileTerminalQueryReplyOptions): Promise<boolean> {
|
||||
// Why: every subscribed mobile xterm suppresses main's responder, including
|
||||
// hidden panes, so ownership follows the subscription rather than focus.
|
||||
// Hosts without terminal.query-reply-input.v1 strip inputKind and would take
|
||||
// reply bytes as floor-stealing shell input, so drop (pre-fix behavior).
|
||||
if (
|
||||
!client ||
|
||||
!connected ||
|
||||
!hostSupportsQueryReplyInput ||
|
||||
!subscribedTerminals.has(handle) ||
|
||||
!isTerminalQueryReply(bytes)
|
||||
) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
return client
|
||||
.sendRequest('terminal.send', {
|
||||
terminal: handle,
|
||||
text: bytes,
|
||||
enter: false,
|
||||
inputKind: 'query-reply',
|
||||
...(clientId ? { client: { id: clientId, type: 'mobile' as const } } : {})
|
||||
})
|
||||
.then(isTerminalSendRpcAccepted, () => false)
|
||||
}
|
||||
@@ -69,7 +69,7 @@ describe('terminal foreground recovery', () => {
|
||||
|
||||
const recovered = recoverActiveTerminalAfterForeground(harness)
|
||||
|
||||
expect(recovered).toBe(true)
|
||||
expect(recovered).toBe('recovered')
|
||||
expect(harness.unsubscribeTerminal).toHaveBeenCalledWith('term-1')
|
||||
expect(harness.initializedHandlesRef.current.has('term-1')).toBe(false)
|
||||
expect(harness.schedule).toHaveBeenCalledWith(
|
||||
@@ -90,7 +90,7 @@ describe('terminal foreground recovery', () => {
|
||||
|
||||
const recovered = recoverActiveTerminalAfterForeground(harness)
|
||||
|
||||
expect(recovered).toBe(true)
|
||||
expect(recovered).toBe('recovered')
|
||||
expect(harness.initializedHandlesRef.current.has('term-1')).toBe(false)
|
||||
expect(harness.initializedHandlesRef.current.has('term-2')).toBe(false)
|
||||
expect(harness.unsubscribeTerminal).toHaveBeenCalledWith('term-1')
|
||||
@@ -102,11 +102,37 @@ describe('terminal foreground recovery', () => {
|
||||
|
||||
const recovered = recoverActiveTerminalAfterForeground(harness)
|
||||
|
||||
expect(recovered).toBe(false)
|
||||
expect(recovered).toBe('skipped')
|
||||
expect(harness.unsubscribeTerminal).not.toHaveBeenCalled()
|
||||
expect(harness.schedule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers without touching state when the socket is not connected yet', () => {
|
||||
const harness = createHarness()
|
||||
harness.connStateRef.current = 'reconnecting'
|
||||
|
||||
const recovered = recoverActiveTerminalAfterForeground(harness)
|
||||
|
||||
expect(recovered).toBe('deferred')
|
||||
// Why this matters: the deferred retry needs the initialized flags intact
|
||||
// so the post-reconnect recovery can still force a scrollback replay.
|
||||
expect(harness.initializedHandlesRef.current.has('term-1')).toBe(true)
|
||||
expect(harness.unsubscribeTerminal).not.toHaveBeenCalled()
|
||||
expect(harness.schedule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replays scrollback when re-run after the deferred reconnect completes', () => {
|
||||
const harness = createHarness()
|
||||
harness.connStateRef.current = 'reconnecting'
|
||||
|
||||
expect(recoverActiveTerminalAfterForeground(harness)).toBe('deferred')
|
||||
|
||||
harness.connStateRef.current = 'connected'
|
||||
expect(recoverActiveTerminalAfterForeground(harness)).toBe('recovered')
|
||||
harness.runScheduled()
|
||||
expect(harness.subscribeToTerminal).toHaveBeenCalledWith('term-1')
|
||||
})
|
||||
|
||||
it('does not replay a stale terminal if focus changes before the delayed subscribe', () => {
|
||||
const harness = createHarness()
|
||||
|
||||
@@ -134,4 +160,17 @@ describe('terminal foreground recovery', () => {
|
||||
expect(readinessInvalidation).toBeGreaterThanOrEqual(0)
|
||||
expect(replay).toBeGreaterThan(readinessInvalidation)
|
||||
})
|
||||
|
||||
it('re-runs a deferred recovery once the session screen reconnects', () => {
|
||||
// Why: resume usually lands mid-reconnect; the session screen must retry
|
||||
// recovery on the connState→connected transition or blanked panes stay
|
||||
// stale until a manual tab switch.
|
||||
expect(sessionSource).toContain("pendingForegroundRecoveryRef.current = outcome === 'deferred'")
|
||||
const reconnectRetry = sliceSessionSource(
|
||||
"if (connState !== 'connected' || !pendingForegroundRecoveryRef.current)",
|
||||
'recoverActiveTerminalAfterForeground({'
|
||||
)
|
||||
expect(reconnectRetry).toContain('pendingForegroundRecoveryRef.current = false')
|
||||
expect(reconnectRetry).toContain("AppState.currentState !== 'active'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,11 @@ import type { TerminalWebViewHandle } from './TerminalWebView'
|
||||
|
||||
export const TERMINAL_FOREGROUND_RECOVERY_DELAY_MS = 120
|
||||
|
||||
// 'deferred' = the socket wasn't connected at the foreground edge (it usually
|
||||
// dies after ~60-80s of background); the caller must re-run recovery once the
|
||||
// connection is back or a blanked WKWebView stays stale until a tab switch.
|
||||
export type TerminalForegroundRecoveryOutcome = 'recovered' | 'deferred' | 'skipped'
|
||||
|
||||
type TerminalForegroundRecoveryOptions = {
|
||||
activeHandleRef: RefObject<string | null>
|
||||
terminalRefs: RefObject<Map<string, TerminalWebViewHandle>>
|
||||
@@ -36,15 +41,15 @@ export function recoverActiveTerminalAfterForeground({
|
||||
subscribeToTerminal,
|
||||
schedule,
|
||||
delayMs = TERMINAL_FOREGROUND_RECOVERY_DELAY_MS
|
||||
}: TerminalForegroundRecoveryOptions): boolean {
|
||||
}: TerminalForegroundRecoveryOptions): TerminalForegroundRecoveryOutcome {
|
||||
if (connStateRef.current !== 'connected') {
|
||||
return false
|
||||
return 'deferred'
|
||||
}
|
||||
const initializedMountedHandles = Array.from(initializedHandlesRef.current).filter((handle) =>
|
||||
terminalRefs.current.has(handle)
|
||||
)
|
||||
if (initializedMountedHandles.length === 0) {
|
||||
return false
|
||||
return 'skipped'
|
||||
}
|
||||
const handle = activeHandleRef.current
|
||||
const shouldRecoverActive =
|
||||
@@ -57,7 +62,7 @@ export function recoverActiveTerminalAfterForeground({
|
||||
}
|
||||
|
||||
if (!shouldRecoverActive || !handle) {
|
||||
return true
|
||||
return 'recovered'
|
||||
}
|
||||
|
||||
unsubscribeTerminal(handle)
|
||||
@@ -70,5 +75,5 @@ export function recoverActiveTerminalAfterForeground({
|
||||
}
|
||||
subscribeToTerminal(handle)
|
||||
}, delayMs)
|
||||
return true
|
||||
return 'recovered'
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export type TerminalSelectionEvents = {
|
||||
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
|
||||
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
|
||||
onTerminalInput?: (bytes: string) => void
|
||||
onTerminalQueryReply?: (bytes: string) => void
|
||||
onTerminalTap?: () => void
|
||||
// Tap landed on a detected file path; RN resolves + opens it.
|
||||
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
|
||||
|
||||
@@ -8,6 +8,7 @@ import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.gen
|
||||
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-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'
|
||||
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
|
||||
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
|
||||
|
||||
@@ -215,7 +216,7 @@ window.onerror = function(msg) {
|
||||
var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g');
|
||||
var statusDotPendingSelector = false;
|
||||
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
|
||||
var term = null;
|
||||
var term = null; ${TERMINAL_QUERY_REPLY_JS}
|
||||
var scrollIndicator = document.getElementById('scroll-indicator');
|
||||
var scrollThumb = document.getElementById('scroll-thumb');
|
||||
var scrollIndicatorHideTimer = null;
|
||||
@@ -599,6 +600,10 @@ ${TERMINAL_WEBVIEW_THEME_JS}
|
||||
writeQueue.push(normalizeStatusDotPresentation(data));
|
||||
}
|
||||
|
||||
function enqueueWriteBoundary(callback) {
|
||||
writeQueue.push(callback);
|
||||
}
|
||||
|
||||
function nextQueuedWrite() {
|
||||
if (writeQueueHead >= writeQueue.length) {
|
||||
resetWriteQueue();
|
||||
@@ -644,6 +649,7 @@ ${TERMINAL_WEBVIEW_THEME_JS}
|
||||
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
|
||||
var next = nextQueuedWrite();
|
||||
if (typeof next !== 'string') {
|
||||
if (typeof next === 'function') return next(), pumpWrites(gen);
|
||||
var callbacks = afterDrainCallbacks;
|
||||
afterDrainCallbacks = [];
|
||||
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
|
||||
@@ -675,6 +681,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1;
|
||||
terminalGeneration++;
|
||||
var gen = terminalGeneration;
|
||||
// Why: snapshot replay can contain old queries whose replies must never
|
||||
// re-enter the live PTY. Each replacement terminal earns authority anew.
|
||||
resetTerminalDataReplyAuthority();
|
||||
cancelWebglContextRecovery();
|
||||
webglAddon = null;
|
||||
ready = false;
|
||||
@@ -731,7 +740,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
fontWeight: '300',
|
||||
fontWeightBold: '500',
|
||||
scrollback: 5000,
|
||||
disableStdin: true,
|
||||
// Why: xterm suppresses parser-generated query replies when disableStdin
|
||||
// is true. Native accepts only validated reply grammars from onData.
|
||||
disableStdin: false,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'bar',
|
||||
cursorInactiveStyle: 'none',
|
||||
@@ -749,6 +760,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
resetEvictionCounter();
|
||||
cancelSelect();
|
||||
attachTermObservers();
|
||||
attachTerminalQueryReplyBridge(term, gen);
|
||||
|
||||
requestAnimationFrame(function() {
|
||||
if (gen !== terminalGeneration) return;
|
||||
@@ -943,7 +955,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
write(msg.data);
|
||||
} else if (msg.type === 'clear') {
|
||||
terminalGeneration++;
|
||||
resetWriteQueue();
|
||||
resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary.
|
||||
statusDotPendingSelector = false;
|
||||
afterDrainCallbacks = [];
|
||||
writesDraining = false;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Kept as one injectable unit so tests execute the same replay/generation gate
|
||||
// that the WebView document runs, rather than a TypeScript reimplementation.
|
||||
export const TERMINAL_QUERY_REPLY_JS = `
|
||||
var terminalDataRepliesEnabled = false;
|
||||
|
||||
function resetTerminalDataReplyAuthority() {
|
||||
terminalDataRepliesEnabled = false;
|
||||
}
|
||||
|
||||
function resumeTerminalDataReplyAuthority() {
|
||||
terminalDataRepliesEnabled = true;
|
||||
}
|
||||
|
||||
function forwardTerminalDataReply(data) {
|
||||
if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data });
|
||||
}
|
||||
|
||||
function enqueueTerminalDataReplyBoundary(gen) {
|
||||
enqueueWriteBoundary(function() {
|
||||
if (gen === terminalGeneration) terminalDataRepliesEnabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
function attachTerminalQueryReplyBridge(term, gen) {
|
||||
// Why: parser replies require stdin enabled, but mobile input is owned by
|
||||
// native controls. Keep xterm's textarea inert for touch/hardware keys.
|
||||
try {
|
||||
term.attachCustomKeyEventHandler(function() { return false; });
|
||||
if (term.textarea) {
|
||||
term.textarea.readOnly = true;
|
||||
term.textarea.tabIndex = -1;
|
||||
term.textarea.setAttribute('inputmode', 'none');
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
termObserverDisposables.push(term.onData(function(data) {
|
||||
forwardTerminalDataReply(data);
|
||||
}));
|
||||
} catch (e) {}
|
||||
// Why: live output can queue before initial replay finishes. Enable replies
|
||||
// at the replay boundary so those live queries are answered, never replayed ones.
|
||||
enqueueTerminalDataReplyBoundary(gen);
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalWebView } from './TerminalWebView'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: { OS: 'ios' },
|
||||
StyleSheet: {
|
||||
absoluteFillObject: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0
|
||||
},
|
||||
create: (styles: unknown) => styles
|
||||
},
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('react-native-webview', async () => {
|
||||
const React = await import('react')
|
||||
const WebView = React.forwardRef((props: Record<string, unknown>, _ref) =>
|
||||
React.createElement('WebView', props)
|
||||
)
|
||||
return { WebView, default: WebView }
|
||||
})
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
RefreshCw: 'RefreshCw'
|
||||
}))
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function postWebViewMessage(payload: Record<string, unknown>): void {
|
||||
if (!renderer) {
|
||||
throw new Error('TerminalWebView did not render')
|
||||
}
|
||||
const webView = renderer.root.findByType('WebView')
|
||||
act(() => {
|
||||
webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } })
|
||||
})
|
||||
}
|
||||
|
||||
describe('TerminalWebView query reply routing', () => {
|
||||
afterEach(() => {
|
||||
if (renderer) {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('routes only complete terminal query replies to native', () => {
|
||||
const onTerminalQueryReply = vi.fn()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
act(() => {
|
||||
renderer = create(createElement(TerminalWebView, { onTerminalQueryReply }))
|
||||
})
|
||||
|
||||
postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[3;4R' })
|
||||
postWebViewMessage({ type: 'terminal-data', bytes: 'a' })
|
||||
postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[A' })
|
||||
|
||||
expect(onTerminalQueryReply).toHaveBeenCalledTimes(1)
|
||||
expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[3;4R')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply'
|
||||
|
||||
export function routeTerminalQueryReply(
|
||||
message: Record<string, unknown>,
|
||||
onTerminalQueryReply: ((bytes: string) => void) | undefined
|
||||
): void {
|
||||
if (
|
||||
message.type === 'terminal-data' &&
|
||||
typeof message.bytes === 'string' &&
|
||||
isTerminalQueryReply(message.bytes)
|
||||
) {
|
||||
onTerminalQueryReply?.(message.bytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
|
||||
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
|
||||
|
||||
type QueryReplyGate = {
|
||||
forward: (data: string) => void
|
||||
queueBoundary: (generation: number) => void
|
||||
reset: () => void
|
||||
resume: () => void
|
||||
setGeneration: (generation: number) => void
|
||||
}
|
||||
|
||||
function createQueryReplyGate(notify: (message: unknown) => void): {
|
||||
gate: QueryReplyGate
|
||||
queuedBoundaries: Array<() => void>
|
||||
} {
|
||||
const queuedBoundaries: Array<() => void> = []
|
||||
const factory = new Function(
|
||||
'notify',
|
||||
'enqueueWriteBoundary',
|
||||
`var terminalGeneration = 0;
|
||||
${TERMINAL_QUERY_REPLY_JS}
|
||||
return {
|
||||
forward: forwardTerminalDataReply,
|
||||
queueBoundary: enqueueTerminalDataReplyBoundary,
|
||||
reset: resetTerminalDataReplyAuthority,
|
||||
resume: resumeTerminalDataReplyAuthority,
|
||||
setGeneration: function(next) { terminalGeneration = next; }
|
||||
};`
|
||||
) as (
|
||||
notify: (message: unknown) => void,
|
||||
enqueueBoundary: (callback: () => void) => void
|
||||
) => QueryReplyGate
|
||||
const gate = factory(notify, (callback) => queuedBoundaries.push(callback))
|
||||
return { gate, queuedBoundaries }
|
||||
}
|
||||
|
||||
describe('mobile terminal query replies', () => {
|
||||
it('forwards xterm-generated data only after initial replay drains', () => {
|
||||
const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)')
|
||||
const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
|
||||
'attachTerminalQueryReplyBridge(term, gen)',
|
||||
listenerIndex
|
||||
)
|
||||
const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
|
||||
'forwardTerminalDataReply(data)',
|
||||
listenerIndex
|
||||
)
|
||||
|
||||
expect(listenerIndex).toBeGreaterThan(-1)
|
||||
expect(enableIndex).toBeGreaterThan(listenerIndex)
|
||||
expect(notifyIndex).toBeGreaterThan(listenerIndex)
|
||||
expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false')
|
||||
expect(XTERM_WEBVIEW_SOURCE.html).toContain(
|
||||
'term.attachCustomKeyEventHandler(function() { return false; })'
|
||||
)
|
||||
expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true')
|
||||
})
|
||||
|
||||
it('mutes a replacement terminal until its own replay drains', () => {
|
||||
const initIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('function init(cols, rows, initialData')
|
||||
const disableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
|
||||
'resetTerminalDataReplyAuthority()',
|
||||
initIndex
|
||||
)
|
||||
const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
|
||||
'attachTerminalQueryReplyBridge(term, gen)',
|
||||
disableIndex
|
||||
)
|
||||
|
||||
expect(initIndex).toBeGreaterThan(-1)
|
||||
expect(disableIndex).toBeGreaterThan(initIndex)
|
||||
expect(enableIndex).toBeGreaterThan(disableIndex)
|
||||
})
|
||||
|
||||
it('suppresses replay, then forwards live queries queued behind its boundary', () => {
|
||||
const messages: unknown[] = []
|
||||
const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
|
||||
gate.setGeneration(1)
|
||||
gate.reset()
|
||||
gate.queueBoundary(1)
|
||||
|
||||
gate.forward('\x1b[1;1R')
|
||||
expect(messages).toEqual([])
|
||||
|
||||
queuedBoundaries[0]?.()
|
||||
gate.forward('\x1b[2;2R')
|
||||
expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[2;2R' }])
|
||||
})
|
||||
|
||||
it('does not let a superseded generation reclaim reply authority', () => {
|
||||
const messages: unknown[] = []
|
||||
const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
|
||||
gate.setGeneration(1)
|
||||
gate.queueBoundary(1)
|
||||
gate.setGeneration(2)
|
||||
gate.reset()
|
||||
|
||||
queuedBoundaries[0]?.()
|
||||
gate.forward('\x1b[1;1R')
|
||||
expect(messages).toEqual([])
|
||||
})
|
||||
|
||||
it('restores reply authority when clear discards the replay boundary', () => {
|
||||
const messages: unknown[] = []
|
||||
const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
|
||||
gate.setGeneration(1)
|
||||
gate.reset()
|
||||
gate.queueBoundary(1)
|
||||
|
||||
// A clear drops all queued writes, including the replay boundary.
|
||||
queuedBoundaries.length = 0
|
||||
gate.resume()
|
||||
gate.forward('\x1b[3;4R')
|
||||
|
||||
expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }])
|
||||
const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf("} else if (msg.type === 'clear') {")
|
||||
const clearEnd = XTERM_WEBVIEW_SOURCE.html.indexOf(
|
||||
"} else if (msg.type === 'measure')",
|
||||
clearStart
|
||||
)
|
||||
expect(XTERM_WEBVIEW_SOURCE.html.slice(clearStart, clearEnd)).toContain(
|
||||
'resumeTerminalDataReplyAuthority()'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -119,6 +119,7 @@ async function renderHarness(hostId: string): Promise<Harness> {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
connectMock.mockReset()
|
||||
loadHostsMock.mockReset()
|
||||
})
|
||||
@@ -156,4 +157,76 @@ describe('useHostClient', () => {
|
||||
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('does not open a client after the host is closed during an in-flight lookup', async () => {
|
||||
let resolveHosts: ((hosts: (typeof HOST)[]) => void) | null = null
|
||||
const hostLookup = new Promise<(typeof HOST)[]>((resolve) => {
|
||||
resolveHosts = resolve
|
||||
})
|
||||
const fake = makeFakeClient('connected')
|
||||
connectMock.mockReturnValue(fake)
|
||||
loadHostsMock.mockReturnValue(hostLookup)
|
||||
|
||||
let closeHost: ((hostId: string) => void) | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
function Probe(): null {
|
||||
closeHost = useCloseHost()
|
||||
useHostClient(HOST.id)
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
expect(loadHostsMock).toHaveBeenCalledOnce()
|
||||
if (!closeHost || !resolveHosts || !renderer) {
|
||||
throw new Error('pending-open harness did not initialize')
|
||||
}
|
||||
|
||||
act(() => closeHost?.(HOST.id))
|
||||
await act(async () => {
|
||||
resolveHosts?.([HOST])
|
||||
await hostLookup
|
||||
})
|
||||
|
||||
expect(connectMock).not.toHaveBeenCalled()
|
||||
expect(fake.closeMock).not.toHaveBeenCalled()
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('does not open a client after provider unmount during an in-flight lookup', async () => {
|
||||
let resolveHosts: ((hosts: (typeof HOST)[]) => void) | null = null
|
||||
const hostLookup = new Promise<(typeof HOST)[]>((resolve) => {
|
||||
resolveHosts = resolve
|
||||
})
|
||||
connectMock.mockReturnValue(makeFakeClient('connected'))
|
||||
loadHostsMock.mockReturnValue(hostLookup)
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
function Probe(): null {
|
||||
useHostClient(HOST.id)
|
||||
return null
|
||||
}
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
expect(loadHostsMock).toHaveBeenCalledOnce()
|
||||
act(() => renderer?.unmount())
|
||||
await act(async () => {
|
||||
resolveHosts?.([HOST])
|
||||
await hostLookup
|
||||
})
|
||||
|
||||
expect(connectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { connect, 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 type { ConnectionState, HostProfile } from './types'
|
||||
|
||||
@@ -66,7 +67,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
// Pending opens (avoid two acquire() callers in the same render racing the
|
||||
// host lookup). Keyed by hostId, value is a sentinel resolved when the
|
||||
// entry materialises.
|
||||
const pendingOpensRef = useRef<Map<string, Promise<void>>>(new Map())
|
||||
const pendingOpensRef = useRef(new HostClientOpenRegistry())
|
||||
|
||||
// Why: a fast-path cache of already-loaded HostProfiles. Screens that
|
||||
// have run loadHosts() can call primeHosts() to populate this and skip
|
||||
@@ -91,19 +92,18 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
const closeEntry = useCallback((hostId: string) => {
|
||||
pendingOpensRef.current.cancel(hostId)
|
||||
primedHostsRef.current.delete(hostId)
|
||||
const entry = storeRef.current.get(hostId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
entry.unsubState()
|
||||
entry.client.close()
|
||||
entry?.unsubState()
|
||||
storeRef.current.delete(hostId)
|
||||
entry?.client.close()
|
||||
notifyHostState(hostId, 'disconnected')
|
||||
notifyAllHosts()
|
||||
}, [])
|
||||
|
||||
const openEntry = useCallback(async (hostId: string): Promise<StoreEntry | null> => {
|
||||
const existing = pendingOpensRef.current.get(hostId)
|
||||
const existing = pendingOpensRef.current.getActivePromise(hostId)
|
||||
if (existing) {
|
||||
await existing
|
||||
return storeRef.current.get(hostId) ?? null
|
||||
@@ -112,7 +112,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
pendingOpensRef.current.set(hostId, promise)
|
||||
const pendingOpen = pendingOpensRef.current.register(hostId, promise)
|
||||
|
||||
try {
|
||||
// Why: prefer the primed cache (populated by primeHosts when the
|
||||
@@ -143,6 +143,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingOpen.cancelled) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Re-check after any await — another acquire() may have completed.
|
||||
const after = storeRef.current.get(hostId)
|
||||
if (after) {
|
||||
@@ -184,7 +188,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
notifyAllHosts()
|
||||
return entry
|
||||
} finally {
|
||||
pendingOpensRef.current.delete(hostId)
|
||||
pendingOpensRef.current.deleteIfCurrent(hostId, pendingOpen)
|
||||
resolve()
|
||||
}
|
||||
}, [])
|
||||
@@ -326,6 +330,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const store = storeRef.current
|
||||
return () => {
|
||||
pendingOpensRef.current.cancelAll()
|
||||
for (const [hostId] of store) {
|
||||
closeEntry(hostId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export type HostClientOpenTicket = {
|
||||
cancelled: boolean
|
||||
promise: Promise<void>
|
||||
}
|
||||
|
||||
export class HostClientOpenRegistry {
|
||||
private readonly pending = new Map<string, HostClientOpenTicket>()
|
||||
|
||||
getActivePromise(hostId: string): Promise<void> | null {
|
||||
const ticket = this.pending.get(hostId)
|
||||
return ticket && !ticket.cancelled ? ticket.promise : null
|
||||
}
|
||||
|
||||
register(hostId: string, promise: Promise<void>): HostClientOpenTicket {
|
||||
const ticket = { cancelled: false, promise }
|
||||
this.pending.set(hostId, ticket)
|
||||
return ticket
|
||||
}
|
||||
|
||||
cancel(hostId: string): void {
|
||||
const ticket = this.pending.get(hostId)
|
||||
if (ticket) {
|
||||
ticket.cancelled = true
|
||||
// Why: the host lookup may never settle; release the registry's strong
|
||||
// reference immediately while the ticket still cancels its continuation.
|
||||
this.pending.delete(hostId)
|
||||
}
|
||||
}
|
||||
|
||||
deleteIfCurrent(hostId: string, ticket: HostClientOpenTicket): void {
|
||||
if (this.pending.get(hostId) === ticket) {
|
||||
this.pending.delete(hostId)
|
||||
}
|
||||
}
|
||||
|
||||
cancelAll(): void {
|
||||
for (const ticket of this.pending.values()) {
|
||||
ticket.cancelled = true
|
||||
}
|
||||
this.pending.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
loadPendingHostCredentialCleanup,
|
||||
loadPendingHostCredentialCleanupIds,
|
||||
resetHostCredentialCleanupForTests,
|
||||
retryPendingHostCredentialCleanups,
|
||||
scheduleHostCredentialCleanup,
|
||||
subscribePendingHostCredentialCleanup
|
||||
} from './host-credential-cleanup'
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: asyncStorageMock
|
||||
}))
|
||||
|
||||
const PENDING_STORAGE_KEY = 'orca:pending-host-credential-cleanups'
|
||||
let storedPendingIds: string[]
|
||||
let readShouldFail = false
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('host credential cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
resetHostCredentialCleanupForTests()
|
||||
storedPendingIds = []
|
||||
readShouldFail = false
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key !== PENDING_STORAGE_KEY) {
|
||||
return null
|
||||
}
|
||||
if (readShouldFail) {
|
||||
throw new Error('async storage unavailable')
|
||||
}
|
||||
return JSON.stringify(storedPendingIds)
|
||||
})
|
||||
asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => {
|
||||
if (key === PENDING_STORAGE_KEY) {
|
||||
storedPendingIds = JSON.parse(raw) as string[]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
resetHostCredentialCleanupForTests()
|
||||
})
|
||||
|
||||
it('clears the durable intent after a successful cleanup', async () => {
|
||||
const deleteCredential = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await scheduleHostCredentialCleanup('host-1', deleteCredential, 20)
|
||||
await vi.waitFor(async () => {
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([])
|
||||
})
|
||||
expect(deleteCredential).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('persists cleanup intent before a rejected delete for manual retry', async () => {
|
||||
const deleteCredential = vi.fn().mockRejectedValue(new Error('keychain unavailable'))
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribePendingHostCredentialCleanup(listener)
|
||||
|
||||
await scheduleHostCredentialCleanup('host-1', deleteCredential, 20)
|
||||
await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce())
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1'])
|
||||
expect(listener).toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('records intent before a stalled delete times out', async () => {
|
||||
vi.useFakeTimers()
|
||||
const deleteCredential = vi.fn(() => new Promise<void>(() => undefined))
|
||||
|
||||
await scheduleHostCredentialCleanup('host-1', deleteCredential, 3_000)
|
||||
await flushMicrotasks()
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1'])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1'])
|
||||
expect(deleteCredential).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns after durable intent without waiting for the native delete', async () => {
|
||||
vi.useFakeTimers()
|
||||
const deleteCredential = vi.fn(() => new Promise<void>(() => undefined))
|
||||
|
||||
await expect(
|
||||
scheduleHostCredentialCleanup('host-1', deleteCredential, 3_000)
|
||||
).resolves.toBeUndefined()
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(storedPendingIds).toEqual(['host-1'])
|
||||
expect(deleteCredential).toHaveBeenCalledOnce()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
})
|
||||
|
||||
it('clears a timed-out pending entry when the native delete later succeeds', async () => {
|
||||
vi.useFakeTimers()
|
||||
let resolveDelete: (() => void) | null = null
|
||||
const deleteCredential = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveDelete = resolve
|
||||
})
|
||||
)
|
||||
|
||||
await scheduleHostCredentialCleanup('host-1', deleteCredential, 3_000)
|
||||
await flushMicrotasks()
|
||||
expect(deleteCredential).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1'])
|
||||
expect(resolveDelete).not.toBeNull()
|
||||
|
||||
resolveDelete?.()
|
||||
await vi.waitFor(async () => {
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('joins a concurrently observed delete instead of stacking native calls', async () => {
|
||||
let resolveDelete: (() => void) | null = null
|
||||
const deleteCredential = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveDelete = resolve
|
||||
})
|
||||
)
|
||||
|
||||
await scheduleHostCredentialCleanup('host-1', deleteCredential, 1_000)
|
||||
await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce())
|
||||
const readsBeforeSecondAttempt = asyncStorageMock.getItem.mock.calls.length
|
||||
await scheduleHostCredentialCleanup('host-1', deleteCredential, 1_000)
|
||||
await vi.waitFor(() => {
|
||||
expect(asyncStorageMock.getItem.mock.calls.length).toBeGreaterThan(readsBeforeSecondAttempt)
|
||||
})
|
||||
await flushMicrotasks()
|
||||
expect(deleteCredential).toHaveBeenCalledOnce()
|
||||
|
||||
resolveDelete?.()
|
||||
await vi.waitFor(async () => {
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('starts a fresh native attempt when the user retries after a timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const deleteCredential = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
await scheduleHostCredentialCleanup('host-1', deleteCredential, 3_000)
|
||||
await flushMicrotasks()
|
||||
expect(deleteCredential).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
await expect(retryPendingHostCredentialCleanups(deleteCredential)).resolves.toEqual({
|
||||
clearedCount: 1,
|
||||
remainingIds: [],
|
||||
storageUnreadable: false
|
||||
})
|
||||
expect(deleteCredential).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries only on an explicit call and clears successful ids', async () => {
|
||||
storedPendingIds = ['host-1', 'host-2']
|
||||
const deleteCredential = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('still unavailable'))
|
||||
.mockResolvedValue(undefined)
|
||||
|
||||
expect(deleteCredential).not.toHaveBeenCalled()
|
||||
await expect(retryPendingHostCredentialCleanups(deleteCredential)).resolves.toEqual({
|
||||
clearedCount: 1,
|
||||
remainingIds: ['host-1'],
|
||||
storageUnreadable: false
|
||||
})
|
||||
expect(deleteCredential).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('surfaces a fallback handle without clobbering durable ids when the queue write fails', async () => {
|
||||
storedPendingIds = ['host-a']
|
||||
const deleteCredential = vi.fn().mockRejectedValue(new Error('keychain unavailable'))
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribePendingHostCredentialCleanup(listener)
|
||||
|
||||
readShouldFail = true
|
||||
await scheduleHostCredentialCleanup('host-b', deleteCredential, 20)
|
||||
await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce())
|
||||
readShouldFail = false
|
||||
|
||||
// Durable queue is untouched (not clobbered); the failed-to-record host-b is
|
||||
// still surfaced via the session-scoped fallback so its orphaned token keeps
|
||||
// a retry affordance instead of being silently lost.
|
||||
expect(deleteCredential).toHaveBeenCalledOnce()
|
||||
expect(listener).toHaveBeenCalled()
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-a', 'host-b'])
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('reports storageUnreadable when the durable queue cannot be read', async () => {
|
||||
storedPendingIds = ['host-a']
|
||||
|
||||
readShouldFail = true
|
||||
await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({
|
||||
ids: [],
|
||||
storageUnreadable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the fallback handle once the native delete finally succeeds', async () => {
|
||||
let resolveDelete: (() => void) | null = null
|
||||
const deleteCredential = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveDelete = resolve
|
||||
})
|
||||
)
|
||||
|
||||
readShouldFail = true
|
||||
await scheduleHostCredentialCleanup('host-b', deleteCredential, 1_000)
|
||||
await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce())
|
||||
readShouldFail = false
|
||||
await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({
|
||||
ids: ['host-b'],
|
||||
storageUnreadable: false
|
||||
})
|
||||
|
||||
resolveDelete?.()
|
||||
await vi.waitFor(async () => {
|
||||
await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({
|
||||
ids: [],
|
||||
storageUnreadable: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('cleans a new credential when the same host id is paired and removed again', async () => {
|
||||
const deleteCredential = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await scheduleHostCredentialCleanup('host-1', deleteCredential, 20)
|
||||
await vi.waitFor(async () => {
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([])
|
||||
})
|
||||
await scheduleHostCredentialCleanup('host-1', deleteCredential, 20)
|
||||
await vi.waitFor(async () => {
|
||||
expect(deleteCredential).toHaveBeenCalledTimes(2)
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
expect(deleteCredential).toHaveBeenCalledTimes(2)
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('soft-loads empty when pending-cleanup storage is malformed', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValueOnce('{')
|
||||
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const PENDING_STORAGE_KEY = 'orca:pending-host-credential-cleanups'
|
||||
const CLEANUP_CONFIRM_TIMEOUT_MS = 3_000
|
||||
|
||||
type DeleteHostCredential = (hostId: string) => Promise<void>
|
||||
type CleanupAttemptResult = 'cleared' | 'pending'
|
||||
type CleanupOutcome = 'cleared' | 'failed' | 'timed-out'
|
||||
type PendingIdsRead = { ok: true; ids: string[] } | { ok: false }
|
||||
|
||||
export type PendingHostCredentialCleanup = {
|
||||
ids: string[]
|
||||
// Why: the durable AsyncStorage queue could not be read. Settings surfaces a
|
||||
// "pending unknown / retry to be safe" state instead of a silently-empty
|
||||
// (hidden) section so an orphaned keychain token keeps a recovery affordance.
|
||||
storageUnreadable: boolean
|
||||
}
|
||||
|
||||
let pendingMutation: Promise<void> = Promise.resolve()
|
||||
const pendingListeners = new Set<() => void>()
|
||||
// Why: concurrent taps/callers share one native operation while it is being
|
||||
// confirmed. A timed-out operation is released so the next user tap can retry.
|
||||
const inflightDeletes = new Map<string, Promise<void>>()
|
||||
// Why: when the durable queue write fails we still need a recovery handle for a
|
||||
// failed keychain delete. Keep the hostId here (session-scoped) so Settings can
|
||||
// surface it and offer a retry; cleared once the native delete confirms.
|
||||
const unrecordedPendingIds = new Set<string>()
|
||||
|
||||
function notifyPendingListeners(): void {
|
||||
for (const listener of pendingListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
function markUnrecordedPending(hostId: string): void {
|
||||
if (unrecordedPendingIds.has(hostId)) {
|
||||
return
|
||||
}
|
||||
unrecordedPendingIds.add(hostId)
|
||||
notifyPendingListeners()
|
||||
}
|
||||
|
||||
function clearUnrecordedPending(hostId: string): void {
|
||||
if (unrecordedPendingIds.delete(hostId)) {
|
||||
notifyPendingListeners()
|
||||
}
|
||||
}
|
||||
|
||||
function parsePendingIds(raw: string): string[] | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
return [...new Set(parsed.filter((value): value is string => typeof value === 'string'))]
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function sameIdList(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((id, index) => id === b[index])
|
||||
}
|
||||
|
||||
async function readPendingIdsForMutation(): Promise<PendingIdsRead> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PENDING_STORAGE_KEY)
|
||||
if (raw === null) {
|
||||
return { ok: true, ids: [] }
|
||||
}
|
||||
const ids = parsePendingIds(raw)
|
||||
if (!ids) {
|
||||
// Why: refuse to RMW over unreadable payload — treating it as [] would
|
||||
// wipe durable pending ids on the next add/remove.
|
||||
return { ok: false }
|
||||
}
|
||||
return { ok: true, ids }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingCleanupState(): Promise<PendingHostCredentialCleanup> {
|
||||
await pendingMutation
|
||||
const result = await readPendingIdsForMutation()
|
||||
const fallback = [...unrecordedPendingIds]
|
||||
if (!result.ok) {
|
||||
// Why: durable queue unreadable — only the session-scoped fallback is
|
||||
// known. Report unreadable so callers can surface a retry rather than
|
||||
// pretend the queue is empty.
|
||||
return { ids: [...new Set(fallback)], storageUnreadable: true }
|
||||
}
|
||||
return { ids: [...new Set([...result.ids, ...fallback])], storageUnreadable: false }
|
||||
}
|
||||
|
||||
async function mutatePendingIds(update: (ids: string[]) => string[]): Promise<void> {
|
||||
const mutation = pendingMutation.then(async () => {
|
||||
const current = await readPendingIdsForMutation()
|
||||
if (!current.ok) {
|
||||
throw new Error('pending host credential cleanup storage unreadable')
|
||||
}
|
||||
const next = update(current.ids)
|
||||
if (sameIdList(current.ids, next)) {
|
||||
return
|
||||
}
|
||||
await AsyncStorage.setItem(PENDING_STORAGE_KEY, JSON.stringify(next))
|
||||
notifyPendingListeners()
|
||||
})
|
||||
pendingMutation = mutation.catch(() => {})
|
||||
return mutation
|
||||
}
|
||||
|
||||
async function addPendingId(hostId: string): Promise<void> {
|
||||
await mutatePendingIds((ids) => (ids.includes(hostId) ? ids : [...ids, hostId]))
|
||||
}
|
||||
|
||||
async function removePendingId(hostId: string): Promise<void> {
|
||||
await mutatePendingIds((ids) => ids.filter((id) => id !== hostId))
|
||||
}
|
||||
|
||||
function observeCleanup(cleanup: Promise<void>, timeoutMs: number): Promise<CleanupOutcome> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (outcome: CleanupOutcome) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
resolve(outcome)
|
||||
}
|
||||
const timeout = setTimeout(() => finish('timed-out'), timeoutMs)
|
||||
cleanup.then(
|
||||
() => finish('cleared'),
|
||||
() => finish('failed')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredential): Promise<void> {
|
||||
const existing = inflightDeletes.get(hostId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const cleanup = Promise.resolve()
|
||||
.then(() => deleteCredential(hostId))
|
||||
.finally(() => {
|
||||
if (inflightDeletes.get(hostId) === cleanup) {
|
||||
inflightDeletes.delete(hostId)
|
||||
}
|
||||
})
|
||||
inflightDeletes.set(hostId, cleanup)
|
||||
return cleanup
|
||||
}
|
||||
|
||||
async function recordCleanupIntent(hostId: string): Promise<boolean> {
|
||||
try {
|
||||
await addPendingId(hostId)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmNativeCleanup(
|
||||
hostId: string,
|
||||
deleteCredential: DeleteHostCredential,
|
||||
timeoutMs: number
|
||||
): Promise<CleanupAttemptResult> {
|
||||
const cleanup = startOrJoinDelete(hostId, deleteCredential)
|
||||
// Why: attach before observing so a success that races the confirm timeout
|
||||
// still clears the queue entry (including after timed-out returns). Clears the
|
||||
// session-scoped fallback too, so a durable-write-failed intent stops being
|
||||
// surfaced once the native delete finally lands.
|
||||
const clearWhenDeleted = cleanup.then(
|
||||
() => {
|
||||
clearUnrecordedPending(hostId)
|
||||
return removePendingId(hostId).catch(() => undefined)
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
const outcome = await observeCleanup(cleanup, timeoutMs)
|
||||
if (outcome === 'cleared') {
|
||||
await clearWhenDeleted
|
||||
return 'cleared'
|
||||
}
|
||||
|
||||
if (outcome === 'timed-out' && inflightDeletes.get(hostId) === cleanup) {
|
||||
// Why: timing out is the boundary between automatic work and a future
|
||||
// user-owned retry. Releasing only the dedupe entry does not start work.
|
||||
inflightDeletes.delete(hostId)
|
||||
}
|
||||
|
||||
// Why: failed/unconfirmed attempts stay user-owned; nothing auto-retries.
|
||||
void clearWhenDeleted
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
export async function loadPendingHostCredentialCleanup(): Promise<PendingHostCredentialCleanup> {
|
||||
return loadPendingCleanupState()
|
||||
}
|
||||
|
||||
export async function loadPendingHostCredentialCleanupIds(): Promise<string[]> {
|
||||
return (await loadPendingCleanupState()).ids
|
||||
}
|
||||
|
||||
export function subscribePendingHostCredentialCleanup(listener: () => void): () => void {
|
||||
pendingListeners.add(listener)
|
||||
return () => pendingListeners.delete(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record cleanup intent, then fire-and-forget the native keychain delete so
|
||||
* removeHost never blocks on SecureStore. If the durable intent write fails,
|
||||
* keep a session-scoped recovery handle so a failed keychain delete still
|
||||
* surfaces in Settings instead of orphaning the token with no retry affordance.
|
||||
*/
|
||||
export async function scheduleHostCredentialCleanup(
|
||||
hostId: string,
|
||||
deleteCredential: DeleteHostCredential,
|
||||
timeoutMs = CLEANUP_CONFIRM_TIMEOUT_MS
|
||||
): Promise<void> {
|
||||
const recorded = await recordCleanupIntent(hostId)
|
||||
if (!recorded) {
|
||||
// Why: the only durable recovery handle failed to persist. Hold an in-memory
|
||||
// one so Settings can still surface + retry; confirmNativeCleanup clears it
|
||||
// if the native delete lands. removeHost stays non-blocking (freeze fix).
|
||||
markUnrecordedPending(hostId)
|
||||
}
|
||||
void confirmNativeCleanup(hostId, deleteCredential, timeoutMs).catch(() => {})
|
||||
}
|
||||
|
||||
export async function retryPendingHostCredentialCleanups(
|
||||
deleteCredential: DeleteHostCredential
|
||||
): Promise<{ clearedCount: number; remainingIds: string[]; storageUnreadable: boolean }> {
|
||||
const pending = await loadPendingCleanupState()
|
||||
const outcomes = await Promise.all(
|
||||
// Why: these ids are already durable (or a session-scoped fallback). Re-adding
|
||||
// intent can race a late success and recreate a ghost row after deletion.
|
||||
pending.ids.map((id) => confirmNativeCleanup(id, deleteCredential, CLEANUP_CONFIRM_TIMEOUT_MS))
|
||||
)
|
||||
const remaining = await loadPendingCleanupState()
|
||||
return {
|
||||
clearedCount: outcomes.filter((outcome) => outcome === 'cleared').length,
|
||||
remainingIds: remaining.ids,
|
||||
storageUnreadable: remaining.storageUnreadable
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: drop module listeners/in-flight state between cases. */
|
||||
export function resetHostCredentialCleanupForTests(): void {
|
||||
inflightDeletes.clear()
|
||||
pendingListeners.clear()
|
||||
unrecordedPendingIds.clear()
|
||||
pendingMutation = Promise.resolve()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const removeHostMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./host-store', () => ({
|
||||
removeHost: (hostId: string) => removeHostMock(hostId)
|
||||
}))
|
||||
|
||||
import { removeHostAndCloseClient } from './host-removal-lifecycle'
|
||||
|
||||
describe('host removal lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
removeHostMock.mockReset()
|
||||
})
|
||||
|
||||
it('closes the client only after metadata removal commits', async () => {
|
||||
let commitRemoval: (() => void) | null = null
|
||||
removeHostMock.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
commitRemoval = resolve
|
||||
})
|
||||
)
|
||||
const closeHostClient = vi.fn()
|
||||
|
||||
const removal = removeHostAndCloseClient('host-1', closeHostClient)
|
||||
expect(closeHostClient).not.toHaveBeenCalled()
|
||||
commitRemoval?.()
|
||||
await removal
|
||||
|
||||
expect(closeHostClient).toHaveBeenCalledWith('host-1')
|
||||
})
|
||||
|
||||
it('keeps the client open when metadata removal fails', async () => {
|
||||
removeHostMock.mockRejectedValue(new Error('storage unavailable'))
|
||||
const closeHostClient = vi.fn()
|
||||
|
||||
await expect(removeHostAndCloseClient('host-1', closeHostClient)).rejects.toThrow(
|
||||
'storage unavailable'
|
||||
)
|
||||
expect(closeHostClient).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { removeHost } from './host-store'
|
||||
|
||||
export async function removeHostAndCloseClient(
|
||||
hostId: string,
|
||||
closeHostClient: (hostId: string) => void
|
||||
): Promise<void> {
|
||||
// Why: closing before the metadata commit can strand a still-paired host on
|
||||
// storage failure; closing immediately after success prevents socket leaks.
|
||||
await removeHost(hostId)
|
||||
closeHostClient(hostId)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
}))
|
||||
|
||||
const secureStoreMock = vi.hoisted(() => ({
|
||||
deleteItemAsync: vi.fn(),
|
||||
getItemAsync: vi.fn(),
|
||||
setItemAsync: vi.fn()
|
||||
}))
|
||||
|
||||
const scheduleCleanupMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: asyncStorageMock
|
||||
}))
|
||||
|
||||
vi.mock('expo-secure-store', () => ({
|
||||
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
|
||||
...secureStoreMock
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: { OS: 'ios' }
|
||||
}))
|
||||
|
||||
vi.mock('./host-credential-cleanup', () => ({
|
||||
scheduleHostCredentialCleanup: (...args: unknown[]) => scheduleCleanupMock(...args),
|
||||
retryPendingHostCredentialCleanups: vi.fn()
|
||||
}))
|
||||
|
||||
import { removeHost, renameHost, resetHostStoreForTests, updateLastConnected } from './host-store'
|
||||
|
||||
const HOSTS_STORAGE_KEY = 'orca:hosts'
|
||||
const HOST_ONE = {
|
||||
id: 'host-1',
|
||||
name: 'Host 1',
|
||||
endpoint: 'ws://127.0.0.1:1',
|
||||
publicKeyB64: 'key',
|
||||
lastConnected: 0
|
||||
}
|
||||
const HOST_TWO = {
|
||||
id: 'host-2',
|
||||
name: 'Host 2',
|
||||
endpoint: 'ws://127.0.0.1:2',
|
||||
publicKeyB64: 'key-2',
|
||||
lastConnected: 0
|
||||
}
|
||||
|
||||
describe('host-store list mutations', () => {
|
||||
let storedHostsRaw: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetHostStoreForTests()
|
||||
scheduleCleanupMock.mockReset()
|
||||
scheduleCleanupMock.mockResolvedValue(undefined)
|
||||
storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO])
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === HOSTS_STORAGE_KEY) {
|
||||
return storedHostsRaw
|
||||
}
|
||||
return null
|
||||
})
|
||||
asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => {
|
||||
if (key === HOSTS_STORAGE_KEY) {
|
||||
storedHostsRaw = raw
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('commits the removal when credential cleanup scheduling rejects', async () => {
|
||||
scheduleCleanupMock.mockRejectedValue(new Error('intent storage unavailable'))
|
||||
|
||||
await expect(removeHost(HOST_ONE.id)).resolves.toBeUndefined()
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO])
|
||||
expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function))
|
||||
})
|
||||
|
||||
it('awaits cleanup scheduling after metadata commit', async () => {
|
||||
let resolveSchedule: (() => void) | null = null
|
||||
scheduleCleanupMock.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSchedule = resolve
|
||||
})
|
||||
)
|
||||
|
||||
let settled = false
|
||||
const removal = removeHost(HOST_ONE.id).then(() => {
|
||||
settled = true
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO])
|
||||
})
|
||||
expect(settled).toBe(false)
|
||||
expect(scheduleCleanupMock).toHaveBeenCalledOnce()
|
||||
|
||||
resolveSchedule?.()
|
||||
await removal
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('applies concurrent rename and remove without clobbering either', async () => {
|
||||
let releaseReads: (() => void) | null = null
|
||||
const readsReleased = new Promise<void>((resolve) => {
|
||||
releaseReads = resolve
|
||||
})
|
||||
let pendingReads = 0
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key !== HOSTS_STORAGE_KEY) {
|
||||
return null
|
||||
}
|
||||
pendingReads += 1
|
||||
if (pendingReads <= 2) {
|
||||
await readsReleased
|
||||
}
|
||||
return storedHostsRaw
|
||||
})
|
||||
|
||||
const rename = renameHost(HOST_ONE.id, '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.
|
||||
await vi.waitFor(() => {
|
||||
expect(pendingReads).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
releaseReads?.()
|
||||
await Promise.all([rename, remove])
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([
|
||||
{
|
||||
...HOST_ONE,
|
||||
name: 'Renamed Host'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves a rename when lastConnected updates race it', async () => {
|
||||
const before = Date.now()
|
||||
await Promise.all([
|
||||
renameHost(HOST_ONE.id, 'Alpha'),
|
||||
updateLastConnected(HOST_ONE.id),
|
||||
renameHost(HOST_TWO.id, 'Beta')
|
||||
])
|
||||
|
||||
const stored = JSON.parse(storedHostsRaw) as Array<typeof HOST_ONE>
|
||||
expect(stored).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: HOST_ONE.id, name: 'Alpha' }),
|
||||
expect.objectContaining({ id: HOST_TWO.id, name: 'Beta' })
|
||||
])
|
||||
)
|
||||
const hostOne = stored.find((host) => host.id === HOST_ONE.id)
|
||||
expect(hostOne?.lastConnected).toBeGreaterThanOrEqual(before)
|
||||
})
|
||||
|
||||
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/)
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
|
||||
expect(storedHostsRaw).toBe('{')
|
||||
})
|
||||
|
||||
it('resolves instead of rejecting when updateLastConnected hits unreadable storage', async () => {
|
||||
// Why: callers fire updateLastConnected with `void`; a rejection here would
|
||||
// surface as an unhandled promise rejection rather than a caught error.
|
||||
storedHostsRaw = '{'
|
||||
await expect(updateLastConnected(HOST_ONE.id)).resolves.toBeUndefined()
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
|
||||
expect(storedHostsRaw).toBe('{')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
type StoredHostProfile
|
||||
} from './types'
|
||||
import { getNextHostNameFromHosts } from './host-names'
|
||||
import {
|
||||
retryPendingHostCredentialCleanups,
|
||||
scheduleHostCredentialCleanup
|
||||
} from './host-credential-cleanup'
|
||||
|
||||
const STORAGE_KEY = 'orca:hosts'
|
||||
// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected.
|
||||
@@ -66,8 +70,39 @@ async function deleteDeviceToken(hostId: string): Promise<void> {
|
||||
// (cleared on app uninstall, persisted across foreground/background).
|
||||
const tokenCache = new Map<string, string>()
|
||||
let inflightLoad: Promise<HostProfile[]> | null = null
|
||||
// Why: rename / lastConnected / remove / save all RMW the same hosts JSON.
|
||||
// Without a queue, concurrent writers re-read a stale snapshot and the last
|
||||
// setItem wins — resurrecting a removed host or dropping a rename.
|
||||
let hostListMutation: Promise<void> = Promise.resolve()
|
||||
|
||||
function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
return parsed.flatMap((item) => {
|
||||
// Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage.
|
||||
// Drop them silently — the three pre-launch users will re-pair on
|
||||
// first run rather than carry a migration shim through the auth path.
|
||||
if (item && typeof item === 'object' && 'deviceToken' in item) {
|
||||
return []
|
||||
}
|
||||
const result = StoredHostProfileSchema.safeParse(item)
|
||||
return result.success ? [result.data] : []
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadHosts(): Promise<HostProfile[]> {
|
||||
// Why: writers hold the mutation chain across their full RMW; wait so a
|
||||
// load right after rename/remove does not race a half-written list.
|
||||
await hostListMutation
|
||||
// Why: deduplicate concurrent loadHosts() calls so multiple screens
|
||||
// mounting simultaneously share one Keychain read pass.
|
||||
if (inflightLoad) {
|
||||
@@ -81,38 +116,18 @@ export async function loadHosts(): Promise<HostProfile[]> {
|
||||
|
||||
async function doLoadHosts(): Promise<HostProfile[]> {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
const storedHosts = parseStoredHosts(raw)
|
||||
if (!storedHosts) {
|
||||
return []
|
||||
}
|
||||
|
||||
const out: HostProfile[] = []
|
||||
for (const item of parsed) {
|
||||
// Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage.
|
||||
// Drop them silently — the three pre-launch users will re-pair on
|
||||
// first run rather than carry a migration shim through the auth
|
||||
// path.
|
||||
if (item && typeof item === 'object' && 'deviceToken' in item) {
|
||||
continue
|
||||
}
|
||||
const stored = StoredHostProfileSchema.safeParse(item)
|
||||
if (!stored.success) {
|
||||
continue
|
||||
}
|
||||
|
||||
let token = tokenCache.get(stored.data.id)
|
||||
for (const stored of storedHosts) {
|
||||
let token = tokenCache.get(stored.id)
|
||||
if (!token) {
|
||||
let fetched: string | null
|
||||
try {
|
||||
fetched = await readDeviceToken(stored.data.id)
|
||||
fetched = await readDeviceToken(stored.id)
|
||||
} catch {
|
||||
// Why: a transient Keychain failure for one entry (e.g.
|
||||
// errSecInteractionNotAllowed while the device is briefly locked,
|
||||
@@ -127,37 +142,50 @@ async function doLoadHosts(): Promise<HostProfile[]> {
|
||||
continue
|
||||
}
|
||||
token = fetched
|
||||
tokenCache.set(stored.data.id, token)
|
||||
tokenCache.set(stored.id, token)
|
||||
}
|
||||
out.push({ ...stored.data, deviceToken: token })
|
||||
out.push({ ...stored, deviceToken: token })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function loadStoredHosts(): Promise<StoredHostProfile[]> {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
return parsed.flatMap((item) => {
|
||||
// Why: same drop-old-records rule as loadHosts; keeps internal
|
||||
// mutators from re-persisting pre-v0.0.3 entries.
|
||||
if (item && typeof item === 'object' && 'deviceToken' in item) {
|
||||
return []
|
||||
}
|
||||
const result = StoredHostProfileSchema.safeParse(item)
|
||||
return result.success ? [result.data] : []
|
||||
})
|
||||
return parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function readStoredHostsForMutation(): Promise<StoredHostProfile[]> {
|
||||
try {
|
||||
const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY))
|
||||
if (!parsed) {
|
||||
// Why: refuse to RMW over unreadable payload — treating it as [] would
|
||||
// wipe the durable host list on the next rename/remove/save.
|
||||
throw new Error('host list storage unreadable')
|
||||
}
|
||||
return parsed
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'host list storage unreadable') {
|
||||
throw error
|
||||
}
|
||||
throw new Error('host list storage unreadable')
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateStoredHosts(
|
||||
update: (hosts: StoredHostProfile[]) => StoredHostProfile[]
|
||||
): Promise<void> {
|
||||
const mutation = hostListMutation.then(async () => {
|
||||
const current = await readStoredHostsForMutation()
|
||||
const next = update(current)
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
})
|
||||
hostListMutation = mutation.catch(() => {})
|
||||
return mutation
|
||||
}
|
||||
|
||||
function toStored(host: HostProfile): StoredHostProfile {
|
||||
return {
|
||||
id: host.id,
|
||||
@@ -170,51 +198,84 @@ function toStored(host: HostProfile): StoredHostProfile {
|
||||
|
||||
export async function saveHost(host: HostProfile): Promise<void> {
|
||||
const validated = HostProfileSchema.parse(host)
|
||||
const hosts = await loadStoredHosts()
|
||||
const stored = toStored(validated)
|
||||
const index = hosts.findIndex((h) => h.id === stored.id)
|
||||
if (index >= 0) {
|
||||
hosts[index] = stored
|
||||
} else {
|
||||
hosts.push(stored)
|
||||
}
|
||||
await mutateStoredHosts((hosts) => {
|
||||
const index = hosts.findIndex((h) => h.id === stored.id)
|
||||
if (index >= 0) {
|
||||
const next = hosts.slice()
|
||||
next[index] = stored
|
||||
return next
|
||||
}
|
||||
return [...hosts, stored]
|
||||
})
|
||||
// Why: write metadata BEFORE the keychain token so a crash between the two
|
||||
// leaves orphaned metadata (which loadHosts skips and removeHost can clean
|
||||
// up) rather than an orphaned keychain token with no metadata pointer —
|
||||
// the latter would persist forever since removeHost only deletes by hostId
|
||||
// from current metadata.
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
|
||||
await writeDeviceToken(stored.id, validated.deviceToken)
|
||||
tokenCache.set(stored.id, validated.deviceToken)
|
||||
}
|
||||
|
||||
export async function removeHost(hostId: string): Promise<void> {
|
||||
const hosts = await loadStoredHosts()
|
||||
const filtered = hosts.filter((h) => h.id !== hostId)
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
|
||||
await deleteDeviceToken(hostId)
|
||||
await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId))
|
||||
tokenCache.delete(hostId)
|
||||
}
|
||||
|
||||
export async function renameHost(hostId: string, newName: string): Promise<void> {
|
||||
const hosts = await loadStoredHosts()
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (host) {
|
||||
host.name = newName
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
|
||||
// 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)
|
||||
} catch {
|
||||
// Metadata is already committed; orphan-token recovery is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryPendingHostCredentialCleanup(): Promise<{
|
||||
clearedCount: number
|
||||
remainingIds: string[]
|
||||
storageUnreadable: boolean
|
||||
}> {
|
||||
return retryPendingHostCredentialCleanups(deleteDeviceToken)
|
||||
}
|
||||
|
||||
export async function renameHost(hostId: string, newName: string): Promise<void> {
|
||||
await mutateStoredHosts((hosts) => {
|
||||
const index = hosts.findIndex((h) => h.id === hostId)
|
||||
if (index < 0) {
|
||||
return hosts
|
||||
}
|
||||
const next = hosts.slice()
|
||||
next[index] = { ...next[index]!, name: newName }
|
||||
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> {
|
||||
const hosts = await loadStoredHosts()
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (host) {
|
||||
host.lastConnected = Date.now()
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
|
||||
try {
|
||||
await mutateStoredHosts((hosts) => {
|
||||
const index = hosts.findIndex((h) => h.id === hostId)
|
||||
if (index < 0) {
|
||||
return hosts
|
||||
}
|
||||
const next = hosts.slice()
|
||||
next[index] = { ...next[index]!, lastConnected: Date.now() }
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// Why: last-connected is a best-effort timestamp and callers fire it with
|
||||
// `void`. Swallow unreadable-storage failures so they don't surface as an
|
||||
// unhandled promise rejection.
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: drain module mutation chain between cases. */
|
||||
export function resetHostStoreForTests(): void {
|
||||
hostListMutation = Promise.resolve()
|
||||
tokenCache.clear()
|
||||
inflightLoad = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { connect, type RpcClient } from './rpc-client'
|
||||
|
||||
vi.mock('./e2ee', () => ({
|
||||
generateKeyPair: () => ({
|
||||
publicKey: new Uint8Array(32),
|
||||
secretKey: new Uint8Array(32)
|
||||
}),
|
||||
deriveSharedKey: () => new Uint8Array(32),
|
||||
publicKeyFromBase64: () => new Uint8Array(32),
|
||||
publicKeyToBase64: () => 'client-public-key',
|
||||
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
|
||||
decrypt: (raw: string) => raw.replace(/^encrypted:/, ''),
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
class RuntimeEventTestSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSING = 2
|
||||
static CLOSED = 3
|
||||
|
||||
readonly CONNECTING = RuntimeEventTestSocket.CONNECTING
|
||||
readonly OPEN = RuntimeEventTestSocket.OPEN
|
||||
readonly CLOSING = RuntimeEventTestSocket.CLOSING
|
||||
readonly CLOSED = RuntimeEventTestSocket.CLOSED
|
||||
|
||||
readyState = RuntimeEventTestSocket.CONNECTING
|
||||
onopen: (() => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
sent: string[] = []
|
||||
|
||||
constructor(readonly endpoint: string) {
|
||||
sockets.push(this)
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
this.sent.push(payload)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = RuntimeEventTestSocket.CLOSED
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = RuntimeEventTestSocket.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
|
||||
receive(payload: unknown): void {
|
||||
this.onmessage?.({ data: payload })
|
||||
}
|
||||
}
|
||||
|
||||
type SentRequest = { id: string; method: string; params?: unknown }
|
||||
|
||||
const sockets: RuntimeEventTestSocket[] = []
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
|
||||
function sentRequests(socket: RuntimeEventTestSocket, method: string): SentRequest[] {
|
||||
return socket.sent
|
||||
.map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRequest)
|
||||
.filter((request) => request.method === method)
|
||||
}
|
||||
|
||||
function connectReadyClient(): { client: RpcClient; socket: RuntimeEventTestSocket } {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
const socket = sockets[0]!
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
|
||||
return { client, socket }
|
||||
}
|
||||
|
||||
function emitReady(
|
||||
socket: RuntimeEventTestSocket,
|
||||
requestId: string,
|
||||
subscriptionId: string
|
||||
): void {
|
||||
socket.receive(
|
||||
`encrypted:${JSON.stringify({
|
||||
id: requestId,
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'ready', subscriptionId },
|
||||
_meta: { runtimeId: 'r1' }
|
||||
})}`
|
||||
)
|
||||
}
|
||||
|
||||
describe('runtime client-event stream disposal', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sockets.length = 0
|
||||
globalThis.WebSocket = RuntimeEventTestSocket as unknown as typeof WebSocket
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = originalWebSocket
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('unsubscribes a stream disposed after ready', () => {
|
||||
const { client, socket } = connectReadyClient()
|
||||
const unsubscribe = client.subscribe('runtime.clientEvents.subscribe', null, () => {})
|
||||
const request = sentRequests(socket, 'runtime.clientEvents.subscribe')[0]!
|
||||
emitReady(socket, request.id, 'runtime-events:test')
|
||||
|
||||
unsubscribe()
|
||||
|
||||
expect(sentRequests(socket, 'runtime.clientEvents.unsubscribe')).toEqual([
|
||||
expect.objectContaining({ params: { subscriptionId: 'runtime-events:test' } })
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('keeps a tombstone and unsubscribes a stream disposed before ready', () => {
|
||||
const { client, socket } = connectReadyClient()
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = client.subscribe('runtime.clientEvents.subscribe', null, listener)
|
||||
const request = sentRequests(socket, 'runtime.clientEvents.subscribe')[0]!
|
||||
|
||||
unsubscribe()
|
||||
emitReady(socket, request.id, 'runtime-events:late')
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
expect(sentRequests(socket, 'runtime.clientEvents.unsubscribe')).toEqual([
|
||||
expect.objectContaining({ params: { subscriptionId: 'runtime-events:late' } })
|
||||
])
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
@@ -567,10 +567,10 @@ export function connect(
|
||||
const stream = streamListeners.get(response.id)
|
||||
if (stream && response.ok) {
|
||||
const result = (response as RpcSuccess).result
|
||||
if (isBrowserScreencastReadyResult(result)) {
|
||||
if (isStreamingSubscriptionReadyResult(result)) {
|
||||
stream.subscriptionId = result.subscriptionId
|
||||
if (stream.cancelled) {
|
||||
sendBrowserScreencastUnsubscribe(result.subscriptionId)
|
||||
sendServerSubscriptionUnsubscribe(stream)
|
||||
removeStreamListener(response.id)
|
||||
return
|
||||
}
|
||||
@@ -947,8 +947,21 @@ export function connect(
|
||||
if (pendingBrowserScreencastRequestId === id) {
|
||||
pendingBrowserScreencastRequestId = null
|
||||
}
|
||||
disposeServerSubscriptionStream(id, stream)
|
||||
}
|
||||
|
||||
function disposeRuntimeClientEventsStream(id: string): void {
|
||||
const stream = streamListeners.get(id)
|
||||
if (!stream || stream.method !== 'runtime.clientEvents.subscribe') {
|
||||
return
|
||||
}
|
||||
disposeServerSubscriptionStream(id, stream)
|
||||
}
|
||||
|
||||
function disposeServerSubscriptionStream(id: string, stream: StreamRequest): void {
|
||||
stream.cancelled = true
|
||||
if (stream.subscriptionId) {
|
||||
sendBrowserScreencastUnsubscribe(stream.subscriptionId)
|
||||
sendServerSubscriptionUnsubscribe(stream)
|
||||
removeStreamListener(id)
|
||||
return
|
||||
}
|
||||
@@ -1022,6 +1035,24 @@ export function connect(
|
||||
})
|
||||
}
|
||||
|
||||
function sendServerSubscriptionUnsubscribe(stream: StreamRequest): void {
|
||||
if (!stream.subscriptionId) {
|
||||
return
|
||||
}
|
||||
if (stream.method === 'browser.screencast') {
|
||||
sendBrowserScreencastUnsubscribe(stream.subscriptionId)
|
||||
return
|
||||
}
|
||||
if (stream.method === 'runtime.clientEvents.subscribe') {
|
||||
sendEncrypted({
|
||||
id: nextId(),
|
||||
deviceToken,
|
||||
method: 'runtime.clientEvents.unsubscribe',
|
||||
params: { subscriptionId: stream.subscriptionId }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
openConnection()
|
||||
|
||||
return {
|
||||
@@ -1120,6 +1151,10 @@ export function connect(
|
||||
disposeBrowserScreencastStream(id)
|
||||
return
|
||||
}
|
||||
if (stream?.method === 'runtime.clientEvents.subscribe') {
|
||||
disposeRuntimeClientEventsStream(id)
|
||||
return
|
||||
}
|
||||
if (stream?.method === 'terminal.subscribe') {
|
||||
// Why: the runtime registers cleanup under the composite key
|
||||
// `${terminal}:${clientId}` so two phones subscribing to the same
|
||||
@@ -1243,7 +1278,7 @@ function isTerminalSubscribedResult(
|
||||
)
|
||||
}
|
||||
|
||||
function isBrowserScreencastReadyResult(
|
||||
function isStreamingSubscriptionReadyResult(
|
||||
value: unknown
|
||||
): value is { type: 'ready'; subscriptionId: string } {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
// Mobile stays on TypeScript 6 so Expo/Metro can still use the JS
|
||||
// compiler API (require('typescript').sys) during release bundling.
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"strict": true,
|
||||
// TypeScript 7 resolves path targets relative to this config without baseUrl.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"tweetnacl": ["./node_modules/tweetnacl"],
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Collections.Specialized;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
internal static class OrcaCliLauncher
|
||||
{
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
string launcherDirectory = Path.GetDirectoryName(typeof(OrcaCliLauncher).Assembly.Location);
|
||||
string resourcesDirectory = Directory.GetParent(launcherDirectory).FullName;
|
||||
string appDirectory = Directory.GetParent(resourcesDirectory).FullName;
|
||||
string electronPath = Path.Combine(appDirectory, "Orca.exe");
|
||||
string cliPath = Path.Combine(
|
||||
resourcesDirectory,
|
||||
"app.asar.unpacked",
|
||||
"out",
|
||||
"cli",
|
||||
"index.js"
|
||||
);
|
||||
|
||||
if (!File.Exists(electronPath))
|
||||
{
|
||||
Console.Error.WriteLine("Unable to locate Orca.exe next to \"{0}\"", resourcesDirectory);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!File.Exists(cliPath))
|
||||
{
|
||||
Console.Error.WriteLine("Unable to locate the Orca CLI entrypoint at \"{0}\"", cliPath);
|
||||
return 1;
|
||||
}
|
||||
|
||||
ProcessStartInfo startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = electronPath,
|
||||
Arguments = BuildArguments(cliPath, args),
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
// Why: launching without cmd.exe preserves embedded newlines while matching the
|
||||
// packaged batch launcher's Electron-as-Node environment contract.
|
||||
MoveEnvironmentVariable(startInfo.EnvironmentVariables, "NODE_OPTIONS", "ORCA_NODE_OPTIONS");
|
||||
MoveEnvironmentVariable(
|
||||
startInfo.EnvironmentVariables,
|
||||
"NODE_REPL_EXTERNAL_MODULE",
|
||||
"ORCA_NODE_REPL_EXTERNAL_MODULE"
|
||||
);
|
||||
startInfo.EnvironmentVariables["ELECTRON_RUN_AS_NODE"] = "1";
|
||||
|
||||
using (Process child = Process.Start(startInfo))
|
||||
{
|
||||
child.WaitForExit();
|
||||
return child.ExitCode;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.Error.WriteLine("Unable to start the Orca CLI: {0}", error.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void MoveEnvironmentVariable(
|
||||
StringDictionary environment,
|
||||
string sourceName,
|
||||
string targetName
|
||||
)
|
||||
{
|
||||
string value = Environment.GetEnvironmentVariable(sourceName);
|
||||
environment.Remove(sourceName);
|
||||
environment.Remove(targetName);
|
||||
if (value != null)
|
||||
{
|
||||
environment[targetName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildArguments(string cliPath, string[] args)
|
||||
{
|
||||
StringBuilder commandLine = new StringBuilder(QuoteArgument(cliPath));
|
||||
foreach (string arg in args)
|
||||
{
|
||||
commandLine.Append(' ');
|
||||
commandLine.Append(QuoteArgument(arg));
|
||||
}
|
||||
return commandLine.ToString();
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
bool requiresQuotes = value.Length == 0;
|
||||
for (int index = 0; index < value.Length && !requiresQuotes; index += 1)
|
||||
{
|
||||
requiresQuotes = value[index] == '"' || Char.IsWhiteSpace(value[index]);
|
||||
}
|
||||
if (!requiresQuotes)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
StringBuilder quoted = new StringBuilder("\"");
|
||||
int backslashCount = 0;
|
||||
foreach (char character in value)
|
||||
{
|
||||
if (character == '\\')
|
||||
{
|
||||
backslashCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character == '"')
|
||||
{
|
||||
quoted.Append('\\', backslashCount * 2 + 1);
|
||||
quoted.Append('"');
|
||||
}
|
||||
else
|
||||
{
|
||||
quoted.Append('\\', backslashCount);
|
||||
quoted.Append(character);
|
||||
}
|
||||
backslashCount = 0;
|
||||
}
|
||||
|
||||
quoted.Append('\\', backslashCount * 2);
|
||||
quoted.Append('"');
|
||||
return quoted.ToString();
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.135-rc.5",
|
||||
"version": "1.4.138-rc.2",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -93,6 +93,8 @@
|
||||
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs",
|
||||
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs",
|
||||
"bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs",
|
||||
"bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tools/benchmarks/terminal-cold-park-reveal-bench.mjs",
|
||||
"bench:cold-park-resource": "pnpm run ensure:electron-runtime && node tools/benchmarks/terminal-cold-park-resource-bench.mjs",
|
||||
"bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -177,6 +179,7 @@
|
||||
"lint-staged": "^16.4.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"oxfmt": "^0.52.0",
|
||||
|
||||
Generated
+35
-166
@@ -15,7 +15,7 @@ patchedDependencies:
|
||||
hash: 81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9
|
||||
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
|
||||
'@xterm/addon-webgl@0.20.0-beta.286':
|
||||
hash: c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19
|
||||
hash: 1d4557ed6d14437403023085c037053bbc6b8566c4b08f1f712232eb3269ca78
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
|
||||
@@ -214,7 +214,7 @@ importers:
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287)
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19)(@xterm/xterm@6.1.0-beta.287)
|
||||
version: 0.20.0-beta.286(patch_hash=1d4557ed6d14437403023085c037053bbc6b8566c4b08f1f712232eb3269ca78)(@xterm/xterm@6.1.0-beta.287)
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
@@ -263,6 +263,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.577.0
|
||||
version: 0.577.0(react@19.2.5)
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.6
|
||||
mermaid:
|
||||
specifier: ^11.15.0
|
||||
version: 11.15.0
|
||||
@@ -399,10 +402,6 @@ packages:
|
||||
'@antfu/install-pkg@1.1.0':
|
||||
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -415,10 +414,6 @@ packages:
|
||||
resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/generator@7.29.1':
|
||||
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/generator@7.29.7':
|
||||
resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -437,10 +432,6 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0
|
||||
|
||||
'@babel/helper-globals@7.28.0':
|
||||
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-globals@7.29.7':
|
||||
resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -449,20 +440,10 @@ packages:
|
||||
resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-module-imports@7.28.6':
|
||||
resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-module-imports@7.29.7':
|
||||
resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-module-transforms@7.28.6':
|
||||
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0
|
||||
|
||||
'@babel/helper-module-transforms@7.29.7':
|
||||
resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -487,26 +468,14 @@ packages:
|
||||
resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-option@7.27.1':
|
||||
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-option@7.29.7':
|
||||
resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -515,11 +484,6 @@ packages:
|
||||
resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.3':
|
||||
resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -577,26 +541,14 @@ packages:
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/traverse@7.29.0':
|
||||
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/traverse@7.29.7':
|
||||
resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -3457,6 +3409,7 @@ packages:
|
||||
|
||||
boolean@3.2.0:
|
||||
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
brace-expansion@1.1.14:
|
||||
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
|
||||
@@ -4354,14 +4307,6 @@ packages:
|
||||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
fs-extra@11.3.4:
|
||||
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fs-extra@11.3.5:
|
||||
resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fs-extra@11.3.6:
|
||||
resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==}
|
||||
engines: {node: '>=14.14'}
|
||||
@@ -4446,6 +4391,7 @@ packages:
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
global-agent@3.0.0:
|
||||
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
|
||||
@@ -4642,6 +4588,7 @@ packages:
|
||||
|
||||
inflight@1.0.6:
|
||||
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
||||
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
@@ -4967,6 +4914,7 @@ packages:
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
@@ -5330,6 +5278,7 @@ packages:
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch@3.3.2:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
@@ -6740,12 +6689,6 @@ snapshots:
|
||||
package-manager-detector: 1.6.0
|
||||
tinyexec: 1.1.2
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
js-tokens: 4.0.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
@@ -6774,14 +6717,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/generator@7.29.1':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jsesc: 3.1.0
|
||||
|
||||
'@babel/generator@7.29.7':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
@@ -6792,7 +6727,7 @@ snapshots:
|
||||
|
||||
'@babel/helper-annotate-as-pure@7.27.3':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/helper-compilation-targets@7.29.7':
|
||||
dependencies:
|
||||
@@ -6810,26 +6745,17 @@ snapshots:
|
||||
'@babel/helper-optimise-call-expression': 7.27.1
|
||||
'@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/traverse': 7.29.7
|
||||
semver: 6.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-globals@7.28.0': {}
|
||||
|
||||
'@babel/helper-globals@7.29.7': {}
|
||||
|
||||
'@babel/helper-member-expression-to-functions@7.28.5':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-imports@7.28.6':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6840,15 +6766,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-module-imports': 7.28.6
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
'@babel/traverse': 7.29.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
@@ -6860,7 +6777,7 @@ snapshots:
|
||||
|
||||
'@babel/helper-optimise-call-expression@7.27.1':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/helper-plugin-utils@7.28.6': {}
|
||||
|
||||
@@ -6869,27 +6786,21 @@ snapshots:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-member-expression-to-functions': 7.28.5
|
||||
'@babel/helper-optimise-call-expression': 7.27.1
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/traverse': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-option@7.27.1': {}
|
||||
|
||||
'@babel/helper-validator-option@7.29.7': {}
|
||||
|
||||
'@babel/helpers@7.29.7':
|
||||
@@ -6897,10 +6808,6 @@ snapshots:
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/parser@7.29.3':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/parser@7.29.7':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
@@ -6923,7 +6830,7 @@ snapshots:
|
||||
'@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -6953,7 +6860,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
'@babel/helper-validator-option': 7.27.1
|
||||
'@babel/helper-validator-option': 7.29.7
|
||||
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7)
|
||||
@@ -6962,30 +6869,12 @@ snapshots:
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/template@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/traverse@7.29.0':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/generator': 7.29.1
|
||||
'@babel/helper-globals': 7.28.0
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.29.0
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/traverse@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -6998,11 +6887,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
@@ -7127,7 +7011,7 @@ snapshots:
|
||||
fs-extra: 10.1.0
|
||||
isbinaryfile: 4.0.10
|
||||
minimist: 1.2.8
|
||||
plist: 3.1.0
|
||||
plist: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7148,9 +7032,9 @@ snapshots:
|
||||
'@malept/cross-spawn-promise': 2.0.0
|
||||
debug: 4.4.3
|
||||
dir-compare: 4.2.0
|
||||
fs-extra: 11.3.4
|
||||
fs-extra: 11.3.6
|
||||
minimatch: 9.0.9
|
||||
plist: 3.1.0
|
||||
plist: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -8992,24 +8876,24 @@ snapshots:
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
'@types/babel__generator': 7.27.0
|
||||
'@types/babel__template': 7.4.4
|
||||
'@types/babel__traverse': 7.28.0
|
||||
|
||||
'@types/babel__generator@7.27.0':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@types/babel__template@7.4.4':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@types/babel__traverse@7.28.0':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@types/cacheable-request@6.0.3':
|
||||
dependencies:
|
||||
@@ -9369,8 +9253,7 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xmldom/xmldom@0.9.10':
|
||||
optional: true
|
||||
'@xmldom/xmldom@0.9.10': {}
|
||||
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
|
||||
dependencies:
|
||||
@@ -9398,7 +9281,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19)(@xterm/xterm@6.1.0-beta.287)':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=1d4557ed6d14437403023085c037053bbc6b8566c4b08f1f712232eb3269ca78)(@xterm/xterm@6.1.0-beta.287)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287
|
||||
|
||||
@@ -10588,24 +10471,11 @@ snapshots:
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
|
||||
fs-extra@11.3.4:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
|
||||
fs-extra@11.3.5:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
|
||||
fs-extra@11.3.6:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
optional: true
|
||||
|
||||
fs-extra@7.0.1:
|
||||
dependencies:
|
||||
@@ -12019,7 +11889,7 @@ snapshots:
|
||||
|
||||
parse-json@5.2.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/code-frame': 7.29.7
|
||||
error-ex: 1.3.4
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
lines-and-columns: 1.2.4
|
||||
@@ -12089,7 +11959,6 @@ snapshots:
|
||||
'@xmldom/xmldom': 0.9.10
|
||||
base64-js: 1.5.1
|
||||
xmlbuilder: 15.1.1
|
||||
optional: true
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
@@ -12689,7 +12558,7 @@ snapshots:
|
||||
shadcn@4.7.0(@types/node@25.6.0)(typescript@7.0.2):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)
|
||||
'@dotenvx/dotenvx': 1.66.0
|
||||
@@ -12703,7 +12572,7 @@ snapshots:
|
||||
diff: 8.0.4
|
||||
execa: 9.6.1
|
||||
fast-glob: 3.3.3
|
||||
fs-extra: 11.3.5
|
||||
fs-extra: 11.3.6
|
||||
fuzzysort: 3.1.0
|
||||
https-proxy-agent: 7.0.6
|
||||
kleur: 4.1.5
|
||||
@@ -12788,7 +12657,7 @@ snapshots:
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
semver: 7.8.1
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
for %%I in ("%SCRIPT_DIR%..") do set "RESOURCES_DIR=%%~fI"
|
||||
REM Why: once %%~fI canonicalizes RESOURCES_DIR it no longer ends with a slash,
|
||||
REM so Windows batch needs an explicit "\.." segment here. Without it the CLI
|
||||
REM launcher resolves APP_DIR back to resources/ and `orca open` cannot find
|
||||
REM Orca.exe on packaged Windows installs.
|
||||
for %%I in ("%RESOURCES_DIR%\..") do set "APP_DIR=%%~fI"
|
||||
set "ELECTRON=%APP_DIR%\Orca.exe"
|
||||
set "LAUNCHER=%SCRIPT_DIR%orca.exe"
|
||||
|
||||
if not exist "%ELECTRON%" (
|
||||
echo Unable to locate Orca.exe next to "%RESOURCES_DIR%" 1>&2
|
||||
if not exist "%LAUNCHER%" (
|
||||
echo Unable to locate the native Orca CLI launcher at "%LAUNCHER%" 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Why: Orca packages the CLI entrypoint outside app.asar so the public shell
|
||||
REM command can execute it directly with ELECTRON_RUN_AS_NODE instead of
|
||||
REM depending on a separately installed Node CLI.
|
||||
set "CLI=%RESOURCES_DIR%\app.asar.unpacked\out\cli\index.js"
|
||||
REM Why: cmd.exe reparses %%* and can execute/truncate embedded newlines. The
|
||||
REM native launcher is the supported path for orchestration message bodies.
|
||||
if /I "%~1"=="orchestration" if /I "%~2"=="send" goto :unsafe_body
|
||||
if /I "%~1"=="orchestration" if /I "%~2"=="reply" goto :unsafe_body
|
||||
|
||||
set "ORCA_NODE_OPTIONS=%NODE_OPTIONS%"
|
||||
set "ORCA_NODE_REPL_EXTERNAL_MODULE=%NODE_REPL_EXTERNAL_MODULE%"
|
||||
set NODE_OPTIONS=
|
||||
set NODE_REPL_EXTERNAL_MODULE=
|
||||
set ELECTRON_RUN_AS_NODE=1
|
||||
"%LAUNCHER%" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
"%ELECTRON%" "%CLI%" %*
|
||||
:unsafe_body
|
||||
echo orca.cmd cannot safely forward orchestration message bodies. Use "%LAUNCHER%" instead. 1>&2
|
||||
exit /b 2
|
||||
|
||||
@@ -37,6 +37,8 @@ orca computer click --app com.spotify.client --element-index 42 --json
|
||||
|
||||
Use the fresh state returned by each action for the next element index. Element indexes are the numeric labels shown in the tree; they may be sparse when noisy sections are omitted, so never infer valid indexes from `elementCount` or "Visible elements." Element indexes are short-lived and go stale after delays, navigation, focus changes, scrolling, window changes, or app re-rendering.
|
||||
|
||||
In `--json` output, read the accessibility tree and action indexes from `result.snapshot.treeText`; `elementCount` is only a count and must not be used to infer indexes.
|
||||
|
||||
## App Selectors
|
||||
|
||||
Prefer bundle IDs from `list-apps`; names are acceptable when unambiguous. Use `pid:<number>` only when bundle ID or name matching is ambiguous.
|
||||
|
||||
+30
-19
@@ -15,7 +15,7 @@ description: >-
|
||||
|
||||
# Orca CLI
|
||||
|
||||
Use `orca` when Orca's running editor/runtime is the source of truth. On Linux, use `orca-ide` wherever this file says `orca`.
|
||||
Use `orca` when Orca's running editor/runtime is the source of truth. Inside Orca-managed terminals, `orca` always resolves to the Orca CLI on every platform. In any other shell on Linux, use `orca-ide` wherever this file says `orca` — outside Orca's terminals, bare `orca` on Linux is usually the GNOME Orca screen reader (`/usr/bin/orca`), and running it starts speech on the user's machine.
|
||||
|
||||
**Dev builds (`pnpm dev`):** after `pnpm build:cli`, the dev CLI is exposed as `orca-dev` (the global shim points at this checkout's wrapper + out/cli). Inside a dev Orca's terminals use `orca-dev emulator ...` (or `./config/scripts/orca-dev.mjs emulator ...` for worktree-local invocation that does not depend on the /usr/local/bin symlink). Plain `orca` targets any installed production Orca. The app's own agent preambles use `orca-dev` automatically in dev mode.
|
||||
|
||||
@@ -24,7 +24,9 @@ Use plain shell tools when Orca state does not matter.
|
||||
## Start Here
|
||||
|
||||
```bash
|
||||
command -v orca || command -v orca-ide
|
||||
# Prefer orca-ide first: on Linux, a bare `orca` hit outside an Orca-managed
|
||||
# terminal is likely the GNOME screen reader, not the Orca CLI.
|
||||
command -v orca-ide || command -v orca
|
||||
orca status --json
|
||||
orca worktree ps --json
|
||||
orca terminal list --json
|
||||
@@ -55,11 +57,15 @@ Use `--no-parent` and omit `--base-branch` for independent top-level handoffs un
|
||||
|
||||
Custom Codex model/effort handoff:
|
||||
|
||||
`worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. For requests such as `gpt-5.5 xhigh`, create the independent worktree, launch the requested Codex command there, wait only for TUI readiness if needed to avoid losing input, send the prompt, and stop:
|
||||
`worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. For requests such as `gpt-5.5 xhigh`, create the independent worktree, launch the requested Codex command there, wait only for TUI readiness if needed to avoid losing input, send the prompt, and stop.
|
||||
|
||||
**Extra first terminal:** when no repo default-terminal configuration supplies a primary terminal, bare `worktree create` (no `--agent`) opens a fallback shell before the later `terminal create --command ...` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever the built-in launcher is enough. When custom argv forces the two-step path, target the agent handle only; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.
|
||||
|
||||
The create result's `worktree.id` already contains both pieces Orca needs: `<repoId>::<worktreePath>`. Copy that whole value into the next command; do not shorten it to the repo id.
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --no-parent --json
|
||||
orca terminal create --worktree id:<newWorktreeId> --title <task-name> --command 'codex --model gpt-5.5 -c model_reasoning_effort="xhigh"' --json
|
||||
orca terminal create --worktree id:<repoId>::<newWorktreePath> --title <task-name> --command 'codex --model gpt-5.5 -c model_reasoning_effort="xhigh"' --json
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca terminal send --terminal <handle> --text "<task brief>" --enter --json
|
||||
```
|
||||
@@ -74,6 +80,8 @@ orca terminal send --terminal <handle> --text "<task brief>" --enter --json
|
||||
|
||||
An Orca worktree is Orca's tracked view of a repo checkout, its metadata, terminals, browser tabs, and UI state.
|
||||
|
||||
Think of its id as a two-part address: `<repoId>::<worktreePath>`. For example, `repo-123::/Users/me/orca/fix-login` means “the `fix-login` checkout inside repo `repo-123`.” Always copy the complete `id` field from `orca worktree create --json` or `orca worktree list --json`; `repo-123` alone identifies only the repo.
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
@@ -91,23 +99,24 @@ orca worktree create --repo id:<repoId> --name related-task --parent-worktree ac
|
||||
orca worktree create --repo id:<repoId> --name folder-child --parent-worktree folder:<folderId> --json
|
||||
orca worktree create --name child-task --agent codex --prompt "hi" --json
|
||||
orca worktree create --name independent-task --no-parent --json
|
||||
orca worktree set --worktree id:<worktreeId> --display-name "My Task" --json
|
||||
orca worktree set --worktree id:<repoId>::<worktreePath> --display-name "My Task" --json
|
||||
orca worktree set --worktree active --comment "reproduced bug; testing fix" --json
|
||||
orca worktree set --worktree active --workspace-status in-review --json
|
||||
orca worktree rm --worktree id:<worktreeId> --force --json
|
||||
orca worktree rm --worktree id:<repoId>::<worktreePath> --force --json
|
||||
```
|
||||
|
||||
Selectors:
|
||||
|
||||
- `id:<worktreeId>`, `name:<displayName>`, `path:<absolutePath>`, `branch:<branchName>`, `issue:<number>`
|
||||
- `id:<repoId>::<worktreePath>`, `name:<displayName>`, `path:<absolutePath>`, `branch:<branchName>`, `issue:<number>`
|
||||
- The full id is the exact `<repo-id>::<path>` value returned by `orca worktree create --json` or `orca worktree list --json`; a bare repo id is not a worktree id.
|
||||
- `active` / `current` for the enclosing Orca-managed worktree from the shell cwd
|
||||
- For `worktree create --parent-worktree` only, folder/worktree parent context keys are also valid: `folder:<folderId>`, `worktree:<worktreeId>`, `id:folder:<folderId>`, `id:worktree:<worktreeId>`
|
||||
- For `worktree create --parent-worktree` only, folder/worktree parent context keys are also valid: `folder:<folderId>`, `worktree:<repoId>::<worktreePath>`, `id:folder:<folderId>`, `id:worktree:<repoId>::<worktreePath>`
|
||||
|
||||
Lineage rules:
|
||||
|
||||
- When creating from inside an Orca-managed worktree or folder context, Orca infers the current parent context when it can.
|
||||
- Use `--parent-worktree active` when the child worktree relationship should be explicit.
|
||||
- Use `--parent-worktree folder:<folderId>` or `--parent-worktree worktree:<worktreeId>` when a folder or worktree parent context should be explicit.
|
||||
- Use `--parent-worktree folder:<folderId>` or `--parent-worktree worktree:<repoId>::<worktreePath>` when a folder or worktree parent context should be explicit.
|
||||
- Use `--no-parent` only when the new work is independent.
|
||||
- `--no-parent` only controls Orca lineage; it does not choose the Git base. For independent top-level work, omit `--base-branch` so Orca uses the repo default base, or explicitly pass the repo default base. Never base it on the current feature branch unless the user asks for stacked work or "branch from current".
|
||||
- If `--repo` is omitted, Orca infers the repo from the current Orca worktree when possible.
|
||||
@@ -121,13 +130,15 @@ orca worktree create --name task --setup skip --json
|
||||
orca worktree create --name task --run-hooks --json
|
||||
```
|
||||
|
||||
- `--agent <id>` launches that agent in the first terminal; `--prompt <text>` sends initial work to it.
|
||||
- `--agent <id>` launches that agent **in the first terminal** (Orca docs: *"`--agent` launches the selected agent in the first terminal"*); `--prompt <text>` sends initial work to it. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed TUI agents.
|
||||
- **Prefer agent-first create for agent workers.** `orca worktree create --agent <id> --prompt "..."` puts the agent in the worktree's first terminal without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Without configured default tabs, the bare-create fallback shell plus a later `terminal create --command <agent>` is an anti-pattern for ordinary agent worktrees — use `--agent` instead of “create worktree, then open agent.” Configured default tabs are intentional surfaces; never treat one as disposable without verifying that it is an unused shell.
|
||||
- After create, use exactly one agent handle: `startupTerminal.handle` from the create response when present, or the matching result from `orca terminal list --worktree id:<repoId>::<newWorktreePath> --json` (or `name:<displayName>`) when the response omits it. If a handle later returns `terminal_handle_stale`, re-list it; never dual-send to old and replacement handles.
|
||||
- `--setup run|skip|inherit` controls repo setup hooks. Default is `inherit`, which follows the repo's setup policy.
|
||||
- `--run-hooks` is a legacy alias for `--setup run`; it also reveals/activates the new worktree.
|
||||
- `--agent`, `--activate`, and `--run-hooks` reveal the new worktree. Plain create stays in the background.
|
||||
- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior. Do not manually create extra setup terminals.
|
||||
- If an older installed CLI rejects `--agent`, `--prompt`, or `--setup`, create the worktree normally, then run `orca terminal create --worktree <selector> --command "codex"` and `orca terminal send` if a prompt is needed.
|
||||
- `worktree create` creates a new checkout. For a fresh agent in the current checkout, use `orca terminal create --worktree active --command "codex" --json`.
|
||||
- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior. Do not manually create extra setup terminals when `--agent` already owns the first tab.
|
||||
- If an older installed CLI rejects `--agent`, `--prompt`, or `--setup`, create the worktree normally, then run `orca terminal create --worktree <selector> --command "<requested-agent>"` and `orca terminal send` if a prompt is needed. This can leave a fallback shell when no default tabs are configured; close it only after confirming it is unused.
|
||||
- `worktree create` creates a new checkout. For a fresh agent in the **current** checkout (no new worktree), use `orca terminal create --worktree active --command "codex" --json` — that path does not create a second worktree shell.
|
||||
|
||||
## Worktree Comments
|
||||
|
||||
@@ -148,7 +159,7 @@ Card status uses `--workspace-status <id>`; defaults are `todo`, `in-progress`,
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
orca terminal list --worktree id:<worktreeId> --json
|
||||
orca terminal list --worktree id:<repoId>::<worktreePath> --json
|
||||
orca terminal show --terminal <handle> --json
|
||||
orca terminal read --terminal <handle> --json
|
||||
orca terminal read --terminal <handle> --cursor <cursor> --limit 1000 --json
|
||||
@@ -157,7 +168,7 @@ orca terminal send --terminal <handle> --text "continue" --enter --json
|
||||
orca terminal send --text "echo hello" --enter --json
|
||||
orca terminal wait --terminal <handle> --for exit --timeout-ms 5000 --json
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 300000 --json
|
||||
orca terminal stop --worktree id:<worktreeId> --json
|
||||
orca terminal stop --worktree id:<repoId>::<worktreePath> --json
|
||||
orca terminal create --json
|
||||
orca terminal create --title "Worker" --json
|
||||
orca terminal create --worktree active --command "codex" --json
|
||||
@@ -173,10 +184,10 @@ Terminal rules:
|
||||
- `--terminal` is optional for most commands; omitted means the active terminal in the current worktree.
|
||||
- Use `terminal read` before `terminal send` unless the next input is obvious.
|
||||
- Use `terminal send` only for direct terminal input or one-off prompts where no task state, inbox, or reply tracking is needed.
|
||||
- For structured coordination, invoke the `orchestration` skill; it uses `orca orchestration ...` commands for messages, handoffs, task DAGs, dispatches, inbox/reply flows, and coordinator loops.
|
||||
- Use `terminal create --worktree active --command "<agent>"` for a fresh agent in the current worktree. Use `worktree create --agent <agent>` only for a separate checkout.
|
||||
- Use `terminal wait --for tui-idle` for agent CLIs such as Claude Code, Gemini, and Codex; always pass `--timeout-ms`.
|
||||
- Terminal handles are runtime-scoped. If Orca restarts or returns `terminal_handle_stale`, reacquire with `terminal list`.
|
||||
- For structured coordination, invoke the `orchestration` skill; it uses `orca orchestration ...` commands for messages, handoffs, task DAGs, dispatches, inbox/reply flows, and coordinator loops. A receiving agent can run `orca orchestration check --unread --inject` to render its unread mail in agent-readable form; this checks the caller's inbox and does not remotely deliver input to another terminal.
|
||||
- Use `terminal create --worktree active --command "<agent>"` for a fresh agent in the current worktree. Use `worktree create --agent <agent>` only for a separate checkout (agent in the first terminal — do not also `terminal create` the same agent).
|
||||
- Use `terminal wait --for tui-idle` for agent CLIs such as Claude Code, Gemini, Codex, OMP, Pi, and Grok; always pass `--timeout-ms`.
|
||||
- Terminal handles are runtime-scoped. Use `startupTerminal.handle` as the sole agent handle when `worktree create --agent` returns it; if Orca restarts, omits the handle, or returns `terminal_handle_stale`, reacquire with `terminal list` and continue with the replacement only.
|
||||
- For long output, use cursor reads. After a limited tail preview, page from `oldestCursor`; after a cursor read, continue with `nextCursor` while `limited` is true and `nextCursor !== latestCursor`.
|
||||
- `--direction horizontal` splits left/right. `--direction vertical` splits top/bottom.
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ Most support `--worktree <selector>` and explicit `--device <udid|name>` or `--e
|
||||
## Targeting devices & worktrees
|
||||
|
||||
- Default: current worktree's active emulator (resolved from shell cwd or Orca context).
|
||||
- Explicit worktree: `--worktree id:abc123` or `--worktree active`.
|
||||
- Explicit worktree: `--worktree id:<fullWorktreeId>` or `--worktree active`. The full id is the exact `<repo-id>::<path>` value returned by `orca worktree list --json`; a bare repo id is not valid here.
|
||||
- Explicit device: `--device "iPhone 16 Pro"` or `--device <udid>` (after `list`).
|
||||
- Orca-generated emulator id (for stability, like browserPageId): use `--emulator <id>` returned by list (recommended for scripts that persist ids).
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ Do not use orchestration merely because the user says "hand off", "handoff", "ha
|
||||
|
||||
## Ownership
|
||||
|
||||
Orchestration messages and tasks are runtime-global. Completion authority comes from the active dispatch context: `taskId` + `dispatchId` + assignee handle.
|
||||
Orchestration messages and tasks are runtime-global. Lifecycle authority comes from the payload `taskId` + `dispatchId` of the active dispatch, verified against the dispatched pane. Terminal handles are routing metadata — a pane can receive a new handle after restart — so never accept or reject lifecycle provenance by comparing handles. Send `worker_done` and `heartbeat` from the worker's own terminal; the runtime ignores them when sent from a different pane.
|
||||
|
||||
Classify inherited context before sending lifecycle messages:
|
||||
|
||||
@@ -79,7 +79,7 @@ orca orchestration dispatch-show --task <task_id> --json
|
||||
|
||||
```bash
|
||||
orca orchestration send --to <handle|@group> --subject <text> [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--json]
|
||||
orca orchestration check [--terminal <handle>] [--unread] [--types <type,...>] [--inject] [--wait] [--timeout-ms <n>] [--json]
|
||||
orca orchestration check [--terminal <handle>] [--unread|--peek|--all] [--types <type,...>] [--inject] [--wait] [--timeout-ms <n>] [--json]
|
||||
orca orchestration reply --id <msg_id> --body <text> [--from <handle>] [--json]
|
||||
orca orchestration ask --to <handle> --question <text> [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--json]
|
||||
orca orchestration inbox [--limit <n>] [--json]
|
||||
@@ -88,15 +88,19 @@ orca orchestration inbox [--limit <n>] [--json]
|
||||
Rules:
|
||||
|
||||
- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.
|
||||
- `check` and `check --unread` return unread matches and mark them read. Use `--peek` for unread matches without consuming them; use `--all` for read and unread history without consuming anything. If an older CLI rejects `--peek` as an unknown flag, use `--all` and filter unread rows yourself.
|
||||
- Message **one** live agent handle per worker. Use `startupTerminal.handle` from the create response when present; if it is missing or later returns `terminal_handle_stale`, re-resolve with `orca terminal list --worktree ... --json` and continue with the replacement only.
|
||||
- `orca orchestration check --unread --inject --json` renders unread mail for the agent terminal that runs it; it does not remotely wake another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.
|
||||
- While supervising workers manually, use `check --wait --types worker_done,escalation,decision_gate --timeout-ms <n>` instead of sleep/poll loops. Reply to `decision_gate` messages with `orca orchestration reply --id <msg_id> --body <answer> --json`, then keep waiting.
|
||||
- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.
|
||||
- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.
|
||||
- Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly.
|
||||
- `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion.
|
||||
- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, and `@worktree:<id>`.
|
||||
- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, and `@worktree:<id>`.
|
||||
- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`.
|
||||
- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.
|
||||
- `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals.
|
||||
- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.
|
||||
- `heartbeat` is also dispatch-scoped. Send it only to the concrete coordinator handle with both `taskId` and `dispatchId`; use `status` for broad progress updates.
|
||||
|
||||
## Tasks And Dispatch
|
||||
@@ -105,7 +109,7 @@ A task is the work item, a dispatch assigns it to a terminal, and a gate blocks
|
||||
|
||||
```bash
|
||||
orca orchestration task-create --spec <text> [--deps <json_array>] [--parent <task_id>] [--json]
|
||||
orca orchestration task-list [--status <status>] [--ready] [--json]
|
||||
orca orchestration task-list [--status <status>] [--ready] [--brief] [--json]
|
||||
orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--json]
|
||||
orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--inject] [--json]
|
||||
orca orchestration dispatch-show --task <task_id> [--json]
|
||||
@@ -118,6 +122,7 @@ Dispatch rules:
|
||||
- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.
|
||||
- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal <handle> --text <prompt> --enter --json`.
|
||||
- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.
|
||||
- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.
|
||||
|
||||
## Gates And Coordinator
|
||||
|
||||
@@ -159,11 +164,15 @@ orca terminal send --terminal <handle> --text "<task brief>" --enter --json
|
||||
|
||||
Custom Codex model/effort handoff:
|
||||
|
||||
`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop:
|
||||
`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.
|
||||
|
||||
Note: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.
|
||||
|
||||
Use the exact full `<repo-id>::<path>` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --no-parent --json
|
||||
orca terminal create --worktree id:<newWorktreeId> --title <task-name> --command 'codex --model gpt-5.5 -c model_reasoning_effort="xhigh"' --json
|
||||
orca terminal create --worktree id:<newFullWorktreeId> --title <task-name> --command 'codex --model gpt-5.5 -c model_reasoning_effort="xhigh"' --json
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca terminal send --terminal <handle> --text "<task brief>" --enter --json
|
||||
```
|
||||
@@ -186,12 +195,15 @@ Reuse an idle agent in the required worktree only if the prompt allows reuse; ot
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --agent codex --json
|
||||
orca terminal list --worktree id:<newWorktreeId> --json
|
||||
# or: --agent claude | omp | pi | grok | ...
|
||||
# Read <handle> from startupTerminal.handle in the create response.
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
```
|
||||
|
||||
For new-worktree workers, read the id from `worktree create`, then use `terminal list` to get the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo <selector>`. `--agent` reveals the new worktree and launches the selected agent in its first terminal, so do not create a separate startup terminal. Do not run `worktree create` when the task must stay in the current worktree.
|
||||
For new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo <selector>`.
|
||||
|
||||
**Agent-first (required for ordinary agent workers):** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command <agent>` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.
|
||||
|
||||
Use `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.
|
||||
|
||||
@@ -214,7 +226,7 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- Workers with a valid live preamble must send `worker_done` exactly once, even on failure:
|
||||
- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal, even on failure:
|
||||
`orca orchestration send --to <coordinator_handle> --type worker_done --subject "<short status>" --body "<3-sentence summary: what you did, what you found, what's left>" --payload '{"taskId":"<task_id>","dispatchId":"<dispatch_id>","filesModified":["path/a"],"reportPath":"<optional>"}' --json`
|
||||
- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.
|
||||
- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:
|
||||
|
||||
@@ -13,6 +13,9 @@ export type CommandSpec = {
|
||||
// or handler registrations.
|
||||
aliases?: string[][]
|
||||
argumentMode?: 'parsed' | 'passthrough'
|
||||
// Why: irreversibly destroys persistent state — typo recovery must not steer a
|
||||
// benign mistake into one of these via the agent nextSteps channel. #6303
|
||||
destructive?: boolean
|
||||
summary: string
|
||||
usage: string
|
||||
allowedFlags: string[]
|
||||
|
||||
@@ -6,7 +6,11 @@ import { levenshtein, suggestCommands, unknownCommandData } from './command-sugg
|
||||
const specs: CommandSpec[] = [
|
||||
{
|
||||
path: ['worktree', 'rm'],
|
||||
aliases: [['worktree', 'remove']],
|
||||
aliases: [
|
||||
['worktree', 'remove'],
|
||||
['worktree', 'delete']
|
||||
],
|
||||
destructive: true,
|
||||
summary: 'Remove a worktree',
|
||||
usage: 'orca worktree rm',
|
||||
allowedFlags: []
|
||||
@@ -22,6 +26,15 @@ const specs: CommandSpec[] = [
|
||||
summary: 'Send input',
|
||||
usage: 'orca terminal send',
|
||||
allowedFlags: []
|
||||
},
|
||||
{
|
||||
// A destructive command outside the delete-family, to prove the guard keys
|
||||
// off the spec flag rather than a hardcoded verb list.
|
||||
path: ['emulator', 'kill'],
|
||||
destructive: true,
|
||||
summary: 'Kill the emulator',
|
||||
usage: 'orca emulator kill',
|
||||
allowedFlags: []
|
||||
}
|
||||
]
|
||||
|
||||
@@ -67,6 +80,37 @@ describe('suggestCommands', () => {
|
||||
const result = suggestCommands(specs, ['terminal', 'sen'])
|
||||
expect(result[0]).toBe('terminal send')
|
||||
})
|
||||
|
||||
it('never suggests a destructive command for a benign non-destructive typo', () => {
|
||||
// `worktree move` sits distance 2 from `worktree remove`; without the
|
||||
// guard it would sole-suggest an irreversible delete on blind retry. #6303
|
||||
const result = suggestCommands(specs, ['worktree', 'move'])
|
||||
expect(result).not.toContain('worktree remove')
|
||||
expect(result).not.toContain('worktree rm')
|
||||
expect(result).not.toContain('worktree delete')
|
||||
})
|
||||
|
||||
it('still suggests remove for a near-miss of a destructive verb', () => {
|
||||
const result = suggestCommands(specs, ['worktree', 'remov'])
|
||||
expect(result).toContain('worktree rm')
|
||||
expect(result).toContain('worktree remove')
|
||||
})
|
||||
|
||||
it('still suggests delete for a near-miss of the delete alias', () => {
|
||||
expect(suggestCommands(specs, ['worktree', 'delet'])).toContain('worktree delete')
|
||||
})
|
||||
|
||||
it('guards destructive commands outside the delete-family via the spec flag', () => {
|
||||
// `emulator ball` is a benign token, distance 2 from the flagged `emulator
|
||||
// kill` — close enough to otherwise rank, so the guard must exclude it.
|
||||
expect(suggestCommands(specs, ['emulator', 'ball'])).not.toContain('emulator kill')
|
||||
// A genuine near-miss of the destructive verb still recovers.
|
||||
expect(suggestCommands(specs, ['emulator', 'kil'])).toContain('emulator kill')
|
||||
})
|
||||
|
||||
it('still recovers non-destructive near-misses', () => {
|
||||
expect(suggestCommands(specs, ['worktree', 'lst'])).toContain('worktree list')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknownCommandData', () => {
|
||||
@@ -82,4 +126,10 @@ describe('unknownCommandData', () => {
|
||||
expect(data.suggestions).toEqual([])
|
||||
expect(data.nextSteps).toEqual([])
|
||||
})
|
||||
|
||||
it('does not route a benign typo into a destructive nextStep', () => {
|
||||
const data = unknownCommandData(specs, ['worktree', 'move'])
|
||||
expect(data.suggestions).not.toContain('worktree remove')
|
||||
expect(data.nextSteps.join(' ')).not.toContain('remove')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,42 @@ import { specPaths } from './args'
|
||||
const SUGGESTION_THRESHOLD = 3
|
||||
const MAX_SUGGESTIONS = 3
|
||||
|
||||
// Why: a close typo of a destructive verb (`remov`→`remove`) still signals that
|
||||
// intent, but `move` (distance 2 from `remove`) does not — keep this at 1 so
|
||||
// genuine recovery works while unrelated verbs stay locked out. #6303
|
||||
const DESTRUCTIVE_INTENT_THRESHOLD = 1
|
||||
|
||||
function finalToken(path: string[]): string {
|
||||
return path.at(-1) ?? ''
|
||||
}
|
||||
|
||||
// Why: destructiveness is declared on the spec (single source of truth); the
|
||||
// intent verbs are the final tokens of every destructive path/alias so the guard
|
||||
// tracks the registry instead of a hand-maintained list.
|
||||
function destructiveVerbs(specs: CommandSpec[]): Set<string> {
|
||||
const verbs = new Set<string>()
|
||||
for (const spec of specs) {
|
||||
if (spec.destructive) {
|
||||
for (const path of specPaths(spec)) {
|
||||
verbs.add(finalToken(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
return verbs
|
||||
}
|
||||
|
||||
// Why: deletion is irreversible and suggestions flow into agents' recovery
|
||||
// channel (--json nextSteps), so only unlock destructive candidates when the
|
||||
// input token is itself a near-miss of a destructive verb. #6303
|
||||
function intendsDestruction(inputToken: string, verbs: Set<string>): boolean {
|
||||
for (const verb of verbs) {
|
||||
if (levenshtein(inputToken, verb) <= DESTRUCTIVE_INTENT_THRESHOLD) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export type CommandErrorData = {
|
||||
suggestions: string[]
|
||||
nextSteps: string[]
|
||||
@@ -47,9 +83,15 @@ function rankByDistance(scored: { label: string; distance: number }[]): string[]
|
||||
// Why: same-depth matching avoids suggesting parent groups or unrelated commands.
|
||||
export function suggestCommands(specs: CommandSpec[], commandPath: string[]): string[] {
|
||||
const input = commandPath.join(' ')
|
||||
// Why: only surface destructive commands when the user actually reached for one;
|
||||
// otherwise a benign typo could recover into an irreversible action. #6303
|
||||
const allowDestructive = intendsDestruction(finalToken(commandPath), destructiveVerbs(specs))
|
||||
const seen = new Set<string>()
|
||||
const scored: { label: string; distance: number }[] = []
|
||||
for (const spec of specs) {
|
||||
if (spec.destructive && !allowDestructive) {
|
||||
continue
|
||||
}
|
||||
const candidates = specPaths(spec).map((path) =>
|
||||
commandPath.length === 1 ? path.slice(0, 1) : path
|
||||
)
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('formatCliError', () => {
|
||||
message: 'Parent selector was not found.',
|
||||
data: {
|
||||
nextSteps: [
|
||||
'Pass a valid --parent-worktree selector such as folder:<id>, worktree:<id>, id:<worktreeId>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
|
||||
'Pass a valid --parent-worktree selector such as folder:<id>, worktree:<worktreeId>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
|
||||
'Retry with --no-parent to create without lineage.',
|
||||
123
|
||||
]
|
||||
@@ -111,7 +111,7 @@ describe('formatCliError', () => {
|
||||
expect(formatCliError(error)).toBe(
|
||||
[
|
||||
'Parent selector was not found.',
|
||||
'Next step: Pass a valid --parent-worktree selector such as folder:<id>, worktree:<id>, id:<worktreeId>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
|
||||
'Next step: Pass a valid --parent-worktree selector such as folder:<id>, worktree:<worktreeId>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
|
||||
'Next step: Retry with --no-parent to create without lineage.'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { printResult } from '../format'
|
||||
|
||||
function staleHandleError(): RuntimeClientError {
|
||||
return new RuntimeClientError('terminal_handle_stale', 'terminal_handle_stale')
|
||||
@@ -135,6 +136,21 @@ describe('orchestration send structured payload flags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards multiline message bodies without normalization', async () => {
|
||||
const body = 'paragraph one line one\nparagraph one line two\n\nparagraph two'
|
||||
|
||||
await invokeSend(
|
||||
new Map<string, string | boolean>([
|
||||
['from', 'term_worker'],
|
||||
['to', 'term_coord'],
|
||||
['subject', 'multiline'],
|
||||
['body', body]
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.send', expect.objectContaining({ body }))
|
||||
})
|
||||
|
||||
it('rejects mixing raw payload with structured payload flags', async () => {
|
||||
await expect(
|
||||
invokeSend(
|
||||
@@ -213,7 +229,7 @@ describe('orchestration send structured payload flags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('continues to use ORCA_TERMINAL_HANDLE as worker lifecycle sender authority', async () => {
|
||||
it('sends lifecycle messages from ORCA_TERMINAL_HANDLE without a liveness probe', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker_env'
|
||||
|
||||
await invokeSend(
|
||||
@@ -238,6 +254,50 @@ describe('orchestration send structured payload flags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['worker_done', 'heartbeat'] as const)(
|
||||
'never probes or remints a %s sender even when a pane key is set',
|
||||
async (type) => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker_env'
|
||||
process.env.ORCA_PANE_KEY = 'tab_worker:leaf_worker'
|
||||
|
||||
await invokeSend(
|
||||
new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['subject', 'update'],
|
||||
['type', type]
|
||||
])
|
||||
)
|
||||
|
||||
// Why: pre-payload-authority runtimes only complete a worker_done whose
|
||||
// sender equals the recorded (equally stale) assignee handle, and
|
||||
// coordinator replies route to the sender row the worker's env-handle
|
||||
// `check` actually reads — so lifecycle sends must stay env-verbatim.
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.send',
|
||||
expect.objectContaining({ from: 'term_worker_env' })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('passes ORCA_PANE_KEY as the sender pane identity', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker_env'
|
||||
process.env.ORCA_PANE_KEY = 'tab_worker:leaf_worker'
|
||||
|
||||
await invokeSend(
|
||||
new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['subject', 'done'],
|
||||
['type', 'worker_done']
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.send',
|
||||
expect.objectContaining({ senderPaneKey: 'tab_worker:leaf_worker' })
|
||||
)
|
||||
})
|
||||
|
||||
it('reports sender resolution failure instead of raw no_active_terminal', async () => {
|
||||
getTerminalHandleMock.mockRejectedValue(
|
||||
new RuntimeClientError('no_active_terminal', 'no_active_terminal')
|
||||
@@ -631,20 +691,24 @@ describe('orchestration timeout flag validation', () => {
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes a parsed check timeout into the RPC payload', async () => {
|
||||
it('passes a parsed check timeout and peek mode into the RPC payload', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({ result: { messages: [], count: 0 } })
|
||||
|
||||
await invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['wait', true],
|
||||
['peek', true],
|
||||
['timeout-ms', '250']
|
||||
])
|
||||
)
|
||||
|
||||
// Why: --peek rides with unread:false so pre-peek runtimes fall back to
|
||||
// the non-consuming all mode instead of the destructive mark-read default.
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
|
||||
terminal: 'term_worker',
|
||||
unread: undefined,
|
||||
unread: false,
|
||||
peek: true,
|
||||
all: undefined,
|
||||
types: undefined,
|
||||
inject: undefined,
|
||||
@@ -653,6 +717,86 @@ describe('orchestration timeout flag validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('filters already-read rows from a peek response for pre-peek runtimes', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
messages: [
|
||||
{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 },
|
||||
{ id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 }
|
||||
],
|
||||
count: 2,
|
||||
formatted: 'banners built from all rows'
|
||||
}
|
||||
})
|
||||
vi.mocked(printResult).mockClear()
|
||||
|
||||
await invokeCheck(new Map<string, string | boolean>([['peek', true]]))
|
||||
|
||||
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { messages: { id: string }[]; count: number; formatted?: string }
|
||||
}
|
||||
expect(response.result.messages.map((m) => m.id)).toEqual(['msg_new'])
|
||||
expect(response.result.count).toBe(1)
|
||||
// Why: the pre-peek runtime built `formatted` from all rows, including
|
||||
// the read one the filter just removed.
|
||||
expect(response.result.formatted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects combined read modes before calling the runtime', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockClear()
|
||||
|
||||
await expect(
|
||||
invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['unread', true],
|
||||
['peek', true]
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: expect.stringContaining('read mode')
|
||||
})
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when a pre-peek runtime returned a full 100-row page', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
const rows = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: `msg_${i}`,
|
||||
from_handle: 'a',
|
||||
subject: `s${i}`,
|
||||
read: i === 0 ? 0 : 1
|
||||
}))
|
||||
callMock.mockResolvedValue({ result: { messages: rows, count: 100 } })
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await invokeCheck(new Map<string, string | boolean>([['peek', true]]))
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages'))
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('fails --peek --wait against a runtime that returned only read rows', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }],
|
||||
count: 1
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['peek', true],
|
||||
['wait', true]
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'peek_wait_unsupported' })
|
||||
})
|
||||
|
||||
it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => {
|
||||
const flags = new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
@@ -697,3 +841,53 @@ describe('orchestration timeout flag validation', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration task-list brief output', () => {
|
||||
it('requests server-side brief and falls back client-side for older runtimes', async () => {
|
||||
callMock.mockReset().mockResolvedValue({
|
||||
result: {
|
||||
// No spec_truncated field — the pre-brief-runtime signature.
|
||||
tasks: [{ id: 'task_1', spec: `First line\n${'detail '.repeat(40)}`, status: 'ready' }],
|
||||
count: 1
|
||||
}
|
||||
})
|
||||
vi.mocked(printResult).mockClear()
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration task-list']({
|
||||
flags: new Map([['brief', true]]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.taskList',
|
||||
expect.objectContaining({ brief: true })
|
||||
)
|
||||
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { tasks: { spec: string; spec_truncated: boolean }[] }
|
||||
}
|
||||
expect(response.result.tasks[0].spec).toHaveLength(160)
|
||||
expect(response.result.tasks[0].spec_truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('passes server-abbreviated rows through untouched', async () => {
|
||||
const serverTasks = [
|
||||
{ id: 'task_1', spec: 'already brief…', status: 'ready', spec_truncated: true }
|
||||
]
|
||||
callMock.mockReset().mockResolvedValue({ result: { tasks: serverTasks, count: 1 } })
|
||||
vi.mocked(printResult).mockClear()
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration task-list']({
|
||||
flags: new Map([['brief', true]]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { tasks: { spec: string; spec_truncated: boolean }[] }
|
||||
}
|
||||
// Why: re-abbreviating a server-truncated spec would flip spec_truncated
|
||||
// back to false (the truncated text fits the cap).
|
||||
expect(response.result.tasks).toBe(serverTasks)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
} from '../flags'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { getTerminalHandle } from '../selectors'
|
||||
import { abbreviateOrchestrationTasks } from '../../shared/orchestration-task-summary'
|
||||
|
||||
// Why: 15 s is well under Claude Code's empirical ~2 min Bash-tool silence
|
||||
// budget and generates only ~40 lines per 10 min wait — enough to assure the
|
||||
// parent process the subprocess is alive without flooding logs. See design
|
||||
// doc §3.4.
|
||||
const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000
|
||||
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
|
||||
function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
|
||||
return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.`
|
||||
}
|
||||
@@ -21,33 +22,36 @@ function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): str
|
||||
// Why: test-only escape hatch so subprocess tests can verify the feature in
|
||||
// under 10 s rather than needing a full 15 s silence window. Production users
|
||||
// should never set this — there is no surface documentation. A bogus value
|
||||
// falls back to the default rather than disabling the heartbeat.
|
||||
function resolveHeartbeatIntervalMs(): number {
|
||||
const raw = process.env.ORCA_HEARTBEAT_INTERVAL_MS
|
||||
// falls back to the default rather than disabling the keepalive.
|
||||
function resolveKeepaliveIntervalMs(): number {
|
||||
const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS
|
||||
if (!raw) {
|
||||
return DEFAULT_HEARTBEAT_INTERVAL_MS
|
||||
return DEFAULT_KEEPALIVE_INTERVAL_MS
|
||||
}
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DEFAULT_HEARTBEAT_INTERVAL_MS
|
||||
return DEFAULT_KEEPALIVE_INTERVAL_MS
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function startCheckHeartbeat(deadlineMs: number | undefined): () => void {
|
||||
function startCheckKeepalive(deadlineMs: number | undefined): () => void {
|
||||
const startedAt = Date.now()
|
||||
const interval = setInterval(() => {
|
||||
const payload = {
|
||||
_keepalive: true,
|
||||
// Why: retain the old marker for scripts filtering merged stderr while
|
||||
// callers migrate to the unambiguous _keepalive field.
|
||||
_heartbeat: true,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
deadlineMs: deadlineMs ?? null
|
||||
}
|
||||
// Why: `process.stderr.write` is line-flushed per-call in Node, whereas a
|
||||
// fully-buffered writer would hold all heartbeat lines until exit and
|
||||
// fully-buffered writer would hold all keepalive lines until exit and
|
||||
// silently defeat the whole point of the ping. Subprocess test asserts
|
||||
// this by reading stderr incrementally. See §3.4.
|
||||
process.stderr.write(`${JSON.stringify(payload)}\n`)
|
||||
}, resolveHeartbeatIntervalMs())
|
||||
}, resolveKeepaliveIntervalMs())
|
||||
if (typeof interval.unref === 'function') {
|
||||
interval.unref()
|
||||
}
|
||||
@@ -73,6 +77,7 @@ type MessageSummary = {
|
||||
type?: string
|
||||
body?: string
|
||||
payload?: string | null
|
||||
read?: number
|
||||
}
|
||||
|
||||
function getOptionalStructuredMessagePayload(
|
||||
@@ -142,7 +147,11 @@ async function resolveOrchestrationTerminalHandle(
|
||||
// coordinator preambles.
|
||||
const live = await isLiveTerminalHandle(envHandle, client)
|
||||
if (!live) {
|
||||
return await resolveStaleOrchestrationSender(client)
|
||||
const reminted = await resolveOrchestrationPaneTerminalHandle(client)
|
||||
if (reminted) {
|
||||
return reminted
|
||||
}
|
||||
throwNoActiveSenderTerminal()
|
||||
}
|
||||
}
|
||||
return envHandle
|
||||
@@ -268,16 +277,6 @@ function getClientErrorMessage(err: unknown): string | undefined {
|
||||
return typeof message === 'string' ? message : undefined
|
||||
}
|
||||
|
||||
async function resolveStaleOrchestrationSender(
|
||||
client: Parameters<CommandHandler>[0]['client']
|
||||
): Promise<string> {
|
||||
const paneHandle = await resolveOrchestrationPaneTerminalHandle(client)
|
||||
if (paneHandle) {
|
||||
return paneHandle
|
||||
}
|
||||
throwNoActiveSenderTerminal()
|
||||
}
|
||||
|
||||
async function resolveCoordinatorTerminalHandle(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
@@ -348,6 +347,12 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
const type = getOptionalStringFlag(flags, 'type')
|
||||
rejectLifecycleGroupRecipient(type, to)
|
||||
|
||||
// Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness
|
||||
// probe (terminal.show throws runtime_unavailable in the exact mid-restart
|
||||
// window worker_done must survive) and no pane remint (pre-payload-
|
||||
// authority runtimes require from === the equally stale assignee_handle,
|
||||
// and coordinator replies route to the sender row while the worker's own
|
||||
// `check` reads its env-handle inbox).
|
||||
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
|
||||
const result = await client.call<
|
||||
{ message: { id: string } } | { messages: { id: string }[]; recipients: number }
|
||||
@@ -360,6 +365,9 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
priority: getOptionalStringFlag(flags, 'priority'),
|
||||
threadId: getOptionalStringFlag(flags, 'thread-id'),
|
||||
payload: getOptionalStructuredMessagePayload(flags),
|
||||
// Why: the pane key is the remint-stable sender identity the runtime
|
||||
// verifies lifecycle ownership against; older runtimes strip it.
|
||||
senderPaneKey: process.env.ORCA_PANE_KEY || undefined,
|
||||
devMode: isDevCliInvocation()
|
||||
})
|
||||
printResult(result, json, (r) => {
|
||||
@@ -372,17 +380,27 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
|
||||
'orchestration check': async ({ flags, client, cwd, json }) => {
|
||||
const wait = flags.has('wait')
|
||||
const peek = flags.has('peek')
|
||||
// Why: enforce mode exclusivity client-side too — an older runtime strips
|
||||
// the unknown `peek` param and would otherwise execute --unread --peek as
|
||||
// a destructive mark-read.
|
||||
if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Choose at most one message read mode: --unread, --peek, or --all.'
|
||||
)
|
||||
}
|
||||
const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms')
|
||||
const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal')
|
||||
|
||||
// Why: Claude Code's Bash tool auto-backgrounds subprocesses that produce
|
||||
// no output for ~2 min (shorter on the non-interactive path). Emit a
|
||||
// heartbeat line to stderr every HEARTBEAT_INTERVAL_MS while the wait is
|
||||
// keepalive line to stderr every KEEPALIVE_INTERVAL_MS while the wait is
|
||||
// active so the parent process can see the subprocess is still alive.
|
||||
// Stderr rather than stdout so stdout stays a single final JSON payload,
|
||||
// and JSON-shaped rather than `# …` so `2>&1 | jq` pipelines still work
|
||||
// (jq refuses `#`-prefixed lines). See design doc §3.4.
|
||||
const stopHeartbeat = wait ? startCheckHeartbeat(timeoutMs) : null
|
||||
const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null
|
||||
type CheckResult = {
|
||||
messages: MessageSummary[]
|
||||
count: number
|
||||
@@ -392,7 +410,12 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
try {
|
||||
result = await client.call<CheckResult>('orchestration.check', {
|
||||
terminal,
|
||||
unread: flags.has('unread') ? true : undefined,
|
||||
// Why: --peek also sends unread:false so runtimes that predate the
|
||||
// peek param (which their non-strict schema strips) degrade to the
|
||||
// non-consuming all-messages mode instead of the destructive
|
||||
// mark-read default; the read filter below restores peek semantics.
|
||||
unread: flags.has('unread') ? true : peek ? false : undefined,
|
||||
peek: peek ? true : undefined,
|
||||
all: flags.has('all') ? true : undefined,
|
||||
types: getOptionalStringFlag(flags, 'types'),
|
||||
inject: flags.has('inject') ? true : undefined,
|
||||
@@ -400,7 +423,41 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
timeoutMs
|
||||
})
|
||||
} finally {
|
||||
stopHeartbeat?.()
|
||||
stopKeepalive?.()
|
||||
}
|
||||
if (peek) {
|
||||
const rawRowCount = result.result.messages.length
|
||||
const unreadOnly = result.result.messages.filter((m) => m.read !== 1)
|
||||
const removedReadRows = unreadOnly.length !== rawRowCount
|
||||
// Why: read rows in a peek response are the pre-peek-runtime signature
|
||||
// (its schema stripped `peek` and it ran the all mode). Such a runtime
|
||||
// returned instead of blocking, so honoring --wait is impossible —
|
||||
// failing beats silently returning empty before the deadline.
|
||||
if (wait && removedReadRows && unreadOnly.length === 0) {
|
||||
throw new RuntimeClientError(
|
||||
'peek_wait_unsupported',
|
||||
'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.'
|
||||
)
|
||||
}
|
||||
// Why: pre-peek runtimes cap the all mode at the newest 100 rows, so a
|
||||
// full page means older unread messages may have been cut off. Warn on
|
||||
// stderr so stdout stays a single JSON payload.
|
||||
if (removedReadRows && rawRowCount >= 100) {
|
||||
console.error(
|
||||
'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.'
|
||||
)
|
||||
}
|
||||
result = {
|
||||
...result,
|
||||
result: {
|
||||
...result.result,
|
||||
// Why: a pre-peek runtime builds `formatted` from all rows; drop it
|
||||
// when the read filter removed any so output matches the peek set.
|
||||
...(removedReadRows ? { formatted: undefined } : {}),
|
||||
messages: unreadOnly,
|
||||
count: unreadOnly.length
|
||||
}
|
||||
}
|
||||
}
|
||||
printResult(result, json, (r) => {
|
||||
if (r.formatted) {
|
||||
@@ -476,6 +533,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
|
||||
'orchestration task-list': async ({ flags, client, json }) => {
|
||||
const brief = flags.has('brief')
|
||||
const result = await client.call<{
|
||||
tasks: {
|
||||
id: string
|
||||
@@ -485,13 +543,26 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
status: string
|
||||
assignee_handle?: string | null
|
||||
dispatch_id?: string | null
|
||||
spec_truncated?: boolean
|
||||
}[]
|
||||
count: number
|
||||
}>('orchestration.taskList', {
|
||||
status: getOptionalStringFlag(flags, 'status'),
|
||||
ready: flags.has('ready') ? true : undefined
|
||||
ready: flags.has('ready') ? true : undefined,
|
||||
brief: brief ? true : undefined
|
||||
})
|
||||
printResult(result, json, (r) => {
|
||||
// Why: current runtimes abbreviate server-side (rows carry
|
||||
// spec_truncated) so full specs never cross the wire; older runtimes
|
||||
// strip the brief param and need the client-side fallback.
|
||||
const needsClientAbbreviation =
|
||||
brief && result.result.tasks.some((task) => task.spec_truncated === undefined)
|
||||
const output = needsClientAbbreviation
|
||||
? {
|
||||
...result,
|
||||
result: { ...result.result, tasks: abbreviateOrchestrationTasks(result.result.tasks) }
|
||||
}
|
||||
: result
|
||||
printResult(output, json, (r) => {
|
||||
if (r.count === 0) {
|
||||
return 'No tasks.'
|
||||
}
|
||||
|
||||
+5
-5
@@ -236,9 +236,9 @@ Common Commands:
|
||||
|
||||
Selectors:
|
||||
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
|
||||
--worktree <selector> Worktree selector such as id:<id>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--worktree <selector> Worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
|
||||
--parent-worktree <selector> Parent worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--parent-worktree <selector> Parent worktree selector such as id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--no-parent Force no parent lineage for unrelated worktree creation/update
|
||||
|
||||
Terminal Send Options:
|
||||
@@ -446,7 +446,7 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
|
||||
return '--parent-current Use the current linked issue as parent'
|
||||
}
|
||||
if (command === 'worktree create' && flag === 'parent-worktree') {
|
||||
return '--parent-worktree <selector> Parent selector such as active/current, id:<id>, branch:<branch>, issue:<number>, path:<path>, folder:<id>, or worktree:<id>'
|
||||
return '--parent-worktree <selector> Parent selector such as active/current, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, folder:<id>, or worktree:<worktreeId>'
|
||||
}
|
||||
if (command === 'orchestration task-create' && flag === 'task-title') {
|
||||
return '--task-title <text> Concise title for the orchestration task'
|
||||
@@ -501,7 +501,7 @@ export function formatFlagHelp(flag: string): string {
|
||||
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
|
||||
pages: '--pages <n> Number of scroll pages',
|
||||
'parent-worktree':
|
||||
'--parent-worktree <selector> Parent worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current',
|
||||
'--parent-worktree <selector> Parent worktree selector such as id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
|
||||
path: '--path <path> Path argument for the command',
|
||||
prompt: '--prompt <text> Prompt text for agent-backed commands',
|
||||
query: '--query <text> Search text for matching refs',
|
||||
@@ -525,7 +525,7 @@ export function formatFlagHelp(flag: string): string {
|
||||
'to-x': '--to-x <x> Destination window-local x coordinate',
|
||||
'to-y': '--to-y <y> Destination window-local y coordinate',
|
||||
worktree:
|
||||
'--worktree <selector> Worktree selector such as id:<id>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
|
||||
'--worktree <selector> Worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
|
||||
workspace: '--workspace <selector> Existing worktree selector for automation runs',
|
||||
'workspace-status':
|
||||
'--workspace-status <id> Board status id (defaults: todo, in-progress, in-review, completed)',
|
||||
|
||||
@@ -446,7 +446,7 @@ describe('orca root help', () => {
|
||||
expect(createHelp).not.toContain('folderWorkspaceId')
|
||||
expect(createHelp).toContain('folder:<id>')
|
||||
expect(createHelp).toContain('folder:<folderId>')
|
||||
expect(createHelp).toContain('worktree:<id>')
|
||||
expect(createHelp).toContain('worktree:<worktreeId>')
|
||||
expect(createHelp).toContain(
|
||||
'--no-parent only affects Orca lineage; omit --base-branch to use the repo default base'
|
||||
)
|
||||
@@ -499,6 +499,9 @@ describe('orca cli worktree awareness', () => {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
delete process.env.ORCA_WORKSPACE_ID
|
||||
delete process.env.ORCA_WORKTREE_ID
|
||||
// Isolate the pane key so claude-teams tests that set it don't leak a
|
||||
// senderPaneKey into later orchestration.send assertions.
|
||||
delete process.env.ORCA_PANE_KEY
|
||||
serveOrcaAppMock.mockReset()
|
||||
getDefaultUserDataPathMock.mockClear()
|
||||
addEnvironmentFromPairingCodeMock.mockReset()
|
||||
@@ -756,6 +759,9 @@ describe('orca cli worktree awareness', () => {
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'current is a local cwd shortcut and cannot be resolved against a remote runtime.'
|
||||
)
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'id:<repo-id>::<path>'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
@@ -1886,7 +1892,7 @@ describe('orca cli worktree awareness', () => {
|
||||
message: 'Parent selector was not found.',
|
||||
data: {
|
||||
nextSteps: [
|
||||
'Pass a valid --parent-worktree selector such as folder:<id>, worktree:<id>, id:<worktreeId>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
|
||||
'Pass a valid --parent-worktree selector such as folder:<id>, worktree:<worktreeId>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<absolute-path>, or active/current.',
|
||||
'Retry with --no-parent to create without lineage.'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient)
|
||||
// server, so cwd-derived worktree selectors are only valid locally.
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:<id>, name:<displayName>, branch:<branch>, issue:<number>, or path:<absolute-server-path>.`
|
||||
`${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, or path:<absolute-server-path>.`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
path: ['automations', 'remove'],
|
||||
destructive: true,
|
||||
summary: 'Remove an Orca automation and its run history',
|
||||
usage: 'orca automations remove <id> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id'],
|
||||
|
||||
@@ -29,6 +29,7 @@ export const BROWSER_ADVANCED_COMMAND_SPECS: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
path: ['cookie', 'delete'],
|
||||
destructive: true,
|
||||
summary: 'Delete a cookie by name',
|
||||
usage:
|
||||
'orca cookie delete --name <n> [--domain <d>] [--url <u>] [--worktree <selector>] [--json]',
|
||||
@@ -240,6 +241,7 @@ export const BROWSER_ADVANCED_COMMAND_SPECS: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
path: ['storage', 'local', 'clear'],
|
||||
destructive: true,
|
||||
summary: 'Clear all localStorage',
|
||||
usage: 'orca storage local clear [--worktree <selector>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
|
||||
@@ -258,6 +260,7 @@ export const BROWSER_ADVANCED_COMMAND_SPECS: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
path: ['storage', 'session', 'clear'],
|
||||
destructive: true,
|
||||
summary: 'Clear all sessionStorage',
|
||||
usage: 'orca storage session clear [--worktree <selector>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user