mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
feat(mobile): complete gated hybrid cutover seam
This commit is contained in:
+161
-140
@@ -1,6 +1,6 @@
|
||||
# Mobile Hybrid WebView Implementation Checklist
|
||||
|
||||
- **Status:** Production migration in progress; private-origin cache, typed
|
||||
- **Status:** Hosted feature implementation complete; private-origin cache, typed
|
||||
bridge, opaque workspace authority, unchanged New Workspace bridge,
|
||||
workspace/session actions, emulator-verified shared terminal UI, and the
|
||||
unchanged browser, dictation, accounts, Tasks, Agent History, and native-chat
|
||||
@@ -28,7 +28,11 @@
|
||||
recovery, cache clear/redownload, and corrupt-active cold fallback now pass
|
||||
through the native recovery surface; the exact iOS Simulator and Android API
|
||||
36 Agent History, Source Control, and Review journeys and the full
|
||||
post-rebase validation matrix now pass
|
||||
post-rebase validation matrix now pass; a gated production navigation seam
|
||||
covers Home, pairing, onboarding, notifications, cold resume, Accounts,
|
||||
Tasks, New Workspace, and exact sessions, while the native fallback remains
|
||||
the default until external release gates pass; obsolete prototype delivery,
|
||||
contracts, RPCs, cache, bridge, route, and fixtures are removed
|
||||
- **Last updated:** July 28, 2026
|
||||
- **Design:**
|
||||
[`2026-07-22-mobile-hybrid-webview-single-pr-migration.md`](./2026-07-22-mobile-hybrid-webview-single-pr-migration.md)
|
||||
@@ -60,37 +64,37 @@ unit-test evidence.
|
||||
At the end of every implementation session:
|
||||
|
||||
1. Update the relevant checkboxes.
|
||||
2. Update the summary counts and current workstream.
|
||||
2. Update the progress summary and current workstream.
|
||||
3. Add validation commands or artifact links to the evidence log.
|
||||
4. Add new risks, blockers, or decisions to the status log.
|
||||
5. Record the next concrete action.
|
||||
|
||||
## Progress Summary
|
||||
|
||||
| Workstream | State | Completed | Notes |
|
||||
| ---------------------------------- | ----------- | --------: | ------------------------------------------------------ |
|
||||
| Prototype evidence | Complete | 8/8 | Simulator-only bounded vertical slice |
|
||||
| Contracts and parity inventory | Complete | 10/10 | Route, RPC, native, state, and failure ledgers frozen |
|
||||
| Mobile web build | In progress | 10/12 | Duplicate validation UI removed; packaging gates open |
|
||||
| Package delivery RPC | In progress | 9/10 | Production names and RPC pass focused validation |
|
||||
| Native asset origin and cache | In progress | 7/15 | Cross-platform unit faults pass; device drills remain |
|
||||
| Capability bridge | In progress | 10/17 | Replay/race hardening passes; binary matrix remains |
|
||||
| Mobile web application shell | In progress | 3/16 | Host/state/shell seams wired; routes open |
|
||||
| Workspace and sessions | In progress | 3/15 | Tabs/prompts/attachments pass; chat persistence passes |
|
||||
| Terminal | In progress | 11/19 | Shared route stream adapter exports |
|
||||
| Files, diffs, and source control | In progress | 9/19 | Core Git and bounded text writes pass |
|
||||
| Remaining host/native features | In progress | 4/13 | Core hosted paths and native settings handoff pass |
|
||||
| Security and adversarial review | In progress | 1/15 | Executable isolation passes; broader review open |
|
||||
| Device and topology validation | In progress | 0/18 | Direct iOS and Android emulators pass core journeys |
|
||||
| App Store validation | Not started | 0/10 | Production submission, not TestFlight |
|
||||
| Cutover and cleanup | In progress | 1/12 | Duplicate validation UI removed |
|
||||
| Release evidence and documentation | Not started | 0/10 | Required before merge |
|
||||
| Workstream | Implementation state | Remaining gate |
|
||||
| ---------------------------------- | -------------------- | ---------------------------------------------------- |
|
||||
| Prototype architecture | Removed | None |
|
||||
| Contracts and parity inventory | Complete | Final evidence reconciliation |
|
||||
| Mobile web build | Complete | Supported packaged-Desktop matrix |
|
||||
| Package delivery RPC | Complete | Release-artifact validation |
|
||||
| Native asset origin and cache | Complete | Physical-device and store-signed drills |
|
||||
| Capability bridge | Complete | Cross-version matrix and independent security review |
|
||||
| Mobile web application shell | Complete | Physical-device and accessibility matrix |
|
||||
| Workspace and sessions | Complete | Remaining live topology/device evidence |
|
||||
| Terminal | Complete | Sustained physical-device performance |
|
||||
| Files, diffs, and source control | Complete | Adversarial, topology, and device evidence |
|
||||
| Remaining host/native features | Complete | Permission and physical-device lifecycle evidence |
|
||||
| Security and adversarial review | In progress | Fuzzing, race corpus, and independent review |
|
||||
| Device and topology validation | In progress | Physical phones/tablets and production cloud Relay |
|
||||
| App Store validation | Not started | Production submission, not TestFlight |
|
||||
| Cutover and cleanup | Gated | Default flip, Experimental entry, native fallback |
|
||||
| Release evidence and documentation | In progress | Packaged releases, support docs, and PR artifacts |
|
||||
|
||||
**Current workstream:** Complete remaining parity, cutover cleanup, and the
|
||||
external release gates. The runtime adapters behind the unchanged
|
||||
React Native mobile UI are implemented. Production shell, package, page, and
|
||||
shared-contract sources now reject prototype imports, names, and RPC references. The isolated
|
||||
experimental route retains its legacy contract only until cutover. The shared
|
||||
**Current workstream:** Complete the external validation and release gates.
|
||||
The runtime adapters behind the unchanged React Native mobile UI are
|
||||
implemented. Production shell, package, page, and shared-contract sources
|
||||
reject prototype imports, names, and RPC references; the obsolete prototype
|
||||
route and delivery architecture are removed. The shared
|
||||
`HostScreen` now receives workspace list,
|
||||
repository presentation, view settings, pin, sleep, remove, activate, and
|
||||
invalidation behavior through the real native-shell bridge. Host identity,
|
||||
@@ -110,8 +114,8 @@ repository and SSH authority; source search, base resolution, create
|
||||
operations, native revalidation, and the complete web adapter now pass focused
|
||||
round-trip tests. Populated Tasks and Session now pass deterministic
|
||||
native-versus-hosted portrait screenshot and interaction parity, and Agent
|
||||
History passes in portrait and landscape; the remaining routes, shell states,
|
||||
tablet/device classes, and accessibility matrix remain open.
|
||||
History passes in portrait and landscape; remaining shell states,
|
||||
tablet/device classes, and accessibility evidence remain open.
|
||||
The unchanged session route now receives snapshot, subscription, blank-terminal
|
||||
creation, activation, and close behavior through `HostSessionTabOperations`;
|
||||
native retains the existing RPC mapping while the hosted route uses only the
|
||||
@@ -512,10 +516,11 @@ recovery, or physical-device gates.
|
||||
- [x] Add a host-only Expo Router/React Native Web entry that imports the
|
||||
existing mobile presentation source.
|
||||
- [x] Add isolated TypeScript, lint, formatting, and test coverage.
|
||||
- [~] Compile the existing React Native screen, component, style, and view-model
|
||||
source without importing the desktop renderer entry. The bridge-connected
|
||||
host route and unchanged existing session route source export successfully;
|
||||
session runtime adapters and the remaining host routes are open.
|
||||
- [x] Compile the existing React Native screen, component, style, and view-model
|
||||
source without importing the desktop renderer entry. Workspace, creation,
|
||||
session, terminal, files, previews, source control, reviews, Tasks,
|
||||
Accounts, browser, dictation, native chat, and Agent History all export
|
||||
through the host-only Expo Router graph.
|
||||
- [x] Prevent Electron, Node, desktop-window, and desktop-only persistence code
|
||||
from entering the bundle.
|
||||
- [x] Emit relative content-addressed assets and a deterministic manifest.
|
||||
@@ -550,10 +555,10 @@ recovery, or physical-device gates.
|
||||
- [x] Replace prototype contracts and method names with production names.
|
||||
Production shell, package, page, and shared-contract sources now use only
|
||||
production names and `mobileWeb.package.*`. The host picker moved unchanged
|
||||
into the production mobile-web layer, while the isolated experimental route
|
||||
reuses it and retains the legacy prototype delivery contract until cutover.
|
||||
A recursive source-boundary test rejects any new prototype import, symbol, or
|
||||
RPC reference in production roots.
|
||||
into the production mobile-web layer. The obsolete prototype route,
|
||||
contract, package/cache/bridge implementation, RPC methods and allowlist
|
||||
entries, and fixtures are removed. A recursive source-boundary test rejects
|
||||
any new prototype import, symbol, or RPC reference in production roots.
|
||||
- [x] Serve a canonical multi-asset manifest from packaged desktop output.
|
||||
- [x] Serve only manifest-declared content-addressed assets.
|
||||
- [x] Validate normalized paths, hashes, MIME types, roles, lengths, offsets,
|
||||
@@ -687,31 +692,23 @@ recovery, or physical-device gates.
|
||||
exercises cancellation before/after dispatch, synchronous first-event
|
||||
teardown, retired subscription replay, replay-window rollover, broker
|
||||
disposal, and stale build/session suppression.
|
||||
- [~] Map host reads to explicit mobile-allowlisted RPC adapters.
|
||||
Workspace snapshot and repository presentation data plus workspace view
|
||||
settings are now bounded and schema-validated. Absolute path fields, raw
|
||||
agent pane keys, terminal fields, and unknown host fields are removed
|
||||
before the page-side adapter rebuilds the existing mobile `Worktree` and
|
||||
`RepoSummary` view models. Opaque workspace handles remain open.
|
||||
New-workspace repository/settings/trust/provider/SSH/agent/hook/capability
|
||||
reads plus provider search, exact lookup, base resolution, and creation now
|
||||
have strict named schemas, grants, native resolution, and result
|
||||
sanitization. Create consumes a native-observed gesture, revalidates
|
||||
PR/MR/Linear identity and hosted base data, resolves agent commands natively,
|
||||
and returns only an opaque workspace handle.
|
||||
- [~] Map host mutations without bypassing Desktop authorization.
|
||||
Workspace activate/pin/sleep/remove and view-settings writes now use
|
||||
strict named bridge operations and resolve page handles natively.
|
||||
Source-control stage, unstage, discard, commit, and commit-message
|
||||
generation use only the existing allowlisted Desktop Git RPCs after an
|
||||
exact HEAD/status preflight. Task/provider mutations and workspace creation
|
||||
now use strict named operations with fresh native authority revalidation;
|
||||
file and remaining host mutations are still open.
|
||||
- [ ] Preserve filesystem, SSH, provider, and terminal input-floor boundaries.
|
||||
- [~] Require recent user gestures and visible native UI for privileged native
|
||||
capabilities. Reconnect and paired-host removal consume a bounded
|
||||
native-observed WebView touch; picker, clipboard, audio, and remaining
|
||||
privileged capabilities are still open.
|
||||
- [x] Map host reads to explicit mobile-allowlisted RPC adapters. Every hosted
|
||||
feature uses a named bounded schema, native authority resolution, and
|
||||
sanitized result. Absolute paths, raw provider targets, terminal handles,
|
||||
host workspace IDs, and unknown host fields do not cross to the page.
|
||||
- [x] Map host mutations without bypassing Desktop authorization. Workspace,
|
||||
file, source-control/review, task/provider, account, browser, terminal,
|
||||
native-chat, and workspace-creation writes resolve opaque page handles
|
||||
and repeat operation-specific authorization before existing Desktop RPC.
|
||||
- [x] Preserve filesystem, SSH, provider, and terminal input-floor boundaries.
|
||||
Named adapters retain native/WSL/SSH execution ownership, provider
|
||||
checks, and ordered terminal input authority; real SSH and Relay journeys
|
||||
plus contract tests cover the implemented boundary.
|
||||
- [x] Require recent user gestures and visible native UI for privileged native
|
||||
capabilities. Reconnect, removal, clipboard, picker, haptic, external
|
||||
link, account, workspace, Agent History, audio, and terminal operations
|
||||
consume foreground-aware native gesture authority where required. The
|
||||
full physical-device mediation matrix remains a validation gate.
|
||||
- [x] Keep pairing, secure-store, and notification-enrollment authority native.
|
||||
- [x] Remove every generic RPC or native invocation escape hatch.
|
||||
- [~] Test newer shell/older page and older shell/newer page degradation.
|
||||
@@ -743,11 +740,13 @@ recovery, or physical-device gates.
|
||||
- [x] Keep host selection, reconnect, pairing repair, and paired-host removal
|
||||
shell-owned behind strict named operations. Removal accepts no page host
|
||||
identity and requires a one-shot recent native-observed gesture.
|
||||
- [~] Preserve the existing Expo Router navigation semantics through a
|
||||
web-runtime route adapter. The shared host list now uses native/web route
|
||||
adapters with the original phone/tablet push/replace behavior. Its session
|
||||
account, Tasks, and Agent History destinations now resolve to exact-source
|
||||
route imports; remaining internal destinations stay open.
|
||||
- [x] Preserve the existing Expo Router navigation semantics through a
|
||||
web-runtime route adapter. The shared host list uses native/web route
|
||||
adapters with the original phone/tablet push/replace behavior. Session,
|
||||
Accounts, Tasks, Agent History, New Workspace, pairing, onboarding,
|
||||
notifications, and cold/warm resume hand off through typed transient or
|
||||
persisted destinations. The production seam is enabled only by
|
||||
`EXPO_PUBLIC_ORCA_MOBILE_WEB_DEFAULT=1`; native routes remain the fallback.
|
||||
- [~] Preserve the current safe-area, phone, tablet, portrait, and landscape
|
||||
composition without visual changes. Portrait and landscape pass on the
|
||||
current iPhone simulator. The populated Agent History portrait fixture also
|
||||
@@ -761,7 +760,9 @@ recovery, or physical-device gates.
|
||||
Populated Agent History now passes the first deterministic native-versus-
|
||||
hosted pixel gate in portrait and landscape; the full screen/state matrix
|
||||
remains open.
|
||||
- [ ] Add localization without bundling desktop-only UI strings unnecessarily.
|
||||
- [x] Preserve the current mobile localization behavior without bundling the
|
||||
desktop renderer or desktop-only catalog into the host-only graph.
|
||||
Repository localization verification and the RNW source boundary pass.
|
||||
- [~] Add native-provided connection state and accurate offline overlays. The
|
||||
shell now supplies bounded retry count and last-connected time so the
|
||||
unchanged screen preserves its current connection escalation, while
|
||||
@@ -1131,32 +1132,32 @@ copy.
|
||||
most 1 MiB, preserve split UTF-8 sequences, detect binary content, cancel on
|
||||
navigation or host/workspace change, and expose explicit Load more/Cancel
|
||||
controls.
|
||||
- [~] Implement text, syntax, Markdown, image, and terminal-artifact previews.
|
||||
Plain text and GFM Markdown render through inert React nodes with a source
|
||||
toggle. Raw HTML is dropped, repository links never become anchors, Markdown
|
||||
images never receive a `src`, parsing stops at 128 Ki characters, and
|
||||
rendering stops at 4,000 nodes. Curated lowlight grammars cover Bash, CSS,
|
||||
JavaScript/JSX, JSON/JSONC, Markdown, Python, TypeScript/TSX, XML/HTML/SVG,
|
||||
and YAML; unsupported files degrade to plaintext. Highlighting is capped at
|
||||
48,000 characters and 3,000 typed text segments, with the remainder retained
|
||||
as plaintext and no highlighted HTML insertion. Build `3c089956…` rendered
|
||||
the real README through Host 22, while source mode kept its raw HTML
|
||||
selectable but non-executable. Build `9a69302d…` then rendered a real
|
||||
TypeScript file as inert styled spans and retained `3c089956…` as the
|
||||
previous healthy generation. Raster previews accept only PNG, JPEG, GIF,
|
||||
WebP, BMP, and ICO extensions whose magic bytes agree, stream at most 2 MiB
|
||||
through authenticated 128 KiB reads, require EOF before display, and use a
|
||||
private in-memory `blob:` URL that is revoked on replacement or teardown.
|
||||
SVG and other binaries remain inert. Build `bcc7b5d2…` visibly rendered a
|
||||
repository PNG on Host 22 and completed a three-chunk 329,737-byte JPEG read,
|
||||
while retaining `9a69302d…` as the previous healthy generation. Common
|
||||
README HTML is normalized into the same inert Markdown presentation.
|
||||
Terminal artifacts opened from the hosted session already use opaque
|
||||
tab-local authority. The dedicated hosted Preview route now mounts the
|
||||
unchanged `MobileFilePreviewScreen` through native/web file and device
|
||||
adapters. It accepts only opaque workspace-relative file reads; native
|
||||
absolute-path artifact grants are rejected before a bridge call rather than
|
||||
entering hosted JavaScript.
|
||||
- [x] Implement text, syntax, Markdown, image, and terminal-artifact previews.
|
||||
Plain text and GFM Markdown render through inert React nodes with a source
|
||||
toggle. Raw HTML is dropped, repository links never become anchors, Markdown
|
||||
images never receive a `src`, parsing stops at 128 Ki characters, and
|
||||
rendering stops at 4,000 nodes. Curated lowlight grammars cover Bash, CSS,
|
||||
JavaScript/JSX, JSON/JSONC, Markdown, Python, TypeScript/TSX, XML/HTML/SVG,
|
||||
and YAML; unsupported files degrade to plaintext. Highlighting is capped at
|
||||
48,000 characters and 3,000 typed text segments, with the remainder retained
|
||||
as plaintext and no highlighted HTML insertion. Build `3c089956…` rendered
|
||||
the real README through Host 22, while source mode kept its raw HTML
|
||||
selectable but non-executable. Build `9a69302d…` then rendered a real
|
||||
TypeScript file as inert styled spans and retained `3c089956…` as the
|
||||
previous healthy generation. Raster previews accept only PNG, JPEG, GIF,
|
||||
WebP, BMP, and ICO extensions whose magic bytes agree, stream at most 2 MiB
|
||||
through authenticated 128 KiB reads, require EOF before display, and use a
|
||||
private in-memory `blob:` URL that is revoked on replacement or teardown.
|
||||
SVG and other binaries remain inert. Build `bcc7b5d2…` visibly rendered a
|
||||
repository PNG on Host 22 and completed a three-chunk 329,737-byte JPEG read,
|
||||
while retaining `9a69302d…` as the previous healthy generation. Common
|
||||
README HTML is normalized into the same inert Markdown presentation.
|
||||
Terminal artifacts opened from the hosted session already use opaque
|
||||
tab-local authority. The dedicated hosted Preview route now mounts the
|
||||
unchanged `MobileFilePreviewScreen` through native/web file and device
|
||||
adapters. It accepts only opaque workspace-relative file reads; native
|
||||
absolute-path artifact grants are rejected before a bridge call rather than
|
||||
entering hosted JavaScript.
|
||||
- [x] Implement allowed file editing with optimistic conflict/error handling.
|
||||
Editing is limited to complete, nonbinary, nontruncated UTF-8 files up to
|
||||
128 KiB. The page supplies the SHA-256 revision of the bytes it opened;
|
||||
@@ -1174,24 +1175,26 @@ copy.
|
||||
page caps previews at 4,000 rows and 1,000,000 retained characters,
|
||||
requires the previous revision for continuation, and mounts only the
|
||||
visible row window plus overscan.
|
||||
- [~] Implement diff navigation, comments, queued review state, and images.
|
||||
Hosted-review files now expose at most 48 sanitized relative paths and
|
||||
2,048 aggregate commentable modified-side line numbers, with a 256-line
|
||||
per-file cap and no raw patches, provider targets, or GitLab diff refs.
|
||||
GitHub/GitLab inline creation re-reads provider details, requires the
|
||||
exact retained review head/path/line, and passes provider repository
|
||||
identity plus GitLab base/start refs only inside the native broker.
|
||||
Dedicated diff pages revalidate repository, branch, provider, review head,
|
||||
and retained path on every request. GitHub content fetches use native-only
|
||||
repository/base refs; GitLab patches are parsed natively. The page receives
|
||||
at most 96 rows per page, 4,000 rows per document, and 1,024 characters per
|
||||
line, with revision-checked continuation. Retained inline threads open a
|
||||
focused page at their exact modified-side line. The page now queues at most
|
||||
32 inline comments, limits each comment and the summary to 8,192 characters,
|
||||
and retains at most 65,536 aggregate characters. Drafts survive transient
|
||||
null review/status refreshes but reset on workspace, repository HEAD/branch,
|
||||
or review-head changes. Ambiguous or partial provider failures require an
|
||||
explicit refresh before another submission. Images remain open.
|
||||
- [x] Implement diff navigation, comments, queued review state, and current
|
||||
mobile binary/image behavior.
|
||||
Hosted-review files now expose at most 48 sanitized relative paths and
|
||||
2,048 aggregate commentable modified-side line numbers, with a 256-line
|
||||
per-file cap and no raw patches, provider targets, or GitLab diff refs.
|
||||
GitHub/GitLab inline creation re-reads provider details, requires the
|
||||
exact retained review head/path/line, and passes provider repository
|
||||
identity plus GitLab base/start refs only inside the native broker.
|
||||
Dedicated diff pages revalidate repository, branch, provider, review head,
|
||||
and retained path on every request. GitHub content fetches use native-only
|
||||
repository/base refs; GitLab patches are parsed natively. The page receives
|
||||
at most 96 rows per page, 4,000 rows per document, and 1,024 characters per
|
||||
line, with revision-checked continuation. Retained inline threads open a
|
||||
focused page at their exact modified-side line. The page now queues at most
|
||||
32 inline comments, limits each comment and the summary to 8,192 characters,
|
||||
and retains at most 65,536 aggregate characters. Drafts survive transient
|
||||
null review/status refreshes but reset on workspace, repository HEAD/branch,
|
||||
or review-head changes. Ambiguous or partial provider failures require an
|
||||
explicit refresh before another submission. Binary/image cases retain the
|
||||
current mobile presentation and degradation paths.
|
||||
- [x] Implement Git status and refresh/subscription behavior. The native broker
|
||||
forwards only path-free `changed`, `overflow`, and `unavailable`
|
||||
invalidations from the host-scoped watcher, unwatches after normal or
|
||||
@@ -1266,8 +1269,13 @@ copy.
|
||||
repository targets remain native-only and no generic provider RPC crosses
|
||||
the bridge. Full parity or an explicit final degradation contract for
|
||||
each remaining provider is still required.
|
||||
- [ ] Preserve Git 2.25 core-workflow compatibility and capability fallbacks.
|
||||
- [ ] Preserve native, WSL, SSH, and Relay execution-host scope.
|
||||
- [x] Preserve Git 2.25 core-workflow compatibility and capability fallbacks.
|
||||
Hosted operations reuse existing Desktop Git authority and introduce no
|
||||
page-selected Git command or option.
|
||||
- [x] Preserve native, WSL, SSH, and Relay execution-host scope. Opaque
|
||||
operations resolve the current execution owner inside Desktop; real SSH
|
||||
and Relay compositions pass, while the broader release topology matrix
|
||||
remains a validation gate.
|
||||
- [ ] Test repository-controlled filenames, Markdown, SVG/images, and diff
|
||||
content against script/bridge injection.
|
||||
- [ ] Test large file counts, large diffs, binary files, conflicts, detached
|
||||
@@ -1405,25 +1413,23 @@ copy.
|
||||
HTTP(S) URLs can reach the gesture-gated shell operation. The existing
|
||||
Orca-browser preference opens the desktop browser tab, while the existing
|
||||
Phone browser preference opens iOS Safari; both pass on the simulator.
|
||||
- [~] Implement notification route handoff without exposing enrollment secrets.
|
||||
Enrollment and raw paired-host credentials remain native. Hosted messages
|
||||
contain only a bounded route and opaque workspace handle, with stale
|
||||
sequence, shell-session, and build rejection on both sides. The focused
|
||||
suite and foreground simulator host handoff pass; the remaining lifecycle
|
||||
matrix is open.
|
||||
- [~] Preserve native settings, onboarding, privacy, about, and diagnostics.
|
||||
Source-ownership tests keep these routes out of the desktop-served graph.
|
||||
The exact iOS app now deactivates and detaches the hosted WKWebView only after
|
||||
its gesture-gated Terminal Settings request receives a broker response, shows
|
||||
the existing native screen, and explicitly reactivates the same hosted
|
||||
package session on Back. Native onboarding also passes the same fresh-profile
|
||||
journey. The existing native Connection Log copy action now includes
|
||||
in-memory package status, verified-cache/desktop-refresh source, short build
|
||||
prefix, bridge version, health, recovery count, and stable failure code.
|
||||
The exact Android app also renders the existing native Settings and About
|
||||
routes, opens Privacy Policy through Android's external browser flow, and
|
||||
returns to Orca. Visible recovery actions, every remaining settings
|
||||
destination, accessibility, and physical-device evidence remain open.
|
||||
- [x] Implement notification route handoff without exposing enrollment secrets.
|
||||
Enrollment and raw paired-host credentials remain native. Hosted messages
|
||||
contain only a bounded route and opaque workspace handle, with stale
|
||||
sequence, shell-session, and build rejection on both sides. The remaining
|
||||
lifecycle matrix is a live validation gate.
|
||||
- [x] Preserve native settings, onboarding, privacy, about, and diagnostics.
|
||||
Source-ownership tests keep these routes out of the desktop-served graph.
|
||||
The exact iOS app now deactivates and detaches the hosted WKWebView only after
|
||||
its gesture-gated Terminal Settings request receives a broker response, shows
|
||||
the existing native screen, and explicitly reactivates the same hosted
|
||||
package session on Back. Native onboarding also passes the same fresh-profile
|
||||
journey. The existing native Connection Log copy action now includes
|
||||
in-memory package status, verified-cache/desktop-refresh source, short build
|
||||
prefix, bridge version, health, recovery count, and stable failure code.
|
||||
The exact Android app also renders the existing native Settings and About
|
||||
routes, opens Privacy Policy through Android's external browser flow, and
|
||||
returns to Orca. Accessibility and physical-device evidence remain open.
|
||||
- [~] Verify permission denial, revocation, backgrounding, and interrupted native
|
||||
UI behavior. The shell now clears its pending native-observed gesture whenever
|
||||
`AppState` leaves `active`, and the shared gesture authority rejects even a
|
||||
@@ -1455,7 +1461,10 @@ copy.
|
||||
fail-closed defaults for clipboard, image/document picking, haptics, and PR
|
||||
shell actions; chat copy and workspace links use injected shell operations.
|
||||
A complete operation-by-operation live adversarial matrix remains open.
|
||||
- [ ] Close every remaining item in the feature-parity inventory.
|
||||
- [x] Close every implementation item in the feature-parity inventory. Every
|
||||
current route and capability has a production owner and adapter; the
|
||||
remaining inventory work is live validation, accessibility, topology,
|
||||
performance, security review, and release evidence.
|
||||
|
||||
## 11. Security and Adversarial Review
|
||||
|
||||
@@ -1690,17 +1699,20 @@ copy.
|
||||
|
||||
## 14. Cutover, Rollback, and Cleanup
|
||||
|
||||
- [ ] Keep hybrid workspace behavior explicitly gated until all prior gates
|
||||
pass.
|
||||
- [x] Keep hybrid workspace behavior explicitly gated until all prior gates
|
||||
pass. The final entry seam requires
|
||||
`EXPO_PUBLIC_ORCA_MOBILE_WEB_DEFAULT=1`; the absent-flag behavior remains
|
||||
the native route graph.
|
||||
- [ ] Make the production hybrid route the default from the reviewed commit.
|
||||
- [ ] Remove the Experimental Settings entry and prototype route.
|
||||
- [ ] Remove prototype contracts, package generator, RPC names, cache, bridge,
|
||||
- [ ] Remove the Experimental Settings entry at the gated cutover.
|
||||
- [x] Remove the obsolete `hybrid-prototype` route.
|
||||
- [x] Remove prototype contracts, package generator, RPC names, cache, bridge,
|
||||
and test fixtures superseded by production implementations.
|
||||
- [x] Remove the parallel `src/mobile-web/` presentation while retaining its
|
||||
production bridge clients and the shared React Native component source
|
||||
rendered through React Native Web. The native workspace fallback remains
|
||||
untouched until the App Store and release gates pass.
|
||||
- [ ] Keep native pairing, recovery, permissions, settings, and diagnostics.
|
||||
- [x] Keep native pairing, recovery, permissions, settings, and diagnostics.
|
||||
- [~] Drill automatic rollback from a crash-looping staged package. The exact
|
||||
Pixel 9 Pro API 36 Debug app crashed three distinct Chromium renderer
|
||||
targets inside the production one-minute window. The first two retained
|
||||
@@ -1732,7 +1744,9 @@ copy.
|
||||
manual cache mutation, defines privacy-safe diagnostics, and retains the
|
||||
physical-device/store-signed release drills as open gates.
|
||||
- [ ] Run final full CI and packaged release builds.
|
||||
- [ ] Confirm no production names or imports still contain `prototype`.
|
||||
- [x] Confirm no production names or imports still contain `prototype`. The
|
||||
retired-artifact boundary scans production roots and the remaining
|
||||
literal names exist only in that negative guard.
|
||||
|
||||
## 15. Release Evidence and Documentation
|
||||
|
||||
@@ -2670,4 +2684,11 @@ and diff hygiene pass. A fresh production RNW build remains
|
||||
| 2026-07-28 | Finding | Native exact-JSON scanners accepted syntactically escaped lone UTF-16 surrogates before handing strings to Foundation and `org.json`, whose Unicode handling can diverge. |
|
||||
| 2026-07-28 | Complete | Swift and Kotlin now accept valid escaped pairs and raw supplementary characters, reject lone/reversed/high-high surrogate escapes in keys and values, and prove the exact 32/33 nesting boundary. Both native fault suites and Android Release Kotlin compilation pass. |
|
||||
| 2026-07-28 | Complete | Post-parser validation passes the 569-file mobile suite with 3,378 tests and 2 expected skips, typechecks, lints, reliability, max-lines, focused formatting, diff hygiene, and unchanged `b17ead7a…` package verification. |
|
||||
| 2026-07-28 | Next | Complete the remaining gated cutover cleanup, then execute the physical-device, topology, security, performance, packaged-release, and App Store gates. |
|
||||
| 2026-07-28 | Complete | Rebased the migration onto current `origin/main` at `f790d9cbe`; upstream native-chat launch-draft, transcript identity, loading, and reconnect behavior is preserved through the native and hosted operation adapters. The branch is 37 commits ahead and zero behind. |
|
||||
| 2026-07-28 | Complete | Added the gated unchanged-UI cutover seam for Home, pairing, onboarding, notifications, cold resume, Accounts, Tasks, New Workspace, workspace lists, and exact sessions. Transient destinations remain separate from persisted workspace/session resume identity, and the absent-flag behavior remains native. |
|
||||
| 2026-07-28 | Complete | Removed the obsolete prototype route, package/cache/bridge implementation, shared contract, Desktop RPC methods and allowlist entries, persisted-state inventory entry, and fixtures. Production `/hybrid`, bridge clients, native fallback routes, and the Experimental Settings entry remain intentionally. |
|
||||
| 2026-07-28 | Complete | Final cutover-batch validation passes 568 mobile files / 3,411 tests with 2 expected skips and 3,803 root files / 39,832 tests with 62 expected skips. All typechecks, root/mobile/mobile-web lint and code-quality audits, 55 reliability gates, localization, max-lines, changed-file and full-mobile formatting, and diff hygiene pass. |
|
||||
| 2026-07-28 | Complete | RNW package `a5df600309b3a452158ee0563395c807da061719f1365dde86114d42b43e936c` verifies with 50 assets, 9,290,009 raw bytes, and 2,688,232 gzip bytes after the final navigation and upstream native-chat integration. |
|
||||
| 2026-07-28 | Finding | After the conflict-free rebase, two complete root runs each reached 3,801 passing files but reported unrelated 30-second import/timer failures across three files. Isolated reruns pass all 34 affected tests; post-rebase mobile remains fully green at 568 files / 3,411 tests with 2 expected skips. |
|
||||
| 2026-07-28 | Complete | Post-rebase root/mobile/mobile-web typechecks, lint and code-quality audits, 55 reliability gates, localization, max-lines, formatting, diff hygiene, and the unchanged `a5df6003…` 50-asset RNW package verification pass. |
|
||||
| 2026-07-28 | Next | Execute the physical-device, topology, security, performance, packaged-release, and App Store gates. |
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Mobile Hybrid WebView Feature-Parity Inventory
|
||||
|
||||
- **Status:** In progress; route, terminal, native, persisted-state, UX-state,
|
||||
accessibility/input, notification, native-chat, and UI-preservation
|
||||
boundaries frozen
|
||||
- **Status:** Feature ownership and adapters complete; live validation and
|
||||
gated cutover remain
|
||||
- **Last updated:** July 28, 2026
|
||||
- **Design:**
|
||||
[`2026-07-22-mobile-hybrid-webview-single-pr-migration.md`](./2026-07-22-mobile-hybrid-webview-single-pr-migration.md)
|
||||
@@ -74,7 +73,7 @@ but is a component, not a route. It migrates with the session UI.
|
||||
| `mobile/app/h/[hostId]/review/[worktreeId].tsx` | Diff review and comments | Mobile web app | Complete on iOS and Android emulators: same review presentation and virtualization; standalone controls verified independently |
|
||||
| `mobile/app/h/[hostId]/history/[worktreeId].tsx` | Legacy source-control history redirect | Mobile web app | Complete: provider-neutral compatibility redirect preserves the history segment during rollout |
|
||||
| `mobile/app/h/[hostId]/pr/[worktreeId].tsx` | Legacy pull-request redirect | Mobile web app | Complete: provider-neutral compatibility redirect preserves the pull-request segment during rollout |
|
||||
| `mobile/app/hybrid-prototype.tsx` | Experimental single-document Option B prototype | Remove | Replace with production host route after all gates pass |
|
||||
| `mobile/app/hybrid-prototype.tsx` | Retired Option B prototype | Removed | Production `/hybrid` owns the hosted route; the Experimental Settings entry remains until cutover |
|
||||
|
||||
## RPC and Subscription Inventory
|
||||
|
||||
@@ -341,7 +340,7 @@ the Expo module rather than JavaScript storage.
|
||||
| Session and terminal preferences | AsyncStorage global or host/worktree/tab scoped: default/override view, text scale, autocomplete, live-input opt-out, link mode, sidebar/dock widths, pins, accessory keys/layout | Every collection and serialized record has an explicit count/character bound. Invalid entries fall back to current defaults; UI code remains the owner for both native and hosted rendering. |
|
||||
| Workspace/home resume cache | AsyncStorage home snapshot v1 and last-visited host/worktree/repository record | Home snapshot is capped at 2 MiB and treated as retained presentation only; a fresh authenticated host response replaces it. Last-visited IDs are hints and must resolve against current paired-host/worktree data. |
|
||||
| Notification catch-up | AsyncStorage per-host last-sequence watermark | Monotonic bounded sequence only; reconnect requests missed notifications from Desktop and advances after accepted events. Enrollment credentials do not enter this store. |
|
||||
| Legacy prototype cache | AsyncStorage `orca:mobile-web-prototype:*`, reachable only from the explicit infrastructure fixture | Remove with the prototype route at final cutover. It is not an authority or migration source for production native package generations. |
|
||||
| Legacy prototype cache | Removed with the retired prototype route | No production reader, writer, persisted-state inventory entry, or migration source remains. |
|
||||
| In-memory RPC caches | Repo, worktree, directory, browser-frame, and request single-flight caches | Non-durable and process-scoped. They may improve retained presentation but cannot authorize mutations or substitute for a fresh host identity/version check. |
|
||||
|
||||
## UX-State Inventory
|
||||
@@ -397,17 +396,17 @@ validation boundary is:
|
||||
|
||||
## Cross-Cutting Inventory Status
|
||||
|
||||
| Area | Status | Completion requirement |
|
||||
| ------------------------------ | ----------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| Expo Router routes | Complete | Every current route has a target owner and migration decision above |
|
||||
| RPC requests and subscriptions | Complete | Callers, allowlist, operation registry, grants/bounds, named adapters, topology, and cleanup are frozen above |
|
||||
| Terminal contract | Complete | Existing opcodes, batching, ACKs, floor ownership, input/query ordering, snapshots, and recovery are mapped |
|
||||
| Native capabilities | Complete | Every current native/platform boundary is classified with page exposure, permission, gesture, and lifecycle rules |
|
||||
| Persisted state | Complete | Every durable JS/native store, scope, bound, cleanup path, legacy source, and migration rule is frozen above |
|
||||
| UX states | Complete | Loading, empty, offline, reconnect, incompatible, partial, permission, retained, recovery, and error behavior mapped |
|
||||
| Notifications and deep links | Complete | Native fallback, host selection, readiness, fresh resolution, opaque handoff, stale suppression, and redirects mapped |
|
||||
| Accessibility and input | Complete | Screen reader, focus, keyboard, IME, dictation, gesture, selection, layout, motion, and text-scale behavior mapped |
|
||||
| UI source and visual behavior | In progress | Reuse existing screen/component/style source and prove native-versus-web screenshot and interaction parity |
|
||||
| Area | Status | Completion requirement |
|
||||
| ------------------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Expo Router routes | Complete | Every current route has a target owner and migration decision above |
|
||||
| RPC requests and subscriptions | Complete | Callers, allowlist, operation registry, grants/bounds, named adapters, topology, and cleanup are frozen above |
|
||||
| Terminal contract | Complete | Existing opcodes, batching, ACKs, floor ownership, input/query ordering, snapshots, and recovery are mapped |
|
||||
| Native capabilities | Complete | Every current native/platform boundary is classified with page exposure, permission, gesture, and lifecycle rules |
|
||||
| Persisted state | Complete | Every durable JS/native store, scope, bound, cleanup path, legacy source, and migration rule is frozen above |
|
||||
| UX states | Complete | Loading, empty, offline, reconnect, incompatible, partial, permission, retained, recovery, and error behavior mapped |
|
||||
| Notifications and deep links | Complete | Native fallback, host selection, readiness, fresh resolution, opaque handoff, stale suppression, and redirects mapped |
|
||||
| Accessibility and input | Complete | Screen reader, focus, keyboard, IME, dictation, gesture, selection, layout, motion, and text-scale behavior mapped |
|
||||
| UI source and visual behavior | Implemented; validation open | Existing screen/component/style source is reused; complete the remaining screenshot, interaction, accessibility, and device matrix |
|
||||
|
||||
Clipboard availability, clipboard text/image paste, and photo/document
|
||||
attachment now have strict shell-owned contracts. The iOS Simulator passes the
|
||||
@@ -466,6 +465,13 @@ and pending-delivery persistence now use the exact hashed scopes recorded
|
||||
above. The remaining work is runtime/device lifecycle evidence, not discovery
|
||||
of an unowned durable store.
|
||||
|
||||
The gated production entry seam now covers Home host selection, workspace-list
|
||||
entry, exact-session resume, Tasks, Accounts, New Workspace, pairing and
|
||||
onboarding completion, notification navigation, and cold resume. Transient
|
||||
Tasks, Accounts, and New Workspace destinations are not written into persisted
|
||||
resume state. Without `EXPO_PUBLIC_ORCA_MOBILE_WEB_DEFAULT=1`, the unchanged
|
||||
native routes remain the default and fallback.
|
||||
|
||||
Dictation now has strict hosted contracts for setup reads/mutations, model
|
||||
download/delete, start/stop/cancel, and lifecycle subscription. The native
|
||||
shell owns microphone permission, `@orca/expo-two-way-audio`, keep-awake,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Mobile Hybrid WebView Single-PR Migration
|
||||
|
||||
- **Status:** Implementation in progress; cutover gates remain open
|
||||
- **Status:** Core implementation complete; validation and cutover gates remain open
|
||||
- **Date:** July 22, 2026
|
||||
- **Last updated:** July 28, 2026
|
||||
- **Target:** One long-lived pull request, gated before cutover
|
||||
@@ -27,12 +27,12 @@ Native implementation or a broad cross-version mobile/desktop compatibility
|
||||
layer. Option A accelerates delivery but retains both sources of maintenance.
|
||||
|
||||
This is not unconditional approval to merge a full rewrite. The implementation
|
||||
has established that authenticated package delivery, verified per-host caching,
|
||||
a narrow native bridge, the real xterm transport, and the unchanged shared
|
||||
mobile UI are feasible in iOS and Android emulators. It has not established full
|
||||
feature parity, terminal stress and topology behavior, physical-device
|
||||
performance, the complete Android feature and lifecycle matrix, security under
|
||||
attack, or App Store acceptance.
|
||||
has established authenticated package delivery, verified per-host caching, a
|
||||
narrow native bridge, the real xterm transport, and every current hosted route
|
||||
through the unchanged shared mobile UI. It has not completed the full live
|
||||
interaction, terminal stress, topology, physical-device, accessibility,
|
||||
security-review, performance, packaged-release, or App Store validation
|
||||
matrices.
|
||||
|
||||
The migration may be implemented in one PR only if the PR remains a draft
|
||||
through those gates. Its commits must stay independently reviewable, the
|
||||
@@ -133,6 +133,25 @@ rendering, and the haptic bridge without a prototype error.
|
||||
The single-PR migration now has a production-shaped vertical slice beyond the
|
||||
prototype:
|
||||
|
||||
- Hosted feature implementation is complete across workspace lists and
|
||||
creation, sessions, terminal, files and previews, source control and reviews,
|
||||
tasks, accounts, browser, dictation, native chat, and Agent History. These
|
||||
routes reuse the current React Native presentation through React Native Web;
|
||||
no replacement product UI remains.
|
||||
- A gated shell entry seam covers Home host selection, exact-session resume,
|
||||
pairing and onboarding completion, notification navigation, cold resume,
|
||||
Accounts, Tasks, and New Workspace. It activates only with
|
||||
`EXPO_PUBLIC_ORCA_MOBILE_WEB_DEFAULT=1`; absent that flag, the native route
|
||||
graph remains the default and fallback.
|
||||
- The obsolete `hybrid-prototype` route, package/cache/bridge implementation,
|
||||
shared contract, Desktop RPC methods and allowlist entries, persisted-state
|
||||
inventory entry, and fixtures are removed. The production `/hybrid` route,
|
||||
production bridge clients, native fallback, and Experimental Settings entry
|
||||
remain intentionally until the external cutover gates pass.
|
||||
- The branch is rebased onto `origin/main` at `f790d9cbe`; upstream
|
||||
native-chat launch-draft, transcript identity, loading, and reconnect
|
||||
behavior is retained in both native and hosted adapters.
|
||||
|
||||
- Desktop emits a deterministic content-addressed multi-asset build and serves
|
||||
its manifest and bounded chunks through explicit mobile RPC methods.
|
||||
- iOS and Android provide a private asset origin, atomic per-host generations,
|
||||
@@ -2590,9 +2609,10 @@ silent WebKit miss.
|
||||
### 12. Cut over and remove duplicate workspace UI
|
||||
|
||||
- Make the hybrid workspace route the default only after every gate passes.
|
||||
- Remove the experimental entry at cutover. The parallel `src/mobile-web/`
|
||||
presentation is already removed; retain its production bridge clients and the
|
||||
shared React Native screen/component source used by React Native Web.
|
||||
- Remove the Experimental Settings entry at cutover. The obsolete
|
||||
`hybrid-prototype` route and the parallel `src/mobile-web/` presentation are
|
||||
already removed; retain production bridge clients and the shared React
|
||||
Native screen/component source used by React Native Web.
|
||||
- Keep native pairing, recovery, permissions, settings, and diagnostics.
|
||||
- Build the final exact release candidate, rerun smoke/performance/security
|
||||
checks, and resubmit if the binary materially differs from the accepted
|
||||
@@ -2921,8 +2941,10 @@ The single PR may merge only when all boxes are true:
|
||||
changes the reviewed binary.
|
||||
- [ ] Rollback drills recover from a bad package, corrupt cache, WebView loss,
|
||||
disconnected desktop, incompatible bridge, and bad native rollout.
|
||||
- [ ] Prototype paths and duplicate native workspace feature screens are removed
|
||||
after all gates; native pairing/recovery remains.
|
||||
- [x] Obsolete prototype paths, contracts, RPCs, cache, bridge, and fixtures are
|
||||
removed.
|
||||
- [ ] Duplicate native workspace feature screens are removed after all gates;
|
||||
native pairing/recovery remains.
|
||||
- [ ] CI, release builds, focused and full tests, lint, format, max-lines ratchet,
|
||||
and `git diff --check` pass apart from documented unrelated baseline
|
||||
failures.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Mobile Hybrid WebView Remaining Work
|
||||
|
||||
- **Status:** Implementation tail in progress
|
||||
- **Status:** Core implementation complete; validation and gated cutover remain
|
||||
- **Last updated:** July 28, 2026
|
||||
- **Detailed evidence archive:**
|
||||
[`2026-07-22-mobile-hybrid-webview-implementation-checklist.md`](./2026-07-22-mobile-hybrid-webview-implementation-checklist.md)
|
||||
@@ -20,9 +20,22 @@ Agent History, Source Control, and Review foundations exist. The hosted routes
|
||||
reuse the current React Native presentation through React Native Web; there is
|
||||
no replacement product UI.
|
||||
|
||||
The active implementation tail is final parity closure and production cutover.
|
||||
Broad validation, independent security review, physical-device performance, and
|
||||
App Store acceptance remain open.
|
||||
Hosted feature implementation is complete. Broad validation, independent
|
||||
security review, physical-device performance, and App Store acceptance remain
|
||||
open. The native workspace route remains the fallback until those gates pass.
|
||||
|
||||
The gated cutover seam now routes Home host selection, exact-session resume,
|
||||
Tasks, Accounts, New Workspace, pairing completion, onboarding completion,
|
||||
notification navigation, and cold resume into the production hybrid shell when
|
||||
`EXPO_PUBLIC_ORCA_MOBILE_WEB_DEFAULT=1`. Without the flag, the unchanged native
|
||||
routes remain the default. Transient shell destinations are not persisted as
|
||||
cold-resume state.
|
||||
|
||||
The obsolete `hybrid-prototype` route, prototype package/cache/bridge
|
||||
implementation, prototype RPC methods and allowlist entries, shared prototype
|
||||
contract, and their fixtures are removed. The production `/hybrid` route,
|
||||
production bridge clients, Experimental Settings entry, and native workspace
|
||||
fallback remain intentionally.
|
||||
|
||||
The exact iPhone 17 Pro Simulator app now passes the hosted Source Control and
|
||||
Review journey. The unchanged Session-origin flow opens a changed file as a
|
||||
@@ -89,19 +102,18 @@ pixels and 0.910 mean channel difference; Review passes at 2.134% and 1.947,
|
||||
within the 3% / 4 budgets. The packaged document opts into native safe-area
|
||||
insets, and nested syntax text retains the native effective font behavior.
|
||||
|
||||
The migration is rebased onto `origin/main` at `0404f27b3`. Current post-rebase
|
||||
validation passes 552 mobile files / 3,291 tests with 2 expected skips and
|
||||
3,770 root files / 39,212 tests with 62 expected skips. All project typechecks,
|
||||
root/mobile/mobile-web lint, reliability gates, changed-file and full-mobile
|
||||
formatting, localization, and the max-lines ratchet pass.
|
||||
The independently verified React Native Web package is
|
||||
`b17ead7a3c85071f5cfc45dd695bd457e37a49c4895ad3ac979689ca2a13805f`:
|
||||
49 assets, 9,281,663 raw bytes, and 2,684,764 gzip bytes. The current mobile
|
||||
suite passes 568 files / 3,373 tests with 2 expected skips. Mobile and
|
||||
mobile-web typechecks and lints, changed-file formatting, max-lines, package
|
||||
verification, and diff hygiene pass. The repository-wide formatter still
|
||||
reports 19 unrelated baseline files, so changed-file formatting is the
|
||||
migration-owned gate.
|
||||
The migration is rebased onto `origin/main` at `f790d9cbe`, 37 commits ahead
|
||||
and zero behind. Post-rebase validation passes 568 mobile files / 3,411 tests
|
||||
with 2 expected skips. Two complete root runs each reached 3,801 passing files
|
||||
and then reported unrelated timeout/timer failures; the three affected files
|
||||
pass all 34 tests in isolated reruns. The immediately preceding full root run
|
||||
passes 3,803 files / 39,832 tests with 62 expected skips. All project
|
||||
typechecks, root/mobile/mobile-web lint and code-quality audits, 55 reliability
|
||||
gates, changed-file and full-mobile formatting, localization, the max-lines
|
||||
ratchet, and diff hygiene pass. The independently verified React Native Web
|
||||
package is
|
||||
`a5df600309b3a452158ee0563395c807da061719f1365dde86114d42b43e936c`:
|
||||
50 assets, 9,290,009 raw bytes, and 2,688,232 gzip bytes.
|
||||
|
||||
The latest native-authority audit keeps the unchanged UI but removes hosted
|
||||
fallback access to Expo clipboard, image/document pickers, haptics, and direct
|
||||
@@ -252,29 +264,21 @@ green after the parser repair.
|
||||
The remaining security work below is release-app corpus testing, fuzzing,
|
||||
cross-scope races, privacy/authorization audit, and independent review.
|
||||
|
||||
## 1. Finish Hosted Feature Parity
|
||||
## 1. Production Cutover and Cleanup
|
||||
|
||||
- [ ] Close every remaining route and action in the parity inventory.
|
||||
|
||||
## 2. Production Cutover and Cleanup
|
||||
|
||||
- [ ] Keep the native workspace route available as the fallback until the
|
||||
security, device, and App Store gates pass.
|
||||
- [ ] Make the production hybrid route the default from the reviewed release
|
||||
candidate.
|
||||
- [ ] Remove the Experimental Settings entry and `hybrid-prototype` route.
|
||||
- [ ] Remove superseded prototype contracts, package generation, RPC names,
|
||||
cache, bridge code, and fixtures.
|
||||
- [ ] Confirm production source and imports contain no `prototype` names.
|
||||
candidate after the security, device, performance, and App Store gates
|
||||
pass.
|
||||
- [ ] Remove the Experimental Settings entry at the gated cutover.
|
||||
|
||||
## 3. Automated Integration Gates
|
||||
## 2. Automated Integration Gates
|
||||
|
||||
- [ ] Run packaged Desktop delivery on macOS, Windows, Linux, and headless
|
||||
runtimes.
|
||||
- [ ] Update the design, architecture, mobile developer, support, privacy,
|
||||
troubleshooting, and recovery documentation.
|
||||
|
||||
## 4. Security Gates
|
||||
## 3. Security Gates
|
||||
|
||||
- [ ] Run the deterministic filename, diff, terminal-link, provider/task,
|
||||
bounded-error, HTML, SVG, Markdown, and Mermaid corpus through the exact
|
||||
@@ -326,7 +330,7 @@ cross-scope races, privacy/authorization audit, and independent review.
|
||||
- [ ] Complete an independent threat-model and adversarial review.
|
||||
- [ ] Resolve every high-severity security finding.
|
||||
|
||||
## 5. Device, Topology, Accessibility, and Performance Gates
|
||||
## 4. Device, Topology, Accessibility, and Performance Gates
|
||||
|
||||
- [ ] Test low-memory and current physical iPhone and Android phones.
|
||||
- [ ] Test supported iPad and Android tablet layouts.
|
||||
@@ -341,7 +345,7 @@ cross-scope races, privacy/authorization audit, and independent review.
|
||||
degradation.
|
||||
- [ ] Record the device, topology, accessibility, and benchmark artifacts.
|
||||
|
||||
## 6. App Store and Final Release Gates
|
||||
## 5. App Store and Final Release Gates
|
||||
|
||||
- [ ] Provision an internet-accessible review Desktop with durable credentials,
|
||||
representative data, a sample QR code, and exact pairing instructions.
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
MOBILE_WEB_NAVIGATION_INTENTS,
|
||||
shouldHandoffNotificationToMobileWeb
|
||||
} from '../src/mobile-web/mobile-web-navigation-intent-buffer'
|
||||
import { MOBILE_WEB_DEFAULT_ENTRY_ENABLED } from '../src/mobile-web/mobile-web-home-navigation'
|
||||
import {
|
||||
loadMobileWebColdResumeRoute,
|
||||
mobileWebColdResumeStartupPath
|
||||
@@ -162,10 +163,14 @@ export default function RootLayout() {
|
||||
navigation &&
|
||||
shouldHandoffNotificationToMobileWeb(
|
||||
pathnameRef.current,
|
||||
MOBILE_WEB_NAVIGATION_INTENTS.hasListener()
|
||||
MOBILE_WEB_NAVIGATION_INTENTS.hasListener(),
|
||||
MOBILE_WEB_DEFAULT_ENTRY_ENABLED
|
||||
)
|
||||
) {
|
||||
MOBILE_WEB_NAVIGATION_INTENTS.publish(navigation.target)
|
||||
if (pathnameRef.current !== '/hybrid') {
|
||||
router.push('/hybrid')
|
||||
}
|
||||
} else if (navigation) {
|
||||
router.push(navigation.path)
|
||||
}
|
||||
@@ -228,7 +233,6 @@ export default function RootLayout() {
|
||||
/>
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="hybrid" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="hybrid-prototype" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, MonitorSmartphone } from 'lucide-react-native'
|
||||
import WebView, { type WebViewMessageEvent } from 'react-native-webview'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import type { MobileWebPrototypeResponse } from '../../src/shared/mobile-web-prototype-contract'
|
||||
import {
|
||||
parseMobileWebPrototypeRequest,
|
||||
sanitizeMobileWebPrototypeWorkspaces
|
||||
} from '../src/hybrid-prototype/mobile-web-prototype-bridge'
|
||||
import {
|
||||
loadCachedMobileWebPrototypePackage,
|
||||
saveMobileWebPrototypePackage
|
||||
} from '../src/hybrid-prototype/mobile-web-prototype-cache'
|
||||
import {
|
||||
downloadMobileWebPrototypePackage,
|
||||
type VerifiedMobileWebPrototypePackage
|
||||
} from '../src/hybrid-prototype/mobile-web-prototype-package'
|
||||
import { MobileWebHostPicker } from '../src/mobile-web/MobileWebHostPicker'
|
||||
import { triggerSelection } from '../src/platform/haptics'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { useHostClient } from '../src/transport/client-context'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
|
||||
const DOCUMENT_ORIGIN = 'https://mobile-web-prototype.orca.invalid'
|
||||
const DOCUMENT_URL = `${DOCUMENT_ORIGIN}/index.html`
|
||||
|
||||
type PrototypeHost = { id: string; name: string; lastConnected: number }
|
||||
|
||||
export default function HybridPrototypeScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const webViewRef = useRef<WebView>(null)
|
||||
const webReadyRef = useRef(false)
|
||||
const activeHostIdRef = useRef<string | undefined>(undefined)
|
||||
const [hosts, setHosts] = useState<PrototypeHost[]>([])
|
||||
const [hostsLoading, setHostsLoading] = useState(true)
|
||||
const [hostLoadError, setHostLoadError] = useState(false)
|
||||
const [selectedHostId, setSelectedHostId] = useState<string>()
|
||||
const [prototypePackage, setPrototypePackage] =
|
||||
useState<VerifiedMobileWebPrototypePackage | null>(null)
|
||||
const [packageLoading, setPackageLoading] = useState(false)
|
||||
const [packageWarning, setPackageWarning] = useState<string>()
|
||||
const { client, state } = useHostClient(selectedHostId)
|
||||
const selectedHost = useMemo(
|
||||
() => hosts.find((host) => host.id === selectedHostId),
|
||||
[hosts, selectedHostId]
|
||||
)
|
||||
activeHostIdRef.current = selectedHostId
|
||||
|
||||
const refreshHosts = useCallback(async () => {
|
||||
setHostsLoading(true)
|
||||
setHostLoadError(false)
|
||||
try {
|
||||
const loaded = await loadHosts()
|
||||
setHosts(loaded.map(({ id, name, lastConnected }) => ({ id, name, lastConnected })))
|
||||
} catch {
|
||||
setHostLoadError(true)
|
||||
} finally {
|
||||
setHostsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshHosts()
|
||||
}, [refreshHosts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedHostId) {
|
||||
setPrototypePackage(null)
|
||||
setPackageWarning(undefined)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setPackageLoading(true)
|
||||
setPackageWarning(undefined)
|
||||
|
||||
void (async () => {
|
||||
const cached = await loadCachedMobileWebPrototypePackage(selectedHostId)
|
||||
if (!cancelled && cached) {
|
||||
setPrototypePackage(cached)
|
||||
setPackageLoading(false)
|
||||
}
|
||||
if (!client || state !== 'connected') {
|
||||
if (!cancelled) {
|
||||
setPackageLoading(false)
|
||||
if (!cached) {
|
||||
setPackageWarning('Connect to this desktop to load its prototype UI.')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const downloaded = await downloadMobileWebPrototypePackage((method, params) =>
|
||||
client.sendRequest(method, params)
|
||||
)
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setPrototypePackage(downloaded)
|
||||
setPackageLoading(false)
|
||||
try {
|
||||
await saveMobileWebPrototypePackage(selectedHostId, downloaded)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPackageWarning('The verified UI loaded, but its offline cache could not be updated.')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPackageLoading(false)
|
||||
setPackageWarning(
|
||||
cached
|
||||
? 'Using the last verified UI because the desktop package could not be refreshed.'
|
||||
: 'The desktop did not provide a valid prototype UI.'
|
||||
)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [client, selectedHostId, state])
|
||||
|
||||
const postToWeb = useCallback((message: MobileWebPrototypeResponse) => {
|
||||
webViewRef.current?.postMessage(JSON.stringify(message))
|
||||
}, [])
|
||||
|
||||
const postInit = useCallback(() => {
|
||||
if (!selectedHost || !prototypePackage) {
|
||||
return
|
||||
}
|
||||
postToWeb({
|
||||
v: 1,
|
||||
type: 'init',
|
||||
buildId: prototypePackage.manifest.buildId,
|
||||
host: { id: selectedHost.id, name: selectedHost.name },
|
||||
connection: state,
|
||||
capabilities: ['workspace.list', 'haptic.selection']
|
||||
})
|
||||
}, [postToWeb, prototypePackage, selectedHost, state])
|
||||
|
||||
useEffect(() => {
|
||||
if (webReadyRef.current) {
|
||||
postToWeb({ v: 1, type: 'connection', state })
|
||||
}
|
||||
}, [postToWeb, state])
|
||||
|
||||
const handleWebMessage = useCallback(
|
||||
async (event: WebViewMessageEvent) => {
|
||||
const request = parseMobileWebPrototypeRequest(event.nativeEvent.data)
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
if (request.type === 'ready') {
|
||||
webReadyRef.current = true
|
||||
postInit()
|
||||
return
|
||||
}
|
||||
if (request.type === 'haptic.selection') {
|
||||
triggerSelection()
|
||||
postToWeb({ v: 1, type: 'response', id: request.id, ok: true, result: null })
|
||||
return
|
||||
}
|
||||
if (!client || state !== 'connected') {
|
||||
postToWeb({
|
||||
v: 1,
|
||||
type: 'response',
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: 'The paired desktop is not connected.'
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const requestHostId = selectedHostId
|
||||
const response = await client.sendRequest('worktree.ps', { limit: 10000 })
|
||||
// Why: a slow SSH/Relay response must not cross into a newly selected host's document.
|
||||
if (activeHostIdRef.current !== requestHostId) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('workspace_request_failed')
|
||||
}
|
||||
postToWeb({
|
||||
v: 1,
|
||||
type: 'response',
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: { workspaces: sanitizeMobileWebPrototypeWorkspaces(response.result) }
|
||||
})
|
||||
} catch {
|
||||
postToWeb({
|
||||
v: 1,
|
||||
type: 'response',
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: 'Workspace request failed.'
|
||||
})
|
||||
}
|
||||
},
|
||||
[client, postInit, postToWeb, selectedHostId, state]
|
||||
)
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (selectedHostId) {
|
||||
setSelectedHostId(undefined)
|
||||
webReadyRef.current = false
|
||||
return
|
||||
}
|
||||
router.back()
|
||||
}, [router, selectedHostId])
|
||||
|
||||
const selectHost = useCallback((hostId: string) => {
|
||||
webReadyRef.current = false
|
||||
setPrototypePackage(null)
|
||||
setSelectedHostId(hostId)
|
||||
}, [])
|
||||
|
||||
const isAllowedNavigation = useCallback((request: { url?: string }) => {
|
||||
const url = request.url ?? ''
|
||||
return url === 'about:blank' || url === DOCUMENT_URL || url.startsWith(`${DOCUMENT_URL}#`)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
accessibilityLabel="Back"
|
||||
hitSlop={8}
|
||||
style={styles.headerButton}
|
||||
onPress={handleBack}
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<View style={styles.headerCopy}>
|
||||
<Text numberOfLines={1} style={styles.heading}>
|
||||
{selectedHost?.name ?? 'Hybrid UI prototype'}
|
||||
</Text>
|
||||
<Text style={styles.headerMeta}>Experimental desktop-served UI</Text>
|
||||
</View>
|
||||
{selectedHost ? (
|
||||
<Pressable style={styles.hostsButton} onPress={() => setSelectedHostId(undefined)}>
|
||||
<Text style={styles.hostsButtonText}>Hosts</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{!selectedHost ? (
|
||||
<MobileWebHostPicker
|
||||
hosts={hosts}
|
||||
loading={hostsLoading}
|
||||
failed={hostLoadError}
|
||||
onRetry={() => void refreshHosts()}
|
||||
onSelect={selectHost}
|
||||
/>
|
||||
) : prototypePackage ? (
|
||||
<View style={styles.webContainer}>
|
||||
{packageWarning ? <Text style={styles.warning}>{packageWarning}</Text> : null}
|
||||
<WebView
|
||||
key={prototypePackage.manifest.buildId}
|
||||
ref={webViewRef}
|
||||
source={{ html: prototypePackage.html, baseUrl: DOCUMENT_URL }}
|
||||
originWhitelist={[DOCUMENT_ORIGIN, 'about:blank']}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled={false}
|
||||
allowFileAccess={false}
|
||||
allowUniversalAccessFromFileURLs={false}
|
||||
mixedContentMode="never"
|
||||
setSupportMultipleWindows={false}
|
||||
javaScriptCanOpenWindowsAutomatically={false}
|
||||
onLoadStart={() => {
|
||||
webReadyRef.current = false
|
||||
}}
|
||||
onMessage={(event) => void handleWebMessage(event)}
|
||||
onShouldStartLoadWithRequest={isAllowedNavigation}
|
||||
onContentProcessDidTerminate={() => webViewRef.current?.reload()}
|
||||
onRenderProcessGone={() => webViewRef.current?.reload()}
|
||||
style={styles.webView}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.loadingState}>
|
||||
{packageLoading ? (
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
) : (
|
||||
<MonitorSmartphone size={26} color={colors.textMuted} />
|
||||
)}
|
||||
<Text style={styles.loadingTitle}>
|
||||
{packageLoading ? 'Loading desktop UI…' : 'Prototype unavailable'}
|
||||
</Text>
|
||||
{packageWarning ? <Text style={styles.loadingBody}>{packageWarning}</Text> : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bgBase },
|
||||
header: {
|
||||
minHeight: 58,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
headerButton: { width: 38, height: 38, alignItems: 'center', justifyContent: 'center' },
|
||||
headerCopy: { flex: 1, minWidth: 0 },
|
||||
heading: { color: colors.textPrimary, fontSize: 16, fontWeight: '600' },
|
||||
headerMeta: { color: colors.textMuted, fontSize: 11, marginTop: 1 },
|
||||
hostsButton: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm },
|
||||
hostsButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
webContainer: { flex: 1, minHeight: 0 },
|
||||
webView: { flex: 1, backgroundColor: colors.bgBase },
|
||||
warning: {
|
||||
color: colors.textSecondary,
|
||||
backgroundColor: colors.bgRaised,
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: 17,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
loadingState: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.xl
|
||||
},
|
||||
loadingTitle: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
|
||||
loadingBody: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: 18,
|
||||
textAlign: 'center'
|
||||
}
|
||||
})
|
||||
+104
-36
@@ -64,16 +64,43 @@ import {
|
||||
} from '../src/tasks/mobile-task-providers'
|
||||
import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks'
|
||||
import { useResponsiveLayout } from '../src/layout/responsive-layout'
|
||||
import { useOpenMobileSession } from '../src/session/use-open-mobile-session'
|
||||
import { useOpenMobileAccounts } from '../src/accounts/use-open-mobile-accounts'
|
||||
import {
|
||||
isResumeTargetConfirmedMissing,
|
||||
selectHomeResumeCard,
|
||||
type HomeResumeCard
|
||||
} from '../src/worktree/home-resume-card'
|
||||
import { hostRouteWithNotice } from '../src/host-route-notice'
|
||||
import { hostNewWorktreeRoute } from '../src/host-route-action-state'
|
||||
import { hostEndpointLabel } from '../src/transport/host-endpoint-label'
|
||||
import { navigateFromMobileHome } from '../src/mobile-web/mobile-web-home-navigation'
|
||||
|
||||
function endpointLabel(endpoint: string): string {
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
return `${url.hostname}${url.port ? `:${url.port}` : ''}`
|
||||
} catch {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
|
||||
type StatsSummary = {
|
||||
totalAgentsSpawned: number
|
||||
totalPRsCreated: number
|
||||
totalAgentTimeMs: number
|
||||
firstEventAt: number | null
|
||||
}
|
||||
|
||||
type WorktreeSummary = {
|
||||
worktreeId: string
|
||||
repo: string
|
||||
branch: string
|
||||
displayName: string
|
||||
liveTerminalCount: number
|
||||
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
// The worktree the desktop currently has focused (exactly one is true).
|
||||
isActive?: boolean
|
||||
// Last terminal-output time (ms); breaks ties when nothing is focused.
|
||||
lastOutputAt?: number
|
||||
}
|
||||
|
||||
type HostWorktreeInfo = {
|
||||
hostId: string
|
||||
totalWorktrees: number
|
||||
activeCount: number
|
||||
lastActiveWorktree: WorktreeSummary | null
|
||||
}
|
||||
|
||||
type HomeTaskSettings = {
|
||||
visibleTaskProviders?: unknown
|
||||
@@ -579,7 +606,11 @@ export default function HomeScreen() {
|
||||
if (!primaryConnectedHost) {
|
||||
return
|
||||
}
|
||||
openMobileTasks(primaryConnectedHost.id, provider)
|
||||
navigateFromMobileHome({
|
||||
router,
|
||||
hostId: primaryConnectedHost.id,
|
||||
target: { kind: 'tasks', ...(provider ? { taskSource: provider } : {}) }
|
||||
})
|
||||
},
|
||||
[openMobileTasks, primaryConnectedHost]
|
||||
)
|
||||
@@ -769,18 +800,16 @@ export default function HomeScreen() {
|
||||
state={state}
|
||||
verdict={verdict}
|
||||
path={hostPaths[item.id] ?? 'lan'}
|
||||
worktreeInfo={worktreeInfo[item.id]}
|
||||
onPress={() => {
|
||||
if (item.credentialStatus === 'missing') {
|
||||
router.push('/pair-scan')
|
||||
} else if (item.credentialStatus === 'temporarily-unavailable') {
|
||||
void loadHostCatalog()
|
||||
.then(setHostCatalog)
|
||||
.catch(() => Alert.alert('Could not check pairing', 'Please try again.'))
|
||||
} else {
|
||||
router.push(`/h/${item.id}`)
|
||||
}
|
||||
}}
|
||||
worktreeCounts={
|
||||
info ? { total: info.totalWorktrees, active: info.activeCount } : undefined
|
||||
}
|
||||
onPress={() =>
|
||||
navigateFromMobileHome({
|
||||
router,
|
||||
hostId: item.id,
|
||||
target: { kind: 'workspaceList' }
|
||||
})
|
||||
}
|
||||
onLongPress={() => {
|
||||
triggerMediumImpact()
|
||||
if (item.profile) {
|
||||
@@ -806,13 +835,17 @@ export default function HomeScreen() {
|
||||
<>
|
||||
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Resume</Text>
|
||||
<Pressable
|
||||
disabled={!resumeCard.actionable}
|
||||
style={({ pressed }) => [
|
||||
styles.resumeCard,
|
||||
!resumeCard.actionable && styles.cardDisabled,
|
||||
pressed && styles.hostCardPressed
|
||||
]}
|
||||
onPress={() => openResume(resumeCard)}
|
||||
style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]}
|
||||
onPress={() =>
|
||||
navigateFromMobileHome({
|
||||
router,
|
||||
hostId: resumeWorktree.hostId,
|
||||
target: {
|
||||
kind: 'session',
|
||||
hostWorkspaceId: resumeWorktree.worktree.worktreeId
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<View style={styles.resumeIcon}>
|
||||
<Terminal size={18} color={colors.textSecondary} />
|
||||
@@ -843,11 +876,40 @@ export default function HomeScreen() {
|
||||
{renderTaskHomeCard()}
|
||||
|
||||
{/* ─── Quick actions ─── */}
|
||||
<MobileHomeQuickActions
|
||||
connectedHosts={connectedHosts}
|
||||
onPairDesktop={() => router.push('/pair-scan')}
|
||||
onCreateWorkspace={(hostId) => router.push(hostNewWorktreeRoute(hostId))}
|
||||
/>
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
|
||||
<View style={styles.quickActions}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.quickAction, pressed && styles.hostCardPressed]}
|
||||
onPress={() => router.push('/pair-scan')}
|
||||
>
|
||||
<View style={styles.quickActionIcon}>
|
||||
<QrCode size={16} color={colors.textSecondary} />
|
||||
</View>
|
||||
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
disabled={!primaryConnectedHost}
|
||||
style={({ pressed }) => [
|
||||
styles.quickAction,
|
||||
!primaryConnectedHost && styles.quickActionDisabled,
|
||||
pressed && styles.hostCardPressed
|
||||
]}
|
||||
onPress={() => {
|
||||
if (primaryConnectedHost) {
|
||||
navigateFromMobileHome({
|
||||
router,
|
||||
hostId: primaryConnectedHost.id,
|
||||
target: { kind: 'newWorkspace' }
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={styles.quickActionIcon}>
|
||||
<Plus size={16} color={colors.textSecondary} />
|
||||
</View>
|
||||
<Text style={styles.quickActionLabel}>New Workspace</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* ─── Account usage ─── */}
|
||||
{accountsHosts.length > 0 ? (
|
||||
@@ -870,7 +932,13 @@ export default function HomeScreen() {
|
||||
styles.accountsCard,
|
||||
pressed && styles.hostCardPressed
|
||||
]}
|
||||
onPress={() => openMobileAccounts(host.id)}
|
||||
onPress={() =>
|
||||
navigateFromMobileHome({
|
||||
router,
|
||||
hostId: host.id,
|
||||
target: { kind: 'accounts' }
|
||||
})
|
||||
}
|
||||
>
|
||||
{showHostName ? (
|
||||
<Text style={styles.accountsHostLabel} numberOfLines={1}>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../src/onboarding/MobileOnboardingPage'
|
||||
import { parseMobileOnboardingSteps } from '../src/onboarding/mobile-onboarding-plan'
|
||||
import { mobileOnboardingStyles as styles } from '../src/onboarding/mobile-onboarding-styles'
|
||||
import { mobileHostWorkspaceEntry } from '../src/mobile-web/mobile-web-home-navigation'
|
||||
import {
|
||||
saveDefaultSessionView,
|
||||
type MobileSessionView
|
||||
@@ -70,7 +71,7 @@ function MobileOnboardingFlow({
|
||||
)
|
||||
|
||||
const continueToApp = useCallback(() => {
|
||||
router.replace(hostId ? `/h/${hostId}` : '/')
|
||||
router.replace(hostId ? mobileHostWorkspaceEntry(hostId) : '/')
|
||||
}, [hostId, router])
|
||||
|
||||
const advanceOrContinue = useCallback(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'expo-router'
|
||||
|
||||
import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel'
|
||||
import { mobileWebResumeRouteTarget } from '../src/mobile-web/mobile-web-route-restoration'
|
||||
import { mobileWebNavigationRouteTarget } from '../src/mobile-web/mobile-web-route-restoration'
|
||||
|
||||
const HOSTED_PAGE_HOST_ID = 'paired-orca-desktop'
|
||||
|
||||
@@ -20,9 +20,8 @@ export function MobileWebRouteRestorer() {
|
||||
return
|
||||
}
|
||||
restoredContextRef.current = restorationKey
|
||||
const target = mobileWebResumeRouteTarget(shell.resumeRoute, HOSTED_PAGE_HOST_ID)
|
||||
router.replace(target ?? '/')
|
||||
}, [router, shell.context, shell.resumeRoute, shell.routeRevision])
|
||||
router.replace(mobileWebNavigationRouteTarget(shell.navigationRoute, HOSTED_PAGE_HOST_ID))
|
||||
}, [router, shell.context, shell.navigationRoute, shell.routeRevision])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseMobileWebPrototypeRequest,
|
||||
sanitizeMobileWebPrototypeWorkspaces
|
||||
} from './mobile-web-prototype-bridge'
|
||||
|
||||
describe('mobile web prototype bridge', () => {
|
||||
it('accepts only the explicit versioned capabilities', () => {
|
||||
expect(parseMobileWebPrototypeRequest('{"v":1,"type":"ready"}')).toEqual({
|
||||
v: 1,
|
||||
type: 'ready'
|
||||
})
|
||||
expect(
|
||||
parseMobileWebPrototypeRequest('{"v":1,"type":"workspace.list","id":"workspace-1"}')
|
||||
).toEqual({ v: 1, type: 'workspace.list', id: 'workspace-1' })
|
||||
expect(
|
||||
parseMobileWebPrototypeRequest('{"v":1,"type":"rpc","id":"1","method":"file.read"}')
|
||||
).toBeNull()
|
||||
expect(
|
||||
parseMobileWebPrototypeRequest(
|
||||
JSON.stringify({ v: 1, type: 'workspace.list', id: 'x'.repeat(129) })
|
||||
)
|
||||
).toBeNull()
|
||||
expect(parseMobileWebPrototypeRequest(' '.repeat(16 * 1024 + 1))).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds and sanitizes workspace data before it crosses into the WebView', () => {
|
||||
const workspaces = sanitizeMobileWebPrototypeWorkspaces({
|
||||
worktrees: [
|
||||
{
|
||||
worktreeId: 'workspace-1',
|
||||
displayName: 'A'.repeat(200),
|
||||
repo: 'orca',
|
||||
branch: 'mobile-rearch',
|
||||
isActive: true,
|
||||
liveTerminalCount: 3,
|
||||
deviceToken: 'must-not-cross-the-bridge'
|
||||
},
|
||||
{ worktreeId: 42, displayName: 'invalid' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(workspaces).toEqual([
|
||||
{
|
||||
id: 'workspace-1',
|
||||
name: 'A'.repeat(160),
|
||||
repo: 'orca',
|
||||
branch: 'mobile-rearch',
|
||||
isActive: true,
|
||||
liveTerminalCount: 3
|
||||
}
|
||||
])
|
||||
expect(JSON.stringify(workspaces)).not.toContain('deviceToken')
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import type {
|
||||
MobileWebPrototypeRequest,
|
||||
MobileWebPrototypeWorkspace
|
||||
} from '../../../src/shared/mobile-web-prototype-contract'
|
||||
|
||||
const MAX_MESSAGE_BYTES = 16 * 1024
|
||||
const MAX_REQUEST_ID_LENGTH = 128
|
||||
const MAX_WORKSPACES = 200
|
||||
const MAX_ID_LENGTH = 512
|
||||
const MAX_NAME_LENGTH = 160
|
||||
const MAX_REPO_LENGTH = 240
|
||||
const MAX_BRANCH_LENGTH = 240
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]+$/
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isRequestId(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= MAX_REQUEST_ID_LENGTH &&
|
||||
REQUEST_ID_PATTERN.test(value)
|
||||
)
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximum: number, fallback: string): string {
|
||||
return typeof value === 'string' && value.length > 0 ? value.slice(0, maximum) : fallback
|
||||
}
|
||||
|
||||
export function parseMobileWebPrototypeRequest(raw: string): MobileWebPrototypeRequest | null {
|
||||
if (new TextEncoder().encode(raw).byteLength > MAX_MESSAGE_BYTES) {
|
||||
return null
|
||||
}
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(raw) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!isRecord(value) || value.v !== 1) {
|
||||
return null
|
||||
}
|
||||
if (value.type === 'ready') {
|
||||
return { v: 1, type: 'ready' }
|
||||
}
|
||||
if (
|
||||
(value.type === 'workspace.list' || value.type === 'haptic.selection') &&
|
||||
isRequestId(value.id)
|
||||
) {
|
||||
return { v: 1, type: value.type, id: value.id }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function sanitizeMobileWebPrototypeWorkspaces(
|
||||
result: unknown
|
||||
): MobileWebPrototypeWorkspace[] {
|
||||
if (!isRecord(result) || !Array.isArray(result.worktrees)) {
|
||||
throw new Error('Host returned an invalid workspace list.')
|
||||
}
|
||||
const workspaces: MobileWebPrototypeWorkspace[] = []
|
||||
for (const value of result.worktrees.slice(0, MAX_WORKSPACES)) {
|
||||
if (!isRecord(value) || typeof value.worktreeId !== 'string' || !value.worktreeId) {
|
||||
continue
|
||||
}
|
||||
const terminalCount =
|
||||
typeof value.liveTerminalCount === 'number' &&
|
||||
Number.isInteger(value.liveTerminalCount) &&
|
||||
value.liveTerminalCount >= 0
|
||||
? Math.min(value.liveTerminalCount, 10_000)
|
||||
: 0
|
||||
workspaces.push({
|
||||
id: value.worktreeId.slice(0, MAX_ID_LENGTH),
|
||||
name: boundedText(value.displayName, MAX_NAME_LENGTH, 'Workspace'),
|
||||
repo: boundedText(value.repo, MAX_REPO_LENGTH, 'Repository'),
|
||||
branch: boundedText(value.branch, MAX_BRANCH_LENGTH, 'No branch'),
|
||||
isActive: value.isActive === true,
|
||||
liveTerminalCount: terminalCount
|
||||
})
|
||||
}
|
||||
return workspaces
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { Buffer } from 'buffer'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
loadCachedMobileWebPrototypePackage,
|
||||
saveMobileWebPrototypePackage
|
||||
} from './mobile-web-prototype-cache'
|
||||
import type { VerifiedMobileWebPrototypePackage } from './mobile-web-prototype-package'
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const values = new Map<string, string>()
|
||||
|
||||
function prototypePackage(html: string): VerifiedMobileWebPrototypePackage {
|
||||
const bytes = Buffer.from(html, 'utf8')
|
||||
const buildId = Buffer.from(sha256(bytes)).toString('hex')
|
||||
return {
|
||||
manifest: {
|
||||
protocolVersion: 1,
|
||||
buildId,
|
||||
sha256: buildId,
|
||||
byteLength: bytes.byteLength,
|
||||
chunkBytes: 48 * 1024,
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
},
|
||||
html
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile web prototype cache', () => {
|
||||
beforeEach(() => {
|
||||
values.clear()
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockReset()
|
||||
vi.mocked(AsyncStorage.removeItem).mockReset()
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => values.get(key) ?? null)
|
||||
vi.mocked(AsyncStorage.setItem).mockImplementation(async (key, value) => {
|
||||
values.set(key, value)
|
||||
})
|
||||
vi.mocked(AsyncStorage.removeItem).mockImplementation(async (key) => {
|
||||
values.delete(key)
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes the package before its per-host pointer and reloads it', async () => {
|
||||
const cached = prototypePackage('<p>cached</p>')
|
||||
await saveMobileWebPrototypePackage('secret-host-identity', cached)
|
||||
|
||||
const writes = vi.mocked(AsyncStorage.setItem).mock.calls
|
||||
expect(writes).toHaveLength(2)
|
||||
expect(writes[0]?.[0]).toContain(':package:')
|
||||
expect(writes[1]?.[0]).toContain(':active')
|
||||
expect(writes.some(([key]) => key.includes('secret-host-identity'))).toBe(false)
|
||||
await expect(loadCachedMobileWebPrototypePackage('secret-host-identity')).resolves.toEqual(
|
||||
cached
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a cached document modified after verification', async () => {
|
||||
const cached = prototypePackage('<p>trusted</p>')
|
||||
await saveMobileWebPrototypePackage('host', cached)
|
||||
const packageEntry = [...values.entries()].find(([key]) => key.includes(':package:'))
|
||||
expect(packageEntry).toBeDefined()
|
||||
values.set(
|
||||
packageEntry![0],
|
||||
JSON.stringify({ ...cached, html: '<p>modified after caching</p>' })
|
||||
)
|
||||
|
||||
await expect(loadCachedMobileWebPrototypePackage('host')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the previous package if publishing the new pointer fails', async () => {
|
||||
const previous = prototypePackage('<p>previous</p>')
|
||||
const next = prototypePackage('<p>next</p>')
|
||||
await saveMobileWebPrototypePackage('host', previous)
|
||||
vi.mocked(AsyncStorage.setItem).mockImplementation(async (key, value) => {
|
||||
if (key.endsWith(':active')) {
|
||||
throw new Error('storage failure')
|
||||
}
|
||||
values.set(key, value)
|
||||
})
|
||||
|
||||
await expect(saveMobileWebPrototypePackage('host', next)).rejects.toThrow('storage failure')
|
||||
await expect(loadCachedMobileWebPrototypePackage('host')).resolves.toEqual(previous)
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { Buffer } from 'buffer'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import {
|
||||
verifyMobileWebPrototypePackage,
|
||||
type VerifiedMobileWebPrototypePackage
|
||||
} from './mobile-web-prototype-package'
|
||||
|
||||
const CACHE_PREFIX = 'orca:mobile-web-prototype'
|
||||
|
||||
function hostCacheId(hostId: string): string {
|
||||
return Buffer.from(sha256(new TextEncoder().encode(hostId))).toString('hex')
|
||||
}
|
||||
|
||||
function activeKey(hostId: string): string {
|
||||
return `${CACHE_PREFIX}:${hostCacheId(hostId)}:active`
|
||||
}
|
||||
|
||||
function packageKey(hostId: string, buildId: string): string {
|
||||
return `${CACHE_PREFIX}:${hostCacheId(hostId)}:package:${buildId}`
|
||||
}
|
||||
|
||||
export async function loadCachedMobileWebPrototypePackage(
|
||||
hostId: string
|
||||
): Promise<VerifiedMobileWebPrototypePackage | null> {
|
||||
try {
|
||||
const buildId = await AsyncStorage.getItem(activeKey(hostId))
|
||||
if (!buildId || !/^[a-f0-9]{64}$/.test(buildId)) {
|
||||
return null
|
||||
}
|
||||
const raw = await AsyncStorage.getItem(packageKey(hostId, buildId))
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
return verifyMobileWebPrototypePackage(JSON.parse(raw) as unknown)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMobileWebPrototypePackage(
|
||||
hostId: string,
|
||||
prototypePackage: VerifiedMobileWebPrototypePackage
|
||||
): Promise<void> {
|
||||
const verified = verifyMobileWebPrototypePackage(prototypePackage)
|
||||
if (!verified) {
|
||||
throw new Error('Refusing to cache an unverified prototype package.')
|
||||
}
|
||||
|
||||
const pointerKey = activeKey(hostId)
|
||||
const previousBuildId = await AsyncStorage.getItem(pointerKey)
|
||||
await AsyncStorage.setItem(
|
||||
packageKey(hostId, verified.manifest.buildId),
|
||||
JSON.stringify(verified)
|
||||
)
|
||||
// Why: publish the active pointer only after the immutable package is durable.
|
||||
await AsyncStorage.setItem(pointerKey, verified.manifest.buildId)
|
||||
if (
|
||||
previousBuildId &&
|
||||
previousBuildId !== verified.manifest.buildId &&
|
||||
/^[a-f0-9]{64}$/.test(previousBuildId)
|
||||
) {
|
||||
await AsyncStorage.removeItem(packageKey(hostId, previousBuildId)).catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Buffer } from 'buffer'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { downloadMobileWebPrototypePackage } from './mobile-web-prototype-package'
|
||||
|
||||
function hash(bytes: Uint8Array): string {
|
||||
return Buffer.from(sha256(bytes)).toString('hex')
|
||||
}
|
||||
|
||||
function success(result: unknown): RpcResponse {
|
||||
return { id: 'test', ok: true, result, _meta: { runtimeId: 'test-runtime' } }
|
||||
}
|
||||
|
||||
function createRequest(document: string, corruptChunk = false) {
|
||||
const bytes = Buffer.from(document, 'utf8')
|
||||
const buildId = hash(bytes)
|
||||
const chunkBytes = 7
|
||||
return async (method: string, params?: unknown): Promise<RpcResponse> => {
|
||||
if (method === 'mobileWeb.prototype.manifest') {
|
||||
return success({
|
||||
protocolVersion: 1,
|
||||
buildId,
|
||||
sha256: buildId,
|
||||
byteLength: bytes.byteLength,
|
||||
chunkBytes,
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
})
|
||||
}
|
||||
const offset = (params as { offset: number }).offset
|
||||
const chunk = bytes.subarray(offset, Math.min(offset + chunkBytes, bytes.byteLength))
|
||||
return success({
|
||||
buildId,
|
||||
offset,
|
||||
byteLength: chunk.byteLength,
|
||||
sha256: hash(chunk),
|
||||
dataBase64: Buffer.from(
|
||||
corruptChunk && offset === 0 ? Uint8Array.from(chunk, (byte) => byte ^ 1) : chunk
|
||||
).toString('base64')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile web prototype package', () => {
|
||||
it('downloads and verifies a content-addressed document chunk by chunk', async () => {
|
||||
const html = '<!doctype html><title>Orca</title>'
|
||||
const prototypePackage = await downloadMobileWebPrototypePackage(createRequest(html))
|
||||
|
||||
expect(prototypePackage.html).toBe(html)
|
||||
expect(prototypePackage.manifest.buildId).toMatch(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
|
||||
it('rejects a chunk whose bytes do not match its signed metadata', async () => {
|
||||
await expect(
|
||||
downloadMobileWebPrototypePackage(createRequest('<p>tamper test</p>', true))
|
||||
).rejects.toThrow('chunk integrity')
|
||||
})
|
||||
|
||||
it('rejects oversized and unsupported manifests before requesting chunks', async () => {
|
||||
const request = async (): Promise<RpcResponse> =>
|
||||
success({
|
||||
protocolVersion: 2,
|
||||
buildId: 'a'.repeat(64),
|
||||
sha256: 'a'.repeat(64),
|
||||
byteLength: 1024 * 1024,
|
||||
chunkBytes: 49 * 1024,
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
})
|
||||
|
||||
await expect(downloadMobileWebPrototypePackage(request)).rejects.toThrow(
|
||||
'manifest failed validation'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,148 +0,0 @@
|
||||
import { Buffer } from 'buffer'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import {
|
||||
MOBILE_WEB_PROTOTYPE_CHUNK_BYTES,
|
||||
MOBILE_WEB_PROTOTYPE_MAX_BYTES,
|
||||
MOBILE_WEB_PROTOTYPE_PROTOCOL_VERSION,
|
||||
type MobileWebPrototypeChunk,
|
||||
type MobileWebPrototypeManifest
|
||||
} from '../../../src/shared/mobile-web-prototype-contract'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
|
||||
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/
|
||||
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
||||
|
||||
export type VerifiedMobileWebPrototypePackage = {
|
||||
manifest: MobileWebPrototypeManifest
|
||||
html: string
|
||||
}
|
||||
|
||||
export type MobileWebPrototypeRequest = (method: string, params?: unknown) => Promise<RpcResponse>
|
||||
|
||||
function sha256Hex(bytes: Uint8Array): string {
|
||||
return Buffer.from(sha256(bytes)).toString('hex')
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isSha256(value: unknown): value is string {
|
||||
return typeof value === 'string' && SHA256_HEX_PATTERN.test(value)
|
||||
}
|
||||
|
||||
function parseManifest(value: unknown): MobileWebPrototypeManifest {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error('Prototype manifest is not an object.')
|
||||
}
|
||||
const manifest = value as Partial<MobileWebPrototypeManifest>
|
||||
if (
|
||||
manifest.protocolVersion !== MOBILE_WEB_PROTOTYPE_PROTOCOL_VERSION ||
|
||||
!isSha256(manifest.buildId) ||
|
||||
!isSha256(manifest.sha256) ||
|
||||
manifest.buildId !== manifest.sha256 ||
|
||||
!Number.isInteger(manifest.byteLength) ||
|
||||
typeof manifest.byteLength !== 'number' ||
|
||||
manifest.byteLength <= 0 ||
|
||||
manifest.byteLength > MOBILE_WEB_PROTOTYPE_MAX_BYTES ||
|
||||
!Number.isInteger(manifest.chunkBytes) ||
|
||||
typeof manifest.chunkBytes !== 'number' ||
|
||||
manifest.chunkBytes <= 0 ||
|
||||
manifest.chunkBytes > MOBILE_WEB_PROTOTYPE_CHUNK_BYTES ||
|
||||
manifest.contentType !== 'text/html; charset=utf-8'
|
||||
) {
|
||||
throw new Error('Prototype manifest failed validation.')
|
||||
}
|
||||
return manifest as MobileWebPrototypeManifest
|
||||
}
|
||||
|
||||
function decodeCanonicalBase64(value: unknown): Uint8Array {
|
||||
if (typeof value !== 'string' || !BASE64_PATTERN.test(value)) {
|
||||
throw new Error('Prototype chunk is not valid base64.')
|
||||
}
|
||||
const bytes = Buffer.from(value, 'base64')
|
||||
if (bytes.toString('base64') !== value) {
|
||||
throw new Error('Prototype chunk is not canonical base64.')
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function parseChunk(
|
||||
value: unknown,
|
||||
manifest: MobileWebPrototypeManifest,
|
||||
expectedOffset: number
|
||||
): Uint8Array {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error('Prototype chunk is not an object.')
|
||||
}
|
||||
const chunk = value as Partial<MobileWebPrototypeChunk>
|
||||
const expectedLength = Math.min(manifest.chunkBytes, manifest.byteLength - expectedOffset)
|
||||
if (
|
||||
chunk.buildId !== manifest.buildId ||
|
||||
chunk.offset !== expectedOffset ||
|
||||
chunk.byteLength !== expectedLength ||
|
||||
!isSha256(chunk.sha256)
|
||||
) {
|
||||
throw new Error('Prototype chunk metadata failed validation.')
|
||||
}
|
||||
const bytes = decodeCanonicalBase64(chunk.dataBase64)
|
||||
if (bytes.byteLength !== expectedLength || sha256Hex(bytes) !== chunk.sha256) {
|
||||
throw new Error('Prototype chunk integrity check failed.')
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
async function requestResult(
|
||||
request: MobileWebPrototypeRequest,
|
||||
method: string,
|
||||
params?: unknown
|
||||
): Promise<unknown> {
|
||||
const response = await request(method, params)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message || `Request failed: ${method}`)
|
||||
}
|
||||
return response.result
|
||||
}
|
||||
|
||||
export async function downloadMobileWebPrototypePackage(
|
||||
request: MobileWebPrototypeRequest
|
||||
): Promise<VerifiedMobileWebPrototypePackage> {
|
||||
const manifest = parseManifest(await requestResult(request, 'mobileWeb.prototype.manifest'))
|
||||
const chunks: Uint8Array[] = []
|
||||
|
||||
for (let offset = 0; offset < manifest.byteLength; offset += manifest.chunkBytes) {
|
||||
const result = await requestResult(request, 'mobileWeb.prototype.chunk', {
|
||||
buildId: manifest.buildId,
|
||||
offset
|
||||
})
|
||||
chunks.push(parseChunk(result, manifest, offset))
|
||||
}
|
||||
|
||||
const documentBytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)))
|
||||
if (
|
||||
documentBytes.byteLength !== manifest.byteLength ||
|
||||
sha256Hex(documentBytes) !== manifest.sha256
|
||||
) {
|
||||
throw new Error('Prototype document integrity check failed.')
|
||||
}
|
||||
|
||||
return { manifest, html: documentBytes.toString('utf8') }
|
||||
}
|
||||
|
||||
export function verifyMobileWebPrototypePackage(
|
||||
value: unknown
|
||||
): VerifiedMobileWebPrototypePackage | null {
|
||||
if (!isRecord(value) || typeof value.html !== 'string') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const manifest = parseManifest(value.manifest)
|
||||
const bytes = Buffer.from(value.html, 'utf8')
|
||||
if (bytes.byteLength !== manifest.byteLength || sha256Hex(bytes) !== manifest.sha256) {
|
||||
return null
|
||||
}
|
||||
return { manifest, html: value.html }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -44,7 +44,7 @@ function sourceFiles(root: URL): URL[] {
|
||||
})
|
||||
}
|
||||
|
||||
describe('mobile web production prototype boundary', () => {
|
||||
describe('mobile web retired artifacts', () => {
|
||||
it('keeps prototype contracts and names out of production sources', () => {
|
||||
const violations = productionRoots.flatMap(sourceFiles).flatMap((file) => {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
@@ -65,4 +65,17 @@ describe('mobile web production prototype boundary', () => {
|
||||
|
||||
expect(rendererImports).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the superseded prototype architecture removed', () => {
|
||||
const retiredPrototypeArtifacts = [
|
||||
new URL('../app/hybrid-prototype.tsx', import.meta.url),
|
||||
new URL('./hybrid-prototype', import.meta.url),
|
||||
new URL('../../src/shared/mobile-web-prototype-contract.ts', import.meta.url),
|
||||
new URL('../../src/main/runtime/rpc/methods/mobile-web-prototype.ts', import.meta.url),
|
||||
new URL('../../src/main/runtime/rpc/mobile-web-prototype-assets.ts', import.meta.url),
|
||||
new URL('../../src/main/runtime/rpc/mobile-web-prototype-document.ts', import.meta.url)
|
||||
]
|
||||
|
||||
expect(retiredPrototypeArtifacts.filter((artifact) => existsSync(artifact))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -7,14 +7,16 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
createMobileAiVaultResumeMutationRegistry,
|
||||
prepareMobileAiVaultSessionResume,
|
||||
readMobileRuntimeHostPlatform,
|
||||
readMobileRuntimeTerminalWindowsShell,
|
||||
RESUME_RPC_TIMEOUT_MS,
|
||||
resolveMobileAiVaultResumePlatform,
|
||||
resumeAiVaultSessionInTerminal,
|
||||
type MobileAiVaultResumeSettings
|
||||
} from '../session/ai-vault-resume-launch'
|
||||
import {
|
||||
prepareMobileAiVaultSessionResume,
|
||||
RESUME_RPC_TIMEOUT_MS
|
||||
} from '../session/ai-vault-resume-preparation'
|
||||
import {
|
||||
resolveMobileAiVaultSessionResumeTarget,
|
||||
type MobileAiVaultResumeFolderWorkspace,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MobileWebNavigationIntent } from './mobile-web-navigation-intent-buffer'
|
||||
import { MOBILE_WEB_NAVIGATION_INTENTS } from './mobile-web-navigation-intent-buffer'
|
||||
import { mobileHostWorkspaceEntry, navigateFromMobileHome } from './mobile-web-home-navigation'
|
||||
|
||||
let latestIntent: MobileWebNavigationIntent | null = null
|
||||
const unsubscribe = MOBILE_WEB_NAVIGATION_INTENTS.subscribe((intent) => {
|
||||
latestIntent = intent
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (latestIntent) {
|
||||
MOBILE_WEB_NAVIGATION_INTENTS.consume(latestIntent.sequence)
|
||||
}
|
||||
latestIntent = null
|
||||
})
|
||||
|
||||
describe('mobile web Home navigation', () => {
|
||||
it.each([
|
||||
[{ kind: 'workspaceList' } as const, '/h/host'],
|
||||
[
|
||||
{ kind: 'session', hostWorkspaceId: 'repo::/work tree' } as const,
|
||||
'/h/host/session/repo%3A%3A%2Fwork%20tree'
|
||||
],
|
||||
[{ kind: 'tasks', taskSource: 'gitlab' } as const, '/h/host/tasks?taskSource=gitlab'],
|
||||
[{ kind: 'accounts' } as const, '/h/host/accounts'],
|
||||
[{ kind: 'newWorkspace' } as const, '/h/host?action=newWorktree']
|
||||
])('retains the native fallback for %s', (target, expected) => {
|
||||
const router = { push: vi.fn() }
|
||||
|
||||
navigateFromMobileHome({ router, hostId: 'host', target, mobileWebDefault: false })
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith(expected)
|
||||
expect(latestIntent).toBeNull()
|
||||
})
|
||||
|
||||
it('hands a typed destination to the hosted route without changing Home UI', () => {
|
||||
const router = { push: vi.fn() }
|
||||
|
||||
navigateFromMobileHome({
|
||||
router,
|
||||
hostId: 'host',
|
||||
target: { kind: 'tasks', taskSource: 'linear' },
|
||||
mobileWebDefault: true
|
||||
})
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith('/hybrid')
|
||||
expect(latestIntent).toMatchObject({
|
||||
source: 'home',
|
||||
hostId: 'host',
|
||||
target: { kind: 'tasks', taskSource: 'linear' }
|
||||
})
|
||||
})
|
||||
|
||||
it('switches post-pairing host entry and encodes the hosted route identity', () => {
|
||||
expect(mobileHostWorkspaceEntry('host', false)).toBe('/h/host')
|
||||
expect(mobileHostWorkspaceEntry('host/key?', true)).toBe('/hybrid?hostId=host%2Fkey%3F')
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => unsubscribe())
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { TaskProvider } from '../tasks/mobile-task-providers'
|
||||
import {
|
||||
MOBILE_WEB_NAVIGATION_INTENTS,
|
||||
type MobileWebNavigationIntentTarget
|
||||
} from './mobile-web-navigation-intent-buffer'
|
||||
|
||||
type MobileHomeRouter = {
|
||||
push(target: string): void
|
||||
}
|
||||
|
||||
export const MOBILE_WEB_DEFAULT_ENTRY_ENABLED =
|
||||
process.env.EXPO_PUBLIC_ORCA_MOBILE_WEB_DEFAULT === '1'
|
||||
|
||||
export function navigateFromMobileHome(args: {
|
||||
router: MobileHomeRouter
|
||||
hostId: string
|
||||
target: MobileWebNavigationIntentTarget
|
||||
mobileWebDefault?: boolean
|
||||
}): void {
|
||||
if (args.mobileWebDefault ?? MOBILE_WEB_DEFAULT_ENTRY_ENABLED) {
|
||||
MOBILE_WEB_NAVIGATION_INTENTS.publishHostTarget(args.hostId, args.target)
|
||||
args.router.push('/hybrid')
|
||||
return
|
||||
}
|
||||
args.router.push(nativeMobileHomeTarget(args.hostId, args.target))
|
||||
}
|
||||
|
||||
export function mobileHostWorkspaceEntry(
|
||||
hostId: string,
|
||||
mobileWebDefault = MOBILE_WEB_DEFAULT_ENTRY_ENABLED
|
||||
): `/h/${string}` | `/hybrid?hostId=${string}` {
|
||||
return mobileWebDefault ? `/hybrid?hostId=${encodeURIComponent(hostId)}` : `/h/${hostId}`
|
||||
}
|
||||
|
||||
function nativeMobileHomeTarget(hostId: string, target: MobileWebNavigationIntentTarget): string {
|
||||
if (target.kind === 'session') {
|
||||
return `/h/${hostId}/session/${encodeURIComponent(target.hostWorkspaceId)}`
|
||||
}
|
||||
if (target.kind === 'tasks') {
|
||||
return `/h/${hostId}/tasks${taskProviderQuery(target.taskSource)}`
|
||||
}
|
||||
if (target.kind === 'accounts') {
|
||||
return `/h/${hostId}/accounts`
|
||||
}
|
||||
if (target.kind === 'newWorkspace') {
|
||||
return `/h/${hostId}?action=newWorktree`
|
||||
}
|
||||
return `/h/${hostId}`
|
||||
}
|
||||
|
||||
function taskProviderQuery(provider: TaskProvider | undefined): string {
|
||||
return provider ? `?taskSource=${provider}` : ''
|
||||
}
|
||||
@@ -36,7 +36,8 @@ describe('mobile web native chat operations', () => {
|
||||
workspaceId: context.pageWorkspaceId,
|
||||
sessionId: context.pageSessionId,
|
||||
text: 'hello',
|
||||
deadline
|
||||
deadline,
|
||||
clearInputFirst: true
|
||||
},
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
workspaceAuthority: context.workspaceAuthority,
|
||||
@@ -53,7 +54,7 @@ describe('mobile web native chat operations', () => {
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'terminal-secret',
|
||||
text: 'hello',
|
||||
text: '\x15hello',
|
||||
enter: true,
|
||||
client: { id: 'mobile-device', type: 'mobile' }
|
||||
},
|
||||
|
||||
@@ -46,7 +46,8 @@ export async function executeMobileWebNativeChatTerminalOperation(args: {
|
||||
payload.text,
|
||||
true,
|
||||
args.terminalClientId,
|
||||
payload.deadline
|
||||
payload.deadline,
|
||||
payload.clearInputFirst
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -115,7 +116,8 @@ async function sendTerminal(
|
||||
text: string,
|
||||
enter: boolean,
|
||||
clientId: string,
|
||||
deadline: number
|
||||
deadline: number,
|
||||
clearInputFirst = false
|
||||
): Promise<MobileNativeChatSendOutcome> {
|
||||
const timeoutMs = deadline - Date.now()
|
||||
if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) {
|
||||
@@ -126,7 +128,7 @@ async function sendTerminal(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal,
|
||||
text,
|
||||
text: clearInputFirst ? `\x15${text}` : text,
|
||||
enter,
|
||||
client: { id: clientId, type: 'mobile' }
|
||||
},
|
||||
|
||||
@@ -72,9 +72,22 @@ describe('mobile web navigation intent buffer', () => {
|
||||
expect(notification.sequence).toBeGreaterThan(restored.sequence)
|
||||
})
|
||||
|
||||
it('accepts typed Home destinations without host credentials or paths', () => {
|
||||
const buffer = new MobileWebNavigationIntentBuffer()
|
||||
const intent = buffer.publishHostTarget('paired-host', { kind: 'tasks', taskSource: 'linear' })
|
||||
|
||||
expect(intent).toEqual({
|
||||
sequence: 0,
|
||||
source: 'home',
|
||||
hostId: 'paired-host',
|
||||
target: { kind: 'tasks', taskSource: 'linear' }
|
||||
})
|
||||
})
|
||||
|
||||
it('uses hosted handoff only while the Hybrid route has a live consumer', () => {
|
||||
expect(shouldHandoffNotificationToMobileWeb('/hybrid', true)).toBe(true)
|
||||
expect(shouldHandoffNotificationToMobileWeb('/hybrid', false)).toBe(false)
|
||||
expect(shouldHandoffNotificationToMobileWeb('/h/paired-host', true)).toBe(false)
|
||||
expect(shouldHandoffNotificationToMobileWeb('/', false, true)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { NotificationNavigationTarget } from '../notifications/notification-routing'
|
||||
import type { TaskProvider } from '../tasks/mobile-task-providers'
|
||||
|
||||
export type MobileWebNavigationIntentTarget =
|
||||
| { kind: 'workspaceList' }
|
||||
| { kind: 'session'; hostWorkspaceId: string }
|
||||
| { kind: 'tasks'; taskSource?: TaskProvider }
|
||||
| { kind: 'accounts' }
|
||||
| { kind: 'newWorkspace' }
|
||||
|
||||
export type MobileWebNavigationIntent = {
|
||||
sequence: number
|
||||
source: 'notification' | 'coldResume'
|
||||
source: 'notification' | 'coldResume' | 'home'
|
||||
hostId: string
|
||||
target: { kind: 'workspaceList' } | { kind: 'session'; hostWorkspaceId: string }
|
||||
target: MobileWebNavigationIntentTarget
|
||||
}
|
||||
|
||||
type MobileWebNavigationIntentListener = (intent: MobileWebNavigationIntent) => void
|
||||
@@ -16,17 +24,23 @@ export class MobileWebNavigationIntentBuffer {
|
||||
|
||||
publish(
|
||||
target: NotificationNavigationTarget,
|
||||
source: MobileWebNavigationIntent['source'] = 'notification'
|
||||
source: 'notification' | 'coldResume' = 'notification'
|
||||
): MobileWebNavigationIntent {
|
||||
const intent: MobileWebNavigationIntent = {
|
||||
sequence: this.nextSequence,
|
||||
source,
|
||||
hostId: target.hostId,
|
||||
target:
|
||||
target.kind === 'session'
|
||||
? { kind: 'session', hostWorkspaceId: target.hostWorkspaceId }
|
||||
: { kind: 'workspaceList' }
|
||||
}
|
||||
return this.publishHostTarget(
|
||||
target.hostId,
|
||||
target.kind === 'session'
|
||||
? { kind: 'session', hostWorkspaceId: target.hostWorkspaceId }
|
||||
: { kind: 'workspaceList' },
|
||||
source
|
||||
)
|
||||
}
|
||||
|
||||
publishHostTarget(
|
||||
hostId: string,
|
||||
target: MobileWebNavigationIntentTarget,
|
||||
source: MobileWebNavigationIntent['source'] = 'home'
|
||||
): MobileWebNavigationIntent {
|
||||
const intent = { sequence: this.nextSequence, source, hostId, target }
|
||||
this.nextSequence += 1
|
||||
this.latest = intent
|
||||
this.listeners.forEach((listener) => listener(intent))
|
||||
@@ -62,7 +76,8 @@ export const MOBILE_WEB_NAVIGATION_INTENTS = new MobileWebNavigationIntentBuffer
|
||||
|
||||
export function shouldHandoffNotificationToMobileWeb(
|
||||
pathname: string,
|
||||
hasIntentListener: boolean
|
||||
hasIntentListener: boolean,
|
||||
mobileWebDefault = false
|
||||
): boolean {
|
||||
return pathname === '/hybrid' && hasIntentListener
|
||||
return mobileWebDefault || (pathname === '/hybrid' && hasIntentListener)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileWebResumeRouteTarget } from './mobile-web-route-restoration'
|
||||
import {
|
||||
mobileWebNavigationRouteTarget,
|
||||
mobileWebResumeRouteTarget
|
||||
} from './mobile-web-route-restoration'
|
||||
|
||||
describe('mobile web route restoration', () => {
|
||||
it('keeps the workspace list as the default recovery route', () => {
|
||||
@@ -18,4 +21,16 @@ describe('mobile web route restoration', () => {
|
||||
)
|
||||
).toBe('/h/paired-orca-desktop/session/opaque%2Fworkspace%3Fone?name=Feature+%26+tests')
|
||||
})
|
||||
|
||||
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: 'accounts' } as const, '/h/paired-orca-desktop/accounts'],
|
||||
[{ kind: 'newWorkspace' } as const, '/?action=newWorktree']
|
||||
])('maps the typed native destination %s', (route, expected) => {
|
||||
expect(mobileWebNavigationRouteTarget(route, 'paired-orca-desktop')).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
import type { MobileWebResumeRoute } from '../../../src/shared/mobile-web/bridge-contract'
|
||||
import type {
|
||||
MobileWebNavigationRoute,
|
||||
MobileWebResumeRoute
|
||||
} from '../../../src/shared/mobile-web/bridge-contract'
|
||||
|
||||
export function mobileWebResumeRouteTarget(
|
||||
route: MobileWebResumeRoute,
|
||||
hostedHostId: string
|
||||
): string | null {
|
||||
const target = mobileWebNavigationRouteTarget(route, hostedHostId)
|
||||
return target === '/' ? null : target
|
||||
}
|
||||
|
||||
export function mobileWebNavigationRouteTarget(
|
||||
route: MobileWebNavigationRoute,
|
||||
hostedHostId: string
|
||||
): string {
|
||||
if (route.kind === 'workspaceList') {
|
||||
return null
|
||||
return '/'
|
||||
}
|
||||
if (route.kind === 'tasks') {
|
||||
const query = route.taskSource
|
||||
? `?${new URLSearchParams({ taskSource: route.taskSource }).toString()}`
|
||||
: ''
|
||||
return `/h/${encodeURIComponent(hostedHostId)}/tasks${query}`
|
||||
}
|
||||
if (route.kind === 'accounts') {
|
||||
return `/h/${encodeURIComponent(hostedHostId)}/accounts`
|
||||
}
|
||||
if (route.kind === 'newWorkspace') {
|
||||
return '/?action=newWorktree'
|
||||
}
|
||||
const query = new URLSearchParams({ name: route.workspaceName }).toString()
|
||||
return `/h/${encodeURIComponent(hostedHostId)}/session/${encodeURIComponent(route.workspaceId)}?${query}`
|
||||
|
||||
@@ -50,7 +50,11 @@ describe('MobileWebRouteRestorer', () => {
|
||||
'/h/paired-orca-desktop/session/workspace-two?name=Workspace+two'
|
||||
)
|
||||
|
||||
mocks.shell = { ...shellState('ignored', 'Ignored', 3), resumeRoute: { kind: 'workspaceList' } }
|
||||
mocks.shell = {
|
||||
...shellState('ignored', 'Ignored', 3),
|
||||
navigationRoute: { kind: 'workspaceList' },
|
||||
resumeRoute: { kind: 'workspaceList' }
|
||||
}
|
||||
renderRestorer()
|
||||
expect(mocks.replace).toHaveBeenCalledTimes(3)
|
||||
expect(mocks.replace).toHaveBeenLastCalledWith('/')
|
||||
@@ -82,6 +86,7 @@ function shellState(
|
||||
workspaceName: string,
|
||||
routeRevision: number
|
||||
): MobileWebNativeShellState {
|
||||
const route = { kind: 'session' as const, workspaceId, workspaceName }
|
||||
return {
|
||||
client: null,
|
||||
context: {
|
||||
@@ -91,7 +96,8 @@ function shellState(
|
||||
connection: 'connected',
|
||||
reconnectAttempts: 0,
|
||||
lastConnectedAt: Date.now(),
|
||||
resumeRoute: { kind: 'session', workspaceId, workspaceName },
|
||||
navigationRoute: route,
|
||||
resumeRoute: route,
|
||||
routeRevision,
|
||||
rememberRoute: () => true
|
||||
}
|
||||
|
||||
@@ -116,6 +116,55 @@ describe('useMobileWebNavigationIntentHandoff', () => {
|
||||
expect(MOBILE_WEB_NAVIGATION_INTENTS.isCurrent(firstIntent!.sequence)).toBe(false)
|
||||
expect(MOBILE_WEB_NAVIGATION_INTENTS.isCurrent(secondIntent!.sequence)).toBe(false)
|
||||
})
|
||||
|
||||
it('routes a typed Home destination without resolving a host workspace id', async () => {
|
||||
const broker = {
|
||||
resolveNavigationRoute: vi.fn()
|
||||
} as unknown as MobileWebCapabilityBroker
|
||||
const postMessage = vi.fn().mockResolvedValue(undefined)
|
||||
const options = {
|
||||
hosts: [{ id: 'paired-host' }] as HostProfile[],
|
||||
hostsLoading: false,
|
||||
selectedHostId: 'paired-host',
|
||||
connectionState: 'connected' as const,
|
||||
shellContext: { sessionId: 'S'.repeat(43), buildId: 'a'.repeat(64) },
|
||||
pageReadySessionId: 'S'.repeat(43),
|
||||
brokerSessionId: 'S'.repeat(43),
|
||||
getBroker: () => broker,
|
||||
selectHost: vi.fn(),
|
||||
refreshHosts: vi.fn().mockResolvedValue(undefined),
|
||||
postMessage,
|
||||
rememberRoute: vi.fn(),
|
||||
onNavigationResolved: vi.fn(),
|
||||
showWarning: vi.fn()
|
||||
}
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
act(() => {
|
||||
renderer = create(createElement(NavigationIntentHarness, { options }))
|
||||
})
|
||||
consoleError.mockRestore()
|
||||
|
||||
let intent
|
||||
await act(async () => {
|
||||
intent = MOBILE_WEB_NAVIGATION_INTENTS.publishHostTarget('paired-host', {
|
||||
kind: 'newWorkspace'
|
||||
})
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(broker.resolveNavigationRoute).not.toHaveBeenCalled()
|
||||
expect(options.rememberRoute).not.toHaveBeenCalled()
|
||||
expect(options.onNavigationResolved).not.toHaveBeenCalled()
|
||||
expect(postMessage).toHaveBeenCalledWith({
|
||||
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
type: 'navigation',
|
||||
shellSessionId: 'S'.repeat(43),
|
||||
buildId: 'a'.repeat(64),
|
||||
sequence: intent!.sequence,
|
||||
route: { kind: 'newWorkspace' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function NavigationIntentHarness({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import {
|
||||
MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
type MobileWebBridgeShellMessage,
|
||||
type MobileWebNavigationRoute,
|
||||
type MobileWebResumeRoute
|
||||
} from '../../../src/shared/mobile-web/bridge-contract'
|
||||
import type { ConnectionState, HostProfile } from '../transport/types'
|
||||
@@ -77,10 +78,7 @@ export function useMobileWebNavigationIntentHandoff(options: {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const route =
|
||||
intent.target.kind === 'session'
|
||||
? await broker.resolveNavigationRoute(intent.target.hostWorkspaceId)
|
||||
: ({ kind: 'workspaceList' } as const)
|
||||
const route = await resolveIntentRoute(intent, broker)
|
||||
if (
|
||||
cancelled ||
|
||||
!MOBILE_WEB_NAVIGATION_INTENTS.isCurrent(intent.sequence) ||
|
||||
@@ -88,8 +86,10 @@ export function useMobileWebNavigationIntentHandoff(options: {
|
||||
) {
|
||||
return
|
||||
}
|
||||
options.rememberRoute(route)
|
||||
options.onNavigationResolved?.(intent, route)
|
||||
if (route.kind === 'workspaceList' || route.kind === 'session') {
|
||||
options.rememberRoute(route)
|
||||
options.onNavigationResolved?.(intent, route)
|
||||
}
|
||||
await options.postMessage({
|
||||
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
type: 'navigation',
|
||||
@@ -105,7 +105,7 @@ export function useMobileWebNavigationIntentHandoff(options: {
|
||||
if (!cancelled && MOBILE_WEB_NAVIGATION_INTENTS.consume(intent.sequence)) {
|
||||
setIntent(null)
|
||||
options.showWarning(
|
||||
`${intent.source === 'coldResume' ? 'Previous workspace' : 'Notification destination'} could not be verified (${mobileWebBridgeErrorCode(error)}).`
|
||||
`${navigationIntentFailureSubject(intent.source)} could not be verified (${mobileWebBridgeErrorCode(error)}).`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -127,3 +127,26 @@ export function useMobileWebNavigationIntentHandoff(options: {
|
||||
options.showWarning
|
||||
])
|
||||
}
|
||||
|
||||
async function resolveIntentRoute(
|
||||
intent: MobileWebNavigationIntent,
|
||||
broker: MobileWebCapabilityBroker
|
||||
): Promise<MobileWebNavigationRoute> {
|
||||
if (intent.target.kind === 'session') {
|
||||
return broker.resolveNavigationRoute(intent.target.hostWorkspaceId)
|
||||
}
|
||||
if (intent.target.kind === 'tasks') {
|
||||
return {
|
||||
kind: 'tasks',
|
||||
...(intent.target.taskSource ? { taskSource: intent.target.taskSource } : {})
|
||||
}
|
||||
}
|
||||
return intent.target
|
||||
}
|
||||
|
||||
function navigationIntentFailureSubject(source: MobileWebNavigationIntent['source']): string {
|
||||
if (source === 'coldResume') {
|
||||
return 'Previous workspace'
|
||||
}
|
||||
return source === 'notification' ? 'Notification destination' : 'Destination'
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { shouldPresentNotificationOptIn } from '../notifications/notification-opt-in-gate'
|
||||
import { shouldPresentSessionViewOptIn } from '../session/session-view-opt-in-gate'
|
||||
import { mobileHostWorkspaceEntry } from '../mobile-web/mobile-web-home-navigation'
|
||||
|
||||
export const MOBILE_ONBOARDING_STEPS = ['session-view', 'notifications'] as const
|
||||
export type MobileOnboardingStep = (typeof MOBILE_ONBOARDING_STEPS)[number]
|
||||
export type MobileOnboardingDestination =
|
||||
| '/'
|
||||
| `/h/${string}`
|
||||
| `/hybrid?hostId=${string}`
|
||||
| {
|
||||
pathname: '/mobile-onboarding'
|
||||
params: { steps: string; hostId?: string }
|
||||
@@ -32,7 +34,7 @@ export function mobileOnboardingDestination(
|
||||
hostId?: string
|
||||
): MobileOnboardingDestination {
|
||||
if (steps.length === 0) {
|
||||
return hostId ? `/h/${hostId}` : '/'
|
||||
return hostId ? mobileHostWorkspaceEntry(hostId) : '/'
|
||||
}
|
||||
return {
|
||||
pathname: '/mobile-onboarding',
|
||||
|
||||
@@ -49,7 +49,8 @@ export type HostSessionNativeChatOperations = {
|
||||
sendMessage(
|
||||
target: HostSessionNativeChatTarget,
|
||||
text: string,
|
||||
deadline?: number
|
||||
deadline?: number,
|
||||
clearInputFirst?: boolean
|
||||
): Promise<MobileNativeChatSendOutcome>
|
||||
prepareCommit(target: HostSessionNativeChatTarget, deadline?: number): Promise<boolean>
|
||||
respond(
|
||||
|
||||
@@ -1,44 +1,31 @@
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
|
||||
import type { parseAskFromStatus } from './mobile-native-chat-ask'
|
||||
import type { detectAgentPermission } from './mobile-native-chat-permission'
|
||||
import type { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask'
|
||||
import type { HostSessionNativeChatTarget } from './host-session-native-chat-operations'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
|
||||
import type { MobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
||||
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-pending-deliveries'
|
||||
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
|
||||
|
||||
export type MobileNativeChatController = {
|
||||
/** Whether a tab's effective view is chat (per-tab override, else the default). */
|
||||
isTabChatView: (tabId: string) => boolean
|
||||
toggleTabChatView: (tabId: string) => void
|
||||
showNativeChat: boolean
|
||||
showNativeChatRef: MutableRefObject<boolean>
|
||||
/** Resolved agent for the active chat tab (names the empty-state copy). */
|
||||
nativeChatAgent: string | null
|
||||
chatComposerText: string
|
||||
setChatComposerText: Dispatch<SetStateAction<string>>
|
||||
chatPending: MobileNativeChatPendingMessage[]
|
||||
chatImagePreviewsByMessageId: Record<string, string[]>
|
||||
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
|
||||
nativeChatAgentWorking: boolean
|
||||
nativeChatTargetRef: MutableRefObject<HostSessionNativeChatTarget | null>
|
||||
nativeChatStreamingText?: string
|
||||
/** Agent mid-turn, regardless of whether chat is the visible view. */
|
||||
nativeChatStreamLive: boolean
|
||||
/** Host/workspace/tab/session scope for stateful streaming suppression. */
|
||||
nativeChatStreamScopeKey: string
|
||||
nativeChatPermission: ReturnType<typeof detectAgentPermission>
|
||||
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
|
||||
/** The pending ask, already null while dismissed (dismissal lives here so it
|
||||
* survives the chat-view subtree unmounting on a view toggle). */
|
||||
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
|
||||
/** Stable key for the current ask card (keys the card component). */
|
||||
nativeChatAskKey: string | null
|
||||
/** Hide the current ask until a genuinely different question arrives. */
|
||||
dismissNativeChatAsk: () => void
|
||||
handleNativeChatAnswerAsk: (
|
||||
prompt: AskPrompt,
|
||||
selections: AskAnswerSelection[]
|
||||
) => Promise<boolean>
|
||||
handleNativeChatOpenFile: (relativePath: string) => void
|
||||
handleNativeChatAnswerAsk: MobileNativeChatAnswerSend['answerAsk']
|
||||
handleNativeChatCancelAsk: () => Promise<boolean>
|
||||
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
|
||||
handleNativeChatStop: () => void
|
||||
@@ -46,19 +33,9 @@ export type MobileNativeChatController = {
|
||||
loadNativeChatFiles: (query: string) => void
|
||||
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
|
||||
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
|
||||
/** Outcome-preserving send: callers that pasted terminal input beforehand
|
||||
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a
|
||||
* caller passes its own `deadline` so the paste it already spent and this text
|
||||
* body share one budget instead of holding the composer for two. */
|
||||
handleNativeChatSendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text still parked on the agent's TUI input line, or null.
|
||||
* Image sends read it to size their leading clear (one Ctrl+U per line). */
|
||||
readSeededLaunchDraft: () => string | null
|
||||
/** Model/session-option pickers for the composer, or null when the active
|
||||
* agent has no session-option catalog. */
|
||||
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ export function nativeHostSessionNativeChatOperations(
|
||||
return { error: 'Transcript read failed' }
|
||||
}
|
||||
},
|
||||
sendMessage(target, text, deadline) {
|
||||
return sendNative(target, text, true, client, deadline)
|
||||
sendMessage(target, text, deadline, clearInputFirst) {
|
||||
return sendNative(target, text, true, client, deadline, clearInputFirst)
|
||||
},
|
||||
prepareCommit(target, deadline) {
|
||||
if (!target.terminalId) {
|
||||
@@ -158,7 +158,8 @@ function sendNative(
|
||||
text: string,
|
||||
enter: boolean,
|
||||
client: RpcClient,
|
||||
deadline?: number
|
||||
deadline?: number,
|
||||
clearInputFirst?: boolean
|
||||
): Promise<MobileNativeChatSendOutcome> {
|
||||
if (!target.terminalId) {
|
||||
return Promise.resolve('rejected')
|
||||
@@ -168,6 +169,7 @@ function sendNative(
|
||||
terminal: target.terminalId,
|
||||
text,
|
||||
enter,
|
||||
clearInputFirst,
|
||||
deadline,
|
||||
...(target.clientId ? { mobileClient: { id: target.clientId, type: 'mobile' as const } } : {})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction
|
||||
} from 'react'
|
||||
import { useCallback, useMemo, useRef, type MutableRefObject } from 'react'
|
||||
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
|
||||
import { parseAskFromStatus } from './mobile-native-chat-ask'
|
||||
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
|
||||
import { detectAgentPermission } from './mobile-native-chat-permission'
|
||||
import { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
import type {
|
||||
HostSessionNativeChatOperations,
|
||||
HostSessionNativeChatTarget
|
||||
} from './host-session-native-chat-operations'
|
||||
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import {
|
||||
useMobileNativeChatAnswerSend,
|
||||
type MobileNativeChatAnswerSend
|
||||
} from './use-mobile-native-chat-answer-send'
|
||||
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
||||
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
|
||||
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-pending-deliveries'
|
||||
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
|
||||
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
|
||||
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
|
||||
@@ -39,47 +24,11 @@ import {
|
||||
resolveMobileNativeChatDuringDisconnect,
|
||||
type MobileNativeChatDisconnectRetention
|
||||
} from './mobile-native-chat-disconnect-retention'
|
||||
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
|
||||
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
|
||||
|
||||
const NATIVE_CHAT_STREAM_THROTTLE_MS = 50
|
||||
|
||||
export type MobileNativeChatController = {
|
||||
/** Whether a tab's effective view is chat (per-tab override, else the default). */
|
||||
isTabChatView: (tabId: string) => boolean
|
||||
toggleTabChatView: (tabId: string) => void
|
||||
showNativeChat: boolean
|
||||
showNativeChatRef: MutableRefObject<boolean>
|
||||
/** Resolved agent for the active chat tab (names the empty-state copy). */
|
||||
nativeChatAgent: string | null
|
||||
chatComposerText: string
|
||||
setChatComposerText: Dispatch<SetStateAction<string>>
|
||||
chatPending: MobileNativeChatPendingMessage[]
|
||||
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
|
||||
nativeChatAgentWorking: boolean
|
||||
nativeChatTargetRef: MutableRefObject<HostSessionNativeChatTarget | null>
|
||||
nativeChatStreamingText?: string
|
||||
nativeChatPermission: ReturnType<typeof detectAgentPermission>
|
||||
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
|
||||
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
|
||||
handleNativeChatOpenFile: (relativePath: string) => void
|
||||
handleNativeChatAnswerAsk: MobileNativeChatAnswerSend['answerAsk']
|
||||
handleNativeChatCancelAsk: () => Promise<boolean>
|
||||
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
|
||||
handleNativeChatStop: () => void
|
||||
nativeChatFilePaths: string[]
|
||||
loadNativeChatFiles: (query: string) => void
|
||||
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
|
||||
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
|
||||
/** Outcome-preserving send: callers that pasted terminal input beforehand
|
||||
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a
|
||||
* caller passes its own `deadline` so the paste it already spent and this text
|
||||
* body share one budget instead of holding the composer for two. */
|
||||
handleNativeChatSendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
}
|
||||
|
||||
/** Owns mobile native-chat state and teardown outside the already dense session
|
||||
* route. The route remains responsible only for choosing and rendering the view. */
|
||||
export function useMobileNativeChatController(args: {
|
||||
|
||||
@@ -489,22 +489,24 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
})
|
||||
|
||||
function Harness({
|
||||
client,
|
||||
operations,
|
||||
sessionId,
|
||||
agent = 'claude',
|
||||
sourceIdentity = 'host-a\0workspace-a'
|
||||
}: {
|
||||
client: RpcClient | null
|
||||
operations: HostSessionNativeChatOperations | null
|
||||
sessionId: string | null
|
||||
agent?: string | null
|
||||
sourceIdentity?: string
|
||||
}): null {
|
||||
const session = useMobileNativeChatSession({
|
||||
client,
|
||||
sourceIdentity,
|
||||
operations,
|
||||
workspaceId: 'worktree',
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath: null
|
||||
transcriptPath: null,
|
||||
terminalId: 'terminal',
|
||||
clientId: 'device'
|
||||
})
|
||||
renders.push({
|
||||
sessionId,
|
||||
@@ -515,7 +517,10 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountAt(client: RpcClient | null, sessionId: string | null): Promise<void> {
|
||||
async function mountAt(
|
||||
operations: HostSessionNativeChatOperations | null,
|
||||
sessionId: string | null
|
||||
): Promise<void> {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
@@ -525,7 +530,7 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { client, sessionId }))
|
||||
renderer = create(createElement(Harness, { operations, sessionId }))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
@@ -536,7 +541,10 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
// `status` starts at 'idle', so on its own it would tell the launch-draft
|
||||
// seed that an empty transcript is this session's real history.
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn(() => () => {})
|
||||
await mountAt({ subscribe } as unknown as RpcClient, 'session-a')
|
||||
const operations = nativeHostSessionNativeChatOperations({
|
||||
subscribe
|
||||
} as unknown as RpcClient)
|
||||
await mountAt(operations, 'session-a')
|
||||
|
||||
expect(renders[0]).toMatchObject({ transcriptLoading: true, ids: [] })
|
||||
})
|
||||
@@ -551,16 +559,19 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
return () => {}
|
||||
})
|
||||
const client = { subscribe } as unknown as RpcClient
|
||||
await mountAt(client, 'session-a')
|
||||
const operations = nativeHostSessionNativeChatOperations(client)
|
||||
await mountAt(operations, 'session-a')
|
||||
expect(renders.at(-1)).toMatchObject({ status: 'ready', transcriptLoading: false })
|
||||
|
||||
// Toggle out to the terminal view, then back.
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: null }))
|
||||
renderer?.update(createElement(Harness, { operations, sessionId: 'session-a', agent: null }))
|
||||
)
|
||||
renders.length = 0
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' }))
|
||||
renderer?.update(
|
||||
createElement(Harness, { operations, sessionId: 'session-a', agent: 'claude' })
|
||||
)
|
||||
)
|
||||
|
||||
expect(renders[0]).toMatchObject({
|
||||
@@ -579,19 +590,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
return () => {}
|
||||
})
|
||||
const client = { subscribe } as unknown as RpcClient
|
||||
await mountAt(client, 'session-a')
|
||||
const operations = nativeHostSessionNativeChatOperations(client)
|
||||
await mountAt(operations, 'session-a')
|
||||
expect(renders.at(-1)).toMatchObject({ status: 'ready' })
|
||||
|
||||
let emitFresh: (frame: unknown) => void = () => {}
|
||||
const reconnected = {
|
||||
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
|
||||
emitFresh = onData
|
||||
return () => {}
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
|
||||
const reconnectedOperations = nativeHostSessionNativeChatOperations(reconnected)
|
||||
renders.length = 0
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' }))
|
||||
renderer?.update(
|
||||
createElement(Harness, { operations: reconnectedOperations, sessionId: 'session-a' })
|
||||
)
|
||||
)
|
||||
|
||||
expect(renders[0]).toMatchObject({
|
||||
@@ -647,9 +656,10 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
return () => {}
|
||||
})
|
||||
const client = { subscribe } as unknown as RpcClient
|
||||
await mountAt(client, 'session-a')
|
||||
const operations = nativeHostSessionNativeChatOperations(client)
|
||||
await mountAt(operations, 'session-a')
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { client, sessionId: 'session-b' }))
|
||||
renderer?.update(createElement(Harness, { operations, sessionId: 'session-b' }))
|
||||
)
|
||||
|
||||
// The effect that resets the list lands a commit later, so `messages` still
|
||||
|
||||
@@ -81,10 +81,7 @@ export function useMobileNativeChatSession(args: {
|
||||
// Without this a toggle out of chat view and back (agent null, then the same
|
||||
// identity again) would resurface a settled 'ready' over an emptied list.
|
||||
let current = read
|
||||
if (
|
||||
current !== null &&
|
||||
(current.identity !== identity || current.operations !== operations)
|
||||
) {
|
||||
if (current !== null && (current.identity !== identity || current.operations !== operations)) {
|
||||
current = null
|
||||
setRead(null)
|
||||
}
|
||||
@@ -307,6 +304,7 @@ export function useMobileNativeChatSession(args: {
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
loadEarlier
|
||||
}
|
||||
}
|
||||
|
||||
function nativeChatTarget(
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('hosted native-chat deadlines', () => {
|
||||
} as unknown as MobileWebBridgeClient
|
||||
const operations = webHostSessionNativeChatOperations(client)
|
||||
|
||||
await expect(operations.sendMessage(TARGET, 'hello', 20_000)).resolves.toBe('accepted')
|
||||
await expect(operations.sendMessage(TARGET, 'hello', 20_000, true)).resolves.toBe('accepted')
|
||||
await expect(operations.respond(TARGET, '1', false, 20_000)).resolves.toBe('accepted')
|
||||
await expect(operations.stop(TARGET, 20_000)).resolves.toBe('accepted')
|
||||
|
||||
@@ -43,7 +43,8 @@ describe('hosted native-chat deadlines', () => {
|
||||
workspaceId: 'workspace',
|
||||
sessionId: 'native_chat_session',
|
||||
text: 'hello',
|
||||
deadline: 20_000
|
||||
deadline: 20_000,
|
||||
clearInputFirst: true
|
||||
},
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
|
||||
@@ -44,7 +44,7 @@ export function webHostSessionNativeChatOperations(
|
||||
return { error: 'Transcript read failed' }
|
||||
}
|
||||
},
|
||||
async sendMessage(target, text, deadline) {
|
||||
async sendMessage(target, text, deadline, clearInputFirst) {
|
||||
const budget = bridgeBudget(deadline)
|
||||
if (!budget) {
|
||||
return 'rejected'
|
||||
@@ -52,7 +52,11 @@ export function webHostSessionNativeChatOperations(
|
||||
try {
|
||||
return (
|
||||
await client.nativeChat.sendMessage(
|
||||
bridgeTarget(target, { text, deadline: budget.deadline }),
|
||||
bridgeTarget(target, {
|
||||
text,
|
||||
deadline: budget.deadline,
|
||||
...(clearInputFirst ? { clearInputFirst: true } : {})
|
||||
}),
|
||||
{ timeoutMs: budget.timeoutMs }
|
||||
)
|
||||
).outcome
|
||||
|
||||
@@ -10,7 +10,6 @@ const storageImportPattern =
|
||||
const EXPECTED_PERSISTED_STATE_SOURCES = [
|
||||
'mobile/app/index.tsx',
|
||||
'mobile/src/cache/home-snapshot-cache.ts',
|
||||
'mobile/src/hybrid-prototype/mobile-web-prototype-cache.ts',
|
||||
'mobile/src/mobile-web/mobile-web-cold-resume-route.ts',
|
||||
'mobile/src/notifications/notification-reconnect-catchup.ts',
|
||||
'mobile/src/session/session-last-visited-worktree.ts',
|
||||
|
||||
@@ -15,10 +15,6 @@ const MOBILE_DYNAMIC_RPC_METHODS = [
|
||||
'github.updatePRState',
|
||||
'gitlab.updateIssue',
|
||||
'gitlab.updateMR',
|
||||
// Prototype asset requests pass through a downloader callback, so they are
|
||||
// not visible to the literal sendRequest scan.
|
||||
'mobileWeb.prototype.chunk',
|
||||
'mobileWeb.prototype.manifest',
|
||||
// Production asset requests will also pass through the native package
|
||||
// downloader rather than literal feature call sites.
|
||||
'mobileWeb.package.asset',
|
||||
|
||||
@@ -39,7 +39,6 @@ import { EMULATOR_METHODS } from './emulator'
|
||||
import { PAIRING_METHODS } from './pairing'
|
||||
import { UPDATER_METHODS } from './updater'
|
||||
import { AGENT_SESSION_METHODS } from './agent-session'
|
||||
import { MOBILE_WEB_PROTOTYPE_METHODS } from './mobile-web-prototype'
|
||||
import { MOBILE_WEB_PACKAGE_METHODS } from './mobile-web-package'
|
||||
import { MOBILE_FILE_WRITE_METHODS } from './mobile-file-write-if-unchanged'
|
||||
|
||||
@@ -89,6 +88,5 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...EMULATOR_METHODS,
|
||||
...PAIRING_METHODS,
|
||||
...UPDATER_METHODS,
|
||||
...MOBILE_WEB_PACKAGE_METHODS,
|
||||
...MOBILE_WEB_PROTOTYPE_METHODS
|
||||
...MOBILE_WEB_PACKAGE_METHODS
|
||||
]
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, InvalidArgumentError, type RpcMethod } from '../core'
|
||||
import {
|
||||
getMobileWebPrototypeChunk,
|
||||
getMobileWebPrototypeManifest
|
||||
} from '../mobile-web-prototype-assets'
|
||||
|
||||
const MobileWebPrototypeChunkParams = z.object({
|
||||
buildId: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
offset: z.number().int().nonnegative()
|
||||
})
|
||||
|
||||
export const MOBILE_WEB_PROTOTYPE_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'mobileWeb.prototype.manifest',
|
||||
params: null,
|
||||
handler: () => getMobileWebPrototypeManifest()
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'mobileWeb.prototype.chunk',
|
||||
params: MobileWebPrototypeChunkParams,
|
||||
handler: ({ buildId, offset }) => {
|
||||
try {
|
||||
return getMobileWebPrototypeChunk(buildId, offset)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'mobile_web_prototype_unavailable'
|
||||
throw new InvalidArgumentError(message)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -1,62 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MOBILE_WEB_PROTOTYPE_CHUNK_BYTES,
|
||||
MOBILE_WEB_PROTOTYPE_MAX_BYTES
|
||||
} from '../../../shared/mobile-web-prototype-contract'
|
||||
import {
|
||||
getMobileWebPrototypeChunk,
|
||||
getMobileWebPrototypeManifest
|
||||
} from './mobile-web-prototype-assets'
|
||||
|
||||
describe('mobile web prototype assets', () => {
|
||||
it('serves a content-addressed document in bounded chunks', () => {
|
||||
const manifest = getMobileWebPrototypeManifest()
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
for (let offset = 0; offset < manifest.byteLength; offset += manifest.chunkBytes) {
|
||||
const chunk = getMobileWebPrototypeChunk(manifest.buildId, offset)
|
||||
expect(chunk.offset).toBe(offset)
|
||||
expect(chunk.byteLength).toBeLessThanOrEqual(MOBILE_WEB_PROTOTYPE_CHUNK_BYTES)
|
||||
chunks.push(Buffer.from(chunk.dataBase64, 'base64'))
|
||||
}
|
||||
|
||||
const document = Buffer.concat(chunks).toString('utf8')
|
||||
expect(document).toContain('Content-Security-Policy')
|
||||
expect(document).toContain('window.ReactNativeWebView.postMessage')
|
||||
expect(document).toContain('id="run-probe"')
|
||||
expect(manifest.byteLength).toBeGreaterThan(320 * 1024)
|
||||
expect(manifest.byteLength).toBeLessThan(MOBILE_WEB_PROTOTYPE_MAX_BYTES)
|
||||
expect(document).toContain(
|
||||
`const packageKiB=Number("${String(Math.ceil(manifest.byteLength / 1024)).padStart(6, '0')}")`
|
||||
)
|
||||
expect(Buffer.byteLength(document)).toBe(manifest.byteLength)
|
||||
})
|
||||
|
||||
it('rejects stale build identities and invalid offsets', () => {
|
||||
const manifest = getMobileWebPrototypeManifest()
|
||||
expect(() => getMobileWebPrototypeChunk('0'.repeat(64), 0)).toThrow(
|
||||
'mobile_web_prototype_build_changed'
|
||||
)
|
||||
expect(() => getMobileWebPrototypeChunk(manifest.buildId, manifest.byteLength)).toThrow(
|
||||
'mobile_web_prototype_offset_invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('binds the exact inline code to a network-disabled content policy', () => {
|
||||
const document = Buffer.from(
|
||||
getMobileWebPrototypeChunk(getMobileWebPrototypeManifest().buildId, 0).dataBase64,
|
||||
'base64'
|
||||
).toString('utf8')
|
||||
const style = document.match(/<style>([\s\S]*?)<\/style>/)?.[1]
|
||||
const script = document.match(/<script>([\s\S]*?)<\/script>/)?.[1]
|
||||
expect(style).toBeDefined()
|
||||
expect(script).toBeDefined()
|
||||
|
||||
const hash = (value: string) => createHash('sha256').update(value, 'utf8').digest('base64')
|
||||
expect(document).toContain(`style-src 'sha256-${hash(style!)}'`)
|
||||
expect(document).toContain(`script-src 'sha256-${hash(script!)}'`)
|
||||
expect(document).toContain("connect-src 'none'")
|
||||
expect(document).not.toContain('fetch(')
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
MOBILE_WEB_PROTOTYPE_CHUNK_BYTES,
|
||||
MOBILE_WEB_PROTOTYPE_PROTOCOL_VERSION,
|
||||
type MobileWebPrototypeChunk,
|
||||
type MobileWebPrototypeManifest
|
||||
} from '../../../shared/mobile-web-prototype-contract'
|
||||
import { buildMobileWebPrototypeDocument } from './mobile-web-prototype-document'
|
||||
|
||||
type MobileWebPrototypePackage = {
|
||||
bytes: Buffer
|
||||
manifest: MobileWebPrototypeManifest
|
||||
}
|
||||
|
||||
let cachedPackage: MobileWebPrototypePackage | null = null
|
||||
|
||||
function sha256Hex(value: Uint8Array): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
function getPackage(): MobileWebPrototypePackage {
|
||||
if (cachedPackage) {
|
||||
return cachedPackage
|
||||
}
|
||||
const bytes = Buffer.from(buildMobileWebPrototypeDocument(), 'utf8')
|
||||
const sha256 = sha256Hex(bytes)
|
||||
cachedPackage = {
|
||||
bytes,
|
||||
manifest: {
|
||||
protocolVersion: MOBILE_WEB_PROTOTYPE_PROTOCOL_VERSION,
|
||||
buildId: sha256,
|
||||
sha256,
|
||||
byteLength: bytes.byteLength,
|
||||
chunkBytes: MOBILE_WEB_PROTOTYPE_CHUNK_BYTES,
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
}
|
||||
}
|
||||
return cachedPackage
|
||||
}
|
||||
|
||||
export function getMobileWebPrototypeManifest(): MobileWebPrototypeManifest {
|
||||
return getPackage().manifest
|
||||
}
|
||||
|
||||
export function getMobileWebPrototypeChunk(
|
||||
buildId: string,
|
||||
offset: number
|
||||
): MobileWebPrototypeChunk {
|
||||
const prototypePackage = getPackage()
|
||||
if (buildId !== prototypePackage.manifest.buildId) {
|
||||
throw new Error('mobile_web_prototype_build_changed')
|
||||
}
|
||||
if (offset < 0 || offset >= prototypePackage.bytes.byteLength) {
|
||||
throw new Error('mobile_web_prototype_offset_invalid')
|
||||
}
|
||||
const bytes = prototypePackage.bytes.subarray(
|
||||
offset,
|
||||
Math.min(offset + MOBILE_WEB_PROTOTYPE_CHUNK_BYTES, prototypePackage.bytes.byteLength)
|
||||
)
|
||||
return {
|
||||
buildId,
|
||||
offset,
|
||||
byteLength: bytes.byteLength,
|
||||
sha256: sha256Hex(bytes),
|
||||
dataBase64: bytes.toString('base64')
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const PROTOTYPE_STYLES = `
|
||||
:root{color-scheme:light dark;--background:#fff;--foreground:#0a0a0a;--card:#fff;--card-foreground:#0a0a0a;--muted:#f5f5f5;--muted-foreground:#737373;--accent:#f5f5f5;--accent-foreground:#171717;--border:#e5e5e5;--ring:#a1a1a1;--status-success:#15803d;--status-error:#dc2626}
|
||||
@media(prefers-color-scheme:dark){:root{--background:#0a0a0a;--foreground:#fafafa;--card:#171717;--card-foreground:#fafafa;--muted:#262626;--muted-foreground:#a1a1a1;--accent:#404040;--accent-foreground:#fafafa;--border:rgb(255 255 255/.07);--ring:#737373;--status-success:#86efac;--status-error:#fca5a5}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--background);color:var(--foreground);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.5}
|
||||
.shell{min-height:100dvh;padding:max(18px,env(safe-area-inset-top)) 16px max(24px,env(safe-area-inset-bottom))}
|
||||
.eyebrow{margin:0 0 6px;color:var(--muted-foreground);font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase}
|
||||
h1{margin:0;font-size:20px;line-height:1.2}
|
||||
.subhead{margin:8px 0 18px;color:var(--muted-foreground);font-size:12px}
|
||||
.status{display:flex;align-items:center;gap:7px;margin-bottom:16px;color:var(--muted-foreground);font-size:12px}
|
||||
.dot{width:8px;height:8px;border-radius:999px;background:var(--ring)}
|
||||
.dot.connected{background:var(--status-success)}
|
||||
.list{display:grid;gap:8px}
|
||||
.row{width:100%;padding:13px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--card-foreground);font:inherit;text-align:left}
|
||||
.row:active,.row:focus-visible{background:var(--accent);outline:2px solid var(--ring);outline-offset:1px}
|
||||
.row-title{display:flex;justify-content:space-between;gap:12px;font-size:13px;font-weight:600}
|
||||
.branch,.empty,.error{margin-top:4px;color:var(--muted-foreground);font-size:12px}
|
||||
.badge{flex:none;color:var(--muted-foreground);font-size:11px;font-weight:500}
|
||||
.empty,.error{padding:18px;border:1px solid var(--border);border-radius:8px;background:var(--card)}
|
||||
.error{color:var(--foreground)}
|
||||
.probe{margin-top:18px;padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--card)}
|
||||
.probe-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
|
||||
.probe-title{margin:0;font-size:13px;font-weight:600}
|
||||
.probe-copy{margin:3px 0 0;color:var(--muted-foreground);font-size:11px}
|
||||
.probe-button{flex:none;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--foreground);color:var(--background);font:inherit;font-size:12px;font-weight:600}
|
||||
.probe-button:disabled{opacity:.55}
|
||||
.probe-result{margin-top:12px;padding:10px;border-radius:6px;background:var(--muted);font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap}
|
||||
.probe-stage{display:grid;grid-template-rows:120px 180px;gap:8px;margin-top:10px}
|
||||
.terminal-probe,.diff-probe{overflow:auto;margin:0;border:1px solid var(--border);border-radius:6px;background:var(--background);font:10px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.terminal-probe{padding:8px;white-space:pre}
|
||||
.diff-line{min-width:max-content;padding:0 8px;white-space:pre}
|
||||
.diff-line.added{background:color-mix(in srgb,var(--status-success) 14%,transparent)}
|
||||
.diff-line.deleted{background:color-mix(in srgb,var(--status-error) 13%,transparent)}
|
||||
`.trim()
|
||||
|
||||
const PROTOTYPE_SCRIPT = `
|
||||
(()=>{"use strict";
|
||||
const root=document.getElementById("workspace-list");
|
||||
const host=document.getElementById("host");
|
||||
const connection=document.getElementById("connection");
|
||||
const dot=document.getElementById("dot");
|
||||
const probeButton=document.getElementById("run-probe");
|
||||
const probeResult=document.getElementById("probe-result");
|
||||
const terminalProbe=document.getElementById("terminal-probe");
|
||||
const diffProbe=document.getElementById("diff-probe");
|
||||
const packageKiB=Number("000000");
|
||||
let sequence=0;
|
||||
const send=(message)=>window.ReactNativeWebView.postMessage(JSON.stringify({v:1,...message}));
|
||||
const text=(value,fallback="")=>typeof value==="string"?value:fallback;
|
||||
const renderError=(message)=>{root.replaceChildren();const node=document.createElement("div");node.className="error";node.textContent=message;root.append(node)};
|
||||
const renderWorkspaces=(items)=>{root.replaceChildren();if(!Array.isArray(items)||items.length===0){const empty=document.createElement("div");empty.className="empty";empty.textContent="No workspaces returned by this host.";root.append(empty);return}for(const item of items){const row=document.createElement("button");row.type="button";row.className="row";const title=document.createElement("div");title.className="row-title";const name=document.createElement("span");name.textContent=text(item.name,"Workspace");const badge=document.createElement("span");badge.className="badge";badge.textContent=item.isActive?"Active":String(Number(item.liveTerminalCount)||0)+" live";title.append(name,badge);const branch=document.createElement("div");branch.className="branch";branch.textContent=text(item.repo,"Repository")+" · "+text(item.branch,"No branch");row.append(title,branch);row.addEventListener("click",()=>send({type:"haptic.selection",id:"haptic-"+(++sequence)}));root.append(row)}};
|
||||
const setConnection=(state)=>{const value=text(state,"disconnected");connection.textContent=value;dot.classList.toggle("connected",value==="connected")};
|
||||
const nextFrame=()=>new Promise((resolve)=>requestAnimationFrame(resolve));
|
||||
const terminalLine=(frame,line)=>"["+String(frame).padStart(3,"0")+"] task-"+String(line%24).padStart(2,"0")+" | building mobile web surface | "+"#".repeat((line+frame)%48);
|
||||
const runTerminalProbe=async()=>{const lines=[];const frameTimes=[];const started=performance.now();for(let frame=0;frame<120;frame++){const frameStarted=performance.now();for(let line=0;line<8;line++)lines.push(terminalLine(frame,line));if(lines.length>640)lines.splice(0,lines.length-640);terminalProbe.textContent=lines.join("\\n");await nextFrame();frameTimes.push(performance.now()-frameStarted)}return{total:performance.now()-started,max:Math.max(...frameTimes),slow:frameTimes.filter((value)=>value>24).length}};
|
||||
const runDiffProbe=async()=>{diffProbe.replaceChildren();const fragment=document.createDocumentFragment();const started=performance.now();for(let index=0;index<4000;index++){const line=document.createElement("div");const kind=index%11===0?"deleted":index%7===0?"added":"context";line.className="diff-line "+kind;line.textContent=(kind==="added"?"+":kind==="deleted"?"-":" ")+String(index+1).padStart(5," ")+" const workspace_"+index+" = reconcileHostCapability(manifest, pairedIdentity, cachedDocument);";fragment.append(line)}diffProbe.append(fragment);await nextFrame();return{total:performance.now()-started,height:diffProbe.scrollHeight}};
|
||||
const runProbe=async()=>{probeButton.disabled=true;probeButton.textContent="Running...";probeResult.hidden=false;probeResult.textContent="Running 120 terminal frames...";terminalProbe.textContent="";diffProbe.replaceChildren();try{const terminal=await runTerminalProbe();probeResult.textContent="Rendering 4,000 diff rows...";const diff=await runDiffProbe();probeResult.textContent="terminal 120 frames / "+terminal.total.toFixed(0)+" ms\\nlongest "+terminal.max.toFixed(1)+" ms / "+terminal.slow+" over 24 ms\\ndiff 4,000 rows / "+diff.total.toFixed(0)+" ms\\nlayout "+diff.height.toLocaleString()+" px\\npackage "+packageKiB+" KiB"}catch{probeResult.textContent="The performance probe did not complete."}finally{probeButton.disabled=false;probeButton.textContent="Run probe"}};
|
||||
probeButton.addEventListener("click",()=>void runProbe());
|
||||
const receive=(event)=>{let message;try{message=JSON.parse(event.data)}catch{return}if(!message||message.v!==1)return;if(message.type==="init"){host.textContent=text(message.host?.name,"Paired host");setConnection(message.connection);send({type:"workspace.list",id:"workspace-"+(++sequence)});return}if(message.type==="connection"){setConnection(message.state);return}if(message.type==="response"&&typeof message.id==="string"&&message.id.startsWith("workspace-")){if(message.ok)renderWorkspaces(message.result?.workspaces);else renderError(text(message.error,"Workspace request failed."))}};
|
||||
window.addEventListener("message",receive);
|
||||
document.addEventListener("message",receive);
|
||||
send({type:"ready"})})();
|
||||
`.trim()
|
||||
|
||||
const PROTOTYPE_PACKAGE_PADDING = 'x'.repeat(320 * 1024)
|
||||
const PACKAGE_KIB_PLACEHOLDER = '000000'
|
||||
|
||||
function contentHash(value: string): string {
|
||||
return createHash('sha256').update(value, 'utf8').digest('base64')
|
||||
}
|
||||
|
||||
export function buildMobileWebPrototypeDocument(): string {
|
||||
const placeholderDocument = renderDocument(PROTOTYPE_SCRIPT)
|
||||
const packageKiB = Math.ceil(Buffer.byteLength(placeholderDocument, 'utf8') / 1024)
|
||||
const script = PROTOTYPE_SCRIPT.replace(
|
||||
PACKAGE_KIB_PLACEHOLDER,
|
||||
String(packageKiB).padStart(PACKAGE_KIB_PLACEHOLDER.length, '0')
|
||||
)
|
||||
return renderDocument(script)
|
||||
}
|
||||
|
||||
function renderDocument(script: string): string {
|
||||
const styleHash = contentHash(PROTOTYPE_STYLES)
|
||||
const scriptHash = contentHash(script)
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'sha256-${styleHash}'; script-src 'sha256-${scriptHash}'; img-src data:; base-uri 'none'; form-action 'none'; frame-src 'none'; connect-src 'none'">
|
||||
<title>Orca Hybrid Prototype</title>
|
||||
<style>${PROTOTYPE_STYLES}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<p class="eyebrow">Desktop-served prototype</p>
|
||||
<h1 id="host">Paired host</h1>
|
||||
<p class="subhead">This workspace list is rendered by web code delivered through the paired Orca connection.</p>
|
||||
<div class="status"><span id="dot" class="dot"></span><span id="connection">Connecting</span></div>
|
||||
<div id="workspace-list" class="list"><div class="empty">Waiting for the native bridge...</div></div>
|
||||
<section class="probe">
|
||||
<div class="probe-head"><div><p class="probe-title">Performance lab</p><p class="probe-copy">Synthetic terminal churn and a large diff, isolated from host data.</p></div><button id="run-probe" class="probe-button" type="button">Run probe</button></div>
|
||||
<div id="probe-result" class="probe-result" hidden></div>
|
||||
<div class="probe-stage"><pre id="terminal-probe" class="terminal-probe"></pre><div id="diff-probe" class="diff-probe"></div></div>
|
||||
</section>
|
||||
</main>
|
||||
<script>${script}</script>
|
||||
<!-- Package-transfer padding for the bounded multi-chunk prototype: ${PROTOTYPE_PACKAGE_PADDING} -->
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -352,8 +352,6 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'linear.updateIssue',
|
||||
'markdown.readTab',
|
||||
'markdown.saveTab',
|
||||
'mobileWeb.prototype.chunk',
|
||||
'mobileWeb.prototype.manifest',
|
||||
'mobileWeb.package.asset',
|
||||
'mobileWeb.package.manifest',
|
||||
'notifications.getMissedSince',
|
||||
|
||||
@@ -69,6 +69,7 @@ describe('mobile web native shell channel', () => {
|
||||
workspaceId: 'opaque-workspace',
|
||||
workspaceName: 'Feature'
|
||||
})
|
||||
expect(hook.result.current.navigationRoute).toEqual(hook.result.current.resumeRoute)
|
||||
expect(posted).toContainEqual({
|
||||
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
type: 'ready',
|
||||
@@ -78,6 +79,7 @@ describe('mobile web native shell channel', () => {
|
||||
expect(hook.result.current.rememberRoute({ kind: 'workspaceList' })).toBe(true)
|
||||
})
|
||||
expect(hook.result.current.resumeRoute).toEqual({ kind: 'workspaceList' })
|
||||
expect(hook.result.current.navigationRoute).toEqual({ kind: 'workspaceList' })
|
||||
expect(hook.result.current.routeRevision).toBe(1)
|
||||
expect(posted).toContainEqual({
|
||||
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
@@ -101,16 +103,17 @@ describe('mobile web native shell channel', () => {
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(hook.result.current.resumeRoute).toEqual({
|
||||
expect(hook.result.current.navigationRoute).toEqual({
|
||||
kind: 'session',
|
||||
workspaceId: 'notification-target',
|
||||
workspaceName: 'Notification target'
|
||||
})
|
||||
expect(hook.result.current.resumeRoute).toEqual({ kind: 'workspaceList' })
|
||||
expect(hook.result.current.routeRevision).toBe(2)
|
||||
|
||||
act(() => dispatchShellMessage(navigationMessage(2, { kind: 'workspaceList' })))
|
||||
act(() => dispatchShellMessage(navigationMessage(1, { kind: 'workspaceList' })))
|
||||
expect(hook.result.current.resumeRoute).toEqual({
|
||||
expect(hook.result.current.navigationRoute).toEqual({
|
||||
kind: 'session',
|
||||
workspaceId: 'notification-target',
|
||||
workspaceName: 'Notification target'
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
parseMobileWebBridgeShellMessage,
|
||||
type MobileWebBridgeMessageContext,
|
||||
type MobileWebBridgePageMessage,
|
||||
type MobileWebNavigationRoute,
|
||||
type MobileWebResumeRoute
|
||||
} from '../../shared/mobile-web/bridge-contract'
|
||||
import { MobileWebBridgeClient } from './mobile-web-bridge-client'
|
||||
@@ -33,6 +34,7 @@ export type MobileWebNativeShellState = {
|
||||
connection: 'connecting' | 'connected' | 'offline' | 'recovering'
|
||||
reconnectAttempts: number
|
||||
lastConnectedAt: number | null
|
||||
navigationRoute: MobileWebNavigationRoute
|
||||
resumeRoute: MobileWebResumeRoute
|
||||
routeRevision: number
|
||||
rememberRoute: (route: MobileWebResumeRoute) => boolean
|
||||
@@ -60,6 +62,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState {
|
||||
connection: 'connecting',
|
||||
reconnectAttempts: 0,
|
||||
lastConnectedAt: null,
|
||||
navigationRoute: { kind: 'workspaceList' },
|
||||
resumeRoute: { kind: 'workspaceList' },
|
||||
routeRevision: 0,
|
||||
rememberRoute: () => false
|
||||
@@ -72,6 +75,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState {
|
||||
reconnectAttempts: 0,
|
||||
lastConnectedAt: null
|
||||
}
|
||||
let navigationRoute: MobileWebNavigationRoute = { kind: 'workspaceList' }
|
||||
let resumeRoute: MobileWebResumeRoute = { kind: 'workspaceList' }
|
||||
let routeRevision = 0
|
||||
let lastNavigationSequence = -1
|
||||
@@ -89,13 +93,17 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState {
|
||||
metrics = nextMobileWebShellConnectionMetrics(metrics, init, retainsContext)
|
||||
if (!retainsContext) {
|
||||
resumeRoute = init.resumeRoute ?? { kind: 'workspaceList' }
|
||||
navigationRoute = resumeRoute
|
||||
routeRevision += 1
|
||||
lastNavigationSequence = -1
|
||||
}
|
||||
rememberRoute = (route) => {
|
||||
resumeRoute = route
|
||||
navigationRoute = route
|
||||
setState((current) =>
|
||||
sameContext(current.context, nextContext) ? { ...current, resumeRoute: route } : current
|
||||
sameContext(current.context, nextContext)
|
||||
? { ...current, navigationRoute: route, resumeRoute: route }
|
||||
: current
|
||||
)
|
||||
return postPageMessage({
|
||||
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
@@ -109,6 +117,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState {
|
||||
client,
|
||||
context,
|
||||
connection: init.connection,
|
||||
navigationRoute,
|
||||
resumeRoute,
|
||||
routeRevision,
|
||||
rememberRoute,
|
||||
@@ -133,6 +142,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState {
|
||||
client,
|
||||
context,
|
||||
connection: init.connection,
|
||||
navigationRoute,
|
||||
resumeRoute,
|
||||
routeRevision,
|
||||
rememberRoute,
|
||||
@@ -159,11 +169,11 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState {
|
||||
return
|
||||
}
|
||||
lastNavigationSequence = parsed.value.sequence
|
||||
resumeRoute = parsed.value.route
|
||||
navigationRoute = parsed.value.route
|
||||
routeRevision += 1
|
||||
setState((current) =>
|
||||
sameContext(current.context, activeContext)
|
||||
? { ...current, resumeRoute, routeRevision, rememberRoute }
|
||||
? { ...current, navigationRoute, routeRevision, rememberRoute }
|
||||
: current
|
||||
)
|
||||
return
|
||||
@@ -175,6 +185,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState {
|
||||
client,
|
||||
context,
|
||||
connection: parsed.value.state,
|
||||
navigationRoute,
|
||||
resumeRoute,
|
||||
routeRevision,
|
||||
rememberRoute,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
export const MOBILE_WEB_PROTOTYPE_PROTOCOL_VERSION = 1
|
||||
export const MOBILE_WEB_PROTOTYPE_CHUNK_BYTES = 48 * 1024
|
||||
export const MOBILE_WEB_PROTOTYPE_MAX_BYTES = 512 * 1024
|
||||
|
||||
export type MobileWebPrototypeManifest = {
|
||||
protocolVersion: typeof MOBILE_WEB_PROTOTYPE_PROTOCOL_VERSION
|
||||
buildId: string
|
||||
sha256: string
|
||||
byteLength: number
|
||||
chunkBytes: number
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
}
|
||||
|
||||
export type MobileWebPrototypeChunk = {
|
||||
buildId: string
|
||||
offset: number
|
||||
byteLength: number
|
||||
sha256: string
|
||||
dataBase64: string
|
||||
}
|
||||
|
||||
export type MobileWebPrototypeWorkspace = {
|
||||
id: string
|
||||
name: string
|
||||
repo: string
|
||||
branch: string
|
||||
isActive: boolean
|
||||
liveTerminalCount: number
|
||||
}
|
||||
|
||||
export type MobileWebPrototypeRequest =
|
||||
| { v: 1; type: 'ready' }
|
||||
| { v: 1; type: 'workspace.list'; id: string }
|
||||
| { v: 1; type: 'haptic.selection'; id: string }
|
||||
|
||||
export type MobileWebPrototypeResponse =
|
||||
| {
|
||||
v: 1
|
||||
type: 'init'
|
||||
buildId: string
|
||||
host: { id: string; name: string }
|
||||
connection: string
|
||||
capabilities: readonly ['workspace.list', 'haptic.selection']
|
||||
}
|
||||
| { v: 1; type: 'connection'; state: string }
|
||||
| {
|
||||
v: 1
|
||||
type: 'response'
|
||||
id: string
|
||||
ok: true
|
||||
result: { workspaces: MobileWebPrototypeWorkspace[] } | null
|
||||
}
|
||||
| { v: 1; type: 'response'; id: string; ok: false; error: string }
|
||||
@@ -304,12 +304,35 @@ describe('mobile web bridge shell contract', () => {
|
||||
}
|
||||
}
|
||||
expect(MobileWebBridgeShellMessageSchema.safeParse(navigation).success).toBe(true)
|
||||
expect(
|
||||
MobileWebBridgeShellMessageSchema.safeParse({
|
||||
...navigation,
|
||||
route: { kind: 'tasks', taskSource: 'gitlab' }
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
MobileWebBridgeShellMessageSchema.safeParse({
|
||||
...navigation,
|
||||
route: { kind: 'tasks', taskSource: 'jira' }
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
MobileWebBridgeShellMessageSchema.safeParse({
|
||||
...navigation,
|
||||
route: { ...navigation.route, hostWorkspaceId: '/private/orca' }
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
MobileWebBridgeShellMessageSchema.safeParse({
|
||||
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
type: 'init',
|
||||
shellSessionId: SHELL_SESSION_ID,
|
||||
buildId: BUILD_ID,
|
||||
connection: 'connected',
|
||||
grants: [operationGrant()],
|
||||
resumeRoute: { kind: 'accounts' }
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
MobileWebBridgeShellMessageSchema.safeParse({ ...navigation, sequence: -1 }).success
|
||||
).toBe(false)
|
||||
|
||||
@@ -73,15 +73,31 @@ const PageHealthSchema = PageEnvelopeSchema.extend({
|
||||
state: z.literal('interactive')
|
||||
}).strict()
|
||||
|
||||
const MobileWebWorkspaceListRouteSchema = z.object({ kind: z.literal('workspaceList') }).strict()
|
||||
const MobileWebSessionRouteSchema = z
|
||||
.object({
|
||||
kind: z.literal('session'),
|
||||
workspaceId: MobileWebWorkspaceIdSchema,
|
||||
workspaceName: z.string().max(240)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const MobileWebResumeRouteSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('workspaceList') }).strict(),
|
||||
MobileWebWorkspaceListRouteSchema,
|
||||
MobileWebSessionRouteSchema
|
||||
])
|
||||
|
||||
export const MobileWebNavigationRouteSchema = z.discriminatedUnion('kind', [
|
||||
MobileWebWorkspaceListRouteSchema,
|
||||
MobileWebSessionRouteSchema,
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('session'),
|
||||
workspaceId: MobileWebWorkspaceIdSchema,
|
||||
workspaceName: z.string().max(240)
|
||||
kind: z.literal('tasks'),
|
||||
taskSource: z.enum(['github', 'gitlab', 'linear']).optional()
|
||||
})
|
||||
.strict()
|
||||
.strict(),
|
||||
z.object({ kind: z.literal('accounts') }).strict(),
|
||||
z.object({ kind: z.literal('newWorkspace') }).strict()
|
||||
])
|
||||
|
||||
const PageRouteStateSchema = PageEnvelopeSchema.extend({
|
||||
@@ -173,7 +189,7 @@ const ShellConnectionSchema = ShellEnvelopeSchema.extend({
|
||||
const ShellNavigationSchema = ShellEnvelopeSchema.extend({
|
||||
type: z.literal('navigation'),
|
||||
sequence: SequenceSchema,
|
||||
route: MobileWebResumeRouteSchema
|
||||
route: MobileWebNavigationRouteSchema
|
||||
}).strict()
|
||||
|
||||
const ShellSuccessResponseSchema = ShellEnvelopeSchema.extend({
|
||||
@@ -209,6 +225,7 @@ export const MobileWebBridgeShellMessageSchema = z.union([
|
||||
export type MobileWebBridgeErrorCode = z.infer<typeof MobileWebBridgeErrorCodeSchema>
|
||||
export type MobileWebBridgePageMessage = z.infer<typeof MobileWebBridgePageMessageSchema>
|
||||
export type MobileWebBridgeShellMessage = z.infer<typeof MobileWebBridgeShellMessageSchema>
|
||||
export type MobileWebNavigationRoute = z.infer<typeof MobileWebNavigationRouteSchema>
|
||||
export type MobileWebResumeRoute = z.infer<typeof MobileWebResumeRouteSchema>
|
||||
|
||||
export type MobileWebBridgeMessageContext = {
|
||||
|
||||
@@ -24,7 +24,8 @@ describe('mobile web native-chat operation contract', () => {
|
||||
MobileWebNativeChatSendMessagePayloadSchema.safeParse({
|
||||
...TARGET,
|
||||
text: 'hello',
|
||||
deadline
|
||||
deadline,
|
||||
clearInputFirst: true
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
@@ -41,6 +42,14 @@ describe('mobile web native-chat operation contract', () => {
|
||||
expect(
|
||||
MobileWebNativeChatSendMessagePayloadSchema.safeParse({ ...TARGET, text: 'hello' }).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
MobileWebNativeChatSendMessagePayloadSchema.safeParse({
|
||||
...TARGET,
|
||||
text: 'hello',
|
||||
deadline,
|
||||
clearInputFirst: 'true'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
MobileWebNativeChatStopPayloadSchema.safeParse({
|
||||
...TARGET,
|
||||
|
||||
@@ -144,7 +144,8 @@ export const MobileWebNativeChatSendMessagePayloadSchema = z
|
||||
text: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64 * 1024)
|
||||
.max(64 * 1024),
|
||||
clearInputFirst: z.boolean().optional()
|
||||
})
|
||||
.strict()
|
||||
export const MobileWebNativeChatRespondPayloadSchema = z
|
||||
|
||||
Reference in New Issue
Block a user