RFC-018 · Capacitor Mobile Wrapper — Android + iOS
Status: Draft v0.3 · 2026-05-15 (decisions baked 2026-05-16) · Closes: PRD-015
Why this is an RFC. The choice of Capacitor 7 (vs Tauri Mobile, React Native, or PWA-only) and the bundle-slimming + PWA-autoUpdate strategy that make a 355 MB naive build shippable are architectural decisions that bind every future asset, route, and runtime-state choice. They need to be debated and recorded before any
ios/orandroid/directory lands in the repo.
1 · Motivation
Orrery (v0.6.0, May 2026) is a SvelteKit static site deployed to GitHub Pages. The build output (build/) is HTML, JS, CSS, JSON manifests, fonts, planet textures, mission galleries, agency logos, and 12 Paraglide message bundles. Capacitor takes that directory and wraps it in a Chromium WebView (Android) or WKWebView (iOS) to produce native app binaries for Google Play and the App Store.
The web app does not change. The build pipeline gains a sync step. Three things, however, are no longer free:
- Bundle size. The
build/output is ~355 MB today. Google Play has no hard cap but flags downloads above ~150 MB; iOS rejects OTA installs above 200 MB and bundles above 4 GB. A naivecap syncships an app that fails Google Play's expedited review and exceeds iOS's OTA install threshold. - WebView feature parity. Three.js r128, WebGL2, History API routing (ADR-013), Web Workers (Lambert solver), service workers,
IntersectionObserver,prefers-reduced-motion, viewport units — all already shipped, all need verification on Android Chromium WebView and iOS WKWebView 16+. - Update story. The PWA service worker we shipped in
v0.5.x(ADR-029,registerType: 'autoUpdate') silently swaps in new bundles on every navigation in browser. Inside a Capacitor wrapper that same SW is still installed against the local file:// origin and continues to update content over the air — which doubles as the answer to "how do I push a content fix without a Play/App Store submission?" but raises a tension with App Store review rules about remote code loading. Section 8 details the policy line.
This RFC defines what changes (and what does not) to ship Orrery on Google Play first and the App Store second, without breaking the existing GitHub-Pages browser deploy.
2 · Capacitor 7 (not 6)
Capacitor 7.x — current stable as of May 2026. v7 raised the iOS minimum to 16+ (matches PRD M3) and the Android minSdkVersion to 23 / targetSdkVersion 34 (Play Store policy minimum). The plugin API is stable across 6 → 7 with one breaking change: the Capacitor.getPlatform() value is now strictly 'android' | 'ios' | 'web' (no more 'electron'); irrelevant to us.
npm install -D @capacitor/cli
npm install @capacitor/core @capacitor/android @capacitor/ios
npx cap init "Orrery" "io.github.chipi.orrery" --web-dir=buildappId uses the reverse-DNS form of the GitHub Pages host (chipi.github.io) so we don't need to register a new domain just to publish.
--web-dir=build is the only coupling between SvelteKit and Capacitor — the static adapter already writes there.
3 · Repository changes
orrery/
├── capacitor.config.ts ← new, committed
├── android/ ← new, committed (Android Studio project)
│ └── app/
│ ├── src/main/AndroidManifest.xml
│ └── build.gradle
├── ios/ ← new, committed (Xcode project)
│ └── App/App/Info.plist
├── build/ ← existing, gitignored (SvelteKit output)
└── src/ ← existing (no structural change)The android/ and ios/ directories are committed (Capacitor convention). They grow as plugins are added. CI will not touch them — Xcode and Android SDK are not in the GitHub Actions runner; mobile builds are local-only for this phase (§9).
4 · Bundle strategy — the 355 MB problem
Naive npm run build produces ≈ 355 MB across roughly:
| Bucket | Size | Notes |
|---|---|---|
static/images/fleet-galleries/ | ~120 MB | 30 missions × ~4 MB hero images |
static/data/locale/* + paraglide bundles | ~150 MB | 12 locales × ~12 MB of mission/site/fleet JSON + overlays |
static/textures/planets/ | ~30 MB | 8K Earth + Mars + Moon + Jupiter |
static/data/science-overlays/ + diagrams | ~10 MB | SVG + JSON science 101 chapters |
| Fonts (Space Grotesk, Space Mono, JetBrains Mono) | ~5 MB | 3 families × 2 weights × WOFF2 |
Agency logos (static/logos/) | ~3 MB | NASA, ESA, JAXA, ISRO, ROSCOSMOS, CNSA SVG/PNG |
| App shell + Three.js + Svelte chunk | ~37 MB raw / ~5 MB gzipped | Code |
To meet the PRD M11 budget (≤ 150 MB installed), three actions land in this RFC:
4.1 Texture downsample for mobile builds. Add a MOBILE=1 Vite env that swaps static/textures/planets/8k.jpg → 4k.jpg references. The 4K textures already exist in the repo (used by the ?lowres=1 URL flag from ADR-022 reduced-motion fallback). Saves ~22 MB.
4.2 Lazy-loaded locale bundles. Today all 12 Paraglide locales ship in every bundle. Move 11 non-default locales to async imports loaded on locale switch. Saves ~140 MB (the en-US bundle stays in the initial chunk; others fetch on demand). The PWA service worker caches them after first load. Detail in §8.
4.3 Fleet-gallery thumbnail tier. Bundle 256 px thumbnails (~150 KB each) for all 30 missions. Hero-quality images stay on GitHub Pages and stream on-demand via the existing PWA cache. Saves ~110 MB. Acceptable because the gallery is browse-then-tap; the hero swap on tap is invisible at WebView paint speed and the SW caches subsequent visits.
Net result: ~355 MB → ~85 MB installed bundle. Within Google Play expedited review (≤ 150 MB) and well under iOS OTA cap (200 MB).
What this does not require: a separate codebase, a build matrix, or per-platform repos. The MOBILE=1 env flips three SvelteKit process.env-style branches, all in vite.config.ts or app.html. The browser build at chipi.github.io is unchanged.
5 · capacitor.config.ts
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'io.github.chipi.orrery',
appName: 'Orrery',
webDir: 'build',
server: {
// No live server in production — app serves from local file:// bundle.
// For local development against Vite dev server, set:
// url: 'http://192.168.1.x:5173',
// cleartext: true,
// and call `npx cap run android --live-reload` (LAN access required).
androidScheme: 'https', // keeps the WebView origin stable for SW + History API
},
android: {
backgroundColor: '#04040c', // matches --bg-base; no white flash
captureInput: true, // keyboard focus stays in WebView
webContentsDebuggingEnabled: false, // true only in debug builds
},
ios: {
contentInset: 'always',
backgroundColor: '#04040c',
scrollEnabled: false, // 3D scenes consume touch directly
limitsNavigationsToAppBoundDomains: true,
allowsLinkPreview: false,
},
plugins: {
SplashScreen: {
launchShowDuration: 600,
launchAutoHide: true,
backgroundColor: '#04040c',
androidSplashResourceName: 'splash',
showSpinner: false,
},
},
};
export default config;Decisions worth flagging:
androidScheme: 'https'— Capacitor 7 default. Critical for us because the PWA service worker requires a secure origin and the History API requires a non-file://scheme. iOS usescapacitor://for the same reasons.scrollEnabled: false(iOS) — Three.js pointer events handle orbit/pan/zoom. Native scroll would intercept single-finger drag and break every 3D scene. Android WebView does not need this flag (Chromium passes touch through by default).limitsNavigationsToAppBoundDomains: true(iOS) — App Store hard requirement. External educational links route through@capacitor/browser(§7) so the wrapper WebView never navigates off-origin.backgroundColor: '#04040c'— same on both platforms; matches--bg-base. Eliminates the white flash at WebView mount.
6 · Plugins
| Plugin | Why |
|---|---|
@capacitor/splash-screen | Dark splash matching --bg-base; auto-hides at 600 ms |
@capacitor/status-bar | Translucent dark on Android; matches the app shell |
@capacitor/browser | External educational links open in Chrome Custom Tab / SFSafariViewController, not the app WebView |
@capacitor/share | Native share sheet for mission-arc share-links |
@capacitor/app | App-state lifecycle (foreground / background), appUrlOpen for deep links, Android back-button override |
@capacitor/haptics | Mission-event tactile cues (S2 in PRD-015) |
Explicitly not used:
@capacitor/preferences/@capacitor/storage— runtime-only state per CLAUDE.md (no localStorage / sessionStorage); theorrery_localecookie is the lone persistent value and the WebView handles cookies natively.@capacitor/filesystem— all data bundled or PWA-cached.@capacitor/push-notifications, camera, geolocation — out of scope.
7 · Routing, links, deep-links
Routing. Orrery uses History API routing (ADR-013, SvelteKit default) — no hash routes. The androidScheme: 'https' / iOS capacitor:// config (§5) ensures the WebView treats the local bundle as a real origin, so pushState / popState work without modification.
External links. Every external <a> already goes through the global click delegation in src/routes/+layout.svelte (analytics external-link-click event). For Capacitor we add a small adapter that intercepts those clicks before navigation:
// src/lib/external-link.ts
import { Capacitor } from '@capacitor/core';
import { Browser } from '@capacitor/browser';
export async function openExternal(url: string) {
if (Capacitor.isNativePlatform()) {
await Browser.open({ url, presentationStyle: 'popover' });
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
}The existing layout-level click handler routes through openExternal() when running under Capacitor. One file changes; every route inherits it.
Deep links. PRD-015 S6: orrery://mission?id=curiosity should open the mission directly.
<!-- android/app/src/main/AndroidManifest.xml -->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="orrery" />
</intent-filter><!-- ios/App/App/Info.plist -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array><string>orrery</string></array>
</dict>
</array>// src/lib/deep-links.ts
import { App } from '@capacitor/app';
import { goto } from '$app/navigation';
App.addListener('appUrlOpen', ({ url }) => {
const u = new URL(url); // orrery://mission?id=curiosity
void goto(`/${u.host}${u.search}`); // → /mission?id=curiosity
});Universal Links (https://chipi.github.io/orrery/...) are deferred to a follow-up RFC; they require an apple-app-site-association file and a Digital Asset Links record on the GH Pages domain.
8 · PWA service worker × Capacitor — content updates without store review
This is the most consequential decision in this RFC.
We shipped @vite-pwa/sveltekit in registerType: 'autoUpdate' mode (ADR-029, see vite.config.ts and src/routes/+layout.svelte lines 70–87). The SW caches the app shell + assets and silently rotates them on the next navigation when a new version is published.
Inside Capacitor, that SW is still installed against the wrapper origin (https://localhost on Android, capacitor://localhost on iOS). It will:
- Serve the bundled
build/content from cache on first run. - On subsequent runs with network, attempt to update its own caches against… nothing. The bundled
build/has noService-Worker-Allowedheader, no remote/sw.jsto refresh against. Updates from the web won't propagate.
Two strategies:
8.1 Network-aware SW (recommended). Configure the SW with a runtimeCaching rule that, when running under Capacitor and the device is online, fetches a manifest from https://chipi.github.io/orrery/build-info.json and selectively pulls content-only updates (mission JSON, locale bundles, gallery thumbs). The app shell and Three.js code stay frozen at the version the user installed from the store. Code updates remain a Play / App Store release.
This satisfies App Store §2.5.2 (no remote-loading executable code) because nothing under static/data/ or messages/ is executable — they are JSON / text. Three.js and Svelte chunks are not refreshed at runtime.
8.2 Disable SW under Capacitor. Skip useRegisterSW() when Capacitor.isNativePlatform(). Simpler; means content fixes require a store release. Reasonable for the first ship.
Recommendation: ship with 8.2 (disabled SW under Capacitor) for v1.0 to avoid App Store review surprises, then move to 8.1 in a follow-up once the runtime caching policy is reviewed by the App Store. Update: this is a policy decision and goes back to PRD-015 open Q5.
// src/routes/+layout.svelte — onMount() service-worker block
import { Capacitor } from '@capacitor/core';
// ...
if (Capacitor.isNativePlatform()) return; // skip SW under wrapper for v1.0
const { useRegisterSW } = await import('virtual:pwa-register/svelte');
useRegisterSW({ /* existing config */ });9 · Build pipeline
# Web build (mobile profile)
MOBILE=1 npm run build # → build/, with §4 substitutions
# Sync into native projects
npx cap sync # → android/app/src/main/assets/public/
# → ios/App/App/public/
# Android — Google Play (first to ship)
npx cap open android # opens Android Studio
# Build → Generate Signed App Bundle → upload .aab to Play Console
# First track: Internal Testing (no review)
# iOS (after Android is live)
npx cap open ios # opens Xcode
# Product → Archive → Distribute → App Store Connect
# First track: TestFlight (review-lite)npm scripts:
{
"scripts": {
"build:mobile": "MOBILE=1 npm run build",
"build:android": "npm run build:mobile && npx cap sync android",
"build:ios": "npm run build:mobile && npx cap sync ios",
"open:android": "npx cap open android",
"open:ios": "npx cap open ios",
"dev:android": "npx cap run android --live-reload --external"
}
}CI. GitHub Actions does not build the native binaries — Xcode and Android SDK are not in the standard runners and we don't pay for cloud Mac runners. Mobile builds remain local. The validate-data + preflight chain runs on every push as today and catches any data drift before the operator runs cap sync. If CI mobile builds become needed later, Codemagic and Bitrise both have first-class Capacitor support.
10 · Android — platform specifics (first to ship)
10.1 SDK targets
minSdkVersion: 23 (Android 6.0). 99%+ of active devices.targetSdkVersion: 34 (Android 14). Required by Google Play as of August 2024.compileSdkVersion: 34.
10.2 WebView
Android System WebView is shipped as an updatable Play Store component (or via Chrome on older devices). Chromium-based, current within months of upstream. WebGL2, Web Workers, History API, IntersectionObserver, prefers-reduced-motion, service workers — all supported on every device meeting minSdkVersion.
Hardware acceleration is on by default in Capacitor's generated AndroidManifest.xml. Verify it stays on — software rendering for Three.js drops to <5 fps.
10.3 Back gesture
Android's back gesture should pop the WebView's history stack, not exit the app immediately. With History API routing this is the system default; we add an App.addListener('backButton', …) only to call App.exitApp() when there is no history left.
import { App } from '@capacitor/app';
App.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) window.history.back();
else App.exitApp();
});10.4 Network security
Default policy (deny cleartext) is correct. Orrery makes only HTTPS requests, all to chipi.github.io (under §8.1) or no requests at all (under §8.2). No network_security_config.xml exceptions.
10.5 build.gradle
android {
compileSdkVersion 34
defaultConfig {
applicationId "io.github.chipi.orrery"
minSdkVersion 23
targetSdkVersion 34
versionCode 1 // increment on every Play submission
versionName "0.6.0" // matches package.json
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}11 · iOS — platform specifics
11.1 SDK targets
iOS Deployment Target: 16.0. Covers ≈ 96 % of active iPhones as of May 2026.
11.2 WKWebView and WebGL — context loss
WKWebView on iOS 16+ supports WebGL2 + Three.js r128 reliably. The known issue is WebGL context loss when the app backgrounds — more aggressive on iOS than Android. Three.js does not auto-restore.
Each 3D scene gets a context-loss listener. Concretely there are 7 scenes today: /explore, /fly (heliocentric), /fly (cislunar per ADR-058), /earth, /moon, /mars, /iss, /tiangong. Each has a single renderer setup module; a small mixin handles restore:
// src/lib/webgl-restore.ts
import { App } from '@capacitor/app';
export function attachContextRestore(canvas: HTMLCanvasElement, reinit: () => void) {
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault();
});
canvas.addEventListener('webglcontextrestored', reinit);
App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) return;
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (gl?.isContextLost()) reinit();
});
}Each scene already has a renderer-init function; we factor it so the same function can be called as reinit. PRD-015 M5 owns the per-scene work.
11.3 Pixel ratio
Existing Math.min(devicePixelRatio, 2) cap stays. iPhone Pro models are 3× — uncapped at 60 fps drops frames. Already correct in the renderer setup; just don't let anyone "fix" it on iOS.
11.4 Safe areas
Already wired via env(safe-area-inset-*) in src/lib/styles/app.css and the footer at src/routes/+layout.svelte:212. The fixed .site-footer already uses bottom: max(6px, env(safe-area-inset-bottom)), which is exactly the pattern Capacitor needs. Nothing to change.
11.5 Info.plist
No NSAppTransportSecurity exceptions (all traffic HTTPS, all to chipi.github.io or absent). No NSCameraUsageDescription / location strings. The only key worth adding is for the share plugin if photo-library save is ever enabled (currently not in PRD-015 scope).
11.6 App Store §2.5.2 — remote code
Critical: under §8.2 (no SW under Capacitor) we are clean. Under §8.1 (network-aware SW) we are clean only if runtime cache rules touch JSON/text only and never JavaScript. The SW config must be reviewed against the App Store policy before any iOS submission with §8.1 enabled.
12 · Web Worker (Lambert solver) compatibility
The Lambert solver in /fly runs in a Web Worker. Both Chromium WebView and WKWebView 16+ support Web Workers without restriction. Vite emits the worker as assets/lambert.worker-*.js; verify after cap sync:
ls android/app/src/main/assets/public/_app/immutable/workers/
ls ios/App/App/public/_app/immutable/workers/If the worker file is missing post-sync, vite.config.ts needs worker: { format: 'es' } (default in current Vite, but worth checking after a major Vite bump).
13 · App icons + splash screens
npm install -D @capacitor/assets
npx capacitor-assets generate \
--iconBackgroundColor '#04040c' \
--iconBackgroundColorDark '#04040c' \
--splashBackgroundColor '#04040c' \
--splashBackgroundColorDark '#04040c'Source: a single assets/icon.png (1024 × 1024) and assets/splash.png (2732 × 2732) — generates all required Android mipmap-* densities and iOS AppIcon.appiconset sizes. Icon design is a separate task (PRD-015 open Q4).
14 · Alternatives considered
Tauri Mobile 2.x. Rust-backed, smaller binary. We have no native processing to gain from a Rust backend; smaller binary doesn't help when our payload is data assets, not code. Capacitor is more battle-tested for WebView + WebGL workloads. Rejected.
React Native. Would mean rewriting Three.js orchestration and all UI in React + JSI bridges. The web app is the product. Rejected.
PWA only (Add to Home Screen). Works on Android (Chromium engine, full SW + manifest), works limitedly on iOS Safari (no push, no background sync, smaller storage cap). Reaches motivated users; misses store-driven discovery. Capacitor does not preclude PWA — the chipi.github.io PWA stays online. Rejected as the only distribution.
NativeScript. XML/Vue/Angular template rewrite. Web app is the product. Rejected.
15 · Resolved decisions + open questions
Resolved 2026-05-16 (handed back from PRD-015):
- §8 SW policy — RESOLVED: 8.2 (disabled SW under Capacitor) for v1.0. Wrapper serves the bundled
build/content only; content updates require a Play / App Store release. Eliminates App Store §2.5.2 risk for the first ship. Reopen as a v1.1 ADR if/when content-update friction surfaces in production. - Bundle slimming target — RESOLVED: ~85 MB via §4 slim plan. Three actions land: 4K planet textures (mobile only), lazy-load 11 non-default locales, 256-px fleet thumbnails (hero images stream from
chipi.github.ioon tap). PRD M11 ceiling is 150 MB; 85 MB leaves ~65 MB of headroom for future content growth. - iOS code-signing ownership — RESOLVED: Marko owns it (personal Apple Developer account, $99/yr). Apple Developer Programme membership + provisioning profiles managed under his account. Applies whenever iOS submission begins; Android Google Play ships first regardless.
build-info.jsonshape — moot. §8.1 was not selected for v1.0; spec deferred until §8.1 is reopened.
Still open:
- Per-scene WebGL restore — owners. §11.2 lists 7 scenes that each need a
reinit()factor-out. Track as 7 sub-tasks under PRD-015 M5 when implementation starts. Not an architectural decision; just task-list scope.
RFC-018 · Orrery · 2026-05-15 (decisions baked 2026-05-16) · Closes-into-PRD-015