diff --git a/Sources/PackLib/Packer.swift b/Sources/PackLib/Packer.swift index bd2057b7..a14443a5 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,74 @@ 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: + info["UIDeviceFamily"] = universalDeviceFamily(merging: plist["UIDeviceFamily"]) + } + + return info + } + + 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) { + 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. 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 + } + 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 ["arm64"] + } + 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..6fcc55b0 100644 --- a/Tests/XToolTests/PackerTests.swift +++ b/Tests/XToolTests/PackerTests.swift @@ -13,3 +13,84 @@ 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, 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]) +} + +@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"]) + } +}