diff --git a/.gitattributes b/.gitattributes index 72fdad6e73a..070850ea4a9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/daemon-relocation-spike.yml b/.github/workflows/daemon-relocation-spike.yml index ac9c1bd4ba4..584986086b9 100644 --- a/.github/workflows/daemon-relocation-spike.yml +++ b/.github/workflows/daemon-relocation-spike.yml @@ -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/**', diff --git a/.github/workflows/mobile-android-release.yml b/.github/workflows/mobile-android-release.yml index 17100c788b8..cdb5a59b154 100644 --- a/.github/workflows/mobile-android-release.yml +++ b/.github/workflows/mobile-android-release.yml @@ -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 diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index dd9a265d0c8..975b0618c7f 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -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() diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4eee03c5632..cbc0b78ffb3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index 91ac8fcf226..e0d7b80d047 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -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/**', diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 60091ed1f93..71c140dc0c5 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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{,/**/*}', diff --git a/config/knip.json b/config/knip.json index e5a014b32e1..9af0b8d1a74 100644 --- a/config/knip.json +++ b/config/knip.json @@ -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", diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index cd9fc99ea3f..b7f7072804b 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -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 diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index 45e42fdfa5c..17e8f9ca006 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -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 // `