import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif // MARK: - Models public struct ZedAuthenticatedUserResponse: Decodable, Equatable, Sendable { public let user: ZedAuthenticatedUser public let plan: ZedPlanInfo public init(user: ZedAuthenticatedUser, plan: ZedPlanInfo) { self.user = user self.plan = plan } } public struct ZedAuthenticatedUser: Decodable, Equatable, Sendable { public let id: Int public let githubLogin: String public let name: String? enum CodingKeys: String, CodingKey { case id case githubLogin = "github_login" case name } } public struct ZedPlanInfo: Decodable, Equatable, Sendable { public let planV3: String public let subscriptionPeriod: ZedSubscriptionPeriod? public let usage: ZedCurrentUsage public let hasOverdueInvoices: Bool enum CodingKeys: String, CodingKey { case planV3 = "plan_v3" case subscriptionPeriod = "subscription_period" case usage case hasOverdueInvoices = "has_overdue_invoices" } } public struct ZedSubscriptionPeriod: Decodable, Equatable, Sendable { public let startedAt: Date public let endedAt: Date enum CodingKeys: String, CodingKey { case startedAt = "started_at" case endedAt = "ended_at" } } public struct ZedCurrentUsage: Decodable, Equatable, Sendable { public let editPredictions: ZedUsageData enum CodingKeys: String, CodingKey { case editPredictions = "edit_predictions" } } public struct ZedUsageData: Decodable, Equatable, Sendable { public let used: Int public let limit: ZedUsageLimit } public enum ZedUsageLimit: Equatable, Sendable { case limited(Int) case unlimited } extension ZedUsageLimit: Decodable { public init(from decoder: Decoder) throws { if let single = try? decoder.singleValueContainer() { if let string = try? single.decode(String.self), string == "unlimited" { self = .unlimited return } if let value = try? single.decode(Int.self) { self = .limited(value) return } } let container = try decoder.container(keyedBy: CodingKeys.self) if let value = try container.decodeIfPresent(Int.self, forKey: .limited) { self = .limited(value) return } throw DecodingError.dataCorrupted( DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unrecognized Zed usage limit")) } private enum CodingKeys: String, CodingKey { case limited } } public struct ZedCredentials: Equatable, Sendable { public let userID: String public let accessToken: String public init(userID: String, accessToken: String) { self.userID = userID self.accessToken = accessToken } public var authorizationHeader: String { "\(self.userID) \(self.accessToken)" } } public struct ZedUsageSnapshot: Sendable, Equatable { public let response: ZedAuthenticatedUserResponse public let updatedAt: Date public init(response: ZedAuthenticatedUserResponse, updatedAt: Date = Date()) { self.response = response self.updatedAt = updatedAt } } // MARK: - Errors public enum ZedStatusProbeError: LocalizedError, Sendable, Equatable { case notSupported case notSignedIn case keychainUnavailable case invalidServerURL(String) case untrustedServerConfiguration case networkError(String) case httpError(Int) case unauthorized case parseFailed(String) public var errorDescription: String? { switch self { case .notSupported: "Zed is only supported on macOS." case .notSignedIn: "Not signed in to Zed. Sign in from the Zed editor app with GitHub." case .keychainUnavailable: "Could not read Zed credentials from the Keychain. Grant CodexBar Keychain access or sign in to Zed again." case let .invalidServerURL(value): "Zed server URL is invalid: \(value)" case .untrustedServerConfiguration: "Zed custom servers must use HTTPS and store credentials under the same server URL." case let .networkError(message): "Zed cloud API request failed: \(message)" case let .httpError(status): "Zed cloud API returned HTTP \(status)." case .unauthorized: "Zed credentials are invalid or expired. Sign in to Zed again." case let .parseFailed(message): "Could not parse Zed account response: \(message)" } } } // MARK: - Settings public struct ZedClientSettings: Sendable, Equatable { public let credentialsURL: String? public let serverURL: String? public init(credentialsURL: String?, serverURL: String?) { self.credentialsURL = credentialsURL self.serverURL = serverURL } public var keychainServiceURL: String { let trimmedCredentials = self.credentialsURL?.trimmingCharacters(in: .whitespacesAndNewlines) if let trimmedCredentials, !trimmedCredentials.isEmpty { return trimmedCredentials } let trimmedServer = self.serverURL?.trimmingCharacters(in: .whitespacesAndNewlines) if let trimmedServer, !trimmedServer.isEmpty { return trimmedServer } return ZedStatusProbe.defaultKeychainServiceURL } public var cloudAPIURL: URL? { let trimmedServer = self.serverURL?.trimmingCharacters(in: .whitespacesAndNewlines) let server = if let trimmedServer, !trimmedServer.isEmpty { trimmedServer } else { ZedStatusProbe.defaultKeychainServiceURL } let isTrustedZedServer = server == "https://zed.dev" || server == "https://staging.zed.dev" let trimmedCredentials = self.credentialsURL?.trimmingCharacters(in: .whitespacesAndNewlines) if !isTrustedZedServer, let trimmedCredentials, !trimmedCredentials.isEmpty, trimmedCredentials != server { return nil } let cloudBase = switch server { case "https://zed.dev", "https://staging.zed.dev": "https://cloud.zed.dev" default: server } guard let baseURL = URL(string: cloudBase), let scheme = baseURL.scheme?.lowercased(), scheme == "https", baseURL.host != nil else { return nil } return baseURL.appendingPathComponent("client/users/me") } public static func load(from url: URL = ZedStatusProbe.defaultSettingsURL) -> ZedClientSettings? { guard let data = try? Data(contentsOf: url) else { return nil } struct Payload: Decodable { let credentialsURL: String? let serverURL: String? enum CodingKeys: String, CodingKey { case credentialsURL = "credentials_url" case serverURL = "server_url" } } guard let payload = try? JSONDecoder().decode(Payload.self, from: data) else { return nil } return ZedClientSettings( credentialsURL: payload.credentialsURL, serverURL: payload.serverURL) } } // MARK: - Credentials public protocol ZedCredentialsReading: Sendable { func loadCredentials(serviceURL: String) throws -> ZedCredentials? } #if os(macOS) import Security public struct ZedKeychainCredentialsReader: ZedCredentialsReading, Sendable { public init() {} public func loadCredentials(serviceURL: String) throws -> ZedCredentials? { if let credentials = try self.loadInternetPasswordCredentials(server: serviceURL) { return credentials } return try self.loadGenericPasswordCredentials(service: serviceURL) } private func loadInternetPasswordCredentials(server: String) throws -> ZedCredentials? { var query: [String: Any] = [ kSecClass as String: kSecClassInternetPassword, kSecAttrServer as String: server, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnAttributes as String: true, kSecReturnData as String: true, ] KeychainNoUIQuery.apply(to: &query) return try self.credentials(from: query) } private func loadGenericPasswordCredentials(service: String) throws -> ZedCredentials? { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnAttributes as String: true, kSecReturnData as String: true, ] KeychainNoUIQuery.apply(to: &query) return try self.credentials(from: query) } private func credentials(from query: [String: Any]) throws -> ZedCredentials? { var result: AnyObject? let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) switch status { case errSecSuccess: break case errSecItemNotFound: return nil case errSecInteractionNotAllowed, errSecAuthFailed, errSecNoAccessForItem: throw ZedStatusProbeError.keychainUnavailable default: throw ZedStatusProbeError.keychainUnavailable } guard let item = result as? [String: Any], let account = item[kSecAttrAccount as String] as? String, !account.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } let tokenData: Data? = if let data = item[kSecValueData as String] as? Data { data } else { nil } guard let tokenData, let accessToken = String(data: tokenData, encoding: .utf8), !accessToken.isEmpty else { return nil } return ZedCredentials(userID: account, accessToken: accessToken) } } #else public struct ZedKeychainCredentialsReader: ZedCredentialsReading, Sendable { public init() {} public func loadCredentials(serviceURL _: String) throws -> ZedCredentials? { throw ZedStatusProbeError.notSupported } } #endif // MARK: - Probe public struct ZedStatusProbe: Sendable { public static let defaultKeychainServiceURL = "https://zed.dev" public static let cloudAPIURL = URL(string: "https://cloud.zed.dev/client/users/me")! public static var defaultSettingsURL: URL { let home = FileManager.default.homeDirectoryForCurrentUser return home .appendingPathComponent(".config/zed/settings.json") } private static let logger = CodexBarLog.logger(LogCategories.zed) private let credentialsReader: any ZedCredentialsReading private let transport: any ProviderHTTPTransport private let settingsLoader: @Sendable () -> ZedClientSettings? public init( credentialsReader: any ZedCredentialsReading = ZedKeychainCredentialsReader(), transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, settingsLoader: @escaping @Sendable () -> ZedClientSettings? = { ZedClientSettings.load() }) { self.credentialsReader = credentialsReader self.transport = transport self.settingsLoader = settingsLoader } public func fetch() async throws -> ZedUsageSnapshot { let settings = self.settingsLoader() let serviceURL = settings?.keychainServiceURL ?? Self.defaultKeychainServiceURL let cloudAPIURL: URL if let settings { guard let configuredURL = settings.cloudAPIURL else { let serverURL = settings.serverURL ?? "" guard URL(string: serverURL)?.scheme?.lowercased() == "https" else { throw ZedStatusProbeError.invalidServerURL(serverURL) } throw ZedStatusProbeError.untrustedServerConfiguration } cloudAPIURL = configuredURL } else { cloudAPIURL = Self.cloudAPIURL } guard let credentials = try self.credentialsReader.loadCredentials(serviceURL: serviceURL) else { throw ZedStatusProbeError.notSignedIn } let response = try await self.fetchAuthenticatedUser(credentials: credentials, apiURL: cloudAPIURL) return ZedUsageSnapshot(response: response) } private func fetchAuthenticatedUser( credentials: ZedCredentials, apiURL: URL) async throws -> ZedAuthenticatedUserResponse { var request = URLRequest(url: apiURL) request.httpMethod = "GET" request.setValue(credentials.authorizationHeader, forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") let httpResponse: ProviderHTTPResponse do { httpResponse = try await self.transport.response(for: request) } catch is CancellationError { throw CancellationError() } catch let error as URLError where error.code == .cancelled { throw CancellationError() } catch { Self.logger.debug("Zed cloud API transport failed: \(error.localizedDescription)") throw ZedStatusProbeError.networkError(error.localizedDescription) } switch httpResponse.statusCode { case 200: return try Self.parseResponse(httpResponse.data) case 401, 403: throw ZedStatusProbeError.unauthorized default: throw ZedStatusProbeError.httpError(httpResponse.statusCode) } } public static func parseResponse(_ data: Data) throws -> ZedAuthenticatedUserResponse { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let value = try container.decode(String.self) if let date = Self.parseISO8601Date(value) { return date } throw DecodingError.dataCorruptedError( in: container, debugDescription: "Invalid ISO8601 date: \(value)") } do { return try decoder.decode(ZedAuthenticatedUserResponse.self, from: data) } catch { throw ZedStatusProbeError.parseFailed(error.localizedDescription) } } private static func parseISO8601Date(_ value: String) -> Date? { let withFractional = ISO8601DateFormatter() withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = withFractional.date(from: value) { return date } let plain = ISO8601DateFormatter() plain.formatOptions = [.withInternetDateTime] return plain.date(from: value) } } // MARK: - UsageSnapshot mapping extension ZedUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { let plan = self.response.plan let user = self.response.user let primary = Self.makeEditPredictionsWindow( used: plan.usage.editPredictions.used, limit: plan.usage.editPredictions.limit) let secondary = plan.subscriptionPeriod.map { period in RateWindow( usedPercent: Self.billingCycleUsedPercent(startedAt: period.startedAt, endedAt: period.endedAt), windowMinutes: nil, resetsAt: period.endedAt, resetDescription: Self.formatResetDescription(period.endedAt)) } var extraRateWindows: [NamedRateWindow] = [] if plan.hasOverdueInvoices { extraRateWindows.append(NamedRateWindow( id: "zed.overdue-invoices", title: "Billing", window: RateWindow( usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Overdue invoices"), usageKnown: false)) } let identity = ProviderIdentitySnapshot( providerID: .zed, accountEmail: user.githubLogin.nilIfEmpty, accountOrganization: user.name?.nilIfEmpty, loginMethod: Self.displayPlanName(plan.planV3)) return UsageSnapshot( primary: primary, secondary: secondary, extraRateWindows: extraRateWindows.isEmpty ? nil : extraRateWindows, subscriptionRenewsAt: plan.subscriptionPeriod?.endedAt, updatedAt: self.updatedAt, identity: identity) } private static func makeEditPredictionsWindow(used: Int, limit: ZedUsageLimit) -> RateWindow? { switch limit { case .unlimited: return RateWindow( usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Unlimited") case let .limited(total): guard total > 0 else { return nil } let clampedUsed = max(0, min(total, used)) let usedPercent = Double(clampedUsed) / Double(total) * 100.0 return RateWindow( usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: "\(clampedUsed) / \(total) predictions") } } public static func displayPlanName(_ rawPlan: String) -> String { switch rawPlan.lowercased() { case "zed_free": "Zed Free" case "zed_pro": "Zed Pro" case "zed_pro_trial": "Zed Pro Trial" case "zed_student": "Zed Student" case "zed_business": "Zed Business" default: rawPlan .replacingOccurrences(of: "_", with: " ") .split(separator: " ") .map { word in word.prefix(1).uppercased() + word.dropFirst().lowercased() } .joined(separator: " ") } } private static func billingCycleUsedPercent(startedAt: Date, endedAt: Date) -> Double { let now = Date() let total = endedAt.timeIntervalSince(startedAt) guard total > 0 else { return 0 } let elapsed = now.timeIntervalSince(startedAt) return max(0, min(100, elapsed / total * 100)) } private static func formatResetDescription(_ date: Date) -> String? { let now = Date() let interval = date.timeIntervalSince(now) guard interval > 0 else { return "Cycle ended" } let hours = Int(interval / 3600) let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) if hours > 24 { let days = hours / 24 let remainingHours = hours % 24 return "Cycle ends in \(days)d \(remainingHours)h" } else if hours > 0 { return "Cycle ends in \(hours)h \(minutes)m" } else { return "Cycle ends in \(minutes)m" } } } extension String { fileprivate var nilIfEmpty: String? { self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self } }