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

+7 -5
View File
@@ -66,10 +66,12 @@ there is one (`wristcode://thread/<hostId>/<threadId>`). WidgetKit refreshes it
the app reloads it sooner when its feed counts change, when a push arrives in the foreground, and the app reloads it sooner when its feed counts change, when a push arrives in the foreground, and
after a notification action. after a notification action.
The app and the extension share the App Group `group.org.gbrown.wristcode`. The device token and The app and the extension share the Keychain access group `<team>.org.gbrown.wristcode.shared`, which
gateway URL live in that group's Keychain access group, and the demo flag and last summary in its holds the device token, gateway URL, demo flag and last summary. It is not an App Group because
defaults. Builds before the complication kept the token in the app's own Keychain group; automatic signing with an App Store Connect API key can register bundle IDs and profiles but cannot
`DeviceStore.migrateToSharedGroup()` moves it on launch, so a paired watch stays paired. create or assign App Groups; a Keychain group needs no portal setup. Builds before the complication
kept the token in the app's own Keychain group; `DeviceStore.migrateToSharedGroup()` moves it on
launch, so a paired watch stays paired.
To add it: long-press the watch face, Edit, swipe to Complications, pick a slot, then WristCode. To add it: long-press the watch face, Edit, swipe to Complications, pick a slot, then WristCode.
@@ -83,7 +85,7 @@ To add it: long-press the watch face, Edit, swipe to Complications, pick a slot,
| `API/Models.swift` | Codable mirror of the contract | | `API/Models.swift` | Codable mirror of the contract |
| `API/Route.swift` | Screens the app can open, and their `wristcode://` URLs | | `API/Route.swift` | Screens the app can open, and their `wristcode://` URLs |
| `Auth/DeviceStore.swift` | Device token and gateway URL in the shared Keychain group | | `Auth/DeviceStore.swift` | Device token and gateway URL in the shared Keychain group |
| `Shared/SharedState.swift` | Demo flag and last summary in the App Group, for the complication | | `Shared/SharedState.swift` | Demo flag and last summary in the shared Keychain group, for the complication |
| `Demo/DemoGateway.swift` | In-memory gateway with fixtures | | `Demo/DemoGateway.swift` | In-memory gateway with fixtures |
| `Views/` | Pair, Feed, Thread, New thread, and the mock B theme | | `Views/` | Pair, Feed, Thread, New thread, and the mock B theme |
| `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host` and their actions | | `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host` and their actions |
+2 -2
View File
@@ -1,6 +1,6 @@
// App-wide state: whether the watch is paired, the navigation path, and the latest feed. It also // 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 // keeps the complication current: it shares the mode and latest counts through the shared Keychain
// reloads the complication's timeline when they change. // group and reloads the complication's timeline when they change.
import Foundation import Foundation
import Observation import Observation
+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 // 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. // 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. // The items live in a Keychain access group the app and the complication share, so the complication
// Builds before the complication kept them in the app's own access group; `migrateToSharedGroup()` // can read them too. Builds before the complication kept them in the app's own access group;
// moves them on launch, so a paired watch stays paired. // `migrateToSharedGroup()` moves them on launch, so a paired watch stays paired.
import Foundation import Foundation
import Security import Security
@@ -16,8 +16,9 @@ struct Credentials: Equatable, Sendable {
enum DeviceStore { enum DeviceStore {
static let defaultGatewayURL = "https://wristcode.gbrown.org" static let defaultGatewayURL = "https://wristcode.gbrown.org"
/// The App Group the app and the complication share. It doubles as the Keychain access group. /// `<team>.org.gbrown.wristcode.shared`, from Info.plist, where the build fills in the team prefix.
static let appGroup = "group.org.gbrown.wristcode" 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 service = "org.gbrown.wristcode"
private static let tokenAccount = "deviceToken" private static let tokenAccount = "deviceToken"
@@ -51,9 +52,9 @@ enum DeviceStore {
/// them, so the app keeps working and only the complication goes without. /// them, so the app keeps working and only the complication goes without.
static func migrateToSharedGroup() { static func migrateToSharedGroup() {
for account in [tokenAccount, urlAccount] where readShared(account) == nil { 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) var item = query(account)
item[kSecAttrAccessGroup as String] = appGroup item[kSecAttrAccessGroup as String] = accessGroup
item[kSecValueData as String] = Data(legacy.value.utf8) item[kSecValueData as String] = Data(legacy.value.utf8)
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { continue } guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { continue }
@@ -89,7 +90,7 @@ enum DeviceStore {
} }
private static func sharedQuery(_ account: String) -> [String: Any] { 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. /// 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? { 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) var query = sharedQuery(account)
query[kSecReturnData as String] = true query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef? var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
let data = result as? Data return result as? Data
else { return nil } }
return String(data: data, encoding: .utf8)
/// 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. /// 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) { private static func write(_ value: String, account: String) {
let data = Data(value.utf8) setSharedData(Data(value.utf8), account: account)
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)
}
} }
/// Deletes the item from every access group, so a leftover from an older build goes too. /// Deletes the item from every access group, so a leftover from an older build goes too.
+2
View File
@@ -37,5 +37,7 @@
<true/> <true/>
<key>WKWatchOnly</key> <key>WKWatchOnly</key>
<true/> <true/>
<key>WristCodeKeychainGroup</key>
<string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
</dict> </dict>
</plist> </plist>
+9 -9
View File
@@ -1,22 +1,22 @@
// What the app tells the complication through the App Group's defaults: whether it is in demo mode, // What the app tells the complication besides the credentials: whether it is in demo mode, and the
// and the last summary it saw, which the complication shows when it cannot reach the gateway. // 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 import Foundation
enum SharedState { enum SharedState {
private static var defaults: UserDefaults { UserDefaults(suiteName: DeviceStore.appGroup) ?? .standard } private static let demoAccount = "demo"
private static let demoKey = "demo" private static let summaryAccount = "lastSummary"
private static let summaryKey = "summary"
/// True while the app is in demo mode, so the complication shows the demo counts too. /// True while the app is in demo mode, so the complication shows the demo counts too.
static var demo: Bool { static var demo: Bool {
get { defaults.bool(forKey: demoKey) } get { DeviceStore.sharedData(demoAccount) == Data([1]) }
set { defaults.set(newValue, forKey: demoKey) } set { DeviceStore.setSharedData(newValue ? Data([1]) : nil, account: demoAccount) }
} }
/// The last summary the app or the complication saw, or nil after signing out. /// The last summary the app or the complication saw, or nil after signing out.
static var lastSummary: Summary? { static var lastSummary: Summary? {
get { defaults.data(forKey: summaryKey).flatMap { try? JSON.decoder().decode(Summary.self, from: $0) } } get { DeviceStore.sharedData(summaryAccount).flatMap { try? JSON.decoder().decode(Summary.self, from: $0) } }
set { defaults.set(newValue.flatMap { try? JSON.encoder().encode($0) }, forKey: summaryKey) } set { DeviceStore.setSharedData(newValue.flatMap { try? JSON.encoder().encode($0) }, account: summaryAccount) }
} }
} }
+2 -2
View File
@@ -4,9 +4,9 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.security.application-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>group.org.gbrown.wristcode</string> <string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+62
View File
@@ -2,6 +2,7 @@
// the screens rely on. // the screens rely on.
import Foundation import Foundation
import Security
import Testing import Testing
@testable import WristCode @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 { @Suite struct DemoGatewayTests {
@Test func allowMovesThreadToWorking() async throws { @Test func allowMovesThreadToWorking() async throws {
let gateway = DemoGateway() let gateway = DemoGateway()
+2
View File
@@ -25,5 +25,7 @@
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string> <string>com.apple.widgetkit-extension</string>
</dict> </dict>
<key>WristCodeKeychainGroup</key>
<string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
</dict> </dict>
</plist> </plist>
+2 -2
View File
@@ -1,6 +1,6 @@
// The watch face complication: how many threads are waiting on you, from `GET /v1/summary`. It // The watch face complication: how many threads are waiting on you, from `GET /v1/summary`. It
// reads the device token from the shared Keychain group and the demo flag from the App Group, and // reads the device token and the demo flag from the shared Keychain group, and the app reloads it
// the app reloads it whenever its own feed or a notification changes the counts. // whenever its own feed or a notification changes the counts.
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
@@ -2,9 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>group.org.gbrown.wristcode</string> <string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+8 -5
View File
@@ -41,15 +41,17 @@ targets:
CFBundleURLTypes: CFBundleURLTypes:
- CFBundleURLName: org.gbrown.wristcode - CFBundleURLName: org.gbrown.wristcode
CFBundleURLSchemes: [wristcode] CFBundleURLSchemes: [wristcode]
WristCodeKeychainGroup: $(AppIdentifierPrefix)org.gbrown.wristcode.shared
entitlements: entitlements:
path: WristCode/WristCode.entitlements path: WristCode/WristCode.entitlements
properties: properties:
# Archives are signed for development; exporting for App Store Connect re-signs with the # Archives are signed for development; exporting for App Store Connect re-signs with the
# distribution profile, which turns this into production. # distribution profile, which turns this into production.
aps-environment: development aps-environment: development
# Shared with the complication: the App Group's defaults, and its Keychain access group # Shared with the complication: the device token, gateway URL, demo flag and last summary.
# for the device token. # A Keychain group rather than an App Group, because automatic signing with an App Store
com.apple.security.application-groups: [group.org.gbrown.wristcode] # Connect API key can register IDs and profiles but not App Groups.
keychain-access-groups: [$(AppIdentifierPrefix)org.gbrown.wristcode.shared]
settings: settings:
base: base:
# The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also # The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also
@@ -57,7 +59,7 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# The complication. It shares the API client, models, Keychain store and demo fixtures with the # The complication. It shares the API client, models, Keychain store and demo fixtures with the
# app, and reads the device token through the App Group. # app, and reads the device token through the shared Keychain group.
WristCodeWidget: WristCodeWidget:
type: app-extension type: app-extension
platform: watchOS platform: watchOS
@@ -76,10 +78,11 @@ targets:
CFBundleVersion: $(CURRENT_PROJECT_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSExtension: NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension NSExtensionPointIdentifier: com.apple.widgetkit-extension
WristCodeKeychainGroup: $(AppIdentifierPrefix)org.gbrown.wristcode.shared
entitlements: entitlements:
path: WristCodeWidget/WristCodeWidget.entitlements path: WristCodeWidget/WristCodeWidget.entitlements
properties: properties:
com.apple.security.application-groups: [group.org.gbrown.wristcode] keychain-access-groups: [$(AppIdentifierPrefix)org.gbrown.wristcode.shared]
settings: settings:
base: base:
# Extensions take the watch app's bundle ID as a prefix. # Extensions take the watch app's bundle ID as a prefix.