Files
orca/config/scripts/verify-mobile-web-app-bundle.mjs
Jinwoo Hong da1c322b00 feat(mobile): one build-time switch picks native or OTA, default native (OTA phase E1) (#22193)
* feat(mobile): one build-time constant decides native or OTA, default native

EXPO_PUBLIC_MOBILE_SHELL is read in exactly one place, mobileShellBuildKind in
preferences.ts. Expo's babel preset inlines a literal process.env member
expression at build time, so a release bundle carries the answer as a constant
and anything but the exact string 'ota' — unset, empty, a typo — is native.
Every default build is therefore the native app, unchanged.

mobileWebShellFlagCanBeOn now answers __DEV__ or an OTA build, so the ability to
mount the page comes from the build and never from storage: a native binary
installed over an OTA one, same bundle id and same data container, still refuses
a stored 'true' without reading the key. An unset key reads on only in an OTA
build; a development build keeps its opt-in, and a stored 'false' wins
everywhere so the Troubleshoot toggle can switch an OTA build back to native.

That toggle now mounts wherever the flag can be on, which is the only way back
to the native screens in an OTA build, and its label names the build kind rather
than saying "(dev)". The bundle probe row beside it stays development-only: it
fetches.

The flag census gains two rules — one module reads the switch, in the member
form Expo inlines and not the bracket form, and one named function answers the
build kind — and the build-kind fence now lists the Troubleshoot route that asks
it. Docblocks that said a store build can never mount the shell now say it
mounts only when built for OTA.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* ci(mobile): one workflow input picks the shell, and no input means native

Both release workflows gain a `shell` workflow_dispatch choice, options native
and ota, default native, and hand it to the step that bundles the JavaScript as
EXPO_PUBLIC_MOBILE_SHELL. That is the Gradle assembleRelease step on Android and
the fastlane build_and_upload step on iOS; nothing else in either file sets it.

A tag push and a schedule carry no inputs at all, so `inputs.shell || 'native'`
yields native for them — the first OTA release is a dispatch with one field
changed, and every other run is the app we ship today.

Each build step prints the value it is about to build with, read back from the
same variable rather than from a second copy of the expression, so a run's log
cannot claim a shell the build did not use.

The new contract test evaluates that expression rather than matching its text:
absent, empty and 'native' all resolve to native, 'ota' to ota, and any
expression shape it cannot evaluate is a failure rather than a pass.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* build: the desktop packages the real page, and the placeholder is retired

build:mobile-web now runs the app builder and app verifier, and both take their
output root from MOBILE_WEB_BUNDLE_DIR in the packaging guard rather than each
carrying a constant of their own — one definition of where the bundle lives, so
a drift cannot leave electron-builder's beforePack looking at an empty directory
while the builder reports a tree it wrote elsewhere. build:mobile-web:app is
gone; it was the same two commands.

src/mobile-web/ and its two scripts go with it. What the app builder shared with
them is split into three modules named for what they hold rather than for the
bundle that used to own them: mobile-web-bundle-manifest.mjs (content types, the
canonical asset serialization, buildId, hashed assets, the protocol window and
the manifest write), script-entry-detection.mjs (isDirectInvocation, whose two
failure modes are Windows paths and symlinked entries), and
mobile-web-source-line-endings.mjs (the CRLF guard, now with a required
directory rather than a default pointing at the deleted tree).

The two suites that only needed *a* valid tree on disk — the beforePack guard
and the packaged-bundle guard — build one from mobile-web-bundle-fixture-tree
instead of bundling the whole mobile graph. It goes through the same manifest
writer the page does, so a manifest shape change still reaches them.

Also retired: the placeholder's tsconfig project and its typecheck lane, its
knip entry, its electron-builder exclusion and .gitattributes pins, and the
app-bundle test that asserted the shims stayed out of a builder that no longer
exists. pr.yml's page job builds the same bundle the package job ships.

Inert for native phones: they never fetch it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(config): one import of node:fs/promises in the entry-detection suite

The changed-code quality gate's focused plugins read the two as a duplicate
import; the readFile line was left over from the split.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs: the comments that still describe the retired placeholder bundle

The web entry said it was built by `build:mobile-web:app` into out/mobile-web-app
and shipped by nothing. That script, that directory and that fact are all gone:
it is built by `build:mobile-web` into the packaged bundle dir, and a phone
mounts it only when the binary was built with EXPO_PUBLIC_MOBILE_SHELL=ota.

Two Windows cache keys explained themselves by naming src/mobile-web and "the
two bundle builders"; config/** now covers the builder, the verifier and the
manifest writer, and the spike's key no longer waits on a Phase C flip that has
happened. The keys themselves are unchanged.

Three scratch directories in the app-bundle suites and one in the verifier still
spelled the retired output root. Renamed to mobile-web, which is what the build
writes; they are temp subdirectory names and nothing reads them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 06:18:33 -04:00

284 lines
13 KiB
JavaScript

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import * as esbuild from 'esbuild'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { isDirectInvocation } from './script-entry-detection.mjs'
import { assertNoCarriageReturnsInSource } from './mobile-web-source-line-endings.mjs'
import {
MOBILE_WEB_BUNDLE_DIR as defaultBundleDir,
assertMobileWebBundleBuilt
} from './verify-packaged-mobile-web-bundle.cjs'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const manifestContract = join(
projectDir,
'src',
'shared',
'mobile-web-bundle',
'manifest-contract.ts'
)
/**
* The document, the route chunks and the images the route tree imports. Derived rather than
* pinned, because a flat number stops agreeing with the chunk ceiling as routes are added: at 128
* and 42 images, 18 routes are already allowed 98 chunks, and 98 + 42 + 1 is 141, so the asset
* count would have failed first and named the count rather than the split that caused it. Written
* as chunks + images + the document, a bundle at the chunk ceiling sits exactly at this one, so
* the chunk ceiling always trips first and the failure says what actually grew.
*/
export function mobileWebAppBundleMaxAssets(routeCount, imageCount) {
return mobileWebAppBundleMaxChunks(routeCount) + imageCount + 1
}
/**
* Phase C byte budget for the app bundle, not the contract ceiling (10 MiB per asset,
* MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES). Deliberately below it so growth trips a build rather than a
* refused asset on a phone. Splitting barely moves it — the same code is emitted in more files —
* so shrinking this still means cutting code.
*
* This head reads 7,686,714 bytes of the 9,437,184 here, 81.5%, leaving 1,750,470. A reading and
* not a pin: nothing asserts it, because the number moves with every build. It is here so the
* generation that spends the rest can see it was already this close.
*/
export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024
/**
* Scripts emitted at every prefix of the sorted route key list, and the route each prefix added.
*
* A chunk is emitted per distinct set of importers, not per route, so a route's marginal cost is
* what it fails to share rather than what it weighs. Re-measured on this head by building
* `routes.slice(0, n)` for every n, which is what the fence below is derived from rather than
* fitted to. The spread it shows is 1 to 9: `pr` and `web` add one script each, `review` adds nine.
*
* This table is the fence's only input, so a route added to the tree stales it and the pins beside
* the fence fail until it is re-measured. That is the point: the bound is re-derived, never bumped.
*/
export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [
['./h/[hostId]/[...page].tsx', 3],
['./h/[hostId]/accounts.tsx', 7],
['./h/[hostId]/agent-history/[worktreeId].tsx', 11],
['./h/[hostId]/edit.tsx', 16],
['./h/[hostId]/files/[worktreeId].tsx', 19],
['./h/[hostId]/files/preview/[worktreeId].tsx', 26],
['./h/[hostId]/history/[worktreeId].tsx', 28],
['./h/[hostId]/index.tsx', 33],
['./h/[hostId]/pr/[worktreeId].tsx', 34],
['./h/[hostId]/review/[worktreeId].tsx', 43],
['./h/[hostId]/session/[worktreeId].tsx', 51],
['./h/[hostId]/source-control/[worktreeId].tsx', 56],
['./h/[hostId]/tasks.tsx', 63],
['./h/[hostId]/web.tsx', 64],
['./h/_layout.tsx', 66]
]
const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts)
/** What each route after the first cost, which is the spread the envelope is an upper bound of. */
export const MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD = sweptScripts
.slice(1)
.map((scripts, index) => scripts - sweptScripts[index])
/**
* How far above the measurement the envelope sits, and the only slack a refactor gets.
*
* Measured, not chosen, and per head rather than cumulative: at 8, 10, 12 and 14 routes the head
* that wrote the old `4r + 16` read 32, 43, 61 and 69, the head that first swept these prefixes
* read 34, 44, 57 and 65, and this one reads 33, 43, 56 and 64. So one head has moved the count by
* as much as four at a fixed route count with no route added (61 to 57), and the step that dropped
* the page's second Zod moved it by one everywhere. Four is that worst step, which is what a shared
* importer set moving between heads costs. Summing the steps instead would grow this number every
* head and loosen the fence for free. A refactor inside four keeps building; anything past it
* re-measures the sweep.
*/
export const MOBILE_WEB_APP_BUNDLE_SCRIPT_MARGIN = 4
/**
* How many scripts the page may be cut into, for a given number of routes.
*
* Anchored on the sweep above rather than fitted to the route count, because the count is not a
* function of the route count alone: `4r + 16` was a guess at break-even and its slack ran from 17
* at one route to 4 at thirteen, so it was a near-miss exactly where the tree actually sits. This
* is the measurement plus the margin at the swept tree, growing by the worst route the sweep saw
* for every route past it — a route cannot breach it without costing more than any route measured.
*
* Flat below the swept length, which is the whole sweep's upper bound too: the count rises with the
* prefix, so one number above its top row is above every row. The fence is only ever asked about
* the real tree, and routes are only ever added.
*
* Two-sided in the test beside it. An envelope more than the margin above the build is a fence
* nobody re-derived, and it fails there rather than surviving as headroom for a bump.
*
* The route count stays the only term. A deferred engine belongs inside one artifact and costs one
* script: C7.10 item B first reached mermaid with `import('mermaid')`, which emitted 103 more
* because mermaid lazily imports each of its own diagram types, and a second term admitting those
* would have raised this fence far enough to admit any split at all. The build test's control is
* what holds that line.
*
* This is the ceiling that catches a split running away; MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES
* below is the one that catches it collapsing, and it is the real budget of the two.
*/
export function mobileWebAppBundleMaxChunks(routeCount) {
const beyondTheSweep = Math.max(0, routeCount - MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.length)
return (
sweptScripts.at(-1) +
MOBILE_WEB_APP_BUNDLE_SCRIPT_MARGIN +
Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD) * beyondTheSweep
)
}
/**
* What the browser must parse before the first route can paint: the entry plus every chunk it
* reaches by static import. This is the budget splitting exists to hold — it was 8.16 MB as one
* chunk and measures 1,244,312 bytes split on this head, 1.19 of the 3 MiB — so a route
* re-imported statically, or `splitting` dropped, fails the build here instead of arriving as a
* slow first open on a phone.
*
* It is not a per-route escape hatch. Re-measured here by making one route's manifest entry a
* static import and reading this same closure back: session alone breaks the bound at 3.32 MiB,
* and tasks at 2.17, source-control 2.04, review 2.03, index 1.89 and files/preview 1.85 each
* spend most of a budget that has to cover the entry as well. What keeps the hatch usable at all
* is that expo-router reads `unstable_settings` off layout nodes only, and the subtree's one
* layout, `h/_layout.tsx`, measures 1.89 MiB static. Any other route needing a synchronous export
* needs this number re-measured, not a static import.
*/
export const MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES = 3 * 1024 * 1024
/** Every tree whose bytes reach the buildId, so a CRLF checkout cannot fork it. */
export const MOBILE_WEB_APP_SOURCE_DIRS = [
join(projectDir, 'mobile', 'web-entry'),
join(projectDir, 'mobile', 'app'),
join(projectDir, 'mobile', 'src')
]
class VerificationError extends Error {}
function fail(message) {
throw new VerificationError(message)
}
/**
* How many assets the phone will accept, read from the contract rather than copied: the native
* shells hold their own 256 and refuse a larger manifest outright. Bundled through esbuild
* because node cannot resolve that module's extensionless TypeScript imports, so the number is
* evaluated from the contract and not parsed out of it.
*/
export async function readMobileWebBundleMaxAssets() {
const { outputFiles } = await esbuild.build({
entryPoints: [manifestContract],
bundle: true,
write: false,
format: 'esm',
platform: 'node',
logLevel: 'silent'
})
const source = Buffer.from(outputFiles[0].contents).toString('base64')
const { MOBILE_WEB_BUNDLE_MAX_ASSETS: ceiling } = await import(
`data:text/javascript;base64,${source}`
)
if (typeof ceiling !== 'number') {
fail(`${manifestContract} exports no MOBILE_WEB_BUNDLE_MAX_ASSETS to bound the build with`)
}
return ceiling
}
/**
* The derived ceiling is only a budget while it stays inside the map the phone can hold: the
* shells return null for a manifest over MOBILE_WEB_BUNDLE_MAX_ASSETS rather than dropping the
* extra assets, so a route count that pushes the chunk envelope plus images plus the document past
* it would pass this build and fail on the device with nothing to read. At today's 42 images that
* is 31 routes, inside what Phase C adds, which is why this is a build failure and not a comment.
* The envelope grants the worst swept route to each one past the sweep, so re-measuring a tree
* whose routes share more moves that crossing out again.
*/
export function assertAssetCeilingFitsShell(routeCount, imageCount, shellMaxAssets) {
const ceiling = mobileWebAppBundleMaxAssets(routeCount, imageCount)
if (ceiling > shellMaxAssets) {
fail(
`the ceiling derived for ${String(routeCount)} route(s) and ${String(imageCount)} image(s) ` +
`is ${String(ceiling)} assets, over the ${String(shellMaxAssets)} the shell will load`
)
}
return ceiling
}
async function buildIntoScratch() {
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-verify-'))
try {
return await buildMobileWebAppBundle({ outDir: join(scratch, 'mobile-web') })
} finally {
await rm(scratch, { recursive: true, force: true })
}
}
// bundleDir is a seam for the tests, which verify a scratch build; the script always verifies out/.
export async function verifyMobileWebAppBundle({ bundleDir = defaultBundleDir } = {}) {
for (const directory of MOBILE_WEB_APP_SOURCE_DIRS) {
await assertNoCarriageReturnsInSource(directory)
}
const manifest = assertMobileWebBundleBuilt(bundleDir)
if (manifest.totalBytes > MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES) {
fail(
`bundle is ${String(manifest.totalBytes)} bytes, over the Phase C budget of ` +
`${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)}`
)
}
const first = await buildIntoScratch()
const second = await buildIntoScratch()
if (first.manifest.buildId !== second.manifest.buildId) {
fail(`buildId is not reproducible: ${first.manifest.buildId} then ${second.manifest.buildId}`)
}
if (first.manifest.buildId !== manifest.buildId) {
fail(
`${bundleDir} is stale: it carries buildId ${manifest.buildId}, a fresh build produces ${first.manifest.buildId}`
)
}
// Read off the fresh build rather than the manifest: neither bound is a manifest field, and the
// buildId just proved this build is the one on disk.
// After the fresh build, which is what knows how many of the assets are images.
const maxAssets = assertAssetCeilingFitsShell(
first.routeKeys.length,
first.imageCount,
await readMobileWebBundleMaxAssets()
)
if (manifest.assets.length > maxAssets) {
fail(
`bundle has ${String(manifest.assets.length)} assets, over the Phase C budget of ` +
`${String(maxAssets)} for ${String(first.routeKeys.length)} route(s) and ` +
`${String(first.imageCount)} image(s)`
)
}
const maxChunks = mobileWebAppBundleMaxChunks(first.routeKeys.length)
if (first.chunkCount > maxChunks) {
fail(
`bundle is cut into ${String(first.chunkCount)} chunks, over the Phase C budget of ` +
`${String(maxChunks)} for ${String(first.routeKeys.length)} route(s)`
)
}
if (first.entryStaticBytes > MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES) {
fail(
`${String(first.entryStaticBytes)} bytes load before the first route, over the Phase C ` +
`budget of ${String(MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES)}`
)
}
return manifest
}
if (isDirectInvocation(import.meta.url, process.argv[1])) {
try {
const manifest = await verifyMobileWebAppBundle()
console.log(
`[verify-mobile-web-app-bundle] OK — ${String(manifest.assets.length)} asset(s), ` +
`${String(manifest.totalBytes)}/${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)} bytes, ` +
`reproducible buildId ${manifest.buildId}`
)
} catch (error) {
console.error(`[verify-mobile-web-app-bundle] ${error.message}`)
process.exit(1)
}
}