Files
HiveGrid-Wireframes/hivegrid-cloud/docs/operations/capacitor.md
T

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-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

# 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-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:

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

  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