Skip to content

Xcode project corruption: PBXBuildFile UUID reuse across targets and duplicate embed phases #87

@SamuelBrucksch

Description

@SamuelBrucksch

Library Version

1.2.1

React Native Version

0.81

React Version

19.1

Expo Version

54

Minimal Reproduction

Description

The Expo config plugin that Voltra uses to set up the Live Activity widget extension target causes multiple Xcode project (project.pbxproj) corruption issues when the project already contains other app extension targets (e.g. Share Extension, Watch Extension, Widget Extension). These manifest as [Xcodeproj] Consistency issue errors during pod install and Cycle inside <target> build errors in Xcode.

There are three separate bugs, all in the Xcode project modification logic:

Bug 1: addBuildPhases / ensureBuildPhases always create a new "Embed Foundation Extensions" copy phase

File: plugin/build/ios-widget/xcode/buildPhases.jsaddBuildPhases() and ensureBuildPhases()

When the main app target already has an "Embed App Extensions" PBXCopyFilesBuildPhase (which is standard in any project with existing extensions), the plugin creates a second copy phase named "Embed Foundation Extensions" instead of reusing the existing one.

Both phases have dstSubfolderSpec = 13 (the Xcode constant for embedding app extensions), so they are functionally identical — but having two of them causes:

  1. Build cycle errors — The new phase is appended at the end of the build phases list, after CocoaPods script phases. If the extension depends on Pods frameworks, Xcode detects a circular dependency.
  2. Build phase ordering fragility — Moving the extra phase around to avoid the cycle creates new problems depending on what other extensions and script phases exist.

The fix is to check whether the main target already has a PBXCopyFilesBuildPhase with dstSubfolderSpec == 13 before creating a new one, and reuse it if found — regardless of its display name.

Bug 2: ensureBuildFile reuses PBXBuildFile UUIDs across different targets/phases

File: plugin/build/ios-widget/xcode/buildPhases.jsensureBuildFile()

The current implementation looks up an existing PBXBuildFile by fileRef globally:

function ensureBuildFile(xcodeProject, filePath) {
    const fileReference = ensureFileReference(xcodeProject, filePath);
    const existingBuildFile = findBuildFileKeyByFileRef(xcodeProject, fileReference.fileRef);
    if (existingBuildFile) {
        return { uuid: existingBuildFile, ...fileReference };
    }
    // ...
}

This is incorrect because the same PBXFileReference (e.g. Assets.xcassets) can legitimately appear in multiple targets' PBXResourcesBuildPhase, each needing its own PBXBuildFile entry. When the function finds a PBXBuildFile that belongs to a different target (e.g. the Watch extension), it reuses that UUID, which causes:

[Xcodeproj] Consistency issue: no parent for object 'Assets.xcassets': 'ResourcesBuildPhase', 'ResourcesBuildPhase'

The fix is to scope the lookup to the specific build phase being populated rather than searching globally.

Bug 3: ensureCopyFilesPhaseProduct has the same UUID reuse issue

File: plugin/build/ios-widget/xcode/buildPhases.jsensureCopyFilesPhaseProduct()

Similar to Bug 2, this function checks entry.value === productFile.uuid to detect duplicates, but the same product file UUID can already be used in a different copy phase. This leads to:

[Xcodeproj] Consistency issue: no parent for object 'ABRPLiveActivity.appex': 'Embed App Extensions', 'Embed Foundation Extensions'

The fix is to check by fileRef within the specific phase, and generate a new UUID if the existing one is already used elsewhere.

Bug 4: addPbxGroup creates unwanted PBXBuildFile entries

File: plugin/build/ios-widget/xcode/groups.jsaddPbxGroup()

The xcodeProject.addPbxGroup() call from the xcode npm package creates PBXBuildFile entries as a side effect for every file added to the group. These build file entries conflict with the ones properly created by addBuildPhases / ensureBuildPhases, leading to the same "no parent for object" consistency errors.

The fix is to manually construct the PBXGroup and PBXFileReference entries without going through addPbxGroup(), since build file management is already handled by the build phase functions.

Reproduction

  1. Create an Expo project with existing app extension targets (Share Extension, Watch Extension, or any other extension that already has an "Embed App Extensions" build phase)
  2. Add Voltra and configure a Live Activity widget extension
  3. Run expo prebuild
  4. Run pod install

Expected: Clean pod install, successful build.

Actual: [Xcodeproj] Consistency issue errors from pod install. If manually patched, Cycle inside <target> build errors from Xcode due to the extra embed phase.

Patch

All four bugs are addressed in the following patch-package patch against v1.2.1. It can be applied via npx patch-package with the file saved as patches/voltra+1.2.1.patch:

patches/voltra+1.2.1.patch (click to expand)
diff --git a/node_modules/voltra/plugin/build/ios-widget/xcode/buildPhases.js b/node_modules/voltra/plugin/build/ios-widget/xcode/buildPhases.js
index 3c48514..372ff1a 100644
--- a/node_modules/voltra/plugin/build/ios-widget/xcode/buildPhases.js
+++ b/node_modules/voltra/plugin/build/ios-widget/xcode/buildPhases.js
@@ -38,6 +38,26 @@ exports.ensureBuildPhases = ensureBuildPhases;
 const util = __importStar(require("util"));
 // eslint-disable-next-line @typescript-eslint/no-var-requires
 const pbxFile = require('xcode/lib/pbxFile');
+/**
+ * Finds an existing PBXCopyFilesBuildPhase on the given target that embeds
+ * app extensions (dstSubfolderSpec == 13), regardless of its display name.
+ * Returns { phase, name } or null.
+ */
+function findExistingEmbedExtensionsPhase(xcodeProject, targetUuid) {
+    const nativeTargets = xcodeProject.pbxNativeTargetSection();
+    const target = nativeTargets[targetUuid];
+    if (!target?.buildPhases) {
+        return null;
+    }
+    const copyFilesSection = xcodeProject.hash.project.objects['PBXCopyFilesBuildPhase'] || {};
+    for (const entry of target.buildPhases) {
+        const phase = copyFilesSection[entry.value];
+        if (phase && String(phase.dstSubfolderSpec) === '13') {
+            return { phase, name: entry.comment || phase.name };
+        }
+    }
+    return null;
+} // end findExistingEmbedExtensionsPhase
 /**
  * Adds all required build phases for the widget extension target.
  */
@@ -45,16 +65,23 @@ function addBuildPhases(xcodeProject, options) {
     const { targetUuid, groupName, productFile, widgetFiles } = options;
     const buildPath = `""`;
     const folderType = 'app_extension';
+    const mainTargetUuid = xcodeProject.getFirstTarget().uuid;
     const { swiftFiles, intentFiles, assetDirectories } = widgetFiles;
     // Sources build phase
     xcodeProject.addBuildPhase([...swiftFiles, ...intentFiles], 'PBXSourcesBuildPhase', 'Sources', targetUuid, folderType, buildPath);
-    // Copy files build phase
-    xcodeProject.addBuildPhase([], 'PBXCopyFilesBuildPhase', groupName, xcodeProject.getFirstTarget().uuid, folderType, buildPath);
-    xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, productFile.target).files.push({
-        value: productFile.uuid,
-        comment: util.format('%s in %s', productFile.basename, productFile.group),
-    });
-    xcodeProject.addToPbxBuildFileSection(productFile);
+    // Copy files build phase — reuse existing embed-extensions phase if one exists
+    const existing = findExistingEmbedExtensionsPhase(xcodeProject, mainTargetUuid);
+    if (existing) {
+        ensureCopyFilesPhaseProduct(xcodeProject, existing.phase, productFile);
+    }
+    else {
+        xcodeProject.addBuildPhase([], 'PBXCopyFilesBuildPhase', groupName, mainTargetUuid, folderType, buildPath);
+        xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, productFile.target).files.push({
+            value: productFile.uuid,
+            comment: util.format('%s in %s', productFile.basename, productFile.group),
+        });
+        xcodeProject.addToPbxBuildFileSection(productFile);
+    }
     // Frameworks build phase
     xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetUuid, folderType, buildPath);
     // Resources build phase
@@ -81,8 +108,9 @@ function ensureBuildPhases(xcodeProject, options) {
     if (sourcesPhase) {
         ensureBuildPhaseFiles(xcodeProject, sourcesPhase, [...swiftFiles, ...intentFiles]);
     }
-    // Copy files build phase (embed extension into main app)
-    let copyFilesPhase = xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, mainTargetUuid);
+    // Copy files build phase (embed extension into main app) — reuse any existing embed phase
+    const existing = findExistingEmbedExtensionsPhase(xcodeProject, mainTargetUuid);
+    let copyFilesPhase = existing ? existing.phase : xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, mainTargetUuid);
     if (!copyFilesPhase) {
         xcodeProject.addBuildPhase([], 'PBXCopyFilesBuildPhase', groupName, mainTargetUuid, folderType, buildPath);
         copyFilesPhase = xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, mainTargetUuid);
@@ -188,11 +216,17 @@ function ensureFileReference(xcodeProject, filePath) {
     xcodeProject.addToPbxFileReferenceSection(file);
     return { fileRef: file.fileRef, basename: file.basename, group: file.group };
 }
-function ensureBuildFile(xcodeProject, filePath) {
+function ensureBuildFile(xcodeProject, filePath, buildPhase) {
     const fileReference = ensureFileReference(xcodeProject, filePath);
-    const existingBuildFile = findBuildFileKeyByFileRef(xcodeProject, fileReference.fileRef);
-    if (existingBuildFile) {
-        return { uuid: existingBuildFile, ...fileReference };
+    if (buildPhase?.files) {
+        const buildFileSection = xcodeProject.pbxBuildFileSection();
+        const existingInPhase = buildPhase.files.find((entry) => {
+            const bf = buildFileSection[entry.value];
+            return bf?.fileRef === fileReference.fileRef;
+        });
+        if (existingInPhase) {
+            return { uuid: existingInPhase.value, ...fileReference };
+        }
     }
     const file = new pbxFile(filePath);
     file.uuid = xcodeProject.generateUuid();
@@ -219,7 +253,7 @@ function ensureBuildPhaseFiles(xcodeProject, buildPhase, filePaths) {
         if (buildPhaseHasFile(xcodeProject, buildPhase, fileReference.fileRef)) {
             continue;
         }
-        const buildFile = ensureBuildFile(xcodeProject, filePath);
+        const buildFile = ensureBuildFile(xcodeProject, filePath, buildPhase);
         buildPhase.files.push({
             value: buildFile.uuid,
             comment: util.format('%s in %s', buildFile.basename, buildFile.group),
@@ -230,16 +264,29 @@ function ensureCopyFilesPhaseProduct(xcodeProject, buildPhase, productFile) {
     if (!buildPhase.files) {
         buildPhase.files = [];
     }
-    const alreadyExists = buildPhase.files.some((entry) => entry.value === productFile.uuid);
-    if (alreadyExists) {
+    const buildFileSection = xcodeProject.pbxBuildFileSection();
+    const alreadyInPhase = buildPhase.files.some((entry) => {
+        const bf = buildFileSection[entry.value];
+        return bf?.fileRef === productFile.fileRef;
+    });
+    if (alreadyInPhase) {
         return;
     }
-    const buildFileSection = xcodeProject.pbxBuildFileSection();
-    if (!buildFileSection[productFile.uuid]) {
+    const isUsedElsewhere = buildFileSection[productFile.uuid];
+    let useUuid = productFile.uuid;
+    if (isUsedElsewhere) {
+        useUuid = xcodeProject.generateUuid();
+        const newBuildFile = {
+            ...productFile,
+            uuid: useUuid,
+        };
+        xcodeProject.addToPbxBuildFileSection(newBuildFile);
+    }
+    else {
         xcodeProject.addToPbxBuildFileSection(productFile);
     }
     buildPhase.files.push({
-        value: productFile.uuid,
+        value: useUuid,
         comment: util.format('%s in %s', productFile.basename, productFile.group),
     });
 }
diff --git a/node_modules/voltra/plugin/build/ios-widget/xcode/groups.js b/node_modules/voltra/plugin/build/ios-widget/xcode/groups.js
index 0db925f..0e73df1 100644
--- a/node_modules/voltra/plugin/build/ios-widget/xcode/groups.js
+++ b/node_modules/voltra/plugin/build/ios-widget/xcode/groups.js
@@ -6,21 +6,35 @@ exports.ensurePbxGroup = ensurePbxGroup;
 const pbxFile = require('xcode/lib/pbxFile');
 /**
  * Adds a PBXGroup for the widget extension files.
+ * Creates only PBXFileReference and PBXGroup entries — no PBXBuildFile entries,
+ * since those are managed by addBuildPhases/ensureBuildPhases.
  */
 function addPbxGroup(xcodeProject, options) {
     const { targetName, widgetFiles } = options;
     const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles } = widgetFiles;
-    // Add PBX group with all widget files
-    const { uuid: pbxGroupUuid } = xcodeProject.addPbxGroup([...swiftFiles, ...intentFiles, ...entitlementFiles, ...plistFiles, ...assetDirectories], targetName, targetName);
-    // Add PBXGroup to top level group
-    const groups = xcodeProject.hash.project.objects['PBXGroup'];
-    if (pbxGroupUuid) {
-        Object.keys(groups).forEach(function (key) {
-            if (groups[key].name === undefined && groups[key].path === undefined) {
-                xcodeProject.addToPbxGroup(pbxGroupUuid, key);
-            }
-        });
+    const allFiles = [...swiftFiles, ...intentFiles, ...entitlementFiles, ...plistFiles, ...assetDirectories];
+    const pbxGroupUuid = xcodeProject.generateUuid();
+    const pbxGroup = {
+        isa: 'PBXGroup',
+        children: [],
+        name: targetName,
+        path: targetName,
+        sourceTree: '"<group>"',
+    };
+    for (const filePath of allFiles) {
+        const fileRef = ensureFileReference(xcodeProject, filePath);
+        const file = new pbxFile(filePath);
+        pbxGroup.children.push({ value: fileRef, comment: file.basename });
     }
+    const groups = xcodeProject.hash.project.objects['PBXGroup'];
+    groups[pbxGroupUuid] = pbxGroup;
+    groups[`${pbxGroupUuid}_comment`] = targetName;
+    Object.keys(groups).forEach(function (key) {
+        if (/_comment$/.test(key)) return;
+        if (groups[key].name === undefined && groups[key].path === undefined) {
+            xcodeProject.addToPbxGroup(pbxGroupUuid, key);
+        }
+    });
 }
 /**
  * Ensures a PBXGroup exists for the widget extension files.

Additional Information (Optional)

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions