From 25b51449bc6ea8131255c8d9f2782351af773dcc Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 20 Aug 2026 14:19:04 -0700 Subject: [PATCH] Add EAS Update (OTA) to the mobile app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile app could only ship a JS fix through a full EAS build and an App Store Connect upload. That is fine for internal testers, who install every nightly, but external testers make the round trip expensive. `expo-updates` was not installed and the build profiles carried no channels. What changed: - `expo-updates`, with `updates.url` and `runtimeVersion.policy: "fingerprint"` in `app.json`, and a channel per build profile in `eas.json`. - `fingerprint.config.js` skips `ExpoConfigVersions`. `mobile-ios-eas.yml` rewrites `app.json` `version` with the npm version on every nightly, and the fingerprint hashes the whole Expo config by default. Without the skip each nightly forked the runtime version and no update could ever match an installed build. Confirmed: the fingerprint at `0.0.1` and at `0.39.0` is `7f9c90b3…` vs `0f650d4d…` without the skip, and `b162a732…` for both with it. A native change still forks it (`f9b4c845…` with one extra Info.plist key), so an update cannot land on an incompatible binary. - `app.config.js` turns the update client off when `BB_DISABLE_UPDATES=1`, and `mobile-e2e.yml` sets it. The E2E job builds in Release, and a Release binary with updates enabled asks the production channel for a bundle at launch, which would replace the embedded E2E bundle in the middle of a Maestro flow. Verified through prebuild: `EXUpdatesEnabled` is `false` with the variable and `true` without it. - `mobile-update.yml` publishes an update. It is manual, because an update reaches every tester in minutes with no review in between. It prints the runtime fingerprint next to the recent builds' fingerprints, since an update that reaches nobody otherwise looks like a successful publish. - README: an "Over-the-air updates" section replaces the deferral note. How verified: - `pnpm exec turbo run typecheck lint test --filter=@bb/mobile --force`: green, 834 tests. - `app.config.test.ts` covers both silent failure modes. The E2E case fails when the `BB_DISABLE_UPDATES` guard is removed and passes with it. - `expo prebuild --platform ios` with and without `BB_DISABLE_UPDATES=1`, reading `EXUpdatesEnabled` out of the generated `Expo.plist`. - Fingerprints generated with `expo-updates fingerprint:generate` for the three cases above. Note: build `0.0.1 (2)`, already in TestFlight, carries no update client. The first build after this change is the earliest one that can receive an update. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/mobile-e2e.yml | 10 ++- .github/workflows/mobile-update.yml | 126 ++++++++++++++++++++++++++++ apps/mobile/README.md | 44 +++++++++- apps/mobile/app.config.js | 22 +++++ apps/mobile/app.config.test.ts | 54 ++++++++++++ apps/mobile/app.json | 6 ++ apps/mobile/eas.json | 12 ++- apps/mobile/fingerprint.config.js | 18 ++++ apps/mobile/package.json | 3 +- apps/mobile/vitest.config.ts | 2 +- pnpm-lock.yaml | 52 ++++++++++++ 11 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/mobile-update.yml create mode 100644 apps/mobile/app.config.js create mode 100644 apps/mobile/app.config.test.ts create mode 100644 apps/mobile/fingerprint.config.js diff --git a/.github/workflows/mobile-e2e.yml b/.github/workflows/mobile-e2e.yml index a9516a6021..d1305a0e06 100644 --- a/.github/workflows/mobile-e2e.yml +++ b/.github/workflows/mobile-e2e.yml @@ -161,7 +161,7 @@ jobs: - name: Compute native cache key id: native-key run: | - echo "key=${{ runner.os }}-mobile-native-${{ hashFiles('pnpm-lock.yaml', 'apps/mobile/app.json', 'apps/mobile/package.json', 'patches/**') }}" >> "$GITHUB_OUTPUT" + echo "key=${{ runner.os }}-mobile-native-${{ hashFiles('pnpm-lock.yaml', 'apps/mobile/app.json', 'apps/mobile/app.config.js', 'apps/mobile/package.json', 'patches/**') }}" >> "$GITHUB_OUTPUT" - name: Restore CocoaPods id: pods-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -178,11 +178,17 @@ jobs: path: ~/Library/Developer/Xcode/DerivedData/bb-*/Build key: ${{ steps.native-key.outputs.key }}-deriveddata + # BB_DISABLE_UPDATES: apps/mobile/app.config.js then sets + # `updates.enabled: false`, so prebuild writes EXUpdatesEnabled=false + # into Expo.plist. Without it a Release binary asks the production + # channel for a new bundle at launch and can replace the embedded E2E + # bundle in the middle of a flow. - name: Prebuild and install pods working-directory: apps/mobile env: LANG: en_US.UTF-8 CI: "1" + BB_DISABLE_UPDATES: "1" run: | set -x npx expo prebuild --platform ios --no-install @@ -210,6 +216,8 @@ jobs: NODE_OPTIONS: --max-old-space-size=8192 EXPO_PUBLIC_BB_E2E: "1" EXPO_PUBLIC_BB_SERVER_URL: http://127.0.0.1:${{ env.BB_MOBILE_E2E_PORT }} + # `expo run:ios` evaluates the app config again; keep it in step. + BB_DISABLE_UPDATES: "1" run: | set -x rm -rf build-output diff --git a/.github/workflows/mobile-update.yml b/.github/workflows/mobile-update.yml new file mode 100644 index 0000000000..b2fe004bc6 --- /dev/null +++ b/.github/workflows/mobile-update.yml @@ -0,0 +1,126 @@ +# Publish a JS-only over-the-air update for the bb mobile app (EAS Update). +# +# An update carries the JS bundle and its assets. It cannot carry native +# changes: a new native module, an Expo SDK bump, or an edit to app.json's +# native config all need a full build through mobile-ios-eas.yml. The +# `fingerprint` runtimeVersion policy in apps/mobile/app.json enforces this — +# it hashes the native inputs, and an update installs only on a binary whose +# hash matches, so a native change simply reaches no installed build. +# +# Manual only. An update reaches every tester on the channel within minutes +# and there is no review between this job and their phones, so a bad update +# is worse than a bad nightly. Publish deliberately. +name: Mobile update (EAS Update) + +on: + workflow_dispatch: + inputs: + branch: + description: EAS Update branch. The `production` channel that TestFlight builds carry points at the branch of the same name. + required: true + type: choice + default: production + options: + - production + - preview + message: + description: Update message shown on expo.dev. Empty uses the commit subject. + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + update: + name: Publish an update to ${{ inputs.branch }} + runs-on: ubuntu-latest + timeout-minutes: 30 + # Two concurrent publishes to one branch would race on which update ends + # up newest; the loser silently never ships. + concurrency: + group: mobile-update-${{ inputs.branch }} + cancel-in-progress: false + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + version: 9.15.0 + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.x + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Require the EAS token + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + set -euo pipefail + if [[ -z "${EXPO_TOKEN:-}" ]]; then + echo "::error::EAS Update needs the EXPO_TOKEN secret." + exit 1 + fi + + # The fingerprint decides which builds this update can reach, so print it + # next to the fingerprints of the recent builds. A mismatch means the + # update reaches nobody, and that is invisible in the publish output. + - name: Report the runtime fingerprint + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + set -euo pipefail + fingerprint=$(pnpm exec expo-updates fingerprint:generate --platform ios | node -e ' + let raw = ""; + process.stdin.on("data", (chunk) => (raw += chunk)); + process.stdin.on("end", () => { + console.log(JSON.parse(raw.slice(raw.indexOf("{"))).hash); + }); + ') + { + echo "## Runtime fingerprint" + echo + echo "This update installs only on iOS builds with fingerprint \`${fingerprint}\`." + echo + echo "Recent builds:" + echo + echo '```' + pnpm exec eas build:list --platform ios --limit 5 --non-interactive \ + | grep -E 'Build number|Fingerprint|Version' || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Publish the update + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + UPDATE_BRANCH: ${{ inputs.branch }} + UPDATE_MESSAGE: ${{ inputs.message }} + run: | + set -euo pipefail + message="${UPDATE_MESSAGE:-}" + if [[ -z "$message" ]]; then + message=$(git log -1 --pretty=%s) + fi + pnpm exec eas update \ + --branch "$UPDATE_BRANCH" \ + --platform ios \ + --message "$message" \ + --non-interactive | tee eas-update.log + { + echo + echo "## EAS Update" + echo + grep -Eo 'https://expo\.dev/[^ ]+' eas-update.log | sed 's/^/- /' || true + } >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 3809426776..742a46afef 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -799,8 +799,48 @@ push key); nobody needs a local Xcode signing setup to ship. check against `https://.getbb.app/threads/…`. Android signing (`eas credentials -p android`, FCM V1, `ASSETLINKS_SHA256_FINGERPRINTS`) is still open. -- `eas update` (JS-only fixes over the air) is deferred: `expo-updates` is - not installed, so the profiles define no update channels. + +## Over-the-air updates (EAS Update) + +`expo-updates` ships in the app, so a JS-only fix can reach installed builds +without a new binary. Each build profile in `eas.json` carries a channel of its +own name (`production`, `preview`, `development`, `development-device`), and a +channel points at the update branch of the same name. + +- **Publish**: Actions tab → "Mobile update (EAS Update)" → Run workflow, or + `gh workflow run mobile-update.yml -f branch=production`. It is manual on + purpose: an update reaches every tester in minutes with no review in + between. Locally the same publish is + `pnpm exec eas update --branch production --platform ios --message "…"`. +- **What an update can carry**: anything under `src/` and `app/` — screens, + logic, styles, copy, assets. It cannot carry a new native module, an Expo SDK + bump, or a change to the native parts of `app.json` (permission strings, + `UIBackgroundModes`, associated domains, scheme, icon). Those need + `mobile-ios-eas.yml`. +- **Which builds an update reaches**: `app.json` sets + `runtimeVersion.policy: "fingerprint"`. The fingerprint hashes the native + inputs, and an update installs only on a binary with the same hash, so a + native change cannot land on an incompatible build — it reaches nobody + instead. The publish workflow prints the fingerprint next to the recent + builds' fingerprints, because "reached nobody" otherwise looks like success. + `eas.json` is a fingerprint input too, so editing it also forks the + fingerprint and the next update needs a fresh build to land on. +- **`fingerprint.config.js`** sets `sourceSkips: ["ExpoConfigVersions"]`. The + EAS build job rewrites `app.json` `version` with the npm version on every + nightly. Without this skip the version alone would fork the fingerprint each + night and no update would ever match a build. Verified by generating the + fingerprint at `0.0.1` and at `0.39.0`: identical with the skip, different + without it. A native change still forks it. +- **Delivery**: the client checks on launch and downloads in the background, + then applies the update on the next cold start. A tester who never quits the + app stays on the old bundle. +- **E2E builds carry no update client.** `mobile-e2e.yml` sets + `BB_DISABLE_UPDATES=1`, and `app.config.js` turns + `updates.enabled` off, so prebuild writes `EXUpdatesEnabled=false` into + `Expo.plist`. A Release binary with updates on would fetch the production + bundle at launch and swap the embedded E2E bundle mid-flow. +- **Billing**: EAS Update bills on monthly active users. Check the `bb-team` + plan before opening the app to a large external group. ## Local state diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js new file mode 100644 index 0000000000..b7ac20b585 --- /dev/null +++ b/apps/mobile/app.config.js @@ -0,0 +1,22 @@ +/** + * Expo reads app.json first and passes it here as `config`, so app.json stays + * the single description of the app and this file only applies build-time + * overrides. + * + * `BB_DISABLE_UPDATES=1` turns the expo-updates client off in the built + * binary. The Mobile E2E workflow builds the app in Release, and a Release + * binary with updates enabled asks the production channel for a new bundle at + * launch. That bundle would replace the embedded E2E bundle in the middle of a + * Maestro flow, and the failures would look random. The E2E build is never + * distributed, so it needs no update client. + */ +module.exports = ({ config }) => { + if (process.env.BB_DISABLE_UPDATES !== "1") { + return config; + } + + return { + ...config, + updates: { ...config.updates, enabled: false }, + }; +}; diff --git a/apps/mobile/app.config.test.ts b/apps/mobile/app.config.test.ts new file mode 100644 index 0000000000..18a2a130f3 --- /dev/null +++ b/apps/mobile/app.config.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import appConfigFactory from "./app.config.js"; +import appJson from "./app.json"; +import fingerprintConfig from "./fingerprint.config.js"; + +// The two config files below decide who receives an over-the-air update. +// Both failure modes are silent: an update that reaches nobody looks like a +// successful publish, and an E2E build that fetches a production bundle looks +// like a flaky flow. Neither shows up in a typecheck. + +const evaluate = () => appConfigFactory({ config: appJson.expo }); + +describe("app.config.js", () => { + it("keeps the update client on by default", () => { + delete process.env.BB_DISABLE_UPDATES; + const config = evaluate(); + + expect(config.updates).toEqual({ url: appJson.expo.updates.url }); + expect(config).toMatchObject({ runtimeVersion: { policy: "fingerprint" } }); + }); + + it("disables updates for the E2E Release build", () => { + process.env.BB_DISABLE_UPDATES = "1"; + try { + // Release E2E binaries must not ask the production channel for a bundle + // mid-flow; prebuild turns this into EXUpdatesEnabled=false. + expect(evaluate().updates).toMatchObject({ enabled: false }); + } finally { + delete process.env.BB_DISABLE_UPDATES; + } + }); + + it("changes nothing else about the app config", () => { + process.env.BB_DISABLE_UPDATES = "1"; + try { + const { updates: _disabled, ...rest } = evaluate(); + const { updates: _original, ...original } = appJson.expo; + + expect(rest).toEqual(original); + } finally { + delete process.env.BB_DISABLE_UPDATES; + } + }); +}); + +describe("fingerprint.config.js", () => { + // mobile-ios-eas.yml rewrites app.json `version` on every nightly. Without + // this skip the version alone forks the runtime fingerprint each night, and + // no update ever matches an installed build. + it("keeps the marketing version out of the fingerprint", () => { + expect(fingerprintConfig.sourceSkips).toContain("ExpoConfigVersions"); + }); +}); diff --git a/apps/mobile/app.json b/apps/mobile/app.json index b69d476595..ad3bb8d9c5 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -96,6 +96,12 @@ "eas": { "projectId": "3dca8cca-f48a-4c3a-ba3d-3af40e58a588" } + }, + "runtimeVersion": { + "policy": "fingerprint" + }, + "updates": { + "url": "https://u.expo.dev/3dca8cca-f48a-4c3a-ba3d-3af40e58a588" } } } diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 11f9326c5e..0cfc91810f 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -9,19 +9,23 @@ "distribution": "internal", "ios": { "simulator": true - } + }, + "channel": "development" }, "development-device": { "extends": "development", "ios": { "simulator": false - } + }, + "channel": "development-device" }, "preview": { - "distribution": "internal" + "distribution": "internal", + "channel": "preview" }, "production": { - "autoIncrement": true + "autoIncrement": true, + "channel": "production" } }, "submit": { diff --git a/apps/mobile/fingerprint.config.js b/apps/mobile/fingerprint.config.js new file mode 100644 index 0000000000..37f6587f81 --- /dev/null +++ b/apps/mobile/fingerprint.config.js @@ -0,0 +1,18 @@ +/** + * Fingerprint inputs for the `fingerprint` runtimeVersion policy (app.json). + * + * The fingerprint decides which binaries an `eas update` can reach: an update + * is published for one runtime version and installs only on builds with the + * same one. By default the fingerprint hashes the whole evaluated Expo config, + * including `version`. That is wrong here, because + * .github/workflows/mobile-ios-eas.yml rewrites `version` on every nightly + * with the npm version. Each nightly would then fork the runtime version, and + * an update would reach only the one build made from that exact version. + * + * `ExpoConfigVersions` drops `version`, `ios.buildNumber` and + * `android.versionCode` from the hash. Those fields change no native code, so + * a build differing only by version stays update-compatible. + */ +module.exports = { + sourceSkips: ["ExpoConfigVersions"], +}; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f78077d677..5e2fbff3bd 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -9,7 +9,7 @@ "android": "expo run:android", "prebuild": "expo prebuild", "typecheck": "tsc --noEmit", - "lint": "eslint app src scripts metro.config.js --ext .ts,.tsx", + "lint": "eslint app src scripts metro.config.js app.config.js fingerprint.config.js --ext .ts,.tsx", "test": "vitest run --config vitest.config.ts", "theme:generate": "node --conditions=source --import tsx scripts/generate-native-theme.ts", "terminal:build": "node --conditions=source --import tsx scripts/build-terminal-page.ts", @@ -59,6 +59,7 @@ "expo-splash-screen": "~57.0.7", "expo-status-bar": "~57.0.1", "expo-system-ui": "~57.0.2", + "expo-updates": "~57.0.16", "expo-web-browser": "~57.0.2", "mdast-util-to-string": "^4.0.0", "nativewind": "5.0.0-preview.4", diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index b798123a6d..9bc419da6b 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -13,7 +13,7 @@ export default defineWorkspaceTestConfig({ test: { silent: "passed-only", environment: "node", - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "app.config.test.ts"], passWithNoTests: true, testTimeout: 15_000, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de47ddb762..57379e954b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -898,6 +898,9 @@ importers: expo-system-ui: specifier: ~57.0.2 version: 57.0.2(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4)) + expo-updates: + specifier: ~57.0.16 + version: 57.0.16(expo-dev-client@57.0.13(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4)))(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4) expo-web-browser: specifier: ~57.0.2 version: 57.0.2(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4)) @@ -10727,6 +10730,9 @@ packages: peerDependencies: expo: '*' + expo-eas-client@57.0.1: + resolution: {integrity: sha512-4w51+zsl/ziUHQMJgLgUdgsNhRPAwHBfySpPB1hpWU21X74QS9T4SqDftaRnrDagn/DfcrXUMJvHbDQUxLPJNA==} + expo-file-system@57.0.4: resolution: {integrity: sha512-h8qDOxSW1SQfnZyvH0+HeOfXCr2X4v3x50+d19JuyQcjDR+JA3tng20TlQxIHazzLPPbwtSKA1Dj3+XIOpnoKg==} peerDependencies: @@ -10872,6 +10878,9 @@ packages: react: '*' react-native: '*' + expo-structured-headers@57.0.0: + resolution: {integrity: sha512-//t9UNPbJSEysc2x4VKJG/u7Osvv5DYJWsET5bqt/B+qcD1by/JXvSQzX3Q/YAgA96xFPontrz6OAPLbO4JKEA==} + expo-symbols@57.0.2: resolution: {integrity: sha512-qZ0iqOflm5lZGwRsQ5Y8sDksw3GAUKwHSX1bJBoocXf7gu14vafIXXYWte+JT9VfXAUGyKodJhLHP/GoOrcNWg==} peerDependencies: @@ -10895,6 +10904,18 @@ packages: peerDependencies: expo: '*' + expo-updates@57.0.16: + resolution: {integrity: sha512-eb0arz9Vp5m1RaUKO6PJxogJv8z15/Rjt5GsRcSUjrf0vhignlT7b0D/Z07Po/ACG1H7Yn7rwIc73T647JVJFA==} + hasBin: true + peerDependencies: + expo: '*' + expo-dev-client: '*' + react: '*' + react-native: '*' + peerDependenciesMeta: + expo-dev-client: + optional: true + expo-web-browser@57.0.2: resolution: {integrity: sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==} peerDependencies: @@ -19263,7 +19284,9 @@ snapshots: metro-runtime: 0.84.5 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.79.6': {} @@ -23287,6 +23310,8 @@ snapshots: dependencies: expo: 57.0.14(@babel/core@7.29.0)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.11)(@typescript/typescript6@6.0.2)(expo-router@57.0.14)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.16.1(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native-worklets@0.10.1(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4) + expo-eas-client@57.0.1: {} + expo-file-system@57.0.4(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4)): dependencies: expo: 57.0.14(@babel/core@7.29.0)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.11)(@typescript/typescript6@6.0.2)(expo-router@57.0.14)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.16.1(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native-worklets@0.10.1(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4) @@ -23457,6 +23482,8 @@ snapshots: react: 19.2.4 react-native: 0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4) + expo-structured-headers@57.0.0: {} + expo-symbols@57.0.2(expo-font@57.0.1)(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4): dependencies: '@expo-google-fonts/material-symbols': 0.4.44 @@ -23479,6 +23506,31 @@ snapshots: dependencies: expo: 57.0.14(@babel/core@7.29.0)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.11)(@typescript/typescript6@6.0.2)(expo-router@57.0.14)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.16.1(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native-worklets@0.10.1(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4) + expo-updates@57.0.16(expo-dev-client@57.0.13(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4)))(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4): + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/plist': 0.8.1 + '@expo/spawn-async': 1.8.0 + arg: 4.1.3 + chalk: 4.1.2 + debug: 4.4.3(supports-color@8.1.1) + expo: 57.0.14(@babel/core@7.29.0)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.11)(@typescript/typescript6@6.0.2)(expo-router@57.0.14)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.16.1(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native-worklets@0.10.1(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4) + expo-eas-client: 57.0.1 + expo-manifests: 57.0.1(expo@57.0.14) + expo-structured-headers: 57.0.0 + expo-updates-interface: 57.0.1(expo@57.0.14) + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + nullthrows: 1.1.1 + react: 19.2.4 + react-native: 0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4) + resolve-from: 5.0.0 + optionalDependencies: + expo-dev-client: 57.0.13(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4)) + transitivePeerDependencies: + - supports-color + expo-web-browser@57.0.2(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4)): dependencies: expo: 57.0.14(@babel/core@7.29.0)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.11)(@typescript/typescript6@6.0.2)(expo-router@57.0.14)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.16.1(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native-worklets@0.10.1(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4))(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4)