Install the app from our own interstitial (itms-services)
Status summary#
Implemented and green (typecheck/lint/knip/format, scripts 291, os 13 incl. 6 new manifest/interstitial tests). Remaining: reapply onto main once #2555 merges; a phone-scan sanity check of the itms dialog is the only thing CI can't prove.
The problem#
/m/install/<channel> links the expo.dev build page, whose Install tap
fires itms-services:// (native iOS dialog, background install, Safari
stays put). expo.dev takes no callback URL, so getting back to our page for
the "Open in app" step is a manual Back tap.
The unlock#
eas build:list exposes artifacts.applicationArchiveUrl — a stable,
unsigned expo.dev .ipa URL, valid until the build's expirationDate
(~90 days; verified live on build 6932e8e3). iOS installs ad-hoc apps from
any https manifest plist, so OS can serve the manifest itself and the
interstitial's Install button becomes
itms-services://?action=download-manifest&url=<https manifest> — install
happens ON our page, with "Open in app" sitting right below it.
Design#
- Snapshot grows three optional fields
(
packages/shared/src/mobile-channel-status.ts):ipaUrl,appVersion,bundleId. Optional is justified here: old snapshots predate the fields, an old deployed worker's zod strips unknown keys from a new CI's PUT (deploy-order tolerance), and a freshly triggered build has no artifact yet. Absence of any of them = the interstitial keeps today's build-page-link behavior. - CI writers fill them.
ensureBuildForRuntime(and the refresher'sbuild:view) pass throughartifacts.applicationArchiveUrl;appVersion/bundleIdcome fromapp.json(expo.version,expo.ios.bundleIdentifier). A PR build that finishes later gets itsipaUrlon the next push, same staleness contract asbuildFinished. - OS serves the manifest:
/m/install-manifest/<channel>renders the plist (kind software, bundle-identifier, bundle-version, title, software-package = ipaUrl) from the snapshot; 404 when the snapshot is missing or lacks the fields.application/xml, XML-escaped, no-store. - Interstitial CTA: when
buildFinished && ipaUrl && bundleId && appVersion, the primary button is the itms-services link ("installs in place — watch your home screen, then tap Open in app below"); the expo.dev build page demotes to a "build details" link. Otherwise unchanged. Manifest URL derives from the request's own origin, so previews serve their own. - Explainer + README copy updated (install now happens on the page).
Not doing#
- Signed/expiring manifest URLs: the ipa URL is already unguessable (content-hash path) and expires with the build; the manifest exposes nothing the build page didn't.
- display-image in the manifest: generic icon during install is fine.
- Android: iOS-only flow, like everything else here.
Checklist#
- Schema: optional
ipaUrl/appVersion/bundleId+ comment on why optional -
mobile-preview.ts:InstallBuild.ipaUrl; writers pass ipaUrl/appVersion/bundleId; refresher upgrades ipaUrl when the build finishes - OS:
handleInstallManifestRequest+ thin route, plist rendering, 404s, tests (incl. XML escaping) - OS: interstitial itms CTA variant + demoted build-details link, tests
- Explainer + README copy
- Gauntlet: typecheck, lint, knip, format, scripts + os tests