Files
orca/config/scripts/build-mobile-web-bundle.mjs
Jinwoo Hong 428558b941 fix(mobile): let the shell's page paint a file preview (OTA phase C, C3.0) (#21591)
* fix(mobile): let the shell's page paint a file preview

A file preview has one shape on the wire: the desktop answers a base64 body
and `normalizeMobileFilePreviewResult` composes `data:<mime>;base64,<content>`
for React Native Web's `Image`. Under `img-src 'self'` the browser refuses to
load it, so every image preview in the page paints nothing — reproduced in the
render check, which logged the refusal naming `img-src 'self'` before this.

`data:` is granted to images and to nothing else, so what it admits is what the
page itself composed out of a reply it already holds; `script-src 'self'` and
`connect-src 'self'` are untouched, and `blob:` is not added because nothing in
the closure needs one. Both platform pins narrow from "the header contains no
`data:`" to "`data:` appears on `img-src` and nowhere else", which is the check
that still fails if a later directive grows one.

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

* docs(mobile): say what the data: image case actually loads through

Round 1 is right on both counts. In react-native-web 0.21.2 the hidden <img>
the Image component renders carries `alt`, `style`, `draggable`, `ref` and
`src` and no load handlers at all — it is there for the browser's image context
menu and for `getBackgroundSize()`. The load signal comes from
`ImageLoader.load`, which is `new window.Image()` with `onload`/`onerror` on it,
so the `new Image()` in this case is the same mechanism the screen's own load
runs through rather than a stand-in for it.

And the screen maps `onImageError` to "Unable to load preview"
(`MobileFilePreviewScreen.tsx:282`); "Binary preview unavailable" is the
normalizer's `binary_file` branch, which a CSP refusal never reaches.

Comment only. No assertion moves.

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

* docs(mobile): state the img-src data: bound as the destination, not provenance

"admits only what the page itself built" read as a provenance guarantee, and CSP
has none to give: `data:` is matched as a scheme, so the directive admits any
`data:` image URL and the browser cannot tell one the page composed from one it
was handed. Nor is the content the page's own — the mime type and the base64
body both come from the host, and `normalizeImagePreviewResult` only checks the
mime type is a non-empty string.

The true bound is where the URL goes: it is never fetched as anything but an
image, `img-src` is the only directive admitting it, an image fetch executes
nothing (an SVG inside an `<img>` runs no script), and `script-src 'self'`,
`connect-src 'self'` and `object-src 'none'` are untouched.

Both copies reworded identically, since they are kept in step by the render
check's own policy comparison.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 02:36:39 -04:00

260 lines
9.2 KiB
JavaScript

import { createHash } from 'node:crypto'
import { realpathSync } from 'node:fs'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import * as esbuild from 'esbuild'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
const sourceDir = join(projectDir, 'src', 'mobile-web')
const defaultOutDir = join(projectDir, 'out', 'mobile-web')
export const MOBILE_WEB_BUNDLE_SCHEMA_VERSION = 1
export const MOBILE_WEB_BUNDLE_ENTRYPOINT = 'index.html'
const CONTENT_TYPE_BY_EXTENSION = {
css: 'text/css; charset=utf-8',
html: 'text/html; charset=utf-8',
js: 'text/javascript; charset=utf-8',
png: 'image/png',
// The Phase C app bundle emits images as same-origin assets rather than data: URLs, so each one
// is content-hashed and served from here. Fonts are absent by design: the policy sets
// font-src 'none'.
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml'
}
/**
* Canonical serialization the buildId hashes. Key order is fixed and the list is sorted by path,
* so the id is a pure function of content. Must stay byte-identical to the contract module's
* serializer in src/shared/mobile-web-bundle/.
*/
export function serializeMobileWebBundleAssets(assets) {
return JSON.stringify(
[...assets]
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
.map(({ path, sha256, byteLength, contentType }) => ({
path,
sha256,
byteLength,
contentType
}))
)
}
export function computeMobileWebBundleBuildId(assets) {
return createHash('sha256').update(serializeMobileWebBundleAssets(assets), 'utf8').digest('hex')
}
export function sha256Hex(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
export function contentTypeForExtension(extension) {
const contentType = CONTENT_TYPE_BY_EXTENSION[extension]
if (!contentType) {
throw new Error(`[build-mobile-web-bundle] no content type registered for .${extension}`)
}
return contentType
}
function readIntegerConstant(source, name) {
const match = new RegExp(`export const ${name} = (\\d+)`).exec(source)
if (!match) {
throw new Error(`[build-mobile-web-bundle] ${name} not found in src/shared/protocol-version.ts`)
}
return Number.parseInt(match[1], 10)
}
/**
* Parsed rather than imported because protocol-version.ts is TypeScript and this script runs on
* bare node during packaging, before any build output exists.
*/
export async function readProtocolWindow() {
const source = await readFile(join(projectDir, 'src', 'shared', 'protocol-version.ts'), 'utf8')
return {
runtimeProtocolVersion: readIntegerConstant(source, 'RUNTIME_PROTOCOL_VERSION'),
// The bundle is a client: the floor it cares about is the oldest host protocol it can talk to.
minCompatibleRuntimeProtocolVersion: readIntegerConstant(
source,
'MIN_COMPATIBLE_RUNTIME_SERVER_VERSION'
)
}
}
export async function readDesktopVersion() {
const packageJson = JSON.parse(await readFile(join(projectDir, 'package.json'), 'utf8'))
if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
throw new Error('[build-mobile-web-bundle] root package.json has no version')
}
return packageJson.version
}
async function transformEntries(protocolWindow, desktopVersion) {
const result = await esbuild.build({
absWorkingDir: sourceDir,
entryPoints: [join(sourceDir, 'src', 'bootstrap.ts'), join(sourceDir, 'src', 'bootstrap.css')],
bundle: true,
minify: true,
// Virtual: write is false, so outdir only names the emitted files esbuild hands back.
outdir: 'dist',
write: false,
format: 'iife',
target: ['es2022'],
charset: 'utf8',
legalComments: 'none',
// Why no sourcemap and no metafile: both embed absolute paths, which would break reproducibility.
sourcemap: false,
logLevel: 'silent',
define: {
ORCA_MOBILE_WEB_DESKTOP_VERSION: JSON.stringify(desktopVersion),
ORCA_MOBILE_WEB_RUNTIME_PROTOCOL_VERSION: JSON.stringify(
protocolWindow.runtimeProtocolVersion
),
ORCA_MOBILE_WEB_MIN_COMPATIBLE_RUNTIME_PROTOCOL_VERSION: JSON.stringify(
protocolWindow.minCompatibleRuntimeProtocolVersion
)
}
})
const byExtension = new Map()
for (const file of result.outputFiles) {
const extension = file.path.endsWith('.css') ? 'css' : 'js'
byExtension.set(extension, Buffer.from(file.contents))
}
const script = byExtension.get('js')
const stylesheet = byExtension.get('css')
if (!script || !stylesheet) {
throw new Error('[build-mobile-web-bundle] esbuild did not emit both a script and a stylesheet')
}
return { script, stylesheet }
}
export function hashedAsset(bytes, extension) {
const sha256 = sha256Hex(bytes)
return {
bytes,
path: `assets/${sha256}.${extension}`,
sha256,
byteLength: bytes.byteLength,
contentType: contentTypeForExtension(extension)
}
}
export async function buildMobileWebBundle({ outDir = defaultOutDir } = {}) {
const [desktopVersion, protocolWindow] = await Promise.all([
readDesktopVersion(),
readProtocolWindow()
])
const { script, stylesheet } = await transformEntries(protocolWindow, desktopVersion)
const mark = await readFile(join(sourceDir, 'src', 'orca-mark.png'))
const hashed = [
hashedAsset(script, 'js'),
hashedAsset(stylesheet, 'css'),
hashedAsset(mark, 'png')
]
const [scriptAsset, stylesheetAsset, markAsset] = hashed
const template = await readFile(join(sourceDir, MOBILE_WEB_BUNDLE_ENTRYPOINT), 'utf8')
const substitutions = {
__ORCA_BOOTSTRAP_JS__: scriptAsset.path,
__ORCA_BOOTSTRAP_CSS__: stylesheetAsset.path,
__ORCA_MARK_PNG__: markAsset.path
}
let html = template
for (const [token, value] of Object.entries(substitutions)) {
if (!html.includes(token)) {
throw new Error(`[build-mobile-web-bundle] ${MOBILE_WEB_BUNDLE_ENTRYPOINT} lacks ${token}`)
}
html = html.replaceAll(token, value)
}
const indexBytes = Buffer.from(html, 'utf8')
const indexAsset = {
bytes: indexBytes,
path: MOBILE_WEB_BUNDLE_ENTRYPOINT,
sha256: sha256Hex(indexBytes),
byteLength: indexBytes.byteLength,
contentType: contentTypeForExtension('html')
}
return writeMobileWebBundleTree({
outDir,
written: [indexAsset, ...hashed],
desktopVersion,
protocolWindow
})
}
/**
* Manifest assembly and the on-disk write, shared by the Phase A bootstrap bundle and the Phase C
* app bundle so both produce the same manifest shape the contract module and verifier read.
*/
export async function writeMobileWebBundleTree({
outDir,
written,
desktopVersion,
protocolWindow,
// Empty for the Phase A bootstrap, which carries no route tree at all: a shell reading it finds
// no screen listed and renders every route natively, which is what it already does.
routes = []
}) {
const assets = written
.map(({ path, sha256, byteLength, contentType }) => ({ path, sha256, byteLength, contentType }))
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
const manifest = {
schemaVersion: MOBILE_WEB_BUNDLE_SCHEMA_VERSION,
buildId: computeMobileWebBundleBuildId(assets),
desktopVersion,
minCompatibleRuntimeProtocolVersion: protocolWindow.minCompatibleRuntimeProtocolVersion,
runtimeProtocolVersion: protocolWindow.runtimeProtocolVersion,
entrypoint: MOBILE_WEB_BUNDLE_ENTRYPOINT,
totalBytes: assets.reduce((total, asset) => total + asset.byteLength, 0),
assets,
routes
}
// Why a full clear: a stale asset left from an earlier build would ship unreferenced inside asar.
await rm(outDir, { recursive: true, force: true })
await mkdir(join(outDir, 'assets'), { recursive: true })
for (const asset of written) {
await writeFile(join(outDir, asset.path), asset.bytes)
}
await writeFile(join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
return { manifest, outDir }
}
/**
* Whether this module was run as the entry script. Two ways to get this wrong, both of which end
* with the builder exiting 0 having written nothing: `file://${path}` never matches on Windows,
* where import.meta.url is `file:///C:/...`; and Node resolves symlinks in import.meta.url but not
* in argv[1], so `node /tmp/...` against a /private/tmp realpath compares two different strings.
* Both seams are injectable so win32 and a missing path can be exercised from a posix runner.
*/
export function isDirectInvocation(
moduleUrl,
scriptPath,
{ toFileUrl = pathToFileURL, realpath = realpathSync } = {}
) {
if (!scriptPath) {
return false
}
let resolved = scriptPath
try {
resolved = realpath(scriptPath)
} catch {
// A path that cannot be resolved cannot be this module; fall through to the literal compare.
}
return moduleUrl === toFileUrl(resolved).href
}
if (isDirectInvocation(import.meta.url, process.argv[1])) {
const { manifest, outDir } = await buildMobileWebBundle()
console.log(
`[build-mobile-web-bundle] OK — ${String(manifest.assets.length)} asset(s), ` +
`${String(manifest.totalBytes)} bytes, buildId ${manifest.buildId} -> ${outDir}`
)
}