From 46bf0e375ab74239f1f1cb2d3f0cd0f3dbb8dc47 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 20 Oct 2026 15:00:18 +0100 Subject: [PATCH 01/17] Fix Android deep-link guidance found by the SDK audit A urlHandler that returns false re-opens the URL: Android fires ACTION_VIEW pinned to the app itself, so a link on the app's own scheme or tracking domain loops, offline too, because a failed redirect passes the tracking URL on. The allowed-protocols pitfall claimed http was implicit; only https is, and the match is case-sensitive on the text before "://". The routing rows sent allowed protocols to the UUA article and deep links to the Branch/AppsFlyer stub. The skill now has a compiled App Links snippet with the 3.7.0 names, autoVerify, and the Iterable prerequisites, and new pitfalls 24-25. Co-Authored-By: Claude Opus 5.5 --- iterable-android/PITFALLS.md | 84 +++++++++++++------- iterable-android/SKILL.md | 110 ++++++++++++++++++++++++--- pipeline/config/pitfalls/android.yml | 21 ++++- 3 files changed, 178 insertions(+), 37 deletions(-) diff --git a/iterable-android/PITFALLS.md b/iterable-android/PITFALLS.md index 5b6165b..a01454c 100644 --- a/iterable-android/PITFALLS.md +++ b/iterable-android/PITFALLS.md @@ -67,16 +67,20 @@ hot-path subset; the full list lives here and is loaded on demand. `setEmail(email, onSuccess, onFailure)`. Chain dependent calls inside `onSuccess`. The SDK always provides a proper callback for ordering. -## 5. Custom deep-link scheme dropped silently - -- **Symptom:** Iterable push opens the app, but the URL never reaches the - app's `UrlHandler`. -- **Cause:** The SDK's default allowed-protocols list is `https` and - `http`. A custom scheme like `myapp://` is rejected before the handler - fires. +## 5. A URL scheme is missing from `setAllowedProtocols` + +- **Symptom:** A push, in-app, or embedded link opens the app (or nothing), and + the URL never reaches the app's `urlHandler`. +- **Cause:** Before calling any handler the SDK checks the scheme, and only + `https` passes without being listed — the default `allowedProtocols` is an + empty array, so `http` is **not** implicit. The check compares the text + before `://` exactly (`url.split("://")[0].equals(...)`): it is + case-sensitive, and a URL with no `//` — `tel:`, `mailto:`, `sms:` — never + matches anything, listed or not. - **Fix:** - `IterableConfig.Builder().setAllowedProtocols(arrayOf("myapp"))`. The list - is additive — `http`/`https` stay allowed automatically. + `IterableConfig.Builder().setAllowedProtocols(arrayOf("http", "myapp"))`, + in the exact case your links use. Don't send `tel:` / `mailto:` links + through Iterable's URL path; use a custom action for them. ## 6. `registerForPush()` called explicitly with auto-registration on @@ -352,27 +356,25 @@ hot-path subset; the full list lives here and is loaded on demand. that silently does nothing. Never commit a placeholder; never disable a required plugin to compile. -## 20. Link tap routed to a handler that isn't set (silently dropped) +## 20. Link tap routed to a handler that isn't set - **Symptom:** A push notification (or in-app / embedded message) opens the - app but never navigates — the deep link silently does nothing. A registered - `urlHandler` is never called for these taps. -- **Cause:** Iterable routes an action by its **type / URL scheme**, not - through one catch-all handler. Plain `http`/`https` links (and push actions - of type `openUrl`) go to `IterableConfig.urlHandler` → `handleIterableURL()`. - Links whose scheme is `action://` or `itbl://`, **and push actions whose - type is a custom action**, go to `IterableConfig.customActionHandler` → - `handleIterableCustomAction()`. (`iterable://` URLs are SDK-reserved and - handled internally.) If the handler an action routes to has **not** been set, - the action is silently dropped — no crash, no log. So an integration that - registers only `urlHandler` looks complete but loses every custom-action - link; "opens the app but doesn't navigate" is the only symptom. + app but never navigates, or opens something other than the app's handler. +- **Cause:** Iterable routes an action by its **type / URL scheme**. URLs + (push actions of type `openUrl`, `https` links, and listed schemes) go to + `IterableConfig.urlHandler` → `handleIterableURL()`. `action://` and `itbl://` + links, **and push actions whose type is a custom action**, go to + `IterableConfig.customActionHandler` → `handleIterableCustomAction()` + (`iterable://` URLs are SDK-reserved). The two fail differently: + - **No `customActionHandler`:** the action is dropped — no crash, no log. + - **No `urlHandler`, or one that returns `false`:** the URL is not dropped. + The SDK fires an `ACTION_VIEW` intent for it, which can come straight back + into the app (pitfall #24). - **Fix:** Register **both** `urlHandler` and `customActionHandler` on `IterableConfig`, unless you've confirmed the dashboard only ever sends - `openUrl` / plain-URL actions. Don't assume one handler catches everything. - When a link opens the app but doesn't navigate, suspect a missing - `customActionHandler` first — the dashboard template likely carries the link - in the action *type* rather than as an `openUrl`. + `openUrl` / plain-URL actions. When a link opens the app but doesn't + navigate, suspect a missing `customActionHandler` first — the template likely + carries the link in the action *type* rather than as an `openUrl`. ## 21. Setting `CommerceItem.categories` after construction (compiles, crashes at runtime) @@ -500,3 +502,33 @@ hot-path subset; the full list lives here and is loaded on demand. *their* pushes as well as Iterable's on Android 13+, and looks exactly like "the Iterable change broke our push." Check the grant before accepting that diagnosis, and don't accept a culprit inside your own diff without evidence. + +## 24. A link bounces between the SDK and the app + +- **Symptom:** Tapping a link reopens the app over and over, or the screen + flickers between the same activity — sometimes only offline. +- **Cause:** When `urlHandler` is unset or returns `false`, the SDK fires an + `ACTION_VIEW` intent for the URL and, when several apps match, pins it to the + app itself. If the app's own intent filter claims that URL — its scheme, or + the tracking domain — the activity receives it again, hands it to + `handleAppLink`, and the cycle repeats. A tracking-link redirect that fails + passes the original `/a/` URL to the handler, so the loop also runs offline. +- **Fix:** Make `urlHandler` return `true` for every scheme and host the app + claims, the tracking domain included, and send unknown paths to a fallback + screen; return `false` only for other apps' URLs. Pass a URL to + `handleAppLink` only when it is `https`, on your tracking domain, and + `IterableApi.isIterableDeeplink` agrees (the check in `SKILL.md` → Deep links + and App Links). + +## 25. The App Links reference uses names that don't compile + +- **Symptom:** Code copied from `reference/android-app-links.md` fails to build: + unresolved `getAndTrackDeeplink` or `isIterableDeepLink`, or "overrides + nothing" on `onCreate()`. +- **Cause:** At 3.7.0 the SDK exports the instance method + `IterableApi.getInstance().getAndTrackDeepLink(url, handler)` (capital `L`) + and the static `IterableApi.isIterableDeeplink(url)` (lower-case `l`). The + article calls them the other way round, and declares `onCreate()` without the + `Bundle` parameter. +- **Fix:** Use the names in `SKILL.md` → Deep links and App Links, and + `onCreate(savedInstanceState: Bundle?)`. diff --git a/iterable-android/SKILL.md b/iterable-android/SKILL.md index 404b61b..c81fd75 100644 --- a/iterable-android/SKILL.md +++ b/iterable-android/SKILL.md @@ -361,12 +361,21 @@ app already had. Full explanations and the remaining ~15 traps are in `updateUser`, `track`, etc. inside `onSuccess`. Delays are fragile under real network conditions and will break in production. -5. **Custom deep-link schemes need `setAllowedProtocols(arrayOf("yourscheme"))`.** - Without this the SDK refuses to dispatch the URL to your `UrlHandler` and - silently drops the link. Also register **both** `urlHandler` and - `customActionHandler` — `action://`/`itbl://` links and custom-action push - types route to the latter, and a tap to an unset handler is silently dropped - (PITFALLS #20). +5. **Deep links: list every non-`https` scheme, answer `true` for your own + links, and send only tracking links to `handleAppLink`.** + - Only `https` passes the SDK's URL gate without being listed. `http` and + your own scheme need `setAllowedProtocols(arrayOf("http", "myapp"))`, + spelled exactly — the match is case-sensitive on the text before `://`, + so `tel:` and `mailto:` links never pass at all (pitfall #5). + - When `urlHandler` is unset or returns `false`, the SDK fires an + `ACTION_VIEW` intent — pinned to your own app when it also matches — which + lands back in your own intent filter and loops. Return `true` for every + scheme and host the app claims, the tracking domain included (pitfall #24). + - Register `customActionHandler` too: `action://` / `itbl://` links and + custom-action push types go there, and an unset one drops them + (pitfall #20). + - Pass only `https` links on your tracking domain with an `/a/` path to + `handleAppLink` — see [Deep links and App Links](#deep-links-and-app-links). 6. **Never hardcode the API key into a tracked file.** A *mobile* API key is safe to embed in the compiled app — that's its intended use — but it must @@ -528,6 +537,86 @@ handles one that arrives *after*. Two ways to get this wrong: captures one value, so the account switch never reaches Iterable and events land on the previous user — pitfall #3, which is about exactly this. +### Deep links and App Links + +Only when deep links are in scope. The manifest filter claims the tracking +domain's `/a/` links; `android:autoVerify="true"` asks Android to verify them, +and on Android 12+ an unverified link opens in the browser instead of the app. + +```xml + + + + + + + +``` + +```kotlin +// The Iterable link-tracking domain: Settings > Domains, and the host in the intent filter. +const val LINK_TRACKING_DOMAIN = "links.example.com" + +fun isIterableTrackingLink(url: String): Boolean { + val uri = Uri.parse(url) + return uri.scheme == "https" && uri.host == LINK_TRACKING_DOMAIN && + IterableApi.isIterableDeeplink(url) +} + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // ...setContent { } / setContentView(...)... + handleLink(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleLink(intent) + } + + private fun handleLink(intent: Intent?) { + val url = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.dataString ?: return + setIntent(Intent(Intent.ACTION_MAIN)) // don't replay the link from Recents + if (isIterableTrackingLink(url)) { + // handleAppLink returns false, dropping the link, until init has finished. + IterableApi.onSDKInitialized { IterableApi.getInstance().handleAppLink(url) } + } else { + AppRouter.open(this, url) + } + } +} +``` + +`handleAppLink` tracks the click, follows the redirect, and hands the +destination to your `urlHandler` (`IterableActionSource.APP_LINK`). If the +request fails — offline, or an error response — the handler gets the original +tracking URL, so it must answer `true` for that too. `handleAppLink` called +before initialization returns `false` and the link is lost; Android doesn't hold +it for later, which is why the snippet waits in `onSDKInitialized` (callbacks +run on the main thread). If the app skips initialization — no API key — that +callback never runs, so route the link yourself in that build. + +Names the SDK actually exports at 3.7.0: the static +`IterableApi.isIterableDeeplink(url)` and the instance +`IterableApi.getInstance().getAndTrackDeepLink(url, handler)`. The bundled +`reference/android-app-links.md` spells them `IterableApi.getAndTrackDeeplink` +(static) and `getInstance().isIterableDeepLink`, and its `onCreate()` takes no +`Bundle` — none of those compile (pitfall #25). + +**Iterable prerequisites** (`reference/android-app-links.md`, and Iterable's +"Configuring Deep Links for Email or SMS"): + +1. A custom link-tracking domain with HTTPS — not `links.iterable.com` or + `links.eu.iterable.com`. +2. In Iterable, **Settings > Domains** → edit the tracking domain → **New + Destination Path** → **Edit Configuration** → upload `assetlinks.json` + (one file per tracking domain) — and an `apple-app-site-association` file, + which Iterable uses to decide which links to rewrite as `/a/` deep links even + when there is no iOS app. +3. Check `https:///.well-known/assetlinks.json` lists your + package and signing-certificate fingerprint. + The full per-feature docs (push, in-app, inbox, embedded, deep links, events, profiles, UUA) are in `reference/` — see the routing table below. @@ -581,7 +670,9 @@ the version they're on. | ---------------------------- | ---- | | First-time setup, dependencies, gradle setup, `IterableApi.initializeInBackground`, `IterableConfig` builder | `android-sdk` (read `## Installing the SDK`; skip `## Upgrading the SDK`) | | Upgrading an existing integration from an older SDK version | `android-sdk` → `## Upgrading the SDK`. Entries run newest-first (`### Upgrading to 3.10.0` down to `3.2.0`); read every entry **newer than** the version they're on and stop there. Ask their current version first — don't guess it. | -| Configuration deep-dive (every `IterableConfig` option, `setDataRegion`, allowed protocols, log level) | `configure-the-android-sdk` | +| `IterableConfig` options: `setDataRegion`, log level, handlers | `android-sdk` → `### Step 5: Set SDK configuration options` | +| Allowed URL protocols (`setAllowedProtocols`) | `android-sdk` → `#### Step 5.3: Set allowed URL protocols` — then rule 5: only `https` is implicit | +| Unknown User Activation configuration and consent | `configure-the-android-sdk` | | FCM push, notification channels, `POST_NOTIFICATIONS`, device registration | `setting-up-android-push-notifications` | | The app **already has** its own `FirebaseMessagingService`, or a second push provider (OneSignal, Braze, a home-grown one) | `setting-up-android-push-notifications` → `#### Handling Firebase push messages and tokens`. Forward, don't replace — rule 8 and pitfall #23. | | Push behavior overview (silent push, foreground vs background, deep link from notification) | `push-notification-overview` | @@ -589,8 +680,9 @@ the version they're on. | Mobile inbox UI, `IterableInboxFragment`, default rendering | `setting-up-mobile-inbox-on-android` | | Mobile inbox theming, custom cells, swipe actions | `customizing-mobile-inbox-on-android` | | Embedded messaging, placements, `IterableEmbeddedView` | `embedded-messages-with-iterables-android-sdk` | -| Deep linking, App Links, `UrlHandler`, `setAllowedProtocols` | `deep-linking-with-partners` | -| Android App Links (verified domains, `assetlinks.json`) | `android-app-links` | +| Deep links: `urlHandler` and `customActionHandler` routing | `embedded-messages-with-iterables-android-sdk` → `### Step 4.1: Define a URL handler`, `in-app-messages-on-android`, and rule 5 | +| Android App Links (verified domains, `assetlinks.json`, `handleAppLink`) | `android-app-links`, with the corrected names in [Deep links and App Links](#deep-links-and-app-links) | +| Branch or AppsFlyer deep links | `deep-linking-with-partners` (links out to those integrations) | | `track`, `trackPurchase`, `updateCart`, custom event payloads | `tracking-events-with-iterables-mobile-sdks` | | `setEmail`, `setUserId`, login / logout flow, user identity | `identifying-the-user` | | `updateUser`, profile data fields, JSON merging | `updating-user-profiles` | diff --git a/pipeline/config/pitfalls/android.yml b/pipeline/config/pitfalls/android.yml index 7554550..c10ada6 100644 --- a/pipeline/config/pitfalls/android.yml +++ b/pipeline/config/pitfalls/android.yml @@ -42,7 +42,7 @@ pitfalls: features: [integration, user-profiles] - id: 5 - title: Custom deep-link scheme dropped silently + title: URL scheme missing from setAllowedProtocols triggers: - "setAllowedProtocols" - "custom URL scheme" @@ -163,7 +163,7 @@ pitfalls: features: [integration, push-notifications] - id: 20 - title: Link tap routed to a handler that isn't set (silently dropped) + title: Link tap routed to a handler that isn't set triggers: - "setUrlHandler" - "setCustomActionHandler" @@ -204,3 +204,20 @@ pitfalls: - "OneSignal" - "Braze" features: [push-notifications, integration] + + - id: 24 + title: Link bounces between the SDK and the app + triggers: + - "handleAppLink" + - "ACTION_VIEW" + - "isIterableDeeplink" + - "loop" + features: [deep-links, integration] + + - id: 25 + title: App Links reference uses names that don't compile + triggers: + - "getAndTrackDeeplink" + - "isIterableDeepLink" + - "android-app-links" + features: [deep-links] From 374ee38e21b33f95401d04bb5b67fa1595e353dc Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 20 Oct 2026 15:00:18 +0100 Subject: [PATCH 02/17] Route React Native through Android first, then iOS A React Native app now finishes Android (provisioned, integrated, and proved by iterable-android-verify) before iOS starts. The iOS phase reuses the Android setup and the scope confirmed in Step 0, which the skill passes as --features. The app is integrated when every platform in scope has passed its verifier. For bare iOS the skill owns the native push code: the AppDelegate token and notification callbacks, a native notification-permission request at launch, and universal links in the 0.88 template's SceneDelegate. It gives the exact Release Simulator build and install commands, with signing on, because a live trial installed a stale build found with find and built with code signing off, which strips aps-environment. It loads the Mobile key the way provisioning delivers it: an optional require on bare React Native and EXPO_PUBLIC_ITERABLE_API_KEY on Expo, where push sets requestPermissionsForPushNotifications. Install resolves the latest version with one npm view that survives a scoped private registry, and never prints an npmrc. The New Architecture is always on from 0.82, so the installing article's opt-out is skipped. Deep links no longer loop through Linking.openURL. The Xcode 27 notes live in pitfall 12, and the skill and its regenerated corpus are pinned to SDK 3.2.1. New pitfalls 16-22. Co-Authored-By: Claude Opus 5.5 --- INTENDED-FLOW.md | 24 ++ iterable-react-native/PITFALLS.md | 244 +++++++++--- iterable-react-native/SKILL.md | 375 ++++++++++++++++-- iterable-react-native/reference/api-keys.md | 4 +- .../reference/authentication.md | 6 +- .../deep-links-and-custom-actions.md | 6 +- .../reference/embedded-message-placements.md | 4 +- .../embedded-message-subscription-channels.md | 4 +- ...essages-with-iterables-react-native-sdk.md | 6 +- iterable-react-native/reference/expo.md | 31 +- .../reference/fcm-http-v1-migration.md | 4 +- .../reference/in-app-messages.md | 6 +- iterable-react-native/reference/installing.md | 40 +- .../reference/jwt-enabled-api-keys.md | 4 +- .../reference/managing-user-identity.md | 6 +- iterable-react-native/reference/migrating.md | 6 +- .../reference/mobile-inbox.md | 6 +- iterable-react-native/reference/overview.md | 6 +- .../reference/push-notifications.md | 6 +- .../reference/tracking-events.md | 6 +- ...ofile-data-and-subscription-preferences.md | 6 +- pipeline/config/pitfalls/react-native.yml | 65 ++- pipeline/config/react-native.yml | 9 +- 23 files changed, 680 insertions(+), 194 deletions(-) diff --git a/INTENDED-FLOW.md b/INTENDED-FLOW.md index f662a7d..afb6138 100644 --- a/INTENDED-FLOW.md +++ b/INTENDED-FLOW.md @@ -151,6 +151,30 @@ Test Push remains optional troubleshooting and never blocks completion. Only missing verification credentials return to their narrow capture command. Missing proof metadata is not evidence that the SDK integration is missing. +## React Native path + +A React Native app runs the Android path and then the iOS path, one after the +other, under the `iterable-react-native` skill: + +1. Record the scope once — features, identity mode, region, JWT policy. Both + platforms use that one record. +2. **Android first.** The Android ladder provisions Firebase and Iterable, the + skill writes the integration, and `iterable-android-verify` proves a push. + No iOS step starts before that verdict is green. +3. **iOS second.** The iOS provider path offers to reuse both Iterable keys, + the region, No JWT, and the test email from the Android setup, carries over + the feature scope from step 1 (passed with `--features`, shown again for + confirmation), and adds the Apple/APNs prerequisites. The skill writes the + native push code (bare) or relies on the Expo plugin, and + `iterable-ios-verify` proves a push on a Release Simulator build. +4. The app is integrated when the verifier has passed for every platform in + scope. One green platform is not done. + +The Mobile key reaches the app as a gitignored file the tool writes — bare +React Native `.iterable/rn/iterable.local.ts`, Expo `EXPO_PUBLIC_ITERABLE_API_KEY` +in `.env.local` — and the app loads it so that a checkout without the file +still builds. + ## What divergence looks like The three classes, because they need different fixes and get confused constantly: diff --git a/iterable-react-native/PITFALLS.md b/iterable-react-native/PITFALLS.md index 70eedb4..c66d38b 100644 --- a/iterable-react-native/PITFALLS.md +++ b/iterable-react-native/PITFALLS.md @@ -9,9 +9,10 @@ lives here and is loaded on demand. > (what's actually happening), **Fix** (what to do instead). Skim them when > the user reports any unexplained failure. -Sourced from `@iterable/react-native-sdk` **3.1.0** master: public exports in +Sourced from `@iterable/react-native-sdk` **3.2.1** (first written against +3.1.0): public exports in `src/index.tsx`, the TurboModule spec in `src/api/NativeRNIterableAPI.ts`, -CHANGELOG 3.1.0, the README native-version table, and the working sample in +CHANGELOG 3.1.0–3.2.1, the README native-version table, and the working sample in `example/` (`example/src/hooks/useIterableApp.tsx`, `example/src/components/App/App.tsx`). Expo items also come from `@iterable/expo-plugin` 1.1.0 (`plugin/src/withIterable.ts`) and @@ -25,7 +26,11 @@ iOS (bare **and** Expo): Device Hub launch and resource-bundle compile floors — not an Iterable API change. **#15** is native Android and applies to **both** workflows, but only when the app already sends push. #3's *symptom* is both workflows; its **bare** native fix (manifest + -`PermissionsAndroid`) is wrong on Expo — branch first. +`PermissionsAndroid`) is wrong on Expo — branch first. **#16** applies to every +app with deep links; **#17** is Expo only. **#18** is the install, both +workflows. **#19–#20** are the iOS Release Simulator build (bare, and Expo when +built with `xcodebuild`); **#21** is JavaScript, both workflows; **#22** is bare +iOS (on Expo, #14). --- @@ -85,7 +90,10 @@ to **both** workflows, but only when the app already sends push. #3's push through `@iterable/expo-plugin` and `reference/expo.md` (pitfalls #7–#11, #13–#14). Point `expo.android.googleServicesFile` at the developer-supplied - `google-services.json`. + `google-services.json`. The plugin declares `POST_NOTIFICATIONS` in the + manifest but never requests it, so the app still asks at runtime — + `PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS)` + from a user-facing screen, as in the bare step 2 below. - **Bare:** Copy the sample's two-part pattern, not Kotlin `ActivityResultContracts`: 1. Declare `` @@ -113,7 +121,7 @@ to **both** workflows, but only when the app already sends push. #3's | TurboModule spec (`NativeRNIterableAPI`) | `RNIterableAPI.getUnreadInboxMessagesCount`, `passAlongAuthToken` | No | | Native Android / iOS SDK | Kotlin `IterableApi.getInstance()`, Swift `IterableAPI`, `setDeviceAttribute` | Native *configuration* only (FCM, APNs, entitlements). Not JS. | - Concrete gaps still open on 3.1.0: + Concrete gaps still open on 3.2.1: - **SDK-594:** `getUnreadInboxMessagesCount` is on the TurboModule spec and implemented natively. `IterableInAppManager` exposes `getMessages` / @@ -139,10 +147,11 @@ to **both** workflows, but only when the app already sends push. #3's "latest Android SDK") into a React Native project, or tells the developer to bump `com.iterable:iterableapi` independently of `@iterable/react-native-sdk`. - **Cause:** Each RN release **pins** the native SDKs. They are not "whatever - the Android skill currently documents." On master, `android/build.gradle` - is `com.iterable:iterableapi:3.6.2`. The Android skill's pin may already - be **3.7.0**. APIs that exist only on a newer native SDK are not reachable - through this RN package. + the native skills currently document." At 3.2.1, `android/build.gradle` is + `com.iterable:iterableapi:3.10.1` and the podspec depends on + `Iterable-iOS-SDK` **6.7.5**. APIs that exist only on a newer native SDK are + not reachable through this RN package, and the native skills' pins can be + older or newer than the package's. - **Fix:** Use the RN package's version table (README "Version mapping"), not the Android skill. Confirm the installed pin in `node_modules/@iterable/react-native-sdk` (`android/build.gradle`, @@ -150,6 +159,7 @@ to **both** workflows, but only when the app already sends push. #3's | RN SDK | Android SDK | iOS SDK | |---|---|---| + | 3.2.1 / 3.2.0 | 3.10.1 | 6.7.5 | | 3.1.0 | 3.6.2 | 6.6.7 | | 3.0.x / 2.2.x | 3.6.2 | 6.6.3 | | 2.1.0 – 2.0.0 | 3.5.2 | 6.5.4 | @@ -246,7 +256,8 @@ React Native project. Do not introduce `@iterable/expo-plugin` there. `reference/expo.md` → `## Requirements` is Expo SDK **55+**, React Native **0.83.2+**, React **19.2+**, Node **20.19.4+**, `@iterable/react-native-sdk` **3.0.0+** (this skill is validated against - **3.1.0**), `@iterable/expo-plugin` **1.1.0+**. Install with + **3.2.1**), `@iterable/expo-plugin` **1.1.0** (npm's 1.2.0 is deprecated as + published by accident). Install with `npx expo install @iterable/expo-plugin @iterable/react-native-sdk`. ## 11. Expo: fighting the plugin (New Architecture / `{fmt}`) @@ -265,12 +276,16 @@ React Native project. Do not introduce `@iterable/expo-plugin` there. ## 12. Xcode 27: Device Hub launch and resource-bundle deployment targets +iOS only (bare **and** Expo); Android is unaffected. This is the single home of +the Xcode 27 guidance — the generated `reference/` corpus does not carry it. + - **Symptom (launch):** On Xcode 27, `npx react-native run-ios` or Expo - `run:ios` fails before or during simulator launch. `open -a Simulator`, - AppleScript `id of app "Simulator"`, or paths under + `run:ios` fails before or during simulator launch, often with a missing + `Simulator.app` / simulator launch error. `open -a Simulator`, AppleScript + `id of app "Simulator"`, or paths under `…/Xcode.app/Contents/Developer/Applications/Simulator.app` do not work. - Only `xcode-select` to the active Xcode is not enough when both Xcode 16 - and 27 are installed but the CLI still targets the old Simulator app. + Pointing `xcode-select` at Xcode 27 is not enough when the CLI still targets + the old Simulator app. - **Cause (launch):** Xcode 27 ships the simulator UI as **Device Hub** (`DeviceHub.app`, bundle id `com.apple.dt.Devices`), not `Developer/Applications/Simulator.app`. Older `@react-native-community/cli` @@ -280,42 +295,61 @@ React Native project. Do not introduce `@iterable/expo-plugin` there. `@react-native-community/cli-platform-ios`). Iterable's RN SDK **example** app may still pin **20.1.0** until its repo updates — consumer apps need **20.2.0+** for Xcode 27. See [cli#2806](https://github.com/react-native-community/cli/pull/2806). - - **Expo:** Prefer **Expo SDK 58** (CLI that knows Device Hub). Apps still - on SDK **55** may fail at launch on Xcode 27; short-term: - `npx expo@latest run:ios` or upgrade the Expo SDK — do not hand-edit + - **Expo:** Use **Expo SDK 58**, whose CLI knows Device Hub. Apps still on + SDK **55** may fail at launch on Xcode 27; short-term, + `npx expo@latest run:ios`, or upgrade the Expo SDK — do not hand-edit `ios/` for this (pitfall #8). - - Run `xcode-select -p` and confirm it points at the Xcode you build with. + - Run `xcode-select -p` and confirm it points at the Xcode you build with + when several are installed. - **Symptom (build):** `xcodebuild` fails on Xcode 27 with deployment-target - errors on CocoaPods **resource bundle** targets (for example - `Iterable-iOS-SDK-IterableSDKResources` at **12.0** from - `Iterable-iOS-SDK`, or `*_resources` pods such as async-storage at - **13.4**). Message indicates `IPHONEOS_DEPLOYMENT_TARGET` is outside the - range the iOS 27 SDK allows (**15.0** through the current SDK version). -- **Cause (build):** The Xcode 27 iOS SDK rejects compile settings below - **15.0** on **every** target, including generated Pods resource bundles. - Podspecs can still declare older mins; `pod install` regenerates those - values. This is a **host compile-time** workaround — not a requirement to - bump Iterable's **published** iOS SDK minimum or async-storage's declared - platform in this skill ticket. -- **Fix (build):** Do **not** hand-edit files under `Pods/`. Do **not** treat - raising `Iterable-iOS-SDK` in the Podfile as the primary Expo fix. - - **Bare:** In the host `Podfile` `post_install`, walk - `installer.pods_project.targets` and, for each configuration, if - `IPHONEOS_DEPLOYMENT_TARGET` is unset or below **15.1**, set it to at - least **15.1** (and not below the app target's chosen deployment - target). **15.0** is the minimum the Xcode 27 iOS SDK accepts; **15.1** - is what Iterable's bare RN demo ships so a target at exactly **15.0** - does not slip through. Keep any existing `{fmt}` C++17 workaround - (pitfall #11) — this lift is **separate**. Re-run `pod install`. - - **Expo (CNG):** Do not commit or edit `ios/Podfile` for this. Apply the - same Pods-target lift to at least **15.1** when unset or below **15.1** - via an **Expo config plugin** at `prebuild` (or another supported mod), - not by patching `Pods/` after the fact. - `{fmt}` remains `@iterable/expo-plugin` **1.1.0+** `withIosFmtWorkaround` - (pitfall #11). - - See `reference/installing.md` → `### Xcode 27` and `reference/expo.md` - → `### Xcode 27`. + errors on CocoaPods **resource bundle** targets — for example + `Iterable-iOS-SDK-IterableSDKResources` at **12.0**, or `*_resources` pods + such as async-storage at **13.4**. The message says + `IPHONEOS_DEPLOYMENT_TARGET` is outside the range the iOS 27 SDK allows + (**15.0** through the current SDK version). +- **Cause (build):** The Xcode 27 iOS SDK rejects a deployment target below + **15.0** on **every** target, including generated Pods resource bundles, and + `pod install` regenerates those values from each podspec. `Iterable-iOS-SDK` + **6.7.5** — what `@iterable/react-native-sdk` **3.2.1** pins — still declares + iOS **12.0** in its podspec, and its resource bundle inherits that. + React Native **0.88**'s `react_native_post_install` lifts every pod's own + target to at least **15.1** (`updateOSDeploymentTarget`), but not the pods' + resource-bundle targets, so the bundle stays at 12.0 — the RN SDK 3.2.1 + README says the same. This is a host + compile-time workaround, not a reason to change Iterable's published iOS + minimum. +- **Fix (build):** Do **not** hand-edit files under `Pods/`, and don't raise + `Iterable-iOS-SDK` in the Podfile as the fix. + - **Bare:** in the host `Podfile`, after `react_native_post_install`, raise + every Pods target — resource bundles included — that is unset or below + **15.1**, then re-run `pod install`. **15.0** is the Xcode 27 floor; + **15.1** matches React Native's own minimum and Iterable's bare RN demo, so + nothing is left at exactly 15.0: + + ```ruby + post_install do |installer| + react_native_post_install(installer, config[:reactNativePath], :mac_catalyst_enabled => false) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |build_config| + current = build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.1' if current.nil? || current.to_f < 15.1 + end + end + end + ``` + + Keep any existing `{fmt}` C++17 workaround (pitfall #11); this lift is + separate. + - **Expo SDK 58:** a fresh `prebuild` left no Pods target below 15.0 in our + trial, so no lift is needed. Check rather than assume: + `grep -h IPHONEOS_DEPLOYMENT_TARGET ios/Pods/Pods.xcodeproj/project.pbxproj | sort -u`. + - **Older Expo SDKs:** if that check shows a target below 15.0, lift it in an + **Expo config plugin** at `prebuild` — never by committing `ios/Podfile` or + patching `Pods/` in a continuous-native-generation workflow. The `{fmt}` fix + stays with `@iterable/expo-plugin` **1.1.0+** (`withIosFmtWorkaround`, + pitfall #11); the resource-bundle lift is a host concern until Iterable + ships a first-party plugin option. ## 13. Expo: EAS signing for `IterableExpoRichPush` @@ -339,10 +373,16 @@ React Native project. Do not introduce `@iterable/expo-plugin` there. `requestPermissionsForPushNotifications` to **`false`**. Bundled `reference/expo.md` (and the upstream article) currently list the default as `true`. That table is wrong. The option is **iOS-only**. -- **Fix:** Trust the plugin, not the table. Set the option **explicitly** - if they want iOS permission prompting. Do not hand-edit - `reference/expo.md` to "fix" the table — the next docs refresh will - overwrite it. Report the drift against `Iterable/iterable-docs`. +- **Fix:** Trust the plugin, not the table. Nothing prompts unless the app + asks. Set `"requestPermissionsForPushNotifications": true` in the plugin + options whenever push is in scope — the recommended setting; the plugin's app + delegate then asks at launch until authorized. Don't add a permission package + for it. If the app already uses `expo-notifications`, it makes itself the + notification-center delegate and chains the one set before it; confirm + Iterable push taps still track afterwards. Android 13+ is separate + (pitfall #3). Do not hand-edit `reference/expo.md` to "fix" the table — the + next docs refresh will overwrite it. Report the drift against + `Iterable/iterable-docs`. --- @@ -395,3 +435,105 @@ Pitfall 15 applies to **both** workflows — any app that already sends push. looks exactly like "the Iterable change broke our push." Check the grant before accepting that diagnosis, and don't accept a culprit inside your own diff without evidence. + +## 16. A link bounces between the SDK and the app + +- **Symptom:** Tapping a link reopens the app or re-runs the same navigation + over and over — sometimes only offline. +- **Cause:** With a JS `urlHandler` set, the native side reports every URL as + handled and passes it to JavaScript (`handleIterableURL` returns `true` on + Android; the iOS bridge does the same). If the handler then returns anything + falsy, the SDK's `callUrlHandler` calls `Linking.openURL(url)`. A URL on the + app's own scheme or tracking domain comes back through the app's `Linking` + listener, which hands it to `Iterable.handleAppLink`, and round it goes. A + tracking-link redirect that fails hands the handler the original `/a/` URL, + so it loops offline too. On both platforms `handleAppLink`'s native check + only looks for `/a/` followed by letters or digits anywhere in the URL, so a + custom-scheme URL such as `myapp://shop/a/42` is treated as a tracking link. +- **Fix:** Make `urlHandler` return `true` for every URL on your own scheme or + domains, the tracking domain included, sending unknown paths to a fallback + screen; return `false` only for other apps' URLs. Pass a URL to + `handleAppLink` only when it is `https`, on your tracking domain, with an + `/a/` path (`isIterableTrackingLink` in `SKILL.md`), and register the + `Linking` listener only after `await Iterable.initialize(...)`. + +## 17. Expo: the plugin does not configure deep links + +- **Symptom:** Universal links and App Links open the browser, never the app, + even though the Expo plugin is installed. +- **Cause:** `reference/expo.md` says the plugin "sets up Universal Links and + configures associated domains" and "configures App Links and sets up intent + filters". In 1.1.0 its deep-link step (`plugin/src/withDeepLinks.ts`) only + sets the main activity's `android:launchMode` to `singleTask`. The article's + own examples don't help: `applinks:expo.dev` and `applinks:iterable.com` are + domains the app can't claim, and its intent-filter JSON contains a `//` + comment and a trailing comma. +- **Fix:** Add `expo.ios.associatedDomains` with `applinks:` and an `expo.android.intentFilters` entry for `https`, your tracking + domain, and `pathPrefix` `/a/` with `autoVerify: true` (the JSON in + `SKILL.md` → Deep links), then re-run prebuild. + +## 18. `npm install` returns 401 for `@iterable/*` + +- **Symptom:** `npm install @iterable/react-native-sdk` (or + `npx expo install @iterable/expo-plugin`) fails with `E401 Unauthorized` + against `npm.pkg.github.com` or another private registry, while every other + package installs. +- **Cause:** A user-level `~/.npmrc` maps the `@iterable` scope to another + registry (`@iterable:registry=https://npm.pkg.github.com/`, common on + machines that also build Iterable's own packages). A scoped mapping beats + `--registry`, so passing the public registry on the command line doesn't help. +- **Fix:** Confirm the mapping with `npm config get @iterable:registry`. Never + `cat` `~/.npmrc` or any other credential file to look — it holds the token, + and printing it puts the token in the transcript. Add a project `.npmrc` with + `@iterable:registry=https://registry.npmjs.org/`; the project file overrides + the user one. The public packages need no token. Don't edit the developer's + `~/.npmrc`. + +## 19. iOS: a stale `.app` installed for proof + +- **Symptom:** The Simulator runs an app without the latest change — no + permission prompt, an old bundle — and the verifier judges a build nobody + meant to test. +- **Cause:** `xcodebuild` without `-derivedDataPath` builds into + `~/Library/Developer/Xcode/DerivedData`; a `.app` then picked with `find` + (say, from an old `ios/build`) is whatever was built there last. +- **Fix:** Build with the exact command in `SKILL.md` → Bare iOS + (`-derivedDataPath build`), install + `ios/build/Build/Products/Release-iphonesimulator/.app`, and pass that + path to `ios-prepare-proof start --app-path`. + +## 20. iOS: code signing disabled strips `aps-environment` + +- **Symptom:** The Release Simulator build succeeds, the app runs, and no APNs + token ever registers. +- **Cause:** `CODE_SIGNING_ALLOWED=NO` (with `CODE_SIGNING_REQUIRED=NO`, + `CODE_SIGN_IDENTITY=""`) skips signing, and a Simulator build gets its + entitlements in that step — so the app has no `aps-environment`. A + `CODE_SIGN_ENTITLEMENTS=` passed to one `xcodebuild` is lost on the next build. +- **Fix:** Never pass those flags; the Simulator build signs ad hoc without a + team. Add the entitlements file to the target's `CODE_SIGN_ENTITLEMENTS` build + setting in the project. A Simulator app carries them in its executable, not + its signature, so `codesign -d --entitlements` shows nothing; check with + `otool -s __TEXT __entitlements .app/` — an empty result means none. + +## 21. `Iterable.registerForPush()` does not exist + +- **Symptom:** A `registerForPush is not a function` TypeError, or a + TypeScript error, after the agent added a call to "register for push". +- **Cause:** The JS facade has no push-registration call. Registration is + native: with `autoPushRegistration` (default `true`) the SDK asks for the + token after `Iterable.setEmail` / `setUserId`. +- **Fix:** Delete the call. Identify the user after `Iterable.initialize`; on + bare iOS, forward the token in `AppDelegate` (`IterableAPI.register(token:)`). + +## 22. Bare iOS: nothing asks for notification permission + +- **Symptom:** The token registers and Iterable sends, but nothing shows on + iOS and the app never asked to send notifications. +- **Cause:** Neither the SDK nor the 0.88 template requests authorization; + registering for a token doesn't. +- **Fix:** Request it at launch in `AppDelegate`'s + `didFinishLaunchingWithOptions`: + `UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }` + (the `SKILL.md` → Bare iOS snippet). No permission package. Expo: pitfall #14. diff --git a/iterable-react-native/SKILL.md b/iterable-react-native/SKILL.md index e1745ff..14b5793 100644 --- a/iterable-react-native/SKILL.md +++ b/iterable-react-native/SKILL.md @@ -45,6 +45,10 @@ entitlements, `POST_NOTIFICATIONS`) is still part of a React Native integration — it lives in this skill's own `reference/` docs, not in a separate skill the developer must ask for. +**Read and search the project with your host's file tools** (in Claude Code, +Read, Grep and Glob), not shell `cat`, `grep` or `find`: each shell command can +cost the developer a permission prompt. + --- ## Step 0 — Agree on scope BEFORE writing code @@ -69,10 +73,23 @@ multi-select question tool (in Claude Code, `AskUserQuestion` with use it and offer **at most 4 options**; otherwise ask in plain text: - Push notifications (FCM / APNs) -- Event tracking + user identity +- Event tracking + user profiles (identifying the user is always included) - Deep links: iOS universal links, Android App Links, and custom URL schemes - Other (inbox, embedded) — describe in the option +Each confirmed answer maps to one entry in `iterable-provision`'s feature ids, +which the iOS phase takes as `--features` (comma-separated): + +| Step 0 answer | `--features` ids | +|---|---| +| Push notifications | `push-notifications` | +| Event tracking + user profiles | `event-tracking,user-profiles` | +| Deep links | `deep-linking` | +| Other: Mobile Inbox | `mobile-inbox` | +| Other: Embedded Messages | `embedded-messaging` | +| Other: custom in-app handling | `in-app-messages` | +| None of these (initialize and identify only) | `sdk-initialization` | + **In-app messages are absent from that list on purpose.** By default the SDK displays every in-app message it receives and tracks the interactions (`in-app-messages`), so an initialized, identified app has them whether or not @@ -95,6 +112,30 @@ the plan before touching it. Confirm the scope, *then* run Preflight, *then* build. +### Two platforms: Android first, then iOS + +When the scope covers both platforms, finish one before starting the other: + +1. **Android:** provision with `iterable-provision`, integrate, and prove it — + `iterable-android-verify` green — before any iOS work starts. +2. **iOS:** `iterable-provision`'s iOS path offers to reuse what the Android + phase recorded — both Iterable keys (Mobile and Server-side), the region, + No JWT, and the test email. Pass the scope confirmed in Step 0 as + `--features ` from the table above, first to `ios-provision status`, + so the import screen names it, and its recommended option carries it into + `import-from-android`. The final scope screen shows it again before + anything is confirmed. Pass only what the developer confirmed; without + `--features` the scope is asked again. The path then adds the Apple/APNs + prerequisites. Then write the iOS native push code + ([Bare iOS](#bare-ios-native-push-code)) and prove it with + `iterable-ios-verify`. + +An app is integrated when the verifier passes for **every platform in scope**; +one green platform is half an integration. + +**After any rebuild, re-run that platform's verifier before asking the developer +to act again.** Its last answer was about the build you replaced. + --- ## Definition of done — finish the agreed scope, don't stub it @@ -109,7 +150,10 @@ Within the agreed scope, an integration is finished only when: UUID-generation/persistence code**. - [ ] Native configuration required by the agreed scope is done for the workflow you detected (Expo plugin vs bare `ios/` + `android/`), including - push permission on Android 13+ when push is in scope. + push permission on Android 13+ and on iOS when push is in scope. +- [ ] Each platform in scope passed its verifier: `iterable-android-verify`, + then `iterable-ios-verify` against a **Release** Simulator build (a Debug + build loads JavaScript from Metro and shows a red box without it). Only genuinely developer-supplied inputs (API key, identity model, region, JWT, `google-services.json` / APNs key) are legitimate pauses. @@ -139,12 +183,12 @@ everything". | Input | Needed when | If missing | |---|---|---| | **Workflow** — Expo vs bare | Always | Detect from the project (Step 0). If both signals exist, ask. | -| **Mobile API key** | Always | Ask where it lives; never hardcode into a tracked file. | +| **Mobile API key** | Always | Never hardcode it into a tracked file. `iterable-provision` writes the copy the app reads: bare RN `.iterable/rn/iterable.local.ts`, Expo `EXPO_PUBLIC_ITERABLE_API_KEY` in a gitignored `.env.local` ([loading it](#loading-the-key)). | | **Identity model** — `setEmail` vs `setUserId`, and where the value comes from | Always | Ask. Never guess. | | **JWT?** — is the mobile key JWT-protected? | Always | Ask, and **assume yes until told otherwise** (it is on by default for client-side keys and is permanent). If yes, `authHandler` is mandatory (rule 1) **and the team needs a backend endpoint that mints the tokens** — if they don't have one, raise it as a blocker instead of signing in the app. | | **Data region** — US or EU | Always | Ask if their dashboard is `app.eu.iterable.com`. See pitfall #2. | | **`google-services.json`** (Firebase) | Android push (bare **or** Expo) | **Route into `iterable-provision`**, which downloads the real one; if it isn't installed, STOP and ask. Either way you cannot generate it. Expo: path goes in `expo.android.googleServicesFile`, not a hand-edited `android/`. | -| **APNs key / capabilities** | Bare-workflow push on iOS | Ask. Follow `reference/push-notifications.md`. | +| **APNs key / capabilities** | Push on iOS | Route into `iterable-provision`'s iOS path once Android is proven. Native code: [Bare iOS](#bare-ios-native-push-code); Expo: the plugin. | | **Development build vs Expo Go** | Expo | Expo Go cannot run this SDK (pitfall #7). If they are in Expo Go, stop and move them to a development build before debugging JS. | **Never fabricate a prerequisite to make the build pass.** Do not invent an @@ -185,6 +229,8 @@ Once it reports `done`, **you own placing the file**: on bare RN copy `.iterable/artifacts/google-services.json` to `android/app/google-services.json`; on Expo put it where `expo.android.googleServicesFile` points, never by hand-editing `android/`. Show it as part of your one confirmed diff. +In a monorepo whose app sits below the repository root, provisioning does not +read the Android package name from the project yet; the developer types it. For the steps they do themselves, don't send them to support.iterable.com; open the doc that covers making it. @@ -235,10 +281,16 @@ Firebase Console and cannot be generated. - **Latest version: always fetch it — never trust a number baked into this file.** Before writing a dependency line, resolve the current release from - npm (`@iterable/react-native-sdk`) or the SDK's - [CHANGELOG.md](https://github.com/Iterable/react-native-sdk/blob/master/CHANGELOG.md). - If the host project already pins a version, match it unless they ask to - upgrade. **Expo:** also resolve `@iterable/expo-plugin` from npm or its + npm with one call that a scoped private registry can't break: + `npm view @iterable/react-native-sdk version --@iterable:registry=https://registry.npmjs.org` + (or the SDK's + [CHANGELOG.md](https://github.com/Iterable/react-native-sdk/blob/master/CHANGELOG.md)). + If the network fails, use **3.2.1**, the version this skill's docs were + checked against, and say so. If the host project already pins a version, + match it unless they ask to upgrade. **Expo:** also resolve + `@iterable/expo-plugin` the same way + (`npm view @iterable/expo-plugin version --@iterable:registry=https://registry.npmjs.org`; + offline, **1.1.0**) or from its [CHANGELOG](https://github.com/Iterable/iterable-expo-plugin/blob/main/CHANGELOG.md). State **both** package versions and check they are compatible (pitfall #10). Do not guess Expo SDK / RN / plugin versions independently. @@ -259,12 +311,17 @@ Firebase Console and cannot be generated. Do not import `NativeRNIterableAPI`, `RNIterableAPI`, or the internal `IterableApi` class. - **Native Android/iOS SDK versions come from this RN package, not from the - Android skill.** `@iterable/react-native-sdk` **3.1.0** pins Android - **`3.6.2`** and iOS **`6.6.7`**. Do not call Kotlin/Swift APIs that exist - only on Android `3.7.0` (the Android skill's pin) or any newer native SDK - than this package ships. Confirm in the RN README version table or - `node_modules/@iterable/react-native-sdk` (`android/build.gradle`, podspec) - for the version they have (pitfall #5). + native skills.** `@iterable/react-native-sdk` **3.2.1** pins Android + **`3.10.1`** and iOS **`6.7.5`**; **3.1.0** pinned `3.6.2` / `6.6.7`. Use only + native APIs the installed package ships, whatever the native skills pin + (`iterable-android` is at 3.7.0, `iterable-ios` at 6.7.5). Confirm in the RN + README version table or `node_modules/@iterable/react-native-sdk` + (`android/build.gradle`, podspec) for the version they have (pitfall #5). +- **Skip 3.2.0.** Its Android module did not compile against its own native pin + (fixed in 3.2.1). 3.2.x needs React Native **0.77+** and React **18.3.1+**, + changes no JavaScript APIs, and turns off unknown-user activation on iOS, + which 3.1.0's native pin left on by default. On Expo, `@iterable/expo-plugin` stays at + **1.1.0**: npm's 1.2.0 is deprecated as published by accident. --- @@ -301,7 +358,11 @@ Expo). Rule 8 applies whenever the app already sends push of its own. This is native Android setup performed as part of the React Native integration — read `reference/push-notifications.md` (bare) or `reference/expo.md` (Expo). Do not skip it, and do not wait for the - developer to open the Android skill. + developer to open the Android skill. The bare 0.88 template's manifest + declares only `INTERNET`, and it doesn't apply the + `com.google.gms.google-services` Gradle plugin — so the app builds without + `google-services.json` and simply never gets an FCM token. A green build is + not proof. 4. **Only call the public JS facade in `src/index.tsx`.** Import from `@iterable/react-native-sdk` (`Iterable`, `IterableConfig`, in-app / inbox / @@ -310,8 +371,8 @@ Expo). Rule 8 applies whenever the app already sends push of its own. `IterableApi.getInstance()`, `IterableConfig.Builder()`, or Swift `IterableAPI` into JS. Device attributes and unread-inbox count exist natively but are not on that facade (pitfall #4, SDK-593, SDK-594). This - package at 3.1.0 ships Android `3.6.2` / iOS `6.6.7` — do not pull Android - `3.7.0` APIs from the Android skill (pitfall #5). + package at 3.2.1 ships Android `3.10.1` / iOS `6.7.5` — native APIs newer + than those are not reachable (pitfall #5). 5. **Never hardcode the API key into a tracked file.** Read it from a gitignored env file or the project's existing secrets pattern. An empty @@ -321,16 +382,21 @@ Expo). Rule 8 applies whenever the app already sends push of its own. identity to the first email-shaped field you find. 7. **Expo: both packages, plugin owns native configuration.** Install with - `npx expo install @iterable/expo-plugin @iterable/react-native-sdk`. - Never treat Expo Go as a valid runtime (pitfall #7). Do not hand-edit + `npx expo install @iterable/expo-plugin@ @iterable/react-native-sdk@` + ([Quick facts](#quick-facts-always-relevant)). Never treat Expo Go as a valid runtime (pitfall #7). Do not hand-edit `ios/` or `android/` as the primary path (`prebuild --clean` wipes them — pitfall #8). If they already configure native code by hand, do **not** add the plugin (pitfall #9). When stating versions, give both packages - (pitfall #10). Trust plugin source over the support-doc options table - (`requestPermissionsForPushNotifications` defaults to `false` — pitfall - #14). On Xcode **27**, use CLI **20.2.0+** (bare) or Expo SDK **58** - for Device Hub launch, and host Podfile / config-plugin fixes for - resource-bundle deployment targets (pitfall **#12**) — not hand-edited + (pitfall #10). Trust plugin source over the support-doc options table: + `requestPermissionsForPushNotifications` defaults to `false`, so nothing + prompts — set it to `true` when push is in scope (pitfall #14). Run a + development build (`expo-dev-client`): Expo Go cannot load the + `RNIterableAPI` native module. `ios/` and `android/` are + gitignored and regenerated, so a config or plugin change needs a fresh + prebuild. On Xcode **27**, use CLI **20.2.0+** (bare) or Expo SDK **58** + for Device Hub launch; bare apps also need a `Podfile` lift of Pods + resource-bundle deployment targets, while an Expo SDK 58 prebuild needed none + in our trial — check, don't assume (pitfall **#12**). Never hand-edit `Pods/`. 8. **If the app already sends push, forward to Iterable — never replace what's @@ -352,6 +418,15 @@ Expo). Rule 8 applies whenever the app already sends push of its own. manifest rather than reasoning it out. iOS coexistence is a different mechanism — don't assume the Android shape transfers. +9. **Deep links: `urlHandler` answers `true` for every URL the app owns, and + only tracking links reach `Iterable.handleAppLink`.** A falsy return makes + the SDK's JavaScript call `Linking.openURL(url)`; for a URL on your own + scheme or tracking domain that comes straight back through your `Linking` + listener — a loop (pitfall #16). Pass a URL to `handleAppLink` only when it + is `https`, on your tracking domain, with an `/a/` path, and wire `Linking` + only after `await Iterable.initialize(...)`. On Expo the plugin does **not** + configure deep links (pitfall #17). See [Deep links](#deep-links). + --- ## Canonical minimum integration (start here) @@ -360,33 +435,211 @@ Adapt this; don't bolt the pieces together from scratch. Read `reference/installing.md` (bare) or `reference/expo.md` (Expo) before writing native project files. -**Expo — install both packages first** (then the JS below): +### Install — Android first + +With the versions resolved in [Quick facts](#quick-facts-always-relevant): + +**Bare:** ```bash -npx expo install @iterable/expo-plugin @iterable/react-native-sdk +npm install @iterable/react-native-sdk@ ``` -Add `["@iterable/expo-plugin", {}]` to `expo.plugins` in `app.json` / -`app.config.*`. Do not skip this, and do not treat the JS snippet as the -whole Expo integration. +**Expo** — through `npx expo install`, which the default Expo template's `AGENTS.md` +also requires: + +```bash +npx expo install @iterable/expo-plugin@ @iterable/react-native-sdk@ +``` + +- **`E401` from `npm.pkg.github.com`:** the developer's npm config maps the + `@iterable` scope to a private registry, and `--registry=` doesn't override a + scope. Check with `npm config get @iterable:registry` — **never print + `~/.npmrc` or any other credential file**, it holds their token — then add + pitfall #18's project `.npmrc` and install again. +- **Android first.** After the install, work only in JavaScript and `android/`: + no `pod install`, no edit under `ios/`, no `AppDelegate` change until + `iterable-android-verify` is green. +- **New Architecture: leave it on, there is nothing to configure.** React Native + 0.82+ runs only the New Architecture: its CocoaPods scripts treat it as always + enabled and warn on `ENV['RCT_NEW_ARCH_ENABLED'] = '0'`, and its Gradle plugin + warns on `newArchEnabled=false`, then both run it anyway. SDK 3.2.1 is a + codegen TurboModule (`RNIterableAPISpec`) and builds its New Architecture path + under 0.88. So skip `reference/installing.md` → `## Configuring React Native's + New Architecture`, and keep the template's `newArchEnabled=true` in + `android/gradle.properties`: the SDK's Android module compiles its New + Architecture sources only when that property reads `true`. + +On Expo, add the plugin to `expo.plugins` in `app.json` / `app.config.*` — +with push in scope, as +`["@iterable/expo-plugin", { "requestPermissionsForPushNotifications": true }]` +(pitfall #14); otherwise `["@iterable/expo-plugin", {}]`. Do not skip this, and +do not treat the JS snippet as the whole Expo integration. + +### Initialize and identify ```javascript +import { Linking } from 'react-native'; import { Iterable, IterableConfig, IterableDataRegion } from '@iterable/react-native-sdk'; -export async function initializeIterable(apiKey, { userId, dataRegion } = {}) { +// The Iterable link-tracking domain: Settings > Domains in Iterable. +const LINK_TRACKING_DOMAIN = 'links.example.com'; + +export function isIterableTrackingLink(url) { + const match = /^https:\/\/([^/?#]+)(\/[^?#]*)?/.exec(url ?? ''); + return !!match && match[1] === LINK_TRACKING_DOMAIN && /^\/a\/[A-Za-z0-9]+/.test(match[2] ?? ''); +} + +// routeUrl(url) is your app's router: true for every URL on your own scheme or +// domains (unknown paths go to a fallback screen), false only for other apps' URLs. +export async function initializeIterable(apiKey, { userId, routeUrl }) { const config = new IterableConfig(); // config.dataRegion = IterableDataRegion.EU; // EU projects (pitfall #2) // config.authHandler = () => fetchJwtForCurrentUser(); // JWT keys (pitfall #1) + config.urlHandler = (url, context) => routeUrl(url); // rule 9 await Iterable.initialize(apiKey, config); // native init returned, not in-app ready (pitfall #6) if (userId) { Iterable.setUserId(userId); // or setEmail(...) — pick ONE (rule 6) } + + // After initialize: on Android, handleAppLink before init returns false and the link is lost. + const open = (url) => { + if (!url) return; + if (isIterableTrackingLink(url)) Iterable.handleAppLink(url); + else routeUrl(url); + }; + open(await Linking.getInitialURL()); + const subscription = Linking.addEventListener('url', ({ url }) => open(url)); + return () => subscription.remove(); } ``` **You are not done until `initialize` is actually called** from the host app's startup path. +### Loading the key + +The key file is gitignored, so a fresh clone or a CI build has no key; the app +must still build and simply skip `initialize`. + +```javascript +// Bare React Native: an optional require, which Metro tolerates when the file +// is missing. A static import breaks every build without the file. +export function readIterableApiKey() { + try { + return require('../.iterable/rn/iterable.local').ITERABLE_API_KEY ?? ''; + } catch { + return ''; + } +} +``` + +```javascript +// Expo: inlined from .env.local at build time; write it exactly like this +// (dot notation), or Expo doesn't inline it. +export const ITERABLE_API_KEY = process.env.EXPO_PUBLIC_ITERABLE_API_KEY ?? ''; +``` + +Adjust the relative path to `.iterable/` from the importing file. On Expo, +`@iterable/expo-plugin` 1.1.0 has **no** `apiKey` option — one in `app.json` is +ignored — and a changed `.env.local` needs a restarted bundler or a rebuild. + +### Bare iOS: native push code + +`Iterable.initialize` sets up the native SDK, but the APNs callbacks are native +and belong in the app. The 0.88 template is scene-based: `AppDelegate.swift` +keeps the push callbacks, `SceneDelegate.swift` keeps the URL handling. + +```swift +// AppDelegate.swift — add to the template's class. +import IterableSDK +import UIKit +import UserNotifications + +@main +class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + UNUserNotificationCenter.current().delegate = self + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in } + return true + } + + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + IterableAPI.register(token: deviceToken) + } + + func application(_ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + IterableAppIntegration.application(application, + didReceiveRemoteNotification: userInfo, + fetchCompletionHandler: completionHandler) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .list, .sound, .badge]) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + IterableAppIntegration.userNotificationCenter(center, + didReceive: response, + withCompletionHandler: completionHandler) + } +} +``` + +In Objective-C the calls are `[IterableAPI registerToken:deviceToken]`, +`[IterableAppIntegration application:didReceiveRemoteNotification:fetchCompletionHandler:]`, +and `[IterableAppIntegration userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:]`. + +- **Permission is asked at launch, natively** — the `requestAuthorization` line + above; neither the SDK nor the template asks (pitfall #22). Don't add a + permission package for it. +- **Token registration is the SDK's, after identity.** There is no + `Iterable.registerForPush()` or any other JavaScript registration call + (pitfall #21). With the default `autoPushRegistration`, the SDK asks for the + token after `Iterable.setEmail` / `setUserId`, and `register(token:)` ignores + any token that arrives before an identity. +- **Project:** the `aps-environment` entitlement (Push Notifications + capability), `UIBackgroundModes` → `remote-notification` (silent push, which + also delivers in-app messages), and, for images or buttons, a Notification + Service Extension target with the `Iterable-iOS-AppExtensions` pod. The + `iterable-ios` skill has the exact files (canonical step 1, Push + notifications). Add the entitlements file to the app target's + `CODE_SIGN_ENTITLEMENTS` build setting, for Debug and Release, in the + project — not as a `CODE_SIGN_ENTITLEMENTS=` argument to one `xcodebuild`. +- **Universal links:** keep the template's `SceneDelegate` forwarding + `scene(_:continue:)` and `scene(_:openURLContexts:)` to `RCTLinkingManager`, + and passing `connectionOptions` to `startReactNative` — that is how a link + that launched the app reaches `Linking.getInitialURL()`. The SDK example's + `AppDelegate` `application(_:continue:restorationHandler:)` never fires in a + scene-based app; React Native ignores the app-delegate linking methods once + `UIApplicationSceneManifest` is declared. +- **Proof artifact:** the Release configuration for the Simulator. It embeds + `main.jsbundle`; Debug loads JavaScript from Metro and shows a red box + without it. Build it with exactly this, from the app root: + + ```bash + cd ios && xcodebuild -workspace .xcworkspace -scheme -configuration Release -sdk iphonesimulator -derivedDataPath build build + ``` + + The app is `ios/build/Build/Products/Release-iphonesimulator/.app`. + Install that path with `xcrun simctl install `, and record it + with `/bin/ios-prepare-proof start --confirm --app-path `, or + `restart --confirm --app-path ` when a run already exists. Never install a `.app` found with `find` or from another DerivedData + folder — it can be a stale build (pitfall #19). Never add + `CODE_SIGNING_ALLOWED=NO`, `CODE_SIGNING_REQUIRED=NO` or `CODE_SIGN_IDENTITY=""`: + an unsigned app carries no `aps-environment` and never gets a token + (pitfall #20). + Native push / APNs / FCM / `POST_NOTIFICATIONS` steps are in the installing and push (or expo) reference docs — do them as part of this skill when push is in scope. If a React Native article defers to Android-native detail that @@ -398,6 +651,66 @@ Notification Service Extension) you may open initialization into an RN app: this package initializes the native SDK, and the iOS SDK version it ships (above) wins over the iOS skill's pin. +### Deep links + +Only when deep links are in scope. + +- **What `urlHandler`'s answer does.** With a JS `urlHandler` set, the native + side always reports the URL as handled and hands it to JavaScript; on Android + the SDK waits `config.androidWakeDelayMs` (default **1000 ms**) before calling + it, so a cold-start link routes a second later. If the handler returns + anything falsy, the SDK's JavaScript calls `Linking.openURL(url)` — for your + own scheme or domains, straight back into your app (rule 9, pitfall #16). +- **A tracking link is resolved asynchronously.** `handleAppLink` records the + click and follows the redirect; `urlHandler` then receives the destination — + or the original `/a/` URL when the request fails (offline), which it must also + answer `true` for. +- **Keep tracking links out of your navigation library.** React Navigation's + `linking` prop takes `filter: (url) => !isIterableTrackingLink(url)`. With + Expo Router, export `redirectSystemPath` from `app/+native-intent.tsx` and + return `null` for a tracking link — no redirection occurs — letting the + listener above hand it to Iterable. +- **Native entry points.** Bare iOS forwards links to `RCTLinkingManager` + (`reference/deep-links-and-custom-actions.md` → Step 1). That snippet starts + with `#import `, which is Objective-C and does not + compile in a Swift `AppDelegate` — use `import React`. Its `urlHandler` + example calls `this.navigate(match[1])` without ever binding `match`; capture + the result of `url.match(...)` first. +- **Expo:** the plugin (1.1.0) only sets the main activity's + `android:launchMode` to `singleTask`. It adds no intent filters and no + associated domains, whatever `reference/expo.md` says — add them yourself in + `app.json` (pitfall #17): + + ```json + { + "expo": { + "ios": { "associatedDomains": ["applinks:links.example.com"] }, + "android": { + "intentFilters": [ + { + "action": "VIEW", + "autoVerify": true, + "data": [{ "scheme": "https", "host": "links.example.com", "pathPrefix": "/a/" }], + "category": ["BROWSABLE", "DEFAULT"] + } + ] + } + } + } + ``` + + Use your own tracking domain. The `reference/expo.md` examples list + `applinks:expo.dev` and `applinks:iterable.com` — domains the app cannot + claim — and its intent-filter JSON has a `//` comment and a trailing comma, + which `app.json` rejects. +- **Iterable prerequisites** for either platform: a custom HTTPS link-tracking + domain (not `links.iterable.com`), the `apple-app-site-association` and + `assetlinks.json` uploads under **Settings > Domains** → the tracking domain → + **New Destination Path**, and on Android 12+ a verified App Link — an + unverified one opens in the browser. The native skills have the detail: + `iterable-ios` (Deep links and universal links) and `iterable-android` (Deep + links and App Links). + --- ## How to use this skill (fetching task-specific docs) @@ -419,7 +732,7 @@ source — it is already on disk.** | If the user is asking about… | Slug | | ---------------------------- | ---- | | What the RN SDK covers, architecture, JS vs native | `overview` | -| First-time setup, npm install, iOS + Android native project setup, `Iterable.initialize` | `installing` (bare; skip `## Upgrading the SDK` unless they are upgrading). Expo: `expo` | +| First-time setup, npm install, iOS + Android native project setup, `Iterable.initialize` | `installing` (bare; skip `## Upgrading the SDK` unless they are upgrading, and on React Native 0.82+ skip `## Configuring React Native's New Architecture` — see [Install](#install--android-first)). Expo: `expo` | | Upgrading from an older RN SDK version | `installing` → `## Upgrading the SDK`, or `migrating` | | Expo / `expo prebuild` / config plugin | `expo` | | JWT, `authHandler`, JWT-enabled API keys | `authentication` | diff --git a/iterable-react-native/reference/api-keys.md b/iterable-react-native/reference/api-keys.md index b19d58a..d09e057 100644 --- a/iterable-react-native/reference/api-keys.md +++ b/iterable-react-native/reference/api-keys.md @@ -2,7 +2,7 @@ slug: api-keys feature: integration archetype: prerequisite -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: API Keys source_url: https://support.iterable.com/hc/articles/360043464871 @@ -10,7 +10,7 @@ source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/api/api-keys/index.md source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: a471f729824c851571d0c97c020dfc9c68d0dcbc -fetched_at: 2026-09-17T15:31:58.821Z +fetched_at: 2026-10-19T10:21:48.016Z summary: "[Iterable's API](https://support.iterable.com/hc/articles/204780579) can be used to interact with a project's users, templates, campaigns, journeys, and more. To authenticate with the API, you must use an API key." diff --git a/iterable-react-native/reference/authentication.md b/iterable-react-native/reference/authentication.md index 6a52a9e..dc00a56 100644 --- a/iterable-react-native/reference/authentication.md +++ b/iterable-react-native/reference/authentication.md @@ -2,15 +2,15 @@ slug: authentication feature: jwt-authentication archetype: identity -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: JWT Authentication with Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/43901001355412 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/authentication/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 6b2543add6f9cabb69d410636adf764ec46a78bb -fetched_at: 2026-08-28T14:44:26.816Z +fetched_at: 2026-10-19T10:21:42.037Z summary: JSON Web Token (JWT) authentication adds an extra layer of security to your mobile app by requiring a cryptographically signed token for each API request. This helps prevent unauthorized access to your Iterable project and diff --git a/iterable-react-native/reference/deep-links-and-custom-actions.md b/iterable-react-native/reference/deep-links-and-custom-actions.md index fb77db6..fbeab99 100644 --- a/iterable-react-native/reference/deep-links-and-custom-actions.md +++ b/iterable-react-native/reference/deep-links-and-custom-actions.md @@ -2,15 +2,15 @@ slug: deep-links-and-custom-actions feature: deep-linking archetype: feature -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Deep Links and Custom Actions with Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/360046134911 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/deep-links-and-custom-actions/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: ee16919f6cfe9bcf16e04aee34b06b8f41be523e -fetched_at: 2026-08-28T14:44:26.844Z +fetched_at: 2026-10-19T10:21:44.848Z summary: It's often useful to link directly from your marketing campaigns to specific content in your mobile apps. To make this possible, Iterable supports _deep links_ and _custom actions_. Deep links can open a particular screen in diff --git a/iterable-react-native/reference/embedded-message-placements.md b/iterable-react-native/reference/embedded-message-placements.md index 3df8f76..3fb4db5 100644 --- a/iterable-react-native/reference/embedded-message-placements.md +++ b/iterable-react-native/reference/embedded-message-placements.md @@ -2,7 +2,7 @@ slug: embedded-message-placements feature: embedded-messaging archetype: prerequisite -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Defining Placements for Embedded Messages source_url: https://support.iterable.com/hc/articles/23060983437076 @@ -10,7 +10,7 @@ source_repo: Iterable/iterable-docs source_path: docs/sending-messages-by-channel/embedded-messaging/placements-for-embedded-messages/index.md source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 9d3f509c00fb5c26601026bd89837ba68cfcad9c -fetched_at: 2026-09-17T15:32:00.898Z +fetched_at: 2026-10-19T10:21:49.719Z summary: Before you can create embedded message templates and campaigns, you'll need to decide where in your apps to display them. Places in your app or website that display embedded messages are called _placements_, and you'll diff --git a/iterable-react-native/reference/embedded-message-subscription-channels.md b/iterable-react-native/reference/embedded-message-subscription-channels.md index 7dbcf8a..e190965 100644 --- a/iterable-react-native/reference/embedded-message-subscription-channels.md +++ b/iterable-react-native/reference/embedded-message-subscription-channels.md @@ -2,7 +2,7 @@ slug: embedded-message-subscription-channels feature: embedded-messaging archetype: prerequisite -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Adding an Embedded Message Subscription Channel and Type to Your Iterable Project @@ -11,7 +11,7 @@ source_repo: Iterable/iterable-docs source_path: docs/sending-messages-by-channel/embedded-messaging/embedded-messaging-subscription-channels-and-types/index.md source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: b4ece97017a56433619bad04118c3532f518cca3 -fetched_at: 2026-09-17T15:32:01.452Z +fetched_at: 2026-10-19T10:21:50.181Z summary: Before creating embedded messages templates and campaigns in Iterable, you'll need to add an embedded messaging subscription channel with an opt-out message type to your Iterable project. diff --git a/iterable-react-native/reference/embedded-messages-with-iterables-react-native-sdk.md b/iterable-react-native/reference/embedded-messages-with-iterables-react-native-sdk.md index bc4dd65..81d8127 100644 --- a/iterable-react-native/reference/embedded-messages-with-iterables-react-native-sdk.md +++ b/iterable-react-native/reference/embedded-messages-with-iterables-react-native-sdk.md @@ -2,15 +2,15 @@ slug: embedded-messages-with-iterables-react-native-sdk feature: embedded-messaging archetype: feature -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Embedded Messages with Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/49144129810324 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/embedded-messaging/embedded-messages-with-iterables-react-native-sdk/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 88948a289f4214d375316e4cd8ab8dca1af22120 -fetched_at: 2026-08-28T14:44:26.871Z +fetched_at: 2026-10-19T10:21:47.491Z summary: This article describes the steps you'll need to follow to use Iterable's React Native SDK to display embedded messages in your mobile app. It describes only the steps necessary to use embedded messaging. To learn diff --git a/iterable-react-native/reference/expo.md b/iterable-react-native/reference/expo.md index 72c4a7b..85ae930 100644 --- a/iterable-react-native/reference/expo.md +++ b/iterable-react-native/reference/expo.md @@ -2,15 +2,15 @@ slug: expo feature: integration archetype: integration -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Expo Plugin for Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/37200225506196 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/expo/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 258b9e115662257942fb8f6bb7ab57d8cc4d68eb -fetched_at: 2026-08-28T14:44:26.860Z +fetched_at: 2026-10-19T10:21:46.411Z summary: "Iterable's React Native SDK offers an Expo plugin that extends configuration management, making it easier to manage dependencies and configurations. Use the Expo plugin to minimize the potential for @@ -118,31 +118,6 @@ workaround during `expo prebuild` that compiles the `{fmt}` C++ library pod in C++17 mode, for compatibility with [Xcode 26.4](https://github.com/expo/expo/issues/44229) or later and React Native 0.83.2 or later. No additional configuration is required. -### Xcode 27 - -These notes are **iOS-only**. See [`PITFALLS.md`](../PITFALLS.md) pitfall **#12** -for the combined launch + build story. The `{fmt}` workaround above is -**separate** from the deployment-target lift below. - -**Launch (Device Hub).** On Xcode 27, Expo SDK **58** (and its CLI) targets -**Device Hub** instead of the removed `Simulator.app` under -`Developer/Applications/`. Apps still on **Expo SDK 55** may fail at -`run:ios` on Xcode 27 (often with a missing **Simulator.app** / simulator -launch error) until they upgrade or use a short-term -`npx expo@latest run:ios`. Do not fix this by hand-editing `ios/Podfile` or -`Pods/` in a continuous-native-generation workflow (see [Native code](#native-code)). - -**Build (resource bundles).** The same Xcode 27 **15.0** compile floor applies -to every Pods target, including `Iterable-iOS-SDK-IterableSDKResources` and -other `*_resources` bundles. In **CNG** workflows, apply a **config plugin** -at `prebuild` that lifts generated Pods deployment targets to at least -**15.1** when unset or below **15.1** (same lift as Iterable's bare -`Podfile` workaround; **15.0** is the SDK minimum) — do not commit -`ios/Podfile` changes as the source of truth for this. `@iterable/expo-plugin` -**1.1.0+** handles `{fmt}` via `withIosFmtWorkaround`; the resource-bundle -lift is a **host** concern until Iterable documents a first-party plugin -option. - ### Expo Go Your Expo app needs to be run as a [development build](https://docs.expo.dev/develop/development-builds/introduction/) diff --git a/iterable-react-native/reference/fcm-http-v1-migration.md b/iterable-react-native/reference/fcm-http-v1-migration.md index 7249690..338347c 100644 --- a/iterable-react-native/reference/fcm-http-v1-migration.md +++ b/iterable-react-native/reference/fcm-http-v1-migration.md @@ -2,7 +2,7 @@ slug: fcm-http-v1-migration feature: push-notifications archetype: prerequisite -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: "Mobile Push Notifications: Migrating to the FCM HTTP v1 API" source_url: https://support.iterable.com/hc/articles/26143681644564 @@ -10,7 +10,7 @@ source_repo: Iterable/iterable-docs source_path: docs/sending-messages-by-channel/push-notification/mobile-push-notifications-migrating-to-the-fcm-http-v1-api/index.md source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 72ab970eba676b48e10caa249676e03f51799a8e -fetched_at: 2026-09-17T15:32:00.093Z +fetched_at: 2026-10-19T10:21:49.121Z summary: If any of your Iterable projects use Firebase Cloud Messaging (FCM) to send mobile or web push notifications, migrate them to the FCM HTTP v1 API **before July 22, 2024** (Google has changed the deadline from June 20 to July diff --git a/iterable-react-native/reference/in-app-messages.md b/iterable-react-native/reference/in-app-messages.md index d3081f8..bbc93eb 100644 --- a/iterable-react-native/reference/in-app-messages.md +++ b/iterable-react-native/reference/in-app-messages.md @@ -2,15 +2,15 @@ slug: in-app-messages feature: in-app-messages archetype: feature -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: In-App Messages with Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/360045714172 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/in-app-messages/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: f16e23544e5cb8db400eda069e97955e7458e89e -fetched_at: 2026-08-28T14:44:26.833Z +fetched_at: 2026-10-19T10:21:43.538Z summary: Iterable's React Native SDK makes it possible to display in-app messages, customize them, and track events related to their usage. --- diff --git a/iterable-react-native/reference/installing.md b/iterable-react-native/reference/installing.md index a7640ef..f7a6c25 100644 --- a/iterable-react-native/reference/installing.md +++ b/iterable-react-native/reference/installing.md @@ -2,7 +2,7 @@ slug: installing feature: integration archetype: integration -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Installing Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/360045714132 @@ -10,7 +10,7 @@ source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/installing/index.md source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 25468f8175b9dd3c3c2ef4eb8115ab9c13f2bbe3 -fetched_at: 2026-09-11T13:22:44.397Z +fetched_at: 2026-10-19T10:21:41.457Z summary: This article describes how to install Iterable's React Native SDK in your React Native apps, and how to configure your iOS and Android apps to support Iterable features like push notifications and in-app messages. @@ -42,46 +42,12 @@ Iterable's React Native SDK depends on the following: **iOS** -- Xcode 15+ (see [Xcode 27](#xcode-27) when using Apple's Xcode 27 beta or - release) +- Xcode 15+ - Swift 5+ - [Deployment target 13.4+](https://help.apple.com/xcode/mac/current/#/deve69552ee5) - for Iterable's documented floor on older Xcode versions. **Xcode 27** adds - stricter compile rules for CocoaPods resource bundles — see - [Xcode 27](#xcode-27); that is not the same as Iterable raising its - published iOS SDK minimum (the dependency may still declare **12.0** in - its podspec until an Iterable iOS SDK release changes it). - [Iterable's iOS SDK](https://github.com/Iterable/iterable-swift-sdk) (the instructions below will install this SDK for you) -### Xcode 27 - -These notes are **iOS-only** (Android unchanged). Full symptom/cause/fix -detail is in [`PITFALLS.md`](../PITFALLS.md) pitfall **#12**. - -**Launch (Device Hub).** Xcode 27 replaces the standalone Simulator app with -**Device Hub** (`DeviceHub.app`, bundle id `com.apple.dt.Devices`). Older -React Native CLI still looks for -`Xcode.app/Contents/Developer/Applications/Simulator.app`, so -`npx react-native run-ios` can fail even when `xcode-select` points at Xcode -27. Use `@react-native-community/cli` **20.2.0 or later** (and aligned -`@react-native-community/cli-platform-ios`). Iterable's public RN SDK **example** -may still document **20.1.0** until that repo updates — consumer apps on -Xcode 27 need **20.2.0+**. Confirm `xcode-select -p` matches the Xcode you -build with when multiple versions are installed. - -**Build (resource bundles).** The iOS 27 SDK requires -`IPHONEOS_DEPLOYMENT_TARGET` in the **15.0**–current-SDK range on **every** -Xcode target, including CocoaPods **resource bundle** targets generated from -dependency podspecs (for example `Iterable-iOS-SDK-IterableSDKResources` at -**12.0**, or third-party `*_resources` pods). A host **`post_install`** hook -in the app `Podfile` must raise stale Pods targets to at least **15.1** when -they are missing or below **15.1** — then re-run `pod install`. **15.0** is -the Xcode 27 SDK compile floor; **15.1** matches Iterable's bare RN demo so -targets are not left at exactly **15.0**. Do **not** edit files -under `Pods/` by hand. This is a compile-time host workaround; it does not -mean you should bump `Iterable-iOS-SDK` in the Podfile as the primary fix. - **Android** - [`minSdkVersion` 23+, `compileSdkVersion` 34+](https://medium.com/androiddevelopers/picking-your-compilesdkversion-minsdkversion-targetsdkversion-a098a0341ebd) diff --git a/iterable-react-native/reference/jwt-enabled-api-keys.md b/iterable-react-native/reference/jwt-enabled-api-keys.md index f34a88e..e9a6c51 100644 --- a/iterable-react-native/reference/jwt-enabled-api-keys.md +++ b/iterable-react-native/reference/jwt-enabled-api-keys.md @@ -2,7 +2,7 @@ slug: jwt-enabled-api-keys feature: jwt-authentication archetype: prerequisite -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: JWT-Enabled API Keys source_url: https://support.iterable.com/hc/articles/360050801231 @@ -10,7 +10,7 @@ source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/api/jwt-enabled-api-keys/index.md source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 27e742891fb5f2f842cb0ecfbe403aa9ee5bbbab -fetched_at: 2026-09-17T15:31:59.465Z +fetched_at: 2026-10-19T10:21:48.559Z summary: Iterable's API supports authentication with JSON Web Token (JWT)-enabled API keys. --- diff --git a/iterable-react-native/reference/managing-user-identity.md b/iterable-react-native/reference/managing-user-identity.md index 8f92b18..f79e724 100644 --- a/iterable-react-native/reference/managing-user-identity.md +++ b/iterable-react-native/reference/managing-user-identity.md @@ -2,15 +2,15 @@ slug: managing-user-identity feature: user-profiles archetype: identity -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Managing User Identity with Iterable's React Native SDK source_url: https://iterable.zendesk.com/hc/articles/360045714152 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/managing-user-identity/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: d9c6d023a1405845d55aef9e6315f6e1dbecd905 -fetched_at: 2026-08-28T14:44:26.822Z +fetched_at: 2026-10-19T10:21:42.510Z summary: As people use your mobile apps, you'll likely want to save data and events to their Iterable user profiles. Your marketing team can then use this data to send relevant, personalized messages. diff --git a/iterable-react-native/reference/migrating.md b/iterable-react-native/reference/migrating.md index bf99a44..b7b3abe 100644 --- a/iterable-react-native/reference/migrating.md +++ b/iterable-react-native/reference/migrating.md @@ -2,15 +2,15 @@ slug: migrating feature: integration archetype: integration -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Migrating to Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/360046134931 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/migrating/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: f9c8c2739e94d3d97237bc172f197bc899a2d1c1 -fetched_at: 2026-08-28T14:44:26.866Z +fetched_at: 2026-10-19T10:21:46.913Z summary: This guide describes how to integrate Iterable's React Native SDK into an existing React Native application that interacts with Iterable. --- diff --git a/iterable-react-native/reference/mobile-inbox.md b/iterable-react-native/reference/mobile-inbox.md index b82195a..176869b 100644 --- a/iterable-react-native/reference/mobile-inbox.md +++ b/iterable-react-native/reference/mobile-inbox.md @@ -2,15 +2,15 @@ slug: mobile-inbox feature: mobile-inbox archetype: feature -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Using a Mobile Inbox with Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/5422108307604 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/mobile-inbox/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 0088f17792b5278225b686005e654262de6a4eab -fetched_at: 2026-08-28T14:44:26.838Z +fetched_at: 2026-10-19T10:21:44.084Z summary: A mobile inbox displays a list of saved in-app messages that users can revisit at their convenience. This way, they can access messages right when they're relevant—for example, maybe pulling up a coupon while shopping. diff --git a/iterable-react-native/reference/overview.md b/iterable-react-native/reference/overview.md index ef38c56..f062cdf 100644 --- a/iterable-react-native/reference/overview.md +++ b/iterable-react-native/reference/overview.md @@ -2,15 +2,15 @@ slug: overview feature: integration archetype: integration -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Overview of Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/360045714072 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/overview/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: e8d2268f9774c59a5afee18ad77a5b20aba0a284 -fetched_at: 2026-08-28T14:44:26.800Z +fetched_at: 2026-10-19T10:21:40.833Z summary: Iterable's [React Native SDK](https://github.com/iterable/react-native-sdk) helps you integrate Iterable into iOS and Android applications built with [React diff --git a/iterable-react-native/reference/push-notifications.md b/iterable-react-native/reference/push-notifications.md index 664830d..d78bd68 100644 --- a/iterable-react-native/reference/push-notifications.md +++ b/iterable-react-native/reference/push-notifications.md @@ -2,15 +2,15 @@ slug: push-notifications feature: push-notifications archetype: feature -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Push Notifications with Iterable's React Native SDK source_url: https://iterable.zendesk.com/hc/articles/360046134871 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/push-notifications/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: cd2ea10bb40d789aed557a58d5946ab34fb682e5 -fetched_at: 2026-08-28T14:44:26.827Z +fetched_at: 2026-10-19T10:21:43.013Z summary: Iterable's React Native SDK provides methods to track push notification-related events and to access a push notification's payload, as described below. diff --git a/iterable-react-native/reference/tracking-events.md b/iterable-react-native/reference/tracking-events.md index 51d53bf..5932d66 100644 --- a/iterable-react-native/reference/tracking-events.md +++ b/iterable-react-native/reference/tracking-events.md @@ -2,15 +2,15 @@ slug: tracking-events feature: event-tracking archetype: feature -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Tracking Events and Purchases with Iterable's React Native SDK source_url: https://support.iterable.com/hc/articles/360046134891 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/tracking-events/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 381795f3d7eabb6b0cf97de20ead603d22ff4cf9 -fetched_at: 2026-08-28T14:44:26.850Z +fetched_at: 2026-10-19T10:21:45.382Z summary: When users take particular actions in your mobile apps—open push notifications, complete their profiles, close in-app messages, etc.—Iterable's React Native SDK can save corresponding events, of custom or pre-defined diff --git a/iterable-react-native/reference/user-profile-data-and-subscription-preferences.md b/iterable-react-native/reference/user-profile-data-and-subscription-preferences.md index 85580ab..fdb4fb7 100644 --- a/iterable-react-native/reference/user-profile-data-and-subscription-preferences.md +++ b/iterable-react-native/reference/user-profile-data-and-subscription-preferences.md @@ -2,16 +2,16 @@ slug: user-profile-data-and-subscription-preferences feature: user-profiles archetype: identity -sdk_min_version: 3.1.0 +sdk_min_version: 3.2.1 sdk_artifact: "@iterable/react-native-sdk" title: Managing User Profile Data and Subscription Preferences with Iterable's React Native SDK source_url: https://iterable.zendesk.com/hc/articles/360046134851 source_repo: Iterable/iterable-docs source_path: docs/developer-and-api-docs/iterables-react-native-sdk/user-profile-data-and-subscription-preferences/index.md -source_ref: 59c40504c91bc0b13751c5ef5f348810eb0fd4f2 +source_ref: 275e9063f5aa922a9c282d6d34a8aebec3d15448 source_sha: 1fa754a89e67cfe38db325cc0e0e2cf2ecd6880f -fetched_at: 2026-08-28T14:44:26.855Z +fetched_at: 2026-10-19T10:21:45.847Z summary: As you learn new things about your mobile app's users, you may want to save data to their Iterable profiles. Your marketing team can then use this information to send them personalized messages. diff --git a/pipeline/config/pitfalls/react-native.yml b/pipeline/config/pitfalls/react-native.yml index 7ac8f26..49f5cc1 100644 --- a/pipeline/config/pitfalls/react-native.yml +++ b/pipeline/config/pitfalls/react-native.yml @@ -8,10 +8,11 @@ # Keep `triggers` narrow enough to avoid noise but broad enough to catch # the obvious mentions. Over-triggering is fine — a human reviews. # -# Sourced from RN SDK 3.1.0 master (public exports vs NativeRNIterableAPI +# Sourced from RN SDK 3.2.1 (public exports vs NativeRNIterableAPI # spec, README native-version table, CHANGELOG init-promise fix, example/). # Ids 7–11 and 13–14 are Expo config-plugin traps. Id 12 is Xcode 27 iOS -# (bare and Expo). Apply Expo ids only after SKILL.md Step 0 classifies Expo. +# (bare and Expo). 19–20 are the iOS Release Simulator build, 21 JavaScript, +# 22 bare iOS permission. Apply Expo ids only after SKILL.md Step 0 classifies Expo. platform: react-native @@ -148,3 +149,63 @@ pitfalls: - "@react-native-firebase/messaging" - "react-native-onesignal" features: [push-notifications, integration] + + - id: 16 + title: Link bounces between the SDK and the app + triggers: + - "urlHandler" + - "handleAppLink" + - "Linking.openURL" + - "Linking.addEventListener" + features: [deep-links, integration] + + - id: 17 + title: "Expo: the plugin does not configure deep links" + triggers: + - "associatedDomains" + - "intentFilters" + - "applinks:" + features: [deep-links, integration] + + - id: 18 + title: npm install returns 401 for @iterable packages + triggers: + - "E401" + - "npm.pkg.github.com" + - "@iterable:registry" + - ".npmrc" + features: [integration] + + - id: 19 + title: "iOS: a stale .app installed for proof" + triggers: + - "derivedDataPath" + - "DerivedData" + - "Release-iphonesimulator" + - "simctl install" + features: [push-notifications, integration] + + - id: 20 + title: "iOS: code signing disabled strips aps-environment" + triggers: + - "CODE_SIGNING_ALLOWED=NO" + - "CODE_SIGNING_REQUIRED=NO" + - "CODE_SIGN_IDENTITY=\"\"" + - "CODE_SIGN_ENTITLEMENTS" + - "aps-environment" + features: [push-notifications, integration] + + - id: 21 + title: Iterable.registerForPush() does not exist + triggers: + - "registerForPush" + - "autoPushRegistration" + features: [push-notifications, integration] + + - id: 22 + title: "Bare iOS: nothing asks for notification permission" + triggers: + - "requestAuthorization" + - "UNUserNotificationCenter" + - "react-native-permissions" + features: [push-notifications, integration] diff --git a/pipeline/config/react-native.yml b/pipeline/config/react-native.yml index 6fae716..8ded8ed 100644 --- a/pipeline/config/react-native.yml +++ b/pipeline/config/react-native.yml @@ -11,7 +11,12 @@ # # Expo is one article in this set, not a separate platform. The second # package for Expo projects (`@iterable/expo-plugin`) is recorded in -# SKILL.md / PITFALLS.md, not in `sdk.tag` (that pin is the RN SDK). +# SKILL.md / PITFALLS.md, not in `sdk.tag` (that pin is the RN SDK). It stays +# at 1.1.0: npm's 1.2.0 is deprecated ("Published accidentally; use 1.1.0 +# instead") and built from the same commit. +# +# 3.2.1, not 3.2.0: 3.2.0's Android module did not compile against its own +# native pin (CHANGELOG 3.2.1). platform: react-native @@ -22,7 +27,7 @@ source: sdk: repo: Iterable/react-native-sdk - tag: 3.1.0 + tag: 3.2.1 artifact: "@iterable/react-native-sdk" changelog_path: CHANGELOG.md From d6e5f772839133dc6ff2683f637c9552f4625315 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 20 Oct 2026 15:00:18 +0100 Subject: [PATCH 03/17] Prove iOS push for React Native and Expo apps bin/ios-project.sh answers where the app is, where its Xcode project is, and which kind of app it is: native, react-native, or expo. Every iOS actor resolves through it. Source scans prune node_modules, Pods and build output, so the SDK's own sources no longer pass for the app's integration, and inspect reads React Native signals where they live. The proof artifact for React Native is a Release build: a Debug Simulator build has no main.jsbundle and runs only while Metro serves it. Before giving any push advice, I6 and ios-prove now check the app actually installed on the Simulator. It must contain the Iterable SDK (and, for React Native, a bundle that references RNIterableAPI), carry aps-environment, match the recorded build, and not be older than the source. A live trial reinstalled a pre-integration build four times, and ios-prove's first answer sent it hunting for a permission problem. inspect warns when an Expo app never asks for notification permission. Co-Authored-By: Claude Opus 5.5 --- bin/README.md | 3 +- bin/ios-app-check.sh | 160 ++++++++++++++++++ bin/ios-deliver | 3 +- bin/ios-device | 3 +- bin/ios-install | 3 +- bin/ios-launch | 3 +- bin/ios-observe | 3 +- bin/ios-project.sh | 146 +++++++++++++++++ bin/ios-prove | 12 +- bin/ios-send | 3 +- bin/ios-verify | 223 +++++++++++++++++-------- iterable-ios-verify/SKILL.md | 20 +++ tests/ios-project.sh | 281 ++++++++++++++++++++++++++++++++ tests/ios-prove.sh | 96 ++++++++++- tests/ios-verify-transitions.sh | 24 ++- tests/ios-verify.sh | 128 +++++++++++++++ 16 files changed, 1033 insertions(+), 78 deletions(-) create mode 100644 bin/ios-app-check.sh create mode 100644 bin/ios-project.sh create mode 100755 tests/ios-project.sh diff --git a/bin/README.md b/bin/README.md index 38e902d..c79b5d6 100644 --- a/bin/README.md +++ b/bin/README.md @@ -109,7 +109,8 @@ evidence differ from Firebase and Android device evidence. Verification starts with `ios-verify status`, even in a clean conversation; with no proof run it returns the one-answer proof screen. `ios-verify inspect` reads the project without interpreting a missing `.iterable/ios/` workspace as a -missing SDK integration. Behavioral proof needs no app edits: existing +missing SDK integration; for React Native and Expo it reads `package.json`, +the JavaScript sources, and the native hooks in `ios/`, never `node_modules`. Behavioral proof needs no app edits: existing identity, selected device, installed app, one targeted marker, and, on a Simulator, APNs delivery read from the Simulator's own records by `ios-prove` and re-derived by `ios-verify`. A standalone run diff --git a/bin/ios-app-check.sh b/bin/ios-app-check.sh new file mode 100644 index 0000000..f1abac3 --- /dev/null +++ b/bin/ios-app-check.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Integrity reads of an installed or recorded .app: does it carry the Iterable +# SDK and the push entitlement, is it the recorded build, and was it built +# after the latest source edit. Needs ios-project.sh and ios-simulator.sh. + +# Prints the evidence of the native SDK, or fails. The SDK loads its Core Data +# model from its resource bundle, so every build that links it ships one. +ios_app_sdk_signal() { + local app="$1" candidate + for candidate in IterableSDKResources.bundle IterableSDK_IterableSDK.bundle \ + Frameworks/IterableSDK.framework; do + [[ ! -d "$app/$candidate" ]] || { printf '%s' "$candidate"; return 0; } + done + return 1 +} + +# The JavaScript package reaches native code only through this TurboModule +# name, so it survives minification and Hermes compilation. +ios_app_js_has_sdk() { + LC_ALL=C grep -aFq RNIterableAPI "$1/main.jsbundle" 2>/dev/null +} + +ios_app_executable() { + local name + name="$(plutil -extract CFBundleExecutable raw -o - "$1/Info.plist" 2>/dev/null)" && + [[ -n "$name" && -f "$1/$name" ]] || return 1 + printf '%s' "$1/$name" +} + +ios_app_simulator_aps_environment() { + local executable entitlements + executable="$(ios_app_executable "$1")" || return 1 + entitlements="$(ios_simulator_executable_entitlements "$executable")" || return 1 + printf '%s' "$entitlements" | plutil -extract aps-environment raw -o - - 2>/dev/null +} + +ios_app_time() { + date -u -r "$1" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || printf '%s' "$1" +} + +# The newest native binary in the app. An edit to an extension's or embedded +# framework's source relinks only that binary, not the main executable. +ios_app_newest_binary() { + local app="$1" main + main="$(ios_app_executable "$app")" || return 1 + { printf '%s\0' "$main" + find "$app/PlugIns" "$app/Frameworks" -mindepth 2 -maxdepth 2 -type f -perm -100 -print0 2>/dev/null + } | xargs -0 stat -f '%m%t%N' 2>/dev/null | sort -n | tail -1 | cut -f2- +} + +# Prints "\t" for the newest source newer than the reference +# file, or nothing. +ios_app_newer_source() { + local root="$1" reference="$2" kind="$3" scan_root + if [[ "$kind" == js ]]; then + ios_project_find_js "$root" \ + \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' \) -newer "$reference" + else + scan_root="$(ios_project_ios_dir "$root")" + [[ -z "$scan_root" ]] || + ios_project_find_native "$scan_root" \ + \( -name '*.swift' -o -name '*.m' -o -name '*.mm' -o -name '*.c' -o -name '*.cc' -o -name '*.cpp' \) \ + -newer "$reference" + fi | xargs -0 stat -f '%m%t%N' 2>/dev/null | sort -n | tail -1 +} + +ios_app_rebuild_fix() { + local root="$1" bin="$2" + printf 'Build Release with signing on (never CODE_SIGNING_ALLOWED=NO, which drops the aps-environment entitlement push needs): %s. Then record that exact .app with %s/ios-prepare-proof restart --confirm --app-path and install it with %s/ios-install.' \ + "$(ios_project_release_build_hint "$root" iphonesimulator)" "$bin" "$bin" +} + +# ios_app_installed_integrity +# +# On failure prints "\t" and returns 1; on success prints +# the evidence. +ios_app_installed_integrity() { + local app="$1" root="$2" framework="$3" environment="$4" recorded="$5" + local check_aps="$6" bin="$7" fix sdk aps="" kind built newer + local installed_executable recorded_executable + fix="$(ios_app_rebuild_fix "$root" "$bin")" + if [[ -n "$recorded" ]]; then + installed_executable="$(ios_app_executable "$app")" || installed_executable="" + recorded_executable="$(ios_app_executable "$recorded")" || { + printf 'installed_app_differs_from_recorded_build\tThe recorded build %s has no readable executable, so the installed app cannot be tied to it. %s\n' \ + "$recorded" "$fix" + return 1 + } + if [[ -z "$installed_executable" || + "${installed_executable##*/}" != "${recorded_executable##*/}" || + "$(shasum -a 256 < "$installed_executable")" != "$(shasum -a 256 < "$recorded_executable")" ]] || + { [[ "$framework" != native ]] && + [[ "$(shasum -a 256 < "$app/main.jsbundle" 2>/dev/null)" != "$(shasum -a 256 < "$recorded/main.jsbundle" 2>/dev/null)" ]]; }; then + printf 'installed_app_differs_from_recorded_build\tThe app installed on the Simulator is not byte-identical to the recorded build %s. Install the recorded build with %s/ios-install; if that build is not current either: %s\n' \ + "$recorded" "$bin" "$fix" + return 1 + fi + fi + sdk="$(ios_app_sdk_signal "$app")" || { + printf 'installed_app_without_sdk\tThe app installed on the Simulator has no Iterable iOS SDK (no IterableSDKResources.bundle, IterableSDK_IterableSDK.bundle, or Frameworks/IterableSDK.framework), so it is a build from before the integration. %s\n' \ + "$fix" + return 1 + } + if [[ "$framework" != native && ! -f "$app/main.jsbundle" ]]; then + printf 'installed_app_without_sdk\tThe installed app has no embedded main.jsbundle, so it is a Debug build that runs only while Metro serves its JavaScript. %s\n' \ + "$fix" + return 1 + fi + if [[ "$framework" != native ]] && ! ios_app_js_has_sdk "$app"; then + printf 'installed_app_without_sdk\tThe installed app'"'"'s main.jsbundle never references RNIterableAPI, so its JavaScript was bundled without @iterable/react-native-sdk; it is a build from before the integration. %s\n' \ + "$fix" + return 1 + fi + if [[ "$check_aps" == true ]]; then + aps="$(ios_app_simulator_aps_environment "$app")" || { + printf 'installed_app_missing_aps_environment\tThe app installed on the Simulator has no aps-environment entitlement, so iOS cannot register it for push; a build made with CODE_SIGNING_ALLOWED=NO or without the Push Notifications capability lacks it. %s\n' \ + "$fix" + return 1 + } + [[ "$aps" == "$environment" ]] || { + printf 'installed_app_missing_aps_environment\tThe app installed on the Simulator uses aps-environment=%s, not %s. %s\n' \ + "$aps" "$environment" "$fix" + return 1 + } + fi + for kind in native js; do + if [[ "$kind" == native ]]; then + built="$(ios_app_newest_binary "$app")" + else + [[ "$framework" != native ]] || continue + built="$app/main.jsbundle" + fi + [[ -n "$built" && -f "$built" ]] || continue + newer="$(ios_app_newer_source "$root" "$built" "$kind")" + [[ -n "$newer" ]] || continue + printf 'installed_app_older_than_source\tThe installed app is older than its source: %s was built at %s, but %s changed at %s. %s\n' \ + "${built#"$app"/}" "$(ios_app_time "$(stat -f '%m' "$built")")" \ + "${newer#*$'\t'}" "$(ios_app_time "${newer%%$'\t'*}")" "$fix" + return 1 + done + printf 'Iterable SDK via %s' "$sdk" + [[ "$framework" == native ]] || printf ' and RNIterableAPI in main.jsbundle' + [[ "$check_aps" != true ]] || printf '; aps-environment=%s' "$aps" + printf '; built after the latest source edit\n' +} + +# The same SDK read for a recorded artifact, before it is installed. Prints +# the reason and succeeds when the SDK is missing. +ios_app_artifact_without_sdk() { + local app="$1" root="$2" framework="$3" sdk="$4" bin="$5" fix + printf -v fix 'rebuild it with signing on (never CODE_SIGNING_ALLOWED=NO): %s, then record it with %s/ios-prepare-proof restart --confirm --app-path ' \ + "$(ios_project_release_build_hint "$root" "$sdk")" "$bin" + if ! ios_app_sdk_signal "$app" >/dev/null; then + printf 'the recorded .app has no Iterable iOS SDK (no IterableSDKResources.bundle, IterableSDK_IterableSDK.bundle, or Frameworks/IterableSDK.framework), so it was built before the integration; %s\n' "$fix" + elif [[ "$framework" != native ]] && ! ios_app_js_has_sdk "$app"; then + printf 'the recorded .app'"'"'s main.jsbundle never references RNIterableAPI, so its JavaScript was bundled without @iterable/react-native-sdk; %s\n' "$fix" + else + return 1 + fi +} diff --git a/bin/ios-deliver b/bin/ios-deliver index 006c078..45fd5e4 100755 --- a/bin/ios-deliver +++ b/bin/ios-deliver @@ -9,6 +9,7 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" usage() { cat <<'EOF' @@ -97,7 +98,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being proved, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" diff --git a/bin/ios-device b/bin/ios-device index bdbf5e1..6d7f5e2 100755 --- a/bin/ios-device +++ b/bin/ios-device @@ -5,6 +5,7 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" usage() { cat <<'EOF' @@ -54,7 +55,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being proved, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" diff --git a/bin/ios-install b/bin/ios-install index 988b5ab..e961efe 100755 --- a/bin/ios-install +++ b/bin/ios-install @@ -5,6 +5,7 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" usage() { cat <<'EOF' @@ -50,7 +51,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being proved, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" diff --git a/bin/ios-launch b/bin/ios-launch index 041bb91..eacd0cd 100755 --- a/bin/ios-launch +++ b/bin/ios-launch @@ -9,6 +9,7 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" usage() { cat <<'EOF' @@ -67,7 +68,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being proved, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" diff --git a/bin/ios-observe b/bin/ios-observe index 0e96ff9..2f60553 100755 --- a/bin/ios-observe +++ b/bin/ios-observe @@ -11,6 +11,7 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" source "$BIN/ios-simulator.sh" AGENT_DEVICE="${IOS_AGENT_DEVICE:-$TOOL_ROOT/tools/agent-device}" @@ -192,7 +193,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being verified, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" diff --git a/bin/ios-project.sh b/bin/ios-project.sh new file mode 100644 index 0000000..30a7d63 --- /dev/null +++ b/bin/ios-project.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Shared answer to "where is the app, where is its Xcode project, and what kind +# of app is it" for every iOS actor. A native app keeps its Xcode project at the +# root; React Native keeps it in ios/; Expo may have no ios/ at all until +# prebuild. The verification workspace always lives at /.iterable/ios. + +ios_project_looks_like_ios() { + local candidate="$1" + [[ -n "$candidate" && -d "$candidate" ]] || return 1 + [[ -f "$candidate/project.yml" ]] || + compgen -G "$candidate/*.xcodeproj/project.pbxproj" >/dev/null || + compgen -G "$candidate/*.xcworkspace" >/dev/null +} + +# Prints react-native, expo, or nothing for a directory's package.json/app.json. +ios_project_js_kind() { + local dir="$1" + [[ -f "$dir/package.json" || -f "$dir/app.json" ]] || return 0 + node -e ' + const fs=require("fs"), path=require("path"), dir=process.argv[1]; + const read=file=>{ try { return JSON.parse(fs.readFileSync(path.join(dir,file),"utf8")) } catch { return null } }; + const pkg=read("package.json")||{}, app=read("app.json"); + const deps={...pkg.dependencies,...pkg.devDependencies}; + if ("expo" in deps || (app && typeof app.expo==="object")) process.stdout.write("expo"); + else if ("react-native" in deps || "@iterable/react-native-sdk" in deps) process.stdout.write("react-native"); + ' "$dir" 2>/dev/null +} + +# The app root: walking up from the start directory (no further than the git +# toplevel), the nearest React Native/Expo root, or the nearest iOS project +# that is not the ios/ folder of one. +ios_project_app_root() { + local start="${1:-$PWD}" stop dir parent + start="$(cd "$start" 2>/dev/null && pwd -P)" || return 1 + stop="$(cd "$start" && git rev-parse --show-toplevel 2>/dev/null)" || stop="/" + stop="$(cd "$stop" 2>/dev/null && pwd -P)" || stop="/" + dir="$start" + while :; do + if [[ -n "$(ios_project_js_kind "$dir")" ]]; then + printf '%s' "$dir" + return 0 + fi + if ios_project_looks_like_ios "$dir"; then + parent="$(dirname "$dir")" + if [[ "$(basename "$dir")" == ios && -n "$(ios_project_js_kind "$parent")" ]]; then + printf '%s' "$parent" + else + printf '%s' "$dir" + fi + return 0 + fi + [[ "$dir" != "$stop" && "$dir" != / ]] || break + dir="$(dirname "$dir")" + done + [[ "$stop" != / ]] && printf '%s' "$stop" || printf '%s' "$start" +} + +# The directory holding the Xcode or XcodeGen project: the app root itself, or +# its ios/ folder. Empty for an Expo app that has not been prebuilt. +ios_project_ios_dir() { + local root="$1" + if ios_project_looks_like_ios "$root"; then + printf '%s' "$root" + elif ios_project_looks_like_ios "$root/ios"; then + printf '%s' "$root/ios" + fi +} + +ios_project_framework() { + local kind + kind="$(ios_project_js_kind "$1")" + printf '%s' "${kind:-native}" +} + +# Expo's ios/ is regenerated and often gitignored, so app.json is the source of +# its Bundle ID. +ios_project_expo_bundle_id() { + local root="$1" + [[ -f "$root/app.json" ]] || return 0 + node -e ' + let v; try { v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")) } catch { process.exit(0) } + const id=String(v?.expo?.ios?.bundleIdentifier||""); + if (/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(id)) process.stdout.write(id); + ' "$root/app.json" 2>/dev/null +} + +ios_project_json() { + local root ios_dir framework bundle + root="$(ios_project_app_root "${1:-$PWD}")" || return 1 + ios_dir="$(ios_project_ios_dir "$root")" + framework="$(ios_project_framework "$root")" + bundle="" + [[ "$framework" != expo ]] || bundle="$(ios_project_expo_bundle_id "$root")" + node -e ' + const [root,iosDir,framework,bundle]=process.argv.slice(1); + process.stdout.write(JSON.stringify({app_root:root,ios_dir:iosDir||null,framework, + expo_bundle_id:bundle||null,workspace:`${root}/.iterable/ios`})); + ' "$root" "$ios_dir" "$framework" "$bundle" +} + +# A Debug React Native build for the Simulator skips embedding main.jsbundle +# and needs Metro; a Release build runs on its own. +ios_project_release_build_hint() { + local root="$1" sdk="${2:-iphonesimulator}" ios_dir container flag name + ios_dir="$(ios_project_ios_dir "$root")" + if [[ -z "$ios_dir" ]]; then + printf 'cd %q && npx expo prebuild --platform ios, then xcodebuild -workspace ios/.xcworkspace -scheme -configuration Release -sdk %s -derivedDataPath ios/build build' \ + "$root" "$sdk" + return + fi + container="$(compgen -G "$ios_dir/*.xcworkspace" | head -1)" + flag=-workspace + if [[ -z "$container" ]]; then + container="$(compgen -G "$ios_dir/*.xcodeproj" | head -1)" + flag=-project + fi + name="$(basename "${container%.*}")" + printf 'cd %q && xcodebuild %s %q -scheme %q -configuration Release -sdk %s -derivedDataPath build build (the app is %s/build/Build/Products/Release-%s/%s.app)' \ + "$ios_dir" "$flag" "$(basename "$container")" "$name" "$sdk" "$ios_dir" "$sdk" "$name" +} + +# Source walks skip dependencies, build output, tests, and the verification +# workspace; inspect and the installed-app freshness check share them. The +# arguments are find predicates for the files wanted. +ios_project_find_native() { + local root="$1" + shift + find "$root" \ + \( -path '*/.git' -o -path '*/.derived-data' -o -path '*/DerivedData' \ + -o -path '*/Pods' -o -path '*/.build' -o -path '*/node_modules' -o -path '*/build' \ + -o -path '*/.iterable' \ + -o -path '*/*Tests' -o -path '*/*Tests/*' \ + -o -path '*/*UITests' -o -path '*/*UITests/*' \) -prune -o \ + \( "$@" \) -type f -print0 2>/dev/null +} + +ios_project_find_js() { + local root="$1" + shift + find "$root" \ + \( -path "$root/ios" -o -path "$root/android" -o -path '*/node_modules' \ + -o -path '*/.git' -o -path '*/.expo' -o -path '*/build' -o -path '*/dist' \ + -o -path '*/.iterable' \ + -o -path '*/coverage' -o -path '*/__tests__' -o -path '*/__mocks__' -o -path '*/e2e' \) -prune -o \ + \( "$@" \) ! -name '*.test.*' ! -name '*.spec.*' -type f -print0 2>/dev/null +} diff --git a/bin/ios-prove b/bin/ios-prove index 2d5db3c..1ebca00 100755 --- a/bin/ios-prove +++ b/bin/ios-prove @@ -11,7 +11,9 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" source "$BIN/ios-simulator.sh" +source "$BIN/ios-app-check.sh" READER="$BIN/ios-notify-read.js" usage() { @@ -97,7 +99,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being proved, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" @@ -473,9 +475,15 @@ ios_simulator_host_supports_apns || ios_simulator_device_supports_apns "$simulators" "$DEVICE_ID" || pending_without_send simulator_not_ready \ "The selected Simulator must be booted, available, and on an iOS 16+ runtime. Boot it, then run ios-prove again; nothing was sent." -xcrun simctl get_app_container "$DEVICE_ID" "$BUNDLE_ID" app >/dev/null 2>&1 || +INSTALLED_APP="$(xcrun simctl get_app_container "$DEVICE_ID" "$BUNDLE_ID" app 2>/dev/null)" || pending_without_send app_not_installed \ "$BUNDLE_ID is not installed on the selected Simulator. Build and install it, then run ios-prove again; nothing was sent." +# A stale or unsigned build fails every notification check below for a reason +# no permission fix can reach. +APP_ROOT="$(dirname "$(dirname "$WORKSPACE")")" +INTEGRITY="$(ios_app_installed_integrity "$INSTALLED_APP" "$APP_ROOT" \ + "$(ios_project_framework "$APP_ROOT")" "$ENVIRONMENT" "$(read_target app_path)" true "$BIN")" || + pending_without_send "${INTEGRITY%%$'\t'*}" "${INTEGRITY#*$'\t'} Nothing was sent." STORE_BEFORE="$(read_store)" [[ "$(json_field "$STORE_BEFORE" mapped)" == true ]] || diff --git a/bin/ios-send b/bin/ios-send index a2236e9..1559445 100755 --- a/bin/ios-send +++ b/bin/ios-send @@ -10,6 +10,7 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" +source "$BIN/ios-project.sh" usage() { cat <<'EOF' @@ -86,7 +87,7 @@ if [[ -n "$WORKSPACE_OVERRIDE" ]]; then elif [[ -n "${IOS_WORKSPACE:-}" ]]; then WORKSPACE="$IOS_WORKSPACE" else - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" [[ "$project_root" != "$TOOL_ROOT" ]] || die "run this from the iOS app being proved, not from the skill repository" WORKSPACE="$project_root/.iterable/ios" diff --git a/bin/ios-verify b/bin/ios-verify index 3062527..dbcc307 100755 --- a/bin/ios-verify +++ b/bin/ios-verify @@ -13,6 +13,8 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" source "$BIN/ios-simulator.sh" +source "$BIN/ios-project.sh" +source "$BIN/ios-app-check.sh" usage() { cat <<'EOF' @@ -44,10 +46,11 @@ file_mode() { } looks_like_ios_project() { - local candidate="$1" - [[ -f "$candidate/project.yml" ]] || - compgen -G "$candidate/*.xcodeproj/project.pbxproj" >/dev/null || - compgen -G "$candidate/*.xcworkspace" >/dev/null + ios_project_looks_like_ios "$1" +} + +looks_like_app_root() { + ios_project_looks_like_ios "$1" || [[ -n "$(ios_project_js_kind "$1")" ]] } resolve_workspace() { @@ -56,9 +59,9 @@ resolve_workspace() { return fi local project_root - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" if [[ "$project_root" == "$TOOL_ROOT" ]]; then - if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_ios_project "$PWD"; then + if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_app_root "$PWD"; then project_root="$PWD" else return 30 @@ -100,62 +103,117 @@ done [[ -z "$WORKSPACE_OVERRIDE" ]] || IOS_WORKSPACE="$WORKSPACE_OVERRIDE" inspect_project() { - local project_root files dependency=false initialization=false identity=false - local push=false runtime_proof=false workspace_exists=false next_kind - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + local project_root dependency=false initialization=false identity=false + local push=false runtime_proof=false workspace_exists=false next_kind scan_root framework + local native_files="$TMP/inspect-native" js_files="$TMP/inspect-js" summary warnings='[]' + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" if [[ "$project_root" == "$TOOL_ROOT" ]]; then - if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_ios_project "$PWD"; then + if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_app_root "$PWD"; then project_root="$PWD" else die "run ios-verify inspect from the iOS app, not from the skill repository" fi fi - looks_like_ios_project "$project_root" || - die "the current directory does not look like an iOS project" - files="$( - find "$project_root" \ - \( -path '*/.git' -o -path '*/.derived-data' -o -path '*/DerivedData' \ - -o -path '*/Pods' -o -path '*/.build' \ - -o -path '*/*Tests' -o -path '*/*Tests/*' \ - -o -path '*/*UITests' -o -path '*/*UITests/*' \) -prune -o \ - \( -name '*.swift' -o -name 'project.yml' -o -name 'project.pbxproj' \ - -o -name 'Package.swift' -o -name 'Package.resolved' \ - -o -name '*.entitlements' \) \ - -type f -print 2>/dev/null - )" - if [[ -n "$files" ]]; then - printf '%s\n' "$files" | while IFS= read -r file; do - [[ -f "$file" ]] && printf '%s\0' "$file" - done > "$TMP/inspect-files" - xargs -0 grep -E -l 'IterableSDK|iterable-swift-sdk' \ - < "$TMP/inspect-files" >/dev/null 2>&1 && dependency=true - xargs -0 grep -E -l '^[[:space:]]*(_[[:space:]]*=[[:space:]]*|let[[:space:]]+[A-Za-z0-9_]+[[:space:]]*=[[:space:]]*)?IterableAPI[.]initialize' \ - < "$TMP/inspect-files" >/dev/null 2>&1 && initialization=true - xargs -0 grep -E -l '^[[:space:]]*(IterableAPI[.](email|userId)[[:space:]]*=|IterableAPI[.](setEmail|setUserId)|setEmail[[:space:]]*[(]|setUserId[[:space:]]*[(])' \ - < "$TMP/inspect-files" >/dev/null 2>&1 && identity=true - xargs -0 grep -E -l '^[[:space:]]*([^/].*)?(registerForRemoteNotifications|didRegisterForRemoteNotifications|IterableAPI[.]register)|aps-environment' \ - < "$TMP/inspect-files" >/dev/null 2>&1 && push=true - xargs -0 grep -E -l 'IterableRuntimeProof|ITERABLE_RUNTIME_PROOF|iterable-ios-runtime-proof/1' \ - < "$TMP/inspect-files" >/dev/null 2>&1 && runtime_proof=true + looks_like_app_root "$project_root" || + die "the current directory does not look like an iOS, React Native, or Expo app" + framework="$(ios_project_framework "$project_root")" + scan_root="$(ios_project_ios_dir "$project_root")" + [[ -n "$scan_root" || "$framework" != native ]] || scan_root="$project_root" + : > "$native_files" + : > "$js_files" + [[ -z "$scan_root" ]] || + ios_project_find_native "$scan_root" \ + -name '*.swift' -o -name '*.m' -o -name '*.mm' -o -name 'project.yml' -o -name 'project.pbxproj' \ + -o -name 'Package.swift' -o -name 'Package.resolved' \ + -o -name '*.entitlements' > "$native_files" + if [[ "$framework" == native ]]; then + if [[ -s "$native_files" ]]; then + xargs -0 grep -E -l 'IterableSDK|iterable-swift-sdk' \ + < "$native_files" >/dev/null 2>&1 && dependency=true + xargs -0 grep -E -l '^[[:space:]]*(_[[:space:]]*=[[:space:]]*|let[[:space:]]+[A-Za-z0-9_]+[[:space:]]*=[[:space:]]*)?IterableAPI[.]initialize' \ + < "$native_files" >/dev/null 2>&1 && initialization=true + xargs -0 grep -E -l '^[[:space:]]*(IterableAPI[.](email|userId)[[:space:]]*=|IterableAPI[.](setEmail|setUserId)|setEmail[[:space:]]*[(]|setUserId[[:space:]]*[(])' \ + < "$native_files" >/dev/null 2>&1 && identity=true + xargs -0 grep -E -l '^[[:space:]]*([^/[:space:]].*)?(registerForRemoteNotifications|didRegisterForRemoteNotifications|IterableAPI[.]register)|aps-environment' \ + < "$native_files" >/dev/null 2>&1 && push=true + fi + else + ios_project_find_js "$project_root" \ + -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' > "$js_files" + node -e ' + const pkg=(()=>{ try { return JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")) } catch { return {} } })(); + process.exit("@iterable/react-native-sdk" in {...pkg.dependencies,...pkg.devDependencies} ? 0 : 1); + ' "$project_root/package.json" && dependency=true + if [[ -s "$js_files" ]]; then + xargs -0 grep -E -l '^[[:space:]]*([^/*[:space:]].*)?Iterable[.]initialize[(]' \ + < "$js_files" >/dev/null 2>&1 && initialization=true + # React state setters are often named setEmail too, so the Iterable receiver is required. + xargs -0 grep -E -l '^[[:space:]]*([^/*[:space:]].*)?Iterable[.](setEmail|setUserId)[[:space:]]*[(]' \ + < "$js_files" >/dev/null 2>&1 && identity=true + xargs -0 grep -E -l '^[[:space:]]*([^/*[:space:]].*)?Iterable[.]registerDeviceToken[(]' \ + < "$js_files" >/dev/null 2>&1 && push=true + fi + if [[ -s "$native_files" ]] && + xargs -0 grep -E -l '^[[:space:]]*([^/*[:space:]].*)?(IterableAPI[.]register[(]token:|IterableAPI[[:space:]]+registerToken:|IterableAppIntegration)' \ + < "$native_files" >/dev/null 2>&1; then + push=true + fi + if [[ "$framework" == expo ]] && node -e ' + const fs=require("fs"), path=require("path"), root=process.argv[1]; + let app; try { app=JSON.parse(fs.readFileSync(path.join(root,"app.json"),"utf8")) } catch {} + const entry=(app?.expo?.plugins||[]).map(p=>Array.isArray(p)?p:[p]).find(([name])=>name==="@iterable/expo-plugin"); + if (entry) process.exit(entry[1]?.autoConfigurePushNotifications===false ? 1 : 0); + const dynamic=["app.config.js","app.config.ts"].some(f=>{ + try { return fs.readFileSync(path.join(root,f),"utf8").includes("@iterable/expo-plugin") } catch { return false } + }); + process.exit(dynamic ? 0 : 1); + ' "$project_root"; then + push=true + fi + if [[ "$framework" == expo ]] && node -e ' + const fs=require("fs"), path=require("path"), root=process.argv[1]; + let app; try { app=JSON.parse(fs.readFileSync(path.join(root,"app.json"),"utf8")) } catch {} + const entry=(app?.expo?.plugins||[]).map(p=>Array.isArray(p)?p:[p]).find(([name])=>name==="@iterable/expo-plugin"); + if (entry) process.exit(entry[1]?.requestPermissionsForPushNotifications===true ? 1 : 0); + const dynamic=["app.config.js","app.config.ts"].map(f=>{ + try { return fs.readFileSync(path.join(root,f),"utf8") } catch { return "" } + }).find(text=>text.includes("@iterable/expo-plugin")); + process.exit(dynamic!==undefined && !/requestPermissionsForPushNotifications\s*:\s*true/.test(dynamic) ? 0 : 1); + ' "$project_root" && ! { [[ -s "$js_files" ]] && + xargs -0 grep -E -l '^[[:space:]]*([^/*[:space:]].*)?(requestPermissions|requestPermissionsAsync)[[:space:]]*[(]' \ + < "$js_files" >/dev/null 2>&1; }; then + warnings="$(node -e ' + process.stdout.write(JSON.stringify([{kind:"expo_push_permission_not_requested", + pitfall:"iterable-react-native/PITFALLS.md#14", + summary:"@iterable/expo-plugin does not ask for notification permission unless requestPermissionsForPushNotifications is true, and no requestPermissions or requestPermissionsAsync call was found in the app sources, so iOS never prompts and pushes arrive without a banner. Set the plugin option to true or request permission in the app (React Native pitfall 14)."}])); + ')" + fi + fi + if [[ -s "$native_files" ]] && + xargs -0 grep -E -l 'IterableRuntimeProof|ITERABLE_RUNTIME_PROOF|iterable-ios-runtime-proof/1' \ + < "$native_files" >/dev/null 2>&1; then + runtime_proof=true fi [[ -d "$project_root/.iterable/ios" ]] && workspace_exists=true if [[ "$dependency" == true && "$initialization" == true ]]; then next_kind=prepare_proof + summary='The app source contains Iterable dependency and initialization signals. Prepare a fresh verification run; installed runtime evidence, not this heuristic scan, decides behavior.' + [[ "$framework" == native ]] || + summary="$summary The proof needs a Release Simulator build, which embeds its JavaScript and runs without Metro: $(ios_project_release_build_hint "$project_root")." else next_kind=review_integration + summary='The source scan could not find both Iterable dependency and initialization signals in non-test files. Review the selected application target before attempting proof.' fi printf '{' printf '"tool":"iterable-ios-inspect","contract":2,"read_only":true,"signals_are_heuristic":true,' - printf '"project_root":%s,"verification_workspace_exists":%s,' \ - "$(jstr "$project_root")" "$workspace_exists" - printf '"signals":{"sdk_dependency":%s,"sdk_initialization":%s,"identity_wiring":%s,"push_wiring":%s,"runtime_proof_source":%s},' \ - "$dependency" "$initialization" "$identity" "$push" "$runtime_proof" + printf '"project_root":%s,"framework":%s,"verification_workspace_exists":%s,' \ + "$(jstr "$project_root")" "$(jstr "$framework")" "$workspace_exists" + printf '"signals":{"sdk_dependency":%s,"sdk_initialization":%s,"identity_wiring":%s,"push_wiring":%s,"runtime_proof_source":%s},"warnings":%s,' \ + "$dependency" "$initialization" "$identity" "$push" "$runtime_proof" "$warnings" printf '"next":{"kind":%s,"command":%s,"summary":%s}' \ "$(jstr "$next_kind")" \ "$(jstr "$([[ "$next_kind" == prepare_proof ]] && printf '%s' "$BIN/ios-prepare-proof status")")" \ - "$(jstr "$([[ "$next_kind" == prepare_proof ]] && - printf '%s' 'The app source contains Iterable dependency and initialization signals. Prepare a fresh verification run; installed runtime evidence, not this heuristic scan, decides behavior.' || - printf '%s' 'The source scan could not find both Iterable dependency and initialization signals in non-test files. Review the selected application target before attempting proof.')")" + "$(jstr "$summary")" printf '}\n' } @@ -171,6 +229,9 @@ WORKSPACE="$(resolve_workspace)" || { exit 30 } +APP_ROOT="$(dirname "$(dirname "$WORKSPACE")")" +APP_FRAMEWORK="$(ios_project_framework "$APP_ROOT")" + SCOPE="$WORKSPACE/scope.json" ENV_FILE="$WORKSPACE/.env" PROOF_TARGET="$WORKSPACE/proof-target.json" @@ -1151,6 +1212,10 @@ artifact_contains_runtime_proof() { return 1 } +debug_js_build_detail() { + echo "$1 has no embedded main.jsbundle, so it is a Debug build that only runs while Metro serves its JavaScript; build Release instead: $(ios_project_release_build_hint "$APP_ROOT" "$2")" +} + g9() { [[ -n "$APP_PATH" ]] || { echo "no .app path was recorded for this push run" @@ -1173,6 +1238,16 @@ g9() { echo "the app artifact bundle ID is $artifact_bundle, not $BUNDLE_ID" return 1 } + if [[ "$APP_FRAMEWORK" != native && ! -f "$APP_PATH/main.jsbundle" ]]; then + local sdk=iphonesimulator + [[ "$RECORDED_DEVICE_KIND" != physical ]] || sdk=iphoneos + debug_js_build_detail "this $APP_FRAMEWORK app" "$sdk" + return 1 + fi + local artifact_sdk=iphonesimulator + [[ "$RECORDED_DEVICE_KIND" != physical ]] || artifact_sdk=iphoneos + ios_app_artifact_without_sdk "$APP_PATH" "$APP_ROOT" "$APP_FRAMEWORK" "$artifact_sdk" "$BIN" && + return 1 if [[ "$PUSH_REQUIRED" != true ]]; then echo "exact app artifact uses bundle $BUNDLE_ID" return 0 @@ -1237,7 +1312,7 @@ g9() { aps_environment="$(printf '%s' "$entitlements" | plutil -extract aps-environment raw -o - - 2>/dev/null)" || { - echo "the app has no aps-environment in its $entitlement_source" + echo "the app has no aps-environment in its $entitlement_source, so iOS cannot register it for push; rebuild it with signing on and the Push Notifications capability (never CODE_SIGNING_ALLOWED=NO, which drops the entitlement): $(ios_project_release_build_hint "$APP_ROOT" "$artifact_sdk"), then record it with $BIN/ios-prepare-proof restart --confirm --app-path " return 1 } [[ "$aps_environment" == "$ENVIRONMENT" ]] || { @@ -1252,6 +1327,19 @@ g9() { echo "artifact valid for $PROOF_MODE proof; bundle $BUNDLE_ID; aps-environment=$aps_environment from $entitlement_source; platform=$expected_platform" } +# Records the named diagnosis so the next action can tell a reinstall from a rebuild. +installed_app_integrity() { + local result + if result="$(ios_app_installed_integrity "$1" "$APP_ROOT" "$APP_FRAMEWORK" \ + "$ENVIRONMENT" "$APP_PATH" "$PUSH_REQUIRED" "$BIN")"; then + printf '%s' "$result" + return 0 + fi + printf '%s\n' "${result%%$'\t'*}" > "$TMP/I6.diagnosis" + printf '%s: %s\n' "${result%%$'\t'*}" "${result#*$'\t'}" + return 1 +} + g10() { [[ "$OFFLINE" != true ]] || { echo "installed-app read was skipped by --offline" @@ -1272,7 +1360,16 @@ g10() { echo "the selected Simulator returned a different installed bundle" return 1 } - echo "$BUNDLE_ID is already installed on the selected Simulator; standard proof does not require reinstalling it" + if [[ "$APP_FRAMEWORK" != native && ! -f "$installed_app/main.jsbundle" ]]; then + debug_js_build_detail "the installed $APP_FRAMEWORK app" iphonesimulator + return 1 + fi + local integrity + integrity="$(installed_app_integrity "$installed_app")" || { + echo "$integrity" + return 1 + } + echo "$BUNDLE_ID is already installed on the selected Simulator; standard proof does not require reinstalling it; $integrity" return 0 fi local installed_apps @@ -1314,7 +1411,6 @@ g10() { } if [[ "$DEVICE_KIND" == simulator ]]; then local installed_app installed_version installed_build - local local_executable installed_executable local_hash installed_hash installed_app="$(xcrun simctl get_app_container "$DEVICE_ID" "$BUNDLE_ID" app 2>/dev/null)" || { echo "$BUNDLE_ID is not installed on the selected Simulator" return 2 @@ -1325,27 +1421,16 @@ g10() { return 3 [[ "$installed_version" == "$local_version" && "$installed_build" == "$local_build" ]] || { - echo "the Simulator has $installed_version ($installed_build), not the recorded $local_version ($local_build)" - return 1 - } - local_executable="$(plist_value "$APP_PATH/Info.plist" CFBundleExecutable)" || - return 1 - installed_executable="$(plist_value "$installed_app/Info.plist" CFBundleExecutable)" || - return 1 - [[ "$installed_executable" == "$local_executable" && - -f "$APP_PATH/$local_executable" && - -f "$installed_app/$installed_executable" ]] || { - echo "the installed Simulator app executable does not match the recorded artifact" + printf 'installed_app_differs_from_recorded_build\n' > "$TMP/I6.diagnosis" + echo "installed_app_differs_from_recorded_build: the Simulator has $installed_version ($installed_build), not the recorded $local_version ($local_build); install the recorded build with $BIN/ios-install" return 1 } - local_hash="$(shasum -a 256 "$APP_PATH/$local_executable" | awk '{print $1}')" - installed_hash="$(shasum -a 256 "$installed_app/$installed_executable" | - awk '{print $1}')" - [[ "$installed_hash" == "$local_hash" ]] || { - echo "the Simulator has a different executable despite matching version/build" + local integrity + integrity="$(installed_app_integrity "$installed_app")" || { + echo "$integrity" return 1 } - echo "$BUNDLE_ID $local_version ($local_build) with the exact executable is installed on the selected Simulator" + echo "$BUNDLE_ID $local_version ($local_build) with the exact executable is installed on the selected Simulator; $integrity" return 0 fi local apps @@ -2423,7 +2508,11 @@ if [[ -n "$NEXT_ID" ]]; then NEXT_COMMAND="" ;; I6) - if [[ -n "$APP_PATH" ]]; then + I6_DIAGNOSIS="$(cat "$TMP/I6.diagnosis" 2>/dev/null)" + if [[ -n "$I6_DIAGNOSIS" && "$I6_DIAGNOSIS" != installed_app_differs_from_recorded_build ]]; then + NEXT_KIND=build_app + NEXT_COMMAND="" + elif [[ -n "$APP_PATH" ]]; then NEXT_KIND=install_app NEXT_COMMAND="$BIN/ios-install" else @@ -2789,8 +2878,8 @@ fi printf '{' printf '"tool":"iterable-ios-verify","contract":1,"rc":%d,"verdict":%s,' \ "$RC" "$(jstr "$VERDICT")" -printf '"workspace":%s,"proof_mode":%s,"verification_level":%s,"environment":%s,"apns_environment":%s,"device_kind":%s,"push_required":%s,"push_proven":%s,"production_follow_up_required":%s,' \ - "$(jstr "$WORKSPACE")" "$(jstr "$PROOF_MODE")" \ +printf '"workspace":%s,"framework":%s,"proof_mode":%s,"verification_level":%s,"environment":%s,"apns_environment":%s,"device_kind":%s,"push_required":%s,"push_proven":%s,"production_follow_up_required":%s,' \ + "$(jstr "$WORKSPACE")" "$(jstr "$APP_FRAMEWORK")" "$(jstr "$PROOF_MODE")" \ "$(jstr "$([[ "$PROOF_MODE" == behavioral ]] && printf behavioral || printf instrumented)")" \ "$(jstr "$ENVIRONMENT")" \ "$(jstr "$APNS_ENVIRONMENT")" "$(jstr "$RECORDED_DEVICE_KIND")" \ diff --git a/iterable-ios-verify/SKILL.md b/iterable-ios-verify/SKILL.md index 1751089..b015486 100644 --- a/iterable-ios-verify/SKILL.md +++ b/iterable-ios-verify/SKILL.md @@ -38,6 +38,26 @@ If the verifier says the workspace has no context at all, `ios-verify inspect` reads the project without needing `.iterable/ios/`; a missing verification workspace is not evidence that Iterable is missing from the app. +For React Native and Expo, the app root is the directory with `package.json` +(running from `ios/` resolves to it), and `inspect` and `status` report +`framework`. Prove a Release build: a Debug Simulator build does not embed +`main.jsbundle` and runs only while Metro serves it, so I5 (a recorded +artifact) or I6 (the installed app) refuses it and names the +`xcodebuild … -configuration Release` command to run instead. Relay any +`warnings` from `inspect`; they name the pitfall to read. + +I6 and `ios-prove` also read the app actually installed on the Simulator, +before any notification check. They refuse it as +`installed_app_without_sdk` (no Iterable SDK resource bundle or framework, +or, for React Native and Expo, no `RNIterableAPI` in `main.jsbundle`), +`installed_app_missing_aps_environment`, +`installed_app_differs_from_recorded_build` (not byte-identical to the +`--app-path` build), or `installed_app_older_than_source`. I5 applies the SDK +and entitlement reads to a recorded artifact before install. Each one names +the Release build command; build with signing on, never with +`CODE_SIGNING_ALLOWED=NO`, then pass that exact `.app` to +`ios-prepare-proof restart --confirm --app-path`. + Do not run these from the plugin directory. `ios-verify` never edits the project or workspace. `ios-prepare-proof` writes only ignored, mode-restricted proof metadata; it does not enter Apple, APNs, Iterable app diff --git a/tests/ios-project.sh b/tests/ios-project.sh new file mode 100755 index 0000000..0e613b3 --- /dev/null +++ b/tests/ios-project.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# The shared iOS project resolver against native, bare React Native, and Expo +# trees: app root, Xcode project directory, framework, and Bundle ID. + +set -uo pipefail +cd "$(dirname "$0")/.." || exit +REPO="$PWD" + +FAILED=0 +ok() { printf 'PASS %s\n' "$1"; } +bad() { printf 'FAIL %s\n' "$1"; [[ $# -lt 2 ]] || printf ' %s\n' "$2"; FAILED=1; } + +ROOT="$(mktemp -d "${TMPDIR:-/tmp}/iterable-ios-project.XXXXXX")" +case "$ROOT" in + "${TMPDIR:-/tmp}"/iterable-ios-project.*|/private/tmp/iterable-ios-project.*) ;; + *) echo "refusing unexpected temporary path: $ROOT" >&2; exit 1 ;; +esac +trap 'rm -rf "$ROOT"' EXIT +ROOT="$(cd "$ROOT" && pwd -P)" + +pbxproj() { + mkdir -p "$1" + printf '// !$*UTF8*$!\n{ buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = %s; }; }\n' "$2" > "$1/project.pbxproj" +} + +NATIVE="$ROOT/native" +pbxproj "$NATIVE/Native.xcodeproj" com.example.native +mkdir -p "$NATIVE/Native/Sources" +git -C "$NATIVE" init -q + +RN="$ROOT/rn" +mkdir -p "$RN/ios" "$RN/src" +printf '{"name":"rnapp","dependencies":{"react-native":"0.88.0","@iterable/react-native-sdk":"3.0.0"}}\n' > "$RN/package.json" +pbxproj "$RN/ios/RNApp.xcodeproj" com.example.rnapp +pbxproj "$RN/node_modules/@iterable/react-native-sdk/ios/RNIterableAPI.xcodeproj" com.iterable.decoy +mkdir -p "$RN/node_modules/@iterable/react-native-sdk/ios/RNIterableAPI" "$RN/node_modules/@iterable/react-native-sdk/src" +cat > "$RN/node_modules/@iterable/react-native-sdk/ios/RNIterableAPI/ReactIterableAPI.swift" <<'EOF' +import IterableSDK +IterableAPI.initialize(apiKey: key, launchOptions: nil, config: config) +IterableAPI.email = email +IterableAPI.register(token: token) +EOF +cat > "$RN/node_modules/@iterable/react-native-sdk/src/Iterable.ts" <<'EOF' +Iterable.initialize(apiKey, config); +Iterable.setEmail(email); +EOF +cat > "$RN/src/App.tsx" <<'EOF' +import { Iterable, IterableConfig } from '@iterable/react-native-sdk'; +export function start(key: string, email: string) { + Iterable.initialize(key, new IterableConfig()); + Iterable.setEmail(email); +} +EOF +mkdir -p "$RN/ios/RNApp" +cat > "$RN/ios/RNApp/AppDelegate.swift" <<'EOF' +import IterableSDK +func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + IterableAPI.register(token: deviceToken) +} +EOF +git -C "$RN" init -q + +DECOY="$ROOT/rn-decoy" +mkdir -p "$DECOY/src" "$DECOY/ios/Decoy" "$DECOY/node_modules/@iterable/react-native-sdk/src" \ + "$DECOY/node_modules/@iterable/react-native-sdk/ios" +printf '{"dependencies":{"react-native":"0.88.0","@iterable/react-native-sdk":"3.0.0"}}\n' > "$DECOY/package.json" +pbxproj "$DECOY/ios/Decoy.xcodeproj" com.example.decoy +cp "$RN/node_modules/@iterable/react-native-sdk/src/Iterable.ts" "$DECOY/node_modules/@iterable/react-native-sdk/src/" +cp "$RN/node_modules/@iterable/react-native-sdk/ios/RNIterableAPI/ReactIterableAPI.swift" \ + "$DECOY/node_modules/@iterable/react-native-sdk/ios/" +cat > "$DECOY/src/SignIn.tsx" <<'EOF' +const [email, setEmail] = useState(''); +setEmail(value); +setUserId(value); + // Iterable.initialize(key, config); + * Iterable.setUserId(id) +EOF +printf ' // IterableAPI.register(token: deviceToken)\n' > "$DECOY/ios/Decoy/AppDelegate.swift" +git -C "$DECOY" init -q + +OBJC="$ROOT/rn-objc" +mkdir -p "$OBJC/ios/ObjC" "$OBJC/ios/ObjC.xcworkspace" +printf '{"dependencies":{"react-native":"0.88.0","@iterable/react-native-sdk":"3.0.0"}}\n' > "$OBJC/package.json" +pbxproj "$OBJC/ios/ObjC.xcodeproj" com.example.objc +printf ' [IterableAPI registerToken:deviceToken];\n' > "$OBJC/ios/ObjC/AppDelegate.mm" +printf 'await Iterable.initialize(key, config);\n' > "$OBJC/index.js" +git -C "$OBJC" init -q + +EXPO="$ROOT/expo" +mkdir -p "$EXPO" +printf '{"name":"expoapp","dependencies":{"expo":"58.0.0","react-native":"0.88.0","@iterable/react-native-sdk":"3.0.0","@iterable/expo-plugin":"1.0.0"}}\n' > "$EXPO/package.json" +printf '{"expo":{"name":"ExpoApp","ios":{"bundleIdentifier":"com.example.expoapp"},"plugins":[["@iterable/expo-plugin",{}]]}}\n' > "$EXPO/app.json" +printf "import { Iterable } from '@iterable/react-native-sdk';\nIterable.initialize(key, config);\n" > "$EXPO/App.tsx" +git -C "$EXPO" init -q + +EXPO_OFF="$ROOT/expo-manual-push" +mkdir -p "$EXPO_OFF" +cp "$EXPO/package.json" "$EXPO/App.tsx" "$EXPO_OFF/" +printf '{"expo":{"ios":{"bundleIdentifier":"com.example.expooff"},"plugins":[["@iterable/expo-plugin",{"autoConfigurePushNotifications":false}]]}}\n' > "$EXPO_OFF/app.json" +git -C "$EXPO_OFF" init -q + +PLAIN="$ROOT/plain-rn" +mkdir -p "$PLAIN/ios" +printf '{"dependencies":{"react-native":"0.88.0"}}\n' > "$PLAIN/package.json" +pbxproj "$PLAIN/ios/Plain.xcodeproj" com.example.plain + +MONO="$ROOT/mono" +mkdir -p "$MONO/packages/mobile/ios" +printf '{"private":true,"workspaces":["packages/*"]}\n' > "$MONO/package.json" +printf '{"dependencies":{"react-native":"0.88.0"}}\n' > "$MONO/packages/mobile/package.json" +pbxproj "$MONO/packages/mobile/ios/Mobile.xcodeproj" com.example.mobile +git -C "$MONO" init -q + +resolve() { + (cd "$1" && bash -c 'source "$1/bin/ios-project.sh"; ios_project_json "$PWD"' _ "$REPO") +} +field() { + node -e 'const v=JSON.parse(process.argv[1]); const f=v[process.argv[2]]; process.stdout.write(f==null?"":String(f))' "$1" "$2" +} +expect() { + local name="$1" json="$2" root="$3" ios_dir="$4" framework="$5" bundle="${6:-}" + if [[ "$(field "$json" app_root)" == "$root" && "$(field "$json" ios_dir)" == "$ios_dir" && + "$(field "$json" framework)" == "$framework" && "$(field "$json" expo_bundle_id)" == "$bundle" && + "$(field "$json" workspace)" == "$root/.iterable/ios" ]]; then + ok "$name" + else + bad "$name" "$json" + fi +} + +expect "a native app resolves to its own root" "$(resolve "$NATIVE")" "$NATIVE" "$NATIVE" native +expect "a native app resolves from a source subdirectory" "$(resolve "$NATIVE/Native/Sources")" "$NATIVE" "$NATIVE" native +expect "a bare React Native app keeps its root and finds the project in ios/" "$(resolve "$RN")" "$RN" "$RN/ios" react-native +expect "starting inside ios/ resolves to the React Native root" "$(resolve "$RN/ios")" "$RN" "$RN/ios" react-native +expect "an Expo app with no ios/ uses app.json for its Bundle ID" "$(resolve "$EXPO")" "$EXPO" "" expo com.example.expoapp +expect "outside git, ios/ of a React Native app still resolves to its parent" "$(resolve "$PLAIN/ios")" "$PLAIN" "$PLAIN/ios" react-native +expect "a React Native package inside a monorepo resolves to the package" \ + "$(resolve "$MONO/packages/mobile/ios")" "$MONO/packages/mobile" "$MONO/packages/mobile/ios" react-native + +mkdir -p "$ROOT/fakebin" +cat > "$ROOT/fakebin/xcrun" <<'EOF' +#!/usr/bin/env bash +case "$*" in + "simctl list devices --json") printf '{"devices":{}}\n' ;; + *devicectl*) printf '{"result":{"devices":[]}}\n' ;; + *) exit 2 ;; +esac +EOF +chmod +x "$ROOT/fakebin/xcrun" +proposed_bundle() { + (cd "$1" && env PATH="$ROOT/fakebin:$PATH" IOS_WORKSPACE="$1/.iterable/ios" "$REPO/bin/ios-prepare-proof" status 2>/dev/null) | + node -e 'let i="";process.stdin.on("data",c=>i+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(i).proposed?.bundle_id||""))}catch{}})' +} +[[ "$(proposed_bundle "$RN")" == com.example.rnapp ]] && + ok "React Native Bundle ID comes from ios/, not from a package in node_modules" || + bad "React Native Bundle ID detection" "$(proposed_bundle "$RN")" +[[ "$(proposed_bundle "$EXPO")" == com.example.expoapp ]] && + ok "Expo Bundle ID falls back to app.json when ios/ has not been generated" || + bad "Expo Bundle ID detection" "$(proposed_bundle "$EXPO")" +[[ "$(proposed_bundle "$NATIVE")" == com.example.native ]] && + ok "a native app's Bundle ID detection is unchanged" || + bad "native Bundle ID detection" "$(proposed_bundle "$NATIVE")" + +inspect() { + (cd "$1" && "$REPO/bin/ios-verify" inspect 2>/dev/null) +} +signals() { + node -e ' + const v=JSON.parse(process.argv[1]), s=v.signals; + process.stdout.write([v.framework,s.sdk_dependency,s.sdk_initialization,s.identity_wiring,s.push_wiring,v.next.kind].join(" ")); + ' "$1" +} +expect_signals() { + local name="$1" json="$2" want="$3" + [[ "$(signals "$json")" == "$want" ]] && ok "$name" || bad "$name" "got: $(signals "$json")" +} + +rn_inspect="$(inspect "$RN")" +expect_signals "a React Native app's JS initialization, Iterable identity, and native token hook are all found" \ + "$rn_inspect" "react-native true true true true prepare_proof" +node -e ' + const v=JSON.parse(process.argv[1]); + process.exit(v.project_root===process.argv[2] && v.next.summary.includes("-configuration Release -sdk iphonesimulator") && + v.next.summary.includes("RNApp.xcodeproj") ? 0 : 1); +' "$rn_inspect" "$RN" && + ok "inspect tells a React Native app the proof needs a Release Simulator build, with the command" || + bad "inspect did not name the Release Simulator build" "$rn_inspect" +expect_signals "the SDK's sources in node_modules, React state setters, and commented calls are not signals" \ + "$(inspect "$DECOY")" "react-native true false false false review_integration" +objc_inspect="$(inspect "$OBJC")" +[[ "$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).signals.push_wiring))' "$objc_inspect")" == true && + "$objc_inspect" == *'-workspace ObjC.xcworkspace -scheme ObjC'* ]] && + ok "an Objective-C++ registerToken: hook counts, and the build command uses the workspace" || + bad "the Objective-C++ hook or workspace build command was missed" "$objc_inspect" +expo_inspect="$(inspect "$EXPO")" +expect_signals "an Expo app's @iterable/expo-plugin entry counts as its native push wiring" \ + "$expo_inspect" "expo true true false true prepare_proof" +expect_signals "an Expo plugin with autoConfigurePushNotifications false is not push wiring" \ + "$(inspect "$EXPO_OFF")" "expo true true false false prepare_proof" +[[ "$expo_inspect" == *'npx expo prebuild --platform ios'* ]] && + ok "an Expo app with no ios/ is told to prebuild before the Release build" || + bad "the Expo prebuild step is missing" "$expo_inspect" +warning_kinds() { + node -e 'process.stdout.write(JSON.parse(process.argv[1]).warnings.map(w=>w.kind).join(","))' "$1" +} +[[ "$(warning_kinds "$expo_inspect")" == expo_push_permission_not_requested && + "$expo_inspect" == *'PITFALLS.md#14'* ]] && + ok "an Expo plugin that never asks for notification permission is warned about, naming pitfall 14" || + bad "the Expo permission warning is missing" "$expo_inspect" +EXPO_ASKS="$ROOT/expo-plugin-asks" +mkdir -p "$EXPO_ASKS" +cp "$EXPO/package.json" "$EXPO/App.tsx" "$EXPO_ASKS/" +printf '{"expo":{"ios":{"bundleIdentifier":"com.example.expoasks"},"plugins":[["@iterable/expo-plugin",{"requestPermissionsForPushNotifications":true}]]}}\n' > "$EXPO_ASKS/app.json" +git -C "$EXPO_ASKS" init -q +[[ -z "$(warning_kinds "$(inspect "$EXPO_ASKS")")" ]] && + ok "no permission warning when the Expo plugin is set to ask" || + bad "the permission warning fired although the plugin asks" "$(inspect "$EXPO_ASKS")" +EXPO_JS_ASKS="$ROOT/expo-js-asks" +mkdir -p "$EXPO_JS_ASKS/src" "$EXPO_JS_ASKS/node_modules/expo-notifications" +cp "$EXPO/package.json" "$EXPO/app.json" "$EXPO/App.tsx" "$EXPO_JS_ASKS/" +printf 'await Notifications.requestPermissionsAsync();\n' > "$EXPO_JS_ASKS/node_modules/expo-notifications/index.js" +git -C "$EXPO_JS_ASKS" init -q +[[ "$(warning_kinds "$(inspect "$EXPO_JS_ASKS")")" == expo_push_permission_not_requested ]] && + ok "a permission request inside node_modules does not silence the warning" || + bad "node_modules silenced the permission warning" "$(inspect "$EXPO_JS_ASKS")" +printf "const { status } = await Notifications.requestPermissionsAsync();\n" > "$EXPO_JS_ASKS/src/permissions.ts" +[[ -z "$(warning_kinds "$(inspect "$EXPO_JS_ASKS")")" ]] && + ok "no permission warning when the app's own JavaScript requests permission" || + bad "the permission warning ignored the app's own request call" "$(inspect "$EXPO_JS_ASKS")" +[[ -z "$(warning_kinds "$rn_inspect")" ]] && + ok "a bare React Native app gets no Expo permission warning" || + bad "a bare React Native app got the Expo permission warning" "$rn_inspect" + +native_inspect="$(inspect "$NATIVE")" +[[ "$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).framework)' "$native_inspect")" == native && + "$native_inspect" != *Release* ]] && + ok "a native app reports framework native and gets no React Native build advice" || + bad "a native app reported the wrong framework" "$native_inspect" + +cat > "$ROOT/fakebin/xcrun" <<'EOF' +#!/usr/bin/env bash +case "$*" in + "simctl list devices --json") + printf '{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-27-0":[{"udid":"SIM-1","name":"iPhone 17","state":"Booted","isAvailable":true}]}}\n' ;; + *devicectl*) printf '{"result":{"devices":[]}}\n' ;; + *) exit 2 ;; +esac +EOF +confirm_screen() { + local app="$1" app_path="${2:-}" + mkdir -p "$app/.iterable/ios" + node -e ' + require("fs").writeFileSync(process.argv[1], JSON.stringify({proof_mode:"behavioral",bundle_id:"com.example.rnapp", + data_region:"US",identity_type:"email",device:"SIM-1",environment:"development",app_path:process.argv[2]})); + ' "$app/.iterable/ios/proof-discovery.json" "$app_path" + (cd "$app" && env PATH="$ROOT/fakebin:$PATH" IOS_WORKSPACE="$app/.iterable/ios" "$REPO/bin/ios-prepare-proof" status 2>/dev/null) + rm -rf "$app/.iterable" +} +installed_confirm="$(confirm_screen "$RN")" +[[ "$installed_confirm" == *'"kind":"confirm_proof"'* && + "$installed_confirm" == *'the installed app must be a Release build'* && + "$installed_confirm" == *'-configuration Release -sdk iphonesimulator'* ]] && + ok "the React Native confirm screen says the installed app must be a Release Simulator build" || + bad "the React Native confirm screen did not state the Release build requirement" "$installed_confirm" +mkdir -p "$ROOT/RNApp.app" +debug_confirm="$(confirm_screen "$RN" "$ROOT/RNApp.app")" +[[ "$debug_confirm" == *'this app has no embedded main.jsbundle'* ]] && + ok "the React Native confirm screen flags a chosen Debug build before the run starts" || + bad "the React Native confirm screen accepted a Debug build silently" "$debug_confirm" +printf 'bundle\n' > "$ROOT/RNApp.app/main.jsbundle" +release_confirm="$(confirm_screen "$RN" "$ROOT/RNApp.app")" +[[ "$release_confirm" == *'"kind":"confirm_proof"'* && "$release_confirm" != *'Release build'* ]] && + ok "a chosen Release build needs no extra note on the confirm screen" || + bad "a Release build still drew the Debug warning" "$release_confirm" +native_confirm="$(confirm_screen "$NATIVE")" +[[ "$native_confirm" == *'"kind":"confirm_proof"'* && "$native_confirm" != *'Release build'* ]] && + ok "a native app's confirm screen has no React Native build note" || + bad "a native app's confirm screen mentioned a React Native build" "$native_confirm" + +((FAILED)) && exit 1 +exit 0 diff --git a/tests/ios-prove.sh b/tests/ios-prove.sh index 535b903..2736ced 100755 --- a/tests/ios-prove.sh +++ b/tests/ios-prove.sh @@ -57,7 +57,7 @@ case "$*" in ;; "simctl get_app_container SIM-PROVE-1 com.example.dogshelter app") [[ ! -f "$PROVE_STATE/not-installed" ]] || exit 2 - printf '/tmp/DogShelter.app\n' + printf '%s/DogShelter.app\n' "$PROVE_STATE" ;; "simctl terminate SIM-PROVE-1 com.example.dogshelter"|"simctl launch SIM-PROVE-1 com.apple.Preferences") ;; *"--predicate process == \"apsd\" OR process == \"SpringBoard\""*) @@ -110,8 +110,37 @@ case "$url" in *) printf '{"msg":"unexpected"}\n500\n' ;; esac EOF +# Prints the fixture plist the way otool dumps __TEXT,__entitlements. +cat > "$FAKEBIN/otool" <<'EOF' +#!/usr/bin/env bash +[[ -f "$PROVE_STATE/entitlements.plist" ]] || exit 1 +node -e ' + const data=require("fs").readFileSync(process.argv[1]); + console.log("Contents of (__TEXT,__entitlements) section"); + for (let at=0; at "$app/DogShelter" + chmod +x "$app/DogShelter" + printf '%s\n' 'application-identifierTEAM123456.com.example.dogshelteraps-environmentdevelopment' \ + > "$STATE/entitlements.plist" +} + # A fake APNs for the Apple check, used only by the cases that import a key. APNS_PID="" @@ -154,6 +183,7 @@ fresh() { },null,2)+"\n",{mode:0o600}); ' "$WORKSPACE/proof-target.json" "$SIMULATE" register "$DEVICES" SIM-PROVE-1 com.example.dogshelter + installed_app_fixture } prove() { @@ -422,5 +452,69 @@ else bad "ios-prove sent without the app having registered" "$out" fi +# The installed app must be the integrated, signed, current build before any +# notification-store advice is given. +fresh apns +rm -rf "$STATE/DogShelter.app/IterableSDKResources.bundle" +rm -f "$DEVICES/SIM-PROVE-1/data/Library/UserNotifications/Library.plist" +out="$(prove --timeout 5 2>/dev/null)" +rc=$? +if [[ "$rc" -eq 40 && "$(field "$out" diagnosis)" == installed_app_without_sdk && + "$(field "$out" summary)" == *"no Iterable iOS SDK"*"never CODE_SIGNING_ALLOWED=NO"*"ios-prepare-proof restart --confirm --app-path"*"Nothing was sent." && + "$(field "$out" send.state)" == not-sent && ! -s "$STATE/curl-calls" && + ! -e "$WORKSPACE/sends/development/user-targeted.json" ]]; then + ok "an installed app without the SDK is named instead of app_never_registered_notifications, and nothing is sent" +else + bad "an installed app without the SDK was not stopped by name" "rc=$rc $out" +fi + +fresh apns +rm -f "$STATE/entitlements.plist" +out="$(prove --timeout 5 2>/dev/null)" +if [[ "$(field "$out" diagnosis)" == installed_app_missing_aps_environment && "$(proof_calls)" -eq 0 && + "$(field "$out" summary)" == *"CODE_SIGNING_ALLOWED=NO"* ]]; then + ok "an installed app without aps-environment is stopped before sending" +else + bad "an installed app without aps-environment reached the send" "$out" +fi + +fresh apns +cp -R "$STATE/DogShelter.app" "$ROOT/Recorded.app" +printf 'the build that was recorded\n' > "$ROOT/Recorded.app/DogShelter" +node -e ' + const fs=require("fs"), p=process.argv[1], v=JSON.parse(fs.readFileSync(p,"utf8")); + v.app_path=process.argv[2]; + fs.writeFileSync(p,JSON.stringify(v,null,2)+"\n",{mode:0o600}); +' "$WORKSPACE/proof-target.json" "$ROOT/Recorded.app" +out="$(prove --timeout 5 2>/dev/null)" +if [[ "$(field "$out" diagnosis)" == installed_app_differs_from_recorded_build && "$(proof_calls)" -eq 0 && + "$(field "$out" summary)" == *"$ROOT/Recorded.app"*"ios-install"* ]]; then + ok "an installed app that differs from the recorded build is stopped before sending" +else + bad "an installed app that differs from the recorded build reached the send" "$out" +fi +cp "$STATE/DogShelter.app/DogShelter" "$ROOT/Recorded.app/DogShelter" +out="$(prove --timeout 10 2>/dev/null)" +if [[ "$(field "$out" observed)" == true && "$(proof_calls)" -eq 1 ]]; then + ok "an installed app byte-identical to the recorded build is proved" +else + bad "the recorded build was refused although it is installed" "$out" +fi +rm -rf "$ROOT/Recorded.app" + +fresh apns +printf 'name: DogShelter\n' > "$APP/project.yml" +mkdir -p "$APP/DogShelter" +touch -t 202601011200 "$STATE/DogShelter.app/DogShelter" +printf 'IterableAPI.initialize(apiKey: key)\n' > "$APP/DogShelter/AppDelegate.swift" +out="$(prove --timeout 5 2>/dev/null)" +rm -rf "$APP/project.yml" "$APP/DogShelter" +if [[ "$(field "$out" diagnosis)" == installed_app_older_than_source && "$(proof_calls)" -eq 0 && + "$(field "$out" summary)" == *"DogShelter was built at 2026-01-01"*"$APP/DogShelter/AppDelegate.swift changed at"* ]]; then + ok "an installed app older than a source edit is stopped before sending, naming the file and both times" +else + bad "a stale installed app reached the send" "$out" +fi + ((FAILED)) && exit 1 exit 0 diff --git a/tests/ios-verify-transitions.sh b/tests/ios-verify-transitions.sh index e7591c2..45b7ef7 100755 --- a/tests/ios-verify-transitions.sh +++ b/tests/ios-verify-transitions.sh @@ -38,6 +38,9 @@ IterableAPI.email = currentEmail UIApplication.shared.registerForRemoteNotifications() EOF printf 'fixture\n' > "$SIM_APP/Info.plist" +printf 'linked executable\n' > "$SIM_APP/DogShelter" +mkdir -p "$SIM_APP/IterableSDKResources.bundle" +touch -t 202601011200 "$APP/DogShelter/AppDelegate.swift" printf 'Iterable initialized\n' > "$STATE/app.log" cat > "$FAKEBIN/xcodebuild" <<'EOF' @@ -58,7 +61,26 @@ key="" while (($#)); do if [[ "$1" == -extract ]]; then key="$2"; shift 2; else shift; fi done -[[ "$key" == CFBundleIdentifier ]] && printf 'com.example.dogshelter\n' +case "$key" in + CFBundleIdentifier) printf 'com.example.dogshelter\n' ;; + CFBundleExecutable) printf 'DogShelter\n' ;; + aps-environment) grep -q 'development' && printf 'development\n' ;; +esac +EOF +cat > "$FAKEBIN/otool" <<'EOF' +#!/usr/bin/env bash +node -e ' + const data=Buffer.from("aps-environmentdevelopment"); + console.log("Contents of (__TEXT,__entitlements) section"); + for (let at=0; at "$FAKEBIN/xcrun" <<'EOF' #!/usr/bin/env bash diff --git a/tests/ios-verify.sh b/tests/ios-verify.sh index aa9fe85..1dacd95 100755 --- a/tests/ios-verify.sh +++ b/tests/ios-verify.sh @@ -264,6 +264,7 @@ APP_PATH="$ROOT/DogShelter.app" mkdir -p "$APP_PATH" printf 'fixture\n' > "$APP_PATH/Info.plist" printf 'same executable\n' > "$APP_PATH/DogShelter" +mkdir -p "$APP_PATH/IterableSDKResources.bundle" printf 'iterable-ios-runtime-proof/1\n' > "$APP_PATH/IterableRuntimeProof.fixture" cat > "$WORKSPACE/scope.json" <<'EOF' @@ -714,6 +715,7 @@ fi mkdir -p "$STATE/sim-app" printf 'fixture\n' > "$STATE/sim-app/Info.plist" printf 'same executable\n' > "$STATE/sim-app/DogShelter" +mkdir -p "$STATE/sim-app/IterableSDKResources.bundle" mkdir -p "$STATE/sim-data/Library/Application Support/IterableProof/runs" cp "$STATE/runtime-proof.jsonl" \ "$STATE/sim-data/Library/Application Support/IterableProof/runs/$RUN_ID.jsonl" @@ -858,6 +860,132 @@ else ' "$simulator_push_complete" "$simulator_push_rc")" fi +[[ "$(json_value "$simulator_push_complete" framework)" == native ]] && + ok "the verifier reports a native app's framework" || + bad "the verifier reported the wrong framework for a native app" + +printf '{"dependencies":{"react-native":"0.88.0","@iterable/react-native-sdk":"3.1.0"}}\n' > "$ROOT/app/package.json" +mkdir -p "$ROOT/app/ios/DogShelter.xcworkspace" +rn_debug="$(run_verify 2>/dev/null)" +rn_debug_rc=$? +if [[ "$rn_debug_rc" -eq 20 && "$(json_value "$rn_debug" framework)" == react-native ]] && + node -e ' + const gate=JSON.parse(process.argv[1]).gates.find(g=>g.id==="I5"); + process.exit(gate?.status==="red" && gate.detail.includes("no embedded main.jsbundle") && + gate.detail.includes("-workspace DogShelter.xcworkspace -scheme DogShelter -configuration Release -sdk iphonesimulator") ? 0 : 1); + ' "$rn_debug"; then + ok "a React Native Debug build without main.jsbundle is refused with the Release Simulator build command" +else + bad "a React Native Debug build was accepted as the proof artifact" \ + "rc=$rn_debug_rc $(node -e 'const g=JSON.parse(process.argv[1]).gates.find(g=>g.id==="I5");process.stdout.write(`${g?.status}: ${g?.detail}`)' "$rn_debug")" +fi +printf 'RNIterableAPI\n' > "$APP_PATH/main.jsbundle" +cp "$APP_PATH/main.jsbundle" "$STATE/sim-app/main.jsbundle" +rn_release="$(run_verify 2>/dev/null)" +rn_release_rc=$? +if [[ "$rn_release_rc" -eq 0 && "$(json_value "$rn_release" verdict)" == done ]]; then + ok "a React Native Release build with its embedded main.jsbundle completes the Simulator proof" +else + bad "a React Native Release build was not accepted" "rc=$rn_release_rc $(json_value "$rn_release" next.summary)" +fi +rm "$STATE/sim-app/main.jsbundle" +cp "$WORKSPACE/proof-target.json" "$ROOT/proof-target.with-app-path" +node -e ' + const fs=require("fs"), path=process.argv[1]; + const t=JSON.parse(fs.readFileSync(path,"utf8")); + Object.assign(t,{app_path:"",proof_mode:"behavioral"}); + fs.writeFileSync(path,JSON.stringify(t,null,2)+"\n",{mode:0o600}); +' "$WORKSPACE/proof-target.json" +installed_gate() { + node -e ' + const g=JSON.parse(process.argv[1]).gates.find(g=>g.id==="I6"); + process.stdout.write(`${g?.status}: ${g?.detail}`); + ' "$1" +} +rn_installed_debug="$(installed_gate "$(run_verify 2>/dev/null)")" +[[ "$rn_installed_debug" == red:*'installed react-native app has no embedded main.jsbundle'*'-configuration Release -sdk iphonesimulator'* ]] && + ok "an installed React Native Debug build is refused with the Release Simulator build command" || + bad "an installed React Native Debug build was accepted" "$rn_installed_debug" +printf 'RNIterableAPI\n' > "$STATE/sim-app/main.jsbundle" +rn_installed_release="$(installed_gate "$(run_verify 2>/dev/null)")" +[[ "$rn_installed_release" == green:*'IterableSDKResources.bundle and RNIterableAPI in main.jsbundle; aps-environment=development'* ]] && + ok "a fresh installed React Native Release build with the SDK, bundle marker and entitlement passes" || + bad "an installed React Native Release build was refused" "$rn_installed_release" +printf 'bundle without the package\n' > "$STATE/sim-app/main.jsbundle" +rn_installed_no_js_sdk="$(run_verify 2>/dev/null)" +[[ "$(installed_gate "$rn_installed_no_js_sdk")" == "red: "installed_app_without_sdk:*'never references RNIterableAPI'* && + "$(json_value "$rn_installed_no_js_sdk" next.kind)" == build_app ]] && + ok "an installed React Native app whose bundle lacks the Iterable package is refused" || + bad "an installed React Native bundle without the Iterable package was accepted" "$(installed_gate "$rn_installed_no_js_sdk")" +printf 'RNIterableAPI\n' > "$STATE/sim-app/main.jsbundle" +touch -t 202601011200 "$STATE/sim-app/main.jsbundle" +printf 'Iterable.initialize(key, config)\n' > "$ROOT/app/App.tsx" +rn_installed_stale_js="$(installed_gate "$(run_verify 2>/dev/null)")" +rm "$ROOT/app/App.tsx" +[[ "$rn_installed_stale_js" == "red: "installed_app_older_than_source:*'main.jsbundle was built at 2026-01-01'*"$ROOT/app/App.tsx changed at"* ]] && + ok "an installed React Native bundle older than a JavaScript edit is refused, naming the file and both times" || + bad "a stale installed React Native bundle was accepted" "$rn_installed_stale_js" +rm "$STATE/sim-app/main.jsbundle" +rm -rf "$ROOT/app/package.json" "$ROOT/app/ios" "$APP_PATH/main.jsbundle" +native_installed="$(installed_gate "$(run_verify 2>/dev/null)")" +[[ "$native_installed" == green:*'Iterable SDK via IterableSDKResources.bundle; aps-environment=development'* && + "$native_installed" != *RNIterableAPI* ]] && + ok "an installed native app with the SDK passes without any React Native rule" || + bad "an installed native app was held to the React Native rule" "$native_installed" +mv "$STATE/sim-app/IterableSDKResources.bundle" "$STATE/sim-app.sdk" +no_sdk="$(run_verify 2>/dev/null)" +mv "$STATE/sim-app.sdk" "$STATE/sim-app/IterableSDKResources.bundle" +[[ "$(installed_gate "$no_sdk")" == "red: "installed_app_without_sdk:*'no Iterable iOS SDK'*'never CODE_SIGNING_ALLOWED=NO'*'ios-prepare-proof restart --confirm --app-path'* && + "$(json_value "$no_sdk" next.kind)" == build_app ]] && + ok "an installed app without the Iterable SDK is red with the rebuild-and-record fix" || + bad "an installed app without the Iterable SDK was accepted" "$(installed_gate "$no_sdk")" +touch "$STATE/simulator-missing-entitlements" +no_aps="$(installed_gate "$(run_verify 2>/dev/null)")" +rm "$STATE/simulator-missing-entitlements" +[[ "$no_aps" == "red: "installed_app_missing_aps_environment:*'no aps-environment entitlement'*'CODE_SIGNING_ALLOWED=NO'* ]] && + ok "an installed app missing aps-environment is red" || + bad "an installed app without aps-environment was accepted" "$no_aps" +printf 'name: DogShelter\n' > "$ROOT/app/project.yml" +mkdir -p "$ROOT/app/DogShelter" +touch -t 202601011200 "$STATE/sim-app/DogShelter" +printf 'IterableAPI.initialize(apiKey: key)\n' > "$ROOT/app/DogShelter/AppDelegate.swift" +stale_native="$(installed_gate "$(run_verify 2>/dev/null)")" +rm -rf "$ROOT/app/project.yml" "$ROOT/app/DogShelter" +touch "$STATE/sim-app/DogShelter" +[[ "$stale_native" == "red: "installed_app_older_than_source:*'DogShelter was built at 2026-01-01'*"$ROOT/app/DogShelter/AppDelegate.swift changed at"* ]] && + ok "an installed app older than a source edit is red, naming the file and both times" || + bad "an installed app older than its source was accepted" "$stale_native" +cp "$ROOT/proof-target.with-app-path" "$WORKSPACE/proof-target.json" +chmod 600 "$WORKSPACE/proof-target.json" +printf 'an older executable\n' > "$STATE/sim-app/DogShelter" +differs="$(run_verify 2>/dev/null)" +printf 'same executable\n' > "$STATE/sim-app/DogShelter" +[[ "$(installed_gate "$differs")" == "red: "installed_app_differs_from_recorded_build:*'not byte-identical'* && + "$(json_value "$differs" next.kind)" == install_app ]] && + ok "an installed app that differs from the recorded build is red and reinstalls the recorded one" || + bad "an installed app that differs from the recorded build was accepted" "$(installed_gate "$differs")" +printf 'name: DogShelter\n' > "$ROOT/app/project.yml" +mkdir -p "$ROOT/app/DogShelter" +touch -t 202601011200 "$STATE/sim-app/DogShelter" "$APP_PATH/DogShelter" +printf 'IterableAPI.initialize(apiKey: key)\n' > "$ROOT/app/DogShelter/AppDelegate.swift" +recorded_stale="$(run_verify 2>/dev/null)" +rm -rf "$ROOT/app/project.yml" "$ROOT/app/DogShelter" +touch "$STATE/sim-app/DogShelter" "$APP_PATH/DogShelter" +[[ "$(installed_gate "$recorded_stale")" == "red: "installed_app_older_than_source:* && + "$(json_value "$recorded_stale" next.kind)" == build_app ]] && + ok "a stale recorded build asks for a rebuild, not a reinstall of the same build" || + bad "a stale recorded build was routed to reinstall" "$(json_value "$recorded_stale" next.kind) $(installed_gate "$recorded_stale")" +mv "$APP_PATH/IterableSDKResources.bundle" "$STATE/artifact.sdk" +artifact_no_sdk="$(run_verify 2>/dev/null)" +mv "$STATE/artifact.sdk" "$APP_PATH/IterableSDKResources.bundle" +node -e ' + const v=JSON.parse(process.argv[1]), g=v.gates.find(g=>g.id==="I5"); + process.exit(g?.status==="red" && g.detail.includes("recorded .app has no Iterable iOS SDK") && + g.detail.includes("CODE_SIGNING_ALLOWED=NO") && v.gates.find(g=>g.id==="I6")?.status==="blocked" ? 0 : 1); +' "$artifact_no_sdk" && + ok "a recorded artifact without the Iterable SDK is refused before install" || + bad "a recorded artifact without the Iterable SDK was accepted" + touch "$STATE/simulator-missing-entitlements" simulator_without_entitlements="$(run_verify 2>/dev/null)" simulator_without_entitlements_rc=$? From 68592b02943f668ae3005292de08ad181f4220de Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 20 Oct 2026 15:00:18 +0100 Subject: [PATCH 04/17] Provision React Native and Expo iOS from the Android setup The iOS provisioning path writes the Mobile key where a JavaScript app reads it: .iterable/rn/iterable.local.ts on bare React Native, and EXPO_PUBLIC_ITERABLE_API_KEY in a gitignored .env.local on Expo. Native apps keep the xcconfig. When the Android program already ran, iOS offers import-from-android. One Mobile and one Server-side key serve both platforms, with the Android region, No JWT and test email, validated again for iOS. The search walks up to the git toplevel, so a monorepo app finds the workspace kept at the root. The scope the React Native skill confirmed arrives as --features. It is shown on the import and confirmation screens before anything is written, and recorded with its source. restart --app-path now binds a fresh build to an existing proof run, and a missing .app is refused before anything is archived. Co-Authored-By: Claude Opus 5.5 --- bin/ios-provision | 534 ++++++++++++++++++++++++---- bin/ios-questions.sh | 70 +++- iterable-provision/SKILL.md | 22 +- iterable-provision/reference/ios.md | 37 ++ tests/ios-prepare-proof.sh | 34 ++ tests/ios-questions.sh | 16 +- tests/ios-rn-provision.sh | 418 ++++++++++++++++++++++ 7 files changed, 1054 insertions(+), 77 deletions(-) create mode 100755 tests/ios-rn-provision.sh diff --git a/bin/ios-provision b/bin/ios-provision index 890b354..ea979d8 100755 --- a/bin/ios-provision +++ b/bin/ios-provision @@ -5,14 +5,15 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOL_ROOT="$(cd "$BIN/.." && pwd)" source "$BIN/ios-simulator.sh" +source "$BIN/ios-project.sh" FASTLANE_VERSION="2.240.1" PRIVATE_APNS_ACTOR="$TOOL_ROOT/tools/ios-apns-private.rb" usage() { cat <<'EOF' Usage: - ios-provision status [--offline] [--bundle-id ID] [--workspace PATH] - ios-provision question --kind KIND [--workspace PATH] + ios-provision status [--offline] [--bundle-id ID] [--features ID,...] [--workspace PATH] + ios-provision question --kind KIND [--features ID,...] [--workspace PATH] ios-provision answer [--field FIELD --value VALUE [--add] | --clear] [--workspace PATH] ios-provision record-scope [--from-answers | --features LIST --identity-mode MODE --region US|EU --jwt-enabled true|false] --confirm [--workspace PATH] ios-provision record-iterable-setup --driver guided|developer --confirm [--workspace PATH] @@ -30,6 +31,9 @@ Usage: ios-provision validate-apns [--workspace PATH] ios-provision capture-mobile --region US|EU --jwt-enabled true|false [--replace] --confirm [--workspace PATH] ios-provision validate-mobile [--workspace PATH] + ios-provision import-from-android --confirm [--features ID,...] [--framework native|react-native|expo] [--workspace PATH] + ios-provision deliver-mobile-key [--framework native|react-native|expo] [--workspace PATH] + ios-provision ignore-expo-env --confirm [--workspace PATH] ios-provision capture-server [--replace] --confirm [--workspace PATH] ios-provision validate-server [--workspace PATH] ios-provision proof-status [--workspace PATH] @@ -74,8 +78,11 @@ create, or revoke Apple credentials. validate-apns signs a provider token with it and sends one probe to a random device token in the APNs sandbox: BadDeviceToken proves the key, Key ID, and Team ID, not the App ID. capture-mobile moves a copied Iterable Mobile API key from the clipboard into -the ignored iOS workspace without printing it. It also writes an ignored -IterableSecrets.xcconfig that the app can include directly. +the ignored iOS workspace without printing it, and writes the copy the app +reads: IterableSecrets.xcconfig for a native app, .iterable/rn/iterable.local.ts +for bare React Native, or EXPO_PUBLIC_ITERABLE_API_KEY in a gitignored +.env.local for Expo. import-from-android reuses the keys and region an Android +setup in the same project recorded. validate-mobile proves the captured value authenticates as a Mobile key in the recorded region, using an empty request and a deliberately invalid control. capture-server and validate-server prepare a separate Iterable verification @@ -142,26 +149,25 @@ file_mode() { } looks_like_ios_project() { - local candidate="$1" - [[ -f "$candidate/project.yml" ]] || - compgen -G "$candidate/*.xcodeproj/project.pbxproj" >/dev/null || - compgen -G "$candidate/*.xcworkspace" >/dev/null + ios_project_looks_like_ios "$1" +} + +# An app root is an iOS project, or a React Native/Expo root whose project +# lives in ios/ (or, for Expo, has not been generated yet). +looks_like_app_root() { + ios_project_looks_like_ios "$1" || [[ -n "$(ios_project_js_kind "$1")" ]] } verification_project_root() { local root workspace_root="" if [[ -n "${IOS_WORKSPACE:-}" ]]; then workspace_root="$(cd "$IOS_WORKSPACE/../.." 2>/dev/null && pwd || true)" - if [[ -n "$workspace_root" ]] && looks_like_ios_project "$workspace_root"; then + if [[ -n "$workspace_root" ]] && looks_like_app_root "$workspace_root"; then printf '%s' "$workspace_root" return fi fi - root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="$PWD" - if [[ "$root" == "$TOOL_ROOT" && "$PWD" != "$TOOL_ROOT" ]] && - looks_like_ios_project "$PWD"; then - root="$PWD" - fi + root="$(ios_project_app_root "$PWD")" || root="$PWD" printf '%s' "$root" } @@ -170,6 +176,7 @@ verification_source_files() { find "$root" \ \( -path '*/.git' -o -path '*/.iterable' -o -path '*/.derived-data' \ -o -path '*/DerivedData' -o -path '*/Pods' -o -path '*/.build' \ + -o -path '*/node_modules' -o -path '*/build' \ -o -path '*/*Tests' -o -path '*/*Tests/*' \ -o -path '*/*UITests' -o -path '*/*UITests/*' \) \ -prune -o \ @@ -179,7 +186,12 @@ verification_source_files() { } verification_detect_bundle() { - local root="$1" values="" project value="" + local app_root="$1" root values="" project value="" + root="$(ios_project_ios_dir "$app_root")" + if [[ -z "$root" ]]; then + ios_project_expo_bundle_id "$app_root" + return + fi if [[ -f "$root/project.yml" ]]; then values="$( sed -nE 's/^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER:[[:space:]]*"?([^"#[:space:]]+)"?.*/\1/p' \ @@ -187,7 +199,7 @@ verification_detect_bundle() { )" fi if [[ -z "$values" ]]; then - project="$(find "$root" -path '*/.git' -prune -o \ + project="$(find "$root" -maxdepth 2 \( -path '*/.git' -o -path '*/Pods' -o -path '*/node_modules' \) -prune -o \ -name project.pbxproj -type f -print 2>/dev/null | head -1)" if [[ -n "$project" ]]; then values="$( @@ -201,26 +213,28 @@ verification_detect_bundle() { grep -Eiv '([.]tests?|[.]uitests?)$' | sort -u | awk 'NR==1{value=$0} NR>1{multiple=1} END{if(!multiple) print value}')" - valid_bundle_id "$value" && printf '%s' "$value" + if valid_bundle_id "$value"; then + printf '%s' "$value" + elif [[ "$(ios_project_framework "$app_root")" == expo ]]; then + ios_project_expo_bundle_id "$app_root" + fi } +# The directory holding the app's Xcode project (the app root, or ios/ in a +# React Native or prebuilt Expo app). app_project_root() { - local candidate workspace_root="" + local root="" workspace_root="" ios_dir if [[ -n "${WORKSPACE:-}" ]]; then workspace_root="$(cd "$WORKSPACE/../.." 2>/dev/null && pwd || true)" - if [[ -n "$workspace_root" ]] && looks_like_ios_project "$workspace_root"; then - printf '%s' "$workspace_root" - return - fi + [[ -n "$workspace_root" ]] && looks_like_app_root "$workspace_root" && root="$workspace_root" fi - if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_ios_project "$PWD"; then - printf '%s' "$PWD" - return + if [[ -z "$root" ]]; then + root="$(ios_project_app_root "$PWD")" || return 1 fi - candidate="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1 - [[ "$candidate" != "$TOOL_ROOT" ]] && looks_like_ios_project "$candidate" || - return 1 - printf '%s' "$candidate" + [[ "$root" != "$TOOL_ROOT" ]] || return 1 + ios_dir="$(ios_project_ios_dir "$root")" + [[ -n "$ios_dir" ]] || return 1 + printf '%s' "$ios_dir" } DETECTED_TARGET="" @@ -252,6 +266,8 @@ load_detected_target() { "${projects[0]}/project.pbxproj" 2>/dev/null | sort -u)" fi [[ -n "${teams:-}" && "$teams" != *$'\n'* ]] && static_team="$teams" + else + static_bundle="$(verification_detect_bundle "$(verification_project_root)")" fi DETECTED_TARGET="$(node -e ' const [raw, staticBundle, staticName, staticTeam] = process.argv.slice(1); @@ -373,9 +389,9 @@ resolve_workspace() { return fi local project_root - project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" + project_root="$(ios_project_app_root "$PWD")" || project_root="$PWD" if [[ "$project_root" == "$TOOL_ROOT" && -z "${IOS_ALLOW_TOOL_ROOT:-}" ]]; then - if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_ios_project "$PWD"; then + if [[ "$PWD" != "$TOOL_ROOT" ]] && looks_like_app_root "$PWD"; then project_root="$PWD" else cat >&2 <= 2)) || die "--framework needs a value" + set_framework "$2" + shift 2 + ;; + --framework=*) + set_framework "${1#*=}" + shift + ;; --strategy) (($# >= 2)) || die "--strategy needs a value" APPLE_STRATEGY="$2" @@ -867,6 +892,19 @@ normalize_features() { ' "$1" } +# Strict: only the ids the choose_features and choose_more_features screens record. +carried_features() { + node -e ' + const allowed = new Set([ + "sdk-initialization", "push-notifications", "event-tracking", "user-profiles", + "deep-linking", "in-app-messages", "mobile-inbox", "embedded-messaging" + ]); + const values = process.argv[1].split(",").map(value => value.trim()); + if (values.some(value => !allowed.has(value))) process.exit(2); + process.stdout.write([...new Set(["sdk-initialization", ...values])].sort().join(",")); + ' "$1" +} + answer_scope() { command -v node >/dev/null 2>&1 || die "node is not installed" if [[ "$ANSWER_CLEAR" == true && -z "$ANSWER_FIELD" ]]; then @@ -910,6 +948,10 @@ answer_scope() { "$ANSWER_VALUE" == false ]] || die "$ANSWER_FIELD must be true or false" ;; + android-import) + [[ "$ANSWER_CLEAR" == true || "$ANSWER_VALUE" == skip ]] || + die "android-import can only be answered with skip; run import-from-android to reuse the Android setup" + ;; *) die "unknown answer field: $ANSWER_FIELD" ;; @@ -931,9 +973,11 @@ answer_scope() { "identity-mode": "identity_mode", "region": "data_region", "jwt-enabled": "jwt_enabled", - "jwt-check": "jwt_check" + "jwt-check": "jwt_check", + "android-import": "android_import" }[field]; const featureKey=field.startsWith("feature-") ? field.slice(8) : ""; + if (field === "features" || featureKey) delete current.scope_source; if (clear) { if (featureKey) { if (current.feature_choices) delete current.feature_choices[featureKey]; @@ -1154,7 +1198,7 @@ proof_min_os() { root="$(verification_project_root 2>/dev/null)" || root="" if [[ -n "$root" && -d "$root" ]]; then value="$(find "$root" -maxdepth 3 \( -name project.pbxproj -o -name project.yml -o -name '*.xcconfig' \) \ - -not -path '*/DerivedData/*' -not -path '*/Pods/*' -type f -print0 2>/dev/null | + -not -path '*/DerivedData/*' -not -path '*/Pods/*' -not -path '*/node_modules/*' -type f -print0 2>/dev/null | xargs -0 grep -h -E -o '(IPHONEOS_DEPLOYMENT_TARGET[ =:"]+|iOS:[ "]+)[0-9]+(\.[0-9]+)?' 2>/dev/null | grep -E -o '[0-9]+(\.[0-9]+)?$' | sort -t. -k1,1n -k2,2n | tail -1)" fi @@ -1428,6 +1472,19 @@ proof_status_json() { summary="$summary Only Simulators on iOS $min_os or later are listed: that is the app's deployment target." ;; confirm_proof) + local framework js_build="" + framework="$(ios_project_framework "$project_root")" + if [[ "$framework" != native ]]; then + local sdk=iphoneos + ! is_booted_simulator "$device" || sdk=iphonesimulator + if [[ -z "$app_path" ]]; then + js_build=" +Build: the installed app must be a Release build, which embeds its JavaScript and runs without Metro: $(ios_project_release_build_hint "$project_root" "$sdk")" + elif [[ ! -f "$app_path/main.jsbundle" ]]; then + js_build=" +Build: this app has no embedded main.jsbundle, so it is a Debug build that needs Metro and the verifier will refuse it; build Release instead: $(ios_project_release_build_hint "$project_root" "$sdk")" + fi + fi if [[ "$proof_mode" == behavioral ]]; then summary_text="Verification: standard integration proof Bundle: $bundle @@ -1436,7 +1493,7 @@ Environment: $environment Identity type: $identity_type Identity: ${identity:-the exact $identity_type the app is signed in with (type it to start)} Device: $device -App artifact: ${app_path:-use the app already installed for this bundle} +App artifact: ${app_path:-use the app already installed for this bundle}$js_build Checks: installed app, Iterable identity, device registration, then one marked push sent through Iterable and read back from the device's own records" else summary_text="Verification: instrumented evaluation @@ -1447,7 +1504,7 @@ Identity type: $identity_type User A: $identity User B: $switch_identity Device: $device -App: $app_path +App: $app_path$js_build Profile expectation: ${profile_field:-none selected} Event expectation: ${event:-none selected} Tap expectation: ${tap_expect:-not applicable} @@ -1464,6 +1521,8 @@ Logout expectation: $logout_expect" proof_app) question="$(ios_question proof_app "$proof_mode")" summary="Select the exact app artifact for the chosen proof mode." + [[ "$(ios_project_framework "$project_root")" == native ]] || + summary="$summary Select a Release build, which embeds its JavaScript and runs without Metro: $(ios_project_release_build_hint "$project_root")." ;; *) question="$(ios_question "$next_kind")" @@ -1483,6 +1542,7 @@ Logout expectation: $logout_expect" } record_scope() { + local scope_source="" [[ "$CONFIRM" == true ]] || die "record-scope requires --confirm after the developer answers the integration questions" if [[ "$FROM_ANSWERS" == true ]]; then @@ -1519,6 +1579,10 @@ record_scope() { typeof value.jwt_enabled === "boolean" ? String(value.jwt_enabled) : "" ); ' "$DISCOVERY_META")" + scope_source="$(node -e ' + const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(value.scope_source || "")); + ' "$DISCOVERY_META")" fi [[ -n "$FEATURES" ]] || die "record-scope requires --features or --from-answers" [[ "$IDENTITY_MODE" == email || "$IDENTITY_MODE" == user-id || @@ -1547,9 +1611,10 @@ record_scope() { features: JSON.parse(process.argv[1]), identity_mode: process.argv[2], data_region: process.argv[3], - jwt_enabled: process.argv[4] === "true" + jwt_enabled: process.argv[4] === "true", + ...(process.argv[5] ? { scope_source: process.argv[5] } : {}) }, null, 2) + "\n"); - ' "$normalized" "$IDENTITY_MODE" "$DATA_REGION" "$JWT_ENABLED")" + ' "$normalized" "$IDENTITY_MODE" "$DATA_REGION" "$JWT_ENABLED" "${scope_source:-}")" if [[ -f "$SCOPE_META" ]]; then if node -e ' @@ -1659,7 +1724,7 @@ status_json() { local discovery_jwt="" discovery_jwt_present=false discovery_jwt_check=false local discovery_push="" discovery_events="" discovery_profiles="" local discovery_more="" discovery_in_app="" discovery_inbox="" discovery_embedded="" - local discovery_deep_links="" + local discovery_deep_links="" discovery_scope_source="" scope_source_recorded="" local iterable_setup_present=false iterable_setup_secure=false local iterable_setup_valid=false iterable_setup_driver="" local apple_setup_present=false apple_setup_secure=false @@ -1722,6 +1787,10 @@ status_json() { const scope = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(scope.verification_only===true)); ' "$SCOPE_META")" + scope_source_recorded="$(node -e ' + const scope = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(scope.scope_source || "")); + ' "$SCOPE_META")" if [[ ",$scope_features," == *,push-notifications,* ]]; then push_required=true fi @@ -1801,6 +1870,10 @@ status_json() { const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(value.jwt_check === true)); ' "$DISCOVERY_META")" + discovery_scope_source="$(node -e ' + const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(value.scope_source || "")); + ' "$DISCOVERY_META")" fi fi @@ -1978,6 +2051,16 @@ status_json() { [[ "$(file_mode "$MOBILE_XCCONFIG")" == 600 ]] && mobile_xcconfig_secure=true fi + local key_framework key_delivery key_delivery_ok=false key_delivery_ready=true + key_framework="$(ios_framework)" + key_delivery="$(key_delivery_for "$key_framework")" + [[ "$key_delivery" != expo-env ]] || expo_env_ignored "$(app_root)" || key_delivery_ready=false + if [[ "$mobile_key_present" == true ]] && + [[ "$(env_value ITERABLE_KEY_DELIVERY)" == "$key_delivery" || + ( -z "$(env_value ITERABLE_KEY_DELIVERY)" && "$key_delivery" == xcconfig ) ]] && + key_delivery_ok "$key_delivery" "$mobile_key_value"; then + key_delivery_ok=true + fi if [[ -f "$MOBILE_VALIDATION" ]]; then mobile_validation_present=true [[ "$(file_mode "$MOBILE_VALIDATION")" == 600 ]] && @@ -2166,9 +2249,17 @@ status_json() { fi fi + local android_import_answered=false + [[ -f "$DISCOVERY_META" ]] && + node -e 'process.exit(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).android_import ? 0 : 1)' \ + "$DISCOVERY_META" 2>/dev/null && android_import_answered=true if [[ "$scope_valid" != true || "$scope_secure" != true ]]; then next_owner="human" - if [[ "$discovery_valid" != true || + if [[ "$android_import_answered" != true && "$mobile_key_present" != true ]] && + android_setup_present; then + next_kind="import_android" + next_summary="The Android setup in $(dirname "$WORKSPACE") already recorded the Iterable keys and region. Offer to reuse them for iOS instead of asking again." + elif [[ "$discovery_valid" != true || "$discovery_push" == unset || "$discovery_events" == unset || "$discovery_profiles" == unset || "$discovery_deep_links" == unset || "$discovery_more" == unset ]]; then @@ -2305,8 +2396,16 @@ status_json() { next_kind="validate_apns" next_owner="tool" next_summary="Sign an APNs provider token with the imported key and send one probe to a random device token in the APNs sandbox. BadDeviceToken proves the key, Key ID, and Team ID; it does not check the App ID or Push capability." - elif [[ "$mobile_key_present" != true || "$mobile_env_secure" != true || - "$mobile_xcconfig_present" != true || "$mobile_xcconfig_secure" != true ]]; then + elif [[ "$key_delivery_ready" != true && "$key_delivery_ok" != true ]]; then + next_kind="fix_expo_gitignore" + next_owner="human" + next_summary="This Expo app reads the Mobile key from .env.local, but its .gitignore does not ignore that file. Add .env*.local before any key is written." + elif [[ "$mobile_key_present" == true && "$mobile_env_secure" == true && + "$key_delivery_ok" != true ]]; then + next_kind="deliver_mobile_key" + next_owner="tool" + next_summary="Write the captured Mobile key where this $key_framework app reads it ($(key_delivery_path "$key_delivery")), at mode 0600, without printing it." + elif [[ "$mobile_key_present" != true || "$mobile_env_secure" != true ]]; then if [[ "$iterable_setup_valid" == true ]]; then next_kind="capture_iterable" next_owner="human" @@ -2370,7 +2469,7 @@ status_json() { ;; confirm_scope) next_question="$(ios_question confirm_scope "$discovery_features" \ - "$discovery_identity" "$discovery_region" "$discovery_jwt")" + "$discovery_identity" "$discovery_region" "$discovery_jwt" "$discovery_scope_source")" next_command="$BIN/ios-provision question --kind confirm_scope" ;; configure_iterable) @@ -2406,6 +2505,19 @@ status_json() { asc_read) next_command="$BIN/ios-provision asc-read" ;; + import_android) + local carried="" + [[ -z "$FEATURES" ]] || carried="$(carried_features "$FEATURES")" || carried="" + next_question="$(ios_question import_android "$(android_import_summary)" "$carried")" + next_command="$BIN/ios-provision question --kind import_android${carried:+ --features $carried}" + ;; + fix_expo_gitignore) + next_question="$(ios_question fix_expo_gitignore "$(app_root)")" + next_command="$BIN/ios-provision question --kind fix_expo_gitignore" + ;; + deliver_mobile_key) + next_command="$BIN/ios-provision deliver-mobile-key" + ;; asc_key|asc_issuer|asc_rejected|asc_conflict|asc_plan) next_question="$(ios_question "$next_kind" "$BUNDLE_ID" "$asc_reason" "$asc_actions" "$(asc_app_name)")" next_command="$BIN/ios-provision question --kind $next_kind" @@ -2452,16 +2564,16 @@ status_json() { printf '"target":{"bundle_id":%s,"name":%s,"recorded":%s,"detected_bundle_id":%s,"detected_name":%s,"detected_source":%s},' \ "$(jstr "$BUNDLE_ID")" "$(jstr "$APP_NAME")" "$target_recorded" \ "$(jstr "$detected_bundle")" "$(jstr "$detected_name")" "$(jstr "$detected_source")" - printf '"scope":{"path":%s,"present":%s,"mode_0600":%s,"valid":%s,"features":%s,"identity_mode":%s,"data_region":%s,"jwt_enabled":%s,"push_required":%s,"verification_only":%s},' \ + printf '"scope":{"path":%s,"present":%s,"mode_0600":%s,"valid":%s,"features":%s,"identity_mode":%s,"data_region":%s,"jwt_enabled":%s,"push_required":%s,"verification_only":%s,"source":%s},' \ "$(jstr "$SCOPE_META")" "$scope_present" "$scope_secure" "$scope_valid" \ "$(jstr "$scope_features")" "$(jstr "$scope_identity")" \ "$(jstr "$scope_region")" "$(jstr "$scope_jwt")" "$push_required" \ - "$scope_verification_only" - printf '"discovery":{"path":%s,"present":%s,"mode_0600":%s,"valid":%s,"features":%s,"identity_mode":%s,"data_region":%s,"jwt_enabled":%s},' \ + "$scope_verification_only" "$(jstr "$scope_source_recorded")" + printf '"discovery":{"path":%s,"present":%s,"mode_0600":%s,"valid":%s,"features":%s,"identity_mode":%s,"data_region":%s,"jwt_enabled":%s,"scope_source":%s},' \ "$(jstr "$DISCOVERY_META")" "$discovery_present" "$discovery_secure" \ "$discovery_valid" "$(jstr "$discovery_features")" \ "$(jstr "$discovery_identity")" "$(jstr "$discovery_region")" \ - "$(jstr "$discovery_jwt")" + "$(jstr "$discovery_jwt")" "$(jstr "$discovery_scope_source")" printf '"tools":{"xcodebuild":%s,"node":%s,"npx":%s,"curl":%s},' \ "$has_xcode" "$has_node" "$has_npx" "$has_curl" printf '"apple_setup":{"path":%s,"present":%s,"mode_0600":%s,"valid":%s,"strategy":%s,"readiness_path":%s,"readiness_present":%s,"readiness_mode_0600":%s,"readiness_valid":%s,"evidence_level":%s,"guide_steps":%s,"guide_next":%s,"asc_key_present":%s,"asc_issuer_present":%s,"asc_state":%s,"asc_actions":%s},' \ @@ -2479,7 +2591,7 @@ status_json() { "$apns_rejected" "$(jstr "$apns_rejection_reason")" \ "$(jstr "$([[ "$apns_classification" == - ]] || printf '%s' "$apns_classification")")" \ "$apns_credentials_valid" - printf '"iterable":{"setup_path":%s,"setup_present":%s,"setup_mode_0600":%s,"setup_valid":%s,"setup_driver":%s,"mobile_env_path":%s,"mobile_xcconfig_path":%s,"mobile_validation_path":%s,"mobile_key_present":%s,"mobile_key_validated":%s,"mode_0600":%s,"xcconfig_present":%s,"xcconfig_mode_0600":%s,"validation_present":%s,"validation_mode_0600":%s,"validation_classification":%s,"server_validation_path":%s,"server_key_present":%s,"server_key_validated":%s,"server_validation_present":%s,"server_validation_mode_0600":%s,"server_validation_classification":%s,"mobile_key_rejected":%s,"mobile_rejection_reason":%s,"server_key_rejected":%s,"server_rejection_reason":%s,"keys_identical":%s,"data_region":%s,"jwt_enabled":%s},' \ + printf '"iterable":{"setup_path":%s,"setup_present":%s,"setup_mode_0600":%s,"setup_valid":%s,"setup_driver":%s,"mobile_env_path":%s,"mobile_xcconfig_path":%s,"mobile_validation_path":%s,"mobile_key_present":%s,"mobile_key_validated":%s,"mode_0600":%s,"xcconfig_present":%s,"xcconfig_mode_0600":%s,"validation_present":%s,"validation_mode_0600":%s,"validation_classification":%s,"server_validation_path":%s,"server_key_present":%s,"server_key_validated":%s,"server_validation_present":%s,"server_validation_mode_0600":%s,"server_validation_classification":%s,"mobile_key_rejected":%s,"mobile_rejection_reason":%s,"server_key_rejected":%s,"server_rejection_reason":%s,"keys_identical":%s,"framework":%s,"key_delivery":%s,"key_delivery_path":%s,"key_delivery_ok":%s,"imported_from":%s,"data_region":%s,"jwt_enabled":%s},' \ "$(jstr "$ITERABLE_SETUP_META")" "$iterable_setup_present" \ "$iterable_setup_secure" "$iterable_setup_valid" \ "$(jstr "$iterable_setup_driver")" \ @@ -2494,6 +2606,9 @@ status_json() { "$mobile_validation_rejected" "$(jstr "$mobile_rejection_reason")" \ "$server_validation_rejected" "$(jstr "$server_rejection_reason")" \ "$keys_identical" \ + "$(jstr "$key_framework")" "$(jstr "$key_delivery")" \ + "$(jstr "$(key_delivery_path "$key_delivery")")" "$key_delivery_ok" \ + "$(jstr "$(env_value ITERABLE_IMPORTED_FROM)")" \ "$(jstr "$mobile_region")" "$(jstr "$mobile_jwt")" printf '"proof":{"target_path":%s,"present":%s,"mode_0600":%s,"valid":%s,"proof_mode":%s,"identity_type":%s,"identity":%s,"switch_identity":%s,"device":%s,"device_kind":%s,"simulator_runtime":%s,"app_path":%s,"environment":%s,"apns_environment":%s,"production_follow_up_required":%s,"profile_fields":%s,"events":%s,"started_at":%s,"run_id":%s,"tap_expect":%s,"logout_expect":%s,"direct_marker":%s,"targeted_marker":%s,"switch_marker":%s},' \ "$(jstr "$PROOF_TARGET")" "$proof_target_present" "$proof_target_secure" \ @@ -2953,6 +3068,279 @@ write_key_receipt() { chmod 600 "$path" } +app_root() { + (cd "$(dirname "$(dirname "$WORKSPACE")")" 2>/dev/null && pwd -P) +} + +set_framework() { + case "$1" in + native|react-native|expo) IOS_FRAMEWORK="$1" ;; + *) die "--framework must be native, react-native, or expo" ;; + esac +} + +ios_framework() { + case "${IOS_FRAMEWORK:-}" in + native|react-native|expo) printf '%s' "$IOS_FRAMEWORK"; return ;; + esac + local root + root="$(app_root)" && [[ -n "$root" ]] || { printf native; return; } + ios_project_framework "$root" +} + +key_delivery_for() { + case "$1" in + react-native) printf 'rn-module' ;; + expo) printf 'expo-env' ;; + *) printf 'xcconfig' ;; + esac +} + +key_delivery_path() { + case "$1" in + rn-module) printf '%s/.iterable/rn/iterable.local.ts' "$(app_root)" ;; + expo-env) printf '%s/.env.local' "$(app_root)" ;; + *) printf '%s' "$MOBILE_XCCONFIG" ;; + esac +} + +# Expo reads .env.local into the bundle; refuse to write a key into a file git +# would commit. +expo_env_ignored() { + local root="$1" + if git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$root" check-ignore -q .env.local + return + fi + [[ -f "$root/.gitignore" ]] && + grep -Eq '^[[:space:]]*/?(\.env\*\.local|\.env\.local|\.env\*|\*\.local)[[:space:]]*$' "$root/.gitignore" +} + +require_key_delivery_ready() { + local delivery="$1" + [[ "$delivery" != expo-env ]] || expo_env_ignored "$(app_root)" || + die "$(app_root)/.gitignore does not ignore .env.local, so the Mobile key would be committed. Add .env*.local to .gitignore, then run this again." +} + +deliver_mobile_key() { + local value="$1" region="$2" jwt="$3" delivery="$4" path + path="$(key_delivery_path "$delivery")" + case "$delivery" in + rn-module) + mkdir -p "$(dirname "$path")" + [[ -f "$(dirname "$path")/.gitignore" ]] || printf '*\n' > "$(dirname "$path")/.gitignore" + ( + umask 077 + { + printf '// Written by ios-provision. Local and ignored; load it with an optional require.\n' + printf 'export const ITERABLE_API_KEY = "%s";\n' "$value" + } > "$path" + ) + ;; + expo-env) + local kept="" + [[ ! -f "$path" ]] || kept="$(grep -v '^EXPO_PUBLIC_ITERABLE_API_KEY=' "$path" || true)" + ( + umask 077 + { + [[ -z "$kept" ]] || printf '%s\n' "$kept" + printf 'EXPO_PUBLIC_ITERABLE_API_KEY=%s\n' "$value" + } > "$path" + ) + ;; + *) + ( + umask 077 + { + printf '// Written by ios-provision. Local and ignored.\n' + printf 'ITERABLE_API_KEY = %s\n' "$value" + printf 'ITERABLE_DATA_REGION = %s\n' "$region" + printf 'ITERABLE_JWT_ENABLED = %s\n' "$jwt" + } > "$path" + ) + ;; + esac + chmod 600 "$path" + DELIVERY_PATH="$path" +} + +write_mobile_env() { + local value="$1" region="$2" jwt="$3" delivery="$4" server_lines="$5" origin="$6" + local deliver="${7:-deliver}" + init_workspace + ( + umask 077 + { + printf '# Written by ios-provision %s. This file is local and ignored.\n' "$origin" + printf 'ITERABLE_API_KEY=%s\n' "$value" + printf 'ITERABLE_DATA_REGION=%s\n' "$region" + printf 'ITERABLE_JWT_ENABLED=%s\n' "$jwt" + printf 'ITERABLE_KEY_DELIVERY=%s\n' "$delivery" + [[ -z "$server_lines" ]] || printf '\n%s\n' "$server_lines" + } > "$MOBILE_ENV" + ) + chmod 600 "$MOBILE_ENV" + [[ "$deliver" != deliver ]] || deliver_mobile_key "$value" "$region" "$jwt" "$delivery" +} + +key_delivery_ok() { + local delivery="$1" value="$2" path + path="$(key_delivery_path "$delivery")" + [[ -f "$path" && "$(file_mode "$path")" == 600 ]] || return 1 + local line + case "$delivery" in + rn-module) line="export const ITERABLE_API_KEY = \"$value\";" ;; + expo-env) line="EXPO_PUBLIC_ITERABLE_API_KEY=$value" ;; + *) line="ITERABLE_API_KEY = $value" ;; + esac + printf '%s\n' "$line" | grep -qxF -f - "$path" +} + +# Nearest .iterable/.env from the app root up to the git toplevel: a monorepo keeps it at the root. +android_env_path() { + local root dir stop + root="$(app_root)" + dir="$root" + stop="$(git -C "$root" rev-parse --show-toplevel 2>/dev/null)" && + stop="$(cd "$stop" 2>/dev/null && pwd -P)" || stop="$root" + while [[ -n "$dir" ]]; do + if [[ -f "$dir/.iterable/.env" ]]; then + printf '%s/.iterable/.env' "$dir" + return + fi + [[ "$dir" != "$stop" && "$dir" != / ]] || break + dir="$(dirname "$dir")" + done + printf '%s/.iterable/.env' "$root" +} + +android_env_value() { + local path + path="$(android_env_path)" + [[ -f "$path" ]] || return 0 + sed -n "s/^$1=//p" "$path" | tail -1 | sed -e "s/^[\"']//" -e "s/[\"']\$//" +} + +android_setup_present() { + [[ -n "$(android_env_value ITBL_MOBILE_KEY)" ]] +} + +import_from_android() { + [[ "$CONFIRM" == true ]] || die "import-from-android requires --confirm" + command -v node >/dev/null 2>&1 || die "node is not installed" + local carried="" + if [[ -n "$FEATURES" ]]; then + carried="$(carried_features "$FEATURES")" || die "--features takes the ids choose_features records: push-notifications, event-tracking, user-profiles, deep-linking, in-app-messages, mobile-inbox, embedded-messaging, or sdk-initialization" + fi + android_setup_present || + die "no Android setup with a Mobile key was found at $(android_env_path)" + require_ios_next "import-from-android" import_android + local mobile server base email region delivery existing server_lines="" android_env + android_env="$(android_env_path)" + mobile="$(android_env_value ITBL_MOBILE_KEY)" + server="$(android_env_value ITBL_SERVER_KEY)" + base="$(android_env_value ITBL_BASE)" + email="$(android_env_value ITBL_EMAIL)" + region=US + [[ "$base" != https://api.eu.iterable.com ]] || region=EU + [[ "$mobile" =~ ^[A-Za-z0-9._~+/=-]{20,}$ ]] || + die "the Android Mobile key in $(android_env_path) does not look like an Iterable API key" + if [[ -n "$server" && ! "$server" =~ ^[A-Za-z0-9._~+/=-]{20,}$ ]]; then + die "the Android Server-side key in $(android_env_path) does not look like an Iterable API key" + fi + existing="$(env_value ITERABLE_API_KEY)" + if [[ -n "$existing" && "$existing" != "$mobile" ]]; then + unset mobile server existing + die "the iOS workspace already holds a different Mobile key; it was not overwritten" + fi + unset existing + delivery="$(key_delivery_for "$(ios_framework)")" + local deliver=deliver + [[ "$delivery" != expo-env ]] || expo_env_ignored "$(app_root)" || deliver=later + [[ -z "$server" ]] || + server_lines="$(printf '# Read-only verification credential; never linked into the app.\nITERABLE_SERVER_KEY=%s' "$server")" + write_mobile_env "$mobile" "$region" false "$delivery" "$server_lines" import-from-android "$deliver" + printf 'ITERABLE_IMPORTED_FROM=%s\n' "$android_env" >> "$MOBILE_ENV" + rm -f "$MOBILE_VALIDATION" "$SERVER_VALIDATION" + unset mobile server server_lines + init_workspace + local current='{}' + [[ ! -f "$DISCOVERY_META" ]] || current="$(cat "$DISCOVERY_META")" + ( + umask 077 + node -e ' + const [path, current, region, email, source, carried] = process.argv.slice(1); + const value = JSON.parse(current); + value.android_import = "imported"; + value.android_import_path = source; + if (carried) { + delete value.feature_choices; + value.features = carried.split(","); + value.scope_source = "react-native-step0"; + } + value.data_region = region; + value.jwt_enabled = false; + if (email && !value.identity_mode) value.identity_mode = "email"; + require("fs").writeFileSync(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); + ' "$DISCOVERY_META" "$current" "$region" "$email" "$android_env" "$carried" + ) + chmod 600 "$DISCOVERY_META" + printf 'Reused the Android setup from %s: the Mobile%s key, region %s, and No JWT. Both keys are validated again for iOS later; nothing was printed.\n' \ + "$android_env" "$([[ -n "$(env_value ITERABLE_SERVER_KEY)" ]] && printf ' and Server-side')" "$region" >&2 + [[ -z "$carried" ]] || + printf 'Proposed the feature scope confirmed in the React Native skill: %s. It is recorded only after the confirmation screen.\n' "$carried" >&2 + status_json +} + +android_import_summary() { + local region=US server=no email + [[ "$(android_env_value ITBL_BASE)" != https://api.eu.iterable.com ]] || region=EU + [[ -z "$(android_env_value ITBL_SERVER_KEY)" ]] || server=yes + email="$(android_env_value ITBL_EMAIL)" + printf 'Android workspace: %s\nRegion: %s\nMobile key: recorded\nServer-side key: %s\nTest identity: %s' \ + "$(dirname "$(android_env_path)")" "$region" \ + "$([[ "$server" == yes ]] && printf recorded || printf 'not recorded; iOS asks for it later')" \ + "${email:-not recorded}" +} + +deliver_mobile_key_cmd() { + require_ios_next "deliver-mobile-key" deliver_mobile_key + local value region jwt delivery + value="$(env_value ITERABLE_API_KEY)" + region="$(env_value ITERABLE_DATA_REGION)" + jwt="$(env_value ITERABLE_JWT_ENABLED)" + [[ -n "$value" ]] || die "no Mobile key has been captured" + delivery="$(key_delivery_for "$(ios_framework)")" + require_key_delivery_ready "$delivery" + local kept + kept="$(grep -v '^ITERABLE_KEY_DELIVERY=' "$MOBILE_ENV")" + ( + umask 077 + printf '%s\nITERABLE_KEY_DELIVERY=%s\n' "$kept" "$delivery" > "$MOBILE_ENV" + ) + chmod 600 "$MOBILE_ENV" + deliver_mobile_key "$value" "$region" "$jwt" "$delivery" + unset value kept + printf 'Wrote the Mobile key to %s at mode 0600; the key was not printed.\n' "$DELIVERY_PATH" >&2 + status_json +} + +ignore_expo_env_cmd() { + [[ "$CONFIRM" == true ]] || die "ignore-expo-env requires --confirm" + require_ios_next "ignore-expo-env" fix_expo_gitignore + local root + root="$(app_root)" + [[ -n "$root" ]] || die "the app root could not be resolved from the workspace" + if [[ -s "$root/.gitignore" && -n "$(tail -c1 "$root/.gitignore")" ]]; then + printf '\n' >> "$root/.gitignore" + fi + printf '\n# local env files\n.env*.local\n' >> "$root/.gitignore" + expo_env_ignored "$root" || + die "$root/.gitignore now lists .env*.local, but git still does not ignore .env.local; it is already tracked or a later rule re-includes it." + printf 'Added .env*.local to %s/.gitignore.\n' "$root" >&2 + status_json +} + capture_mobile() { [[ "$CONFIRM" == true ]] || die "capture-mobile requires --confirm" [[ "$DATA_REGION" == US || "$DATA_REGION" == EU ]] || @@ -2983,6 +3371,9 @@ capture_mobile() { die "the ignored iOS workspace already contains Mobile-key configuration; it was not overwritten. Use --replace only when status offers it" require_ios_next "capture-mobile" configure_iterable capture_iterable fi + local delivery + delivery="$(key_delivery_for "$(ios_framework)")" + require_key_delivery_ready "$delivery" read_clipboard_key local value="$CLIPBOARD_KEY" @@ -3001,30 +3392,13 @@ capture_mobile() { [[ ! -f "$MOBILE_ENV" ]] || server_lines="$(grep -E '^(# Read-only verification credential|ITERABLE_SERVER_KEY=)' "$MOBILE_ENV" || true)" - init_workspace - ( - umask 077 - { - printf '# Written by ios-provision capture-mobile. This file is local and ignored.\n' - printf 'ITERABLE_API_KEY=%s\n' "$value" - printf 'ITERABLE_DATA_REGION=%s\n' "$DATA_REGION" - printf 'ITERABLE_JWT_ENABLED=%s\n' "$JWT_ENABLED" - [[ -z "$server_lines" ]] || printf '\n%s\n' "$server_lines" - } > "$MOBILE_ENV" - { - printf '// Written by ios-provision capture-mobile. Local and ignored.\n' - printf 'ITERABLE_API_KEY = %s\n' "$value" - printf 'ITERABLE_DATA_REGION = %s\n' "$DATA_REGION" - printf 'ITERABLE_JWT_ENABLED = %s\n' "$JWT_ENABLED" - } > "$MOBILE_XCCONFIG" - ) - chmod 600 "$MOBILE_ENV" "$MOBILE_XCCONFIG" + write_mobile_env "$value" "$DATA_REGION" "$JWT_ENABLED" "$delivery" "$server_lines" capture-mobile rm -f "$MOBILE_VALIDATION" unset value server_lines clip_clear printf 'Stored the Iterable Mobile API key in %s and %s at mode 0600; the key was not printed.\n' \ - "$MOBILE_ENV" "$MOBILE_XCCONFIG" >&2 + "$MOBILE_ENV" "$DELIVERY_PATH" >&2 printf 'The clipboard was cleared.\n' >&2 status_json } @@ -4214,7 +4588,9 @@ restart_proof() { local requested_device="$PROOF_DEVICE" PROOF_DEVICE="$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.device||""))' "$PROOF_TARGET")" [[ -z "$requested_device" ]] || PROOF_DEVICE="$requested_device" + local requested_app_path="$PROOF_APP_PATH" PROOF_APP_PATH="$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.app_path||""))' "$PROOF_TARGET")" + [[ -z "$requested_app_path" ]] || PROOF_APP_PATH="$requested_app_path" PROOF_ENVIRONMENT="$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.environment||""))' "$PROOF_TARGET")" if [[ -n "$requested_device" ]] && is_booted_simulator "$requested_device"; then PROOF_ENVIRONMENT=development @@ -4438,6 +4814,7 @@ read_args "$@" case "$COMMAND" in status) + [[ -z "$FEATURES" ]] || carried_features "$FEATURES" >/dev/null || die "--features takes the ids choose_features records: push-notifications, event-tracking, user-profiles, deep-linking, in-app-messages, mobile-inbox, embedded-messaging, or sdk-initialization" status_json ;; question) @@ -4468,7 +4845,8 @@ case "$COMMAND" in ' "$DISCOVERY_META")" \ "$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.identity_mode||""))' "$DISCOVERY_META")" \ "$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.data_region||""))' "$DISCOVERY_META")" \ - "$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(typeof v.jwt_enabled==="boolean"?String(v.jwt_enabled):"")' "$DISCOVERY_META")" + "$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(typeof v.jwt_enabled==="boolean"?String(v.jwt_enabled):"")' "$DISCOVERY_META")" \ + "$(node -e 'const v=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(String(v.scope_source||""))' "$DISCOVERY_META")" printf '\n' ;; choose_features|choose_more_features|choose_identity|choose_region|choose_jwt|choose_apple_setup) @@ -4512,6 +4890,19 @@ case "$COMMAND" in ios_question help_apns_replace "$(detected_field team_id "$BUNDLE_ID")" printf '\n' ;; + import_android) + require_ios_next "question import_android" import_android + question_carried="" + if [[ -n "$FEATURES" ]]; then + question_carried="$(carried_features "$FEATURES")" || die "--features takes the ids choose_features records: push-notifications, event-tracking, user-profiles, deep-linking, in-app-messages, mobile-inbox, embedded-messaging, or sdk-initialization" + fi + ios_question import_android "$(android_import_summary)" "$question_carried" + printf '\n' + ;; + fix_expo_gitignore) + ios_question fix_expo_gitignore "$(app_root)" + printf '\n' + ;; asc_key|asc_issuer|asc_rejected|asc_conflict|asc_plan|help_asc_key) asc_status="$(status_json)" ios_question "$QUESTION_KIND" "$BUNDLE_ID" \ @@ -4695,6 +5086,15 @@ case "$COMMAND" in validate-apns) validate_apns ;; + import-from-android) + import_from_android + ;; + ignore-expo-env) + ignore_expo_env_cmd + ;; + deliver-mobile-key) + deliver_mobile_key_cmd + ;; proof-status) proof_status_json ;; diff --git a/bin/ios-questions.sh b/bin/ios-questions.sh index b3cc66d..9f90311 100644 --- a/bin/ios-questions.sh +++ b/bin/ios-questions.sh @@ -37,6 +37,66 @@ ios_q_free_text() { "$(jstr "$1")" "$(jstr "$2")" "$(jstr "$3")" } +ios_q_import_android() { + local summary="$1" features="${2:-}" + local scope_note="Feature scope is still asked, because the Android setup does not record it." + local reuse_description="Copies the Android Mobile and Server-side keys into the ignored iOS workspace and where the app reads them, without printing them." + local reuse_effect="keys, region, and No JWT recorded for iOS; the feature questions come next" + local reuse_command="$BIN/ios-provision import-from-android --confirm" + if [[ -n "$features" ]]; then + scope_note="Reusing also proposes the feature scope confirmed in the React Native skill: ${features//,/, }. It is recorded only after you confirm it on the scope screen, and Change the answers there asks it again." + reuse_description="Copies the Android Mobile and Server-side keys into the ignored iOS workspace and where the app reads them, without printing them, and proposes the features ${features//,/, }." + reuse_effect="keys, region, No JWT, and the proposed features recorded for iOS; the scope confirmation comes next" + reuse_command="$reuse_command --features $features" + fi + printf '{"header":"Android","prompt":%s,"selection":"single",' \ + "$(jstr "Reuse the Iterable keys and region the Android setup already recorded for this app?")" + printf '"context":%s,' "$(printf '%s\n\n%s\n' "$summary" \ + "One Mobile key and one Server-side key serve both platforms. Reusing them skips the region and JWT questions and the key-copy steps; the keys are still validated again for iOS. The Android setup requires Require JWT off, so iOS records No JWT. $scope_note" | ios_q_arr)" + printf '"requires":[],"recommended":%s,"before_commands":[],"options":[' \ + "$(jstr "Reuse the Android setup")" + ios_q_opt "Reuse the Android setup" \ + "$reuse_description" \ + "$reuse_effect" \ + "$reuse_command" + printf ',' + ios_q_opt "Set up iOS separately" \ + "Ask every iOS question and capture keys again, for example when iOS should use a different Iterable project." \ + "nothing imported; the feature questions come next" \ + "$BIN/ios-provision answer --field android-import --value skip" + printf ']}' +} + +ios_q_fix_expo_gitignore() { + local root="$1" + printf '{"header":"Expo env","prompt":%s,"selection":"single",' \ + "$(jstr "1. Open $root/.gitignore. +2. Add the line .env*.local (the Expo template keeps it under # local env files). +3. Save the file. + +Add .env*.local to .gitignore so the Mobile key in .env.local is never committed — done?")" + printf '"context":%s,' "$(ios_q_arr <<'EOF' +This Expo app reads the Mobile key from EXPO_PUBLIC_ITERABLE_API_KEY in .env.local. The tool writes it there only when git already ignores that file. +EOF +)" + printf '"requires":[],"recommended":%s,"before_commands":[],"options":[' \ + "$(jstr "Done — check again")" + ios_q_opt "Done — check again" \ + "Re-reads .gitignore and continues once .env.local is ignored." \ + "status runs again" \ + "$BIN/ios-provision status" + printf ',' + ios_q_opt "Help me — add it to .gitignore for me" \ + "Appends .env*.local under # local env files in $root/.gitignore, then checks git ignores .env.local." \ + ".gitignore updated; the Mobile key screen comes next" \ + "$BIN/ios-provision ignore-expo-env --confirm" + printf ',' + ios_q_opt "Stop here" \ + "No key is written into the project." \ + "the Mobile key stays out of the app" + printf ']}' +} + ios_q_choose_features() { printf '{"header":"Features","prompt":%s,"selection":"multiple",' \ "$(jstr "Which Iterable features should this integration include?")" @@ -258,7 +318,9 @@ EOF } ios_q_confirm_scope() { - local features="$1" identity="$2" region="$3" jwt="$4" + local features="$1" identity="$2" region="$3" jwt="$4" source="${5:-}" origin="" + [[ "$source" != react-native-step0 ]] || + origin=" (the scope confirmed in the React Native skill)" local next_description="secure Mobile API-key capture" local option_description="Writes the confirmed non-secret decisions to the ignored iOS workspace, then continues to Mobile API-key setup." local option_effect="scope.json recorded; Mobile API-key setup becomes the next state" @@ -270,7 +332,7 @@ ios_q_confirm_scope() { printf '{"header":"Confirm","prompt":%s,"selection":"single",' \ "$(jstr "Use this Iterable integration scope?")" printf '"context":%s,' "$(ios_q_arr </bin/ios-provision`. Conduct the interactive state machine in the main session. Do not hand the @@ -122,7 +125,13 @@ Inspect the app before invoking a program: delete the retained APNs private key. - For an iOS target owned by a React Native or Expo integration, keep the React Native skill in charge of application code. Use the iOS provisioning - path only for the Apple and Iterable prerequisites it explicitly needs. + path only for the Apple and Iterable prerequisites it explicitly needs. Run + the Android program first: the iOS path then offers to reuse its Mobile and + Server-side keys, region, No JWT policy, and test email, plus the feature + scope the React Native skill confirmed when passed with `--features`, and + writes the Mobile key where the JavaScript app reads it + (bare React Native `.iterable/rn/iterable.local.ts`, Expo + `EXPO_PUBLIC_ITERABLE_API_KEY` in a gitignored `.env.local`). Do not run the Android gate ladder against an iOS project. Its Firebase, `adb`, package-name, and FCM conclusions do not apply to APNs. @@ -570,4 +579,5 @@ it as part of its one confirmed diff. This skill produces the file; it does not write into the developer's source tree. After the integration is written, `iterable-android-verify` proves it with a -real push. +real push. For a React Native app with iOS in scope, that green verdict is +where the iOS phase begins. diff --git a/iterable-provision/reference/ios.md b/iterable-provision/reference/ios.md index e30ca34..0629d55 100644 --- a/iterable-provision/reference/ios.md +++ b/iterable-provision/reference/ios.md @@ -80,6 +80,30 @@ behavior, Mobile Inbox, and Embedded Messages. Initialization, identification, and the default Iterable in-app message display are included automatically. Identity, region, JWT policy, and final confirmation follow. +When the Android program already ran for this app (its `.iterable/.env` holds +a Mobile key), fresh status first asks `import_android`. Its recommended +option runs `import-from-android --confirm`: one Mobile key and one +Server-side key serve both platforms, so it reuses them with the Android +region and No JWT (the Android flow requires Require JWT off), never prints +them, and both keys are still validated again for iOS. The Android setup +records no feature scope, so the React Native skill passes the scope its Step 0 +confirmed as `--features` with comma-separated ids — exactly the ones +`choose_features` and `choose_more_features` record: `push-notifications`, +`event-tracking`, `user-profiles`, `deep-linking`, `in-app-messages`, +`mobile-inbox`, `embedded-messaging`, or `sdk-initialization` alone for +initialization only. Pass it to `status` first (`status --features …`) so the +`import_android` screen names the scope, and its recommended option carries the +same list to `import-from-android`. Any other id is refused and nothing is +recorded. The list is recorded as the `choose_features` answer with +`scope_source: "react-native-step0"`, so those screens are skipped, and +`confirm_scope` shows it with its origin before anything is confirmed; **Change +the answers** asks it again. Without `--features` the feature questions follow +as usual. **Set up iOS separately** asks everything again. In a monorepo the Android workspace sits +at the repository root, so the search walks up from the app root to the git +toplevel, takes the nearest `.iterable/.env`, and records that path as +`iterable.imported_from`. Android package auto-detection does not support +monorepos yet: there the developer types the package name. + Each question has the same core fields as the Android conducted workflow: - `context`, `header`, and `prompt` are developer-facing words; @@ -236,6 +260,17 @@ clipboard into `.iterable/ios/.env`, generates `.iterable/ios/IterableSecrets.xcconfig`, restricts both files to mode `0600`, and clears the clipboard. It never prints the key. +Where the key goes follows the app's framework, read from `package.json` and +`app.json` at the app root. Bare React Native gets +`.iterable/rn/iterable.local.ts` instead of the xcconfig: it exports +`ITERABLE_API_KEY` inside the self-ignoring workspace, and the app loads it +with an optional `require` in `try`/`catch`, because a static import breaks +fresh clones and CI. Expo gets `EXPO_PUBLIC_ITERABLE_API_KEY` in `.env.local`, +written only when git ignores that file; otherwise status returns +`fix_expo_gitignore` before any key is written. If the delivered file goes +missing, status returns `deliver_mobile_key`, which rewrites it from the +workspace. + Next, prove that the value is a Mobile key for the recorded region: ```text @@ -481,6 +516,8 @@ to `iterable-ios` for application wiring and proof. The generated `.iterable/ios/IterableSecrets.xcconfig` is the secure handoff to Xcode. Include it from the app's committed base configuration using the correct relative path; do not copy or print its value. +For React Native or Expo, the delivered module or `.env.local` is the handoff +instead. Keep proof levels distinct: diff --git a/tests/ios-prepare-proof.sh b/tests/ios-prepare-proof.sh index eb24ada..608ce64 100755 --- a/tests/ios-prepare-proof.sh +++ b/tests/ios-prepare-proof.sh @@ -271,6 +271,40 @@ else bad "the one-answer Simulator start did not record a neutral development run" "$sim_started" fi +mkdir -p "$ROOT/Fresh.app" "$ROOT/Recorded.app" +fresh_app="$(cd "$ROOT/Fresh.app" && pwd -P)" +recorded_app="$(cd "$ROOT/Recorded.app" && pwd -P)" +run_sim_prepare restart --confirm --app-path "$ROOT/Fresh.app" >/dev/null 2>&1 +rebound="$(json_value "$(cat "$SIMSPACE/proof-target.json")" app_path)" +[[ "$rebound" == "$fresh_app" ]] && + ok "restart --app-path binds a fresh build to a run started without one" || + bad "restart ignored --app-path" "app_path=$rebound" + +node -e ' + const fs=require("fs"), p=process.argv[1], v=JSON.parse(fs.readFileSync(p,"utf8")); + v.app_path=process.argv[2]; + fs.writeFileSync(p,JSON.stringify(v,null,2)+"\n",{mode:0o600}); +' "$SIMSPACE/proof-target.json" "$recorded_app" +run_sim_prepare restart --confirm >/dev/null 2>&1 +kept="$(json_value "$(cat "$SIMSPACE/proof-target.json")" app_path)" +[[ "$kept" == "$recorded_app" ]] && + ok "restart without --app-path keeps the run's recorded app path" || + bad "restart without --app-path dropped the recorded app path" "app_path=$kept" + +before_missing="$(cat "$SIMSPACE/proof-target.json")" +archives_before="$(find "$SIMSPACE/archive" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" +run_sim_prepare restart --confirm --app-path "$ROOT/Missing.app" >/dev/null 2>"$ROOT/missing.err" +missing_rc=$? +archives_after="$(find "$SIMSPACE/archive" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" +if [[ "$missing_rc" -ne 0 && "$(cat "$SIMSPACE/proof-target.json")" == "$before_missing" && + "$archives_after" == "$archives_before" ]] && + grep -q 'must name an existing .app bundle' "$ROOT/missing.err"; then + ok "restart refuses an --app-path that is not an existing .app before archiving anything" +else + bad "restart accepted a missing .app or archived the run first" \ + "rc=$missing_rc archives=$archives_before->$archives_after $(cat "$ROOT/missing.err")" +fi + MINBIN="$ROOT/minbin" MINAPP="$ROOT/minapp" MINSPACE="$MINAPP/.iterable/ios" diff --git a/tests/ios-questions.sh b/tests/ios-questions.sh index 69c08cb..74822da 100755 --- a/tests/ios-questions.sh +++ b/tests/ios-questions.sh @@ -75,6 +75,14 @@ question() { ios_q_apple_step_push "${bundle:-com.example.dogshelter}" return ;; + import_android) + ios_q_import_android "Android setup: Mobile key, Server-side key, EU region." + return + ;; + fix_expo_gitignore) + ios_q_fix_expo_gitignore "$ROOT/expo-app" + return + ;; check_jwt) ios_q_check_jwt "${bundle:-US}" return @@ -148,7 +156,7 @@ const artifact = new Set([ "configure_iterable_app", "configure_iterable_push", "proof_region", "proof_identity_type", "proof_bundle", "proof_identity", "proof_switch_identity", "proof_app", "asc_key", "asc_issuer", - "asc_rejected", "asc_conflict", "replace_apns_key" + "asc_rejected", "asc_conflict", "replace_apns_key", "fix_expo_gitignore" ]); const developerSide = /^(I don't have|I do not have|I don't know|I'm not sure|I am not sure|Help me)\b/; const concrete = text => /https?:\/\//.test(text) || /(^|\s)1\.\s/.test(text) || /\/bin\/ios-/.test(text); @@ -413,7 +421,11 @@ screen_dump() { printf '%s\t%s\n' "$kind" "$(ios_question "$kind")" done printf 'check_jwt\t%s\n' "$(ios_q_check_jwt US)" + printf 'import_android\t%s\n' "$(ios_q_import_android "Android setup: Mobile key, Server-side key, EU region.")" + printf 'import_android\t%s\n' "$(ios_q_import_android "Android setup: Mobile key, Server-side key, EU region." push-notifications,sdk-initialization)" + printf 'fix_expo_gitignore\t%s\n' "$(ios_q_fix_expo_gitignore /app)" printf 'confirm_scope\t%s\n' "$(ios_q_confirm_scope push-notifications email US false)" + printf 'confirm_scope\t%s\n' "$(ios_q_confirm_scope push-notifications email US false react-native-step0)" printf 'configure_iterable\t%s\n' "$(ios_q_configure_iterable US false)" printf 'capture_iterable\t%s\n' "$(ios_q_capture_iterable US false)" printf 'configure_server\t%s\n' "$(ios_q_configure_server US false)" @@ -588,6 +600,8 @@ append_question choose_identity append_question choose_region append_question choose_jwt append_question check_jwt EU +append_question import_android +append_question fix_expo_gitignore append_question configure_iterable append_question capture_iterable append_question configure_server US diff --git a/tests/ios-rn-provision.sh b/tests/ios-rn-provision.sh new file mode 100755 index 0000000..a528b7b --- /dev/null +++ b/tests/ios-rn-provision.sh @@ -0,0 +1,418 @@ +#!/usr/bin/env bash +# React Native and Expo on the iOS provisioning path: where the Mobile key is +# delivered for each framework, and reusing the keys an Android setup recorded. +# Fakes only: clipboard, curl. Fixture trees stand in for native, bare RN, Expo. + +set -uo pipefail +cd "$(dirname "$0")/.." || exit +REPO="$PWD" + +FAILED=0 +ok() { printf 'PASS %s\n' "$1"; } +bad() { + printf 'FAIL %s\n' "$1" + [[ $# -lt 2 ]] || printf ' %s\n' "$2" + FAILED=1 +} + +ROOT="$(mktemp -d "${TMPDIR:-/tmp}/iterable-ios-rn-provision.XXXXXX")" +case "$ROOT" in + "${TMPDIR:-/tmp}"/iterable-ios-rn-provision.*|/private/tmp/iterable-ios-rn-provision.*) ;; + *) echo "refusing unexpected temporary path: $ROOT" >&2; exit 1 ;; +esac +trap 'rm -rf "$ROOT"' EXIT + +FAKEBIN="$ROOT/bin" +STATE="$ROOT/state" +mkdir -p "$FAKEBIN" "$STATE" +cat > "$FAKEBIN/pbpaste" <<'EOF' +#!/usr/bin/env bash +cat "$RN_STATE/clipboard" +EOF +cat > "$FAKEBIN/pbcopy" <<'EOF' +#!/usr/bin/env bash +cat > "$RN_STATE/clipboard" +EOF +cat > "$FAKEBIN/curl" <<'EOF' +#!/usr/bin/env bash +config="$(cat)" +printf 'curl %s\n' "$*" >> "$RN_STATE/calls" +key="$(sed -n 's/^header = "Api-Key: \(.*\)"$/\1/p' <<< "$config")" +case "$key" in + mobile-key-canary-*) + if [[ "$*" == *registerDeviceToken* ]]; then + printf '{"msg":"Invalid parameters"}\n400\n' + else + printf '{"msg":"API key has insufficient privileges","code":"Unauthorized"}\n401\n' + fi + ;; + server-key-canary-*) + if [[ "$*" == *registerDeviceToken* ]]; then + printf '{"msg":"Invalid parameters"}\n400\n' + else + printf '{"channels":[{"messageMedium":"Push"}]}\n200\n' + fi + ;; + *) printf '{"msg":"invalid api key"}\n401\n' ;; +esac +EOF +cat > "$FAKEBIN/xcodebuild" <<'EOF' +#!/usr/bin/env bash +echo "Xcode 18.0" +EOF +chmod +x "$FAKEBIN"/* + +MOBILE_KEY="mobile-key-canary-1234567890" +SERVER_KEY="server-key-canary-1234567890" + +run_in() { + local app="$1" + shift + (cd "$app" && env PATH="$FAKEBIN:$PATH" RN_STATE="$STATE" \ + IOS_WORKSPACE="$app/.iterable/ios" "$REPO/bin/ios-provision" "$@") +} + +field() { + node -e ' + const value = process.argv[2].split(".").reduce( + (object, key) => object == null ? object : object[key], JSON.parse(process.argv[1])); + process.stdout.write(value === null ? "null" : + typeof value === "object" ? JSON.stringify(value) : String(value ?? "")); + ' "$1" "$2" +} + +new_app() { + local app="$ROOT/$1" kind="$2" + mkdir -p "$app" + case "$kind" in + react-native) printf '{"dependencies":{"react-native":"0.88.0"}}\n' > "$app/package.json" ;; + expo) printf '{"dependencies":{"expo":"~58.0.14","react-native":"0.88.0"}}\n' > "$app/package.json" ;; + esac + printf '%s' "$app" +} + +scope_to_capture() { + local app="$1" + run_in "$app" record-scope --features event-tracking --identity-mode email \ + --region US --jwt-enabled false --confirm >/dev/null 2>&1 + run_in "$app" record-iterable-setup --driver guided --confirm >/dev/null 2>&1 +} + +capture() { + printf '%s' "$MOBILE_KEY" > "$STATE/clipboard" + run_in "$1" capture-mobile --region US --jwt-enabled false --confirm 2>"$STATE/capture.err" +} + +# Native: the Xcode config, as before. +NATIVE="$(new_app native native)" +scope_to_capture "$NATIVE" +native_captured="$(capture "$NATIVE")" +if [[ "$(field "$native_captured" iterable.framework)" == native && + "$(field "$native_captured" iterable.key_delivery)" == xcconfig && + "$(field "$native_captured" iterable.key_delivery_ok)" == true && + "$(field "$native_captured" next.kind)" == validate_iterable && + "$(stat -f '%Lp' "$NATIVE/.iterable/ios/IterableSecrets.xcconfig")" == 600 && + ! -e "$NATIVE/.iterable/rn" ]]; then + ok "a native app still gets IterableSecrets.xcconfig" +else + bad "the native key delivery changed" +fi + +# Bare React Native: an ignored TypeScript module, and no xcconfig. +RN="$(new_app rn react-native)" +scope_to_capture "$RN" +rn_captured="$(capture "$RN")" +rn_module="$RN/.iterable/rn/iterable.local.ts" +if [[ "$(field "$rn_captured" iterable.framework)" == react-native && + "$(field "$rn_captured" iterable.key_delivery)" == rn-module && + "$(field "$rn_captured" iterable.key_delivery_ok)" == true && + "$(field "$rn_captured" next.kind)" == validate_iterable && + "$(stat -f '%Lp' "$rn_module")" == 600 && + "$(cat "$RN/.iterable/rn/.gitignore")" == '*' && + ! -e "$RN/.iterable/ios/IterableSecrets.xcconfig" ]] && + grep -qx "export const ITERABLE_API_KEY = \"$MOBILE_KEY\";" "$rn_module" && + ! grep -q "$MOBILE_KEY" <<< "$rn_captured$(cat "$STATE/capture.err")"; then + ok "bare React Native gets .iterable/rn/iterable.local.ts at 0600 and no xcconfig" +else + bad "bare React Native key delivery is wrong" +fi + +rm -f "$rn_module" +missing_module="$(run_in "$RN" status)" +redelivered="$(run_in "$RN" deliver-mobile-key 2>/dev/null)" +if [[ "$(field "$missing_module" next.kind)" == deliver_mobile_key && + "$(field "$missing_module" next.owner)" == tool && + "$(field "$redelivered" next.kind)" == validate_iterable && + -f "$rn_module" ]]; then + ok "a missing React Native key module is rewritten from the workspace, not re-captured" +else + bad "a missing React Native key module left a dead end" +fi + +FROM_IOS="$(new_app from-ios react-native)" +mkdir -p "$FROM_IOS/ios/App.xcodeproj" +: > "$FROM_IOS/ios/App.xcodeproj/project.pbxproj" +from_ios="$(cd "$FROM_IOS/ios" && env PATH="$FAKEBIN:$PATH" RN_STATE="$STATE" "$REPO/bin/ios-provision" status)" +if [[ "$(field "$from_ios" workspace)" == "$(cd "$FROM_IOS" && pwd -P)/.iterable/ios" && + "$(field "$from_ios" iterable.framework)" == react-native && + "$(field "$from_ios" iterable.key_delivery_path)" == "$(cd "$FROM_IOS" && pwd -P)/.iterable/rn/iterable.local.ts" ]]; then + ok "run from a React Native app's ios/ folder, the key still goes to the app root's module" +else + bad "run from ios/, the React Native key delivery resolved the wrong root" \ + "$(field "$from_ios" workspace) $(field "$from_ios" iterable.key_delivery_path)" +fi + +forced="$(run_in "$FROM_IOS" status --framework native)" +run_in "$FROM_IOS" status --framework flutter >/dev/null 2>"$STATE/framework.err" +forced_bad=$? +if [[ "$(field "$forced" iterable.framework)" == native && + "$(field "$forced" iterable.key_delivery)" == xcconfig && "$forced_bad" -eq 2 ]] && + grep -q 'native, react-native, or expo' "$STATE/framework.err"; then + ok "--framework overrides the resolver, and an unknown framework is refused" +else + bad "--framework was ignored or accepted an unknown framework" +fi + +# Expo: .env.local, only when git ignores it. +EXPO="$(new_app expo expo)" +printf 'node_modules/\n' > "$EXPO/.gitignore" +printf 'OTHER_SETTING=1\n' > "$EXPO/.env.local" +scope_to_capture "$EXPO" +expo_blocked="$(run_in "$EXPO" status)" +printf '%s' "$MOBILE_KEY" > "$STATE/clipboard" +run_in "$EXPO" capture-mobile --region US --jwt-enabled false --confirm \ + >/dev/null 2>"$STATE/expo-refused.err" +expo_refused_rc=$? +if [[ "$(field "$expo_blocked" next.kind)" == fix_expo_gitignore && + "$(field "$expo_blocked" next.question.prompt)" == *"Add the line .env*.local"* && + "$expo_refused_rc" -eq 2 && + "$(cat "$STATE/clipboard")" == "$MOBILE_KEY" ]] && + ! grep -q EXPO_PUBLIC "$EXPO/.env.local" && + grep -q 'does not ignore' "$STATE/expo-refused.err"; then + ok "Expo refuses to write the key until .gitignore covers .env.local" +else + bad "Expo wrote the key into a file git would commit" +fi + +printf '.env*.local\n' >> "$EXPO/.gitignore" +expo_ready="$(run_in "$EXPO" status)" +expo_captured="$(capture "$EXPO")" +if [[ "$(field "$expo_ready" next.kind)" == configure_iterable && + "$(field "$expo_captured" iterable.key_delivery)" == expo-env && + "$(field "$expo_captured" iterable.key_delivery_ok)" == true && + "$(stat -f '%Lp' "$EXPO/.env.local")" == 600 && + ! -e "$EXPO/.iterable/ios/IterableSecrets.xcconfig" ]] && + grep -qx "EXPO_PUBLIC_ITERABLE_API_KEY=$MOBILE_KEY" "$EXPO/.env.local" && + grep -qx 'OTHER_SETTING=1' "$EXPO/.env.local"; then + ok "Expo gets EXPO_PUBLIC_ITERABLE_API_KEY in .env.local, keeping its other lines" +else + bad "Expo key delivery is wrong" +fi + +GIT_EXPO="$(new_app git-expo expo)" +git -C "$GIT_EXPO" init -q +printf '.env*.local\n.iterable/\n' > "$GIT_EXPO/.gitignore" +scope_to_capture "$GIT_EXPO" +git_expo="$(capture "$GIT_EXPO")" +if [[ "$(field "$git_expo" iterable.key_delivery_ok)" == true ]] && + git -C "$GIT_EXPO" check-ignore -q .env.local; then + ok "in a git repository, Expo asks git whether .env.local is ignored" +else + bad "Expo key delivery in a git repository is wrong" +fi + +# Reusing the Android setup: the keys and region it recorded, validated again. +android_setup() { + local app="$1" base="$2" + mkdir -p "$app/.iterable" + printf '*\n' > "$app/.iterable/.gitignore" + ( + umask 077 + { + printf 'ITBL_SERVER_KEY=%s\n' "$SERVER_KEY" + printf 'ITBL_MOBILE_KEY=%s\n' "$MOBILE_KEY" + printf 'ITBL_EMAIL=dev@example.com\n' + [[ -z "$base" ]] || printf 'ITBL_BASE=%s\n' "$base" + } > "$app/.iterable/.env" + ) +} +SHARED="$(new_app shared react-native)" +android_setup "$SHARED" https://api.eu.iterable.com +offer="$(run_in "$SHARED" status)" +imported="$(run_in "$SHARED" import-from-android --confirm 2>"$STATE/import.err")" +for answer in "feature-push false" "feature-events true" "feature-profiles true" \ + "feature-deep-links false" "feature-more false"; do + read -r f v <<< "$answer" + run_in "$SHARED" answer --field "$f" --value "$v" >/dev/null +done +scoped="$(run_in "$SHARED" record-scope --from-answers --confirm 2>/dev/null)" +: > "$STATE/calls" +mobile_ok="$(run_in "$SHARED" validate-mobile 2>/dev/null)" +server_ok="$(run_in "$SHARED" validate-server 2>/dev/null)" +if [[ "$(field "$offer" next.kind)" == import_android && + "$(field "$offer" next.question.recommended)" == "Reuse the Android setup" && + "$(field "$offer" next.question.options.0.commands.0)" == *"import-from-android --confirm" && + "$(field "$imported" next.kind)" == choose_features && + "$(field "$imported" discovery.data_region)" == EU && + "$(field "$imported" discovery.jwt_enabled)" == false && + "$(field "$imported" discovery.identity_mode)" == email && + "$(field "$imported" iterable.key_delivery_ok)" == true && + "$(field "$scoped" next.kind)" == validate_iterable && + "$(field "$mobile_ok" next.kind)" == validate_server && + "$(field "$server_ok" next.kind)" == integrate_app && + "$(grep -c 'api.eu.iterable.com' "$STATE/calls")" -ge 3 ]] && + grep -qx "ITERABLE_SERVER_KEY=$SERVER_KEY" "$SHARED/.iterable/ios/.env" && + grep -qx "export const ITERABLE_API_KEY = \"$MOBILE_KEY\";" "$SHARED/.iterable/rn/iterable.local.ts" && + ! grep -q "$MOBILE_KEY\|$SERVER_KEY" <<< "$offer$imported$(cat "$STATE/import.err")"; then + ok "the Android setup's keys, EU region, and No JWT are reused, delivered, and validated again" +else + bad "reusing the Android setup asked again, skipped validation, or exposed a key" +fi + +if [[ "$(field "$offer" next.question.context)" == *"Feature scope is still asked"* && + "$(field "$imported" discovery.scope_source)" == "" && + "$(field "$scoped" scope.source)" == "" ]]; then + ok "without --features the import proposes no scope and the feature questions are asked" +else + bad "an import without --features proposed or recorded a feature scope" +fi + +# The scope the React Native skill confirmed in Step 0, carried into iOS. +CARRY="$(new_app carry react-native)" +android_setup "$CARRY" "" +carried_ids="event-tracking,push-notifications,sdk-initialization,user-profiles" +carry_offer="$(run_in "$CARRY" status --features push-notifications,event-tracking,user-profiles)" +carry_imported="$(run_in "$CARRY" import-from-android --confirm \ + --features push-notifications,event-tracking,user-profiles 2>"$STATE/carry.err")" +carry_scoped="$(run_in "$CARRY" record-scope --from-answers --confirm 2>/dev/null)" +if [[ "$(field "$carry_offer" next.kind)" == import_android && + "$(field "$carry_offer" next.question.context)" == *"React Native skill: event-tracking, push-notifications, sdk-initialization, user-profiles."* && + "$(field "$carry_offer" next.question.options.0.description)" == *"proposes the features event-tracking, push-notifications"* && + "$(field "$carry_offer" next.question.options.0.commands.0)" == *"import-from-android --confirm --features $carried_ids" && + "$(field "$carry_offer" next.command)" == *"question --kind import_android --features $carried_ids" && + "$(field "$carry_imported" next.kind)" == confirm_scope && + "$(field "$carry_imported" discovery.features)" == "$carried_ids" && + "$(field "$carry_imported" discovery.scope_source)" == react-native-step0 && + "$(field "$carry_imported" next.question.context)" == *"Features: $carried_ids (the scope confirmed in the React Native skill)"* && + "$(field "$carry_scoped" scope.features)" == "$carried_ids" && + "$(field "$carry_scoped" scope.source)" == react-native-step0 ]] && + grep -q "Proposed the feature scope confirmed in the React Native skill" "$STATE/carry.err"; then + ok "a Step 0 scope passed with --features is shown on the import and confirmation screens, skips choose_features, and keeps its source" +else + bad "the Step 0 scope was not carried, shown, or attributed" +fi + +CHANGED="$(new_app changed react-native)" +android_setup "$CHANGED" "" +run_in "$CHANGED" import-from-android --confirm --features push-notifications >/dev/null 2>&1 +changed="$(run_in "$CHANGED" answer --field features --value event-tracking)" +if [[ "$(field "$changed" discovery.features)" == event-tracking,sdk-initialization && + "$(field "$changed" discovery.scope_source)" == "" && + "$(field "$changed" next.question.context)" != *"React Native skill"* ]]; then + ok "a scope the developer changes on iOS is no longer attributed to the React Native skill" +else + bad "a changed scope still claims it came from the React Native skill" +fi + +BOGUS="$(new_app bogus react-native)" +android_setup "$BOGUS" "" +run_in "$BOGUS" status --features push-notifications,unknown-user-activation \ + >/dev/null 2>"$STATE/bogus-status.err" +bogus_status_rc=$? +run_in "$BOGUS" import-from-android --confirm --features push-notifications,inbox \ + >/dev/null 2>"$STATE/bogus.err" +bogus_rc=$? +bogus_after="$(run_in "$BOGUS" status)" +if [[ "$bogus_status_rc" -eq 2 && "$bogus_rc" -eq 2 && + ! -e "$BOGUS/.iterable/ios/.env" && ! -e "$BOGUS/.iterable/ios/discovery.json" && + ! -e "$BOGUS/.iterable/rn" && + "$(field "$bogus_after" next.kind)" == import_android ]] && + grep -q -- '--features takes the ids choose_features records' "$STATE/bogus.err" && + grep -q -- '--features takes the ids choose_features records' "$STATE/bogus-status.err"; then + ok "an id choose_features does not record is refused, and nothing is written" +else + bad "an unknown --features id was accepted or left something behind" +fi + +SKIP="$(new_app skip react-native)" +android_setup "$SKIP" "" +skipped="$(run_in "$SKIP" answer --field android-import --value skip)" +if [[ "$(field "$skipped" next.kind)" == choose_features && + "$(field "$skipped" iterable.mobile_key_present)" == false ]]; then + ok "setting iOS up separately asks every question again" +else + bad "declining the Android reuse still imported something" +fi + +EXPO_SHARED="$(new_app expo-shared expo)" +printf 'node_modules/\n' > "$EXPO_SHARED/.gitignore" +android_setup "$EXPO_SHARED" "" +run_in "$EXPO_SHARED" import-from-android --confirm >/dev/null 2>&1 +run_in "$EXPO_SHARED" record-scope --features event-tracking --identity-mode email \ + --region US --jwt-enabled false --confirm >/dev/null 2>&1 +held="$(run_in "$EXPO_SHARED" status)" +held_env=absent +[[ ! -e "$EXPO_SHARED/.env.local" ]] || held_env=present +helped="$(run_in "$EXPO_SHARED" ignore-expo-env --confirm 2>/dev/null)" +expo_delivered="$(run_in "$EXPO_SHARED" deliver-mobile-key 2>/dev/null)" +if [[ "$(field "$held" next.kind)" == fix_expo_gitignore && + "$(field "$held" iterable.mobile_key_present)" == true && + "$held_env" == absent && + "$(field "$helped" next.kind)" == deliver_mobile_key && + "$(field "$expo_delivered" next.kind)" == validate_iterable && + "$(field "$expo_delivered" iterable.key_delivery_ok)" == true ]] && + grep -qx '.env\*.local' "$EXPO_SHARED/.gitignore" && + grep -qx 'node_modules/' "$EXPO_SHARED/.gitignore" && + grep -qx "EXPO_PUBLIC_ITERABLE_API_KEY=$MOBILE_KEY" "$EXPO_SHARED/.env.local"; then + ok "an Expo import holds the key until .env.local is ignored, and the help option ignores it" +else + bad "an Expo import wrote the key early or the .gitignore help left a dead end" +fi + +MONO="$ROOT/mono" +mkdir -p "$MONO/apps" +git -C "$MONO" init -q +android_setup "$MONO" https://api.eu.iterable.com +MONO_APP="$(new_app mono/apps/mobile react-native)" +MONO_REAL="$(cd "$MONO" && pwd -P)" +mono_offer="$(run_in "$MONO_APP" status)" +mono_imported="$(run_in "$MONO_APP" import-from-android --confirm 2>"$STATE/mono.err")" +if [[ "$(field "$mono_offer" next.kind)" == import_android && + "$(field "$mono_offer" next.question.context)" == *"$MONO_REAL/.iterable"* && + "$(field "$mono_imported" iterable.imported_from)" == "$MONO_REAL/.iterable/.env" && + "$(field "$(cat "$MONO_APP/.iterable/ios/discovery.json")" android_import_path)" == "$MONO_REAL/.iterable/.env" && + "$(field "$mono_imported" discovery.data_region)" == EU && + "$(field "$mono_imported" iterable.key_delivery_ok)" == true && + -f "$MONO_APP/.iterable/rn/iterable.local.ts" ]] && + grep -q "$MONO_REAL/.iterable/.env" "$STATE/mono.err" && + ! grep -q "$MOBILE_KEY\|$SERVER_KEY" <<< "$mono_offer$mono_imported$(cat "$STATE/mono.err")"; then + ok "in a monorepo, the Android setup at the git root is found from the app below it and its path recorded" +else + bad "a monorepo app did not find or record the Android setup at the git root" +fi + +LOOSE="$ROOT/loose" +android_setup "$LOOSE" "" +LOOSE_APP="$(new_app loose/app react-native)" +loose="$(run_in "$LOOSE_APP" status)" +if [[ "$(field "$loose" next.kind)" == choose_features ]]; then + ok "outside a git repository, no Android setup above the app root is picked up" +else + bad "an Android setup outside the app's repository was offered" +fi + +CLASH="$(new_app clash react-native)" +android_setup "$CLASH" "" +mkdir -p "$CLASH/.iterable/ios" +(umask 077; printf 'ITERABLE_API_KEY=mobile-key-canary-different-0000\n' > "$CLASH/.iterable/ios/.env") +run_in "$CLASH" import-from-android --confirm >/dev/null 2>"$STATE/clash.err" +clash_rc=$? +if [[ "$clash_rc" -eq 2 ]] && + grep -q 'mobile-key-canary-different-0000' "$CLASH/.iterable/ios/.env"; then + ok "import refuses to replace a different iOS Mobile key" +else + bad "import overwrote a different iOS Mobile key" +fi + +((FAILED)) && exit 1 +exit 0 From 2b9f94ce7ee2490599e0807e9a6326c76a26b347 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 20 Oct 2026 15:00:19 +0100 Subject: [PATCH 05/17] Show each Android provisioning screen's steps in its prompt A live trial's agent never printed context on any Android screen, so the developer saw "Which key is on your clipboard?" with no steps. The Firebase approval also asked them to agree to "the changes listed above" with no list on screen. The steps, links, values, and the list of changes being approved now go in the prompt, with the question last, as on iOS. Steps 1 and 2 link to app.eu.iterable.com for an EU base, and "show again" re-asks the question instead of printing into a tool result nobody sees. The package screens recommend the applicationId read from the app's app.json or build.gradle. Co-Authored-By: Claude Opus 5.5 --- bin/config.sh | 24 ++++++++ bin/iterable-keys | 58 ++++++++++++++---- bin/questions.sh | 117 ++++++++++++++++++++++++----------- copy/screens/opening.md | 4 ++ tests/agent-questions.sh | 128 +++++++++++++++++++++++++++++++++------ 5 files changed, 266 insertions(+), 65 deletions(-) diff --git a/bin/config.sh b/bin/config.sh index ab42fec..171a135 100755 --- a/bin/config.sh +++ b/bin/config.sh @@ -161,6 +161,30 @@ load_env_file "$WS/resolved.env" : "${PID:=}" : "${PACKAGE:=}" + +# Prints "\t", or nothing when no single applicationId is readable. +project_package() { + local root="${PROJECT_ROOT:-$(dirname "$WS")}" f value="" source="" + if [[ -f "$root/app.json" ]]; then + value="$(node -e ' + try { + const app = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(app?.expo?.android?.package || "")); + } catch {} + ' "$root/app.json" 2>/dev/null)" + [[ -z "$value" ]] || source="app.json" + fi + if [[ -z "$value" ]]; then + for f in android/app/build.gradle android/app/build.gradle.kts app/build.gradle app/build.gradle.kts; do + [[ -f "$root/$f" ]] || continue + value="$(sed -nE "s/^[[:space:]]*applicationId[[:space:]]*(=[[:space:]]*)?[\"']([A-Za-z][A-Za-z0-9_.]*)[\"'].*/\\2/p" "$root/$f" | sort -u)" + source="$f" + break + done + fi + [[ -n "$value" && "$value" != *$'\n'* && "$value" == *.* ]] || return 0 + printf '%s\t%s' "$value" "$source" +} : "${SA_ID:=itbl-onboard-fcm}" : "${APP_DISPLAY_NAME:=Iterable Onboard Test}" diff --git a/bin/iterable-keys b/bin/iterable-keys index d795ed1..757ed46 100755 --- a/bin/iterable-keys +++ b/bin/iterable-keys @@ -20,12 +20,17 @@ source "$(dirname "${BASH_SOURCE[0]}")/config.sh" # skim: what survives skimming is a numbered list and an arrow at the value that matters. # The two arrows are not decoration — JWT and the FCM type are the whole reason this walk # is narrated rather than linked. +# EU projects sign in on their own host; a US link there lands on the wrong login. +dash_host() { + [[ "$ITBL_BASE" == https://api.eu.iterable.com ]] && printf app.eu.iterable.com || printf app.iterable.com +} + step_keys() { cat <}") ← character for character @@ -85,6 +90,26 @@ step_identity() { EOF } +# The question-screen form: the identity text above is a paragraph a chat selector can't +# hold, and the join-key point is the one thing in it that has to survive. +step_identity_short() { + echo "Use the exact address your app passes to setEmail(). It is the join key: if the app and Iterable disagree, the token is filed under one identity and the proof push goes to the other." +} + +# A step as a question prompt: no heading or colour, and each numbered line or paragraph +# joined onto one line so a chat selector doesn't break it mid-sentence. +as_prompt() { + plain | awk ' + function flush() { if (cur != "") { if (gap && wrote) print ""; print cur; cur = ""; gap = 0; wrote = 1 } } + { sub(/^[[:space:]]+/, ""); sub(/[[:space:]]+$/, ""); gsub(/[[:space:]]+/, " ") } + /^Step [0-9] of 4/ { next } + $0 == "" { flush(); gap = 1; next } + /^[0-9]+\. / { flush(); cur = $0; next } + { cur = cur == "" ? $0 : cur " " $0 } + END { flush() } + ' +} + # Where the values go, printed with the step that produces them. A destination named in a # block at the end is a destination nobody reads until after they have already pasted the # key somewhere else — and on the agent path there is no end to put it at, because the @@ -249,7 +274,7 @@ banner() { # experiences it. Four screens of instructions handed over at once is a document; # a developer who is on step 2 wants step 2. # -# --relay prints the clicks and nothing around them, for a question to carry into a chat. +# --prompt prints the clicks and nothing around them, for a question to carry into a chat. # What it leaves out is everything that names a command: the screen it goes on already # has the two capture lines and the next step as options, and a command line printed # beside an option that runs it is the "type this yourself" the chat path exists to @@ -258,33 +283,40 @@ banner() { if [[ "${1:-}" == "--step" ]]; then ensure_env || true STEP_N="${2:-}" - RELAY="" + PROMPT="" case "${3:-}" in "") ;; - --relay) RELAY=1 ;; - *) echo "usage: $(cmd_path iterable-keys) --step 1|2|3|4 [--relay]" >&2; exit 30 ;; + --prompt) PROMPT=1 ;; + *) echo "usage: $(cmd_path iterable-keys) --step 1|2|3|4 [--prompt]" >&2; exit 30 ;; esac case "$STEP_N" in - 1) [[ -n "$RELAY" ]] || banner ;; + 1) [[ -n "$PROMPT" ]] || banner ;; 2|3|4) ;; - *) echo "usage: $(cmd_path iterable-keys) --step 1|2|3|4 [--relay]" >&2; exit 30 ;; + *) echo "usage: $(cmd_path iterable-keys) --step 1|2|3|4 [--prompt]" >&2; exit 30 ;; esac + if [[ -n "$PROMPT" ]]; then + case "$STEP_N" in + 1) step_keys | as_prompt ;; + 2) step_mobile_app | as_prompt ;; + 3) step_push_integration | as_prompt ;; + 4) step_identity_short ;; + esac + exit 0 + fi # Every step says where it is, not just the first: somebody who arrives at step 3 by # following the one before it has no other way of knowing how much is left. In the # heading rather than on a line of its own, so the inline walk gets it too. case "$STEP_N" in - 1) step_keys; [[ -n "$RELAY" ]] || where_keys ;; + 1) step_keys; where_keys ;; 2) step_mobile_app ;; 3) step_push_integration ;; - 4) step_identity; [[ -n "$RELAY" ]] || { offer_identity; where_identity; } ;; + 4) step_identity; offer_identity; where_identity ;; esac # A walk has to name its own next step. Only the fourth knew it had a successor, so a # developer handed step 1 — which is exactly what the pending ending hands them — did # that one step and hit a dead end, with nothing on screen saying three more existed. # It reads as a one-step job that silently failed. - if [[ -n "$RELAY" ]]; then - : - elif ((STEP_N < 4)); then + if ((STEP_N < 4)); then echo " When that is done:" echo run_line iterable-keys --step $((STEP_N + 1)) diff --git a/bin/questions.sh b/bin/questions.sh index a1f31d1..3c05cb9 100644 --- a/bin/questions.sh +++ b/bin/questions.sh @@ -92,7 +92,7 @@ q_opening() { here="$(screen_copy_option_scalar opening agent Label)" printf '{"header":%s,"prompt":%s,' \ "$(jstr "$(screen_copy_scalar opening Header)")" \ - "$(jstr "$(screen_copy_scalar opening Prompt)")" + "$(jstr "$(screen_copy_section opening Prompt)")" printf '"context":%s,' "$(q_opening_context | q_arr)" printf '"requires":%s,' "$(screen_copy_section opening Requirements | q_arr)" printf '"recommended":%s,' "$(jstr "$here")" @@ -151,7 +151,9 @@ q_choose_target() { local look="Yes — list my Firebase projects" tok tok="$(ask_for choose_target)" || tok="" printf '{"header":"Discover","prompt":%s,' \ - "$(jstr "List your Firebase projects?")" + "$(jstr "The skill lists the Firebase projects your gcloud account can see and the Android apps in each. This is a read: nothing is created or changed. + +List your Firebase projects?")" printf '"context":%s,' "$(q_look_context | q_arr)" printf '"requires":%s,' "$(q_approve_requires | q_arr)" printf '"recommended":%s,' "$(jstr "$look")" @@ -186,7 +188,11 @@ q_sign_in() { # wizard runs it in the terminal it already has. What varies goes in the option's # description, which the chat shows and the terminal menu does not. local go="Sign in with gcloud" - printf '{"header":"Sign in","prompt":%s,' "$(jstr "Ready to sign in to Google?")" + printf '{"header":"Sign in","prompt":%s,' "$(jstr "Sign in with this command in your own terminal. It opens your browser, and the password stays between you and Google: + +gcloud auth login + +Ready to sign in to Google?")" printf '"context":%s,' "$(q_arr <<'EOF' Nothing here reads your Google projects until gcloud has a session with your sign-in: this action opens your browser, and neither the tool nor the skill ever see the password. @@ -219,14 +225,16 @@ EOF # project is how the wrong account gets provisioned. q_keep_target() { local keep="Yes — use $PID / $PACKAGE" again="No — let me pick again" - printf '{"header":"Target","prompt":%s,' "$(jstr "Still the same project and app?")" + printf '{"header":"Target","prompt":%s,' "$(jstr "On record from an earlier run: project $PID, package $PACKAGE. From here on, the skill reads and changes this project only. + +Still the same project and app?")" printf '"context":%s,' "$(q_arr < } q_pick_app() { - local rows sep="" i=0 pkg appid + local rows sep="" i=0 pkg appid detected source rec=null rows="$(apps_of_chosen)" || return 1 [[ -n "$rows" ]] || return 1 + IFS=$'\t' read -r detected source <<< "$(project_package)" + if [[ -n "$detected" ]] && cut -f1 <<< "$rows" | head -"$Q_PICK_MAX" | grep -qxF "$detected"; then + rec="$(jstr "$detected")" + fi - printf '{"header":"App","prompt":%s,' "$(jstr "Which Android app is in this repository?")" - printf '"context":%s,' "$(q_pick_app_context "$rows" | q_arr)" + local ask="Which Android app is in this repository?" + [[ -z "$detected" ]] || ask="This repository builds $detected (read from $source). Pick the app with that exact package. + +$ask" + printf '{"header":"App","prompt":%s,' "$(jstr "$ask")" + printf '"context":%s,' "$( { q_pick_app_context "$rows" + [[ -z "$detected" ]] || { echo ""; echo "This repository builds $detected (read from $source)."; }; } | q_arr)" printf '"requires":[],' - printf '"recommended":null,' + printf '"recommended":%s,' "$rec" printf '"options":[' while IFS=$'\t' read -r pkg appid; do ((i < Q_PICK_MAX)) || break @@ -469,15 +486,21 @@ q_register_app_context() { q_register_app() { local yes="Yes — register $PACKAGE in $PID" prompt="Register this Android app in $PID?" + local detected source rec=null + [[ -n "$PACKAGE" ]] || IFS=$'\t' read -r detected source <<< "$(project_package)" + [[ -z "$detected" ]] || rec="$(jstr "Use $detected from $source")" # The listing is also what makes "pick a different project" a working answer: with no # cache behind it the picker has nothing to draw, so that option has to fetch first. local -a back=("$BIN/agent question pick_project") listed_project || back=("$BIN/agent discover" "$BIN/agent question pick_project") listed_project || [[ -z "$PACKAGE" ]] || prompt="Look before creating anything in $PID?" + [[ -n "$PACKAGE" ]] && listed_project && prompt="This creates one Android app in Firebase project $PID: package $PACKAGE, display name $APP_DISPLAY_NAME. Nothing else is created or changed. + +$prompt" printf '{"header":"New app","prompt":%s,' "$(jstr "$prompt")" printf '"context":%s,' "$(q_register_app_context | q_arr)" printf '"requires":[],' - printf '"recommended":null,' + printf '"recommended":%s,' "$rec" printf '"options":[' if [[ -n "$PACKAGE" ]] && ! listed_project; then q_opt "Check whether it already exists first" \ @@ -489,6 +512,11 @@ q_register_app() { "Records your approval for $PID and nothing more. The provisioning step creates the app, and that step asks for its own approval first." \ "app registration approved for $PID; ladder re-read" "" \ "$BIN/agent approve create-app" "$BIN/agent" + elif [[ -n "$detected" ]]; then + q_opt "Use $detected from $source" \ + "Records the applicationId this repository builds. Naming the package doesn't create anything. This screen comes back with the package named, and you still choose whether to approve it." \ + "package recorded as $detected; ladder re-read" "" \ + "$BIN/agent set PACKAGE=$detected" "$BIN/agent" else q_opt "I'll give you the package name" \ "Type the applicationId from build.gradle. Naming the package doesn't create anything. This screen comes back with the package named, and you still choose whether to approve it." \ @@ -585,8 +613,14 @@ q_run_app_context() { } q_run_app() { + local who="Sign in, then tell the skill which address you used" + [[ -z "${ITBL_EMAIL:-}" ]] || who="Sign in as $ITBL_EMAIL, that exact address" printf '{"header":"Your app","prompt":%s,' \ - "$(jstr "Signed in on ${TARGET_DEVICE:-the device}, with notifications allowed?")" + "$(jstr "In your app on ${TARGET_DEVICE:-the device}: +1. $who +2. Allow notifications when Android asks + +Signed in on ${TARGET_DEVICE:-the device}, with notifications allowed?")" printf '"context":%s,' "$(q_run_app_context | q_arr)" printf '"requires":[],' # Nothing out here can see their screen, so nothing out here gets to nudge "done" — the @@ -643,6 +677,15 @@ q_approve_requires() { echo "Sign in to Google yourself. Run 'gcloud auth login' in your terminal. Setup never sees your password." } +# The changes themselves, because the prompt is the one part of a screen every host shows. +q_approve_prompt() { + echo "Setting up the Google part changes Firebase project ${PID:-}, package ${PACKAGE:-}. Nothing has happened yet. It would:" + firebase_plan | awk '/^ / { sub(/^ +/, ""); cur = cur " " $0; next } { if (cur != "") print cur; cur = "· " $0 } END { if (cur != "") print cur }' \ + | sed 's/ */ /g' + echo "" + echo "Do you want to proceed?" +} + q_approve_firebase() { # The recommendation comes out of the same variable as the label it points at, because # a recommendation naming an option that has since been reworded recommends nothing. @@ -650,8 +693,7 @@ q_approve_firebase() { # have: unanswered, the ladder reports choose_driver and the opening screen asks. So the # recommendation here is "carry on the way you said", not an opinion about which is better. local manual="No — I want to do this manually" yes="Yes — the tool does it, and I agree" - printf '{"header":"Firebase","prompt":%s,' \ - "$(jstr "Do you want to proceed?")" + printf '{"header":"Firebase","prompt":%s,' "$(jstr "$(q_approve_prompt)")" printf '"context":%s,' "$(q_approve_context | q_arr)" printf '"requires":%s,' "$(q_approve_requires | q_arr)" # Follows what they already said. Somebody who answered the opening question with "set @@ -665,7 +707,7 @@ q_approve_firebase() { fi printf '"options":[' q_opt "$yes" \ - "Records your approval for ${PID:-this project} and hands the Google steps to the skill. Only the changes listed above are made, and nothing that's already working is redone." \ + "Records your approval for ${PID:-this project} and hands the Google steps to the skill. Only the changes this question lists are made, and nothing that's already working is redone." \ "approval recorded against ${PID:-}; DRIVER=agent recorded" "" \ "$BIN/agent approve firebase" "$BIN/agent set DRIVER=agent" "$BIN/agent" printf ',' @@ -696,7 +738,9 @@ q_iterable_context() { q_iterable_entry() { local start="Start — step 1 of 4" printf '{"header":"Iterable","prompt":%s,' \ - "$(jstr "Start the four Iterable steps now?")" + "$(jstr "Four steps in the Iterable dashboard, about five minutes. Iterable has no API for them, so you click and the skill checks each result. The keys go from your clipboard into a mode-0600 file, never into this conversation. + +Start the four Iterable steps now?")" printf '"context":%s,' "$(q_iterable_context | q_arr)" printf '"requires":[],' printf '"recommended":%s,' "$(jstr "$start")" @@ -738,18 +782,10 @@ q_iterable_entry() { # The relay instruction, not a summary of the step — and on step 1, the one rule the # relaying agent has to follow: it has shell access, so "never handle the key" has to say # which command handles it instead. +# The step's clicks live in the prompt, not here: agents were seen relaying the prompt and +# never printing context, which left the developer a question with no steps above it. q_step_context() { - # The step itself, not a reference to one. This used to open with "the block above is - # step N" — an assertion about output a *second* command had to have produced first, and - # an option carrying two commands is an ordering an agent can get wrong. Run only the - # question and the developer gets the right choices with no instructions above them, and - # a line pointing at a block that was never printed. So one call carries both, and the - # cheapest thing to do is also the whole screen. - # Dedented by two, because the terminal's left margin inside a chat message is just a - # ragged edge; the leading blank goes because q_arr keeps every line it is given. - "$BIN/iterable-keys" --step "$1" --relay 2>/dev/null | plain | sed 's/^ //' \ - | sed '1{/^[[:space:]]*$/d;}' - echo "Show the steps above exactly as written, including every value. The exact strings matter. Shortening this step is the costliest mistake in setup." + echo "The step is in the prompt. Show it whole, with every value and line break. The exact strings matter. Shortening this step is the costliest mistake in setup." if (( $1 == 1 )); then echo "" echo "Don't ask for either key, and don't read the clipboard yourself. pbpaste and similar commands are off limits here. --take is what moves a key, and it is the only thing in this tool that ever holds one." @@ -772,6 +808,9 @@ q_iterable_step() { && prompt="The test identity is $ITBL_EMAIL. Prove the whole thing now?" \ || prompt="What address does your app sign in as?" ;; esac + prompt="$("$BIN/iterable-keys" --step "$n" --prompt 2>/dev/null) + +$prompt" printf '{"header":%s,"prompt":%s,' "$(jstr "Step $n of 4")" "$(jstr "$prompt")" # The relay instruction, not a summary of the step. A model that condenses these # screens is how "FCM type: Data notifications" becomes "enable notifications", and @@ -813,7 +852,7 @@ q_iterable_step() { q_opt "Show step 1 again" \ "The step is shown again, unchanged, including the two lines that take a key. Nothing is skipped. A missing key shows up later as a push that never sends." \ "step 1 still open" "" \ - "$BIN/iterable-keys --step 1" + "$BIN/agent question iterable_step 1" elif ((n < 4)); then rec="Done — next step" q_opt "$rec" \ @@ -824,7 +863,7 @@ q_iterable_step() { q_opt "It doesn't look like that" \ "The step is shown again, unchanged, and stays open. Help stays on this step. The Iterable screens can change, but the values and what they mean do not. Nothing is skipped. A step marked done that wasn't done shows up later as a broken integration." \ "step $n still open" "" \ - "$BIN/iterable-keys --step $n" + "$BIN/agent question iterable_step $n" else # The identity is not a secret, so unlike the keys it can be said out loud — and the # free-text answer is the point of this screen. The device knows it already when the @@ -851,12 +890,12 @@ q_iterable_step() { q_opt "Show step 4 again" \ "The step is shown again, including any addresses the device has registered." \ "step 4 still open" "" \ - "$BIN/iterable-keys --step 4" + "$BIN/agent question iterable_step 4" else q_opt "Show step 4 again" \ "The step is shown again, unchanged. Enter the address below, or set ITBL_EMAIL in the 0600 file." \ "step 4 still open" "" \ - "$BIN/iterable-keys --step 4" + "$BIN/agent question iterable_step 4" printf ',' # The way out for somebody who does not know the answer yet — the app may not sign # anyone in at all. Everything up to here still gets proved; the identity gate says @@ -912,10 +951,7 @@ q_render() { # Paragraphs arrive unwrapped on purpose — a chat window reflows them anyway — so the # width belongs to whoever is drawing. Wrapped here rather than with fold, which would # lose the indent on every continuation line. - prompt="$(printf '%s' "$q" | jqn 'process.stdout.write(j.prompt||"")')" - echo - [[ -n "$prompt" ]] && printf ' %s\n\n' "$(bold "$prompt")" - printf '%s' "$q" | jqn ' + local wrap=' const W = 76, pad = " ", out = []; const wrap = s => { if (!s) { out.push(""); return; } @@ -925,7 +961,18 @@ q_render() { else cur = cur ? cur + " " + word : word; } if (cur) out.push(pad + cur); - }; + };' + # The prompt's last line is the question; any lines before it are the steps it asks about. + prompt="$(printf '%s' "$q" | jqn 'process.stdout.write((j.prompt||"").split("\n").pop())')" + echo + printf '%s' "$q" | jqn "$wrap"' + const lead = (j.prompt || "").split("\n").slice(0, -1); + while (lead.length && !lead[lead.length - 1]) lead.pop(); + lead.forEach(wrap); + if (out.length) process.stdout.write(out.join("\n") + "\n\n"); + ' + [[ -n "$prompt" ]] && printf ' %s\n\n' "$(bold "$prompt")" + printf '%s' "$q" | jqn "$wrap"' (j.context || []).forEach(wrap); (j.requires || []).forEach(s => { out.push(""); wrap(s); }); process.stdout.write(out.join("\n") + "\n\n"); diff --git a/copy/screens/opening.md b/copy/screens/opening.md index 621140c..7b7ed8d 100644 --- a/copy/screens/opening.md +++ b/copy/screens/opening.md @@ -6,6 +6,10 @@ Setup ## Prompt +This setup creates your app's `google-services.json` file and a push-only service +account and key, then walks you through the Iterable API keys, mobile app and push +integration. Each change is listed for your approval before it happens. + Do you want to proceed with setup? ## Context diff --git a/tests/agent-questions.sh b/tests/agent-questions.sh index 8391944..2afbe5c 100644 --- a/tests/agent-questions.sh +++ b/tests/agent-questions.sh @@ -332,27 +332,39 @@ grep -qF "no API" <<< "$entry" \ # without running that command therefore showed the right options above nothing, and a # live trial produced exactly that: a selector with invented instructions, including a # rename this repo had already deleted. One call carries both now. -ctx() { q iterable_step "$1" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log((JSON.parse(s).context||[]).join("\n"))})'; } +# Agents relay `prompt` and were seen never printing `context`, so the step itself has to +# be in the prompt: links, values and warnings, with the question as its last line. +prm() { q iterable_step "$1" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(JSON.parse(s).prompt||"")})'; } +step_has() { #