FTStore Manager puts an app on a device through the same three stages FTStore does, and had nothing to show for any of it once the screen was off. It has a Live Activity now: the same widget the app carries, embedded as its own extension under its own identifier, naming the app, filling a bar while the bytes arrive, adding the rate and the time left, then Preparing and Installing. It is the app's island with the pause button taken out. This download manager has no pause and no screen to offer one from, so there are no intents to compile into the extension and no paused state in the payload. The island is read-only on every version rather than only on the older ones. It ends when the installer answers, which is the part the app had to learn the hard way. Open launches what was just installed and leaves the sheet standing behind it, so ending on dismissal left the island describing an install that had finished, until somebody came back and closed a receipt they were done with. Dismissal still ends it, which is what covers a flow that never gets an answer at all. A dropped connection is now one of those answers. It used to remove the download and tell nobody, leaving the flow spinning with no way to find out; that was merely invisible before and would now be an island counting bytes that stopped arriving. None of this is visible from a build: an IPA whose extension is missing, misnamed or misfiled installs, runs, and simply shows no island. Both of the Manager's release workflows therefore run the same check the app's do against the built IPA, and the ad-hoc signing step seals the nested extension. Two products now build an extension of the same name out of projects that share their identifiers, so the separate derived data paths carry more weight than before, and the notes that explain them say so.
98 lines
3.8 KiB
Bash
Executable File
98 lines
3.8 KiB
Bash
Executable File
#!/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 <path-to-ipa>
|
|
|
|
set -euo pipefail
|
|
|
|
IPA="${1:?usage: verify-live-activity.sh <path-to-ipa>}"
|
|
APPEX_NAME="FTStoreActivity"
|
|
|
|
fail() {
|
|
echo "::error::$1"
|
|
echo " ↳ no Dynamic Island in this build. See <product>/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"
|