mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
* fix(mobile): wall hosts that cannot serve the page instead of opening native screens * refactor(mobile): drop the native-route state and the shell fallback prop; unknown routes say unavailable * test(mobile): read the nullable page route refusal in its policy test * refactor(mobile): every build is the OTA shell; drop the native/OTA build switch The owner retired the native-screen fallback with no emergency native build, so EXPO_PUBLIC_MOBILE_SHELL, mobileShellBuildKind, shellSwitchDecision, the Metro cache-key suffix and both release workflows' shell input go. Every host route mounts the shell; a route naming no screen the shell could open gets the existing refusal screen instead of a native screen. Claude-Session: https://claude.ai/code/session_6cea92f6 * refactor(mobile): delete native screens the OTA shell no longer reaches With every build the OTA shell, these native siblings run nowhere: the native-executed closure (index.js plus every non-web route) does not reach them, and the page resolves their .web siblings. Each .web sibling takes the plain name, its web-overrides entry goes, and the native-only rich Markdown editor and Mermaid WebView generators and their outputs go with them. Kept: native siblings the node test suite still renders shared screens through (terminal WebView, browser frame path, clipboard, dictation, media picker, live input, snapshot budget, input styles). Claude-Session: https://claude.ai/code/session_6cea92f6 * test(mobile): drop the phone-preview comparison the deleted native sibling held Claude-Session: https://claude.ai/code/session_6cea92f6 * refactor(mobile): drop the dead web route, flag-era comments and the one-value shell kind The host route files no longer switch, so their comments say what they do now: a route naming no screen shows the unavailable screen. app/h/[hostId]/web.tsx duplicated index.tsx and nothing linked to it, so it and its stack entry go (the bundle sweep is re-measured: 77 scripts at 15 routes). Diagnostics print the bridge protocol on its own instead of a shell kind that has one value. Claude-Session: https://claude.ai/code/session_6cea92f6 * refactor(mobile): one host for the rich editor, plain Text, and drop two unused native deps MobileSelectableText was react-native's Text, so callers use Text. The rich Markdown editor's host-facing seams (messages, URL prompt, keyboard inset) are required now that the page is its only host; the WebView defaults go. Comments that described deleted native components are rewritten, and expo-image-manipulator and react-native-uitextview leave package.json and the lockfile. Claude-Session: https://claude.ai/code/session_6cea92f6 * feat(mobile): draw the host Accounts screen from the desktop's page Accounts was the last host screen the shell still drew natively, so its changes needed a phone build. It is now a page route like Tasks: the screen moves to src/accounts/MobileAccountsScreen.tsx, accounts.web.tsx renders it on the page, and accounts.tsx mounts the shell for it. What React Native Web needed: Alert.alert is a no-op there, so the reset confirmation, its outcome and a failed account switch are ConfirmModal drawers (ConfirmModal can now drop Cancel for a notice); Back goes through the route handoff, which pops the shell's stack; and the reset idempotency key is built from getRandomBytes, since randomUUID needs a secure context the iOS custom scheme is not. edit.tsx edits the phone's own pairing and never renders on the page, so the route manifest leaves it out and the page's host stack does not declare it. The script sweep is re-measured for the 14-route tree. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(accounts): answer a same-offer Codex reset retry with its recorded outcome; mobile mirrors the desktop The desktop keeps no client journal for a reset credit: the host's ledger owns the attempt. A remote client that lost a reply and retried the same offer under a new key was refused with "already attempted" (or "unknown outcome" while the provider call was unresolved), which is why the phone carried its own durable journal to replay the old key. The coordinator now answers a request for an offer an earlier key already claimed as that attempt: the settled outcome, the running attempt, or a resume of the pending one under its original key. A different offer for the account is still refused while an outcome is unknown, so nothing can spend twice. The reply shape is the one a same-key replay always had, so installed clients with their journal read it unchanged. With that, the Accounts page sends a fresh key per attempt like the desktop and keeps nothing on the phone: the AsyncStorage journal, its client singleflight and the retry-record warnings are deleted, and the journal-resume recording goes with them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(accounts): resume an account's unresolved Codex reset when the retry names a newer offer Review follow-ups: - Host: after an unknown provider outcome the usage refresh moves the offer, so a retry names a newer one. The coordinator now answers it as the account's unresolved attempt (resumed under its own key and scope), as the desktop's consumeCurrent does, instead of refusing forever. One ledger lookup finds own key, offer claimant, then unresolved attempt. - Mobile: a reply is matched on target, account and account revision, not the offer, so the resumed attempt's scope is accepted. - The key is minted inside requestCodexResetCredit from randomV4Uuid, the session helper moved to src/platform/random-v4-uuid.ts. - RefreshControl goes; the header refresh stays. ConfirmModal's onConfirm is optional for an OK-only notice. - Stale journal comments and the mobile docs line are corrected. - The edit.tsx page exclusion is dropped from this PR; the script sweep is re-measured for 15 routes. - Root oxlint: escape the apostrophe; zod v4 strictObject. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): secure UUID fallback from expo-crypto; pin reset retry routing - randomV4Uuid never falls back to Math.random: without crypto.randomUUID it builds the v4 UUID from ExpoCrypto.getRandomBytes, as the old reset key did. Tests get Node's secure bytes for expo-crypto from the setup file, since the native module has no Node build. - Host tests: a new offer after a settled attempt spends again rather than replaying, and a retry naming a newer offer joins a resumed attempt in flight (one provider call, original key). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore: restore main's bytes for files the merge's pre-commit format touched The merge commit's lint-staged run reformatted 27 files this PR does not change. Committed without the hook so they match origin/main exactly. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): stand in for expo-crypto with a Node alias, not a setup-file mock Anti-slop forbids module mocking outside test files. The vitest config now aliases expo-crypto to a faithful Node implementation of the names shared code reaches; a test file's own mock still wins. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): match the bridged-parity comments to the measured 795-golden tally Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
78 lines
4.7 KiB
JavaScript
78 lines
4.7 KiB
JavaScript
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import * as esbuild from 'esbuild'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { mobileWebAppBuildOptions } from './build-mobile-web-app-bundle.mjs'
|
|
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
|
import { collectMobileWebAppRoutes } from './mobile-web-app-route-manifest.mjs'
|
|
|
|
/**
|
|
* The push subsystem, and why no page route may reach any part of it.
|
|
*
|
|
* Not `runtime-capability-probe.ts`, which `push-registration.ts` also imported. The session route
|
|
* reaches it through `use-mobile-session-tab-reconciliation.ts`, and there it works: it sends
|
|
* `status.get`, a read with no client identity that the shell forwards like any non-`native.`
|
|
* request (`bridge-host-requests.ts`) and the desktop's mobile allowlist admits. Its other importer,
|
|
* `codex-reset-credit-capability.ts`, is the Accounts page route's, and works there the same way.
|
|
*
|
|
* `expo-notifications` first, and why no page route may import it.
|
|
*
|
|
* It is not a module a browser can merely import. `DevicePushTokenAutoRegistration.fx` runs at
|
|
* import: it adds a push-token listener, which React Native Web answers with a warning and an inert
|
|
* subscription, and it reads the persisted server registration out of `window.localStorage`. That
|
|
* read is guarded by `typeof localStorage === 'undefined'`, and the Android shell's WebView has DOM
|
|
* storage off, where `window.localStorage` is `null` rather than undefined — so the guard passes
|
|
* and the read raises "Cannot read properties of null (reading 'getItem')". The emulator run saw
|
|
* both lines on every page load, the second at error level, from a subsystem the page cannot use:
|
|
* push registration needs a device token the shell owns and a gateway the page has no client for.
|
|
*
|
|
* Two modules imported it — `push-token.ts` and `desktop-notification-channel.ts`, both reached
|
|
* through `push-registration.ts`, which `app/h/_layout.tsx` pulled in via the host screen's remove
|
|
* action. Both were given `.web` siblings. That was the first fence, and it is kept because
|
|
* nothing else stops a third importer: every call in those two files was already inert on web, so
|
|
* a page that imports one behaves correctly and still loads the package.
|
|
*
|
|
* `push-registration.ts` is fenced a layer up, and that is what the assertions below read. The
|
|
* page cannot register for push at all: it has no device token and no gateway client, so every
|
|
* path through that module either does nothing or writes `orca:remotePushHostRegistrations`
|
|
* through a page storage adapter that drops the write and says so. `host-removal-lifecycle.web.ts`
|
|
* is what cut it out — removing a host is native-only — and it took the whole of
|
|
* `src/notifications` with it, because that import was the only path into the directory from any
|
|
* page route. So the fence is stated as the directory rather than as three module names: the two
|
|
* `.web` siblings above are no longer in the page bundle either, and a test that asserted their
|
|
* presence as its precondition would now be asserting the fence had a hole.
|
|
*/
|
|
|
|
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
|
const appDir = join(projectDir, 'mobile', 'app')
|
|
|
|
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
|
|
|
|
describeClosure(
|
|
'the push subsystem against the page',
|
|
() => {
|
|
it('is in no module the shipped bundle contains', async () => {
|
|
// The bundle the shell serves, not a closure read per route: the entry's manifest is what
|
|
// reaches every route, deferred chunks included, so this is the whole of what a document
|
|
// can load. Read per route, a module would only have to move one route over to hide.
|
|
const routes = await collectMobileWebAppRoutes(appDir)
|
|
expect(routes.length).toBeGreaterThan(5)
|
|
const { metafile } = await esbuild.build({
|
|
...mobileWebAppBuildOptions(routes),
|
|
metafile: true,
|
|
write: false
|
|
})
|
|
const modules = Object.keys(metafile.inputs)
|
|
// The precondition, because a walk that resolved nothing would also contain nothing, and one
|
|
// that ignored `.web` resolution would report the native lifecycle file rather than the
|
|
// sibling whose refusal is the reason push is absent.
|
|
expect(modules.length).toBeGreaterThan(1000)
|
|
expect(modules).toContain('src/transport/host-removal-lifecycle.web.ts')
|
|
expect(modules).not.toContain('src/transport/host-removal-lifecycle.ts')
|
|
expect(modules.filter((input) => input.includes('expo-notifications'))).toEqual([])
|
|
expect(modules.filter((input) => input.startsWith('src/notifications/'))).toEqual([])
|
|
}, 300_000)
|
|
},
|
|
600_000
|
|
)
|