#!/bin/bash # verify-live-activity.sh — prove a built IPA can actually show the Dynamic Island. # # The island is drawn by an embedded app extension, and every way it can be # wrong is invisible from the outside: the IPA installs, the app runs, and the # island simply never appears. There is no crash, no log, and nothing in the # build output. So the build has to be the thing that notices. # # It has gone dead twice. The first time the extension was missing outright, # because two products shared one Xcode derived-data directory and the other # one's stale-file sweep deleted it. A presence check was added for that, and # presence is only the first of three ways to ship a dead island — the other two # still passed it. This checks all three: # # 1. the extension is in the IPA, with a real executable inside it; # 2. its bundle id is prefixed by the host app's, which is the condition iOS # requires to load an app extension at all, and refuses silently without; # 3. it declares the widgetkit extension point. # # Usage: verify-live-activity.sh set -euo pipefail IPA="${1:?usage: verify-live-activity.sh }" APPEX_NAME="FTStoreActivity" fail() { echo "::error::$1" echo " ↳ no Dynamic Island in this build. See /FTStoreActivity/README.md" exit 1 } [ -f "$IPA" ] || fail "no IPA at $IPA" # Plain grep, never -q or -m1: both exit on the first match, and the SIGPIPE # that sends back into unzip would fail this script under `set -o pipefail` # even when the check passed. listing=$(unzip -Z1 "$IPA") # 1. Present, with an executable. An empty .appex directory still zips. if ! printf '%s\n' "$listing" \ | grep -E "^Payload/[^/]+\.app/PlugIns/${APPEX_NAME}\.appex/${APPEX_NAME}$" >/dev/null then fail "${APPEX_NAME}.appex is missing from the IPA" fi work=$(mktemp -d) trap 'rm -rf "$work"' EXIT unzip -q "$IPA" -d "$work" \ 'Payload/*.app/Info.plist' \ "Payload/*.app/PlugIns/${APPEX_NAME}.appex/Info.plist" app_plist=$(find "$work/Payload" -maxdepth 2 -name Info.plist | head -1) ext_plist=$(find "$work/Payload" -path "*/PlugIns/${APPEX_NAME}.appex/Info.plist" | head -1) [ -n "$app_plist" ] || fail "the app has no Info.plist" [ -n "$ext_plist" ] || fail "${APPEX_NAME}.appex has no Info.plist" # Overridable so the script's own logic can be exercised off a Mac, where # PlistBuddy does not exist. CI leaves it at the default. PLIST_BUDDY="${PLIST_BUDDY:-/usr/libexec/PlistBuddy}" read_plist() { "$PLIST_BUDDY" -c "Print :$2" "$1" 2>/dev/null || true } app_id=$(read_plist "$app_plist" CFBundleIdentifier) ext_id=$(read_plist "$ext_plist" CFBundleIdentifier) ext_point=$(read_plist "$ext_plist" "NSExtension:NSExtensionPointIdentifier") [ -n "$app_id" ] || fail "the app declares no CFBundleIdentifier" [ -n "$ext_id" ] || fail "${APPEX_NAME}.appex declares no CFBundleIdentifier" # 2. The prefix rule. iOS will not load an extension whose id does not sit under # its host app's, and it reports this nowhere a user or a build can see. case "$ext_id" in "$app_id".*) ;; *) fail "extension id '$ext_id' is not under the app's '$app_id'" ;; esac # 3. A Live Activity is a WidgetKit extension. Anything else is dead weight. if [ "$ext_point" != "com.apple.widgetkit-extension" ]; then fail "extension point is '${ext_point:-unset}', expected com.apple.widgetkit-extension" fi # The app also has to advertise that it supports Live Activities, or ActivityKit # refuses the request at runtime and the manager treats it as best-effort — a # no-op that looks exactly like everything working. if [ "$(read_plist "$app_plist" NSSupportsLiveActivities)" != "true" ]; then fail "the app's Info.plist does not set NSSupportsLiveActivities" fi echo "${APPEX_NAME}.appex is embedded, loadable and declared:" echo " app $app_id" echo " extension $ext_id" echo " point $ext_point"