project-capacitor-mobile updated 2026-03-27Project: Capacitor Mobile
Canonical arch: labels for board items. Per convention-architecture-ids, these map to architecture diagram nodes and are used on board items for traceability.
| Label | Component | Description |
|---|---|---|
arch:cap-core |
Capacitor Core | cap init, cap sync, capacitor.config.ts — the bridge between SvelteKit and native |
arch:ios-shell |
iOS Native Shell | Xcode project, Info.plist, WKWebView, AppDelegate.swift |
arch:signing |
Code Signing | Fastlane match, certificates, provisioning profiles (dev + distribution) |
arch:mac-agent |
Mac CI Agent | Woodpecker agent on MacBook M1, platform=darwin label routing |
arch:ios-pipeline |
iOS Build Pipeline | .woodpecker/ios.yml — npm ci → build → cap sync → xcodebuild → fastlane pilot |
arch:testflight |
TestFlight | Beta distribution via Fastlane pilot, iteration loop with Lucas |
arch:appstore |
App Store Connect | Metadata, assets, review submission, Fastlane deliver |
arch:keycloak-ios |
Keycloak iOS Auth | capacitor://localhost redirect URI, platform detection, silent SSO handling |
The production readiness practice for mobile apps. Take a proven playground design (HTML/CSS, zero infra) and walk it through every step to the App Store: SvelteKit scaffold, Capacitor native plugins, cluster deployment, Mac CI pipeline, TestFlight iteration, and App Store submission.
At the playground stage, an app has nothing: no SvelteKit repo, no k8s namespace, no CI, no ArgoCD, no Capacitor. Just HTML/CSS files served by nginx. This project owns the entire transformation — every piece of infrastructure, every configuration, every Apple requirement — so that individual app projects follow a proven path instead of figuring it out from scratch.
Three practice projects, one pipeline:
- Frontend Playground — proves the look (seconds: save → refresh)
- Capacitor Mobile — proves the functionality and ships to production (minutes to days: build → deploy → TestFlight → App Store)
- pal-e-platform — provides the infrastructure (cluster, CI, GitOps, observability)
The native mobile development practice. Bridge proven web design (from the playground) to native mobile functionality via Capacitor. Own the complexity of iOS builds, native plugins, App Store submission, and the TestFlight iteration loop — so that individual app projects (mcd-tracker, future apps) don't have to.
Relationship to Frontend Playground: Playground proves the look. This project proves the functionality. Playground iterates in seconds (save → refresh). This project iterates in minutes (build → sign → TestFlight → install on phone). Two cadences, two practices, complementary.
User Stories
| Key | Role | Story | Success Metric |
|---|---|---|---|
cap-setup |
Dev agent | I want to add Capacitor to an existing SvelteKit SPA | npx cap add ios works, Xcode project generated, platform detection in keycloak.js |
cap-build |
Dev agent | I want CI to build an iOS binary and upload to TestFlight automatically | Push to Forgejo → Mac agent → xcodebuild → Fastlane → TestFlight |
cap-appstore |
Lucas | I want to submit an app to the App Store with correct metadata and pass review | App approved, live on App Store |
iterate |
Lucas | I want to test the app on my phone via TestFlight and iterate on iOS-specific issues | App installs on iPhone, full user flow works, Lucas approves |
promote |
Dev agent | I want to scaffold a playground prototype as a SvelteKit + Capacitor app | HTML/CSS from playground used directly as SvelteKit templates + app.css |
deploy |
Dev agent | I want to deploy the SvelteKit app to the cluster with full GitOps | pal-e-services onboarding + pal-e-deployments overlay + ArgoCD syncing |
cap-camera |
App user | I want to use the native camera from within the app | @capacitor/camera opens iOS camera, returns photo, web fallback works |
cap-gps |
App user | I want the app to detect my location automatically | @capacitor/geolocation returns lat/lng, permission prompt handled |
cap-push |
App user | I want to receive push notifications | @capacitor/push-notifications delivers to lock screen |
analytics |
Lucas | I want to see how many people use my app and what they do | Umami (web) + App Store Connect (iOS) + custom event tracking |
dual-channel |
App user | I want to use the app on web OR iOS, whichever I prefer | Same app at web URL and on App Store, analytics tracks both |
monetize |
Lucas | I want to monetize when I have ~100 active users | Stripe (web) + Apple IAP (iOS), RevenueCat unified |
Plan
Plans are obsolete per convention-kanban-over-plans. The board IS the work decomposition tool. Existing plan references (plan-wkq phases 22-24, plan-pal-e-platform phases 28-30) are historical context only.
Board
board-capacitor-mobile — Continuous kanban. Tracks the web-to-App-Store transformation pipeline. Cross-repo: items reference issues on consumer app repos (westside-app, mcd-tracker-app) and platform infra repos (pal-e-platform). consumer:X labels identify which project each item serves.
Status
See board-capacitor-mobile. The board is the source of truth for current state — not this paragraph. Key blocker: Apple Developer enrollment ($99, blocker:external). Infrastructure is ready (Mac agent connected, subnet router deployed, Salt managing config).
Milestones
None yet. First milestone will be: westside-app running on Lucas's iPhone via TestFlight.
Architecture
Architecture Component IDs
How Capacitor Works
SvelteKit App (SPA mode, adapter-static)
↓ npx cap sync
Capacitor iOS Shell (WKWebView)
├── Your SvelteKit app runs inside WKWebView
├── @capacitor/camera → native AVFoundation
├── @capacitor/geolocation → native CLLocationManager
├── @capacitor/push-notifications → native APNs
└── @capacitor/filesystem → native file system
Xcode Project (auto-generated by Capacitor)
├── ios/App/App/
│ ├── Info.plist (permissions, entitlements)
│ ├── AppDelegate.swift (minimal, Capacitor-managed)
│ └── public/ (your built SvelteKit SPA)
└── ios/App.xcodeproj (opened by Xcode for signing + build)
SvelteKit SPA Configuration
Full convention documented in convention-sveltekit-spa. Summary of SPA requirements for Capacitor apps:
| Setting | Value | Why |
|---|---|---|
| Adapter | @sveltejs/adapter-static with fallback: 'index.html' |
SPA mode — all routes resolve to index.html, SvelteKit router handles navigation client-side |
| SSR | false |
No server-side rendering — everything runs in browser. Required for Capacitor. |
| bundleStrategy | 'single' (recommended) |
Capacitor local server uses HTTP/1 — single bundle avoids waterfall |
| Auth | keycloak-js + PKCE (public client) |
No server to store secrets. In-memory tokens only. See convention-sveltekit-spa for full pattern. |
| Data fetching | Client-side fetch() with Bearer token |
No +page.server.ts. API wrapper module (src/lib/api.js) with keycloak.updateToken(30) before every call. |
| CSS | Global app.css, no Tailwind |
Per convention-frontend-css. Design tokens as CSS custom properties. |
| Dockerfile | nginx:alpine serving static build/ |
try_files $uri $uri/ /index.html for SPA fallback. Cache headers on hashed assets. |
| Env vars | VITE_KEYCLOAK_URL, VITE_KEYCLOAK_REALM, VITE_KEYCLOAK_CLIENT_ID, VITE_API_URL |
VITE_ prefix required — Vite only exposes prefixed vars to client-side code. |
Local Development Workflow (CRITICAL — test before deploy)
Three iteration speeds. Never skip level 2.
| Speed | Tool | What it catches | Iteration time |
|---|---|---|---|
| 1. Playground | Save HTML → refresh browser | Design, layout, UX flow. No auth, no API. | Seconds |
| 2. Local dev | npm run dev → http://localhost:5173 | Auth flow, CORS, API shape, data binding, error states. Real keycloak-js, real API. | Seconds |
| 3. Cluster deploy | Push → CI → Harbor → ArgoCD | Production infra: ports, image names, ingress, TLS. Only after level 2 validates. | Minutes |
Lesson (2026-03-16): Phase 7 skipped level 2 entirely. Six production hotfixes in a row — wrong port, wrong registry, wrong realm, check-sso redirect, no CORS, client not public. Every single one would have been caught in seconds with npm run dev. The rule: if it doesn't work on localhost, it doesn't get pushed to prod. Local dev requires: (1) http://localhost:5173 in Keycloak redirect URIs ✓ (already added), (2) http://localhost in API CORS origins ✓ (already added), (3) API accessible at https://mcd-tracker.tail5b443a.ts.net (already deployed).
Capacitor requires SvelteKit to run as a Single Page Application (no SSR). Configuration:
adapter-staticinstead ofadapter-nodessr: falseinsvelte.config.js(or per-page)bundleStrategy: 'single'recommended (Capacitor local server uses HTTP/1)- API calls go to the remote backend URL (not relative paths — no server-side in SPA mode)
Authentication (keycloak-js — decided 2026-03-16)
SPA mode means no server-side auth. Auth.js (used by westside-app) requires adapter-node + SSR for server-side token exchange — incompatible with adapter-static. Decision: use keycloak-js (official Keycloak JavaScript adapter) for client-side OIDC with PKCE. Rejected: Auth.js (requires SSR), oidc-spa (warns against SvelteKit), keycloak-ionic (deprecated), @byteowls/capacitor-oauth2 (native-only, would need separate web auth = two code paths), BFF pattern (adds server back, defeats SPA purpose).
| Concern | Approach |
|---|---|
| Keycloak client type | Public client (Client authentication OFF — can't store secrets client-side) |
| PKCE | Enabled by default in keycloak-js (SHA256) |
| Token storage | In-memory only (never localStorage). Per Keycloak docs: "tokens should never be persisted to prevent hijacking attacks." |
| Token refresh | keycloak.updateToken(30) before API calls. On Capacitor appStateChange, refresh on resume. |
| Silent check-SSO | Hidden iframe on web. Full check on app resume for iOS. |
| API calls | Bearer token: fetch(url, { headers: { Authorization: `Bearer ${keycloak.token}` } }) |
| Redirect URIs | Web: https://mcd-tracker-app.tail5b443a.ts.net/* — iOS: capacitor://localhost/* |
| Web Origins | https://mcd-tracker-app.tail5b443a.ts.net, capacitor://localhost, http://localhost |
| SvelteKit pattern | Initialize in +layout.svelte (onMount). Auth guard blocks rendering until resolved. Data loading via client-side fetch(), not +page.server.ts. |
| Platform detection | Capacitor.isNativePlatform() sets redirectUri. Web: window.location.origin. iOS: capacitor://localhost/ |
Plugin Catalog
| Plugin | npm Package | Native API (iOS) | Web Fallback | mcd-tracker uses |
|---|---|---|---|---|
| Camera | @capacitor/camera |
AVFoundation | <input type="file" capture> |
Yes — receipt photos |
| Geolocation | @capacitor/geolocation |
CLLocationManager | navigator.geolocation |
Yes — auto-detect McDonald's |
| Push Notifications | @capacitor/push-notifications |
APNs | n/a | Future — slot reopen alerts |
| Clipboard | @capacitor/clipboard |
UIPasteboard | navigator.clipboard |
Yes — copy survey code |
| Haptics | @capacitor/haptics |
UIImpactFeedbackGenerator | n/a | Nice-to-have |
| Filesystem | @capacitor/filesystem |
Native file system | IndexedDB | Maybe — offline receipt cache |
| App | @capacitor/app |
UIApplication lifecycle | n/a | Yes — deep links, state restoration |
iOS Build Pipeline
Developer pushes code to Forgejo
↓ Woodpecker webhook
Mac Woodpecker Agent (local backend, platform=darwin)
├── npm install
├── npm run build (SvelteKit → static)
├── npx cap sync (copy build to ios/App/public/)
├── xcodebuild archive
├── xcodebuild -exportArchive → .ipa
├── fastlane pilot upload → TestFlight
└── (manual) promote TestFlight → App Store
App Store Requirements
- Apple Developer Program ($99/yr)
- Signing certificates + provisioning profiles (Fastlane
match) - App icons (1024x1024 + all required sizes)
- Launch screen / splash screen
- Privacy policy URL
Info.plistpermission descriptions: camera usage, location usage- App Store Connect metadata: description, keywords, screenshots
- App review: usually 24-48 hours
Analytics & User Tracking
Self-hosted, privacy-respecting analytics. No Google Analytics, no third-party trackers. Three layers:
| Layer | Tool | What it tracks | Self-hosted | When to add |
|---|---|---|---|---|
| Web analytics | Umami or Plausible | Page views, referrers, devices, web vs iOS split | Yes (k8s deployment) | After App Store launch |
| Product events | Custom API endpoint (POST /events) | App-specific actions: scanned receipt, redeemed code, slot checked | Yes (same Postgres) | When 10+ users |
| App Store metrics | App Store Connect (Apple) | Downloads, impressions, conversion rate, crashes, ratings | Apple-hosted (free) | Automatic on publish |
| Monetization | Stripe (web) + Apple IAP (iOS) + RevenueCat (unified) | Subscriptions, revenue, churn | Stripe self-managed | When ~100 active users |
Dual-Channel Distribution
Every Capacitor app ships to both web and iOS from one codebase. Standard for modern apps:
- Web — deployed via ArgoCD, accessible at public URL. SEO-indexable. No install required. Catches Android users + desktop users.
- iOS — Capacitor wraps SvelteKit SPA in WKWebView. Native camera/GPS. App Store distribution. Push notifications.
- Analytics must track both channels — Umami embeds in the SvelteKit app (works on both), App Store Connect covers iOS-specific metrics.
POST /eventstracks user actions regardless of channel.
Repos
This project doesn't own repos — it owns knowledge. Capacitor integration lives inside each consumer's app repo:
| Consumer | Repo | Playground | Docker Compose | Dev Port |
|---|---|---|---|---|
| mcd-tracker | mcd-tracker-app |
Approved — CSS consolidated, @-comment specs | Merged (PR #5, #6) | 5173 |
| westside | westside-app |
Approved — playground complete | Not yet created | 5174 |
Key Conventions
sop-frontend-experiment— design in playground FIRST (locked before Capacitor touches it)convention-frontend-css— CSS carries through playground → SvelteKit → Capacitor unchangedconvention-sveltekit-spa— adapter-static, keycloak-js PKCE, client-side fetch, nginx static serving- SOP: Native Mobile Integration — TBD, created when Phase 7 begins
- SOP: App Store Submission — TBD, created when Phase 10 begins
Inbox
| Slug | Summary | Discovered |
|---|---|---|
| empty |