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)