From da1c322b000a2b2c9c12ec76a330cdae164d64ce Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:18:33 -0400 Subject: [PATCH] feat(mobile): one build-time switch picks native or OTA, default native (OTA phase E1) (#22193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .gitattributes | 15 +- .github/workflows/daemon-relocation-spike.yml | 8 +- .github/workflows/mobile-android-release.yml | 17 +- .github/workflows/mobile-ios-release.yml | 16 +- .github/workflows/pr.yml | 9 +- .github/workflows/win-crash-survival-e2e.yml | 4 +- config/electron-builder.config.cjs | 3 - config/knip.json | 1 - .../scripts/build-mobile-web-app-bundle.mjs | 12 +- .../build-mobile-web-app-bundle.test.mjs | 22 +- config/scripts/build-mobile-web-bundle.mjs | 259 ----------------- .../scripts/build-mobile-web-bundle.test.mjs | 265 ------------------ .../scripts/electron-builder-config.test.mjs | 4 +- ...ile-release-shell-switch-workflow.test.mjs | 105 +++++++ .../mobile-web-bundle-fixture-tree.mjs | 66 +++++ config/scripts/mobile-web-bundle-manifest.mjs | 141 ++++++++++ .../mobile-web-bundle-manifest.test.mjs | 57 ++++ ...bile-web-bundle-serializer-parity.test.mjs | 2 +- .../scripts/mobile-web-page-routes.test.mjs | 2 +- .../mobile-web-source-line-endings.mjs | 60 ++++ .../mobile-web-source-line-endings.test.mjs | 105 +++++++ config/scripts/pr-code-change-scope.mjs | 11 +- config/scripts/pr-code-change-scope.test.mjs | 2 +- .../run-typecheck-projects-in-parallel.mjs | 3 +- config/scripts/script-entry-detection.mjs | 26 ++ .../scripts/script-entry-detection.test.mjs | 72 +++++ .../scripts/verify-mobile-web-app-bundle.mjs | 12 +- config/scripts/verify-mobile-web-bundle.mjs | 138 --------- .../verify-packaged-mobile-web-bundle.cjs | 2 +- ...verify-packaged-mobile-web-bundle.test.mjs | 11 +- config/tsconfig.mobile-web.json | 8 - mobile/app/h/[hostId]/web.tsx | 12 +- mobile/app/troubleshoot.tsx | 7 +- .../mobile-web-shell-dev-row.test.tsx | 22 +- .../diagnostics/mobile-web-shell-dev-row.tsx | 30 +- mobile/src/diagnostics/troubleshoot-view.tsx | 2 +- .../mobile-web-shell-flag-census.test.ts | 58 +++- .../use-mobile-web-shell-enabled.ts | 6 +- mobile/src/storage/preferences.test.ts | 109 ++++++- mobile/src/storage/preferences.ts | 43 ++- mobile/web-entry/index.tsx | 3 +- package.json | 3 +- src/mobile-web/index.html | 17 -- src/mobile-web/src/bootstrap.css | 54 ---- src/mobile-web/src/bootstrap.ts | 65 ----- src/mobile-web/src/orca-mark.png | Bin 105 -> 0 bytes 46 files changed, 970 insertions(+), 919 deletions(-) delete mode 100644 config/scripts/build-mobile-web-bundle.mjs delete mode 100644 config/scripts/build-mobile-web-bundle.test.mjs create mode 100644 config/scripts/mobile-release-shell-switch-workflow.test.mjs create mode 100644 config/scripts/mobile-web-bundle-fixture-tree.mjs create mode 100644 config/scripts/mobile-web-bundle-manifest.mjs create mode 100644 config/scripts/mobile-web-bundle-manifest.test.mjs create mode 100644 config/scripts/mobile-web-source-line-endings.mjs create mode 100644 config/scripts/mobile-web-source-line-endings.test.mjs create mode 100644 config/scripts/script-entry-detection.mjs create mode 100644 config/scripts/script-entry-detection.test.mjs delete mode 100644 config/scripts/verify-mobile-web-bundle.mjs delete mode 100644 config/tsconfig.mobile-web.json delete mode 100644 src/mobile-web/index.html delete mode 100644 src/mobile-web/src/bootstrap.css delete mode 100644 src/mobile-web/src/bootstrap.ts delete mode 100644 src/mobile-web/src/orca-mark.png 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 // `