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
after a notification action.
The app and the extension share the App Group `group.org.gbrown.wristcode`. The device token and
gateway URL live in that group's Keychain access group, and the demo flag and last summary in its
defaults. 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.
The app and the extension share the Keychain access group `<team>.org.gbrown.wristcode.shared`, which
holds the device token, gateway URL, demo flag and last summary. It is not an App Group because
automatic signing with an App Store Connect API key can register bundle IDs and profiles but cannot
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.
@@ -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/Route.swift` | Screens the app can open, and their `wristcode://` URLs |
| `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 |
| `Views/` | Pair, Feed, Thread, New thread, and the mock B theme |
| `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
// 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
+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.
+2
View File
@@ -37,5 +37,7 @@
<true/>
<key>WKWatchOnly</key>
<true/>
<key>WristCodeKeychainGroup</key>
<string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
</dict>
</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,
// 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) }
}
}
+2 -2
View File
@@ -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>
+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()
+2
View File
@@ -25,5 +25,7 @@
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>WristCodeKeychainGroup</key>
<string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
</dict>
</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
// reads the device token from the shared Keychain group and the demo flag from the App Group, and
// the app reloads it whenever its own feed or a notification changes the counts.
// reads the device token and the demo flag from the shared Keychain group, and the app reloads it
// whenever its own feed or a notification changes the counts.
import SwiftUI
import WidgetKit
@@ -2,9 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<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>
+8 -5
View File
@@ -41,15 +41,17 @@ targets:
CFBundleURLTypes:
- CFBundleURLName: org.gbrown.wristcode
CFBundleURLSchemes: [wristcode]
WristCodeKeychainGroup: $(AppIdentifierPrefix)org.gbrown.wristcode.shared
entitlements:
path: WristCode/WristCode.entitlements
properties:
# Archives are signed for development; exporting for App Store Connect re-signs with the
# distribution profile, which turns this into production.
aps-environment: development
# Shared with the complication: the App Group's defaults, and its Keychain access group
# for the device token.
com.apple.security.application-groups: [group.org.gbrown.wristcode]
# Shared with the complication: the device token, gateway URL, demo flag and last summary.
# A Keychain group rather than an App Group, because automatic signing with an App Store
# Connect API key can register IDs and profiles but not App Groups.
keychain-access-groups: [$(AppIdentifierPrefix)org.gbrown.wristcode.shared]
settings:
base:
# 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
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# 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:
type: app-extension
platform: watchOS
@@ -76,10 +78,11 @@ targets:
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
WristCodeKeychainGroup: $(AppIdentifierPrefix)org.gbrown.wristcode.shared
entitlements:
path: WristCodeWidget/WristCodeWidget.entitlements
properties:
com.apple.security.application-groups: [group.org.gbrown.wristcode]
keychain-access-groups: [$(AppIdentifierPrefix)org.gbrown.wristcode.shared]
settings:
base:
# Extensions take the watch app's bundle ID as a prefix.