// 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 struct Credentials: Equatable, Sendable { var gatewayURL: URL var token: String } 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" 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 != 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? { 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: 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? { 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? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } 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(sharedQuery(account) as CFDictionary, attributes as CFDictionary) if status == errSecItemNotFound { 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) } }