Files
wristcode/watch/WristCode/Auth/DeviceStore.swift
T
gibandClaude Opus 5.5 27469dbc77 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]>
2026-11-01 11:15:31 -05:00

156 lines
6.7 KiB
Swift

// 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 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
struct Credentials: Equatable, Sendable {
var gatewayURL: URL
var token: String
}
enum DeviceStore {
static let defaultGatewayURL = "https://wristcode.gbrown.org"
/// `<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"
private static let urlAccount = "gatewayURL"
static func load() -> Credentials? {
guard let token = read(tokenAccount),
let urlString = read(urlAccount),
let url = URL(string: urlString)
else { return nil }
return Credentials(gatewayURL: url, token: token)
}
static func save(_ credentials: Credentials) {
write(credentials.gatewayURL.absoluteString, account: urlAccount)
write(credentials.token, account: tokenAccount)
}
/// Forgets the token but keeps the URL, so the pairing screen is prefilled.
static func clearToken() {
delete(tokenAccount)
}
/// The last gateway URL used, for prefilling the pairing screen.
static var lastGatewayURL: String {
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 != accessGroup else { continue }
var item = query(account)
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 }
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? {
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
if !text.contains("://") { text = "https://" + text }
while text.hasSuffix("/") { text.removeLast() }
guard let components = URLComponents(string: text),
let scheme = components.scheme?.lowercased(),
scheme == "https" || scheme == "http",
let host = components.host, !host.isEmpty
else { return nil }
return components.url
}
// MARK: - Keychain
private static func query(_ account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
private static func sharedQuery(_ account: String) -> [String: Any] {
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.
private static func read(_ account: String) -> String? {
readShared(account) ?? readAnyGroup(account)?.value
}
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 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.
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) {
setSharedData(Data(value.utf8), account: account)
}
/// 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)
}
}