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

+34 -22
View File
@@ -1,9 +1,9 @@
// The device token and gateway URL, kept in the Keychain. They are readable after first unlock, so
// a notification action handled in the background can still call the gateway.
//
// The items live in the App Group's Keychain access group, so the complication can read them too.
// Builds before the complication kept them in the app's own access group; `migrateToSharedGroup()`
// moves them on launch, so a paired watch stays paired.
// The items live in a Keychain access group the app and the complication share, so the complication
// can read them too. Builds before the complication kept them in the app's own access group;
// `migrateToSharedGroup()` moves them on launch, so a paired watch stays paired.
import Foundation
import Security
@@ -16,8 +16,9 @@ struct Credentials: Equatable, Sendable {
enum DeviceStore {
static let defaultGatewayURL = "https://wristcode.gbrown.org"
/// The App Group the app and the complication share. It doubles as the Keychain access group.
static let appGroup = "group.org.gbrown.wristcode"
/// `<team>.org.gbrown.wristcode.shared`, from Info.plist, where the build fills in the team prefix.
static let accessGroup = Bundle.main.object(forInfoDictionaryKey: "WristCodeKeychainGroup") as? String
?? "GQJNM5YK2M.org.gbrown.wristcode.shared"
private static let service = "org.gbrown.wristcode"
private static let tokenAccount = "deviceToken"
@@ -51,9 +52,9 @@ enum DeviceStore {
/// them, so the app keeps working and only the complication goes without.
static func migrateToSharedGroup() {
for account in [tokenAccount, urlAccount] where readShared(account) == nil {
guard let legacy = readAnyGroup(account), legacy.group != appGroup else { continue }
guard let legacy = readAnyGroup(account), legacy.group != accessGroup else { continue }
var item = query(account)
item[kSecAttrAccessGroup as String] = appGroup
item[kSecAttrAccessGroup as String] = accessGroup
item[kSecValueData as String] = Data(legacy.value.utf8)
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { continue }
@@ -89,7 +90,7 @@ enum DeviceStore {
}
private static func sharedQuery(_ account: String) -> [String: Any] {
query(account).merging([kSecAttrAccessGroup as String: appGroup]) { _, new in new }
query(account).merging([kSecAttrAccessGroup as String: accessGroup]) { _, new in new }
}
/// The shared item, or failing that one an older build left in the app's own group.
@@ -98,14 +99,34 @@ enum DeviceStore {
}
private static func readShared(_ account: String) -> String? {
sharedData(account).flatMap { String(data: $0, encoding: .utf8) }
}
/// An item in the shared group only. `SharedState` keeps its values here too.
static func sharedData(_ account: String) -> Data? {
var query = sharedQuery(account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data
else { return nil }
return String(data: data, encoding: .utf8)
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
return result as? Data
}
/// Saves or, given nil, deletes an item in the shared group.
static func setSharedData(_ data: Data?, account: String) {
guard let data else {
SecItemDelete(sharedQuery(account) as CFDictionary)
return
}
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let status = SecItemUpdate(sharedQuery(account) as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
let item = sharedQuery(account).merging(attributes) { _, new in new }
SecItemAdd(item as CFDictionary, nil)
}
}
/// An item in any access group this process can read, with the group it is in.
@@ -124,16 +145,7 @@ enum DeviceStore {
}
private static func write(_ value: String, account: String) {
let data = Data(value.utf8)
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let status = SecItemUpdate(sharedQuery(account) as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
let item = sharedQuery(account).merging(attributes) { _, new in new }
SecItemAdd(item as CFDictionary, nil)
}
setSharedData(Data(value.utf8), account: account)
}
/// Deletes the item from every access group, so a leftover from an older build goes too.