Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "iterable-sdk",
"version": "26.10.2-beta",
"version": "26.10.3-beta",
"description": "Takes an Android or iOS app — native or React Native — from nothing to a push notification proven on a device: Firebase or Apple (APNs) provisioning, version-pinned SDK integration, silent-failure pitfalls, and device-side proof that a push actually arrived. Android proof runs on a real device; iOS proof runs on the iOS Simulator.",
"author": { "name": "Iterable" },
"repository": "https://github.com/Iterable/iterable-sdk-skill",
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"owner": { "name": "Iterable" },
"metadata": {
"description": "Iterable's official skills for AI coding assistants — mobile SDK integration guidance.",
"version": "26.10.2-beta"
"version": "26.10.3-beta"
},
"plugins": [
{
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "iterable-sdk",
"version": "26.10.2-beta",
"version": "26.10.3-beta",
"description": "Takes an Android or iOS app — native or React Native — from nothing to a push notification proven on a device: Firebase or Apple (APNs) provisioning, version-pinned SDK integration, silent-failure pitfalls, and device-side proof that a push actually arrived. Android proof runs on a real device; iOS proof runs on the iOS Simulator.",
"author": { "name": "Iterable" },
"repository": "https://github.com/Iterable/iterable-sdk-skill",
Expand Down
24 changes: 24 additions & 0 deletions INTENDED-FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ before reading or changing scoped cloud resources.
| `iterable-android` | Integrates and troubleshoots Iterable's native Android SDK using version-pinned documentation. |
| `iterable-android-verify` | Independently verifies native Android and React Native Android integrations and push delivery. |
| `iterable-ios` | Integrates and troubleshoots Iterable's native iOS SDK using Iterable documentation and iOS lifecycle guidance. |
| `iterable-ios-verify` | Independently verifies an existing native iOS integration, using no-app-edits behavioral proof by default and optional instrumented diagnostics. |
| `iterable-ios-verify` | Independently verifies a native iOS, React Native iOS, or Expo iOS integration, using no-app-edits behavioral proof by default and optional instrumented diagnostics. |
| `iterable-react-native` | Integrates and troubleshoots `@iterable/react-native-sdk`, including Expo projects. |

The skills can work independently. A new push integration normally follows:
Expand Down
3 changes: 2 additions & 1 deletion bin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions bin/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,30 @@ load_env_file "$WS/resolved.env"

: "${PID:=}"
: "${PACKAGE:=}"

# Prints "<package>\t<file>", 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}"

Expand Down
160 changes: 160 additions & 0 deletions bin/ios-app-check.sh
Original file line number Diff line number Diff line change
@@ -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 "<epoch>\t<source>" 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 <that .app> and install it with %s/ios-install.' \
"$(ios_project_release_build_hint "$root" iphonesimulator)" "$bin" "$bin"
}

# ios_app_installed_integrity <installed .app> <app root> <framework>
# <environment> <recorded .app or ""> <check aps: true|false> <bin dir>
# On failure prints "<diagnosis>\t<summary>" 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 <that .app>' \
"$(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
}
3 changes: 2 additions & 1 deletion bin/ios-deliver
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion bin/ios-device
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion bin/ios-install
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion bin/ios-launch
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion bin/ios-observe
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down Expand Up @@ -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"
Expand Down
Loading