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..