The trap
PR #419 adds the expo-widgets widget-extension target. Xcode appends the generated "Embed Foundation Extensions" copy phase last in the Threadbase target's buildPhases, after three script phases:
[Expo Dev Launcher] Strip Local Network Keys for Release
[CP] Embed Pods Frameworks
Upload Debug Symbols to Sentry
Those scripts consume Threadbase.app/Info.plist, whose ProcessInfoPlistFile command Xcode orders after the PlugIns copy. The copy therefore transitively depends on itself and the build dies:
Cycle inside Threadbase; building could produce unreliable results.
→ Target 'Threadbase' has copy command from '.../ExpoWidgetsTarget.appex' to '.../Threadbase.app/PlugIns/ExpoWidgetsTarget.appex'
○ That command depends on command in Target 'Threadbase': script phase "Upload Debug Symbols to Sentry"
○ That command depends on command in Target 'Threadbase': script phase "[CP] Embed Pods Frameworks"
○ That command depends on command in Target 'Threadbase': script phase "[Expo Dev Launcher] Strip Local Network Keys for Release"
○ Target 'Threadbase' has process command with output '.../Threadbase.app/Info.plist'
○ Target 'Threadbase' has copy command from '.../ExpoWidgetsTarget.appex' to '.../Threadbase.app/PlugIns/ExpoWidgetsTarget.appex'
The fix applied in #419 moves the embed phase to its conventional position, immediately after Resources. That resolves the cycle and the build succeeds.
It is a hand edit to ios/Threadbase.xcodeproj/project.pbxproj. The next expo prebuild regenerates the phase order and reintroduces the cycle — and it breaks for whoever runs prebuild next, not for whoever introduced it. That person gets an opaque Cycle inside Threadbase with no obvious link to Live Activities.
What to build
plugins/withLiveActivityTarget.js, registered in app.json after the expo-widgets plugin entry so it runs on the already-generated target.
Requirements, modelled on the conventions in plugins/withAndroidReleaseSigning.js:
- Tagged — carry a
TAG const (e.g. live-activity-embed-order) used in any generated markers and in every error message, matching the existing plugin's style.
- Idempotent across prebuild — reordering an already-correct
buildPhases array must be a no-op, not a second move. Check the phase's current index before touching it.
- Loud throw when the anchor stops matching — if the "Embed Foundation Extensions" phase or the
Resources phase can't be found, throw with the tag and enough context to act on, rather than silently leaving the cycle in place. The existing Android plugin's alreadyApplied / anchor-miss branches are the pattern to copy: a silent failure here surfaces as a confusing build error much later.
One deviation from the Android plugin
withAndroidReleaseSigning.js uses mergeContents from @expo/config-plugins/build/utils/generateCode because it patches Groovy source text with regex anchors.
A pbxproj should not be text-patched. Use withXcodeProject, which hands over the parsed xcode project object — locate the native target by name, find its buildPhases entries by comment, and reorder the array. Same tagging/idempotence/throw discipline, different mechanism.
Resolve @expo/config-plugins through expo (as the Android plugin does) rather than adding a top-level dependency, so the same copy prebuild uses is picked up.
Verification
After implementing, from a clean checkout:
npx expo prebuild --platform ios --no-clean
cd ios && pod install && cd ..
xcodebuild -workspace ios/Threadbase.xcworkspace -scheme Threadbase \
-configuration Debug -destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO build
Expected: BUILD SUCCEEDED, no Cycle inside Threadbase, and Threadbase.app/PlugIns/ExpoWidgetsTarget.appex present. Running prebuild twice in a row must produce an identical project.pbxproj the second time.
Context
The trap
PR #419 adds the
expo-widgetswidget-extension target. Xcode appends the generated "Embed Foundation Extensions" copy phase last in theThreadbasetarget'sbuildPhases, after three script phases:[Expo Dev Launcher] Strip Local Network Keys for Release[CP] Embed Pods FrameworksUpload Debug Symbols to SentryThose scripts consume
Threadbase.app/Info.plist, whoseProcessInfoPlistFilecommand Xcode orders after the PlugIns copy. The copy therefore transitively depends on itself and the build dies:The fix applied in #419 moves the embed phase to its conventional position, immediately after
Resources. That resolves the cycle and the build succeeds.It is a hand edit to
ios/Threadbase.xcodeproj/project.pbxproj. The nextexpo prebuildregenerates the phase order and reintroduces the cycle — and it breaks for whoever runs prebuild next, not for whoever introduced it. That person gets an opaqueCycle inside Threadbasewith no obvious link to Live Activities.What to build
plugins/withLiveActivityTarget.js, registered inapp.jsonafter theexpo-widgetsplugin entry so it runs on the already-generated target.Requirements, modelled on the conventions in
plugins/withAndroidReleaseSigning.js:TAGconst (e.g.live-activity-embed-order) used in any generated markers and in every error message, matching the existing plugin's style.buildPhasesarray must be a no-op, not a second move. Check the phase's current index before touching it.Resourcesphase can't be found, throw with the tag and enough context to act on, rather than silently leaving the cycle in place. The existing Android plugin'salreadyApplied/ anchor-miss branches are the pattern to copy: a silent failure here surfaces as a confusing build error much later.One deviation from the Android plugin
withAndroidReleaseSigning.jsusesmergeContentsfrom@expo/config-plugins/build/utils/generateCodebecause it patches Groovy source text with regex anchors.A pbxproj should not be text-patched. Use
withXcodeProject, which hands over the parsedxcodeproject object — locate the native target by name, find itsbuildPhasesentries bycomment, and reorder the array. Same tagging/idempotence/throw discipline, different mechanism.Resolve
@expo/config-pluginsthroughexpo(as the Android plugin does) rather than adding a top-level dependency, so the same copy prebuild uses is picked up.Verification
After implementing, from a clean checkout:
Expected:
BUILD SUCCEEDED, noCycle inside Threadbase, andThreadbase.app/PlugIns/ExpoWidgetsTarget.appexpresent. Running prebuild twice in a row must produce an identicalproject.pbxprojthe second time.Context
chore(ios): add expo-widgets and live activity target), part of the Live Activities stack chore(ios): add expo-widgets and live activity target #419–feat(android): promote running sessions to an ongoing notification #423.expo prebuild.CLAUDE.mdrequiresexpo prebuild --no-cleanon this repo, sinceios/andandroid/are hand-maintained and committed.