8.6 KiB
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-runnpx cap add ios && npx cap add androidonce to overwrite the skeletons with the canonical CLI output. - DeviceToken Prisma model +
POST /v1/devices/registerupsert endpoint. dispatchPushNotificationworker orchestrator with APNs + FCM seams (real SDK adapters wire in operator-side at deployment).@sentry/capacitorinit 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.p8provisioning.- 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
# 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:
# 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):
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
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
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-apnagainst the PEM key configured inAPNS_KEY/APNS_KEY_ID/APNS_TEAM_ID.sendFcm(token, payload)→firebase-adminusing credentials fromFCM_SERVICE_ACCOUNT_JSON.sendWebPush(token, payload)→ optionalweb-pushadapter 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:
POST /v1/devices/register
Authorization: Bearer <service-token>
Content-Type: application/json
{
"userId": "u-1",
"tenantId": "t-1",
"platform": "ios",
"token": "<APNs / FCM / WebPush 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
- Apple Developer account active (USD 99/year).
- App Store Connect record created with
de.toolctrl.appbundle id. - Push certificate / Auth Key (
.p8) generated. - Privacy Policy URL published at
/legal/privacy. - App Privacy questionnaire answered (we collect: contact info, identifiers, usage data; we do NOT collect: precise location, financial info, health data).
- Screenshots (6.7" iPhone 15 Pro Max, 5.5" iPhone 8 Plus, optionally 12.9" iPad Pro).
- Review notes with demo credentials.
- Submit via TestFlight → "Submit for Review".
Android
- Google Play Developer account active (USD 25 one-time).
- Play Console app created with
de.toolctrl.appapplication id. - Service account JSON for Play API uploads.
- Privacy Policy URL.
- Data safety form (same answers as iOS).
- Screenshots (phone 9:16 + tablet 16:10).
- 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.enabledfeature flag off (the dispatcher short-circuits without touching DeviceToken rows). - Roll back the Prisma migration
20260517200000_08_capacitor_device_tokenswithprisma migrate resolve --rolled-back+ a manualDROP TABLEif the schema needs to revert.