mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat(build): build, verify and package the mobile web bundle with every desktop release (OTA phase A, 2/5) (#21326)
* feat(mobile-web): add the Phase A bootstrap web source A peer of src/ so the root workspace owns it and mobile's separate lockfile stays out of packaging. Four assets across four content types, enough to exercise multi-asset manifest handling rather than assume it. The page reads buildId from manifest.json at runtime: buildId hashes the asset list that index.html belongs to, so injecting it into a hashed asset would make that asset's hash depend on itself. Registered as a fourth typecheck project; without it the entry would be the only TypeScript in a release path that tsc never sees. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(build): build and verify the mobile web bundle from the root workspace Root esbuild over mobile-web/ into out/mobile-web/, content-addressed as assets/<sha256>.<ext> with index.html the only stable name. buildId is the sha256 of the canonical serialization of the sorted asset list, so it is a pure function of content and usable as a cache key with no further reasoning. The verifier builds twice into scratch dirs and compares: a timestamp, an absolute path, or an unstable ordering fails the build when someone introduces it, not the first time a phone gets a spurious cache miss. It also enforces the Phase A budget of 16 assets and 256 KiB, separate from the permanent contract ceiling. build:release does not call build:desktop, so build:mobile-web is wired into build:desktop, build:release, and build:release:parallel. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(packaging): fail the release when the mobile web bundle is missing or stale electron-builder only warns about a missing input, so without a beforePack guard a release ships 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/. The source tree is excluded from app.asar; out/mobile-web ships inside it under the existing out rules, exactly as out/web does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web): narrow the manifest with `in` instead of a cast The changed-code casting gate rejects assertions, and `in` narrows the same untrusted JSON without one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): move the bundle source under src/ so the root guard passes .github/scripts/check-root-directory-entries.mjs blocks any new top-level entry by name, so mobile-web/ could not live at the root. The source is excluded from app.asar by the existing '!src{,/**/*}' rule; the explicit '!src/mobile-web{,/**/*}' entry stays as a marker. out/mobile-web is unaffected and still ships under the out rules like out/web. No tsconfig includes src/**, so node, web, cli, and relay do not pick the tree up; it is registered as a knip entry so audit:dead-code does not call it unused. buildId is unchanged at 9d78435e: the builder hashes content, not paths. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(build): resolve the entry-script guard through pathToFileURL `file://${process.argv[1]}` never equals import.meta.url on Windows, where that url is file:///C:/... So the builder exited 0 having written nothing and the Windows packaging job failed later, at the guard, with no clue why. Every other script in config/scripts already uses pathToFileURL; this one now does too, via an exported predicate a posix runner can exercise with a win32 path. The verify script had no entry guard at all, so importing its budget constants ran the whole verification — including its process.exit — inside the test worker. It is now a function behind the same guard. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(ci): build the mobile web bundle in the PR package job That job assembles packaging inputs step by step instead of calling build:release, so the new beforePack guard hard-failed it. The census test added here is the oracle: it walks every workflow job that invokes electron-builder without --prepackaged (which short-circuits doPack before beforePack) and requires a bundle-producing script in the same job. It goes red on exactly pr.yml's package job when this step is removed. Ten jobs covered; the other nine already ran build:release, build:release:parallel, or build:desktop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): pin source line endings, because CRLF changes the buildId Every text byte under src/mobile-web 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 instead of 9d78435e. That would make a Windows-built desktop disagree with a mac-built one about which bundle a phone has cached. .gitattributes pins eol=lf for the text sources and -text for the PNG, matching the four trees already pinned for byte-hashing. The verify script asserts no source file carries a CR, so the build fails if the pin ever stops applying rather than silently shipping a second bundle identity. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(build): read the test's own path from import.meta.filename oxlint unicorn/prefer-import-meta-properties. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(test): census packaging jobs over raw workflow text, not re-serialized YAML yaml.stringify folds long lines, and in dev-channel-win-build.yml's build-win the fold landed between `electron-builder` and `--config`, so a real packaging job was invisible to the census: 11 jobs exist, the test saw 10. Slice each job's raw source by its parsed boundaries instead, and pin the inventory so a new packaging workflow has to be added here on purpose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): assert the script chain the packaging census trusts The census only checks that a packaging job invokes one of ten build scripts; that those scripts still reach build:mobile-web was asserted nowhere, so a dropped link would leave every job looking covered while packaging failed at beforePack. Resolve each script for real, and pin pr.yml's hand-rolled step, since that job never calls build:release. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(build): realpath the entry path before the direct-invocation compare Node resolves symlinks in import.meta.url but not in argv[1], so `node /tmp/...` against a /private/tmp realpath compared two different strings: the builder and the verifier exited 0 having written and checked nothing. Same silent-success shape as the Windows file:// bug, so the fix sits next to it, with both seams injectable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile-web): format bootstrap.css with oxfmt It was the only tracked CSS failing oxfmt --check. The buildId is unchanged at 9d78435e8bb73c3341f833c20aaefbd7bfdfc414b68dadf87c1689d86728fe33, because esbuild's CSS minifier normalises the whitespace this touches before the asset is hashed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(packaging): reject bundle files the manifest does not list The guard only walked the manifest, so a dropped assets/stale.js passed: assets are content-addressed, nothing ever overwrites a stale copy, and it would ship inside asar unreachable and unverified. Require every file under out/mobile-web to be the manifest or a listed asset. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(packaging): give beforePack an explicit mobile web bundle root The bundle guard read the repo's out/mobile-web unconditionally, so the two arch-aware packaging tests that call the real beforePack went red in the unit-test job, which never runs build:mobile-web. beforePack now takes the bundle root as a second parameter defaulting to out/mobile-web, which is what electron-builder gets, and those tests build a real bundle into a temp dir instead. The guard is neither skipped nor made tolerant of a missing bundle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(packaging): census sees script-wrapped packers; dev verify reuses the guard The workflow census only matched a literal `electron-builder --config` line, so daemon-relocation-spike's `pnpm run build:unpack` (which packs and runs beforePack) was invisible to it. Jobs now count when any `pnpm run <script>` they invoke chains to electron-builder without --prepackaged; the spike joins the pinned list (12 jobs). verify-mobile-web-bundle.mjs re-implemented a weaker subset of the packaging guard (no safe-path check, no buildId recompute). It now calls assertMobileWebBundleBuilt, so a manifest edited after the build fails at `pnpm build:mobile-web` exactly as at beforePack. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
}
|
||||
@@ -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/<link>` 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <name>` 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 <name>` 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/)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": ["../src/mobile-web/src/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": []
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -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",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Orca mobile bundle</title>
|
||||
<link rel="stylesheet" href="__ORCA_BOOTSTRAP_CSS__" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="bootstrap" id="bootstrap">
|
||||
<img class="bootstrap__mark" src="__ORCA_MARK_PNG__" width="48" height="48" alt="" />
|
||||
<h1 class="bootstrap__title">Orca mobile bundle</h1>
|
||||
<dl class="bootstrap__facts" id="bootstrap-facts"></dl>
|
||||
</main>
|
||||
<script src="__ORCA_BOOTSTRAP_JS__"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+54
@@ -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;
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
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()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 105 B |
Reference in New Issue
Block a user