Start here
Adopt Mobile Surfaces
Adopt Mobile Surfaces
You already ship a Live Activity. You have an Expo app with expo-live-activity (or a hand-rolled bridge), a backend that signs APNs JWTs, and a working Activity.request(...) flow. What’s still painful: each surface (Lock Screen, widget, control) carries its own ad-hoc mapping function, the backend code duplicates the typed aps envelope shape from your iOS code, and silent ActivityKit failures still surface as “the customer screenshot looks wrong.”
Mobile Surfaces does not replace your bridge. It is the layer above any iOS bridge: a typed wire contract, a Node APNs SDK, and a catalog of every silent-failure mode iOS has, enforceable in CI. Drop two packages in alongside what you already ship.
Install
pnpm add @mobile-surfaces/surface-contracts @mobile-surfaces/push
Both packages are bridge-agnostic. Neither imports expo-live-activity, neither imports the Mobile Surfaces native module, and neither cares which one you use.
Project your data through the contract
LiveSurfaceSnapshot is one discriminated union, six branches (liveActivity, widget, control, notification, lockAccessory, standby). Every surface derives its render input from the same snapshot through a kind-gated projection helper.
import {
assertSnapshot,
toLiveActivityContentState,
type LiveSurfaceSnapshot,
} from "@mobile-surfaces/surface-contracts";
function snapshotFromJob(job: Job): LiveSurfaceSnapshot {
return {
schemaVersion: "5",
kind: "liveActivity",
id: `${job.id}@${job.revision}`,
surfaceId: `job-${job.id}`,
updatedAt: new Date().toISOString(),
state: job.status === "done" ? "completed" : "active",
liveActivity: {
title: job.title,
body: job.subtitle ?? "",
progress: job.progress,
deepLink: `myapp://job/${job.id}`,
modeLabel: "active",
contextLabel: job.queueName,
statusLine: `${job.queueName} · ${Math.round(job.progress * 100)}%`,
stage: job.status === "done" ? "completing" : "inProgress",
estimatedSeconds: job.etaSeconds ?? 0,
morePartsCount: 0,
},
};
}
// On the client side: project through to whatever your bridge accepts.
const snapshot = assertSnapshot(snapshotFromJob(job));
const contentState = toLiveActivityContentState(snapshot);
// → { headline, subhead, progress, stage }
// Pass `contentState` straight into expo-live-activity, a hand-rolled native
// module, or any other ActivityKit bridge. The contract does not care.
The published JSON Schema at https://unpkg.com/@mobile-surfaces/[email protected]/schema.json is generated from the same Zod source. Backends that prefer Ajv, jsonschema, or a Valibot-via-Standard-Schema interop can validate without depending on Zod at runtime.
Drop in the push client
@mobile-surfaces/push is a Node SDK for APNs. Single-session HTTP/2 multiplexing, ES256 JWT signing and rotation, typed error classes per Apple reason code, push-to-start, iOS 18 broadcast channels, channel management. One client per (auth-key, environment, bundleId) tuple, multiplexed across alert / Live Activity / broadcast / channel-management requests.
import { createPushClient } from "@mobile-surfaces/push";
const push = createPushClient({
keyId: process.env.APNS_KEY_ID!,
teamId: process.env.APNS_TEAM_ID!,
keyPath: process.env.APNS_KEY_PATH!,
bundleId: process.env.APNS_BUNDLE_ID!,
environment: "development",
});
// Live Activity update against an existing per-activity push token.
await push.update(activityToken, snapshot);
// Live Activity remote start (iOS 17.2+) against the push-to-start token.
await push.start(pushToStartToken, snapshot, {
surfaceId: snapshot.surfaceId,
modeLabel: snapshot.liveActivity.modeLabel,
});
// End the activity.
await push.end(activityToken, snapshot);
// Alert fallback for users who have Live Activities turned off.
await push.alert(deviceToken, snapshot);
The SDK validates every snapshot through liveSurfaceSnapshot.safeParse and rejects mismatched kinds with a typed InvalidSnapshotError before any network call. Non-2xx APNs responses throw a typed ApnsError subclass per Apple reason: BadDeviceTokenError, UnregisteredError, PayloadTooLargeError, TooManyRequestsError, and so on. The full taxonomy is in push.md.
Audit your project against the trap catalog
The trap catalog runs from inside a Mobile Surfaces checkout, against any Expo project, without forking it. Clone this repo, install once, and point the audit at your project root:
pnpm surface:audit --root /path/to/your-expo-project
The audit runs the static and config gates that apply to a foreign project and prints pass/warn/fail rows with MS-id chips: App Group identity across host and widget extension (MS013), iOS deployment target (MS012), App Group declaration in app.json (MS025), the workspace dependency on the contract package (MS024), the widget target managed by @bacons/apple-targets (MS026), the gitignored apps/mobile/ios/ directory (MS029), and the toolchain preflight (MS010).
Add --json for CI:
pnpm surface:audit --root /path/to/your-expo-project --json
The output is a single JSON object aggregating one DiagnosticReport per gate, the canonical shape every Mobile Surfaces check emits. Wire it into your existing PR checks.
Live alongside expo-live-activity
Both packages are designed to coexist with the established ecosystem bridge. The contract is bridge-agnostic; the push client doesn’t import any native module. See vs-expo-live-activity.md for the cooperative-positioning detail and a decision matrix for which Mobile Surfaces pieces add value when you’re already on expo-live-activity.
What to read next
docs/surfaces.md: everykindvalue, its projection helper, the native target it drives.docs/backend.md: domain event to snapshot to APNs end-to-end.docs/push.md: wire-layer reference, token taxonomy, error taxonomy, broadcast channels.docs/catalog-maintenance.md: trap catalog maintenance, the sourcedata/traps.jsonconsumes.docs/troubleshooting.md: symptom-to-fix recipes for silent ActivityKit failures.