250 lines
8.6 KiB
Markdown
250 lines
8.6 KiB
Markdown
# 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 <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
|
|
|
|
- [`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)
|