From 0ab2ba3480b05a23adf7eda1aed260be22b5ef25 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:07:16 -0400 Subject: [PATCH] fix(mobile): the page stops writing, importing and requesting what it cannot use (#22241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): the page keeps no host app-version record `host-status-gates.ts` runs above every page route, and a readable `status.get` had it write `orca:host-app-version:v1:` through `host-app-version-store.ts`. Inside the page AsyncStorage is the bridge's adapter and that key is not one `page-storage-keys.ts` hands a route, so every mount posted a write the shell refused and logged as `storage-write-dropped`. Not admitted through the storage seam, because the page never reads it back: the record's only reader is the native troubleshoot screen's `native-diagnostics-operations.ts`, which is not in the page's bundle. A `.web` sibling keeps no record instead. The bounds check moves to `host-app-version.ts` so both hosts read a reported version the same way. The session render check now collects warnings as well as errors and answers `status.get`, which is what arms the write: the other cases' double answers no RPC, so the drop needed a reply rather than a control. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page does not import expo-notifications `DevicePushTokenAutoRegistration.fx` runs at import: it adds a push-token listener React Native Web answers with a warning, and it reads the persisted server registration out of `window.localStorage` behind a `typeof localStorage === 'undefined'` guard. The Android shell's WebView has DOM storage off, where `window.localStorage` is `null` rather than undefined, so the guard passed and the read raised "Cannot read properties of null (reading 'getItem')" at error level on every page load. Two modules imported the package — `push-token.ts` and `desktop-notification-channel.ts`, both reached through `push-registration.ts`, which the host layout pulls in via the host screen's remove action. Both get a `.web` sibling. The page holds no device push token and creates no Android channel; push registration needs a token the shell owns and a gateway the page has no client for. Every call in those two files was already inert on web, so a page that imports one behaves correctly and still loads the package: the closure check beside them is what keeps a third importer out. The session render check adds the device's own shape — `localStorage` reading `null` — and reds on the error the emulator saw. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page declares an icon, so no browser asks for one With none declared a browser asks the origin for /favicon.ico on its own, and the shell's asset server answers 403 because the path is in no manifest — which the emulator run saw repeatedly. The document now carries ``, a browser's own way of being told there is no icon. An empty data URI rather than an asset: the page is a WebView document with no tab to put an icon in, and the bundle's images are content-hashed route assets whose names change with their bytes. `img-src 'self' data: https:` already admits the scheme. Two assertions, because each is blind where the other sees. The build check reads the document and runs everywhere. The session render check reads the request, which only a full Chrome makes — `ORCA_MOBILE_WEB_RENDER_BROWSER`, what CI resolves — and reads it off the server's own log: a favicon fetch comes from the browser process rather than the page, so Playwright's request events never report one. It also settles on network idle first, because the fetch comes after the text the route waited on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): trim the page-noise comments to the bar Comment-only. The three `.web` siblings, the document's icon line and the three override reasons each said their cause once and then said it again; each now states what the page keeps and why, once. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the session closure after expo-notifications left Measured on this head with all five generators run first, against a scratch worktree detached at the base, which reads the committed pin exactly: 4271 modules and 1023 local. modules 4271 -> 4210 (-61) local modules 1023 -> 1024 (+1) 65 modules leave and 4 join. 62 of the 65 are vendored: expo-notifications' own 55, and expo-application, badgin, abort-controller and event-target-shim behind them. The other three are the native files the `.web` siblings replace, so the siblings cost the local count nothing and its +1 is `host-app-version.ts`, the one new module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): count the packages the closure note names The note said 62 vendored modules left and then named five packages without counts, so the names read as the whole of the 62 and summed to five. Each carries its own count now: 55 + 3 + 2 + 1 + 1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../scripts/build-mobile-web-app-bundle.mjs | 4 + .../build-mobile-web-app-bundle.test.mjs | 14 +++ ...eb-app-expo-notifications-closure.test.mjs | 56 ++++++++++++ .../scripts/mobile-web-app-render-harness.mjs | 12 ++- .../mobile-web-app-session-render.test.mjs | 85 +++++++++++++++++-- ...-web-app-session-terminal-closure.test.mjs | 9 +- .../connection-diagnostics-report.ts | 2 +- .../desktop-notification-channel.web.ts | 10 +++ mobile/src/notifications/push-token.web.ts | 14 +++ .../transport/host-app-version-store.test.ts | 7 +- .../src/transport/host-app-version-store.ts | 18 +--- .../transport/host-app-version-store.web.ts | 10 +++ mobile/src/transport/host-app-version.ts | 25 ++++++ ...ost-status-capability-ignorability.test.ts | 1 - .../src/transport/host-status-gates.test.ts | 1 - mobile/src/transport/host-status-gates.ts | 3 +- mobile/web-entry/web-overrides.json | 12 +++ 17 files changed, 248 insertions(+), 35 deletions(-) create mode 100644 config/scripts/mobile-web-app-expo-notifications-closure.test.mjs create mode 100644 mobile/src/notifications/desktop-notification-channel.web.ts create mode 100644 mobile/src/notifications/push-token.web.ts create mode 100644 mobile/src/transport/host-app-version-store.web.ts create mode 100644 mobile/src/transport/host-app-version.ts diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index b7f7072804b..21215524deb 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -560,6 +560,10 @@ export async function buildMobileWebAppBundle({ const html = '\n\n\n\n' + '\n' + + // Undeclared, a browser asks the origin for /favicon.ico itself and the shell's asset server + // answers 403, the path being in no manifest. Empty rather than an asset: a WebView document + // has no tab for an icon, and the bundle's images are route assets named by their own bytes. + '\n' + `Orca\n${MOBILE_WEB_APP_ROOT_RESET}\n\n\n
\n` + `\n\n\n` const indexBytes = Buffer.from(html, 'utf8') diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index 17e8f9ca006..1e43486221c 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -368,6 +368,20 @@ describeBundling('the app bundle', () => { }) }, 120_000) + it('declares an icon, so no browser asks the shell for one', async () => { + await withScratch(async (scratch) => { + const outDir = join(scratch, 'icon') + const { manifest } = await buildMobileWebAppBundle({ outDir }) + const html = await readFile(join(outDir, 'index.html'), 'utf8') + // Undeclared, a browser asks the origin for /favicon.ico on its own, and the shell's asset + // server answers 403 because the path is in no manifest — repeatedly, on the emulator run. + expect(html).toContain('') + // And the empty URI rather than an asset: the bundle carries no icon, so a declaration + // naming one would point at a route image whose name changes with its bytes. + expect(manifest.assets.map((asset) => asset.path)).not.toContain('favicon.ico') + }) + }, 120_000) + it('carries the root reset, so the mounted tree has a height to be 1 of', async () => { await withScratch(async (scratch) => { const outDir = join(scratch, 'root-reset') diff --git a/config/scripts/mobile-web-app-expo-notifications-closure.test.mjs b/config/scripts/mobile-web-app-expo-notifications-closure.test.mjs new file mode 100644 index 00000000000..7e9110e0605 --- /dev/null +++ b/config/scripts/mobile-web-app-expo-notifications-closure.test.mjs @@ -0,0 +1,56 @@ +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' + +/** + * `expo-notifications`, 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` pulls in via the host screen's remove + * action. Both now have `.web` siblings. This is the fence, 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. + */ + +const projectDir = fileURLToPath(new URL('../..', import.meta.url)) +const appDir = join(projectDir, 'mobile', 'app') + +const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip + +describeClosure( + 'expo-notifications 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) + expect(modules.filter((input) => input.includes('expo-notifications'))).toEqual([]) + // The precondition: a walk that resolved nothing would also contain nothing. The two modules + // that imported it are still here, as their siblings. + expect(modules).toContain('src/notifications/push-token.web.ts') + expect(modules).toContain('src/notifications/desktop-notification-channel.web.ts') + }, 300_000) + }, + 600_000 +) diff --git a/config/scripts/mobile-web-app-render-harness.mjs b/config/scripts/mobile-web-app-render-harness.mjs index 2b8536ce5f5..f056223bec1 100644 --- a/config/scripts/mobile-web-app-render-harness.mjs +++ b/config/scripts/mobile-web-app-render-harness.mjs @@ -453,8 +453,13 @@ export async function createBundleServer({ transformChunk, handleRequest }) { + const requestedPaths = [] const server = createServer((request, response) => { const path = new URL(request.url, 'http://localhost').pathname + // Every path this origin was asked for, the browser's own fetches included. A favicon request + // is made by the browser process rather than the page, and Playwright's `page.on('request')` + // never reports one, so the server is the only place a check can see it. + requestedPaths.push(path) // An endpoint of the check's own, answered before anything is looked for on disk: a policy's // `report-uri` has to name a real server, and naming this one keeps it on the page's origin. if (handleRequest?.(request, response, path)) { @@ -462,7 +467,10 @@ export async function createBundleServer({ } // A browser asks for this on its own and the shell's WebView never does. The bundle carries // no icon, so a 404 would put a console error in every check that runs against a full Chrome - // -- which is what CI resolves -- and none against the bundled headless shell. + // -- which is what CI resolves -- and none against the bundled headless shell. Kept for the + // probe documents the checks compose themselves, which declare no icon; the page's own + // document does declare one, and answering 204 hides nothing from a check that reads the + // request rather than the response (`mobile-web-app-session-render.test.mjs`). if (path === '/favicon.ico') { response.writeHead(204) response.end() @@ -502,7 +510,7 @@ export async function createBundleServer({ ) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - return { server, origin: `http://127.0.0.1:${String(server.address().port)}` } + return { server, origin: `http://127.0.0.1:${String(server.address().port)}`, requestedPaths } } /** diff --git a/config/scripts/mobile-web-app-session-render.test.mjs b/config/scripts/mobile-web-app-session-render.test.mjs index 014797eaaf7..57ba6457869 100644 --- a/config/scripts/mobile-web-app-session-render.test.mjs +++ b/config/scripts/mobile-web-app-session-render.test.mjs @@ -89,6 +89,7 @@ let server let browser let origin let routeChunks = {} +let servedPaths = [] let cspHeader = null let bridgeVersion = null let faultGrant = null @@ -106,6 +107,7 @@ beforeAll(async () => { const served = await createBundleServer({ outDir: built.outDir, cspHeader }) server = served.server origin = served.origin + servedPaths = served.requestedPaths const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }) }, 240_000) @@ -119,8 +121,16 @@ afterAll(async () => { }) /** A page carrying every signal these cases read: uncaught errors, console errors, request paths. */ -async function openPage(route) { +async function openPage(route, replies = {}, { domStorageOff = false } = {}) { const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) + if (domStorageOff) { + // What the Android shell serves: DOM storage is off on its WebView, and a WebView with it off + // answers `window.localStorage` with `null` rather than leaving it undefined. Read off the + // device rather than assumed — the emulator run's own error names `null` (reading 'getItem'). + await page.addInitScript(() => { + Object.defineProperty(window, 'localStorage', { configurable: true, get: () => null }) + }) + } // At document start, where the native shell installs the real channel: the entry reads it while // its own script runs, so a channel added after `load` would already be too late. await page.addInitScript(installShellDouble, { @@ -133,9 +143,10 @@ async function openPage(route) { faultGrant, grants: [faultGrant, ...sessionGrants()], pageRoutes: PAGE_ROUTE_PATTERNS, - replies: {} + replies }) const errors = [] + const warnings = [] const scripts = [] const requestedHosts = [] page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`)) @@ -143,6 +154,11 @@ async function openPage(route) { if (message.type() === 'error') { errors.push(`console.error: ${message.text()}`) } + // Kept apart from `errors`: the bridge reports a refused storage write at warning level, so a + // page writing a key it was never handed is invisible to every assertion above. + if (message.type() === 'warning') { + warnings.push(message.text()) + } }) // Every request, not only the ones that answered: a CSP refusal fails the request, and a check // reading responses alone would read a blocked fetch as one that never happened. @@ -153,7 +169,7 @@ async function openPage(route) { scripts.push(path) } }) - return { page, errors, scripts, requestedHosts } + return { page, errors, warnings, scripts, requestedHosts } } /** @@ -186,8 +202,8 @@ async function waitForRoute({ page, errors }, route, awaitText) { } } -async function openRoute(route, awaitText) { - const opened = await openPage(route) +async function openRoute(route, awaitText, replies = {}, options = {}) { + const opened = await openPage(route, replies, options) await opened.page.goto(`${origin}/`, { waitUntil: 'load' }) await waitForRoute(opened, route, awaitText) return opened @@ -265,6 +281,62 @@ describeRender( await opened.page.close() }, 120_000) + it('asks the origin for no icon, which the shell has none to answer with', async () => { + // The document declares ``. Without it a browser asks the + // origin for /favicon.ico on its own, and the shell's asset server answers 403 because the + // path is in no manifest — which the emulator run saw, repeatedly. + // + // Only a full Chrome asks; the bundled headless shell never does, so against the default + // browser this case is a precondition rather than a measurement. + // `ORCA_MOBILE_WEB_RENDER_BROWSER` is what CI resolves, and that is where this bites. + // Read off the server's own log, not the page's: a favicon fetch is made by the browser + // process rather than the page, and Playwright's `page.on('request')` never reports one. + // The whole file's log, because no case here may produce this request. + const opened = await openRoute(SESSION_ROUTE, 'Terminal') + // Settled rather than read at the paint: a browser asks for the icon after `load`, later + // than the text the route waited on, and reading there passes on a request still to come. + await opened.page.waitForLoadState('networkidle') + expect(servedPaths.filter((path) => path === '/favicon.ico')).toEqual([]) + // The precondition, so a run that recorded no request at all cannot pass this. + expect(servedPaths).toContain('/') + await opened.page.close() + }, 120_000) + + it('paints with DOM storage off, which is how the Android shell serves it', async () => { + // Every other case here runs against a real `localStorage`, which the page never has. The + // one module that needed it was `expo-notifications`: `push-registration.ts` reached it and + // its `DevicePushTokenAutoRegistration.fx` reads the persisted registration at import behind + // a `typeof localStorage === 'undefined'` guard, which `null` walks straight through. That + // put "Cannot read properties of null (reading 'getItem')" at error level on every page load + // on the device. The page has no push registration; the shell owns it. + const opened = await openRoute(SESSION_ROUTE, 'Terminal', {}, { domStorageOff: true }) + // Exact and not a filter, like the case above it: a module reaching browser storage the page + // does not have is a defect wherever it comes from. + expect(opened.errors).toEqual([]) + await opened.page.close() + }, 120_000) + + it('writes no storage key it was never handed, on a mount that read the host status', async () => { + // `status.get` is what arms it: `host-status-gates.ts` runs on every mount above the route, + // and on a readable status the native `host-app-version-store.ts` writes + // `orca:host-app-version:v1:` — a key no page route reads and `page-storage-keys.ts` + // does not admit, so the bridge refused it and logged one `storage-write-dropped` per mount + // on the device. Answered here because the other cases' double answers no RPC at all, which + // is exactly why this went unseen: the write needs a reply, not a control. + const opened = await openRoute(SESSION_ROUTE, 'Terminal', { + 'status.get': { + protocolVersion: 9, + minCompatibleMobileVersion: 1, + appVersion: '1.4.191', + capabilities: [] + } + }) + // The whole refusal and not this one key: any page-closure writer of an unlisted key lands + // on the same line, and naming the key here would let the next one through. + expect(opened.warnings.filter((text) => text.includes('storage-write-dropped'))).toEqual([]) + await opened.page.close() + }, 120_000) + it('asks the desktop for the session it was opened on, so the page above is live', async () => { // The precondition every assertion above needs: a screen that mounted and asked for nothing // would paint the same chrome. The three reads are the header's live title, the tab snapshot @@ -303,7 +375,8 @@ describeRender( * what this route owes it is the `screencastBinary` grant, which * `mobile-web-app-screencast-lane-grant.test.mjs` derives from this closure. * - * **The storage refusals.** A page write needs a control to make it. The refusal's own chain is + * **The storage refusals a control makes.** The case above covers the writes a mount makes on its + * own; a refusal a user's own write earns still needs the control. That chain is * `mobile/src/session/mobile-structured-send-page-storage-refusal.test.ts` end to end over the * real `page-async-storage`. */ diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs index 2dd1095869e..b7b8ef4b70d 100644 --- a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs +++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs @@ -347,8 +347,15 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/' * * modules 4270 -> 4271 (+1) * local modules 1022 -> 1023 (+1) + * + * Cutting `expo-notifications` out of the page takes 62 vendored modules with it: 55 of its own, + * and behind it expo-application 3, abort-controller 2, badgin 1, event-target-shim 1. The three + * `.web` siblings replace their native files, so the local +1 is `host-app-version.ts` alone. + * + * modules 4271 -> 4210 (-61) + * local modules 1023 -> 1024 (+1) */ -const SESSION_ROUTE_MODULES = 4271 +const SESSION_ROUTE_MODULES = 4210 /** What the page enters this route through once the route is a switch with a `.web.tsx` sibling. */ const ROUTE_ENTRY = [ diff --git a/mobile/src/diagnostics/connection-diagnostics-report.ts b/mobile/src/diagnostics/connection-diagnostics-report.ts index c49eb0b01f7..41c22014471 100644 --- a/mobile/src/diagnostics/connection-diagnostics-report.ts +++ b/mobile/src/diagnostics/connection-diagnostics-report.ts @@ -5,7 +5,7 @@ import type { ConnectionState, MobileConnectionDiagnosticPath } from '../transport/types' -import { normalizeHostAppVersion } from '../transport/host-app-version-store' +import { normalizeHostAppVersion } from '../transport/host-app-version' import { formatEndpoint } from './host-reachability' import { diagnoseConnection } from './connection-diagnostics-analysis' import { redactConnectionLogEntry, redactConnectionLogText } from './connection-log-redaction' diff --git a/mobile/src/notifications/desktop-notification-channel.web.ts b/mobile/src/notifications/desktop-notification-channel.web.ts new file mode 100644 index 00000000000..3f022bc46fb --- /dev/null +++ b/mobile/src/notifications/desktop-notification-channel.web.ts @@ -0,0 +1,10 @@ +/** + * Web sibling: the page creates no Android notification channel and cannot register push. The + * native file already returns early off Android, so what this changes is the import: it reaches + * `expo-notifications`, which at import reads the persisted registration behind a + * `typeof localStorage === 'undefined'` guard that the shell's DOM-storage-off WebView walks + * through with `null`, raising on `.getItem`. See `push-token.web.ts` beside it. + */ +export const DESKTOP_NOTIFICATION_CHANNEL_ID = 'orca-desktop' + +export const ensureDesktopNotificationChannel = (): Promise => Promise.resolve() diff --git a/mobile/src/notifications/push-token.web.ts b/mobile/src/notifications/push-token.web.ts new file mode 100644 index 00000000000..8b6b5acf1ae --- /dev/null +++ b/mobile/src/notifications/push-token.web.ts @@ -0,0 +1,14 @@ +import type { MobilePushToken } from './push-token' + +/** + * Web sibling: the page holds no device push token and cannot register one — the token is the + * shell's and the gateway has no page client. The import is the defect, not the calls, which are + * already inert here: `expo-notifications` runs `DevicePushTokenAutoRegistration.fx` at import, + * which reads the persisted registration behind a `typeof localStorage === 'undefined'` guard that + * Android's DOM-storage-off WebView walks through with `null`, raising on `.getItem`. + */ +export const getDevicePushToken = (): Promise => Promise.resolve(null) + +export function addPushTokenListener(_listener: (token: MobilePushToken) => void): () => void { + return () => {} +} diff --git a/mobile/src/transport/host-app-version-store.test.ts b/mobile/src/transport/host-app-version-store.test.ts index a9f9f92d105..9f7d916c3a7 100644 --- a/mobile/src/transport/host-app-version-store.test.ts +++ b/mobile/src/transport/host-app-version-store.test.ts @@ -9,11 +9,8 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock })) -import { - loadHostAppVersion, - normalizeHostAppVersion, - recordHostAppVersion -} from './host-app-version-store' +import { normalizeHostAppVersion } from './host-app-version' +import { loadHostAppVersion, recordHostAppVersion } from './host-app-version-store' describe('host app version store', () => { beforeEach(() => { diff --git a/mobile/src/transport/host-app-version-store.ts b/mobile/src/transport/host-app-version-store.ts index 9c649cd2d0c..60c7d54e46c 100644 --- a/mobile/src/transport/host-app-version-store.ts +++ b/mobile/src/transport/host-app-version-store.ts @@ -1,23 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage' +import { normalizeHostAppVersion } from './host-app-version' const STORAGE_KEY_PREFIX = 'orca:host-app-version:v1:' -const MAX_VERSION_LENGTH = 64 - -export function normalizeHostAppVersion(value: unknown): string | null { - if (typeof value !== 'string') { - return null - } - const normalized = value.trim() - if ( - normalized.length === 0 || - normalized.length > MAX_VERSION_LENGTH || - normalized.includes('\n') || - normalized.includes('\r') - ) { - return null - } - return normalized -} export async function loadHostAppVersion(hostId: string): Promise { try { diff --git a/mobile/src/transport/host-app-version-store.web.ts b/mobile/src/transport/host-app-version-store.web.ts new file mode 100644 index 00000000000..0f7cbb91a29 --- /dev/null +++ b/mobile/src/transport/host-app-version-store.web.ts @@ -0,0 +1,10 @@ +/** + * Web sibling: the page keeps no record of the host's app version, because nothing in it reads one + * — the reader is the native troubleshoot screen's, outside this bundle. Not admitted through the + * storage seam for that reason: a key the page only writes is not page state, so `status.get` had + * every mount post `orca:host-app-version:v1:` for the bridge to refuse and log. + */ +export const loadHostAppVersion = (_hostId: string): Promise => Promise.resolve(null) + +export const recordHostAppVersion = (_hostId: string, _value: unknown): Promise => + Promise.resolve() diff --git a/mobile/src/transport/host-app-version.ts b/mobile/src/transport/host-app-version.ts new file mode 100644 index 00000000000..e8d0c8a6a04 --- /dev/null +++ b/mobile/src/transport/host-app-version.ts @@ -0,0 +1,25 @@ +const MAX_VERSION_LENGTH = 64 + +/** + * A host's self-reported app version as anything may hold it, or null. + * + * Beside the store rather than inside it because the two have different hosts: every build reads a + * version off `status.get`, and only a build with a device store keeps one. The bounds are the + * reasons a reported string is unusable at all — a newline splices a line into a diagnostics + * report, and an unbounded one is a host deciding how much of this device's storage to spend. + */ +export function normalizeHostAppVersion(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const normalized = value.trim() + if ( + normalized.length === 0 || + normalized.length > MAX_VERSION_LENGTH || + normalized.includes('\n') || + normalized.includes('\r') + ) { + return null + } + return normalized +} diff --git a/mobile/src/transport/host-status-capability-ignorability.test.ts b/mobile/src/transport/host-status-capability-ignorability.test.ts index 4d4e9101d3c..b0714d09b33 100644 --- a/mobile/src/transport/host-status-capability-ignorability.test.ts +++ b/mobile/src/transport/host-status-capability-ignorability.test.ts @@ -21,7 +21,6 @@ import type { RpcResponse } from './types' const recordHostAppVersionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) vi.mock('./host-app-version-store', () => ({ - normalizeHostAppVersion: (value: unknown) => (typeof value === 'string' ? value : null), recordHostAppVersion: (...args: unknown[]) => recordHostAppVersionMock(...args) })) diff --git a/mobile/src/transport/host-status-gates.test.ts b/mobile/src/transport/host-status-gates.test.ts index 7820eebc0d7..983f8146391 100644 --- a/mobile/src/transport/host-status-gates.test.ts +++ b/mobile/src/transport/host-status-gates.test.ts @@ -7,7 +7,6 @@ import { useHostStatusGates, type HostStatusGates } from './host-status-gates' const recordHostAppVersionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) vi.mock('./host-app-version-store', () => ({ - normalizeHostAppVersion: (value: unknown) => (typeof value === 'string' ? value : null), recordHostAppVersion: (...args: unknown[]) => recordHostAppVersionMock(...args) })) diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts index c9f92cb2305..f8e4817ec67 100644 --- a/mobile/src/transport/host-status-gates.ts +++ b/mobile/src/transport/host-status-gates.ts @@ -4,7 +4,8 @@ import type { ConnectionState } from './types' import { hostStatusProbe, readHostStatusGates } from './host-status-probe-operations' import { evaluateCompat, type CompatVerdict } from './protocol-compat' import type { HostStatusReply } from './host-status-reply-schema' -import { normalizeHostAppVersion, recordHostAppVersion } from './host-app-version-store' +import { normalizeHostAppVersion } from './host-app-version' +import { recordHostAppVersion } from './host-app-version-store' export type HostStatusGates = { hostCapabilities: string[] diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index cf2cbd812dd..f931d6084e0 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -156,6 +156,18 @@ { "file": "src/terminal/terminal-live-input-text-write.web.ts", "reason": "setNativeProps does not exist on React Native Web, where a TextInput ref is the DOM node, so the native write threw out of the session route's mount effect and faulted the page instead of clearing the field; this one sets value on the or