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:
1 parent
d4d67295c6
commit
0994fd7d5a
18 files changed
+800
-23
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user