diff --git a/StrandTests/WidgetSnapshotTests.swift b/StrandTests/WidgetSnapshotTests.swift index 251bd2f021..f4a284ef56 100644 --- a/StrandTests/WidgetSnapshotTests.swift +++ b/StrandTests/WidgetSnapshotTests.swift @@ -45,6 +45,41 @@ final class WidgetSnapshotTests: XCTestCase { ) } + func testSideloadlyProfileGroupWinsWithoutAltStoreKey() { + XCTAssertEqual( + WidgetSnapshot.resolveSuiteName( + infoDictionary: ["AppGroupIdentifier": "group.com.noopapp.noop.staging"], + provisionedGroups: ["group.com.noopapp.noop.staging.RGA8MQSJB6"] + ), + "group.com.noopapp.noop.staging.RGA8MQSJB6" + ) + } + + func testXcodeProfileGrantingConfiguredGroupKeepsIt() { + XCTAssertEqual( + WidgetSnapshot.resolveSuiteName( + infoDictionary: ["AppGroupIdentifier": "group.example.noop"], + provisionedGroups: ["group.example.noop"] + ), + "group.example.noop" + ) + } + + func testAppGroupsAreReadFromProvisioningProfileEnvelope() { + let plist = """ + + Entitlements + com.apple.security.application-groups + group.example.noop.TEAM123456 + + """ + let profile = Data([0x30, 0x82, 0x00]) + Data(plist.utf8) + Data([0xA0, 0x82]) + + XCTAssertEqual(WidgetSnapshot.appGroups(fromProvisioningProfile: profile), + ["group.example.noop.TEAM123456"]) + XCTAssertEqual(WidgetSnapshot.appGroups(fromProvisioningProfile: Data([0x30, 0x82])), []) + } + func testRuntimeUnavailableSnapshotContainsNoDemoValues() { let snapshot = WidgetSnapshot.unavailable diff --git a/StrandiOSShared/WidgetSnapshot.swift b/StrandiOSShared/WidgetSnapshot.swift index d7fab6051f..aa66f97532 100644 --- a/StrandiOSShared/WidgetSnapshot.swift +++ b/StrandiOSShared/WidgetSnapshot.swift @@ -98,7 +98,8 @@ public struct WidgetSnapshot: Codable, Equatable { /// key is somehow absent (each process reads its OWN bundle, so the app and the widget extension /// each carry the key in their generated Info.plist). public static let suiteName: String = { - resolveSuiteName(infoDictionary: Bundle.main.infoDictionary ?? [:]) + resolveSuiteName(infoDictionary: Bundle.main.infoDictionary ?? [:], + provisionedGroups: embeddedProfileAppGroups(in: Bundle.main)) }() public static let storageKey = "noop.widget.snapshot" @@ -110,13 +111,19 @@ public struct WidgetSnapshot: Codable, Equatable { /// Reading only the build-time `AppGroupIdentifier` therefore points at an unprovisioned container /// in a sideloaded build, even though the host app and widget extension were both signed correctly. /// - /// Normal Xcode builds don't carry `ALTAppGroups`, so they keep using `AppGroupIdentifier`. - static func resolveSuiteName(infoDictionary: [String: Any]) -> String { + /// Sideloadly remaps the groups the same way but writes no `ALTAppGroups`, so the groups the + /// signature really grants are also read from the bundle's `embedded.mobileprovision` + /// (`provisionedGroups`) and treated exactly like `ALTAppGroups`. + /// + /// Normal Xcode builds don't carry `ALTAppGroups`, and their profile grants the configured group + /// itself, so they keep using `AppGroupIdentifier`. + static func resolveSuiteName(infoDictionary: [String: Any], provisionedGroups: [String] = []) -> String { let configured = (infoDictionary["AppGroupIdentifier"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) - let altGroups = (infoDictionary["ALTAppGroups"] as? [String])? + let altGroups = ((infoDictionary["ALTAppGroups"] as? [String] ?? []) + provisionedGroups) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { $0.hasPrefix("group.") && !$0.isEmpty } ?? [] + .filter { $0.hasPrefix("group.") } + .reduce(into: [String]()) { if !$0.contains($1) { $0.append($1) } } if let configured, !configured.isEmpty, let provisioned = altGroups.first(where: { @@ -133,6 +140,26 @@ public struct WidgetSnapshot: Codable, Equatable { return "group.com.noopapp.noop" } + /// App Groups granted by the provisioning profile embedded in `bundle` (each process reads its + /// OWN bundle, so the widget extension reads the appex's profile). The profile is a CMS envelope + /// around a plain XML plist; the plist is sliced out rather than verifying the signature, since + /// this only picks which already-entitled container to open. Empty for Simulator / App Store + /// builds, which carry no embedded profile. + static func embeddedProfileAppGroups(in bundle: Bundle) -> [String] { + guard let url = bundle.url(forResource: "embedded", withExtension: "mobileprovision"), + let data = try? Data(contentsOf: url) else { return [] } + return appGroups(fromProvisioningProfile: data) + } + + static func appGroups(fromProvisioningProfile data: Data) -> [String] { + guard let start = data.range(of: Data("".utf8), in: start.lowerBound..