From b86a8dff37a72d5b52ec5473d2d99a77949b9bb1 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/5] signer: reconcile entitlements with the provisioning profile The entitlements xtool signs with were never checked against the profile created for the app, so any difference became an opaque 0xe8008015 when installing. - aps-environment is taken from the profile: a development profile only allows 'development', whatever the app was built with. - Entitlements the profile does not grant (notably App Groups with an App Store Connect API key, which cannot manage them) now fail early with a message naming them and, for groups, how to fix it. Entitlements xtool does not provision are left untouched. --- .../DeveloperServicesAddAppOperation.swift | 6 + .../Entitlements/EntitlementsReconciler.swift | 126 ++++++++++++++++++ .../EntitlementsReconcilerTests.swift | 103 ++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift create mode 100644 Tests/XToolTests/EntitlementsReconcilerTests.swift diff --git a/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift b/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift index d726e91e..814e0801 100644 --- a/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift +++ b/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift @@ -225,6 +225,12 @@ public struct DeveloperServicesAddAppOperation: DeveloperServicesOperation { signingInfo: signingInfo ).perform() + // the profile is created last and doesn't necessarily grant everything we're about to claim + let profile = try mobileprovision.digest() + entitlements = try EntitlementsReconciler.reconcile( + entitlements, with: profile.entitlements, profileName: profile.name + ) + return ProvisioningInfo( newBundleID: newBundleID, entitlements: entitlements, diff --git a/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift b/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift new file mode 100644 index 00000000..a3a464b9 --- /dev/null +++ b/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift @@ -0,0 +1,126 @@ +import Foundation + +/// Brings the entitlements an app is about to be signed with in line with what its provisioning +/// profile actually grants. +/// +/// installd rejects an app whose signature claims an entitlement the profile doesn't grant, with the +/// unhelpful `0xe8008015 A valid provisioning profile for this executable was not found`. The profile +/// is created through the API after the entitlements are computed, and it doesn't necessarily end up with +/// all of them (for instance, App Groups can't be managed with an App Store Connect API key). This +/// catches the difference before it reaches the device. +public enum EntitlementsReconciler { + + public enum Error: LocalizedError, Equatable { + case appGroupsNotGranted(groups: [String], profile: String) + case notGranted(key: String, profile: String) + case valueNotGranted(key: String, profile: String) + + public var errorDescription: String? { + switch self { + case .appGroupsNotGranted(let groups, let profile): + "The provisioning profile '\(profile)' does not include the app groups " + + "\(groups.joined(separator: ", ")). App Groups can only be created when signed in with an " + + "Apple ID, not with an App Store Connect API key: run `xtool auth login --mode password`, " + + "or remove the com.apple.security.application-groups entitlement." + case .notGranted(let key, let profile): + "The app requests the entitlement '\(key)', which the provisioning profile '\(profile)' " + + "does not grant. Remove it from the app's entitlements, or enable the matching " + + "capability for this app ID." + case .valueNotGranted(let key, let profile): + "The value of the entitlement '\(key)' is not allowed by the provisioning profile '\(profile)'." + } + } + } + + private static let appGroupsKey = AppGroupEntitlement.identifier + private static let apsEnvironmentKey = APSEnvironmentEntitlement.identifier + + /// Entitlements that are derived from the profile and the bundle, never requested by the app + private static let derivedKeys: Set = [ + ApplicationIdentifierEntitlement.identifier, + TeamIdentifierEntitlement.identifier, + GetTaskAllowEntitlement.identifier, + KeychainAccessGroupsEntitlement.identifier, + ] + + private static let knownKeys: Set = Set( + EntitlementContainer.supportedTypes.compactMap { ($0 as? any Entitlement.Type)?.identifier } + ).subtracting(derivedKeys) + + /// - Parameters: + /// - entitlements: What the app is going to be signed with. + /// - granted: The entitlements of the provisioning profile. + /// - Returns: `entitlements`, with the values the profile dictates replaced. + /// - Throws: ``Error`` if the app claims something the profile doesn't grant. + /// + /// Only entitlements xtool knows how to provision are checked. Others are passed through + /// untouched, since we can't tell whether the profile is expected to list them. + public static func reconcile( + _ entitlements: Entitlements, + with granted: Entitlements, + profileName: String + ) throws -> Entitlements { + let grants = try granted.plistDictionary() + var result = try entitlements.plistDictionary() + + for key in result.keys.sorted() where knownKeys.contains(key) { + guard let request = result[key] else { continue } + guard let grant = grants[key] else { + throw key == appGroupsKey + ? Error.appGroupsNotGranted(groups: request as? [String] ?? [], profile: profileName) + : Error.notGranted(key: key, profile: profileName) + } + + if key == apsEnvironmentKey { + // a development profile only allows the sandbox push environment, whatever the + // app was built with; Xcode makes the same substitution + result[key] = grant + } else if !permits(grant: grant, request: request) { + if key == appGroupsKey { + // the profile may list the entitlement with no (or other) groups, which is + // what happens when groups can't be assigned + let missing = (request as? [String] ?? []).filter { + !permits(grant: grant, request: [$0]) + } + throw Error.appGroupsNotGranted(groups: missing, profile: profileName) + } + throw Error.valueNotGranted(key: key, profile: profileName) + } + } + + return try Entitlements(plistDictionary: result) + } + + /// Whether `grant` (a value from a profile, possibly with wildcards) allows `request` + static func permits(grant: Any, request: Any) -> Bool { + switch (grant, request) { + case let (grant as [String], request as [String]): + return request.allSatisfy { item in + grant.contains { $0 == item || ($0.hasSuffix("*") && item.hasPrefix(String($0.dropLast()))) } + } + case let (grant as String, request as String): + return grant == request || (grant.hasSuffix("*") && request.hasPrefix(String(grant.dropLast()))) + case let (grant as Bool, request as Bool): + return grant || !request + default: + return (grant as? NSObject)?.isEqual(request) ?? false + } + } + +} + +extension Entitlements { + + init(plistDictionary: [String: Any]) throws { + let data = try PropertyListSerialization.data(fromPropertyList: plistDictionary, format: .xml, options: 0) + self = try PropertyListDecoder().decode(Entitlements.self, from: data) + } + + func plistDictionary() throws -> [String: Any] { + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + let data = try encoder.encode(self) + return try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] ?? [:] + } + +} diff --git a/Tests/XToolTests/EntitlementsReconcilerTests.swift b/Tests/XToolTests/EntitlementsReconcilerTests.swift new file mode 100644 index 00000000..abc60943 --- /dev/null +++ b/Tests/XToolTests/EntitlementsReconcilerTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +@testable import XKit + +@Suite struct EntitlementsReconcilerTests { + private func entitlements(_ dictionary: [String: Any]) throws -> Entitlements { + try Entitlements(plistDictionary: dictionary) + } + + private func reconcile( + _ requested: [String: Any], + granted: [String: Any] + ) throws -> [String: Any] { + try EntitlementsReconciler.reconcile( + entitlements(requested), with: entitlements(granted), profileName: "dev profile" + ).plistDictionary() + } + + /// What a development profile created through the ASC API grants an app with push but no groups + private let developmentGrants: [String: Any] = [ + "application-identifier": "TEAM.XTL-1.app", + "com.apple.developer.team-identifier": "TEAM", + "get-task-allow": true, + "aps-environment": "development", + "com.apple.security.application-groups": [String](), + "keychain-access-groups": ["TEAM.*"], + ] + + @Test func pushEnvironmentComesFromTheProfile() throws { + let result = try reconcile( + ["aps-environment": "production", "get-task-allow": true], granted: developmentGrants + ) + #expect(result["aps-environment"] as? String == "development") + #expect(result["get-task-allow"] as? Bool == true) + } + + @Test func appGroupsTheProfileLacksAreAnActionableError() throws { + let error = try #require(throws: EntitlementsReconciler.Error.self) { + try reconcile( + ["com.apple.security.application-groups": ["group.com.example.app"]], + granted: developmentGrants + ) + } + #expect(error == .appGroupsNotGranted(groups: ["group.com.example.app"], profile: "dev profile")) + let message = try #require(error.errorDescription) + #expect(message.contains("group.com.example.app")) + #expect(message.contains("xtool auth login --mode password")) + } + + @Test func appGroupsAreKeptWhenTheProfileHasThem() throws { + var granted = developmentGrants + granted["com.apple.security.application-groups"] = ["group.XTL-1.example"] + let result = try reconcile( + ["com.apple.security.application-groups": ["group.XTL-1.example"]], granted: granted + ) + #expect(result["com.apple.security.application-groups"] as? [String] == ["group.XTL-1.example"]) + } + + @Test func missingGrantNamesTheEntitlement() throws { + let granted = developmentGrants.filter { $0.key != "aps-environment" } + let error = try #require(throws: EntitlementsReconciler.Error.self) { + try reconcile(["aps-environment": "development"], granted: granted) + } + #expect(error == .notGranted(key: "aps-environment", profile: "dev profile")) + } + + @Test func onlyTheMissingGroupsAreReported() throws { + var granted = developmentGrants + granted["com.apple.security.application-groups"] = ["group.a"] + #expect(throws: EntitlementsReconciler.Error.appGroupsNotGranted(groups: ["group.b"], profile: "dev profile")) { + try reconcile(["com.apple.security.application-groups": ["group.a", "group.b"]], granted: granted) + } + } + + @Test func valueOutsideTheGrantIsRejected() throws { + var granted = developmentGrants + granted["com.apple.developer.networking.multipath"] = false + #expect(throws: EntitlementsReconciler.Error.valueNotGranted( + key: "com.apple.developer.networking.multipath", profile: "dev profile" + )) { + try reconcile(["com.apple.developer.networking.multipath": true], granted: granted) + } + } + + @Test func derivedAndUnknownEntitlementsAreLeftAlone() throws { + let requested: [String: Any] = [ + "application-identifier": "TEAM.XTL-1.app", + "keychain-access-groups": ["TEAM.XTL-1.app"], + "com.example.not-a-capability-we-manage": true, + ] + let result = try reconcile(requested, granted: developmentGrants) + #expect(result["application-identifier"] as? String == "TEAM.XTL-1.app") + #expect(result["keychain-access-groups"] as? [String] == ["TEAM.XTL-1.app"]) + #expect(result["com.example.not-a-capability-we-manage"] as? Bool == true) + } + + @Test func entitlementsWithoutIssuesAreUnchanged() throws { + let requested: [String: Any] = ["get-task-allow": true, "aps-environment": "development"] + let result = try reconcile(requested, granted: developmentGrants) + #expect(result["aps-environment"] as? String == "development") + #expect(Set(result.keys) == Set(requested.keys)) + } +} From ac6892878cb38eba6096700707f6458a2b7e9c0a 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 2/5] signer: print reconcile errors with their message The CLI interpolates errors, which ignores LocalizedError and showed the raw enum case. Conform to CustomStringConvertible as well. --- .../App IDs/Entitlements/EntitlementsReconciler.swift | 8 ++++++-- Tests/XToolTests/EntitlementsReconcilerTests.swift | 3 +++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift b/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift index a3a464b9..0d20256f 100644 --- a/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift +++ b/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift @@ -10,12 +10,12 @@ import Foundation /// catches the difference before it reaches the device. public enum EntitlementsReconciler { - public enum Error: LocalizedError, Equatable { + public enum Error: LocalizedError, CustomStringConvertible, Equatable { case appGroupsNotGranted(groups: [String], profile: String) case notGranted(key: String, profile: String) case valueNotGranted(key: String, profile: String) - public var errorDescription: String? { + private var message: String { switch self { case .appGroupsNotGranted(let groups, let profile): "The provisioning profile '\(profile)' does not include the app groups " @@ -30,6 +30,10 @@ public enum EntitlementsReconciler { "The value of the entitlement '\(key)' is not allowed by the provisioning profile '\(profile)'." } } + + // the CLI prints errors with string interpolation, which ignores `errorDescription` + public var description: String { message } + public var errorDescription: String? { message } } private static let appGroupsKey = AppGroupEntitlement.identifier diff --git a/Tests/XToolTests/EntitlementsReconcilerTests.swift b/Tests/XToolTests/EntitlementsReconcilerTests.swift index abc60943..d542e367 100644 --- a/Tests/XToolTests/EntitlementsReconcilerTests.swift +++ b/Tests/XToolTests/EntitlementsReconcilerTests.swift @@ -45,6 +45,9 @@ import Testing let message = try #require(error.errorDescription) #expect(message.contains("group.com.example.app")) #expect(message.contains("xtool auth login --mode password")) + // what the CLI prints for `"\(error)"` + #expect("\(error)" == message) + #expect(String(describing: error as any Swift.Error) == message) } @Test func appGroupsAreKeptWhenTheProfileHasThem() throws { From d68f5068f7a120b452c3022d781301286c2c1042 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 3/5] signer: drop distribution-only entitlements in development signing Re-signing an IPA that was signed for distribution kept beta-reports-active, which a development profile does not grant, so installd rejected the app. Remove it (and the iCloud container environment, same kind) when the profile lacks it, with a warning. --- .../DeveloperServicesAddAppOperation.swift | 9 +++++- .../Entitlements/EntitlementsReconciler.swift | 23 +++++++++++++-- .../EntitlementsReconcilerTests.swift | 28 ++++++++++++++++++- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift b/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift index 814e0801..0848673f 100644 --- a/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift +++ b/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift @@ -227,9 +227,16 @@ public struct DeveloperServicesAddAppOperation: DeveloperServicesOperation { // the profile is created last and doesn't necessarily grant everything we're about to claim let profile = try mobileprovision.digest() - entitlements = try EntitlementsReconciler.reconcile( + let reconciled = try EntitlementsReconciler.reconcile( entitlements, with: profile.entitlements, profileName: profile.name ) + entitlements = reconciled.entitlements + for key in reconciled.removed { + FileHandle.standardError.write(Data(( + "warning: dropping the entitlement '\(key)' from \(bundleID): it only applies to " + + "distribution builds and the development profile doesn't grant it\n" + ).utf8)) + } return ProvisioningInfo( newBundleID: newBundleID, diff --git a/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift b/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift index 0d20256f..49c7b87a 100644 --- a/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift +++ b/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift @@ -36,6 +36,19 @@ public enum EntitlementsReconciler { public var errorDescription: String? { message } } + /// Entitlements only meaningful for a distribution build (an Xcode-exported or xtool-distribution-signed + /// IPA carries them). A development profile never grants them, so re-signing for development drops them. + private static let distributionOnlyKeys: Set = [ + "beta-reports-active", + "com.apple.developer.icloud-container-environment", + ] + + public struct Result { + public var entitlements: Entitlements + /// Distribution-only entitlements that were dropped because the profile doesn't grant them + public var removed: [String] + } + private static let appGroupsKey = AppGroupEntitlement.identifier private static let apsEnvironmentKey = APSEnvironmentEntitlement.identifier @@ -54,7 +67,8 @@ public enum EntitlementsReconciler { /// - Parameters: /// - entitlements: What the app is going to be signed with. /// - granted: The entitlements of the provisioning profile. - /// - Returns: `entitlements`, with the values the profile dictates replaced. + /// - Returns: `entitlements`, with the values the profile dictates replaced and the distribution-only + /// entitlements it doesn't grant removed. /// - Throws: ``Error`` if the app claims something the profile doesn't grant. /// /// Only entitlements xtool knows how to provision are checked. Others are passed through @@ -63,10 +77,13 @@ public enum EntitlementsReconciler { _ entitlements: Entitlements, with granted: Entitlements, profileName: String - ) throws -> Entitlements { + ) throws -> Result { let grants = try granted.plistDictionary() var result = try entitlements.plistDictionary() + let removed = distributionOnlyKeys.filter { result[$0] != nil && grants[$0] == nil }.sorted() + removed.forEach { result[$0] = nil } + for key in result.keys.sorted() where knownKeys.contains(key) { guard let request = result[key] else { continue } guard let grant = grants[key] else { @@ -92,7 +109,7 @@ public enum EntitlementsReconciler { } } - return try Entitlements(plistDictionary: result) + return Result(entitlements: try Entitlements(plistDictionary: result), removed: removed) } /// Whether `grant` (a value from a profile, possibly with wildcards) allows `request` diff --git a/Tests/XToolTests/EntitlementsReconcilerTests.swift b/Tests/XToolTests/EntitlementsReconcilerTests.swift index d542e367..66d9c9fc 100644 --- a/Tests/XToolTests/EntitlementsReconcilerTests.swift +++ b/Tests/XToolTests/EntitlementsReconcilerTests.swift @@ -13,7 +13,7 @@ import Testing ) throws -> [String: Any] { try EntitlementsReconciler.reconcile( entitlements(requested), with: entitlements(granted), profileName: "dev profile" - ).plistDictionary() + ).entitlements.plistDictionary() } /// What a development profile created through the ASC API grants an app with push but no groups @@ -103,4 +103,30 @@ import Testing #expect(result["aps-environment"] as? String == "development") #expect(Set(result.keys) == Set(requested.keys)) } + + @Test func distributionOnlyEntitlementsAreDroppedWhenNotGranted() throws { + let outcome = try EntitlementsReconciler.reconcile( + entitlements([ + "beta-reports-active": true, + "aps-environment": "production", + "get-task-allow": false, + ]), + with: entitlements(developmentGrants), + profileName: "dev profile" + ) + let result = try outcome.entitlements.plistDictionary() + #expect(outcome.removed == ["beta-reports-active"]) + #expect(result["beta-reports-active"] == nil) + #expect(result["aps-environment"] as? String == "development") + } + + @Test func distributionOnlyEntitlementsAreKeptWhenGranted() throws { + var granted = developmentGrants + granted["beta-reports-active"] = true + let outcome = try EntitlementsReconciler.reconcile( + entitlements(["beta-reports-active": true]), with: entitlements(granted), profileName: "p" + ) + #expect(outcome.removed.isEmpty) + #expect(try outcome.entitlements.plistDictionary()["beta-reports-active"] as? Bool == true) + } } From ccab33369a07986fc0e9ee451c44f37dabdf486d Mon Sep 17 00:00:00 2001 From: Ugo Mignon <56931733+TartanLeGrand@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:37:57 +0200 Subject: [PATCH 4/5] signer: error when the profile lacks the iCloud environment Stop dropping com.apple.developer.icloud-container-environment silently: it selects the CloudKit database, so a profile that does not grant it, or not the requested value, is now reported. beta-reports-active is still dropped. A profile may list several values, so a plain string request is checked against the granted list. --- .../Entitlements/EntitlementsReconciler.swift | 13 +++++++++++- .../EntitlementsReconcilerTests.swift | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift b/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift index 49c7b87a..70036543 100644 --- a/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift +++ b/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift @@ -40,6 +40,12 @@ public enum EntitlementsReconciler { /// IPA carries them). A development profile never grants them, so re-signing for development drops them. private static let distributionOnlyKeys: Set = [ "beta-reports-active", + ] + + /// Entitlements that are checked against the profile although xtool has no type for them. Dropping + /// these silently would change behavior without telling (the iCloud container environment decides + /// which CloudKit database the app talks to), so a profile that doesn't grant one is an error. + private static let checkedKeys: Set = [ "com.apple.developer.icloud-container-environment", ] @@ -62,7 +68,7 @@ public enum EntitlementsReconciler { private static let knownKeys: Set = Set( EntitlementContainer.supportedTypes.compactMap { ($0 as? any Entitlement.Type)?.identifier } - ).subtracting(derivedKeys) + ).subtracting(derivedKeys).union(checkedKeys) /// - Parameters: /// - entitlements: What the app is going to be signed with. @@ -115,6 +121,11 @@ public enum EntitlementsReconciler { /// Whether `grant` (a value from a profile, possibly with wildcards) allows `request` static func permits(grant: Any, request: Any) -> Bool { switch (grant, request) { + case let (grant as [String], request as String): + // a profile lists every value it allows, e.g. [Development, Production] + return permits(grant: grant, request: [request]) + case let (grant as String, request as [String]): + return permits(grant: [grant], request: request) case let (grant as [String], request as [String]): return request.allSatisfy { item in grant.contains { $0 == item || ($0.hasSuffix("*") && item.hasPrefix(String($0.dropLast()))) } diff --git a/Tests/XToolTests/EntitlementsReconcilerTests.swift b/Tests/XToolTests/EntitlementsReconcilerTests.swift index 66d9c9fc..ccaf751f 100644 --- a/Tests/XToolTests/EntitlementsReconcilerTests.swift +++ b/Tests/XToolTests/EntitlementsReconcilerTests.swift @@ -129,4 +129,25 @@ import Testing #expect(outcome.removed.isEmpty) #expect(try outcome.entitlements.plistDictionary()["beta-reports-active"] as? Bool == true) } + + private let environmentKey = "com.apple.developer.icloud-container-environment" + + @Test func iCloudContainerEnvironmentTheProfileLacksIsAnError() throws { + let error = try #require(throws: EntitlementsReconciler.Error.self) { + try reconcile([environmentKey: "Production"], granted: developmentGrants) + } + #expect(error == .notGranted(key: environmentKey, profile: "dev profile")) + } + + @Test func iCloudContainerEnvironmentMustBeOneTheProfileAllows() throws { + var granted = developmentGrants + granted[environmentKey] = ["Development", "Production"] + let result = try reconcile([environmentKey: "Development"], granted: granted) + #expect(result[environmentKey] as? String == "Development") + + granted[environmentKey] = ["Development"] + #expect(throws: EntitlementsReconciler.Error.valueNotGranted(key: environmentKey, profile: "dev profile")) { + try reconcile([environmentKey: "Production"], granted: granted) + } + } } From 74f69f68188f0b895aa609aaa73834f8c081780a Mon Sep 17 00:00:00 2001 From: Ugo Mignon <56931733+TartanLeGrand@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:35:10 +0200 Subject: [PATCH 5/5] signer: document the checked entitlement keys --- .../App IDs/Entitlements/EntitlementsReconciler.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift b/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift index 70036543..c674283c 100644 --- a/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift +++ b/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift @@ -77,8 +77,9 @@ public enum EntitlementsReconciler { /// entitlements it doesn't grant removed. /// - Throws: ``Error`` if the app claims something the profile doesn't grant. /// - /// Only entitlements xtool knows how to provision are checked. Others are passed through - /// untouched, since we can't tell whether the profile is expected to list them. + /// Only entitlements xtool knows how to provision, plus the ones in `checkedKeys`, are checked + /// against the profile. Others are passed through untouched, since we can't tell whether the + /// profile is expected to list them. public static func reconcile( _ entitlements: Entitlements, with granted: Entitlements,