Files
steipete--codexbar/Sources/CodexBarCore/TokenAccounts.swift
T
2026-07-13 12:22:33 +08:00

165 lines
5.8 KiB
Swift

import Foundation
public struct ProviderTokenAccount: Codable, Identifiable, Sendable {
public let id: UUID
public let label: String
public let token: String
public let addedAt: TimeInterval
public let lastUsed: TimeInterval?
/// Stable provider-specific identity (e.g. GitHub `login`) used for
/// re-auth deduplication. Optional so legacy accounts keep working.
public let externalIdentifier: String?
/// Optional provider-specific usage scope. z.ai uses `personal` / `team`.
public let usageScope: String?
/// Optional provider-specific organization/workspace target. Claude web
/// sessionKey accounts use this to disambiguate linked Anthropic emails.
/// z.ai team accounts use this for the BigModel organization header.
public let organizationID: String?
/// Optional provider-specific workspace/project target. z.ai team accounts
/// use this for the BigModel project header.
public let workspaceID: String?
enum CodingKeys: String, CodingKey {
case id
case label
case token
case addedAt
case lastUsed
case externalIdentifier
case usageScope
case organizationID = "organizationId"
case workspaceID
}
public init(
id: UUID,
label: String,
token: String,
addedAt: TimeInterval,
lastUsed: TimeInterval?,
externalIdentifier: String? = nil,
usageScope: String? = nil,
organizationID: String? = nil,
workspaceID: String? = nil)
{
self.id = id
self.label = label
self.token = token
self.addedAt = addedAt
self.lastUsed = lastUsed
self.externalIdentifier = externalIdentifier
self.usageScope = usageScope
self.organizationID = organizationID
self.workspaceID = workspaceID
}
public var displayName: String {
self.label
}
public var sanitizedOrganizationID: String? {
Self.clean(self.organizationID)
}
public var sanitizedUsageScope: String? {
Self.clean(self.usageScope)
}
public var sanitizedWorkspaceID: String? {
Self.clean(self.workspaceID)
}
private static func clean(_ raw: String?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
return (trimmed?.isEmpty ?? true) ? nil : trimmed
}
}
public struct ProviderTokenAccountData: Codable, Sendable {
public let version: Int
public let accounts: [ProviderTokenAccount]
public let activeIndex: Int
public init(version: Int, accounts: [ProviderTokenAccount], activeIndex: Int) {
self.version = version
self.accounts = accounts
self.activeIndex = activeIndex
}
public func clampedActiveIndex() -> Int {
guard !self.accounts.isEmpty else { return 0 }
return min(max(self.activeIndex, 0), self.accounts.count - 1)
}
}
private struct ProviderTokenAccountsFile: Codable {
let version: Int
let providers: [String: ProviderTokenAccountData]
}
public protocol ProviderTokenAccountStoring: Sendable {
func loadAccounts() throws -> [UsageProvider: ProviderTokenAccountData]
func storeAccounts(_ accounts: [UsageProvider: ProviderTokenAccountData]) throws
func ensureFileExists() throws -> URL
}
public struct FileTokenAccountStore: ProviderTokenAccountStoring, @unchecked Sendable {
private let fileURL: URL
private let fileManager: FileManager
public init(fileURL: URL = Self.defaultURL(), fileManager: FileManager = .default) {
self.fileURL = fileURL
self.fileManager = fileManager
}
public func loadAccounts() throws -> [UsageProvider: ProviderTokenAccountData] {
guard self.fileManager.fileExists(atPath: self.fileURL.path) else { return [:] }
let data = try Data(contentsOf: self.fileURL)
let decoder = JSONDecoder()
let decoded = try decoder.decode(ProviderTokenAccountsFile.self, from: data)
var result: [UsageProvider: ProviderTokenAccountData] = [:]
for (key, value) in decoded.providers {
guard let provider = UsageProvider(rawValue: key) else { continue }
result[provider] = value
}
return result
}
public func storeAccounts(_ accounts: [UsageProvider: ProviderTokenAccountData]) throws {
let payload = ProviderTokenAccountsFile(
version: 1,
providers: Dictionary(uniqueKeysWithValues: accounts.map { ($0.key.rawValue, $0.value) }))
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(payload)
let directory = self.fileURL.deletingLastPathComponent()
if !self.fileManager.fileExists(atPath: directory.path) {
try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
try data.write(to: self.fileURL, options: [.atomic])
try self.applySecurePermissionsIfNeeded()
}
public func ensureFileExists() throws -> URL {
if self.fileManager.fileExists(atPath: self.fileURL.path) { return self.fileURL }
try self.storeAccounts([:])
return self.fileURL
}
private func applySecurePermissionsIfNeeded() throws {
#if os(macOS)
try self.fileManager.setAttributes([
.posixPermissions: NSNumber(value: Int16(0o600)),
], ofItemAtPath: self.fileURL.path)
#endif
}
public static func defaultURL() -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser
return base
.appendingPathComponent("CodexBar", isDirectory: true)
.appendingPathComponent("token-accounts.json")
}
}