mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
feat(mobile): one build-time switch picks native or OTA, default native (OTA phase E1) (#22193)
* feat(mobile): one build-time constant decides native or OTA, default native EXPO_PUBLIC_MOBILE_SHELL is read in exactly one place, mobileShellBuildKind in preferences.ts. Expo's babel preset inlines a literal process.env member expression at build time, so a release bundle carries the answer as a constant and anything but the exact string 'ota' — unset, empty, a typo — is native. Every default build is therefore the native app, unchanged. mobileWebShellFlagCanBeOn now answers __DEV__ or an OTA build, so the ability to mount the page comes from the build and never from storage: a native binary installed over an OTA one, same bundle id and same data container, still refuses a stored 'true' without reading the key. An unset key reads on only in an OTA build; a development build keeps its opt-in, and a stored 'false' wins everywhere so the Troubleshoot toggle can switch an OTA build back to native. That toggle now mounts wherever the flag can be on, which is the only way back to the native screens in an OTA build, and its label names the build kind rather than saying "(dev)". The bundle probe row beside it stays development-only: it fetches. The flag census gains two rules — one module reads the switch, in the member form Expo inlines and not the bracket form, and one named function answers the build kind — and the build-kind fence now lists the Troubleshoot route that asks it. Docblocks that said a store build can never mount the shell now say it mounts only when built for OTA. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci(mobile): one workflow input picks the shell, and no input means native Both release workflows gain a `shell` workflow_dispatch choice, options native and ota, default native, and hand it to the step that bundles the JavaScript as EXPO_PUBLIC_MOBILE_SHELL. That is the Gradle assembleRelease step on Android and the fastlane build_and_upload step on iOS; nothing else in either file sets it. A tag push and a schedule carry no inputs at all, so `inputs.shell || 'native'` yields native for them — the first OTA release is a dispatch with one field changed, and every other run is the app we ship today. Each build step prints the value it is about to build with, read back from the same variable rather than from a second copy of the expression, so a run's log cannot claim a shell the build did not use. The new contract test evaluates that expression rather than matching its text: absent, empty and 'native' all resolve to native, 'ota' to ota, and any expression shape it cannot evaluate is a failure rather than a pass. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build: the desktop packages the real page, and the placeholder is retired build:mobile-web now runs the app builder and app verifier, and both take their output root from MOBILE_WEB_BUNDLE_DIR in the packaging guard rather than each carrying a constant of their own — one definition of where the bundle lives, so a drift cannot leave electron-builder's beforePack looking at an empty directory while the builder reports a tree it wrote elsewhere. build:mobile-web:app is gone; it was the same two commands. src/mobile-web/ and its two scripts go with it. What the app builder shared with them is split into three modules named for what they hold rather than for the bundle that used to own them: mobile-web-bundle-manifest.mjs (content types, the canonical asset serialization, buildId, hashed assets, the protocol window and the manifest write), script-entry-detection.mjs (isDirectInvocation, whose two failure modes are Windows paths and symlinked entries), and mobile-web-source-line-endings.mjs (the CRLF guard, now with a required directory rather than a default pointing at the deleted tree). The two suites that only needed *a* valid tree on disk — the beforePack guard and the packaged-bundle guard — build one from mobile-web-bundle-fixture-tree instead of bundling the whole mobile graph. It goes through the same manifest writer the page does, so a manifest shape change still reaches them. Also retired: the placeholder's tsconfig project and its typecheck lane, its knip entry, its electron-builder exclusion and .gitattributes pins, and the app-bundle test that asserted the shims stayed out of a builder that no longer exists. pr.yml's page job builds the same bundle the package job ships. Inert for native phones: they never fetch it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(config): one import of node:fs/promises in the entry-detection suite The changed-code quality gate's focused plugins read the two as a duplicate import; the readFile line was left over from the split. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs: the comments that still describe the retired placeholder bundle The web entry said it was built by `build:mobile-web:app` into out/mobile-web-app and shipped by nothing. That script, that directory and that fact are all gone: it is built by `build:mobile-web` into the packaged bundle dir, and a phone mounts it only when the binary was built with EXPO_PUBLIC_MOBILE_SHELL=ota. Two Windows cache keys explained themselves by naming src/mobile-web and "the two bundle builders"; config/** now covers the builder, the verifier and the manifest writer, and the spike's key no longer waits on a Phase C flip that has happened. The keys themselves are unchanged. Three scratch directories in the app-bundle suites and one in the verifier still spelled the retired output root. Renamed to mobile-web, which is what the build writes; they are temp subdirectory names and nothing reads them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
+4
-11
@@ -46,21 +46,14 @@
|
||||
# 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
|
||||
# Mobile web page source. Same buildId hazard as src/mobile-web above: these bytes are
|
||||
# hashed into the Phase C bundle, so a CRLF Windows checkout would ship a different
|
||||
# buildId for identical source. web-entry/ does not exist yet; the pin lands ahead of it.
|
||||
# Mobile web page source. Every text byte here is hashed into an asset digest and from
|
||||
# there into buildId, so a CRLF Windows checkout would ship a different bundle id for
|
||||
# identical source (91af2897 vs 9d78435e, measured on the bundle this replaced).
|
||||
/mobile/src/** text eol=lf
|
||||
/mobile/app/** text eol=lf
|
||||
/mobile/web-entry/** text eol=lf
|
||||
# The blanket pin above would mark a future binary as text; exempt the asset types an
|
||||
# RN page actually carries, the same way src/mobile-web exempts its PNG.
|
||||
# RN page actually carries.
|
||||
/mobile/src/**/*.png -text
|
||||
/mobile/src/**/*.jpg -text
|
||||
/mobile/src/**/*.jpeg -text
|
||||
|
||||
@@ -57,10 +57,10 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: dist/win-unpacked
|
||||
# mobile/ is in the key because beforePack requires out/mobile-web, whose bytes come from
|
||||
# the mobile install and, once Phase C flips the bundle, from the page trees below; a
|
||||
# mobile-only change must miss this cache, not reuse a stale installer. src/** and
|
||||
# config/** already cover src/mobile-web and the two bundle builders.
|
||||
# mobile/ is in the key because beforePack requires out/mobile-web, whose bytes are the
|
||||
# page built from the mobile install and the page trees below; a mobile-only change must
|
||||
# miss this cache, not reuse a stale installer. config/** already covers the builder, the
|
||||
# verifier and the manifest writer.
|
||||
key: >-
|
||||
win-unpacked-${{ hashFiles(
|
||||
'src/**',
|
||||
|
||||
@@ -18,6 +18,14 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
shell:
|
||||
description: 'Which shell the binary mounts: native screens, or the web page delivered over the air. Default native; `ota` is the only value that changes it.'
|
||||
required: false
|
||||
default: native
|
||||
type: choice
|
||||
options:
|
||||
- native
|
||||
- ota
|
||||
|
||||
jobs:
|
||||
android-build:
|
||||
@@ -68,7 +76,14 @@ jobs:
|
||||
run: npx expo prebuild --platform android --no-install
|
||||
|
||||
- name: Build Android release APK
|
||||
run: cd android && ./gradlew assembleRelease
|
||||
env:
|
||||
# The one build-time constant that decides whether this binary mounts the web page or
|
||||
# the native screens. A tag push and a schedule carry no inputs, so both read native.
|
||||
EXPO_PUBLIC_MOBILE_SHELL: ${{ inputs.shell || 'native' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Mobile shell: $EXPO_PUBLIC_MOBILE_SHELL"
|
||||
cd android && ./gradlew assembleRelease
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -22,6 +22,14 @@ on:
|
||||
description: 'Optional TestFlight external tester changelog'
|
||||
required: false
|
||||
type: string
|
||||
shell:
|
||||
description: 'Which shell the binary mounts: native screens, or the web page delivered over the air. Default native; `ota` is the only value that changes it.'
|
||||
required: false
|
||||
default: native
|
||||
type: choice
|
||||
options:
|
||||
- native
|
||||
- ota
|
||||
|
||||
jobs:
|
||||
ios-build:
|
||||
@@ -141,7 +149,13 @@ jobs:
|
||||
# Keep fastlane non-interactive and quiet about analytics in CI.
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||
FASTLANE_HIDE_CHANGELOG: '1'
|
||||
run: bundle exec fastlane ios build_and_upload
|
||||
# The one build-time constant that decides whether this binary mounts the web page or
|
||||
# the native screens. A tag push and a schedule carry no inputs, so both read native.
|
||||
EXPO_PUBLIC_MOBILE_SHELL: ${{ inputs.shell || 'native' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Mobile shell: $EXPO_PUBLIC_MOBILE_SHELL"
|
||||
bundle exec fastlane ios build_and_upload
|
||||
|
||||
- name: Upload .ipa artifact
|
||||
if: always()
|
||||
|
||||
@@ -650,9 +650,8 @@ jobs:
|
||||
src/main/orcad/external-chromium-browser-process.integration.test.ts
|
||||
|
||||
# Why its own job: it needs mobile/node_modules and a real browser, and the sharded `test`
|
||||
# matrix would pay for both on every shard to run two files. Dark through Phase C: this proves
|
||||
# `build:mobile-web:app` on every PR that touches the page, and ships nothing -- packaging still
|
||||
# builds the Phase A bootstrap via build:mobile-web.
|
||||
# matrix would pay for both on every shard to run two files. It builds the same bundle the
|
||||
# package job ships, and adds the render and census checks packaging does not run.
|
||||
mobile_web_app:
|
||||
name: mobile web app bundle
|
||||
needs: [code_paths]
|
||||
@@ -699,7 +698,7 @@ jobs:
|
||||
run: pnpm exec playwright install --with-deps webkit
|
||||
|
||||
- name: Build and verify the app bundle
|
||||
run: pnpm run build:mobile-web:app
|
||||
run: pnpm run build:mobile-web
|
||||
|
||||
# The bundling tests skip themselves where mobile dependencies are absent, which is how they
|
||||
# stay green in the sharded `test` job. This is the job that installs them, so here a missing
|
||||
@@ -852,7 +851,7 @@ jobs:
|
||||
|
||||
# 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.
|
||||
# without out/mobile-web. This is the real page, ~8 MB, not a bootstrap document.
|
||||
- name: Build mobile web bundle
|
||||
run: pnpm run build:mobile-web
|
||||
|
||||
|
||||
@@ -71,8 +71,8 @@ jobs:
|
||||
with:
|
||||
path: dist/orca-windows-setup.exe
|
||||
# The mobile page trees are in the key because beforePack builds the mobile web bundle
|
||||
# into the installer; src/** and config/** already cover src/mobile-web and the two
|
||||
# bundle builders. A mobile-only change must miss this cache, not reuse a stale exe.
|
||||
# into the installer; config/** already covers the builder, the verifier and the manifest
|
||||
# writer. A mobile-only change must miss this cache, not reuse a stale exe.
|
||||
key: >-
|
||||
crash-survival-installer-${{ hashFiles(
|
||||
'src/**',
|
||||
|
||||
@@ -181,9 +181,6 @@ 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{,/**/*}',
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { basename, extname, join, resolve } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import * as esbuild from 'esbuild'
|
||||
import {
|
||||
MOBILE_WEB_BUNDLE_ENTRYPOINT,
|
||||
hashedAsset,
|
||||
isDirectInvocation,
|
||||
readDesktopVersion,
|
||||
readProtocolWindow,
|
||||
sha256Hex,
|
||||
writeMobileWebBundleTree,
|
||||
contentTypeForExtension
|
||||
} from './build-mobile-web-bundle.mjs'
|
||||
} from './mobile-web-bundle-manifest.mjs'
|
||||
import { isDirectInvocation } from './script-entry-detection.mjs'
|
||||
import {
|
||||
ROUTE_SOURCE_LOADERS,
|
||||
assertRoutesCarryNoSynchronousExports,
|
||||
@@ -26,7 +27,12 @@ const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const mobileDir = join(projectDir, 'mobile')
|
||||
const defaultAppDir = join(mobileDir, 'app')
|
||||
const entryPoint = join(mobileDir, 'web-entry', 'index.tsx')
|
||||
const defaultOutDir = join(projectDir, 'out', 'mobile-web-app')
|
||||
// The one definition of where the packaged bundle lives, taken from the guard that enforces it:
|
||||
// a second constant here could drift and leave electron-builder's beforePack looking at an empty
|
||||
// directory while the builder reported a tree it had written somewhere else.
|
||||
const { MOBILE_WEB_BUNDLE_DIR: defaultOutDir } = createRequire(import.meta.url)(
|
||||
'./verify-packaged-mobile-web-bundle.cjs'
|
||||
)
|
||||
|
||||
/**
|
||||
* Every shim the app bundle needs, each one a documented Metro/RN-Web gap. `appliesTo` reads the
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import {
|
||||
BINARY_SOURCE_EXTENSIONS,
|
||||
assertNoCarriageReturnsInSource
|
||||
} from './verify-mobile-web-bundle.mjs'
|
||||
} from './mobile-web-source-line-endings.mjs'
|
||||
import { spelledCountsAgainstTables } from './spelled-count-census.mjs'
|
||||
import {
|
||||
hashedAsset,
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
readProtocolWindow,
|
||||
sha256Hex,
|
||||
writeMobileWebBundleTree
|
||||
} from './build-mobile-web-bundle.mjs'
|
||||
} from './mobile-web-bundle-manifest.mjs'
|
||||
import {
|
||||
MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES,
|
||||
MOBILE_WEB_BUNDLE_MAX_ASSETS
|
||||
@@ -323,18 +323,6 @@ describeBundling('the app bundle', () => {
|
||||
expect(MOBILE_WEB_APP_SHIMS.filter((shim) => shim.appliesTo(stripped))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the shims out of the shipped Phase A bootstrap builder', async () => {
|
||||
const shipped = await readFile(
|
||||
join(projectDir, 'config', 'scripts', 'build-mobile-web-bundle.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
for (const { name } of MOBILE_WEB_APP_SHIMS) {
|
||||
expect(shipped, `the Phase A bootstrap builder mentions ${name}`).not.toContain(name)
|
||||
}
|
||||
expect(shipped).not.toContain('react-native-web')
|
||||
expect(shipped).not.toContain('lucide')
|
||||
})
|
||||
|
||||
it('ships no haptic that reaches for the DOM', async () => {
|
||||
// expo-haptics' web build fakes an iOS haptic by appending a hidden
|
||||
// `<label><input type="checkbox" switch>` to document.head, clicking it, and removing it —
|
||||
@@ -598,7 +586,7 @@ describe('the verifier', () => {
|
||||
'accepts a bundle it has just built',
|
||||
async () => {
|
||||
await withScratch(async (scratch) => {
|
||||
const outDir = join(scratch, 'mobile-web-app')
|
||||
const outDir = join(scratch, 'mobile-web')
|
||||
await buildMobileWebAppBundle({ outDir })
|
||||
await expect(verifyMobileWebAppBundle({ bundleDir: outDir })).resolves.toBeDefined()
|
||||
})
|
||||
@@ -610,7 +598,7 @@ describe('the verifier', () => {
|
||||
"rejects a buildId the manifest's own asset list does not derive",
|
||||
async () => {
|
||||
await withScratch(async (scratch) => {
|
||||
const outDir = join(scratch, 'mobile-web-app')
|
||||
const outDir = join(scratch, 'mobile-web')
|
||||
await buildMobileWebAppBundle({ outDir })
|
||||
const manifestPath = join(outDir, 'manifest.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
@@ -628,7 +616,7 @@ describe('the verifier', () => {
|
||||
'rejects a self-consistent bundle a fresh build does not reproduce',
|
||||
async () => {
|
||||
await withScratch(async (scratch) => {
|
||||
const outDir = join(scratch, 'mobile-web-app')
|
||||
const outDir = join(scratch, 'mobile-web')
|
||||
const { manifest } = await buildMobileWebAppBundle({ outDir })
|
||||
// What a stale out/ actually looks like: every digest agrees with its bytes and the
|
||||
// buildId derives from the asset list, but the source has moved on. Only the two fresh
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import * as esbuild from 'esbuild'
|
||||
|
||||
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const sourceDir = join(projectDir, 'src', 'mobile-web')
|
||||
const defaultOutDir = join(projectDir, 'out', 'mobile-web')
|
||||
|
||||
export const MOBILE_WEB_BUNDLE_SCHEMA_VERSION = 1
|
||||
export const MOBILE_WEB_BUNDLE_ENTRYPOINT = 'index.html'
|
||||
|
||||
const CONTENT_TYPE_BY_EXTENSION = {
|
||||
css: 'text/css; charset=utf-8',
|
||||
html: 'text/html; charset=utf-8',
|
||||
js: 'text/javascript; charset=utf-8',
|
||||
png: 'image/png',
|
||||
// The Phase C app bundle emits images as same-origin assets rather than data: URLs, so each one
|
||||
// is content-hashed and served from here. Fonts are absent by design: the policy sets
|
||||
// font-src 'none'.
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml'
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical serialization the buildId hashes. Key order is fixed and the list is sorted by path,
|
||||
* so the id is a pure function of content. Must stay byte-identical to the contract module's
|
||||
* serializer in src/shared/mobile-web-bundle/.
|
||||
*/
|
||||
export function serializeMobileWebBundleAssets(assets) {
|
||||
return JSON.stringify(
|
||||
[...assets]
|
||||
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
||||
.map(({ path, sha256, byteLength, contentType }) => ({
|
||||
path,
|
||||
sha256,
|
||||
byteLength,
|
||||
contentType
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
export function computeMobileWebBundleBuildId(assets) {
|
||||
return createHash('sha256').update(serializeMobileWebBundleAssets(assets), 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
export function sha256Hex(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
export function contentTypeForExtension(extension) {
|
||||
const contentType = CONTENT_TYPE_BY_EXTENSION[extension]
|
||||
if (!contentType) {
|
||||
throw new Error(`[build-mobile-web-bundle] no content type registered for .${extension}`)
|
||||
}
|
||||
return contentType
|
||||
}
|
||||
|
||||
function readIntegerConstant(source, name) {
|
||||
const match = new RegExp(`export const ${name} = (\\d+)`).exec(source)
|
||||
if (!match) {
|
||||
throw new Error(`[build-mobile-web-bundle] ${name} not found in src/shared/protocol-version.ts`)
|
||||
}
|
||||
return Number.parseInt(match[1], 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed rather than imported because protocol-version.ts is TypeScript and this script runs on
|
||||
* bare node during packaging, before any build output exists.
|
||||
*/
|
||||
export async function readProtocolWindow() {
|
||||
const source = await readFile(join(projectDir, 'src', 'shared', 'protocol-version.ts'), 'utf8')
|
||||
return {
|
||||
runtimeProtocolVersion: readIntegerConstant(source, 'RUNTIME_PROTOCOL_VERSION'),
|
||||
// The bundle is a client: the floor it cares about is the oldest host protocol it can talk to.
|
||||
minCompatibleRuntimeProtocolVersion: readIntegerConstant(
|
||||
source,
|
||||
'MIN_COMPATIBLE_RUNTIME_SERVER_VERSION'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function readDesktopVersion() {
|
||||
const packageJson = JSON.parse(await readFile(join(projectDir, 'package.json'), 'utf8'))
|
||||
if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
|
||||
throw new Error('[build-mobile-web-bundle] root package.json has no version')
|
||||
}
|
||||
return packageJson.version
|
||||
}
|
||||
|
||||
async function transformEntries(protocolWindow, desktopVersion) {
|
||||
const result = await esbuild.build({
|
||||
absWorkingDir: sourceDir,
|
||||
entryPoints: [join(sourceDir, 'src', 'bootstrap.ts'), join(sourceDir, 'src', 'bootstrap.css')],
|
||||
bundle: true,
|
||||
minify: true,
|
||||
// Virtual: write is false, so outdir only names the emitted files esbuild hands back.
|
||||
outdir: 'dist',
|
||||
write: false,
|
||||
format: 'iife',
|
||||
target: ['es2022'],
|
||||
charset: 'utf8',
|
||||
legalComments: 'none',
|
||||
// Why no sourcemap and no metafile: both embed absolute paths, which would break reproducibility.
|
||||
sourcemap: false,
|
||||
logLevel: 'silent',
|
||||
define: {
|
||||
ORCA_MOBILE_WEB_DESKTOP_VERSION: JSON.stringify(desktopVersion),
|
||||
ORCA_MOBILE_WEB_RUNTIME_PROTOCOL_VERSION: JSON.stringify(
|
||||
protocolWindow.runtimeProtocolVersion
|
||||
),
|
||||
ORCA_MOBILE_WEB_MIN_COMPATIBLE_RUNTIME_PROTOCOL_VERSION: JSON.stringify(
|
||||
protocolWindow.minCompatibleRuntimeProtocolVersion
|
||||
)
|
||||
}
|
||||
})
|
||||
const byExtension = new Map()
|
||||
for (const file of result.outputFiles) {
|
||||
const extension = file.path.endsWith('.css') ? 'css' : 'js'
|
||||
byExtension.set(extension, Buffer.from(file.contents))
|
||||
}
|
||||
const script = byExtension.get('js')
|
||||
const stylesheet = byExtension.get('css')
|
||||
if (!script || !stylesheet) {
|
||||
throw new Error('[build-mobile-web-bundle] esbuild did not emit both a script and a stylesheet')
|
||||
}
|
||||
return { script, stylesheet }
|
||||
}
|
||||
|
||||
export function hashedAsset(bytes, extension) {
|
||||
const sha256 = sha256Hex(bytes)
|
||||
return {
|
||||
bytes,
|
||||
path: `assets/${sha256}.${extension}`,
|
||||
sha256,
|
||||
byteLength: bytes.byteLength,
|
||||
contentType: contentTypeForExtension(extension)
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildMobileWebBundle({ outDir = defaultOutDir } = {}) {
|
||||
const [desktopVersion, protocolWindow] = await Promise.all([
|
||||
readDesktopVersion(),
|
||||
readProtocolWindow()
|
||||
])
|
||||
const { script, stylesheet } = await transformEntries(protocolWindow, desktopVersion)
|
||||
const mark = await readFile(join(sourceDir, 'src', 'orca-mark.png'))
|
||||
|
||||
const hashed = [
|
||||
hashedAsset(script, 'js'),
|
||||
hashedAsset(stylesheet, 'css'),
|
||||
hashedAsset(mark, 'png')
|
||||
]
|
||||
const [scriptAsset, stylesheetAsset, markAsset] = hashed
|
||||
|
||||
const template = await readFile(join(sourceDir, MOBILE_WEB_BUNDLE_ENTRYPOINT), 'utf8')
|
||||
const substitutions = {
|
||||
__ORCA_BOOTSTRAP_JS__: scriptAsset.path,
|
||||
__ORCA_BOOTSTRAP_CSS__: stylesheetAsset.path,
|
||||
__ORCA_MARK_PNG__: markAsset.path
|
||||
}
|
||||
let html = template
|
||||
for (const [token, value] of Object.entries(substitutions)) {
|
||||
if (!html.includes(token)) {
|
||||
throw new Error(`[build-mobile-web-bundle] ${MOBILE_WEB_BUNDLE_ENTRYPOINT} lacks ${token}`)
|
||||
}
|
||||
html = html.replaceAll(token, value)
|
||||
}
|
||||
const indexBytes = Buffer.from(html, 'utf8')
|
||||
const indexAsset = {
|
||||
bytes: indexBytes,
|
||||
path: MOBILE_WEB_BUNDLE_ENTRYPOINT,
|
||||
sha256: sha256Hex(indexBytes),
|
||||
byteLength: indexBytes.byteLength,
|
||||
contentType: contentTypeForExtension('html')
|
||||
}
|
||||
|
||||
return writeMobileWebBundleTree({
|
||||
outDir,
|
||||
written: [indexAsset, ...hashed],
|
||||
desktopVersion,
|
||||
protocolWindow
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Manifest assembly and the on-disk write, shared by the Phase A bootstrap bundle and the Phase C
|
||||
* app bundle so both produce the same manifest shape the contract module and verifier read.
|
||||
*/
|
||||
export async function writeMobileWebBundleTree({
|
||||
outDir,
|
||||
written,
|
||||
desktopVersion,
|
||||
protocolWindow,
|
||||
// Empty for the Phase A bootstrap, which carries no route tree at all: a shell reading it finds
|
||||
// no screen listed and renders every route natively, which is what it already does.
|
||||
routes = []
|
||||
}) {
|
||||
const assets = written
|
||||
.map(({ path, sha256, byteLength, contentType }) => ({ path, sha256, byteLength, contentType }))
|
||||
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
||||
const manifest = {
|
||||
schemaVersion: MOBILE_WEB_BUNDLE_SCHEMA_VERSION,
|
||||
buildId: computeMobileWebBundleBuildId(assets),
|
||||
desktopVersion,
|
||||
minCompatibleRuntimeProtocolVersion: protocolWindow.minCompatibleRuntimeProtocolVersion,
|
||||
runtimeProtocolVersion: protocolWindow.runtimeProtocolVersion,
|
||||
entrypoint: MOBILE_WEB_BUNDLE_ENTRYPOINT,
|
||||
totalBytes: assets.reduce((total, asset) => total + asset.byteLength, 0),
|
||||
assets,
|
||||
routes
|
||||
}
|
||||
|
||||
// Why a full clear: a stale asset left from an earlier build would ship unreferenced inside asar.
|
||||
await rm(outDir, { recursive: true, force: true })
|
||||
await mkdir(join(outDir, 'assets'), { recursive: true })
|
||||
for (const asset of written) {
|
||||
await writeFile(join(outDir, asset.path), asset.bytes)
|
||||
}
|
||||
await writeFile(join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
||||
return { manifest, outDir }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this module was run as the entry script. Two ways to get this wrong, both of which end
|
||||
* with the builder exiting 0 having written nothing: `file://${path}` never matches on Windows,
|
||||
* where import.meta.url is `file:///C:/...`; and Node resolves symlinks in import.meta.url but not
|
||||
* in argv[1], so `node /tmp/...` against a /private/tmp realpath compares two different strings.
|
||||
* Both seams are injectable so win32 and a missing path can be exercised from a posix runner.
|
||||
*/
|
||||
export function isDirectInvocation(
|
||||
moduleUrl,
|
||||
scriptPath,
|
||||
{ toFileUrl = pathToFileURL, realpath = realpathSync } = {}
|
||||
) {
|
||||
if (!scriptPath) {
|
||||
return false
|
||||
}
|
||||
let resolved = scriptPath
|
||||
try {
|
||||
resolved = realpath(scriptPath)
|
||||
} catch {
|
||||
// A path that cannot be resolved cannot be this module; fall through to the literal compare.
|
||||
}
|
||||
return moduleUrl === toFileUrl(resolved).href
|
||||
}
|
||||
|
||||
if (isDirectInvocation(import.meta.url, process.argv[1])) {
|
||||
const { manifest, outDir } = await buildMobileWebBundle()
|
||||
console.log(
|
||||
`[build-mobile-web-bundle] OK — ${String(manifest.assets.length)} asset(s), ` +
|
||||
`${String(manifest.totalBytes)} bytes, buildId ${manifest.buildId} -> ${outDir}`
|
||||
)
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
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',
|
||||
'routes'
|
||||
])
|
||||
expect(manifest.schemaVersion).toBe(1)
|
||||
expect(manifest.entrypoint).toBe('index.html')
|
||||
// The bootstrap bundle carries no route tree, so a shell reading this one finds no screen
|
||||
// listed and renders every route natively.
|
||||
expect(manifest.routes).toEqual([])
|
||||
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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { buildMobileWebBundle } from './build-mobile-web-bundle.mjs'
|
||||
import { writeMobileWebBundleFixtureTree } from './mobile-web-bundle-fixture-tree.mjs'
|
||||
|
||||
const REPO_ROOT = join(import.meta.dirname, '..', '..')
|
||||
const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main')
|
||||
@@ -452,7 +452,7 @@ describe('arch-aware packaging guard', () => {
|
||||
beforeAll(async () => {
|
||||
scratch = await mkdtemp(join(tmpdir(), 'orca-electron-builder-guard-'))
|
||||
bundleDir = join(scratch, 'mobile-web')
|
||||
await buildMobileWebBundle({ outDir: bundleDir })
|
||||
await writeMobileWebBundleFixtureTree({ outDir: bundleDir })
|
||||
})
|
||||
afterAll(async () => {
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
/**
|
||||
* Every mobile release is the native app unless one workflow input says otherwise.
|
||||
*
|
||||
* The phone reads `EXPO_PUBLIC_MOBILE_SHELL` once, at build time, through Expo's env inlining — so
|
||||
* the only thing standing between a scheduled or tag-triggered release and a binary that mounts
|
||||
* the web page is what these two workflows put in that variable. A default that drifted, or a
|
||||
* build step that stopped carrying the variable, would ship the wrong app with nothing else
|
||||
* failing.
|
||||
*/
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const SWITCH = 'EXPO_PUBLIC_MOBILE_SHELL'
|
||||
const RELEASE_WORKFLOWS = {
|
||||
android: {
|
||||
file: 'mobile-android-release.yml',
|
||||
job: 'android-build',
|
||||
step: 'Build Android release APK'
|
||||
},
|
||||
ios: { file: 'mobile-ios-release.yml', job: 'ios-build', step: 'Build and upload to TestFlight' }
|
||||
}
|
||||
|
||||
function workflowOf(file) {
|
||||
return parse(readFileSync(resolve(projectDir, '.github/workflows', file), 'utf8'))
|
||||
}
|
||||
|
||||
function stepsOf(workflow) {
|
||||
return Object.entries(workflow.jobs).flatMap(([job, body]) =>
|
||||
(body.steps ?? []).map((step) => ({ job, step }))
|
||||
)
|
||||
}
|
||||
|
||||
/** The steps that hand the switch down to whatever they run. */
|
||||
function switchCarriers(workflow) {
|
||||
return stepsOf(workflow).filter(({ step }) => step.env?.[SWITCH] !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* What GitHub does with `${{ inputs.<name> || '<fallback>' }}`, and the whole reason a run with no
|
||||
* inputs reads native: on a `push` or a `schedule` the `inputs` context is null, and `||` yields
|
||||
* its right operand for null and for the empty string alike.
|
||||
*
|
||||
* Narrow on purpose. Anything but this one expression shape is a failure rather than something to
|
||||
* interpret, because a shape this test cannot evaluate is one it cannot make a claim about.
|
||||
*/
|
||||
function evaluateInputExpression(expression, inputs) {
|
||||
const match = /^\$\{\{\s*inputs\.([A-Za-z_]\w*)\s*\|\|\s*'([^']*)'\s*\}\}$/.exec(expression)
|
||||
expect(match, `unevaluatable expression: ${expression}`).not.toBeNull()
|
||||
const [, name, fallback] = match
|
||||
const supplied = inputs?.[name]
|
||||
return supplied === undefined || supplied === null || supplied === '' ? fallback : supplied
|
||||
}
|
||||
|
||||
describe.each(Object.entries(RELEASE_WORKFLOWS))(
|
||||
'the %s release workflow',
|
||||
(_platform, { file, job, step: stepName }) => {
|
||||
const workflow = workflowOf(file)
|
||||
|
||||
it('offers the shell as a two-option choice that defaults to native', () => {
|
||||
const input = workflow.on.workflow_dispatch.inputs.shell
|
||||
|
||||
expect(input.type).toBe('choice')
|
||||
expect(input.options).toEqual(['native', 'ota'])
|
||||
expect(input.default).toBe('native')
|
||||
expect(input.description).toMatch(/native/i)
|
||||
expect(input.description).toMatch(/ota/i)
|
||||
})
|
||||
|
||||
it('hands the switch to the step that bundles the JavaScript, and to no other step', () => {
|
||||
const carriers = switchCarriers(workflow)
|
||||
|
||||
expect(carriers.map(({ job: owner, step }) => `${owner}: ${step.name}`)).toEqual([
|
||||
`${job}: ${stepName}`
|
||||
])
|
||||
})
|
||||
|
||||
it('builds native when no input was supplied, which is every tag push and every schedule', () => {
|
||||
const { step } = switchCarriers(workflow)[0]
|
||||
|
||||
expect(evaluateInputExpression(step.env[SWITCH], undefined)).toBe('native')
|
||||
expect(evaluateInputExpression(step.env[SWITCH], {})).toBe('native')
|
||||
expect(evaluateInputExpression(step.env[SWITCH], { shell: '' })).toBe('native')
|
||||
expect(evaluateInputExpression(step.env[SWITCH], { shell: 'native' })).toBe('native')
|
||||
expect(evaluateInputExpression(step.env[SWITCH], { shell: 'ota' })).toBe('ota')
|
||||
})
|
||||
|
||||
it('prints the value it is about to build with, read from the same variable', () => {
|
||||
const { step } = switchCarriers(workflow)[0]
|
||||
|
||||
// Not a second copy of the expression: a log line built from its own literal could disagree
|
||||
// with the build beside it, and a run would then report a shell it did not ship.
|
||||
expect(step.run).toContain(`echo "Mobile shell: $${SWITCH}"`)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves the switch out of every other workflow, so only a release can set it', () => {
|
||||
const workflows = ['mobile.yml', 'pr.yml', 'mobile-android-release.yml', 'mobile-ios-release.yml']
|
||||
const setters = workflows.filter((file) => switchCarriers(workflowOf(file)).length > 0)
|
||||
|
||||
expect(setters).toEqual(['mobile-android-release.yml', 'mobile-ios-release.yml'])
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
MOBILE_WEB_BUNDLE_ENTRYPOINT,
|
||||
contentTypeForExtension,
|
||||
hashedAsset,
|
||||
readDesktopVersion,
|
||||
readProtocolWindow,
|
||||
sha256Hex,
|
||||
writeMobileWebBundleTree
|
||||
} from './mobile-web-bundle-manifest.mjs'
|
||||
|
||||
/**
|
||||
* A small, real bundle tree for the suites that test the packaging guard rather than the page.
|
||||
*
|
||||
* The guard reads a manifest and the bytes beside it; what those bytes are is not its business.
|
||||
* Building the app bundle to get them would make every one of those cases pay for esbuild over
|
||||
* the whole mobile graph, and would fail for a reason that has nothing to do with the guard on any
|
||||
* machine where mobile's dependencies are absent.
|
||||
*
|
||||
* Real rather than hand-written: it goes through the same manifest writer the page does, so a
|
||||
* change to the manifest shape reaches these suites instead of leaving them asserting a shape
|
||||
* nothing produces any more.
|
||||
*/
|
||||
/** A 1x1 transparent PNG, the smallest real image the content-type rule accepts. */
|
||||
const PIXEL_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
)
|
||||
|
||||
export async function writeMobileWebBundleFixtureTree({ outDir, body = 'fixture' }) {
|
||||
const [desktopVersion, protocolWindow] = await Promise.all([
|
||||
readDesktopVersion(),
|
||||
readProtocolWindow()
|
||||
])
|
||||
// One asset per content type the guard's own cases reach for by extension: a script, a
|
||||
// stylesheet and an image. Fewer would make those cases read `undefined.path` rather than fail.
|
||||
const script = hashedAsset(
|
||||
Buffer.from(`globalThis.orca = ${JSON.stringify(body)}\n`, 'utf8'),
|
||||
'js'
|
||||
)
|
||||
const stylesheet = hashedAsset(Buffer.from(`:root{--orca:${body}}\n`, 'utf8'), 'css')
|
||||
const image = hashedAsset(PIXEL_PNG, 'png')
|
||||
const indexBytes = Buffer.from(
|
||||
`<!doctype html><meta charset="utf-8">` +
|
||||
`<link rel="stylesheet" href="/${stylesheet.path}">` +
|
||||
`<img src="/${image.path}" alt="">` +
|
||||
`<script src="/${script.path}"></script>\n`,
|
||||
'utf8'
|
||||
)
|
||||
return writeMobileWebBundleTree({
|
||||
outDir,
|
||||
written: [
|
||||
{
|
||||
bytes: indexBytes,
|
||||
path: MOBILE_WEB_BUNDLE_ENTRYPOINT,
|
||||
sha256: sha256Hex(indexBytes),
|
||||
byteLength: indexBytes.byteLength,
|
||||
contentType: contentTypeForExtension('html')
|
||||
},
|
||||
script,
|
||||
stylesheet,
|
||||
image
|
||||
],
|
||||
desktopVersion,
|
||||
protocolWindow
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
||||
|
||||
export const MOBILE_WEB_BUNDLE_SCHEMA_VERSION = 1
|
||||
export const MOBILE_WEB_BUNDLE_ENTRYPOINT = 'index.html'
|
||||
|
||||
const CONTENT_TYPE_BY_EXTENSION = {
|
||||
css: 'text/css; charset=utf-8',
|
||||
html: 'text/html; charset=utf-8',
|
||||
js: 'text/javascript; charset=utf-8',
|
||||
png: 'image/png',
|
||||
// The app bundle emits images as same-origin assets rather than data: URLs, so each one is
|
||||
// content-hashed and served from here. Fonts are absent by design: the policy sets
|
||||
// font-src 'none'.
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml'
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical serialization the buildId hashes. Key order is fixed and the list is sorted by path,
|
||||
* so the id is a pure function of content. Must stay byte-identical to the contract module's
|
||||
* serializer in src/shared/mobile-web-bundle/.
|
||||
*/
|
||||
export function serializeMobileWebBundleAssets(assets) {
|
||||
return JSON.stringify(
|
||||
[...assets]
|
||||
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
||||
.map(({ path, sha256, byteLength, contentType }) => ({
|
||||
path,
|
||||
sha256,
|
||||
byteLength,
|
||||
contentType
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
export function computeMobileWebBundleBuildId(assets) {
|
||||
return createHash('sha256').update(serializeMobileWebBundleAssets(assets), 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
export function sha256Hex(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
export function contentTypeForExtension(extension) {
|
||||
const contentType = CONTENT_TYPE_BY_EXTENSION[extension]
|
||||
if (!contentType) {
|
||||
throw new Error(`[mobile-web-bundle-manifest] no content type registered for .${extension}`)
|
||||
}
|
||||
return contentType
|
||||
}
|
||||
|
||||
export function hashedAsset(bytes, extension) {
|
||||
const sha256 = sha256Hex(bytes)
|
||||
return {
|
||||
bytes,
|
||||
path: `assets/${sha256}.${extension}`,
|
||||
sha256,
|
||||
byteLength: bytes.byteLength,
|
||||
contentType: contentTypeForExtension(extension)
|
||||
}
|
||||
}
|
||||
|
||||
function readIntegerConstant(source, name) {
|
||||
const match = new RegExp(`export const ${name} = (\\d+)`).exec(source)
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`[mobile-web-bundle-manifest] ${name} not found in src/shared/protocol-version.ts`
|
||||
)
|
||||
}
|
||||
return Number.parseInt(match[1], 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed rather than imported because protocol-version.ts is TypeScript and this script runs on
|
||||
* bare node during packaging, before any build output exists.
|
||||
*/
|
||||
export async function readProtocolWindow() {
|
||||
const source = await readFile(join(projectDir, 'src', 'shared', 'protocol-version.ts'), 'utf8')
|
||||
return {
|
||||
runtimeProtocolVersion: readIntegerConstant(source, 'RUNTIME_PROTOCOL_VERSION'),
|
||||
// The bundle is a client: the floor it cares about is the oldest host protocol it can talk to.
|
||||
minCompatibleRuntimeProtocolVersion: readIntegerConstant(
|
||||
source,
|
||||
'MIN_COMPATIBLE_RUNTIME_SERVER_VERSION'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function readDesktopVersion() {
|
||||
const packageJson = JSON.parse(await readFile(join(projectDir, 'package.json'), 'utf8'))
|
||||
if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
|
||||
throw new Error('[mobile-web-bundle-manifest] root package.json has no version')
|
||||
}
|
||||
return packageJson.version
|
||||
}
|
||||
|
||||
/**
|
||||
* Manifest assembly and the on-disk write, kept apart from any one builder so the manifest shape
|
||||
* the contract module and the packaging guard read has a single producer.
|
||||
*/
|
||||
export async function writeMobileWebBundleTree({
|
||||
outDir,
|
||||
written,
|
||||
desktopVersion,
|
||||
protocolWindow,
|
||||
// Empty for a tree with no route list at all: a shell reading it finds no screen listed and
|
||||
// renders every route natively.
|
||||
routes = []
|
||||
}) {
|
||||
const assets = written
|
||||
.map(({ path, sha256, byteLength, contentType }) => ({ path, sha256, byteLength, contentType }))
|
||||
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
||||
const manifest = {
|
||||
schemaVersion: MOBILE_WEB_BUNDLE_SCHEMA_VERSION,
|
||||
buildId: computeMobileWebBundleBuildId(assets),
|
||||
desktopVersion,
|
||||
minCompatibleRuntimeProtocolVersion: protocolWindow.minCompatibleRuntimeProtocolVersion,
|
||||
runtimeProtocolVersion: protocolWindow.runtimeProtocolVersion,
|
||||
entrypoint: MOBILE_WEB_BUNDLE_ENTRYPOINT,
|
||||
totalBytes: assets.reduce((total, asset) => total + asset.byteLength, 0),
|
||||
assets,
|
||||
routes
|
||||
}
|
||||
|
||||
// Why a full clear: a stale asset left from an earlier build would ship unreferenced inside asar.
|
||||
await rm(outDir, { recursive: true, force: true })
|
||||
await mkdir(join(outDir, 'assets'), { recursive: true })
|
||||
for (const asset of written) {
|
||||
await writeFile(join(outDir, asset.path), asset.bytes)
|
||||
}
|
||||
await writeFile(join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
||||
return { manifest, outDir }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
computeMobileWebBundleBuildId,
|
||||
contentTypeForExtension,
|
||||
hashedAsset,
|
||||
serializeMobileWebBundleAssets
|
||||
} from './mobile-web-bundle-manifest.mjs'
|
||||
|
||||
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('hashedAsset', () => {
|
||||
it('names an asset by its own digest, which is what makes the tree content-addressed', () => {
|
||||
const asset = hashedAsset(Buffer.from('body', 'utf8'), 'js')
|
||||
|
||||
expect(asset.path).toBe(`assets/${asset.sha256}.js`)
|
||||
expect(asset.sha256).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(asset.byteLength).toBe(4)
|
||||
expect(asset.contentType).toBe(contentTypeForExtension('js'))
|
||||
})
|
||||
|
||||
it('refuses an extension with no registered content type', () => {
|
||||
// A type the phone has no rule for would otherwise reach the manifest and be served as
|
||||
// whatever the shell guessed, which is the one thing a content-addressed tree cannot allow.
|
||||
expect(() => hashedAsset(Buffer.from('body'), 'wasm')).toThrow(/no content type registered/)
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
computeMobileWebBundleBuildId,
|
||||
serializeMobileWebBundleAssets as serializeInBuilder
|
||||
} from './build-mobile-web-bundle.mjs'
|
||||
} from './mobile-web-bundle-manifest.mjs'
|
||||
import {
|
||||
computeMobileWebBundleId,
|
||||
MobileWebBundleManifestSchema,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
buildMobileWebAppBundle,
|
||||
resolveMobileWebPageRoutes
|
||||
} from './build-mobile-web-app-bundle.mjs'
|
||||
import { computeMobileWebBundleBuildId } from './build-mobile-web-bundle.mjs'
|
||||
import { computeMobileWebBundleBuildId } from './mobile-web-bundle-manifest.mjs'
|
||||
import {
|
||||
collectMobileWebAppRouteKeys,
|
||||
routePathnameFromKey
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Pinned `-text` in .gitattributes and skipped below, because a 0x0d in them means nothing. .svg
|
||||
* is absent on purpose: it is text, so the eol=lf pin applies and a CRLF .svg forks the buildId.
|
||||
* A test keeps this list and the .gitattributes exemptions in step.
|
||||
*/
|
||||
export const BINARY_SOURCE_EXTENSIONS = [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.ico',
|
||||
'.webp',
|
||||
'.ttf',
|
||||
'.otf',
|
||||
'.woff',
|
||||
'.woff2'
|
||||
]
|
||||
|
||||
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) {
|
||||
const offenders = []
|
||||
for (const file of await listSourceFiles(directory)) {
|
||||
if (BINARY_SOURCE_EXTENSIONS.some((extension) => file.endsWith(extension))) {
|
||||
continue
|
||||
}
|
||||
// Written by mobile's postinstall, gitignored, so no eol pin applies and none is needed.
|
||||
if (file.endsWith('.generated.ts')) {
|
||||
continue
|
||||
}
|
||||
if ((await readFile(file)).includes(0x0d)) {
|
||||
offenders.push(file.slice(directory.length + 1))
|
||||
}
|
||||
}
|
||||
if (offenders.length > 0) {
|
||||
throw new Error(
|
||||
`CRLF in mobile web source, which would change every asset hash and the buildId: ` +
|
||||
`${offenders.join(', ')}. Check the .gitattributes eol=lf pin for ${directory}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BINARY_SOURCE_EXTENSIONS,
|
||||
assertNoCarriageReturnsInSource
|
||||
} from './mobile-web-source-line-endings.mjs'
|
||||
|
||||
/**
|
||||
* `core.autocrlf=true` ships in the Git-for-Windows system config, so without a pin a Windows
|
||||
* runner checks the page's source out as CRLF. Every text byte under these trees is hashed into an
|
||||
* asset digest and from there into the buildId, so the same commit would ship a different bundle
|
||||
* id from a Windows runner than from a Linux one, and a phone that had the Linux build cached
|
||||
* would fetch the whole page again.
|
||||
*/
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const PAGE_SOURCE_TREES = ['mobile/src', 'mobile/app', 'mobile/web-entry']
|
||||
|
||||
function git(args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
}
|
||||
|
||||
/** `git check-attr -z` emits NUL-separated path/attr/value triples. Paths go over stdin because
|
||||
* the page's trees hold thousands of files, well past a single argv. */
|
||||
function attributes(name, paths) {
|
||||
const output = execFileSync('git', ['check-attr', '-z', name, '--stdin'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
// NUL-separated on the way in as well: with -z that is what git parses, and a newline
|
||||
// would make the whole list one path whose name carries the others.
|
||||
input: `${paths.join('\0')}\0`,
|
||||
maxBuffer: 256 * 1024 * 1024
|
||||
})
|
||||
const fields = output.split('\0')
|
||||
const found = new Map()
|
||||
for (let index = 0; index + 2 < fields.length; index += 3) {
|
||||
found.set(fields[index], fields[index + 2])
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function trackedPageSources() {
|
||||
return git(['ls-files', '-z', '--', ...PAGE_SOURCE_TREES])
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
describe('the page source line-ending pin', () => {
|
||||
it('pins every tracked text source to LF and leaves the binaries alone', () => {
|
||||
const sources = trackedPageSources()
|
||||
expect(sources.length).toBeGreaterThan(0)
|
||||
|
||||
const eol = attributes('eol', sources)
|
||||
const text = attributes('text', sources)
|
||||
const binary = sources.filter((path) =>
|
||||
BINARY_SOURCE_EXTENSIONS.some((extension) => path.endsWith(extension))
|
||||
)
|
||||
const unpinnedText = sources.filter((path) => !binary.includes(path) && eol.get(path) !== 'lf')
|
||||
const pinnedBinary = binary.filter((path) => text.get(path) !== 'unset')
|
||||
|
||||
expect(unpinnedText, 'a CRLF checkout of these would fork the buildId').toEqual([])
|
||||
expect(pinnedBinary, 'a binary marked text would be rewritten on a Windows checkout').toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
// The assertion above only sees files that exist today. These fix the pattern itself: broad
|
||||
// enough to cover a file added tomorrow, narrow enough not to claim a neighbouring tree.
|
||||
it.each([
|
||||
['mobile/src/deep/nested/module.ts', 'eol', 'lf'],
|
||||
['mobile/app/h/[hostId]/new-route.tsx', 'eol', 'lf'],
|
||||
['mobile/web-entry/index.tsx', 'eol', 'lf'],
|
||||
['mobile/src/assets/new.png', 'text', 'unset'],
|
||||
['mobile/app/assets/new.woff2', 'text', 'unset'],
|
||||
['mobile/scripts/build-something.mjs', 'eol', 'unspecified']
|
||||
])('resolves %s to %s=%s', (path, name, expected) => {
|
||||
expect(attributes(name, [path]).get(path)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertNoCarriageReturnsInSource', () => {
|
||||
it('accepts every committed page source tree', async () => {
|
||||
for (const tree of PAGE_SOURCE_TREES) {
|
||||
await expect(assertNoCarriageReturnsInSource(join(projectDir, tree))).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, 'route.tsx'), '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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -107,14 +107,17 @@ const ORCAD_BROWSER_PREFIXES = [
|
||||
'src/main/orcad/electron-serve-browser-process'
|
||||
]
|
||||
|
||||
// The Route A page bundle: the builder and verifier, the entry, the route tree it mounts, the
|
||||
// mobile source those routes import, and the shell policy the render check runs the page under.
|
||||
// The page bundle the desktop packages: the builder and verifier, the manifest writer and the
|
||||
// packaging guard they share, the entry, the route tree it mounts, the mobile source those routes
|
||||
// import, and the shell policy the render check runs the page under.
|
||||
const MOBILE_WEB_APP_PREFIXES = [
|
||||
'config/scripts/build-mobile-web-app',
|
||||
'config/scripts/verify-mobile-web-app-bundle',
|
||||
'config/scripts/mobile-web-app-',
|
||||
'config/scripts/build-mobile-web-bundle',
|
||||
'config/scripts/verify-mobile-web-bundle',
|
||||
'config/scripts/mobile-web-bundle-',
|
||||
'config/scripts/verify-packaged-mobile-web-bundle',
|
||||
'config/scripts/mobile-web-source-line-endings',
|
||||
'config/scripts/script-entry-detection',
|
||||
'mobile/web-entry/',
|
||||
'mobile/app/',
|
||||
'mobile/src/',
|
||||
|
||||
@@ -277,7 +277,7 @@ describe('per-job path classification', () => {
|
||||
})
|
||||
|
||||
it('needs no package.json prefix, because package.json already forces every job', () => {
|
||||
// build:mobile-web:app is defined there, so the job has to run on an edit to it. A prefix
|
||||
// build:mobile-web is defined there, so the job has to run on an edit to it. A prefix
|
||||
// that broad is not how: GLOBAL_FORCE_FILES already covers the file.
|
||||
expect(classifyPrJobs(['package.json']).mobile_web_app).toBe(true)
|
||||
})
|
||||
|
||||
@@ -11,8 +11,7 @@ const BYTES_PER_GIB = 1024 ** 3
|
||||
export const TYPECHECK_PROJECTS = [
|
||||
{ config: 'tsconfig.node.json', heapGib: 7 },
|
||||
{ config: 'tsconfig.tc.web.json', heapGib: 6 },
|
||||
{ config: 'tsconfig.tc.cli.json', heapGib: 2 },
|
||||
{ config: 'tsconfig.mobile-web.json', heapGib: 1 }
|
||||
{ config: 'tsconfig.tc.cli.json', heapGib: 2 }
|
||||
]
|
||||
|
||||
// The OS, node itself, and the runner agent need their share; the rest is what tsc may hold.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
/**
|
||||
* Whether this module was run as the entry script. Two ways to get this wrong, both of which end
|
||||
* with a 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
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isDirectInvocation } from './script-entry-detection.mjs'
|
||||
|
||||
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-app-bundle.mjs'
|
||||
const moduleUrl = 'file:///C:/orca/config/scripts/build-mobile-web-app-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('./script-entry-detection.mjs', import.meta.url), 'utf8')
|
||||
expect(source).not.toMatch(/file:\/\/\$\{process\.argv\[1\]\}/)
|
||||
expect(source).toContain('pathToFileURL')
|
||||
})
|
||||
})
|
||||
|
||||
describe('running a 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-script-entry-link-'))
|
||||
try {
|
||||
const moduleUrl = new URL('./script-entry-detection.mjs', import.meta.url).href
|
||||
const real = join(scratch, 'entry.mjs')
|
||||
await writeFile(
|
||||
real,
|
||||
`import { isDirectInvocation } from ${JSON.stringify(moduleUrl)}\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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,12 +4,14 @@ import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import * as esbuild from 'esbuild'
|
||||
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
|
||||
import { isDirectInvocation } from './build-mobile-web-bundle.mjs'
|
||||
import { assertNoCarriageReturnsInSource } from './verify-mobile-web-bundle.mjs'
|
||||
import { assertMobileWebBundleBuilt } from './verify-packaged-mobile-web-bundle.cjs'
|
||||
import { isDirectInvocation } from './script-entry-detection.mjs'
|
||||
import { assertNoCarriageReturnsInSource } from './mobile-web-source-line-endings.mjs'
|
||||
import {
|
||||
MOBILE_WEB_BUNDLE_DIR as defaultBundleDir,
|
||||
assertMobileWebBundleBuilt
|
||||
} from './verify-packaged-mobile-web-bundle.cjs'
|
||||
|
||||
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const defaultBundleDir = join(projectDir, 'out', 'mobile-web-app')
|
||||
const manifestContract = join(
|
||||
projectDir,
|
||||
'src',
|
||||
@@ -204,7 +206,7 @@ export function assertAssetCeilingFitsShell(routeCount, imageCount, shellMaxAsse
|
||||
async function buildIntoScratch() {
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-verify-'))
|
||||
try {
|
||||
return await buildMobileWebAppBundle({ outDir: join(scratch, 'mobile-web-app') })
|
||||
return await buildMobileWebAppBundle({ outDir: join(scratch, 'mobile-web') })
|
||||
} finally {
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinned `-text` in .gitattributes and skipped below, because a 0x0d in them means nothing. .svg
|
||||
* is absent on purpose: it is text, so the eol=lf pin applies and a CRLF .svg forks the buildId.
|
||||
* A test keeps this list and the .gitattributes exemptions in step.
|
||||
*/
|
||||
export const BINARY_SOURCE_EXTENSIONS = [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.ico',
|
||||
'.webp',
|
||||
'.ttf',
|
||||
'.otf',
|
||||
'.woff',
|
||||
'.woff2'
|
||||
]
|
||||
|
||||
/**
|
||||
* 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)) {
|
||||
if (BINARY_SOURCE_EXTENSIONS.some((extension) => file.endsWith(extension))) {
|
||||
continue
|
||||
}
|
||||
// Written by mobile's postinstall, gitignored, so no eol pin applies and none is needed.
|
||||
if (file.endsWith('.generated.ts')) {
|
||||
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 ${directory}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ function assertInteger(value, field) {
|
||||
|
||||
/**
|
||||
* Canonical serialization of the asset list. Must stay byte-identical to
|
||||
* serializeMobileWebBundleAssets in config/scripts/build-mobile-web-bundle.mjs; a divergence here
|
||||
* serializeMobileWebBundleAssets in config/scripts/mobile-web-bundle-manifest.mjs; a divergence here
|
||||
* would reject every honest bundle, so the two move together.
|
||||
*/
|
||||
function serializeAssets(assets) {
|
||||
|
||||
@@ -3,7 +3,7 @@ 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'
|
||||
import { writeMobileWebBundleFixtureTree } from './mobile-web-bundle-fixture-tree.mjs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const {
|
||||
@@ -17,7 +17,7 @@ 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 })
|
||||
const { manifest } = await writeMobileWebBundleFixtureTree({ outDir: bundleDir })
|
||||
await run({ bundleDir, manifest })
|
||||
} finally {
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
@@ -174,11 +174,10 @@ describe('assertMobileWebBundleBuilt', () => {
|
||||
})
|
||||
|
||||
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.
|
||||
it('excludes every repo source tree from app.asar', () => {
|
||||
// The page is built from mobile/, which this excludes wholesale; out/mobile-web is what ships.
|
||||
expect(electronBuilderConfig.files).toContain('!src{,/**/*}')
|
||||
expect(electronBuilderConfig.files).toContain('!mobile{,/**/*}')
|
||||
})
|
||||
|
||||
it('does not exclude the built bundle, so out/mobile-web ships like out/web', () => {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": ["../src/mobile-web/src/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": []
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import { ShellSwitchPendingScreen } from '../../../src/mobile-web-shell/ShellSwi
|
||||
import { useShellSwitchDecision } from '../../../src/mobile-web-shell/shell-switch-decision'
|
||||
|
||||
/**
|
||||
* The hybrid shell route, dark behind a development-only flag.
|
||||
* The hybrid shell route, dark behind a flag only some builds can turn on.
|
||||
*
|
||||
* With the flag off — which is every store build, since the only writer is the `__DEV__`
|
||||
* Troubleshoot toggle — this redirects and the screen is never constructed, so nothing is fetched,
|
||||
* written or swept. It sits under `app/h/[hostId]` so `HostProtocolGate` in that group's layout
|
||||
* still owns the `desktop-too-old` wall above it.
|
||||
* With the flag off — which is every native store build, one built without
|
||||
* `EXPO_PUBLIC_MOBILE_SHELL=ota` — this redirects and the screen is never constructed, so nothing
|
||||
* is fetched, written or swept. It sits under `app/h/[hostId]` so `HostProtocolGate` in that
|
||||
* group's layout still owns the `desktop-too-old` wall above it.
|
||||
*
|
||||
* Reachable by deep link and from the developer row only; no screen links here.
|
||||
* Reachable by deep link and from the Troubleshoot row only; no screen links here.
|
||||
*/
|
||||
export default function MobileWebShellRoute() {
|
||||
const { hostId } = useLocalSearchParams<{ hostId: string }>()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MobileWebBundleProbeRow } from '../src/diagnostics/mobile-web-bundle-pr
|
||||
import { MobileWebShellDevRow } from '../src/diagnostics/mobile-web-shell-dev-row'
|
||||
import { TroubleshootView } from '../src/diagnostics/troubleshoot-view'
|
||||
import { useTroubleshootDiagnostics } from '../src/diagnostics/use-troubleshoot-diagnostics'
|
||||
import { mobileWebShellFlagCanBeOn } from '../src/storage/preferences'
|
||||
|
||||
// Same guard as push-token.ts: `__DEV__` is undefined outside the React Native runtime. The import
|
||||
// above is static, so a release bundle still carries the row's graph and evaluates its hoisted
|
||||
@@ -23,9 +24,11 @@ export default function NativeTroubleshootRoute() {
|
||||
onBack={() => router.back()}
|
||||
onConnectionLog={() => router.push('/connection-log')}
|
||||
developerRow={
|
||||
isDevelopmentBuild ? (
|
||||
// The shell row wherever the flag can be on, which in an OTA build is the only way back to
|
||||
// the native screens. The bundle probe stays development-only: it fetches.
|
||||
mobileWebShellFlagCanBeOn() ? (
|
||||
<>
|
||||
<MobileWebBundleProbeRow />
|
||||
{isDevelopmentBuild ? <MobileWebBundleProbeRow /> : null}
|
||||
<MobileWebShellDevRow />
|
||||
</>
|
||||
) : null
|
||||
|
||||
@@ -9,11 +9,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
*/
|
||||
type Doubles = {
|
||||
stored: boolean
|
||||
buildKind: 'native' | 'ota'
|
||||
saves: { next: boolean; settle: () => void; fail: () => void }[]
|
||||
pushes: string[]
|
||||
}
|
||||
|
||||
const doubles = vi.hoisted((): Doubles => ({ stored: false, saves: [], pushes: [] }))
|
||||
const doubles = vi.hoisted((): Doubles => ({
|
||||
stored: false,
|
||||
buildKind: 'native',
|
||||
saves: [],
|
||||
pushes: []
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Pressable: 'Pressable',
|
||||
@@ -32,6 +38,7 @@ vi.mock('lucide-react-native', () => ({ LayoutTemplate: 'LayoutTemplate' }))
|
||||
vi.mock('../transport/host-store', () => ({ loadHosts: async () => [{ id: 'host-1' }] }))
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadMobileWebShellEnabled: async () => doubles.stored,
|
||||
mobileShellBuildKind: () => doubles.buildKind,
|
||||
saveMobileWebShellEnabled: (next: boolean) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
doubles.saves.push({
|
||||
@@ -78,10 +85,23 @@ async function toggle(tree: ReactTestRenderer, next: boolean): Promise<void> {
|
||||
describe('the hybrid shell developer row', () => {
|
||||
beforeEach(() => {
|
||||
doubles.stored = false
|
||||
doubles.buildKind = 'native'
|
||||
doubles.saves.length = 0
|
||||
doubles.pushes.length = 0
|
||||
})
|
||||
|
||||
// The row mounts in a development build and in an OTA build, so a label that said "dev" would be
|
||||
// wrong in the build where it is the only way back to the native screens.
|
||||
it.each([
|
||||
['native', 'Hybrid shell (development build)'],
|
||||
['ota', 'Hybrid shell (OTA build)']
|
||||
] as const)('names the %s build it is running in', async (buildKind, expected) => {
|
||||
doubles.buildKind = buildKind
|
||||
const tree = await mountRow()
|
||||
|
||||
expect(only(tree, 'mobile-web-shell-label').props.children).toBe(expected)
|
||||
})
|
||||
|
||||
it('offers neither the new position nor the route until the write lands', async () => {
|
||||
const tree = await mountRow()
|
||||
await toggle(tree, true)
|
||||
|
||||
@@ -3,18 +3,32 @@ import { Pressable, Switch, Text, View } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { LayoutTemplate } from 'lucide-react-native'
|
||||
import { loadHosts } from '../transport/host-store'
|
||||
import { loadMobileWebShellEnabled, saveMobileWebShellEnabled } from '../storage/preferences'
|
||||
import {
|
||||
loadMobileWebShellEnabled,
|
||||
mobileShellBuildKind,
|
||||
saveMobileWebShellEnabled
|
||||
} from '../storage/preferences'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { troubleshootScreenStyles as styles } from './troubleshoot-screen-styles'
|
||||
|
||||
/**
|
||||
* Development-only: the one caller of `saveMobileWebShellEnabled`, and the one way into the hybrid
|
||||
* shell route that is not a deep link.
|
||||
* The one caller of `saveMobileWebShellEnabled`, and the one way into the hybrid shell route that
|
||||
* is not a deep link.
|
||||
*
|
||||
* `app/troubleshoot.tsx` mounts it behind `__DEV__`, exactly as it mounts A5's probe row, so a
|
||||
* shipped build never renders the toggle and the flag it guards can only stay off. The route itself
|
||||
* reads the flag again rather than trusting this screen, because a deep link arrives without it.
|
||||
* `app/troubleshoot.tsx` mounts it wherever the flag can be on at all: a development build, or a
|
||||
* release built with `EXPO_PUBLIC_MOBILE_SHELL=ota`. A native store build never renders the toggle
|
||||
* and the flag it guards can only stay off there. The route itself reads the flag again rather
|
||||
* than trusting this screen, because a deep link arrives without it.
|
||||
*
|
||||
* The label names the build kind rather than saying "dev", because in an OTA build this row is the
|
||||
* only way back to the native screens and calling it a developer switch would misdescribe it.
|
||||
*/
|
||||
/** What this build is, not what the row is for: an OTA build is the page's own release binary. */
|
||||
const HYBRID_SHELL_LABELS = {
|
||||
native: 'Hybrid shell (development build)',
|
||||
ota: 'Hybrid shell (OTA build)'
|
||||
} as const
|
||||
|
||||
export function MobileWebShellDevRow() {
|
||||
const router = useRouter()
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null)
|
||||
@@ -40,7 +54,9 @@ export function MobileWebShellDevRow() {
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.checkRow}>
|
||||
<Text style={styles.checkLabel}>Hybrid shell (dev)</Text>
|
||||
<Text style={styles.checkLabel} testID="mobile-web-shell-label">
|
||||
{HYBRID_SHELL_LABELS[mobileShellBuildKind()]}
|
||||
</Text>
|
||||
<Switch
|
||||
testID="mobile-web-shell-flag"
|
||||
value={enabled === true}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function TroubleshootView({
|
||||
runDiagnostics: () => void
|
||||
onBack: () => void
|
||||
onConnectionLog: () => void
|
||||
/** Slot the route fills only under `__DEV__`; null in every shipped build. */
|
||||
/** Slot the route fills in a development build and in an OTA build; null in a native build. */
|
||||
developerRow?: ReactNode
|
||||
}) {
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
@@ -4,13 +4,22 @@ import { describe, expect, it } from 'vitest'
|
||||
import { censusSourceFiles } from '../test-support/census-source-files'
|
||||
|
||||
/**
|
||||
* The hybrid shell flag is the whole of what keeps this feature dark, so who touches it is a
|
||||
* product invariant rather than a convention. A second reader is how a dark feature stops being
|
||||
* dark: a launch-time sweep, a prefetch or a menu item that consults the flag would run in a store
|
||||
* build the moment anything flipped it, and none of those would fail a type check.
|
||||
* The hybrid shell flag is the whole of what keeps this feature dark in a native build, so who
|
||||
* touches it is a product invariant rather than a convention. A second reader is how a dark feature
|
||||
* stops being dark: a launch-time sweep, a prefetch or a menu item that consults the flag would run
|
||||
* in a native store build the moment anything flipped it, and none of those would fail a type
|
||||
* check.
|
||||
*
|
||||
* The build-kind fact is the same shape of invariant one level up. `EXPO_PUBLIC_MOBILE_SHELL` is
|
||||
* what a release workflow sets to build the OTA binary, and a second module spelling it would be a
|
||||
* second answer to "is this the page's build" that no type check would catch either.
|
||||
*/
|
||||
const MOBILE_ROOT = join(import.meta.dirname, '..', '..')
|
||||
const FLAG_KEY = 'orca:mobileWebShellEnabled'
|
||||
/** The inlined form. Prose may name the variable; this is the spelling that reads it. */
|
||||
const BUILD_SWITCH_READ = 'process.env.EXPO_PUBLIC_'
|
||||
/** The other spelling Expo's plugin inlines, which is how the rule above would be evaded. */
|
||||
const BUILD_SWITCH_BRACKET_READ = "process.env['EXPO_PUBLIC_"
|
||||
const DEFINITION = 'src/storage/preferences.ts'
|
||||
/** The one product reader, which only the shared switch decision asks. */
|
||||
const FLAG_HOOK = 'src/mobile-web-shell/use-mobile-web-shell-enabled.ts'
|
||||
@@ -42,6 +51,8 @@ const SWITCHED_ROUTES = [
|
||||
CATCH_ALL_ROUTE
|
||||
]
|
||||
const DEVELOPER_ROW = 'src/diagnostics/mobile-web-shell-dev-row.tsx'
|
||||
/** The one screen that decides whether that row mounts, which is a build-kind question. */
|
||||
const TROUBLESHOOT_ROUTE = 'app/troubleshoot.tsx'
|
||||
/** Every tree that ships in the app bundle, with the floor each must clear. `modules` is two files,
|
||||
* but it is where the native view lives and so the easiest place for a second reader to hide. */
|
||||
const TREES = { src: 200, app: 10, modules: 1 }
|
||||
@@ -100,6 +111,7 @@ describe('who touches the hybrid shell flag', () => {
|
||||
expect(paths).toContain(route)
|
||||
}
|
||||
expect(paths).toContain(DEVELOPER_ROW)
|
||||
expect(paths).toContain(TROUBLESHOOT_ROUTE)
|
||||
expect(paths).toContain(SHELL_VIEW)
|
||||
const trees = Object.keys(TREES)
|
||||
for (const [tree, floor] of Object.entries(TREES)) {
|
||||
@@ -112,6 +124,27 @@ describe('who touches the hybrid shell flag', () => {
|
||||
expect(filesContaining(FLAG_KEY)).toEqual([DEFINITION])
|
||||
})
|
||||
|
||||
it('reads the build-time switch in one module', () => {
|
||||
// `babel-preset-expo` inlines an `EXPO_PUBLIC_*` variable only where it is spelled as a
|
||||
// literal member expression of `process.env`, so a second read is a second literal to keep in
|
||||
// step with the workflow input — and a value copied out into a binding is not rewritten at
|
||||
// all, which reads `undefined` on a device and answers native in a build that is not.
|
||||
//
|
||||
// Prose is not a read: the docblocks that name the variable are how a reader finds this
|
||||
// module, and pinning them would make every wording change a census failure.
|
||||
expect(filesContaining(BUILD_SWITCH_READ), matchesOf(BUILD_SWITCH_READ)).toEqual([DEFINITION])
|
||||
expect(
|
||||
filesContaining(BUILD_SWITCH_BRACKET_READ),
|
||||
matchesOf(BUILD_SWITCH_BRACKET_READ)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('answers the build kind through one named function, asked by the flag and the row label', () => {
|
||||
expect(filesContaining('mobileShellBuildKind'), matchesOf('mobileShellBuildKind')).toEqual(
|
||||
[DEFINITION, DEVELOPER_ROW].sort()
|
||||
)
|
||||
})
|
||||
|
||||
it('is read by one hook and by the developer row that writes it, and nowhere else', () => {
|
||||
expect(filesContaining('loadMobileWebShellEnabled')).toEqual(
|
||||
[DEFINITION, DEVELOPER_ROW, FLAG_HOOK].sort()
|
||||
@@ -152,15 +185,20 @@ describe('who touches the hybrid shell flag', () => {
|
||||
).toEqual([PENDING_SCREEN, ROUTE, ...SWITCHED_ROUTES].sort())
|
||||
})
|
||||
|
||||
it('fences the build kind in one place, which both the read and the hook ask', () => {
|
||||
// The `__DEV__` test that makes a store build unable to turn the flag on. The hook starts its
|
||||
// state on it so a release build never reaches the neutral state, which is the same answer
|
||||
// `loadMobileWebShellEnabled` gives one render later — and two spellings of one build-kind
|
||||
// test are two things to keep true, where this feature's darkness rests on exactly one.
|
||||
it('fences the build kind in one place, which the read, the hook and the row gate ask', () => {
|
||||
// The test that makes a native store build unable to turn the flag on: `__DEV__`, or a binary
|
||||
// built with the switch set to `ota`. The hook starts its state on it so a native release
|
||||
// build never reaches the neutral state, which is the same answer `loadMobileWebShellEnabled`
|
||||
// gives one render later — and two spellings of one build-kind test are two things to keep
|
||||
// true, where this feature's darkness rests on exactly one.
|
||||
//
|
||||
// The Troubleshoot route asks the same question to decide whether to mount the toggle, which
|
||||
// is the one place a user can switch an OTA build back to the native screens. Asking it rather
|
||||
// than re-deriving it is why that row and the flag can never disagree about the build.
|
||||
expect(
|
||||
filesContaining('mobileWebShellFlagCanBeOn'),
|
||||
matchesOf('mobileWebShellFlagCanBeOn')
|
||||
).toEqual([DEFINITION, FLAG_HOOK].sort())
|
||||
).toEqual([DEFINITION, FLAG_HOOK, TROUBLESHOOT_ROUTE].sort())
|
||||
})
|
||||
|
||||
it('is written only by the developer row', () => {
|
||||
|
||||
@@ -11,8 +11,10 @@ import { loadMobileWebShellEnabled, mobileWebShellFlagCanBeOn } from '../storage
|
||||
* That frame is worth a native mount only where the flag could resolve on, so a build that cannot
|
||||
* have it on starts at `false` rather than `null`: `mobileWebShellFlagCanBeOn` is the same fact
|
||||
* `loadMobileWebShellEnabled` would answer with, one render earlier, and it makes `null`
|
||||
* unreachable on every store build. The effect still runs there and still answers `false`, because
|
||||
* the initialiser is a starting point and the read is what decides.
|
||||
* unreachable on a native store build — one built without `EXPO_PUBLIC_MOBILE_SHELL=ota`, which is
|
||||
* every default build. A build made with it reaches the neutral frame like a development build
|
||||
* does. The effect still runs either way, because the initialiser is a starting point and the read
|
||||
* is what decides.
|
||||
*/
|
||||
export function useMobileWebShellEnabled(): boolean | null {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(() =>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
loadPushNotificationsEnabled,
|
||||
loadTerminalAutocompleteEnabled,
|
||||
loadTerminalLinkOpenMode,
|
||||
mobileShellBuildKind,
|
||||
mobileWebShellFlagCanBeOn,
|
||||
readPushNotificationsPreference,
|
||||
readDisabledTerminalLiveInputHandlesPreference,
|
||||
saveDisabledTerminalLiveInputHandles,
|
||||
@@ -518,10 +520,78 @@ function setDevelopmentBuild(isDevelopmentBuild: boolean | undefined): void {
|
||||
Object.assign(globalThis, { __DEV__: isDevelopmentBuild })
|
||||
}
|
||||
|
||||
/** The build-time constant the release workflow sets. Under Metro this name is inlined before the
|
||||
* bundle is written, so these cases measure the answer the inlined value produces, not the read. */
|
||||
function setShellBuildSwitch(value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'EXPO_PUBLIC_MOBILE_SHELL')
|
||||
return
|
||||
}
|
||||
process.env.EXPO_PUBLIC_MOBILE_SHELL = value
|
||||
}
|
||||
|
||||
describe('the mobile shell build kind', () => {
|
||||
beforeEach(() => {
|
||||
setShellBuildSwitch(undefined)
|
||||
})
|
||||
|
||||
it('is native when the build set no switch at all, which is every default build', () => {
|
||||
expect(mobileShellBuildKind()).toBe('native')
|
||||
})
|
||||
|
||||
it.each([[''], ['native'], ['OTA'], [' ota'], ['ota-preview'], ['true']])(
|
||||
'is native for %p, so only the exact word opts in',
|
||||
(value) => {
|
||||
setShellBuildSwitch(value)
|
||||
|
||||
expect(mobileShellBuildKind()).toBe('native')
|
||||
}
|
||||
)
|
||||
|
||||
it('is ota when the build set exactly that', () => {
|
||||
setShellBuildSwitch('ota')
|
||||
|
||||
expect(mobileShellBuildKind()).toBe('ota')
|
||||
})
|
||||
})
|
||||
|
||||
describe('whether the hybrid shell flag can be on', () => {
|
||||
beforeEach(() => {
|
||||
setDevelopmentBuild(undefined)
|
||||
setShellBuildSwitch(undefined)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a release build', false],
|
||||
['a runtime with no __DEV__ at all', undefined]
|
||||
])('cannot be on in %s with no switch set', (_label, isDev) => {
|
||||
setDevelopmentBuild(isDev)
|
||||
|
||||
expect(mobileWebShellFlagCanBeOn()).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a release build', false],
|
||||
['a runtime with no __DEV__ at all', undefined]
|
||||
])('can be on in %s built with the switch set to ota', (_label, isDev) => {
|
||||
setDevelopmentBuild(isDev)
|
||||
setShellBuildSwitch('ota')
|
||||
|
||||
expect(mobileWebShellFlagCanBeOn()).toBe(true)
|
||||
})
|
||||
|
||||
it('can be on in a development build whatever the switch says', () => {
|
||||
setDevelopmentBuild(true)
|
||||
|
||||
expect(mobileWebShellFlagCanBeOn()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hybrid shell flag', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
setDevelopmentBuild(undefined)
|
||||
setShellBuildSwitch(undefined)
|
||||
})
|
||||
|
||||
it('reads the developer toggle in a development build', async () => {
|
||||
@@ -532,15 +602,52 @@ describe('hybrid shell flag', () => {
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('orca:mobileWebShellEnabled')
|
||||
})
|
||||
|
||||
it('is off in a development build until the toggle writes it on', async () => {
|
||||
setDevelopmentBuild(true)
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
|
||||
|
||||
await expect(loadMobileWebShellEnabled()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a release build', false],
|
||||
['a runtime with no __DEV__ at all', undefined]
|
||||
])('is off in %s even with the key left on, and never reads it', async (_label, isDev) => {
|
||||
setDevelopmentBuild(isDev)
|
||||
// The value a development build left behind in a container the install-over kept.
|
||||
// The value a development build, or an OTA build this one was installed over, left behind in
|
||||
// a container the install-over kept. The ability comes from the build, so the key cannot
|
||||
// revive it — this is the case a native build shipped over an OTA build has to survive.
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue('true')
|
||||
|
||||
await expect(loadMobileWebShellEnabled()).resolves.toBe(false)
|
||||
expect(AsyncStorage.getItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a release build', false],
|
||||
['a runtime with no __DEV__ at all', undefined]
|
||||
])('is on in %s built for ota with the key never written', async (_label, isDev) => {
|
||||
setDevelopmentBuild(isDev)
|
||||
setShellBuildSwitch('ota')
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
|
||||
|
||||
await expect(loadMobileWebShellEnabled()).resolves.toBe(true)
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('orca:mobileWebShellEnabled')
|
||||
})
|
||||
|
||||
it('obeys an explicit off written by the Troubleshoot toggle in an ota build', async () => {
|
||||
setDevelopmentBuild(false)
|
||||
setShellBuildSwitch('ota')
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue('false')
|
||||
|
||||
await expect(loadMobileWebShellEnabled()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('is off in an ota build whose store cannot be read at all', async () => {
|
||||
setDevelopmentBuild(false)
|
||||
setShellBuildSwitch('ota')
|
||||
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('no store'))
|
||||
|
||||
await expect(loadMobileWebShellEnabled()).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,22 +120,41 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise
|
||||
|
||||
const MOBILE_WEB_SHELL_KEY = 'orca:mobileWebShellEnabled'
|
||||
|
||||
// Why: the hybrid shell route is dark. Default-off means a store build never fetches, writes or
|
||||
// sweeps a bundle cache, and the only writer is the __DEV__ Troubleshoot toggle — anything but
|
||||
// `'true'`, including an unreadable store, is off.
|
||||
export type MobileShellBuildKind = 'native' | 'ota'
|
||||
|
||||
/**
|
||||
* Which shell this binary was built for, and the only place the build-time constant is spelled.
|
||||
*
|
||||
* `babel-preset-expo`'s inline-env-vars plugin replaces a literal `process.env.EXPO_PUBLIC_*`
|
||||
* member expression with the build machine's value, so in a release bundle this function has no
|
||||
* variable left in it. That rewrite only fires on a literal member expression: a destructure, a
|
||||
* computed key or a copy through another binding is not inlined and would read `undefined` on a
|
||||
* device, which is why every caller goes through this one and never through `process.env`.
|
||||
*
|
||||
* Anything but the exact string `ota` — unset, empty, a typo, a value from a stale shell — is
|
||||
* native. A release built without the variable is the native app, which is every default build.
|
||||
*/
|
||||
export function mobileShellBuildKind(): MobileShellBuildKind {
|
||||
return process.env.EXPO_PUBLIC_MOBILE_SHELL === 'ota' ? 'ota' : 'native'
|
||||
}
|
||||
|
||||
// Why: the hybrid shell route is dark in every build but an OTA one. Default-off means a native
|
||||
// store build never fetches, writes or sweeps a bundle cache — anything but `'true'`, including an
|
||||
// unreadable store, is off there.
|
||||
/**
|
||||
* Whether this build can have the flag on at all.
|
||||
*
|
||||
* A release build never reads the key: it shares its bundle id with the development build and the
|
||||
* iOS data container survives an install-over, so a flag a developer left on would otherwise
|
||||
* follow the store build in and mount the shell on a deep link.
|
||||
* A native release build never reads the key: it shares its bundle id with the development build
|
||||
* and with an OTA build, and the iOS data container survives an install-over, so a flag either of
|
||||
* those left on would otherwise follow the native store build in and mount the shell on a deep
|
||||
* link. The ability comes from the build, never from storage, which is what makes that impossible.
|
||||
*
|
||||
* Named rather than spelled twice. The hook beside the reader starts its state on this answer so
|
||||
* a store build is decided on its first render rather than after an effect, and two spellings of
|
||||
* one `__DEV__` test would be two things to keep true.
|
||||
* a native store build is decided on its first render rather than after an effect, and two
|
||||
* spellings of one build-kind test would be two things to keep true.
|
||||
*/
|
||||
export function mobileWebShellFlagCanBeOn(): boolean {
|
||||
return typeof __DEV__ !== 'undefined' && __DEV__
|
||||
return (typeof __DEV__ !== 'undefined' && __DEV__) || mobileShellBuildKind() === 'ota'
|
||||
}
|
||||
|
||||
export async function loadMobileWebShellEnabled(): Promise<boolean> {
|
||||
@@ -144,6 +163,12 @@ export async function loadMobileWebShellEnabled(): Promise<boolean> {
|
||||
}
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(MOBILE_WEB_SHELL_KEY)
|
||||
// An untouched OTA install mounts the page on first launch; a development build keeps its
|
||||
// opt-in. Either way a stored value decides, so the Troubleshoot toggle can switch an OTA
|
||||
// build off and that choice survives the next launch.
|
||||
if (raw === null) {
|
||||
return mobileShellBuildKind() === 'ota'
|
||||
}
|
||||
return raw === 'true'
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Route A web entry: mounts the phone's h/[hostId] route tree on react-native-web.
|
||||
// Dark: built by `build:mobile-web:app` into out/mobile-web-app, shipped by nothing until C1.
|
||||
// Built by `build:mobile-web` into the packaged bundle dir; mounted only by a build with
|
||||
// `EXPO_PUBLIC_MOBILE_SHELL=ota`.
|
||||
import { useEffect, type PropsWithChildren } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ExpoRoot } from 'expo-router'
|
||||
|
||||
+1
-2
@@ -93,8 +93,7 @@
|
||||
"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:mobile-web": "node config/scripts/build-mobile-web-bundle.mjs && node config/scripts/verify-mobile-web-bundle.mjs",
|
||||
"build:mobile-web:app": "node config/scripts/build-mobile-web-app-bundle.mjs && node config/scripts/verify-mobile-web-app-bundle.mjs",
|
||||
"build:mobile-web": "node config/scripts/build-mobile-web-app-bundle.mjs && node config/scripts/verify-mobile-web-app-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 && pnpm run build:mobile-web",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<!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
@@ -1,54 +0,0 @@
|
||||
: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;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// 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.
|
Before Width: | Height: | Size: 105 B |
Reference in New Issue
Block a user