codeburn/mac/Sources/CodeBurnMenubar/Data/CapacityDockPreferences.swift
Resham Joshi 39b9b3adcd
feat(quota): Command Code quota and ClinePass from the Cline sign-in (#1682)
* feat(quota): Command Code quota and ClinePass from the Cline sign-in

Command Code: reads the CLI's ~/.commandcode/auth.json key and the billing
credits and subscription endpoints. 5-hour and weekly windows as used of
cap, remaining credits as a note, plan from planId. Live in the Mac dock
from the local CLI, in codeburn quota and the Windows dock.

ClinePass: without a saved or env key, falls back to the Cline CLI's
providers.json session (accessToken, then apiKey, then auth.apiKey, with
Cline's path overrides). Read-only; an expired session stops before the
network and says to run cline. The Mac catalog adds the local CLI source
so Automatic connects without a key.

* fix(quota): keep retrying after an expired Cline sign-in

Running cline refreshes the session file CodeBurn only reads, so an
expired or rejected Cline session is now a retryable failure that keeps
the last quota and shows the guidance, on the Mac and in the CLI. A
rejected API key and a missing credential stay terminal.

* test(quota): build expected Cline paths with path.join for Windows
2026-10-07 23:34:31 +02:00

501 lines
20 KiB
Swift

import Foundation
/// Stable Capacity Dock identity backed by the audited provider catalog.
///
/// This is a value type rather than a 69-case enum so the provider inventory has
/// one source of truth. Existing CodeBurn quota adapters retain their
/// `ProviderFilter` bridge; the remaining providers are fetched through the
/// CodeBurn-owned provider adapter at runtime.
struct CapacityDockProvider: RawRepresentable, CaseIterable, Identifiable, Hashable, Sendable {
let rawValue: String
init?(rawValue: String) {
guard ProviderConnectionCatalog.providers.contains(where: { $0.id == rawValue }) else {
return nil
}
self.rawValue = rawValue
}
private init(known rawValue: String) {
self.rawValue = rawValue
}
var id: String { rawValue }
static var allCases: [CapacityDockProvider] {
ProviderConnectionCatalog.providers.map { CapacityDockProvider(known: $0.id) }
}
static let codex = CapacityDockProvider(known: "codex")
static let claude = CapacityDockProvider(known: "claude")
static let gemini = CapacityDockProvider(known: "gemini")
static let copilot = CapacityDockProvider(known: "copilot")
static let kimiCode = CapacityDockProvider(known: "kimi")
static let antigravity = CapacityDockProvider(known: "antigravity")
var catalogEntry: ProviderConnectionCatalogEntry {
ProviderConnectionCatalog.providers.first { $0.id == rawValue }!
}
var legacyFilter: ProviderFilter? {
switch rawValue {
case Self.claude.rawValue: .claude
case Self.codex.rawValue: .codex
case Self.gemini.rawValue: .gemini
case Self.copilot.rawValue: .copilot
case Self.kimiCode.rawValue: .kimiCode
case Self.antigravity.rawValue: .antigravity
default: nil
}
}
var displayName: String { catalogEntry.displayName }
/// The CLI's provider id for this dock provider, used to find its row in the
/// payload's `providerDetails`. Catalog ids and CLI ids agree everywhere the
/// dock has no legacy bridge; where it has one the bridge is the authority,
/// because the dock's `kimi` entry is Kimi Code, which the CLI calls
/// `kimicode` (its `kimi` is a different provider).
var payloadProviderID: String { legacyFilter?.cliArg ?? rawValue }
/// Every payload provider this one tile stands for. A tile is an account the
/// user recognises; the CLI splits some accounts across more than one provider
/// row, and a tile that reads only the first row reports zero while the account
/// was busy. Cursor is the case in hand: one subscription, but sessions land
/// under `cursor` for the editor and `cursor-agent` for the agent.
var payloadProviderIDs: [String] {
switch payloadProviderID {
// One subscription, two rows: the editor and the agent.
case "cursor": ["cursor", "cursor-agent"]
// The tile is the Factory account, which the CLI records under its
// product name. Without this the Droid tile reads zero while Droid works.
case "factory": ["droid"]
// The ClinePass plan pays for both Cline surfaces, and the CLI records
// the extension and the command line tool as separate rows.
case "clinepass": ["cline", "cline-cli"]
case "commandcode": ["command-code"]
default: [payloadProviderID]
}
}
var iconName: String {
switch rawValue {
case Self.codex.rawValue, "openai", "azureopenai": "codex"
case Self.gemini.rawValue: "gemini"
case Self.copilot.rawValue: "copilot"
case "alibabatokenplan": "alibaba"
case "moonshot": "kimi"
default: rawValue
}
}
}
enum CapacityDockEdge: String, CaseIterable, Sendable {
case left
case right
case top
case bottom
var isVertical: Bool { self == .left || self == .right }
var opposite: CapacityDockEdge {
switch self {
case .left: .right
case .right: .left
case .top: .bottom
case .bottom: .top
}
}
}
enum CapacityDockTheme: String, CaseIterable, Sendable {
case graphite
case liquidGlass
var displayName: String {
switch self {
case .graphite: L("Graphite")
case .liquidGlass: L("Liquid Glass")
}
}
}
enum CapacityDockGaugeShape: String, CaseIterable, Sendable {
case circle
case squircle
var displayName: String {
switch self {
case .circle: L("Circle")
case .squircle: L("Squircle")
}
}
}
enum CapacityDockProviderSelection {
static func isDockEligible(
_ provider: CapacityDockProvider,
isConnected: Bool,
hasSavedCredential: Bool
) -> Bool {
isConnected || (
provider.catalogEntry.hasLiveCodeBurnQuotaAdapter && hasSavedCredential
)
}
static func eligibleProviders(
from providers: [CapacityDockProvider] = CapacityDockPreferences.supportedProviders,
isEligible: (CapacityDockProvider) -> Bool
) -> [CapacityDockProvider] {
providers.filter(isEligible)
}
static func manageableProviders(
from providers: [CapacityDockProvider] = CapacityDockPreferences.supportedProviders,
selected: [CapacityDockProvider],
isConnected: (CapacityDockProvider) -> Bool
) -> [CapacityDockProvider] {
let selectedSet = Set(selected)
return providers.filter { selectedSet.contains($0) || isConnected($0) }
}
static func canDeselect(
_ provider: CapacityDockProvider,
selected: [CapacityDockProvider],
isConnected: (CapacityDockProvider) -> Bool
) -> Bool {
guard selected.contains(provider) else { return true }
let connectedSelection = selected.filter(isConnected)
return !isConnected(provider) || connectedSelection.count > 1
}
}
enum CapacityDockPreferences {
static let enabledKey = "CodeBurnCapacityDockEnabled"
static let selectedProvidersKey = "CodeBurnCapacityDockProviders"
static let preferredProviderKey = "CodeBurnCapacityDockPreferredProvider"
static let dockEdgeKey = "CodeBurnCapacityDockEdge"
static let attachmentEdgeKey = "CodeBurnCapacityDockAttachmentEdge"
static let legacyDockedKey = "CodeBurnCapacityDockDocked"
static let normalizedHorizontalOffsetKey = "CodeBurnCapacityDockHorizontalOffset"
static let normalizedVerticalOffsetKey = "CodeBurnCapacityDockVerticalOffset"
static let scaleKey = "CodeBurnCapacityDockScale"
static let themeKey = "CodeBurnCapacityDockTheme"
static let gaugeShapeKey = "CodeBurnCapacityDockGaugeShape"
static let glanceWindowsKey = "CodeBurnCapacityDockGlanceWindows"
static let manualSelectionKey = "CodeBurnCapacityDockManualSelection"
static let defaultProvider: CapacityDockProvider = .codex
static let supportedProviders = CapacityDockProvider.allCases
static let maxAutoProviders = 5
static let defaultScale = 0.6
static let scaleRange = 0.6 ... 1.2
struct Snapshot: Equatable, Sendable {
let isEnabled: Bool
let selectedProviders: [CapacityDockProvider]
let preferredProvider: CapacityDockProvider
let dockedEdge: CapacityDockEdge?
let attachmentEdge: CapacityDockEdge
let normalizedHorizontalOffset: Double?
let normalizedVerticalOffset: Double?
let scale: Double
let theme: CapacityDockTheme
let gaugeShape: CapacityDockGaugeShape
/// Which quota horizon each provider's gauge reports, keyed by provider
/// id. Sparse: an absent provider uses the billing horizon, which is
/// what the dock has always shown.
let glanceWindows: [String: CapacityDockGlanceWindowKind]
func glanceWindow(for provider: CapacityDockProvider) -> CapacityDockGlanceWindowKind {
glanceWindows[provider.rawValue] ?? .billing
}
}
static func load(defaults: UserDefaults = .standard) -> Snapshot {
let selected = normalizedProviders(
rawIdentifiers: defaults.stringArray(forKey: selectedProvidersKey)
)
let storedPreferred = defaults.string(forKey: preferredProviderKey)
.flatMap(CapacityDockProvider.init(rawValue:))
let preferred = normalizedPreferred(storedPreferred, selected: selected)
let horizontalOffset: Double?
if defaults.object(forKey: normalizedHorizontalOffsetKey) != nil {
let stored = defaults.double(forKey: normalizedHorizontalOffsetKey)
horizontalOffset = stored.isFinite ? clamp(stored) : nil
} else {
horizontalOffset = nil
}
let verticalOffset: Double?
if defaults.object(forKey: normalizedVerticalOffsetKey) != nil {
let stored = defaults.double(forKey: normalizedVerticalOffsetKey)
verticalOffset = stored.isFinite ? clamp(stored) : nil
} else {
verticalOffset = nil
}
let dockedEdge: CapacityDockEdge?
if let rawEdge = defaults.string(forKey: dockEdgeKey),
let storedEdge = CapacityDockEdge(rawValue: rawEdge) {
dockedEdge = storedEdge
} else if defaults.object(forKey: legacyDockedKey) != nil,
!defaults.bool(forKey: legacyDockedKey) {
dockedEdge = nil
} else {
dockedEdge = .right
}
let attachmentEdge = defaults.string(forKey: attachmentEdgeKey)
.flatMap(CapacityDockEdge.init(rawValue:))
?? dockedEdge
?? .right
return Snapshot(
isEnabled: defaults.bool(forKey: enabledKey),
selectedProviders: selected,
preferredProvider: preferred,
dockedEdge: dockedEdge,
attachmentEdge: attachmentEdge,
normalizedHorizontalOffset: horizontalOffset,
normalizedVerticalOffset: verticalOffset,
scale: normalizedScale(
defaults.object(forKey: scaleKey) == nil
? defaultScale
: defaults.double(forKey: scaleKey)
),
theme: defaults.string(forKey: themeKey)
.flatMap(CapacityDockTheme.init(rawValue:)) ?? .graphite,
gaugeShape: defaults.string(forKey: gaugeShapeKey)
.flatMap(CapacityDockGaugeShape.init(rawValue:)) ?? .squircle,
glanceWindows: normalizedGlanceWindows(
defaults.dictionary(forKey: glanceWindowsKey)
)
)
}
static func setEnabled(_ enabled: Bool, defaults: UserDefaults = .standard) {
defaults.set(enabled, forKey: enabledKey)
notifyChanged()
}
static func setSelectedProviders(
_ providers: [CapacityDockProvider],
defaults: UserDefaults = .standard
) {
let identifiers = providers.map(\.rawValue)
let normalized = normalizedProviders(rawIdentifiers: identifiers)
let currentPreferred = defaults.string(forKey: preferredProviderKey)
.flatMap(CapacityDockProvider.init(rawValue:))
let preferred = normalizedPreferred(currentPreferred, selected: normalized)
defaults.set(normalized.map(\.rawValue), forKey: selectedProvidersKey)
defaults.set(preferred.rawValue, forKey: preferredProviderKey)
// The user has taken over the dock provider set, so stop auto-seeding
// from connected subscriptions and respect their choice from now on.
defaults.set(true, forKey: manualSelectionKey)
notifyChanged()
}
/// Drops one provider from the persisted dock selection without latching
/// manual-selection mode. Disconnect needs this: a credential-less
/// adapter (Cursor) left in the selection would silently reconnect on the
/// next scheduled refresh.
static func removeProvider(
_ provider: CapacityDockProvider,
defaults: UserDefaults = .standard
) {
let stored = defaults.stringArray(forKey: selectedProvidersKey) ?? []
let remaining = stored.filter { $0 != provider.rawValue }
guard remaining.count != stored.count else { return }
defaults.set(remaining, forKey: selectedProvidersKey)
let preferred = normalizedPreferred(
defaults.string(forKey: preferredProviderKey).flatMap(CapacityDockProvider.init(rawValue:)),
selected: normalizedProviders(rawIdentifiers: remaining)
)
defaults.set(preferred.rawValue, forKey: preferredProviderKey)
notifyChanged()
}
/// Until the user manually edits the dock set, mirror the connected
/// subscriptions (capped) so a fresh install shows what's actually active.
/// No-ops once `manualSelectionKey` latches or the set already matches.
static func autoSeedFromConnected(
_ connected: [CapacityDockProvider],
defaults: UserDefaults = .standard
) {
guard !defaults.bool(forKey: manualSelectionKey) else { return }
// ZCode yields to Z.ai at seed time: both adapters read the same z.ai
// plan endpoint, so a machine signed in through the Pi CLI and the
// ZCode app would otherwise seed two rings reporting the same numbers.
// The deliberately configured Z.ai credential wins; pinning ZCode in
// Settings always shows it regardless.
var seeded = connected
if seeded.contains(where: { $0.id == "zai" }) {
seeded.removeAll { $0.id == "zcode" }
}
let desired = supportedProviders
.filter(seeded.contains)
.prefix(maxAutoProviders)
let desiredIDs = desired.map(\.rawValue)
guard !desiredIDs.isEmpty else { return } // nothing active yet — wait
guard defaults.stringArray(forKey: selectedProvidersKey) != desiredIDs else { return }
defaults.set(desiredIDs, forKey: selectedProvidersKey)
let preferred = normalizedPreferred(
defaults.string(forKey: preferredProviderKey).flatMap(CapacityDockProvider.init(rawValue:)),
selected: Array(desired)
)
defaults.set(preferred.rawValue, forKey: preferredProviderKey)
notifyChanged()
}
static func setPreferredProvider(
_ provider: CapacityDockProvider,
defaults: UserDefaults = .standard
) {
let selected = load(defaults: defaults).selectedProviders
let normalized = normalizedPreferred(provider, selected: selected)
defaults.set(normalized.rawValue, forKey: preferredProviderKey)
notifyChanged()
}
static func setNormalizedVerticalOffset(
_ offset: Double?,
defaults: UserDefaults = .standard
) {
if let offset, offset.isFinite {
defaults.set(clamp(offset), forKey: normalizedVerticalOffsetKey)
} else {
defaults.removeObject(forKey: normalizedVerticalOffsetKey)
}
notifyChanged()
}
static func setPlacement(
dockedEdge: CapacityDockEdge?,
attachmentEdge: CapacityDockEdge? = nil,
normalizedHorizontalOffset: Double?,
normalizedVerticalOffset: Double?,
defaults: UserDefaults = .standard
) {
if let dockedEdge {
defaults.set(dockedEdge.rawValue, forKey: dockEdgeKey)
defaults.removeObject(forKey: legacyDockedKey)
} else {
defaults.removeObject(forKey: dockEdgeKey)
// Absence means a fresh install, whose product default is the
// right edge. Retain an explicit false marker so a deliberately
// detached rail survives relaunch and screen changes.
defaults.set(false, forKey: legacyDockedKey)
}
let retainedAttachmentEdge = attachmentEdge
?? dockedEdge
?? defaults.string(forKey: attachmentEdgeKey).flatMap(CapacityDockEdge.init(rawValue:))
?? .right
defaults.set(retainedAttachmentEdge.rawValue, forKey: attachmentEdgeKey)
if let normalizedHorizontalOffset,
normalizedHorizontalOffset.isFinite {
defaults.set(clamp(normalizedHorizontalOffset), forKey: normalizedHorizontalOffsetKey)
} else {
defaults.removeObject(forKey: normalizedHorizontalOffsetKey)
}
if let normalizedVerticalOffset, normalizedVerticalOffset.isFinite {
defaults.set(clamp(normalizedVerticalOffset), forKey: normalizedVerticalOffsetKey)
} else {
defaults.removeObject(forKey: normalizedVerticalOffsetKey)
}
notifyChanged()
}
static func setScale(_ scale: Double, defaults: UserDefaults = .standard) {
defaults.set(normalizedScale(scale), forKey: scaleKey)
notifyChanged()
}
static func setTheme(_ theme: CapacityDockTheme, defaults: UserDefaults = .standard) {
defaults.set(theme.rawValue, forKey: themeKey)
notifyChanged()
}
static func setGaugeShape(
_ gaugeShape: CapacityDockGaugeShape,
defaults: UserDefaults = .standard
) {
defaults.set(gaugeShape.rawValue, forKey: gaugeShapeKey)
notifyChanged()
}
/// Records which horizon one provider's gauge reports. Stored per provider
/// rather than as one dock-wide switch because the providers are
/// independent questions: a user can watch Claude's 5-hour window while
/// keeping Codex on its weekly one.
static func setGlanceWindow(
_ kind: CapacityDockGlanceWindowKind,
for provider: CapacityDockProvider,
defaults: UserDefaults = .standard
) {
var stored = storedGlanceWindows(defaults: defaults)
guard stored[provider.rawValue] != kind.rawValue else { return }
stored[provider.rawValue] = kind.rawValue
defaults.set(stored, forKey: glanceWindowsKey)
notifyChanged()
}
private static func storedGlanceWindows(defaults: UserDefaults) -> [String: String] {
var stored: [String: String] = [:]
for (identifier, value) in defaults.dictionary(forKey: glanceWindowsKey) ?? [:] {
guard let value = value as? String else { continue }
stored[identifier] = value
}
return stored
}
/// Drops entries a newer or older build cannot make sense of — an unknown
/// provider id, a horizon this build does not have — so a foreign value in
/// the domain cannot decide what the gauge shows.
private static func normalizedGlanceWindows(
_ stored: [String: Any]?
) -> [String: CapacityDockGlanceWindowKind] {
guard let stored else { return [:] }
var normalized: [String: CapacityDockGlanceWindowKind] = [:]
for (identifier, value) in stored {
guard CapacityDockProvider(rawValue: identifier) != nil,
let rawValue = value as? String,
let kind = CapacityDockGlanceWindowKind(rawValue: rawValue) else { continue }
normalized[identifier] = kind
}
return normalized
}
private static func normalizedProviders(rawIdentifiers: [String]?) -> [CapacityDockProvider] {
let identifiers = Set(rawIdentifiers ?? [defaultProvider.rawValue])
let normalized = supportedProviders.filter { identifiers.contains($0.rawValue) }
return normalized.isEmpty ? [defaultProvider] : normalized
}
private static func normalizedPreferred(
_ preferred: CapacityDockProvider?,
selected: [CapacityDockProvider]
) -> CapacityDockProvider {
if let preferred, selected.contains(preferred) { return preferred }
if selected.contains(defaultProvider) { return defaultProvider }
return selected.first ?? defaultProvider
}
private static func clamp(_ value: Double) -> Double {
min(max(value, 0), 1)
}
private static func normalizedScale(_ value: Double) -> Double {
guard value.isFinite else { return defaultScale }
return min(max(value, scaleRange.lowerBound), scaleRange.upperBound)
}
private static func notifyChanged() {
NotificationCenter.default.post(name: .capacityDockPreferencesDidChange, object: nil)
}
}
extension Notification.Name {
static let capacityDockPreferencesDidChange = Notification.Name("com.codeburn.capacityDockPreferencesDidChange")
static let capacityDockOpenProviderSettings = Notification.Name("com.codeburn.capacityDockOpenProviderSettings")
}