mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 08:02:35 +00:00
* feat(mobile): the page owns its safe area, like a native screen The shell reserved both system-bar strips outside the WebView and painted them bgBase, so every page screen showed a flat band above its header, sheet scrims stopped short of the status bar, and the dock floated above the gesture bar. For a page that declares `safe-area-insets` in `ready.accepts`, the shell now draws the WebView edge-to-edge and keeps only the keyboard strip off it. `init` carries the insets the view sits under (bottom 0 while the keyboard ends the view, top 0 under the update banner), and a move is re-sent over the existing route-update `init`. An older page keeps the reserved strips, since it has no reader for the insets. On the page, a root layout (`app/_layout.web.tsx`) feeds those insets to react-native-safe-area-context below ExpoRoot's env()-measuring provider. It also replaces expo-router's DefaultNavigator, an all-edges SafeAreaView that padded a second time. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web): re-measure the page pins for the root layout The page's route tree gained `./_layout.tsx` (its web sibling of the native root), so every pin that counts the tree moved: - Script sweep re-measured by building `routes.slice(0, n)` for each n. It reads 69 scripts at 16 routes, which matches the real build. The asset-ceiling crossing moves from 31 routes to 32. - Route closures now enter through both layouts. `entryNames` gains `[dir]` because `app/_layout` and `app/h/_layout` share a name. - Session closure pin 4216 -> 4219. The added modules are bridge-safe-area-insets, page-safe-area-provider and _layout.web. - The web-overrides allowlist names `app/_layout.web.tsx`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read safe-area ownership from the page-document state Review fixes on the page-owns-safe-area change. - Ownership is page-document state now. `page-ready` carries `accepts` beside `reports`, the patch sets `pageOwnsSafeArea` from `safe-area-insets`, and the session hook projects it like `backClaimed`. The screen's own per-session copy is gone. - Insets moves re-send `init` only to a page that declared `safe-area-insets`. A page that took route updates but not insets was sent a useless `init` on every keyboard show and hide. - The banner wrapper is gone. The root pads the status bar strip while the banner shows. - The shell session defaults the insets inline, with no predicate that mutated its argument. - The provider is folded into its single caller, `app/_layout.web.tsx`. The session closure pin reads 4218 (local 1032). - The screen tests share their module mocks, and the safe-area cases move to a suite of their own: owned page, banner, iOS and Android keyboard, and an older page that gets no re-init. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
287 lines
13 KiB
JavaScript
287 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.
|
|
* The root `./_layout.tsx` (the page's web sibling of the native root) sorts first; with it the
|
|
* swept tree reads 69 scripts at 16 routes, the old 15 read 67 on the same head.
|
|
*
|
|
* 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', 7],
|
|
['./h/[hostId]/accounts.tsx', 9],
|
|
['./h/[hostId]/agent-history/[worktreeId].tsx', 13],
|
|
['./h/[hostId]/edit.tsx', 18],
|
|
['./h/[hostId]/files/[worktreeId].tsx', 21],
|
|
['./h/[hostId]/files/preview/[worktreeId].tsx', 28],
|
|
['./h/[hostId]/history/[worktreeId].tsx', 30],
|
|
['./h/[hostId]/index.tsx', 35],
|
|
['./h/[hostId]/pr/[worktreeId].tsx', 36],
|
|
['./h/[hostId]/review/[worktreeId].tsx', 45],
|
|
['./h/[hostId]/session/[worktreeId].tsx', 54],
|
|
['./h/[hostId]/source-control/[worktreeId].tsx', 59],
|
|
['./h/[hostId]/tasks.tsx', 66],
|
|
['./h/[hostId]/web.tsx', 67],
|
|
['./h/_layout.tsx', 69]
|
|
]
|
|
|
|
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)
|
|
}
|
|
}
|