Files
orca/config/scripts/verify-mobile-web-app-bundle.mjs
T
Jinwoo Hong f47f632bd7 feat(mobile): Home's New Workspace opens the desktop's create drawer over Home (native part ships with the next app build) (#31305)
* fix(mobile): the home screen's New Workspace opens the create sheet again

The native host-list route dropped the home screen's action=newWorktree when it built
the page's route, so the page opened as a plain list. The route now forwards it, and
the page names it back once applied so a reload does not reopen the sheet.

Reaches phones only with the next native build.

* test(mobile): drop the New Workspace tests that only re-check types and a null guard

* fix(mobile): Home's New Workspace opens the create sheet over Home

Replaces the action=newWorktree route hand-off. The quick action picks a
host (as Tasks does), then mounts the host's create sheet on Home; a
create opens the new workspace's session marked as just created.

The sheet controller becomes a plain visible/onClose component, and the
host screen's route-action state, the action route param and its builder
are deleted.

* fix(mobile): Home's create sheet seeds only proven worktree rows

Home's cold-start seed is a pathless resume row that the name suggester
cannot read, so the sheet now seeds from proven rows and is keyed by host.
The host screen gets one setter for the sheet's visibility and its ref.

* test(mobile): name the Home new-workspace test after the screen it renders

* proto(mobile): Home's New Workspace opens a native drawer hosting the page's create form

Home pushes /new-workspace/<host>, a react-native-screens formSheet over Home whose content is
MobileWebShellScreen for the page route /h/<host>/new-workspace. The page renders the shared
Create Workspace form with its drawers inline, so the form and its pickers fill the native
drawer. The shell's loading, download and failure states render inside the drawer. Prototype only.

* proto(mobile): the New Workspace drawer is the host screen's own sheet over a transparent modal

The native route is a transparentModal hosting the shell over Home; the page route renders the
host screen's NewWorktreeModal on a transparent page, so the drawer is the same component. While
the page loads, the shell shows a placeholder of that sheet (scrim, grabber, spinner or download
bar), and the page's sheet opens in place without its slide. A host that cannot serve the page
(old desktop, blocked, failed) opens the host list instead of a wall in a drawer.

* feat(mobile): Home's New Workspace opens the page's create drawer over a dimmed Home

A transparent native overlay route hosts the host picker and then the page's
/h/[hostId]/new-workspace route, which renders the same NewWorktreeModal as the
host screen's +. The native scrim matches the drawer backdrop and the page's
first drawer opens already dimmed, so Home stays dimmed throughout. Old desktops
without the route fall back to the host list.

* fix(mobile): one loading layer for Home's New Workspace overlay

The overlay route owns a single tappable loading layer (spinner, or the
download on a small card) that stays mounted from the pick until the page
paints, so the fade and spinner no longer restart per shell state. The shell
reports its load and shows the host list for anything it cannot open; the
picker hands the pick over at once and keeps sliding away. The page's first
drawer takes the dimmed backdrop once, and a gate's overlay pending view no
longer stacks a second scrim over a mounted stack. Native /h/[hostId]/new-workspace
redirects to the overlay. Drops the unused session-route params, the
clipboard grant, and pass-through tests.

* feat(mobile): New Workspace loads in a short drawer over an undimmed Home

While the page loads, the overlay shows one short native drawer in the shared
drawer chrome with a spinner or the download, and nothing dims Home: a tap
above it, a drag down or Back cancels. The page's real drawer then slides up
with its own backdrop exactly as on the host screen's +. Drops the native
scrim, the loading card and the already-dimmed backdrop handoff; the overlay's
WebView stays invisible until the page paints, since a fresh one draws opaque.

* feat(mobile): the New Workspace loading drawer dims Home like any drawer

The short loading drawer opens with the shared drawer backdrop and show
duration, and a tap on that backdrop, a drag down or Back cancels. It stays
in the screen's own window (a native Modal over the loading WebView stalls
the page) and goes in the frame the page paints: the shell reports its load
from a layout effect so the WebView shows and the drawer leaves together.

* fix(mobile): the page's first paint waits for a drawer opening with it

A drawer opening as its page screen mounts holds that screen's first-paint
report until its open animation ends, through a hold on the existing report
rather than a new bridge message. The New Workspace overlay's loading drawer
now stays until the create drawer is up, so a cold open no longer shows Home
undimmed between the two.

* refactor(mobile): the New Workspace overlay owns its exits; the loading drawer fades out

The shell no longer imports the feature: an overlay is a shell with
onOverlayLoad, which reports `unavailable` for what it cannot show, and the
overlay route opens the host list for every exit. HostProtocolGate takes one
transparentWhilePending flag. The loading drawer's drag handler follows a
stable cancel, and on paint the drawer fades out eased in, as the shell's page
cover does, because the WebView takes a frame or two to show the page after it
is revealed; removing it outright left Home undimmed for those frames.

* Revert "fix(mobile): the page's first paint waits for a drawer opening with it"

This reverts commit be0913b9d5.

* fix(mobile): Home's host picker leaves when there is no desktop to pick

An unreadable host catalog, or offered desktops that are all gone, left the
picker invisible inside the transparent overlay, holding a screen over Home
with nothing to dismiss it. The catalog read now resolves to no hosts on
failure, and an empty pick list goes back to Home.

* fix(mobile): Home's create drawer names SSH hosts as the host screen does

The page drawer opened from Home passed no host labels, so a project on an SSH
host showed its run target by raw host id. Reading the SSH target summaries
and display overrides moves into one function that both the host screen's
metadata fetch and the page drawer call.
2026-10-21 20:02:11 -04:00

319 lines
14 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
// Only this licensed desktop-identical asset has its own allowance; code retains the 9 MiB fence.
export const MOBILE_WEB_APP_TERMINAL_FONT_MAX_BYTES = 1200 * 1024
const TERMINAL_FONT_SHA256 = '8efa6ba89f0a1f3eefde028f36aa64a13e36282e15ea0ca6929c664501037467'
const TERMINAL_FONT_BYTES = 1_177_916
export function mobileWebAppBundleBudgetBytes(manifest) {
const fonts = manifest.assets.filter(
(asset) => asset.path.endsWith('.woff2') || asset.contentType.startsWith('font/')
)
if (fonts.length > 1) {
fail('only one licensed terminal font may use the independent font allowance')
}
const font = fonts[0]
if (!font) {
return manifest.totalBytes
}
if (
font.path !== `assets/${TERMINAL_FONT_SHA256}.woff2` ||
font.sha256 !== TERMINAL_FONT_SHA256 ||
font.contentType !== 'font/woff2' ||
font.byteLength !== TERMINAL_FONT_BYTES ||
font.byteLength > MOBILE_WEB_APP_TERMINAL_FONT_MAX_BYTES
) {
fail(
'terminal font allowance requires the approved hash, path, MIME type and bounded byte length'
)
}
return manifest.totalBytes - font.byteLength
}
/**
* 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 14: `pr` adds one script, `tasks` adds fourteen.
* The root `./_layout.tsx` (the page's web sibling of the native root) sorts first; with it the
* swept tree reads 91 scripts at 16 routes, with theme tokens in the entry.
*
* 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 = [
['./_layout.tsx', 3],
['./h/[hostId]/[...page].tsx', 6],
['./h/[hostId]/accounts.tsx', 10],
['./h/[hostId]/agent-history/[worktreeId].tsx', 15],
['./h/[hostId]/edit.tsx', 20],
['./h/[hostId]/files/[worktreeId].tsx', 24],
['./h/[hostId]/files/preview/[worktreeId].tsx', 31],
['./h/[hostId]/history/[worktreeId].tsx', 34],
['./h/[hostId]/index.tsx', 42],
['./h/[hostId]/new-workspace.tsx', 49],
['./h/[hostId]/pr/[worktreeId].tsx', 50],
['./h/[hostId]/review/[worktreeId].tsx', 57],
['./h/[hostId]/session/[worktreeId].tsx', 69],
['./h/[hostId]/source-control/[worktreeId].tsx', 73],
['./h/[hostId]/tasks.tsx', 87],
['./h/_layout.tsx', 91]
]
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, and one refactor with no route added moved
* the 12-route prefix by four (61 to 57). Four is the worst step a refactor alone has cost. The
* sweep before this branch read 34, 40, 58 and 71 and this head reads 34, 40, 59 and 75: four at
* 14 routes, from main's own drift plus the server-workspace modules this head adds. A head
* between them read 77 there, six past that sweep, and re-measured rather than fitting inside the
* margin, which is what a step past four is meant to force.
* Summing the steps instead would grow this number every head and loosen the fence for free.
*/
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 28 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)
const budgetBytes = mobileWebAppBundleBudgetBytes(manifest)
if (budgetBytes > MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES) {
fail(
`bundle excluding the approved font is ${String(budgetBytes)} 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)} total bytes, ` +
`${String(mobileWebAppBundleBudgetBytes(manifest))}/${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)} bytes excluding the approved font, ` +
`reproducible buildId ${manifest.buildId}`
)
} catch (error) {
console.error(`[verify-mobile-web-app-bundle] ${error.message}`)
process.exit(1)
}
}