Skip to content

fix(ios): stop Xcode project corruption with pre-existing app extensions#211

Merged
V3RON merged 2 commits into
mainfrom
fix/xcode-pbxproj-corruption
Jul 7, 2026
Merged

fix(ios): stop Xcode project corruption with pre-existing app extensions#211
V3RON merged 2 commits into
mainfrom
fix/xcode-pbxproj-corruption

Conversation

@V3RON

@V3RON V3RON commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

What is this?

This PR fixes the Xcode project corruption that occurs when the Voltra config plugin runs against an app that already contains another app extension (a Share extension, a notification service, expo-live-activity's widget, etc.). Affected users hit pod install failures like [Xcodeproj] Consistency issue: no parent for object or Cycle inside <target>; building could produce unreliable results, and repeated expo prebuild runs made the project progressively worse.

Fixes #87. Fixes #32.

How does it work?

The corruption came from how the xcode npm library resolves objects globally by bare path and comment, which breaks down as soon as two targets contain identically named files or phases. The plugin now scopes every pbxproj mutation to the widget's own objects:

  • File references are resolved through the widget's own PBXGroup (ensureWidgetFileReference), so a bare path like Assets.xcassets can no longer collide with another extension's asset catalog and steal its PBXFileReference.
  • The widget group is constructed manually instead of via addPbxGroup, which side-effect-creates PBXBuildFile objects that collide with the ones managed by the build phases and end up parentless.
  • Build phases are created empty and populated through a phase-scoped ensureBuildFile, because addBuildPhase(files, …) reuses build files globally by path — including ones already owned by another target's phase.
  • An existing "Embed App Extensions" phase is reused instead of adding a second one; it is detected semantically via dstSubfolderSpec == 13 rather than by its comment string.
  • Stale duplicate phases and orphaned phase objects left behind by previous plugin versions are cleaned up on the next prebuild.

The pbxproj mutation core is extracted into a pure applyXcodeChanges(xcodeProject, props, widgetFiles) function and covered by fixture-based integration tests: a fresh Expo project fixture and a fixture with a pre-existing extension (the exact #87/#32 shape), a consistency checker mirroring the invariants CocoaPods' Xcodeproj enforces (every build file has exactly one parent phase, no orphan phases, exactly one embed phase, serialization round-trip), idempotency across three consecutive runs, and a snapshot.

Why is this useful?

When the app already contains another app-extension target (Share/Watch/
expo-live-activity), the widget config plugin corrupted project.pbxproj:
pod install failed with "[Xcodeproj] Consistency issue: no parent for
object" and Xcode reported "Cycle inside <target>".

Root causes and fixes in the ios-widget/xcode plugin:

- Reuse an existing embed-app-extensions copy phase (dstSubfolderSpec == 13,
  any display name) instead of always adding a second "Embed Foundation
  Extensions" phase (addBuildPhases/ensureBuildPhases).
- Scope PBXBuildFile lookup to the target's own build phase so we never adopt
  a build file that belongs to another target's phase (ensureBuildFile).
- Dedupe copy-files products by resolved fileRef and mint a fresh UUID when
  the product's build-file UUID is already used elsewhere
  (ensureCopyFilesPhaseProduct).
- Construct the widget PBXGroup by hand so the xcode lib no longer
  side-effect-creates conflicting PBXBuildFile entries (addPbxGroup).
- Resolve widget file references scoped to the widget's own PBXGroup, so an
  identically named Assets.xcassets no longer collides with another
  extension's catalog and shares a fileRef/PBXBuildFile (fixes the #32
  "no parent for object 'Assets.xcassets'" family). Unified into one helper.
- Delete orphan phase objects (and their _comment keys) when de-duplicating
  build-phase refs.

Also extracts the pure applyXcodeChanges() core from configureXcodeProject and
adds fixture-based integration tests (fresh project + project with a
pre-existing extension) plus a pbxproj consistency validator proving the fix
and idempotency.

Fixes #87
Fixes #32
@V3RON V3RON merged commit 121fa8d into main Jul 7, 2026
13 checks passed
@V3RON V3RON deleted the fix/xcode-pbxproj-corruption branch July 7, 2026 11:11
V3RON added a commit that referenced this pull request Jul 7, 2026
…ion passthrough (#213)

> [!NOTE]
> Stacked on #211 (`fix/xcode-pbxproj-corruption`) — review only the
last five commits. Merge #211 first, then retarget this PR to `main`.

## What is this?

This PR restructures how the plugin manages the widget's Xcode target
and closes several long-standing behavioral gaps. It is aimed at
maintainers: the corruption fixes in #211 made the pbxproj mutation code
testable, and this follow-up removes the structural duplication that
made those bugs likely in the first place. It also ships three
user-visible corrections: widget signing now mirrors the main app per
build configuration, the widget inherits the app's version and build
number, and a missing `ios.bundleIdentifier` fails loudly instead of
silently disabling the plugin.

One commit per concern:

1. `refactor(ios)`: collapse the add/ensure duality into a single ensure
pipeline
2. `refactor(ios)`: match build phases semantically instead of by
comment
3. `fix(ios)`: sync widget code signing per build configuration
4. `fix(ios)`: thread app version/buildNumber into widget build settings
5. `feat(ios)!`: throw when `expo.ios.bundleIdentifier` is missing

## How does it work?

- **Single ensure pipeline.** Every pbxproj object used to have parallel
`add*` (create) and `ensure*` (reconcile) code paths that had to be kept
behaviorally identical by hand. Now the only create-only step is
`createTargetSkeleton` — a target cannot be "ensured" into existence —
and everything else (configuration list, product file, group, build
phases, target attributes, dependency) goes through one idempotent
ensure chain regardless of whether the target already existed. The
collapse surfaced a latent bug: the `xcode` lib's `buildPhaseObject`
falls back to a project-wide comment search and was returning the *main
app's* Sources phase for a fresh widget target; phase lookup is now
scoped to the target's own `buildPhases` list.
- **Semantic phase matching.** Phases are identified by membership in
their pbxproj type section (and the embed phase purely by
`dstSubfolderSpec == 13`), never by comment strings, which are
decorative and stripped by other tools. Deduplication follows the same
rule and leaves unrelated copy-files phases untouched.
- **Per-configuration signing.** `getMainAppTargetSettings` now reads
every build configuration of the main app instead of only the first one,
and the widget's Debug/Release configurations each receive their
name-matched signing settings (`CODE_SIGN_STYLE`, `DEVELOPMENT_TEAM`,
`PROVISIONING_PROFILE_SPECIFIER`), with a first-configuration fallback
for unmatched names. Previously the Debug signing was copied into
Release, which breaks manual-signing release builds with distinct
provisioning profiles.
- **Version passthrough.** `expo.version` and `expo.ios.buildNumber` are
threaded into the widget's `MARKETING_VERSION` and
`CURRENT_PROJECT_VERSION` (previously hardcoded `"1.0"`/`"1"`, which App
Store Connect flags because an appex version must match its host app).
The hardcoded values remain as fallback when the app config doesn't
provide them.
- **Fail fast on missing bundle identifier.** Without
`ios.bundleIdentifier` the plugin used to return the config untouched,
so prebuild "succeeded" with no widget and nothing explaining why. It
now throws with a message pointing at the exact app-config key to set.
Technically breaking for configs that relied on the silent no-op, hence
the `!`.

All existing fixture/consistency/idempotency tests from #211 stay
untouched and green after every commit; the branch adds a
per-config-signing fixture and tests, version-passthrough tests, and
bundle-identifier tests (38 tests total).

## Why is this useful?

- One reconciliation path means create-vs-update drift — the root cause
behind several of the historical corruption bugs — is no longer possible
by construction.
- Release builds with manual signing get the correct provisioning
profile instead of Debug's.
- App Store submissions no longer trip over a widget stuck at version
`1.0 (1)`.
- Misconfigured apps fail at prebuild with an actionable message instead
of shipping without their widget.
- Dead create-only exports and comment-based phase lookup are gone,
shrinking the surface the next contributor has to understand.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant