Watch: share with the complication through a Keychain group, not an App Group

Automatic signing with an App Store Connect API key registers bundle IDs and
profiles but cannot create or assign App Groups, so the archive failed. A
keychain-access-groups entry needs no portal setup. The demo flag and last
summary move into the same group; tests cover the round trip and the
migration of a pre-complication token.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
gibandClaude Opus 5.5 committed 2026-11-01 11:15:31 -05:00
1 parent 67992dbf43
commit 27469dbc77
11 files changed
+132 -49

No files matched your search

+62
View File
@@ -2,6 +2,7 @@
// the screens rely on.
import Foundation
import Security
import Testing
@testable import WristCode
@@ -260,6 +261,67 @@ private let feedItemJSON = """
}
}
/// Reads and writes the real shared Keychain group, so the entitlement and Info.plist group agree.
@Suite(.serialized) struct SharedStateTests {
@Test func accessGroupHasTeamPrefix() {
#expect(DeviceStore.accessGroup.hasSuffix(".org.gbrown.wristcode.shared"))
#expect(!DeviceStore.accessGroup.hasPrefix("$"))
}
@Test func roundTrip() {
let demo = SharedState.demo
let summary = SharedState.lastSummary
defer {
SharedState.demo = demo
SharedState.lastSummary = summary
}
let saved = Summary(needsYou: 2, working: 1, next: nil, hostsOffline: 0)
SharedState.lastSummary = saved
#expect(SharedState.lastSummary == saved)
SharedState.lastSummary = nil
#expect(SharedState.lastSummary == nil)
SharedState.demo = true
#expect(SharedState.demo)
SharedState.demo = false
#expect(!SharedState.demo)
}
/// A token saved by a build before the complication sits in the app's own group. Launch moves it.
@Test func migratesLegacyItems() throws {
let before = DeviceStore.load()
defer {
if let before { DeviceStore.save(before) } else { DeviceStore.clearToken() }
}
let team = try #require(DeviceStore.accessGroup.split(separator: ".").first)
let appGroup = "\(team).org.gbrown.wristcode.watchkitapp"
func item(_ account: String) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "org.gbrown.wristcode",
kSecAttrAccount as String: account]
}
for (account, value) in [("deviceToken", "wc_legacy"), ("gatewayURL", "https://legacy.example")] {
SecItemDelete(item(account) as CFDictionary)
var legacy = item(account)
legacy[kSecAttrAccessGroup as String] = appGroup
legacy[kSecValueData as String] = Data(value.utf8)
#expect(SecItemAdd(legacy as CFDictionary, nil) == errSecSuccess)
}
#expect(DeviceStore.sharedData("deviceToken") == nil)
// Before the move the app still finds the old items.
#expect(DeviceStore.load()?.token == "wc_legacy")
DeviceStore.migrateToSharedGroup()
#expect(DeviceStore.sharedData("deviceToken") == Data("wc_legacy".utf8))
#expect(DeviceStore.load() == Credentials(gatewayURL: URL(string: "https://legacy.example")!, token: "wc_legacy"))
var old = item("deviceToken")
old[kSecAttrAccessGroup as String] = appGroup
#expect(SecItemCopyMatching(old as CFDictionary, nil) == errSecItemNotFound)
}
}
@Suite struct DemoGatewayTests {
@Test func allowMovesThreadToWorking() async throws {
let gateway = DemoGateway()