Skip to content

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. The android/ and ios/ 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

TargetToolchainNotes
iOSXcode 26+, CocoaPods (brew install cocoapods)Accept the Xcode licence: sudo xcodebuild -license accept. pod uses system Ruby — install via brew, not gem.
AndroidJDK 21 (brew install --cask temurin@21), Android Studio + SDK 35, an arm64 emulator imageCapacitor 8 requires JDK 21. export JAVA_HOME="$(/usr/libexec/java_home -v 21)". Android Studio bundles its own JDK for the emulator.
BothNode 20+, npm installCapacitor 8.4.1.

Verify: xcodebuild -version && pod --version (iOS) · java -version && adb --version (Android).


2 · The build → sync → run loop

bash
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:android
  • build:mobile is a 4-step chain (each MOBILE=1, since env doesn't cross &&): buildprune-streamed-assets.mjs (strips build/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:

bash
# 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 simulator
  • CAP_DEV_SERVER (read by capacitor.config.ts) points the WebView at the local dev server instead of the bundle. Set → live-reload; unset → release bundle. Both dev:* scripts set it to http://localhost:${VITE_DEV_PORT:-5273}.
  • localhost works for both platforms with zero internet: the iOS simulator shares the Mac's localhost natively (and iOS ATS exempts localhost, so plain http is fine); the Android emulator can't reach the host's loopback directly, so dev:android runs adb reverse tcp:<port> tcp:<port> first. No LAN IP, no CDN, no 10.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_ORIGIN env → 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). See src/lib/asset-url.ts.

The dev server ignores android/ + ios/ in its file watcher (vite.config.tsserver.watch.ignored) — otherwise cap sync writing 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.tsassetOrigin / 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 via Main.storyboard) injects the real view.safeAreaInsets as CSS vars; CSS reads var(--safe-area-inset-top, env(...)). Don't trust env() here; instrument the computed value on-screen when debugging.
  • ios.scrollEnabled config isn't reliably applied. The shim also forces webView.scrollView.isScrollEnabled = true. Config alone left touch-scroll dead (programmatic scroll worked — the tell).
  • capacitor-assets generate errors on the PWA step (looks for www/manifest.json). Use --ios / --android flags to scope it; it still generates the native icons/splash. Source art: assets/icon.png (1024) + assets/splash.png (2732) rendered from static/favicon.svg via sharp. It also drops a stray icons/ 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-*) from package-lock.json → CI npm ci fails on Linux. After adding a dep, git diff package-lock.json | grep '^-.*linux' and grep-confirm @rollup/rollup-linux-x64-gnu survives (a full rm lock && npm install regen usually restores them; verify).
  • tech-bom license gate: @capacitor/assets pulls @trapezedev/* (MIT, but package.json says non-SPDX "SEE LICENSE"). Add to PACKAGE_LICENSE_OVERRIDES in scripts/build-tech-bom.ts, then npm run build-tech-bom.
  • Locale flags were dropped — flag emoji tofu ("?") on the iOS Simulator (and Windows). The EN/ES/FR shortTag 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:

sh
# 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:mobile

Keys 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:mobile above is how you switch.

Manual (Xcode)

  1. npm run sync:mobile (build:mobile + cap sync) — with the tier env exported above.
  2. 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.
  3. Product → Archive → Distribute App → App Store Connect → upload.
  4. 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:

  1. 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.
  2. 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.
  3. cp ios/App/fastlane/.env.beta.example ios/App/fastlane/.env.beta and fill in ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH. .env.beta and *.p8 are gitignored — never commit them.
  4. Install the Ruby tooling once: cd ios/App && bundle install.
  5. Be signed into Xcode with an account on team 3P3PX275ZM (automatic signing mints the distribution profile during the archive).

Every release after that:

sh
# 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.


  • Deep links: orrery://fly?mission=curiosity/fly?mission=curiosity. Scheme registered in ios/App/App/Info.plist (CFBundleURLTypes) + AndroidManifest.xml (intent-filter); handled in src/lib/native/deep-links.ts.
  • Share: the nav share button (src/lib/share.ts) → native share sheet on Capacitor, navigator.share on web, copy-link fallback. It shares a public chipi.github.io/orrery/<route> URL (not the internal capacitor://localhost one).

7 · Architecture map

See TA.md §Mobile subsystem for the full component map, and:

  • ADR-078 — iOS-first + stream-heavy bundle
  • ADR-079assetUrl() 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

Orrery — architecture documentation · MIT · No tracking