Watch: waiting-count complication and multi-select questions

A WidgetKit extension (accessoryCircular, Rectangular, Inline, Corner) shows
GET /v1/summary. The device token moves to the App Group's Keychain access
group on launch so the extension can read it without re-pairing; the app
reloads the timeline when its feed counts change and on notifications.
Multi-select questions toggle options and send an array of values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
gibandClaude Opus 5.5 committed 2026-11-01 11:06:38 -05:00
1 parent d4d67295c6
commit 0994fd7d5a
18 files changed
+800 -23

No files matched your search

+52 -3
View File
@@ -1,5 +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.
import Foundation
import Security
@@ -12,6 +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"
private static let service = "org.gbrown.wristcode"
private static let tokenAccount = "deviceToken"
private static let urlAccount = "gatewayURL"
@@ -39,6 +46,23 @@ enum DeviceStore {
read(urlAccount) ?? defaultGatewayURL
}
/// Moves items an older build saved in the app's own access group into the shared one. Does
/// nothing once they have moved. If the move fails the old items stay, and `load()` still finds
/// 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 }
var item = query(account)
item[kSecAttrAccessGroup as String] = appGroup
item[kSecValueData as String] = Data(legacy.value.utf8)
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { continue }
var old = query(account)
old[kSecAttrAccessGroup as String] = legacy.group
SecItemDelete(old as CFDictionary)
}
}
/// Turns what someone typed into a gateway base URL: trims it, adds `https://` when there is
/// no scheme, and drops trailing slashes. Returns nil when it is not a usable URL.
static func normalizeGatewayURL(_ input: String) -> URL? {
@@ -64,8 +88,17 @@ enum DeviceStore {
]
}
private static func sharedQuery(_ account: String) -> [String: Any] {
query(account).merging([kSecAttrAccessGroup as String: appGroup]) { _, new in new }
}
/// The shared item, or failing that one an older build left in the app's own group.
private static func read(_ account: String) -> String? {
var query = query(account)
readShared(account) ?? readAnyGroup(account)?.value
}
private static func readShared(_ account: String) -> String? {
var query = sharedQuery(account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
@@ -75,19 +108,35 @@ enum DeviceStore {
return String(data: data, encoding: .utf8)
}
/// An item in any access group this process can read, with the group it is in.
private static func readAnyGroup(_ account: String) -> (value: String, group: String)? {
var query = query(account)
query[kSecReturnData as String] = true
query[kSecReturnAttributes as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let attributes = result as? [String: Any],
let data = attributes[kSecValueData as String] as? Data,
let value = String(data: data, encoding: .utf8)
else { return nil }
return (value, attributes[kSecAttrAccessGroup as String] as? String ?? "")
}
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(query(account) as CFDictionary, attributes as CFDictionary)
let status = SecItemUpdate(sharedQuery(account) as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
let item = query(account).merging(attributes) { _, new in new }
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.
private static func delete(_ account: String) {
SecItemDelete(query(account) as CFDictionary)
}