diff --git a/.gitattributes b/.gitattributes index 1b447a9189e..145c06043bd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -41,3 +41,10 @@ # Generated method->params catalog: compared byte-for-byte by # verify:rpc-params-catalog, so a CRLF checkout would fail the gate. /src/shared/rpc-contract/rpc-params-catalog.generated.ts linguist-generated=true text eol=lf +# Mobile web bundle source. Every text byte here is hashed into an asset digest and +# from there into buildId, so a CRLF checkout produces a different bundle id for the +# same commit (91af2897 vs 9d78435e). The PNG is -text because it must not be touched. +/src/mobile-web/index.html text eol=lf +/src/mobile-web/src/*.ts text eol=lf +/src/mobile-web/src/*.css text eol=lf +/src/mobile-web/src/*.png -text diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 27e14c930e3..e2112de521b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -780,6 +780,12 @@ jobs: - name: Project web client from renderer build run: pnpm run build:web-from-renderer + # Why here and not inside "Build package inputs": this job assembles packaging inputs step by + # step instead of calling build:release, and electron-builder's beforePack guard hard-fails + # without out/mobile-web. + - name: Build mobile web bundle + run: pnpm run build:mobile-web + - name: Build native components run: pnpm run build:native diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index c26d1b4858a..72efa691dbf 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -14,6 +14,10 @@ const { } = require('./packaged-runtime-node-modules.cjs') const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs') const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs') +const { + MOBILE_WEB_BUNDLE_DIR, + assertMobileWebBundleBuilt +} = require('./scripts/verify-packaged-mobile-web-bundle.cjs') const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') const { verifyPackagedWindowsNodePty @@ -177,6 +181,9 @@ module.exports = { // Why: these repo-only inputs are either bundled into out/ or copied via // extraResources. Shipping them in app.asar bloats the desktop bundle. '!src{,/**/*}', + // Redundant under !src above, kept explicit: the built bundle ships from out/mobile-web via the + // out rules exactly as out/web does, and the source tree must never be mistaken for it. + '!src/mobile-web{,/**/*}', '!config{,/**/*}', '!docs{,/**/*}', '!mobile{,/**/*}', @@ -289,8 +296,11 @@ module.exports = { verifyStaticAppImagePackage(file, arch) } }, - beforePack: (context) => { + // electron-builder calls this with the context alone. The second parameter is the bundle root, + // so a test can point the guard at a scratch bundle instead of needing the repo's out/ built. + beforePack: (context, mobileWebBundleDir = MOBILE_WEB_BUNDLE_DIR) => { assertPackagedNativeVariantsInstalled(context.electronPlatformName, context.arch) + assertMobileWebBundleBuilt(mobileWebBundleDir) }, afterPack: async (context) => { const resourcesDir = diff --git a/config/knip.json b/config/knip.json index 9af0b8d1a74..e5a014b32e1 100644 --- a/config/knip.json +++ b/config/knip.json @@ -16,6 +16,7 @@ "src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts", "src/main/agent-hooks/managed-agent-hook-controls.ts", "src/main/claude-accounts/keychain.ts", + "src/mobile-web/src/bootstrap.ts", "src/renderer/src/main.tsx", "src/renderer/src/popout.tsx", "src/renderer/src/web/main.tsx", diff --git a/config/scripts/build-mobile-web-bundle.mjs b/config/scripts/build-mobile-web-bundle.mjs new file mode 100644 index 00000000000..52957858784 --- /dev/null +++ b/config/scripts/build-mobile-web-bundle.mjs @@ -0,0 +1,230 @@ +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' +} + +/** + * 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') +} + +function sha256Hex(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +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. + */ +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' + ) + } +} + +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 } +} + +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') + } + + const written = [indexAsset, ...hashed] + 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 + } + + // 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}` + ) +} diff --git a/config/scripts/build-mobile-web-bundle.test.mjs b/config/scripts/build-mobile-web-bundle.test.mjs new file mode 100644 index 00000000000..ca2f74bd1d0 --- /dev/null +++ b/config/scripts/build-mobile-web-bundle.test.mjs @@ -0,0 +1,261 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + buildMobileWebBundle, + computeMobileWebBundleBuildId, + isDirectInvocation, + serializeMobileWebBundleAssets +} from './build-mobile-web-bundle.mjs' +import { + MOBILE_WEB_BUNDLE_PHASE_A_MAX_ASSETS, + MOBILE_WEB_BUNDLE_PHASE_A_MAX_TOTAL_BYTES, + assertNoCarriageReturnsInSource +} from './verify-mobile-web-bundle.mjs' + +async function buildIntoScratch() { + const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-build-')) + const bundleDir = join(scratch, 'mobile-web') + const { manifest } = await buildMobileWebBundle({ outDir: bundleDir }) + return { scratch, bundleDir, manifest } +} + +describe('buildMobileWebBundle', () => { + it('emits a content-addressed bundle whose only stable name is the entrypoint', async () => { + const { scratch, bundleDir, manifest } = await buildIntoScratch() + try { + const root = await readdir(bundleDir) + expect(root.sort()).toEqual(['assets', 'index.html', 'manifest.json']) + for (const name of await readdir(join(bundleDir, 'assets'))) { + const [digest, extension] = name.split('.') + expect(digest).toMatch(/^[0-9a-f]{64}$/) + const bytes = await readFile(join(bundleDir, 'assets', name)) + expect(createHash('sha256').update(bytes).digest('hex')).toBe(digest) + expect(extension).toMatch(/^(js|css|png)$/) + } + const html = await readFile(join(bundleDir, 'index.html'), 'utf8') + for (const asset of manifest.assets) { + if (asset.path !== 'index.html') { + expect(html).toContain(asset.path) + } + } + expect(html).not.toContain('__ORCA_') + } finally { + await rm(scratch, { recursive: true, force: true }) + } + }) + + it('carries every manifest field the Phase A contract names', async () => { + const { scratch, manifest } = await buildIntoScratch() + try { + expect(Object.keys(manifest)).toEqual([ + 'schemaVersion', + 'buildId', + 'desktopVersion', + 'minCompatibleRuntimeProtocolVersion', + 'runtimeProtocolVersion', + 'entrypoint', + 'totalBytes', + 'assets' + ]) + expect(manifest.schemaVersion).toBe(1) + expect(manifest.entrypoint).toBe('index.html') + const packageJson = JSON.parse( + await readFile(new URL('../../package.json', import.meta.url), 'utf8') + ) + expect(manifest.desktopVersion).toBe(packageJson.version) + const protocolSource = await readFile( + new URL('../../src/shared/protocol-version.ts', import.meta.url), + 'utf8' + ) + expect(protocolSource).toContain( + `export const RUNTIME_PROTOCOL_VERSION = ${String(manifest.runtimeProtocolVersion)}` + ) + expect(protocolSource).toContain( + `export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = ${String(manifest.minCompatibleRuntimeProtocolVersion)}` + ) + expect(manifest.totalBytes).toBe( + manifest.assets.reduce((total, asset) => total + asset.byteLength, 0) + ) + } finally { + await rm(scratch, { recursive: true, force: true }) + } + }) + + it('produces the same buildId from two independent builds', async () => { + const first = await buildIntoScratch() + const second = await buildIntoScratch() + try { + expect(second.manifest.buildId).toBe(first.manifest.buildId) + expect(second.manifest).toEqual(first.manifest) + } finally { + await rm(first.scratch, { recursive: true, force: true }) + await rm(second.scratch, { recursive: true, force: true }) + } + }) + + it('embeds no absolute path from the machine that built it', async () => { + const { scratch, bundleDir } = await buildIntoScratch() + try { + const names = [ + 'index.html', + 'manifest.json', + ...(await readdir(join(bundleDir, 'assets'))).map((name) => join('assets', name)) + ] + for (const name of names) { + const text = (await readFile(join(bundleDir, name))).toString('latin1') + expect(text).not.toContain(scratch) + expect(text).not.toContain(process.cwd()) + } + } finally { + await rm(scratch, { recursive: true, force: true }) + } + }) + + it('stays inside the Phase A budget', async () => { + const { scratch, manifest } = await buildIntoScratch() + try { + expect(manifest.assets.length).toBeLessThanOrEqual(MOBILE_WEB_BUNDLE_PHASE_A_MAX_ASSETS) + expect(manifest.totalBytes).toBeLessThanOrEqual(MOBILE_WEB_BUNDLE_PHASE_A_MAX_TOTAL_BYTES) + } finally { + await rm(scratch, { recursive: true, force: true }) + } + }) +}) + +describe('computeMobileWebBundleBuildId', () => { + const assets = [ + { path: 'index.html', sha256: 'a'.repeat(64), byteLength: 3, contentType: 'text/html' }, + { path: 'assets/b.js', sha256: 'b'.repeat(64), byteLength: 5, contentType: 'text/javascript' } + ] + + it('sorts by path, so input order cannot change the id', () => { + expect(computeMobileWebBundleBuildId(assets.toReversed())).toBe( + computeMobileWebBundleBuildId(assets) + ) + }) + + it('serializes a fixed key order regardless of the input object key order', () => { + const reordered = assets.map(({ contentType, byteLength, sha256, path }) => ({ + contentType, + byteLength, + sha256, + path + })) + expect(serializeMobileWebBundleAssets(reordered)).toBe(serializeMobileWebBundleAssets(assets)) + }) + + it('changes when any hashed field changes', () => { + const baseline = computeMobileWebBundleBuildId(assets) + for (const field of ['sha256', 'byteLength', 'contentType', 'path']) { + const mutated = assets.map((asset, index) => + index === 0 ? { ...asset, [field]: field === 'byteLength' ? 4 : `${asset[field]}x` } : asset + ) + expect(computeMobileWebBundleBuildId(mutated)).not.toBe(baseline) + } + }) +}) + +describe('isDirectInvocation', () => { + const thisFile = import.meta.filename + + it('matches the path this module was loaded from', () => { + expect(isDirectInvocation(import.meta.url, thisFile)).toBe(true) + }) + + it('does not match a different script', () => { + expect(isDirectInvocation(import.meta.url, join(thisFile, '..', 'other.mjs'))).toBe(false) + }) + + it('tolerates an absent argv[1]', () => { + expect(isDirectInvocation(import.meta.url, undefined)).toBe(false) + expect(isDirectInvocation(import.meta.url, '')).toBe(false) + }) + + // Why an injected converter: a win32 path cannot be exercised through node:url's pathToFileURL + // on a posix runner, and CI is ubuntu. + const toWin32FileUrl = (windowsPath) => new URL(`file:///${windowsPath.replaceAll('\\', '/')}`) + + it('matches a Windows entry path, which the file:// template form never does', () => { + const scriptPath = 'C:\\orca\\config\\scripts\\build-mobile-web-bundle.mjs' + const moduleUrl = 'file:///C:/orca/config/scripts/build-mobile-web-bundle.mjs' + const keepAsIs = (path) => path + expect( + isDirectInvocation(moduleUrl, scriptPath, { + toFileUrl: toWin32FileUrl, + realpath: keepAsIs + }) + ).toBe(true) + // The regression this guards: `file://${argv[1]}` yields file://C:\orca\... on Windows, + // so the builder exited 0 having written nothing and packaging failed downstream. + expect(`file://${scriptPath}`).not.toBe(moduleUrl) + }) + + it('is not written with the file:// template form', async () => { + const source = await readFile(new URL('./build-mobile-web-bundle.mjs', import.meta.url), 'utf8') + expect(source).not.toMatch(/file:\/\/\$\{process\.argv\[1\]\}/) + expect(source).toContain('pathToFileURL') + }) +}) + +describe('mobile web source line endings', () => { + it('accepts the committed source tree', async () => { + await expect(assertNoCarriageReturnsInSource()).resolves.toBeUndefined() + }) + + it('rejects a CRLF source file, because CRLF changes every asset hash and the buildId', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-eol-')) + try { + await writeFile(join(scratch, 'bootstrap.ts'), 'const a = 1\r\nconst b = 2\r\n', 'utf8') + await expect(assertNoCarriageReturnsInSource(scratch)).rejects.toThrow( + /CRLF in mobile web source/ + ) + } finally { + await rm(scratch, { recursive: true, force: true }) + } + }) + + it('pins eol=lf for every committed text source and -text for the binary', () => { + const files = execFileSync('git', ['ls-files', 'src/mobile-web'], { encoding: 'utf8' }) + .split('\n') + .filter(Boolean) + expect(files.length).toBeGreaterThanOrEqual(4) + for (const file of files) { + const attributes = execFileSync('git', ['check-attr', 'text', 'eol', '--', file], { + encoding: 'utf8' + }) + if (file.endsWith('.png')) { + expect(attributes).toContain('text: unset') + } else { + expect(attributes).toContain('eol: lf') + } + } + }) +}) + +describe('running the builder through a symlink', () => { + // Node resolves symlinks in import.meta.url but not in argv[1]. Before the guard realpath'd the + // entry path, `node /tmp/` compared /tmp against /private/tmp and the builder exited 0 + // having written nothing — a green packaging job with no bundle in it. + it('still recognises the entry module', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-link-')) + try { + const builderUrl = new URL('./build-mobile-web-bundle.mjs', import.meta.url).href + const real = join(scratch, 'entry.mjs') + await writeFile( + real, + `import { isDirectInvocation } from ${JSON.stringify(builderUrl)}\n` + + 'process.stdout.write(String(isDirectInvocation(import.meta.url, process.argv[1])))\n', + 'utf8' + ) + const link = join(scratch, 'entry-link.mjs') + await symlink(real, link) + expect(execFileSync(process.execPath, [link], { encoding: 'utf8' })).toBe('true') + } finally { + await rm(scratch, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index b7d5f16400b..f88967e5132 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -3,7 +3,8 @@ import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildMobileWebBundle } from './build-mobile-web-bundle.mjs' const REPO_ROOT = join(import.meta.dirname, '..', '..') const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main') @@ -442,8 +443,23 @@ describe('arch-aware packaging guard', () => { const OTHER_ARCH_NAME = process.arch === 'arm64' ? 'x64' : 'arm64' const SHERPA_PLATFORM = process.platform === 'win32' ? 'win' : process.platform const otherSherpa = `sherpa-onnx-${SHERPA_PLATFORM}-${OTHER_ARCH_NAME}` + + // beforePack also hash-verifies the mobile web bundle, which the unit-test job never builds. + // Point it at a real bundle built into a temp dir: these tests are about the native-variant + // guard, and the bundle guard has its own suite. + let scratch + let bundleDir + beforeAll(async () => { + scratch = await mkdtemp(join(tmpdir(), 'orca-electron-builder-guard-')) + bundleDir = join(scratch, 'mobile-web') + await buildMobileWebBundle({ outDir: bundleDir }) + }) + afterAll(async () => { + await rm(scratch, { recursive: true, force: true }) + }) + const packHost = (arch) => - electronBuilderConfig.beforePack({ electronPlatformName: process.platform, arch }) + electronBuilderConfig.beforePack({ electronPlatformName: process.platform, arch }, bundleDir) it('allows packaging the host platform and architecture', () => { expect(() => packHost(HOST_ARCH)).not.toThrow() @@ -471,7 +487,7 @@ describe('arch-aware packaging guard', () => { (resource) => resource.to === join('node_modules', '@vscode', 'windows-process-tree') ) const packWindows = () => - electronBuilderConfig.beforePack({ electronPlatformName: 'win32', arch: 1 }) + electronBuilderConfig.beforePack({ electronPlatformName: 'win32', arch: 1 }, bundleDir) if (process.platform === 'win32' || windowsAddon) { expect(packWindows).not.toThrow() } else { diff --git a/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs b/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs new file mode 100644 index 00000000000..203bea1d87e --- /dev/null +++ b/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs @@ -0,0 +1,159 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { parseDocument } from 'yaml' + +const workflowsDir = fileURLToPath(new URL('../../.github/workflows', import.meta.url)) + +// Every script whose chain reaches build:mobile-web. build:unpack -> build -> build:desktop, and +// build:mac/linux/win each call build:desktop, so all of them produce out/mobile-web. The chain +// itself is not an assumption here: 'the build scripts' below resolves each one for real. +const BUNDLE_PRODUCING_SCRIPTS = [ + 'build', + 'build:desktop', + 'build:release', + 'build:release:parallel', + 'build:unpack', + 'build:mobile-web', + 'build:mac', + 'build:mac:release', + 'build:linux', + 'build:win' +] + +const BUNDLE_PRODUCER = new RegExp( + `pnpm (?:run )?(?:${BUNDLE_PRODUCING_SCRIPTS.join('|')})(?=$|[\\s'"&|;])`, + 'm' +) + +const packageScripts = JSON.parse( + readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8') +).scripts + +const SCRIPT_INVOCATION = /pnpm (?:run )?([\w:-]+)(?=$|[\s'"&|;])/g + +/** Whether `pnpm run ` eventually runs build:mobile-web. */ +function reachesBundleBuild(name, seen = new Set()) { + if (name === 'build:mobile-web') { + return true + } + if (seen.has(name)) { + return false + } + seen.add(name) + const body = packageScripts[name] + if (typeof body !== 'string') { + return false + } + return [...body.matchAll(SCRIPT_INVOCATION)].some((match) => reachesBundleBuild(match[1], seen)) +} + +/** + * Whether `pnpm run ` eventually runs electron-builder without --prepackaged, i.e. runs + * beforePack. A workflow job that packs through such a script is a packaging job even though the + * literal electron-builder line lives in package.json (daemon-relocation-spike's build:unpack). + */ +function reachesElectronBuilder(name, seen = new Set()) { + if (seen.has(name)) { + return false + } + seen.add(name) + const body = packageScripts[name] + if (typeof body !== 'string') { + return false + } + if (packsWithBeforePack(body)) { + return true + } + return [...body.matchAll(SCRIPT_INVOCATION)].some((match) => + reachesElectronBuilder(match[1], seen) + ) +} + +/** Whether text invokes electron-builder in a way that reaches beforePack. */ +function packsWithBeforePack(text) { + const invocations = [...text.matchAll(/[^\n]*electron-builder --config[^\n]*/g)].map( + (match) => match[0] + ) + // --prepackaged short-circuits doPack before emitBeforePack, so those jobs never run the guard. + return ( + invocations.length > 0 && + !invocations.every((invocation) => invocation.includes('--prepackaged')) + ) +} + +// Every job that packs an app and therefore runs beforePack. Listed so that a new packaging +// workflow has to be added here deliberately, with its bundle step, rather than slipping in. +const EXPECTED_PACKAGING_JOBS = [ + 'adhoc-mac-build.yml build-adhoc-mac', + 'daemon-relocation-spike.yml spike', + 'daily-mac-build.yml build-daily-mac', + 'dev-channel-win-build.yml build-win', + 'hourly-mac-build.yml build-hourly-mac', + 'pr.yml package', + 'pr.yml package_windows', + 'release-cut.yml build', + 'release-mac-build.yml build-mac', + 'win-crash-survival-e2e.yml crash-survival', + 'win-update-survival-e2e.yml survival', + 'windows-signing-rehearsal.yml rehearse' +] + +/** + * Raw source text per job, sliced by the parsed job boundaries. Why not yaml.stringify(job): + * re-serializing folds long lines, and the fold in dev-channel-win-build's build-win landed + * between `electron-builder` and `--config`, hiding a whole packaging job from this census. + */ +function packagingJobs() { + const jobs = [] + for (const file of readdirSync(workflowsDir).filter((name) => name.endsWith('.yml'))) { + const source = readFileSync(join(workflowsDir, file), 'utf8') + const jobsNode = parseDocument(source).get('jobs', true) + const items = jobsNode?.items ?? [] + for (const [index, pair] of items.entries()) { + const end = index + 1 < items.length ? items[index + 1].key.range[0] : jobsNode.range[2] + const text = source.slice(pair.key.range[0], end) + const packsViaScript = [...text.matchAll(SCRIPT_INVOCATION)].some((match) => + reachesElectronBuilder(match[1]) + ) + if (!packsWithBeforePack(text) && !packsViaScript) { + continue + } + jobs.push({ label: `${file} ${String(pair.key.value)}`, text }) + } + } + return jobs +} + +describe('mobile web bundle packaging coverage', () => { + it('finds every packaging job', () => { + // A rename or a restructure that shrank this list would make every assertion below vacuous. + const labels = packagingJobs().map((job) => job.label) + expect(labels.length).toBeGreaterThanOrEqual(EXPECTED_PACKAGING_JOBS.length) + expect(labels.toSorted()).toEqual(EXPECTED_PACKAGING_JOBS.toSorted()) + }) + + it.each(packagingJobs().map((job) => [job.label, job]))( + 'produces out/mobile-web before electron-builder packs: %s', + (_label, job) => { + // Job granularity, not step ordering: the failure this exists for is a job that never builds + // the bundle at all, which is what beforePack turns into a hard packaging failure. + expect(job.text).toMatch(BUNDLE_PRODUCER) + } + ) +}) + +describe('the build scripts the census trusts', () => { + // The census only checks that a packaging job invokes one of these. If a chain stopped calling + // build:mobile-web, every job would still look covered while packaging failed at beforePack. + it.each(BUNDLE_PRODUCING_SCRIPTS)('%s runs build:mobile-web', (name) => { + expect(packageScripts[name]).toBeTypeOf('string') + expect(reachesBundleBuild(name)).toBe(true) + }) + + it('pr.yml package builds the bundle by hand, because it never calls build:release', () => { + const source = readFileSync(join(workflowsDir, 'pr.yml'), 'utf8') + expect(source).toMatch(/- name: Build mobile web bundle\n\s+run: pnpm run build:mobile-web\n/) + }) +}) diff --git a/config/scripts/run-typecheck-projects-in-parallel.mjs b/config/scripts/run-typecheck-projects-in-parallel.mjs index 13047989db5..09b0a8e1d1f 100644 --- a/config/scripts/run-typecheck-projects-in-parallel.mjs +++ b/config/scripts/run-typecheck-projects-in-parallel.mjs @@ -2,9 +2,14 @@ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { fileURLToPath } from 'node:url' -// The three projects overlap heavily in src/shared but have no build dependency on +// These projects overlap heavily in src/shared but have no build dependency on // each other, so tsc can check them concurrently instead of in a `&&` chain. -const projects = ['tsconfig.node.json', 'tsconfig.tc.cli.json', 'tsconfig.tc.web.json'] +const projects = [ + 'tsconfig.node.json', + 'tsconfig.tc.cli.json', + 'tsconfig.tc.web.json', + 'tsconfig.mobile-web.json' +] const repoRoot = fileURLToPath(new URL('../..', import.meta.url)) const tsc = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url)) diff --git a/config/scripts/verify-mobile-web-bundle.mjs b/config/scripts/verify-mobile-web-bundle.mjs new file mode 100644 index 00000000000..0fedaac11c1 --- /dev/null +++ b/config/scripts/verify-mobile-web-bundle.mjs @@ -0,0 +1,117 @@ +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { buildMobileWebBundle, isDirectInvocation } from './build-mobile-web-bundle.mjs' +import { assertMobileWebBundleBuilt } from './verify-packaged-mobile-web-bundle.cjs' + +const projectDir = fileURLToPath(new URL('../..', import.meta.url)) +const bundleDir = join(projectDir, 'out', 'mobile-web') +const sourceDir = join(projectDir, 'src', 'mobile-web') + +// Phase A budget, not the contract ceiling: a bootstrap page past a quarter-megabyte has stopped +// being a bootstrap. Phase C raises these deliberately. +export const MOBILE_WEB_BUNDLE_PHASE_A_MAX_ASSETS = 16 +export const MOBILE_WEB_BUNDLE_PHASE_A_MAX_TOTAL_BYTES = 256 * 1024 + +class VerificationError extends Error {} + +function fail(message) { + throw new VerificationError(message) +} + +async function buildIntoScratch() { + const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-verify-')) + try { + const { manifest } = await buildMobileWebBundle({ outDir: join(scratch, 'mobile-web') }) + return manifest + } finally { + await rm(scratch, { recursive: true, force: true }) + } +} + +async function listSourceFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + const files = [] + for (const entry of entries) { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + files.push(...(await listSourceFiles(entryPath))) + } else if (entry.isFile()) { + files.push(entryPath) + } + } + return files.sort() +} + +/** + * A CRLF checkout changes the bytes of every text source, which changes every asset hash and so + * the buildId. .gitattributes pins eol=lf; this is what notices when that pin stops working. + */ +export async function assertNoCarriageReturnsInSource(directory = sourceDir) { + const offenders = [] + for (const file of await listSourceFiles(directory)) { + // Binary assets are pinned -text and may legitimately contain 0x0d. + if (file.endsWith('.png')) { + continue + } + if ((await readFile(file)).includes(0x0d)) { + offenders.push(file.slice(directory.length + 1)) + } + } + if (offenders.length > 0) { + fail( + `CRLF in mobile web source, which would change every asset hash and the buildId: ` + + `${offenders.join(', ')}. Check the .gitattributes eol=lf pin for src/mobile-web.` + ) + } +} + +export async function verifyMobileWebBundle() { + await assertNoCarriageReturnsInSource() + + // The packaging guard owns manifest integrity (safe paths, recomputed buildId, totalBytes, hashes, + // no stray files); a manifest edited after the build fails here exactly as it would at beforePack. + const manifest = assertMobileWebBundleBuilt(bundleDir) + + if (manifest.assets.length > MOBILE_WEB_BUNDLE_PHASE_A_MAX_ASSETS) { + fail( + `bundle has ${String(manifest.assets.length)} assets, over the Phase A budget of ` + + `${String(MOBILE_WEB_BUNDLE_PHASE_A_MAX_ASSETS)}` + ) + } + if (manifest.totalBytes > MOBILE_WEB_BUNDLE_PHASE_A_MAX_TOTAL_BYTES) { + fail( + `bundle is ${String(manifest.totalBytes)} bytes, over the Phase A budget of ` + + `${String(MOBILE_WEB_BUNDLE_PHASE_A_MAX_TOTAL_BYTES)}` + ) + } + + // Two fresh builds into scratch dirs: a timestamp, an absolute path, or an unstable ordering + // anywhere in the pipeline shows up here as a buildId mismatch rather than as a phone cache miss. + const first = await buildIntoScratch() + const second = await buildIntoScratch() + if (first.buildId !== second.buildId) { + fail(`buildId is not reproducible: ${first.buildId} then ${second.buildId}`) + } + if (first.buildId !== manifest.buildId) { + fail( + `${bundleDir} is stale: it carries buildId ${manifest.buildId}, a fresh build produces ${first.buildId}` + ) + } + return manifest +} + +if (isDirectInvocation(import.meta.url, process.argv[1])) { + try { + const manifest = await verifyMobileWebBundle() + console.log( + `[verify-mobile-web-bundle] OK — ${String(manifest.assets.length)} asset(s), ` + + `${String(manifest.totalBytes)}/${String(MOBILE_WEB_BUNDLE_PHASE_A_MAX_TOTAL_BYTES)} bytes, ` + + `reproducible buildId ${manifest.buildId}` + ) + } catch (error) { + console.error(`[verify-mobile-web-bundle] ${error.message}`) + process.exit(1) + } +} diff --git a/config/scripts/verify-packaged-mobile-web-bundle.cjs b/config/scripts/verify-packaged-mobile-web-bundle.cjs new file mode 100644 index 00000000000..13cf71b008b --- /dev/null +++ b/config/scripts/verify-packaged-mobile-web-bundle.cjs @@ -0,0 +1,191 @@ +const { createHash } = require('node:crypto') +const { readFileSync, readdirSync, statSync } = require('node:fs') +const { join, resolve } = require('node:path') + +const projectDir = resolve(__dirname, '..', '..') +const MOBILE_WEB_BUNDLE_DIR = join(projectDir, 'out', 'mobile-web') +const REMEDY = 'Run pnpm build:mobile-web (build:desktop and build:release already do).' +const ENTRYPOINT = 'index.html' +const SHA256_PATTERN = /^[0-9a-f]{64}$/ + +function failure(message) { + return new Error(`[verify-packaged-mobile-web-bundle] ${message}`) +} + +function assertSafeRelativePath(path) { + if (typeof path !== 'string' || path.length === 0) { + throw failure('manifest asset has a missing or empty path') + } + const segments = path.split('/') + if ( + path.includes('\\') || + path.startsWith('/') || + /^[a-zA-Z]:/.test(path) || + segments.some((segment) => segment === '' || segment === '.' || segment === '..') + ) { + throw failure(`manifest asset path is not a safe relative path: ${path}`) + } +} + +function assertInteger(value, field) { + if (!Number.isSafeInteger(value) || value < 0) { + throw failure(`manifest field ${field} is not a non-negative integer: ${String(value)}`) + } +} + +/** + * Canonical serialization of the asset list. Must stay byte-identical to + * serializeMobileWebBundleAssets in config/scripts/build-mobile-web-bundle.mjs; a divergence here + * would reject every honest bundle, so the two move together. + */ +function serializeAssets(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 + })) + ) +} + +function parseManifest(bundleDir) { + const manifestPath = join(bundleDir, 'manifest.json') + let raw + try { + raw = readFileSync(manifestPath, 'utf8') + } catch (error) { + throw failure( + `no bundle manifest at ${manifestPath} (${error.code ?? error.message}). ${REMEDY}` + ) + } + let manifest + try { + manifest = JSON.parse(raw) + } catch (error) { + throw failure(`${manifestPath} is not valid JSON: ${error.message}. ${REMEDY}`) + } + if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) { + throw failure(`${manifestPath} is not a JSON object. ${REMEDY}`) + } + if (manifest.schemaVersion !== 1) { + throw failure(`unsupported manifest schemaVersion: ${String(manifest.schemaVersion)}`) + } + if (typeof manifest.buildId !== 'string' || !SHA256_PATTERN.test(manifest.buildId)) { + throw failure(`manifest buildId is not a sha256 digest: ${String(manifest.buildId)}`) + } + if (typeof manifest.desktopVersion !== 'string' || manifest.desktopVersion.length === 0) { + throw failure('manifest desktopVersion is missing') + } + assertInteger(manifest.minCompatibleRuntimeProtocolVersion, 'minCompatibleRuntimeProtocolVersion') + assertInteger(manifest.runtimeProtocolVersion, 'runtimeProtocolVersion') + assertInteger(manifest.totalBytes, 'totalBytes') + if (manifest.entrypoint !== ENTRYPOINT) { + throw failure(`manifest entrypoint must be ${ENTRYPOINT}, got ${String(manifest.entrypoint)}`) + } + if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) { + throw failure('manifest lists no assets') + } + for (const asset of manifest.assets) { + if (typeof asset !== 'object' || asset === null) { + throw failure('manifest asset entry is not an object') + } + assertSafeRelativePath(asset.path) + if (typeof asset.sha256 !== 'string' || !SHA256_PATTERN.test(asset.sha256)) { + throw failure(`manifest asset ${asset.path} has no sha256 digest`) + } + assertInteger(asset.byteLength, `assets[${asset.path}].byteLength`) + if (typeof asset.contentType !== 'string' || asset.contentType.length === 0) { + throw failure(`manifest asset ${asset.path} has no contentType`) + } + } + if (!manifest.assets.some((asset) => asset.path === manifest.entrypoint)) { + throw failure(`manifest entrypoint ${manifest.entrypoint} is not one of its assets`) + } + const declaredTotal = manifest.assets.reduce((total, asset) => total + asset.byteLength, 0) + if (declaredTotal !== manifest.totalBytes) { + throw failure( + `manifest totalBytes is ${String(manifest.totalBytes)}, its assets sum to ${String(declaredTotal)}` + ) + } + const recomputed = createHash('sha256') + .update(serializeAssets(manifest.assets), 'utf8') + .digest('hex') + if (recomputed !== manifest.buildId) { + throw failure( + `manifest buildId ${manifest.buildId} does not match its asset list (expected ${recomputed}). ${REMEDY}` + ) + } + return manifest +} + +/** Every file under the bundle directory, as a manifest-shaped relative path. */ +function listBundleFiles(directory, prefix = '') { + const found = [] + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relativePath = prefix === '' ? entry.name : `${prefix}/${entry.name}` + if (entry.isDirectory()) { + found.push(...listBundleFiles(join(directory, entry.name), relativePath)) + } else { + found.push(relativePath) + } + } + return found +} + +/** + * Nothing in the bundle directory may be unaccounted for. An asset dropped from the manifest but + * left on disk by an interrupted build ships inside asar, unreachable and unverified, and grows + * the installer; content-addressed names mean stale copies never get overwritten. + */ +function assertNoUnlistedFiles(bundleDir, manifest) { + const listed = new Set(['manifest.json', ...manifest.assets.map((asset) => asset.path)]) + const strays = listBundleFiles(bundleDir).filter((path) => !listed.has(path)) + if (strays.length > 0) { + throw failure( + `${bundleDir} holds ${String(strays.length)} file(s) the manifest does not list: ` + + `${strays.sort().join(', ')}. ${REMEDY}` + ) + } +} + +/** + * Packaging guard: electron-builder only warns about a missing input, so without this a release + * would ship an app that advertises the bundle capability and then errors on every request. The + * hash check, not the existence check, is what catches a half-written or stale out/. + */ +function assertMobileWebBundleBuilt(bundleDir = MOBILE_WEB_BUNDLE_DIR) { + const manifest = parseManifest(bundleDir) + assertNoUnlistedFiles(bundleDir, manifest) + for (const asset of manifest.assets) { + const assetPath = join(bundleDir, asset.path) + let size + try { + size = statSync(assetPath).size + } catch (error) { + throw failure( + `manifest lists ${asset.path}, which is missing from ${bundleDir} (${error.code ?? error.message}). ${REMEDY}` + ) + } + if (size !== asset.byteLength) { + throw failure( + `${asset.path} is ${String(size)} bytes on disk, manifest says ${String(asset.byteLength)}. ${REMEDY}` + ) + } + const sha256 = createHash('sha256').update(readFileSync(assetPath)).digest('hex') + if (sha256 !== asset.sha256) { + throw failure( + `${asset.path} hashes to ${sha256} on disk, manifest says ${asset.sha256}. ${REMEDY}` + ) + } + } + console.log( + `[verify-packaged-mobile-web-bundle] OK — buildId ${manifest.buildId}, ` + + `${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes` + ) + return manifest +} + +module.exports = { MOBILE_WEB_BUNDLE_DIR, assertMobileWebBundleBuilt } diff --git a/config/scripts/verify-packaged-mobile-web-bundle.test.mjs b/config/scripts/verify-packaged-mobile-web-bundle.test.mjs new file mode 100644 index 00000000000..40acb8fb144 --- /dev/null +++ b/config/scripts/verify-packaged-mobile-web-bundle.test.mjs @@ -0,0 +1,214 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildMobileWebBundle } from './build-mobile-web-bundle.mjs' + +const require = createRequire(import.meta.url) +const { + MOBILE_WEB_BUNDLE_DIR, + assertMobileWebBundleBuilt +} = require('./verify-packaged-mobile-web-bundle.cjs') +const electronBuilderConfig = require('../electron-builder.config.cjs') +const REPO_ROOT = join(import.meta.dirname, '..', '..') + +async function withBundle(run) { + const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-guard-')) + const bundleDir = join(scratch, 'mobile-web') + try { + const { manifest } = await buildMobileWebBundle({ outDir: bundleDir }) + await run({ bundleDir, manifest }) + } finally { + await rm(scratch, { recursive: true, force: true }) + } +} + +async function rewriteManifest(bundleDir, mutate) { + const manifestPath = join(bundleDir, 'manifest.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + mutate(manifest) + await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8') +} + +describe('assertMobileWebBundleBuilt', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('accepts a freshly built bundle', async () => { + await withBundle(({ bundleDir, manifest }) => { + expect(() => assertMobileWebBundleBuilt(bundleDir)).not.toThrow() + expect(manifest.entrypoint).toBe('index.html') + expect(manifest.assets.length).toBeGreaterThanOrEqual(3) + expect( + new Set(manifest.assets.map((asset) => asset.contentType)).size + ).toBeGreaterThanOrEqual(2) + }) + }) + + it('fails on a file the manifest does not list, so no stale asset ships inside asar', async () => { + await withBundle(async ({ bundleDir }) => { + // An asset dropped from the manifest keeps its content-addressed name, so nothing ever + // overwrites it; without this check it packs unreachable and unverified. + await writeFile(join(bundleDir, 'assets', 'stale.js'), '// from an earlier build\n', 'utf8') + expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow( + /does not list: assets\/stale\.js/ + ) + }) + }) + + it('accepts exactly the manifest, the entrypoint and the listed assets', async () => { + await withBundle(async ({ bundleDir, manifest }) => { + const onDisk = (await readdir(bundleDir, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name).slice(bundleDir.length + 1)) + expect(onDisk.toSorted()).toEqual( + ['manifest.json', ...manifest.assets.map((asset) => asset.path)].toSorted() + ) + }) + }) + + it('fails when the manifest is missing', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-guard-')) + try { + expect(() => assertMobileWebBundleBuilt(scratch)).toThrow(/no bundle manifest/) + } finally { + await rm(scratch, { recursive: true, force: true }) + } + }) + + it('fails when the manifest is not JSON', async () => { + await withBundle(async ({ bundleDir }) => { + await writeFile(join(bundleDir, 'manifest.json'), 'not json', 'utf8') + expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/not valid JSON/) + }) + }) + + it('fails when an asset is tampered with on disk', async () => { + await withBundle(async ({ bundleDir, manifest }) => { + const asset = manifest.assets.find((entry) => entry.path.endsWith('.js')) + const bytes = await readFile(join(bundleDir, asset.path)) + // Same length, different content: only the hash check can catch this. + bytes[bytes.length - 1] = bytes.at(-1) === 0x20 ? 0x09 : 0x20 + await writeFile(join(bundleDir, asset.path), bytes) + expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/hashes to .* on disk/) + }) + }) + + it('fails when an asset is truncated', async () => { + await withBundle(async ({ bundleDir, manifest }) => { + const asset = manifest.assets.find((entry) => entry.path.endsWith('.css')) + await writeFile(join(bundleDir, asset.path), 'truncated', 'utf8') + expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/bytes on disk, manifest says/) + }) + }) + + it('fails when a listed asset was never written', async () => { + await withBundle(async ({ bundleDir, manifest }) => { + const asset = manifest.assets.find((entry) => entry.path.endsWith('.png')) + await rm(join(bundleDir, asset.path)) + expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/which is missing from/) + }) + }) + + it('fails when the manifest buildId no longer matches its asset list', async () => { + await withBundle(async ({ bundleDir }) => { + await rewriteManifest(bundleDir, (manifest) => { + manifest.buildId = 'f'.repeat(64) + }) + expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/does not match its asset list/) + }) + }) + + it('fails on an unknown schemaVersion', async () => { + await withBundle(async ({ bundleDir }) => { + await rewriteManifest(bundleDir, (manifest) => { + manifest.schemaVersion = 2 + }) + expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow( + /unsupported manifest schemaVersion/ + ) + }) + }) + + it('fails when a required field is dropped', async () => { + await withBundle(async ({ bundleDir }) => { + await rewriteManifest(bundleDir, (manifest) => { + delete manifest.desktopVersion + }) + expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/desktopVersion is missing/) + }) + }) + + it('fails when totalBytes disagrees with the asset list', async () => { + await withBundle(async ({ bundleDir }) => { + await rewriteManifest(bundleDir, (manifest) => { + manifest.totalBytes += 1 + }) + expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/its assets sum to/) + }) + }) + + it('refuses an asset path that escapes the bundle directory', async () => { + await withBundle(async ({ bundleDir }) => { + await rewriteManifest(bundleDir, (manifest) => { + manifest.assets[0].path = '../outside.js' + }) + expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/not a safe relative path/) + }) + }) + + it('refuses a manifest whose entrypoint is not one of its assets', async () => { + await withBundle(async ({ bundleDir }) => { + await rewriteManifest(bundleDir, (manifest) => { + manifest.entrypoint = 'index.html' + manifest.assets = manifest.assets.filter((asset) => asset.path !== 'index.html') + }) + expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/is not one of its assets/) + }) + }) +}) + +describe('electron-builder packaging wiring', () => { + it('excludes the mobile-web source tree from app.asar', () => { + expect(electronBuilderConfig.files).toContain('!src/mobile-web{,/**/*}') + // The source tree lives under src/, which is excluded wholesale; the explicit entry above + // only survives as a marker, so assert the broad rule is still what does the work. + expect(electronBuilderConfig.files).toContain('!src{,/**/*}') + }) + + it('does not exclude the built bundle, so out/mobile-web ships like out/web', () => { + const excludesBuiltBundle = electronBuilderConfig.files.some( + (entry) => typeof entry === 'string' && entry.startsWith('!out/mobile-web') + ) + expect(excludesBuiltBundle).toBe(false) + }) + + it('runs the bundle guard in beforePack', () => { + expect(String(electronBuilderConfig.beforePack)).toContain('assertMobileWebBundleBuilt') + }) + + it('defaults the bundle root to out/mobile-web when electron-builder calls it', () => { + expect(MOBILE_WEB_BUNDLE_DIR).toBe(join(REPO_ROOT, 'out', 'mobile-web')) + // electron-builder passes the context alone, so the default is what ships. + expect(electronBuilderConfig.beforePack.length).toBe(1) + }) + + it('verifies the bundle root it is given, not the repo one', async () => { + // The seam exists so unit tests need no built out/; it would be worthless if the root were + // accepted and then ignored. + await withBundle(async ({ bundleDir }) => { + await rm(join(bundleDir, 'manifest.json')) + expect(() => + electronBuilderConfig.beforePack( + { electronPlatformName: process.platform, arch: process.arch === 'arm64' ? 3 : 1 }, + bundleDir + ) + ).toThrow(/no bundle manifest/) + }) + }) +}) diff --git a/config/tsconfig.mobile-web.json b/config/tsconfig.mobile-web.json new file mode 100644 index 00000000000..93436c7a4b3 --- /dev/null +++ b/config/tsconfig.mobile-web.json @@ -0,0 +1,8 @@ +{ + "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", + "include": ["../src/mobile-web/src/**/*"], + "compilerOptions": { + "composite": true, + "types": [] + } +} diff --git a/package.json b/package.json index 9d37cb13cba..23d1ecf3e34 100644 --- a/package.json +++ b/package.json @@ -93,10 +93,11 @@ "build:electron-vite:parallel": "node config/scripts/run-electron-vite-targets-in-parallel.mjs", "build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", "build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs", - "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer", + "build:mobile-web": "node config/scripts/build-mobile-web-bundle.mjs && node config/scripts/verify-mobile-web-bundle.mjs", + "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", "build": "pnpm run build:desktop && pnpm run build:native", - "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer", - "build:release:parallel": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite:parallel && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer", + "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", + "build:release:parallel": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite:parallel && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer && pnpm run build:mobile-web", "postinstall": "node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "reclaim:electron-dists": "node config/scripts/reclaim-electron-dists.mjs", diff --git a/src/mobile-web/index.html b/src/mobile-web/index.html new file mode 100644 index 00000000000..9e91c0e3e0b --- /dev/null +++ b/src/mobile-web/index.html @@ -0,0 +1,17 @@ + + + + + + Orca mobile bundle + + + +
+ +

Orca mobile bundle

+
+
+ + + diff --git a/src/mobile-web/src/bootstrap.css b/src/mobile-web/src/bootstrap.css new file mode 100644 index 00000000000..3fc2529db97 --- /dev/null +++ b/src/mobile-web/src/bootstrap.css @@ -0,0 +1,54 @@ +:root { + color-scheme: dark light; + --bootstrap-fg: #e6edf3; + --bootstrap-muted: #8b98a5; + --bootstrap-bg: #0d1117; +} + +body { + margin: 0; + background: var(--bootstrap-bg); + color: var(--bootstrap-fg); + font-family: + ui-sans-serif, + system-ui, + -apple-system, + 'Segoe UI', + sans-serif; +} + +.bootstrap { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + padding: 24px; +} + +.bootstrap__mark { + image-rendering: pixelated; +} + +.bootstrap__title { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.bootstrap__facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 4px 12px; + margin: 0; + font-size: 13px; +} + +.bootstrap__facts dt { + color: var(--bootstrap-muted); +} + +.bootstrap__facts dd { + margin: 0; + overflow-wrap: anywhere; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} diff --git a/src/mobile-web/src/bootstrap.ts b/src/mobile-web/src/bootstrap.ts new file mode 100644 index 00000000000..805ee5381df --- /dev/null +++ b/src/mobile-web/src/bootstrap.ts @@ -0,0 +1,65 @@ +// Build-time constants, substituted by config/scripts/build-mobile-web-bundle.mjs via esbuild define. +declare const ORCA_MOBILE_WEB_DESKTOP_VERSION: string +declare const ORCA_MOBILE_WEB_RUNTIME_PROTOCOL_VERSION: number +declare const ORCA_MOBILE_WEB_MIN_COMPATIBLE_RUNTIME_PROTOCOL_VERSION: number + +// Why a runtime read and not a define: buildId is the hash of the asset list that index.html +// belongs to, so injecting it into a hashed asset would make the hash depend on itself. +const MANIFEST_URL = './manifest.json' + +function isBuildId(value: unknown): value is string { + return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value) +} + +async function readBuildId(): Promise { + const response = await fetch(MANIFEST_URL, { cache: 'no-store' }) + if (!response.ok) { + throw new Error(`manifest request failed with ${String(response.status)}`) + } + const manifest: unknown = await response.json() + // `in` narrows without an assertion; the manifest is untrusted JSON either way. + if (typeof manifest !== 'object' || manifest === null || !('buildId' in manifest)) { + throw new Error('manifest has no buildId') + } + const { buildId } = manifest + if (!isBuildId(buildId)) { + throw new Error('manifest buildId is not a sha256 digest') + } + return buildId +} + +function renderFacts(facts: readonly (readonly [string, string])[]): void { + const list = document.getElementById('bootstrap-facts') + if (!(list instanceof HTMLDListElement)) { + return + } + list.replaceChildren() + for (const [term, description] of facts) { + const dt = document.createElement('dt') + dt.textContent = term + const dd = document.createElement('dd') + dd.textContent = description + dd.dataset.fact = term + list.append(dt, dd) + } +} + +async function start(): Promise { + let buildId: string + try { + buildId = await readBuildId() + } catch (error) { + buildId = `unavailable (${error instanceof Error ? error.message : String(error)})` + } + renderFacts([ + ['buildId', buildId], + ['desktopVersion', ORCA_MOBILE_WEB_DESKTOP_VERSION], + ['runtimeProtocolVersion', String(ORCA_MOBILE_WEB_RUNTIME_PROTOCOL_VERSION)], + [ + 'minCompatibleRuntimeProtocolVersion', + String(ORCA_MOBILE_WEB_MIN_COMPATIBLE_RUNTIME_PROTOCOL_VERSION) + ] + ]) +} + +void start() diff --git a/src/mobile-web/src/orca-mark.png b/src/mobile-web/src/orca-mark.png new file mode 100644 index 00000000000..274fd7bf782 Binary files /dev/null and b/src/mobile-web/src/orca-mark.png differ