iOS Environment

iOS Environment

Mobile Surfaces is an Expo iOS dev-client starter for ActivityKit-backed Live Activity, Dynamic Island, home-screen widget, and iOS 18 control widget workflows.

In plain English: Expo Go is not enough because these surfaces need real native iOS code. Use a development build while building locally, then TestFlight or App Store builds for production-style testing. The current pinned toolchain row (Expo SDK 55, RN 0.83.6, iOS 17.2, Xcode 26) lives in docs/compatibility.md.

Native Pieces

  1. Main app: apps/mobile/, bundle id com.example.mobilesurfaces.
  2. Widget extension: apps/mobile/targets/widget/, generated into Xcode by @bacons/apple-targets; it contains the Live Activity, home-screen widget, and iOS 18 control widget.
  3. Expo native module: packages/live-activity/ (@mobile-surfaces/live-activity), wrapping Activity<MobileSurfacesActivityAttributes>.request, update, list, end, push token events, and activity state events.

The Swift attribute type is intentionally duplicated in the module and widget target. Both files are now generated from the Zod source of truth (liveSurfaceActivityContentState and liveSurfaceStage in packages/surface-contracts/src/schema.ts) and must not be edited by hand:

  • packages/live-activity/ios/MobileSurfacesActivityAttributes.swift
  • apps/mobile/targets/widget/MobileSurfacesActivityAttributes.swift

To change a ContentState field or a Stage case, edit the Zod schema and run pnpm surface:codegen. pnpm surface:check gates codegen drift at stage 2 and byte-identity + Zod parity at stage 3, so a missed codegen step fails CI loudly. The duplication itself remains until @bacons/apple-targets local SPM support and RN 0.84 spm_dependency local-path support land in Expo SDK 56; codegen is the intermediate state that gives us a single source of truth without waiting on upstream.

Widget and control snapshots are shared through the App Group in apps/mobile/app.json:

"com.apple.security.application-groups": ["group.com.example.mobilesurfaces"]

The widget target copies that same entitlement from apps/mobile/targets/widget/expo-target.config.js. If you rename the bundle id, keep the App Group aligned.

Commands

pnpm dev:setup                        # verify toolchain and install dependencies
pnpm dev:doctor                       # verify Node, pnpm, Xcode, and simulator availability
pnpm surface:check                    # validate fixtures and ActivityKit attribute drift
pnpm typecheck                        # TypeScript check
pnpm mobile:sim                       # build/install dev app to the default simulator
pnpm mobile:dev-client                # start Metro for the dev-client build
pnpm mobile:prebuild:ios              # regenerate apps/mobile/ios
pnpm mobile:run:ios                   # build/install to simulator
pnpm mobile:run:ios:device            # build/install to a connected iPhone
pnpm mobile:push:sim                  # simctl push smoke payload
pnpm mobile:push:device:alert         # APNs alert push, requires APNS_* env vars
pnpm mobile:push:device:liveactivity  # ActivityKit push update/end, requires an activity token

pnpm mobile:sim defaults to iPhone 17 Pro. Override with:

DEVICE="iPhone 17 Pro Max" pnpm mobile:sim

The default simulator name tracks the current development environment. If Xcode changes simulator names, set DEVICE to any available simulator from xcrun simctl list devices available.

Apple Team ID

apps/mobile/app.json ships with expo.ios.appleTeamId set to the placeholder XXXXXXXXXX. Replace it with your 10-character team id before running pnpm mobile:run:ios:device or any signed build; otherwise the generated Xcode project will fail to sign and expo run:ios --device will error out on first launch. Find your team id in Xcode → Signing & Capabilities → Team, or at developer.apple.com → Membership.

pnpm dev:doctor warns when the placeholder is still present.

Generated iOS Policy

apps/mobile/ios/ is intentionally ignored. Regenerate it with Expo prebuild:

pnpm mobile:prebuild:ios

The committed native sources of truth are:

  • apps/mobile/app.json
  • packages/live-activity/
  • apps/mobile/targets/widget/

This keeps the repo reviewable and avoids committing generated Xcode churn.

Expo SDK 55 makes the New Architecture mandatory; newArchEnabled is no longer a togglable app.json option. The local ActivityKit bridge runs on the new arch by default.

Testing Matrix

CapabilityExpo GoDev build simulatorDev build device
React Native harness UIPartialYesYes
Local native moduleNoYesYes
Simulated alert pushNoYesNo
Real APNs alertNoNoYes
Live Activity local start/update/endNoLimitedYes
Home-screen widget shared stateNoYesYes
iOS 18 control widget shared stateNoiOS 18+ runtimeiOS 18+ device
Dynamic IslandNoNoiPhone 14 Pro or newer
ActivityKit push update/endNoNoYes

Device Live Activity Loop

  1. Run pnpm mobile:run:ios:device on a physical iPhone.
  2. Open Mobile Surfaces and confirm “Activities supported” shows yes.
  3. Tap a generic Start fixture in the harness, such as queued or active.
  4. Lock the phone and verify the Lock Screen Live Activity.
  5. On a Dynamic Island-capable iPhone, verify compact, expanded, and minimal presentations.
  6. Tap update and end controls in the harness.
  7. Copy the activity push token and run an APNs Live Activity update:
pnpm mobile:push:device:liveactivity -- \
  --activity-token=<paste> \
  --event=update \
  --state-file=./scripts/sample-state.json \
  --env=development

APNs Environment

Create an APNs auth key in the Apple Developer portal and store it outside the repo, for example:

mkdir -p ~/.mobile-surfaces
mv ~/Downloads/AuthKey_*.p8 ~/.mobile-surfaces/
chmod 600 ~/.mobile-surfaces/AuthKey_*.p8

Set:

export APNS_KEY_PATH="$HOME/.mobile-surfaces/AuthKey_XXXXXXXXXX.p8"
export APNS_KEY_ID="XXXXXXXXXX"
export APNS_TEAM_ID="XXXXXXXXXX"
export APNS_BUNDLE_ID="com.example.mobilesurfaces"

Use --env=development for dev builds and --env=production for TestFlight/App Store builds.