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. 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 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/__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/__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/applyXcodeChanges.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts index df6a0fb3..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 @@ -129,3 +129,88 @@ describe('applyXcodeChanges — project with a pre-existing extension (fixture b expect(afterThird).toEqual(afterSecond) }) }) + +/** 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) + + 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() + }) +}) + +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/buildPhases.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts index 21016df7..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 @@ -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,29 @@ function findExistingEmbedExtensionsPhase(xcodeProject: XcodeProject, targetUuid } /** - * Adds all required build phases for the widget extension target. + * 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. * - * 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`). + * 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`). */ -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 findTargetPhaseByType(xcodeProject: XcodeProject, targetUuid: string, phaseType: 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) { + 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 } /** @@ -200,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 = xcodeProject.buildPhaseObject('PBXSourcesBuildPhase', 'Sources', targetUuid) + let sourcesPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXSourcesBuildPhase') if (!sourcesPhase) { xcodeProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', targetUuid, folderType, buildPath) - sourcesPhase = xcodeProject.buildPhaseObject('PBXSourcesBuildPhase', 'Sources', targetUuid) + 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 ?? xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, mainTargetUuid) + // 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 = xcodeProject.buildPhaseObject('PBXCopyFilesBuildPhase', groupName, mainTargetUuid) + copyFilesPhase = findExistingEmbedExtensionsPhase(xcodeProject, mainTargetUuid) } if (copyFilesPhase) { ensureCopyFilesPhaseProduct(xcodeProject, copyFilesPhase, productFile) } // Frameworks build phase - const frameworksPhase = xcodeProject.buildPhaseObject('PBXFrameworksBuildPhase', 'Frameworks', targetUuid) + const frameworksPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase') if (!frameworksPhase) { xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', targetUuid, folderType, buildPath) } // Resources build phase - let resourcesPhase = xcodeProject.buildPhaseObject('PBXResourcesBuildPhase', 'Resources', targetUuid) + let resourcesPhase = findTargetPhaseByType(xcodeProject, targetUuid, 'PBXResourcesBuildPhase') if (!resourcesPhase) { xcodeProject.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', targetUuid) - resourcesPhase = xcodeProject.buildPhaseObject('PBXResourcesBuildPhase', 'Resources', targetUuid) + 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) { @@ -256,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 @@ -274,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 } @@ -295,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 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..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 @@ -1,34 +1,52 @@ 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 + /** + * 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 } -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, version, buildNumber, 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}"`, 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"`, @@ -37,41 +55,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 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: 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'), }, ] @@ -85,11 +99,13 @@ export function addXCConfigurationList(xcodeProject: XcodeProject, options: AddC } /** - * 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 = @@ -103,7 +119,6 @@ export function ensureXCConfigurationList( return addXCConfigurationList(xcodeProject, options) } - const commonBuildSettings = createCommonBuildSettings(options) const buildConfigurations = configurationList.buildConfigurations const buildConfigurationSection = xcodeProject.pbxXCBuildConfigurationSection() @@ -117,9 +132,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/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..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 @@ -4,17 +4,21 @@ 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 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[] } @@ -26,8 +30,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, @@ -35,123 +40,77 @@ 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' - // 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, + version, + buildNumber, + mainAppSettings, + }, + 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/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 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]) -}