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:
1 parent
67992dbf43
commit
27469dbc77
11 files changed
+132
-49
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// App-wide state: whether the watch is paired, the navigation path, and the latest feed. It also
|
||||
// keeps the complication current: it shares the mode and latest counts through the App Group and
|
||||
// reloads the complication's timeline when they change.
|
||||
// keeps the complication current: it shares the mode and latest counts through the shared Keychain
|
||||
// group and reloads the complication's timeline when they change.
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -37,5 +37,7 @@
|
||||
<true/>
|
||||
<key>WKWatchOnly</key>
|
||||
<true/>
|
||||
<key>WristCodeKeychainGroup</key>
|
||||
<string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,22 +1,22 @@
|
||||
// What the app tells the complication through the App Group's defaults: whether it is in demo mode,
|
||||
// and the last summary it saw, which the complication shows when it cannot reach the gateway.
|
||||
// What the app tells the complication besides the credentials: whether it is in demo mode, and the
|
||||
// last summary it saw, which the complication shows when it cannot reach the gateway. Both live in
|
||||
// the shared Keychain group next to the device token, the one store the two processes share.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SharedState {
|
||||
private static var defaults: UserDefaults { UserDefaults(suiteName: DeviceStore.appGroup) ?? .standard }
|
||||
private static let demoKey = "demo"
|
||||
private static let summaryKey = "summary"
|
||||
private static let demoAccount = "demo"
|
||||
private static let summaryAccount = "lastSummary"
|
||||
|
||||
/// True while the app is in demo mode, so the complication shows the demo counts too.
|
||||
static var demo: Bool {
|
||||
get { defaults.bool(forKey: demoKey) }
|
||||
set { defaults.set(newValue, forKey: demoKey) }
|
||||
get { DeviceStore.sharedData(demoAccount) == Data([1]) }
|
||||
set { DeviceStore.setSharedData(newValue ? Data([1]) : nil, account: demoAccount) }
|
||||
}
|
||||
|
||||
/// The last summary the app or the complication saw, or nil after signing out.
|
||||
static var lastSummary: Summary? {
|
||||
get { defaults.data(forKey: summaryKey).flatMap { try? JSON.decoder().decode(Summary.self, from: $0) } }
|
||||
set { defaults.set(newValue.flatMap { try? JSON.encoder().encode($0) }, forKey: summaryKey) }
|
||||
get { DeviceStore.sharedData(summaryAccount).flatMap { try? JSON.decoder().decode(Summary.self, from: $0) } }
|
||||
set { DeviceStore.setSharedData(newValue.flatMap { try? JSON.encoder().encode($0) }, account: summaryAccount) }
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>group.org.gbrown.wristcode</string>
|
||||
<string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in new issue
Block a user