mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* 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
260 lines
9.2 KiB
JavaScript
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}`
|
|
)
|
|
}
|