# Implementing the iOS app Implementation notes for shipping Compass on iOS. Nothing here is built yet — this is the plan, written against what the Android app ([`android/README.md`](../android/README.md), [`android/CLAUDE.md`](../android/CLAUDE.md)) already does, so the work is framed as "what carries over" vs. "what has no iOS equivalent yet". The strategy is the same as Android: **Capacitor wrapper around the existing Next.js static export**. No React Native, no second UI codebase. `web/` stays the single source of the product. --- ## 1. What already works, unchanged These are not iOS work items — they're already platform-agnostic and will light up as soon as an iOS target exists: | Piece | Where | | ------------------------------------------------ | --------------------------------------------------- | | The whole UI | `web/` — same static export as Android | | Static-export build (strips SSR/ISR/SSG) | `scripts/build_web_view.sh` | | Native-platform detection | `web/lib/util/webview.ts` (`isAndroidApp` — rename) | | Safe-area insets (notch / home indicator) | `web/styles/globals.css` `env(safe-area-inset-*)` | | Status-bar theming | `web/hooks/use-theme.ts` (`updateStatusBar`) | | Keyboard show/hide handling | `web/pages/_app.tsx` (`@capacitor/keyboard`) | | Native share sheet | `web/lib/util/share.ts` (`@capacitor/share`) | | Push registration + token save | `web/lib/service/android-push.ts` (rename) | | `save-subscription-mobile` endpoint + FCM tokens | `backend/api/src/save-subscription-mobile.ts` | The Capacitor plugins we already depend on (`@capacitor/app`, `keyboard`, `push-notifications`, `share`, `status-bar`, `@capgo/capacitor-social-login`) all support iOS. Nothing needs replacing. ### Naming cleanup to do first `isAndroidApp()`, `AndroidPush`, `android-push.ts` are all misnomers the moment iOS exists — they already mean "native app". Rename to `isNativeApp()` / `NativePush` / `native-push.ts` before adding the platform, and use `Capacitor.getPlatform()` (`'ios' | 'android' | 'web'`) wherever behaviour genuinely diverges. `isNativeMobile()` in `web/lib/util/webview.ts` is already the right name and can stay. --- ## 2. Prerequisites (hard blockers) - **A Mac.** Xcode is macOS-only, and there is no supported way to build or sign an iOS app without it. This includes CI: GitHub Actions needs a `macos-latest` runner (billed at 10× Linux minutes). - **Apple Developer Program membership** — $99/year. Not just for shipping: the Push Notifications and Sign in with Apple **entitlements are unavailable on a free account**, so the two features that make this more than a wrapped website can't even be built without it. - **A physical iPhone** — see below. The Simulator is not sufficient. - Xcode 16+, CocoaPods (`sudo gem install cocoapods`), Node 22+, Java is _not_ needed. ### Test device Buy one. The Simulator can inject a local `.apns` payload into a running app, which validates our tap handler, but it **cannot obtain a real APNs device token**, so it can't exercise the part we actually need to trust: `PushNotifications.register()` → token → `save-subscription-mobile` → `sendPushToToken` → delivery. Everything in §6 is untestable without hardware. Hardware specs are close to irrelevant here — the app is a WKWebView, and the push plugin needs no Face ID, no Dynamic Island, no particular chip. Two things about the device do matter: - **It must have a notch or Dynamic Island.** Content sitting under the status bar is the single most common layout bug in a webview app, and our layout leans hard on `env(safe-area-inset-*)` (`web/styles/globals.css`, `bottom-nav-bar.tsx`, `filters.tsx`, `search.tsx`, `media-modal.tsx`). A device without one gives a bottom inset of `0px` and never surfaces those bugs. - **It must run a current iOS**, so the permission dialogs and APNs behaviour match what users see. **Recommendation: a refurbished iPhone 12 or 13, ~$150–250.** Notch, current iOS, cheap. That's the whole requirement. **Avoid the iPhone SE (2nd/3rd gen)** even though it's often the cheapest option — Home-button body, no notch, smaller screen. It would miss exactly the class of bug the device is being bought to catch, to save $50–100. False economy. Dynamic Island phones (14 Pro and later) have slightly larger top insets than notched ones, but since everything is driven by `env()` rather than hardcoded values, a notched device is a fine proxy. Check the top of the profile page and the filter sheet on whatever you get. --- ## 3. Scaffolding the platform ```bash yarn --cwd=web add -D @capacitor/ios npx cap add ios # creates ios/App/… at the repo root, alongside android/ yarn build-web-view npx cap sync ios npx cap open ios # opens ios/App/App.xcworkspace in Xcode ``` `capacitor.config.ts` at the repo root is shared — `appId`, `appName`, `webDir: 'web/out'` and `includePlugins` all apply to both platforms as-is. Two things to add: ```ts ios: { contentInset: 'always', // avoids WKWebView double-insetting under the notch scheme: 'Compass', // app is served from capacitor://; see §6 on cookies/CORS }, ``` The dev-server override (`server: {url: 'http://10.0.2.2:3000', cleartext: true}`) is Android-specific: `10.0.2.2` is the Android emulator's alias for the host. The iOS Simulator shares the host's network, so it should use `localhost:3000`; a physical iPhone needs the LAN IP, same as `NEXT_PUBLIC_WEBVIEW_DEV_PHONE=1` already does. Branch on `process.env.CAP_PLATFORM` or just add an `ios.url` when we wire this up. Cleartext HTTP also needs an ATS exception in `Info.plist` (`NSAllowsLocalNetworking`) — **debug configuration only**, App Review rejects a blanket `NSAllowsArbitraryLoads`. Also register the repo-root `ios/` directory in `.gitignore` carefully: commit `ios/App/App.xcodeproj`, `Info.plist`, and the source, but ignore `ios/App/Pods/` and `ios/App/build/` (mirror what `android/.gitignore` does). --- ## 4. Native code with no iOS equivalent yet `android/app/src/main/java/com/compassconnections/app/MainActivity.java` has grown four hand-written native features. Each needs a decision on iOS: ### 4.1 Deep-link bridge (`handleAppLink`) Android stashes the launch `Intent` URL in `pendingDeepLink`, exposes it over a `@JavascriptInterface` (`window.AndroidBridge.getPendingDeepLink()`), and pushes later links in by calling `evaluateJavascript("handleAppLink(...)")` from `onNewIntent`. `web/pages/_app.tsx:198-209` consumes both paths. On iOS **don't reimplement the bridge** — `@capacitor/app` already gives you this cross-platform: ```ts App.addListener('appUrlOpen', ({url}) => handleAppLink({endpoint: new URL(url).pathname})) const launch = await App.getLaunchUrl() // replaces getPendingDeepLink() ``` Ideally migrate Android onto the same listener afterwards and delete the `AndroidBridge` deep-link half. Universal Links (the iOS equivalent of the `autoVerify` intent filter for `compassmeet.com`) need: - the **Associated Domains** capability with `applinks:compassmeet.com` and `applinks:www.compassmeet.com`, - an `apple-app-site-association` JSON file served from `https://compassmeet.com/.well-known/`, no redirect, `Content-Type: application/json`. Add it to `web/public/.well-known/` and confirm the Vercel config doesn't rewrite it. ### 4.2 `downloadFile` (data export) `web/components/settings/general-settings.tsx:353` calls `window.AndroidBridge.downloadFile(...)` because Android's WebView won't honour a blob download. WKWebView on iOS 14+ _does_ handle `` / blob URLs and hands off to the share sheet. Simplest path: keep the `AndroidBridge` branch for Android, and on iOS fall through to `@capacitor/share` or `@capacitor/filesystem` (`Directory.Documents` + `Share.share({url})`). Don't write a Swift `WKScriptMessageHandler` unless that fails in testing. ### 4.3 In-app update prompt `AppUpdateManagerFactory` / `AppUpdateType.IMMEDIATE` is Play-Store-only and **has no iOS counterpart** — Apple forbids apps from forcing their own updates. The iOS equivalent is either nothing (users update via the App Store) or a soft version check: query a `min-supported-version` value from the API on launch and render an in-app "please update" screen linking to the App Store. Ship without it first. ### 4.4 Google Sign-In `onActivityResult` plumbing The `GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_*` handling in `MainActivity` and the `ModifiedMainActivityForSocialLoginPlugin` interface are the Android-specific half of `@capgo/capacitor-social-login`. On iOS the plugin needs instead: - an **iOS OAuth client ID** in Google Cloud Console (we currently only have `WEB_GOOGLE_CLIENT_ID` in `common/src/constants.ts:48`; the commented-out `ANDROID_GOOGLE_CLIENT_ID` shows the shape), - the reversed client ID registered as a `CFBundleURLSchemes` entry in `Info.plist`, - `SocialLogin.initialize({google: {webClientId, iOSClientId}})` in `web/lib/firebase/users.ts:93`. The rest of `googleNativeLogin()` (exchange `idToken` → `signInWithCredential`) is unchanged. --- ## 5. Sign in with Apple (required, new work) App Store Review Guideline **4.8** requires Sign in with Apple in any app that offers third-party social login — which we do. This is not optional and is a common first-submission rejection. Work involved: 1. Enable the **Sign in with Apple** capability in Xcode and on the App ID. 2. Enable the Apple provider in Firebase Console → Authentication, register the Services ID and key. 3. Add an Apple button to the login UI, gated on `Capacitor.getPlatform() === 'ios'`. 4. `@capgo/capacitor-social-login` supports `provider: 'apple'` — reuse the `googleNativeLogin` shape and `signInWithCredential(auth, OAuthProvider('apple.com').credential({idToken, rawNonce}))`. 5. Apple's **private relay emails** (`…@privaterelay.appleid.com`) are real and deliverable but forwarded. Check that onboarding, `backend/email/` sends, and any email-uniqueness logic tolerate them, and that we handle the "name is only returned on the very first authorization" quirk — if we drop it, the user has no name and Apple will never send it again. --- ## 6. Push notifications (APNs) `@capacitor/push-notifications` is already wired in `web/lib/service/android-push.ts` and works on iOS, but the transport underneath is different and the **backend payload is currently Android-only**. Setup: 1. Push Notifications capability + `aps-environment` entitlement in Xcode. 2. Create an APNs **auth key** (`.p8`, preferred over certs — doesn't expire) in the Apple Developer portal, upload it to Firebase Console → Project Settings → Cloud Messaging, with Team ID and Key ID. Then FCM tokens keep working and no backend token-storage change is needed (`push_subscriptions_mobile` stays as-is). 3. Add the iOS app (bundle ID `com.compassconnections.app`) to the Firebase project and drop `GoogleService-Info.plist` into `ios/App/App/`. **Backend change required.** `sendPushToToken` in `backend/shared/src/mobile.ts:96` builds a `TokenMessage` with an `android.notification` block and a bare `data: {endpoint}`. Sent to an iOS token as-is, that is a _data-only_ push: it will not display anything and is delivered at low priority or not at all. Add: ```ts apns: { payload: {aps: {alert: {title: payload.title, body: payload.body}, sound: 'default', badge: …}}, fcmOptions: payload.imageUrl ? {imageUrl: payload.imageUrl} : undefined, }, ``` Notes: - Rich images on iOS additionally require a **Notification Service Extension** target; skip it until images matter, and the plain alert still shows. - Notification taps: Android reads an `endpoint` intent extra in `onNewIntent`. On iOS use the cross-platform `PushNotifications.addListener('pushNotificationActionPerformed', …)` and read `notification.data.endpoint` — the `data` field above already carries it. Worth switching Android to the same listener while we're here. - `pushNotificationReceived` only fires in the foreground on iOS, and iOS suppresses the banner in foreground by default — the existing `toast.success` fallback in `android-push.ts` covers that. - Permission timing: `PushNotifications.requestPermissions()` triggers the one-shot iOS system prompt. The current code fires it right after login. Consider asking in context instead — a denied iOS prompt can only be reversed in Settings. --- ## 7. Build, sign, ship Local: ```bash yarn build-web-view npx cap sync ios npx cap open ios # then Product → Archive ``` Add `yarn build-sync-ios` mirroring `scripts/build_sync_android.sh`. Versioning: `CFBundleShortVersionString` (user-visible, ≈ `versionName`) and `CFBundleVersion` (build number, ≈ `versionCode`, must strictly increase per upload). Keep them in step with `android/app/build.gradle` so a release is one version across both stores. Signing and CI: the Android release path is [`.github/workflows/cd-android.yml`](../.github/workflows/cd-android.yml) — bump `versionCode` on `main`, Action builds a signed AAB and uploads to Play. The iOS analogue is a `macos-latest` job using **fastlane** (`match` for certificate/profile management, `pilot` for TestFlight upload) with an **App Store Connect API key** in GitHub Secrets. New secrets needed, alongside the existing `ANDROID_*` / `PLAY_SERVICE_ACCOUNT_JSON`: ``` APP_STORE_CONNECT_KEY_ID APP_STORE_CONNECT_ISSUER_ID APP_STORE_CONNECT_KEY_P8 MATCH_PASSWORD / MATCH_GIT_URL (or a manually managed .p12 + provisioning profile) ``` Do the first submission by hand from Xcode to shake out the metadata, then automate. --- ## 8. App Review risks specific to us Ordered by how likely they are to cost us a rejection round: 1. **Guideline 4.2 — "minimum functionality" / repackaged website.** A pure WebView wrapper gets rejected. Our defence is the same as on Play: local assets rather than a remote URL, plus genuine native integration (push, native share, native Google/Apple sign-in, deep links). Do **not** ship the remote-URL mode. 2. **Guideline 4.8 — Sign in with Apple.** See §5. Blocking. 3. **Guideline 5.1.1(v) — account deletion.** Apple requires in-app account deletion for any app with account creation, reachable without contacting support. Verify the settings flow does this on-device. 4. **Guideline 1.2 / 1.1.6 — UGC on a dating-adjacent app.** Expect scrutiny: they will want a report mechanism, a block mechanism, a published moderation policy, and a terms-of-service acceptance at signup. Have the moderation story documented before submitting. 5. **Age rating.** A connections app rates 17+/18+; set it honestly or risk removal. 6. **Guideline 3.1.1 — in-app purchase.** If anything paid is ever added, iOS must route it through IAP (30%/15%). Not an issue today; a reason not to add web-only checkout links to the iOS build later. 7. **Demo account.** Review needs working credentials in App Review notes, since the app is gated behind login. Prepare a seeded account with a populated profile. --- ## 9. Suggested order of work 1. Rename `isAndroidApp` → `isNativeApp`, `android-push.ts` → `native-push.ts`; branch on `Capacitor.getPlatform()`. 2. Move deep-link handling and notification-tap handling off `AndroidBridge`/intent extras onto `@capacitor/app` + `pushNotificationActionPerformed` (works on both platforms). 3. Add the `apns` block to `sendPushToToken` — harmless on Android, prerequisite for iOS. 4. `npx cap add ios`, get it running in the Simulator against the static export. 5. Firebase iOS app + APNs key; verify push end-to-end on the **physical device** from §2 — the Simulator has no APNs token, so this step cannot be faked. 6. Google Sign-In (iOS client ID) then Sign in with Apple. 7. Universal Links + `apple-app-site-association`. 8. Manual TestFlight build; internal testing. 9. fastlane + GitHub Action; first App Store submission. --- ## 10. Resources - [Capacitor iOS docs](https://capacitorjs.com/docs/ios) - [Firebase iOS setup](https://firebase.google.com/docs/ios/setup) · [APNs + FCM](https://firebase.google.com/docs/cloud-messaging/ios/certs) - [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) - [Supporting Universal Links](https://developer.apple.com/documentation/xcode/supporting-associated-domains) - [fastlane for iOS](https://docs.fastlane.tools/getting-started/ios/setup/)