From 2f1ec323bcb7c62af92913f234abbfb4cb2fea6a Mon Sep 17 00:00:00 2001 From: Ugo Mignon <56931733+TartanLeGrand@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:31:19 +0200 Subject: [PATCH 1/3] packer: preserve user Info.plist capabilities UIRequiredDeviceCapabilities was overwritten with [arm64], dropping values such as metal from the user's Info.plist. Merge arm64 into whatever is declared (array or dictionary form). Also set CFBundleSupportedPlatforms and the default UIDeviceFamily on app extensions, as Xcode does. --- Sources/PackLib/Packer.swift | 45 ++++++++++++++++++++++----- Tests/XToolTests/PackerTests.swift | 49 ++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index bd2057b7..3d9daadc 100644 --- a/Sources/PackLib/Packer.swift +++ b/Sources/PackLib/Packer.swift @@ -188,13 +188,7 @@ public struct Packer: Sendable { try await packFile(srcName: product.targetName, dstName: product.product) } group.addTask { - var info = product.infoPlist - - if product.type == .application { - info["UIRequiredDeviceCapabilities"] = ["arm64"] - info["LSRequiresIPhoneOS"] = true - info["CFBundleSupportedPlatforms"] = Packer.cfBundleSupportedPlatforms(for: buildSettings.triple) - } + var info = Packer.platformInfoPlist(product.infoPlist, type: product.type, triple: buildSettings.triple) if let iconPath = product.iconPath { let iconName = URL(fileURLWithPath: iconPath).deletingPathExtension().lastPathComponent @@ -211,6 +205,43 @@ public struct Packer: Sendable { } } + /// Adds the keys that tie a bundle to its platform, without discarding what the user declared. + static func platformInfoPlist( + _ plist: [String: any Sendable], + type: Plan.ProductType, + triple: String + ) -> [String: any Sendable] { + var info = plist + + info["CFBundleSupportedPlatforms"] = cfBundleSupportedPlatforms(for: triple) + info["UIRequiredDeviceCapabilities"] = requiredDeviceCapabilities(merging: plist["UIRequiredDeviceCapabilities"]) + + switch type { + case .application: + info["LSRequiresIPhoneOS"] = true + case .appExtension: + if info["UIDeviceFamily"] == nil { + info["UIDeviceFamily"] = [1, 2] + } + } + + return info + } + + /// `UIRequiredDeviceCapabilities` is either an array of names or a dictionary of name to Bool. + /// Whichever form the user picked, `arm64` is guaranteed to be required. + private static func requiredDeviceCapabilities(merging existing: (any Sendable)?) -> any Sendable { + if var dictionary = existing as? [String: any Sendable] { + dictionary["arm64"] = true + return dictionary + } + var array = existing as? [String] ?? [] + if !array.contains("arm64") { + array.insert("arm64", at: 0) + } + return array + } + static func cfBundleSupportedPlatforms(for triple: String) -> [String] { let components = triple.split(separator: "-").map(String.init) guard let appleIndex = components.firstIndex(of: "apple"), diff --git a/Tests/XToolTests/PackerTests.swift b/Tests/XToolTests/PackerTests.swift index 5aabda83..d6a30d21 100644 --- a/Tests/XToolTests/PackerTests.swift +++ b/Tests/XToolTests/PackerTests.swift @@ -13,3 +13,52 @@ import Testing #expect(Packer.cfBundleSupportedPlatforms(for: "unknown-triple") == ["iPhoneOS"]) } + +@Test func platformInfoPlistKeepsUserCapabilities() { + let info = Packer.platformInfoPlist( + ["UIRequiredDeviceCapabilities": ["metal"]], + type: .application, + triple: "arm64-apple-ios" + ) + #expect(info["UIRequiredDeviceCapabilities"] as? [String] == ["arm64", "metal"]) + #expect(info["LSRequiresIPhoneOS"] as? Bool == true) + #expect(info["CFBundleSupportedPlatforms"] as? [String] == ["iPhoneOS"]) +} + +@Test func platformInfoPlistDefaultsToArm64() { + let info = Packer.platformInfoPlist([:], type: .application, triple: "arm64-apple-ios") + #expect(info["UIRequiredDeviceCapabilities"] as? [String] == ["arm64"]) +} + +@Test func platformInfoPlistDoesNotDuplicateArm64() { + let info = Packer.platformInfoPlist( + ["UIRequiredDeviceCapabilities": ["arm64", "metal"]], + type: .application, + triple: "arm64-apple-ios" + ) + #expect(info["UIRequiredDeviceCapabilities"] as? [String] == ["arm64", "metal"]) +} + +@Test func platformInfoPlistMergesDictionaryCapabilities() { + let info = Packer.platformInfoPlist( + ["UIRequiredDeviceCapabilities": ["metal": true]], + type: .application, + triple: "arm64-apple-ios" + ) + #expect(info["UIRequiredDeviceCapabilities"] as? [String: Bool] == ["metal": true, "arm64": true]) +} + +@Test func platformInfoPlistCoversAppExtensions() { + let info = Packer.platformInfoPlist( + ["UIRequiredDeviceCapabilities": ["metal"], "UIDeviceFamily": [1]], + type: .appExtension, + triple: "arm64-apple-ios-simulator" + ) + #expect(info["CFBundleSupportedPlatforms"] as? [String] == ["iPhoneSimulator"]) + #expect(info["UIRequiredDeviceCapabilities"] as? [String] == ["arm64", "metal"]) + #expect(info["UIDeviceFamily"] as? [Int] == [1]) + #expect(info["LSRequiresIPhoneOS"] == nil) + + let defaults = Packer.platformInfoPlist([:], type: .appExtension, triple: "arm64-apple-ios") + #expect(defaults["UIDeviceFamily"] as? [Int] == [1, 2]) +} From fb2d7dde48484dd2824545558b2793295f59c806 Mon Sep 17 00:00:00 2001 From: Ugo Mignon <56931733+TartanLeGrand@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:41:17 +0200 Subject: [PATCH 2/3] packer: make app extensions universal App Review requires app extensions to target both iPhone and iPad, whatever the containing app targets. Merge 1 and 2 into any declared UIDeviceFamily and warn on stderr when the declared value was narrower. --- Sources/PackLib/Packer.swift | 17 ++++++++++++++--- Tests/XToolTests/PackerTests.swift | 14 +++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index 3d9daadc..a8fd0fb5 100644 --- a/Sources/PackLib/Packer.swift +++ b/Sources/PackLib/Packer.swift @@ -220,14 +220,25 @@ public struct Packer: Sendable { case .application: info["LSRequiresIPhoneOS"] = true case .appExtension: - if info["UIDeviceFamily"] == nil { - info["UIDeviceFamily"] = [1, 2] - } + info["UIDeviceFamily"] = universalDeviceFamily(merging: plist["UIDeviceFamily"]) } return info } + /// App Review requires app extensions to be universal, whatever the containing app targets, so + /// iPhone (1) and iPad (2) are always present. A narrower value the user declared is widened. + private static func universalDeviceFamily(merging existing: (any Sendable)?) -> [Int] { + let declared = existing as? [Int] ?? [] + let universal = Array(Set(declared).union([1, 2])).sorted() + if !declared.isEmpty, Set(declared) != Set(universal) { + let message = "warning: UIDeviceFamily \(declared) in an app extension Info.plist is not universal; " + + "using \(universal), as App Review requires\n" + FileHandle.standardError.write(Data(message.utf8)) + } + return universal + } + /// `UIRequiredDeviceCapabilities` is either an array of names or a dictionary of name to Bool. /// Whichever form the user picked, `arm64` is guaranteed to be required. private static func requiredDeviceCapabilities(merging existing: (any Sendable)?) -> any Sendable { diff --git a/Tests/XToolTests/PackerTests.swift b/Tests/XToolTests/PackerTests.swift index d6a30d21..13846c54 100644 --- a/Tests/XToolTests/PackerTests.swift +++ b/Tests/XToolTests/PackerTests.swift @@ -56,9 +56,21 @@ import Testing ) #expect(info["CFBundleSupportedPlatforms"] as? [String] == ["iPhoneSimulator"]) #expect(info["UIRequiredDeviceCapabilities"] as? [String] == ["arm64", "metal"]) - #expect(info["UIDeviceFamily"] as? [Int] == [1]) + #expect(info["UIDeviceFamily"] as? [Int] == [1, 2]) #expect(info["LSRequiresIPhoneOS"] == nil) let defaults = Packer.platformInfoPlist([:], type: .appExtension, triple: "arm64-apple-ios") #expect(defaults["UIDeviceFamily"] as? [Int] == [1, 2]) } + +@Test func platformInfoPlistWidensAppExtensionDeviceFamily() { + for declared in [[2], [1], [2, 1], [1, 2, 4]] { + let info = Packer.platformInfoPlist(["UIDeviceFamily": declared], type: .appExtension, triple: "arm64-apple-ios") + #expect(info["UIDeviceFamily"] as? [Int] == Set(declared).union([1, 2]).sorted()) + } +} + +@Test func platformInfoPlistLeavesApplicationDeviceFamilyAlone() { + let info = Packer.platformInfoPlist(["UIDeviceFamily": [2]], type: .application, triple: "arm64-apple-ios") + #expect(info["UIDeviceFamily"] as? [Int] == [2]) +} From c77ee7a1f2aa0109665ca30fe9feb9473e57732e Mon Sep 17 00:00:00 2001 From: Ugo Mignon <56931733+TartanLeGrand@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:24:18 +0200 Subject: [PATCH 3/3] packer: warn when overriding declared capabilities Warn on stderr when UIRequiredDeviceCapabilities has an unrecognized shape and falls back to ["arm64"], and when "arm64": false is flipped to true. Document that arm64 overrides an explicit declaration, and cite the App Extension Programming Guide for the universal device family requirement. --- Sources/PackLib/Packer.swift | 40 ++++++++++++++++++++++-------- Tests/XToolTests/PackerTests.swift | 20 +++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index a8fd0fb5..a14443a5 100644 --- a/Sources/PackLib/Packer.swift +++ b/Sources/PackLib/Packer.swift @@ -226,31 +226,51 @@ public struct Packer: Sendable { return info } - /// App Review requires app extensions to be universal, whatever the containing app targets, so - /// iPhone (1) and iPad (2) are always present. A narrower value the user declared is widened. + private static func warn(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) + } + + /// Apple's App Extension Programming Guide ("Ensure Your iOS App Extension Works on All Devices") + /// says that to pass App Review an extension must specify the iPhone/iPad (universal) targeted + /// device family, whatever the containing app targets. So iPhone (1) and iPad (2) are always + /// present, and a narrower value the user declared is widened. private static func universalDeviceFamily(merging existing: (any Sendable)?) -> [Int] { let declared = existing as? [Int] ?? [] let universal = Array(Set(declared).union([1, 2])).sorted() if !declared.isEmpty, Set(declared) != Set(universal) { - let message = "warning: UIDeviceFamily \(declared) in an app extension Info.plist is not universal; " - + "using \(universal), as App Review requires\n" - FileHandle.standardError.write(Data(message.utf8)) + warn( + "warning: UIDeviceFamily \(declared) in an app extension Info.plist is not universal; " + + "using \(universal), as App Review requires" + ) } return universal } /// `UIRequiredDeviceCapabilities` is either an array of names or a dictionary of name to Bool. - /// Whichever form the user picked, `arm64` is guaranteed to be required. + /// Whichever form the user picked, `arm64` is guaranteed to be required. This overrides an + /// explicit user declaration: `"arm64": false` in the dictionary form is replaced by `true` + /// (with a warning), and a value of any other shape is replaced by `["arm64"]` (with a warning). private static func requiredDeviceCapabilities(merging existing: (any Sendable)?) -> any Sendable { if var dictionary = existing as? [String: any Sendable] { + if dictionary["arm64"] as? Bool == false { + warn("warning: UIRequiredDeviceCapabilities declares arm64 as false; using true, as arm64 is required") + } dictionary["arm64"] = true return dictionary } - var array = existing as? [String] ?? [] - if !array.contains("arm64") { - array.insert("arm64", at: 0) + if var array = existing as? [String] { + if !array.contains("arm64") { + array.insert("arm64", at: 0) + } + return array + } + if existing != nil { + warn( + "warning: UIRequiredDeviceCapabilities must be an array of strings or a dictionary of " + + "name to Bool; ignoring \(String(describing: existing!)) and using [\"arm64\"]" + ) } - return array + return ["arm64"] } static func cfBundleSupportedPlatforms(for triple: String) -> [String] { diff --git a/Tests/XToolTests/PackerTests.swift b/Tests/XToolTests/PackerTests.swift index 13846c54..6fcc55b0 100644 --- a/Tests/XToolTests/PackerTests.swift +++ b/Tests/XToolTests/PackerTests.swift @@ -74,3 +74,23 @@ import Testing let info = Packer.platformInfoPlist(["UIDeviceFamily": [2]], type: .application, triple: "arm64-apple-ios") #expect(info["UIDeviceFamily"] as? [Int] == [2]) } + +@Test func platformInfoPlistOverridesArm64False() { + let info = Packer.platformInfoPlist( + ["UIRequiredDeviceCapabilities": ["arm64": false, "metal": true]], + type: .application, + triple: "arm64-apple-ios" + ) + #expect(info["UIRequiredDeviceCapabilities"] as? [String: Bool] == ["arm64": true, "metal": true]) +} + +@Test func platformInfoPlistFallsBackOnUnrecognizedCapabilities() { + for declared: any Sendable in ["metal", [1, 2], 3] { + let info = Packer.platformInfoPlist( + ["UIRequiredDeviceCapabilities": declared], + type: .application, + triple: "arm64-apple-ios" + ) + #expect(info["UIRequiredDeviceCapabilities"] as? [String] == ["arm64"]) + } +}