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/oxlint-anti-slop.json b/config/oxlint-anti-slop.json
index bba8588d949..7d379a9df4e 100644
--- a/config/oxlint-anti-slop.json
+++ b/config/oxlint-anti-slop.json
@@ -56,6 +56,15 @@
"anti-slop/no-module-mocking": "off"
}
},
+ // mock-descendant-sweep.ts (daemon and relay) is a test-only side-effect shim: its whole body
+ // is one vi.mock that keeps mock PTY PIDs away from the host process table, and it exists so
+ // 60 suites do not each inline the same hoisted factory. It is never imported by product code.
+ {
+ "files": ["**/mock-descendant-sweep.ts"],
+ "rules": {
+ "anti-slop/no-module-mocking": "off"
+ }
+ },
// The exemptions below are file-scoped rather than inline `oxlint-disable` comments
// because the root lint scan does not load this plugin, so an inline directive naming
// an anti-slop rule always reads back as an unused directive there.
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/mobile-web-bundle-serializer-parity.test.mjs b/config/scripts/mobile-web-bundle-serializer-parity.test.mjs
new file mode 100644
index 00000000000..9b82a09265b
--- /dev/null
+++ b/config/scripts/mobile-web-bundle-serializer-parity.test.mjs
@@ -0,0 +1,119 @@
+/**
+ * The canonical serialization that buildId hashes exists three times, because the two packaging
+ * scripts run on bare node before any build output exists and so cannot import the TypeScript
+ * contract. Three copies drift; this is what stops them. A divergence in any one of them would
+ * reject every honest bundle at packaging, or ship a bundle whose id the phone recomputes
+ * differently and re-downloads forever.
+ */
+import { createHash } from 'node:crypto'
+import { createRequire } from 'node:module'
+import { describe, expect, it } from 'vitest'
+import {
+ computeMobileWebBundleBuildId,
+ serializeMobileWebBundleAssets as serializeInBuilder
+} from './build-mobile-web-bundle.mjs'
+import {
+ computeMobileWebBundleId,
+ MobileWebBundleManifestSchema,
+ serializeMobileWebBundleAssets as serializeInContract
+} from '../../src/shared/mobile-web-bundle/manifest-contract'
+
+const require = createRequire(import.meta.url)
+const { serializeAssets: serializeInGuard } = require('./verify-packaged-mobile-web-bundle.cjs')
+
+const digest = (hex) => `${hex}`.padStart(64, '0')
+
+/**
+ * Mixed content types, a nested path, and an uppercase segment that sorts before a lowercase one
+ * only under code-unit order: `localeCompare` would put `assets/aQ.js` first, so any serializer
+ * that reached for it produces a different string here.
+ */
+const ASSETS = [
+ {
+ path: 'assets/Za.js',
+ sha256: digest('a1'),
+ byteLength: 2048,
+ contentType: 'text/javascript; charset=utf-8'
+ },
+ { path: 'assets/aQ.css', sha256: digest('b2'), byteLength: 512, contentType: 'text/css' },
+ {
+ path: 'assets/nested/mark.png',
+ sha256: digest('c3'),
+ byteLength: 40_960,
+ contentType: 'image/png'
+ },
+ {
+ path: 'index.html',
+ sha256: digest('d4'),
+ byteLength: 640,
+ contentType: 'text/html; charset=utf-8'
+ }
+]
+
+const REORDERED = [ASSETS[3], ASSETS[1], ASSETS[0], ASSETS[2]]
+const REVERSED = ASSETS.toReversed()
+
+const sha256Hex = (value) => createHash('sha256').update(value, 'utf8').digest('hex')
+
+describe('the three mobile web bundle serializers', () => {
+ it('produce one string for the builder, the packaging guard, and the shared contract', () => {
+ const fromContract = serializeInContract(ASSETS)
+
+ expect(serializeInBuilder(ASSETS)).toBe(fromContract)
+ expect(serializeInGuard(ASSETS)).toBe(fromContract)
+ })
+
+ it.each([
+ ['reordered', REORDERED],
+ ['reversed', REVERSED]
+ ])('are order-independent, so %s input serializes identically', (_label, input) => {
+ const expected = serializeInContract(ASSETS)
+
+ expect(serializeInContract(input)).toBe(expected)
+ expect(serializeInBuilder(input)).toBe(expected)
+ expect(serializeInGuard(input)).toBe(expected)
+ })
+
+ it('leaves the caller-supplied array untouched, so a build cannot depend on the sort', () => {
+ const input = [...REORDERED]
+ serializeInContract(input)
+ serializeInBuilder(input)
+ serializeInGuard(input)
+
+ expect(input).toEqual(REORDERED)
+ })
+
+ it('emit exactly path, sha256, byteLength, contentType, in that order, and nothing else', () => {
+ const decorated = ASSETS.map((asset) => ({ ...asset, sourcePath: '/tmp/ignored', extra: 1 }))
+
+ expect(serializeInContract(decorated)).toBe(serializeInContract(ASSETS))
+ expect(serializeInBuilder(decorated)).toBe(serializeInContract(ASSETS))
+ expect(serializeInGuard(decorated)).toBe(serializeInContract(ASSETS))
+ expect(JSON.parse(serializeInContract(ASSETS))[0]).toEqual({
+ path: 'assets/Za.js',
+ sha256: digest('a1'),
+ byteLength: 2048,
+ contentType: 'text/javascript; charset=utf-8'
+ })
+ })
+
+ it('hash to one buildId, which the manifest schema then accepts', () => {
+ const buildId = computeMobileWebBundleId(REORDERED)
+
+ expect(computeMobileWebBundleBuildId(REORDERED)).toBe(buildId)
+ expect(sha256Hex(serializeInGuard(REORDERED))).toBe(buildId)
+
+ const manifest = {
+ schemaVersion: 1,
+ buildId,
+ desktopVersion: '1.4.200',
+ minCompatibleRuntimeProtocolVersion: 2,
+ runtimeProtocolVersion: 2,
+ entrypoint: 'index.html',
+ totalBytes: ASSETS.reduce((total, asset) => total + asset.byteLength, 0),
+ assets: [...ASSETS]
+ }
+
+ expect(MobileWebBundleManifestSchema.parse(manifest).buildId).toBe(buildId)
+ })
+})
diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs
index 3b8f2e90afb..b9c7c387716 100644
--- a/config/scripts/pr-e2e-source-routing.mjs
+++ b/config/scripts/pr-e2e-source-routing.mjs
@@ -178,6 +178,17 @@ export const PR_E2E_SOURCE_ROUTES = [
file
)
},
+ {
+ // Why: layout resolution is the only place a split direction can be invented, and the
+ // loss is one-way — the guess is published and written back over the real tree.
+ id: 'terminal-session.split-orientation-resolution',
+ specs: ['tests/e2e/desktop-published-split-orientation-legacy-leaf.spec.ts'],
+ matches: (file) =>
+ isProductSource(file) &&
+ /^src\/renderer\/src\/runtime\/(?:remote-terminal-layout-resolution\.ts|sync-runtime-graph\/(?:graph-publication|mobile-session-terminal-tabs|mobile-session-surfaces)\.ts|web-session-tabs-sync\/terminal-surfaces\.ts)$/.test(
+ file
+ )
+ },
{
id: 'terminal-session.remote-pane-layout-retry',
specs: ['tests/e2e/paired-remote-pane-layout-retry.spec.ts'],
diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs
index fc0d628ab78..2eea3575fde 100644
--- a/config/scripts/run-ssh-docker-e2e.mjs
+++ b/config/scripts/run-ssh-docker-e2e.mjs
@@ -78,6 +78,7 @@ const result = spawnSync(
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-restart-tab-accumulation.spec.ts',
'tests/e2e/ssh-skill-installation.spec.ts',
+ 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts',
'--config',
'tests/playwright.config.ts',
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..c521882691d
--- /dev/null
+++ b/config/scripts/verify-packaged-mobile-web-bundle.cjs
@@ -0,0 +1,193 @@
+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
+}
+
+// serializeAssets is exported for the parity test that pins it against the builder's and the
+// contract's serializers; nothing in packaging calls it from outside this module.
+module.exports = { MOBILE_WEB_BUNDLE_DIR, assertMobileWebBundleBuilt, serializeAssets }
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/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index 766bd1e4cad..20fd5d9c2f2 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-