Files
2026-07-13 12:22:33 +08:00

555 lines
19 KiB
Swift

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
}
}