// 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. import Foundation import Security struct Credentials: Equatable, Sendable { var gatewayURL: URL var token: String } enum DeviceStore { static let defaultGatewayURL = "https://wristcode.gbrown.org" 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 } /// 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 read(_ account: String) -> String? { var query = query(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) } 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) if status == errSecItemNotFound { let item = query(account).merging(attributes) { _, new in new } SecItemAdd(item as CFDictionary, nil) } } private static func delete(_ account: String) { SecItemDelete(query(account) as CFDictionary) } }