Mobile app — build, run, deploy (contributor guide)
PRD-015 / RFC-018 · ADR-078 (iOS-first + stream-heavy) · ADR-079 (assetUrl spine) · v0.8
The operator-side companion to PRD-015 + RFC-018. How a contributor builds the Capacitor iOS/Android app, runs it on a simulator/device, and ships it — plus the sharp edges this codebase hit on the first on-device run so you don't re-discover them.
The web app is the product. Capacitor wraps the SvelteKit static
build/in a WKWebView (iOS) / Chromium WebView (Android). There is no native UI. Theandroid/andios/directories are committed (Capacitor convention).
Validating your changes: see Mobile testing strategy.
npm run test:e2e:mobile(device-free, in CI) asserts the streaming contract + size budget on the pruned bundle; the guide's Layer 3 checklist covers the on-device bits before a mobile release.
1 · Prerequisites
| Target | Toolchain | Notes |
|---|---|---|
| iOS | Xcode 26+, CocoaPods (brew install cocoapods) | Accept the Xcode licence: sudo xcodebuild -license accept. pod uses system Ruby — install via brew, not gem. |
| Android | JDK 21 (brew install --cask temurin@21), Android Studio + SDK 35, an arm64 emulator image | Capacitor 8 requires JDK 21. export JAVA_HOME="$(/usr/libexec/java_home -v 21)". Android Studio bundles its own JDK for the emulator. |
| Both | Node 20+, npm install | Capacitor 8.4.1. |
Verify: xcodebuild -version && pod --version (iOS) · java -version && adb --version (Android).
2 · The build → sync → run loop
npm run build:mobile # MOBILE=1 build + prune + downscale + budget gate (~47 MB on-device)
npx cap sync # copy build/ into ios/ + android/, install pods/plugins
npx cap run ios --target "<simulator-udid>" # build + deploy + launch (iOS)
npx cap run android --target "<emulator-name>" # (Android)
# or open the native IDE:
npm run open:ios # npx cap open ios
npm run open:androidbuild:mobileis a 4-step chain (eachMOBILE=1, since env doesn't cross&&):build→prune-streamed-assets.mjs(stripsbuild/images,build/audio, the 13 non-default locale HTML trees + bundles + raw i18n, dead.br/.gz, and the 4K LOD textures) →downscale-base-textures.mjs(io/titan/enceladus/pluto in place) →check-mobile-size-budget.mjs(fails over 65 MB). Takes a ~2 GB naive build down to ~47 MB.- Simulator UDIDs:
xcrun simctl list devices available | grep iPhone. Boot one:xcrun simctl boot <udid>. - Screenshot (headless):
xcrun simctl io <udid> screenshot /tmp/x.png(iOS) ·adb exec-out screencap -p > /tmp/x.png(Android).
Local dev — live-reload from a local server (100% offline)
The build → sync → run loop above installs a release-shaped bundle that streams heavy assets (images/audio/other-locale bundles) from STREAM_ORIGIN — i.e. the deployed host. On a fresh machine, or before that host has the current assets, galleries render blank (the app asks for …/01.webp, the CDN only has …/01.jpg → 404). That's deploy skew, not a bug. For day-to-day dev you don't want to stream from anywhere — you want everything from your own machine:
# terminal 1 — local dev server (serves the whole app incl. webp images)
npm run dev # honours VITE_DEV_PORT (default 5273)
# terminal 2 — pick your platform
npm run dev:android # sets adb reverse + CAP_DEV_SERVER, runs on the emulator
npm run dev:ios # runs on the booted simulatorCAP_DEV_SERVER(read bycapacitor.config.ts) points the WebView at the local dev server instead of the bundle. Set → live-reload; unset → release bundle. Bothdev:*scripts set it tohttp://localhost:${VITE_DEV_PORT:-5273}.localhostworks for both platforms with zero internet: the iOS simulator shares the Mac'slocalhostnatively (and iOS ATS exemptslocalhost, so plain http is fine); the Android emulator can't reach the host's loopback directly, sodev:androidrunsadb reverse tcp:<port> tcp:<port>first. No LAN IP, no CDN, no10.0.2.2.- In dev the app runs as a browser build (
__MOBILE__false), so images are origin-relative → served by the dev server. Nothing is pruned or streamed. - Prod origin is configurable, not hardcoded (
STREAM_ORIGINenv → vite define): a release build points at whatever host it'll be served from — GitHub Pages today, a VPS IP next, a domain later — via one env var, e.g.STREAM_ORIGIN=https://<host> npm run build:mobile. Web builds ignore it entirely (they stream origin-relative from their own host). Seesrc/lib/asset-url.ts.
The dev server ignores
android/+ios/in its file watcher (vite.config.tsserver.watch.ignored) — otherwisecap syncwriting into those dirs triggers Vite dep re-optimization mid-session, which breaks route-chunk loading in the WebView.
3 · How streaming works (why the bundle is ~47 MB, not 2 GB)
The naive build is ~2 GB — 10× the iOS 200 MB OTA cap. So (ADR-078 / ADR-079):
- Bundled on-device: code, planet textures, core mission/site/fleet JSON, the en-US locale bundle.
- Streamed from
chipi.github.io/orrery(SW-cached on first view): all gallery/hero imagery, narration audio, the other 13 locale bundles.
The switch is src/lib/asset-url.ts — assetOrigin / assetUrl() / streamedUrl() / localeBundleOrigin() resolve to the CDN origin only when __MOBILE__ is true (a Vite define, MOBILE=1). In every browser build assetOrigin === base, so URLs are byte-identical. When adding a new consumer of /images/… or /audio/…, route it through assetUrl/streamedUrl or it 404s on mobile (the panorama swapTexture bug was exactly this).
Trade-off (PRD-015 M5): core experience works offline from install; galleries + audio need one online view to cache.
4 · Sharp edges (learned the hard way, 2026-07-07)
env(safe-area-inset-*)returns 0 in Capacitor iOS. The nav/footer render under the status bar / Dynamic Island. Fixed by a native shim —SafeAreaViewController(ios/App/App/AppDelegate.swift, wired viaMain.storyboard) injects the realview.safeAreaInsetsas CSS vars; CSS readsvar(--safe-area-inset-top, env(...)). Don't trustenv()here; instrument the computed value on-screen when debugging.ios.scrollEnabledconfig isn't reliably applied. The shim also forceswebView.scrollView.isScrollEnabled = true. Config alone left touch-scroll dead (programmatic scroll worked — the tell).capacitor-assets generateerrors on the PWA step (looks forwww/manifest.json). Use--ios/--androidflags to scope it; it still generates the native icons/splash. Source art:assets/icon.png(1024) +assets/splash.png(2732) rendered fromstatic/favicon.svgviasharp. It also drops a strayicons/at repo root — delete it.- Splash flashes by unseen without
@capacitor/splash-screen. Installed +launchShowDuration: 1800. - macOS lockfile trap: an incremental
npm i <pkg>on macOS can strip Linux-only optionals (@rollup/rollup-linux-*) frompackage-lock.json→ CInpm cifails on Linux. After adding a dep,git diff package-lock.json | grep '^-.*linux'and grep-confirm@rollup/rollup-linux-x64-gnusurvives (a fullrm lock && npm installregen usually restores them; verify). - tech-bom license gate:
@capacitor/assetspulls@trapezedev/*(MIT, butpackage.jsonsays non-SPDX "SEE LICENSE"). Add toPACKAGE_LICENSE_OVERRIDESinscripts/build-tech-bom.ts, thennpm run build-tech-bom. - Locale flags were dropped — flag emoji tofu ("?") on the iOS Simulator (and Windows). The
EN/ES/FRshortTag stays.
5 · Ship — iOS TestFlight (S13)
CI does not build the native binaries (no Xcode/Android SDK in the runners); mobile builds are local. Version lives in package.json (0.8.0-wip → PWA/footer), iOS MARKETING_VERSION (numeric, Apple requires it), Android versionName.
Telemetry tier — bake it before sync:mobile (ADR-082)
Native builds are local, so the tier is whatever you export at build time. Mobile is a segment of the ladder, not a separate app: environment = the tier, the platform tag (ios/android) separates the app from the browser (see ADR-082 amendment). Bake both Sentry and Umami for the tier, or the in-app telemetry is silent:
# Simulator / staging test build — crashes → GlitchTip project 6, pageviews → staging Umami
export PUBLIC_SENTRY_DSN='https://<staging-key>@telemetry.orrerylearn.com/6'
export PUBLIC_SENTRY_ENVIRONMENT=staging
export PUBLIC_UMAMI_HOST='https://analytics.orrerylearn.com'
export PUBLIC_UMAMI_WEBSITE_ID='<staging-umami-site-id>'
npm run sync:mobile
# Release / App Store build — ALWAYS prod: GlitchTip project 4, prod Umami
export PUBLIC_SENTRY_DSN='https://<prod-key>@telemetry.orrerylearn.com/4'
export PUBLIC_SENTRY_ENVIRONMENT=prod
export PUBLIC_UMAMI_HOST='https://analytics.orrerylearn.com'
export PUBLIC_UMAMI_WEBSITE_ID='<prod-umami-site-id>'
npm run sync:mobileKeys are public (they ship in the bundle); strip the dashes from the GlitchTip key (see .env.example). Native crash symbolication is a separate layer — App Store Connect / Play Console consoles (ADR-082 amendment / the #428 note). STREAM_ORIGIN (asset host) is independent of the telemetry tier.
Coming (ADR-083, pending — iOS thread): internal / TestFlight / simulator builds get a runtime "target environment" switcher (staging↔prod) that repoints assets and telemetry together, so you won't rebuild to flip tiers. App Store release stays prod-locked (no switcher). Until it lands, the per-tier
export … && sync:mobileabove is how you switch.
Manual (Xcode)
npm run sync:mobile(build:mobile + cap sync) — with the tier env exported above.npm run open:ios→ in Xcode, select the App target → Signing & Capabilities → set your Apple Developer Team (auto-manage signing). This step needs your Apple ID; it can't be scripted here.- Product → Archive → Distribute App → App Store Connect → upload.
- In App Store Connect, create the app record (bundle id
io.github.chipi.orrery); it appears in TestFlight for internal testers.
Automated — npm run beta (Fastlane)
One command builds, archives, and uploads to TestFlight (Fastlane + pilot). No Xcode clicking, no Apple-ID 2FA per upload, and the build number is taken from the latest TestFlight build so every push is unique.
One-time setup:
- App record must exist in App Store Connect for
io.github.chipi.orrery(Apps → + → New App). TestFlight can't accept a build before the record exists. - App Store Connect API key — App Store Connect → Users and Access → Integrations → App Store Connect API → generate a key (role App Manager). Download the
AuthKey_*.p8(one download only) and store it outside the repo. cp ios/App/fastlane/.env.beta.example ios/App/fastlane/.env.betaand fill inASC_KEY_ID,ASC_ISSUER_ID,ASC_KEY_PATH..env.betaand*.p8are gitignored — never commit them.- Install the Ruby tooling once:
cd ios/App && bundle install. - Be signed into Xcode with an account on team
3P3PX275ZM(automatic signing mints the distribution profile during the archive).
Every release after that:
# export the telemetry tier (prod for a real beta) as in the block above, then:
npm run beta # = sync:mobile → fastlane beta (archive + upload)The build lands in TestFlight → Internal Testing in ~15-30 min. To share with people outside your team, add them to an External group in App Store Connect, fill in the "What to test" info, submit the build for the one-time Beta App Review (~1 day), then hand out the public TestFlight link. (The lane uploads with distribute_external: false; external distribution is an App Store Connect toggle, not a code change.)
Lane + identity live in ios/App/fastlane/{Fastfile,Appfile}; ITSAppUsesNonExemptEncryption=false in Info.plist skips the export-compliance prompt (the app uses only standard HTTPS).
Android (later): npm run open:android → Build → Generate Signed App Bundle → upload the .aab to Play Console (Internal Testing track). Needs JDK 21.
6 · Deep links + share
- Deep links:
orrery://fly?mission=curiosity→/fly?mission=curiosity. Scheme registered inios/App/App/Info.plist(CFBundleURLTypes) +AndroidManifest.xml(intent-filter); handled insrc/lib/native/deep-links.ts. - Share: the nav share button (
src/lib/share.ts) → native share sheet on Capacitor,navigator.shareon web, copy-link fallback. It shares a publicchipi.github.io/orrery/<route>URL (not the internalcapacitor://localhostone).
7 · Architecture map
See TA.md §Mobile subsystem for the full component map, and:
- ADR-078 — iOS-first + stream-heavy bundle
- ADR-079 —
assetUrl()origin spine + source/derived separation - RFC-018 — the Capacitor integration (read the v0.5 correction notes)
- #195 — WebGL context-loss (shipped reload; per-scene reinit deferred)
Orrery · docs/guides/mobile-build-and-deploy.md · July 2026