# Capacitor mobile wrapper — operations Last updated: 2026-05-25 (mobile optimization sweep) The toolctrl mobile app is a Capacitor 7 native wrapper around the React Router v7 web app at `apps/web/`. There is no separate UI codebase. This document covers local development, building for iOS / Android, signing, and store submission. ## Quick reference | Concern | Where | |---|---| | App workspace | `apps/mobile/` | | Bundle id | `de.toolctrl.app` | | Capacitor config | `apps/mobile/capacitor.config.ts` | | Push backend worker | `apps/worker/src/jobs/push-notification.ts` | | Device-token API | `apps/api/src/routes/devices-routes.ts` (`POST /v1/devices/register`) | | Prisma model | `prisma/schema/notifications.prisma` → `model DeviceToken` | | Sentry init | `apps/mobile/src/sentry.ts` | ## Status: code-complete, native builds and store submission deferred The Phase 08 #65 scope ships: - The `apps/mobile/` workspace skeleton + Capacitor 7 config. - Hand-scaffolded iOS + Android native projects under `apps/mobile/{ios,android}/`. An operator with Xcode / Android Studio installed should re-run `npx cap add ios && npx cap add android` once to overwrite the skeletons with the canonical CLI output. - DeviceToken Prisma model + `POST /v1/devices/register` upsert endpoint. - `dispatchPushNotification` worker orchestrator with APNs + FCM seams (real SDK adapters wire in operator-side at deployment). - `@sentry/capacitor` init mirroring the web client config. **Explicitly deferred** (operator-owned): - App-Store and Play-Store submission flows (screenshots, review notes, release marketing). - iOS signing certificate + provisioning profile management. - Android keystore generation + native encrypted storage for long-lived tokens. - `google-services.json` + APNs `.p8` provisioning. - CI pipelines (Codemagic / GitHub Actions macOS-runner / Play upload). ## Local development ### Prerequisites - macOS for iOS (Xcode 15+, CocoaPods, an Apple Developer team) - Any OS for Android (Android Studio Hedgehog+, Android SDK 34, JDK 17) - pnpm 10+, Node 22+ ### Initial setup ```bash # 1. Install workspace deps (from repo root) pnpm install # 2. Build the web bundle that the wrapper loads pnpm --filter @toolctrl/mobile build:web # 3. Sync web assets + plugins into the native projects pnpm --filter @toolctrl/mobile sync # 4. Open in the platform IDE pnpm --filter @toolctrl/mobile open:ios # Xcode pnpm --filter @toolctrl/mobile open:android # Android Studio ``` ### Live-reload dev loop For tight iteration cycles, point the wrapper at a running web dev origin instead of the bundled web assets: ```bash # iOS simulator can reach the host dev server through localhost. export MOBILE_LIVE_RELOAD_URL=http://localhost:3000 # Android emulator maps 10.0.2.2 to the host loopback instead. # export MOBILE_LIVE_RELOAD_URL=http://10.0.2.2:3000 pnpm --filter @toolctrl/mobile sync pnpm --filter @toolctrl/mobile open:ios # or open:android ``` The Capacitor config emits `server.url` only when `MOBILE_LIVE_RELOAD_URL` is set. It must be an absolute `http://` or `https://` URL. Production builds run with the variable unset so the bundle loads the embedded `webDir`. Use `http://localhost:3000` for the iOS simulator, `http://10.0.2.2:3000` for the Android emulator, and HTTPS staging/tunnel URLs for physical devices. The committed Android skeleton only permits cleartext live reload for `localhost` and `10.0.2.2`; do not broaden cleartext allowances in release builds. ### Resetting the native projects When the canonical Capacitor CLI is needed (signing config, new plugin, platform upgrade): ```bash cd apps/mobile rm -rf ios android npx cap add ios npx cap add android npx cap sync ``` Bundle id, app name and webDir come from `capacitor.config.ts`, so re-creation is deterministic. ## Building iOS ```bash cd apps/mobile/ios/App pod install xcodebuild \ -workspace App.xcworkspace \ -scheme App \ -configuration Release \ -archivePath build/App.xcarchive \ archive xcodebuild \ -exportArchive \ -archivePath build/App.xcarchive \ -exportOptionsPlist ExportOptions.plist \ -exportPath build/ipa ``` `ExportOptions.plist` is operator-provided (not committed). Upload to TestFlight via Transporter.app or `xcrun altool --upload-app`. ## Building Android ```bash cd apps/mobile/android ./gradlew bundleRelease # AAB lands at app/build/outputs/bundle/release/app-release.aab ``` Signing requires `signingConfigs.release` in `app/build.gradle` and a keystore. The committed skeleton does not declare a release signing config — operator must add one before the first release build. ## Push notifications ### Backend The push job is implemented as a pure orchestrator (`apps/worker/src/jobs/push-notification.ts`) with three injected provider adapters: - `sendApns(token, payload)` → `node-apn` / `@parse/node-apn` against the PEM key configured in `APNS_KEY` / `APNS_KEY_ID` / `APNS_TEAM_ID`. - `sendFcm(token, payload)` → `firebase-admin` using credentials from `FCM_SERVICE_ACCOUNT_JSON`. - `sendWebPush(token, payload)` → optional `web-push` adapter for the PWA fallback path. The adapters live in `apps/worker/src/index.ts` (operator-side implementation, kept out of the orchestrator so unit tests stay SDK-free). When a provider returns one of the canonical revocation codes (`Unregistered`, `InvalidRegistration`, `BadDeviceToken`, `messaging/registration-token-not-registered`, `messaging/invalid-registration-token`), the corresponding DeviceToken row is soft-deleted via `revokedAt`. ### Provider credentials | Env var | Source | Notes | |---|---|---| | `FCM_SERVICE_ACCOUNT_JSON` | Firebase console → Project settings → Service accounts → Generate new private key | Either raw JSON or base64-encoded JSON | | `APNS_KEY` | Apple Developer → Certificates, Identifiers & Profiles → Keys → Create `.p8` | PKCS#8 PEM block from the downloaded `.p8` file (the standard `BEGIN PRIVATE KEY` framing). | | `APNS_KEY_ID` | Apple Developer (10 chars) | Shown alongside the `.p8` | | `APNS_TEAM_ID` | Apple Developer → Account → Membership (10 chars) | | | `APNS_BUNDLE_ID` | Defaults to `de.toolctrl.app` | Must match `capacitor.config.ts` | **Rotate APNs keys every 12 months.** Apple does not auto-expire `.p8` files but rotation contains compromise blast-radius. ### Device-token API The Capacitor shell calls `POST /v1/devices/register` after the native push plugin yields a token: ```json POST /v1/devices/register Authorization: Bearer Content-Type: application/json { "userId": "u-1", "tenantId": "t-1", "platform": "ios", "token": "", "pushProvider": "apns", "appVersion": "1.0.0" } ``` The endpoint upserts by `token` (globally unique) so cold-start re-registration produces zero duplicates. Logout calls `POST /v1/devices/revoke` with `{ "token": "..." }`. ## App-Store / Play-Store submission Both flows are **operator-owned** and deferred from this scaffold. High-level checklists: ### iOS 1. Apple Developer account active (USD 99/year). 2. App Store Connect record created with `de.toolctrl.app` bundle id. 3. Push certificate / Auth Key (`.p8`) generated. 4. Privacy Policy URL published at `/legal/privacy`. 5. App Privacy questionnaire answered (we collect: contact info, identifiers, usage data; we do NOT collect: precise location, financial info, health data). 6. Screenshots (6.7" iPhone 15 Pro Max, 5.5" iPhone 8 Plus, optionally 12.9" iPad Pro). 7. Review notes with demo credentials. 8. Submit via TestFlight → "Submit for Review". ### Android 1. Google Play Developer account active (USD 25 one-time). 2. Play Console app created with `de.toolctrl.app` application id. 3. Service account JSON for Play API uploads. 4. Privacy Policy URL. 5. Data safety form (same answers as iOS). 6. Screenshots (phone 9:16 + tablet 16:10). 7. Internal testing → Production rollout (staged 5% → 25% → 100%). ## Rollback - Revert the `08/capacitor:` commit range to drop the workspace. - Disable push fan-out by flipping `mobile.push.enabled` feature flag off (the dispatcher short-circuits without touching DeviceToken rows). - Roll back the Prisma migration `20260517200000_08_capacitor_device_tokens` with `prisma migrate resolve --rolled-back` + a manual `DROP TABLE` if the schema needs to revert. ## See also - [`docs/improvement-plan/08-mobile-cli-storybook-sdk/capacitor.md`](../improvement-plan/08-mobile-cli-storybook-sdk/capacitor.md) - [`apps/mobile/README.md`](../../apps/mobile/README.md) - [`apps/mobile/ios/README.md`](../../apps/mobile/ios/README.md) - [`apps/mobile/android/README.md`](../../apps/mobile/android/README.md)