From 9e2da765fa1d81890160fde3f36aa98b76b3042e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 23 Aug 2026 20:08:01 -0400 Subject: [PATCH] feat(mobile): complete hybrid parity hardening --- config/knip.mobile.json | 9 - ...hybrid-webview-implementation-checklist.md | 56 ++-- ...-mobile-hybrid-webview-parity-inventory.md | 4 +- ...bile-hybrid-webview-single-pr-migration.md | 6 +- ...27-mobile-hybrid-webview-remaining-work.md | 18 +- ...ile-hybrid-webview-simplification-audit.md | 23 +- mobile/app/_layout.tsx | 1 + .../h/[hostId]/agent-history/[worktreeId].tsx | 4 +- mobile/app/h/[hostId]/files/[worktreeId].tsx | 4 +- .../h/[hostId]/files/preview/[worktreeId].tsx | 4 +- .../app/h/[hostId]/history/[worktreeId].tsx | 5 +- mobile/app/h/[hostId]/index.tsx | 5 +- mobile/app/h/[hostId]/pr/[worktreeId].tsx | 5 +- mobile/app/h/[hostId]/review/[worktreeId].tsx | 11 +- .../app/h/[hostId]/session/[worktreeId].tsx | 28 +- .../[hostId]/source-control/[worktreeId].tsx | 14 +- mobile/app/h/[hostId]/tasks.tsx | 5 +- mobile/app/hybrid.tsx | 96 ++++--- mobile/host-web-app/_layout.tsx | 37 ++- mobile/host-web-app/h/[hostId]/accounts.tsx | 7 +- .../h/[hostId]/agent-history/[worktreeId].tsx | 12 +- .../h/[hostId]/review/[worktreeId].tsx | 5 + .../h/[hostId]/session/[worktreeId].tsx | 4 +- .../[hostId]/source-control/[worktreeId].tsx | 5 + mobile/host-web-app/index.tsx | 8 +- .../mobile-web-route-error-boundary.tsx | 43 +++- .../mobile-web-route-restorer.tsx | 42 ++- mobile/package.json | 3 +- .../scripts/emulator-macos-keychain-home.mjs | 30 +++ .../emulator-pairing-child-environment.mjs | 20 ++ .../emulator-pairing-runtime-process.mjs | 11 + mobile/scripts/export-host-mobile-web.mjs | 83 ++++-- .../hosted-adversarial-provider-content.mjs | 2 +- .../hosted-android-agent-history-journey.mjs | 72 +++--- .../hosted-android-emulator-session.mjs | 8 +- .../hosted-android-hardware-back-journey.mjs | 119 +++++++++ mobile/scripts/hosted-android-pairing.mjs | 42 +++ mobile/scripts/hosted-ios-accounts-parity.mjs | 3 +- .../hosted-ios-agent-history-journey.mjs | 20 +- .../hosted-ios-agent-history-parity.mjs | 12 + .../scripts/hosted-ios-core-route-parity.mjs | 51 +++- .../hosted-ios-emulator-accessibility.mjs | 74 +++++- .../hosted-ios-files-preview-parity.mjs | 9 +- ...ios-host-origin-source-control-journey.mjs | 130 ++++++++++ .../hosted-ios-native-alert-journey.mjs | 157 ++++++++++++ .../hosted-ios-native-settings-journey.mjs | 2 +- ...hosted-ios-photo-permission-revocation.mjs | 45 ++-- ...sted-ios-source-control-review-journey.mjs | 71 +++++- ...osted-ios-source-control-review-parity.mjs | 46 +++- .../hosted-ios-terminal-clipboard-paste.mjs | 2 +- ...osted-webview-bridge-error-observation.mjs | 64 +++++ mobile/scripts/hosted-webview-cdp-session.mjs | 45 +++- mobile/scripts/hosted-webview-e2e-report.mjs | 51 ++++ ...osted-webview-route-exception-evidence.mjs | 62 +++++ .../hosted-webview-route-navigation.mjs | 3 +- .../hosted-webview-security-evidence.mjs | 29 +++ .../run-hosted-android-corrupt-cache.mjs | 2 +- .../run-hosted-android-hardware-back-e2e.mjs | 238 +++++++++++++++++ ...sted-android-source-control-review-e2e.mjs | 46 +--- .../run-hosted-webview-simulator-e2e.mjs | 170 ++++++------- .../start-emulator-pairing-runtime.mjs | 21 +- ...web-accounts-screen-binding-source.test.ts | 4 +- .../src/accounts/use-open-mobile-accounts.ts | 14 - mobile/src/components/ActionSheetModal.tsx | 11 +- mobile/src/components/BottomDrawer.tsx | 3 + mobile/src/components/ConfirmModal.tsx | 24 +- mobile/src/components/mobile-pr-url.ts | 6 - .../src/components/mounted-bottom-drawer.tsx | 61 ++--- mobile/src/hooks/use-mobile-keyboard-inset.ts | 31 +++ .../use-mobile-keyboard-inset.web.test.ts | 16 ++ .../hooks/use-mobile-keyboard-inset.web.ts | 39 +++ .../disabled-page-client-context.tsx | 27 +- .../hosted-android-emulator-session.test.ts | 17 ++ ...sted-android-hardware-back-journey.test.ts | 157 ++++++++++++ .../hosted-ios-accounts-parity.test.ts | 1 + .../hosted-ios-agent-history-parity.test.ts | 22 +- .../hosted-ios-core-route-parity.test.ts | 34 ++- .../hosted-ios-emulator-accessibility.test.ts | 72 ++++++ .../hosted-ios-files-preview-parity.test.ts | 9 +- ...host-origin-source-control-journey.test.ts | 65 +++++ .../hosted-ios-native-alert-journey.test.ts | 60 +++++ ...ed-ios-photo-permission-revocation.test.ts | 10 +- ...-ios-source-control-review-journey.test.ts | 127 ++++++++- ...d-ios-source-control-review-parity.test.ts | 66 ++++- .../hosted-webview-cdp-session.test.ts | 17 +- ...ted-webview-cdp-target-replacement.test.ts | 80 ++++++ ...d-webview-route-exception-evidence.test.ts | 48 ++++ .../hosted-webview-route-navigation.test.ts | 2 +- ...obile-native-shell-route-ownership.test.ts | 31 ++- .../mobile-web-alert-adapter.test.tsx | 232 +++++++++++++++++ .../mobile-web/mobile-web-alert-adapter.tsx | 240 ++++++++++++++++++ ...mobile-web-back-navigation-adapter.test.ts | 181 +++++++++++++ .../mobile-web-back-navigation-adapter.ts | 192 ++++++++++++++ .../mobile-web-bridge-roundtrip-fixture.ts | 1 + .../mobile-web-broker-message-handoff.ts | 47 ++++ .../mobile-web-cached-build-probe.ts | 13 + .../mobile-web-capability-authorities.ts | 4 + ...b-capability-broker-branch-compare.test.ts | 145 +++++++++++ .../mobile-web-capability-broker-race.test.ts | 86 ++++++- .../mobile-web-capability-broker.ts | 11 +- ...e-web-capability-execution-dependencies.ts | 2 + .../mobile-web-capability-execution.ts | 1 + .../mobile-web-file-open-operation.test.ts | 95 +++++++ .../mobile-web-file-open-operation.ts | 30 +++ .../mobile-web/mobile-web-file-operations.ts | 14 +- .../mobile-web-hardware-back-handoff.ts | 150 +++++++++++ .../mobile-web-native-alert.test.ts | 60 +++++ .../src/mobile-web/mobile-web-native-alert.ts | 34 +++ ...web-native-authority-module-source.test.ts | 21 ++ .../mobile-web-native-behavior-adapter.tsx | 29 +++ .../mobile-web-native-capability-authority.ts | 5 + ...mobile-web-native-capability-operations.ts | 9 + .../mobile-web-native-roundtrip.test.ts | 22 ++ .../mobile-web-package-downloader.test.ts | 36 ++- .../mobile-web-package-downloader.ts | 43 +++- ...mobile-web-production-grant-limits.test.ts | 22 ++ .../mobile-web-production-native-grants.ts | 11 + .../mobile-web-queryless-history.test.ts | 54 ++++ .../mobile-web-queryless-history.ts | 57 +++++ .../mobile-web-route-error-boundary.test.ts | 80 +++++- .../mobile-web-route-query-cache.ts | 36 +++ .../mobile-web-route-restoration.test.ts | 11 +- .../mobile-web-route-restoration.ts | 10 +- .../mobile-web-route-restorer.test.ts | 32 +++ .../mobile-web-session-operations.test.ts | 4 +- .../mobile-web-session-operations.ts | 20 +- .../mobile-web-session-snapshot.test.ts | 39 +++ .../mobile-web/mobile-web-session-snapshot.ts | 23 +- ...ource-control-branch-compare-pager.test.ts | 91 +++++++ ...web-source-control-branch-compare-pager.ts | 136 ++++++++++ ...-source-control-history-operations.test.ts | 37 ++- ...e-web-source-control-history-operations.ts | 16 +- .../mobile-web-source-control-operations.ts | 5 +- ...b-source-control-review-operations.test.ts | 49 +++- ...le-web-source-control-review-operations.ts | 20 +- .../mobile-web-terminal-flow-control.ts | 1 + .../mobile-web-terminal-host-transport.ts | 2 +- .../mobile-web/mobile-web-terminal-input.ts | 4 +- .../mobile-web-terminal-multiplex-events.ts | 6 + .../mobile-web-terminal-stream-record.ts | 1 + .../mobile-web-terminal-streams.test.ts | 39 ++- .../mobile-web-workspace-authority.test.ts | 17 ++ .../mobile-web-workspace-authority.ts | 31 ++- ...e-mobile-web-hardware-back-handoff.test.ts | 154 +++++++++++ ...-mobile-web-hardware-back-handoff.test.tsx | 118 +++++++++ .../use-mobile-web-hardware-back-handoff.ts | 65 +++++ .../use-mobile-web-package-capability.ts | 58 +++++ .../use-mobile-web-package-session.test.ts | 168 +++++++++++- .../use-mobile-web-package-session.ts | 71 +++++- .../mobile-web/use-mobile-web-route-params.ts | 10 + .../use-open-notification-route.ts | 17 -- .../mobile-native-chat-message-occurrences.ts | 26 -- .../mobile-session-file-tab-activation.ts | 135 ++++++++++ .../use-mobile-native-chat-cancel-ask.ts | 45 ---- mobile/src/session/use-open-mobile-session.ts | 14 - ...urce-control-screen-binding-source.test.ts | 1 + ...veal-mobile-source-control-session-diff.ts | 121 +-------- ...use-mobile-source-control-keyboard-lift.ts | 24 +- .../web-host-provider-review-creation.ts | 7 +- .../web-host-provider-review-loader.ts | 11 +- .../web-host-provider-review-requests.test.ts | 2 + .../web-host-provider-review-requests.ts | 19 +- .../web-host-source-control-client.test.ts | 96 +++++++ .../web-host-source-control-client.ts | 68 ++++- .../web-host-source-control-reads.ts | 10 +- ...web-host-source-control-status-snapshot.ts | 45 ++++ .../use-mobile-pr-file-content-cache.test.ts | 11 + .../tasks/use-mobile-pr-file-content-cache.ts | 5 +- mobile/src/tasks/use-open-mobile-tasks.ts | 15 -- .../terminal/terminal-viewport-refit.test.ts | 3 +- .../emulator-macos-keychain-home.test.ts | 60 +++++ ...emulator-pairing-child-environment.test.ts | 27 ++ .../emulator-pairing-runtime-process.test.ts | 12 + .../host-mobile-web-export-process.test.ts | 72 ++++++ mobile/src/transport/host-status-gates.ts | 7 +- .../src/transport/runtime-capability-probe.ts | 24 +- src/main/index.ts | 2 +- src/main/runtime/orca-runtime.test.ts | 1 + src/main/runtime/rpc/methods/terminal.ts | 19 +- .../rpc/mobile-web-package-root.test.ts | 36 ++- .../runtime/rpc/mobile-web-package-root.ts | 22 +- .../terminal-query-reply-negotiation.test.ts | 151 +++++++++++ ...serve-update-handoff-startup-order.test.ts | 13 + .../shell-startup-feature-channel.test.ts | 7 + .../src/mobile-web-hardware-back-handler.ts | 26 ++ .../src/mobile-web-native-request-client.ts | 18 ++ .../src/native-shell-channel.test.tsx | 46 ++++ src/mobile-web/src/native-shell-channel.ts | 49 +++- src/shared/mobile-web/bridge-contract.test.ts | 116 +++++++++ src/shared/mobile-web/bridge-contract.ts | 21 +- src/shared/mobile-web/bridge-limits.ts | 2 +- .../mobile-web/bridge-operation-registry.ts | 1 + .../mobile-web/native-operation-contract.ts | 43 ++++ src/shared/protocol-version.ts | 2 + 194 files changed, 6937 insertions(+), 974 deletions(-) create mode 100644 mobile/scripts/emulator-macos-keychain-home.mjs create mode 100644 mobile/scripts/emulator-pairing-child-environment.mjs create mode 100644 mobile/scripts/hosted-android-hardware-back-journey.mjs create mode 100644 mobile/scripts/hosted-android-pairing.mjs create mode 100644 mobile/scripts/hosted-ios-host-origin-source-control-journey.mjs create mode 100644 mobile/scripts/hosted-ios-native-alert-journey.mjs create mode 100644 mobile/scripts/hosted-webview-bridge-error-observation.mjs create mode 100644 mobile/scripts/hosted-webview-e2e-report.mjs create mode 100644 mobile/scripts/hosted-webview-route-exception-evidence.mjs create mode 100644 mobile/scripts/hosted-webview-security-evidence.mjs create mode 100644 mobile/scripts/run-hosted-android-hardware-back-e2e.mjs delete mode 100644 mobile/src/accounts/use-open-mobile-accounts.ts delete mode 100644 mobile/src/components/mobile-pr-url.ts create mode 100644 mobile/src/hooks/use-mobile-keyboard-inset.ts create mode 100644 mobile/src/hooks/use-mobile-keyboard-inset.web.test.ts create mode 100644 mobile/src/hooks/use-mobile-keyboard-inset.web.ts create mode 100644 mobile/src/mobile-web/hosted-android-hardware-back-journey.test.ts create mode 100644 mobile/src/mobile-web/hosted-ios-host-origin-source-control-journey.test.ts create mode 100644 mobile/src/mobile-web/hosted-ios-native-alert-journey.test.ts create mode 100644 mobile/src/mobile-web/hosted-webview-cdp-target-replacement.test.ts create mode 100644 mobile/src/mobile-web/hosted-webview-route-exception-evidence.test.ts create mode 100644 mobile/src/mobile-web/mobile-web-alert-adapter.test.tsx create mode 100644 mobile/src/mobile-web/mobile-web-alert-adapter.tsx create mode 100644 mobile/src/mobile-web/mobile-web-back-navigation-adapter.test.ts create mode 100644 mobile/src/mobile-web/mobile-web-back-navigation-adapter.ts create mode 100644 mobile/src/mobile-web/mobile-web-broker-message-handoff.ts create mode 100644 mobile/src/mobile-web/mobile-web-cached-build-probe.ts create mode 100644 mobile/src/mobile-web/mobile-web-capability-broker-branch-compare.test.ts create mode 100644 mobile/src/mobile-web/mobile-web-file-open-operation.test.ts create mode 100644 mobile/src/mobile-web/mobile-web-file-open-operation.ts create mode 100644 mobile/src/mobile-web/mobile-web-hardware-back-handoff.ts create mode 100644 mobile/src/mobile-web/mobile-web-native-alert.test.ts create mode 100644 mobile/src/mobile-web/mobile-web-native-alert.ts create mode 100644 mobile/src/mobile-web/mobile-web-native-behavior-adapter.tsx create mode 100644 mobile/src/mobile-web/mobile-web-queryless-history.test.ts create mode 100644 mobile/src/mobile-web/mobile-web-queryless-history.ts create mode 100644 mobile/src/mobile-web/mobile-web-route-query-cache.ts create mode 100644 mobile/src/mobile-web/mobile-web-source-control-branch-compare-pager.test.ts create mode 100644 mobile/src/mobile-web/mobile-web-source-control-branch-compare-pager.ts create mode 100644 mobile/src/mobile-web/use-mobile-web-hardware-back-handoff.test.ts create mode 100644 mobile/src/mobile-web/use-mobile-web-hardware-back-handoff.test.tsx create mode 100644 mobile/src/mobile-web/use-mobile-web-hardware-back-handoff.ts create mode 100644 mobile/src/mobile-web/use-mobile-web-package-capability.ts create mode 100644 mobile/src/mobile-web/use-mobile-web-route-params.ts delete mode 100644 mobile/src/notifications/use-open-notification-route.ts delete mode 100644 mobile/src/session/mobile-native-chat-message-occurrences.ts create mode 100644 mobile/src/session/mobile-session-file-tab-activation.ts delete mode 100644 mobile/src/session/use-mobile-native-chat-cancel-ask.ts delete mode 100644 mobile/src/session/use-open-mobile-session.ts create mode 100644 mobile/src/source-control/web-host-source-control-status-snapshot.ts delete mode 100644 mobile/src/tasks/use-open-mobile-tasks.ts create mode 100644 mobile/src/transport/emulator-macos-keychain-home.test.ts create mode 100644 mobile/src/transport/emulator-pairing-child-environment.test.ts create mode 100644 mobile/src/transport/host-mobile-web-export-process.test.ts create mode 100644 src/main/runtime/rpc/terminal-query-reply-negotiation.test.ts create mode 100644 src/main/serve-update-handoff-startup-order.test.ts create mode 100644 src/mobile-web/src/mobile-web-hardware-back-handler.ts diff --git a/config/knip.mobile.json b/config/knip.mobile.json index ac35e9fc4be..fc5095b09b2 100644 --- a/config/knip.mobile.json +++ b/config/knip.mobile.json @@ -23,15 +23,6 @@ "packages/*/src/**/*.{js,jsx,ts,tsx}", "*.{js,ts}" ], - "ignoreFiles": [ - "src/accounts/use-open-mobile-accounts.ts", - "src/components/mobile-pr-url.ts", - "src/notifications/use-open-notification-route.ts", - "src/session/mobile-native-chat-message-occurrences.ts", - "src/session/use-mobile-native-chat-cancel-ask.ts", - "src/session/use-open-mobile-session.ts", - "src/tasks/use-open-mobile-tasks.ts" - ], "ignoreExportsUsedInFile": true, "includeEntryExports": false } diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md index e20c97bd7d5..f593fd853c4 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md @@ -21,11 +21,11 @@ performance, accessibility, or production-promotion gates. | Candidate checkpoint | `e931b2db07` | | `origin/main` and merge base at checkpoint | `4c984d4c1b` | | Checkpoint comparison | 81 commits; 1,396 files; 140,153 additions; 8,772 deletions | -| Post-audit integration checkpoint | `b14fe74214` | -| Current `origin/main` checkpoint | `b2902cb61e` | -| Post-audit comparison | 89 commits; 1,401 files; 134,626 additions; 8,771 deletions | -| Latest verified package | `121fe8682fc221fd7e6f2955fe1f246017d164db3122d71526bc3f66b19578c5` | -| Latest package size | 51 assets; 9,151,993 raw bytes; 2,649,166 gzip bytes | +| Post-audit integration checkpoint | `b8d04428c0` | +| Current `origin/main` checkpoint | `e50cc309c3` | +| Post-audit comparison | 92 commits; 1,401 files; 134,633 additions; 8,771 deletions | +| Latest verified package | `015f15ca1bbbc28f25318283a2fff0ea616db868540a2e86bd3d5ce61c4a3f25` | +| Latest package size | 52 assets; 9,310,227 raw bytes; 2,691,744 gzip bytes | Commit and branch counts are historical evidence for that checkpoint; rerun `git merge-base origin/main HEAD`, `git rev-list --count origin/main..HEAD`, and @@ -45,7 +45,7 @@ Commit and branch counts are historical evidence for that checkpoint; rerun | Bridge | Exact v2 contract, named operations, generated grants/schemas/bounds, opaque authority, mutation reauthorization, response correlation, lifecycle cleanup | `548d8d64aa`, `e526780848`, `b2068661f7`, `ee15298704`, `2094dde1f9` | | Routes | Workspace, Accounts, Tasks, Session, Agent History, Files/Preview, Source Control, and Review use explicit hosted adapters | [Parity inventory](2026-07-22-mobile-hybrid-webview-parity-inventory.md) | | Terminal | Real xterm/PTY byte path, bounded ACK/backpressure, links, selection/paste, reconnect/resnapshot, SSH recovery | Direct, SSH, and packaged SSH E2E commands | -| Native capabilities | Gesture/permission/foreground-mediated clipboard, documents/photos, haptics, external URLs, speech/audio, settings, and diagnostics | iOS/Android capability journeys and focused tests | +| Native capabilities | Gesture/permission/foreground-mediated clipboard, documents/photos, haptics, external URLs, speech/audio, alerts, Android Back, settings, and diagnostics | iOS/Android capability journeys and focused tests | | Privacy | Credentials, endpoints, paths, durable host identity, page payloads, and full build IDs excluded from page authority, storage, logs, cache identity, and diagnostics | `8ee4fcdac1`, `d6b8b14c82`, exact-app privacy audits | | Security review | 2026-08-21 independent OpenCode review covered bridge, shells, package store, runtime RPC, Relay, and SSH; high-severity findings fixed | Review record summarized below; not physical/store certification | | Rollback | Automatic active/previous recovery, process-loss rollback, host-scoped manual recovery, and corrected Desktop/native incident procedure | [Rollback runbook](../mobile-hybrid-webview-rollback.md) | @@ -68,6 +68,7 @@ Run from the repository root unless noted. | iOS hosted journey | `pnpm --dir mobile test:e2e:hosted-webview` | Route/capability/reconnect simulator journey | | iOS hostile-content journey | `pnpm --dir mobile test:e2e:hosted-webview:security` | Network/navigation/executable/storage isolation | | Android route journey | `pnpm --dir mobile test:e2e:hosted-webview:android-routes` | Hosted Source Control/Review and route interactions | +| Android real hardware Back | `pnpm --dir mobile test:e2e:hosted-webview:android-back` | Real `KEYCODE_BACK`: nested route, Session, workspace, native shell | | Android hostile-content journey | `pnpm --dir mobile test:e2e:hosted-webview:android-security` | Executable/network/navigation/privacy isolation | | Android locally signed Release inspection | `pnpm --dir mobile test:e2e:hosted-webview:android-release` | Local Release WebView behavior only; not Play signing | | iOS process-loss rollback | `pnpm --dir mobile test:e2e:hosted-webview:ios-crash-loop` | Repeated WebView loss and generation recovery on Simulator | @@ -83,8 +84,8 @@ and raw result location. ## Package and Topology Evidence - Latest independent verification: - `121fe8682fc221fd7e6f2955fe1f246017d164db3122d71526bc3f66b19578c5`, - 51 assets, 9,151,993 raw bytes, 2,649,166 gzip bytes. + `015f15ca1bbbc28f25318283a2fff0ea616db868540a2e86bd3d5ce61c4a3f25`, + 52 assets, 9,310,227 raw bytes, 2,691,744 gzip bytes. - Packaged macOS arm64 Desktop through an isolated Docker SSH provider to an actual iOS WKWebView used `7c7c673deb74e158cdfb99b1ca536fd88cd3ab5dac4eb8db78c43ca12f6ce31d`. @@ -115,6 +116,15 @@ native-versus-hosted results must replace the invalidated figures before parity certification. Simulator evidence will still not close physical-device, accessibility, input, or performance gates. +The 2026-08-23 strict iPhone Simulator Source Control/Review run used package +`015f15ca1bbbc28f25318283a2fff0ea616db868540a2e86bd3d5ce61c4a3f25` and an +actual WKWebView. It covered host-list long press into Source Control, changed +file Review, return to Source Control and workspace root, physical workspace +selection into Session, and a second session-origin Source Control mount. The +corrected native-versus-hosted captures passed at 1.172% and 1.189% changed +pixels against a 3% budget, with 1.470 and 1.321 mean channel differences +against a 4.0 budget. The second mount retained branch and pull-request state. + ## Security and Lifecycle Evidence - TypeScript, Swift, and Kotlin share exact path, hash, MIME/role, manifest, @@ -124,13 +134,17 @@ accessibility, input, or performance gates. traversal, symlinks, linked parents, unexpected file types, and out-of-root reads before activation. Mirrored concurrency tests cover same-host stage, activation, cleanup, commit/abort, and removal races. -- All 224 recorded production grants rejected eight malformed request shapes - plus an oversized request before native/host access: 2,016 cases. Exported +- All 225 recorded production grants rejected eight malformed request shapes + plus an oversized request before native/host access: 2,025 cases. Exported results and subscription events have invalid-payload admission coverage; invalid events retire their subscriptions. - Cross-build/session tests cover a 15-pair stale authority grid. Cancellation, client replacement, disposal, replay, late subscription registration, and delayed mutation results fail closed. +- Branch comparisons retain one bounded shell-owned host snapshot across + single-use page continuations. Only the exact next page bypasses the initial + rate charge; forged and replayed continuations remain throttled, and large + comparisons no longer rerun Git for every 128-entry page. - Exact iOS and Android emulator apps rendered hostile filenames, diffs, terminal links, provider/task strings, errors, Markdown, HTML, SVG, Mermaid, and image metadata inertly, with no execution marker or sentinel traffic. @@ -141,6 +155,10 @@ accessibility, input, or performance gates. - Automatic recovery covered corruption and repeated process loss. The iOS Simulator candidate-to-final three-crash drill restored the verified previous generation. Final physical/store-signed rollback drills remain open. +- Android API 36 exercised three real hardware Back events from Agent History + through Session and workspace root to the reset app's native shell, followed + by a clean bridge-log audit. Dirty-state, mixed-version, and physical-device + Back coverage remain open. - The 2026-08-21 independent OpenCode review found issues in live bridge/native/ package/runtime RPC/Relay/SSH boundaries. High-severity findings were repaired, including bounded SSH/Relay methods, connection-owned file-watch teardown, @@ -149,11 +167,19 @@ accessibility, input, or performance gates. ## Evidence Interpretation -The latest recorded full mobile validation passed 599 mobile files and 3,568 -tests with two expected skips, plus mobile typecheck/lint/format and diff -hygiene. Root typecheck/lint/code-quality, 56 reliability gates, localization, -and max-lines also passed at that checkpoint. Rerun current CI before release; -historical counts are not evergreen. +The latest recorded full mobile validation passed 722 files and 4,708 tests, +with three skips. The latest concurrent root run passed 6,193 files and 58,084 +tests, with 39 skipped files and 245 skipped tests; two deadline-sensitive tests +failed under load and then passed 25/25 together with one worker. Root, mobile, +and hosted-web typechecks and lints, changed-code quality, 90 reliability gates, +localization, max-lines, and Electron ratchets also passed. Relay Markdown +discovery passed its shared cross-platform process-launcher boundary tests. +Rerun current CI before release; historical counts are not evergreen. + +The 2026-08-23 branch-comparison correction additionally passed 33 focused +broker/history/grant tests, mobile and hosted-web typechecks, focused lint and +formatting, diff hygiene, and the strict actual-WKWebView Source Control/Review +journey above. No entry in this document claims the following passed: physical phones or tablets, production-store-signed apps, Windows/Linux/headless packaged Desktop, diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md index 8efc9bb57e5..89bdc8a6d39 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md @@ -559,8 +559,8 @@ renderer-independent, while the authoritative RNW package still verifies as build `f293890e…`. Raw bridge envelopes, including initial shell setup, now pass an exact unique-key, paired-surrogate, bounded JSON grammar before schema parsing. The -generated broker corpus rejects 2,016 malformed or oversized requests across -all 224 production grants before host or native access. Valid response +generated broker corpus rejects 2,025 malformed or oversized requests across +all 225 production grants before host or native access. Valid response envelopes also cross 157 exported result schemas and all eight subscription event schemas with invalid payloads; requests fail closed and invalid events cancel their subscriptions. Pending terminal/native-chat setup cannot register diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md index 7cf0a05d45f..6219798c5d5 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md @@ -158,8 +158,8 @@ pnpm test:e2e:hosted-mobile-webview:ssh:packaged ``` The final recorded independently verified package was -`121fe8682fc221fd7e6f2955fe1f246017d164db3122d71526bc3f66b19578c5`: -51 assets, 9,151,993 raw bytes, and 2,649,166 gzip bytes. A packaged macOS arm64 +`5eda0f5c7f5265e9be2420d9178c03605dbe4c8e5ea9e669327d680f1e9c0eb3`: +52 assets, 9,308,959 raw bytes, and 2,691,373 gzip bytes. A packaged macOS arm64 Desktop to Docker SSH to actual iOS WKWebView journey used package `7c7c673deb74e158cdfb99b1ca536fd88cd3ab5dac4eb8db78c43ca12f6ce31d` without checkout fallback. @@ -179,6 +179,8 @@ performance gates. - Shared workspace, Accounts, Tasks, Session, Files/Preview, Agent History, Source Control, Review, terminal, browser, attachments, and native-chat presentation through explicit native/web adapters. +- Negotiated Android hardware Back and native Alert presentation so hosted + routes retain current shell behavior and mixed versions degrade safely. - Bound files, source control, provider review, tasks, agent mutations, terminal streams, browser controls, native chat, and continuations to opaque current authority. diff --git a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md index 51a1c641a45..8c857df0590 100644 --- a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md +++ b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md @@ -23,8 +23,8 @@ composition have recorded evidence. The 2026-08-21 independent OpenCode review was completed and its high-severity findings were fixed. Latest recorded package: -`121fe8682fc221fd7e6f2955fe1f246017d164db3122d71526bc3f66b19578c5`, -51 assets, 9,151,993 raw bytes, 2,649,166 gzip bytes. This summary is not a +`015f15ca1bbbc28f25318283a2fff0ea616db868540a2e86bd3d5ce61c4a3f25`, +52 assets, 9,310,227 raw bytes, 2,691,744 gzip bytes. This summary is not a production-readiness claim. ## Packaged Desktop and Signed App Matrix @@ -43,9 +43,12 @@ production-readiness claim. ## Physical Devices and Layouts +- [x] Re-run the corrected iPhone Simulator native-versus-hosted fixture for + Source Control/Review, including host-origin navigation and a second + session-origin Source Control mount. - [ ] Re-run the corrected iPhone Simulator native-versus-hosted fixture for - Workspace, Accounts, Tasks, Session, Agent History, Files/Preview, Source - Control/Review, host editing, Floating Workspace, and recovery. Require + Workspace, Accounts, Tasks, Session, Agent History, Files/Preview, host + editing, Floating Workspace, and recovery. Require CDP proof that no hosted private-origin target exists around each native capture and replace the invalidated historical screenshot metrics. - [ ] Test the oldest supported low-memory and current iPhone. @@ -55,6 +58,13 @@ production-readiness claim. memory pressure, attachment permission/denial/revocation/interruption, notification/deep-link routing, rotation, and host/session replacement on those devices. +- [x] Validate Android hardware Back from nested Agent History through Session + and workspace root to the native shell on the current API 36 emulator, + using real `KEYCODE_BACK` input and a clean bridge-log audit. +- [ ] Validate Android hardware Back at dirty drafts, timeout, reconnect, mixed + Desktop/mobile versions, and on a physical device. +- [ ] Validate native Alert button ordering, dismissal, queueing, destructive + actions, and old-shell fallback on iOS and Android release candidates. ## Topology and Compatibility diff --git a/docs/reference/plans/2026-08-23-mobile-hybrid-webview-simplification-audit.md b/docs/reference/plans/2026-08-23-mobile-hybrid-webview-simplification-audit.md index ed099c522c7..62dedbb270a 100644 --- a/docs/reference/plans/2026-08-23-mobile-hybrid-webview-simplification-audit.md +++ b/docs/reference/plans/2026-08-23-mobile-hybrid-webview-simplification-audit.md @@ -4,8 +4,8 @@ - **Status:** Simplifications integrated; release validation remains - **Candidate checkpoint:** `e931b2db07` - **Base and `origin/main` checkpoint:** `4c984d4c1b` -- **Post-audit integration checkpoint:** `b14fe74214` -- **Current `origin/main` checkpoint:** `b2902cb61e` +- **Post-audit integration checkpoint:** `b8d04428c0` +- **Current `origin/main` checkpoint:** `e50cc309c3` - **Decision record:** [migration record](2026-07-22-mobile-hybrid-webview-single-pr-migration.md) - **Release gates:** [remaining-work tracker](2026-07-27-mobile-hybrid-webview-remaining-work.md) @@ -30,9 +30,9 @@ files, 140,153 additions, and 8,772 deletions. Those counts describe `4c984d4c1b...e931b2db07`; they are not current-main release metrics. After the approved reductions, fixes, and current-main merge, the comparison is -89 commits, 1,401 files, 134,626 additions, and 8,771 deletions relative to -`b2902cb61e`. Documentation condensation accounts for most of the reduction; -the explicit 224-operation bridge and shared RNW presentation remain the main +92 commits, 1,401 files, 134,633 additions, and 8,771 deletions relative to +`e50cc309c3`. Documentation condensation accounts for most of the reduction; +the explicit 225-operation bridge and shared RNW presentation remain the main implementation span. The non-negotiable constraints were: @@ -157,10 +157,11 @@ Tasks, Files, and Session before any broad conversion. | Parity and cutover seams | `7815f3afb3` | Preserved native host editing, made workspace routes hybrid-only, removed the hosted picker duplicate, added test-only native baselines, and negotiated host gates | | Subscription regression | `ee15298704` | Restored the shared bridge envelope used by subscriptions after the parity refactor exposed a removed-method call | | Parity bounds | `2094dde1f9` | Made native CDP exclusion fail closed above its inspection limit and expanded the bounded session-capability response allowance | +| Relay process boundary | `c3725a60fc` | Moved Relay Markdown discovery to the shared cross-platform process launcher and preserved the child-process import ratchet | The declarative registry prototype covered Tasks, Files, and Session and proved that metadata, grants, and typed calls can be generated without replacing the -handwritten authorization executors. Converting all 224 operations in this PR +handwritten authorization executors. Converting all 225 operations in this PR would increase review and compatibility risk, so the registry remains a non-blocking follow-up. @@ -180,9 +181,13 @@ non-blocking follow-up. presentation. - [x] Prototype the declarative registry on Tasks, Files, and Session and defer broad conversion to a follow-up. -- [ ] Rerun package verification, typecheck/lint, bridge/cache/security suites, - Direct/SSH hosted E2E, emulator parity, and diff hygiene after integrated - simplifications. +- [x] Rerun deterministic package verification, full mobile/root suites, + typecheck/lint, bridge/cache/security suites, and diff hygiene after + integrated simplifications. +- [x] Rerun corrected Source Control/Review native-versus-hosted emulator parity, + including host-origin navigation and the second session-origin mount. +- [ ] Complete corrected native-versus-hosted emulator parity for the remaining + route matrix after integrated simplifications. - [ ] Keep the [release-gate tracker](2026-07-27-mobile-hybrid-webview-remaining-work.md) current; do not promote simulator/local evidence into physical, store, production cloud Relay, mixed-version, performance, or App Review proof. diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 6996bd9809b..1813087c12f 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -249,6 +249,7 @@ export default function RootLayout() { + diff --git a/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx b/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx index 50c62205cd7..89d062079f7 100644 --- a/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx @@ -1,9 +1,9 @@ -import { useLocalSearchParams } from 'expo-router' import { MobileAgentSessionHistoryPanel } from '../../../../src/agent-history/MobileAgentSessionHistoryPanel' +import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params' import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' export default function MobileAgentSessionHistoryScreen() { - const params = useLocalSearchParams<{ + const params = useMobileWebRouteParams<{ hostId?: string | string[] worktreeId?: string | string[] name?: string | string[] diff --git a/mobile/app/h/[hostId]/files/[worktreeId].tsx b/mobile/app/h/[hostId]/files/[worktreeId].tsx index 6ee06c9248b..2f371391974 100644 --- a/mobile/app/h/[hostId]/files/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/[worktreeId].tsx @@ -1,5 +1,5 @@ -import { useLocalSearchParams } from 'expo-router' import { MobileFileExplorerPanel } from '../../../../src/files/MobileFileExplorerPanel' +import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params' import type { HostFileExplorerOperations } from '../../../../src/files/host-file-explorer-operations' import type { ConnectionState } from '../../../../src/transport/types' @@ -12,7 +12,7 @@ export function MobileFileExplorerScreen({ connectionState?: ConnectionState nativeHostBinding?: boolean } = {}) { - const { hostId, worktreeId, name } = useLocalSearchParams<{ + const { hostId, worktreeId, name } = useMobileWebRouteParams<{ hostId: string worktreeId: string name?: string diff --git a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx index a67dffdab0b..018e01bcd1e 100644 --- a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx @@ -1,5 +1,5 @@ -import { useLocalSearchParams } from 'expo-router' import { MobileFilePreviewScreen } from '../../../../../src/files/MobileFilePreviewScreen' +import { useMobileWebRouteParams } from '../../../../../src/mobile-web/use-mobile-web-route-params' import type { HostFilePreviewOperations } from '../../../../../src/files/host-file-preview-operations' import { normalizeMobileFilePreviewRouteParams } from '../../../../../src/files/mobile-file-preview-route' import type { ConnectionState } from '../../../../../src/transport/types' @@ -13,7 +13,7 @@ export function MobileFilePreviewRoute({ connectionState?: ConnectionState nativeHostBinding?: boolean } = {}) { - const params = useLocalSearchParams<{ + const params = useMobileWebRouteParams<{ hostId?: string | string[] worktreeId?: string | string[] relativePath?: string | string[] diff --git a/mobile/app/h/[hostId]/history/[worktreeId].tsx b/mobile/app/h/[hostId]/history/[worktreeId].tsx index 19198e4e5f1..7413203be1b 100644 --- a/mobile/app/h/[hostId]/history/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/history/[worktreeId].tsx @@ -1,11 +1,12 @@ -import { Redirect, useLocalSearchParams } from 'expo-router' +import { Redirect } from 'expo-router' import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' +import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params' // History is now a segment of the Source Control hub. This route stays as a thin // redirect so existing deep links (and any cached navigation) land on the hub with // the History segment selected. export default function HistoryRedirect() { - const params = useLocalSearchParams<{ + const params = useMobileWebRouteParams<{ hostId?: string | string[] worktreeId?: string | string[] name?: string | string[] diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 8de27b0de03..420df73cd18 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useFocusEffect, useLocalSearchParams } from 'expo-router' +import { useFocusEffect } from 'expo-router' import { Search, X, @@ -21,6 +21,7 @@ import { SquareTerminal } from 'lucide-react-native' import { useHostClient } from '../../../src/transport/client-context' +import { useMobileWebRouteParams } from '../../../src/mobile-web/use-mobile-web-route-params' import { useWorktreeResync } from '../../../src/transport/use-worktree-resync' import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh' import { @@ -140,7 +141,7 @@ export function HostScreen({ hostState: hostStateProp, shellOperations: shellOperationsProp }: HostScreenProps = {}) { - const params = useLocalSearchParams<{ hostId: string; action?: string; notice?: string }>() + const params = useMobileWebRouteParams<{ hostId: string; action?: string; notice?: string }>() const hostId = hostIdProp ?? params.hostId const action = actionProp ?? params.action const [dismissedNotice, setDismissedNotice] = useState(null) diff --git a/mobile/app/h/[hostId]/pr/[worktreeId].tsx b/mobile/app/h/[hostId]/pr/[worktreeId].tsx index 682e4d3481c..8035ea4df7e 100644 --- a/mobile/app/h/[hostId]/pr/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/pr/[worktreeId].tsx @@ -1,12 +1,13 @@ -import { Redirect, useLocalSearchParams } from 'expo-router' +import { Redirect } from 'expo-router' import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' +import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params' // The Pull Request view is now a segment of the Source Control hub. This route // stays as a thin redirect so existing deep links land on the hub with the Pull // Request segment selected. The wide-layout dock opens the same hub with // initialTab="pr" (SessionDockColumn), not this route. export default function PrRedirect() { - const params = useLocalSearchParams<{ + const params = useMobileWebRouteParams<{ hostId?: string | string[] worktreeId?: string | string[] name?: string | string[] diff --git a/mobile/app/h/[hostId]/review/[worktreeId].tsx b/mobile/app/h/[hostId]/review/[worktreeId].tsx index 853938cfa57..134dea261b3 100644 --- a/mobile/app/h/[hostId]/review/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/review/[worktreeId].tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo } from 'react' -import { useLocalSearchParams, useRouter } from 'expo-router' +import { useRouter } from 'expo-router' import { MobileDiffReviewScreenView } from '../../../../src/components/MobileDiffReviewScreenView' +import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params' import { firstReviewParam, normalizeReviewFilterParam @@ -11,11 +12,13 @@ import type { HostDiffReviewBinding } from '../../../../src/session/host-diff-re import { useHostDiffReviewBinding } from '../../../../src/session/use-host-diff-review-binding' export function MobileDiffReviewRoute({ - binding + binding, + routeName }: { binding?: HostDiffReviewBinding + routeName?: string } = {}) { - const params = useLocalSearchParams<{ + const params = useMobileWebRouteParams<{ hostId?: string | string[] worktreeId?: string | string[] name?: string | string[] @@ -25,7 +28,7 @@ export function MobileDiffReviewRoute({ }>() const hostId = firstReviewParam(params.hostId) const worktreeId = firstReviewParam(params.worktreeId) - const name = firstReviewParam(params.name) + const name = routeName ?? firstReviewParam(params.name) const initialFilter = normalizeReviewFilterParam(firstReviewParam(params.scope)) const initialFile = firstReviewParam(params.file) const initialArea = normalizeReviewAreaParam(firstReviewParam(params.area)) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 581817ec4d0..18036d84ef2 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -14,12 +14,11 @@ import { Keyboard, Platform, ActivityIndicator, - type KeyboardEvent, type LayoutChangeEvent, type ListRenderItem } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' +import { useFocusEffect, useRouter } from 'expo-router' import { AlertTriangle, ArrowUp, @@ -54,6 +53,7 @@ import { type MobileTerminalLinkOpenMode } from '../../../../src/storage/preferences' import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context' +import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params' import { useLastConnectedAt, useRelayRecoveryStatus, @@ -92,6 +92,7 @@ import type { } from '../../../../src/terminal/terminal-webview-contract' import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift' import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit' +import { useMobileKeyboardInset } from '../../../../src/hooks/use-mobile-keyboard-inset' import { getDefaultTerminalAccessoryBuiltInIds, getVisibleTerminalAccessoryKeys @@ -751,7 +752,7 @@ export function SessionScreen({ name: routeWorktreeName, created, warning: createdWarning - } = useLocalSearchParams<{ + } = useMobileWebRouteParams<{ hostId: string worktreeId: string name?: string @@ -1013,7 +1014,7 @@ export function SessionScreen({ [visibleBuiltInIds] ) // Why: Expo SDK 55 edge-to-edge doesn't resize the window on IME open, so track keyboard height ourselves and lift the input without resizing the desktop PTY. - const [keyboardHeight, setKeyboardHeight] = useState(0) + const keyboardHeight = useMobileKeyboardInset().height // Why: server-authoritative display mode per terminal, populated from subscribe responses. const [terminalModes, setTerminalModes] = useState>(new Map()) const [terminalKeyboardMetrics, setTerminalKeyboardMetrics] = useState< @@ -2519,23 +2520,8 @@ export function SessionScreen({ }) useEffect(() => { - const onShow = (e: KeyboardEvent) => { - notifyKeyboardVisibility(true) - setKeyboardHeight(e.endCoordinates?.height ?? 0) - } - const onHide = () => { - notifyKeyboardVisibility(false) - setKeyboardHeight(0) - } - const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' - const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' - const showSub = Keyboard.addListener(showEvent, onShow) - const hideSub = Keyboard.addListener(hideEvent, onHide) - return () => { - showSub.remove() - hideSub.remove() - } - }, [notifyKeyboardVisibility]) + notifyKeyboardVisibility(keyboardHeight > 0) + }, [keyboardHeight, notifyKeyboardVisibility]) const scrollActiveTabIntoView = useCallback((tabId: string | null, animated: boolean) => { if (!tabId) { diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx index a8dbcd8aef9..1ab985d1ebb 100644 --- a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx @@ -1,15 +1,19 @@ -import { useLocalSearchParams } from 'expo-router' import { MobileSourceControlPanel } from '../../../../src/source-control/MobileSourceControlPanel' +import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params' import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' import { parseSourceControlHubTab } from '../../../../src/source-control/mobile-source-control-hub-tab' import type { HostSourceControlBinding } from '../../../../src/source-control/host-source-control-binding' export function MobileSourceControlRoute({ - binding + binding, + routeName, + routeOrigin }: { binding?: HostSourceControlBinding + routeName?: string + routeOrigin?: string } = {}) { - const params = useLocalSearchParams<{ + const params = useMobileWebRouteParams<{ hostId?: string | string[] worktreeId?: string | string[] name?: string | string[] @@ -20,8 +24,8 @@ export function MobileSourceControlRoute({ () + const params = useMobileWebRouteParams<{ hostId: string; taskSource?: string }>() const hostId = hostIdProp ?? params.hostId const taskSource = params.taskSource const router = useRouter() diff --git a/mobile/app/hybrid.tsx b/mobile/app/hybrid.tsx index 442bf076f93..51438dcf1d1 100644 --- a/mobile/app/hybrid.tsx +++ b/mobile/app/hybrid.tsx @@ -25,10 +25,9 @@ import { useMobileWebUserGestureAuthority as useGestureAuthority } from '../src/ import { useMobileWebHostCatalog } from '../src/mobile-web/use-mobile-web-host-catalog' import { mobileWebDiagnosticsStore } from '../src/mobile-web/mobile-web-diagnostics-store' import { useMobileWebBridgeRuntimeRef } from '../src/mobile-web/use-mobile-web-bridge-runtime-ref' -import { - completeMobileWebNativeRouteHandoffAfterResponse, - MobileWebNativeRouteHandoff -} from '../src/mobile-web/mobile-web-native-route-handoff' +import { useMobileWebHardwareBackHandoff } from '../src/mobile-web/use-mobile-web-hardware-back-handoff' +import { MobileWebNativeRouteHandoff } from '../src/mobile-web/mobile-web-native-route-handoff' +import { handleMobileWebBrokerMessage } from '../src/mobile-web/mobile-web-broker-message-handoff' import { useForceReconnect, useForgetHostClient, @@ -67,19 +66,20 @@ export default function HybridScreen() { const { client, state } = useHostClient(selectedHostId) const closeHostClient = useForgetHostClient() const forceReconnectHost = useForceReconnect() - const reconnectAttempts = useReconnectAttempt(selectedHostId) - const lastConnectedAt = useLastConnectedAt(selectedHostId) + const reconnects = useReconnectAttempt(selectedHostId) + const lastConnected = useLastConnectedAt(selectedHostId) const selectedHost = useMemo( () => hosts.find((host) => host.id === selectedHostId), [hosts, selectedHostId] ) + const hostName = selectedHost?.name const { session, viewEpoch, packageLoading, packageWarning, markHealthy, - handleHealthTimeout, + handleHealthTimeout: onHealthTimeout, handleProcessTerminated, retryPackage, recoverPrevious, @@ -96,6 +96,10 @@ export default function HybridScreen() { shellSessionId: session?.sessionId, selectHost }) + const handleBack = useCallback(() => { + coldResumeRoute.clearRoute() + leaveHostRoute(router) + }, [coldResumeRoute.clearRoute, router]) useEffect(() => { if (hostsLoading || selectedHost || e2eHostId) { @@ -151,6 +155,13 @@ export default function HybridScreen() { } await view.postMessage(JSON.stringify(message)) }, []) + const hardwareBackHandoff = useMobileWebHardwareBackHandoff({ + shellSessionId: session?.sessionId, + buildId: session?.buildId, + forwardingEnabled: hostedViewActive, + postMessage: postToWeb, + onUnhandled: handleBack + }) useEffect(() => { brokerRef.current?.dispose() @@ -251,7 +262,7 @@ export default function HybridScreen() { initializedSessionRef.current = current.sessionId healthDeadlineRef.current.arm(current.sessionId, (sessionId) => { if (activeSessionIdRef.current === sessionId) { - void handleHealthTimeout(sessionId) + void onHealthTimeout(sessionId) } }) await postToWeb({ @@ -260,12 +271,13 @@ export default function HybridScreen() { shellSessionId: current.sessionId, buildId: current.buildId, connection: mobileWebBridgeConnectionState(state), - reconnectAttempts, - lastConnectedAt, + hostDisplayName: hostName, + reconnectAttempts: reconnects, + lastConnectedAt: lastConnected, resumeRoute: resumeRouteRef.current, grants: [...MOBILE_WEB_PRODUCTION_GRANTS] }) - }, [handleHealthTimeout, lastConnectedAt, postToWeb, reconnectAttempts, session, state]) + }, [hostName, lastConnected, onHealthTimeout, postToWeb, reconnects, session, state]) useEffect(() => { postInitRef.current = postInit }, [postInit]) @@ -282,10 +294,10 @@ export default function HybridScreen() { shellSessionId: current.sessionId, buildId: current.buildId, state: mobileWebBridgeConnectionState(state), - reconnectAttempts, - lastConnectedAt + reconnectAttempts: reconnects, + lastConnectedAt: lastConnected }) - }, [lastConnectedAt, postToWeb, reconnectAttempts, session, state]) + }, [lastConnected, postToWeb, reconnects, session, state]) const handleBridgeMessage = useCallback( async (raw: string) => { @@ -301,53 +313,40 @@ export default function HybridScreen() { return } responseDropRef.current.recordRequest(parsed.value) + const backMessageHandled = hardwareBackHandoff.handlePageMessage(parsed.value) if (parsed.value.type === 'ready') { // `ready` acknowledges init; echoing init here starves the health frame. if (activeSessionIdRef.current === current.sessionId) { setPageReadySessionId(current.sessionId) } + } else if (backMessageHandled) { + return } else if (parsed.value.type === 'health') { healthDeadlineRef.current.acknowledge(current.sessionId) await markHealthy(current.sessionId) } else if (parsed.value.type === 'routeState') { brokerRef.current?.rememberRoute(parsed.value.route) } else { - const broker = brokerRef.current - await broker?.handle(parsed.value) - if ( - parsed.value.type === 'request' && - broker && - brokerRef.current === broker && - activeSessionIdRef.current === current.sessionId - ) { - completeMobileWebNativeRouteHandoffAfterResponse({ - handoff: nativeRouteHandoffRef.current, - requestId: parsed.value.requestId, - shouldNavigate: () => - brokerRef.current === broker && activeSessionIdRef.current === current.sessionId, - deactivateSessionView: async () => { - const view = viewRef.current - if (!view) { - throw new Error('mobile_web_session_view_unavailable') - } - await view.deactivateSessionView() - }, - setHostedViewActive, - navigate: () => { - router.push('/terminal-settings') - }, - onFailure: () => showWarning('Terminal settings could not be opened.') - }) - } + await handleMobileWebBrokerMessage({ + message: parsed.value, + brokerRef, + activeSessionIdRef, + sessionId: current.sessionId, + viewRef, + routeHandoff: nativeRouteHandoffRef.current, + setHostedViewActive, + navigateToTerminalSettings: () => router.push('/terminal-settings'), + onNavigationFailure: () => showWarning('Terminal settings could not be opened.') + }) } }, - [markHealthy, postInit, router, session, showWarning] + [hardwareBackHandoff, markHealthy, postInit, router, session, showWarning] ) - const shellContext = useMemo( () => (session ? { sessionId: session.sessionId, buildId: session.buildId } : null), [session?.buildId, session?.sessionId] ) + const getBroker = useCallback(() => brokerRef.current, []) const rememberRoute = useCallback((route: MobileWebResumeRoute) => { resumeRouteRef.current = route @@ -369,11 +368,6 @@ export default function HybridScreen() { showWarning }) - const handleBack = useCallback(() => { - coldResumeRoute.clearRoute() - leaveHostRoute(router) - }, [coldResumeRoute.clearRoute, router]) - return ( void handleBridgeMessage(message)} - onPageLoaded={() => void postInit()} + onPageLoaded={() => { + hardwareBackHandoff.resetPage() + void postInit() + }} onNavigationBlocked={() => showWarning('Navigation outside Orca was blocked.')} onProcessTerminated={(sessionId) => { + hardwareBackHandoff.resetPage() healthDeadlineRef.current.clear() void handleProcessTerminated(sessionId) }} diff --git a/mobile/host-web-app/_layout.tsx b/mobile/host-web-app/_layout.tsx index 24ad8713fc9..a15cd0daf6a 100644 --- a/mobile/host-web-app/_layout.tsx +++ b/mobile/host-web-app/_layout.tsx @@ -1,16 +1,26 @@ -import { Stack } from 'expo-router' +import { Stack, usePathname } from 'expo-router' import { StyleSheet, View } from 'react-native' import { GestureHandlerRootView } from 'react-native-gesture-handler' import { SafeAreaProvider } from 'react-native-safe-area-context' -import { MobileWebNativeShellProvider } from '../../src/mobile-web/src/native-shell-channel' +import { + MobileWebNativeShellProvider, + useMobileWebNativeShell +} from '../../src/mobile-web/src/native-shell-channel' import { installMobileWebHistorySessionFragment } from '../src/mobile-web/mobile-web-history-session-fragment' +import { + MobileWebNativeBehaviorAdapter, + installMobileWebNativeBehaviorAdapters +} from '../src/mobile-web/mobile-web-native-behavior-adapter' +import { installMobileWebQuerylessHistory } from '../src/mobile-web/mobile-web-queryless-history' import { colors } from '../src/theme/mobile-theme' import { RpcClientProvider } from '../src/transport/client-context' import { MobileWebRouteErrorBoundary } from './mobile-web-route-error-boundary' import { MobileWebRouteRestorer } from './mobile-web-route-restorer' installMobileWebHistorySessionFragment() +installMobileWebQuerylessHistory() +installMobileWebNativeBehaviorAdapters() export default function HostMobileWebLayout() { return ( @@ -19,11 +29,8 @@ export default function HostMobileWebLayout() { - - - - - + + @@ -31,6 +38,22 @@ export default function HostMobileWebLayout() { ) } +function MobileWebRouteStack() { + const pathname = usePathname() + const shell = useMobileWebNativeShell() + const contextKey = shell.context + ? `${shell.context.shellSessionId}:${shell.context.buildId}` + : 'pending' + + return ( + + + + + + ) +} + const styles = StyleSheet.create({ root: { flex: 1, diff --git a/mobile/host-web-app/h/[hostId]/accounts.tsx b/mobile/host-web-app/h/[hostId]/accounts.tsx index 36f0ace4d62..e539c86395b 100644 --- a/mobile/host-web-app/h/[hostId]/accounts.tsx +++ b/mobile/host-web-app/h/[hostId]/accounts.tsx @@ -8,8 +8,11 @@ const HOSTED_PAGE_HOST_ID = 'paired-orca-desktop' export default function HostMobileWebAccountsRoute() { const shell = useMobileWebNativeShell() const operations = useMemo( - () => (shell.client ? webHostAccountsOperations(shell.client, 'Orca Desktop') : undefined), - [shell.client] + () => + shell.client + ? webHostAccountsOperations(shell.client, shell.hostDisplayName ?? 'Orca Desktop') + : undefined, + [shell.client, shell.hostDisplayName] ) const connectionState = shell.connection === 'offline' diff --git a/mobile/host-web-app/h/[hostId]/agent-history/[worktreeId].tsx b/mobile/host-web-app/h/[hostId]/agent-history/[worktreeId].tsx index 05035877853..6434db4b683 100644 --- a/mobile/host-web-app/h/[hostId]/agent-history/[worktreeId].tsx +++ b/mobile/host-web-app/h/[hostId]/agent-history/[worktreeId].tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useLocalSearchParams, useRouter } from 'expo-router' +import { useRouter } from 'expo-router' import type { AiVaultScope } from '../../../../../src/shared/ai-vault-types' import type { MobileWebAgentHistorySession, @@ -7,6 +7,7 @@ import type { } from '../../../../../src/shared/mobile-web/agent-history-operation-contract' import type { MobileWebBridgeClient } from '../../../../../src/mobile-web/src/mobile-web-bridge-client' import { useMobileWebNativeShell } from '../../../../../src/mobile-web/src/native-shell-channel' +import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params' import { MobileAgentSessionHistoryPresentation, type MobileAgentHistoryPresentationState @@ -18,7 +19,7 @@ import { getWorktreeLabel } from '../../../../src/session/worktree-label' export default function HostMobileWebAgentHistoryRoute() { const shell = useMobileWebNativeShell() const router = useRouter() - const { hostId, worktreeId, name } = useLocalSearchParams<{ + const { hostId, worktreeId, name } = useMobileWebRouteParams<{ hostId: string worktreeId: string name?: string @@ -31,6 +32,11 @@ export default function HostMobileWebAgentHistoryRoute() { const [resumingSessionId, setResumingSessionId] = useState(null) const sessionsRef = useRef([]) const loadGenerationRef = useRef(0) + const workspaceName = + name ?? + (shell.resumeRoute.kind === 'session' && shell.resumeRoute.workspaceId === worktreeId + ? shell.resumeRoute.workspaceName + : undefined) const load = useCallback( async (force: boolean) => { @@ -145,7 +151,7 @@ export default function HostMobileWebAgentHistoryRoute() { return ( () const workspaceId = firstParam(params.worktreeId) const shell = useMobileWebNativeShell() + const workspaceName = + shell.resumeRoute.kind === 'session' && shell.resumeRoute.workspaceId === workspaceId + ? shell.resumeRoute.workspaceName + : undefined const client = useMemo( () => (shell.client && workspaceId ? webHostDiffReviewClient(shell.client, workspaceId) : null), [shell.client, workspaceId] @@ -21,6 +25,7 @@ export default function HostMobileWebDiffReviewRoute() { return ( () diff --git a/mobile/host-web-app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/host-web-app/h/[hostId]/source-control/[worktreeId].tsx index 15c0be10424..eeecd745a73 100644 --- a/mobile/host-web-app/h/[hostId]/source-control/[worktreeId].tsx +++ b/mobile/host-web-app/h/[hostId]/source-control/[worktreeId].tsx @@ -8,6 +8,10 @@ export default function HostMobileWebSourceControlRoute() { const params = useLocalSearchParams<{ worktreeId?: string | string[] }>() const workspaceId = firstParam(params.worktreeId) const shell = useMobileWebNativeShell() + const workspaceName = + shell.resumeRoute.kind === 'session' && shell.resumeRoute.workspaceId === workspaceId + ? shell.resumeRoute.workspaceName + : undefined const client = useMemo( () => shell.client && workspaceId ? webHostSourceControlClient(shell.client, workspaceId) : null, @@ -22,6 +26,7 @@ export default function HostMobileWebSourceControlRoute() { return ( webHostScreenHostState({ name: 'Orca Desktop', publicKeyB64: '' }), - [] + () => + webHostScreenHostState({ + name: shell.hostDisplayName ?? 'Orca Desktop', + publicKeyB64: '' + }), + [shell.hostDisplayName] ) const shellOperations = useMemo( () => diff --git a/mobile/host-web-app/mobile-web-route-error-boundary.tsx b/mobile/host-web-app/mobile-web-route-error-boundary.tsx index fdc3dfa8394..cb07a36cc42 100644 --- a/mobile/host-web-app/mobile-web-route-error-boundary.tsx +++ b/mobile/host-web-app/mobile-web-route-error-boundary.tsx @@ -6,19 +6,35 @@ import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' type MobileWebRouteErrorBoundaryProps = { children: ReactNode + resetKey: string } type MobileWebRouteErrorBoundaryState = { failureCode: string | null + resetKey: string } export class MobileWebRouteErrorBoundary extends Component< MobileWebRouteErrorBoundaryProps, MobileWebRouteErrorBoundaryState > { - state: MobileWebRouteErrorBoundaryState = { failureCode: null } + state: MobileWebRouteErrorBoundaryState = { + failureCode: null, + resetKey: this.props.resetKey + } - static getDerivedStateFromError(error: unknown): MobileWebRouteErrorBoundaryState { + static getDerivedStateFromProps( + props: MobileWebRouteErrorBoundaryProps, + state: MobileWebRouteErrorBoundaryState + ): MobileWebRouteErrorBoundaryState | null { + return props.resetKey === state.resetKey + ? null + : { failureCode: null, resetKey: props.resetKey } + } + + static getDerivedStateFromError( + error: unknown + ): Pick { return { failureCode: mobileWebRouteFailureCode(error) } @@ -37,11 +53,23 @@ export class MobileWebRouteErrorBoundary extends Component< } return ( - Workspace view stopped - + + Workspace view stopped + + Reload the desktop-served interface to reconnect. Diagnostic: {this.state.failureCode} - window.location.reload()}> + Reload interface @@ -49,6 +77,11 @@ export class MobileWebRouteErrorBoundary extends Component< } } +function reloadMobileWebRoot(): void { + window.history.replaceState(window.history.state, '', '/') + window.location.reload() +} + const styles = StyleSheet.create({ container: { flex: 1, diff --git a/mobile/host-web-app/mobile-web-route-restorer.tsx b/mobile/host-web-app/mobile-web-route-restorer.tsx index 5b79c38239f..33c2a319506 100644 --- a/mobile/host-web-app/mobile-web-route-restorer.tsx +++ b/mobile/host-web-app/mobile-web-route-restorer.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'expo-router' import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel' import { mobileWebNavigationRouteTarget } from '../src/mobile-web/mobile-web-route-restoration' +import { rememberMobileWebRouteQuery } from '../src/mobile-web/mobile-web-route-query-cache' export function MobileWebRouteRestorer() { const router = useRouter() @@ -18,8 +19,47 @@ export function MobileWebRouteRestorer() { return } restoredContextRef.current = restorationKey - router.replace(mobileWebNavigationRouteTarget(shell.navigationRoute)) + const target = mobileWebNavigationRouteTarget(shell.navigationRoute) + const query = navigationRouteQuery(shell.navigationRoute) + rememberNavigationRouteQuery(target, query) + router.replace(mobileWebRouterReplacementHref(target, query)) }, [router, shell.context, shell.navigationRoute, shell.routeRevision]) return null } + +function rememberNavigationRouteQuery( + target: string, + query: Readonly> +): void { + const pathname = new URL(target, 'https://orca-mobile-web.invalid').pathname + rememberMobileWebRouteQuery(pathname, query) +} + +function navigationRouteQuery( + route: ReturnType['navigationRoute'] +): Readonly> { + if (route.kind === 'session') { + return { name: route.workspaceName } + } + if (route.kind === 'tasks') { + return { taskSource: route.taskSource } + } + if (route.kind === 'newWorkspace') { + return { action: 'newWorktree' } + } + return {} +} + +function mobileWebRouterReplacementHref( + target: string, + query: Readonly> +): string { + const url = new URL(target, 'https://orca-mobile-web.invalid') + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + url.searchParams.set(key, value) + } + } + return `${url.pathname}${url.search}` +} diff --git a/mobile/package.json b/mobile/package.json index 0902d2ca874..43115531eb7 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -7,7 +7,7 @@ "start": "node scripts/start-expo.mjs", "android": "expo run:android", "ios": "expo run:ios", - "export:host-web": "node scripts/export-host-mobile-web.mjs", + "export:host-web": "tsx scripts/export-host-mobile-web.mjs", "postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs", "test": "vitest run", "typecheck": "tsc --noEmit", @@ -20,6 +20,7 @@ "test:e2e:hosted-webview": "node scripts/run-hosted-webview-simulator-e2e.mjs", "test:e2e:hosted-webview:security": "node scripts/run-hosted-webview-simulator-e2e.mjs --security-only", "test:e2e:hosted-webview:android-routes": "node scripts/run-hosted-android-source-control-review-e2e.mjs", + "test:e2e:hosted-webview:android-back": "node scripts/run-hosted-android-hardware-back-e2e.mjs", "test:e2e:hosted-webview:android-security": "node scripts/run-hosted-android-webview-security-e2e.mjs", "test:e2e:hosted-webview:android-release": "node scripts/verify-hosted-android-release-webview.mjs", "test:e2e:hosted-webview:android-crash-loop": "node scripts/run-hosted-android-webview-crash-loop.mjs", diff --git a/mobile/scripts/emulator-macos-keychain-home.mjs b/mobile/scripts/emulator-macos-keychain-home.mjs new file mode 100644 index 00000000000..c2d4c5ab717 --- /dev/null +++ b/mobile/scripts/emulator-macos-keychain-home.mjs @@ -0,0 +1,30 @@ +import { existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +export function linkEmulatorMacKeychain( + homeDir, + { platform = process.platform, realHome = os.homedir() } = {} +) { + if (platform !== 'darwin') { + return null + } + const source = path.join(realHome, 'Library', 'Keychains') + if (!existsSync(source)) { + return null + } + const target = path.join(homeDir, 'Library', 'Keychains') + mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }) + if (existsSync(target)) { + if (lstatSync(target).isSymbolicLink()) { + const linkedSource = path.resolve(path.dirname(target), readlinkSync(target)) + if (linkedSource === path.resolve(source)) { + return target + } + } + throw new Error(`Refusing to replace emulator Keychain path: ${target}`) + } + symlinkSync(source, target, 'dir') + return target +} diff --git a/mobile/scripts/emulator-pairing-child-environment.mjs b/mobile/scripts/emulator-pairing-child-environment.mjs new file mode 100644 index 00000000000..ec7a448e7d7 --- /dev/null +++ b/mobile/scripts/emulator-pairing-child-environment.mjs @@ -0,0 +1,20 @@ +export function createEmulatorPairingChildEnvironment({ + inheritedEnvironment, + environment, + userData, + homeDir +}) { + const childEnvironment = { + ...inheritedEnvironment, + ...environment, + ORCA_DEV_USER_DATA_PATH: userData, + ORCA_E2E_USER_DATA_DIR: userData, + ORCA_E2E_HOME_DIR: homeDir, + HOME: homeDir, + USERPROFILE: homeDir + } + // Keep the disposable home from importing the developer's real agent histories. + delete childEnvironment.CODEX_HOME + delete childEnvironment.ORCA_CODEX_HOME + return childEnvironment +} diff --git a/mobile/scripts/emulator-pairing-runtime-process.mjs b/mobile/scripts/emulator-pairing-runtime-process.mjs index b2908a38b1d..3c675eda414 100644 --- a/mobile/scripts/emulator-pairing-runtime-process.mjs +++ b/mobile/scripts/emulator-pairing-runtime-process.mjs @@ -36,6 +36,17 @@ export function pairingDaemonPidsFromUserData( } } +export function currentPairingDaemonPids( + userData, + knownPids, + readDirectory = (directory) => readdirSync(directory), + readText = (filePath) => readFileSync(filePath, 'utf8') +) { + return [ + ...new Set([...knownPids, ...pairingDaemonPidsFromUserData(userData, readDirectory, readText)]) + ] +} + export function signalPairingRuntime(runtimePid, sendSignal = process.kill) { if (runtimePid) { sendSignal(runtimePid, 'SIGTERM') diff --git a/mobile/scripts/export-host-mobile-web.mjs b/mobile/scripts/export-host-mobile-web.mjs index 325ec774c79..e8a85ffcd5b 100644 --- a/mobile/scripts/export-host-mobile-web.mjs +++ b/mobile/scripts/export-host-mobile-web.mjs @@ -1,37 +1,72 @@ -import { spawn } from 'node:child_process' import path from 'node:path' import process from 'node:process' +import { spawnProcess } from '../../src/shared/child-process/run-process.ts' const mobileRoot = path.resolve(import.meta.dirname, '..') -const outputArgument = process.argv.slice(2).find((value) => value !== '--') -const outputDirectory = path.resolve(mobileRoot, outputArgument ?? '../out/mobile-web-rnw-proof') -const packageManager = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -const child = spawn( - packageManager, - ['exec', 'expo', 'export', '--platform', 'web', '--output-dir', outputDirectory], - { - cwd: mobileRoot, +export function createHostMobileWebExportProcessSpec({ + environment = process.env, + mobileDirectory = mobileRoot, + outputDirectory, + platform = process.platform +}) { + return { + program: platform === 'win32' ? 'pnpm.cmd' : 'pnpm', + args: ['exec', 'expo', 'export', '--platform', 'web', '--output-dir', outputDirectory], + cwd: mobileDirectory, env: { - ...process.env, + ...environment, NODE_ENV: 'production', ORCA_EXPO_ROUTER_ROOT: 'host-web-app' - }, - shell: process.platform === 'win32', - stdio: 'inherit' + } } -) +} -child.on('error', (error) => { - console.error(error instanceof Error ? error.message : String(error)) - process.exitCode = 1 -}) +export async function runHostMobileWebExport({ + environment = process.env, + mobileDirectory = mobileRoot, + outputDirectory, + platform = process.platform, + spawn = spawnProcess, + stderr = process.stderr, + stdout = process.stdout +}) { + const child = spawn( + createHostMobileWebExportProcessSpec({ + environment, + mobileDirectory, + outputDirectory, + platform + }) + ) + child.stdout?.pipe(stdout) + child.stderr?.pipe(stderr) + for (const stream of [child.stdin, child.stdout, child.stderr]) { + stream?.on('error', () => {}) + } + child.stdin?.end() -child.on('exit', (code, signal) => { + const { code, signal } = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (exitCode, exitSignal) => { + resolve({ code: exitCode, signal: exitSignal }) + }) + }) if (signal) { - console.error(`Expo web export terminated by ${signal}`) - process.exitCode = 1 - return + stderr.write(`Expo web export terminated by ${signal}\n`) + return 1 } - process.exitCode = code ?? 1 -}) + return code ?? 1 +} + +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) { + const outputArgument = process.argv.slice(2).find((value) => value !== '--') + const outputDirectory = path.resolve(mobileRoot, outputArgument ?? '../out/mobile-web-rnw-proof') + + try { + process.exitCode = await runHostMobileWebExport({ outputDirectory }) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } +} diff --git a/mobile/scripts/hosted-adversarial-provider-content.mjs b/mobile/scripts/hosted-adversarial-provider-content.mjs index b1c3b5fe65b..d197ef094c0 100644 --- a/mobile/scripts/hosted-adversarial-provider-content.mjs +++ b/mobile/scripts/hosted-adversarial-provider-content.mjs @@ -97,7 +97,7 @@ export async function verifyHostedAdversarialTasks({ try { workspaceDocument = await waitForVisibleHostedWebView({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: 'Host 1', timeoutMs: Math.min(timeoutMs, 15_000) }) } catch (error) { diff --git a/mobile/scripts/hosted-android-agent-history-journey.mjs b/mobile/scripts/hosted-android-agent-history-journey.mjs index 47e5a919723..63be700e76d 100644 --- a/mobile/scripts/hosted-android-agent-history-journey.mjs +++ b/mobile/scripts/hosted-android-agent-history-journey.mjs @@ -21,35 +21,13 @@ export async function verifyHostedAndroidAgentHistoryJourney({ sessionDocument, timeoutMs }) { - if (!sessionDocument.href.includes('/session/')) { - throw new Error('Agent History journey requires an active Session route.') - } - const actionsDocument = await evidenceStep('open Session actions', () => - retryAndroidRouteActivation({ - activate: () => tapAndroidLabel(emulator, sessionDocument, 'More session actions'), - wait: () => - waitForVisibleHostedWebView({ - discoveryUrl, - expectedText: 'Agent History', - expectedHrefIncludes: '/session/', - timeoutMs: Math.min(timeoutMs, 3_000) - }) - }) - ) - await evidenceStep('open Agent History', () => - retryAndroidRouteActivation({ - activate: () => - tapAndroidText(emulator, actionsDocument, 'Agent History', Math.min(timeoutMs, 5_000)), - wait: () => - waitForVisibleHostedWebView({ - discoveryUrl, - expectedText: 'Agent Session History', - expectedHrefIncludes: '/agent-history/', - timeoutMs: Math.min(timeoutMs, 3_000) - }) - }) - ) - let historyDocument = await waitForVisibleHostedWebView({ + let historyDocument = await openHostedAndroidAgentHistory({ + discoveryUrl, + emulator, + sessionDocument, + timeoutMs + }) + historyDocument = await waitForVisibleHostedWebView({ discoveryUrl, expectedText: EMULATOR_AGENT_HISTORY_TITLE, expectedHrefIncludes: '/agent-history/', @@ -142,6 +120,42 @@ export async function verifyHostedAndroidAgentHistoryJourney({ } } +export async function openHostedAndroidAgentHistory({ + discoveryUrl, + emulator, + sessionDocument, + timeoutMs +}) { + if (!sessionDocument.href.includes('/session/')) { + throw new Error('Agent History journey requires an active Session route.') + } + const actionsDocument = await evidenceStep('open Session actions', () => + retryAndroidRouteActivation({ + activate: () => tapAndroidLabel(emulator, sessionDocument, 'More session actions'), + wait: () => + waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: 'Agent History', + expectedHrefIncludes: '/session/', + timeoutMs: Math.min(timeoutMs, 3_000) + }) + }) + ) + return evidenceStep('open Agent History', () => + retryAndroidRouteActivation({ + activate: () => + tapAndroidText(emulator, actionsDocument, 'Agent History', Math.min(timeoutMs, 5_000)), + wait: () => + waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: 'Agent Session History', + expectedHrefIncludes: '/agent-history/', + timeoutMs: Math.min(timeoutMs, 3_000) + }) + }) + ) +} + async function tapAndroidLabel(emulator, document, label) { const point = await readStableHostedAndroidWebViewPoint(() => readHostedWebViewControlPoint(document, label) diff --git a/mobile/scripts/hosted-android-emulator-session.mjs b/mobile/scripts/hosted-android-emulator-session.mjs index 2de23b5bd69..563278faad1 100644 --- a/mobile/scripts/hosted-android-emulator-session.mjs +++ b/mobile/scripts/hosted-android-emulator-session.mjs @@ -81,10 +81,10 @@ export async function startHostedAndroidMetro({ mobileDir, pairingUrl, port }) { } } -export async function installAndResetHostedAndroidApp(adb, apk) { - await runAndroidAdb(adb, ['install', '-r', '-t', apk], 120_000) - await runAndroidAdb(adb, ['shell', 'pm', 'clear', packageName]) - await runAndroidAdb(adb, [ +export async function installAndResetHostedAndroidApp(adb, apk, runAdb = runAndroidAdb) { + await runAdb(adb, ['install', '-r', '-d', '-t', apk], 120_000) + await runAdb(adb, ['shell', 'pm', 'clear', packageName]) + await runAdb(adb, [ 'shell', 'pm', 'grant', diff --git a/mobile/scripts/hosted-android-hardware-back-journey.mjs b/mobile/scripts/hosted-android-hardware-back-journey.mjs new file mode 100644 index 00000000000..10f9d813e6c --- /dev/null +++ b/mobile/scripts/hosted-android-hardware-back-journey.mjs @@ -0,0 +1,119 @@ +import { waitForHostedAndroidAccessibilityControlMatch } from './hosted-android-emulator-accessibility.mjs' +import { runAndroidAdb } from './hosted-android-mobile-web-cache.mjs' +import { + activateHostedWebViewControl, + waitForVisibleHostedWebView +} from './hosted-webview-cdp-session.mjs' + +export async function sendHostedAndroidHardwareBack(adb, runAdb = runAndroidAdb) { + await runAdb(adb, ['shell', 'input', 'keyevent', 'KEYCODE_BACK']) +} + +export async function verifyHostedAndroidHardwareBackJourney( + { adb, discoveryUrl, emulator, sessionDocument, timeoutMs, workspaceMarker }, + dependencies = {} +) { + const openAgentHistory = dependencies.openAgentHistory ?? openHostedAndroidHardwareBackNestedRoute + const pressBack = dependencies.pressBack ?? sendHostedAndroidHardwareBack + const waitForDocument = dependencies.waitForDocument ?? waitForVisibleHostedWebView + const waitForWorkspaceRoot = + dependencies.waitForWorkspaceRoot ?? waitForHostedAndroidWorkspaceRoot + const waitForNativeControl = + dependencies.waitForNativeControl ?? waitForHostedAndroidAccessibilityControlMatch + + const historyDocument = await openAgentHistory({ + discoveryUrl, + emulator, + sessionDocument, + timeoutMs + }) + await pressBack(adb) + const returnedSession = await waitForDocument({ + discoveryUrl, + expectedText: '1 tab', + expectedHrefIncludes: '/session/', + requireInteractiveControls: false, + timeoutMs + }) + await pressBack(adb) + const workspaceRoot = await waitForWorkspaceRoot({ + discoveryUrl, + timeoutMs, + waitForDocument, + workspaceMarker + }) + if (workspaceRoot.href.includes('/session/') || workspaceRoot.href.includes('/agent-history/')) { + throw new Error(`Android hardware Back did not reach the workspace root: ${workspaceRoot.href}`) + } + await pressBack(adb) + const nativeShell = await waitForNativeControl( + emulator, + [ + 'Open settings', + 'Open sessions in Chat UI', + 'Open sessions in the terminal', + 'Show paired hosts' + ], + timeoutMs + ) + + return { + nestedRoute: historyDocument.href, + returnedSessionRoute: returnedSession.href, + workspaceRootRoute: workspaceRoot.href, + nativeShellControl: nativeShell.label, + hardwareBackPresses: 3 + } +} + +export async function waitForHostedAndroidWorkspaceRoot({ + discoveryUrl, + settle = delay, + timeoutMs, + waitForDocument = waitForVisibleHostedWebView, + workspaceMarker +}) { + const deadline = Date.now() + timeoutMs + let lastHref = '' + while (Date.now() < deadline) { + const document = await waitForDocument({ + discoveryUrl, + expectedText: workspaceMarker, + timeoutMs: Math.min(2_000, Math.max(1, deadline - Date.now())) + }) + lastHref = document.href + if (!lastHref.includes('/session/') && !lastHref.includes('/agent-history/')) { + return document + } + await settle(100) + } + throw new Error(`Android hardware Back did not reach the workspace root: ${lastHref}`) +} + +export async function openHostedAndroidHardwareBackNestedRoute( + { discoveryUrl, sessionDocument, timeoutMs }, + dependencies = {} +) { + const activate = dependencies.activate ?? activateHostedWebViewControl + const waitForDocument = dependencies.waitForDocument ?? waitForVisibleHostedWebView + const settle = dependencies.settle ?? delay + await activate(sessionDocument, { kind: 'label', value: 'More session actions' }) + const actionsDocument = await waitForDocument({ + discoveryUrl, + expectedText: 'Agent History', + expectedHrefIncludes: '/session/', + timeoutMs + }) + await settle(500) + await activate(actionsDocument, { kind: 'text', value: 'Agent History' }) + return waitForDocument({ + discoveryUrl, + expectedText: 'Agent Session History', + expectedHrefIncludes: '/agent-history/', + timeoutMs + }) +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/mobile/scripts/hosted-android-pairing.mjs b/mobile/scripts/hosted-android-pairing.mjs new file mode 100644 index 00000000000..6e9d2c3f04f --- /dev/null +++ b/mobile/scripts/hosted-android-pairing.mjs @@ -0,0 +1,42 @@ +import { + tapHostedAndroidAccessibilityControl, + waitForHostedAndroidAccessibilityControlMatch +} from './hosted-android-emulator-accessibility.mjs' +import { openHostedAndroidUrl } from './hosted-android-emulator-session.mjs' + +export async function pairHostedAndroidApp({ adb, pairingUrl, timeoutMs }) { + const emulator = { adb } + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + await openHostedAndroidUrl(adb, pairingUrl) + try { + const control = await waitForHostedAndroidAccessibilityControlMatch( + emulator, + ['Close', 'Continue', 'Pair'], + Math.min(5_000, deadline - Date.now()) + ) + if (control.label === 'Pair') { + await tapHostedAndroidAccessibilityControl(emulator, control.label, 2_000) + break + } + await tapHostedAndroidAccessibilityControl(emulator, control.label, 2_000) + } catch { + // The development bundle can mount after the first deep link. + } + } + const destination = await waitForHostedAndroidAccessibilityControlMatch( + emulator, + [ + 'Open sessions in Chat UI', + 'Open sessions in the terminal', + 'Enable agent notifications', + 'Skip notifications for now', + 'Show paired hosts', + 'Back to home' + ], + Math.max(1_000, deadline - Date.now()) + ) + if (destination.label === 'Back to home') { + throw new Error('Android pairing failed before reaching the onboarding flow') + } +} diff --git a/mobile/scripts/hosted-ios-accounts-parity.mjs b/mobile/scripts/hosted-ios-accounts-parity.mjs index 90b5216829d..4ba446c5b54 100644 --- a/mobile/scripts/hosted-ios-accounts-parity.mjs +++ b/mobile/scripts/hosted-ios-accounts-parity.mjs @@ -45,6 +45,7 @@ export async function captureHostedAccountsParity({ deviceUdid, discoveryUrl, emulator, + expectedWorkspace, nativeBaseline, runtimeDirectory, timeoutMs, @@ -67,7 +68,7 @@ export async function captureHostedAccountsParity({ await tapHostedIosPoint(emulator, accountsBackPoint(accounts.screenTitlePoint)) const returnedWorkspaceDocument = await waitForVisibleHostedWebView({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: expectedWorkspace, timeoutMs }) return { diff --git a/mobile/scripts/hosted-ios-agent-history-journey.mjs b/mobile/scripts/hosted-ios-agent-history-journey.mjs index 2c9254fda25..71a91ff3a45 100644 --- a/mobile/scripts/hosted-ios-agent-history-journey.mjs +++ b/mobile/scripts/hosted-ios-agent-history-journey.mjs @@ -37,7 +37,7 @@ export async function verifyHostedAgentHistoryJourney({ () => waitForVisibleHostedWebView({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: expectedWorkspace, timeoutMs }) ) @@ -94,6 +94,12 @@ export async function verifyHostedAgentHistoryJourney({ if (!state.placeholders.includes('Search sessions, repo:, path:')) { throw new Error('Agent History is missing its existing search input.') } + await evidenceStep('filter Agent History to parity fixture', () => + setHostedWebViewInput(activeHistoryDocument, { + placeholder: 'Search sessions, repo:, path:', + value: EMULATOR_AGENT_HISTORY_TITLE + }) + ) activeHistoryDocument = await waitForVisibleHostedWebView({ discoveryUrl, expectedText: EMULATOR_AGENT_HISTORY_TITLE, @@ -151,7 +157,17 @@ export async function verifyHostedAgentHistoryJourney({ const connectionEntries = await waitForHostedWebViewConnectionSequence( activeHistoryDocument, ['recovering', 'connected'], - timeoutMs + timeoutMs, + { + reacquireDocument: (remainingMs) => + waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: EMULATOR_AGENT_HISTORY_TITLE, + expectedHrefIncludes: '/agent-history/', + requireInteractiveControls: false, + timeoutMs: remainingMs + }) + } ) const recoveringEntry = connectionEntries.find((entry) => entry.state === 'recovering') if (!recoveringEntry?.retainedExpectedText || !recoveringEntry.retainedExpectedRoute) { diff --git a/mobile/scripts/hosted-ios-agent-history-parity.mjs b/mobile/scripts/hosted-ios-agent-history-parity.mjs index cbb5c9038ce..f4c6914829a 100644 --- a/mobile/scripts/hosted-ios-agent-history-parity.mjs +++ b/mobile/scripts/hosted-ios-agent-history-parity.mjs @@ -7,6 +7,7 @@ import { rotateHostedIosEmulator, tapHostedIosAccessibilityControl, tapHostedIosAccessibilityControlByLabelPrefix, + typeHostedIosText, waitForHostedIosAccessibilityControl, waitForHostedIosAccessibilityControlByLabelPrefix } from './hosted-ios-emulator-accessibility.mjs' @@ -14,6 +15,8 @@ import { assertHostedIosScreenshotParity } from './hosted-ios-screenshot-parity. import { readHostedWebViewTextPoint } from './hosted-webview-cdp-session.mjs' const execFileAsync = promisify(execFile) +const AGENT_HISTORY_SEARCH_PLACEHOLDER = 'Search sessions, repo:, path:' +const AGENT_HISTORY_SEARCH_QUERY = 'hybrid agent history fixture' export async function captureNativeAgentHistoryBaseline({ deviceUdid, @@ -47,6 +50,15 @@ export async function openNativeAgentHistoryBaseline({ emulator, expectedWorkspa await tapHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs) await tapHostedIosAccessibilityControl(emulator, 'More session actions', timeoutMs) await tapHostedIosAccessibilityControlByLabelPrefix(emulator, 'Agent History', timeoutMs) + await tapHostedIosAccessibilityControl(emulator, AGENT_HISTORY_SEARCH_PLACEHOLDER, timeoutMs) + await delay(500) + await typeHostedIosText(emulator, AGENT_HISTORY_SEARCH_QUERY) + await waitForHostedIosAccessibilityControlByLabelPrefix( + emulator, + EMULATOR_AGENT_HISTORY_TITLE, + timeoutMs + ) + await tapHostedIosAccessibilityControl(emulator, 'Agent Session History', timeoutMs) } export async function captureHostedAgentHistoryParity({ diff --git a/mobile/scripts/hosted-ios-core-route-parity.mjs b/mobile/scripts/hosted-ios-core-route-parity.mjs index 6defa00a11b..e6c0cdbea62 100644 --- a/mobile/scripts/hosted-ios-core-route-parity.mjs +++ b/mobile/scripts/hosted-ios-core-route-parity.mjs @@ -8,7 +8,8 @@ import { tapHostedIosPoint, waitForHostedIosAccessibilityControl, waitForHostedIosAccessibilityControlByLabelPrefix, - waitForHostedIosAccessibilityControlMatch + waitForHostedIosAccessibilityControlMatch, + waitForHostedIosAccessibilityControlMatching } from './hosted-ios-emulator-accessibility.mjs' import { assertHostedIosScreenshotParity } from './hosted-ios-screenshot-parity.mjs' import { @@ -42,11 +43,7 @@ export async function captureNativeCoreRouteBaselines({ const filterPoint = await waitForHostedIosAccessibilityControl(emulator, 'Filter', timeoutMs) // The existing non-embedded Tasks icon has no native accessibility label. await tapHostedIosPoint(emulator, { x: TASKS_TOOLBAR_X, y: filterPoint.y }) - const taskStableState = await waitForHostedIosAccessibilityControlMatch( - emulator, - TASK_STABLE_TEXTS, - timeoutMs - ) + const taskStableText = await waitForNativeTasksStableText(emulator, timeoutMs) const tasks = await captureNativeRoute({ deviceUdid, emulator, @@ -55,7 +52,7 @@ export async function captureNativeCoreRouteBaselines({ title: 'Tasks', timeoutMs }) - tasks.stableText = taskStableState.label + tasks.stableText = taskStableText await tapHostedIosPoint(emulator, tasksBackPoint(tasks.screenTitlePoint)) await waitForHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs) await tapHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs) @@ -73,6 +70,40 @@ export async function captureNativeCoreRouteBaselines({ return { session, tasks } } +async function waitForNativeTasksStableText(emulator, timeoutMs) { + const emptyStateTimeoutMs = Math.min(timeoutMs, 5_000) + try { + const state = await waitForHostedIosAccessibilityControlMatch( + emulator, + TASK_STABLE_TEXTS, + emptyStateTimeoutMs + ) + return state.label + } catch { + const task = await waitForHostedIosAccessibilityControlMatching( + emulator, + (node) => nativeTaskTitleFromAccessibilityLabel(node.label) !== null, + Math.max(1, timeoutMs - emptyStateTimeoutMs) + ) + const title = nativeTaskTitleFromAccessibilityLabel(task.label) + if (!title) { + throw new Error('Populated native Tasks row did not expose a stable title') + } + return title + } +} + +function nativeTaskTitleFromAccessibilityLabel(label) { + if (typeof label !== 'string') { + return null + } + return ( + label.match( + /^(.*), \d+[mhd], (?:Pull request|Issue|Merge request|Linear ticket|[^,]+ todo) · / + )?.[1] ?? null + ) +} + export async function captureHostedCoreRouteParity({ deviceUdid, discoveryUrl, @@ -101,7 +132,7 @@ export async function captureHostedCoreRouteParity({ await tapHostedIosPoint(emulator, tasksBackPoint(tasks.screenTitlePoint)) let activeWorkspaceDocument = await waitForVisibleHostedWebView({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: expectedWorkspace, timeoutMs }) await activateHostedWorkspaceRow( @@ -112,7 +143,7 @@ export async function captureHostedCoreRouteParity({ () => waitForVisibleHostedWebView({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: expectedWorkspace, timeoutMs }) ) @@ -133,7 +164,7 @@ export async function captureHostedCoreRouteParity({ await tapHostedSessionBack(emulator, sessionDocument, timeoutMs) activeWorkspaceDocument = await waitForVisibleHostedWebView({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: expectedWorkspace, timeoutMs }) return { diff --git a/mobile/scripts/hosted-ios-emulator-accessibility.mjs b/mobile/scripts/hosted-ios-emulator-accessibility.mjs index 91bbf8b2a83..a94e93763b0 100644 --- a/mobile/scripts/hosted-ios-emulator-accessibility.mjs +++ b/mobile/scripts/hosted-ios-emulator-accessibility.mjs @@ -36,6 +36,21 @@ export async function tapHostedIosPoint(args, point, runCommand = runHostedIosEm return point } +export async function typeHostedIosText(args, value, runCommand = runHostedIosEmulatorCommand) { + for (const character of value) { + const command = ['type', character] + try { + await runCommand(args, command) + } catch (error) { + if (!isMissingActiveEmulatorError(error)) { + throw error + } + await restartHostedIosEmulatorController(args, runCommand) + await runCommand(args, command) + } + } +} + export async function tapHostedIosAccessibilityControlAtOccurrence( args, label, @@ -219,6 +234,50 @@ export async function waitForHostedIosAccessibilityControlMatch( ) } +export async function waitForHostedIosAccessibilityControlMatching( + args, + matches, + timeoutMs, + runCommand = runHostedIosEmulatorCommand +) { + return waitForHostedIosAccessibilityControlOccurrence( + args, + 'matching accessibility control', + 0, + timeoutMs, + runCommand, + matches, + (frame) => ({ + x: frame.x + frame.width / 2, + y: frame.y + frame.height / 2 + }), + (node, point) => ({ ...point, label: node.label ?? '', value: node.value ?? '' }) + ) +} + +export async function waitForHostedIosAccessibilityLabel( + args, + label, + timeoutMs, + runCommand = runHostedIosEmulatorCommand +) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const nodes = await readAccessibilityNodesUntilDeadline(args, runCommand, deadline) + const node = nodes.find( + (candidate) => + (candidate.label === label || candidate.value === label) && + candidate.enabled !== false && + isVisibleFrame(candidate.frame) + ) + if (node) { + return { frame: node.frame, label: node.label ?? '', value: node.value ?? '' } + } + await delay(250) + } + throw new Error(`${label} was not accessible in the visible viewport`) +} + export async function rotateHostedIosEmulator( args, orientation, @@ -237,7 +296,8 @@ async function waitForHostedIosAccessibilityControlOccurrence( pointForFrame = (frame) => ({ x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 - }) + }), + resultForControl = (_node, point) => point ) { const deadline = Date.now() + timeoutMs let lastLabels = [] @@ -251,7 +311,7 @@ async function waitForHostedIosAccessibilityControlOccurrence( if (control) { const point = pointForFrame(control.frame) if (isNormalizedPoint(point)) { - return point + return resultForControl(control, point) } } await delay(250) @@ -440,6 +500,16 @@ function isFiniteFrame(frame) { ) } +function isVisibleFrame(frame) { + return ( + isFiniteFrame(frame) && + frame.x + frame.width > 0 && + frame.y + frame.height > 0 && + frame.x < 1 && + frame.y < 1 + ) +} + function isNormalizedPoint(point) { return point.x >= 0 && point.x <= 1 && point.y >= 0 && point.y <= 1 } diff --git a/mobile/scripts/hosted-ios-files-preview-parity.mjs b/mobile/scripts/hosted-ios-files-preview-parity.mjs index 8aaad1b180b..011152a8ffd 100644 --- a/mobile/scripts/hosted-ios-files-preview-parity.mjs +++ b/mobile/scripts/hosted-ios-files-preview-parity.mjs @@ -7,7 +7,8 @@ import { tapHostedIosAccessibilityControlByLabelPrefix, tapHostedIosPoint, waitForHostedIosAccessibilityControl, - waitForHostedIosAccessibilityControlByLabelPrefix + waitForHostedIosAccessibilityControlByLabelPrefix, + waitForHostedIosAccessibilityLabel } from './hosted-ios-emulator-accessibility.mjs' import { assertHostedIosScreenshotParity } from './hosted-ios-screenshot-parity.mjs' import { @@ -47,7 +48,7 @@ export async function captureNativeFilesPreviewBaselines({ }) await tapHostedIosAccessibilityControl(emulator, FILES_STABLE_LABEL, timeoutMs) await tapHostedIosAccessibilityControl(emulator, PREVIEW_FILE_LABEL, timeoutMs) - await waitForHostedIosAccessibilityControl(emulator, PREVIEW_STABLE_LABEL, timeoutMs) + await waitForHostedIosAccessibilityLabel(emulator, PREVIEW_STABLE_LABEL, timeoutMs) const preview = await captureNativeRoute({ deviceUdid, emulator, @@ -78,7 +79,7 @@ export async function captureHostedFilesPreviewParity({ () => waitForVisibleHostedWebView({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: expectedWorkspace, timeoutMs }) ) @@ -253,7 +254,7 @@ async function returnHostedToWorkspaces(options) { resolve: (transitionTimeoutMs) => waitForVisibleHostedWebView({ discoveryUrl: options.discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: options.expectedWorkspace, timeoutMs: transitionTimeoutMs }) }) diff --git a/mobile/scripts/hosted-ios-host-origin-source-control-journey.mjs b/mobile/scripts/hosted-ios-host-origin-source-control-journey.mjs new file mode 100644 index 00000000000..c72bbc4ce43 --- /dev/null +++ b/mobile/scripts/hosted-ios-host-origin-source-control-journey.mjs @@ -0,0 +1,130 @@ +import { + activateHostedWebViewControl, + readHostedWebViewState, + waitForVisibleHostedWebView +} from './hosted-webview-cdp-session.mjs' +import { tapHostedIosAccessibilityControl } from './hosted-ios-emulator-accessibility.mjs' +import { longPressHostedIosAccessibilityControlByLabelPrefix } from './hosted-ios-emulator-long-press.mjs' + +const CHANGED_FILE_PREFIX = 'Open changed file ' +const REVIEW_CONTROLS = ['Back', 'Open review actions'] + +export async function verifyHostedHostOriginSourceControlJourney({ + discoveryUrl, + emulator, + nativeBaseline, + timeoutMs, + workspaceName, + operations = {} +}) { + const activate = operations.activate ?? activateHostedWebViewControl + const longPress = operations.longPress ?? longPressHostedIosAccessibilityControlByLabelPrefix + const tapNative = operations.tapNative ?? tapHostedIosAccessibilityControl + const waitForDocument = operations.waitForDocument ?? waitForVisibleHostedWebView + const readState = operations.readState ?? readHostedWebViewState + + await longPress(emulator, workspaceName, timeoutMs) + await tapNative(emulator, 'Source Control', timeoutMs) + const sourceControl = await waitForDocument({ + discoveryUrl, + expectedHrefIncludes: '/source-control/', + expectedText: 'Source Control', + timeoutMs + }) + const sourceState = await waitForChangedFileState(sourceControl, timeoutMs, readState) + const changedFileLabel = nativeBaseline.changedFileLabel + if (!sourceState.labels.includes(changedFileLabel)) { + throw new Error(`Host-origin Source Control is missing ${changedFileLabel}`) + } + + await activate(sourceControl, { kind: 'label', value: changedFileLabel }) + const review = await waitForDocument({ + discoveryUrl, + expectedHrefIncludes: '/review/', + expectedText: 'reviewed', + timeoutMs + }) + const reviewState = await waitForReviewControls(review, timeoutMs, readState) + + await activate(review, { kind: 'label', value: 'Back' }) + const returnedSourceControl = await waitForDocument({ + discoveryUrl, + expectedHrefIncludes: '/source-control/', + expectedText: 'Source Control', + timeoutMs + }) + await activate(returnedSourceControl, { kind: 'label', value: 'Back to session' }) + const returnedWorkspace = await waitForWorkspaceRoot({ + discoveryUrl, + expectedText: workspaceName, + timeoutMs, + waitForDocument + }) + + return { + changedFileLabel, + reviewRoute: reviewState.href, + sourceControlRoute: sourceState.href, + workspaceDocument: returnedWorkspace + } +} + +async function waitForWorkspaceRoot({ discoveryUrl, expectedText, timeoutMs, waitForDocument }) { + const deadline = Date.now() + timeoutMs + let document + while (Date.now() < deadline) { + document = await waitForDocument({ + discoveryUrl, + expectedText, + timeoutMs: Math.min(1_000, Math.max(1, deadline - Date.now())) + }) + if (isHostedRoot(document.href)) { + return document + } + await delay(250) + } + throw new Error( + `Host-origin Source Control did not return to the workspace list: ${document?.href}` + ) +} + +function isHostedRoot(href) { + try { + return new URL(href).pathname === '/' + } catch { + return false + } +} + +async function waitForReviewControls(document, timeoutMs, readState) { + const deadline = Date.now() + Math.min(timeoutMs, 10_000) + let state + while (Date.now() < deadline) { + state = await readState(document) + if (REVIEW_CONTROLS.every((label) => state.labels.includes(label))) { + return state + } + await delay(250) + } + const missing = REVIEW_CONTROLS.filter((label) => !state?.labels.includes(label)) + throw new Error( + `Host-origin Review is missing ${missing.join(', ')}. Labels: ${state?.labels.join(', ') ?? ''}` + ) +} + +async function waitForChangedFileState(document, timeoutMs, readState) { + const deadline = Date.now() + timeoutMs + let state + while (Date.now() < deadline) { + state = await readState(document) + if (state.labels.some((label) => label.startsWith(CHANGED_FILE_PREFIX))) { + return state + } + await delay(250) + } + throw new Error('Host-origin Source Control has no changed file') +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/mobile/scripts/hosted-ios-native-alert-journey.mjs b/mobile/scripts/hosted-ios-native-alert-journey.mjs new file mode 100644 index 00000000000..bf4c476b934 --- /dev/null +++ b/mobile/scripts/hosted-ios-native-alert-journey.mjs @@ -0,0 +1,157 @@ +import { randomBytes } from 'node:crypto' +import { + tapHostedIosAccessibilityControl, + waitForHostedIosAccessibilityLabel, + waitForHostedIosAccessibilityLabelToDisappear +} from './hosted-ios-emulator-accessibility.mjs' +import { + activateHostedWebViewControl, + evaluateHostedDocumentWithRetry, + waitForVisibleHostedWebView +} from './hosted-webview-cdp-session.mjs' +import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs' + +const ALERT_PROBE_PROPERTY = '__orcaE2eNativeAlertProbe' +const ALERT_TITLE = 'Hosted native Alert probe' + +export async function verifyHostedIosNativeAlertJourney( + { discoveryUrl, emulator, expectedWorkspace, timeoutMs, workspaceDocument }, + operations = {} +) { + const evaluate = operations.evaluate ?? evaluateHostedDocumentWithRetry + const waitForDocument = operations.waitForDocument ?? waitForVisibleHostedWebView + const activateWorkspace = operations.activateWorkspace ?? activateHostedWorkspaceRow + const activateControl = operations.activateControl ?? activateHostedWebViewControl + const waitForLabel = operations.waitForLabel ?? waitForHostedIosAccessibilityLabel + const tapControl = operations.tapControl ?? tapHostedIosAccessibilityControl + const waitForLabelToDisappear = + operations.waitForLabelToDisappear ?? waitForHostedIosAccessibilityLabelToDisappear + + await installNativeAlertProbe(workspaceDocument, evaluate) + await activateWorkspace(workspaceDocument, expectedWorkspace, activateControl, timeoutMs, () => + waitForWorkspaceDocument(discoveryUrl, expectedWorkspace, timeoutMs, waitForDocument) + ) + const sessionDocument = await waitForDocument({ + discoveryUrl, + expectedText: 'Mobile Emulator', + expectedHrefIncludes: '/session/', + timeoutMs + }) + const requestId = randomBytes(16).toString('base64url') + await postNativeAlertProbe(sessionDocument, requestId, evaluate) + const title = await waitForLabel(emulator, ALERT_TITLE, timeoutMs) + const button = await tapControl(emulator, 'Keep editing', timeoutMs) + await waitForLabelToDisappear(emulator, ALERT_TITLE, timeoutMs) + const response = await waitForNativeAlertResponse(sessionDocument, requestId, timeoutMs, evaluate) + if (response?.status !== 'success' || response.payload?.buttonIndex !== 0) { + throw new Error(`Native Alert returned an invalid response: ${JSON.stringify(response)}`) + } + await activateControl(sessionDocument, { kind: 'label', value: 'Back to worktrees' }) + const returnedWorkspaceDocument = await waitForWorkspaceDocument( + discoveryUrl, + expectedWorkspace, + timeoutMs, + waitForDocument + ) + return { + evidence: { + button, + buttonIndex: response.payload.buttonIndex, + presentation: 'native', + title: title.frame + }, + workspaceDocument: returnedWorkspaceDocument + } +} + +async function installNativeAlertProbe(document, evaluate) { + const expression = `(() => { + const key = ${JSON.stringify(ALERT_PROBE_PROPERTY)}; + if (globalThis[key]) return JSON.stringify({ started: true }); + const native = globalThis.OrcaNative; + if (!native || typeof native.postMessage !== 'function') { + return JSON.stringify({ started: false }); + } + const state = globalThis[key] = { context: null, responses: Object.create(null) }; + addEventListener('message', (event) => { + try { + const message = typeof event.data === 'string' ? JSON.parse(event.data) : null; + if (message?.type === 'response' && typeof message.requestId === 'string') { + state.responses[message.requestId] = message; + } + } catch {} + }); + globalThis.OrcaNative = Object.freeze({ + postMessage(value) { + try { + const message = JSON.parse(value); + if (message?.shellSessionId && message?.buildId && Number.isInteger(message.version)) { + state.context = { + version: message.version, + shellSessionId: message.shellSessionId, + buildId: message.buildId + }; + } + } catch {} + native.postMessage(value); + } + }); + return JSON.stringify({ started: true }); + })()` + const result = JSON.parse(await evaluate(document, expression)) + if (result?.started !== true) { + throw new Error('Native Alert probe could not observe the hosted bridge') + } +} + +async function postNativeAlertProbe(document, requestId, evaluate) { + const expression = `(() => { + const state = globalThis[${JSON.stringify(ALERT_PROBE_PROPERTY)}]; + if (!state?.context) return JSON.stringify({ posted: false }); + globalThis.OrcaNative.postMessage(JSON.stringify({ + ...state.context, + type: 'request', + mode: 'once', + requestId: ${JSON.stringify(requestId)}, + capability: 'native', + operation: 'alert', + payload: { + title: ${JSON.stringify(ALERT_TITLE)}, + message: 'The hosted page requested this operating-system dialog.', + buttons: [ + { text: 'Keep editing', style: 'cancel' }, + { text: 'Discard', style: 'destructive' } + ], + options: { cancelable: false } + } + })); + return JSON.stringify({ posted: true }); + })()` + const result = JSON.parse(await evaluate(document, expression)) + if (result?.posted !== true) { + throw new Error('Native Alert probe did not capture an active bridge context') + } +} + +async function waitForNativeAlertResponse(document, requestId, timeoutMs, evaluate) { + const deadline = Date.now() + timeoutMs + const expression = `JSON.stringify(globalThis[${JSON.stringify( + ALERT_PROBE_PROPERTY + )}]?.responses?.[${JSON.stringify(requestId)}] ?? null)` + while (Date.now() < deadline) { + const result = JSON.parse(await evaluate(document, expression)) + if (result) { + return result + } + await delay(100) + } + throw new Error('Native Alert response did not return to the hosted page') +} + +function waitForWorkspaceDocument(discoveryUrl, expectedWorkspace, timeoutMs, waitForDocument) { + return waitForDocument({ discoveryUrl, expectedText: expectedWorkspace, timeoutMs }) +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/mobile/scripts/hosted-ios-native-settings-journey.mjs b/mobile/scripts/hosted-ios-native-settings-journey.mjs index 93e5ff18384..3bcd6afd562 100644 --- a/mobile/scripts/hosted-ios-native-settings-journey.mjs +++ b/mobile/scripts/hosted-ios-native-settings-journey.mjs @@ -20,7 +20,7 @@ export async function verifyHostedIosNativeSettingsJourney({ () => waitForVisibleHostedWebView({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: expectedWorkspace, timeoutMs }) ) diff --git a/mobile/scripts/hosted-ios-photo-permission-revocation.mjs b/mobile/scripts/hosted-ios-photo-permission-revocation.mjs index 55312496c6d..309876aafe4 100644 --- a/mobile/scripts/hosted-ios-photo-permission-revocation.mjs +++ b/mobile/scripts/hosted-ios-photo-permission-revocation.mjs @@ -98,7 +98,7 @@ export async function verifyHostedIosPhotoPermissionRevocation( waitForDocument }) let activeSessionDocument = grantedSession.document - assertSameSessionRoute(sessionDocument, activeSessionDocument) + assertSessionRoute(activeSessionDocument) const grantPrivateOriginRotated = hostedPrivateOrigin(sessionDocument.href) !== hostedPrivateOrigin(activeSessionDocument.href) const grantWorkspaceAuthorityRotated = @@ -144,7 +144,7 @@ export async function verifyHostedIosPhotoPermissionRevocation( waitForDocument }) activeSessionDocument = interruptedSession.document - assertSameSessionRoute(sessionDocument, activeSessionDocument) + assertSessionRoute(activeSessionDocument) const interruptionPrivateOriginRetained = hostedPrivateOrigin(grantedSession.document.href) === hostedPrivateOrigin(activeSessionDocument.href) @@ -171,7 +171,7 @@ export async function verifyHostedIosPhotoPermissionRevocation( waitForDocument }) activeSessionDocument = revokedSession.document - assertSameSessionRoute(sessionDocument, activeSessionDocument) + assertSessionRoute(activeSessionDocument) const revocationPrivateOriginRotated = hostedPrivateOrigin(grantedSession.document.href) !== hostedPrivateOrigin(activeSessionDocument.href) @@ -241,10 +241,10 @@ async function changeHostedIosPhotosPermission(action, deviceUdid, runCommand) { ]) } -function waitForSessionDocument(discoveryUrl, timeoutMs, waitForDocument) { +function waitForSessionDocument(discoveryUrl, timeoutMs, waitForDocument, expectedWorkspace) { return waitForDocument({ discoveryUrl, - expectedText: 'Mobile Emulator', + expectedText: expectedWorkspace, expectedHrefIncludes: '/session/', requireInteractiveControls: false, timeoutMs @@ -267,7 +267,8 @@ async function restoreHostedIosSessionAfterLaunch({ document: await waitForSessionDocument( discoveryUrl, Math.min(timeoutMs, 5_000), - waitForDocument + waitForDocument, + expectedWorkspace ), recovery: 'session-retained' } @@ -277,7 +278,7 @@ async function restoreHostedIosSessionAfterLaunch({ await openHybridRoute(emulator, timeoutMs) const workspaceDocument = await waitForDocument({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: expectedWorkspace, timeoutMs }) await activateWorkspace( @@ -288,12 +289,17 @@ async function restoreHostedIosSessionAfterLaunch({ () => waitForDocument({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: expectedWorkspace, timeoutMs }) ) return { - document: await waitForSessionDocument(discoveryUrl, timeoutMs, waitForDocument), + document: await waitForSessionDocument( + discoveryUrl, + timeoutMs, + waitForDocument, + expectedWorkspace + ), recovery: 'hybrid-route-handoff' } } @@ -320,24 +326,9 @@ function assertNoPrivilegedPageMarkers(state) { } } -function assertSameSessionRoute(expectedDocument, actualDocument) { - const expectedRoute = hostedSessionRouteIdentity(expectedDocument.href) - const actualRoute = hostedSessionRouteIdentity(actualDocument.href) - if ( - actualRoute.routeKind !== expectedRoute.routeKind || - actualRoute.name !== expectedRoute.name - ) { - throw new Error( - `Photos permission change did not restore the exact session: ${actualDocument.href}` - ) - } -} - -function hostedSessionRouteIdentity(href) { - const url = new URL(href) - return { - name: url.searchParams.get('name'), - routeKind: url.pathname.split('/').at(-2) +function assertSessionRoute(document) { + if (new URL(document.href).pathname.split('/').at(-2) !== 'session') { + throw new Error(`Photos permission change did not restore the session route: ${document.href}`) } } diff --git a/mobile/scripts/hosted-ios-source-control-review-journey.mjs b/mobile/scripts/hosted-ios-source-control-review-journey.mjs index 996fecfe650..5474ccdc03b 100644 --- a/mobile/scripts/hosted-ios-source-control-review-journey.mjs +++ b/mobile/scripts/hosted-ios-source-control-review-journey.mjs @@ -12,6 +12,12 @@ import { sourceControlReviewParityEvidence } from './hosted-ios-source-control-review-parity.mjs' import { navigateHostedWebViewRoute } from './hosted-webview-route-navigation.mjs' +import { + readHostedWebViewBridgeErrors, + startHostedWebViewBridgeErrorObservation +} from './hosted-webview-bridge-error-observation.mjs' + +const SOURCE_CONTROL_SETTLE_TIMEOUT_MS = 15_000 export async function verifyHostedSourceControlReviewJourney({ deviceUdid, @@ -27,6 +33,7 @@ export async function verifyHostedSourceControlReviewJourney({ tapPoint = tapHostedJourneyPoint, transformPoint = retainHostedJourneyPoint }) { + await startHostedWebViewBridgeErrorObservation(sessionDocument) const sourceControl = await journeyStep('wait for Source Control route', () => openSourceControlRoute({ discoveryUrl, @@ -72,12 +79,6 @@ export async function verifyHostedSourceControlReviewJourney({ ) } - const changedFileLabel = sourceState.labels.find((label) => - label.startsWith('Open changed file ') - ) - if (!changedFileLabel) { - throw new Error('Source Control has no changed file available for Review.') - } if (inspectChangedContent) { await journeyStep('inspect Source Control content', () => inspectChangedContent({ phase: 'sourceControl', document: sourceControl }) @@ -85,9 +86,15 @@ export async function verifyHostedSourceControlReviewJourney({ } if (nativeBaselines) { sourceState = await journeyStep('wait for stable Source Control parity state', () => - waitForSourceControlParityState(sourceControl, timeoutMs, sourceState) + waitForSourceControlParityState( + sourceControl, + timeoutMs, + sourceState, + nativeBaselines.sourceControl.pullRequestState + ) ) } + const changedFileLabel = selectChangedFileLabel(sourceState, nativeBaselines) const hostedSourceControl = nativeBaselines ? await journeyStep('capture Source Control parity', () => captureHostedSourceControlReviewScreen({ @@ -278,20 +285,56 @@ async function waitForChangedFileState(document, timeoutMs) { return state ?? readHostedWebViewState(document) } -async function waitForSourceControlParityState(document, timeoutMs, initialState) { - const deadline = Date.now() + timeoutMs +async function waitForSourceControlParityState( + document, + timeoutMs, + initialState, + pullRequestState +) { + const deadline = Date.now() + Math.min(timeoutMs, SOURCE_CONTROL_SETTLE_TIMEOUT_MS) let state = initialState while (Date.now() < deadline) { - if ( - state.bodyText.includes('Create pull request') && - /\b\d+ on branch\b/.test(state.bodyText) - ) { + if (sourceControlMatchesPullRequestState(state, pullRequestState)) { return state } await delay(250) state = await readHostedWebViewState(document) } - throw new Error('Source Control did not settle its branch and pull-request state.') + const bridgeErrors = await readHostedWebViewBridgeErrors(document) + throw new Error( + `Source Control did not settle its branch and pull-request state. Bridge errors: ${JSON.stringify(bridgeErrors)}` + ) +} + +function sourceControlMatchesPullRequestState(state, pullRequestState) { + if (!/\b\d+ on branch\b/.test(state.bodyText) || !pullRequestState) { + return false + } + if (state.labels.includes(pullRequestState.label)) { + return true + } + if (pullRequestState.kind === 'create') { + return state.bodyText.includes('Create pull request') + } + if (pullRequestState.kind === 'ready') { + return state.bodyText.includes(`#${pullRequestState.number}`) + } + return state.bodyText.includes(pullRequestState.label.replace('Pull request unavailable: ', '')) +} + +function selectChangedFileLabel(sourceState, nativeBaselines) { + const nativeLabel = nativeBaselines?.sourceControl.changedFileLabel + if (nativeLabel && sourceState.labels.includes(nativeLabel)) { + return nativeLabel + } + if (nativeLabel) { + throw new Error(`Source Control is missing the native baseline file: ${nativeLabel}`) + } + const label = sourceState.labels.find((candidate) => candidate.startsWith('Open changed file ')) + if (!label) { + throw new Error('Source Control has no changed file available for Review.') + } + return label } function standaloneReviewRoute(sourceControlHref) { diff --git a/mobile/scripts/hosted-ios-source-control-review-parity.mjs b/mobile/scripts/hosted-ios-source-control-review-parity.mjs index c7889ed7e48..b8e7f0b9c12 100644 --- a/mobile/scripts/hosted-ios-source-control-review-parity.mjs +++ b/mobile/scripts/hosted-ios-source-control-review-parity.mjs @@ -4,11 +4,10 @@ import { promisify } from 'node:util' import { dismissEmulatorDeveloperMenuIfPresent } from './emulator-developer-menu-dismissal.mjs' import { tapHostedIosAccessibilityControl, - tapHostedIosAccessibilityControlStartingWith, waitForHostedIosAccessibilityControl, waitForHostedIosAccessibilityControlByLabelPrefix, waitForHostedIosAccessibilityControlEndingWith, - waitForHostedIosAccessibilityControlStartingWith + waitForHostedIosAccessibilityControlMatching } from './hosted-ios-emulator-accessibility.mjs' import { longPressHostedIosAccessibilityControlByLabelPrefix } from './hosted-ios-emulator-long-press.mjs' import { assertHostedIosScreenshotParity } from './hosted-ios-screenshot-parity.mjs' @@ -27,12 +26,19 @@ export async function captureNativeSourceControlReviewBaselines({ await dismissEmulatorDeveloperMenuIfPresent(emulator) await longPressHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs) await tapHostedIosAccessibilityControl(emulator, 'Source Control', timeoutMs) - await waitForHostedIosAccessibilityControlStartingWith( + const changedFileControl = await waitForHostedIosAccessibilityControlMatching( emulator, - CHANGED_FILE_LABEL_PREFIX, + (node) => + node.label?.startsWith(CHANGED_FILE_LABEL_PREFIX) || + node.value?.startsWith(CHANGED_FILE_LABEL_PREFIX), + timeoutMs + ) + const reviewControl = await waitForHostedIosAccessibilityControlMatching( + emulator, + (node) => + nativePullRequestState(node.label) !== null || nativePullRequestState(node.value) !== null, timeoutMs ) - await waitForHostedIosAccessibilityControl(emulator, 'Create pull request', timeoutMs) await waitForHostedIosAccessibilityControlEndingWith(emulator, ' on branch', timeoutMs) const sourceControl = await captureNativeRoute({ deviceUdid, @@ -42,7 +48,12 @@ export async function captureNativeSourceControlReviewBaselines({ title: 'Source Control', timeoutMs }) - await tapHostedIosAccessibilityControlStartingWith(emulator, CHANGED_FILE_LABEL_PREFIX, timeoutMs) + sourceControl.changedFileLabel = accessibilityControlText(changedFileControl, (value) => + value.startsWith(CHANGED_FILE_LABEL_PREFIX) + ) + sourceControl.pullRequestState = + nativePullRequestState(reviewControl.label) ?? nativePullRequestState(reviewControl.value) + await tapHostedIosAccessibilityControl(emulator, sourceControl.changedFileLabel, timeoutMs) await waitForHostedIosAccessibilityControl(emulator, 'Open review actions', timeoutMs) await waitForHostedIosAccessibilityControlEndingWith(emulator, ' reviewed', timeoutMs) const review = await captureNativeRoute({ @@ -60,6 +71,29 @@ export async function captureNativeSourceControlReviewBaselines({ return { review, sourceControl } } +function nativePullRequestState(label) { + if (label === 'Create pull request') { + return { kind: 'create', label } + } + const number = label?.match(/^Pull request #(\d+),/)?.[1] + if (number) { + return { kind: 'ready', label, number } + } + if (label?.startsWith('Pull request unavailable:')) { + return { kind: 'unavailable', label } + } + return null +} + +function accessibilityControlText(control, matches) { + for (const value of [control.label, control.value]) { + if (typeof value === 'string' && matches(value)) { + return value + } + } + throw new Error('Accessibility control lost its matched text') +} + export async function captureHostedSourceControlReviewScreen({ deviceUdid, document, diff --git a/mobile/scripts/hosted-ios-terminal-clipboard-paste.mjs b/mobile/scripts/hosted-ios-terminal-clipboard-paste.mjs index 1a72623aa61..a4fb974beaa 100644 --- a/mobile/scripts/hosted-ios-terminal-clipboard-paste.mjs +++ b/mobile/scripts/hosted-ios-terminal-clipboard-paste.mjs @@ -39,7 +39,7 @@ export async function verifyHostedIosTerminalClipboardPaste( () => waitForDocument({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: expectedWorkspace, timeoutMs }) ) diff --git a/mobile/scripts/hosted-webview-bridge-error-observation.mjs b/mobile/scripts/hosted-webview-bridge-error-observation.mjs new file mode 100644 index 00000000000..cfedf67f2e2 --- /dev/null +++ b/mobile/scripts/hosted-webview-bridge-error-observation.mjs @@ -0,0 +1,64 @@ +import { evaluateHostedDocumentWithRetry } from './hosted-webview-cdp-session.mjs' + +const OBSERVATION_PROPERTY = '__orcaE2eBridgeErrorObservation' + +export async function startHostedWebViewBridgeErrorObservation(document) { + const expression = `(() => { + const key = ${JSON.stringify(OBSERVATION_PROPERTY)}; + const previous = globalThis[key]; + if (previous) { + previous.errors.length = 0; + previous.requests = Object.create(null); + return JSON.stringify({ started: true }); + } + const native = globalThis.OrcaNative; + if (!native || typeof native.postMessage !== 'function') { + return JSON.stringify({ started: false }); + } + const state = globalThis[key] = { + errors: [], + requests: Object.create(null) + }; + addEventListener('message', (event) => { + try { + const message = typeof event.data === 'string' ? JSON.parse(event.data) : null; + if (message?.type !== 'response' || message?.status !== 'error') return; + const request = state.requests[message.requestId]; + state.errors.push({ + capability: request?.capability ?? 'unknown', + operation: request?.operation ?? 'unknown', + code: message.error?.code ?? 'unknown', + retryable: message.error?.retryable === true + }); + if (state.errors.length > 64) state.errors.shift(); + } catch {} + }); + globalThis.OrcaNative = Object.freeze({ + postMessage(value) { + try { + const message = JSON.parse(value); + if (message?.type === 'request' && typeof message.requestId === 'string') { + state.requests[message.requestId] = { + capability: String(message.capability ?? '').slice(0, 64), + operation: String(message.operation ?? '').slice(0, 64) + }; + } + } catch {} + native.postMessage(value); + } + }); + return JSON.stringify({ started: true }); + })()` + const result = JSON.parse(await evaluateHostedDocumentWithRetry(document, expression)) + if (result?.started !== true) { + throw new Error('Hosted WebView bridge error observation could not start') + } +} + +export async function readHostedWebViewBridgeErrors(document) { + const expression = `JSON.stringify(globalThis[${JSON.stringify( + OBSERVATION_PROPERTY + )}]?.errors ?? [])` + const errors = JSON.parse(await evaluateHostedDocumentWithRetry(document, expression)) + return Array.isArray(errors) ? errors : [] +} diff --git a/mobile/scripts/hosted-webview-cdp-session.mjs b/mobile/scripts/hosted-webview-cdp-session.mjs index 9391c3098b3..3e82220810a 100644 --- a/mobile/scripts/hosted-webview-cdp-session.mjs +++ b/mobile/scripts/hosted-webview-cdp-session.mjs @@ -183,7 +183,18 @@ export async function readHostedWebViewState(document, WebSocketCtor = WebSocket const expression = `JSON.stringify({ href: String(location.href).slice(0, 2048), bodyText: String(document.body?.innerText ?? '').slice(0, ${HOSTED_DOCUMENT_TEXT_LIMIT}), - labels: Array.from(document.querySelectorAll('[aria-label]')).slice(0, 128) + labels: Array.from(document.querySelectorAll('[aria-label]')).filter((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== 'none' && + style.visibility !== 'hidden' && + rect.width > 0 && + rect.height > 0 && + rect.bottom > 0 && + rect.right > 0 && + rect.top < innerHeight && + rect.left < innerWidth; + }).slice(0, 128) .map((element) => String(element.getAttribute('aria-label') ?? '').slice(0, 240)), placeholders: Array.from(document.querySelectorAll('input[placeholder],textarea[placeholder]')) .slice(0, 32) @@ -381,16 +392,36 @@ export async function waitForHostedWebViewConnectionSequence( document, expectedStates, timeoutMs, - WebSocketCtor = WebSocket + transport = WebSocket ) { + const WebSocketCtor = + typeof transport === 'function' ? transport : (transport.WebSocketCtor ?? WebSocket) + const reacquireDocument = + typeof transport === 'function' ? undefined : transport.reacquireDocument const deadline = Date.now() + timeoutMs let entries = [] + let activeDocument = document + const observedEntryCounts = new Map() const expression = `JSON.stringify(globalThis[${JSON.stringify( HOSTED_CONNECTION_OBSERVATION_PROPERTY )}]?.entries ?? [])` while (Date.now() < deadline) { - const value = await evaluateHostedDocumentWithRetry(document, expression, WebSocketCtor) - entries = JSON.parse(value) + let currentEntries + try { + const value = await evaluateHostedDocumentWithRetry(activeDocument, expression, WebSocketCtor) + currentEntries = JSON.parse(value) + } catch (error) { + if (!reacquireDocument) { + throw error + } + activeDocument = await reacquireDocument(Math.max(1, deadline - Date.now())) + continue + } + const documentKey = activeDocument.webSocketDebuggerUrl + const previousCount = observedEntryCounts.get(documentKey) ?? 0 + const nextOffset = currentEntries.length < previousCount ? 0 : previousCount + entries.push(...currentEntries.slice(nextOffset)) + observedEntryCounts.set(documentKey, currentEntries.length) if (hasOrderedConnectionStates(entries, expectedStates)) { return entries } @@ -413,7 +444,11 @@ function hasOrderedConnectionStates(entries, expectedStates) { return expectedIndex === expectedStates.length } -export async function evaluateHostedDocumentWithRetry(document, expression, WebSocketCtor) { +export async function evaluateHostedDocumentWithRetry( + document, + expression, + WebSocketCtor = WebSocket +) { let lastError = null for (let attempt = 0; attempt < 3; attempt += 1) { try { diff --git a/mobile/scripts/hosted-webview-e2e-report.mjs b/mobile/scripts/hosted-webview-e2e-report.mjs new file mode 100644 index 00000000000..51a94c1baf1 --- /dev/null +++ b/mobile/scripts/hosted-webview-e2e-report.mjs @@ -0,0 +1,51 @@ +export async function evidenceStep(label, run) { + try { + return await run() + } catch (error) { + throw new Error(`${label} failed: ${error instanceof Error ? error.message : String(error)}`, { + cause: error + }) + } +} + +export function printHostedWebViewE2eReport(evidence) { + console.log( + JSON.stringify( + { + ok: true, + device: evidence.deviceUdid, + targetId: evidence.workspaceDocument.targetId, + route: evidence.workspaceDocument.href, + nativeAppPath: evidence.nativeAppPath, + visibleText: evidence.expectedWorkspace, + interactiveControls: evidence.workspaceDocument.buttonCount, + networkIsolation: evidence.networkIsolation, + navigationIsolation: evidence.navigationIsolation, + executableIsolation: evidence.executableIsolation, + privacyIsolation: evidence.privacyIsolation, + nativeOnboarding: evidence.nativeOnboarding, + nativeAlert: evidence.nativeAlert.evidence, + documentUpload: evidence.terminalDeviceInput?.documentUpload?.evidence ?? null, + photoPermissionDenial: + evidence.terminalDeviceInput?.photoPermissionDenial?.evidence ?? null, + photoPermissionRevocation: + evidence.terminalDeviceInput?.photoPermissionRevocation?.evidence ?? null, + terminalClipboardImagePaste: + evidence.terminalDeviceInput?.terminalClipboardImagePaste?.evidence ?? null, + terminalClipboardPaste: + evidence.terminalDeviceInput?.terminalClipboardPaste.evidence ?? null, + workspaceParity: evidence.hostedWorkspace, + accountsParity: evidence.hostedAccounts?.evidence ?? null, + agentHistory: evidence.historyEvidence, + coreRouteParity: evidence.hostedCoreRoutes?.evidence ?? null, + filesPreviewParity: evidence.hostedFilesPreview?.evidence ?? null, + sourceControlReview: evidence.sourceControlReview, + adversarialContent: evidence.adversarialContent, + adversarialProviderContent: evidence.adversarialProviderContent, + adversarialTerminalLinks: evidence.adversarialTerminalLinks + }, + null, + 2 + ) + ) +} diff --git a/mobile/scripts/hosted-webview-route-exception-evidence.mjs b/mobile/scripts/hosted-webview-route-exception-evidence.mjs new file mode 100644 index 00000000000..ba34090fac0 --- /dev/null +++ b/mobile/scripts/hosted-webview-route-exception-evidence.mjs @@ -0,0 +1,62 @@ +import { evaluateHostedDocumentWithRetry } from './hosted-webview-cdp-session.mjs' +import { WebSocket } from 'ws' + +const CAPTURE_KEY = '__orcaE2eHostedRouteExceptions' +const MAX_ENTRIES = 24 +const MAX_TEXT_LENGTH = 4_096 + +export async function installHostedWebViewRouteExceptionCapture( + document, + WebSocketCtor = WebSocket +) { + await evaluateHostedDocumentWithRetry( + document, + `(() => { + const key = ${JSON.stringify(CAPTURE_KEY)}; + if (globalThis[key]?.installed === true) return 'installed'; + const evidence = { entries: [], installed: true }; + const append = (kind, values) => { + const text = values.map((value) => { + if (value instanceof Error) return value.stack || value.message || value.name; + if (typeof value === 'string') return value; + try { return JSON.stringify(value); } catch { return String(value); } + }).join(' ').slice(0, ${MAX_TEXT_LENGTH}); + evidence.entries.push({ kind, text }); + if (evidence.entries.length > ${MAX_ENTRIES}) evidence.entries.shift(); + }; + const originalConsoleError = console.error.bind(console); + console.error = (...values) => { + append('console-error', values); + originalConsoleError(...values); + }; + addEventListener('error', (event) => append('window-error', [event.error ?? event.message])); + addEventListener('unhandledrejection', (event) => append('unhandled-rejection', [event.reason])); + globalThis[key] = evidence; + return 'installed'; + })()`, + WebSocketCtor + ) +} + +export async function readHostedWebViewRouteExceptionEvidence(document, WebSocketCtor = WebSocket) { + const value = await evaluateHostedDocumentWithRetry( + document, + `JSON.stringify((globalThis[${JSON.stringify(CAPTURE_KEY)}]?.entries ?? []).slice(-${MAX_ENTRIES}))`, + WebSocketCtor + ) + const entries = JSON.parse(value) + if (!Array.isArray(entries)) { + return [] + } + return entries.filter(isExceptionEvidenceEntry) +} + +function isExceptionEvidenceEntry(value) { + return ( + value !== null && + typeof value === 'object' && + ['console-error', 'window-error', 'unhandled-rejection'].includes(value.kind) && + typeof value.text === 'string' && + value.text.length <= MAX_TEXT_LENGTH + ) +} diff --git a/mobile/scripts/hosted-webview-route-navigation.mjs b/mobile/scripts/hosted-webview-route-navigation.mjs index 2365dd1f828..8f3623e21f4 100644 --- a/mobile/scripts/hosted-webview-route-navigation.mjs +++ b/mobile/scripts/hosted-webview-route-navigation.mjs @@ -15,7 +15,8 @@ export async function navigateHostedWebViewRoute(document, route, WebSocketCtor })()`, WebSocketCtor ) - if (value !== route) { + const expectedPath = new URL(route, 'https://orca-mobile-web.invalid').pathname + if (value !== expectedPath) { throw new Error('Hosted WebView route navigation was not retained') } } diff --git a/mobile/scripts/hosted-webview-security-evidence.mjs b/mobile/scripts/hosted-webview-security-evidence.mjs new file mode 100644 index 00000000000..3338ef64f30 --- /dev/null +++ b/mobile/scripts/hosted-webview-security-evidence.mjs @@ -0,0 +1,29 @@ +import { + verifyHostedWebViewNavigationIsolation, + verifyHostedWebViewNetworkIsolation +} from './hosted-webview-cdp-session.mjs' +import { verifyHostedWebViewExecutableIsolation } from './hosted-webview-executable-isolation.mjs' +import { verifyHostedWebViewPrivacyIsolation } from './hosted-webview-privacy-isolation.mjs' + +export async function captureHostedWebViewSecurityEvidence({ + document, + evidenceStep, + probeId, + workspacePrivacyIsolation +}) { + const networkIsolation = await evidenceStep('network isolation probe', () => + verifyHostedWebViewNetworkIsolation({ document, probeId }) + ) + const navigationIsolation = await evidenceStep('navigation isolation probe', () => + verifyHostedWebViewNavigationIsolation({ document, probeId }) + ) + const executableIsolation = await evidenceStep('executable isolation probe', () => + verifyHostedWebViewExecutableIsolation({ document, probeId }) + ) + const privacyIsolation = + workspacePrivacyIsolation ?? + (await evidenceStep('privacy isolation probe', () => + verifyHostedWebViewPrivacyIsolation({ document }) + )) + return { executableIsolation, navigationIsolation, networkIsolation, privacyIsolation } +} diff --git a/mobile/scripts/run-hosted-android-corrupt-cache.mjs b/mobile/scripts/run-hosted-android-corrupt-cache.mjs index 843de25b03d..e0693c8e74c 100644 --- a/mobile/scripts/run-hosted-android-corrupt-cache.mjs +++ b/mobile/scripts/run-hosted-android-corrupt-cache.mjs @@ -229,7 +229,7 @@ function parseOptions(args) { adb: 'adb', desktopPid: 0, devClientUrl: 'exp+orca-mobile://expo-development-client/?url=http%3A%2F%2F127.0.0.1%3A8081', - expectedText: 'Orca Desktop', + expectedText: 'Host 1', timeoutMs: 30_000 } for (let index = 0; index < args.length; index += 1) { diff --git a/mobile/scripts/run-hosted-android-hardware-back-e2e.mjs b/mobile/scripts/run-hosted-android-hardware-back-e2e.mjs new file mode 100644 index 00000000000..a97aa1253b6 --- /dev/null +++ b/mobile/scripts/run-hosted-android-hardware-back-e2e.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node + +import { execFile } from 'node:child_process' +import path from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' +import { resolveEmulatorOrcaCli } from './emulator-orca-cli-selection.mjs' +import { + assertHostedAndroidBridgeLogClean, + buildHostedAndroidDebugApp, + forwardHostedAndroidInspector, + installAndResetHostedAndroidApp, + launchHostedAndroidDevClient, + openHostedAndroidUrl, + resolveHostedAndroidAdb, + startHostedAndroidMetro, + stopHostedAndroidApp, + waitForHostedAndroidReactReady +} from './hosted-android-emulator-session.mjs' +import { verifyHostedAndroidHardwareBackJourney } from './hosted-android-hardware-back-journey.mjs' +import { runAndroidAdb } from './hosted-android-mobile-web-cache.mjs' +import { pairHostedAndroidApp } from './hosted-android-pairing.mjs' +import { + activateHostedAndroidWorkspaceControl, + prepareHostedAndroidWorkspaceInput +} from './hosted-android-workspace-activation.mjs' +import { HOSTED_MOBILE_APP_ROUTE_URL } from './hosted-mobile-e2e-launch.mjs' +import { waitForVisibleHostedWebView } from './hosted-webview-cdp-session.mjs' +import { resolveHostedWebViewRuntimeDirectory } from './hosted-webview-runtime-directory.mjs' +import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs' +import { startHostedWebViewSecurityProbe } from './hosted-ios-webview-security-probe.mjs' +import { + registerWorktreeForPairingRuntime, + startHeadlessPairingRuntime +} from './start-emulator-pairing-runtime.mjs' + +const execFileAsync = promisify(execFile) +const worktree = path.resolve(import.meta.dirname, '../..') +const mobileDir = path.join(worktree, 'mobile') +const androidDir = path.join(mobileDir, 'android') +const defaultApk = path.join(androidDir, 'app/build/outputs/apk/debug/app-debug.apk') +const options = parseOptions(process.argv.slice(2)) +const adb = resolveHostedAndroidAdb(options.adb) +const runtimeDirectory = resolveHostedWebViewRuntimeDirectory({ + worktree, + override: process.env.ORCA_E2E_MOBILE_WEBVIEW_RUN_DIRECTORY +}) +const orcaCli = resolveEmulatorOrcaCli({ + explicitCommand: process.env.ORCA_CLI, + managedCommand: process.env.ORCA_CLI_COMMAND, + devRepoRoot: process.env.ORCA_DEV_REPO_ROOT, + worktree, + cwd: worktree +}).command + +async function main() { + let runtime + let metro + let probe + let inspector + const reversePorts = new Set() + try { + await stage('Android emulator', () => runAndroidAdb(adb, ['get-state'])) + await stage('Android log reset', () => runAndroidAdb(adb, ['logcat', '-c'])) + if (!options.skipNativeBuild) { + await stage('Android debug app build', () => buildHostedAndroidDebugApp({ adb, androidDir })) + } + probe = await stage('network isolation sentinel', startHostedWebViewSecurityProbe) + runtime = await stage('temporary paired desktop runtime', () => + startHeadlessPairingRuntime({ + enabled: true, + orcaCli, + cwd: worktree, + runDirectory: path.join(runtimeDirectory, 'paired-host'), + lanIpCandidates: () => ['127.0.0.1'], + logStep: () => {}, + logSuccess: () => {} + }) + ) + await stage('test workspace registration', () => + registerWorktreeForPairingRuntime(runtime, worktree, { + orca: runOrca, + logStep: () => {}, + logSuccess: () => {} + }) + ) + metro = await stage('Metro', () => + startHostedAndroidMetro({ mobileDir, pairingUrl: runtime.pairingUrl }) + ) + for (const port of [runtime.port, metro.port, probe.port]) { + await runAndroidAdb(adb, ['reverse', `tcp:${port}`, `tcp:${port}`]) + reversePorts.add(port) + } + await stage('exact Android app install', () => + installAndResetHostedAndroidApp(adb, options.apk) + ) + await stage('development client launch', () => + launchHostedAndroidDevClient(adb, metro.port, probe) + ) + await stage('React runtime', () => waitForHostedAndroidReactReady(adb, options.timeoutMs)) + await stage('native pairing', () => + pairHostedAndroidApp({ adb, pairingUrl: runtime.pairingUrl, timeoutMs: options.timeoutMs }) + ) + await stage('native hybrid route handoff', () => + openHostedAndroidUrl(adb, HOSTED_MOBILE_APP_ROUTE_URL) + ) + inspector = await stage('Android WebView inspector', () => + forwardHostedAndroidInspector(adb, options.timeoutMs) + ) + const discoveryUrl = `http://127.0.0.1:${inspector.port}` + const workspaceName = path.basename(worktree) + let workspaceDocument = await stage('hosted workspace data', () => + waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: workspaceName.toLocaleUpperCase(), + timeoutMs: options.timeoutMs + }) + ) + const sessionDocument = await stage('hosted Session route', async () => { + await prepareHostedAndroidWorkspaceInput({ adb }) + await activateHostedWorkspaceRow( + workspaceDocument, + workspaceName, + (document, target) => activateHostedAndroidWorkspaceControl({ adb }, document, target), + options.timeoutMs, + async () => { + workspaceDocument = await waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: workspaceName.toLocaleUpperCase(), + timeoutMs: options.timeoutMs + }) + return workspaceDocument + } + ) + return waitForVisibleHostedWebView({ + discoveryUrl, + expectedText: '1 tab', + expectedHrefIncludes: '/session/', + requireInteractiveControls: false, + timeoutMs: options.timeoutMs + }) + }) + const evidence = await stage('real Android hardware Back journey', () => + verifyHostedAndroidHardwareBackJourney({ + adb, + discoveryUrl, + emulator: { adb }, + sessionDocument, + timeoutMs: options.timeoutMs, + workspaceMarker: workspaceName.toLocaleUpperCase() + }) + ) + await stage('Android bridge log audit', () => assertHostedAndroidBridgeLogClean(adb)) + console.log( + JSON.stringify( + { + ok: true, + device: await runAndroidAdb(adb, ['shell', 'getprop', 'ro.product.model']), + workspace: workspaceName, + ...evidence + }, + null, + 2 + ) + ) + } finally { + await stopHostedAndroidApp(adb) + if (inspector) { + await runAndroidAdb(adb, ['forward', '--remove', `tcp:${inspector.port}`]).catch(() => {}) + } + for (const port of reversePorts) { + await runAndroidAdb(adb, ['reverse', '--remove', `tcp:${port}`]).catch(() => {}) + } + await metro?.stop() + await runtime?.stop({ shutdownDaemon: true }) + await probe?.stop() + } +} + +async function runOrca(args, runOptions) { + const result = await execFileAsync(orcaCli, args, { + cwd: runOptions.cwd, + env: runOptions.env, + encoding: 'utf8', + timeout: runOptions.timeout + }) + return { stdout: result.stdout.trim(), stderr: result.stderr.trim() } +} + +async function stage(label, run) { + process.stderr.write(`[android-back-e2e] ${label}...\n`) + try { + const result = await run() + process.stderr.write(`[android-back-e2e] ${label}: ok\n`) + return result + } catch (error) { + throw new Error(`${label} failed: ${error instanceof Error ? error.message : String(error)}`, { + cause: error + }) + } +} + +function parseOptions(args) { + const result = { adb: null, apk: defaultApk, skipNativeBuild: false, timeoutMs: 90_000 } + for (let index = 0; index < args.length; index += 1) { + const option = args[index] + if (option === '--') { + continue + } + if (option === '--adb') { + result.adb = requireValue(args, ++index, option) + } else if (option === '--apk') { + result.apk = path.resolve(requireValue(args, ++index, option)) + } else if (option === '--skip-native-build') { + result.skipNativeBuild = true + } else if (option === '--timeout-ms') { + result.timeoutMs = Number.parseInt(requireValue(args, ++index, option), 10) + } else { + throw new Error(`Unknown option: ${option}`) + } + } + if (!Number.isInteger(result.timeoutMs) || result.timeoutMs < 1_000) { + throw new Error('--timeout-ms must be an integer of at least 1000') + } + return result +} + +function requireValue(args, index, option) { + if (!args[index]) { + throw new Error(`${option} requires a value`) + } + return args[index] +} + +main().catch((error) => { + console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)) + process.exitCode = 1 +}) diff --git a/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs b/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs index df11a38d19a..55c3c6c030f 100644 --- a/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs +++ b/mobile/scripts/run-hosted-android-source-control-review-e2e.mjs @@ -23,8 +23,7 @@ import { } from './hosted-android-privacy-audit.mjs' import { tapHostedAndroidAccessibilityControl, - tapHostedAndroidPoint, - waitForHostedAndroidAccessibilityControlMatch + tapHostedAndroidPoint } from './hosted-android-emulator-accessibility.mjs' import { activateHostedAndroidWorkspaceControl, @@ -43,6 +42,7 @@ import { waitForHostedAndroidReactReady } from './hosted-android-emulator-session.mjs' import { runAndroidAdb } from './hosted-android-mobile-web-cache.mjs' +import { pairHostedAndroidApp } from './hosted-android-pairing.mjs' import { HOSTED_MOBILE_APP_ROUTE_URL } from './hosted-mobile-e2e-launch.mjs' import { activateHostedWebViewControl, @@ -172,7 +172,7 @@ async function main() { await stage('React runtime', () => waitForHostedAndroidReactReady(adb, options.timeoutMs)) const emulator = { adb } await stage('native pairing', () => - pairAndroidApp(emulator, runtime.pairingUrl, options.timeoutMs) + pairHostedAndroidApp({ adb, pairingUrl: runtime.pairingUrl, timeoutMs: options.timeoutMs }) ) await stage('native hybrid route handoff', async () => { await openHostedAndroidUrl(adb, HOSTED_MOBILE_APP_ROUTE_URL) @@ -184,7 +184,7 @@ async function main() { await stage('hosted workspace route', () => waitForVisibleHostedWebView({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: 'Host 1', timeoutMs: options.timeoutMs }) ) @@ -247,7 +247,7 @@ async function main() { lastError = error activeDocument = await waitForVisibleHostedWebView({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: 'Host 1', timeoutMs: options.timeoutMs }) } @@ -456,42 +456,6 @@ async function inspectAndroidAdversarialContent({ document, fixture, observation } } -async function pairAndroidApp(emulator, pairingUrl, timeoutMs) { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - await openHostedAndroidUrl(adb, pairingUrl) - try { - const control = await waitForHostedAndroidAccessibilityControlMatch( - emulator, - ['Close', 'Continue', 'Pair'], - Math.min(5_000, deadline - Date.now()) - ) - if (control.label === 'Pair') { - await tapHostedAndroidAccessibilityControl(emulator, control.label, 2_000) - break - } - await tapHostedAndroidAccessibilityControl(emulator, control.label, 2_000) - } catch { - // The first deep link can arrive before the development bundle mounts. - } - } - const destination = await waitForHostedAndroidAccessibilityControlMatch( - emulator, - [ - 'Open sessions in Chat UI', - 'Open sessions in the terminal', - 'Enable agent notifications', - 'Skip notifications for now', - 'Show paired hosts', - 'Back to home' - ], - Math.max(1_000, deadline - Date.now()) - ) - if (destination.label === 'Back to home') { - throw new Error('Android pairing failed before reaching the onboarding flow') - } -} - async function tapHostedAndroidJourneyControl(emulator, point, label, attempt = 0, document) { if (label && attempt === 0) { try { diff --git a/mobile/scripts/run-hosted-webview-simulator-e2e.mjs b/mobile/scripts/run-hosted-webview-simulator-e2e.mjs index fcf9e40d7c6..b57a9730d66 100644 --- a/mobile/scripts/run-hosted-webview-simulator-e2e.mjs +++ b/mobile/scripts/run-hosted-webview-simulator-e2e.mjs @@ -23,13 +23,9 @@ import { import { startHostedIosEmulatorController } from './hosted-ios-emulator-controller.mjs' import { createHostedIosNativeBaselineStep } from './hosted-ios-native-baseline-step.mjs' import { - activateHostedWebViewControl, assertNoHostedMobileWebCdpTarget, - verifyHostedWebViewNavigationIsolation, - verifyHostedWebViewNetworkIsolation, waitForVisibleHostedWebView } from './hosted-webview-cdp-session.mjs' -import { verifyHostedWebViewExecutableIsolation } from './hosted-webview-executable-isolation.mjs' import { verifyHostedWebViewPrivacyIsolation } from './hosted-webview-privacy-isolation.mjs' import { captureNativeAgentHistoryBaseline } from './hosted-ios-agent-history-parity.mjs' import { @@ -54,24 +50,28 @@ import { alignHostedIosSessionPoint, verifyHostedIosAdversarialTerminalLinks } from './hosted-ios-adversarial-terminal-links.mjs' -import { tapHostedIosPoint } from './hosted-ios-emulator-accessibility.mjs' +import { + tapHostedIosAccessibilityControlByLabelPrefix, + tapHostedIosPoint +} from './hosted-ios-emulator-accessibility.mjs' import { openHostedIosHybridRoute } from './hosted-ios-hybrid-route-handoff.mjs' import { startHostedIosMobileLauncher, waitForHostedIosMobileLauncher } from './hosted-ios-mobile-launcher.mjs' import { verifyHostedIosNativeSettingsJourney } from './hosted-ios-native-settings-journey.mjs' +import { verifyHostedIosNativeAlertJourney } from './hosted-ios-native-alert-journey.mjs' import { bootHostedIosSimulator, resolveHostedIosSimulatorUdid } from './hosted-ios-simulator-device.mjs' import { verifyHostedSourceControlReviewJourney } from './hosted-ios-source-control-review-journey.mjs' +import { verifyHostedHostOriginSourceControlJourney } from './hosted-ios-host-origin-source-control-journey.mjs' import { captureNativeSourceControlReviewBaselines } from './hosted-ios-source-control-review-parity.mjs' import { resetHostedIosPhotosPermission } from './hosted-ios-photo-permission-denial.mjs' import { verifyHostedIosTerminalInputJourney } from './hosted-ios-terminal-device-input-journey.mjs' import { waitForHostedIosBuildActivation } from './hosted-ios-build-activation.mjs' import { completeHostedIosNativeOnboarding } from './hosted-ios-native-onboarding.mjs' -import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs' import { resolveHostedWebViewRuntimeDirectory } from './hosted-webview-runtime-directory.mjs' import { clearHostedIosWebViewSecurityProbe, @@ -79,6 +79,12 @@ import { startHostedIosWebViewSecurityProbe } from './hosted-ios-webview-security-probe.mjs' import { hostedIosSimulatorAppPreparation } from './hosted-ios-simulator-app-preparation.mjs' +import { + installHostedWebViewRouteExceptionCapture, + readHostedWebViewRouteExceptionEvidence +} from './hosted-webview-route-exception-evidence.mjs' +import { captureHostedWebViewSecurityEvidence } from './hosted-webview-security-evidence.mjs' +import { evidenceStep, printHostedWebViewE2eReport } from './hosted-webview-e2e-report.mjs' const worktree = path.resolve(import.meta.dirname, '../..') const options = parseHostedWebViewSimulatorE2eOptions(process.argv.slice(2)) @@ -111,6 +117,7 @@ async function main() { let adversarialSessionYOffset = 0 let adversarialTerminalLinks = null let adversarialTerminalHandle = null + let hostedExceptionDocument = null try { networkProbe = await startHostedIosWebViewSecurityProbe() if (options.adversarialContent) { @@ -273,9 +280,21 @@ async function main() { ) let workspaceDocument = await waitForVisibleHostedWebView({ discoveryUrl, - expectedText: 'Orca Desktop', + expectedText: expectedWorkspace, timeoutMs: options.timeoutMs }) + hostedExceptionDocument = workspaceDocument + await installHostedWebViewRouteExceptionCapture(workspaceDocument) + const nativeAlert = await evidenceStep('native Alert bridge journey', () => + verifyHostedIosNativeAlertJourney({ + discoveryUrl, + emulator, + expectedWorkspace, + timeoutMs: options.timeoutMs, + workspaceDocument + }) + ) + workspaceDocument = nativeAlert.workspaceDocument if (adversarialFixture) { await enableHostedTerminalDiagnostics(workspaceDocument) } @@ -343,6 +362,7 @@ async function main() { nativeBaseline: nativeAccounts, runtimeDirectory, timeoutMs: options.timeoutMs, + expectedWorkspace, workspaceDocument }) ) @@ -421,18 +441,22 @@ async function main() { options.nativeSettingsOnly ? null : await evidenceStep('Source Control and Review journey', async () => { + let hostOrigin if (options.sourceControlOnly) { - await activateHostedWorkspaceRow( - workspaceDocument, + hostOrigin = await evidenceStep('host-origin Source Control journey', () => + verifyHostedHostOriginSourceControlJourney({ + discoveryUrl, + emulator, + nativeBaseline: nativeSourceControlReview.sourceControl, + timeoutMs: options.timeoutMs, + workspaceName: expectedWorkspace + }) + ) + workspaceDocument = hostOrigin.workspaceDocument + await tapHostedIosAccessibilityControlByLabelPrefix( + emulator, adversarialFixture?.workspaceRowName ?? expectedWorkspace, - activateHostedWebViewControl, - options.timeoutMs, - () => - waitForVisibleHostedWebView({ - discoveryUrl: `http://127.0.0.1:${inspectorPort}`, - expectedText: adversarialFixture?.workspaceRowName ?? 'Orca Desktop', - timeoutMs: options.timeoutMs - }) + options.timeoutMs ) } let sessionDocument = await waitForVisibleHostedWebView({ @@ -459,7 +483,7 @@ async function main() { adversarialSessionYOffset = result.yOffset sessionDocument = result.sessionDocument } - return verifyHostedSourceControlReviewJourney({ + const sessionOrigin = await verifyHostedSourceControlReviewJourney({ deviceUdid, discoveryUrl: `http://127.0.0.1:${inspectorPort}`, emulator, @@ -488,6 +512,7 @@ async function main() { : undefined, timeoutMs: options.timeoutMs }) + return { ...sessionOrigin, ...(hostOrigin ? { hostOrigin } : {}) } }) const adversarialContent = adversarialInspector ? await evidenceStep('adversarial filename and diff presentation', () => @@ -507,72 +532,53 @@ async function main() { expectedHrefIncludes: options.nativeSettingsOnly ? '/session/' : '/review/', timeoutMs: options.timeoutMs }) - const networkIsolation = await evidenceStep('network isolation probe', () => - verifyHostedWebViewNetworkIsolation({ + const { executableIsolation, navigationIsolation, networkIsolation, privacyIsolation } = + await captureHostedWebViewSecurityEvidence({ document: securityDocument, - probeId: networkProbe.token + evidenceStep, + probeId: networkProbe.token, + workspacePrivacyIsolation }) - ) - const navigationIsolation = await evidenceStep('navigation isolation probe', () => - verifyHostedWebViewNavigationIsolation({ - document: securityDocument, - probeId: networkProbe.token - }) - ) - const executableIsolation = await evidenceStep('executable isolation probe', () => - verifyHostedWebViewExecutableIsolation({ - document: securityDocument, - probeId: networkProbe.token - }) - ) - const privacyIsolation = - workspacePrivacyIsolation ?? - (await evidenceStep('privacy isolation probe', () => - verifyHostedWebViewPrivacyIsolation({ document: securityDocument }) - )) - await delay(500) + await new Promise((resolve) => setTimeout(resolve, 500)) if (networkProbe.observations.length > 0) { throw new Error( `Hosted WebView reached the network probe: ${networkProbe.observations.join(', ')}` ) } await waitForHostedIosBuildActivation(deviceUdid, options, runtimeDirectory) - console.log( - JSON.stringify( - { - ok: true, - device: deviceUdid, - targetId: workspaceDocument.targetId, - route: workspaceDocument.href, - nativeAppPath, - visibleText: expectedWorkspace, - interactiveControls: workspaceDocument.buttonCount, - networkIsolation, - navigationIsolation, - executableIsolation, - privacyIsolation, - nativeOnboarding, - documentUpload: terminalDeviceInput?.documentUpload?.evidence ?? null, - photoPermissionDenial: terminalDeviceInput?.photoPermissionDenial?.evidence ?? null, - photoPermissionRevocation: - terminalDeviceInput?.photoPermissionRevocation?.evidence ?? null, - terminalClipboardImagePaste: - terminalDeviceInput?.terminalClipboardImagePaste?.evidence ?? null, - terminalClipboardPaste: terminalDeviceInput?.terminalClipboardPaste.evidence ?? null, - workspaceParity: hostedWorkspace, - accountsParity: hostedAccounts?.evidence ?? null, - agentHistory: historyEvidence, - coreRouteParity: hostedCoreRoutes?.evidence ?? null, - filesPreviewParity: hostedFilesPreview?.evidence ?? null, - sourceControlReview, - adversarialContent, - adversarialProviderContent, - adversarialTerminalLinks - }, - null, - 2 + printHostedWebViewE2eReport({ + adversarialContent, + adversarialProviderContent, + adversarialTerminalLinks, + deviceUdid, + executableIsolation, + expectedWorkspace, + historyEvidence, + hostedAccounts, + hostedCoreRoutes, + hostedFilesPreview, + hostedWorkspace, + nativeAlert, + nativeAppPath, + nativeOnboarding, + navigationIsolation, + networkIsolation, + privacyIsolation, + sourceControlReview, + terminalDeviceInput, + workspaceDocument + }) + } catch (error) { + const evidence = hostedExceptionDocument + ? await readHostedWebViewRouteExceptionEvidence(hostedExceptionDocument).catch(() => []) + : [] + if (evidence.length > 0) { + throw new Error( + `${error instanceof Error ? error.message : String(error)} Hosted exception evidence: ${JSON.stringify(evidence)}`, + { cause: error } ) - ) + } + throw error } finally { inspector?.stop() await stopHostedChildProcess(launcher) @@ -585,20 +591,6 @@ async function main() { } } -async function evidenceStep(label, run) { - try { - return await run() - } catch (error) { - throw new Error(`${label} failed: ${error instanceof Error ? error.message : String(error)}`, { - cause: error - }) - } -} - -function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - main().catch((error) => { console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)) process.exitCode = 1 diff --git a/mobile/scripts/start-emulator-pairing-runtime.mjs b/mobile/scripts/start-emulator-pairing-runtime.mjs index d463e741a1e..cd7c390c997 100644 --- a/mobile/scripts/start-emulator-pairing-runtime.mjs +++ b/mobile/scripts/start-emulator-pairing-runtime.mjs @@ -7,9 +7,12 @@ import { appendProcessOutputTail, attachBoundedProcessLineReader } from './bounded-process-line-reader.mjs' +import { createEmulatorPairingChildEnvironment } from './emulator-pairing-child-environment.mjs' +import { linkEmulatorMacKeychain } from './emulator-macos-keychain-home.mjs' import { seedEmulatorAgentHistoryFixture } from './emulator-agent-history-fixture.mjs' import { pairingEndpointPortFromUrl } from './emulator-pairing-public-key.mjs' import { + currentPairingDaemonPids, pairingDaemonPidsFromUserData, pairingRuntimePidFromUserData, signalPairingDaemons, @@ -45,6 +48,8 @@ export async function startHeadlessPairingRuntime({ // home, so the pairing runtime must hand it a matching disposable HOME. const homeDir = path.join(runDir, 'home') mkdirSync(homeDir, { recursive: true, mode: 0o700 }) + // Keep the disposable home boundary while letting macOS reuse Orca Dev's approved safeStorage item. + linkEmulatorMacKeychain(homeDir) const pairingAddress = primaryLanIp(lanIpCandidates) const child = spawn( orcaCli, @@ -58,15 +63,12 @@ export async function startHeadlessPairingRuntime({ ], { cwd, - env: { - ...process.env, - ...environment, - ORCA_DEV_USER_DATA_PATH: userData, - ORCA_E2E_USER_DATA_DIR: userData, - ORCA_E2E_HOME_DIR: homeDir, - HOME: homeDir, - USERPROFILE: homeDir - }, + env: createEmulatorPairingChildEnvironment({ + inheritedEnvironment: process.env, + environment, + userData, + homeDir + }), stdio: ['ignore', 'pipe', 'pipe'] } ) @@ -151,6 +153,7 @@ async function waitForPairingRuntime({ child.stdout?.destroy() child.stderr?.destroy() if (shutdownDaemon) { + daemonPids = currentPairingDaemonPids(userData, daemonPids) signalPairingDaemons(daemonPids) const gracefulTimeouts = await waitForProcessExit(daemonPids, 2_500) signalPairingDaemons(gracefulTimeouts, process.kill, 'SIGKILL') diff --git a/mobile/src/accounts/mobile-web-accounts-screen-binding-source.test.ts b/mobile/src/accounts/mobile-web-accounts-screen-binding-source.test.ts index 0db2803c39d..7db15b450c8 100644 --- a/mobile/src/accounts/mobile-web-accounts-screen-binding-source.test.ts +++ b/mobile/src/accounts/mobile-web-accounts-screen-binding-source.test.ts @@ -19,7 +19,9 @@ describe('mobile web accounts screen binding', () => { expect(hostedRoute).toContain( "import { AccountsScreen } from '../../../app/h/[hostId]/accounts'" ) - expect(hostedRoute).toContain("webHostAccountsOperations(shell.client, 'Orca Desktop')") + expect(hostedRoute).toContain( + "webHostAccountsOperations(shell.client, shell.hostDisplayName ?? 'Orca Desktop')" + ) expect(hostedRoute).toContain('operations={operations}') expect(hostedRoute).toContain('nativeHostBinding={false}') expect(hostedRoute).not.toMatch(/StyleSheet|className| void { - const openHostStackRoute = useOpenHostStackRoute() - - return useCallback( - (hostId) => { - openHostStackRoute(hostId, mobileAccountsRouteTarget(hostId)) - }, - [openHostStackRoute] - ) -} diff --git a/mobile/src/components/ActionSheetModal.tsx b/mobile/src/components/ActionSheetModal.tsx index 44ec0ce03f7..17d43c31f74 100644 --- a/mobile/src/components/ActionSheetModal.tsx +++ b/mobile/src/components/ActionSheetModal.tsx @@ -22,6 +22,7 @@ type Props = { title?: string message?: string actions: ActionSheetAction[] + dismissible?: boolean onClose: () => void } @@ -107,7 +108,14 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content ) } -export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) { +export function ActionSheetModal({ + visible, + title, + message, + actions, + dismissible = true, + onClose +}: Props) { const pendingActionRef = useRef<(() => void) | null>(null) const sequencedActions = actions.map((action) => action.closeBeforePress @@ -123,6 +131,7 @@ export function ActionSheetModal({ visible, title, message, actions, onClose }: return ( { // Why: iOS cannot present a second native modal until the action diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 9d27c2c0c4e..89302aafc6a 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -7,6 +7,7 @@ type Props = { onClose: () => void onAfterClose?: () => void children: ReactNode + dismissible?: boolean dragContentToDismiss?: boolean contentScrollable?: boolean // Why: smart-source (and similar) need a stable outer frame so a docked @@ -24,6 +25,7 @@ export function BottomDrawer({ onClose, onAfterClose, children, + dismissible = true, dragContentToDismiss = true, contentScrollable = true, fillAvailable = false, @@ -81,6 +83,7 @@ export function BottomDrawer({ visible={visible} onClose={onClose} onHidden={handleHidden} + dismissible={dismissible} dragContentToDismiss={dragContentToDismiss} contentScrollable={contentScrollable} fillAvailable={fillAvailable} diff --git a/mobile/src/components/ConfirmModal.tsx b/mobile/src/components/ConfirmModal.tsx index a393593b5cf..6f94fc8d030 100644 --- a/mobile/src/components/ConfirmModal.tsx +++ b/mobile/src/components/ConfirmModal.tsx @@ -7,10 +7,12 @@ type Props = { title: string message?: string confirmLabel?: string - cancelLabel?: string + cancelLabel?: string | null destructive?: boolean + dismissible?: boolean onConfirm: () => void onCancel: () => void + onDismiss?: () => void } export function ConfirmModal({ @@ -20,22 +22,26 @@ export function ConfirmModal({ confirmLabel = 'Confirm', cancelLabel = 'Cancel', destructive = false, + dismissible = true, onConfirm, - onCancel + onCancel, + onDismiss = onCancel }: Props) { return ( - + {title} {message ? {message} : null} - [styles.button, styles.cancelButton, pressed && styles.pressed]} - onPress={onCancel} - > - {cancelLabel} - + {cancelLabel ? ( + [styles.button, styles.cancelButton, pressed && styles.pressed]} + onPress={onCancel} + > + {cancelLabel} + + ) : null} [ styles.button, diff --git a/mobile/src/components/mobile-pr-url.ts b/mobile/src/components/mobile-pr-url.ts deleted file mode 100644 index 3f642d5ccf6..00000000000 --- a/mobile/src/components/mobile-pr-url.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Linking } from 'react-native' - -export function openMobilePrUrl(url: string): void { - // Prevent unhandled rejections when no app accepts the URL. - void Linking.openURL(url).catch(() => {}) -} diff --git a/mobile/src/components/mounted-bottom-drawer.tsx b/mobile/src/components/mounted-bottom-drawer.tsx index 3331d7298d8..fcdf9827b5a 100644 --- a/mobile/src/components/mounted-bottom-drawer.tsx +++ b/mobile/src/components/mounted-bottom-drawer.tsx @@ -28,6 +28,7 @@ import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants' import { bottomDrawerStyles as styles } from './bottom-drawer-styles' import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host' import { useResponsiveLayout } from '../layout/responsive-layout' +import { useMobileKeyboardInset } from '../hooks/use-mobile-keyboard-inset' const DISMISS_THRESHOLD = 80 const SPRING_CONFIG = { damping: 28, stiffness: 400 } @@ -43,6 +44,7 @@ export type MountedBottomDrawerProps = { onClose: () => void onHidden: () => void children: ReactNode + dismissible?: boolean dragContentToDismiss?: boolean contentScrollable?: boolean fillAvailable?: boolean @@ -57,6 +59,7 @@ export function MountedBottomDrawer({ onClose, onHidden, children, + dismissible = true, dragContentToDismiss = true, contentScrollable = true, fillAvailable = false, @@ -80,6 +83,7 @@ export function MountedBottomDrawer({ // transforms below) is unchanged, so phone behavior stays identical. const { isWideLayout, modalMaxWidth } = useResponsiveLayout() const insideModalHost = useInsideBottomDrawerModalHost() + const mobileKeyboardInset = useMobileKeyboardInset() const fillHeight = fillAvailable ? resolveBottomDrawerFillHeight({ screenHeight, @@ -133,34 +137,19 @@ export function MountedBottomDrawer({ } } - // Why: fill sheets dock to the true keyboard top; autoFocus can raise the - // keyboard before listeners attach. Seed only in fill mode so content-sized - // outer sheets do not inherit a stale metrics height after an inner dismiss. - if (fillAvailable) { - const existing = Keyboard.metrics() - if (existing != null && existing.height > 0) { - applyKeyboardHeight(existing.height) - } - } - - const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' - const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' - - const onShow = Keyboard.addListener(showEvent, (e) => { - applyKeyboardHeight(e.endCoordinates.height, e.duration || 250) - }) - const onHide = Keyboard.addListener(hideEvent, (e) => { - setKeyboardInset(0) - keyboardOffset.value = withTiming(0, { duration: e.duration || 250 }) - }) - + applyKeyboardHeight(mobileKeyboardInset.height, mobileKeyboardInset.duration || 250) return () => { - onShow.remove() - onHide.remove() keyboardOffset.value = 0 setKeyboardInset(0) } - }, [visible, interactive, insets.bottom, fillAvailable]) + }, [ + visible, + interactive, + insets.bottom, + fillAvailable, + mobileKeyboardInset.duration, + mobileKeyboardInset.height + ]) const dismiss = useCallback(() => { Keyboard.dismiss() @@ -177,11 +166,13 @@ export function MountedBottomDrawer({ } const sub = BackHandler.addEventListener('hardwareBackPress', () => { - dismiss() + if (dismissible) { + dismiss() + } return true }) return () => sub.remove() - }, [visible, interactive, dismiss]) + }, [visible, interactive, dismissible, dismiss]) const scrollHandler = useAnimatedScrollHandler((event) => { scrollOffsetY.value = Math.max(event.contentOffset.y, 0) @@ -189,6 +180,7 @@ export function MountedBottomDrawer({ const scrollGesture = Gesture.Native() const handlePanGesture = Gesture.Pan() + .enabled(dismissible) .activeOffsetY([-8, 8]) .simultaneousWithExternalGesture(scrollGesture) .onUpdate((e) => { @@ -212,6 +204,7 @@ export function MountedBottomDrawer({ } }) const contentPanGesture = Gesture.Pan() + .enabled(dismissible) .activeOffsetY([-8, 8]) .simultaneousWithExternalGesture(scrollGesture) .onBegin(() => { @@ -293,8 +286,8 @@ export function MountedBottomDrawer({ @@ -353,7 +346,9 @@ export function MountedBottomDrawer({ // their backdrop so only the top interactive drawer dims the canvas. style={[styles.backdrop, interactive ? backdropStyle : { opacity: 0 }]} > - {interactive ? : null} + {interactive && dismissible ? ( + + ) : null} @@ -393,7 +388,13 @@ export function MountedBottomDrawer({ } return ( - + {}} + > {overlay} ) diff --git a/mobile/src/hooks/use-mobile-keyboard-inset.ts b/mobile/src/hooks/use-mobile-keyboard-inset.ts new file mode 100644 index 00000000000..5e4be5000ee --- /dev/null +++ b/mobile/src/hooks/use-mobile-keyboard-inset.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react' +import { Keyboard, Platform, type KeyboardEvent } from 'react-native' + +export type MobileKeyboardInset = { + height: number + duration: number +} + +export function useMobileKeyboardInset(): MobileKeyboardInset { + const [inset, setInset] = useState(() => ({ + height: Math.max(0, Keyboard.metrics?.()?.height ?? 0), + duration: 0 + })) + + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' + const show = Keyboard.addListener(showEvent, (event: KeyboardEvent) => { + setInset({ height: Math.max(0, event.endCoordinates?.height ?? 0), duration: event.duration }) + }) + const hide = Keyboard.addListener(hideEvent, (event: KeyboardEvent) => { + setInset({ height: 0, duration: event.duration }) + }) + return () => { + show.remove() + hide.remove() + } + }, []) + + return inset +} diff --git a/mobile/src/hooks/use-mobile-keyboard-inset.web.test.ts b/mobile/src/hooks/use-mobile-keyboard-inset.web.test.ts new file mode 100644 index 00000000000..cb32a3733ca --- /dev/null +++ b/mobile/src/hooks/use-mobile-keyboard-inset.web.test.ts @@ -0,0 +1,16 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { measureMobileWebKeyboardInset } from './use-mobile-keyboard-inset.web' + +describe('mobile web keyboard inset', () => { + it('measures the viewport region obscured by the software keyboard', () => { + expect(measureMobileWebKeyboardInset(844, { height: 510, offsetTop: 47 })).toBe(287) + expect(measureMobileWebKeyboardInset(844, { height: 844, offsetTop: 0 })).toBe(0) + }) + + it('clamps oversize and unavailable viewports', () => { + expect(measureMobileWebKeyboardInset(600, { height: 650, offsetTop: 0 })).toBe(0) + expect(measureMobileWebKeyboardInset(600, null)).toBe(0) + }) +}) diff --git a/mobile/src/hooks/use-mobile-keyboard-inset.web.ts b/mobile/src/hooks/use-mobile-keyboard-inset.web.ts new file mode 100644 index 00000000000..a2781c34590 --- /dev/null +++ b/mobile/src/hooks/use-mobile-keyboard-inset.web.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react' +import type { MobileKeyboardInset } from './use-mobile-keyboard-inset' + +export function measureMobileWebKeyboardInset( + innerHeight: number, + viewport: Pick | null +): number { + return viewport ? Math.max(0, Math.round(innerHeight - viewport.height - viewport.offsetTop)) : 0 +} + +export function useMobileKeyboardInset(): MobileKeyboardInset { + const [inset, setInset] = useState({ height: 0, duration: 0 }) + + useEffect(() => { + const viewport = window.visualViewport + const update = () => { + const height = measureMobileWebKeyboardInset(window.innerHeight, viewport) + setInset((current) => + current.height === height ? current : { height, duration: current.duration === 0 ? 0 : 250 } + ) + } + update() + if (!viewport) { + return + } + const animatedUpdate = () => { + const height = measureMobileWebKeyboardInset(window.innerHeight, viewport) + setInset((current) => (current.height === height ? current : { height, duration: 250 })) + } + viewport.addEventListener('resize', animatedUpdate) + viewport.addEventListener('scroll', animatedUpdate) + return () => { + viewport.removeEventListener('resize', animatedUpdate) + viewport.removeEventListener('scroll', animatedUpdate) + } + }, []) + + return inset +} diff --git a/mobile/src/mobile-web/disabled-page-client-context.tsx b/mobile/src/mobile-web/disabled-page-client-context.tsx index 199cf5d1077..1daeb9b5711 100644 --- a/mobile/src/mobile-web/disabled-page-client-context.tsx +++ b/mobile/src/mobile-web/disabled-page-client-context.tsx @@ -1,33 +1,28 @@ import { createContext, useContext, type ReactNode } from 'react' import type { RpcClient } from '../transport/rpc-client' +import type { RpcClientContextValue } from '../transport/rpc-client-context-contract' import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' import type { ConnectionState, HostProfile } from '../transport/types' -export type RpcClientContextValue = { - acquire: (hostId: string, host?: HostProfile) => RpcClient | null - release: (hostId: string) => void - forceReconnect: (hostId: string) => Promise - closeHost: (hostId: string) => void - getState: (hostId: string) => ConnectionState - getReconnectAttempt: (hostId: string) => number - getLastConnectedAt: (hostId: string) => number | null - getActivePath: (hostId: string) => MobileConnectionPath - subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void - getAllClients: () => { hostId: string; client: RpcClient }[] - subscribeAllHosts: (listener: () => void) => () => void - primeHosts: (hosts: HostProfile[]) => void -} +export type { RpcClientContextValue } from '../transport/rpc-client-context-contract' const noop = () => {} const value: RpcClientContextValue = { acquire: () => null, release: noop, + releaseAndCloseIfUnused: noop, + closeIfUnused: noop, async forceReconnect() {}, - closeHost: noop, + refreshHostClient: noop, + forgetHostClient: noop, + disconnectHostClient: noop, getState: () => 'disconnected', + getKnownState: () => null, getReconnectAttempt: () => 0, getLastConnectedAt: () => null, getActivePath: () => 'lan', + getPendingPath: () => null, + isPairingRejected: () => false, subscribeHostState: () => noop, getAllClients: () => [], subscribeAllHosts: () => noop, @@ -67,7 +62,7 @@ export function useAllHostClients(_hostIds: string[]): { } export function useCloseHost(): (hostId: string) => void { - return useRpcClientContext().closeHost + return useRpcClientContext().disconnectHostClient } export function useForceReconnect(): (hostId: string) => Promise { diff --git a/mobile/src/mobile-web/hosted-android-emulator-session.test.ts b/mobile/src/mobile-web/hosted-android-emulator-session.test.ts index 71b7b5a23fc..9c316c32d42 100644 --- a/mobile/src/mobile-web/hosted-android-emulator-session.test.ts +++ b/mobile/src/mobile-web/hosted-android-emulator-session.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { assertHostedAndroidBridgeLogClean, findHostedAndroidBridgeLogFailures, + installAndResetHostedAndroidApp, waitForHostedAndroidReactReady } from '../../scripts/hosted-android-emulator-session.mjs' @@ -50,6 +51,22 @@ describe('hosted Android emulator session', () => { await expect(waitForHostedAndroidReactReady('adb', 2_000, runAdb)).resolves.toBe('7301') }) + it('allows an exact debug APK to replace a higher-version beta install', async () => { + const calls: Array<{ args: string[]; timeout?: number }> = [] + const runAdb = async (_adb: string, args: string[], timeout?: number) => { + calls.push({ args, timeout }) + return '' + } + + await installAndResetHostedAndroidApp('adb', '/tmp/app-debug.apk', runAdb) + + expect(calls[0]).toEqual({ + args: ['install', '-r', '-d', '-t', '/tmp/app-debug.apk'], + timeout: 120_000 + }) + expect(calls[1]?.args).toEqual(['shell', 'pm', 'clear', 'com.stably.orca.mobile']) + }) + it('scopes the bridge audit to the current Android app process', async () => { const calls: string[][] = [] const runAdb = async (_adb: string, args: string[]) => { diff --git a/mobile/src/mobile-web/hosted-android-hardware-back-journey.test.ts b/mobile/src/mobile-web/hosted-android-hardware-back-journey.test.ts new file mode 100644 index 00000000000..b30acc39deb --- /dev/null +++ b/mobile/src/mobile-web/hosted-android-hardware-back-journey.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest' +import { + openHostedAndroidHardwareBackNestedRoute, + sendHostedAndroidHardwareBack, + waitForHostedAndroidWorkspaceRoot, + verifyHostedAndroidHardwareBackJourney +} from '../../scripts/hosted-android-hardware-back-journey.mjs' + +describe('hosted Android hardware Back journey', () => { + it('uses the real Android Back key event', async () => { + const runAdb = vi.fn(async () => '') + + await sendHostedAndroidHardwareBack('/sdk/adb', runAdb) + + expect(runAdb).toHaveBeenCalledWith('/sdk/adb', ['shell', 'input', 'keyevent', 'KEYCODE_BACK']) + }) + + it('opens the nested route through the hosted UI navigation stack', async () => { + const sessionDocument = { href: 'orca-mobile-web://build/h/host/session/workspace' } + const actionsDocument = { href: sessionDocument.href } + const historyDocument = { + href: 'orca-mobile-web://build/h/host/agent-history/workspace' + } + const activate = vi.fn().mockResolvedValue(undefined) + const settle = vi.fn().mockResolvedValue(undefined) + const waitForDocument = vi + .fn() + .mockResolvedValueOnce(actionsDocument) + .mockResolvedValueOnce(historyDocument) + + await expect( + openHostedAndroidHardwareBackNestedRoute( + { + discoveryUrl: 'http://127.0.0.1:9222', + sessionDocument, + timeoutMs: 30_000 + }, + { activate, settle, waitForDocument } + ) + ).resolves.toBe(historyDocument) + + expect(activate.mock.calls.map((call) => call[1])).toEqual([ + { kind: 'label', value: 'More session actions' }, + { kind: 'text', value: 'Agent History' } + ]) + expect(waitForDocument).toHaveBeenLastCalledWith( + expect.objectContaining({ expectedHrefIncludes: '/agent-history/' }) + ) + expect(settle).toHaveBeenCalledWith(500) + }) + + it('waits for the Session route to leave before accepting the workspace root', async () => { + const waitForDocument = vi + .fn() + .mockResolvedValueOnce({ href: '/session/workspace' }) + .mockResolvedValueOnce({ href: '/session/workspace' }) + .mockResolvedValueOnce({ href: '/' }) + const settle = vi.fn().mockResolvedValue(undefined) + + await expect( + waitForHostedAndroidWorkspaceRoot({ + discoveryUrl: 'http://127.0.0.1:9222', + settle, + timeoutMs: 30_000, + waitForDocument, + workspaceMarker: 'MOBILE-REARCH' + }) + ).resolves.toEqual({ href: '/' }) + + expect(settle).toHaveBeenCalledTimes(2) + }) + + it('returns through Session and workspace root before dismissing to native home', async () => { + const events: string[] = [] + const pressBack = vi.fn(async () => events.push('back')) + const openAgentHistory = vi.fn(async () => { + events.push('agent-history') + return { href: 'orca-mobile-web://build/h/host/agent-history/workspace' } + }) + const waitForDocument = vi.fn(async ({ expectedHrefIncludes }) => { + const route = expectedHrefIncludes ? 'session' : 'workspace' + events.push(route) + return { href: `orca-mobile-web://build/h/host/${route}` } + }) + const waitForNativeControl = vi.fn(async () => { + events.push('native-shell') + return { label: 'Open settings' } + }) + + const evidence = await verifyHostedAndroidHardwareBackJourney( + { + adb: '/sdk/adb', + discoveryUrl: 'http://127.0.0.1:9222', + emulator: { adb: '/sdk/adb' }, + sessionDocument: { href: 'orca-mobile-web://build/h/host/session/workspace' }, + timeoutMs: 30_000, + workspaceMarker: 'MOBILE-REARCH' + }, + { openAgentHistory, pressBack, waitForDocument, waitForNativeControl } + ) + + expect(events).toEqual([ + 'agent-history', + 'back', + 'session', + 'back', + 'workspace', + 'back', + 'native-shell' + ]) + expect(evidence).toEqual({ + nestedRoute: 'orca-mobile-web://build/h/host/agent-history/workspace', + returnedSessionRoute: 'orca-mobile-web://build/h/host/session', + workspaceRootRoute: 'orca-mobile-web://build/h/host/workspace', + nativeShellControl: 'Open settings', + hardwareBackPresses: 3 + }) + expect(waitForDocument).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ expectedHrefIncludes: '/session/', expectedText: '1 tab' }) + ) + expect(waitForNativeControl).toHaveBeenCalledWith( + { adb: '/sdk/adb' }, + [ + 'Open settings', + 'Open sessions in Chat UI', + 'Open sessions in the terminal', + 'Show paired hosts' + ], + 30_000 + ) + }) + + it('rejects a nested route that remains visible at the workspace step', async () => { + await expect( + verifyHostedAndroidHardwareBackJourney( + { + adb: '/sdk/adb', + discoveryUrl: 'http://127.0.0.1:9222', + emulator: { adb: '/sdk/adb' }, + sessionDocument: { href: 'orca-mobile-web://build/h/host/session/workspace' }, + timeoutMs: 30_000, + workspaceMarker: 'MOBILE-REARCH' + }, + { + openAgentHistory: async () => ({ href: '/agent-history/' }), + pressBack: async () => {}, + waitForWorkspaceRoot: async () => ({ href: '/agent-history/' }), + waitForDocument: async ({ expectedHrefIncludes }) => ({ + href: expectedHrefIncludes ? '/session/' : '/agent-history/' + }), + waitForNativeControl: vi.fn() + } + ) + ).rejects.toThrow('did not reach the workspace root') + }) +}) diff --git a/mobile/src/mobile-web/hosted-ios-accounts-parity.test.ts b/mobile/src/mobile-web/hosted-ios-accounts-parity.test.ts index af4654cc4a8..d21319794ff 100644 --- a/mobile/src/mobile-web/hosted-ios-accounts-parity.test.ts +++ b/mobile/src/mobile-web/hosted-ios-accounts-parity.test.ts @@ -82,6 +82,7 @@ describe('hosted iOS Accounts parity', () => { deviceUdid: 'simulator', discoveryUrl: 'http://127.0.0.1:9222', emulator: { deviceUdid: 'simulator' }, + expectedWorkspace: 'mobile-rearch', nativeBaseline: { screenTitlePoint: { x: 0.2, y: 0.1 }, screenshot: '/tmp/parity/native-accounts-portrait.png' diff --git a/mobile/src/mobile-web/hosted-ios-agent-history-parity.test.ts b/mobile/src/mobile-web/hosted-ios-agent-history-parity.test.ts index c22d6ba75b8..31fe69319d4 100644 --- a/mobile/src/mobile-web/hosted-ios-agent-history-parity.test.ts +++ b/mobile/src/mobile-web/hosted-ios-agent-history-parity.test.ts @@ -1,10 +1,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { dismissDeveloperMenu, rotateEmulator, tapControl, tapControlByPrefix } = vi.hoisted(() => ({ +const { + dismissDeveloperMenu, + rotateEmulator, + tapControl, + tapControlByPrefix, + typeText, + waitByPrefix +} = vi.hoisted(() => ({ dismissDeveloperMenu: vi.fn(), rotateEmulator: vi.fn(), tapControl: vi.fn(), - tapControlByPrefix: vi.fn() + tapControlByPrefix: vi.fn(), + typeText: vi.fn(), + waitByPrefix: vi.fn() })) vi.mock('../../scripts/emulator-developer-menu-dismissal.mjs', () => ({ @@ -14,8 +23,9 @@ vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({ rotateHostedIosEmulator: rotateEmulator, tapHostedIosAccessibilityControl: tapControl, tapHostedIosAccessibilityControlByLabelPrefix: tapControlByPrefix, + typeHostedIosText: typeText, waitForHostedIosAccessibilityControl: vi.fn(), - waitForHostedIosAccessibilityControlByLabelPrefix: vi.fn() + waitForHostedIosAccessibilityControlByLabelPrefix: waitByPrefix })) import { openNativeAgentHistoryBaseline } from '../../scripts/hosted-ios-agent-history-parity.mjs' @@ -34,7 +44,7 @@ describe('native Agent History baseline', () => { timeoutMs: 30_000 }) - await vi.advanceTimersByTimeAsync(500) + await vi.runAllTimersAsync() await opened expect(dismissDeveloperMenu).toHaveBeenCalledWith(emulator) @@ -42,6 +52,10 @@ describe('native Agent History baseline', () => { expect(tapControlByPrefix).toHaveBeenNthCalledWith(1, emulator, 'mobile-rearch', 30_000) expect(tapControl).toHaveBeenCalledWith(emulator, 'More session actions', 30_000) expect(tapControlByPrefix).toHaveBeenNthCalledWith(2, emulator, 'Agent History', 30_000) + expect(tapControl).toHaveBeenCalledWith(emulator, 'Search sessions, repo:, path:', 30_000) + expect(typeText).toHaveBeenCalledWith(emulator, 'hybrid agent history fixture') + expect(waitByPrefix).toHaveBeenCalledWith(emulator, 'Hybrid Agent History Fixture', 30_000) + expect(tapControl).toHaveBeenCalledWith(emulator, 'Agent Session History', 30_000) expect(rotateEmulator.mock.invocationCallOrder[0]).toBeLessThan( dismissDeveloperMenu.mock.invocationCallOrder[0] ) diff --git a/mobile/src/mobile-web/hosted-ios-core-route-parity.test.ts b/mobile/src/mobile-web/hosted-ios-core-route-parity.test.ts index 4d2052e0303..1cb880e23da 100644 --- a/mobile/src/mobile-web/hosted-ios-core-route-parity.test.ts +++ b/mobile/src/mobile-web/hosted-ios-core-route-parity.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ waitForControl: vi.fn(), waitForControlByPrefix: vi.fn(), waitForControlMatch: vi.fn(), + waitForControlMatching: vi.fn(), waitForDocument: vi.fn() })) @@ -29,7 +30,8 @@ vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({ tapHostedIosPoint: mocks.tapPoint, waitForHostedIosAccessibilityControl: mocks.waitForControl, waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix, - waitForHostedIosAccessibilityControlMatch: mocks.waitForControlMatch + waitForHostedIosAccessibilityControlMatch: mocks.waitForControlMatch, + waitForHostedIosAccessibilityControlMatching: mocks.waitForControlMatching })) vi.mock('../../scripts/hosted-ios-screenshot-parity.mjs', () => ({ assertHostedIosScreenshotParity: mocks.compareScreenshots @@ -70,6 +72,12 @@ describe('hosted iOS core-route parity', () => { x: 0.5, y: 0.5 }) + mocks.waitForControlMatching.mockResolvedValue({ + label: 'Stable task title, 3m, Issue · orca #1, Open', + value: '', + x: 0.5, + y: 0.5 + }) mocks.waitForDocument.mockImplementation(({ expectedHrefIncludes }) => ({ href: `orca-mobile-web://build${expectedHrefIncludes ?? '/h/host'}` })) @@ -104,6 +112,30 @@ describe('hosted iOS core-route parity', () => { expect(baselines.session.screenshot).toBe('/tmp/parity/native-session-portrait.png') }) + it('captures a populated GitHub Tasks list after pagination appears', async () => { + mocks.waitForControlMatch.mockRejectedValueOnce(new Error('no empty state')) + + const baselines = await captureNativeCoreRouteBaselines({ + deviceUdid: 'simulator', + emulator: { deviceUdid: 'simulator' }, + expectedWorkspace: 'mobile-rearch', + runtimeDirectory: '/tmp/parity', + timeoutMs: 30_000 + }) + + expect(mocks.waitForControlMatch).toHaveBeenCalledWith( + { deviceUdid: 'simulator' }, + expect.any(Array), + 5_000 + ) + expect(mocks.waitForControlMatching).toHaveBeenCalledWith( + { deviceUdid: 'simulator' }, + expect.any(Function), + 25_000 + ) + expect(baselines.tasks.stableText).toBe('Stable task title') + }) + it('compares hosted Tasks and Session and returns to the workspace route', async () => { const nativeBaselines = { tasks: { diff --git a/mobile/src/mobile-web/hosted-ios-emulator-accessibility.test.ts b/mobile/src/mobile-web/hosted-ios-emulator-accessibility.test.ts index 2c15f5c220b..5b9ec72b568 100644 --- a/mobile/src/mobile-web/hosted-ios-emulator-accessibility.test.ts +++ b/mobile/src/mobile-web/hosted-ios-emulator-accessibility.test.ts @@ -8,9 +8,12 @@ import { tapHostedIosAccessibilityControlByLabelPrefixAtPosition, tapHostedIosAccessibilityControlStartingWith, tapHostedIosPoint, + typeHostedIosText, waitForHostedIosAccessibilityControl, waitForHostedIosAccessibilityControlByLabelPrefix, waitForHostedIosAccessibilityControlEndingWith, + waitForHostedIosAccessibilityControlMatching, + waitForHostedIosAccessibilityLabel, waitForHostedIosAccessibilityLabelToDisappear } from '../../scripts/hosted-ios-emulator-accessibility.mjs' @@ -246,6 +249,62 @@ describe('hosted iOS emulator accessibility controls', () => { ).resolves.toEqual({ x: 0.75, y: 0.35 }) }) + it('returns metadata for a visible control matched by shape', async () => { + const runCommand = vi.fn().mockResolvedValueOnce({ + stderr: '', + stdout: JSON.stringify({ + ok: true, + result: [ + { + label: 'Stable task title, 3m, Issue · orca #1, Open', + value: '', + enabled: true, + frame: { x: 0.1, y: 0.3, width: 0.8, height: 0.1 } + } + ] + }) + }) + + await expect( + waitForHostedIosAccessibilityControlMatching( + emulator, + (node) => node.label?.endsWith(', Open'), + 1_000, + runCommand + ) + ).resolves.toEqual({ + label: 'Stable task title, 3m, Issue · orca #1, Open', + value: '', + x: 0.5, + y: 0.35 + }) + }) + + it('accepts a tall visible label whose center is below the viewport', async () => { + const runCommand = vi.fn().mockResolvedValueOnce({ + stderr: '', + stdout: JSON.stringify({ + ok: true, + result: [ + { + label: 'File preview', + value: '', + enabled: true, + frame: { x: 0.03, y: 0.28, width: 0.94, height: 1.52 } + } + ] + }) + }) + + await expect( + waitForHostedIosAccessibilityLabel(emulator, 'File preview', 1_000, runCommand) + ).resolves.toEqual({ + frame: { x: 0.03, y: 0.28, width: 0.94, height: 1.52 }, + label: 'File preview', + value: '' + }) + }) + it('rejects an invalid accessibility response', async () => { await expect( waitForHostedIosAccessibilityControl(emulator, 'Resume agent session', 1_000, async () => ({ @@ -307,6 +366,19 @@ describe('hosted iOS emulator accessibility controls', () => { expect(runCommand).toHaveBeenNthCalledWith(3, emulator, ['attach', emulator.deviceUdid]) }) + it('types through the isolated emulator controller', async () => { + const runCommand = vi.fn().mockResolvedValue({ + stderr: '', + stdout: JSON.stringify({ ok: true }) + }) + + await typeHostedIosText(emulator, 'Hybrid Agent History Fixture', runCommand) + + expect(runCommand.mock.calls.map(([, command]) => command)).toEqual( + Array.from('Hybrid Agent History Fixture', (character) => ['type', character]) + ) + }) + it('retries transient helper failures while reattaching a point tap', async () => { let attachAttempts = 0 const runCommand = vi.fn(async (_args, command) => { diff --git a/mobile/src/mobile-web/hosted-ios-files-preview-parity.test.ts b/mobile/src/mobile-web/hosted-ios-files-preview-parity.test.ts index 1d5eb12d7c1..7a79d837757 100644 --- a/mobile/src/mobile-web/hosted-ios-files-preview-parity.test.ts +++ b/mobile/src/mobile-web/hosted-ios-files-preview-parity.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ tapPoint: vi.fn(), waitForControl: vi.fn(), waitForControlByPrefix: vi.fn(), + waitForLabel: vi.fn(), waitForDocument: vi.fn() })) @@ -28,7 +29,8 @@ vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({ tapHostedIosAccessibilityControlByLabelPrefix: mocks.tapControlByPrefix, tapHostedIosPoint: mocks.tapPoint, waitForHostedIosAccessibilityControl: mocks.waitForControl, - waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix + waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix, + waitForHostedIosAccessibilityLabel: mocks.waitForLabel })) vi.mock('../../scripts/hosted-ios-screenshot-parity.mjs', () => ({ assertHostedIosScreenshotParity: mocks.compareScreenshots @@ -71,6 +73,11 @@ describe('hosted iOS Files and Preview parity', () => { mocks.tapPoint.mockResolvedValue(undefined) mocks.waitForControl.mockResolvedValue({ x: 0.2, y: 0.1 }) mocks.waitForControlByPrefix.mockResolvedValue({ x: 0.2, y: 0.1 }) + mocks.waitForLabel.mockResolvedValue({ + frame: { x: 0, y: 0.3, width: 1, height: 1.5 }, + label: 'File preview', + value: '' + }) mocks.waitForDocument.mockImplementation(({ expectedHrefIncludes }) => ({ href: `orca-mobile-web://build${expectedHrefIncludes ?? '/h/host'}`, targetId: expectedHrefIncludes ?? 'workspace' diff --git a/mobile/src/mobile-web/hosted-ios-host-origin-source-control-journey.test.ts b/mobile/src/mobile-web/hosted-ios-host-origin-source-control-journey.test.ts new file mode 100644 index 00000000000..0d442c142b6 --- /dev/null +++ b/mobile/src/mobile-web/hosted-ios-host-origin-source-control-journey.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { verifyHostedHostOriginSourceControlJourney } from '../../scripts/hosted-ios-host-origin-source-control-journey.mjs' + +describe('hosted iOS host-origin Source Control journey', () => { + it('opens mobile Review without mutating the Session route', async () => { + const sourceControl = { + href: 'orca-mobile-web://build/h/host/source-control/workspace' + } + const review = { href: 'orca-mobile-web://build/h/host/review/workspace' } + const returnedWorkspace = { href: 'orca-mobile-web://build/' } + const activate = vi.fn().mockResolvedValue(undefined) + const longPress = vi.fn().mockResolvedValue(undefined) + const tapNative = vi.fn().mockResolvedValue(undefined) + const waitForDocument = vi + .fn() + .mockResolvedValueOnce(sourceControl) + .mockResolvedValueOnce(review) + .mockResolvedValueOnce(sourceControl) + .mockResolvedValueOnce(sourceControl) + .mockResolvedValueOnce(returnedWorkspace) + const readState = vi + .fn() + .mockResolvedValueOnce({ + href: sourceControl.href, + labels: ['Refresh source control', 'Open changed file mobile/src/mobile-web/bridge.ts'] + }) + .mockResolvedValueOnce({ + href: review.href, + labels: ['Show all review files'] + }) + .mockResolvedValueOnce({ + href: review.href, + labels: ['Back', 'Open review actions'] + }) + + const result = await verifyHostedHostOriginSourceControlJourney({ + discoveryUrl: 'http://127.0.0.1:9222', + emulator: { udid: 'SIMULATOR-1' }, + nativeBaseline: { + changedFileLabel: 'Open changed file mobile/src/mobile-web/bridge.ts' + }, + timeoutMs: 30_000, + workspaceName: 'mobile-rearch', + operations: { activate, longPress, readState, tapNative, waitForDocument } + }) + + expect(longPress).toHaveBeenCalledWith({ udid: 'SIMULATOR-1' }, 'mobile-rearch', 30_000) + expect(tapNative).toHaveBeenCalledWith({ udid: 'SIMULATOR-1' }, 'Source Control', 30_000) + expect(activate.mock.calls.map((call) => call[1])).toEqual([ + { kind: 'label', value: 'Open changed file mobile/src/mobile-web/bridge.ts' }, + { kind: 'label', value: 'Back' }, + { kind: 'label', value: 'Back to session' } + ]) + expect(waitForDocument).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ expectedHrefIncludes: '/review/' }) + ) + expect(waitForDocument).toHaveBeenCalledTimes(5) + expect(result).toMatchObject({ + reviewRoute: review.href, + sourceControlRoute: sourceControl.href, + workspaceDocument: returnedWorkspace + }) + }) +}) diff --git a/mobile/src/mobile-web/hosted-ios-native-alert-journey.test.ts b/mobile/src/mobile-web/hosted-ios-native-alert-journey.test.ts new file mode 100644 index 00000000000..dea4e8ff4e0 --- /dev/null +++ b/mobile/src/mobile-web/hosted-ios-native-alert-journey.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { verifyHostedIosNativeAlertJourney } from '../../scripts/hosted-ios-native-alert-journey.mjs' + +describe('hosted iOS native Alert journey', () => { + it('presents an OS alert, correlates its action, and returns to workspaces', async () => { + const workspaceDocument = { href: 'orca-mobile-web://build/h/host' } + const sessionDocument = { href: 'orca-mobile-web://build/h/host/session/workspace' } + const returnedDocument = { href: 'orca-mobile-web://build/h/host' } + const evaluate = vi + .fn() + .mockResolvedValueOnce(JSON.stringify({ started: true })) + .mockResolvedValueOnce(JSON.stringify({ posted: true })) + .mockResolvedValueOnce( + JSON.stringify({ status: 'success', payload: { kind: 'button', buttonIndex: 0 } }) + ) + const waitForDocument = vi + .fn() + .mockResolvedValueOnce(sessionDocument) + .mockResolvedValueOnce(returnedDocument) + const activateWorkspace = vi.fn().mockResolvedValue(undefined) + const activateControl = vi.fn().mockResolvedValue(undefined) + const waitForLabel = vi.fn().mockResolvedValue({ frame: { x: 0, y: 0, width: 1, height: 1 } }) + const tapControl = vi.fn().mockResolvedValue({ x: 0.5, y: 0.5 }) + const waitForLabelToDisappear = vi.fn().mockResolvedValue(undefined) + + const result = await verifyHostedIosNativeAlertJourney( + { + discoveryUrl: 'http://127.0.0.1:9222', + emulator: { deviceUdid: 'simulator' }, + expectedWorkspace: 'mobile-rearch', + timeoutMs: 30_000, + workspaceDocument + }, + { + activateControl, + activateWorkspace, + evaluate, + tapControl, + waitForDocument, + waitForLabel, + waitForLabelToDisappear + } + ) + + expect(tapControl).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, 'Keep editing', 30_000) + expect(activateControl).toHaveBeenCalledWith(sessionDocument, { + kind: 'label', + value: 'Back to worktrees' + }) + expect(result).toEqual({ + evidence: { + button: { x: 0.5, y: 0.5 }, + buttonIndex: 0, + presentation: 'native', + title: { x: 0, y: 0, width: 1, height: 1 } + }, + workspaceDocument: returnedDocument + }) + }) +}) diff --git a/mobile/src/mobile-web/hosted-ios-photo-permission-revocation.test.ts b/mobile/src/mobile-web/hosted-ios-photo-permission-revocation.test.ts index 9017a8f6522..848c6602230 100644 --- a/mobile/src/mobile-web/hosted-ios-photo-permission-revocation.test.ts +++ b/mobile/src/mobile-web/hosted-ios-photo-permission-revocation.test.ts @@ -15,7 +15,7 @@ const args = { orcaCli: './config/scripts/orca-dev.mjs', pairingRuntimeUserDataPath: '/tmp/pairing/userData', sessionDocument: { - href: 'orca-mobile-web://build/h/host/session/workspace?name=mobile-rearch' + href: 'orca-mobile-web://build/h/host/session/workspace' }, terminalHandle: 'terminal-handle', timeoutMs: 30_000, @@ -24,12 +24,12 @@ const args = { const grantedDocument = { href: args.sessionDocument.href .replace('://build/', '://grant-restart/') - .replace('/workspace?', '/grant-workspace?') + .replace('/workspace', '/grant-workspace') } const revokedDocument = { href: args.sessionDocument.href .replace('://build/', '://revocation-restart/') - .replace('/workspace?', '/revoked-workspace?') + .replace('/workspace', '/revoked-workspace') } function createOperations() { @@ -231,7 +231,7 @@ describe('hosted iOS Photos permission revocation', () => { it('rejects opaque authority replacement across picker interruption', async () => { const operations = createOperations() const replacedAuthority = { - href: grantedDocument.href.replace('/grant-workspace?', '/interrupted-workspace?') + href: grantedDocument.href.replace('/grant-workspace', '/interrupted-workspace') } operations.waitForDocument .mockReset() @@ -249,7 +249,7 @@ describe('hosted iOS Photos permission revocation', () => { const interruptedDocument = { href: grantedDocument.href .replace('://grant-restart/', '://interruption-restart/') - .replace('/grant-workspace?', '/interrupted-workspace?') + .replace('/grant-workspace', '/interrupted-workspace') } operations.waitForDocument .mockReset() diff --git a/mobile/src/mobile-web/hosted-ios-source-control-review-journey.test.ts b/mobile/src/mobile-web/hosted-ios-source-control-review-journey.test.ts index 23d214bc448..50e409f1a46 100644 --- a/mobile/src/mobile-web/hosted-ios-source-control-review-journey.test.ts +++ b/mobile/src/mobile-web/hosted-ios-source-control-review-journey.test.ts @@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ captureParityScreen: vi.fn(), + readBridgeErrors: vi.fn(), navigateRoute: vi.fn(), parityEvidence: vi.fn(), readControlPoint: vi.fn(), readState: vi.fn(), tapAccessibilityControl: vi.fn(), tapPoint: vi.fn(), + startBridgeErrorObservation: vi.fn(), waitForDocument: vi.fn() })) @@ -29,6 +31,11 @@ vi.mock('../../scripts/hosted-webview-route-navigation.mjs', () => ({ navigateHostedWebViewRoute: mocks.navigateRoute })) +vi.mock('../../scripts/hosted-webview-bridge-error-observation.mjs', () => ({ + readHostedWebViewBridgeErrors: mocks.readBridgeErrors, + startHostedWebViewBridgeErrorObservation: mocks.startBridgeErrorObservation +})) + vi.mock('../../scripts/hosted-ios-source-control-review-parity.mjs', () => ({ captureHostedSourceControlReviewScreen: mocks.captureParityScreen, sourceControlReviewParityEvidence: mocks.parityEvidence @@ -51,19 +58,20 @@ describe('hosted iOS Source Control and Review journey', () => { .mockReturnValueOnce({ screen: 'review' }) mocks.tapAccessibilityControl.mockResolvedValue(undefined) mocks.tapPoint.mockResolvedValue(undefined) + mocks.startBridgeErrorObservation.mockResolvedValue(undefined) mocks.waitForDocument .mockResolvedValueOnce({ - href: 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session' + href: 'orca-mobile-web://build/h/host/source-control/workspace' }) .mockResolvedValueOnce({ - href: 'orca-mobile-web://build/h/host/session/workspace?name=repo' + href: 'orca-mobile-web://build/h/host/session/workspace' }) .mockResolvedValueOnce({ href: 'orca-mobile-web://build/h/host/review/workspace' }) mocks.readState .mockResolvedValueOnce({ - href: 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session', + href: 'orca-mobile-web://build/h/host/source-control/workspace', bodyText: 'Source Control Changes Pull Request Commits Create pull request 128 on branch', labels: ['Refresh source control', 'Open changed file mobile/app/index.tsx'] }) @@ -88,10 +96,9 @@ describe('hosted iOS Source Control and Review journey', () => { timeoutMs: 30_000 }) ).resolves.toEqual({ - sourceControlRoute: - 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session', + sourceControlRoute: 'orca-mobile-web://build/h/host/source-control/workspace', sourceControlSegments: ['Changes', 'Pull Request', 'Commits'], - sessionDiffRoute: 'orca-mobile-web://build/h/host/session/workspace?name=repo', + sessionDiffRoute: 'orca-mobile-web://build/h/host/session/workspace', reviewRoute: 'orca-mobile-web://build/h/host/review/workspace', reviewControls: ['Back', 'Open review actions'] }) @@ -115,8 +122,8 @@ describe('hosted iOS Source Control and Review journey', () => { ) expect(mocks.tapPoint).not.toHaveBeenCalled() expect(mocks.navigateRoute).toHaveBeenCalledWith( - { href: 'orca-mobile-web://build/h/host/session/workspace?name=repo' }, - '/h/host/review/workspace?scope=all&name=repo' + { href: 'orca-mobile-web://build/h/host/session/workspace' }, + '/h/host/review/workspace?scope=all' ) }) @@ -158,7 +165,14 @@ describe('hosted iOS Source Control and Review journey', () => { discoveryUrl: 'http://127.0.0.1:9222', emulator: { deviceUdid: 'simulator' }, nativeBaselines: { - sourceControl: { screenshot: '/tmp/native-source-control.png' }, + sourceControl: { + changedFileLabel: 'Open changed file mobile/app/index.tsx', + pullRequestState: { + kind: 'create', + label: 'Create pull request' + }, + screenshot: '/tmp/native-source-control.png' + }, review: { screenshot: '/tmp/native-review.png' } }, runtimeDirectory: '/tmp/parity', @@ -190,6 +204,91 @@ describe('hosted iOS Source Control and Review journey', () => { }) }) + it('settles parity against the existing pull request captured by native', async () => { + const pullRequestLabel = 'Pull request #13386, Draft, No checks. Open pull request.' + mocks.readState + .mockReset() + .mockResolvedValueOnce({ + href: 'orca-mobile-web://build/h/host/source-control/workspace', + bodyText: 'Source Control Changes Pull Request Commits #13386 Draft 128 on branch', + labels: [ + 'Refresh source control', + pullRequestLabel, + 'Open changed file mobile/app/index.tsx' + ] + }) + .mockResolvedValueOnce({ + href: 'orca-mobile-web://build/h/host/review/workspace', + bodyText: 'reviewed', + labels: ['Back', 'Open review actions'] + }) + + await expect( + verifyHostedSourceControlReviewJourney({ + deviceUdid: 'simulator', + discoveryUrl: 'http://127.0.0.1:9222', + emulator: { deviceUdid: 'simulator' }, + nativeBaselines: { + sourceControl: { + changedFileLabel: 'Open changed file mobile/app/index.tsx', + pullRequestState: { kind: 'ready', label: pullRequestLabel, number: '13386' }, + screenshot: '/tmp/native-source-control.png' + }, + review: { screenshot: '/tmp/native-review.png' } + }, + runtimeDirectory: '/tmp/parity', + sessionDocument: { + href: 'orca-mobile-web://build/h/host/session/workspace' + }, + timeoutMs: 30_000 + }) + ).resolves.toMatchObject({ sourceControlSegments: ['Changes', 'Pull Request', 'Commits'] }) + }) + + it('opens the exact changed file captured by the native baseline', async () => { + const nativeChangedFileLabel = 'Open changed file mobile/src/mobile-web/bridge.ts' + mocks.readState + .mockReset() + .mockResolvedValueOnce({ + href: 'orca-mobile-web://build/h/host/source-control/workspace', + bodyText: 'Source Control Changes Pull Request Commits Create pull request 128 on branch', + labels: [ + 'Refresh source control', + 'Open changed file mobile/app/index.tsx', + nativeChangedFileLabel + ] + }) + .mockResolvedValueOnce({ + href: 'orca-mobile-web://build/h/host/review/workspace', + bodyText: 'reviewed', + labels: ['Back', 'Open review actions'] + }) + + await verifyHostedSourceControlReviewJourney({ + deviceUdid: 'simulator', + discoveryUrl: 'http://127.0.0.1:9222', + emulator: { deviceUdid: 'simulator' }, + nativeBaselines: { + sourceControl: { + changedFileLabel: nativeChangedFileLabel, + pullRequestState: { kind: 'create', label: 'Create pull request' }, + screenshot: '/tmp/native-source-control.png' + }, + review: { screenshot: '/tmp/native-review.png' } + }, + runtimeDirectory: '/tmp/parity', + sessionDocument: { href: 'orca-mobile-web://build/h/host/session/workspace' }, + timeoutMs: 30_000 + }) + + expect(mocks.tapAccessibilityControl).toHaveBeenNthCalledWith( + 2, + { deviceUdid: 'simulator' }, + nativeChangedFileLabel, + 5_000 + ) + }) + it('falls back to measured points when WebKit omits accessibility descendants', async () => { mocks.tapAccessibilityControl.mockRejectedValue(new Error('missing descendant')) @@ -214,7 +313,7 @@ describe('hosted iOS Source Control and Review journey', () => { it('uses measured native taps for duplicated Source Control segment labels', async () => { const sourceState = { - href: 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session', + href: 'orca-mobile-web://build/h/host/source-control/workspace', bodyText: 'Source Control Changes Pull Request Commits Create pull request 128 on branch', labels: ['Refresh source control', 'Open changed file mobile/app/index.tsx'] } @@ -283,12 +382,12 @@ describe('hosted iOS Source Control and Review journey', () => { mocks.waitForDocument .mockReset() .mockResolvedValueOnce({ - href: 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session' + href: 'orca-mobile-web://build/h/host/source-control/workspace' }) .mockRejectedValueOnce(new Error('route unchanged')) .mockRejectedValueOnce(new Error('route unchanged')) .mockResolvedValueOnce({ - href: 'orca-mobile-web://build/h/host/session/workspace?name=repo' + href: 'orca-mobile-web://build/h/host/session/workspace' }) .mockResolvedValueOnce({ href: 'orca-mobile-web://build/h/host/review/workspace' @@ -312,12 +411,12 @@ describe('hosted iOS Source Control and Review journey', () => { it('waits for tab state after the route changes', async () => { const sessionDocument = { - href: 'orca-mobile-web://build/h/host/session/workspace?name=repo' + href: 'orca-mobile-web://build/h/host/session/workspace' } mocks.waitForDocument .mockReset() .mockResolvedValueOnce({ - href: 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session' + href: 'orca-mobile-web://build/h/host/source-control/workspace' }) .mockRejectedValueOnce(new Error('tab count pending')) .mockResolvedValueOnce(sessionDocument) diff --git a/mobile/src/mobile-web/hosted-ios-source-control-review-parity.test.ts b/mobile/src/mobile-web/hosted-ios-source-control-review-parity.test.ts index 399ef38c58c..877ff6b8d07 100644 --- a/mobile/src/mobile-web/hosted-ios-source-control-review-parity.test.ts +++ b/mobile/src/mobile-web/hosted-ios-source-control-review-parity.test.ts @@ -7,11 +7,10 @@ const mocks = vi.hoisted(() => ({ longPressControlByPrefix: vi.fn(), readTextPoint: vi.fn(), tapControl: vi.fn(), - tapControlStartingWith: vi.fn(), waitForControl: vi.fn(), waitForControlByPrefix: vi.fn(), waitForControlEndingWith: vi.fn(), - waitForControlStartingWith: vi.fn() + waitForControlMatching: vi.fn() })) vi.mock('node:child_process', () => ({ @@ -22,11 +21,10 @@ vi.mock('../../scripts/emulator-developer-menu-dismissal.mjs', () => ({ })) vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({ tapHostedIosAccessibilityControl: mocks.tapControl, - tapHostedIosAccessibilityControlStartingWith: mocks.tapControlStartingWith, waitForHostedIosAccessibilityControl: mocks.waitForControl, waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix, waitForHostedIosAccessibilityControlEndingWith: mocks.waitForControlEndingWith, - waitForHostedIosAccessibilityControlStartingWith: mocks.waitForControlStartingWith + waitForHostedIosAccessibilityControlMatching: mocks.waitForControlMatching })) vi.mock('../../scripts/hosted-ios-emulator-long-press.mjs', () => ({ longPressHostedIosAccessibilityControlByLabelPrefix: mocks.longPressControlByPrefix @@ -53,11 +51,22 @@ describe('hosted iOS Source Control and Review parity', () => { mocks.longPressControlByPrefix.mockResolvedValue({ x: 0.5, y: 0.4 }) mocks.readTextPoint.mockResolvedValue({ x: 0.2, y: 0.1 }) mocks.tapControl.mockResolvedValue({ x: 0.2, y: 0.1 }) - mocks.tapControlStartingWith.mockResolvedValue({ x: 0.5, y: 0.5 }) mocks.waitForControl.mockResolvedValue({ x: 0.2, y: 0.1 }) mocks.waitForControlByPrefix.mockResolvedValue({ x: 0.5, y: 0.4 }) mocks.waitForControlEndingWith.mockResolvedValue({ x: 0.5, y: 0.4 }) - mocks.waitForControlStartingWith.mockResolvedValue({ x: 0.5, y: 0.5 }) + mocks.waitForControlMatching + .mockResolvedValueOnce({ + label: 'Open changed file mobile/app/index.tsx', + value: '', + x: 0.5, + y: 0.5 + }) + .mockResolvedValueOnce({ + label: 'Pull request #13386, Draft, No checks. Open pull request.', + value: '', + x: 0.5, + y: 0.3 + }) }) it('captures the real host-origin Source Control and standalone Review path', async () => { @@ -79,9 +88,9 @@ describe('hosted iOS Source Control and Review parity', () => { 'Source Control', 30_000 ) - expect(mocks.tapControlStartingWith).toHaveBeenCalledWith( + expect(mocks.tapControl).toHaveBeenCalledWith( { deviceUdid: 'simulator' }, - 'Open changed file ', + 'Open changed file mobile/app/index.tsx', 30_000 ) expect(baselines).toEqual({ @@ -90,12 +99,53 @@ describe('hosted iOS Source Control and Review parity', () => { screenshot: '/tmp/parity/native-review-portrait.png' }, sourceControl: { + changedFileLabel: 'Open changed file mobile/app/index.tsx', + pullRequestState: { + kind: 'ready', + label: 'Pull request #13386, Draft, No checks. Open pull request.', + number: '13386' + }, screenTitlePoint: { x: 0.2, y: 0.1 }, screenshot: '/tmp/parity/native-source-control-portrait.png' } }) }) + it('uses value-backed accessibility text for the changed file and pull request', async () => { + mocks.waitForControlMatching + .mockReset() + .mockResolvedValueOnce({ + label: 'Changed file', + value: 'Open changed file mobile/src/mobile-web/bridge.ts', + x: 0.5, + y: 0.5 + }) + .mockResolvedValueOnce({ + label: '', + value: 'Pull request #13386, Draft, No checks. Open pull request.', + x: 0.5, + y: 0.3 + }) + + const baselines = await captureNativeSourceControlReviewBaselines({ + deviceUdid: 'simulator', + emulator: { deviceUdid: 'simulator' }, + expectedWorkspace: 'mobile-rearch', + runtimeDirectory: '/tmp/parity', + timeoutMs: 30_000 + }) + + expect(baselines.sourceControl).toMatchObject({ + changedFileLabel: 'Open changed file mobile/src/mobile-web/bridge.ts', + pullRequestState: { kind: 'ready', number: '13386' } + }) + expect(mocks.tapControl).toHaveBeenCalledWith( + { deviceUdid: 'simulator' }, + 'Open changed file mobile/src/mobile-web/bridge.ts', + 30_000 + ) + }) + it('compares a hosted route against its native screenshot and title landmark', async () => { const capture = await captureHostedSourceControlReviewScreen({ deviceUdid: 'simulator', diff --git a/mobile/src/mobile-web/hosted-webview-cdp-session.test.ts b/mobile/src/mobile-web/hosted-webview-cdp-session.test.ts index 9fe3e3498a1..2b46122e791 100644 --- a/mobile/src/mobile-web/hosted-webview-cdp-session.test.ts +++ b/mobile/src/mobile-web/hosted-webview-cdp-session.test.ts @@ -51,6 +51,14 @@ const simulatorHarnessSource = readFileSync( new URL('../../scripts/run-hosted-webview-simulator-e2e.mjs', import.meta.url), 'utf8' ) +const simulatorSecurityEvidenceSource = readFileSync( + new URL('../../scripts/hosted-webview-security-evidence.mjs', import.meta.url), + 'utf8' +) +const simulatorReportSource = readFileSync( + new URL('../../scripts/hosted-webview-e2e-report.mjs', import.meta.url), + 'utf8' +) const simulatorAppBuildSource = readFileSync( new URL('../../scripts/hosted-ios-simulator-app-build.mjs', import.meta.url), 'utf8' @@ -518,6 +526,8 @@ describe('hosted WebView CDP target selection', () => { .map((evaluation) => evaluation.params.expression) .join('\n') expect(expressions).toContain('input[placeholder],textarea[placeholder]') + expect(expressions).toContain('element.getBoundingClientRect()') + expect(expressions).toContain('rect.top < innerHeight') expect(expressions).toContain('More session actions') expect(expressions).toContain('closest(\'button,[role="button"],a,[tabindex]\')') expect(expressions).toContain('getBoundingClientRect()') @@ -704,11 +714,12 @@ describe('hosted WebView CDP target selection', () => { expect(simulatorHarnessSource).toContain( 'terminalDeviceInput.photoPermissionRevocation?.sessionDocument' ) - expect(simulatorHarnessSource).toContain( + expect(simulatorReportSource).toContain( 'terminalDeviceInput?.terminalClipboardImagePaste?.evidence' ) - expect(simulatorHarnessSource).toContain('verifyHostedWebViewExecutableIsolation') - expect(simulatorHarnessSource).toContain('verifyHostedWebViewPrivacyIsolation') + expect(simulatorHarnessSource).toContain('captureHostedWebViewSecurityEvidence') + expect(simulatorSecurityEvidenceSource).toContain('verifyHostedWebViewExecutableIsolation') + expect(simulatorSecurityEvidenceSource).toContain('verifyHostedWebViewPrivacyIsolation') expect(simulatorHarnessSource).toContain("await evidenceStep('Photos permission reset'") expect(simulatorHarnessSource).toContain('await clearHostedIosWebViewSecurityProbe(deviceUdid)') expect(simulatorAppBuildSource).toContain("'xcodebuild'") diff --git a/mobile/src/mobile-web/hosted-webview-cdp-target-replacement.test.ts b/mobile/src/mobile-web/hosted-webview-cdp-target-replacement.test.ts new file mode 100644 index 00000000000..03b8f150dc6 --- /dev/null +++ b/mobile/src/mobile-web/hosted-webview-cdp-target-replacement.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import { waitForHostedWebViewConnectionSequence } from '../../scripts/hosted-webview-cdp-session.mjs' + +describe('hosted WebView CDP target replacement', () => { + it('reacquires the document while observing reconnect', async () => { + const recovering = connectionEntry('recovering') + const connected = connectionEntry('connected') + const stale = new TargetSocket([ + JSON.stringify([recovering]), + new Error('target replaced'), + new Error('target replaced'), + new Error('target replaced') + ]) + const replacement = new TargetSocket([JSON.stringify([connected])]) + const WebSocketCtor = class { + constructor(endpoint: string) { + return endpoint.endsWith('/stale') ? stale : replacement + } + } + const reacquireDocument = vi.fn().mockResolvedValue({ + webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/replacement' + }) + + await expect( + waitForHostedWebViewConnectionSequence( + { webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/stale' }, + ['recovering', 'connected'], + 5_000, + { WebSocketCtor, reacquireDocument } + ) + ).resolves.toEqual([recovering, connected]) + expect(reacquireDocument).toHaveBeenCalledOnce() + }) +}) + +function connectionEntry(state: string) { + return { + state, + href: 'orca-mobile-web://session-a/h/host/agent-history/worktree', + retainedExpectedText: true, + retainedExpectedRoute: true + } +} + +class TargetSocket { + private readonly values: (string | Error)[] + private errorListener: ((error: Error) => void) | undefined + private messageListener: ((data: Buffer) => void) | undefined + + constructor(values: (string | Error)[]) { + this.values = [...values] + } + + once(event: string, listener: (value?: unknown) => void): void { + if (event === 'open') { + queueMicrotask(listener) + } else if (event === 'error') { + this.errorListener = listener as (error: Error) => void + } + } + + on(event: string, listener: (data: Buffer) => void): void { + if (event === 'message') { + this.messageListener = listener + } + } + + send(): void { + const value = this.values.shift() ?? '' + queueMicrotask(() => { + if (value instanceof Error) { + this.errorListener?.(value) + return + } + this.messageListener?.(Buffer.from(JSON.stringify({ id: 1, result: { result: { value } } }))) + }) + } + + close(): void {} +} diff --git a/mobile/src/mobile-web/hosted-webview-route-exception-evidence.test.ts b/mobile/src/mobile-web/hosted-webview-route-exception-evidence.test.ts new file mode 100644 index 00000000000..f86f3bc0987 --- /dev/null +++ b/mobile/src/mobile-web/hosted-webview-route-exception-evidence.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { evaluate } = vi.hoisted(() => ({ evaluate: vi.fn() })) + +vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({ + evaluateHostedDocumentWithRetry: evaluate +})) + +import { + installHostedWebViewRouteExceptionCapture, + readHostedWebViewRouteExceptionEvidence +} from '../../scripts/hosted-webview-route-exception-evidence.mjs' + +describe('hosted WebView route exception evidence', () => { + beforeEach(() => { + evaluate.mockReset() + }) + + it('installs a bounded error-only capture in the hosted document', async () => { + evaluate.mockResolvedValue('installed') + + const WebSocketCtor = class {} + await installHostedWebViewRouteExceptionCapture({ targetId: 'hosted' }, WebSocketCtor) + + expect(evaluate).toHaveBeenCalledWith( + { targetId: 'hosted' }, + expect.stringContaining("append('unhandled-rejection'"), + WebSocketCtor + ) + expect(evaluate.mock.calls[0][1]).toContain('entries.length > 24') + expect(evaluate.mock.calls[0][1]).toContain('.slice(0, 4096)') + }) + + it('returns only bounded exception evidence entries', async () => { + evaluate.mockResolvedValue( + JSON.stringify([ + { kind: 'console-error', text: 'TypeError: failed' }, + { kind: 'other', text: 'ignored' }, + { kind: 'window-error', text: 'x'.repeat(4_097) } + ]) + ) + + const WebSocketCtor = class {} + await expect( + readHostedWebViewRouteExceptionEvidence({ targetId: 'hosted' }, WebSocketCtor) + ).resolves.toEqual([{ kind: 'console-error', text: 'TypeError: failed' }]) + }) +}) diff --git a/mobile/src/mobile-web/hosted-webview-route-navigation.test.ts b/mobile/src/mobile-web/hosted-webview-route-navigation.test.ts index 389a49c97d8..2748ac831db 100644 --- a/mobile/src/mobile-web/hosted-webview-route-navigation.test.ts +++ b/mobile/src/mobile-web/hosted-webview-route-navigation.test.ts @@ -3,7 +3,7 @@ import { navigateHostedWebViewRoute } from '../../scripts/hosted-webview-route-n describe('hosted WebView route navigation', () => { it('moves within the authenticated hosted SPA without a document navigation', async () => { - const socket = cdpSocket('/h/host/review/workspace?scope=all') + const socket = cdpSocket('/h/host/review/workspace') await expect( navigateHostedWebViewRoute( diff --git a/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts b/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts index b6e44a22d40..576bb44e511 100644 --- a/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts +++ b/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts @@ -11,6 +11,14 @@ const hybridPresentation = readFileSync( new URL('./MobileWebHybridShellPresentation.tsx', import.meta.url), 'utf8' ) +const brokerMessageHandoff = readFileSync( + new URL('./mobile-web-broker-message-handoff.ts', import.meta.url), + 'utf8' +) +const hardwareBackHook = readFileSync( + new URL('./use-mobile-web-hardware-back-handoff.ts', import.meta.url), + 'utf8' +) const hostedRouteRoot = new URL('../../host-web-app', import.meta.url) const NATIVE_ROUTE_NAMES = [ @@ -40,7 +48,10 @@ describe('mobile native shell route ownership', () => { expect(nativeSettings).not.toContain('Open hybrid workspace UI') expect(nativeSettings).not.toContain('Hybrid workspace UI') expect(nativeSettings).not.toContain('Experimental') - expect(nativeLayout).not.toContain('') }) it('keeps host selection on the existing native Home presentation', () => { @@ -61,9 +72,10 @@ describe('mobile native shell route ownership', () => { )?.[1] expect(settingsBranch).toContain('nativeRouteHandoffRef.current.record(requestId, destination)') expect(settingsBranch).not.toContain('setHostedViewActive(false)') - expect(hybridShell).toContain('completeMobileWebNativeRouteHandoffAfterResponse') - expect(hybridShell).toContain('await view.deactivateSessionView()') - expect(hybridShell).toContain('setHostedViewActive,') + expect(hybridShell).toContain('handleMobileWebBrokerMessage') + expect(brokerMessageHandoff).toContain('completeMobileWebNativeRouteHandoffAfterResponse') + expect(brokerMessageHandoff).toContain('await view.deactivateSessionView()') + expect(brokerMessageHandoff).toContain('setHostedViewActive: args.setHostedViewActive') expect(hybridShell).toContain("router.push('/terminal-settings')") expect(hybridShell).toContain('void view.activateSessionView(sessionId)') expect(hybridShell).toContain('return () => setHostedViewActive(false)') @@ -85,6 +97,17 @@ describe('mobile native shell route ownership', () => { expect(readyBranch).toContain('setPageReadySessionId(current.sessionId)') expect(readyBranch).not.toContain('postInit') }) + + it('returns Android hardware Back to the native host route when the page does not handle it', () => { + const handleBack = hybridShell.match( + /const handleBack = useCallback\(\(\) => \{([\s\S]*?)\n\s*\},/ + )?.[1] + expect(hybridShell).toContain('useMobileWebHardwareBackHandoff') + expect(hybridShell).toContain('onUnhandled: handleBack') + expect(handleBack).toContain('leaveHostRoute(router)') + expect(hardwareBackHook).toContain("Platform.OS !== 'android'") + expect(hardwareBackHook).toContain("BackHandler.addEventListener('hardwareBackPress'") + }) }) function listRouteFiles(root: URL): string[] { diff --git a/mobile/src/mobile-web/mobile-web-alert-adapter.test.tsx b/mobile/src/mobile-web/mobile-web-alert-adapter.test.tsx new file mode 100644 index 00000000000..0a91aa32831 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-alert-adapter.test.tsx @@ -0,0 +1,232 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + MobileWebAlertAdapter, + createMobileWebAlertController, + installMobileWebAlertAdapter +} from './mobile-web-alert-adapter' + +vi.mock('react-native', () => ({ Alert: { alert: vi.fn() } })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: 'ConfirmModal' })) + +describe('mobile web Alert adapter', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('renders the native confirmation copy and invokes only the chosen action', () => { + const stay = vi.fn() + const discard = vi.fn() + const target = { alert: vi.fn() } + expect(installMobileWebAlertAdapter(target)).toBe(true) + act(() => { + renderer = create(createElement(MobileWebAlertAdapter)) + target.alert('Discard changes?', 'Unsaved edits will be lost.', [ + { text: 'Stay', style: 'cancel', onPress: stay }, + { text: 'Discard', style: 'destructive', onPress: discard } + ]) + }) + + const modal = renderer.root.findByType('ConfirmModal') + expect(modal.props).toMatchObject({ + title: 'Discard changes?', + message: 'Unsaved edits will be lost.', + cancelLabel: 'Stay', + confirmLabel: 'Discard', + destructive: true + }) + act(() => { + modal.props.onConfirm() + modal.props.onCancel() + }) + expect(discard).toHaveBeenCalledOnce() + expect(stay).not.toHaveBeenCalled() + }) + + it('queues alerts and supplies the native implicit OK action', () => { + const controller = createMobileWebAlertController() + controller.alert('First') + controller.alert('Second') + + const first = controller.getSnapshot() + expect(first).toMatchObject({ title: 'First', buttons: [{ text: 'OK' }] }) + controller.choose(first!.id, first!.buttons[0]) + expect(controller.getSnapshot()).toMatchObject({ title: 'Second' }) + }) + + it('bounds queued alerts', () => { + const controller = createMobileWebAlertController() + for (let index = 0; index < 20; index += 1) { + controller.alert(`Alert ${index}`) + } + + const titles: string[] = [] + while (controller.getSnapshot()) { + const prompt = controller.getSnapshot()! + titles.push(prompt.title) + controller.choose(prompt.id, prompt.buttons[0]) + } + expect(titles).toEqual(Array.from({ length: 16 }, (_, index) => `Alert ${index}`)) + }) + + it('uses the negotiated native alert and returns only the selected button callback', async () => { + const cancel = vi.fn() + const confirm = vi.fn() + const presentNative = vi.fn().mockResolvedValue({ kind: 'button', buttonIndex: 1 }) + const target = { alert: vi.fn() } + installMobileWebAlertAdapter(target) + + await act(async () => { + renderer = create(createElement(MobileWebAlertAdapter, { presentNative })) + target.alert( + 'Use reset?', + 'This spends one reset.', + [ + { text: 'Cancel', style: 'cancel', onPress: cancel }, + { text: 'Use reset', isPreferred: true, onPress: confirm } + ], + { cancelable: false, userInterfaceStyle: 'dark' } + ) + await Promise.resolve() + }) + + expect(presentNative).toHaveBeenCalledWith({ + title: 'Use reset?', + message: 'This spends one reset.', + buttons: [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Use reset', isPreferred: true } + ], + options: { cancelable: false, userInterfaceStyle: 'dark' } + }) + expect(confirm).toHaveBeenCalledOnce() + expect(cancel).not.toHaveBeenCalled() + expect(renderer.toJSON()).toBeNull() + }) + + it('returns native dismissals and falls back only when the capability is unavailable', async () => { + const onDismiss = vi.fn() + const target = { alert: vi.fn() } + installMobileWebAlertAdapter(target) + const presentNative = vi + .fn() + .mockResolvedValueOnce({ kind: 'dismissed' }) + .mockRejectedValueOnce(new Error('unsupported_capability')) + + await act(async () => { + renderer = create(createElement(MobileWebAlertAdapter, { presentNative })) + target.alert('First', undefined, undefined, { onDismiss }) + await Promise.resolve() + }) + expect(onDismiss).toHaveBeenCalledOnce() + + await act(async () => { + target.alert('Second') + await Promise.resolve() + }) + expect(renderer.root.findByType('ConfirmModal').props).toMatchObject({ + title: 'Second', + confirmLabel: 'OK' + }) + act(() => renderer!.root.findByType('ConfirmModal').props.onConfirm()) + }) + + it('keeps native presentation ownership across adapter remounts', async () => { + let resolveResult!: (result: { kind: 'button'; buttonIndex: number }) => void + const presentNative = vi.fn( + () => + new Promise<{ kind: 'button'; buttonIndex: number }>((resolve) => { + resolveResult = resolve + }) + ) + const pressed = vi.fn() + const target = { alert: vi.fn() } + installMobileWebAlertAdapter(target) + + act(() => { + renderer = create(createElement(MobileWebAlertAdapter, { presentNative })) + target.alert('Deferred', undefined, [{ text: 'Done', onPress: pressed }]) + }) + act(() => renderer!.unmount()) + act(() => { + renderer = create(createElement(MobileWebAlertAdapter, { presentNative })) + }) + expect(presentNative).toHaveBeenCalledOnce() + + await act(async () => { + resolveResult({ kind: 'button', buttonIndex: 0 }) + await Promise.resolve() + }) + expect(pressed).toHaveBeenCalledOnce() + expect(renderer.toJSON()).toBeNull() + }) + + it('dismisses an Android-cancelable fallback without pressing its first button', () => { + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 Android' }) + const first = vi.fn() + const second = vi.fn() + const onDismiss = vi.fn() + const target = { alert: vi.fn() } + installMobileWebAlertAdapter(target) + + act(() => { + renderer = create(createElement(MobileWebAlertAdapter)) + target.alert( + 'Choose', + undefined, + [ + { text: 'First', onPress: first }, + { text: 'Second', onPress: second } + ], + { cancelable: true, onDismiss } + ) + }) + + const modal = renderer.root.findByType('ConfirmModal') + expect(modal.props.dismissible).toBe(true) + act(() => modal.props.onDismiss()) + expect(onDismiss).toHaveBeenCalledOnce() + expect(first).not.toHaveBeenCalled() + expect(second).not.toHaveBeenCalled() + }) + + it('keeps non-cancelable fallbacks modal while explicit secondary actions still work', () => { + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 Android' }) + const first = vi.fn() + const second = vi.fn() + const onDismiss = vi.fn() + const target = { alert: vi.fn() } + installMobileWebAlertAdapter(target) + + act(() => { + renderer = create(createElement(MobileWebAlertAdapter)) + target.alert( + 'Choose', + undefined, + [ + { text: 'First', onPress: first }, + { text: 'Second', onPress: second } + ], + { cancelable: false, onDismiss } + ) + }) + + const modal = renderer.root.findByType('ConfirmModal') + expect(modal.props.dismissible).toBe(false) + act(() => modal.props.onCancel()) + expect(first).toHaveBeenCalledOnce() + expect(second).not.toHaveBeenCalled() + expect(onDismiss).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/mobile-web/mobile-web-alert-adapter.tsx b/mobile/src/mobile-web/mobile-web-alert-adapter.tsx new file mode 100644 index 00000000000..d8d85fb5522 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-alert-adapter.tsx @@ -0,0 +1,240 @@ +import { useEffect, useRef, useSyncExternalStore, type ReactElement } from 'react' +import { Alert, type AlertButton, type AlertOptions } from 'react-native' +import type { + MobileWebNativeAlertPayload, + MobileWebNativeAlertResult +} from '../../../src/shared/mobile-web/native-operation-contract' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { ConfirmModal } from '../components/ConfirmModal' + +type MobileWebAlertTarget = { + alert(title: string, message?: string, buttons?: AlertButton[], options?: AlertOptions): void +} + +export type MobileWebAlertPrompt = { + id: number + title: string + message?: string + buttons: AlertButton[] + options?: AlertOptions + presentation: 'unattempted' | 'native' | 'fallback' +} + +export type MobileWebAlertController = { + alert: MobileWebAlertTarget['alert'] + beginNativePresentation: (id: number) => boolean + choose: (id: number, button?: AlertButton, dismissed?: boolean) => void + getSnapshot: () => MobileWebAlertPrompt | null + subscribe: (listener: () => void) => () => void + useFallbackPresentation: (id: number) => void +} + +export type MobileWebNativeAlertPresenter = ( + payload: MobileWebNativeAlertPayload +) => Promise + +const installedTargets = new WeakSet() +const MOBILE_WEB_ALERT_QUEUE_LIMIT = 16 + +export function createMobileWebAlertController(): MobileWebAlertController { + const listeners = new Set<() => void>() + const pending: MobileWebAlertPrompt[] = [] + let active: MobileWebAlertPrompt | null = null + let nextId = 1 + + const publish = (): void => { + for (const listener of listeners) { + listener() + } + } + const alert: MobileWebAlertTarget['alert'] = (title, message, buttons, options) => { + const prompt = { + id: nextId++, + title, + message, + buttons: buttons?.length ? buttons : [{ text: 'OK' }], + options, + presentation: 'unattempted' as const + } + if (active) { + if (pending.length >= MOBILE_WEB_ALERT_QUEUE_LIMIT - 1) { + return + } + pending.push(prompt) + return + } + active = prompt + publish() + } + const choose = (id: number, button?: AlertButton, dismissed = button === undefined): void => { + if (active?.id !== id) { + return + } + const completed = active + try { + if (button) { + button.onPress?.() + } else if (dismissed) { + completed.options?.onDismiss?.() + } + } finally { + active = pending.shift() ?? null + publish() + } + } + + return { + alert, + beginNativePresentation(id) { + if (active?.id !== id || active.presentation !== 'unattempted') { + return false + } + active = { ...active, presentation: 'native' } + publish() + return true + }, + choose, + getSnapshot: () => active, + subscribe(listener) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + useFallbackPresentation(id) { + if (active?.id === id && active.presentation !== 'fallback') { + active = { ...active, presentation: 'fallback' } + publish() + } + } + } +} + +const hostedAlertController = createMobileWebAlertController() + +export function installMobileWebAlertAdapter(target: MobileWebAlertTarget = Alert): boolean { + if (installedTargets.has(target)) { + return false + } + target.alert = hostedAlertController.alert + installedTargets.add(target) + return true +} + +export function MobileWebAlertAdapter({ + presentNative +}: { + presentNative?: MobileWebNativeAlertPresenter +}): ReactElement | null { + const prompt = useSyncExternalStore( + hostedAlertController.subscribe, + hostedAlertController.getSnapshot, + hostedAlertController.getSnapshot + ) + const chosenPromptRef = useRef(null) + const presenterRef = useRef(presentNative) + presenterRef.current = presentNative + + useEffect(() => { + const current = hostedAlertController.getSnapshot() + if (!current || !hostedAlertController.beginNativePresentation(current.id)) { + return + } + const presenter = presenterRef.current + if (!presenter) { + hostedAlertController.useFallbackPresentation(current.id) + return + } + void presenter(nativeAlertPayload(current)) + .then((result) => { + if (result.kind === 'button') { + hostedAlertController.choose(current.id, current.buttons[result.buttonIndex], false) + } else { + hostedAlertController.choose(current.id) + } + }) + .catch(() => { + hostedAlertController.useFallbackPresentation(current.id) + }) + }, [prompt?.id]) + + if (!prompt) { + return null + } + if (prompt.presentation !== 'fallback') { + return null + } + + const choose = (button?: AlertButton): void => { + if (chosenPromptRef.current === prompt.id) { + return + } + chosenPromptRef.current = prompt.id + hostedAlertController.choose(prompt.id, button) + queueMicrotask(() => { + if (chosenPromptRef.current === prompt.id) { + chosenPromptRef.current = null + } + }) + } + const dismissible = + prompt.options?.cancelable === true && globalThis.navigator?.userAgent.includes('Android') + if (prompt.buttons.length > 2) { + return ( + ({ + label: button.text ?? 'OK', + destructive: button.style === 'destructive', + onPress: () => choose(button) + }))} + onClose={() => choose()} + /> + ) + } + + const cancelButton = + prompt.buttons.length === 2 + ? (prompt.buttons.find((button) => button.style === 'cancel') ?? prompt.buttons[0]) + : undefined + const confirmButton = prompt.buttons.find((button) => button !== cancelButton) + return ( + choose(confirmButton)} + onCancel={() => choose(cancelButton)} + onDismiss={() => choose()} + /> + ) +} + +function nativeAlertPayload(prompt: MobileWebAlertPrompt): MobileWebNativeAlertPayload { + return { + title: prompt.title, + ...(prompt.message === undefined ? {} : { message: prompt.message }), + buttons: prompt.buttons.map(({ text, style, isPreferred }) => ({ + ...(text === undefined ? {} : { text }), + ...(style === undefined ? {} : { style }), + ...(isPreferred === undefined ? {} : { isPreferred }) + })), + ...(prompt.options + ? { + options: { + ...(prompt.options.cancelable === undefined + ? {} + : { cancelable: prompt.options.cancelable }), + ...(prompt.options.userInterfaceStyle === undefined + ? {} + : { userInterfaceStyle: prompt.options.userInterfaceStyle }) + } + } + : {}) + } +} diff --git a/mobile/src/mobile-web/mobile-web-back-navigation-adapter.test.ts b/mobile/src/mobile-web/mobile-web-back-navigation-adapter.test.ts new file mode 100644 index 00000000000..4a42326d712 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-back-navigation-adapter.test.ts @@ -0,0 +1,181 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + dispatchMobileWebBackNavigation, + installMobileWebBackNavigationAdapter +} from './mobile-web-back-navigation-adapter' + +vi.mock('react-native', () => ({ BackHandler: {} })) + +afterEach(() => vi.useRealTimers()) + +describe('mobile web BackHandler adapter', () => { + it('restores the current entry before dispatching a handled browser back', () => { + const harness = navigationHarness() + const backHandler = backHandlerTarget() + installMobileWebBackNavigationAdapter(backHandler, harness.target) + harness.target.history.pushState({}, '', '/files') + harness.target.history.pushState({}, '', '/preview') + const observed: string[] = [] + harness.target.addEventListener('popstate', () => observed.push(harness.target.location.href)) + backHandler.addEventListener('hardwareBackPress', () => true) + + harness.browserBack() + + expect(harness.target.location.href).toBe('https://orca.test/preview') + expect(observed).toEqual([]) + }) + + it('lets the native callback perform one programmatic back after restoration', () => { + const harness = navigationHarness() + const backHandler = backHandlerTarget() + installMobileWebBackNavigationAdapter(backHandler, harness.target) + harness.target.history.pushState({}, '', '/files') + harness.target.history.pushState({}, '', '/preview') + const observed: string[] = [] + harness.target.addEventListener('popstate', () => observed.push(harness.target.location.href)) + backHandler.addEventListener('hardwareBackPress', () => { + harness.target.history.back() + return true + }) + + harness.browserBack() + + expect(harness.target.location.href).toBe('https://orca.test/files') + expect(observed).toEqual(['https://orca.test/files']) + }) + + it('dispatches the newest active handler first', () => { + const harness = navigationHarness() + const backHandler = backHandlerTarget() + installMobileWebBackNavigationAdapter(backHandler, harness.target) + harness.target.history.pushState({}, '', '/files') + harness.target.history.pushState({}, '', '/preview') + const older = vi.fn(() => true) + const newer = vi.fn(() => true) + backHandler.addEventListener('hardwareBackPress', older) + const subscription = backHandler.addEventListener('hardwareBackPress', newer) + + harness.browserBack() + expect(newer).toHaveBeenCalledOnce() + expect(older).not.toHaveBeenCalled() + + subscription.remove() + harness.browserBack() + expect(older).toHaveBeenCalledOnce() + }) + + it('lets a dirty-route handler consume an explicit hardware back request', () => { + const harness = navigationHarness() + const backHandler = backHandlerTarget() + installMobileWebBackNavigationAdapter(backHandler, harness.target) + harness.target.history.pushState({}, '', '/files') + harness.target.history.pushState({}, '', '/preview') + const dirtyDraftHandler = vi.fn(() => true) + backHandler.addEventListener('hardwareBackPress', dirtyDraftHandler) + + expect(dispatchMobileWebBackNavigation(harness.target)).toBe(true) + expect(dirtyDraftHandler).toHaveBeenCalledOnce() + expect(harness.target.location.href).toBe('https://orca.test/preview') + }) + + it('returns an unconsumed request through page history', () => { + const harness = navigationHarness() + const backHandler = backHandlerTarget() + installMobileWebBackNavigationAdapter(backHandler, harness.target) + harness.target.history.pushState({}, '', '/files') + harness.target.history.pushState({}, '', '/preview') + backHandler.addEventListener('hardwareBackPress', () => false) + + expect(dispatchMobileWebBackNavigation(harness.target)).toBe(true) + expect(harness.target.location.href).toBe('https://orca.test/files') + }) + + it('returns false at the page root so the native shell can leave the route', () => { + const harness = navigationHarness() + const backHandler = backHandlerTarget() + installMobileWebBackNavigationAdapter(backHandler, harness.target) + + expect(dispatchMobileWebBackNavigation(harness.target)).toBe(false) + expect(harness.target.location.href).toBe('https://orca.test/') + }) + + it('does not redispatch a delayed programmatic traversal as physical Back', () => { + vi.useFakeTimers() + const harness = navigationHarness(500) + const backHandler = backHandlerTarget() + installMobileWebBackNavigationAdapter(backHandler, harness.target) + harness.target.history.pushState({}, '', '/files') + harness.target.history.pushState({}, '', '/preview') + const handler = vi.fn(() => false) + backHandler.addEventListener('hardwareBackPress', handler) + + harness.target.history.back() + vi.advanceTimersByTime(500) + + expect(harness.target.location.href).toBe('https://orca.test/files') + expect(handler).not.toHaveBeenCalled() + }) +}) + +function backHandlerTarget() { + return { + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + removeEventListener: vi.fn() + } +} + +function navigationHarness(eventDelayMs = 0) { + const events = new EventTarget() + const location = { href: 'https://orca.test/' } + let entries = [{ state: null as unknown, href: location.href }] + let index = 0 + const traverse = (delta: number): void => { + const nextIndex = Math.max(0, Math.min(entries.length - 1, index + delta)) + if (nextIndex === index) { + return + } + index = nextIndex + location.href = entries[index]!.href + const state = entries[index]!.state + const dispatch = () => events.dispatchEvent(new PopStateEvent('popstate', { state })) + if (eventDelayMs > 0) { + setTimeout(dispatch, eventDelayMs) + } else { + dispatch() + } + } + const history = { + get state() { + return entries[index]!.state + }, + pushState(data: unknown, _unused: string, url?: string | URL | null) { + const href = url == null ? location.href : new URL(String(url), location.href).href + entries = entries.slice(0, index + 1) + entries.push({ state: data, href }) + index += 1 + location.href = href + }, + replaceState(data: unknown, _unused: string, url?: string | URL | null) { + const href = url == null ? location.href : new URL(String(url), location.href).href + entries[index] = { state: data, href } + location.href = href + }, + go(delta = 0) { + traverse(delta) + }, + back() { + traverse(-1) + }, + forward() { + traverse(1) + } + } as History + const target = { + history, + location, + addEventListener: events.addEventListener.bind(events) + } + return { target, browserBack: () => traverse(-1) } +} diff --git a/mobile/src/mobile-web/mobile-web-back-navigation-adapter.ts b/mobile/src/mobile-web/mobile-web-back-navigation-adapter.ts new file mode 100644 index 00000000000..9c5d0a9aaed --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-back-navigation-adapter.ts @@ -0,0 +1,192 @@ +import { BackHandler } from 'react-native' + +type MobileWebBackHandlerCallback = () => boolean | null | undefined + +type MobileWebBackHandlerTarget = { + addEventListener( + eventName: 'hardwareBackPress', + handler: MobileWebBackHandlerCallback + ): { remove(): void } + removeEventListener?: ( + eventName: 'hardwareBackPress', + handler: MobileWebBackHandlerCallback + ) => void +} + +type MobileWebNavigationTarget = { + history: History + location: Pick + addEventListener(type: 'popstate', listener: (event: PopStateEvent) => void): void +} + +const HISTORY_INDEX_KEY = '__orcaMobileWebBackIndex' +const PROGRAMMATIC_TRAVERSAL_TIMEOUT_MS = 5_000 +const installedHistories = new WeakMap boolean>() + +export function installMobileWebBackNavigationAdapter( + backHandler: MobileWebBackHandlerTarget = BackHandler, + target: MobileWebNavigationTarget = window +): boolean { + const { history } = target + if (installedHistories.has(history)) { + return false + } + + const handlers: MobileWebBackHandlerCallback[] = [] + const originalPushState = history.pushState.bind(history) + const originalReplaceState = history.replaceState.bind(history) + const originalGo = history.go.bind(history) + const originalBack = history.back.bind(history) + const originalForward = history.forward.bind(history) + let currentIndex = historyIndex(history.state) ?? 0 + let maximumIndex = currentIndex + let expectedProgrammaticIndex: number | null = null + let programmaticReset: ReturnType | undefined + let restoringFromIndex: number | null = null + + originalReplaceState(indexedHistoryState(history.state, currentIndex), '', undefined) + + const markProgrammatic = (targetIndex: number): void => { + expectedProgrammaticIndex = targetIndex + clearTimeout(programmaticReset) + programmaticReset = setTimeout(() => { + expectedProgrammaticIndex = null + }, PROGRAMMATIC_TRAVERSAL_TIMEOUT_MS) + } + history.pushState = (data, unused, url) => { + currentIndex += 1 + maximumIndex = currentIndex + originalPushState(indexedHistoryState(data, currentIndex), unused, url) + } + history.replaceState = (data, unused, url) => { + originalReplaceState(indexedHistoryState(data, currentIndex), unused, url) + } + history.go = (delta = 0) => { + const targetIndex = Math.max(0, Math.min(maximumIndex, currentIndex + delta)) + if (targetIndex !== currentIndex) { + markProgrammatic(targetIndex) + } + originalGo(delta) + } + history.back = () => { + if (currentIndex > 0) { + markProgrammatic(currentIndex - 1) + } + originalBack() + } + history.forward = () => { + if (currentIndex < maximumIndex) { + markProgrammatic(currentIndex + 1) + } + originalForward() + } + + backHandler.addEventListener = (_eventName, handler) => { + handlers.push(handler) + let active = true + return { + remove() { + if (!active) { + return + } + active = false + removeBackHandler(handlers, handler) + } + } + } + backHandler.removeEventListener = (_eventName, handler) => { + removeBackHandler(handlers, handler) + } + + target.addEventListener('popstate', (event) => { + const nextIndex = historyIndex(event.state) + if (nextIndex === null) { + return + } + if (restoringFromIndex !== null) { + event.stopImmediatePropagation() + restoringFromIndex = null + currentIndex = nextIndex + if (!dispatchBackHandlers(handlers)) { + history.back() + } + return + } + + const direction = Math.sign(nextIndex - currentIndex) + if (nextIndex === expectedProgrammaticIndex) { + expectedProgrammaticIndex = null + clearTimeout(programmaticReset) + currentIndex = nextIndex + return + } + if (direction >= 0 || handlers.length === 0) { + currentIndex = nextIndex + return + } + + event.stopImmediatePropagation() + restoringFromIndex = nextIndex + originalGo(currentIndex - nextIndex) + }) + installedHistories.set(history, () => { + if (dispatchBackHandlers(handlers)) { + return true + } + if (currentIndex === 0) { + return false + } + history.back() + return true + }) + return true +} + +export function dispatchMobileWebBackNavigation( + target: MobileWebNavigationTarget = window +): boolean { + return installedHistories.get(target.history)?.() === true +} + +function dispatchBackHandlers(handlers: MobileWebBackHandlerCallback[]): boolean { + for (let index = handlers.length - 1; index >= 0; index -= 1) { + const handler = handlers[index] + if (!handler) { + continue + } + try { + if (handler() === true) { + return true + } + } catch { + continue + } + } + return false +} + +function removeBackHandler( + handlers: MobileWebBackHandlerCallback[], + target: MobileWebBackHandlerCallback +): void { + const index = handlers.lastIndexOf(target) + if (index !== -1) { + handlers.splice(index, 1) + } +} + +function indexedHistoryState(state: unknown, index: number): Record { + return isRecord(state) ? { ...state, [HISTORY_INDEX_KEY]: index } : { [HISTORY_INDEX_KEY]: index } +} + +function historyIndex(state: unknown): number | null { + if (!isRecord(state)) { + return null + } + const value = state[HISTORY_INDEX_KEY] + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/mobile/src/mobile-web/mobile-web-bridge-roundtrip-fixture.ts b/mobile/src/mobile-web/mobile-web-bridge-roundtrip-fixture.ts index 1aa55e9d403..68b0c3745ce 100644 --- a/mobile/src/mobile-web/mobile-web-bridge-roundtrip-fixture.ts +++ b/mobile/src/mobile-web/mobile-web-bridge-roundtrip-fixture.ts @@ -77,6 +77,7 @@ export function createMobileWebBridgeRoundtripFixture(options: { function defaultNativeAuthority(): MobileWebNativeCapabilityAuthority { return { + alert: async () => ({ kind: 'dismissed' }), hapticFeedback: () => {}, clipboardAvailability: async () => ({ hasText: false, hasImage: false }), clipboardWrite: async () => ({ confirmation: 'in-app' }), diff --git a/mobile/src/mobile-web/mobile-web-broker-message-handoff.ts b/mobile/src/mobile-web/mobile-web-broker-message-handoff.ts new file mode 100644 index 00000000000..65d05b22c1e --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-broker-message-handoff.ts @@ -0,0 +1,47 @@ +import type { RefObject } from 'react' +import type { MobileWebShellViewRef } from '@orca/expo-mobile-web-shell' +import type { MobileWebBridgePageMessage } from '../../../src/shared/mobile-web/bridge-contract' +import type { MobileWebCapabilityBroker } from './mobile-web-capability-broker' +import { + completeMobileWebNativeRouteHandoffAfterResponse, + type MobileWebNativeRouteHandoff +} from './mobile-web-native-route-handoff' + +export async function handleMobileWebBrokerMessage(args: { + message: MobileWebBridgePageMessage + brokerRef: RefObject + activeSessionIdRef: RefObject + sessionId: string + viewRef: RefObject + routeHandoff: MobileWebNativeRouteHandoff + setHostedViewActive: (active: boolean) => void + navigateToTerminalSettings: () => void + onNavigationFailure: () => void +}): Promise { + const broker = args.brokerRef.current + await broker?.handle(args.message) + if ( + args.message.type !== 'request' || + !broker || + args.brokerRef.current !== broker || + args.activeSessionIdRef.current !== args.sessionId + ) { + return + } + completeMobileWebNativeRouteHandoffAfterResponse({ + handoff: args.routeHandoff, + requestId: args.message.requestId, + shouldNavigate: () => + args.brokerRef.current === broker && args.activeSessionIdRef.current === args.sessionId, + deactivateSessionView: async () => { + const view = args.viewRef.current + if (!view) { + throw new Error('mobile_web_session_view_unavailable') + } + await view.deactivateSessionView() + }, + setHostedViewActive: args.setHostedViewActive, + navigate: args.navigateToTerminalSettings, + onFailure: args.onNavigationFailure + }) +} diff --git a/mobile/src/mobile-web/mobile-web-cached-build-probe.ts b/mobile/src/mobile-web/mobile-web-cached-build-probe.ts new file mode 100644 index 00000000000..90bb7beec9a --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-cached-build-probe.ts @@ -0,0 +1,13 @@ +export type MobileWebCachedBuildProbe = { + hostEpoch: number + promise: Promise + resolve: (buildId: string | null) => void +} + +export function createMobileWebCachedBuildProbe(hostEpoch: number): MobileWebCachedBuildProbe { + let resolvePromise: (buildId: string | null) => void = () => {} + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + return { hostEpoch, promise, resolve: resolvePromise } +} diff --git a/mobile/src/mobile-web/mobile-web-capability-authorities.ts b/mobile/src/mobile-web/mobile-web-capability-authorities.ts index d708cc3b126..ffffa65f772 100644 --- a/mobile/src/mobile-web/mobile-web-capability-authorities.ts +++ b/mobile/src/mobile-web/mobile-web-capability-authorities.ts @@ -3,6 +3,7 @@ import { MobileWebAgentHistoryAuthority } from './mobile-web-agent-history-autho import { MobileWebAgentHistoryPager } from './mobile-web-agent-history-pager' import { MobileWebAgentHistoryResume } from './mobile-web-agent-history-resume' import { MobileWebNativeChatAuthority } from './mobile-web-native-chat-authority' +import { MobileWebSourceControlBranchComparePager } from './mobile-web-source-control-branch-compare-pager' import { MobileWebTerminalArtifactAuthority } from './mobile-web-terminal-artifact-authority' import { MobileWebTaskTargetAuthority } from './mobile-web-task-target-authority' import { MobileWebTaskProjectTablePager } from './mobile-web-task-project-table-pager' @@ -15,6 +16,7 @@ export class MobileWebCapabilityAuthorities { readonly agentHistoryResume: MobileWebAgentHistoryResume readonly browser: MobileWebBrowserAuthority readonly nativeChat: MobileWebNativeChatAuthority + readonly sourceControlBranchCompare: MobileWebSourceControlBranchComparePager readonly terminalArtifact: MobileWebTerminalArtifactAuthority readonly taskTarget: MobileWebTaskTargetAuthority readonly taskProjectTable: MobileWebTaskProjectTablePager @@ -27,6 +29,7 @@ export class MobileWebCapabilityAuthorities { this.agentHistoryResume = new MobileWebAgentHistoryResume(options.randomBytes) this.browser = new MobileWebBrowserAuthority(options.randomBytes) this.nativeChat = new MobileWebNativeChatAuthority(options.randomBytes) + this.sourceControlBranchCompare = new MobileWebSourceControlBranchComparePager() this.terminalArtifact = new MobileWebTerminalArtifactAuthority(options) this.taskTarget = new MobileWebTaskTargetAuthority(options.randomBytes) this.taskProjectTable = new MobileWebTaskProjectTablePager(options.randomBytes) @@ -40,6 +43,7 @@ export class MobileWebCapabilityAuthorities { this.agentHistoryResume.clear() this.browser.clear() this.nativeChat.clear() + this.sourceControlBranchCompare.clear() this.terminalArtifact.clear() this.taskTarget.clear() this.taskProjectTable.clear() diff --git a/mobile/src/mobile-web/mobile-web-capability-broker-branch-compare.test.ts b/mobile/src/mobile-web/mobile-web-capability-broker-branch-compare.test.ts new file mode 100644 index 00000000000..2833413fa8a --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-capability-broker-branch-compare.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest' +import { + MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type MobileWebBridgePageMessage, + type MobileWebBridgeShellMessage +} from '../../../src/shared/mobile-web/bridge-contract' +import type { RpcClient } from '../transport/rpc-client' +import { MobileWebCapabilityBroker } from './mobile-web-capability-broker' + +const CONTEXT = { shellSessionId: 'S'.repeat(43), buildId: 'a'.repeat(64) } +const WORKSPACE_ID = `workspace_0_${'01'.repeat(16)}` + +describe('mobile web branch-compare rate accounting', () => { + it('exempts only the exact next page after a charged snapshot request', async () => { + const harness = createHarness() + await harness.broker.handle(workspaceSnapshotRequest()) + + for (let index = 0; index < 8; index += 1) { + await harness.broker.handle(branchCompareRequest(String(index).repeat(22), firstPayload())) + } + const firstPage = successPayload(harness.messages.at(-1)) + const continuation = continuationPayload(firstPage) + + await harness.broker.handle(branchCompareRequest('C'.repeat(22), continuation)) + expect(harness.messages.at(-1)).toMatchObject({ + status: 'success', + payload: { offset: 128, nextOffset: null } + }) + expect(gitCompareCalls(harness.sendRequest)).toHaveLength(8) + + await harness.broker.handle(branchCompareRequest('R'.repeat(22), continuation)) + expect(harness.messages.at(-1)).toMatchObject({ + status: 'error', + error: { code: 'rate_limited', retryable: true } + }) + expect(gitCompareCalls(harness.sendRequest)).toHaveLength(8) + }) +}) + +function createHarness() { + const messages: MobileWebBridgeShellMessage[] = [] + const sendRequest = vi.fn(async (method) => { + if (method === 'worktree.ps') { + return { + ok: true, + result: { worktrees: [{ worktreeId: 'workspace-1', repoId: 'repo-1' }] } + } + } + if (method === 'git.branchCompare') { + return { ok: true, result: compareResult() } + } + return { ok: false, error: { code: 'method_not_found', message: 'unsupported' } } + }) + const client = { sendRequest, subscribe: vi.fn() } as unknown as RpcClient + const broker = new MobileWebCapabilityBroker({ + context: CONTEXT, + getClient: () => client, + isConnected: () => true, + isActive: () => true, + postMessage: (message) => messages.push(message), + nativeAuthority: { + hapticFeedback: vi.fn(), + clipboardWrite: vi.fn(), + openExternal: vi.fn(), + terminalPreferences: vi.fn(), + terminalTextScaleUpdate: vi.fn() + }, + terminalClientId: 'device-token', + randomBytes: (length) => new Uint8Array(length).fill(1), + now: () => 1_000 + }) + return { broker, messages, sendRequest } +} + +function workspaceSnapshotRequest(): Extract { + return request('P'.repeat(22), 'workspace', 'snapshot', { limit: 1 }) +} + +function branchCompareRequest( + requestId: string, + payload: Record +): Extract { + return request(requestId, 'sourceControl', 'branchCompare', payload) +} + +function request( + requestId: string, + capability: string, + operation: string, + payload: unknown +): Extract { + return { + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'request', + mode: 'once', + ...CONTEXT, + requestId, + capability, + operation, + payload + } +} + +function firstPayload() { + return { workspaceId: WORKSPACE_ID, baseRef: 'main', offset: 0, limit: 128 } +} + +function continuationPayload(value: unknown): Record { + if (!isRecord(value) || typeof value.revision !== 'string' || value.nextOffset !== 128) { + throw new Error('Expected a branch-compare continuation') + } + return { ...firstPayload(), offset: value.nextOffset, expectedRevision: value.revision } +} + +function compareResult() { + return { + summary: { + baseOid: 'a'.repeat(40), + compareRef: 'feature/mobile', + headOid: 'b'.repeat(40), + mergeBase: 'a'.repeat(40), + changedFiles: 129, + status: 'ready' + }, + entries: Array.from({ length: 129 }, (_, index) => ({ + path: `src/file-${index}.ts`, + status: 'modified' + })) + } +} + +function successPayload(message: MobileWebBridgeShellMessage | undefined): unknown { + if (message?.type !== 'response' || message.status !== 'success') { + throw new Error(`Expected a successful response: ${JSON.stringify(message)}`) + } + return message.payload +} + +function gitCompareCalls(sendRequest: ReturnType>) { + return sendRequest.mock.calls.filter(([method]) => method === 'git.branchCompare') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/mobile/src/mobile-web/mobile-web-capability-broker-race.test.ts b/mobile/src/mobile-web/mobile-web-capability-broker-race.test.ts index 4b3f4d0b617..4444693192f 100644 --- a/mobile/src/mobile-web/mobile-web-capability-broker-race.test.ts +++ b/mobile/src/mobile-web/mobile-web-capability-broker-race.test.ts @@ -6,6 +6,7 @@ import { } from '../../../src/shared/mobile-web/bridge-contract' import type { RpcClient } from '../transport/rpc-client' import { MobileWebCapabilityBroker } from './mobile-web-capability-broker' +import type { MobileWebNativeCapabilityAuthority } from './mobile-web-native-capability-authority' const CONTEXT = { shellSessionId: 'S'.repeat(43), @@ -76,9 +77,55 @@ describe('mobile web capability broker races', () => { error: { code: 'invalid_request', retryable: false } }) }) + + it('keeps a shell-owned native alert alive across Desktop client replacement', async () => { + const alertResult = deferredAlertResult() + const alert = vi.fn>() + alert.mockReturnValue(alertResult.promise) + const harness = await createPrimedHarness(alert) + + const pending = harness.broker.handle(nativeAlertRequest()) + await vi.waitFor(() => expect(alert).toHaveBeenCalledOnce()) + harness.broker.replaceClient(null) + alertResult.resolve({ kind: 'button', buttonIndex: 1 }) + await pending + + expect(responseFor(harness.messages, 'A')).toEqual([ + expect.objectContaining({ + status: 'success', + payload: { kind: 'button', buttonIndex: 1 } + }) + ]) + }) + + it('keeps an OS alert authoritative after page cancellation', async () => { + const alertResult = deferredAlertResult() + const alert = vi.fn>() + alert.mockReturnValue(alertResult.promise) + const harness = await createPrimedHarness(alert) + + const pending = harness.broker.handle(nativeAlertRequest()) + await vi.waitFor(() => expect(alert).toHaveBeenCalledOnce()) + await harness.broker.handle(requestCancel('A')) + await harness.broker.handle(nativeAlertRequest('B')) + + expect(alert).toHaveBeenCalledOnce() + expect(responseFor(harness.messages, 'B')).toEqual([ + expect.objectContaining({ + status: 'error', + error: { code: 'rate_limited', retryable: true } + }) + ]) + + alertResult.resolve({ kind: 'button', buttonIndex: 0 }) + await pending + expect(responseFor(harness.messages, 'A')).toEqual([ + expect.objectContaining({ status: 'success' }) + ]) + }) }) -async function createPrimedHarness() { +async function createPrimedHarness(alert?: MobileWebNativeCapabilityAuthority['alert']) { const messages: MobileWebBridgeShellMessage[] = [] const sendRequest = vi.fn() const subscribe = vi.fn() @@ -94,6 +141,7 @@ async function createPrimedHarness() { isActive: () => true, postMessage: (message) => messages.push(message), nativeAuthority: { + alert, hapticFeedback: vi.fn(), clipboardWrite: vi.fn(), openExternal: vi.fn(), @@ -111,6 +159,34 @@ async function createPrimedHarness() { return { broker, messages, sendRequest, subscribe } } +function nativeAlertRequest(id = 'A'): Extract { + return { + ...envelope(), + type: 'request', + mode: 'once', + requestId: id.repeat(22), + capability: 'native', + operation: 'alert', + payload: { + title: 'Discard changes?', + message: 'Unsaved edits will be lost.', + buttons: [ + { text: 'Stay', style: 'cancel' }, + { text: 'Discard', style: 'destructive' } + ] + } + } +} + +function requestCancel(id: string): Extract { + return { + ...envelope(), + type: 'cancel', + target: 'request', + id: id.repeat(22) + } +} + function workspaceSnapshotRequest(): Extract { return { ...envelope(), @@ -167,6 +243,14 @@ function deferredHostResult() { return { promise, resolve } } +function deferredAlertResult() { + let resolve = (_value: { kind: 'button'; buttonIndex: number }): void => {} + const promise = new Promise<{ kind: 'button'; buttonIndex: number }>((done) => { + resolve = done + }) + return { promise, resolve } +} + function terminalTabsResult() { return { ok: true as const, diff --git a/mobile/src/mobile-web/mobile-web-capability-broker.ts b/mobile/src/mobile-web/mobile-web-capability-broker.ts index 900e8f1c7be..d32b3cdf418 100644 --- a/mobile/src/mobile-web/mobile-web-capability-broker.ts +++ b/mobile/src/mobile-web/mobile-web-capability-broker.ts @@ -35,7 +35,6 @@ import { type PageRequest = Extract type PendingRequest = { operationKey: string; subscriptionId?: string; cancelled: boolean } export { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' - export class MobileWebCapabilityBroker { private readonly pending = new Map() private readonly replay = new MobileWebBrokerReplayGuard() @@ -108,6 +107,9 @@ export class MobileWebCapabilityBroker { this.terminalStreams.dispose(null) this.speechAuthority.replaceClient() for (const [requestId, pending] of this.pending) { + if (pending.operationKey === 'native.alert') { + continue + } pending.cancelled = true this.pending.delete(requestId) void this.messages.error(requestId, 'cancelled', false) @@ -179,9 +181,12 @@ export class MobileWebCapabilityBroker { await this.messages.error(request.requestId, 'rate_limited', true) return } + const branchCompareContinuation = + this.authorities.sourceControlBranchCompare.claimRequestContinuation(request) if ( !mobileWebWorkspaceSnapshotContinuation(request) && !mobileWebAgentHistoryContinuation(request) && + !branchCompareContinuation && !this.rateLimiter.take(mobileWebOperationKey(request), grant) ) { await this.messages.error(request.requestId, 'rate_limited', true) @@ -234,6 +239,7 @@ export class MobileWebCapabilityBroker { nativeChatSubscriptions: this.subscriptions.nativeChat, sessionSubscriptions: this.subscriptions.session, sourceControlSubscriptions: this.subscriptions.sourceControl, + sourceControlBranchCompare: this.authorities.sourceControlBranchCompare, speechAuthority: this.speechAuthority, postSpeechEvent: (subscriptionId, sequence, event) => this.messages.event(subscriptionId, sequence, event), @@ -282,6 +288,9 @@ export class MobileWebCapabilityBroker { if (!pending) { return } + if (pending.operationKey === 'native.alert') { + return + } pending.cancelled = true this.pending.delete(id) await this.commitMessageGeneration.cancelByRequest(id) diff --git a/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts b/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts index 5a71eba69b0..5cc09de985b 100644 --- a/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts +++ b/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts @@ -14,6 +14,7 @@ import type { MobileWebNativeChatAuthority } from './mobile-web-native-chat-auth import type { MobileWebNativeChatSubscriptions } from './mobile-web-native-chat-subscriptions' import type { MobileWebSessionSubscriptions } from './mobile-web-session-subscriptions' import type { MobileWebSourceControlSubscriptions } from './mobile-web-source-control-subscriptions' +import type { MobileWebSourceControlBranchComparePager } from './mobile-web-source-control-branch-compare-pager' import type { MobileWebSpeechAuthority } from './mobile-web-speech-authority' import type { MobileWebTaskTargetAuthority } from './mobile-web-task-target-authority' import type { MobileWebTaskProjectTablePager } from './mobile-web-task-project-table-pager' @@ -39,6 +40,7 @@ export type MobileWebCapabilityExecutionDependencies = { nativeChatSubscriptions: MobileWebNativeChatSubscriptions sessionSubscriptions: MobileWebSessionSubscriptions sourceControlSubscriptions: MobileWebSourceControlSubscriptions + sourceControlBranchCompare: MobileWebSourceControlBranchComparePager speechAuthority: MobileWebSpeechAuthority postSpeechEvent: ( subscriptionId: string, diff --git a/mobile/src/mobile-web/mobile-web-capability-execution.ts b/mobile/src/mobile-web/mobile-web-capability-execution.ts index 319977b2683..cc5aa0f3dc9 100644 --- a/mobile/src/mobile-web/mobile-web-capability-execution.ts +++ b/mobile/src/mobile-web/mobile-web-capability-execution.ts @@ -230,6 +230,7 @@ export async function executeMobileWebCapabilityRequest( payload: request.payload, client: args.connectedClient(), workspaceAuthority: args.workspaceAuthority, + branchComparePager: args.sourceControlBranchCompare, terminalClientId: args.terminalClientId }) } diff --git a/mobile/src/mobile-web/mobile-web-file-open-operation.test.ts b/mobile/src/mobile-web/mobile-web-file-open-operation.test.ts new file mode 100644 index 00000000000..59b7883bf39 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-file-open-operation.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { executeMobileWebFileOpenOperation } from './mobile-web-file-open-operation' + +describe('mobile web file open operation', () => { + it('activates an opened edit tab beyond the mobile snapshot limit', async () => { + const tabs = [ + { id: 'terminal', type: 'terminal', isActive: true }, + ...Array.from({ length: 199 }, (_, index) => ({ + id: `filler-${index}`, + type: 'file', + mode: 'edit', + relativePath: `src/filler-${index}.ts` + })), + { + id: 'target-edit', + type: 'file', + mode: 'edit', + relativePath: 'src/target.ts' + } + ] + const client = { + sendRequest: vi.fn(async (method: string) => { + if (method === 'session.tabs.list') { + return { ok: true, result: { tabs } } + } + if (method === 'session.tabs.activate') { + return { ok: true, result: { activeTabId: 'target-edit' } } + } + return { ok: true, result: null } + }) + } as unknown as RpcClient + + await expect( + executeMobileWebFileOpenOperation({ + client, + hostWorkspaceId: 'workspace-1', + relativePath: 'src/target.ts', + assertCurrent: () => {} + }) + ).resolves.toBeNull() + + expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'files.open', { + worktree: 'id:workspace-1', + relativePath: 'src/target.ts' + }) + expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'session.tabs.activate', { + worktree: 'id:workspace-1', + tabId: 'target-edit', + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + }) + + it('does not activate after workspace authority changes', async () => { + const client = { + sendRequest: vi.fn(async (method: string) => { + if (method === 'session.tabs.list') { + return { + ok: true, + result: { + tabs: [ + { + id: 'target-edit', + type: 'file', + mode: 'edit', + relativePath: 'src/target.ts' + } + ] + } + } + } + return { ok: true, result: null } + }) + } as unknown as RpcClient + let authorityChecks = 0 + + await expect( + executeMobileWebFileOpenOperation({ + client, + hostWorkspaceId: 'folder-workspace', + relativePath: 'src/target.ts', + assertCurrent: () => { + authorityChecks += 1 + if (authorityChecks === 2) { + throw new Error('workspace_binding_stale') + } + } + }) + ).rejects.toThrow('workspace_binding_stale') + + expect(client.sendRequest).not.toHaveBeenCalledWith('session.tabs.activate', expect.anything()) + }) +}) diff --git a/mobile/src/mobile-web/mobile-web-file-open-operation.ts b/mobile/src/mobile-web/mobile-web-file-open-operation.ts new file mode 100644 index 00000000000..e6a3ad12927 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-file-open-operation.ts @@ -0,0 +1,30 @@ +import type { RpcClient } from '../transport/rpc-client' +import { activateMobileSessionFileTab } from '../session/mobile-session-file-tab-activation' +import { MobileWebBrokerError } from './mobile-web-broker-error' + +export async function executeMobileWebFileOpenOperation(args: { + client: RpcClient + hostWorkspaceId: string + relativePath: string + assertCurrent: () => void +}): Promise { + const response = await args.client.sendRequest('files.open', { + worktree: `id:${args.hostWorkspaceId}`, + relativePath: args.relativePath + }) + if (!response.ok) { + throw new MobileWebBrokerError('host_error') + } + await activateMobileSessionFileTab({ + client: args.client, + worktreeId: args.hostWorkspaceId, + relativePath: args.relativePath, + tabMode: 'edit', + staged: false, + isCurrent: () => { + args.assertCurrent() + return true + } + }) + return null +} diff --git a/mobile/src/mobile-web/mobile-web-file-operations.ts b/mobile/src/mobile-web/mobile-web-file-operations.ts index 157b18e4311..946ed8fd15a 100644 --- a/mobile/src/mobile-web/mobile-web-file-operations.ts +++ b/mobile/src/mobile-web/mobile-web-file-operations.ts @@ -24,6 +24,7 @@ import { import type { MobileWebFileWriteResult } from '../../../src/shared/mobile-web/file-edit-contract' import type { RpcClient } from '../transport/rpc-client' import { MobileWebBrokerError } from './mobile-web-broker-error' +import { executeMobileWebFileOpenOperation } from './mobile-web-file-open-operation' import { executeMobileWebFileWrite } from './mobile-web-file-write' import { compareMobileWebDirectoryEntries, @@ -118,14 +119,13 @@ export async function executeMobileWebFileOperation(args: { if (args.operation === 'open') { const payload = MobileWebFileOpenPayloadSchema.parse(args.payload) const hostWorkspaceId = args.workspaceAuthority.hostWorkspaceId(payload.workspaceId) - const response = await args.client.sendRequest('files.open', { - worktree: `id:${hostWorkspaceId}`, - relativePath: payload.relativePath + return executeMobileWebFileOpenOperation({ + client: args.client, + hostWorkspaceId, + relativePath: payload.relativePath, + assertCurrent: () => + args.workspaceAuthority.assertHostWorkspaceBinding(payload.workspaceId, hostWorkspaceId) }) - if (!response.ok) { - throw new MobileWebBrokerError('host_error') - } - return null } throw new MobileWebBrokerError('unsupported_capability') } diff --git a/mobile/src/mobile-web/mobile-web-hardware-back-handoff.ts b/mobile/src/mobile-web/mobile-web-hardware-back-handoff.ts new file mode 100644 index 00000000000..eb005e765b6 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-hardware-back-handoff.ts @@ -0,0 +1,150 @@ +import { + MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type MobileWebBridgeMessageContext, + type MobileWebBridgePageMessage, + type MobileWebBridgeShellMessage +} from '../../../src/shared/mobile-web/bridge-contract' + +const HARDWARE_BACK_RESULT_TIMEOUT_MS = 750 + +type CapabilityMessage = Extract +type ResultMessage = Extract +type RequestMessage = Extract + +type PendingBack = { + timeout: ReturnType + onUnhandled: () => void +} + +export class MobileWebHardwareBackHandoff { + private context: MobileWebBridgeMessageContext | null = null + private pageReady = false + private supported = false + private sequence = 0 + private readonly pending = new Map() + + constructor(private readonly timeoutMs = HARDWARE_BACK_RESULT_TIMEOUT_MS) {} + + setContext(context: MobileWebBridgeMessageContext | null): void { + if (sameContext(this.context, context)) { + return + } + this.clearPending() + this.context = context + this.pageReady = false + this.supported = false + } + + resetPage(): void { + this.clearPending() + this.pageReady = false + this.supported = false + } + + acknowledgeReady(context: MobileWebBridgeMessageContext): void { + if (!sameContext(this.context, context)) { + return + } + this.clearPending() + this.pageReady = true + this.supported = false + } + + declareSupport(message: CapabilityMessage): void { + if (this.pageReady && sameContext(this.context, message)) { + this.supported = true + } + } + + request( + postMessage: (message: RequestMessage) => Promise, + onUnhandled: () => void + ): boolean { + if (!this.context || !this.pageReady || !this.supported) { + return false + } + this.sequence = this.sequence === Number.MAX_SAFE_INTEGER ? 0 : this.sequence + 1 + const sequence = this.sequence + const message: RequestMessage = { + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + ...this.context, + type: 'hardwareBack', + sequence + } + this.pending.set(sequence, { + timeout: setTimeout(() => this.complete(sequence, false), this.timeoutMs), + onUnhandled + }) + try { + void postMessage(message).catch(() => this.complete(sequence, false)) + } catch { + this.complete(sequence, false) + } + return true + } + + resolve(message: ResultMessage): boolean { + if (!sameContext(this.context, message) || !this.pending.has(message.sequence)) { + return false + } + this.complete(message.sequence, message.handled) + return true + } + + handlePageMessage(message: MobileWebBridgePageMessage): boolean { + if (message.type === 'ready') { + this.acknowledgeReady(message) + return false + } + if (message.type === 'hardwareBackCapability') { + this.declareSupport(message) + return true + } + if (message.type === 'hardwareBackResult') { + this.resolve(message) + return true + } + return false + } + + cancelPending(): void { + this.clearPending() + } + + clear(): void { + this.clearPending() + this.context = null + this.pageReady = false + this.supported = false + } + + private complete(sequence: number, handled: boolean): void { + const pending = this.pending.get(sequence) + if (!pending) { + return + } + clearTimeout(pending.timeout) + this.pending.delete(sequence) + if (!handled) { + pending.onUnhandled() + } + } + + private clearPending(): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout) + } + this.pending.clear() + } +} + +function sameContext( + left: MobileWebBridgeMessageContext | null, + right: MobileWebBridgeMessageContext | null +): boolean { + return ( + left?.shellSessionId === right?.shellSessionId && + left?.buildId === right?.buildId && + Boolean(left) === Boolean(right) + ) +} diff --git a/mobile/src/mobile-web/mobile-web-native-alert.test.ts b/mobile/src/mobile-web/mobile-web-native-alert.test.ts new file mode 100644 index 00000000000..5aa6da6ca95 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-native-alert.test.ts @@ -0,0 +1,60 @@ +import type { AlertButton, AlertOptions } from 'react-native' +import { describe, expect, it, vi } from 'vitest' +import { presentMobileWebNativeAlert } from './mobile-web-native-alert' + +vi.mock('react-native', () => ({ Alert: { alert: vi.fn() } })) + +describe('mobile web native alert', () => { + it('preserves native button/options presentation and correlates the selected index', async () => { + let buttons: AlertButton[] = [] + let options: AlertOptions | undefined + const target = { + alert: vi.fn((_title, _message, nextButtons, nextOptions) => { + buttons = nextButtons ?? [] + options = nextOptions + }) + } + const result = presentMobileWebNativeAlert( + { + title: 'Discard changes?', + message: 'Unsaved edits will be lost.', + buttons: [ + { text: 'Stay', style: 'cancel' }, + { text: 'Discard', style: 'destructive', isPreferred: true } + ], + options: { cancelable: false, userInterfaceStyle: 'dark' } + }, + target + ) + + expect(target.alert).toHaveBeenCalledWith( + 'Discard changes?', + 'Unsaved edits will be lost.', + expect.any(Array), + expect.objectContaining({ cancelable: false, userInterfaceStyle: 'dark' }) + ) + buttons[1]?.onPress?.() + options?.onDismiss?.() + await expect(result).resolves.toEqual({ kind: 'button', buttonIndex: 1 }) + }) + + it('reports an outside/back dismissal when the native platform allows it', async () => { + let options: AlertOptions | undefined + const target = { + alert: vi.fn((_title, _message, _buttons, nextOptions) => { + options = nextOptions + }) + } + const result = presentMobileWebNativeAlert( + { + title: 'Notice', + buttons: [{ text: 'OK' }], + options: { cancelable: true } + }, + target + ) + + options?.onDismiss?.() + await expect(result).resolves.toEqual({ kind: 'dismissed' }) + }) +}) diff --git a/mobile/src/mobile-web/mobile-web-native-alert.ts b/mobile/src/mobile-web/mobile-web-native-alert.ts new file mode 100644 index 00000000000..a617352caa3 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-native-alert.ts @@ -0,0 +1,34 @@ +import { Alert } from 'react-native' +import type { + MobileWebNativeAlertPayload, + MobileWebNativeAlertResult +} from '../../../src/shared/mobile-web/native-operation-contract' + +type MobileWebNativeAlertTarget = Pick + +export function presentMobileWebNativeAlert( + payload: MobileWebNativeAlertPayload, + target: MobileWebNativeAlertTarget = Alert +): Promise { + return new Promise((resolve) => { + let settled = false + const settle = (result: MobileWebNativeAlertResult): void => { + if (!settled) { + settled = true + resolve(result) + } + } + target.alert( + payload.title, + payload.message, + payload.buttons.map((button, buttonIndex) => ({ + ...button, + onPress: () => settle({ kind: 'button', buttonIndex }) + })), + { + ...payload.options, + onDismiss: () => settle({ kind: 'dismissed' }) + } + ) + }) +} diff --git a/mobile/src/mobile-web/mobile-web-native-authority-module-source.test.ts b/mobile/src/mobile-web/mobile-web-native-authority-module-source.test.ts index 746fb343ec6..d628e6b2397 100644 --- a/mobile/src/mobile-web/mobile-web-native-authority-module-source.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-authority-module-source.test.ts @@ -1,5 +1,8 @@ import { readFileSync } from 'node:fs' +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { describe, expect, it } from 'vitest' +import { RpcClientProvider, useRpcClientContext } from './disabled-page-client-context' import { loadHosts, removeHost, @@ -33,6 +36,24 @@ describe('hosted mobile web native authority modules', () => { ) }) + it('implements the complete inert connection context used by shared screens', async () => { + let context: ReturnType | null = null + function Probe() { + context = useRpcClientContext() + return null + } + let renderer: ReactTestRenderer | null = null + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) + + expect(context?.getKnownState('host')).toBeNull() + expect(context?.getPendingPath('host')).toBeNull() + expect(context?.isPairingRejected('host')).toBe(false) + + await act(async () => renderer?.unmount()) + }) + it('returns no page-owned hosts and fails closed for credential mutations', async () => { await expect(loadHosts()).resolves.toEqual([]) await expect(removeHost('host-id')).rejects.toThrow('Native host storage unavailable') diff --git a/mobile/src/mobile-web/mobile-web-native-behavior-adapter.tsx b/mobile/src/mobile-web/mobile-web-native-behavior-adapter.tsx new file mode 100644 index 00000000000..2998c51f1b1 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-native-behavior-adapter.tsx @@ -0,0 +1,29 @@ +import { Alert, BackHandler } from 'react-native' +import { useMobileWebNativeShell } from '../../../src/mobile-web/src/native-shell-channel' +import { installMobileWebHardwareBackHandler } from '../../../src/mobile-web/src/mobile-web-hardware-back-handler' +import { MobileWebAlertAdapter, installMobileWebAlertAdapter } from './mobile-web-alert-adapter' +import { + dispatchMobileWebBackNavigation, + installMobileWebBackNavigationAdapter +} from './mobile-web-back-navigation-adapter' + +let hardwareBackHandlerInstalled = false + +export function installMobileWebNativeBehaviorAdapters(): void { + installMobileWebAlertAdapter(Alert) + installMobileWebBackNavigationAdapter(BackHandler, window) + if (!hardwareBackHandlerInstalled) { + installMobileWebHardwareBackHandler(() => dispatchMobileWebBackNavigation(window)) + hardwareBackHandlerInstalled = true + } +} + +export function MobileWebNativeBehaviorAdapter() { + const shell = useMobileWebNativeShell() + const client = shell.client + return ( + client.native.alert(payload) : undefined} + /> + ) +} diff --git a/mobile/src/mobile-web/mobile-web-native-capability-authority.ts b/mobile/src/mobile-web/mobile-web-native-capability-authority.ts index 3342170a433..9725cf4e8ab 100644 --- a/mobile/src/mobile-web/mobile-web-native-capability-authority.ts +++ b/mobile/src/mobile-web/mobile-web-native-capability-authority.ts @@ -7,6 +7,8 @@ import { type MobileWebClipboardAvailability, type MobileWebClipboardWriteResult, type MobileWebHapticKind, + type MobileWebNativeAlertPayload, + type MobileWebNativeAlertResult, type MobileWebTerminalAccessoryPreferences, type MobileWebTerminalCustomKey, type MobileWebTerminalPreferences, @@ -47,6 +49,7 @@ import { } from '../components/codex-reset-credit' import { readCodexResetCreditCapability } from '../components/codex-reset-credit-capability' import type { RpcClient } from '../transport/rpc-client' +import { presentMobileWebNativeAlert } from './mobile-web-native-alert' type MobileWebNativeDraftScope = { hostIdentity: string @@ -54,6 +57,7 @@ type MobileWebNativeDraftScope = { } export type MobileWebNativeCapabilityAuthority = { + alert?: (payload: MobileWebNativeAlertPayload) => Promise hapticFeedback: (kind: MobileWebHapticKind) => void clipboardAvailability: () => Promise clipboardWrite: (text: string) => Promise @@ -97,6 +101,7 @@ export function createMobileWebNativeCapabilityAuthority( draftScope: MobileWebNativeDraftScope ): MobileWebNativeCapabilityAuthority { return { + alert: presentMobileWebNativeAlert, hapticFeedback(kind) { if (kind === 'selection') { triggerSelection() diff --git a/mobile/src/mobile-web/mobile-web-native-capability-operations.ts b/mobile/src/mobile-web/mobile-web-native-capability-operations.ts index dbdb340c23d..79a81631436 100644 --- a/mobile/src/mobile-web/mobile-web-native-capability-operations.ts +++ b/mobile/src/mobile-web/mobile-web-native-capability-operations.ts @@ -5,6 +5,8 @@ import { MobileWebClipboardWriteResultSchema, MobileWebHapticFeedbackPayloadSchema, MobileWebHapticSelectionPayloadSchema, + MobileWebNativeAlertPayloadSchema, + MobileWebNativeAlertResultSchema, MobileWebOpenExternalPayloadSchema, MobileWebSessionChatDraftReadPayloadSchema, MobileWebSessionChatDraftReadResultSchema, @@ -29,6 +31,13 @@ export async function executeMobileWebNativeCapabilityOperation(args: { workspaceAuthority?: MobileWebWorkspaceAuthority consumeRecentUserGesture: () => boolean }): Promise { + if (args.operation === 'alert') { + const payload = MobileWebNativeAlertPayloadSchema.parse(args.payload) + if (!args.authority.alert) { + throw new MobileWebBrokerError('unavailable') + } + return MobileWebNativeAlertResultSchema.parse(await args.authority.alert(payload)) + } if (args.operation === 'hapticSelection') { MobileWebHapticSelectionPayloadSchema.parse(args.payload) args.authority.hapticFeedback('selection') diff --git a/mobile/src/mobile-web/mobile-web-native-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-native-roundtrip.test.ts index 647a608e3a0..150f85c579e 100644 --- a/mobile/src/mobile-web/mobile-web-native-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-roundtrip.test.ts @@ -4,6 +4,7 @@ import { MOBILE_WEB_PRODUCTION_NATIVE_GRANTS } from './mobile-web-production-nat describe('mobile web native capability round trip', () => { it('keeps device effects in the shell behind typed grants and gestures', async () => { + const alert = vi.fn().mockResolvedValue({ kind: 'button' as const, buttonIndex: 1 }) const hapticFeedback = vi.fn() const clipboardWrite = vi.fn().mockResolvedValue({ confirmation: 'in-app' as const }) const openExternal = vi.fn().mockResolvedValue(undefined) @@ -26,6 +27,7 @@ describe('mobile web native capability round trip', () => { createRequestId: () => String.fromCharCode(65 + requestIndex++).repeat(22), isConnected: () => false, nativeAuthority: { + alert, hapticFeedback, clipboardWrite, openExternal, @@ -42,6 +44,17 @@ describe('mobile web native capability round trip', () => { } }) + await expect( + client.native.alert({ + title: 'Discard changes?', + message: 'Unsaved edits will be lost.', + buttons: [ + { text: 'Stay', style: 'cancel' }, + { text: 'Discard', style: 'destructive' } + ], + options: { cancelable: false } + }) + ).resolves.toEqual({ kind: 'button', buttonIndex: 1 }) await expect(client.native.terminalPreferences()).resolves.toEqual({ textScale: 1.25, autocompleteEnabled: true, @@ -66,6 +79,15 @@ describe('mobile web native capability round trip', () => { ).resolves.toBeNull() expect(hapticFeedback).toHaveBeenCalledWith('selection') + expect(alert).toHaveBeenCalledWith({ + title: 'Discard changes?', + message: 'Unsaved edits will be lost.', + buttons: [ + { text: 'Stay', style: 'cancel' }, + { text: 'Discard', style: 'destructive' } + ], + options: { cancelable: false } + }) expect(clipboardWrite).toHaveBeenCalledWith('selected text') expect(openExternal).toHaveBeenCalledWith('https://example.com') expect(openExternal).not.toHaveBeenCalledWith('javascript:alert(1)') diff --git a/mobile/src/mobile-web/mobile-web-package-downloader.test.ts b/mobile/src/mobile-web/mobile-web-package-downloader.test.ts index 802b032348f..73270777d31 100644 --- a/mobile/src/mobile-web/mobile-web-package-downloader.test.ts +++ b/mobile/src/mobile-web/mobile-web-package-downloader.test.ts @@ -53,23 +53,55 @@ describe('mobile web package downloader', () => { expect(written.byteLength).toBe(fixture.manifest.totalBytes) }) + it('reuses an independently verified matching build before staging', async () => { + const fixture = createFixture() + const stager = createStager() + const reuseVerifiedBuild = vi.fn(async () => true) + + const result = await downloadMobileWebPackage(fixture.request, stager, { + shellBridgeVersion: 1, + reuseVerifiedBuild + }) + + expect(result).toEqual({ + manifest: fixture.manifest, + commit: null, + reusedVerifiedBuild: true + }) + expect(reuseVerifiedBuild).toHaveBeenCalledWith(fixture.manifest.buildId) + expect(fixture.request).toHaveBeenCalledTimes(1) + expect(stager.begin).not.toHaveBeenCalled() + expect(stager.writeAssetChunk).not.toHaveBeenCalled() + expect(stager.commit).not.toHaveBeenCalled() + }) + it('rejects a non-canonical manifest build identity before staging', async () => { const fixture = createFixture({ invalidBuildIdentity: true }) const stager = createStager() + const reuseVerifiedBuild = vi.fn(async () => true) await expect( - downloadMobileWebPackage(fixture.request, stager, { shellBridgeVersion: 1 }) + downloadMobileWebPackage(fixture.request, stager, { + shellBridgeVersion: 1, + reuseVerifiedBuild + }) ).rejects.toMatchObject({ code: 'invalid_manifest' }) + expect(reuseVerifiedBuild).not.toHaveBeenCalled() expect(stager.begin).not.toHaveBeenCalled() }) it('rejects a package requiring a newer native bridge before staging', async () => { const fixture = createFixture({ bridgeMinimum: 2 }) const stager = createStager() + const reuseVerifiedBuild = vi.fn(async () => true) await expect( - downloadMobileWebPackage(fixture.request, stager, { shellBridgeVersion: 1 }) + downloadMobileWebPackage(fixture.request, stager, { + shellBridgeVersion: 1, + reuseVerifiedBuild + }) ).rejects.toMatchObject({ code: 'incompatible_bridge' }) + expect(reuseVerifiedBuild).not.toHaveBeenCalled() expect(stager.begin).not.toHaveBeenCalled() }) diff --git a/mobile/src/mobile-web/mobile-web-package-downloader.ts b/mobile/src/mobile-web/mobile-web-package-downloader.ts index 1b165b0a31a..acef38a06f2 100644 --- a/mobile/src/mobile-web/mobile-web-package-downloader.ts +++ b/mobile/src/mobile-web/mobile-web-package-downloader.ts @@ -60,11 +60,43 @@ type DownloadMobileWebPackageOptions = { signal?: AbortSignal } -export async function downloadMobileWebPackage( +type DownloadMobileWebPackageWithReuseOptions = DownloadMobileWebPackageOptions & { + reuseVerifiedBuild: (buildId: string) => boolean | Promise +} + +type DownloadedMobileWebPackage = { + manifest: MobileWebManifest + commit: TCommit + reusedVerifiedBuild: false +} + +type ReusedOrDownloadedMobileWebPackage = + | DownloadedMobileWebPackage + | { + manifest: MobileWebManifest + commit: null + reusedVerifiedBuild: true + } + +export function downloadMobileWebPackage( + request: MobileWebPackageRequest, + stager: MobileWebPackageStager, + options: DownloadMobileWebPackageWithReuseOptions +): Promise> + +export function downloadMobileWebPackage( request: MobileWebPackageRequest, stager: MobileWebPackageStager, options: DownloadMobileWebPackageOptions -): Promise<{ manifest: MobileWebManifest; commit: TCommit }> { +): Promise> + +export async function downloadMobileWebPackage( + request: MobileWebPackageRequest, + stager: MobileWebPackageStager, + options: DownloadMobileWebPackageOptions & { + reuseVerifiedBuild?: (buildId: string) => boolean | Promise + } +): Promise> { throwIfAborted(options.signal) const manifestResponse = await requestResult(request, 'mobileWeb.package.manifest') throwIfAborted(options.signal) @@ -79,6 +111,11 @@ export async function downloadMobileWebPackage( if (!supportsMobileWebBridgeVersion(manifest.bridge, options.shellBridgeVersion)) { throw new MobileWebPackageDownloadError('incompatible_bridge') } + if (await options.reuseVerifiedBuild?.(manifest.buildId)) { + throwIfAborted(options.signal) + return { manifest, commit: null, reusedVerifiedBuild: true } + } + throwIfAborted(options.signal) let stagingStarted = false try { @@ -90,7 +127,7 @@ export async function downloadMobileWebPackage( throwIfAborted(options.signal) const commit = await stager.commit(manifest) stagingStarted = false - return { manifest, commit } + return { manifest, commit, reusedVerifiedBuild: false } } catch (error) { if (stagingStarted) { await stager.abort().catch(() => {}) diff --git a/mobile/src/mobile-web/mobile-web-production-grant-limits.test.ts b/mobile/src/mobile-web/mobile-web-production-grant-limits.test.ts index c914bf457a5..e59c5185f52 100644 --- a/mobile/src/mobile-web/mobile-web-production-grant-limits.test.ts +++ b/mobile/src/mobile-web/mobile-web-production-grant-limits.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + MOBILE_WEB_BRIDGE_MAX_GRANTS, MOBILE_WEB_BRIDGE_MAX_MESSAGE_BYTES, MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES, MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, @@ -22,6 +23,27 @@ describe('mobile web production grant limits', () => { ).sort() expect(granted).toEqual(registered) + expect(granted.length).toBeLessThan(MOBILE_WEB_BRIDGE_MAX_GRANTS) + }) + + it('leaves initial-message headroom for additive same-version grants', () => { + const parsed = MobileWebBridgeShellMessageSchema.safeParse({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'init', + shellSessionId: 'S'.repeat(43), + buildId: 'a'.repeat(64), + connection: 'connected', + grants: [ + ...MOBILE_WEB_PRODUCTION_GRANTS, + { + capability: 'native', + operation: 'futureOperation', + limits: MOBILE_WEB_PRODUCTION_GRANTS[0]!.limits + } + ] + }) + + expect(parsed.success, parsed.success ? undefined : parsed.error.message).toBe(true) }) it('leaves bounded envelope capacity around every operation payload', () => { diff --git a/mobile/src/mobile-web/mobile-web-production-native-grants.ts b/mobile/src/mobile-web/mobile-web-production-native-grants.ts index 2971562d6d0..26f813d02ed 100644 --- a/mobile/src/mobile-web/mobile-web-production-native-grants.ts +++ b/mobile/src/mobile-web/mobile-web-production-native-grants.ts @@ -7,6 +7,17 @@ type MobileWebNativeOperationGrant = Extract< >['grants'][number] export const MOBILE_WEB_PRODUCTION_NATIVE_GRANTS = [ + { + capability: 'native', + operation: 'alert', + limits: { + maxRequestBytes: 32 * 1024, + maxResponseBytes: 256, + maxConcurrent: 1, + rateCapacity: 4, + rateRefillPerSecond: 1 + } + }, { capability: 'native', operation: 'clipboardAvailability', diff --git a/mobile/src/mobile-web/mobile-web-queryless-history.test.ts b/mobile/src/mobile-web/mobile-web-queryless-history.test.ts new file mode 100644 index 00000000000..cfab99509d3 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-queryless-history.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { mobileWebRouteQuery } from './mobile-web-route-query-cache' +import { installMobileWebQuerylessHistory } from './mobile-web-queryless-history' + +describe('mobile web queryless history', () => { + it('keeps same-origin page state in memory and out of the URL', () => { + const target = historyTarget() + + expect(installMobileWebQuerylessHistory(target)).toBe(true) + target.history.pushState({ route: 'session' }, '', '/h/host/session/workspace?name=repo') + + expect(target.pushState).toHaveBeenCalledWith( + { route: 'session' }, + '', + 'https://orca-mobile-web.invalid/h/host/session/workspace' + ) + expect(mobileWebRouteQuery('/h/host/session/workspace')).toEqual({ name: 'repo' }) + }) + + it('clears stale state for a queryless write and leaves foreign URLs to the browser', () => { + const target = historyTarget() + installMobileWebQuerylessHistory(target) + target.history.replaceState(null, '', '/h/host/tasks?taskSource=linear') + target.history.replaceState(null, '', '/h/host/tasks') + target.history.pushState(null, '', 'https://example.test/path?secret=value') + + expect(mobileWebRouteQuery('/h/host/tasks')).toEqual({}) + expect(target.pushState).toHaveBeenLastCalledWith( + null, + '', + 'https://example.test/path?secret=value' + ) + }) + + it('does not wrap a history twice', () => { + const target = historyTarget() + expect(installMobileWebQuerylessHistory(target)).toBe(true) + expect(installMobileWebQuerylessHistory(target)).toBe(false) + }) +}) + +function historyTarget() { + const pushState = vi.fn() + const replaceState = vi.fn() + return { + pushState, + replaceState, + history: { pushState, replaceState }, + location: { + href: 'https://orca-mobile-web.invalid/#shell', + origin: 'https://orca-mobile-web.invalid' + } + } +} diff --git a/mobile/src/mobile-web/mobile-web-queryless-history.ts b/mobile/src/mobile-web/mobile-web-queryless-history.ts new file mode 100644 index 00000000000..659bf2a8a63 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-queryless-history.ts @@ -0,0 +1,57 @@ +import { rememberMobileWebRouteQuery } from './mobile-web-route-query-cache' + +type MobileWebHistoryWriter = (data: unknown, unused: string, url?: string | URL | null) => void + +type MobileWebHistoryTarget = { + history: { + pushState: MobileWebHistoryWriter + replaceState: MobileWebHistoryWriter + } + location: { + href: string + origin: string + } +} + +const installedHistories = new WeakSet() + +export function installMobileWebQuerylessHistory(target: MobileWebHistoryTarget = window): boolean { + const { history, location } = target + if (installedHistories.has(history)) { + return false + } + history.pushState = querylessHistoryWriter(history, history.pushState, location) + history.replaceState = querylessHistoryWriter(history, history.replaceState, location) + installedHistories.add(history) + return true +} + +function querylessHistoryWriter( + history: MobileWebHistoryTarget['history'], + writer: MobileWebHistoryWriter, + location: MobileWebHistoryTarget['location'] +): MobileWebHistoryWriter { + return (data, unused, url) => { + writer.call(history, data, unused, querylessHistoryUrl(url, location)) + } +} + +function querylessHistoryUrl( + value: string | URL | null | undefined, + location: MobileWebHistoryTarget['location'] +): string | URL | null | undefined { + if (value == null) { + return value + } + try { + const candidate = new URL(String(value), location.href) + if (candidate.origin !== location.origin) { + return value + } + rememberMobileWebRouteQuery(candidate.pathname, candidate.searchParams) + candidate.search = '' + return candidate.href + } catch { + return value + } +} diff --git a/mobile/src/mobile-web/mobile-web-route-error-boundary.test.ts b/mobile/src/mobile-web/mobile-web-route-error-boundary.test.ts index 12e1b94c00b..41cfed7c35d 100644 --- a/mobile/src/mobile-web/mobile-web-route-error-boundary.test.ts +++ b/mobile/src/mobile-web/mobile-web-route-error-boundary.test.ts @@ -1,7 +1,26 @@ -import { describe, expect, it } from 'vitest' +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) + +import { MobileWebRouteErrorBoundary } from '../../host-web-app/mobile-web-route-error-boundary' import { mobileWebRouteFailureCode } from './mobile-web-route-failure-code' +let renderer: ReactTestRenderer | null = null + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + describe('mobile web route failure classification', () => { it.each([ [new Error('Maximum update depth exceeded'), 'react-update-loop'], @@ -13,4 +32,63 @@ describe('mobile web route failure classification', () => { ])('classifies %s as %s', (error, code) => { expect(mobileWebRouteFailureCode(error)).toBe(code) }) + + it('announces recovery while leaving the reload action reachable', () => { + renderFailedRoute('session') + + const alert = renderer!.root.findByProps({ accessibilityRole: 'alert' }) + expect(alert.props.accessibilityLiveRegion).toBe('assertive') + expect(alert.props.accessibilityLabel).toContain('Workspace view stopped') + expect(renderer!.root.findByProps({ accessibilityRole: 'header' })).toBeDefined() + expect(renderer!.root.findByProps({ accessibilityRole: 'button' }).props).toMatchObject({ + accessibilityLabel: 'Reload interface' + }) + }) + + it('returns to the allowed document root before reloading', () => { + const historyState = { id: 'route-entry' } + const replaceState = vi.fn() + const reload = vi.fn(() => { + expect(replaceState).toHaveBeenCalledWith(historyState, '', '/') + }) + vi.stubGlobal('window', { + history: { replaceState, state: historyState }, + location: { reload } + }) + renderFailedRoute('session') + + act(() => renderer!.root.findByProps({ accessibilityRole: 'button' }).props.onPress()) + + expect(replaceState).toHaveBeenCalledOnce() + expect(reload).toHaveBeenCalledOnce() + }) + + it.each(['tasks', 'accounts'])('releases a failed route for a new %s route', (route) => { + renderFailedRoute('session') + + act(() => { + renderer!.update( + createElement( + MobileWebRouteErrorBoundary, + { resetKey: route }, + createElement('route', { name: route }) + ) + ) + }) + + expect(renderer!.root.findByType('route').props.name).toBe(route) + }) }) + +function renderFailedRoute(resetKey: string): void { + vi.spyOn(console, 'error').mockImplementation(() => {}) + act(() => { + renderer = create( + createElement(MobileWebRouteErrorBoundary, { resetKey }, createElement(FailedRoute)) + ) + }) +} + +function FailedRoute(): never { + throw new Error('route failed') +} diff --git a/mobile/src/mobile-web/mobile-web-route-query-cache.ts b/mobile/src/mobile-web/mobile-web-route-query-cache.ts new file mode 100644 index 00000000000..d2084071b90 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-route-query-cache.ts @@ -0,0 +1,36 @@ +const MAX_ROUTE_QUERY_KEYS = 32 +const MAX_ROUTE_QUERY_KEY_LENGTH = 64 +const MAX_ROUTE_QUERY_VALUE_LENGTH = 16_384 + +const queryByPathname = new Map>>() + +export function rememberMobileWebRouteQuery( + pathname: string, + query: URLSearchParams | Readonly> +): void { + const entries = query instanceof URLSearchParams ? query.entries() : Object.entries(query) + const remembered: Record = {} + let count = 0 + for (const [key, value] of entries) { + if ( + value === undefined || + count >= MAX_ROUTE_QUERY_KEYS || + key.length === 0 || + key.length > MAX_ROUTE_QUERY_KEY_LENGTH || + value.length > MAX_ROUTE_QUERY_VALUE_LENGTH + ) { + continue + } + remembered[key] = value + count += 1 + } + if (count === 0) { + queryByPathname.delete(pathname) + return + } + queryByPathname.set(pathname, Object.freeze(remembered)) +} + +export function mobileWebRouteQuery(pathname: string): Readonly> { + return queryByPathname.get(pathname) ?? {} +} diff --git a/mobile/src/mobile-web/mobile-web-route-restoration.test.ts b/mobile/src/mobile-web/mobile-web-route-restoration.test.ts index 92866c05b57..d593bef909c 100644 --- a/mobile/src/mobile-web/mobile-web-route-restoration.test.ts +++ b/mobile/src/mobile-web/mobile-web-route-restoration.test.ts @@ -9,14 +9,14 @@ describe('mobile web route restoration', () => { expect(mobileWebResumeRouteTarget({ kind: 'workspaceList' })).toBeNull() }) - it('restores only the fixed page host label, opaque workspace handle, and display name', () => { + it('keeps page metadata out of the restored URL', () => { expect( mobileWebResumeRouteTarget({ kind: 'session', workspaceId: 'opaque/workspace?one', workspaceName: 'Feature & tests' }) - ).toBe('/h/paired-orca-desktop/session/opaque%2Fworkspace%3Fone?name=Feature+%26+tests') + ).toBe('/h/paired-orca-desktop/session/opaque%2Fworkspace%3Fone') }) it('cannot accept a paired host identity for a page URL', () => { @@ -28,12 +28,9 @@ describe('mobile web route restoration', () => { it.each([ [{ kind: 'tasks' } as const, '/h/paired-orca-desktop/tasks'], - [ - { kind: 'tasks', taskSource: 'gitlab' } as const, - '/h/paired-orca-desktop/tasks?taskSource=gitlab' - ], + [{ kind: 'tasks', taskSource: 'gitlab' } as const, '/h/paired-orca-desktop/tasks'], [{ kind: 'accounts' } as const, '/h/paired-orca-desktop/accounts'], - [{ kind: 'newWorkspace' } as const, '/?action=newWorktree'] + [{ kind: 'newWorkspace' } as const, '/'] ])('maps the typed native destination %s', (route, expected) => { expect(mobileWebNavigationRouteTarget(route)).toBe(expected) }) diff --git a/mobile/src/mobile-web/mobile-web-route-restoration.ts b/mobile/src/mobile-web/mobile-web-route-restoration.ts index 6b80b2f98ba..5d6e7428991 100644 --- a/mobile/src/mobile-web/mobile-web-route-restoration.ts +++ b/mobile/src/mobile-web/mobile-web-route-restoration.ts @@ -15,17 +15,13 @@ export function mobileWebNavigationRouteTarget(route: MobileWebNavigationRoute): return '/' } if (route.kind === 'tasks') { - const query = route.taskSource - ? `?${new URLSearchParams({ taskSource: route.taskSource }).toString()}` - : '' - return `/h/${HOSTED_PAGE_HOST_ID}/tasks${query}` + return `/h/${HOSTED_PAGE_HOST_ID}/tasks` } if (route.kind === 'accounts') { return `/h/${HOSTED_PAGE_HOST_ID}/accounts` } if (route.kind === 'newWorkspace') { - return '/?action=newWorktree' + return '/' } - const query = new URLSearchParams({ name: route.workspaceName }).toString() - return `/h/${HOSTED_PAGE_HOST_ID}/session/${encodeURIComponent(route.workspaceId)}?${query}` + return `/h/${HOSTED_PAGE_HOST_ID}/session/${encodeURIComponent(route.workspaceId)}` } diff --git a/mobile/src/mobile-web/mobile-web-route-restorer.test.ts b/mobile/src/mobile-web/mobile-web-route-restorer.test.ts index a511576dc0d..e3bbe4a0141 100644 --- a/mobile/src/mobile-web/mobile-web-route-restorer.test.ts +++ b/mobile/src/mobile-web/mobile-web-route-restorer.test.ts @@ -2,7 +2,9 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MobileWebNativeShellState } from '../../../src/mobile-web/src/native-shell-channel' +import type { MobileWebNavigationRoute } from '../../../src/shared/mobile-web/bridge-contract' import { MobileWebRouteRestorer } from '../../host-web-app/mobile-web-route-restorer' +import { mobileWebRouteQuery } from './mobile-web-route-query-cache' const mocks = vi.hoisted(() => ({ replace: vi.fn(), @@ -38,6 +40,9 @@ describe('MobileWebRouteRestorer', () => { expect(mocks.replace).toHaveBeenLastCalledWith( '/h/paired-orca-desktop/session/workspace-one?name=Workspace+one' ) + expect(mobileWebRouteQuery('/h/paired-orca-desktop/session/workspace-one')).toEqual({ + name: 'Workspace one' + }) mocks.shell = shellState('page-owned-route', 'Page route', 1) renderRestorer() @@ -70,6 +75,22 @@ describe('MobileWebRouteRestorer', () => { expect(mocks.replace).toHaveBeenCalledTimes(4) }) + it('restores Tasks state and Accounts through queryless hosted history', () => { + mocks.shell = navigationShellState({ kind: 'tasks', taskSource: 'gitlab' }, 1) + renderRestorer() + + expect(mocks.replace).toHaveBeenLastCalledWith('/h/paired-orca-desktop/tasks?taskSource=gitlab') + expect(mobileWebRouteQuery('/h/paired-orca-desktop/tasks')).toEqual({ + taskSource: 'gitlab' + }) + + mocks.shell = navigationShellState({ kind: 'accounts' }, 2) + renderRestorer() + + expect(mocks.replace).toHaveBeenLastCalledWith('/h/paired-orca-desktop/accounts') + expect(mobileWebRouteQuery('/h/paired-orca-desktop/accounts')).toEqual({}) + }) + function renderRestorer(): void { act(() => { if (renderer) { @@ -102,3 +123,14 @@ function shellState( rememberRoute: () => true } } + +function navigationShellState( + navigationRoute: MobileWebNavigationRoute, + routeRevision: number +): MobileWebNativeShellState { + return { + ...shellState('workspace-one', 'Workspace one', routeRevision), + navigationRoute, + resumeRoute: { kind: 'workspaceList' } + } +} diff --git a/mobile/src/mobile-web/mobile-web-session-operations.test.ts b/mobile/src/mobile-web/mobile-web-session-operations.test.ts index 509f84138b7..c96dc580207 100644 --- a/mobile/src/mobile-web/mobile-web-session-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-session-operations.test.ts @@ -39,7 +39,7 @@ describe('mobile web session operations', () => { expect(sendRequest).toHaveBeenCalledWith('status.get') }) - it('returns host gates only for the request-gated projection', async () => { + it('rejects the complete capability array when any entry is malformed', async () => { const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: { @@ -62,7 +62,7 @@ describe('mobile web session operations', () => { nativeChatAuthority: createNativeChatAuthority() }) ).resolves.toEqual({ - hostCapabilities: ['aiVault.v1', 'terminal.quick-commands.v1'], + hostCapabilities: [], floatingWorkspaceEnabled: true }) }) diff --git a/mobile/src/mobile-web/mobile-web-session-operations.ts b/mobile/src/mobile-web/mobile-web-session-operations.ts index 379d44edd52..aadb5a51279 100644 --- a/mobile/src/mobile-web/mobile-web-session-operations.ts +++ b/mobile/src/mobile-web/mobile-web-session-operations.ts @@ -18,6 +18,7 @@ import { type MobileWebSessionCreateResult } from '../../../src/shared/mobile-web/bridge-operation-contract' import type { RpcClient } from '../transport/rpc-client' +import { parseRuntimeStatusCapabilities } from '../transport/runtime-capability-probe' import { projectHostSessionRuntimeCapabilities } from '../session/host-session-runtime-capabilities' import { loadMobileNewTabAgentOptions } from '../session/mobile-new-tab-agent-loader' import type { MobileWebBrowserAuthority } from './mobile-web-browser-authority' @@ -65,16 +66,10 @@ export async function executeMobileWebSessionOperation(args: { if (!response.ok) { throw new MobileWebBrokerError('host_error') } - const status = response.result as { - capabilities?: unknown - floatingWorkspaceEnabled?: unknown - } - const hostCapabilities = Array.isArray(status.capabilities) - ? status.capabilities.filter( - (value): value is string => - typeof value === 'string' && value.length > 0 && value.length <= 120 - ) - : [] + const status = response.result as { floatingWorkspaceEnabled?: unknown } + const hostCapabilities = (parseRuntimeStatusCapabilities(response.result) ?? []).filter( + (value) => value.length > 0 && value.length <= 120 + ) return MobileWebSessionHostGatesResultSchema.parse({ hostCapabilities: hostCapabilities.slice(0, 256), floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true @@ -85,10 +80,7 @@ export async function executeMobileWebSessionOperation(args: { if (!response.ok) { throw new MobileWebBrokerError('host_error') } - const result = response.result as { capabilities?: unknown } - const capabilities = Array.isArray(result.capabilities) - ? result.capabilities.filter((value): value is string => typeof value === 'string') - : [] + const capabilities = parseRuntimeStatusCapabilities(response.result) ?? [] return MobileWebSessionCapabilitiesResultSchema.parse( projectHostSessionRuntimeCapabilities(capabilities) ) diff --git a/mobile/src/mobile-web/mobile-web-session-snapshot.test.ts b/mobile/src/mobile-web/mobile-web-session-snapshot.test.ts index 0bb118ad743..2153a8bbd18 100644 --- a/mobile/src/mobile-web/mobile-web-session-snapshot.test.ts +++ b/mobile/src/mobile-web/mobile-web-session-snapshot.test.ts @@ -88,6 +88,45 @@ describe('mobile web session snapshot', () => { ).toThrow('mobile_web_session_snapshot_invalid') }) + it('retains an active tab beyond the bounded projection', () => { + const tabs = Array.from({ length: MOBILE_WEB_SESSION_TAB_LIMIT + 1 }, (_, index) => ({ + type: 'file', + id: `tab-${index}`, + title: `Tab ${index}`, + relativePath: `src/file-${index}.ts`, + mode: 'diff', + diffSource: 'unstaged', + isActive: index === MOBILE_WEB_SESSION_TAB_LIMIT + })) + const authority = authorities() + + const snapshot = mobileWebSessionSnapshot( + { + worktree: 'workspace-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 4, + activeTabId: `tab-${MOBILE_WEB_SESSION_TAB_LIMIT}`, + activeTabType: 'file', + tabs + }, + 'workspace-1', + 'opaque-workspace', + authority.browser, + authority.nativeChat + ) + + expect(snapshot.tabs).toHaveLength(MOBILE_WEB_SESSION_TAB_LIMIT) + expect(snapshot.tabs.at(-1)).toMatchObject({ + id: `tab-${MOBILE_WEB_SESSION_TAB_LIMIT}`, + relativePath: `src/file-${MOBILE_WEB_SESSION_TAB_LIMIT}.ts`, + isActive: true + }) + expect(snapshot.tabs.some((tab) => tab.id === `tab-${MOBILE_WEB_SESSION_TAB_LIMIT - 1}`)).toBe( + false + ) + expect(snapshot.truncated).toBe(true) + }) + it('removes browser URL credentials and local file paths', () => { const authority = authorities() const snapshot = mobileWebSessionSnapshot( diff --git a/mobile/src/mobile-web/mobile-web-session-snapshot.ts b/mobile/src/mobile-web/mobile-web-session-snapshot.ts index ce47e7debd3..7cba13a5cc6 100644 --- a/mobile/src/mobile-web/mobile-web-session-snapshot.ts +++ b/mobile/src/mobile-web/mobile-web-session-snapshot.ts @@ -60,12 +60,10 @@ export function mobileWebSessionSnapshot( return binding ? [binding] : [] }) nativeChatAuthority.synchronizeWorkspace(hostWorkspaceId, nativeChatBindings) - const tabs = result.tabs - .slice(0, MOBILE_WEB_SESSION_TAB_LIMIT) - .flatMap((value): MobileWebSessionTab[] => { - const tab = mobileWebSessionTab(value, hostWorkspaceId, browserAuthority, nativeChatAuthority) - return tab ? [tab] : [] - }) + const tabs = boundedHostSessionTabs(result.tabs).flatMap((value): MobileWebSessionTab[] => { + const tab = mobileWebSessionTab(value, hostWorkspaceId, browserAuthority, nativeChatAuthority) + return tab ? [tab] : [] + }) const activeTabId = result.activeTabType === 'browser' ? (tabs.find((tab) => tab.type === 'browser' && tab.isActive)?.id ?? null) @@ -88,6 +86,19 @@ export function mobileWebSessionSnapshot( return parsed.data } +function boundedHostSessionTabs(tabs: unknown[]): unknown[] { + const bounded = tabs.slice(0, MOBILE_WEB_SESSION_TAB_LIMIT) + if (tabs.length <= MOBILE_WEB_SESSION_TAB_LIMIT || bounded.some(isActiveSessionTab)) { + return bounded + } + const active = tabs.find(isActiveSessionTab) + return active ? [...bounded.slice(0, -1), active] : bounded +} + +function isActiveSessionTab(value: unknown): boolean { + return isRecord(value) && value.isActive === true +} + function mobileWebSessionTab( value: unknown, hostWorkspaceId: string, diff --git a/mobile/src/mobile-web/mobile-web-source-control-branch-compare-pager.test.ts b/mobile/src/mobile-web/mobile-web-source-control-branch-compare-pager.test.ts new file mode 100644 index 00000000000..e732471cdad --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-source-control-branch-compare-pager.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest' +import { + MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT, + type MobileWebSourceControlBranchComparePayload +} from '../../../src/shared/mobile-web/source-control-history-contract' +import type { RpcClient } from '../transport/rpc-client' +import { MobileWebSourceControlBranchComparePager } from './mobile-web-source-control-branch-compare-pager' +import { createMobileWebWorkspaceAuthorityFixture } from './mobile-web-workspace-authority-test-fixture' + +const workspaceAuthority = createMobileWebWorkspaceAuthorityFixture() +const OID_A = 'a'.repeat(40) +const OID_B = 'b'.repeat(40) + +describe('mobile web source-control branch-compare pager', () => { + it('uses one host snapshot across single-use continuation claims', async () => { + const pager = new MobileWebSourceControlBranchComparePager() + const client = compareClient(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT + 1) + const first = await pager.page(firstPayload(), client, workspaceAuthority) + const continuation = continuationPayload(first) + + expect(pager.claimContinuation({ ...continuation, expectedRevision: 'c'.repeat(64) })).toBe( + false + ) + expect(pager.claimContinuation(continuation)).toBe(true) + expect(pager.claimContinuation(continuation)).toBe(false) + + await expect(pager.page(continuation, client, workspaceAuthority)).resolves.toMatchObject({ + revision: first.revision, + offset: MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT, + nextOffset: null + }) + expect(client.sendRequest).toHaveBeenCalledOnce() + }) + + it('revokes abandoned continuations when its authority is cleared', async () => { + const pager = new MobileWebSourceControlBranchComparePager() + const client = compareClient(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT + 1) + const first = await pager.page(firstPayload(), client, workspaceAuthority) + const continuation = continuationPayload(first) + + pager.clear() + + expect(pager.claimContinuation(continuation)).toBe(false) + await expect(pager.page(continuation, client, workspaceAuthority)).rejects.toMatchObject({ + code: 'invalid_request' + }) + expect(client.sendRequest).toHaveBeenCalledOnce() + }) +}) + +function firstPayload(): MobileWebSourceControlBranchComparePayload { + return { workspaceId: 'workspace-1', baseRef: 'main', offset: 0, limit: 128 } +} + +function continuationPayload(first: { + nextOffset: number | null + revision: string +}): MobileWebSourceControlBranchComparePayload { + if (first.nextOffset === null) { + throw new Error('Expected a continuation') + } + return { + workspaceId: 'workspace-1', + baseRef: 'main', + offset: first.nextOffset, + limit: 128, + expectedRevision: first.revision + } +} + +function compareClient(entryCount: number): RpcClient { + return { + sendRequest: vi.fn().mockResolvedValue({ + ok: true, + result: { + summary: { + baseOid: OID_A, + compareRef: 'feature/mobile', + headOid: OID_B, + mergeBase: OID_A, + changedFiles: entryCount, + status: 'ready' + }, + entries: Array.from({ length: entryCount }, (_, index) => ({ + path: `src/file-${index}.ts`, + status: 'modified' + })) + } + }) + } as unknown as RpcClient +} diff --git a/mobile/src/mobile-web/mobile-web-source-control-branch-compare-pager.ts b/mobile/src/mobile-web/mobile-web-source-control-branch-compare-pager.ts new file mode 100644 index 00000000000..0b822ce30da --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-source-control-branch-compare-pager.ts @@ -0,0 +1,136 @@ +import { + MobileWebSourceControlBranchComparePayloadSchema, + type MobileWebSourceControlBranchComparePayload, + type MobileWebSourceControlBranchCompareResult +} from '../../../src/shared/mobile-web/source-control-history-contract' +import type { RpcClient } from '../transport/rpc-client' +import { MobileWebBrokerError } from './mobile-web-broker-error' +import { mobileWebEncodedByteLength } from './mobile-web-request-accounting' +import { sanitizeMobileWebBranchCompare } from './mobile-web-source-control-history-sanitizers' +import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' + +const HOST_RESULT_MAX_BYTES = 8 * 1024 * 1024 + +type Continuation = { + workspaceId: string + baseRef: string + revision: string + nextOffset: number + hostResult: unknown +} + +export class MobileWebSourceControlBranchComparePager { + private continuation: Continuation | null = null + private claimed: Continuation | null = null + private active = false + + claimRequestContinuation(request: { + capability: string + operation: string + payload: unknown + }): boolean { + this.claimed = null + return ( + request.capability === 'sourceControl' && + request.operation === 'branchCompare' && + this.claimContinuation(request.payload) + ) + } + + claimContinuation(payloadValue: unknown): boolean { + const parsed = MobileWebSourceControlBranchComparePayloadSchema.safeParse(payloadValue) + const continuation = this.continuation + if (!parsed.success || !parsed.data.expectedRevision || !continuation) { + return false + } + const payload = parsed.data + if ( + continuation.workspaceId !== payload.workspaceId || + continuation.baseRef !== payload.baseRef || + continuation.revision !== payload.expectedRevision || + continuation.nextOffset !== payload.offset + ) { + return false + } + this.continuation = null + this.claimed = continuation + return true + } + + async page( + payloadValue: unknown, + client: RpcClient, + workspaceAuthority: MobileWebWorkspaceAuthority + ): Promise { + if (this.active) { + throw new MobileWebBrokerError('rate_limited') + } + this.active = true + try { + const payload = MobileWebSourceControlBranchComparePayloadSchema.parse(payloadValue) + const hostResult = payload.expectedRevision + ? this.consumeClaim(payload) + : await this.begin(payload, client, workspaceAuthority) + const page = sanitizeMobileWebBranchCompare(hostResult, payload) + if (page.nextOffset !== null) { + this.continuation = { + workspaceId: payload.workspaceId, + baseRef: payload.baseRef, + revision: page.revision, + nextOffset: page.nextOffset, + hostResult + } + } + return page + } finally { + this.active = false + this.claimed = null + } + } + + clear(): void { + this.continuation = null + this.claimed = null + } + + private async begin( + payload: MobileWebSourceControlBranchComparePayload, + client: RpcClient, + workspaceAuthority: MobileWebWorkspaceAuthority + ): Promise { + this.clear() + if (payload.offset !== 0) { + throw new MobileWebBrokerError('invalid_request') + } + const hostWorkspaceId = workspaceAuthority.hostWorkspaceId(payload.workspaceId) + const response = await client.sendRequest('git.branchCompare', { + worktree: `id:${hostWorkspaceId}`, + baseRef: payload.baseRef + }) + if (!response.ok) { + throw new MobileWebBrokerError('host_error') + } + if (mobileWebEncodedByteLength(response.result) > HOST_RESULT_MAX_BYTES) { + throw new MobileWebBrokerError('too_large') + } + return response.result + } + + private consumeClaim(payload: MobileWebSourceControlBranchComparePayload): unknown { + const continuation = this.claimed + if (!continuation) { + throw new MobileWebBrokerError('invalid_request') + } + if (continuation.revision !== payload.expectedRevision) { + throw new MobileWebBrokerError('conflict') + } + if ( + continuation.workspaceId !== payload.workspaceId || + continuation.baseRef !== payload.baseRef || + continuation.nextOffset !== payload.offset + ) { + throw new MobileWebBrokerError('invalid_request') + } + return continuation.hostResult + } +} diff --git a/mobile/src/mobile-web/mobile-web-source-control-history-operations.test.ts b/mobile/src/mobile-web/mobile-web-source-control-history-operations.test.ts index c027179b3a8..99dde29e275 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-history-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-source-control-history-operations.test.ts @@ -6,10 +6,12 @@ import { MOBILE_WEB_SOURCE_CONTROL_HISTORY_RESPONSE_MAX_BYTES } from '../../../src/shared/mobile-web/source-control-history-contract' import type { RpcClient } from '../transport/rpc-client' -import { executeMobileWebSourceControlHistoryOperation } from './mobile-web-source-control-history-operations' +import { MobileWebSourceControlBranchComparePager } from './mobile-web-source-control-branch-compare-pager' +import { executeMobileWebSourceControlHistoryOperation as executeHistoryOperation } from './mobile-web-source-control-history-operations' import { createMobileWebWorkspaceAuthorityFixture } from './mobile-web-workspace-authority-test-fixture' const workspaceAuthority = createMobileWebWorkspaceAuthorityFixture() +const branchComparePager = new MobileWebSourceControlBranchComparePager() const OID_A = 'a'.repeat(40) const OID_B = 'b'.repeat(40) @@ -215,6 +217,14 @@ describe('mobile web source-control history operations', () => { expect(JSON.stringify(result)).not.toContain('/private/repository') expect(JSON.stringify(result)).not.toContain('errorMessage') + expect( + branchComparePager.claimContinuation({ + workspaceId: 'workspace-1', + baseRef: 'main', + offset: result.nextOffset, + expectedRevision: result.revision + }) + ).toBe(true) const continuation = await executeMobileWebSourceControlHistoryOperation({ operation: 'branchCompare', payload: { @@ -237,7 +247,7 @@ describe('mobile web source-control history operations', () => { }) }) - it('rejects a branch comparison continuation after the host revision changes', async () => { + it('serves a continuation from one stable host snapshot', async () => { const entries = Array.from( { length: MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT + 1 }, (_, index) => ({ path: `src/file-${index}.ts`, status: 'modified' }) @@ -277,19 +287,22 @@ describe('mobile web source-control history operations', () => { } }) + const payload = { + workspaceId: 'workspace-1', + baseRef: 'main', + offset: first.nextOffset, + expectedRevision: first.revision + } + expect(branchComparePager.claimContinuation(payload)).toBe(true) await expect( executeMobileWebSourceControlHistoryOperation({ operation: 'branchCompare', - payload: { - workspaceId: 'workspace-1', - baseRef: 'main', - offset: first.nextOffset, - expectedRevision: first.revision - }, + payload, client, workspaceAuthority }) - ).rejects.toMatchObject({ code: 'conflict' }) + ).resolves.toMatchObject({ revision: first.revision, nextOffset: null }) + expect(client.sendRequest).toHaveBeenCalledOnce() }) it('requires a full commit ID and returns request-bound comparison identity', async () => { @@ -341,3 +354,9 @@ function rpcClient(result: unknown): RpcClient { function encodedBytes(value: unknown): number { return new TextEncoder().encode(JSON.stringify(value)).byteLength } + +function executeMobileWebSourceControlHistoryOperation( + args: Omit[0], 'branchComparePager'> +) { + return executeHistoryOperation({ ...args, branchComparePager }) +} diff --git a/mobile/src/mobile-web/mobile-web-source-control-history-operations.ts b/mobile/src/mobile-web/mobile-web-source-control-history-operations.ts index 1d7fd146ae0..c971752013c 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-history-operations.ts +++ b/mobile/src/mobile-web/mobile-web-source-control-history-operations.ts @@ -1,5 +1,4 @@ import { - MobileWebSourceControlBranchComparePayloadSchema, MobileWebSourceControlBranchesPayloadSchema, MobileWebSourceControlCommitComparePayloadSchema, MobileWebSourceControlHistoryPayloadSchema, @@ -11,12 +10,12 @@ import { import type { RpcClient } from '../transport/rpc-client' import { MobileWebBrokerError } from './mobile-web-broker-error' import { - sanitizeMobileWebBranchCompare, sanitizeMobileWebBranches, sanitizeMobileWebCommitCompare, sanitizeMobileWebHistory } from './mobile-web-source-control-history-sanitizers' import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' +import type { MobileWebSourceControlBranchComparePager } from './mobile-web-source-control-branch-compare-pager' type HistoryOperation = 'branches' | 'history' | 'branchCompare' | 'commitCompare' type HistoryOperationResult = @@ -41,6 +40,7 @@ export async function executeMobileWebSourceControlHistoryOperation(args: { payload: unknown client: RpcClient workspaceAuthority: MobileWebWorkspaceAuthority + branchComparePager?: MobileWebSourceControlBranchComparePager }): Promise { if (args.operation === 'branches') { const payload = MobileWebSourceControlBranchesPayloadSchema.parse(args.payload) @@ -67,16 +67,10 @@ export async function executeMobileWebSourceControlHistoryOperation(args: { return sanitizeMobileWebHistory(response.result, payload.workspaceId, payload.limit) } if (args.operation === 'branchCompare') { - const payload = MobileWebSourceControlBranchComparePayloadSchema.parse(args.payload) - const hostWorkspaceId = args.workspaceAuthority.hostWorkspaceId(payload.workspaceId) - const response = await args.client.sendRequest('git.branchCompare', { - worktree: `id:${hostWorkspaceId}`, - baseRef: payload.baseRef - }) - if (!response.ok) { - throw new MobileWebBrokerError('host_error') + if (!args.branchComparePager) { + throw new MobileWebBrokerError('internal') } - return sanitizeMobileWebBranchCompare(response.result, payload) + return args.branchComparePager.page(args.payload, args.client, args.workspaceAuthority) } const payload = MobileWebSourceControlCommitComparePayloadSchema.parse(args.payload) diff --git a/mobile/src/mobile-web/mobile-web-source-control-operations.ts b/mobile/src/mobile-web/mobile-web-source-control-operations.ts index 6044729a32e..1f9bc797f79 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-operations.ts +++ b/mobile/src/mobile-web/mobile-web-source-control-operations.ts @@ -53,12 +53,14 @@ import { isMobileWebSourceControlReviewOperation } from './mobile-web-source-control-review-operations' import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' +import type { MobileWebSourceControlBranchComparePager } from './mobile-web-source-control-branch-compare-pager' export async function executeMobileWebSourceControlOperation(args: { operation: string payload: unknown client: RpcClient workspaceAuthority: MobileWebWorkspaceAuthority + branchComparePager?: MobileWebSourceControlBranchComparePager terminalClientId?: string }): Promise< | MobileWebSourceControlStatusResult @@ -93,7 +95,8 @@ export async function executeMobileWebSourceControlOperation(args: { operation: args.operation, payload: args.payload, client: args.client, - workspaceAuthority: args.workspaceAuthority + workspaceAuthority: args.workspaceAuthority, + branchComparePager: args.branchComparePager }) } if (args.operation === 'status') { diff --git a/mobile/src/mobile-web/mobile-web-source-control-review-operations.test.ts b/mobile/src/mobile-web/mobile-web-source-control-review-operations.test.ts index ea3636293da..16597659f76 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-review-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-source-control-review-operations.test.ts @@ -203,12 +203,30 @@ describe('mobile web source-control review operations', () => { id: 'tab-1', type: 'terminal', status: 'ready', - terminal: 'host-terminal' + terminal: 'host-terminal', + isActive: true + }, + ...Array.from({ length: 199 }, (_, index) => ({ + id: `filler-${index}`, + type: 'file', + mode: index === 0 ? 'diff' : 'edit', + ...(index === 0 ? { diffSource: 'unstaged' } : {}), + relativePath: index === 0 ? 'src/app.ts' : `src/filler-${index}.ts` + })), + { + id: 'diff-tab', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/app.ts' } ] } } } + if (method === 'session.tabs.activate') { + return { ok: true, result: { activeTabId: 'diff-tab' } } + } if (method === 'terminal.send') { return { ok: true, result: { send: { accepted: true } } } } @@ -257,6 +275,13 @@ describe('mobile web source-control review operations', () => { relativePath: 'src/app.ts', staged: true }) + expect(client.sendRequest).toHaveBeenCalledWith('session.tabs.activate', { + worktree: 'id:host-workspace', + tabId: 'diff-tab', + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) await expect( executeMobileWebSourceControlReviewOperation({ @@ -318,6 +343,28 @@ describe('mobile web source-control review operations', () => { }) ).resolves.toEqual({ accepted: false }) }) + + it('preserves an unavailable diff method for the page edit fallback', async () => { + const client = { + sendRequest: vi.fn().mockResolvedValue({ + ok: false, + error: { code: 'method_not_found', message: 'Method not found' } + }) + } as unknown as RpcClient + + await expect( + executeMobileWebSourceControlReviewOperation({ + operation: 'reviewOpen', + payload: { + workspaceId: 'page-workspace', + relativePath: 'src/app.ts', + scope: 'unstaged' + }, + client, + workspaceAuthority + }) + ).rejects.toMatchObject({ code: 'unsupported_capability' }) + }) }) function isMetadata(value: unknown): value is { diff --git a/mobile/src/mobile-web/mobile-web-source-control-review-operations.ts b/mobile/src/mobile-web/mobile-web-source-control-review-operations.ts index d18159c8f8e..bf08048d384 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-review-operations.ts +++ b/mobile/src/mobile-web/mobile-web-source-control-review-operations.ts @@ -15,6 +15,8 @@ import { type MobileWebSourceControlReviewTerminalSendResult } from '../../../src/shared/mobile-web/source-control-review-contract' import type { RpcClient } from '../transport/rpc-client' +import { isMobileGitUnavailable } from '../source-control/mobile-git-status' +import { activateMobileSessionFileTab } from '../session/mobile-session-file-tab-activation' import { MobileWebBrokerError } from './mobile-web-broker-error' import { sanitizeMobileWebSourceControlDiff } from './mobile-web-source-control-read-results' import { @@ -86,8 +88,22 @@ export async function executeMobileWebSourceControlReviewOperation(args: { staged: payload.scope === 'staged' }) if (!response.ok) { + if (isMobileGitUnavailable(response.error?.code, response.error?.message)) { + throw new MobileWebBrokerError('unsupported_capability') + } throw new MobileWebBrokerError('host_error') } + await activateMobileSessionFileTab({ + client: args.client, + worktreeId: hostWorkspaceId, + relativePath: payload.relativePath, + tabMode: 'diff', + staged: payload.scope === 'staged', + isCurrent: () => { + args.workspaceAuthority.assertHostWorkspaceBinding(payload.workspaceId, hostWorkspaceId) + return true + } + }) return null } if (args.operation === 'reviewTerminalSend') { @@ -111,7 +127,9 @@ export async function executeMobileWebSourceControlReviewOperation(args: { if (accepted === null) { throw new MobileWebBrokerError('host_error') } - return MobileWebSourceControlReviewTerminalSendResultSchema.parse({ accepted }) + return MobileWebSourceControlReviewTerminalSendResultSchema.parse({ + accepted + }) } throw new MobileWebBrokerError('unsupported_capability') } diff --git a/mobile/src/mobile-web/mobile-web-terminal-flow-control.ts b/mobile/src/mobile-web/mobile-web-terminal-flow-control.ts index 21c47991202..2d8c295350f 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-flow-control.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-flow-control.ts @@ -36,6 +36,7 @@ export type MobileWebTerminalStreamRecord = { viewport: { cols: number; rows: number } visible: boolean hostReady: boolean + supportsQueryReply: boolean bridgeSequence: number sentSequence: number acknowledgedSequence: number diff --git a/mobile/src/mobile-web/mobile-web-terminal-host-transport.ts b/mobile/src/mobile-web/mobile-web-terminal-host-transport.ts index f4c15b0285e..457d52dbc29 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-host-transport.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-host-transport.ts @@ -20,7 +20,7 @@ export function sendMobileWebTerminalSubscribe( terminal: record.terminal, client: { id: clientId, type: 'mobile' }, viewport: record.viewport, - capabilities: { ackOutput: 1 } + capabilities: { ackOutput: 1, queryReply: 1 } }, 0 ) diff --git a/mobile/src/mobile-web/mobile-web-terminal-input.ts b/mobile/src/mobile-web/mobile-web-terminal-input.ts index 1dd39cccbad..daaccb5f009 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-input.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-input.ts @@ -31,7 +31,9 @@ export function handleMobileWebTerminalInput(args: { args.client, args.record, args.request.operation === 'queryReply' - ? TerminalStreamOpcode.QueryReply + ? args.record.supportsQueryReply + ? TerminalStreamOpcode.QueryReply + : TerminalStreamOpcode.Input : TerminalStreamOpcode.Input, Uint8Array.from(atob(args.request.data), (character) => character.charCodeAt(0)) ) diff --git a/mobile/src/mobile-web/mobile-web-terminal-multiplex-events.ts b/mobile/src/mobile-web/mobile-web-terminal-multiplex-events.ts index 2735f181606..5ec48ec1c43 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-multiplex-events.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-multiplex-events.ts @@ -18,6 +18,7 @@ export function handleMobileWebTerminalMultiplexEvent(args: { if (args.result.type === 'ready') { for (const record of args.records) { record.hostReady = false + record.supportsQueryReply = false record.snapshot = null if (record.visible) { args.sendSubscribe(record) @@ -29,6 +30,7 @@ export function handleMobileWebTerminalMultiplexEvent(args: { const record = args.recordForHostId(args.result.streamId) if (record) { record.hostReady = true + record.supportsQueryReply = hasQueryReplyCapability(args.result.capabilities) args.post(record, { type: 'subscribed', streamId: record.pageStreamId, @@ -58,3 +60,7 @@ export function handleMobileWebTerminalMultiplexEvent(args: { function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } + +function hasQueryReplyCapability(value: unknown): boolean { + return isRecord(value) && value.queryReply === 1 +} diff --git a/mobile/src/mobile-web/mobile-web-terminal-stream-record.ts b/mobile/src/mobile-web/mobile-web-terminal-stream-record.ts index 12fc95622ab..b3ed2eeecdd 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-stream-record.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-stream-record.ts @@ -22,6 +22,7 @@ export function createMobileWebTerminalStreamRecord(args: { viewport: args.request.viewport, visible: args.request.visible, hostReady: false, + supportsQueryReply: false, bridgeSequence: 0, sentSequence: 0, acknowledgedSequence: 0, diff --git a/mobile/src/mobile-web/mobile-web-terminal-streams.test.ts b/mobile/src/mobile-web/mobile-web-terminal-streams.test.ts index 049e725d391..31f4d45c34d 100644 --- a/mobile/src/mobile-web/mobile-web-terminal-streams.test.ts +++ b/mobile/src/mobile-web/mobile-web-terminal-streams.test.ts @@ -47,7 +47,7 @@ describe('MobileWebTerminalStreams', () => { expect(hostRequest).toMatchObject({ terminal: 'terminal-secret', client: { id: 'device-secret', type: 'mobile' }, - capabilities: { ackOutput: 1 } + capabilities: { ackOutput: 1, queryReply: 1 } }) expect(harness.sendRequest).toHaveBeenCalledWith('session.tabs.list', { worktree: `id:${HOST_WORKSPACE_ID}` @@ -223,7 +223,7 @@ describe('MobileWebTerminalStreams', () => { expect(JSON.stringify(harness.resyncReasons)).not.toContain('terminal-secret') }) - it('keeps query replies distinct from user input on the Desktop wire', async () => { + it('uses the query-reply opcode only after the host echoes support', async () => { const harness = createHarness() await harness.streams.start({ requestId: 'request-query', @@ -236,7 +236,11 @@ describe('MobileWebTerminalStreams', () => { const subscribe = harness.sentFrames.at(-1)! const hostStreamId = decodeTerminalStreamJson>(subscribe.payload)! .streamId as number - harness.emitMultiplex({ type: 'subscribed', streamId: hostStreamId }) + harness.emitMultiplex({ + type: 'subscribed', + streamId: hostStreamId, + capabilities: { queryReply: 1 } + }) await harness.streams.handle( { @@ -264,6 +268,35 @@ describe('MobileWebTerminalStreams', () => { expect(decodeTerminalStreamText(harness.sentFrames.at(-1)!.payload)).toBe('\x1b[0n') }) + it('falls back to legacy input when an older host omits query-reply support', async () => { + const harness = createHarness() + await harness.streams.start({ + requestId: 'request-legacy-query', + subscriptionId: SUBSCRIPTION_ID, + payload: subscribePayload(), + client: harness.client, + isRequestActive: () => true + }) + harness.emitMultiplex({ type: 'ready' }) + const subscribe = harness.sentFrames.at(-1)! + const hostStreamId = decodeTerminalStreamJson>(subscribe.payload)! + .streamId as number + harness.emitMultiplex({ type: 'subscribed', streamId: hostStreamId }) + + await harness.streams.handle( + { + operation: 'queryReply', + streamId: SUBSCRIPTION_ID, + sequence: 0, + data: Buffer.from('\x1b[0n').toString('base64') + }, + harness.client + ) + + expect(harness.sentFrames.at(-1)?.opcode).toBe(TerminalStreamOpcode.Input) + expect(decodeTerminalStreamText(harness.sentFrames.at(-1)!.payload)).toBe('\x1b[0n') + }) + it('gesture-gates shell-owned device input and returns status without native paths', async () => { const harness = createHarness() await harness.streams.start({ diff --git a/mobile/src/mobile-web/mobile-web-workspace-authority.test.ts b/mobile/src/mobile-web/mobile-web-workspace-authority.test.ts index 464df286b1d..a67df5ac2ae 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-authority.test.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-authority.test.ts @@ -33,10 +33,27 @@ describe('mobile web workspace authority', () => { expect(authority.pageRepoId('/private/repo')).toBe(repo) authority.synchronizeRepositories([]) + expect(authority.hostRepoId(repo)).toBe('/private/repo') + expect(authority.pageRepoId('/private/repo')).toBe(repo) + + authority.synchronize([]) expect(() => authority.hostRepoId(repo)).toThrow('not_found') expect(() => authority.assertHostRepoBinding(repo, '/private/repo')).toThrow('not_found') }) + it('revokes a repository handle only after workspace and catalog authority both omit it', () => { + const authority = new MobileWebWorkspaceAuthority((length) => new Uint8Array(length).fill(8)) + authority.synchronize([{ workspaceId: 'folder-workspace', repoId: 'folder-repo' }]) + authority.synchronizeRepositories(['folder-repo']) + const repo = authority.pageRepoId('folder-repo') + + authority.synchronize([]) + expect(authority.pageRepoId('folder-repo')).toBe(repo) + + authority.synchronizeRepositories([]) + expect(() => authority.hostRepoId(repo)).toThrow('not_found') + }) + it('revokes every mapping when the shell session is cleared', () => { const authority = new MobileWebWorkspaceAuthority((length) => new Uint8Array(length)) authority.synchronize([{ workspaceId: 'host-workspace', repoId: 'host-repo' }]) diff --git a/mobile/src/mobile-web/mobile-web-workspace-authority.ts b/mobile/src/mobile-web/mobile-web-workspace-authority.ts index 1073d913277..ab41f0cc321 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-authority.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-authority.ts @@ -15,8 +15,10 @@ export type MobileWebHostWorkspaceBinding = { export class MobileWebWorkspaceAuthority { private readonly pageWorkspaceIdByHostId = new Map() private readonly hostWorkspaceIdByPageId = new Map() + private hostRepoIdByHostWorkspaceId = new Map() private readonly pageRepoIdByHostId = new Map() private readonly hostRepoIdByPageId = new Map() + private catalogRepoIds = new Set() private readonly hostConnectionIdByPageRepoId = new Map() private readonly pageProjectIdByHostId = new Map() private readonly pageExecutionHostIdByHostId = new Map() @@ -35,10 +37,14 @@ export class MobileWebWorkspaceAuthority { } } } + this.hostRepoIdByHostWorkspaceId = new Map( + bindings.map((binding) => [binding.workspaceId, binding.repoId]) + ) for (const binding of bindings) { this.rememberWorkspace(binding.workspaceId) this.rememberRepo(binding.repoId) } + this.revokeUnreferencedRepos() } pageWorkspaceId(hostWorkspaceId: string): string { @@ -72,15 +78,9 @@ export class MobileWebWorkspaceAuthority { } synchronizeRepositories(hostRepoIds: readonly string[]): void { - const current = new Set(hostRepoIds) - for (const [hostRepoId, pageRepoId] of this.pageRepoIdByHostId) { - if (!current.has(hostRepoId)) { - this.pageRepoIdByHostId.delete(hostRepoId) - this.hostRepoIdByPageId.delete(pageRepoId) - this.hostConnectionIdByPageRepoId.delete(pageRepoId) - } - } + this.catalogRepoIds = new Set(hostRepoIds) hostRepoIds.forEach((hostRepoId) => this.rememberRepo(hostRepoId)) + this.revokeUnreferencedRepos() } synchronizeCreationRepositories(repositories: readonly NewWorkspaceRepository[]): void { @@ -135,6 +135,7 @@ export class MobileWebWorkspaceAuthority { registerWorkspace(hostWorkspaceId: string, hostRepoId: string): string { this.rememberWorkspace(hostWorkspaceId) + this.hostRepoIdByHostWorkspaceId.set(hostWorkspaceId, hostRepoId) this.rememberRepo(hostRepoId) return this.pageWorkspaceId(hostWorkspaceId) } @@ -142,8 +143,10 @@ export class MobileWebWorkspaceAuthority { clear(): void { this.pageWorkspaceIdByHostId.clear() this.hostWorkspaceIdByPageId.clear() + this.hostRepoIdByHostWorkspaceId.clear() this.pageRepoIdByHostId.clear() this.hostRepoIdByPageId.clear() + this.catalogRepoIds.clear() this.hostConnectionIdByPageRepoId.clear() this.pageProjectIdByHostId.clear() this.pageExecutionHostIdByHostId.clear() @@ -166,6 +169,18 @@ export class MobileWebWorkspaceAuthority { } } + private revokeUnreferencedRepos(): void { + const workspaceRepoIds = new Set(this.hostRepoIdByHostWorkspaceId.values()) + for (const [hostRepoId, pageRepoId] of this.pageRepoIdByHostId) { + if (workspaceRepoIds.has(hostRepoId) || this.catalogRepoIds.has(hostRepoId)) { + continue + } + this.pageRepoIdByHostId.delete(hostRepoId) + this.hostRepoIdByPageId.delete(pageRepoId) + this.hostConnectionIdByPageRepoId.delete(pageRepoId) + } + } + private rememberProject(hostProjectId: string): void { if (!this.pageProjectIdByHostId.has(hostProjectId)) { this.pageProjectIdByHostId.set(hostProjectId, this.createHandle('project')) diff --git a/mobile/src/mobile-web/use-mobile-web-hardware-back-handoff.test.ts b/mobile/src/mobile-web/use-mobile-web-hardware-back-handoff.test.ts new file mode 100644 index 00000000000..f3602ae6f76 --- /dev/null +++ b/mobile/src/mobile-web/use-mobile-web-hardware-back-handoff.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type MobileWebBridgeMessageContext, + type MobileWebBridgePageMessage, + type MobileWebBridgeShellMessage +} from '../../../src/shared/mobile-web/bridge-contract' +import { MobileWebHardwareBackHandoff } from './mobile-web-hardware-back-handoff' + +const CONTEXT: MobileWebBridgeMessageContext = { + shellSessionId: 'S'.repeat(43), + buildId: 'a'.repeat(64) +} +const OTHER_CONTEXT: MobileWebBridgeMessageContext = { + shellSessionId: 'T'.repeat(43), + buildId: 'b'.repeat(64) +} + +afterEach(() => vi.useRealTimers()) + +describe('mobile web hardware Back handoff', () => { + it('keeps an old page on the native-shell fallback without sending an unknown message', () => { + const handoff = readyHandoff() + const postMessage = vi.fn(async (_message: MobileWebBridgeShellMessage) => {}) + + expect(handoff.request(postMessage, vi.fn())).toBe(false) + expect(postMessage).not.toHaveBeenCalled() + }) + + it('forwards only after the loaded page declares support and accepts a handled result', () => { + const handoff = supportedHandoff() + const postMessage = vi.fn(async (_message: MobileWebBridgeShellMessage) => {}) + const onUnhandled = vi.fn() + + expect(handoff.request(postMessage, onUnhandled)).toBe(true) + const request = postMessage.mock.calls[0]![0] as Extract< + MobileWebBridgeShellMessage, + { type: 'hardwareBack' } + > + expect(request).toMatchObject({ type: 'hardwareBack', sequence: 1, ...CONTEXT }) + expect(handoff.resolve(resultMessage(request.sequence, true))).toBe(true) + expect(onUnhandled).not.toHaveBeenCalled() + }) + + it('returns an unhandled page result to the native shell exactly once', () => { + const handoff = supportedHandoff() + const postMessage = vi.fn(async (_message: MobileWebBridgeShellMessage) => {}) + const onUnhandled = vi.fn() + handoff.request(postMessage, onUnhandled) + const request = postMessage.mock.calls[0]![0] as Extract< + MobileWebBridgeShellMessage, + { type: 'hardwareBack' } + > + + expect(handoff.resolve(resultMessage(request.sequence, false))).toBe(true) + expect(handoff.resolve(resultMessage(request.sequence, false))).toBe(false) + expect(onUnhandled).toHaveBeenCalledOnce() + }) + + it('correlates every physical Back press while earlier results are pending', () => { + const handoff = supportedHandoff() + const postMessage = vi.fn(async (_message: MobileWebBridgeShellMessage) => {}) + const onUnhandled = vi.fn() + + expect(handoff.request(postMessage, onUnhandled)).toBe(true) + expect(handoff.request(postMessage, onUnhandled)).toBe(true) + const requests = postMessage.mock.calls.map( + ([message]) => message as Extract + ) + expect(requests.map(({ sequence }) => sequence)).toEqual([1, 2]) + + expect(handoff.resolve(resultMessage(2, true))).toBe(true) + expect(handoff.resolve(resultMessage(1, true))).toBe(true) + expect(onUnhandled).not.toHaveBeenCalled() + }) + + it('falls back on timeout and ignores a late result', () => { + vi.useFakeTimers() + const handoff = supportedHandoff(50) + const postMessage = vi.fn(async (_message: MobileWebBridgeShellMessage) => {}) + const onUnhandled = vi.fn() + handoff.request(postMessage, onUnhandled) + const request = postMessage.mock.calls[0]![0] as Extract< + MobileWebBridgeShellMessage, + { type: 'hardwareBack' } + > + + vi.advanceTimersByTime(50) + expect(onUnhandled).toHaveBeenCalledOnce() + expect(handoff.resolve(resultMessage(request.sequence, true))).toBe(false) + expect(onUnhandled).toHaveBeenCalledOnce() + }) + + it('cancels pending work and rejects stale declarations across page lifecycles', () => { + vi.useFakeTimers() + const handoff = supportedHandoff(50) + const postMessage = vi.fn(async (_message: MobileWebBridgeShellMessage) => {}) + const onUnhandled = vi.fn() + handoff.request(postMessage, onUnhandled) + + handoff.setContext(OTHER_CONTEXT) + vi.advanceTimersByTime(50) + expect(onUnhandled).not.toHaveBeenCalled() + handoff.declareSupport(capabilityMessage(CONTEXT)) + handoff.acknowledgeReady(OTHER_CONTEXT) + expect(handoff.request(postMessage, onUnhandled)).toBe(false) + }) + + it('requires ready before accepting a capability declaration', () => { + const handoff = new MobileWebHardwareBackHandoff() + const postMessage = vi.fn(async (_message: MobileWebBridgeShellMessage) => {}) + handoff.setContext(CONTEXT) + handoff.declareSupport(capabilityMessage(CONTEXT)) + + expect(handoff.request(postMessage, vi.fn())).toBe(false) + }) +}) + +function readyHandoff(timeoutMs?: number): MobileWebHardwareBackHandoff { + const handoff = new MobileWebHardwareBackHandoff(timeoutMs) + handoff.setContext(CONTEXT) + handoff.acknowledgeReady(CONTEXT) + return handoff +} + +function supportedHandoff(timeoutMs?: number): MobileWebHardwareBackHandoff { + const handoff = readyHandoff(timeoutMs) + handoff.declareSupport(capabilityMessage(CONTEXT)) + return handoff +} + +function capabilityMessage( + context: MobileWebBridgeMessageContext +): Extract { + return { + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'hardwareBackCapability', + revision: 1, + ...context + } +} + +function resultMessage( + sequence: number, + handled: boolean +): Extract { + return { + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'hardwareBackResult', + sequence, + handled, + ...CONTEXT + } +} diff --git a/mobile/src/mobile-web/use-mobile-web-hardware-back-handoff.test.tsx b/mobile/src/mobile-web/use-mobile-web-hardware-back-handoff.test.tsx new file mode 100644 index 00000000000..6eaedd2ffe4 --- /dev/null +++ b/mobile/src/mobile-web/use-mobile-web-hardware-back-handoff.test.tsx @@ -0,0 +1,118 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type MobileWebBridgePageMessage, + type MobileWebBridgeShellMessage +} from '../../../src/shared/mobile-web/bridge-contract' +import { useMobileWebHardwareBackHandoff } from './use-mobile-web-hardware-back-handoff' +import type { MobileWebHardwareBackHandoff } from './mobile-web-hardware-back-handoff' + +const backState = vi.hoisted(() => ({ + handler: null as (() => boolean) | null, + remove: vi.fn() +})) + +vi.mock('expo-router', async () => { + const { useEffect } = await import('react') + return { + useFocusEffect(effect: () => void | (() => void)) { + useEffect(effect, [effect]) + } + } +}) +vi.mock('react-native', () => ({ + Platform: { OS: 'android' }, + BackHandler: { + addEventListener: vi.fn((_event, handler: () => boolean) => { + backState.handler = handler + return { remove: backState.remove } + }) + } +})) + +const CONTEXT = { + shellSessionId: 'S'.repeat(43), + buildId: 'a'.repeat(64) +} + +describe('Android hardware Back hook', () => { + let renderer: ReactTestRenderer | null = null + let handoff: MobileWebHardwareBackHandoff | null = null + const postMessage = vi.fn(async (_message: MobileWebBridgeShellMessage) => {}) + const onUnhandled = vi.fn() + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + backState.handler = null + handoff = null + vi.clearAllMocks() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('falls back in the native shell while the page has not declared support', () => { + renderProbe() + + expect(backState.handler?.()).toBe(true) + expect(postMessage).not.toHaveBeenCalled() + expect(onUnhandled).toHaveBeenCalledOnce() + }) + + it('forwards negotiated requests and returns unhandled results to the shell', () => { + renderProbe() + act(() => { + handoff?.acknowledgeReady(CONTEXT) + handoff?.declareSupport(capabilityMessage()) + }) + + expect(backState.handler?.()).toBe(true) + const request = postMessage.mock.calls[0]![0] as Extract< + MobileWebBridgeShellMessage, + { type: 'hardwareBack' } + > + expect(request.type).toBe('hardwareBack') + act(() => { + handoff?.resolve({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'hardwareBackResult', + sequence: request.sequence, + handled: false, + ...CONTEXT + }) + }) + expect(onUnhandled).toHaveBeenCalledOnce() + }) + + function renderProbe(): void { + function Probe() { + handoff = useMobileWebHardwareBackHandoff({ + shellSessionId: CONTEXT.shellSessionId, + buildId: CONTEXT.buildId, + forwardingEnabled: true, + postMessage, + onUnhandled + }) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + } +}) + +function capabilityMessage(): Extract< + MobileWebBridgePageMessage, + { type: 'hardwareBackCapability' } +> { + return { + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'hardwareBackCapability', + revision: 1, + ...CONTEXT + } +} diff --git a/mobile/src/mobile-web/use-mobile-web-hardware-back-handoff.ts b/mobile/src/mobile-web/use-mobile-web-hardware-back-handoff.ts new file mode 100644 index 00000000000..54af8c6f389 --- /dev/null +++ b/mobile/src/mobile-web/use-mobile-web-hardware-back-handoff.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useFocusEffect } from 'expo-router' +import { BackHandler, Platform } from 'react-native' +import type { + MobileWebBridgeMessageContext, + MobileWebBridgeShellMessage +} from '../../../src/shared/mobile-web/bridge-contract' +import { MobileWebHardwareBackHandoff } from './mobile-web-hardware-back-handoff' + +type HardwareBackHandoffOptions = { + shellSessionId: string | undefined + buildId: string | undefined + forwardingEnabled: boolean + postMessage: (message: MobileWebBridgeShellMessage) => Promise + onUnhandled: () => void +} + +export function useMobileWebHardwareBackHandoff({ + shellSessionId, + buildId, + forwardingEnabled, + postMessage, + onUnhandled +}: HardwareBackHandoffOptions): MobileWebHardwareBackHandoff { + const [handoff] = useState(() => new MobileWebHardwareBackHandoff()) + const forwardingEnabledRef = useRef(forwardingEnabled) + const postMessageRef = useRef(postMessage) + const onUnhandledRef = useRef(onUnhandled) + forwardingEnabledRef.current = forwardingEnabled + postMessageRef.current = postMessage + onUnhandledRef.current = onUnhandled + + useEffect(() => { + const context: MobileWebBridgeMessageContext | null = + shellSessionId && buildId ? { shellSessionId, buildId } : null + handoff.setContext(context) + return () => handoff.clear() + }, [buildId, handoff, shellSessionId]) + + useFocusEffect( + useCallback(() => { + if (Platform.OS !== 'android') { + return + } + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + const forwarded = + forwardingEnabledRef.current && + handoff.request( + (message) => postMessageRef.current(message), + () => onUnhandledRef.current() + ) + if (!forwarded) { + onUnhandledRef.current() + } + return true + }) + return () => { + subscription.remove() + handoff.cancelPending() + } + }, [handoff]) + ) + + return handoff +} diff --git a/mobile/src/mobile-web/use-mobile-web-package-capability.ts b/mobile/src/mobile-web/use-mobile-web-package-capability.ts new file mode 100644 index 00000000000..2ab064baad4 --- /dev/null +++ b/mobile/src/mobile-web/use-mobile-web-package-capability.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react' +import { MOBILE_WEB_PACKAGE_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import type { RpcClient } from '../transport/rpc-client' +import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' +import type { ConnectionState } from '../transport/types' + +export type MobileWebPackageCapabilityStatus = + | 'offline' + | 'pending' + | 'supported' + | 'update-required' + +type ResolvedPackageCapability = { + client: RpcClient + hostId: string + supported: boolean +} + +export function useMobileWebPackageCapability(args: { + client: RpcClient | null + hostId: string | undefined + state: ConnectionState +}): MobileWebPackageCapabilityStatus { + const { client, hostId, state } = args + const [resolved, setResolved] = useState(null) + const [unverified, setUnverified] = useState(true) + + useEffect(() => { + if (state !== 'connected' || !client || !hostId) { + setUnverified(true) + return + } + const requestClient = client + const requestHostId = hostId + return startRuntimeCapabilityProbe(requestClient, (capabilities) => { + setResolved({ + client: requestClient, + hostId: requestHostId, + supported: capabilities.includes(MOBILE_WEB_PACKAGE_RUNTIME_CAPABILITY) + }) + setUnverified(false) + }) + }, [client, hostId, state]) + + if (state !== 'connected') { + return 'offline' + } + if ( + !client || + !hostId || + unverified || + resolved?.client !== client || + resolved.hostId !== hostId + ) { + return 'pending' + } + return resolved.supported ? 'supported' : 'update-required' +} diff --git a/mobile/src/mobile-web/use-mobile-web-package-session.test.ts b/mobile/src/mobile-web/use-mobile-web-package-session.test.ts index 0524ec37195..eb6f626b1e3 100644 --- a/mobile/src/mobile-web/use-mobile-web-package-session.test.ts +++ b/mobile/src/mobile-web/use-mobile-web-package-session.test.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION } from '../../../src/shared/mobile-web/bridge-contract' +import { MOBILE_WEB_PACKAGE_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, HostProfile } from '../transport/types' @@ -39,7 +40,8 @@ const HOST: HostProfile = { publicKeyB64: 'paired-public-key', lastConnected: 1 } -const CLIENT = { sendRequest: vi.fn() } as unknown as RpcClient +const sendRequest = vi.fn() +const CLIENT = { sendRequest } as unknown as RpcClient const SESSION_A = { sessionId: 'session-a', buildId: 'a'.repeat(64), @@ -65,6 +67,10 @@ describe('useMobileWebPackageSession', () => { removeHostCache.mockReset().mockResolvedValue(undefined) downloadPackage.mockReset() downloadFailure.code = 'test_failure' + sendRequest.mockReset().mockResolvedValue({ + ok: true, + result: { capabilities: [MOBILE_WEB_PACKAGE_RUNTIME_CAPABILITY] } + }) }) afterEach(() => { @@ -118,6 +124,106 @@ describe('useMobileWebPackageSession', () => { expect(native.closeSession).not.toHaveBeenCalledWith(SESSION_A.sessionId) }) + it('keeps a supported session mounted when the host disconnects', async () => { + native.openSession.mockResolvedValue(SESSION_A) + downloadPackage.mockResolvedValue({ commit: { buildId: SESSION_A.buildId } }) + await mount('connected') + expect(packageSession?.session).toEqual(SESSION_A) + + await act(async () => { + renderer?.update(createElement(Harness, { state: 'disconnected' })) + await flushPromises() + }) + + expect(packageSession?.session).toEqual(SESSION_A) + expect(native.closeSession).not.toHaveBeenCalledWith(SESSION_A.sessionId) + expect(native.openSession).toHaveBeenCalledTimes(1) + }) + + it('keeps a supported session mounted while a reconnected client is probed', async () => { + native.openSession.mockResolvedValue(SESSION_A) + downloadPackage.mockResolvedValue({ commit: { buildId: SESSION_A.buildId } }) + await mount('connected') + expect(packageSession?.session).toEqual(SESSION_A) + + await act(async () => { + renderer?.update(createElement(Harness, { state: 'disconnected' })) + await flushPromises() + }) + const status = deferred>>() + sendRequest.mockReturnValue(status.promise) + await act(async () => { + renderer?.update(createElement(Harness, { state: 'connected' })) + await flushPromises() + }) + + expect(packageSession?.session).toEqual(SESSION_A) + expect(native.closeSession).not.toHaveBeenCalledWith(SESSION_A.sessionId) + await act(async () => { + status.resolve({ + ok: true, + result: { capabilities: [MOBILE_WEB_PACKAGE_RUNTIME_CAPABILITY] } + }) + await status.promise + await flushPromises() + }) + expect(packageSession?.session).toEqual(SESSION_A) + expect(native.openSession).toHaveBeenCalledTimes(1) + }) + + it('does not open cache or request package RPCs while capability status is pending', async () => { + const status = deferred>>() + sendRequest.mockReturnValue(status.promise) + native.openSession.mockResolvedValue(SESSION_A) + downloadPackage.mockResolvedValue({ commit: { buildId: SESSION_B.buildId } }) + + await mount('connected') + + expect(packageSession?.session).toBeNull() + expect(packageSession?.packageLoading).toBe(true) + expect(native.openSession).not.toHaveBeenCalled() + expect(downloadPackage).not.toHaveBeenCalled() + expect(sendRequest).toHaveBeenCalledWith('status.get') + }) + + it('removes cached UI and surfaces update-required for an unsupported connected host', async () => { + native.openSession.mockResolvedValue(SESSION_A) + await mount('disconnected') + expect(packageSession?.session).toEqual(SESSION_A) + + sendRequest.mockResolvedValue({ ok: true, result: { capabilities: [] } }) + await act(async () => { + renderer?.update(createElement(Harness, { state: 'connected' })) + await flushPromises() + }) + + expect(packageSession?.session).toBeNull() + expect(packageSession?.packageLoading).toBe(false) + expect(packageSession?.packageWarning).toBe( + 'Update Orca on this desktop to use its workspace interface.' + ) + expect(native.closeSession).toHaveBeenCalledWith(SESSION_A.sessionId) + expect(downloadPackage).not.toHaveBeenCalled() + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('rejects malformed status capabilities before cache or package access', async () => { + sendRequest.mockResolvedValue({ + ok: true, + result: { capabilities: [MOBILE_WEB_PACKAGE_RUNTIME_CAPABILITY, 42] } + }) + native.openSession.mockResolvedValue(SESSION_A) + + await mount('connected') + + expect(packageSession?.session).toBeNull() + expect(packageSession?.packageWarning).toBe( + 'Update Orca on this desktop to use its workspace interface.' + ) + expect(native.openSession).not.toHaveBeenCalled() + expect(downloadPackage).not.toHaveBeenCalled() + }) + it('does not let a delayed cache open replace a refreshed build', async () => { const cached = deferred() native.openSession.mockImplementation((_host: string, buildId: string | null) => @@ -147,6 +253,66 @@ describe('useMobileWebPackageSession', () => { expect(native.closeSession).toHaveBeenCalledWith(SESSION_A.sessionId) }) + it('skips staging and replacement activation when cache verification finishes first', async () => { + const manifestReady = deferred() + native.openSession.mockResolvedValue(SESSION_A) + downloadPackage.mockImplementation(async (_request, _stager, options) => { + await manifestReady.promise + const reused = await options.reuseVerifiedBuild(SESSION_A.buildId) + return { + manifest: { buildId: SESSION_A.buildId }, + commit: reused ? null : { buildId: SESSION_A.buildId }, + reusedVerifiedBuild: reused + } + }) + + await mount('connected') + expect(packageSession?.session).toEqual(SESSION_A) + + await act(async () => { + manifestReady.resolve() + await manifestReady.promise + await flushPromises() + }) + + expect(native.openSession).toHaveBeenCalledTimes(1) + expect(native.openSession).toHaveBeenCalledWith( + HOST.publicKeyB64, + null, + MOBILE_WEB_BRIDGE_PROTOCOL_VERSION + ) + expect(packageSession?.packageLoading).toBe(false) + }) + + it('waits for cache verification when the manifest finishes first', async () => { + const cached = deferred() + let reuseCheckStarted = false + native.openSession.mockReturnValue(cached.promise) + downloadPackage.mockImplementation(async (_request, _stager, options) => { + reuseCheckStarted = true + const reused = await options.reuseVerifiedBuild(SESSION_A.buildId) + return { + manifest: { buildId: SESSION_A.buildId }, + commit: reused ? null : { buildId: SESSION_A.buildId }, + reusedVerifiedBuild: reused + } + }) + + await mount('connected') + expect(reuseCheckStarted).toBe(true) + expect(packageSession?.session).toBeNull() + + await act(async () => { + cached.resolve(SESSION_A) + await cached.promise + await flushPromises() + }) + + expect(packageSession?.session).toEqual(SESSION_A) + expect(native.openSession).toHaveBeenCalledTimes(1) + expect(packageSession?.packageLoading).toBe(false) + }) + it('keeps the loading state while a first desktop refresh is active', async () => { const refresh = deferred<{ commit: { buildId: string } }>() native.openSession.mockRejectedValue(new Error('cache unavailable')) diff --git a/mobile/src/mobile-web/use-mobile-web-package-session.ts b/mobile/src/mobile-web/use-mobile-web-package-session.ts index 764b86ebf76..22b6c609aa5 100644 --- a/mobile/src/mobile-web/use-mobile-web-package-session.ts +++ b/mobile/src/mobile-web/use-mobile-web-package-session.ts @@ -10,9 +10,17 @@ import { mobileWebPackageDownloadFailureCode } from './mobile-web-package-downloader' import { mobileWebDiagnosticsStore } from './mobile-web-diagnostics-store' +import { + createMobileWebCachedBuildProbe, + type MobileWebCachedBuildProbe +} from './mobile-web-cached-build-probe' import { mobileWebPackageRefreshWarning } from './mobile-web-package-refresh-warning' +import { useMobileWebPackageCapability } from './use-mobile-web-package-capability' import { useMobileWebPackageRecovery } from './use-mobile-web-package-recovery' +const MOBILE_WEB_PACKAGE_UPDATE_REQUIRED_WARNING = + 'Update Orca on this desktop to use its workspace interface.' + export type MobileWebPackageSession = { session: MobileWebShellSession | null viewEpoch: number @@ -39,6 +47,7 @@ export function useMobileWebPackageSession({ const hostEpochRef = useRef(0) const activeHostIdRef = useRef(null) const ownedSessionRef = useRef(null) + const cachedBuildProbeRef = useRef(null) const refreshingHostEpochRef = useRef(null) const processFailuresRef = useRef(new MobileWebProcessFailureTracker()) const rejectedBuildIdsRef = useRef(new Set()) @@ -47,6 +56,15 @@ export function useMobileWebPackageSession({ const [packageLoading, setPackageLoading] = useState(false) const [packageWarning, setPackageWarning] = useState() const [refreshEpoch, setRefreshEpoch] = useState(0) + const packageCapability = useMobileWebPackageCapability({ + client, + hostId: host?.id, + state + }) + const packageAccessAllowed = + packageCapability === 'offline' || + packageCapability === 'supported' || + (packageCapability === 'pending' && ownedSessionRef.current !== null) const publishSession = useCallback( async ( @@ -82,6 +100,9 @@ export function useMobileWebPackageSession({ useEffect(() => { const hostEpoch = hostEpochRef.current + 1 hostEpochRef.current = hostEpoch + cachedBuildProbeRef.current?.resolve(null) + const cachedBuildProbe = createMobileWebCachedBuildProbe(hostEpoch) + cachedBuildProbeRef.current = cachedBuildProbe activeHostIdRef.current = host?.id ?? null refreshingHostEpochRef.current = null processFailuresRef.current.reset() @@ -96,18 +117,27 @@ export function useMobileWebPackageSession({ void ExpoMobileWebShell.closeSession(previous.sessionId).catch(() => {}) } if (!host) { + cachedBuildProbe.resolve(null) return } mobileWebDiagnosticsStore.begin(host.id) + if (!packageAccessAllowed) { + cachedBuildProbe.resolve(null) + return + } let disposed = false const cacheActivationStartedAt = Date.now() void ExpoMobileWebShell.openSession(host.publicKeyB64, null, MOBILE_WEB_BRIDGE_PROTOCOL_VERSION) .then(async (cached) => { - await (!disposed && !ownedSessionRef.current + const published = await (!disposed && !ownedSessionRef.current ? publishSession(cached, hostEpoch, host.id, 'verified-cache', cacheActivationStartedAt) - : ExpoMobileWebShell.closeSession(cached.sessionId).catch(() => {})) + : ExpoMobileWebShell.closeSession(cached.sessionId) + .catch(() => {}) + .then(() => false)) + cachedBuildProbe.resolve(published ? cached.buildId : null) }) .catch(() => { + cachedBuildProbe.resolve(null) if (!disposed && hostEpochRef.current === hostEpoch && !ownedSessionRef.current) { mobileWebDiagnosticsStore.warning(host.id, 'cache_open_failed') if (refreshingHostEpochRef.current !== hostEpoch) { @@ -121,6 +151,7 @@ export function useMobileWebPackageSession({ }) return () => { disposed = true + cachedBuildProbe.resolve(null) if (hostEpochRef.current !== hostEpoch) { return } @@ -132,13 +163,25 @@ export function useMobileWebPackageSession({ void ExpoMobileWebShell.closeSession(closing.sessionId).catch(() => {}) } } - }, [host?.id, host?.publicKeyB64, publishSession]) + }, [host?.id, host?.publicKeyB64, packageAccessAllowed, publishSession]) useEffect(() => { - if (!host || !client || state !== 'connected') { + if (!host || packageAccessAllowed) { + return + } + setPackageLoading(packageCapability === 'pending') + if (packageCapability === 'update-required') { + mobileWebDiagnosticsStore.warning(host.id, 'host_update_required') + setPackageWarning(MOBILE_WEB_PACKAGE_UPDATE_REQUIRED_WARNING) + } + }, [host?.id, packageAccessAllowed, packageCapability]) + + useEffect(() => { + if (!host || !client || state !== 'connected' || packageCapability !== 'supported') { return } const hostEpoch = hostEpochRef.current + const cachedBuildProbe = cachedBuildProbeRef.current const controller = new AbortController() refreshingHostEpochRef.current = hostEpoch setPackageLoading(true) @@ -151,12 +194,28 @@ export function useMobileWebPackageSession({ createMobileWebNativeStager(host.publicKeyB64), { shellBridgeVersion: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, - signal: controller.signal + signal: controller.signal, + reuseVerifiedBuild: async (buildId) => { + const verifiedBuildId = + cachedBuildProbe?.hostEpoch === hostEpoch ? await cachedBuildProbe.promise : null + return ( + !controller.signal.aborted && + hostEpochRef.current === hostEpoch && + verifiedBuildId === buildId && + ownedSessionRef.current?.buildId === buildId + ) + } } ) if (controller.signal.aborted || hostEpochRef.current !== hostEpoch) { return } + if (downloaded.reusedVerifiedBuild) { + mobileWebDiagnosticsStore.refreshSucceeded(host.id, Date.now() - refreshStartedAt) + setPackageLoading(false) + setPackageWarning(undefined) + return + } if (ownedSessionRef.current?.buildId === downloaded.commit.buildId) { mobileWebDiagnosticsStore.refreshSucceeded(host.id, Date.now() - refreshStartedAt) setPackageLoading(false) @@ -207,7 +266,7 @@ export function useMobileWebPackageSession({ refreshingHostEpochRef.current = null } } - }, [client, host?.id, host?.publicKeyB64, publishSession, refreshEpoch, state]) + }, [client, host?.id, host?.publicKeyB64, packageCapability, publishSession, refreshEpoch, state]) const { markHealthy, diff --git a/mobile/src/mobile-web/use-mobile-web-route-params.ts b/mobile/src/mobile-web/use-mobile-web-route-params.ts new file mode 100644 index 00000000000..d63db0e9a30 --- /dev/null +++ b/mobile/src/mobile-web/use-mobile-web-route-params.ts @@ -0,0 +1,10 @@ +import { useLocalSearchParams, usePathname } from 'expo-router' +import { mobileWebRouteQuery } from './mobile-web-route-query-cache' + +export function useMobileWebRouteParams< + T extends Record +>(): T { + const pathname = usePathname() + const params = useLocalSearchParams() as T + return { ...mobileWebRouteQuery(pathname), ...params } as T +} diff --git a/mobile/src/notifications/use-open-notification-route.ts b/mobile/src/notifications/use-open-notification-route.ts deleted file mode 100644 index c3903d06308..00000000000 --- a/mobile/src/notifications/use-open-notification-route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useCallback } from 'react' -import { useRouter } from 'expo-router' -import { MOBILE_WEB_NAVIGATION_INTENTS } from '../mobile-web/mobile-web-navigation-intent-buffer' -import { mobileHostWorkspaceEntry } from '../mobile-web/mobile-web-home-navigation' -import type { NotificationNavigationTarget } from './notification-routing' - -export function useOpenNotificationRoute(): (target: NotificationNavigationTarget) => void { - const router = useRouter() - - return useCallback( - (target) => { - MOBILE_WEB_NAVIGATION_INTENTS.publish(target) - router.push(mobileHostWorkspaceEntry(target.hostId, false)) - }, - [router] - ) -} diff --git a/mobile/src/session/mobile-native-chat-message-occurrences.ts b/mobile/src/session/mobile-native-chat-message-occurrences.ts deleted file mode 100644 index c367e825634..00000000000 --- a/mobile/src/session/mobile-native-chat-message-occurrences.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' - -export function normalizedMobileNativeChatUserText(message: NativeChatMessage): string | null { - if (message.role !== 'user') { - return null - } - const text = message.blocks - .filter((block) => block.type === 'text') - .map((block) => (block.type === 'text' ? block.text : '')) - .join('') - .trim() - return text || null -} - -export function countMobileNativeChatUserTextOccurrences( - messages: readonly NativeChatMessage[], - text: string -): number { - let count = 0 - for (const message of messages) { - if (normalizedMobileNativeChatUserText(message) === text) { - count++ - } - } - return count -} diff --git a/mobile/src/session/mobile-session-file-tab-activation.ts b/mobile/src/session/mobile-session-file-tab-activation.ts new file mode 100644 index 00000000000..912377f77b9 --- /dev/null +++ b/mobile/src/session/mobile-session-file-tab-activation.ts @@ -0,0 +1,135 @@ +import type { RpcClient } from '../transport/rpc-client' +import { activateMobileSessionTab } from './mobile-session-tab-activation' + +type SessionFileTabCandidate = { + id: string + type: string + mode?: unknown + relativePath?: unknown + diffSource?: unknown +} + +type Options = { + client: Pick + worktreeId: string + relativePath: string + tabMode: 'diff' | 'edit' + staged: boolean + isCurrent?: () => boolean +} + +export type MobileSessionFileTabActivationResult = 'activated' | 'cancelled' | 'timeout' + +const TAB_POLL_DELAYS_MS = [0, 300, 600, 900] as const + +export async function activateMobileSessionFileTab( + options: Options +): Promise { + for (const delayMs of TAB_POLL_DELAYS_MS) { + await waitForDelay(delayMs) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + + const tab = await findOpenedSessionFileTab(options) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (!tab) { + continue + } + + const activated = await activateSessionFileTab(options, tab.id) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (activated) { + return 'activated' + } + } + + return 'timeout' +} + +async function findOpenedSessionFileTab(options: Options): Promise { + try { + const response = await options.client.sendRequest('session.tabs.list', { + worktree: `id:${options.worktreeId}` + }) + if (!response.ok) { + return null + } + const snapshot = readTabSnapshot(response.result) + if (!snapshot) { + return null + } + + const matches = snapshot.tabs.filter( + (tab) => + tab.type !== 'browser' && + tab.type !== 'terminal' && + matchesTabMode(tab.mode, options.tabMode) && + tab.relativePath === options.relativePath + ) + if (options.tabMode === 'edit') { + return matches[0] ?? null + } + const source = options.staged ? 'staged' : 'unstaged' + return ( + matches.find((tab) => tab.diffSource === source) ?? + matches.find((tab) => tab.diffSource == null) ?? + null + ) + } catch { + return null + } +} + +function matchesTabMode(mode: unknown, expected: 'diff' | 'edit'): boolean { + return expected === 'diff' ? mode === 'diff' : mode === 'edit' || mode == null +} + +async function activateSessionFileTab(options: Options, tabId: string): Promise { + try { + const response = await activateMobileSessionTab(options.client, { + worktree: `id:${options.worktreeId}`, + tabId, + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + return response.ok && readActiveTabId(response.result) === tabId + } catch { + return false + } +} + +function readTabSnapshot(value: unknown): { tabs: SessionFileTabCandidate[] } | null { + if ( + !isRecord(value) || + !Array.isArray(value.tabs) || + !value.tabs.every(isSessionFileTabCandidate) + ) { + return null + } + return { tabs: value.tabs } +} + +function isSessionFileTabCandidate(value: unknown): value is SessionFileTabCandidate { + return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string' +} + +function readActiveTabId(value: unknown): string | null { + return isRecord(value) && typeof value.activeTabId === 'string' ? value.activeTabId : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +async function waitForDelay(delayMs: number): Promise { + if (delayMs === 0) { + return + } + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} diff --git a/mobile/src/session/use-mobile-native-chat-cancel-ask.ts b/mobile/src/session/use-mobile-native-chat-cancel-ask.ts deleted file mode 100644 index fb09f2d4aad..00000000000 --- a/mobile/src/session/use-mobile-native-chat-cancel-ask.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useCallback, type MutableRefObject } from 'react' -import type { RpcClient } from '../transport/rpc-client' -import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' - -/** Sends the Escape that dismisses an ask/question card. Its own module for the - * same reason stop/permission/answer are: the controller owns composition, not - * the per-action write semantics. */ -export function useMobileNativeChatCancelAsk(args: { - client: RpcClient | null - enabled: boolean - handleRef: MutableRefObject - deviceTokenRef: MutableRefObject - /** Drops any in-flight paced answer writes before the Escape lands. */ - cancelPending: () => void - onSendError: (message: string) => void -}): () => Promise { - const { client, enabled, handleRef, deviceTokenRef, cancelPending, onSendError } = args - return useCallback(async (): Promise => { - const handle = handleRef.current - if (!client || !handle || !enabled) { - onSendError('Cancel not sent (disconnected)') - return false - } - cancelPending() - // Escape never submits the composer, so no stale-input heal: it would consume - // the marker still protecting the next real message. - const outcome = await sendMobileNativeChatMessageWithOutcome({ - client, - terminal: handle, - text: String.fromCharCode(27), - enter: false, - ...(deviceTokenRef.current - ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } - : {}) - }) - if (outcome === 'unknown') { - // Why: the Escape may have landed (ack lost / path cutover) — a definite - // "not sent" would invite a second Escape into a changed prompt state. - onSendError('Cancel unconfirmed — check chat before retrying') - } else if (outcome === 'rejected') { - onSendError('Cancel not sent') - } - return outcome === 'accepted' - }, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError]) -} diff --git a/mobile/src/session/use-open-mobile-session.ts b/mobile/src/session/use-open-mobile-session.ts deleted file mode 100644 index 249c9924cb6..00000000000 --- a/mobile/src/session/use-open-mobile-session.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useCallback } from 'react' -import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route' -import { mobileSessionRouteTarget, type MobileSessionRouteParams } from './mobile-session-route' - -export function useOpenMobileSession(): (params: MobileSessionRouteParams) => void { - const openHostStackRoute = useOpenHostStackRoute() - - return useCallback( - (params) => { - openHostStackRoute(params.hostId, mobileSessionRouteTarget(params)) - }, - [openHostStackRoute] - ) -} diff --git a/mobile/src/source-control/mobile-web-source-control-screen-binding-source.test.ts b/mobile/src/source-control/mobile-web-source-control-screen-binding-source.test.ts index 8ecd0433f3c..dd6892bbb28 100644 --- a/mobile/src/source-control/mobile-web-source-control-screen-binding-source.test.ts +++ b/mobile/src/source-control/mobile-web-source-control-screen-binding-source.test.ts @@ -30,6 +30,7 @@ describe('mobile web source control screen binding', () => { "import { MobileSourceControlRoute } from '../../../../app/h/[hostId]/source-control/[worktreeId]'" ) expect(hostedRoute).toContain('webHostSourceControlClient(shell.client, workspaceId)') + expect(hostedRoute).not.toContain('routeOrigin=') expect(hostedRoute).toContain('binding={{') expect(hostedRoute).toContain('shell.client?.native.hapticSelection()') expect(hostedRoute).toContain("shell.client?.native.hapticFeedback('success')") diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts index 9391c0c2961..e65492af80d 100644 --- a/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts @@ -1,16 +1,8 @@ import type { RpcClient } from '../transport/rpc-client' -import { activateMobileSessionTab } from '../session/mobile-session-tab-activation' +import { activateMobileSessionFileTab } from '../session/mobile-session-file-tab-activation' type ActivationClient = Pick -type SessionFileTabCandidate = { - id: string - type: string - mode?: unknown - relativePath?: unknown - diffSource?: unknown -} - type Options = { client: ActivationClient worktreeId: string @@ -23,8 +15,6 @@ type Options = { export type MobileSourceControlSessionDiffRevealResult = 'revealed' | 'cancelled' | 'timeout' -const TAB_POLL_DELAYS_MS = [0, 300, 600, 900] as const - export async function revealMobileSourceControlSessionDiff( options: Options ): Promise { @@ -33,111 +23,6 @@ export async function revealMobileSourceControlSessionDiff( return 'revealed' } - for (const delayMs of TAB_POLL_DELAYS_MS) { - await waitForDelay(delayMs) - if (options.isCurrent?.() === false) { - return 'cancelled' - } - - const tab = await findOpenedSessionFileTab(options) - if (options.isCurrent?.() === false) { - return 'cancelled' - } - if (!tab) { - continue - } - - const activated = await activateSessionFileTab(options, tab.id) - if (options.isCurrent?.() === false) { - return 'cancelled' - } - if (activated) { - return 'revealed' - } - } - - return 'timeout' -} - -async function findOpenedSessionFileTab(options: Options): Promise { - try { - const response = await options.client.sendRequest('session.tabs.list', { - worktree: `id:${options.worktreeId}` - }) - if (!response.ok) { - return null - } - const snapshot = readTabSnapshot(response.result) - if (!snapshot) { - return null - } - - const matches = snapshot.tabs.filter( - (tab) => - tab.type !== 'browser' && - tab.type !== 'terminal' && - matchesTabMode(tab.mode, options.tabMode) && - tab.relativePath === options.relativePath - ) - if (options.tabMode === 'edit') { - return matches[0] ?? null - } - const source = options.staged ? 'staged' : 'unstaged' - return ( - matches.find((tab) => tab.diffSource === source) ?? - matches.find((tab) => tab.diffSource == null) ?? - null - ) - } catch { - return null - } -} - -function matchesTabMode(mode: unknown, expected: 'diff' | 'edit'): boolean { - return expected === 'diff' ? mode === 'diff' : mode === 'edit' || mode == null -} - -async function activateSessionFileTab(options: Options, tabId: string): Promise { - try { - const response = await activateMobileSessionTab(options.client, { - worktree: `id:${options.worktreeId}`, - tabId, - notifyClients: false, - navigation: 'caller', - intent: 'user' - }) - return response.ok && readActiveTabId(response.result) === tabId - } catch { - return false - } -} - -function readTabSnapshot(value: unknown): { tabs: SessionFileTabCandidate[] } | null { - if ( - !isRecord(value) || - !Array.isArray(value.tabs) || - !value.tabs.every(isSessionFileTabCandidate) - ) { - return null - } - return { tabs: value.tabs } -} - -function isSessionFileTabCandidate(value: unknown): value is SessionFileTabCandidate { - return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string' -} - -function readActiveTabId(value: unknown): string | null { - return isRecord(value) && typeof value.activeTabId === 'string' ? value.activeTabId : null -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -async function waitForDelay(delayMs: number): Promise { - if (delayMs === 0) { - return - } - await new Promise((resolve) => setTimeout(resolve, delayMs)) + const result = await activateMobileSessionFileTab(options) + return result === 'activated' ? 'revealed' : result } diff --git a/mobile/src/source-control/use-mobile-source-control-keyboard-lift.ts b/mobile/src/source-control/use-mobile-source-control-keyboard-lift.ts index 7d3b274bcd0..db39bea8f56 100644 --- a/mobile/src/source-control/use-mobile-source-control-keyboard-lift.ts +++ b/mobile/src/source-control/use-mobile-source-control-keyboard-lift.ts @@ -1,25 +1,5 @@ -import { useEffect, useState } from 'react' -import { Keyboard, Platform } from 'react-native' +import { useMobileKeyboardInset } from '../hooks/use-mobile-keyboard-inset' export function useMobileSourceControlKeyboardLift(): number { - const [keyboardLift, setKeyboardLift] = useState(0) - - useEffect(() => { - const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' - const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' - - const onShow = Keyboard.addListener(showEvent, (event) => { - // Why: iOS keyboard height already describes the obscured screen area. - // Subtracting the safe-area inset lets the commit bar tuck under the keyboard. - setKeyboardLift(Math.max(0, event.endCoordinates.height)) - }) - const onHide = Keyboard.addListener(hideEvent, () => setKeyboardLift(0)) - - return () => { - onShow.remove() - onHide.remove() - } - }, []) - - return keyboardLift + return useMobileKeyboardInset().height } diff --git a/mobile/src/source-control/web-host-provider-review-creation.ts b/mobile/src/source-control/web-host-provider-review-creation.ts index dce7457fc21..ba86d2dc9db 100644 --- a/mobile/src/source-control/web-host-provider-review-creation.ts +++ b/mobile/src/source-control/web-host-provider-review-creation.ts @@ -1,5 +1,6 @@ import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' import type { MobileWebProviderReviewEligibilityResult } from '../../../src/shared/mobile-web/provider-review-creation-contract' +import type { WebHostSourceControlStatusSnapshot } from './web-host-source-control-status-snapshot' type RequestParams = Record @@ -18,6 +19,7 @@ export async function handleWebHostProviderReviewCreation(args: { method: string params: RequestParams eligibilityCache: WebHostProviderReviewEligibilityCache + statusSnapshot: WebHostSourceControlStatusSnapshot }): Promise { if ( args.method !== 'hostedReview.getCreationEligibility' && @@ -26,10 +28,7 @@ export async function handleWebHostProviderReviewCreation(args: { ) { return WEB_HOST_PROVIDER_REVIEW_CREATION_UNHANDLED } - const status = await args.client.sourceControlStatus({ - workspaceId: args.workspaceId, - limit: 64 - }) + const status = await args.statusSnapshot.read() if (!status.head || !status.branch) { throw new Error('conflict') } diff --git a/mobile/src/source-control/web-host-provider-review-loader.ts b/mobile/src/source-control/web-host-provider-review-loader.ts index 667b921c63a..9714934a838 100644 --- a/mobile/src/source-control/web-host-provider-review-loader.ts +++ b/mobile/src/source-control/web-host-provider-review-loader.ts @@ -5,6 +5,7 @@ import { takeWebHostProviderReviewEligibility, type WebHostProviderReviewEligibilityCache } from './web-host-provider-review-creation' +import type { WebHostSourceControlStatusSnapshot } from './web-host-source-control-status-snapshot' export type WebHostProviderReviewCache = { key: string | null @@ -19,9 +20,10 @@ export function createWebHostProviderReviewCache(): WebHostProviderReviewCache { export async function readWebHostGitHubRepositoryEligibility( client: MobileWebBridgeClient, workspaceId: string, - cache: WebHostProviderReviewEligibilityCache + cache: WebHostProviderReviewEligibilityCache, + statusSnapshot: WebHostSourceControlStatusSnapshot ) { - const status = await client.sourceControlStatus({ workspaceId, limit: 1 }) + const status = await statusSnapshot.read() if (!status.head || !status.branch) { throw new Error('conflict') } @@ -41,9 +43,10 @@ export async function loadWebHostProviderReview( client: MobileWebBridgeClient, workspaceId: string, cache: WebHostProviderReviewCache, - eligibilityCache: WebHostProviderReviewEligibilityCache + eligibilityCache: WebHostProviderReviewEligibilityCache, + statusSnapshot: WebHostSourceControlStatusSnapshot ): Promise { - const status = await client.sourceControlStatus({ workspaceId, limit: 64 }) + const status = await statusSnapshot.read() if (!status.head || !status.branch) { throw new Error('conflict') } diff --git a/mobile/src/source-control/web-host-provider-review-requests.test.ts b/mobile/src/source-control/web-host-provider-review-requests.test.ts index dbd81c0a655..29a8f0e6f5f 100644 --- a/mobile/src/source-control/web-host-provider-review-requests.test.ts +++ b/mobile/src/source-control/web-host-provider-review-requests.test.ts @@ -64,6 +64,7 @@ describe('web host provider review requests', () => { ok: true, result: { provider: 'github', blockedReason: 'auth_required' } }) + expect(bridge.sourceControlStatus).toHaveBeenCalledTimes(1) expect(bridge.providerReviewCreationEligibility).toHaveBeenCalledTimes(1) const review = await client.sendRequest('hostedReview.forBranch', { @@ -75,6 +76,7 @@ describe('web host provider review requests', () => { ok: true, result: null }) + expect(bridge.sourceControlStatus).toHaveBeenCalledTimes(1) expect(bridge.providerReview).not.toHaveBeenCalled() }) diff --git a/mobile/src/source-control/web-host-provider-review-requests.ts b/mobile/src/source-control/web-host-provider-review-requests.ts index 72c266cad61..3ed3d08682a 100644 --- a/mobile/src/source-control/web-host-provider-review-requests.ts +++ b/mobile/src/source-control/web-host-provider-review-requests.ts @@ -14,6 +14,7 @@ import { handleWebHostProviderReviewMutation, WEB_HOST_PROVIDER_REVIEW_MUTATION_METHODS } from './web-host-provider-review-mutations' +import type { WebHostSourceControlStatusSnapshot } from './web-host-source-control-status-snapshot' type RequestParams = Record @@ -26,8 +27,9 @@ export async function handleWebHostProviderReviewRequest(args: { params: RequestParams cache: WebHostProviderReviewCache eligibilityCache: WebHostProviderReviewEligibilityCache + statusSnapshot: WebHostSourceControlStatusSnapshot }): Promise { - const { client, workspaceId, method, params, cache, eligibilityCache } = args + const { client, workspaceId, method, params, cache, eligibilityCache, statusSnapshot } = args if ( !PROVIDER_READ_METHODS.has(method) && !WEB_HOST_PROVIDER_REVIEW_MUTATION_METHODS.has(method) @@ -35,9 +37,20 @@ export async function handleWebHostProviderReviewRequest(args: { return WEB_HOST_PROVIDER_REVIEW_UNHANDLED } if (method === 'github.repoSlug') { - return readWebHostGitHubRepositoryEligibility(client, workspaceId, eligibilityCache) + return readWebHostGitHubRepositoryEligibility( + client, + workspaceId, + eligibilityCache, + statusSnapshot + ) } - const loaded = await loadWebHostProviderReview(client, workspaceId, cache, eligibilityCache) + const loaded = await loadWebHostProviderReview( + client, + workspaceId, + cache, + eligibilityCache, + statusSnapshot + ) const review = loaded.review if (method === 'hostedReview.forBranch') { return review ? hostedReviewSummary(review) : null diff --git a/mobile/src/source-control/web-host-source-control-client.test.ts b/mobile/src/source-control/web-host-source-control-client.test.ts index e46f337963f..92b7ca6622b 100644 --- a/mobile/src/source-control/web-host-source-control-client.test.ts +++ b/mobile/src/source-control/web-host-source-control-client.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' +import { MobileWebBridgeClientError } from '../../../src/mobile-web/src/mobile-web-bridge-client-error' import { webHostSourceControlClient } from './web-host-source-control-client' const WORKSPACE_ID = 'workspace-page-1' @@ -138,6 +139,98 @@ describe('web host source control client', () => { }) }) + it('preserves the edit fallback when the host cannot open diffs', async () => { + const bridge = bridgeClient() + bridge.sourceControlReviewOpen.mockRejectedValue( + new MobileWebBridgeClientError('unsupported_capability', false) + ) + bridge.fileOpen.mockResolvedValue(null) + const client = webHostSourceControlClient( + bridge as unknown as MobileWebBridgeClient, + WORKSPACE_ID + ) + + const diff = await client.sendRequest('files.openDiff', { + worktree: `id:${WORKSPACE_ID}`, + relativePath: 'src/app.ts', + staged: false + }) + const file = await client.sendRequest('files.open', { + worktree: `id:${WORKSPACE_ID}`, + relativePath: 'src/app.ts' + }) + + expect(diff).toMatchObject({ ok: false, error: { code: 'method_not_found' } }) + expect(file.ok).toBe(true) + expect(bridge.fileOpen).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + relativePath: 'src/app.ts' + }) + }) + + it('reveals the opened diff through the hosted Session bridge', async () => { + const bridge = bridgeClient() + const snapshot = { + workspaceId: WORKSPACE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 7, + activeTabId: 'diff-1', + activeTabType: 'file' as const, + tabs: [ + { + id: 'diff-1', + title: 'app.ts', + type: 'file' as const, + relativePath: 'src/app.ts', + mode: 'diff' as const, + diffSource: 'unstaged' as const, + isActive: true + } + ], + truncated: false + } + bridge.sessionSnapshot.mockResolvedValue(snapshot) + bridge.sessionActivate.mockResolvedValue(snapshot) + const client = webHostSourceControlClient( + bridge as unknown as MobileWebBridgeClient, + WORKSPACE_ID + ) + + const listed = await client.sendRequest('session.tabs.list', { + worktree: `id:${WORKSPACE_ID}` + }) + const activated = await client.sendRequest('session.tabs.activate', { + worktree: `id:${WORKSPACE_ID}`, + tabId: 'diff-1', + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + + expect(listed).toMatchObject({ + ok: true, + result: { + worktree: WORKSPACE_ID, + activeTabId: 'diff-1', + tabs: [ + { + id: 'diff-1', + type: 'file', + relativePath: 'src/app.ts', + mode: 'diff', + diffSource: 'unstaged' + } + ] + } + }) + expect(bridge.sessionSnapshot).toHaveBeenCalledWith({ workspaceId: WORKSPACE_ID }) + expect(bridge.sessionActivate).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + tabId: 'diff-1' + }) + expect(activated).toMatchObject({ ok: true, result: { activeTabId: 'diff-1' } }) + }) + it('maps push through an exact repository snapshot', async () => { const bridge = bridgeClient() bridge.sourceControlUpstream.mockResolvedValue(repositoryState()) @@ -346,6 +439,9 @@ function bridgeClient() { sourceControlReviewLink: vi.fn(), sourceControlReviewLinkUpdate: vi.fn(), sourceControlReviewOpen: vi.fn(), + fileOpen: vi.fn(), + sessionSnapshot: vi.fn(), + sessionActivate: vi.fn(), providerReviewCreationEligibility: vi.fn(), providerReviewCreate: vi.fn(), providerReviewGenerateFields: vi.fn() diff --git a/mobile/src/source-control/web-host-source-control-client.ts b/mobile/src/source-control/web-host-source-control-client.ts index 3eb480dbd3e..164e754e709 100644 --- a/mobile/src/source-control/web-host-source-control-client.ts +++ b/mobile/src/source-control/web-host-source-control-client.ts @@ -1,4 +1,5 @@ import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' +import { MobileWebBridgeClientError } from '../../../src/mobile-web/src/mobile-web-bridge-client-error' import type { RpcClient } from '../transport/rpc-client' import { mutateWebHostSourceControlRequest, @@ -10,7 +11,8 @@ import { } from './web-host-source-control-reads' import { hostedSourceControlFailure, - hostedSourceControlResponse + hostedSourceControlResponse, + hostedSourceControlSuccess } from './web-host-source-control-response' import { createWebHostProviderReviewCache, @@ -22,6 +24,7 @@ import { handleWebHostProviderReviewCreation, WEB_HOST_PROVIDER_REVIEW_CREATION_UNHANDLED } from './web-host-provider-review-creation' +import { createWebHostSourceControlStatusSnapshot } from './web-host-source-control-status-snapshot' export function webHostSourceControlClient( bridgeClient: MobileWebBridgeClient, @@ -29,27 +32,42 @@ export function webHostSourceControlClient( ): RpcClient { const providerReviewCache = createWebHostProviderReviewCache() const providerReviewEligibilityCache = createWebHostProviderReviewEligibilityCache() + const statusSnapshot = createWebHostSourceControlStatusSnapshot(bridgeClient, workspaceId) return { async sendRequest(method, input) { if (!matchesBoundWorkspace(input, workspaceId)) { return hostedSourceControlFailure('forbidden') } const params = isRecord(input) ? input : {} + if (method === 'files.openDiff') { + return openHostedSourceControlDiff(bridgeClient, workspaceId, params) + } return hostedSourceControlResponse(async () => { - if (method === 'files.openDiff') { - await bridgeClient.sourceControlReviewOpen({ + if (method === 'files.open') { + await bridgeClient.fileOpen({ workspaceId, - relativePath: requiredString(params.relativePath), - scope: params.staged === true ? 'staged' : 'unstaged' + relativePath: requiredString(params.relativePath) }) return null } + if (method === 'session.tabs.list') { + return hostedSessionTabSnapshot(await bridgeClient.sessionSnapshot({ workspaceId })) + } + if (method === 'session.tabs.activate') { + return hostedSessionTabSnapshot( + await bridgeClient.sessionActivate({ + workspaceId, + tabId: requiredString(params.tabId) + }) + ) + } const creation = await handleWebHostProviderReviewCreation({ client: bridgeClient, workspaceId, method, params, - eligibilityCache: providerReviewEligibilityCache + eligibilityCache: providerReviewEligibilityCache, + statusSnapshot }) if (creation !== WEB_HOST_PROVIDER_REVIEW_CREATION_UNHANDLED) { return creation @@ -60,7 +78,8 @@ export function webHostSourceControlClient( method, params, cache: providerReviewCache, - eligibilityCache: providerReviewEligibilityCache + eligibilityCache: providerReviewEligibilityCache, + statusSnapshot }) if (provider !== WEB_HOST_PROVIDER_REVIEW_UNHANDLED) { return provider @@ -69,7 +88,8 @@ export function webHostSourceControlClient( client: bridgeClient, workspaceId, method, - params + params, + statusSnapshot }) if (read !== WEB_HOST_SOURCE_CONTROL_REQUEST_UNHANDLED) { return read @@ -110,6 +130,27 @@ export function webHostSourceControlClient( } } +async function openHostedSourceControlDiff( + bridgeClient: MobileWebBridgeClient, + workspaceId: string, + params: Record +) { + try { + await bridgeClient.sourceControlReviewOpen({ + workspaceId, + relativePath: requiredString(params.relativePath), + scope: params.staged === true ? 'staged' : 'unstaged' + }) + return hostedSourceControlSuccess(null) + } catch (error) { + const code = + error instanceof MobileWebBridgeClientError && error.code === 'unsupported_capability' + ? 'method_not_found' + : 'host_error' + return hostedSourceControlFailure(code) + } +} + function matchesBoundWorkspace(input: unknown, workspaceId: string): boolean { if (!isRecord(input)) { return true @@ -128,3 +169,14 @@ function requiredString(value: unknown): string { } return value } + +function hostedSessionTabSnapshot( + snapshot: Awaited> +) { + return { + worktree: snapshot.workspaceId, + activeTabId: snapshot.activeTabId, + activeTabType: snapshot.activeTabType, + tabs: snapshot.tabs + } +} diff --git a/mobile/src/source-control/web-host-source-control-reads.ts b/mobile/src/source-control/web-host-source-control-reads.ts index 05d5ea29bd4..b6ab9627fb7 100644 --- a/mobile/src/source-control/web-host-source-control-reads.ts +++ b/mobile/src/source-control/web-host-source-control-reads.ts @@ -4,7 +4,7 @@ import { MOBILE_WEB_SOURCE_CONTROL_COMPARE_MAX_ENTRIES, type MobileWebSourceControlBranchCompareResult } from '../../../src/shared/mobile-web/source-control-history-contract' -import { MOBILE_WEB_SOURCE_CONTROL_STATUS_LIMIT } from '../../../src/shared/mobile-web/source-control-operation-contract' +import type { WebHostSourceControlStatusSnapshot } from './web-host-source-control-status-snapshot' type RequestParams = Record @@ -13,14 +13,12 @@ export async function readWebHostSourceControlRequest(args: { workspaceId: string method: string params: RequestParams + statusSnapshot: WebHostSourceControlStatusSnapshot }): Promise { - const { client, workspaceId, method, params } = args + const { client, workspaceId, method, params, statusSnapshot } = args if (method === 'git.status') { const [status, repository] = await Promise.all([ - client.sourceControlStatus({ - workspaceId, - limit: MOBILE_WEB_SOURCE_CONTROL_STATUS_LIMIT - }), + statusSnapshot.refresh(), client.sourceControlUpstream({ workspaceId }) ]) return { diff --git a/mobile/src/source-control/web-host-source-control-status-snapshot.ts b/mobile/src/source-control/web-host-source-control-status-snapshot.ts new file mode 100644 index 00000000000..cb430144469 --- /dev/null +++ b/mobile/src/source-control/web-host-source-control-status-snapshot.ts @@ -0,0 +1,45 @@ +import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' +import { MOBILE_WEB_SOURCE_CONTROL_STATUS_LIMIT } from '../../../src/shared/mobile-web/source-control-operation-contract' + +export type WebHostSourceControlStatusSnapshot = ReturnType< + typeof createWebHostSourceControlStatusSnapshot +> + +export function createWebHostSourceControlStatusSnapshot( + client: MobileWebBridgeClient, + workspaceId: string +) { + type Status = Awaited> + let current: Status | null = null + let pending: Promise | null = null + + const refresh = (): Promise => { + if (pending) { + return pending + } + const request = client.sourceControlStatus({ + workspaceId, + limit: MOBILE_WEB_SOURCE_CONTROL_STATUS_LIMIT + }) + pending = request + void request.then( + (status) => { + current = status + if (pending === request) { + pending = null + } + }, + () => { + if (pending === request) { + pending = null + } + } + ) + return request + } + + return { + read: (): Promise => pending ?? (current ? Promise.resolve(current) : refresh()), + refresh + } +} diff --git a/mobile/src/tasks/use-mobile-pr-file-content-cache.test.ts b/mobile/src/tasks/use-mobile-pr-file-content-cache.test.ts index ae888590be2..42cd5b6321d 100644 --- a/mobile/src/tasks/use-mobile-pr-file-content-cache.test.ts +++ b/mobile/src/tasks/use-mobile-pr-file-content-cache.test.ts @@ -16,6 +16,17 @@ afterEach(() => { }) describe('useMobilePrFileContentCache', () => { + it('keeps clear stable when clearing the cache', () => { + renderHarness('scope-a') + const clear = result?.clear + + act(() => clear?.()) + + expect(result?.clear).toBe(clear) + expect(result?.contents).toEqual({}) + expect(result?.loadingPath).toBeNull() + }) + it('resets cached content synchronously when the review scope changes', async () => { renderHarness('scope-a') await loadFile('scope-a', 'a.ts', fileContents('old')) diff --git a/mobile/src/tasks/use-mobile-pr-file-content-cache.ts b/mobile/src/tasks/use-mobile-pr-file-content-cache.ts index 0307e3a46f1..3899106762b 100644 --- a/mobile/src/tasks/use-mobile-pr-file-content-cache.ts +++ b/mobile/src/tasks/use-mobile-pr-file-content-cache.ts @@ -115,9 +115,8 @@ export function useMobilePrFileContentCache(activeScope: string | null): { } const { cache } = currentView const clear = useCallback(() => { - const next = createMobilePrFileContentCacheView(activeScope) - setView((current) => (current.cache === cache ? next : current)) - }, [activeScope, cache]) + setView((current) => createMobilePrFileContentCacheView(current.activeScope)) + }, []) const load = useCallback( async ( diff --git a/mobile/src/tasks/use-open-mobile-tasks.ts b/mobile/src/tasks/use-open-mobile-tasks.ts deleted file mode 100644 index 1c6cf67946b..00000000000 --- a/mobile/src/tasks/use-open-mobile-tasks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useCallback } from 'react' -import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route' -import { mobileTasksRouteTarget } from './mobile-task-navigation' -import type { TaskProvider } from './mobile-task-providers' - -export function useOpenMobileTasks(): (hostId: string, provider?: TaskProvider) => void { - const openHostStackRoute = useOpenHostStackRoute() - - return useCallback( - (hostId, provider) => { - openHostStackRoute(hostId, mobileTasksRouteTarget(hostId, provider)) - }, - [openHostStackRoute] - ) -} diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index b472fb7847e..e11c05b9801 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -153,8 +153,7 @@ describe('terminal viewport refit', () => { expect(sessionSource).toContain('textScale: terminalTextScale') expect(sessionSource).toContain('connState,') expect(sessionSource).toContain('notifyTerminalFrameHeight(nextHeight)') - expect(sessionSource).toContain('notifyKeyboardVisibility(true)') - expect(sessionSource).toContain('notifyKeyboardVisibility(false)') + expect(sessionSource).toContain('notifyKeyboardVisibility(keyboardHeight > 0)') }) it('does not rerender SessionScreen for frame-height-only layout changes', () => { diff --git a/mobile/src/transport/emulator-macos-keychain-home.test.ts b/mobile/src/transport/emulator-macos-keychain-home.test.ts new file mode 100644 index 00000000000..07aa7374228 --- /dev/null +++ b/mobile/src/transport/emulator-macos-keychain-home.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { lstatSync, mkdirSync, mkdtempSync, readlinkSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { linkEmulatorMacKeychain } from '../../scripts/emulator-macos-keychain-home.mjs' + +const temporaryRoots: string[] = [] + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('emulator macOS Keychain home', () => { + it('exposes only the existing Keychain directory inside the disposable home', () => { + const root = temporaryRoot() + const realHome = path.join(root, 'real-home') + const keychains = path.join(realHome, 'Library', 'Keychains') + const disposableHome = path.join(root, 'disposable-home') + mkdirSync(keychains, { recursive: true }) + + const target = linkEmulatorMacKeychain(disposableHome, { platform: 'darwin', realHome }) + + expect(target).toBe(path.join(disposableHome, 'Library', 'Keychains')) + expect(lstatSync(target!).isSymbolicLink()).toBe(true) + expect(readlinkSync(target!)).toBe(keychains) + }) + + it('does nothing off macOS or when the source Keychain is unavailable', () => { + const root = temporaryRoot() + + expect(linkEmulatorMacKeychain(path.join(root, 'linux'), { platform: 'linux' })).toBeNull() + expect( + linkEmulatorMacKeychain(path.join(root, 'mac'), { + platform: 'darwin', + realHome: path.join(root, 'missing') + }) + ).toBeNull() + }) + + it('never replaces an existing disposable-home path', () => { + const root = temporaryRoot() + const realHome = path.join(root, 'real-home') + const disposableHome = path.join(root, 'disposable-home') + mkdirSync(path.join(realHome, 'Library', 'Keychains'), { recursive: true }) + mkdirSync(path.join(disposableHome, 'Library'), { recursive: true }) + writeFileSync(path.join(disposableHome, 'Library', 'Keychains'), 'occupied') + + expect(() => linkEmulatorMacKeychain(disposableHome, { platform: 'darwin', realHome })).toThrow( + 'Refusing to replace emulator Keychain path' + ) + }) +}) + +function temporaryRoot() { + const root = mkdtempSync(path.join(os.tmpdir(), 'orca-emulator-keychain-test-')) + temporaryRoots.push(root) + return root +} diff --git a/mobile/src/transport/emulator-pairing-child-environment.test.ts b/mobile/src/transport/emulator-pairing-child-environment.test.ts new file mode 100644 index 00000000000..589eb35fa78 --- /dev/null +++ b/mobile/src/transport/emulator-pairing-child-environment.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { createEmulatorPairingChildEnvironment } from '../../scripts/emulator-pairing-child-environment.mjs' + +describe('createEmulatorPairingChildEnvironment', () => { + it('uses the disposable profile without inheriting real agent homes', () => { + expect( + createEmulatorPairingChildEnvironment({ + inheritedEnvironment: { + CODEX_HOME: '/real/.codex', + ORCA_CODEX_HOME: '/real/.codex', + PATH: '/bin' + }, + environment: { ORCA_E2E_MOBILE_AGENT_HISTORY_FIXTURE: '1' }, + userData: '/run/userData', + homeDir: '/run/home' + }) + ).toEqual({ + PATH: '/bin', + ORCA_E2E_MOBILE_AGENT_HISTORY_FIXTURE: '1', + ORCA_DEV_USER_DATA_PATH: '/run/userData', + ORCA_E2E_USER_DATA_DIR: '/run/userData', + ORCA_E2E_HOME_DIR: '/run/home', + HOME: '/run/home', + USERPROFILE: '/run/home' + }) + }) +}) diff --git a/mobile/src/transport/emulator-pairing-runtime-process.test.ts b/mobile/src/transport/emulator-pairing-runtime-process.test.ts index d521f8894e9..b3b440c26da 100644 --- a/mobile/src/transport/emulator-pairing-runtime-process.test.ts +++ b/mobile/src/transport/emulator-pairing-runtime-process.test.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { describe, expect, it, vi } from 'vitest' import { + currentPairingDaemonPids, pairingDaemonPidsFromUserData, pairingRuntimePidFromUserData, signalPairingDaemons, @@ -53,6 +54,17 @@ describe('emulator pairing runtime process ownership', () => { ).toEqual([]) }) + it('includes daemon pid files created after the pairing URL was emitted', () => { + expect( + currentPairingDaemonPids( + '/tmp/profile', + [5201], + () => ['daemon-v36.pid'], + () => JSON.stringify({ pid: 5202 }) + ) + ).toEqual([5201, 5202]) + }) + it('signals the Electron owner rather than relying on its CLI wrapper', () => { const sendSignal = vi.fn() diff --git a/mobile/src/transport/host-mobile-web-export-process.test.ts b/mobile/src/transport/host-mobile-web-export-process.test.ts new file mode 100644 index 00000000000..db1b6116b65 --- /dev/null +++ b/mobile/src/transport/host-mobile-web-export-process.test.ts @@ -0,0 +1,72 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { + createHostMobileWebExportProcessSpec, + runHostMobileWebExport +} from '../../scripts/export-host-mobile-web.mjs' +import { resolveSpawn } from '../../../src/shared/child-process/run-process' + +describe('host mobile web export process', () => { + it('keeps adversarial Windows output argv inside the shared cmd boundary', () => { + const outputDirectory = + 'C:\\exports\\space & pipe| redirect percent%PATH% bang! caret^ quote" trail\\' + const spec = createHostMobileWebExportProcessSpec({ + environment: { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }, + mobileDirectory: 'C:\\Orca Workspace', + outputDirectory, + platform: 'win32' + }) + const resolved = resolveSpawn(spec, 'win32') + + expect(spec.args.at(-1)).toBe(outputDirectory) + expect(resolved.file).toBe('C:\\Windows\\System32\\cmd.exe') + expect(resolved.options).toMatchObject({ + shell: false, + windowsHide: true, + windowsVerbatimArguments: true + }) + expect(resolved.args).toHaveLength(1) + expect(resolved.args[0]).toContain('"^%"PATH"^%"') + expectCmdOperatorsQuoted(resolved.args[0]!) + }) + + it('returns the child exit code unchanged', async () => { + const child = new EventEmitter() + const pending = runHostMobileWebExport({ + outputDirectory: '/tmp/export', + spawn: vi.fn(() => child) + }) + + child.emit('exit', 23, null) + + await expect(pending).resolves.toBe(23) + }) + + it('maps a child signal to failure and reports the signal', async () => { + const child = new EventEmitter() + const stderr = { write: vi.fn() } + const pending = runHostMobileWebExport({ + outputDirectory: '/tmp/export', + spawn: vi.fn(() => child), + stderr + }) + + child.emit('exit', null, 'SIGTERM') + + await expect(pending).resolves.toBe(1) + expect(stderr.write).toHaveBeenCalledWith('Expo web export terminated by SIGTERM\n') + }) +}) + +function expectCmdOperatorsQuoted(commandLine: string): void { + const body = commandLine.slice('/d /v:off /s /c "'.length, -1) + let quoted = false + for (const character of body) { + if (character === '"') { + quoted = !quoted + } else if ('&|<>'.includes(character)) { + expect(quoted, `bare ${character} would be parsed as a cmd operator`).toBe(true) + } + } + expect(quoted).toBe(false) +} diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts index f9837a8ac17..75de38b40db 100644 --- a/mobile/src/transport/host-status-gates.ts +++ b/mobile/src/transport/host-status-gates.ts @@ -3,6 +3,7 @@ import type { RpcClient } from './rpc-client' import type { ConnectionState, RpcSuccess } from './types' import { evaluateCompat, type CompatVerdict } from './protocol-compat' import type { DesktopStatus } from '../worktree/host-worktree-rpc-types' +import { parseRuntimeStatusCapabilities } from './runtime-capability-probe' export type HostStatusGates = { hostCapabilities: string[] @@ -57,15 +58,13 @@ export function useHostStatusGates(args: { }) return } - const status = (response as RpcSuccess).result as DesktopStatus & { - capabilities?: string[] - } + const status = (response as RpcSuccess).result as DesktopStatus const verdict = evaluateCompat({ desktopProtocolVersion: status.protocolVersion, desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion }) settle({ - hostCapabilities: status.capabilities ?? [], + hostCapabilities: parseRuntimeStatusCapabilities(status) ?? [], floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true, compatVerdict: verdict }) diff --git a/mobile/src/transport/runtime-capability-probe.ts b/mobile/src/transport/runtime-capability-probe.ts index 1b9f8e8d25c..35cb32eda20 100644 --- a/mobile/src/transport/runtime-capability-probe.ts +++ b/mobile/src/transport/runtime-capability-probe.ts @@ -18,18 +18,24 @@ export function startRuntimeCapabilityProbe( if (!response.ok) { throw new Error('runtime_capability_probe_failed') } - const result = (response as RpcSuccess).result - const rawCapabilities = - result && typeof result === 'object' - ? (result as { capabilities?: unknown }).capabilities - : null - return Array.isArray(rawCapabilities) && - rawCapabilities.every((value) => typeof value === 'string') - ? rawCapabilities - : [] + return parseRuntimeStatusCapabilities((response as RpcSuccess).result) ?? [] }, onCapabilities) } +export function parseRuntimeStatusCapabilities(result: unknown): string[] | null { + if (!result || typeof result !== 'object') { + return null + } + const capabilities = (result as { capabilities?: unknown }).capabilities + if (capabilities === undefined) { + return [] + } + return Array.isArray(capabilities) && + capabilities.every((capability) => typeof capability === 'string') + ? capabilities + : null +} + export function startRuntimeCapabilityRead( readCapabilities: () => Promise, onCapabilities: (capabilities: T) => void diff --git a/src/main/index.ts b/src/main/index.ts index ae8fed9981d..5853b07b5de 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -704,7 +704,6 @@ if (app.isPackaged && process.platform !== 'win32') { } configureDevUserDataPath(is.dev) configureOrcaUserDataPathEnv() -installServeSupervisorDisconnectQuit(isServeMode) // Why: just past createMainWindow's 10s ready-to-show fallback, so a window revealed that way still gets its tray icon. const TRAY_CREATE_FALLBACK_MS = 12_000 @@ -884,6 +883,7 @@ if (hasSingleInstanceLock) { // the app.setName ordering the userData captures below depend on. setAppEnvironment(new ElectronAppEnvironment()) setSecretStore(new ElectronSecretStore()) + installServeSupervisorDisconnectQuit(isServeMode) // Why at process level, not per-window: pty.ts registers against injected surfaces so // it can load without electron, and an Electron main process always has ipcMain — // whether a window exists is irrelevant. Installing this in attachMainWindowServices diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 290fcda81b5..8b9c9b52b45 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -2066,6 +2066,7 @@ describe('OrcaRuntimeService', () => { expect(status.capabilities).toContain('terminal.binary-stream.v1') expect(status.capabilities).toContain('workspace-ports.v1') expect(status.capabilities).toContain('mobile.tasks.v1') + expect(status.capabilities).toContain('mobileWeb.package.v1') expect(status.capabilities).toContain('terminal.quick-commands.v1') expect(status.capabilities).toContain('worktree.create-idempotency.v1') expect(status.capabilities).toContain('files.mutation-ownership.v1') diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index d4a834b20b7..93a2b87f8c4 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -138,6 +138,7 @@ type TerminalMultiplexStream = { ackInFlightBytes: number ackWindowBytes: number supportsOutputPause: boolean + supportsQueryReply: boolean supportsWriteUnavailable: boolean outputPaused: boolean supportsDesktopViewportClaims: boolean @@ -1109,6 +1110,7 @@ const TerminalSubscribe = TerminalHandle.extend({ terminalBinaryStream: z.literal(1).optional(), desktopViewportClaims: z.literal(1).optional(), mobileInputLeaseOnly: z.literal(1).optional(), + queryReply: z.literal(1).optional(), writeUnavailable: z.literal(1).optional() }) .optional() @@ -1131,6 +1133,7 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ ackOutputSourceRanges: z.literal(1).optional(), desktopViewportClaims: z.literal(1).optional(), outputPause: z.literal(1).optional(), + queryReply: z.literal(1).optional(), writeUnavailable: z.literal(1).optional() }) .optional() @@ -2241,7 +2244,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } if ( frame.opcode === TerminalStreamOpcode.Input || - frame.opcode === TerminalStreamOpcode.QueryReply + (frame.opcode === TerminalStreamOpcode.QueryReply && stream.supportsQueryReply) ) { const text = decodeTerminalStreamText(frame.payload) if (!text) { @@ -2605,6 +2608,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ackInFlightBytes: 0, ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, supportsOutputPause: request.capabilities?.outputPause === 1, + supportsQueryReply: request.capabilities?.queryReply === 1, supportsWriteUnavailable: request.capabilities?.writeUnavailable === 1, outputPaused: false, supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, @@ -2734,10 +2738,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ rows: serialized?.rows ?? size?.rows, displayMode, seq: layoutSeq, - ...((stream.ackOutputSourceRanges || stream.supportsOutputPause) && { + ...((stream.ackOutputSourceRanges || + stream.supportsOutputPause || + stream.supportsQueryReply) && { capabilities: { ...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}), - ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}) + ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}), + ...(stream.supportsQueryReply ? { queryReply: 1 as const } : {}) } }), ...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}), @@ -3023,6 +3030,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ : runtime.getRendererTerminalSerializerGeneration(ptyId) : 0 const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 + const supportsQueryReply = params.capabilities?.queryReply === 1 const supportsWriteUnavailable = params.capabilities?.writeUnavailable === 1 if (mobileInputLeaseOnly && clientId) { let closed = false @@ -3273,7 +3281,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } if ( frame.opcode === TerminalStreamOpcode.Input || - frame.opcode === TerminalStreamOpcode.QueryReply + (frame.opcode === TerminalStreamOpcode.QueryReply && supportsQueryReply) ) { const text = decodeTerminalStreamText(frame.payload) if (!text) { @@ -3555,7 +3563,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cols: serialized?.cols ?? size?.cols, rows: serialized?.rows ?? size?.rows, displayMode, - seq: layoutSeq + seq: layoutSeq, + ...(supportsQueryReply ? { capabilities: { queryReply: 1 as const } } : {}) }) const snapshotStats = sendSnapshotFrames(sendFrame, { kind: 'scrollback', diff --git a/src/main/runtime/rpc/mobile-web-package-root.test.ts b/src/main/runtime/rpc/mobile-web-package-root.test.ts index 1b91281b2d4..0888fcca79c 100644 --- a/src/main/runtime/rpc/mobile-web-package-root.test.ts +++ b/src/main/runtime/rpc/mobile-web-package-root.test.ts @@ -13,36 +13,50 @@ afterEach(async () => { }) describe('mobile web package root', () => { - it('uses an explicit development package override before packaged resources', async () => { + it('uses an explicit development package override', async () => { const overrideRoot = await packageRoot('override') const resourcesPath = await packageRoot('resources') await addManifest(overrideRoot) await addManifest(join(resourcesPath, 'mobile-web')) - expect(resolveMobileWebPackageRoot({ overrideRoot, resourcesPath })).toBe(overrideRoot) + expect(resolveMobileWebPackageRoot({ isPackaged: false, overrideRoot, resourcesPath })).toBe( + overrideRoot + ) }) - it('prefers the packaged extra-resource directory', async () => { + it('locks packaged builds to the extra-resource directory', async () => { + const overrideRoot = await packageRoot('override') const resourcesPath = await packageRoot('resources') const cwd = await packageRoot('checkout') + await addManifest(overrideRoot) await addManifest(join(resourcesPath, 'mobile-web')) await addManifest(join(cwd, 'out', 'mobile-web-rnw')) - expect(resolveMobileWebPackageRoot({ resourcesPath, cwd })).toBe( - join(resourcesPath, 'mobile-web') - ) + expect( + resolveMobileWebPackageRoot({ isPackaged: true, overrideRoot, resourcesPath, cwd }) + ).toBe(join(resourcesPath, 'mobile-web')) }) - it('uses the development output and fails closed when neither output exists', async () => { + it('does not fall back when packaged resources are missing', async () => { + const overrideRoot = await packageRoot('override') + const resourcesPath = await packageRoot('resources') + const cwd = await packageRoot('checkout') + await addManifest(overrideRoot) + await addManifest(join(cwd, 'out', 'mobile-web-rnw')) + + expect(() => + resolveMobileWebPackageRoot({ isPackaged: true, overrideRoot, resourcesPath, cwd }) + ).toThrow('mobile_web_package_unavailable') + }) + + it('uses the development output and fails closed when no development output exists', async () => { const cwd = await packageRoot('checkout') const developmentRoot = join(cwd, 'out', 'mobile-web-rnw') await addManifest(developmentRoot) - expect(resolveMobileWebPackageRoot({ resourcesPath: join(cwd, 'missing'), cwd })).toBe( - developmentRoot - ) + expect(resolveMobileWebPackageRoot({ isPackaged: false, cwd })).toBe(developmentRoot) const empty = await packageRoot('empty') - expect(() => resolveMobileWebPackageRoot({ resourcesPath: empty, cwd: empty })).toThrow( + expect(() => resolveMobileWebPackageRoot({ isPackaged: false, cwd: empty })).toThrow( 'mobile_web_package_unavailable' ) }) diff --git a/src/main/runtime/rpc/mobile-web-package-root.ts b/src/main/runtime/rpc/mobile-web-package-root.ts index 967b05283cb..00d071f87bf 100644 --- a/src/main/runtime/rpc/mobile-web-package-root.ts +++ b/src/main/runtime/rpc/mobile-web-package-root.ts @@ -1,24 +1,38 @@ import { existsSync } from 'node:fs' import { join, resolve } from 'node:path' +import { getAppEnvironment, hasAppEnvironment } from '../../../shared/app-environment' type MobileWebPackageRootOptions = { cwd?: string + isPackaged?: boolean overrideRoot?: string resourcesPath?: string } export function resolveMobileWebPackageRoot(options: MobileWebPackageRootOptions = {}): string { - const overrideRoot = options.overrideRoot ?? process.env.ORCA_MOBILE_WEB_PACKAGE_ROOT + const isPackaged = options.isPackaged ?? (hasAppEnvironment() && getAppEnvironment().isPackaged()) const resourcesPath = options.resourcesPath ?? (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath + if (isPackaged) { + return requirePackageRoot(resourcesPath ? join(resourcesPath, 'mobile-web') : null) + } + + const overrideRoot = options.overrideRoot ?? process.env.ORCA_MOBILE_WEB_PACKAGE_ROOT const candidates = [ overrideRoot ? resolve(overrideRoot) : null, - resourcesPath ? join(resourcesPath, 'mobile-web') : null, - resolve(options.cwd ?? process.cwd(), 'out', 'mobile-web-rnw') + resolveDevelopmentRoot(options) ].filter((candidate): candidate is string => candidate !== null) const root = candidates.find((candidate) => existsSync(join(candidate, 'manifest.json'))) - if (!root) { + return requirePackageRoot(root ?? null) +} + +function resolveDevelopmentRoot(options: MobileWebPackageRootOptions): string { + return resolve(options.cwd ?? process.cwd(), 'out', 'mobile-web-rnw') +} + +function requirePackageRoot(root: string | null): string { + if (!root || !existsSync(join(root, 'manifest.json'))) { throw new Error('mobile_web_package_unavailable') } return root diff --git a/src/main/runtime/rpc/terminal-query-reply-negotiation.test.ts b/src/main/runtime/rpc/terminal-query-reply-negotiation.test.ts new file mode 100644 index 00000000000..e64d43f95e7 --- /dev/null +++ b/src/main/runtime/rpc/terminal-query-reply-negotiation.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' +import { createSubscriptionRegistryDouble } from './subscription-registry-test-double' +import { + makeRequest, + startDesktopMultiplexSubscribe, + stubRuntime +} from './terminal-multiplex-test-harness' + +const QUERY_REPLY = '\x1b[0n' + +describe('terminal query-reply opcode negotiation', () => { + it.each([ + { negotiated: true, writes: 1 }, + { negotiated: false, writes: 0 } + ])('accepts multiplex opcode 18 only when negotiated: $negotiated', async (testCase) => { + const sendTerminal = vi.fn().mockResolvedValue({ accepted: true }) + const harness = startDesktopMultiplexSubscribe({ + sendTerminal, + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), + isMobileTerminalQueryReplyAuthority: vi.fn().mockReturnValue(true) + }) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + harness.handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 7, + terminal: 'terminal-1', + client: { id: 'mobile-1', type: 'mobile' }, + capabilities: { + ackOutput: 1, + ...(testCase.negotiated ? { queryReply: 1 } : {}) + } + }) + }) + )! + ) + + await vi.waitFor(() => + expect( + harness.messages.some( + (message) => + JSON.parse(message).result?.type === 'subscribed' && + JSON.parse(message).result?.streamId === 7 + ) + ).toBe(true) + ) + const subscribed = harness.messages + .map((message) => JSON.parse(message).result) + .find((event) => event?.type === 'subscribed' && event.streamId === 7) + expect(subscribed?.capabilities?.queryReply).toBe(testCase.negotiated ? 1 : undefined) + + harness.handlers.get(7)?.(queryReplyFrame(7)) + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(testCase.writes)) + + harness.registry.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint') + await harness.dispatchPromise + }) + + it.each([ + { negotiated: true, writes: 1 }, + { negotiated: false, writes: 0 } + ])('accepts direct opcode 18 only when negotiated: $negotiated', async (testCase) => { + const registry = createSubscriptionRegistryDouble() + const messages: string[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const sendTerminal = vi.fn().mockResolvedValue({ accepted: true }) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), + cleanupSubscription: vi.fn(registry.cleanupSubscription), + waitForTerminal: vi.fn(() => new Promise(() => {})), + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), + isMobileTerminalQueryReplyAuthority: vi.fn().mockReturnValue(true), + sendTerminal + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'mobile-1', type: 'mobile' }, + capabilities: { + terminalBinaryStream: 1, + ...(testCase.negotiated ? { queryReply: 1 } : {}) + } + }), + (message) => messages.push(message), + { + connectionId: `conn-direct-query-${testCase.negotiated}`, + sendBinary: vi.fn(), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe( + true + ) + ) + const subscribed = messages + .map((message) => JSON.parse(message).result) + .find((event) => event?.type === 'subscribed') + expect(subscribed.capabilities?.queryReply).toBe(testCase.negotiated ? 1 : undefined) + + handlers.get(subscribed.streamId)?.(queryReplyFrame(subscribed.streamId)) + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(testCase.writes)) + + runtime.cleanupSubscription('terminal-1:mobile-1') + await dispatchPromise + }) +}) + +function queryReplyFrame(streamId: number) { + return decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.QueryReply, + streamId, + seq: 2, + payload: encodeTerminalStreamText(QUERY_REPLY) + }) + )! +} diff --git a/src/main/serve-update-handoff-startup-order.test.ts b/src/main/serve-update-handoff-startup-order.test.ts new file mode 100644 index 00000000000..ed7f7a9ca74 --- /dev/null +++ b/src/main/serve-update-handoff-startup-order.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +describe('serve update handoff startup order', () => { + it('installs app path ownership before resolving the supervised handoff path', () => { + const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') + + expect(source.indexOf('setAppEnvironment(new ElectronAppEnvironment())')).toBeGreaterThan(-1) + expect(source.indexOf('installServeSupervisorDisconnectQuit(isServeMode)')).toBeGreaterThan( + source.indexOf('setAppEnvironment(new ElectronAppEnvironment())') + ) + }) +}) diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts index 97fee6a6fde..e36491aa3bb 100644 --- a/src/main/shell-startup-feature-channel.test.ts +++ b/src/main/shell-startup-feature-channel.test.ts @@ -155,8 +155,10 @@ describePosix('zsh launch config', () => { const foreignWrapper = join(home, 'other-terminal', 'zsh') mkdirSync(foreignWrapper, { recursive: true }) const previousZdotdir = process.env.ZDOTDIR + const previousOriginalZdotdir = process.env.ORCA_ORIG_ZDOTDIR const previousHome = process.env.HOME process.env.ZDOTDIR = foreignWrapper + delete process.env.ORCA_ORIG_ZDOTDIR process.env.HOME = home try { const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() @@ -187,6 +189,11 @@ describePosix('zsh launch config', () => { } else { process.env.ZDOTDIR = previousZdotdir } + if (previousOriginalZdotdir === undefined) { + delete process.env.ORCA_ORIG_ZDOTDIR + } else { + process.env.ORCA_ORIG_ZDOTDIR = previousOriginalZdotdir + } if (previousHome === undefined) { delete process.env.HOME } else { diff --git a/src/mobile-web/src/mobile-web-hardware-back-handler.ts b/src/mobile-web/src/mobile-web-hardware-back-handler.ts new file mode 100644 index 00000000000..f57e1d727c7 --- /dev/null +++ b/src/mobile-web/src/mobile-web-hardware-back-handler.ts @@ -0,0 +1,26 @@ +type MobileWebHardwareBackHandler = () => boolean + +let activeHandler: MobileWebHardwareBackHandler | null = null + +export function installMobileWebHardwareBackHandler( + handler: MobileWebHardwareBackHandler +): () => void { + activeHandler = handler + return () => { + if (activeHandler === handler) { + activeHandler = null + } + } +} + +export function hasMobileWebHardwareBackHandler(): boolean { + return activeHandler !== null +} + +export function dispatchMobileWebHardwareBack(): boolean { + try { + return activeHandler?.() === true + } catch { + return false + } +} diff --git a/src/mobile-web/src/mobile-web-native-request-client.ts b/src/mobile-web/src/mobile-web-native-request-client.ts index 040168afb32..1248e026aaa 100644 --- a/src/mobile-web/src/mobile-web-native-request-client.ts +++ b/src/mobile-web/src/mobile-web-native-request-client.ts @@ -7,6 +7,8 @@ import { MobileWebHapticResultSchema, MobileWebHapticSelectionPayloadSchema, MobileWebHapticSelectionResultSchema, + MobileWebNativeAlertPayloadSchema, + MobileWebNativeAlertResultSchema, MobileWebOpenExternalPayloadSchema, MobileWebOpenExternalResultSchema, MobileWebSessionChatDraftReadPayloadSchema, @@ -24,6 +26,8 @@ import { type MobileWebClipboardWriteResult, type MobileWebClipboardAvailability, type MobileWebHapticKind, + type MobileWebNativeAlertPayload, + type MobileWebNativeAlertResult, type MobileWebSessionChatDraftReadResult, type MobileWebTerminalAccessoryPreferences, type MobileWebTerminalCustomKey, @@ -32,9 +36,23 @@ import { } from '../../shared/mobile-web/bridge-operation-contract' import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' +// Human-owned native prompts must not inherit the ordinary RPC deadline. +const MOBILE_WEB_NATIVE_ALERT_REQUEST_TIMEOUT_MS = 2_147_483_647 + export class MobileWebNativeRequestClient { constructor(private readonly requests: MobileWebOneShotRequestClient) {} + alert(payload: MobileWebNativeAlertPayload): Promise { + return this.requests.request( + 'native', + 'alert', + payload, + MobileWebNativeAlertPayloadSchema, + MobileWebNativeAlertResultSchema, + { timeoutMs: MOBILE_WEB_NATIVE_ALERT_REQUEST_TIMEOUT_MS } + ) + } + hapticSelection(): Promise { return this.requests.request( 'native', diff --git a/src/mobile-web/src/native-shell-channel.test.tsx b/src/mobile-web/src/native-shell-channel.test.tsx index 0c88c8538fa..384a8bc8dda 100644 --- a/src/mobile-web/src/native-shell-channel.test.tsx +++ b/src/mobile-web/src/native-shell-channel.test.tsx @@ -8,6 +8,7 @@ import { type MobileWebBridgeShellMessage } from '../../shared/mobile-web/bridge-contract' import { MobileWebNativeShellProvider, useMobileWebNativeShell } from './native-shell-channel' +import { installMobileWebHardwareBackHandler } from './mobile-web-hardware-back-handler' import { MOBILE_WEB_SHELL_LISTENING_PROPERTY, MOBILE_WEB_SHELL_PENDING_PROPERTY @@ -63,6 +64,7 @@ describe('mobile web native shell channel', () => { expect(client).not.toBeNull() expect(hook.result.current.context).toEqual(CONTEXT) expect(hook.result.current.connection).toBe('connected') + expect(hook.result.current.hostDisplayName).toBe('Host 1') expect(hook.result.current.routeRevision).toBe(1) expect(hook.result.current.resumeRoute).toEqual({ kind: 'session', @@ -215,6 +217,49 @@ describe('mobile web native shell channel', () => { ) await expect(current).resolves.toEqual({ workspaces: [], truncated: false }) }) + + it('declares Back support after ready and correlates handled requests', () => { + const posted: MobileWebBridgePageMessage[] = [] + const handler = vi.fn(() => true) + const uninstall = installMobileWebHardwareBackHandler(handler) + const target = window as NativeTestWindow + target.OrcaNative = { + postMessage: (raw) => posted.push(JSON.parse(raw) as MobileWebBridgePageMessage) + } + const hook = renderHook(() => useMobileWebNativeShell(), { + wrapper: MobileWebNativeShellProvider + }) + + act(() => dispatchShellMessage(initMessage())) + expect(posted.slice(0, 2)).toEqual([ + { version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, type: 'ready', ...CONTEXT }, + { + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'hardwareBackCapability', + revision: 1, + ...CONTEXT + } + ]) + act(() => + dispatchShellMessage({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'hardwareBack', + sequence: 7, + ...CONTEXT + }) + ) + expect(handler).toHaveBeenCalledOnce() + expect(posted.at(-1)).toEqual({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'hardwareBackResult', + sequence: 7, + handled: true, + ...CONTEXT + }) + + hook.unmount() + uninstall() + }) }) function initMessage( @@ -225,6 +270,7 @@ function initMessage( type: 'init', ...context, connection: 'connected', + hostDisplayName: 'Host 1', grants: [ { capability: 'workspace', diff --git a/src/mobile-web/src/native-shell-channel.ts b/src/mobile-web/src/native-shell-channel.ts index 0688f8806a0..e9f34b473dd 100644 --- a/src/mobile-web/src/native-shell-channel.ts +++ b/src/mobile-web/src/native-shell-channel.ts @@ -17,6 +17,10 @@ import { type MobileWebResumeRoute } from '../../shared/mobile-web/bridge-contract' import { MobileWebBridgeClient } from './mobile-web-bridge-client' +import { + dispatchMobileWebHardwareBack, + hasMobileWebHardwareBackHandler +} from './mobile-web-hardware-back-handler' import { nextMobileWebShellConnectionMetrics, type MobileWebShellConnectionMetrics @@ -31,6 +35,7 @@ export type MobileWebNativeShellState = { client: MobileWebBridgeClient | null context: MobileWebBridgeMessageContext | null connection: 'connecting' | 'connected' | 'offline' | 'recovering' + hostDisplayName: string | null reconnectAttempts: number lastConnectedAt: number | null navigationRoute: MobileWebNavigationRoute @@ -59,6 +64,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { client: null, context: null, connection: 'connecting', + hostDisplayName: null, reconnectAttempts: 0, lastConnectedAt: null, navigationRoute: { kind: 'workspaceList' }, @@ -70,6 +76,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { useEffect(() => { let context: MobileWebBridgeMessageContext | null = null let client: MobileWebBridgeClient | null = null + let hostDisplayName: string | null = null let metrics: MobileWebShellConnectionMetrics = { reconnectAttempts: 0, lastConnectedAt: null @@ -87,6 +94,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { const nextContext = { shellSessionId: init.shellSessionId, buildId: init.buildId } const retainsContext = sameContext(context, nextContext) metrics = nextMobileWebShellConnectionMetrics(metrics, init, retainsContext) + hostDisplayName = init.hostDisplayName ?? null if (!retainsContext) { resumeRoute = init.resumeRoute ?? { kind: 'workspaceList' } navigationRoute = resumeRoute @@ -113,17 +121,14 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { client, context, connection: init.connection, + hostDisplayName, navigationRoute, resumeRoute, routeRevision, rememberRoute, ...metrics }) - postPageMessage({ - version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, - ...nextContext, - type: 'ready' - }) + postPageReady(nextContext) scheduleInteractiveHealth(nextContext) return } @@ -138,17 +143,14 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { client, context, connection: init.connection, + hostDisplayName, navigationRoute, resumeRoute, routeRevision, rememberRoute, ...metrics }) - postPageMessage({ - version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, - ...context, - type: 'ready' - }) + postPageReady(context) scheduleInteractiveHealth(context) return } @@ -174,6 +176,16 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { ) return } + if (parsed.value.type === 'hardwareBack') { + postPageMessage({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + ...activeContext, + type: 'hardwareBackResult', + sequence: parsed.value.sequence, + handled: dispatchMobileWebHardwareBack() + }) + return + } client.receive(parsed.value) if (parsed.value.type === 'connection') { metrics = nextMobileWebShellConnectionMetrics(metrics, parsed.value, true) @@ -181,6 +193,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { client, context, connection: parsed.value.state, + hostDisplayName, navigationRoute, resumeRoute, routeRevision, @@ -235,6 +248,22 @@ function postPageMessage(message: MobileWebBridgePageMessage): boolean { } } +function postPageReady(context: MobileWebBridgeMessageContext): void { + postPageMessage({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + ...context, + type: 'ready' + }) + if (hasMobileWebHardwareBackHandler()) { + postPageMessage({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + ...context, + type: 'hardwareBackCapability', + revision: 1 + }) + } +} + function sameContext( left: MobileWebBridgeMessageContext | null, right: MobileWebBridgeMessageContext diff --git a/src/shared/mobile-web/bridge-contract.test.ts b/src/shared/mobile-web/bridge-contract.test.ts index d2edb1338d0..fddd0c886b1 100644 --- a/src/shared/mobile-web/bridge-contract.test.ts +++ b/src/shared/mobile-web/bridge-contract.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES, MOBILE_WEB_BRIDGE_MAX_MESSAGE_BYTES, @@ -63,6 +64,25 @@ describe('mobile web bridge page contract', () => { buildId: BUILD_ID }).success ).toBe(true) + expect( + MobileWebBridgePageMessageSchema.safeParse({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'hardwareBackCapability', + revision: 1, + shellSessionId: SHELL_SESSION_ID, + buildId: BUILD_ID + }).success + ).toBe(true) + expect( + MobileWebBridgePageMessageSchema.safeParse({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'hardwareBackResult', + sequence: 4, + handled: true, + shellSessionId: SHELL_SESSION_ID, + buildId: BUILD_ID + }).success + ).toBe(true) expect( MobileWebBridgePageMessageSchema.safeParse({ version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, @@ -104,6 +124,51 @@ describe('mobile web bridge page contract', () => { ).toBe(false) }) + it('keeps the ready frame valid for old strict shells before declaring Back support', () => { + const legacyReadySchema = z + .object({ + version: z.literal(MOBILE_WEB_BRIDGE_PROTOCOL_VERSION), + type: z.literal('ready'), + shellSessionId: z.literal(SHELL_SESSION_ID), + buildId: z.literal(BUILD_ID) + }) + .strict() + const ready = { + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'ready', + shellSessionId: SHELL_SESSION_ID, + buildId: BUILD_ID + } + const declaration = { ...ready, type: 'hardwareBackCapability', revision: 1 } + + expect(legacyReadySchema.safeParse(ready).success).toBe(true) + expect(legacyReadySchema.safeParse(declaration).success).toBe(false) + expect(MobileWebBridgePageMessageSchema.safeParse(declaration).success).toBe(true) + }) + + it('rejects unsupported Back revisions and unbounded result sequences', () => { + const envelope = { + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + shellSessionId: SHELL_SESSION_ID, + buildId: BUILD_ID + } + expect( + MobileWebBridgePageMessageSchema.safeParse({ + ...envelope, + type: 'hardwareBackCapability', + revision: 2 + }).success + ).toBe(false) + expect( + MobileWebBridgePageMessageSchema.safeParse({ + ...envelope, + type: 'hardwareBackResult', + sequence: Number.MAX_SAFE_INTEGER + 1, + handled: false + }).success + ).toBe(false) + }) + it.each(['branches', 'history', 'branchCompare', 'commitCompare'])( 'accepts the explicit source-control %s operation', (operation) => { @@ -215,6 +280,7 @@ describe('mobile web bridge shell contract', () => { shellSessionId: SHELL_SESSION_ID, buildId: BUILD_ID, connection: 'connected', + hostDisplayName: 'Host 1', reconnectAttempts: 3, lastConnectedAt: 1_721_234_567_890, resumeRoute: { @@ -227,6 +293,36 @@ describe('mobile web bridge shell contract', () => { ).toBe(true) }) + it('ignores a newer same-capability grant while keeping page requests allowlisted', () => { + expect( + MobileWebBridgeShellMessageSchema.safeParse({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'init', + shellSessionId: SHELL_SESSION_ID, + buildId: BUILD_ID, + connection: 'connected', + grants: [operationGrant({ capability: 'native', operation: 'futureOperation' })] + }).success + ).toBe(true) + expect( + MobileWebBridgePageMessageSchema.safeParse( + pageRequest({ capability: 'native', operation: 'futureOperation' }) + ).success + ).toBe(false) + }) + + it('accepts hardware Back requests without changing init or grants', () => { + expect( + MobileWebBridgeShellMessageSchema.safeParse({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'hardwareBack', + shellSessionId: SHELL_SESSION_ID, + buildId: BUILD_ID, + sequence: 1 + }).success + ).toBe(true) + }) + it('applies exact JSON admission to the initial shell message', () => { const initial = JSON.stringify({ version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, @@ -326,6 +422,26 @@ describe('mobile web bridge shell contract', () => { ).toBe(false) }) + it('bounds the optional local host display name', () => { + const base = { + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'init', + shellSessionId: SHELL_SESSION_ID, + buildId: BUILD_ID, + connection: 'connected', + grants: [operationGrant()] + } + expect( + MobileWebBridgeShellMessageSchema.safeParse({ ...base, hostDisplayName: 'Host 1' }).success + ).toBe(true) + expect( + MobileWebBridgeShellMessageSchema.safeParse({ + ...base, + hostDisplayName: 'x'.repeat(161) + }).success + ).toBe(false) + }) + it('bounds optional shell connection metrics', () => { const connection = { version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, diff --git a/src/shared/mobile-web/bridge-contract.ts b/src/shared/mobile-web/bridge-contract.ts index ff26bff353a..b8d41db2546 100644 --- a/src/shared/mobile-web/bridge-contract.ts +++ b/src/shared/mobile-web/bridge-contract.ts @@ -78,6 +78,17 @@ const PageHealthSchema = PageEnvelopeSchema.extend({ state: z.literal('interactive') }).strict() +const PageHardwareBackCapabilitySchema = PageEnvelopeSchema.extend({ + type: z.literal('hardwareBackCapability'), + revision: z.literal(1) +}).strict() + +const PageHardwareBackResultSchema = PageEnvelopeSchema.extend({ + type: z.literal('hardwareBackResult'), + sequence: SequenceSchema, + handled: z.boolean() +}).strict() + const MobileWebWorkspaceListRouteSchema = z.object({ kind: z.literal('workspaceList') }).strict() const MobileWebSessionRouteSchema = z .object({ @@ -142,6 +153,8 @@ const PageCancelSchema = PageEnvelopeSchema.extend({ export const MobileWebBridgePageMessageSchema = z.union([ PageReadySchema, PageHealthSchema, + PageHardwareBackCapabilitySchema, + PageHardwareBackResultSchema, PageRouteStateSchema, PageOneShotRequestSchema, PageSubscriptionRequestSchema, @@ -161,7 +174,6 @@ const OperationLimitsSchema = z const OperationGrantSchema = z .object({ ...MobileWebBridgeOperationShape, limits: OperationLimitsSchema }) .strict() - .superRefine(validateRequestOperation) const ShellEnvelopeSchema = z.object({ version: z.literal(MOBILE_WEB_BRIDGE_PROTOCOL_VERSION), @@ -179,6 +191,7 @@ const ShellInitSchema = ShellEnvelopeSchema.extend({ type: z.literal('init'), connection: ConnectionStateSchema, grants: z.array(OperationGrantSchema).max(MOBILE_WEB_BRIDGE_MAX_GRANTS), + hostDisplayName: z.string().min(1).max(160).optional(), resumeRoute: MobileWebResumeRouteSchema.optional(), ...ConnectionMetricsShape }) @@ -197,6 +210,11 @@ const ShellNavigationSchema = ShellEnvelopeSchema.extend({ route: MobileWebNavigationRouteSchema }).strict() +const ShellHardwareBackSchema = ShellEnvelopeSchema.extend({ + type: z.literal('hardwareBack'), + sequence: SequenceSchema +}).strict() + const ShellSuccessResponseSchema = ShellEnvelopeSchema.extend({ type: z.literal('response'), requestId: RequestIdSchema, @@ -222,6 +240,7 @@ export const MobileWebBridgeShellMessageSchema = z.union([ ShellInitSchema, ShellConnectionSchema, ShellNavigationSchema, + ShellHardwareBackSchema, ShellSuccessResponseSchema, ShellErrorResponseSchema, ShellEventSchema diff --git a/src/shared/mobile-web/bridge-limits.ts b/src/shared/mobile-web/bridge-limits.ts index 9de0e5b6bc6..41ed8f76bef 100644 --- a/src/shared/mobile-web/bridge-limits.ts +++ b/src/shared/mobile-web/bridge-limits.ts @@ -4,4 +4,4 @@ export const MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES = MOBILE_WEB_BRIDGE_MAX_MESSAGE_BYTES - MOBILE_WEB_BRIDGE_ENVELOPE_RESERVE_BYTES export const MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS = 64 export const MOBILE_WEB_BRIDGE_MAX_SUBSCRIPTIONS = 32 -export const MOBILE_WEB_BRIDGE_MAX_GRANTS = 224 +export const MOBILE_WEB_BRIDGE_MAX_GRANTS = 256 diff --git a/src/shared/mobile-web/bridge-operation-registry.ts b/src/shared/mobile-web/bridge-operation-registry.ts index 0180f7ca704..fbd833ec4f3 100644 --- a/src/shared/mobile-web/bridge-operation-registry.ts +++ b/src/shared/mobile-web/bridge-operation-registry.ts @@ -200,6 +200,7 @@ export const MOBILE_WEB_BRIDGE_OPERATIONS = { 'cancel' ], native: [ + 'alert', 'hapticSelection', 'hapticFeedback', 'clipboardAvailability', diff --git a/src/shared/mobile-web/native-operation-contract.ts b/src/shared/mobile-web/native-operation-contract.ts index aaaa346a85d..a0642c3f370 100644 --- a/src/shared/mobile-web/native-operation-contract.ts +++ b/src/shared/mobile-web/native-operation-contract.ts @@ -2,8 +2,49 @@ import { z } from 'zod' import { MobileWebWorkspaceIdSchema } from './workspace-operation-contract' export const MOBILE_WEB_CLIPBOARD_TEXT_MAX_CHARACTERS = 128 * 1024 +export const MOBILE_WEB_NATIVE_ALERT_MAX_BUTTONS = 8 +export const MOBILE_WEB_NATIVE_ALERT_MAX_MESSAGE_CHARACTERS = 16 * 1024 +export const MOBILE_WEB_NATIVE_ALERT_MAX_TITLE_CHARACTERS = 4096 export const MOBILE_WEB_SESSION_CHAT_DRAFT_MAX_CHARACTERS = 4096 +export const MobileWebNativeAlertButtonSchema = z + .object({ + text: z.string().max(512).optional(), + style: z.enum(['default', 'cancel', 'destructive']).optional(), + isPreferred: z.boolean().optional() + }) + .strict() +export const MobileWebNativeAlertPayloadSchema = z + .object({ + title: z.string().max(MOBILE_WEB_NATIVE_ALERT_MAX_TITLE_CHARACTERS), + message: z.string().max(MOBILE_WEB_NATIVE_ALERT_MAX_MESSAGE_CHARACTERS).optional(), + buttons: z + .array(MobileWebNativeAlertButtonSchema) + .min(1) + .max(MOBILE_WEB_NATIVE_ALERT_MAX_BUTTONS), + options: z + .object({ + cancelable: z.boolean().optional(), + userInterfaceStyle: z.enum(['unspecified', 'light', 'dark']).optional() + }) + .strict() + .optional() + }) + .strict() +export const MobileWebNativeAlertResultSchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('button'), + buttonIndex: z + .number() + .int() + .nonnegative() + .max(MOBILE_WEB_NATIVE_ALERT_MAX_BUTTONS - 1) + }) + .strict(), + z.object({ kind: z.literal('dismissed') }).strict() +]) + export const MobileWebHapticKindSchema = z.enum([ 'selection', 'success', @@ -111,6 +152,8 @@ export const MobileWebSessionChatDraftWritePayloadSchema = z export const MobileWebSessionChatDraftWriteResultSchema = z.null() export type MobileWebHapticKind = z.infer +export type MobileWebNativeAlertPayload = z.infer +export type MobileWebNativeAlertResult = z.infer export type MobileWebClipboardWriteResult = z.infer export type MobileWebClipboardAvailability = z.infer< typeof MobileWebClipboardAvailabilityResultSchema diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d9d356c676a..597efb31d75 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -90,6 +90,7 @@ export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'worktree.create-idempotency.v1' as const export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentials.v1' as const +export const MOBILE_WEB_PACKAGE_RUNTIME_CAPABILITY = 'mobileWeb.package.v1' as const // Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised. export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const @@ -154,6 +155,7 @@ export const RUNTIME_CAPABILITIES = [ WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY, + MOBILE_WEB_PACKAGE_RUNTIME_CAPABILITY, SKILL_INSTALL_CAPABILITY, SKILL_BUNDLE_INSTALL_CAPABILITY, SKILL_INSTALL_CANCEL_CAPABILITY,