project-page active Capacitor Mobile
project-capacitor-mobile updated 2026-03-27

Project: 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-static instead of adapter-node
  • ssr: false in svelte.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.plist permission 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 /events tracks 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 unchanged
  • convention-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