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.
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 issueerrors duringpod installandCycle inside <target>build errors in Xcode.There are three separate bugs, all in the Xcode project modification logic:
Bug 1:
addBuildPhases/ensureBuildPhasesalways create a new "Embed Foundation Extensions" copy phaseFile:
plugin/build/ios-widget/xcode/buildPhases.js—addBuildPhases()andensureBuildPhases()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:The fix is to check whether the main target already has a
PBXCopyFilesBuildPhasewithdstSubfolderSpec == 13before creating a new one, and reuse it if found — regardless of its display name.Bug 2:
ensureBuildFilereuses PBXBuildFile UUIDs across different targets/phasesFile:
plugin/build/ios-widget/xcode/buildPhases.js—ensureBuildFile()The current implementation looks up an existing
PBXBuildFilebyfileRefglobally:This is incorrect because the same
PBXFileReference(e.g.Assets.xcassets) can legitimately appear in multiple targets'PBXResourcesBuildPhase, each needing its ownPBXBuildFileentry. When the function finds aPBXBuildFilethat belongs to a different target (e.g. the Watch extension), it reuses that UUID, which causes:The fix is to scope the lookup to the specific build phase being populated rather than searching globally.
Bug 3:
ensureCopyFilesPhaseProducthas the same UUID reuse issueFile:
plugin/build/ios-widget/xcode/buildPhases.js—ensureCopyFilesPhaseProduct()Similar to Bug 2, this function checks
entry.value === productFile.uuidto detect duplicates, but the same product file UUID can already be used in a different copy phase. This leads to:The fix is to check by
fileRefwithin the specific phase, and generate a new UUID if the existing one is already used elsewhere.Bug 4:
addPbxGroupcreates unwanted PBXBuildFile entriesFile:
plugin/build/ios-widget/xcode/groups.js—addPbxGroup()The
xcodeProject.addPbxGroup()call from thexcodenpm package createsPBXBuildFileentries as a side effect for every file added to the group. These build file entries conflict with the ones properly created byaddBuildPhases/ensureBuildPhases, leading to the same "no parent for object" consistency errors.The fix is to manually construct the
PBXGroupandPBXFileReferenceentries without going throughaddPbxGroup(), since build file management is already handled by the build phase functions.Reproduction
expo prebuildpod installExpected: Clean
pod install, successful build.Actual:
[Xcodeproj] Consistency issueerrors frompod 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-packagepatch against v1.2.1. It can be applied vianpx patch-packagewith the file saved aspatches/voltra+1.2.1.patch:patches/voltra+1.2.1.patch (click to expand)
Additional Information (Optional)
No response