// 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" /// `.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) } }