From 314f4389eab2575b5ae923be7f6fddef8c3ed8f6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 4 Jul 2026 15:25:38 +0200 Subject: [PATCH 1/6] refactor(ios): collapse add/ensure duality into one ensure pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyXcodeChanges previously had two full code paths: a create-only one (addXCConfigurationList/addProductFile/configureTarget/addPbxGroup/ addBuildPhases) for fresh projects and an ensure one for existing targets. The fresh case is just the ensure case with nothing found, so the two paths are now one: when the target is absent a minimal native-target skeleton is created (createTargetSkeleton — targets cannot be "ensured" into existence), and everything else (configuration list, product file, group, build phases, attributes, dependency) goes through the same idempotent ensure functions in both cases. The group is still ensured before the phases, since phases resolve files through the widget-scoped group. Cleanup: - addBuildPhases deleted (only caller was the fresh path) - addXCConfigurationList/addPbxGroup/addProductFile demoted to private create-only fallbacks of their ensure siblings - configureTarget and its helpers replaced by createTargetSkeleton; target dependency and attributes now come from ensureTargetDependency/ ensureTargetAttributes on both paths - AddBuildPhasesOptions folded into EnsureBuildPhasesOptions Bug fixed along the way: the xcode lib's buildPhaseObject falls back to a project-wide comment search when the given target has no matching phase, which on the unified pipeline returned the MAIN APP's Sources/Frameworks/Resources phases for a freshly created widget target. ensureBuildPhases now looks phases up strictly within the target's own buildPhases list (findTargetPhaseByComment). Snapshot updated: content is identical to the previous snapshot modulo deterministic-UUID renumbering and pbxproj section emission order (the copy-files phase, target dependency and container proxy are now created later in the unified pipeline). Verified byte-identical after normalizing UUIDs and sorting lines. --- .../applyXcodeChanges.node.test.ts.snap | 96 +++++------ .../src/ios-widget/xcode/buildPhases.ts | 79 ++++------ .../src/ios-widget/xcode/configurationList.ts | 5 +- .../src/ios-widget/xcode/groups.ts | 4 +- .../expo-plugin/src/ios-widget/xcode/index.ts | 149 ++++++------------ .../src/ios-widget/xcode/productFile.ts | 5 +- .../src/ios-widget/xcode/target.ts | 114 ++++---------- 7 files changed, 170 insertions(+), 282 deletions(-) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap b/packages/ios-client/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap index e42b25f2..fff2f9c8 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap @@ -13,11 +13,11 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit 13B07FBF1A68108700A75B9A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.swift */; }; 13B07FC11A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FC21A68108700A75B9A /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB61A68108700A75B9A /* Info.plist */; }; - F1D000000000000000000011 /* VoltraWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D00000000000000000000A /* VoltraWidget.swift */; }; - F1D000000000000000000012 /* VoltraWidgetInitialStates.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D00000000000000000000B /* VoltraWidgetInitialStates.swift */; }; + F1D00000000000000000000F /* VoltraWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D000000000000000000008 /* VoltraWidget.swift */; }; + F1D000000000000000000010 /* VoltraWidgetInitialStates.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D000000000000000000009 /* VoltraWidgetInitialStates.swift */; }; F1D000000000000000000006 /* VoltraWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F1D000000000000000000005 /* VoltraWidgetExtension.appex */; }; - F1D000000000000000000016 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F1D00000000000000000000E /* Assets.xcassets */; }; - F1D000000000000000000017 /* VoltraWidgets.strings in Resources */ = {isa = PBXBuildFile; fileRef = F1D00000000000000000000F /* VoltraWidgets.strings */; }; + F1D000000000000000000014 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F1D00000000000000000000C /* Assets.xcassets */; }; + F1D000000000000000000015 /* VoltraWidgets.strings in Resources */ = {isa = PBXBuildFile; fileRef = F1D00000000000000000000D /* VoltraWidgets.strings */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -26,12 +26,12 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = voltraexample/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = voltraexample/Info.plist; sourceTree = ""; }; F1D000000000000000000005 /* VoltraWidgetExtension.appex */ = {isa = PBXFileReference; name = "VoltraWidgetExtension.appex"; path = "VoltraWidgetExtension.appex"; sourceTree = BUILT_PRODUCTS_DIR; fileEncoding = undefined; lastKnownFileType = undefined; explicitFileType = wrapper.app-extension; includeInIndex = 0; }; - F1D00000000000000000000A /* VoltraWidget.swift */ = {isa = PBXFileReference; name = "VoltraWidget.swift"; path = "VoltraWidget.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; - F1D00000000000000000000B /* VoltraWidgetInitialStates.swift */ = {isa = PBXFileReference; name = "VoltraWidgetInitialStates.swift"; path = "VoltraWidgetInitialStates.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; - F1D00000000000000000000C /* VoltraWidgetExtension.entitlements */ = {isa = PBXFileReference; name = "VoltraWidgetExtension.entitlements"; path = "VoltraWidgetExtension.entitlements"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; }; - F1D00000000000000000000D /* VoltraWidgetExtension-Info.plist */ = {isa = PBXFileReference; name = "VoltraWidgetExtension-Info.plist"; path = "VoltraWidgetExtension-Info.plist"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = text.plist.xml; explicitFileType = undefined; includeInIndex = 0; }; - F1D00000000000000000000E /* Assets.xcassets */ = {isa = PBXFileReference; name = "Assets.xcassets"; path = "Assets.xcassets"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = folder.assetcatalog; explicitFileType = undefined; includeInIndex = 0; }; - F1D00000000000000000000F /* VoltraWidgets.strings */ = {isa = PBXFileReference; name = "VoltraWidgets.strings"; path = "en.lproj/VoltraWidgets.strings"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = text.plist.strings; explicitFileType = undefined; includeInIndex = 0; }; + F1D000000000000000000008 /* VoltraWidget.swift */ = {isa = PBXFileReference; name = "VoltraWidget.swift"; path = "VoltraWidget.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; + F1D000000000000000000009 /* VoltraWidgetInitialStates.swift */ = {isa = PBXFileReference; name = "VoltraWidgetInitialStates.swift"; path = "VoltraWidgetInitialStates.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; + F1D00000000000000000000A /* VoltraWidgetExtension.entitlements */ = {isa = PBXFileReference; name = "VoltraWidgetExtension.entitlements"; path = "VoltraWidgetExtension.entitlements"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; }; + F1D00000000000000000000B /* VoltraWidgetExtension-Info.plist */ = {isa = PBXFileReference; name = "VoltraWidgetExtension-Info.plist"; path = "VoltraWidgetExtension-Info.plist"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = text.plist.xml; explicitFileType = undefined; includeInIndex = 0; }; + F1D00000000000000000000C /* Assets.xcassets */ = {isa = PBXFileReference; name = "Assets.xcassets"; path = "Assets.xcassets"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = folder.assetcatalog; explicitFileType = undefined; includeInIndex = 0; }; + F1D00000000000000000000D /* VoltraWidgets.strings */ = {isa = PBXFileReference; name = "VoltraWidgets.strings"; path = "en.lproj/VoltraWidgets.strings"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = text.plist.strings; explicitFileType = undefined; includeInIndex = 0; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -42,7 +42,7 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit ); runOnlyForDeploymentPostprocessing = 0; }; - F1D000000000000000000014 /* Frameworks */ = { + F1D000000000000000000012 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( @@ -57,7 +57,7 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit children = ( 13B07FAE1A68108700A75B9A /* voltraexample */, 83CBBA001A601CBA00E9B192 /* Products */, - F1D000000000000000000009 /* VoltraWidgetExtension */, + F1D000000000000000000007 /* VoltraWidgetExtension */, ); indentWidth = 2; sourceTree = ""; @@ -83,15 +83,15 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit name = Products; sourceTree = ""; }; - F1D000000000000000000009 /* VoltraWidgetExtension */ = { + F1D000000000000000000007 /* VoltraWidgetExtension */ = { isa = PBXGroup; children = ( - F1D00000000000000000000A /* VoltraWidget.swift */, - F1D00000000000000000000B /* VoltraWidgetInitialStates.swift */, - F1D00000000000000000000C /* VoltraWidgetExtension.entitlements */, - F1D00000000000000000000D /* VoltraWidgetExtension-Info.plist */, - F1D00000000000000000000E /* Assets.xcassets */, - F1D00000000000000000000F /* VoltraWidgets.strings */, + F1D000000000000000000008 /* VoltraWidget.swift */, + F1D000000000000000000009 /* VoltraWidgetInitialStates.swift */, + F1D00000000000000000000A /* VoltraWidgetExtension.entitlements */, + F1D00000000000000000000B /* VoltraWidgetExtension-Info.plist */, + F1D00000000000000000000C /* Assets.xcassets */, + F1D00000000000000000000D /* VoltraWidgets.strings */, ); name = VoltraWidgetExtension; path = VoltraWidgetExtension; @@ -107,12 +107,12 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, - F1D000000000000000000013 /* Embed Foundation Extensions */, + F1D000000000000000000011 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( - F1D000000000000000000007 /* PBXTargetDependency */, + F1D000000000000000000016 /* PBXTargetDependency */, ); name = voltraexample; productName = voltraexample; @@ -123,13 +123,13 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit isa = PBXNativeTarget; name = VoltraWidgetExtension; productName = VoltraWidgetExtension; - productReference = F1D000000000000000000005; productType = "com.apple.product-type.app-extension"; buildConfigurationList = F1D000000000000000000002; + productReference = F1D000000000000000000005; buildPhases = ( - F1D000000000000000000010 /* Sources */, - F1D000000000000000000014 /* Frameworks */, - F1D000000000000000000015 /* Resources */, + F1D00000000000000000000E /* Sources */, + F1D000000000000000000012 /* Frameworks */, + F1D000000000000000000013 /* Resources */, ); buildRules = ( ); @@ -181,12 +181,12 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit ); runOnlyForDeploymentPostprocessing = 0; }; - F1D000000000000000000015 /* Resources */ = { + F1D000000000000000000013 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - F1D000000000000000000016 /* Assets.xcassets in Resources */, - F1D000000000000000000017 /* VoltraWidgets.strings in Resources */, + F1D000000000000000000014 /* Assets.xcassets in Resources */, + F1D000000000000000000015 /* VoltraWidgets.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -201,12 +201,12 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit ); runOnlyForDeploymentPostprocessing = 0; }; - F1D000000000000000000010 /* Sources */ = { + F1D00000000000000000000E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F1D000000000000000000011 /* VoltraWidget.swift in Sources */, - F1D000000000000000000012 /* VoltraWidgetInitialStates.swift in Sources */, + F1D00000000000000000000F /* VoltraWidget.swift in Sources */, + F1D000000000000000000010 /* VoltraWidgetInitialStates.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -319,16 +319,30 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit }; /* End XCConfigurationList section */ +/* Begin PBXCopyFilesBuildPhase section */ + F1D000000000000000000011 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + files = ( + F1D000000000000000000006 /* VoltraWidgetExtension.appex in Embed Foundation Extensions */, + ); + runOnlyForDeploymentPostprocessing = 0; + name = "Embed Foundation Extensions"; + dstPath = ""; + dstSubfolderSpec = 13; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXTargetDependency section */ - F1D000000000000000000007 /* PBXTargetDependency */ = { + F1D000000000000000000016 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = F1D000000000000000000001 /* VoltraWidgetExtension */; - targetProxy = F1D000000000000000000008 /* PBXContainerItemProxy */; + targetProxy = F1D000000000000000000017 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin PBXContainerItemProxy section */ - F1D000000000000000000008 /* PBXContainerItemProxy */ = { + F1D000000000000000000017 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; proxyType = 1; @@ -336,20 +350,6 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit remoteInfo = VoltraWidgetExtension; }; /* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - F1D000000000000000000013 /* Embed Foundation Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - files = ( - F1D000000000000000000006 /* VoltraWidgetExtension.appex in Embed Foundation Extensions */, - ); - runOnlyForDeploymentPostprocessing = 0; - name = "Embed Foundation Extensions"; - dstPath = ""; - dstSubfolderSpec = 13; - }; -/* End PBXCopyFilesBuildPhase section */ }; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; } diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts index 21016df7..7f9d0d6a 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts @@ -99,7 +99,7 @@ export function ensureWidgetBundleScriptPhase(xcodeProject: XcodeProject, target } } -export interface AddBuildPhasesOptions { +export interface EnsureBuildPhasesOptions { targetUuid: string targetName: string groupName: string @@ -111,9 +111,6 @@ export interface AddBuildPhasesOptions { group: string } widgetFiles: IOSWidgetExtensionFiles -} - -export interface EnsureBuildPhasesOptions extends AddBuildPhasesOptions { mainTargetUuid?: string } @@ -143,49 +140,33 @@ function findExistingEmbedExtensionsPhase(xcodeProject: XcodeProject, targetUuid } /** - * Adds all required build phases for the widget extension target. - * - * File-bearing phases are created empty and then populated through the widget-scoped - * {@link ensureBuildPhaseFiles}, so the widget never adopts a PBXBuildFile that belongs to another - * target's phase. This requires the widget's PBXGroup to already exist (see `addPbxGroup`). + * Finds a build phase of the given pbx section type on the given target, matched by the phase + * entry's comment. The lookup never leaves the target's own `buildPhases` list, unlike the xcode + * lib's `buildPhaseObject`, which silently falls back to a project-wide comment search and can + * return another target's phase (e.g. the main app's "Sources"). */ -export function addBuildPhases(xcodeProject: XcodeProject, options: AddBuildPhasesOptions): void { - const { targetUuid, targetName, groupName, productFile, widgetFiles } = options - const buildPath = `""` - const folderType = 'app_extension' - const mainTargetUuid = xcodeProject.getFirstTarget().uuid - - const { swiftFiles, intentFiles, assetDirectories, localizedStringResources } = widgetFiles - const resourcePaths = [...assetDirectories, ...localizedStringResources] - - // Sources build phase - xcodeProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', targetUuid, folderType, buildPath) - const sourcesPhase = xcodeProject.buildPhaseObject('PBXSourcesBuildPhase', 'Sources', targetUuid) - if (sourcesPhase) { - ensureBuildPhaseFiles(xcodeProject, sourcesPhase, [...swiftFiles, ...intentFiles], targetName) +function findTargetPhaseByComment( + xcodeProject: XcodeProject, + targetUuid: string, + phaseType: string, + comment: string +): any | null { + const target = xcodeProject.pbxNativeTargetSection()[targetUuid] + if (!target?.buildPhases) { + return null } - // Copy files build phase — reuse an existing embed-extensions phase if the app already has one - const existingEmbedPhase = findExistingEmbedExtensionsPhase(xcodeProject, mainTargetUuid) - if (existingEmbedPhase) { - ensureCopyFilesPhaseProduct(xcodeProject, existingEmbedPhase, productFile) - } else { - xcodeProject.addBuildPhase([], 'PBXCopyFilesBuildPhase', groupName, mainTargetUuid, folderType, buildPath) - const copyFilesPhase = xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, mainTargetUuid) - if (copyFilesPhase) { - ensureCopyFilesPhaseProduct(xcodeProject, copyFilesPhase, productFile) + const phaseSection = xcodeProject.hash.project.objects[phaseType] || {} + for (const entry of target.buildPhases) { + if (entry.comment !== comment) { + continue + } + const phase = phaseSection[normalizeRef(entry.value)] + if (phase) { + return phase } } - - // Frameworks build phase - xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetUuid, folderType, buildPath) - - // Resources build phase - xcodeProject.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', targetUuid) - const resourcesPhase = xcodeProject.buildPhaseObject('PBXResourcesBuildPhase', 'Resources', targetUuid) - if (resourcesPhase) { - ensureBuildPhaseFiles(xcodeProject, resourcesPhase, resourcePaths, targetName) - } + return null } /** @@ -205,10 +186,10 @@ export function ensureBuildPhases(xcodeProject: XcodeProject, options: EnsureBui dedupeBuildPhasesByComment(xcodeProject, mainTargetUuid, 'PBXCopyFilesBuildPhase', groupName) // Sources build phase - let sourcesPhase = xcodeProject.buildPhaseObject('PBXSourcesBuildPhase', 'Sources', targetUuid) + let sourcesPhase = findTargetPhaseByComment(xcodeProject, targetUuid, 'PBXSourcesBuildPhase', 'Sources') if (!sourcesPhase) { xcodeProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', targetUuid, folderType, buildPath) - sourcesPhase = xcodeProject.buildPhaseObject('PBXSourcesBuildPhase', 'Sources', targetUuid) + sourcesPhase = findTargetPhaseByComment(xcodeProject, targetUuid, 'PBXSourcesBuildPhase', 'Sources') } if (sourcesPhase) { ensureBuildPhaseFiles(xcodeProject, sourcesPhase, [...swiftFiles, ...intentFiles], targetName) @@ -217,26 +198,26 @@ export function ensureBuildPhases(xcodeProject: XcodeProject, options: EnsureBui // Copy files build phase (embed extension into main app) — reuse any existing embed phase const existingEmbedPhase = findExistingEmbedExtensionsPhase(xcodeProject, mainTargetUuid) let copyFilesPhase = - existingEmbedPhase ?? xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, mainTargetUuid) + existingEmbedPhase ?? findTargetPhaseByComment(xcodeProject, mainTargetUuid, 'PBXCopyFilesBuildPhase', groupName) if (!copyFilesPhase) { xcodeProject.addBuildPhase([], 'PBXCopyFilesBuildPhase', groupName, mainTargetUuid, folderType, buildPath) - copyFilesPhase = xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, mainTargetUuid) + copyFilesPhase = findTargetPhaseByComment(xcodeProject, mainTargetUuid, 'PBXCopyFilesBuildPhase', groupName) } if (copyFilesPhase) { ensureCopyFilesPhaseProduct(xcodeProject, copyFilesPhase, productFile) } // Frameworks build phase - const frameworksPhase = xcodeProject.buildPhaseObject('PBXFrameworksBuildPhase', 'Frameworks', targetUuid) + const frameworksPhase = findTargetPhaseByComment(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase', 'Frameworks') if (!frameworksPhase) { xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetUuid, folderType, buildPath) } // Resources build phase - let resourcesPhase = xcodeProject.buildPhaseObject('PBXResourcesBuildPhase', 'Resources', targetUuid) + let resourcesPhase = findTargetPhaseByComment(xcodeProject, targetUuid, 'PBXResourcesBuildPhase', 'Resources') if (!resourcesPhase) { xcodeProject.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', targetUuid) - resourcesPhase = xcodeProject.buildPhaseObject('PBXResourcesBuildPhase', 'Resources', targetUuid) + resourcesPhase = findTargetPhaseByComment(xcodeProject, targetUuid, 'PBXResourcesBuildPhase', 'Resources') } if (resourcesPhase) { ensureBuildPhaseFiles(xcodeProject, resourcesPhase, resourcePaths, targetName) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/configurationList.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/configurationList.ts index c7c871e3..cdf4caaf 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/configurationList.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/configurationList.ts @@ -52,9 +52,10 @@ function createCommonBuildSettings(options: AddConfigurationListOptions) { } /** - * Adds the XCConfigurationList for the widget extension target. + * Adds a fresh XCConfigurationList for the widget extension target. Internal create-only fallback + * for {@link ensureXCConfigurationList}; not part of the public pipeline. */ -export function addXCConfigurationList(xcodeProject: XcodeProject, options: AddConfigurationListOptions) { +function addXCConfigurationList(xcodeProject: XcodeProject, options: AddConfigurationListOptions) { const { targetName } = options const commonBuildSettings = createCommonBuildSettings(options) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/groups.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/groups.ts index 27d94011..e1c6559b 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/groups.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/groups.ts @@ -32,8 +32,10 @@ function collectGroupFiles(widgetFiles: IOSWidgetExtensionFiles): string[] { * produce the "no parent for object" consistency errors from issues #87/#32. File references are * resolved through the widget-scoped {@link ensureWidgetFileReference} so they are shared with the * build phases instead of duplicated. + * + * Internal create-only fallback for {@link ensurePbxGroup}; not part of the public pipeline. */ -export function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOptions): void { +function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOptions): void { const { targetName, widgetFiles } = options const allFiles = collectGroupFiles(widgetFiles) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts index d4ef27a0..1d7bb207 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts @@ -4,12 +4,12 @@ import * as path from 'path' import type { IOSWidgetConfig, IOSWidgetExtensionFiles } from '../../types' import { getIOSWidgetExtensionFiles } from '../../utils/fileDiscovery' import { detectClientRenderedWidgets } from '../clientRendered' -import { addBuildPhases, ensureBuildPhases, ensureWidgetBundleScriptPhase } from './buildPhases' -import { addXCConfigurationList, ensureXCConfigurationList } from './configurationList' -import { addPbxGroup, ensurePbxGroup } from './groups' +import { ensureBuildPhases, ensureWidgetBundleScriptPhase } from './buildPhases' +import { ensureXCConfigurationList } from './configurationList' +import { ensurePbxGroup } from './groups' import { getMainAppTargetSettings } from './mainAppSettings' -import { addProductFile, ensureProductFile } from './productFile' -import { configureTarget, ensureTargetAttributes, ensureTargetDependency } from './target' +import { ensureProductFile } from './productFile' +import { createTargetSkeleton, ensureTargetAttributes, ensureTargetDependency } from './target' export interface ConfigureXcodeProjectProps { targetName: string @@ -26,8 +26,9 @@ export interface ConfigureXcodeProjectProps { * project in place. It performs no filesystem or `modRequest` access, which makes it directly * testable against pbxproj fixtures. * - * It either creates the widget extension target (and all its build phases, product file and group) - * or, when the target already exists, reconciles it idempotently. + * When the widget target does not exist yet, the minimal native-target skeleton is created first + * (targets cannot be "ensured" into existence); every other object is then reconciled by the same + * idempotent ensure pipeline that runs for an already-existing target. */ export function applyXcodeChanges( xcodeProject: XcodeProject, @@ -38,120 +39,74 @@ export function applyXcodeChanges( const { targetName, bundleIdentifier, deploymentTarget } = props const groupName = 'Embed Foundation Extensions' - // Check if target already exists - const nativeTargets = xcodeProject.pbxNativeTargetSection() - const existingTargetKey = xcodeProject.findTargetKey(targetName) - const existingTarget = existingTargetKey ? nativeTargets[existingTargetKey] : null - - // Read main app target settings to synchronize code signing + // Read main app target settings to synchronize code signing (per configuration). const mainAppSettings = getMainAppTargetSettings(xcodeProject) - // Use the deploymentTarget from plugin config (or default), ignore main app's deployment target - // This allows the widget extension to have its own deployment target independent of the main app - - if (existingTarget && existingTargetKey) { - // Ensure configuration list is up to date - const xCConfigurationList = ensureXCConfigurationList( - xcodeProject, - { - targetName, - bundleIdentifier, - deploymentTarget, - codeSignStyle: mainAppSettings?.codeSignStyle, - developmentTeam: mainAppSettings?.developmentTeam, - provisioningProfileSpecifier: mainAppSettings?.provisioningProfileSpecifier, - }, - existingTarget.buildConfigurationList - ) - - // Ensure product file exists - const productFile = ensureProductFile(xcodeProject, { - targetName, - groupName, - }) - - // Update target references - existingTarget.productReference = productFile.fileRef - existingTarget.buildConfigurationList = xCConfigurationList.uuid - existingTarget.productType = `"com.apple.product-type.app-extension"` - existingTarget.name = targetName - existingTarget.productName = targetName - if (!existingTarget.buildPhases) { - existingTarget.buildPhases = [] - } - - // Ensure the group first so the widget's file references exist before the build phases - // reference them (the phases resolve files through the widget-scoped group). - ensurePbxGroup(xcodeProject, { - targetName, - widgetFiles, - }) - - // Ensure build phases and files - ensureBuildPhases(xcodeProject, { - targetUuid: existingTargetKey, - targetName, - groupName, - productFile, - widgetFiles, - mainTargetUuid: xcodeProject.getFirstTarget().uuid, - }) - - if (hasClientRenderedWidgets) { - ensureWidgetBundleScriptPhase(xcodeProject, existingTargetKey) - } + // Use the deploymentTarget from plugin config (or default), ignore main app's deployment target. + // This allows the widget extension to have its own deployment target independent of the main app. - ensureTargetAttributes(xcodeProject, existingTargetKey) - ensureTargetDependency(xcodeProject, existingTargetKey) - - return + // Resolve (or create) the widget target. A target cannot be reconciled into existence, so the + // absent case creates the minimal skeleton; from here on both paths share one pipeline. + let targetUuid = xcodeProject.findTargetKey(targetName) + if (!targetUuid) { + targetUuid = xcodeProject.generateUuid() + createTargetSkeleton(xcodeProject, { targetName, targetUuid }) } + const target = xcodeProject.pbxNativeTargetSection()[targetUuid] - // Add configuration list - const targetUuid = xcodeProject.generateUuid() - - const xCConfigurationList = addXCConfigurationList(xcodeProject, { - targetName, - bundleIdentifier, - deploymentTarget, - codeSignStyle: mainAppSettings?.codeSignStyle, - developmentTeam: mainAppSettings?.developmentTeam, - provisioningProfileSpecifier: mainAppSettings?.provisioningProfileSpecifier, - }) - - // Add product file - const productFile = addProductFile(xcodeProject, { + // Ensure the configuration list is present and its settings are in sync. + const xCConfigurationList = ensureXCConfigurationList( + xcodeProject, + { + targetName, + bundleIdentifier, + deploymentTarget, + codeSignStyle: mainAppSettings?.codeSignStyle, + developmentTeam: mainAppSettings?.developmentTeam, + provisioningProfileSpecifier: mainAppSettings?.provisioningProfileSpecifier, + }, + target.buildConfigurationList + ) + + // Ensure the product file (.appex) exists. + const productFile = ensureProductFile(xcodeProject, { targetName, groupName, }) - // Configure target (native target, project section, dependency) - configureTarget(xcodeProject, { - targetName, - targetUuid, - productFile, - xCConfigurationList, - }) + // Point the target at the reconciled configuration list and product file. + target.productReference = productFile.fileRef + target.buildConfigurationList = xCConfigurationList.uuid + target.productType = `"com.apple.product-type.app-extension"` + target.name = targetName + target.productName = targetName + if (!target.buildPhases) { + target.buildPhases = [] + } - // Add PBX group first so the widget's file references exist before the build phases reference - // them (the phases resolve files through the widget-scoped group). - addPbxGroup(xcodeProject, { + // Ensure the group first so the widget's file references exist before the build phases + // reference them (the phases resolve files through the widget-scoped group). + ensurePbxGroup(xcodeProject, { targetName, widgetFiles, }) - // Add build phases - addBuildPhases(xcodeProject, { + // Ensure build phases and their files. + ensureBuildPhases(xcodeProject, { targetUuid, targetName, groupName, productFile, widgetFiles, + mainTargetUuid: xcodeProject.getFirstTarget().uuid, }) if (hasClientRenderedWidgets) { ensureWidgetBundleScriptPhase(xcodeProject, targetUuid) } + + ensureTargetAttributes(xcodeProject, targetUuid) + ensureTargetDependency(xcodeProject, targetUuid) } /** diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/productFile.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/productFile.ts index d2f9ee2d..7c1cc412 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/productFile.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/productFile.ts @@ -42,9 +42,10 @@ function findBuildFileForFileRef(xcodeProject: XcodeProject, fileRef: string): { } /** - * Adds the product file (.appex) for the widget extension. + * Adds the product file (.appex) for the widget extension. Internal create-only fallback for + * {@link ensureProductFile}; not part of the public pipeline. */ -export function addProductFile(xcodeProject: XcodeProject, options: AddProductFileOptions) { +function addProductFile(xcodeProject: XcodeProject, options: AddProductFileOptions) { const { targetName, groupName } = options const productFileOptions = { diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/target.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/target.ts index 33ff911a..f4c7a177 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/target.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/target.ts @@ -6,18 +6,9 @@ import { IOS } from '../../constants' // Types // ============================================================================ -export interface ConfigureTargetOptions { +export interface CreateTargetSkeletonOptions { targetName: string targetUuid: string - productFile: { fileRef: string } - xCConfigurationList: { uuid: string } -} - -interface AddNativeTargetOptions { - targetName: string - targetUuid: string - productFile: { fileRef: string } - xCConfigurationList: { uuid: string } } // ============================================================================ @@ -25,19 +16,39 @@ interface AddNativeTargetOptions { // ============================================================================ /** - * Configures the widget extension target in the Xcode project. + * Creates the minimal widget-extension native target and registers it with the project. + * + * A target cannot be "ensured" into existence, so this is the one create-only step in + * {@link applyXcodeChanges}: it adds an empty `PBXNativeTarget` (no product reference, configuration + * list or build phases yet) to the `PBXNativeTarget` and `PBXProject` sections. The shared ensure + * pipeline then fills in the configuration list, product file, group, build phases, attributes and + * dependency — the same code that reconciles an already-existing target. * - * This: - * - Adds the target to PBXNativeTarget section - * - Adds the target to PBXProject section - * - Adds a dependency from the main app to the widget extension + * @returns The freshly created native-target object (the value stored in the `PBXNativeTarget` + * section under `targetUuid`). */ -export function configureTarget(xcodeProject: XcodeProject, options: ConfigureTargetOptions) { - const target = addToPbxNativeTargetSection(xcodeProject, options) - addToPbxProjectSection(xcodeProject, target) - addTargetDependency(xcodeProject, target) +export function createTargetSkeleton(xcodeProject: XcodeProject, options: CreateTargetSkeletonOptions) { + const { targetName, targetUuid } = options + + const target = { + uuid: targetUuid, + pbxNativeTarget: { + isa: 'PBXNativeTarget', + name: targetName, + productName: targetName, + productType: `"com.apple.product-type.app-extension"`, + buildConfigurationList: '', + productReference: '', + buildPhases: [], + buildRules: [], + dependencies: [], + }, + } - return target + xcodeProject.addToPbxNativeTargetSection(target) + xcodeProject.addToPbxProjectSection(target) + + return xcodeProject.pbxNativeTargetSection()[targetUuid] } /** @@ -83,66 +94,3 @@ export function ensureTargetDependency(xcodeProject: XcodeProject, targetUuid: s xcodeProject.addTargetDependency(mainTargetUuid, [targetUuid]) } } - -// ============================================================================ -// Helper Functions -// ============================================================================ - -/** - * Adds the widget extension target to the PBXNativeTarget section. - */ -function addToPbxNativeTargetSection(xcodeProject: XcodeProject, options: AddNativeTargetOptions) { - const { targetName, targetUuid, productFile, xCConfigurationList } = options - - const target = { - uuid: targetUuid, - pbxNativeTarget: { - isa: 'PBXNativeTarget', - name: targetName, - productName: targetName, - productReference: productFile.fileRef, - productType: `"com.apple.product-type.app-extension"`, - buildConfigurationList: xCConfigurationList.uuid, - buildPhases: [], - buildRules: [], - dependencies: [], - }, - } - - xcodeProject.addToPbxNativeTargetSection(target) - - return target -} - -/** - * Adds the target to the PBXProject section. - */ -function addToPbxProjectSection(xcodeProject: XcodeProject, target: { uuid: string }): void { - xcodeProject.addToPbxProjectSection(target) - - // Add target attributes to project section - const projectSection = xcodeProject.pbxProjectSection() - const firstProject = xcodeProject.getFirstProject() - - if (!projectSection[firstProject.uuid].attributes.TargetAttributes) { - projectSection[firstProject.uuid].attributes.TargetAttributes = {} - } - - projectSection[firstProject.uuid].attributes.TargetAttributes[target.uuid] = { - LastSwiftMigration: IOS.LAST_SWIFT_MIGRATION, - } -} - -/** - * Adds a target dependency so the main app depends on the widget extension. - */ -function addTargetDependency(xcodeProject: XcodeProject, target: { uuid: string }): void { - if (!xcodeProject.hash.project.objects['PBXTargetDependency']) { - xcodeProject.hash.project.objects['PBXTargetDependency'] = {} - } - if (!xcodeProject.hash.project.objects['PBXContainerItemProxy']) { - xcodeProject.hash.project.objects['PBXContainerItemProxy'] = {} - } - - xcodeProject.addTargetDependency(xcodeProject.getFirstTarget().uuid, [target.uuid]) -} From e4c911a54173b988aa21b29d78ec239e0be7b445 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 4 Jul 2026 15:27:20 +0200 Subject: [PATCH 2/6] refactor(ios): match build phases semantically instead of by comment pbxproj comments are decorative and other tools strip them, so finding a target's Sources/Frameworks/Resources phase by its comment string is fragile. ensureBuildPhases now finds a target's phase of a given type by walking target.buildPhases and checking membership in the type's object section (first match wins); the embed phase was already matched semantically via dstSubfolderSpec == 13 and now is on the creation fallback path too. Dedupe reworked accordingly: - dedupeBuildPhasesForTarget dedupes by type-section membership (keeps the first phase in buildPhases order, no comment preference) - dedupeBuildPhasesByComment replaced by dedupeEmbedPhasesForTarget, which dedupes dstSubfolderSpec == 13 copy-files phases only and leaves other copy-files phases untouched - orphan phase-object deletion behavior kept No serialized-output change: the applyXcodeChanges snapshot is byte-identical. --- .../src/ios-widget/xcode/buildPhases.ts | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts index 7f9d0d6a..a5fd6e65 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts @@ -140,17 +140,16 @@ function findExistingEmbedExtensionsPhase(xcodeProject: XcodeProject, targetUuid } /** - * Finds a build phase of the given pbx section type on the given target, matched by the phase - * entry's comment. The lookup never leaves the target's own `buildPhases` list, unlike the xcode - * lib's `buildPhaseObject`, which silently falls back to a project-wide comment search and can - * return another target's phase (e.g. the main app's "Sources"). + * Finds a target's build phase of the given pbx section type by walking the target's own + * `buildPhases` list and checking membership in the type's object section; the first match wins. + * + * This is deliberately semantic: pbxproj comments are decorative and other tools strip them, and + * the xcode lib's `buildPhaseObject` both relies on comments and silently falls back to a + * project-wide search that can return another target's phase (e.g. the main app's "Sources"). + * The embed phase is the one exception with extra semantics — see + * {@link findExistingEmbedExtensionsPhase} (`dstSubfolderSpec == 13`). */ -function findTargetPhaseByComment( - xcodeProject: XcodeProject, - targetUuid: string, - phaseType: string, - comment: string -): any | null { +function findTargetPhaseByType(xcodeProject: XcodeProject, targetUuid: string, phaseType: string): any | null { const target = xcodeProject.pbxNativeTargetSection()[targetUuid] if (!target?.buildPhases) { return null @@ -158,9 +157,6 @@ function findTargetPhaseByComment( const phaseSection = xcodeProject.hash.project.objects[phaseType] || {} for (const entry of target.buildPhases) { - if (entry.comment !== comment) { - continue - } const phase = phaseSection[normalizeRef(entry.value)] if (phase) { return phase @@ -181,55 +177,55 @@ export function ensureBuildPhases(xcodeProject: XcodeProject, options: EnsureBui const { swiftFiles, intentFiles, assetDirectories, localizedStringResources } = widgetFiles const resourcePaths = [...assetDirectories, ...localizedStringResources] - dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXSourcesBuildPhase', 'Sources') - dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase', 'Frameworks') - dedupeBuildPhasesByComment(xcodeProject, mainTargetUuid, 'PBXCopyFilesBuildPhase', groupName) + dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXSourcesBuildPhase') + dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase') + dedupeEmbedPhasesForTarget(xcodeProject, mainTargetUuid) // Sources build phase - let sourcesPhase = findTargetPhaseByComment(xcodeProject, targetUuid, 'PBXSourcesBuildPhase', 'Sources') + let sourcesPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXSourcesBuildPhase') if (!sourcesPhase) { xcodeProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', targetUuid, folderType, buildPath) - sourcesPhase = findTargetPhaseByComment(xcodeProject, targetUuid, 'PBXSourcesBuildPhase', 'Sources') + sourcesPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXSourcesBuildPhase') } if (sourcesPhase) { ensureBuildPhaseFiles(xcodeProject, sourcesPhase, [...swiftFiles, ...intentFiles], targetName) } - // Copy files build phase (embed extension into main app) — reuse any existing embed phase - const existingEmbedPhase = findExistingEmbedExtensionsPhase(xcodeProject, mainTargetUuid) - let copyFilesPhase = - existingEmbedPhase ?? findTargetPhaseByComment(xcodeProject, mainTargetUuid, 'PBXCopyFilesBuildPhase', groupName) + // Copy files build phase (embed extension into main app) — the embed phase is matched purely by + // its `dstSubfolderSpec == 13` semantics, both before and after creation + let copyFilesPhase = findExistingEmbedExtensionsPhase(xcodeProject, mainTargetUuid) if (!copyFilesPhase) { xcodeProject.addBuildPhase([], 'PBXCopyFilesBuildPhase', groupName, mainTargetUuid, folderType, buildPath) - copyFilesPhase = findTargetPhaseByComment(xcodeProject, mainTargetUuid, 'PBXCopyFilesBuildPhase', groupName) + copyFilesPhase = findExistingEmbedExtensionsPhase(xcodeProject, mainTargetUuid) } if (copyFilesPhase) { ensureCopyFilesPhaseProduct(xcodeProject, copyFilesPhase, productFile) } // Frameworks build phase - const frameworksPhase = findTargetPhaseByComment(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase', 'Frameworks') + const frameworksPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase') if (!frameworksPhase) { xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetUuid, folderType, buildPath) } // Resources build phase - let resourcesPhase = findTargetPhaseByComment(xcodeProject, targetUuid, 'PBXResourcesBuildPhase', 'Resources') + let resourcesPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXResourcesBuildPhase') if (!resourcesPhase) { xcodeProject.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', targetUuid) - resourcesPhase = findTargetPhaseByComment(xcodeProject, targetUuid, 'PBXResourcesBuildPhase', 'Resources') + resourcesPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXResourcesBuildPhase') } if (resourcesPhase) { ensureBuildPhaseFiles(xcodeProject, resourcesPhase, resourcePaths, targetName) } } -function dedupeBuildPhasesForTarget( - xcodeProject: XcodeProject, - targetUuid: string, - phaseType: string, - preferredComment: string -): void { +/** + * Drops duplicate build phases of one pbx section type from a target, keeping the first phase in + * the target's `buildPhases` order. Membership in the type's object section is the only criterion — + * comments are decorative and other tools strip them. De-referenced phase objects are deleted so no + * orphans remain. + */ +function dedupeBuildPhasesForTarget(xcodeProject: XcodeProject, targetUuid: string, phaseType: string): void { const nativeTargets = xcodeProject.pbxNativeTargetSection() const target = nativeTargets[targetUuid] if (!target?.buildPhases) { @@ -237,17 +233,15 @@ function dedupeBuildPhasesForTarget( } const phaseSection = xcodeProject.hash.project.objects[phaseType] || {} - const matching = target.buildPhases.filter((entry: any) => phaseSection[entry.value]) + const matching = target.buildPhases.filter((entry: any) => phaseSection[normalizeRef(entry.value)]) if (matching.length <= 1) { return } - const keep = matching.find((entry: any) => entry.comment === preferredComment) ?? matching[0] - keep.comment = preferredComment - + const keep = matching[0] const removed = matching.filter((entry: any) => entry.value !== keep.value) target.buildPhases = target.buildPhases.filter((entry: any) => { - if (!phaseSection[entry.value]) { + if (!phaseSection[normalizeRef(entry.value)]) { return true } return entry.value === keep.value @@ -255,20 +249,26 @@ function dedupeBuildPhasesForTarget( deleteOrphanPhaseObjects(phaseSection, removed) } -function dedupeBuildPhasesByComment( - xcodeProject: XcodeProject, - targetUuid: string, - phaseType: string, - comment: string -): void { +/** + * Drops duplicate embed-app-extensions copy-files phases (`dstSubfolderSpec == 13`) from a target, + * keeping the first one in the target's `buildPhases` order. Other copy-files phases (different + * `dstSubfolderSpec`) are left untouched. De-referenced phase objects are deleted so no orphans + * remain. + */ +function dedupeEmbedPhasesForTarget(xcodeProject: XcodeProject, targetUuid: string): void { const nativeTargets = xcodeProject.pbxNativeTargetSection() const target = nativeTargets[targetUuid] if (!target?.buildPhases) { return } - const phaseSection = xcodeProject.hash.project.objects[phaseType] || {} - const matching = target.buildPhases.filter((entry: any) => phaseSection[entry.value] && entry.comment === comment) + const phaseSection = xcodeProject.hash.project.objects['PBXCopyFilesBuildPhase'] || {} + const isEmbedEntry = (entry: any) => { + const phase = phaseSection[normalizeRef(entry.value)] + return phase && String(phase.dstSubfolderSpec) === '13' + } + + const matching = target.buildPhases.filter(isEmbedEntry) if (matching.length <= 1) { return } @@ -276,7 +276,7 @@ function dedupeBuildPhasesByComment( const keep = matching[0] const removed = matching.filter((entry: any) => entry.value !== keep.value) target.buildPhases = target.buildPhases.filter((entry: any) => { - if (!phaseSection[entry.value] || entry.comment !== comment) { + if (!isEmbedEntry(entry)) { return true } return entry.value === keep.value From 0341f7ac028701ee57b02291860af016565968af Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 4 Jul 2026 15:30:32 +0200 Subject: [PATCH 3/6] fix(ios): sync widget code signing per build configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getMainAppTargetSettings read only the FIRST build configuration of the main app target (usually Debug), so the widget extension received Debug signing (DEVELOPMENT_TEAM/CODE_SIGN_STYLE/PROVISIONING_PROFILE_SPECIFIER) for both of its configurations — Release provisioning often differs and archive builds then failed to sign. - getMainAppTargetSettings now returns signing settings keyed by configuration name plus a first-configuration fallback - ensureXCConfigurationList applies settings per widget configuration name (Debug→Debug, Release→Release) and falls back to the first main app configuration's settings for unknown names - new fixture with-per-config-signing.pbxproj (main app Debug/Release carry different PROVISIONING_PROFILE_SPECIFIER) with tests asserting the per-config mapping on both the fresh and idempotent re-run paths The unused deploymentTarget passthrough in MainAppTargetSettings is gone: applyXcodeChanges always used the plugin's own deploymentTarget. Snapshot unchanged (fixtures a/b carry no signing settings). --- .../with-per-config-signing.pbxproj | 203 ++++++++++++++++++ .../xcode/applyXcodeChanges.node.test.ts | 49 +++++ .../src/ios-widget/xcode/configurationList.ts | 73 ++++--- .../expo-plugin/src/ios-widget/xcode/index.ts | 4 +- .../src/ios-widget/xcode/mainAppSettings.ts | 113 +++++----- 5 files changed, 353 insertions(+), 89 deletions(-) create mode 100644 packages/ios-client/expo-plugin/src/ios-widget/xcode/__fixtures__/with-per-config-signing.pbxproj diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/__fixtures__/with-per-config-signing.pbxproj b/packages/ios-client/expo-plugin/src/ios-widget/xcode/__fixtures__/with-per-config-signing.pbxproj new file mode 100644 index 00000000..6b26f94e --- /dev/null +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/__fixtures__/with-per-config-signing.pbxproj @@ -0,0 +1,203 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBF1A68108700A75B9A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.swift */; }; + 13B07FC11A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC21A68108700A75B9A /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB61A68108700A75B9A /* Info.plist */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 13B07F961A680F5B00A75B9A /* voltraexample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = voltraexample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB01A68108700A75B9A /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = voltraexample/AppDelegate.swift; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = voltraexample/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = voltraexample/Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* voltraexample */, + 83CBBA001A601CBA00E9B192 /* Products */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 13B07FAE1A68108700A75B9A /* voltraexample */ = { + isa = PBXGroup; + children = ( + 13B07FB01A68108700A75B9A /* AppDelegate.swift */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + ); + name = voltraexample; + sourceTree = ""; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* voltraexample.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* voltraexample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "voltraexample" */; + buildPhases = ( + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = voltraexample; + productName = voltraexample; + productReference = 13B07F961A680F5B00A75B9A /* voltraexample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1210; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1250; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "voltraexample" */; + compatibilityVersion = "Xcode 12.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* voltraexample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FC11A68108700A75B9A /* Images.xcassets in Resources */, + 13B07FC21A68108700A75B9A /* Info.plist in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBF1A68108700A75B9A /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ABCDE12345; + INFOPLIST_FILE = voltraexample/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.voltra.example; + PRODUCT_NAME = voltraexample; + PROVISIONING_PROFILE_SPECIFIER = "Voltra Dev Profile"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ABCDE12345; + INFOPLIST_FILE = voltraexample/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.voltra.example; + PRODUCT_NAME = voltraexample; + PROVISIONING_PROFILE_SPECIFIER = "Voltra AppStore Profile"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + SDKROOT = iphoneos; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "voltraexample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "voltraexample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts index df6a0fb3..b32ab65b 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts @@ -129,3 +129,52 @@ describe('applyXcodeChanges — project with a pre-existing extension (fixture b expect(afterThird).toEqual(afterSecond) }) }) + +describe('applyXcodeChanges — per-configuration code signing (fixture c)', () => { + /** Resolves the widget target's build settings keyed by configuration name. */ + function widgetBuildSettingsByConfigName(project: any): Record> { + const objects = project.hash.project.objects + const targetKey = project.findTargetKey(PROPS.targetName) + const listId = String(objects.PBXNativeTarget[targetKey].buildConfigurationList).split(' ')[0] + const configRefs = objects.XCConfigurationList[listId].buildConfigurations + + const result: Record> = {} + for (const ref of configRefs) { + const configId = typeof ref === 'string' ? ref.split(' ')[0] : ref.value.split(' ')[0] + const config = objects.XCBuildConfiguration[configId] + result[String(config.name).replace(/^"|"$/g, '')] = config.buildSettings + } + return result + } + + it('maps main app Debug signing to widget Debug and Release to Release', () => { + const project = loadFixtureProject('with-per-config-signing.pbxproj') + useDeterministicUuids(project) + + applyXcodeChanges(project, PROPS, WIDGET_FILES) + + const settings = widgetBuildSettingsByConfigName(project) + expect(settings.Debug.PROVISIONING_PROFILE_SPECIFIER).toBe('"Voltra Dev Profile"') + expect(settings.Release.PROVISIONING_PROFILE_SPECIFIER).toBe('"Voltra AppStore Profile"') + expect(settings.Debug.DEVELOPMENT_TEAM).toBe('"ABCDE12345"') + expect(settings.Release.DEVELOPMENT_TEAM).toBe('"ABCDE12345"') + expect(settings.Debug.CODE_SIGN_STYLE).toBe('"Manual"') + expect(settings.Release.CODE_SIGN_STYLE).toBe('"Manual"') + + expect(() => assertPbxConsistency(project)).not.toThrow() + }) + + it('keeps the per-config mapping on an idempotent re-run (existing-target path)', () => { + const project = loadFixtureProject('with-per-config-signing.pbxproj') + useDeterministicUuids(project) + + applyXcodeChanges(project, PROPS, WIDGET_FILES) + applyXcodeChanges(project, PROPS, WIDGET_FILES) + + const settings = widgetBuildSettingsByConfigName(project) + expect(settings.Debug.PROVISIONING_PROFILE_SPECIFIER).toBe('"Voltra Dev Profile"') + expect(settings.Release.PROVISIONING_PROFILE_SPECIFIER).toBe('"Voltra AppStore Profile"') + + expect(() => assertPbxConsistency(project)).not.toThrow() + }) +}) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/configurationList.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/configurationList.ts index cdf4caaf..fe407c26 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/configurationList.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/configurationList.ts @@ -1,27 +1,38 @@ import { XcodeProject } from '@expo/config-plugins' import { IOS } from '../../constants' +import type { MainAppSigningSettings, MainAppTargetSettings } from './mainAppSettings' -export interface AddConfigurationListOptions { +export interface EnsureConfigurationListOptions { targetName: string bundleIdentifier: string deploymentTarget: string - codeSignStyle?: string - developmentTeam?: string - provisioningProfileSpecifier?: string + /** Main app signing settings, applied per configuration name (Debug→Debug, Release→Release). */ + mainAppSettings?: MainAppTargetSettings | null } -function createCommonBuildSettings(options: AddConfigurationListOptions) { - const { - targetName, - bundleIdentifier, - deploymentTarget, - codeSignStyle, - developmentTeam, - provisioningProfileSpecifier, - } = options - - const commonBuildSettings: Record = { +/** + * Resolves the main app signing settings for a widget configuration name: an exact name match + * wins, otherwise the first available configuration's settings are used as fallback. + */ +function signingForConfiguration( + mainAppSettings: MainAppTargetSettings | null | undefined, + configurationName: string +): MainAppSigningSettings { + if (!mainAppSettings) { + return {} + } + return mainAppSettings.byConfigurationName[configurationName] ?? mainAppSettings.fallback +} + +function createBuildSettings(options: EnsureConfigurationListOptions, configurationName: string) { + const { targetName, bundleIdentifier, deploymentTarget, mainAppSettings } = options + const { codeSignStyle, developmentTeam, provisioningProfileSpecifier } = signingForConfiguration( + mainAppSettings, + configurationName + ) + + const buildSettings: Record = { PRODUCT_NAME: `"$(TARGET_NAME)"`, SWIFT_VERSION: IOS.SWIFT_VERSION, TARGETED_DEVICE_FAMILY: `"${IOS.DEVICE_FAMILY}"`, @@ -37,42 +48,37 @@ function createCommonBuildSettings(options: AddConfigurationListOptions) { ASSETCATALOG_COMPILER_APPICON_NAME: '""', } - // Synchronize code signing settings from main app target + // Synchronize code signing settings from the main app target's matching configuration if (codeSignStyle) { - commonBuildSettings.CODE_SIGN_STYLE = `"${codeSignStyle}"` + buildSettings.CODE_SIGN_STYLE = `"${codeSignStyle}"` } if (developmentTeam) { - commonBuildSettings.DEVELOPMENT_TEAM = `"${developmentTeam}"` + buildSettings.DEVELOPMENT_TEAM = `"${developmentTeam}"` } if (provisioningProfileSpecifier) { - commonBuildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${provisioningProfileSpecifier}"` + buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${provisioningProfileSpecifier}"` } - return commonBuildSettings + return buildSettings } /** * Adds a fresh XCConfigurationList for the widget extension target. Internal create-only fallback * for {@link ensureXCConfigurationList}; not part of the public pipeline. */ -function addXCConfigurationList(xcodeProject: XcodeProject, options: AddConfigurationListOptions) { +function addXCConfigurationList(xcodeProject: XcodeProject, options: EnsureConfigurationListOptions) { const { targetName } = options - const commonBuildSettings = createCommonBuildSettings(options) const buildConfigurationsList = [ { name: 'Debug', isa: 'XCBuildConfiguration', - buildSettings: { - ...commonBuildSettings, - }, + buildSettings: createBuildSettings(options, 'Debug'), }, { name: 'Release', isa: 'XCBuildConfiguration', - buildSettings: { - ...commonBuildSettings, - }, + buildSettings: createBuildSettings(options, 'Release'), }, ] @@ -86,11 +92,13 @@ function addXCConfigurationList(xcodeProject: XcodeProject, options: AddConfigur } /** - * Ensures an existing XCConfigurationList is updated, or adds a new one if missing. + * Ensures an existing XCConfigurationList is updated, or adds a new one if missing. Each widget + * configuration receives the build settings derived for its own name, so Debug and Release signing + * stay in sync with the main app's respective configurations. */ export function ensureXCConfigurationList( xcodeProject: XcodeProject, - options: AddConfigurationListOptions, + options: EnsureConfigurationListOptions, existingConfigurationListId?: string | { value?: string } ) { const configurationListId = @@ -104,7 +112,6 @@ export function ensureXCConfigurationList( return addXCConfigurationList(xcodeProject, options) } - const commonBuildSettings = createCommonBuildSettings(options) const buildConfigurations = configurationList.buildConfigurations const buildConfigurationSection = xcodeProject.pbxXCBuildConfigurationSection() @@ -118,9 +125,11 @@ export function ensureXCConfigurationList( continue } + const configurationName = + typeof buildConfiguration.name === 'string' ? buildConfiguration.name.replace(/^"|"$/g, '') : '' buildConfiguration.buildSettings = { ...(buildConfiguration.buildSettings ?? {}), - ...commonBuildSettings, + ...createBuildSettings(options, configurationName), } } diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts index 1d7bb207..9f65dc8c 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts @@ -61,9 +61,7 @@ export function applyXcodeChanges( targetName, bundleIdentifier, deploymentTarget, - codeSignStyle: mainAppSettings?.codeSignStyle, - developmentTeam: mainAppSettings?.developmentTeam, - provisioningProfileSpecifier: mainAppSettings?.provisioningProfileSpecifier, + mainAppSettings, }, target.buildConfigurationList ) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/mainAppSettings.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/mainAppSettings.ts index 23f3e809..cd949d22 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/mainAppSettings.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/mainAppSettings.ts @@ -1,27 +1,55 @@ import { XcodeProject } from '@expo/config-plugins' -export interface MainAppTargetSettings { - deploymentTarget: string +/** Code-signing settings read from one of the main app target's build configurations. */ +export interface MainAppSigningSettings { codeSignStyle?: string developmentTeam?: string provisioningProfileSpecifier?: string } +export interface MainAppTargetSettings { + /** + * Signing settings keyed by build-configuration name (e.g. `Debug`, `Release`). Release + * provisioning often differs from Debug, so the widget must mirror the main app per + * configuration rather than copying one configuration's signing into both. + */ + byConfigurationName: Record + /** The first configuration's settings, used for widget configurations with no name match. */ + fallback: MainAppSigningSettings +} + +/** Strips surrounding quotes from a raw build-setting value. */ +function unquote(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + return value.replace(/^"|"$/g, '') +} + +/** Reads the signing-related build settings from a single XCBuildConfiguration. */ +function readSigningSettings(buildSettings: Record): MainAppSigningSettings { + return { + codeSignStyle: unquote(buildSettings.CODE_SIGN_STYLE), + developmentTeam: unquote(buildSettings.DEVELOPMENT_TEAM), + provisioningProfileSpecifier: unquote(buildSettings.PROVISIONING_PROFILE_SPECIFIER), + } +} + /** - * Reads build settings from the main app target to synchronize code signing with the widget extension. + * Reads code-signing build settings from every build configuration of the main app target, so the + * widget extension can synchronize signing per configuration (Debug→Debug, Release→Release). * * @param xcodeProject The Xcode project instance - * @returns The main app target's build settings, or null if not found + * @returns Signing settings keyed by configuration name plus a first-configuration fallback, or + * null when the main app target or its configurations cannot be resolved */ export function getMainAppTargetSettings(xcodeProject: XcodeProject): MainAppTargetSettings | null { try { const mainAppTarget = xcodeProject.getFirstTarget()?.firstTarget - if (!mainAppTarget) { return null } - // Get the build configuration list for the main app target const buildConfigurationListId = mainAppTarget.buildConfigurationList if (!buildConfigurationListId) { return null @@ -32,62 +60,39 @@ export function getMainAppTargetSettings(xcodeProject: XcodeProject): MainAppTar const buildConfigurationList = xcodeProject.hash.project.objects['XCConfigurationList']?.[buildConfigurationListUuid] - if (!buildConfigurationList || !buildConfigurationList.buildConfigurations) { - return null - } - - // Get the first build configuration (usually Debug) to read settings - const firstConfigRef = buildConfigurationList.buildConfigurations[0] - if (!firstConfigRef) { - return null - } - - // Extract UUID from config reference (it might include a comment) - const firstConfigUuid = - typeof firstConfigRef === 'string' ? firstConfigRef.split(' ')[0] : firstConfigRef.value?.split(' ')[0] - if (!firstConfigUuid) { + if (!buildConfigurationList?.buildConfigurations) { return null } - const buildConfiguration = xcodeProject.hash.project.objects['XCBuildConfiguration']?.[firstConfigUuid] - if (!buildConfiguration || !buildConfiguration.buildSettings) { - return null + const buildConfigurationSection = xcodeProject.hash.project.objects['XCBuildConfiguration'] ?? {} + const byConfigurationName: Record = {} + let fallback: MainAppSigningSettings | null = null + + for (const configRef of buildConfigurationList.buildConfigurations) { + // Extract UUID from the config reference (it might include a comment) + const configUuid = typeof configRef === 'string' ? configRef.split(' ')[0] : configRef.value?.split(' ')[0] + if (!configUuid) { + continue + } + + const buildConfiguration = buildConfigurationSection[configUuid] + if (!buildConfiguration?.buildSettings) { + continue + } + + const settings = readSigningSettings(buildConfiguration.buildSettings) + const configName = unquote(buildConfiguration.name) + if (configName && !(configName in byConfigurationName)) { + byConfigurationName[configName] = settings + } + fallback = fallback ?? settings } - const buildSettings = buildConfiguration.buildSettings - - // Extract deployment target (remove quotes if present) - const deploymentTarget = - buildSettings.IPHONEOS_DEPLOYMENT_TARGET?.replace(/^"|"$/g, '') || - buildSettings['IPHONEOS_DEPLOYMENT_TARGET']?.replace(/^"|"$/g, '') || - null - - // Extract code signing settings - const codeSignStyle = - buildSettings.CODE_SIGN_STYLE?.replace(/^"|"$/g, '') || - buildSettings['CODE_SIGN_STYLE']?.replace(/^"|"$/g, '') || - null - - const developmentTeam = - buildSettings.DEVELOPMENT_TEAM?.replace(/^"|"$/g, '') || - buildSettings['DEVELOPMENT_TEAM']?.replace(/^"|"$/g, '') || - null - - const provisioningProfileSpecifier = - buildSettings.PROVISIONING_PROFILE_SPECIFIER?.replace(/^"|"$/g, '') || - buildSettings['PROVISIONING_PROFILE_SPECIFIER']?.replace(/^"|"$/g, '') || - null - - if (!deploymentTarget) { + if (!fallback) { return null } - return { - deploymentTarget, - codeSignStyle: codeSignStyle || undefined, - developmentTeam: developmentTeam || undefined, - provisioningProfileSpecifier: provisioningProfileSpecifier || undefined, - } + return { byConfigurationName, fallback } } catch { // If we can't read the settings, return null and use fallback values return null From 096d3de4cc03cfef472bf69b51346333b75c5a3c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 4 Jul 2026 15:32:17 +0200 Subject: [PATCH 4/6] fix(ios): thread app version/buildNumber into widget build settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configurationList.ts hardcoded CURRENT_PROJECT_VERSION="1" and MARKETING_VERSION="1.0" while the plugin's real version/buildNumber only reached Info.plist generation. App Store Connect expects the appex CFBundleShortVersionString to match the app's, so uploads with a mismatched appex version were rejected. version and buildNumber (already computed in src/index.ts and passed to withIOSWidget) now flow through configureXcodeProject props → applyXcodeChanges → ensureXCConfigurationList: MARKETING_VERSION = version, CURRENT_PROJECT_VERSION = buildNumber. The previous "1.0"/"1" values remain as fallbacks when the props are not provided (e.g. direct applyXcodeChanges callers in tests). Tests assert the values land in both Debug and Release build settings, plus the fallback. Snapshot unchanged (the snapshot test passes no version props). --- .../expo-plugin/src/ios-widget/index.ts | 2 +- .../xcode/applyXcodeChanges.node.test.ts | 66 ++++++++++++++----- .../src/ios-widget/xcode/configurationList.ts | 13 +++- .../expo-plugin/src/ios-widget/xcode/index.ts | 8 ++- 4 files changed, 69 insertions(+), 20 deletions(-) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/index.ts index 67652dc0..0d940e63 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/index.ts @@ -54,7 +54,7 @@ export const withIOS: ConfigPlugin = (config, props) => { ...(fonts && fonts.length > 0 ? [[withFonts, { fonts, targetName }] as [ConfigPlugin, any]] : []), // 2. Configure Xcode project (creates the target - must run before fonts mod executes) - [configureXcodeProject, { targetName, bundleIdentifier, deploymentTarget, widgets }], + [configureXcodeProject, { targetName, bundleIdentifier, deploymentTarget, widgets, version, buildNumber }], // 3. Configure Podfile for widget extension target [configurePodfile, { targetName }], diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts index b32ab65b..27edf1b9 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts @@ -130,23 +130,23 @@ describe('applyXcodeChanges — project with a pre-existing extension (fixture b }) }) -describe('applyXcodeChanges — per-configuration code signing (fixture c)', () => { - /** Resolves the widget target's build settings keyed by configuration name. */ - function widgetBuildSettingsByConfigName(project: any): Record> { - const objects = project.hash.project.objects - const targetKey = project.findTargetKey(PROPS.targetName) - const listId = String(objects.PBXNativeTarget[targetKey].buildConfigurationList).split(' ')[0] - const configRefs = objects.XCConfigurationList[listId].buildConfigurations - - const result: Record> = {} - for (const ref of configRefs) { - const configId = typeof ref === 'string' ? ref.split(' ')[0] : ref.value.split(' ')[0] - const config = objects.XCBuildConfiguration[configId] - result[String(config.name).replace(/^"|"$/g, '')] = config.buildSettings - } - return result +/** Resolves the widget target's build settings keyed by configuration name. */ +function widgetBuildSettingsByConfigName(project: any): Record> { + const objects = project.hash.project.objects + const targetKey = project.findTargetKey(PROPS.targetName) + const listId = String(objects.PBXNativeTarget[targetKey].buildConfigurationList).split(' ')[0] + const configRefs = objects.XCConfigurationList[listId].buildConfigurations + + const result: Record> = {} + for (const ref of configRefs) { + const configId = typeof ref === 'string' ? ref.split(' ')[0] : ref.value.split(' ')[0] + const config = objects.XCBuildConfiguration[configId] + result[String(config.name).replace(/^"|"$/g, '')] = config.buildSettings } + return result +} +describe('applyXcodeChanges — per-configuration code signing (fixture c)', () => { it('maps main app Debug signing to widget Debug and Release to Release', () => { const project = loadFixtureProject('with-per-config-signing.pbxproj') useDeterministicUuids(project) @@ -178,3 +178,39 @@ describe('applyXcodeChanges — per-configuration code signing (fixture c)', () expect(() => assertPbxConsistency(project)).not.toThrow() }) }) + +describe('applyXcodeChanges — version passthrough', () => { + const VERSIONED_PROPS: ConfigureXcodeProjectProps = { + ...PROPS, + version: '3.2.1', + buildNumber: '42', + } + + it('applies MARKETING_VERSION and CURRENT_PROJECT_VERSION to both Debug and Release', () => { + const project = loadFixtureProject('fresh.pbxproj') + useDeterministicUuids(project) + + applyXcodeChanges(project, VERSIONED_PROPS, WIDGET_FILES) + + const settings = widgetBuildSettingsByConfigName(project) + expect(settings.Debug.MARKETING_VERSION).toBe('"3.2.1"') + expect(settings.Release.MARKETING_VERSION).toBe('"3.2.1"') + expect(settings.Debug.CURRENT_PROJECT_VERSION).toBe('"42"') + expect(settings.Release.CURRENT_PROJECT_VERSION).toBe('"42"') + + expect(() => assertPbxConsistency(project)).not.toThrow() + }) + + it('falls back to "1.0"/"1" when version and buildNumber are not provided', () => { + const project = loadFixtureProject('fresh.pbxproj') + useDeterministicUuids(project) + + applyXcodeChanges(project, PROPS, WIDGET_FILES) + + const settings = widgetBuildSettingsByConfigName(project) + expect(settings.Debug.MARKETING_VERSION).toBe('"1.0"') + expect(settings.Release.MARKETING_VERSION).toBe('"1.0"') + expect(settings.Debug.CURRENT_PROJECT_VERSION).toBe('"1"') + expect(settings.Release.CURRENT_PROJECT_VERSION).toBe('"1"') + }) +}) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/configurationList.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/configurationList.ts index fe407c26..e21ab91b 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/configurationList.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/configurationList.ts @@ -7,6 +7,13 @@ export interface EnsureConfigurationListOptions { targetName: string bundleIdentifier: string deploymentTarget: string + /** + * App marketing version (CFBundleShortVersionString). App Store Connect expects the appex + * version to match the app's, so this becomes the widget's MARKETING_VERSION. + */ + version?: string + /** App build number; becomes the widget's CURRENT_PROJECT_VERSION. */ + buildNumber?: string /** Main app signing settings, applied per configuration name (Debug→Debug, Release→Release). */ mainAppSettings?: MainAppTargetSettings | null } @@ -26,7 +33,7 @@ function signingForConfiguration( } function createBuildSettings(options: EnsureConfigurationListOptions, configurationName: string) { - const { targetName, bundleIdentifier, deploymentTarget, mainAppSettings } = options + const { targetName, bundleIdentifier, deploymentTarget, version, buildNumber, mainAppSettings } = options const { codeSignStyle, developmentTeam, provisioningProfileSpecifier } = signingForConfiguration( mainAppSettings, configurationName @@ -38,8 +45,8 @@ function createBuildSettings(options: EnsureConfigurationListOptions, configurat TARGETED_DEVICE_FAMILY: `"${IOS.DEVICE_FAMILY}"`, INFOPLIST_FILE: `${targetName}/Info.plist`, INFOPLIST_OUTPUT_FORMAT: `"xml"`, - CURRENT_PROJECT_VERSION: `"1"`, - MARKETING_VERSION: `"1.0"`, + CURRENT_PROJECT_VERSION: `"${buildNumber ?? '1'}"`, + MARKETING_VERSION: `"${version ?? '1.0'}"`, IPHONEOS_DEPLOYMENT_TARGET: `"${deploymentTarget}"`, PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`, SWIFT_OPTIMIZATION_LEVEL: `"-Onone"`, diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts index 9f65dc8c..acca2eef 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts @@ -15,6 +15,10 @@ export interface ConfigureXcodeProjectProps { targetName: string bundleIdentifier: string deploymentTarget: string + /** App marketing version; becomes the widget's MARKETING_VERSION so the appex matches the app. */ + version?: string + /** App build number; becomes the widget's CURRENT_PROJECT_VERSION. */ + buildNumber?: string widgets?: IOSWidgetConfig[] } @@ -36,7 +40,7 @@ export function applyXcodeChanges( widgetFiles: IOSWidgetExtensionFiles, hasClientRenderedWidgets = false ): void { - const { targetName, bundleIdentifier, deploymentTarget } = props + const { targetName, bundleIdentifier, deploymentTarget, version, buildNumber } = props const groupName = 'Embed Foundation Extensions' // Read main app target settings to synchronize code signing (per configuration). @@ -61,6 +65,8 @@ export function applyXcodeChanges( targetName, bundleIdentifier, deploymentTarget, + version, + buildNumber, mainAppSettings, }, target.buildConfigurationList From 734d1809355cfdb6adefaba44ed9eeb6be2b88d6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 4 Jul 2026 15:34:06 +0200 Subject: [PATCH 5/6] feat(ios)!: throw when expo.ios.bundleIdentifier is missing withVoltraIos silently returned the config untouched when config.ios.bundleIdentifier was absent, so the whole plugin no-oped and users filed confused issues about widgets never appearing. It now throws with an actionable message pointing at setting "ios.bundleIdentifier" in app.json/app.config and re-running prebuild. BREAKING CHANGE: prebuild now fails loudly instead of silently skipping Voltra configuration when the iOS bundle identifier is not set. --- .../expo-plugin/src/index.node.test.ts | 25 +++++++++++++++++++ packages/ios-client/expo-plugin/src/index.ts | 6 ++++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 packages/ios-client/expo-plugin/src/index.node.test.ts diff --git a/packages/ios-client/expo-plugin/src/index.node.test.ts b/packages/ios-client/expo-plugin/src/index.node.test.ts new file mode 100644 index 00000000..75288e2d --- /dev/null +++ b/packages/ios-client/expo-plugin/src/index.node.test.ts @@ -0,0 +1,25 @@ +import type { ExpoConfig } from 'expo/config' + +import withVoltraIos from './index' + +function createConfig(overrides: Partial = {}): ExpoConfig { + return { + name: 'voltraexample', + slug: 'voltraexample', + // withPlugins asserts an expo-cli-provided project root when registering plugins. + _internal: { projectRoot: '/tmp/voltra-example' }, + ...overrides, + } +} + +describe('withVoltraIos', () => { + it('throws an actionable error when ios.bundleIdentifier is missing', () => { + expect(() => withVoltraIos(createConfig(), {})).toThrow(/expo\.ios\.bundleIdentifier/) + expect(() => withVoltraIos(createConfig({ ios: {} }), {})).toThrow(/app\.json or app\.config/) + }) + + it('does not throw when ios.bundleIdentifier is set', () => { + const config = createConfig({ ios: { bundleIdentifier: 'com.voltra.example' } }) + expect(() => withVoltraIos(config, {})).not.toThrow() + }) +}) diff --git a/packages/ios-client/expo-plugin/src/index.ts b/packages/ios-client/expo-plugin/src/index.ts index 7d701a2a..1f9b7301 100644 --- a/packages/ios-client/expo-plugin/src/index.ts +++ b/packages/ios-client/expo-plugin/src/index.ts @@ -18,7 +18,11 @@ const withVoltraIos: VoltraIosConfigPlugin = (config, props = {}) => { const iosBundleIdentifier = config.ios?.bundleIdentifier if (!iosBundleIdentifier) { - return config + throw new Error( + 'The Voltra iOS config plugin requires "expo.ios.bundleIdentifier" to be set in the app config — ' + + 'it derives the widget extension bundle identifier from it. ' + + 'Set "ios.bundleIdentifier" (e.g. "com.example.app") in app.json or app.config.(js|ts) and run prebuild again.' + ) } const deploymentTarget = props.deploymentTarget || IOS.DEPLOYMENT_TARGET From c5d40b37a9fdb0a1b4229a5cc7c5e57da33f11c4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sat, 4 Jul 2026 15:45:20 +0200 Subject: [PATCH 6/6] chore: add changeset --- .changeset/xcode-plugin-structure.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/xcode-plugin-structure.md diff --git a/.changeset/xcode-plugin-structure.md b/.changeset/xcode-plugin-structure.md new file mode 100644 index 00000000..b5eb62b8 --- /dev/null +++ b/.changeset/xcode-plugin-structure.md @@ -0,0 +1,16 @@ +--- +'@use-voltra/ios-client': minor +--- + +Restructure the widget Xcode integration and fix signing, versioning, and misconfiguration behavior. + +- Widget code signing now mirrors the main app per build configuration (Debug→Debug, + Release→Release) instead of copying the first configuration's settings into both, so + manual-signing release builds get the correct provisioning profile. +- The widget's `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` now follow `expo.version` + and `expo.ios.buildNumber` (falling back to the previous `1.0`/`1` when unset), matching + App Store Connect's requirement that an appex version match its host app. +- The plugin now throws an actionable error when `expo.ios.bundleIdentifier` is missing + instead of silently skipping widget setup. +- Internally, target setup is collapsed into a single idempotent ensure pipeline and build + phases are matched semantically rather than by comment strings.