From ede6472733c0a8c926eb0e3823589061824ea4c5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 17 Jul 2026 15:15:56 -0700 Subject: [PATCH 01/34] Add LedgerCore library and restore the native macOS platform The model layer for a new Ledger menu-bar app: fetches current-cycle Cursor spend from the Admin API (POST /teams/spend, Basic auth) and reduces it to the signed-in team member via a single observable LoadState. The Admin API key lives in the Keychain (KeychainStore); only the email + refresh interval persist as JSON (LedgerConfigStore). Restores the .macOS(.v26) platform and a LedgerCore product/target in Package.swift. Groundwork for the Ledger app (next commit); builds via swift build. --- Ledger/LedgerCore/Sources/KeychainStore.swift | 110 +++++++ .../Sources/LedgerConfigStore.swift | 66 ++++ Ledger/LedgerCore/Sources/LedgerLog.swift | 30 ++ .../LedgerCore/Sources/LedgerServices.swift | 287 ++++++++++++++++++ .../LedgerCore/Sources/LedgerSettings.swift | 45 +++ .../Sources/LoginItemController.swift | 126 ++++++++ Ledger/LedgerCore/Sources/Spend.swift | 96 ++++++ Ledger/LedgerCore/Sources/SpendProvider.swift | 83 +++++ Ledger/LedgerCore/Sources/TestDoubles.swift | 64 ++++ Package.swift | 9 + 10 files changed, 916 insertions(+) create mode 100644 Ledger/LedgerCore/Sources/KeychainStore.swift create mode 100644 Ledger/LedgerCore/Sources/LedgerConfigStore.swift create mode 100644 Ledger/LedgerCore/Sources/LedgerLog.swift create mode 100644 Ledger/LedgerCore/Sources/LedgerServices.swift create mode 100644 Ledger/LedgerCore/Sources/LedgerSettings.swift create mode 100644 Ledger/LedgerCore/Sources/LoginItemController.swift create mode 100644 Ledger/LedgerCore/Sources/Spend.swift create mode 100644 Ledger/LedgerCore/Sources/SpendProvider.swift create mode 100644 Ledger/LedgerCore/Sources/TestDoubles.swift diff --git a/Ledger/LedgerCore/Sources/KeychainStore.swift b/Ledger/LedgerCore/Sources/KeychainStore.swift new file mode 100644 index 00000000..cadfe89a --- /dev/null +++ b/Ledger/LedgerCore/Sources/KeychainStore.swift @@ -0,0 +1,110 @@ +import Foundation +import Security + +/// A failure reading or writing the Keychain. Wraps the raw `OSStatus` so a +/// caller can log something actionable rather than swallowing the error. +public struct KeychainError: LocalizedError, Equatable, Sendable { + public let status: OSStatus + public init(status: OSStatus) { + self.status = status + } + + public var errorDescription: String? { + let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error" + return "\(message) (OSStatus \(status))" + } +} + +/// Stores a single secret string (Ledger's Admin API key) securely. The seam +/// is a protocol so tests use an in-memory fake — the real Keychain isn't +/// available in a hostless test process without a signed, entitled host. +public protocol KeychainStore: Sendable { + /// The stored secret, or `nil` when nothing is stored. Throws + /// ``KeychainError`` on an unexpected Keychain failure (a missing item is + /// `nil`, not an error). + func read() throws -> String? + /// Stores `secret`, replacing any existing value. Passing an empty or + /// whitespace-only string removes the item. + func write(_ secret: String) throws + /// Removes the stored secret, if any. + func remove() throws +} + +/// The production ``KeychainStore``: a generic-password item in the login +/// Keychain, keyed by `service`/`account`. Ledger isn't sandboxed (like the +/// old Foreman app), so it reaches the default login Keychain without a +/// keychain-access-group entitlement. +public struct SystemKeychainStore: KeychainStore { + private let service: String + private let account: String + + /// Defaults to the app's bundle-style service and a fixed account name; + /// there is only ever one secret (the Admin API key). + public init(service: String = "com.stuff.ledger", account: String = "admin-api-key") { + self.service = service + self.account = account + } + + private var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + public func read() throws -> String? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + switch status { + case errSecSuccess: + guard let data = item as? Data, + let string = String(data: data, encoding: .utf8) + else { + return nil + } + return string + case errSecItemNotFound: + return nil + default: + throw KeychainError(status: status) + } + } + + public func write(_ secret: String) throws { + let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + try remove() + return + } + + let data = Data(trimmed.utf8) + let attributes: [String: Any] = [kSecValueData as String: data] + + let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary) + switch updateStatus { + case errSecSuccess: + return + case errSecItemNotFound: + var addQuery = baseQuery + addQuery[kSecValueData as String] = data + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw KeychainError(status: addStatus) + } + default: + throw KeychainError(status: updateStatus) + } + } + + public func remove() throws { + let status = SecItemDelete(baseQuery as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError(status: status) + } + } +} diff --git a/Ledger/LedgerCore/Sources/LedgerConfigStore.swift b/Ledger/LedgerCore/Sources/LedgerConfigStore.swift new file mode 100644 index 00000000..e0a1a6d9 --- /dev/null +++ b/Ledger/LedgerCore/Sources/LedgerConfigStore.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Everything Ledger persists as JSON: the team-member email and the refresh +/// interval. The Admin API key is deliberately absent — it lives in the +/// Keychain, never in this plaintext file. +public struct LedgerConfiguration: Codable, Equatable, Sendable { + /// The team member whose spend to display; `nil` until the user sets it. + public var teamMemberEmail: String? + /// Seconds between automatic refreshes. + public var refreshInterval: TimeInterval + + public init(teamMemberEmail: String?, refreshInterval: TimeInterval) { + self.teamMemberEmail = teamMemberEmail + self.refreshInterval = refreshInterval + } + + /// The configuration a fresh install starts from: no email yet, refreshing + /// every 15 minutes. + public static let initial = LedgerConfiguration( + teamMemberEmail: nil, + refreshInterval: 15 * 60, + ) +} + +/// Loads and saves the ``LedgerConfiguration`` JSON file. +/// +/// A missing file is the legitimate first-launch state and loads as +/// ``LedgerConfiguration/initial``; a file that exists but can't be read or +/// decoded is corrupt and `load()` throws rather than silently resetting. +public struct LedgerConfigStore: Sendable { + private let fileURL: URL + + /// A store whose config file is `configuration.json` inside `directory` + /// (created on first save). + public init(directory: URL) { + fileURL = directory.appendingPathComponent("configuration.json") + } + + /// The production store, under + /// `~/Library/Application Support/com.stuff.ledger/`. The path is + /// deterministic (Ledger is not sandboxed), so this can't fail; the + /// directory itself is created on first save. + public static func applicationSupport() -> LedgerConfigStore { + let base = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) + return LedgerConfigStore(directory: base.appendingPathComponent("com.stuff.ledger")) + } + + public func load() throws -> LedgerConfiguration { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return .initial + } + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode(LedgerConfiguration.self, from: data) + } + + public func save(_ configuration: LedgerConfiguration) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(configuration).write(to: fileURL, options: .atomic) + } +} diff --git a/Ledger/LedgerCore/Sources/LedgerLog.swift b/Ledger/LedgerCore/Sources/LedgerLog.swift new file mode 100644 index 00000000..c8372a36 --- /dev/null +++ b/Ledger/LedgerCore/Sources/LedgerLog.swift @@ -0,0 +1,30 @@ +import LogKit + +/// Central logging facade for the Ledger menu bar app. Every logger site +/// routes through ``channel(_:)`` so messages reach Apple unified logging +/// (Console.app, subsystem `com.stuff.ledger`) and — in DEBUG builds — the +/// shared in-memory ``LogKit/LogStore``. +/// +/// Categories are a typed enum rather than raw strings so a new logger can't +/// silently typo into an untracked category. +public enum LedgerLog { + /// The subsystem every Ledger log shares. + public static let subsystem = "com.stuff.ledger" + + /// Process-wide buffer. Logging is inherently process-global, so a single + /// shared store is the natural home. + public static let store = LogStore() + + public enum Category: String, CaseIterable, Sendable { + case app = "LedgerApp" + case configStore = "LedgerConfigStore" + case keychain = "KeychainStore" + case services = "LedgerServices" + case spendAPI = "CursorSpendAPI" + } + + /// A logging channel for `category`, wired to the shared buffer. + public static func channel(_ category: Category) -> LogChannel { + LogChannel(subsystem: subsystem, category: category.rawValue, store: store) + } +} diff --git a/Ledger/LedgerCore/Sources/LedgerServices.swift b/Ledger/LedgerCore/Sources/LedgerServices.swift new file mode 100644 index 00000000..91477043 --- /dev/null +++ b/Ledger/LedgerCore/Sources/LedgerServices.swift @@ -0,0 +1,287 @@ +import Foundation +import Observation + +/// The root of the Ledger model tree. Owns the settings node, the Keychain +/// wrapper (Admin API key), the spend provider (network), and the login-item +/// controller, and exposes a single observable ``LoadState`` the UI renders. +/// +/// `configuration` is the *retained backing store* that ``settings`` writes +/// through and persists — the tree never rebuilds it from scratch. +@MainActor +@Observable +public final class LedgerServices { + /// The spend-load state machine: exactly one of these holds at a time, so + /// the UI can't see a half-loaded mix of value + error + spinner. + public enum LoadState: Sendable, Equatable { + /// Nothing loaded yet (before the first fetch). + case idle + /// A fetch is in flight. + case loading + /// A fetch succeeded and produced the signed-in member's spend. + case loaded(MemberSpend) + /// A fetch failed; carries the reason for the UI to explain. + case failed(LoadError) + } + + /// Why a spend fetch couldn't produce a value. + public enum LoadError: Sendable, Equatable { + /// No Admin API key or no team-member email is configured yet. + case missingCredentials + /// The API responded, but no member matched the configured email. + case memberNotFound + /// A non-2xx HTTP response (401 usually means a bad key). + case http(Int) + /// The transport failed (offline, DNS, TLS, timeout). + case network(String) + /// A 2xx response that didn't decode. + case decode(String) + + /// A short, user-facing explanation for the popover. + public var message: String { + switch self { + case .missingCredentials: + "Add your Admin API key and email in Settings." + case .memberNotFound: + "No team member matches that email. Check it in Settings." + case .http(401): + "That Admin API key was rejected (HTTP 401). Check it in Settings." + case let .http(status): + "The spend request failed (HTTP \(status))." + case let .network(reason): + "Couldn't reach Cursor: \(reason)" + case let .decode(reason): + "Couldn't read the spend response: \(reason)" + } + } + } + + /// The current spend-load state. Observable so the UI reflects it. + public private(set) var loadState: LoadState = .idle + + /// When the last successful fetch completed, for the "updated …" caption. + public private(set) var lastUpdated: Date? + + /// Whether an Admin API key is stored. Mirrored from the Keychain so the + /// UI can reflect it without a (throwing) Keychain read on every access. + public private(set) var hasAPIKey: Bool = false + + /// Settings; created from the loaded configuration. + /// + /// `unowned` is safe: `settings` is reached only through a live + /// `LedgerServices`, and its callback fires from a direct property write. + @ObservationIgnored + public private(set) lazy var settings: LedgerSettings = .init( + teamMemberEmail: configuration.teamMemberEmail, + refreshInterval: configuration.refreshInterval, + onPersistentChange: { [unowned self] in settingsDidChange() }, + ) + + /// Whether Ledger is registered to launch at login. Backed by + /// `SMAppService` (the OS owns the real state). The setter registers or + /// unregisters and, on failure, logs *and* surfaces ``loginItemError`` + /// while leaving the observed value honest. + public var startsAtLogin: Bool { + get { loginItem.isEnabled } + set { + do { + try loginItem.setEnabled(newValue) + loginItemError = nil + } catch { + Self.logger.error("Couldn't update the login item: \(error)") + loginItemError = newValue + ? "Couldn't turn on Launch at login: \(error.localizedDescription)" + : "Couldn't turn off Launch at login: \(error.localizedDescription)" + } + } + } + + /// The login item is registered but macOS needs the user to approve it. + public var loginItemNeedsApproval: Bool { + loginItem.needsApproval + } + + /// The most recent login-item failure, surfaced in Settings. Cleared by + /// the next successful toggle. + public private(set) var loginItemError: String? + + private static let logger = LedgerLog.channel(.services) + + @ObservationIgnored private var configuration: LedgerConfiguration + @ObservationIgnored private let configStore: LedgerConfigStore + @ObservationIgnored private let keychain: any KeychainStore + @ObservationIgnored private let provider: any SpendProvider + @ObservationIgnored private let loginItem: LoginItemController + /// The auto-refresh loop; cancelled by ``stop()``. + @ObservationIgnored private var refreshLoop: Task? + /// Increments per fetch so a slow earlier response can't clobber a newer + /// one (manual refresh racing the timer). + @ObservationIgnored private var requestGeneration = 0 + + public convenience init() { + self.init( + configStore: .applicationSupport(), + keychain: SystemKeychainStore(), + provider: CursorSpendAPI(), + loginItem: LoginItemController(), + ) + } + + /// Loads the configuration eagerly so `settings` never sees pre-load + /// defaults. A file that exists but can't be read keeps the in-memory + /// defaults without overwriting it — nothing saves until the user changes + /// something deliberately. + @_spi(Testing) + public init( + configStore: LedgerConfigStore, + keychain: any KeychainStore, + provider: any SpendProvider, + loginItem: LoginItemController, + ) { + self.configStore = configStore + self.keychain = keychain + self.provider = provider + self.loginItem = loginItem + do { + configuration = try configStore.load() + } catch { + Self.logger.error("Couldn't load configuration: \(error)") + configuration = .initial + } + refreshHasAPIKey() + } + + // MARK: - Lifecycle + + /// Kicks off the first fetch and the periodic refresh loop. Call once at + /// app launch. + public func start() { + guard refreshLoop == nil else { return } + refreshLoop = Task { [weak self] in + while !Task.isCancelled { + await self?.refresh() + guard let interval = self?.settings.refreshInterval, interval > 0 else { return } + try? await Task.sleep(for: .seconds(interval)) + } + } + } + + /// Cancels the periodic refresh loop (the app's quit path). + public func stop() { + refreshLoop?.cancel() + refreshLoop = nil + } + + // MARK: - Spend + + /// Fetches the current-cycle spend and reduces it to the configured + /// member. Updates ``loadState`` (and ``lastUpdated`` on success). Safe to + /// call concurrently: a stale response is dropped. + public func refresh() async { + guard let email = settings.teamMemberEmail? + .trimmingCharacters(in: .whitespacesAndNewlines), !email.isEmpty + else { + loadState = .failed(.missingCredentials) + return + } + let key: String? + do { + key = try keychain.read() + } catch { + Self.logger.error("Couldn't read the API key: \(error.localizedDescription)") + key = nil + } + guard let apiKey = key, !apiKey.isEmpty else { + loadState = .failed(.missingCredentials) + return + } + + requestGeneration += 1 + let generation = requestGeneration + loadState = .loading + + do { + let response = try await provider.fetchSpend(apiKey: apiKey) + guard generation == requestGeneration else { return } + if let member = response.member(matching: email) { + loadState = .loaded(member) + lastUpdated = Date() + } else { + loadState = .failed(.memberNotFound) + } + } catch let error as SpendProviderError { + guard generation == requestGeneration else { return } + loadState = .failed(error.asLoadError) + } catch { + guard generation == requestGeneration else { return } + Self.logger.error("Unexpected spend error: \(error.localizedDescription)") + loadState = .failed(.network(error.localizedDescription)) + } + } + + // MARK: - Credentials + + /// Stores (or, for an empty string, clears) the Admin API key in the + /// Keychain and refreshes spend. Rethrows a Keychain failure so the UI can + /// surface it rather than silently losing the key. + public func setAPIKey(_ key: String) throws { + try keychain.write(key) + refreshHasAPIKey() + } + + /// Removes the stored Admin API key. + public func clearAPIKey() throws { + try keychain.remove() + refreshHasAPIKey() + } + + private func refreshHasAPIKey() { + do { + let key = try keychain.read() + hasAPIKey = !(key ?? "").isEmpty + } catch { + Self.logger.error("Couldn't read the API key: \(error.localizedDescription)") + hasAPIKey = false + } + } + + // MARK: - Login item + + /// Re-reads the login-item status from the OS; it can change outside the + /// app (System Settings › General › Login Items). + public func refreshLoginItemStatus() { + loginItem.refresh() + } + + /// Opens System Settings › General › Login Items so the user can approve a + /// pending login item. + public func openSystemSettingsLoginItems() { + loginItem.openSystemSettingsLoginItems() + } + + // MARK: - Persistence + + private func settingsDidChange() { + configuration.teamMemberEmail = settings.teamMemberEmail + configuration.refreshInterval = settings.refreshInterval + persist() + } + + private func persist() { + do { + try configStore.save(configuration) + } catch { + Self.logger.error("Couldn't save configuration: \(error)") + } + } +} + +extension SpendProviderError { + /// Maps the transport-level error into the user-facing ``LedgerServices/LoadError``. + fileprivate var asLoadError: LedgerServices.LoadError { + switch self { + case let .http(status): .http(status) + case let .network(reason): .network(reason) + case let .decode(reason): .decode(reason) + } + } +} diff --git a/Ledger/LedgerCore/Sources/LedgerSettings.swift b/Ledger/LedgerCore/Sources/LedgerSettings.swift new file mode 100644 index 00000000..ce414bfe --- /dev/null +++ b/Ledger/LedgerCore/Sources/LedgerSettings.swift @@ -0,0 +1,45 @@ +import Foundation +import Observation + +/// The settings node of the Ledger model tree: which team member's spend to +/// show and how often to refresh it. The Admin API key is *not* here — it +/// lives in the Keychain (see ``KeychainStore``); this holds only the +/// non-secret preferences that persist as JSON. +/// +/// Mutations notify the injected funnel so the owning tree persists on any +/// change; reassigning an equal value is a no-op. +@MainActor +@Observable +public final class LedgerSettings { + /// The email of the team member whose spend Ledger displays. `nil` until + /// the user sets it in Settings; a spend fetch with no email surfaces + /// ``LedgerServices/LoadError/missingCredentials``. + public var teamMemberEmail: String? { + didSet { + guard oldValue != teamMemberEmail else { return } + onPersistentChange() + } + } + + /// How often the spend is auto-refreshed while the app runs. + public var refreshInterval: TimeInterval { + didSet { + guard oldValue != refreshInterval else { return } + onPersistentChange() + } + } + + private let onPersistentChange: @MainActor () -> Void + + /// Initial values are the saved configuration; assigning them here does + /// not invoke the funnel. + public init( + teamMemberEmail: String?, + refreshInterval: TimeInterval, + onPersistentChange: @escaping @MainActor () -> Void, + ) { + self.teamMemberEmail = teamMemberEmail + self.refreshInterval = refreshInterval + self.onPersistentChange = onPersistentChange + } +} diff --git a/Ledger/LedgerCore/Sources/LoginItemController.swift b/Ledger/LedgerCore/Sources/LoginItemController.swift new file mode 100644 index 00000000..c2000e6b --- /dev/null +++ b/Ledger/LedgerCore/Sources/LoginItemController.swift @@ -0,0 +1,126 @@ +import Foundation +import Observation +import ServiceManagement + +/// The login-item state Ledger cares about, distilled from +/// `SMAppService.Status`. +/// +/// `requiresApproval` is distinct from `notRegistered`: the app *is* registered +/// but macOS is waiting for the user to approve it in System Settings before it +/// will actually launch. Collapsing it into "off" would misreport an enabled +/// (pending) item as disabled. +public enum LoginItemStatus: Sendable, Equatable { + case enabled + case requiresApproval + case notRegistered +} + +/// Registers, unregisters, and reports the app's login-item state behind +/// ``LoginItemController``. Production uses the `SMAppService.mainApp`-backed +/// implementation; tests conform a fake so no real login item is touched. +@MainActor +@_spi(Testing) +public protocol LoginItemBackend: AnyObject { + var status: LoginItemStatus { get } + func register() throws + func unregister() throws + /// Opens System Settings › General › Login Items so the user can approve + /// or inspect the item. + func openSystemSettingsLoginItems() +} + +/// The real backend: `SMAppService.mainApp`. Registering the *main app* as a +/// login item needs no helper bundle and no special entitlement. +@MainActor +final class MainAppLoginItemBackend: LoginItemBackend { + var status: LoginItemStatus { + switch SMAppService.mainApp.status { + case .enabled: .enabled + case .requiresApproval: .requiresApproval + // `.notFound` means the service isn't registered from this bundle; + // for the toggle that reads the same as "not registered". + case .notRegistered, .notFound: .notRegistered + @unknown default: .notRegistered + } + } + + func register() throws { + try SMAppService.mainApp.register() + } + + func unregister() throws { + try SMAppService.mainApp.unregister() + } + + func openSystemSettingsLoginItems() { + SMAppService.openSystemSettingsLoginItems() + } +} + +/// Reflects and controls whether Ledger launches at login, wrapping +/// `SMAppService.mainApp`. +/// +/// The OS owns the real state, so ``status`` is read from the backend (and +/// re-read via ``refresh()``, since the user can change it in System Settings +/// while the app runs). ``setEnabled(_:)`` registers or unregisters and then +/// re-syncs ``status`` from the backend so the observed value never lies — a +/// failed registration leaves the toggle honestly off, not falsely on. +@MainActor +@Observable +public final class LoginItemController { + /// The current login-item status. Observable so the settings UI reflects + /// it (including the pending-approval state). + public private(set) var status: LoginItemStatus + + @ObservationIgnored private let backend: any LoginItemBackend + + /// Whether the login item is on. Includes ``LoginItemStatus/requiresApproval``: + /// the user asked for it and it's registered — it just needs a nod in + /// System Settings — so the toggle should read on, not off. + public var isEnabled: Bool { + status != .notRegistered + } + + /// The login item is registered but macOS needs the user to approve it in + /// System Settings before it will actually launch. + public var needsApproval: Bool { + status == .requiresApproval + } + + public init() { + backend = MainAppLoginItemBackend() + status = backend.status + } + + /// Swaps the real login-item backend for a test double. + @_spi(Testing) + public init(backend: any LoginItemBackend) { + self.backend = backend + status = backend.status + } + + /// Re-reads the OS status; it can change outside the app (System Settings + /// › General › Login Items). + public func refresh() { + status = backend.status + } + + /// Registers or unregisters the login item, then re-syncs ``status`` from + /// the backend. Rethrows the underlying `SMAppService` error; the observed + /// value stays honest whether it succeeds or throws. + public func setEnabled(_ enabled: Bool) throws { + defer { status = backend.status } + guard enabled != isEnabled else { return } + if enabled { + try backend.register() + } else { + try backend.unregister() + } + } + + /// Opens System Settings › General › Login Items (used to approve a + /// pending item). + public func openSystemSettingsLoginItems() { + backend.openSystemSettingsLoginItems() + } +} diff --git a/Ledger/LedgerCore/Sources/Spend.swift b/Ledger/LedgerCore/Sources/Spend.swift new file mode 100644 index 00000000..acc8584d --- /dev/null +++ b/Ledger/LedgerCore/Sources/Spend.swift @@ -0,0 +1,96 @@ +import Foundation + +/// The decoded response of `POST /teams/spend` (Cursor Admin API). +/// +/// Only the fields Ledger reads are modeled; the endpoint returns more +/// (pagination, totals) that synthesized `Codable` simply ignores. Unknown or +/// team-tier-specific fields being absent is expected, not an error. +public struct SpendResponse: Codable, Equatable, Sendable { + /// Per-member spend for the current billing cycle. + public var teamMemberSpend: [MemberSpend] + + /// Start of the current billing cycle, as milliseconds since the Unix + /// epoch, when the API includes it. Surfaced so the UI can label the cycle. + public var subscriptionCycleStart: Double? + + public init(teamMemberSpend: [MemberSpend], subscriptionCycleStart: Double? = nil) { + self.teamMemberSpend = teamMemberSpend + self.subscriptionCycleStart = subscriptionCycleStart + } + + /// The member whose email matches `email`, case-insensitively — the caller + /// (Ledger) filters the whole team down to the signed-in user. `nil` when + /// no member matches (surfaced as ``LedgerServices/LoadError/memberNotFound``). + public func member(matching email: String) -> MemberSpend? { + let target = email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !target.isEmpty else { return nil } + return teamMemberSpend.first { $0.email.lowercased() == target } + } +} + +/// One team member's current-cycle spend. +/// +/// Cent fields are `Double` on purpose: on 2026-06-04 the Admin API added +/// sub-cent precision to `spendCents`/`overallSpendCents` so results reconcile +/// with invoice amounts, so these are not whole integers. +public struct MemberSpend: Codable, Equatable, Sendable, Identifiable { + public var userId: String + public var name: String? + public var email: String + /// On-demand (overage) spend in cents for the current cycle — excludes + /// included usage. + public var spendCents: Double + /// Spend drawn from the member's included usage allowance, in cents, when + /// the API reports it (tiered self-serve teams). + public var includedSpendCents: Double? + /// Total cycle-to-date spend in cents (on-demand + included) when the API + /// reports it. May be absent depending on team type. + public var overallSpendCents: Double? + /// Number of usage-based premium requests made during the cycle. + public var fastPremiumRequests: Int? + + public var id: String { + userId + } + + public init( + userId: String, + name: String?, + email: String, + spendCents: Double, + includedSpendCents: Double?, + overallSpendCents: Double?, + fastPremiumRequests: Int?, + ) { + self.userId = userId + self.name = name + self.email = email + self.spendCents = spendCents + self.includedSpendCents = includedSpendCents + self.overallSpendCents = overallSpendCents + self.fastPremiumRequests = fastPremiumRequests + } + + /// Total cycle-to-date spend in cents. Prefers the API's own + /// `overallSpendCents`; when that's absent (some team types omit it) it + /// falls back to on-demand + included so the headline figure is never + /// silently short. + public var totalCents: Double { + overallSpendCents ?? (spendCents + (includedSpendCents ?? 0)) + } + + /// Total cycle-to-date spend in dollars. + public var totalDollars: Double { + totalCents / 100 + } + + /// On-demand (overage) spend in dollars. + public var onDemandDollars: Double { + spendCents / 100 + } + + /// Included-allowance spend in dollars, or `nil` when the API omits it. + public var includedDollars: Double? { + includedSpendCents.map { $0 / 100 } + } +} diff --git a/Ledger/LedgerCore/Sources/SpendProvider.swift b/Ledger/LedgerCore/Sources/SpendProvider.swift new file mode 100644 index 00000000..db1db9fc --- /dev/null +++ b/Ledger/LedgerCore/Sources/SpendProvider.swift @@ -0,0 +1,83 @@ +import Foundation + +/// A failure fetching spend from a ``SpendProvider``. Kept low-level and +/// transport-shaped; ``LedgerServices`` maps it into its user-facing +/// ``LedgerServices/LoadError``. +public enum SpendProviderError: Error, Equatable, Sendable { + /// A non-2xx HTTP response, carrying the status code (401 = bad key). + case http(Int) + /// The transport failed (offline, DNS, TLS, timeout). + case network(String) + /// A 2xx response whose body didn't decode as a ``SpendResponse``. + case decode(String) +} + +/// The seam between ``LedgerServices`` and the network. Production uses +/// ``CursorSpendAPI``; tests conform ``ScriptedSpendProvider`` so no real HTTP +/// happens (per the "test doubles conform to the production protocol" rule). +public protocol SpendProvider: Sendable { + /// Fetches the current-cycle team spend, authenticating with `apiKey`. + /// Throws ``SpendProviderError`` on transport, HTTP, or decode failure. + func fetchSpend(apiKey: String) async throws -> SpendResponse +} + +/// The production ``SpendProvider``: `POST https://api.cursor.com/teams/spend` +/// with HTTP Basic auth (the Admin API key as the username, empty password), +/// exactly as the Admin API documents. +public struct CursorSpendAPI: SpendProvider { + private let endpoint: URL + private let session: URLSession + + /// The documented Admin API spend endpoint. + public static let defaultEndpoint = URL(string: "https://api.cursor.com/teams/spend")! + + private static let logger = LedgerLog.channel(.spendAPI) + + public init(endpoint: URL = CursorSpendAPI.defaultEndpoint, session: URLSession = .shared) { + self.endpoint = endpoint + self.session = session + } + + public func fetchSpend(apiKey: String) async throws -> SpendResponse { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(Self.basicAuthValue(apiKey: apiKey), forHTTPHeaderField: "Authorization") + // An empty JSON object: the whole team, default sort/pagination. Ledger + // filters to the signed-in user client-side rather than via searchTerm, + // so a rename of the account can't hide the row. + request.httpBody = Data("{}".utf8) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + Self.logger.error("Spend request transport failed: \(error.localizedDescription)") + throw SpendProviderError.network(error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw SpendProviderError.network("Non-HTTP response") + } + guard (200 ..< 300).contains(http.statusCode) else { + Self.logger.error("Spend request returned HTTP \(http.statusCode)") + throw SpendProviderError.http(http.statusCode) + } + + do { + return try JSONDecoder().decode(SpendResponse.self, from: data) + } catch { + Self.logger.error("Spend response failed to decode: \(error.localizedDescription)") + throw SpendProviderError.decode(error.localizedDescription) + } + } + + /// `Basic base64(apiKey + ":")` — the API key is the username, the password + /// is empty (mirrors `curl -u YOUR_API_KEY:`). + @_spi(Testing) + public static func basicAuthValue(apiKey: String) -> String { + let token = Data("\(apiKey):".utf8).base64EncodedString() + return "Basic \(token)" + } +} diff --git a/Ledger/LedgerCore/Sources/TestDoubles.swift b/Ledger/LedgerCore/Sources/TestDoubles.swift new file mode 100644 index 00000000..037629cd --- /dev/null +++ b/Ledger/LedgerCore/Sources/TestDoubles.swift @@ -0,0 +1,64 @@ +#if DEBUG + import Foundation + + /// A ``SpendProvider`` that returns a scripted outcome instead of hitting + /// the network — used by unit tests and SwiftUI previews. Lives in the + /// module (behind `@_spi(Testing)` + `#if DEBUG`) so both callers share one + /// double that conforms to the production protocol. + @_spi(Testing) + public struct ScriptedSpendProvider: SpendProvider { + public enum Outcome: Sendable { + case success(SpendResponse) + case failure(SpendProviderError) + } + + private let outcome: Outcome + + public init(_ outcome: Outcome) { + self.outcome = outcome + } + + /// Convenience: succeed with a single member for `email`. + public init(member: MemberSpend) { + outcome = .success(SpendResponse(teamMemberSpend: [member])) + } + + public func fetchSpend(apiKey _: String) async throws -> SpendResponse { + switch outcome { + case let .success(response): response + case let .failure(error): throw error + } + } + } + + /// An in-memory ``KeychainStore`` — the real Keychain needs a signed, + /// entitled host that hostless test processes and previews don't have. + @_spi(Testing) + public final class InMemoryKeychainStore: KeychainStore, @unchecked Sendable { + private let lock = NSLock() + private var secret: String? + /// When set, `read`/`write`/`remove` throw it — exercises the error path. + private let failure: KeychainError? + + public init(secret: String? = nil, failure: KeychainError? = nil) { + self.secret = secret + self.failure = failure + } + + public func read() throws -> String? { + if let failure { throw failure } + return lock.withLock { secret } + } + + public func write(_ secret: String) throws { + if let failure { throw failure } + let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines) + lock.withLock { self.secret = trimmed.isEmpty ? nil : trimmed } + } + + public func remove() throws { + if let failure { throw failure } + lock.withLock { secret = nil } + } + } +#endif diff --git a/Package.swift b/Package.swift index 119946f9..c4dd3b16 100644 --- a/Package.swift +++ b/Package.swift @@ -6,9 +6,11 @@ let package = Package( defaultLocalization: "en", platforms: [ .iOS(.v26), + .macOS(.v26), ], products: [ .library(name: "StuffCore", targets: ["StuffCore"]), + .library(name: "LedgerCore", targets: ["LedgerCore"]), .library(name: "LifecycleKit", targets: ["LifecycleKit"]), .library(name: "JournalKit", targets: ["JournalKit"]), .library(name: "LogKit", targets: ["LogKit"]), @@ -33,6 +35,13 @@ let package = Package( name: "StuffCore", path: "Shared/StuffCore/Sources", ), + .target( + name: "LedgerCore", + dependencies: [ + .target(name: "LogKit"), + ], + path: "Ledger/LedgerCore/Sources", + ), .target( name: "LifecycleKit", path: "Shared/LifecycleKit/Sources", From c25d11a193bbe4b36259e7f22e4dc600484206eb Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 17 Jul 2026 15:16:04 -0700 Subject: [PATCH 02/34] Add the Ledger menu-bar app and macOS build/test wiring The native-macOS shell over LedgerCore: an NSStatusItem whose title shows the current-cycle spend (updated via an Observations loop) toggling an NSPopover that renders the LoadState, plus a System-Settings-style sidebar (General + Account panes) where the email and Keychain-stored Admin API key are set. Wires the Ledger app target (.mac, LSUIElement), the hostless LedgerCoreTests bundle, the Ledger / Ledger-macOS-Tests schemes, and restores the test-macos CI job. Verified: swiftformat --lint, tuist generate, and tuist test Ledger-macOS-Tests all pass (30 tests). --- .github/workflows/ci.yml | 40 +++- Ledger/Ledger/Sources/CurrencyFormat.swift | 17 ++ Ledger/Ledger/Sources/LedgerApp.swift | 93 ++++++++ Ledger/Ledger/Sources/LedgerSession.swift | 106 +++++++++ Ledger/Ledger/Sources/PreviewSupport.swift | 43 ++++ Ledger/Ledger/Sources/SettingsView.swift | 212 ++++++++++++++++++ Ledger/Ledger/Sources/SpendView.swift | 137 +++++++++++ .../Sources/WindowVisibilityReader.swift | 61 +++++ .../LedgerCore/Tests/KeychainStoreTests.swift | 36 +++ .../Tests/LedgerConfigStoreTests.swift | 40 ++++ .../Tests/LedgerCoreTestSupport.swift | 96 ++++++++ .../Tests/LedgerServicesTests.swift | 101 +++++++++ .../Tests/LoginItemControllerTests.swift | 84 +++++++ .../LedgerCore/Tests/SpendProviderTests.swift | 27 +++ Ledger/LedgerCore/Tests/SpendTests.swift | 60 +++++ Project.swift | 70 ++++++ 16 files changed, 1221 insertions(+), 2 deletions(-) create mode 100644 Ledger/Ledger/Sources/CurrencyFormat.swift create mode 100644 Ledger/Ledger/Sources/LedgerApp.swift create mode 100644 Ledger/Ledger/Sources/LedgerSession.swift create mode 100644 Ledger/Ledger/Sources/PreviewSupport.swift create mode 100644 Ledger/Ledger/Sources/SettingsView.swift create mode 100644 Ledger/Ledger/Sources/SpendView.swift create mode 100644 Ledger/Ledger/Sources/WindowVisibilityReader.swift create mode 100644 Ledger/LedgerCore/Tests/KeychainStoreTests.swift create mode 100644 Ledger/LedgerCore/Tests/LedgerConfigStoreTests.swift create mode 100644 Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift create mode 100644 Ledger/LedgerCore/Tests/LedgerServicesTests.swift create mode 100644 Ledger/LedgerCore/Tests/LoginItemControllerTests.swift create mode 100644 Ledger/LedgerCore/Tests/SpendProviderTests.swift create mode 100644 Ledger/LedgerCore/Tests/SpendTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bda25eae..3b6d9f76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,11 @@ jobs: steps: - uses: actions/checkout@v4 - uses: jdx/mise-action@v3 - # Tests run via the explicit `Stuff-iOS-Tests` scheme (declared in - # Project.swift) rather than the autogenerated `Stuff-Workspace` scheme. + # The workspace mixes iOS targets and the macOS-only Ledger targets, so + # tests run as two platform-scoped schemes (declared in Project.swift) — + # no single xcodebuild destination can build both. The macOS-only Ledger + # scheme runs in its own `test-macos` job (self-contained and fast) to + # keep it off this job's critical path. # Pin the iOS Simulator so hosted tests and signing stay predictable. - name: Build & Test (iOS) run: mise exec -- tuist test Stuff-iOS-Tests --no-selective-testing -- -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' @@ -52,3 +55,36 @@ jobs: path: diagnostics if-no-files-found: warn retention-days: 7 + + test-macos: + name: Build & Test (macOS) + needs: format + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v3 + # The macOS-only Ledger scheme is self-contained and fast, so it runs + # here in parallel with the iOS `test` job rather than adding ~40s to it. + - name: Build & Test (macOS) + run: mise exec -- tuist test Ledger-macOS-Tests --no-selective-testing -- -destination 'platform=macOS' + # Hosted tests can crash the test-host process on CI without leaving any error + # in Tuist's summarized log. On failure, capture crash reports + the .xcresult + # bundle (which holds the per-test crash backtraces) and upload them for analysis. + - name: Collect test diagnostics + if: ${{ failure() }} + run: | + mkdir -p diagnostics/crashes diagnostics/xcresult + cp -R "$HOME/Library/Logs/DiagnosticReports/." diagnostics/crashes/ 2>/dev/null || true + find "$HOME/Library/Developer/Xcode/DerivedData" -maxdepth 6 -name '*.xcresult' \ + -exec cp -R {} diagnostics/xcresult/ \; 2>/dev/null || true + cp -R "$HOME/.local/state/tuist/sessions" diagnostics/tuist-sessions 2>/dev/null || true + echo '--- collected diagnostics ---' + find diagnostics -maxdepth 3 | head -200 + - name: Upload test diagnostics + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: test-diagnostics-macos + path: diagnostics + if-no-files-found: warn + retention-days: 7 diff --git a/Ledger/Ledger/Sources/CurrencyFormat.swift b/Ledger/Ledger/Sources/CurrencyFormat.swift new file mode 100644 index 00000000..c0f88479 --- /dev/null +++ b/Ledger/Ledger/Sources/CurrencyFormat.swift @@ -0,0 +1,17 @@ +import Foundation + +/// USD formatting for spend figures. One place so the menu-bar title and the +/// popover render dollars identically. +enum CurrencyFormat { + /// A full currency string, e.g. `$1,234.56`. + static func full(_ dollars: Double) -> String { + dollars.formatted(.currency(code: "USD")) + } + + /// The menu-bar title form — the same currency string; kept as its own + /// entry point so the status-item presentation can diverge later without + /// touching call sites. + static func compact(_ dollars: Double) -> String { + full(dollars) + } +} diff --git a/Ledger/Ledger/Sources/LedgerApp.swift b/Ledger/Ledger/Sources/LedgerApp.swift new file mode 100644 index 00000000..9db34c6c --- /dev/null +++ b/Ledger/Ledger/Sources/LedgerApp.swift @@ -0,0 +1,93 @@ +import AppKit +import LedgerCore +import Observation +import SwiftUI + +/// The status item and popover are AppKit (not `MenuBarExtra`) on purpose: +/// the menu-bar title mirrors observable model state on every change, and the +/// AppKit `NSStatusItem` + `Observations` loop drives that reliably (the same +/// reason the old Foreman app landed here). See the module README. +@main +struct LedgerApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + // The only SwiftUI scene: the standard Settings window (Cmd-, / the + // popover's Settings button). The menu-bar UI itself is AppKit-managed. + Settings { + SettingsView(session: appDelegate.session) + } + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + let session = LedgerSession() + + private var statusItem: NSStatusItem? + private var popover: NSPopover? + private var titleTask: Task? + + func applicationDidFinishLaunching(_: Notification) { + session.start() + + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.target = self + item.button?.action = #selector(togglePopover) + item.button?.image = NSImage( + systemSymbolName: "dollarsign.circle", + accessibilityDescription: "Cursor spend", + ) + item.button?.imagePosition = .imageLeading + statusItem = item + + updateTitle() + // AppKit owns the title, so keeping it current is a plain loop: the + // stdlib Observations sequence yields after every change to the + // derived status title (no SwiftUI invalidation involved). + titleTask = Task { [weak self, session] in + for await _ in Observations({ @MainActor in session.statusTitle }) { + self?.updateTitle() + } + } + } + + /// Stop the refresh loop on quit. + func applicationWillTerminate(_: Notification) { + session.stop() + } + + /// Status-item click: dismiss the popover when it's open, otherwise show + /// it anchored to the button (and refresh spend as it opens). + @objc private func togglePopover() { + guard let button = statusItem?.button else { return } + let popover = popover ?? makePopover() + self.popover = popover + + if popover.isShown { + popover.performClose(nil) + return + } + session.refresh() + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + // The popover's own window must become key for text fields / buttons to + // take input reliably. + popover.contentViewController?.view.window?.makeKey() + } + + private func makePopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .transient + popover.contentViewController = NSHostingController( + rootView: SpendView(session: session), + ) + return popover + } + + /// Shows the current-cycle dollar amount beside the icon while spend is + /// loaded, and nothing (icon only) otherwise. + private func updateTitle() { + let title = session.statusTitle + statusItem?.button?.title = title == "—" ? "" : " \(title)" + } +} diff --git a/Ledger/Ledger/Sources/LedgerSession.swift b/Ledger/Ledger/Sources/LedgerSession.swift new file mode 100644 index 00000000..db88b8df --- /dev/null +++ b/Ledger/Ledger/Sources/LedgerSession.swift @@ -0,0 +1,106 @@ +import Foundation +import LedgerCore +import Observation + +/// Thin app-side facade over the LedgerCore model tree. Views read the +/// observable ``LedgerServices`` state (and bind its settings) directly; this +/// class owns the root and forwards the few app-level intents (launch, refresh, +/// quit teardown, credential edits). +@MainActor +@Observable +final class LedgerSession { + let services: LedgerServices + + /// The spend-load state the popover renders. + var loadState: LedgerServices.LoadState { + services.loadState + } + + /// When the last successful fetch completed (for the "updated …" caption). + var lastUpdated: Date? { + services.lastUpdated + } + + /// Whether an Admin API key is stored (drives the Settings key field). + var hasAPIKey: Bool { + services.hasAPIKey + } + + /// Settings; `SettingsView` binds these observable properties (persistence + /// happens in Core). + var settings: LedgerSettings { + services.settings + } + + /// Whether Ledger launches at login. `SettingsView` binds this two-way. + var startsAtLogin: Bool { + get { services.startsAtLogin } + set { services.startsAtLogin = newValue } + } + + /// The login item is registered but awaiting approval in System Settings. + var loginItemNeedsApproval: Bool { + services.loginItemNeedsApproval + } + + /// The most recent login-item failure (shown in the General settings pane). + var loginItemError: String? { + services.loginItemError + } + + /// The status-bar title: the current-cycle dollar amount when loaded, a + /// placeholder otherwise. Observed by the app delegate to keep the menu bar + /// current. + var statusTitle: String { + switch services.loadState { + case let .loaded(member): + CurrencyFormat.compact(member.totalDollars) + case .idle, .loading, .failed: + "—" + } + } + + init(services: LedgerServices) { + self.services = services + } + + convenience init() { + self.init(services: LedgerServices()) + } + + /// First fetch and periodic refresh. Called once at app launch. + func start() { + services.start() + } + + /// Stops the periodic refresh; the app's quit path. + func stop() { + services.stop() + } + + /// Fetches spend now (popover open, manual Refresh, after a credential edit). + func refresh() { + Task { await services.refresh() } + } + + /// Stores (or clears, for an empty string) the Admin API key, then refreshes. + func setAPIKey(_ key: String) throws { + try services.setAPIKey(key) + refresh() + } + + /// Removes the stored Admin API key. + func clearAPIKey() throws { + try services.clearAPIKey() + } + + /// Re-reads the login-item status from the OS. + func refreshLoginItemStatus() { + services.refreshLoginItemStatus() + } + + /// Opens System Settings › General › Login Items (to approve a pending item). + func openSystemSettingsLoginItems() { + services.openSystemSettingsLoginItems() + } +} diff --git a/Ledger/Ledger/Sources/PreviewSupport.swift b/Ledger/Ledger/Sources/PreviewSupport.swift new file mode 100644 index 00000000..0608e262 --- /dev/null +++ b/Ledger/Ledger/Sources/PreviewSupport.swift @@ -0,0 +1,43 @@ +#if DEBUG + import Foundation + @_spi(Testing) import LedgerCore + + /// Preview fixtures. Sessions are backed by an in-memory Keychain and a + /// scripted spend provider — they never touch the real Keychain, the + /// network, or `~/Library/Application Support`. + @MainActor + enum PreviewSupport { + /// A session already showing a member's spend. + static func loadedSession() -> LedgerSession { + let member = MemberSpend( + userId: "user_preview", + name: "Preview User", + email: "you@company.com", + spendCents: 4212, + includedSpendCents: 8000, + overallSpendCents: 12212, + fastPremiumRequests: 143, + ) + let session = session( + provider: ScriptedSpendProvider(member: member), + email: member.email, + ) + session.refresh() + return session + } + + private static func session(provider: any SpendProvider, email: String?) -> LedgerSession { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("LedgerPreview-\(UUID().uuidString)") + let store = LedgerConfigStore(directory: base) + try? store.save(LedgerConfiguration(teamMemberEmail: email, refreshInterval: 900)) + let services = LedgerServices( + configStore: store, + keychain: InMemoryKeychainStore(secret: "preview-key"), + provider: provider, + loginItem: LoginItemController(), + ) + return LedgerSession(services: services) + } + } +#endif diff --git a/Ledger/Ledger/Sources/SettingsView.swift b/Ledger/Ledger/Sources/SettingsView.swift new file mode 100644 index 00000000..50ffb455 --- /dev/null +++ b/Ledger/Ledger/Sources/SettingsView.swift @@ -0,0 +1,212 @@ +import AppKit +import LedgerCore +import SwiftUI + +/// A pane in the settings sidebar: static identity/metadata plus its own +/// content view, built from the session. Conformers are ordinary `View`s; +/// `SettingsView` discovers their title/icon and builds them without a central +/// enum or switch. +private protocol SettingsPane: View { + /// Sidebar label and navigation title. + static var title: String { get } + /// Sidebar SF Symbol. + static var icon: String { get } + + init(session: LedgerSession) +} + +/// Type-erased sidebar entry: a pane's metadata plus a builder for its content. +/// Keyed by the pane type's `ObjectIdentifier`, so the sidebar selection stays +/// a typed token rather than a stringly value. +private struct SettingsPaneItem: Identifiable { + let id: ObjectIdentifier + let title: String + let icon: String + let makeView: (LedgerSession) -> AnyView + + init(_ pane: (some SettingsPane).Type) { + id = ObjectIdentifier(pane) + title = pane.title + icon = pane.icon + makeView = { AnyView(pane.init(session: $0)) } + } +} + +/// Global settings, laid out like a modern macOS System Settings window: a +/// `NavigationSplitView` sidebar over the registered ``SettingsPaneItem``s. +struct SettingsView: View { + let session: LedgerSession + + private let panes: [SettingsPaneItem] = [ + SettingsPaneItem(GeneralSettingsPane.self), + SettingsPaneItem(AccountSettingsPane.self), + ] + + @State private var selection: ObjectIdentifier? + + var body: some View { + NavigationSplitView(columnVisibility: .constant(.all)) { + List(panes, selection: $selection) { pane in + Label(pane.title, systemImage: pane.icon) + } + .navigationSplitViewColumnWidth(min: 170, ideal: 190, max: 220) + .toolbar(removing: .sidebarToggle) + } detail: { + detail + } + .frame(minWidth: 460, minHeight: 360) + .onAppear { + if selection == nil { selection = panes.first?.id } + } + } + + private var detail: AnyView { + let pane = panes.first { $0.id == selection } ?? panes.first + return pane?.makeView(session) ?? AnyView(EmptyView()) + } +} + +/// General preferences: whether Ledger launches at login. +private struct GeneralSettingsPane: SettingsPane { + static let title = "General" + static let icon = "gearshape" + + @Bindable var session: LedgerSession + + @State private var isWindowVisible = true + + init(session: LedgerSession) { + _session = Bindable(session) + } + + var body: some View { + Form { + Toggle("Launch Ledger at login", isOn: $session.startsAtLogin) + Text( + "Ledger starts automatically when you log in and keeps your Cursor spend in the menu bar.", + ) + .font(.caption) + .foregroundStyle(.secondary) + + if session.loginItemNeedsApproval { + LabeledContent { + Button("Open Login Items") { + session.openSystemSettingsLoginItems() + } + } label: { + Label( + "Approval needed in System Settings", + systemImage: "exclamationmark.triangle.fill", + ) + .foregroundStyle(.orange) + } + } + + if let error = session.loginItemError { + Label(error, systemImage: "xmark.octagon.fill") + .font(.callout) + .foregroundStyle(.red) + } + } + .formStyle(.grouped) + .navigationTitle(Self.title) + .onAppear { session.refreshLoginItemStatus() } + .background(WindowVisibilityReader(isVisible: $isWindowVisible)) + .onChange(of: isWindowVisible) { _, visible in + if visible { session.refreshLoginItemStatus() } + } + } +} + +/// Account settings: the team-member email and the Admin API key (stored in the +/// Keychain). Both commit explicitly on Save so a half-typed value can't leak +/// out by navigating away. +private struct AccountSettingsPane: SettingsPane { + static let title = "Account" + static let icon = "person.crop.circle" + + let session: LedgerSession + + @State private var emailDraft: String = "" + @State private var keyDraft: String = "" + @State private var keyError: String? + + init(session: LedgerSession) { + self.session = session + } + + var body: some View { + Form { + Section("Team member") { + TextField("Email", text: $emailDraft, prompt: Text("you@company.com")) + .textContentType(.username) + Button("Save Email") { saveEmail() } + .disabled(emailDraft.trimmingCharacters(in: .whitespaces) + == (session.settings.teamMemberEmail ?? "")) + Text("Ledger shows the spend for this member of your Cursor team.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Admin API key") { + SecureField("Admin API key", text: $keyDraft, prompt: Text( + session.hasAPIKey ? "•••••••• (stored)" : "Paste your Admin API key", + )) + HStack { + Button(session.hasAPIKey ? "Update Key" : "Save Key") { saveKey() } + .disabled(keyDraft.trimmingCharacters(in: .whitespaces).isEmpty) + if session.hasAPIKey { + Button("Clear Key", role: .destructive) { clearKey() } + } + } + if let keyError { + Label(keyError, systemImage: "xmark.octagon.fill") + .font(.callout) + .foregroundStyle(.red) + } + Text( + "Stored securely in your Keychain. Create a key in the Cursor dashboard (Admin API).", + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .navigationTitle(Self.title) + .onAppear { + emailDraft = session.settings.teamMemberEmail ?? "" + } + } + + private func saveEmail() { + let trimmed = emailDraft.trimmingCharacters(in: .whitespacesAndNewlines) + session.settings.teamMemberEmail = trimmed.isEmpty ? nil : trimmed + session.refresh() + } + + private func saveKey() { + do { + try session.setAPIKey(keyDraft) + keyDraft = "" + keyError = nil + } catch { + keyError = "Couldn't save the key: \(error.localizedDescription)" + } + } + + private func clearKey() { + do { + try session.clearAPIKey() + keyDraft = "" + keyError = nil + } catch { + keyError = "Couldn't clear the key: \(error.localizedDescription)" + } + } +} + +#if DEBUG + #Preview { + SettingsView(session: PreviewSupport.loadedSession()) + } +#endif diff --git a/Ledger/Ledger/Sources/SpendView.swift b/Ledger/Ledger/Sources/SpendView.swift new file mode 100644 index 00000000..4569f63a --- /dev/null +++ b/Ledger/Ledger/Sources/SpendView.swift @@ -0,0 +1,137 @@ +import LedgerCore +import SwiftUI + +/// The menu-bar popover: the current billing cycle's Cursor spend, plus a +/// footer with Refresh, Settings, and Quit. Renders the single +/// ``LedgerServices/LoadState`` — spinner, error, or value — so the three +/// states can never overlap. +struct SpendView: View { + @Bindable var session: LedgerSession + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + Divider() + content + Divider() + footer + } + .padding(16) + .frame(width: 300) + } + + private var header: some View { + HStack { + Text("Cursor Spend") + .font(.headline) + Spacer() + if case .loading = session.loadState { + ProgressView() + .controlSize(.small) + } + } + } + + @ViewBuilder + private var content: some View { + switch session.loadState { + case .idle, .loading: + placeholder + case let .loaded(member): + loaded(member) + case let .failed(error): + failure(error) + } + } + + private var placeholder: some View { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + .frame(height: 60) + } + + private func loaded(_ member: MemberSpend) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("This cycle") + .font(.caption) + .foregroundStyle(.secondary) + Text(CurrencyFormat.full(member.totalDollars)) + .font(.system(size: 34, weight: .semibold, design: .rounded)) + .monospacedDigit() + + VStack(alignment: .leading, spacing: 4) { + if let included = member.includedDollars { + breakdownRow("Included usage", CurrencyFormat.full(included)) + } + breakdownRow("On-demand", CurrencyFormat.full(member.onDemandDollars)) + if let requests = member.fastPremiumRequests { + breakdownRow("Usage-based requests", requests.formatted()) + } + } + .padding(.top, 2) + + if let updated = session.lastUpdated { + Text("Updated \(updated.formatted(date: .omitted, time: .shortened))") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + private func breakdownRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value) + .monospacedDigit() + } + .font(.callout) + } + + private func failure(_ error: LedgerServices.LoadError) -> some View { + VStack(alignment: .leading, spacing: 8) { + Label(error.message, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.secondary) + if error == .missingCredentials || error == .memberNotFound { + SettingsLink { + Text("Open Settings") + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var footer: some View { + HStack { + Button { + session.refresh() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + Spacer() + SettingsLink { + Image(systemName: "gearshape") + } + .help("Settings") + Button { + NSApp.terminate(nil) + } label: { + Image(systemName: "power") + } + .help("Quit Ledger") + } + .buttonStyle(.borderless) + } +} + +#if DEBUG + #Preview { + SpendView(session: PreviewSupport.loadedSession()) + } +#endif diff --git a/Ledger/Ledger/Sources/WindowVisibilityReader.swift b/Ledger/Ledger/Sources/WindowVisibilityReader.swift new file mode 100644 index 00000000..266519c4 --- /dev/null +++ b/Ledger/Ledger/Sources/WindowVisibilityReader.swift @@ -0,0 +1,61 @@ +import AppKit +import SwiftUI + +/// Reports whether the hosting window is actually on screen — visible and +/// not fully occluded — into a binding. Ordered-out windows keep their +/// SwiftUI hierarchy alive (`.task` loops keep running; verified +/// empirically), so views doing periodic work need this signal to pause. +/// +/// Use as a `.background`; the represented view has no size or appearance. +struct WindowVisibilityReader: NSViewRepresentable { + @Binding var isVisible: Bool + + func makeNSView(context _: Context) -> ReaderView { + ReaderView { visible in + if visible != isVisible { + isVisible = visible + } + } + } + + func updateNSView(_: ReaderView, context _: Context) {} + + final class ReaderView: NSView { + private let onChange: (Bool) -> Void + private var observer: NSObjectProtocol? + + init(onChange: @escaping (Bool) -> Void) { + self.onChange = onChange + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("not used") + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let observer { + NotificationCenter.default.removeObserver(observer) + self.observer = nil + } + guard let window else { return } + observer = NotificationCenter.default.addObserver( + forName: NSWindow.didChangeOcclusionStateNotification, + object: window, + queue: .main, + ) { [weak self] notification in + guard let window = notification.object as? NSWindow else { return } + self?.onChange(window.occlusionState.contains(.visible)) + } + onChange(window.occlusionState.contains(.visible)) + } + + deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + } +} diff --git a/Ledger/LedgerCore/Tests/KeychainStoreTests.swift b/Ledger/LedgerCore/Tests/KeychainStoreTests.swift new file mode 100644 index 00000000..7fbca8f0 --- /dev/null +++ b/Ledger/LedgerCore/Tests/KeychainStoreTests.swift @@ -0,0 +1,36 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct KeychainStoreTests { + @Test func readsBackWhatItWrites() throws { + let store = InMemoryKeychainStore() + #expect(try store.read() == nil) + + try store.write("api-key-123") + #expect(try store.read() == "api-key-123") + } + + @Test func writingWhitespaceRemovesTheSecret() throws { + let store = InMemoryKeychainStore(secret: "existing") + try store.write(" ") + #expect(try store.read() == nil) + } + + @Test func writeTrimsSurroundingWhitespace() throws { + let store = InMemoryKeychainStore() + try store.write(" padded-key\n") + #expect(try store.read() == "padded-key") + } + + @Test func removeClearsTheSecret() throws { + let store = InMemoryKeychainStore(secret: "existing") + try store.remove() + #expect(try store.read() == nil) + } + + @Test func surfacesInjectedFailures() { + let store = InMemoryKeychainStore(failure: KeychainError(status: -25300)) + #expect(throws: KeychainError.self) { try store.read() } + } +} diff --git a/Ledger/LedgerCore/Tests/LedgerConfigStoreTests.swift b/Ledger/LedgerCore/Tests/LedgerConfigStoreTests.swift new file mode 100644 index 00000000..b9772b94 --- /dev/null +++ b/Ledger/LedgerCore/Tests/LedgerConfigStoreTests.swift @@ -0,0 +1,40 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct LedgerConfigStoreTests { + /// A store in a unique temp directory that never touches the user's real + /// Application Support. + private func makeStore() -> (store: LedgerConfigStore, directory: URL) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LedgerConfigStoreTests-\(UUID().uuidString)") + return (LedgerConfigStore(directory: directory), directory) + } + + @Test func missingFileLoadsAsInitial() throws { + let (store, _) = makeStore() + #expect(try store.load() == .initial) + } + + @Test func roundTripsAConfiguration() throws { + let (store, directory) = makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + + let configuration = LedgerConfiguration( + teamMemberEmail: "me@company.com", + refreshInterval: 300, + ) + try store.save(configuration) + #expect(try store.load() == configuration) + } + + @Test func corruptFileThrowsRatherThanResetting() throws { + let (store, directory) = makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("not json".utf8).write(to: directory.appendingPathComponent("configuration.json")) + + #expect(throws: (any Error).self) { try store.load() } + } +} diff --git a/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift b/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift new file mode 100644 index 00000000..238cf606 --- /dev/null +++ b/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift @@ -0,0 +1,96 @@ +import Foundation +@_spi(Testing) import LedgerCore + +/// A `LoginItemBackend` that records register/unregister/open calls in memory +/// and can be told to fail, so login-item wiring is testable without touching +/// the real `SMAppService`. +@MainActor +final class LoginItemRecorder: LoginItemBackend { + private(set) var status: LoginItemStatus + private(set) var registerCount = 0 + private(set) var unregisterCount = 0 + private(set) var openCount = 0 + + /// When set, both `register()` and `unregister()` throw it (and leave + /// `status` unchanged), simulating an `SMAppService` failure. + var failure: (any Error)? + + init(status: LoginItemStatus = .notRegistered, failure: (any Error)? = nil) { + self.status = status + self.failure = failure + } + + func register() throws { + registerCount += 1 + if let failure { throw failure } + status = .enabled + } + + func unregister() throws { + unregisterCount += 1 + if let failure { throw failure } + status = .notRegistered + } + + func openSystemSettingsLoginItems() { + openCount += 1 + } +} + +/// A stand-in error for login-item failure injection. +struct LoginItemTestError: Error {} + +enum SpendFixture { + /// Builds a member with the fields tests care about; the rest default. + static func member( + email: String, + spendCents: Double = 0, + includedSpendCents: Double? = nil, + overallSpendCents: Double? = nil, + fastPremiumRequests: Int? = nil, + ) -> MemberSpend { + MemberSpend( + userId: "user_\(email)", + name: email, + email: email, + spendCents: spendCents, + includedSpendCents: includedSpendCents, + overallSpendCents: overallSpendCents, + fastPremiumRequests: fastPremiumRequests, + ) + } + + /// A realistic `/teams/spend` response body, including fields Ledger does + /// not model (they must be ignored, not fail decoding). + static let responseJSON = """ + { + "teamMemberSpend": [ + { + "userId": "user_ABC", + "name": "Alex Admin", + "email": "alex@company.com", + "role": "owner", + "spendCents": 4212.5, + "includedSpendCents": 8000.0, + "overallSpendCents": 12212.5, + "fastPremiumRequests": 143, + "profilePictureUrl": null, + "monthlyLimitDollars": null, + "hardLimitOverrideDollars": 0 + }, + { + "userId": "user_DEF", + "name": "Blair Member", + "email": "blair@company.com", + "role": "member", + "spendCents": 0, + "includedSpendCents": 150.75, + "fastPremiumRequests": 2 + } + ], + "totalMembers": 2, + "totalPages": 1, + "subscriptionCycleStart": 1708992000000 + } + """ +} diff --git a/Ledger/LedgerCore/Tests/LedgerServicesTests.swift b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift new file mode 100644 index 00000000..821a8ce8 --- /dev/null +++ b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift @@ -0,0 +1,101 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +@MainActor +struct LedgerServicesTests { + private func makeServices( + provider: any SpendProvider = ScriptedSpendProvider(.failure(.network("unused"))), + keychainSecret: String? = nil, + email: String? = nil, + ) -> LedgerServices { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LedgerServicesTests-\(UUID().uuidString)") + let store = LedgerConfigStore(directory: directory) + try? store.save(LedgerConfiguration(teamMemberEmail: email, refreshInterval: 900)) + return LedgerServices( + configStore: store, + keychain: InMemoryKeychainStore(secret: keychainSecret), + provider: provider, + loginItem: LoginItemController(backend: LoginItemRecorder()), + ) + } + + @Test func startsIdle() { + let services = makeServices() + #expect(services.loadState == .idle) + } + + @Test func failsWithMissingCredentialsWhenNoEmail() async { + let services = makeServices(keychainSecret: "key", email: nil) + await services.refresh() + #expect(services.loadState == .failed(.missingCredentials)) + } + + @Test func failsWithMissingCredentialsWhenNoKey() async { + let services = makeServices(keychainSecret: nil, email: "me@company.com") + await services.refresh() + #expect(services.loadState == .failed(.missingCredentials)) + } + + @Test func loadsTheMatchingMember() async { + let member = SpendFixture.member(email: "me@company.com", overallSpendCents: 4200) + let services = makeServices( + provider: ScriptedSpendProvider(member: member), + keychainSecret: "key", + email: "ME@company.com", + ) + await services.refresh() + + #expect(services.loadState == .loaded(member)) + #expect(services.lastUpdated != nil) + } + + @Test func failsWithMemberNotFoundWhenNoEmailMatches() async { + let provider = ScriptedSpendProvider(member: SpendFixture + .member(email: "someone@company.com")) + let services = makeServices( + provider: provider, + keychainSecret: "key", + email: "me@company.com", + ) + await services.refresh() + #expect(services.loadState == .failed(.memberNotFound)) + } + + @Test func mapsHTTPErrors() async { + let services = makeServices( + provider: ScriptedSpendProvider(.failure(.http(401))), + keychainSecret: "bad-key", + email: "me@company.com", + ) + await services.refresh() + #expect(services.loadState == .failed(.http(401))) + } + + @Test func mapsNetworkErrors() async { + let services = makeServices( + provider: ScriptedSpendProvider(.failure(.network("offline"))), + keychainSecret: "key", + email: "me@company.com", + ) + await services.refresh() + #expect(services.loadState == .failed(.network("offline"))) + } + + @Test func setAndClearAPIKeyTracksHasAPIKey() throws { + let services = makeServices() + #expect(!services.hasAPIKey) + + try services.setAPIKey("new-key") + #expect(services.hasAPIKey) + + try services.clearAPIKey() + #expect(!services.hasAPIKey) + } + + @Test func the401MessageMentionsTheKey() { + #expect(LedgerServices.LoadError.http(401).message.contains("401")) + #expect(LedgerServices.LoadError.missingCredentials.message.contains("Settings")) + } +} diff --git a/Ledger/LedgerCore/Tests/LoginItemControllerTests.swift b/Ledger/LedgerCore/Tests/LoginItemControllerTests.swift new file mode 100644 index 00000000..b020b9f8 --- /dev/null +++ b/Ledger/LedgerCore/Tests/LoginItemControllerTests.swift @@ -0,0 +1,84 @@ +@_spi(Testing) import LedgerCore +import Testing + +@MainActor +struct LoginItemControllerTests { + @Test func seedsStateFromTheBackend() { + let off = LoginItemController(backend: LoginItemRecorder(status: .notRegistered)) + #expect(!off.isEnabled) + #expect(!off.needsApproval) + + let on = LoginItemController(backend: LoginItemRecorder(status: .enabled)) + #expect(on.isEnabled) + #expect(!on.needsApproval) + } + + @Test func requiresApprovalReadsAsEnabledButPending() { + let controller = LoginItemController(backend: LoginItemRecorder(status: .requiresApproval)) + + // Registered-but-pending is "on" for the toggle — not off — with a + // flag the UI can use to nudge the user toward System Settings. + #expect(controller.isEnabled) + #expect(controller.needsApproval) + } + + @Test func enablingRegistersAndDisablingUnregisters() throws { + let recorder = LoginItemRecorder() + let controller = LoginItemController(backend: recorder) + + try controller.setEnabled(true) + #expect(controller.isEnabled) + #expect(recorder.registerCount == 1) + + // Re-enabling is a no-op: the backend isn't touched again. + try controller.setEnabled(true) + #expect(recorder.registerCount == 1) + + try controller.setEnabled(false) + #expect(!controller.isEnabled) + #expect(recorder.unregisterCount == 1) + } + + @Test func disablingAPendingItemStillUnregisters() throws { + let recorder = LoginItemRecorder(status: .requiresApproval) + let controller = LoginItemController(backend: recorder) + + try controller.setEnabled(false) + #expect(recorder.unregisterCount == 1) + #expect(!controller.isEnabled) + } + + @Test func aFailedRegistrationLeavesTheStateHonestlyOff() { + let recorder = LoginItemRecorder(failure: LoginItemTestError()) + let controller = LoginItemController(backend: recorder) + + #expect(throws: LoginItemTestError.self) { + try controller.setEnabled(true) + } + // The register attempt happened, but it failed — the observed value + // must not read as "on". + #expect(recorder.registerCount == 1) + #expect(!controller.isEnabled) + } + + @Test func refreshPicksUpAnOutOfBandChange() { + let recorder = LoginItemRecorder(status: .notRegistered) + let controller = LoginItemController(backend: recorder) + #expect(!controller.isEnabled) + + // Simulate the user enabling the login item in System Settings. + try? recorder.register() + #expect(!controller.isEnabled) + + controller.refresh() + #expect(controller.isEnabled) + } + + @Test func openSystemSettingsForwardsToTheBackend() { + let recorder = LoginItemRecorder() + let controller = LoginItemController(backend: recorder) + + controller.openSystemSettingsLoginItems() + #expect(recorder.openCount == 1) + } +} diff --git a/Ledger/LedgerCore/Tests/SpendProviderTests.swift b/Ledger/LedgerCore/Tests/SpendProviderTests.swift new file mode 100644 index 00000000..54ece763 --- /dev/null +++ b/Ledger/LedgerCore/Tests/SpendProviderTests.swift @@ -0,0 +1,27 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct SpendProviderTests { + @Test func basicAuthPutsTheKeyInTheUsernameWithAnEmptyPassword() { + // Mirrors `curl -u KEY:` — base64 of "KEY:". + let expected = "Basic " + Data("secret-key:".utf8).base64EncodedString() + #expect(CursorSpendAPI.basicAuthValue(apiKey: "secret-key") == expected) + } + + @Test func scriptedProviderReturnsItsSuccessOutcome() async throws { + let member = SpendFixture.member(email: "a@b.com", overallSpendCents: 500) + let provider = ScriptedSpendProvider(member: member) + + let response = try await provider.fetchSpend(apiKey: "ignored") + #expect(response.teamMemberSpend == [member]) + } + + @Test func scriptedProviderThrowsItsFailureOutcome() async { + let provider = ScriptedSpendProvider(.failure(.http(401))) + + await #expect(throws: SpendProviderError.http(401)) { + try await provider.fetchSpend(apiKey: "ignored") + } + } +} diff --git a/Ledger/LedgerCore/Tests/SpendTests.swift b/Ledger/LedgerCore/Tests/SpendTests.swift new file mode 100644 index 00000000..0a199b1b --- /dev/null +++ b/Ledger/LedgerCore/Tests/SpendTests.swift @@ -0,0 +1,60 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct SpendTests { + @Test func decodesTheDocumentedResponseIgnoringUnknownFields() throws { + let data = Data(SpendFixture.responseJSON.utf8) + let response = try JSONDecoder().decode(SpendResponse.self, from: data) + + #expect(response.teamMemberSpend.count == 2) + #expect(response.subscriptionCycleStart == 1_708_992_000_000) + + let alex = try #require(response.teamMemberSpend.first) + #expect(alex.email == "alex@company.com") + // Sub-cent precision survives (the fields are Double, not Int). + #expect(alex.spendCents == 4212.5) + #expect(alex.includedSpendCents == 8000) + #expect(alex.overallSpendCents == 12212.5) + #expect(alex.fastPremiumRequests == 143) + } + + @Test func matchesMemberByEmailCaseInsensitively() throws { + let data = Data(SpendFixture.responseJSON.utf8) + let response = try JSONDecoder().decode(SpendResponse.self, from: data) + + #expect(response.member(matching: "ALEX@company.com")?.userId == "user_ABC") + #expect(response.member(matching: " blair@company.com ")?.userId == "user_DEF") + #expect(response.member(matching: "nobody@company.com") == nil) + #expect(response.member(matching: "") == nil) + } + + @Test func totalPrefersOverallSpendWhenPresent() { + let member = SpendFixture.member( + email: "a@b.com", + spendCents: 100, + includedSpendCents: 200, + overallSpendCents: 999, + ) + #expect(member.totalCents == 999) + #expect(member.totalDollars == 9.99) + } + + @Test func totalFallsBackToOnDemandPlusIncludedWhenOverallAbsent() { + let member = SpendFixture.member( + email: "a@b.com", + spendCents: 100, + includedSpendCents: 200, + overallSpendCents: nil, + ) + #expect(member.totalCents == 300) + #expect(member.onDemandDollars == 1) + #expect(member.includedDollars == 2) + } + + @Test func totalFallsBackToOnDemandOnlyWhenIncludedAlsoAbsent() { + let member = SpendFixture.member(email: "a@b.com", spendCents: 250) + #expect(member.totalCents == 250) + #expect(member.includedDollars == nil) + } +} diff --git a/Project.swift b/Project.swift index 1fa30eee..087551d2 100644 --- a/Project.swift +++ b/Project.swift @@ -3,6 +3,10 @@ import ProjectDescription let destinations: Destinations = [.iPhone, .iPad] let deployment: DeploymentTargets = .iOS("26.0") +/// The Ledger menu bar app is the only native-macOS target; everything else +/// stays on the shared iOS destinations above. +let macDeployment: DeploymentTargets = .macOS("26.0") + /// Local Swift package (see root `Package.swift`) for the library products /// (StuffCore, WhereCore, WhereUI, TestHostSupport, the Broadway modules, …). private let stuffPackage = Package.local(path: .relativeToRoot(".")) @@ -202,6 +206,55 @@ let project = Project( "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "", ]), ), + .target( + name: "Ledger", + destinations: [.mac], + product: .app, + bundleId: "com.stuff.ledger", + deploymentTargets: macDeployment, + // Full custom plist (not `.extendingDefault`) so the macOS defaults + // can't sneak in a `NSMainStoryboardFile` — Ledger is a pure + // SwiftUI/AppKit menu-bar app. `LSUIElement` keeps it out of the + // Dock and app switcher; it lives in the menu bar only. + infoPlist: .dictionary([ + "CFBundleDevelopmentRegion": .string("en"), + "CFBundleExecutable": .string("$(EXECUTABLE_NAME)"), + "CFBundleIdentifier": .string("$(PRODUCT_BUNDLE_IDENTIFIER)"), + "CFBundleInfoDictionaryVersion": .string("6.0"), + "CFBundleName": .string("$(PRODUCT_NAME)"), + "CFBundlePackageType": .string("APPL"), + "CFBundleShortVersionString": .string("1.0"), + "CFBundleVersion": .string("1"), + "LSMinimumSystemVersion": .string("$(MACOSX_DEPLOYMENT_TARGET)"), + "LSUIElement": .boolean(true), + "NSPrincipalClass": .string("NSApplication"), + ]), + sources: ["Ledger/Ledger/Sources/**"], + dependencies: [ + .package(product: "LedgerCore"), + ], + // Ledger ships no asset catalog (menu-bar icon is an SF Symbol), so + // clear the asset-catalog name settings the compiler otherwise + // looks for. + settings: .settings(base: [ + "ASSETCATALOG_COMPILER_APPICON_NAME": "", + "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "", + ]), + ), + .target( + name: "LedgerCoreTests", + // Hostless macOS unit tests — the `unitTests` helper above is + // iOS-only (it hosts bundles in StuffTestHost), so this target is + // declared directly. + destinations: [.mac], + product: .unitTests, + bundleId: "com.stuff.ledgercore.tests", + deploymentTargets: macDeployment, + sources: ["Ledger/LedgerCore/Tests/**"], + dependencies: [ + .package(product: "LedgerCore"), + ], + ), .target( name: "WhereTests", destinations: destinations, @@ -420,6 +473,22 @@ let project = Project( buildAction: .buildAction(targets: ["RegionViewer"]), runAction: .runAction(executable: "RegionViewer"), ), + .scheme( + name: "Ledger", + shared: true, + buildAction: .buildAction(targets: ["Ledger"]), + runAction: .runAction(executable: "Ledger"), + ), + // The workspace mixes iOS targets and the macOS-only Ledger targets, so + // CI drives two platform-scoped schemes — no single xcodebuild + // destination can build both. The macOS-only Ledger scheme runs in its + // own `test-macos` CI job (see .github/workflows/ci.yml). + .scheme( + name: "Ledger-macOS-Tests", + shared: true, + buildAction: .buildAction(targets: ["Ledger", "LedgerCoreTests"]), + testAction: .targets(["LedgerCoreTests"]), + ), // CI scheme. Rather than the autogenerated `Stuff-Workspace` scheme, // CI drives this explicit aggregate of every buildable/testable target // (see .github/workflows/ci.yml). @@ -469,6 +538,7 @@ let project = Project( "BroadwayCatalogTests", ]), ), + testScheme(name: "LedgerCoreTests"), testScheme(name: "StuffCoreTests"), testScheme(name: "LifecycleKitTests"), testScheme(name: "LogKitTests"), From 509cb8ff3324aeea5337ce22d048d7cddcba4c87 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 17 Jul 2026 15:16:13 -0700 Subject: [PATCH 03/34] Document Ledger: per-module docs and root AGENTS.md updates Adds README.md + AGENTS.md for Ledger/Ledger and Ledger/LedgerCore, and restores the native-macOS references in the root AGENTS.md (deployment row, the two-platform CI-scheme note, directory-layout mention, and the macOS test command). CLAUDE.md files regenerated via ./sync-agents (gitignored). --- AGENTS.md | 11 ++++--- Ledger/Ledger/AGENTS.md | 50 ++++++++++++++++++++++++++++ Ledger/Ledger/README.md | 56 +++++++++++++++++++++++++++++++ Ledger/LedgerCore/AGENTS.md | 66 +++++++++++++++++++++++++++++++++++++ Ledger/LedgerCore/README.md | 61 ++++++++++++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 Ledger/Ledger/AGENTS.md create mode 100644 Ledger/Ledger/README.md create mode 100644 Ledger/LedgerCore/AGENTS.md create mode 100644 Ledger/LedgerCore/README.md diff --git a/AGENTS.md b/AGENTS.md index 81f4772f..b90de90e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,8 +63,8 @@ by `./sync-agents`. bundles, read [`Package.swift`](Package.swift) and [`Project.swift`](Project.swift); each module's own `README.md` / `AGENTS.md` says what it is and how it may be used. -- Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). -- **CI scheme**: CI runs the explicit shared **Stuff-iOS-Tests** scheme (all test bundles) rather than the autogenerated `Stuff-Workspace` scheme. New test bundles must be added to the `Stuff-iOS-Tests` scheme in `Project.swift` or CI won't run them. +- Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper; native-macOS test bundles are declared directly, like `LedgerCoreTests`, since that helper hosts iOS bundles in StuffTestHost). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). +- **CI schemes**: the workspace mixes iOS targets and the native-macOS **Ledger** targets, which no single xcodebuild destination can build, so CI runs two explicit shared schemes — **Stuff-iOS-Tests** (all iOS bundles) and **Ledger-macOS-Tests** (the Ledger app + `LedgerCoreTests`). New iOS test bundles must be added to the `Stuff-iOS-Tests` scheme in `Project.swift` (and new macOS ones to `Ledger-macOS-Tests`) or CI won't run them. - **Never double-link a package product into a test bundle that already gets it through a dynamic-framework dependency.** Xcode's default SPM integration (how `Package.local` is wired here) embeds a product's code into *every* image that links it. **WhereUI** is a dynamic framework that statically embeds its own dependencies (WhereCore, BroadwayCore/BroadwayUI, LifecycleKit, LogViewerUI, SwiftDataInspector, …), so a `*Tests` bundle that depends on **WhereUI** *and* re-lists one of those in `extraPackageProducts` ends up with a **second copy** of it. When the shared **StuffTestHost** loads several `.xctest` bundles into one process, that leaves duplicate **type metadata** for the module, and any *type-keyed runtime lookup that crosses the WhereUI boundary* — SwiftUI `EnvironmentKey`s, `UITraitBridgedEnvironmentKey` bridging, the type-keyed `BTraits`/`BThemes`/`BStylesheets` containers — silently resolves against the wrong copy and returns the default (the writer stores under one copy's key *type*, the reader looks it up under another's). It only reproduces in the **full multi-bundle scheme** (not isolated `tuist test WhereUITests` runs) and is papered over by newer Xcode linkers, so it is brutal to diagnose (it cost ~2 hours once). **Depend on such products only transitively via `WhereUI`; keep them out of `extraPackageProducts`.** `WhereStylesheetTests.resolvesTraitAwareTokensFromTheBroadwayRoot` is the regression guard — it exercises a `\.stylesheet` (→ `\.bContext`) read across the boundary and fails if a duplicate copy returns. ## Deployment @@ -72,12 +72,13 @@ by `./sync-agents`. | Platform | Minimum OS | |------------------------------|-------------| | iPhone, iPad, Mac Catalyst | iOS 26.0 | +| Mac (native, Ledger) | macOS 26.0 | ## Directory layout Every module — shared ones under `Shared/`, feature ones under a top-level -folder per feature (e.g. `Where/`) — follows this skeleton, and a new module -must too: +folder per feature (e.g. `Where/`, `Ledger/`) — follows this skeleton, and a +new module must too: ``` Shared// @@ -464,4 +465,6 @@ mise install ./swiftformat --lint mise exec -- tuist test Stuff-iOS-Tests --no-selective-testing -- \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' +mise exec -- tuist test Ledger-macOS-Tests --no-selective-testing -- \ + -destination 'platform=macOS' ``` diff --git a/Ledger/Ledger/AGENTS.md b/Ledger/Ledger/AGENTS.md new file mode 100644 index 00000000..6897cd93 --- /dev/null +++ b/Ledger/Ledger/AGENTS.md @@ -0,0 +1,50 @@ +# Ledger – Module Shape + +Ledger is the native-macOS menu bar app that displays your current-cycle Cursor +spend. It is a thin SwiftUI/AppKit shell over [`LedgerCore`](../LedgerCore), +which does all the fetching and modeling; see [`README.md`](README.md) for the +narrative. + +This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns the +build system, formatting, and global conventions. Read that first. + +## Scope & dependencies + +- Depends only on **LedgerCore** (plus SwiftUI/AppKit). It's the app target in + [`Project.swift`](../../Project.swift) (`.mac` destination, `com.stuff.ledger`, + `LSUIElement`), paired with the hostless `LedgerCoreTests` bundle in the same + file and driven by the `Ledger` / `Ledger-macOS-Tests` schemes. +- No behavior lives here — persistence, networking, and domain rules are all in + LedgerCore. This target renders `LoadState` and routes intents. + +## Architecture + +- `LedgerApp` + `AppDelegate` — the `NSStatusItem` + `NSPopover` shell. The + status title mirrors `LedgerSession.statusTitle` through an `Observations` + loop (deliberately AppKit, not `MenuBarExtra`). The SwiftUI `Settings` scene + hosts `SettingsView`. +- `LedgerSession` — the thin `@Observable` facade over `LedgerServices`: views + read its mirrored state and call its intent methods (`refresh`, `setAPIKey`, + …); it owns the Core root. +- `SpendView` — the popover; renders the single `LoadState`. +- `SettingsView` — a System-Settings-style sidebar (General + Account panes). +- `CurrencyFormat` — the one place spend is formatted as USD. + +## Invariants + +- **The menu-bar title is driven by observation, not polling.** Keep the + `Observations({ session.statusTitle })` loop as the update path; don't add a + timer that reads the title. +- **Credentials commit explicitly.** The Account pane's email and API-key + fields save on an explicit button, and the key goes straight to the Keychain + via `session.setAPIKey` — never mirror it into `@AppStorage` or the config + JSON. +- **No `Binding(get:set:)`.** Bind to the observable session/settings; use + local `@State` drafts for the explicit-commit fields. + +## Testing + +The app target has no test bundle of its own — logic is tested in +`LedgerCoreTests`. `PreviewSupport` (DEBUG) builds sessions from +`ScriptedSpendProvider` + `InMemoryKeychainStore`, so previews never hit the +network or Keychain. diff --git a/Ledger/Ledger/README.md b/Ledger/Ledger/README.md new file mode 100644 index 00000000..428e8dcc --- /dev/null +++ b/Ledger/Ledger/README.md @@ -0,0 +1,56 @@ +# Ledger + +A macOS **menu bar app** that shows your current-cycle Cursor spend at a +glance. The status item displays the cycle-to-date dollar amount; clicking it +opens a popover with the breakdown (included usage, on-demand, usage-based +requests) and a Refresh button. + +All behavior lives in [`LedgerCore`](../LedgerCore); this target is the thin +SwiftUI/AppKit shell. + +## Running + +```bash +./ide --no-open # regenerate the Xcode project +mise exec -- tuist build Ledger +``` + +or run the `Ledger` scheme from Xcode. The app lives in the menu bar (a +dollar-sign icon with the amount beside it once loaded) and shows no Dock icon +(`LSUIElement`). It keeps running until you quit it from the popover. + +## Setup + +Open **Settings** (the gear in the popover, or Cmd-,) and fill in the +**Account** pane: + +- **Team member email** — the member of your Cursor team whose spend to show. +- **Admin API key** — created in the Cursor dashboard (Admin API). It's stored + in your **Keychain**, never on disk in plaintext. + +The **General** pane has *Launch Ledger at login* (via `SMAppService`); if +macOS needs you to approve the login item, the pane links straight to System +Settings. + +## What it shows + +- **Menu-bar title** — the current billing cycle's total spend, updated + automatically (every 15 minutes) and whenever you open the popover. +- **Popover** — the cycle total, an included-usage / on-demand breakdown, the + usage-based request count, and when it last updated. On an error (missing + credentials, unknown email, a rejected key, a network failure) it explains + what to fix and offers a shortcut to Settings. + +## Design notes + +- The status item and popover are **AppKit** (`NSStatusItem` + `NSPopover`), + not `MenuBarExtra`: the menu-bar title mirrors observable model state via an + `Observations` loop, which the AppKit path drives reliably. (The old Foreman + menu-bar app landed on the same pattern for the same reason.) +- **Only the current cycle** is shown — the Cursor Admin API exposes no + per-user historical spend, and Ledger keeps no local history. + +## Limitations + +- Requires a Cursor **Admin API key**, which is a team/enterprise capability. +- The Admin API returns spend for the **current billing cycle only**. diff --git a/Ledger/LedgerCore/AGENTS.md b/Ledger/LedgerCore/AGENTS.md new file mode 100644 index 00000000..56acfe7b --- /dev/null +++ b/Ledger/LedgerCore/AGENTS.md @@ -0,0 +1,66 @@ +# LedgerCore – Module Shape + +LedgerCore is the model layer for the Ledger menu bar app: a tree of +`@MainActor @Observable` objects rooted in `LedgerServices` that fetches the +current-cycle Cursor spend from the Admin API and reduces it to the signed-in +member. The SwiftUI/AppKit layer lives in the app target +([`Ledger/Ledger`](../Ledger)) and binds the tree directly; see +[`README.md`](README.md) for the narrative and per-type detail. + +``` +LedgerServices ── LedgerSettings (email, interval) + ├────────── KeychainStore (Admin API key) + ├────────── SpendProvider ── CursorSpendAPI (POST /teams/spend) + ├────────── LedgerConfigStore (LedgerConfiguration JSON) + └────────── LoginItemController (SMAppService) +``` + +This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns the +build system, formatting, and global conventions. Read that first. + +## Scope & dependencies + +- **Foundation + Observation + Security + ServiceManagement + LogKit only.** No + SwiftUI, no AppKit UI — views and the thin session facade belong to the app + target. LedgerCore is the repo's only macOS-only package library + (`.macOS(.v26)` in [`Package.swift`](../../Package.swift)). +- The hostless macOS test bundle `LedgerCoreTests` is declared directly in + [`Project.swift`](../../Project.swift) and runs via the `Ledger-macOS-Tests` + scheme. + +## Invariants + +- **One `LoadState`, never a mix.** Success, failure, loading, and "not loaded + yet" are the four cases of `LedgerServices.LoadState` — the UI reads exactly + one. Don't reintroduce parallel `isLoading` / `error` / `value` fields. +- **The API key never touches disk-in-plaintext.** It lives only in the + Keychain (`KeychainStore`); `LedgerConfiguration` persists the email and + refresh interval and nothing else. Never add the key to the JSON. +- **Filter to the member client-side, by email, case-insensitively.** The + request sends `{}` (the whole team) and `SpendResponse.member(matching:)` + selects the row — a `searchTerm` on the wire could hide a renamed account. + No match is `LoadError.memberNotFound`, not an empty success. +- **`totalCents` prefers `overallSpendCents`,** falling back to `spendCents + + includedSpendCents` only when the API omits it — so the headline figure is + never silently short. Cent fields are `Double` (sub-cent precision), not Int. +- **Failures are observable, never swallowed.** Transport/HTTP/decode failures + become a typed `SpendProviderError`, mapped into `LoadState.failed(LoadError)` + and logged; a stale response (an earlier fetch finishing after a newer one) + is dropped via the request generation counter, not applied. +- **The login item is OS-owned, not persisted config.** `LoginItemController` + reads/writes `SMAppService.mainApp` (behind an `@_spi(Testing)` backend) as a + typed `LoginItemStatus`; `requiresApproval` counts as *on*. `startsAtLogin` + is a live read, and its setter surfaces failures on `loginItemError` while + keeping the observed value honest. +- **Absence vs failure.** A missing config file is `.initial`; a file that + exists but can't be decoded throws rather than silently resetting. + +## Testing + +Swift Testing in [`Tests/`](Tests), hostless on macOS (`tuist test +LedgerCoreTests -- -destination 'platform=macOS'`). Shared fixtures live in +[`LedgerCoreTestSupport.swift`](Tests/LedgerCoreTestSupport.swift). The network +and Keychain seams use the module's `@_spi(Testing)` DEBUG doubles +(`ScriptedSpendProvider`, `InMemoryKeychainStore`); filesystem tests use unique +temp directories and never touch the user's real Application Support or +Keychain. diff --git a/Ledger/LedgerCore/README.md b/Ledger/LedgerCore/README.md new file mode 100644 index 00000000..0c752861 --- /dev/null +++ b/Ledger/LedgerCore/README.md @@ -0,0 +1,61 @@ +# LedgerCore + +The model layer for the **Ledger** menu bar app: it fetches your current +Cursor billing-cycle spend from the Cursor **Admin API** and reduces it to the +single team member you're signed in as. The SwiftUI/AppKit shell lives in the +[`Ledger`](../Ledger) app target and binds this tree directly. + +## What it does + +- Calls `POST https://api.cursor.com/teams/spend` (HTTP Basic auth, the Admin + API key as the username and an empty password — the same shape as `curl -u + YOUR_API_KEY:`). +- Filters the team response down to the member whose email you configured + (case-insensitively). +- Exposes one observable `LoadState` (`idle` / `loading` / `loaded(MemberSpend)` + / `failed(LoadError)`) so the UI can never show a half-loaded mix of value, + spinner, and error. + +Only the **current billing cycle** is available from the API — there is no +per-user historical endpoint, and LedgerCore keeps no local history. + +## Public API + +- `LedgerServices` — the `@MainActor @Observable` root. Owns the settings, + Keychain, spend provider, and login item. Key surface: `loadState`, + `lastUpdated`, `hasAPIKey`, `settings`, `startsAtLogin`, `refresh()`, + `setAPIKey(_:)` / `clearAPIKey()`, `start()` / `stop()`. +- `LedgerSettings` — the observable settings node (`teamMemberEmail`, + `refreshInterval`); mutations funnel to the tree, which persists them. +- `LedgerConfiguration` / `LedgerConfigStore` — the persisted JSON + (`~/Library/Application Support/com.stuff.ledger/configuration.json`). The + email and refresh interval only — **never** the API key. +- `KeychainStore` (protocol) + `SystemKeychainStore` — the Admin API key lives + in the login Keychain, not the JSON. +- `SpendProvider` (protocol) + `CursorSpendAPI` — the network seam. +- `Spend` types — `SpendResponse` / `MemberSpend`, with a `totalCents` that + prefers the API's `overallSpendCents` and falls back to on-demand + included. +- `LoginItemController` — launch-at-login via `SMAppService`. +- `LedgerLog` — the LogKit logging facade (subsystem `com.stuff.ledger`). + +## How spend is computed + +`MemberSpend.totalCents` prefers `overallSpendCents` when the API returns it, +otherwise sums `spendCents` (on-demand overage) and `includedSpendCents` +(allowance usage). Cent fields are `Double`: the Admin API added sub-cent +precision on 2026-06-04 so results reconcile with invoices. + +## Credentials + +Create an Admin API key in the Cursor dashboard. It's stored in the Keychain +(`SystemKeychainStore`); tests and previews swap in `InMemoryKeychainStore`. +Ledger isn't sandboxed, so it reaches the login Keychain without a +keychain-access-group entitlement. + +## Testing + +Swift Testing in [`Tests/`](Tests), hostless on macOS (`tuist test +LedgerCoreTests -- -destination 'platform=macOS'`). Network and Keychain are +behind protocol seams, so `ScriptedSpendProvider` and `InMemoryKeychainStore` +(both `@_spi(Testing)`, DEBUG-only) drive the suites without real HTTP or +Keychain access. From ec89b31f046ab8214ad3f074004f9288514c6b03 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 17 Jul 2026 16:43:14 -0700 Subject: [PATCH 04/34] Switch Ledger to the individual dashboard API (session token) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Admin API (/teams/spend) only sees team accounts, so an individual account can't use it. Re-point the data layer at the same undocumented endpoints the cursor.com dashboard itself calls: - GET /api/usage-summary — current cycle dates, plan, and live usage-based spend (individualUsage.onDemand.used). - POST /api/dashboard/get-monthly-invoice — per-month billed totals, summed across the year for a year-to-date figure (the original yearly ask, now feasible). Auth is the WorkOS session cookie, value "::". SessionToken derives the userId from the JWT sub (a bare JWT 401s). CursorLocalTokenSource auto-detects it read-only from the local Cursor app's state.vscdb; a pasted token in the Keychain overrides it. Drops the email + Admin API key UI. Verified against a live account (usage-summary + invoices) and via Ledger-macOS-Tests (26 tests); swiftformat --lint clean. --- Ledger/Ledger/AGENTS.md | 14 +- Ledger/Ledger/README.md | 45 ++-- Ledger/Ledger/Sources/CurrencyFormat.swift | 9 +- Ledger/Ledger/Sources/LedgerSession.swift | 44 ++-- Ledger/Ledger/Sources/PreviewSupport.swift | 47 ++-- Ledger/Ledger/Sources/SettingsView.swift | 85 ++++--- Ledger/Ledger/Sources/SpendView.swift | 59 +++-- Ledger/LedgerCore/AGENTS.md | 80 ++++--- Ledger/LedgerCore/README.md | 94 ++++---- .../Sources/DashboardProvider.swift | 122 ++++++++++ Ledger/LedgerCore/Sources/KeychainStore.swift | 10 +- .../Sources/LedgerConfigStore.swift | 20 +- .../LedgerCore/Sources/LedgerServices.swift | 221 ++++++++++-------- .../LedgerCore/Sources/LedgerSettings.swift | 20 +- .../LedgerCore/Sources/MonthlyInvoice.swift | 30 +++ Ledger/LedgerCore/Sources/SessionToken.swift | 64 +++++ .../Sources/SessionTokenSource.swift | 84 +++++++ Ledger/LedgerCore/Sources/Spend.swift | 96 -------- Ledger/LedgerCore/Sources/SpendProvider.swift | 83 ------- Ledger/LedgerCore/Sources/SpendSnapshot.swift | 55 +++++ Ledger/LedgerCore/Sources/TestDoubles.swift | 77 +++++- Ledger/LedgerCore/Sources/UsageSummary.swift | 86 +++++++ .../Tests/DashboardProviderTests.swift | 28 +++ .../Tests/LedgerConfigStoreTests.swift | 5 +- .../Tests/LedgerCoreTestSupport.swift | 95 ++++---- .../Tests/LedgerServicesTests.swift | 119 ++++++---- .../Tests/MonthlyInvoiceTests.swift | 23 ++ .../Tests/SessionTokenSourceTests.swift | 52 +++++ .../LedgerCore/Tests/SessionTokenTests.swift | 31 +++ .../LedgerCore/Tests/SpendProviderTests.swift | 27 --- Ledger/LedgerCore/Tests/SpendTests.swift | 60 ----- .../LedgerCore/Tests/UsageSummaryTests.swift | 41 ++++ 32 files changed, 1202 insertions(+), 724 deletions(-) create mode 100644 Ledger/LedgerCore/Sources/DashboardProvider.swift create mode 100644 Ledger/LedgerCore/Sources/MonthlyInvoice.swift create mode 100644 Ledger/LedgerCore/Sources/SessionToken.swift create mode 100644 Ledger/LedgerCore/Sources/SessionTokenSource.swift delete mode 100644 Ledger/LedgerCore/Sources/Spend.swift delete mode 100644 Ledger/LedgerCore/Sources/SpendProvider.swift create mode 100644 Ledger/LedgerCore/Sources/SpendSnapshot.swift create mode 100644 Ledger/LedgerCore/Sources/UsageSummary.swift create mode 100644 Ledger/LedgerCore/Tests/DashboardProviderTests.swift create mode 100644 Ledger/LedgerCore/Tests/MonthlyInvoiceTests.swift create mode 100644 Ledger/LedgerCore/Tests/SessionTokenSourceTests.swift create mode 100644 Ledger/LedgerCore/Tests/SessionTokenTests.swift delete mode 100644 Ledger/LedgerCore/Tests/SpendProviderTests.swift delete mode 100644 Ledger/LedgerCore/Tests/SpendTests.swift create mode 100644 Ledger/LedgerCore/Tests/UsageSummaryTests.swift diff --git a/Ledger/Ledger/AGENTS.md b/Ledger/Ledger/AGENTS.md index 6897cd93..417f850c 100644 --- a/Ledger/Ledger/AGENTS.md +++ b/Ledger/Ledger/AGENTS.md @@ -26,8 +26,10 @@ build system, formatting, and global conventions. Read that first. - `LedgerSession` — the thin `@Observable` facade over `LedgerServices`: views read its mirrored state and call its intent methods (`refresh`, `setAPIKey`, …); it owns the Core root. -- `SpendView` — the popover; renders the single `LoadState`. -- `SettingsView` — a System-Settings-style sidebar (General + Account panes). +- `SpendView` — the popover; renders the single `LoadState` (this cycle + + year-to-date). +- `SettingsView` — a System-Settings-style sidebar (General + Account panes); + Account shows the auto-detect status and an optional pasted-token override. - `CurrencyFormat` — the one place spend is formatted as USD. ## Invariants @@ -35,10 +37,10 @@ build system, formatting, and global conventions. Read that first. - **The menu-bar title is driven by observation, not polling.** Keep the `Observations({ session.statusTitle })` loop as the update path; don't add a timer that reads the title. -- **Credentials commit explicitly.** The Account pane's email and API-key - fields save on an explicit button, and the key goes straight to the Keychain - via `session.setAPIKey` — never mirror it into `@AppStorage` or the config - JSON. +- **Auth is mostly zero-config.** Ledger auto-detects the Cursor session; the + Account pane's token field is an *optional override* that commits on an + explicit button and goes straight to the Keychain via `session.setManualToken` + — never mirror it into `@AppStorage` or the config JSON. - **No `Binding(get:set:)`.** Bind to the observable session/settings; use local `@State` drafts for the explicit-commit fields. diff --git a/Ledger/Ledger/README.md b/Ledger/Ledger/README.md index 428e8dcc..48f73bdc 100644 --- a/Ledger/Ledger/README.md +++ b/Ledger/Ledger/README.md @@ -2,8 +2,8 @@ A macOS **menu bar app** that shows your current-cycle Cursor spend at a glance. The status item displays the cycle-to-date dollar amount; clicking it -opens a popover with the breakdown (included usage, on-demand, usage-based -requests) and a Refresh button. +opens a popover with this cycle's spend, a year-to-date total, the included-usage +breakdown, and your plan. All behavior lives in [`LedgerCore`](../LedgerCore); this target is the thin SwiftUI/AppKit shell. @@ -21,36 +21,39 @@ dollar-sign icon with the amount beside it once loaded) and shows no Dock icon ## Setup -Open **Settings** (the gear in the popover, or Cmd-,) and fill in the -**Account** pane: +If you're signed in to the Cursor app, **there's nothing to configure** — Ledger +auto-detects your session from Cursor's local state. The Account pane shows +"Using your signed-in Cursor session". -- **Team member email** — the member of your Cursor team whose spend to show. -- **Admin API key** — created in the Cursor dashboard (Admin API). It's stored - in your **Keychain**, never on disk in plaintext. +If auto-detect can't find a session (or it expired), paste a token in +**Settings › Account**: on `cursor.com`, open DevTools › Application › Cookies, +copy `WorkosCursorSessionToken`, and paste it. It's stored in your Keychain and +overrides auto-detect until you clear it. -The **General** pane has *Launch Ledger at login* (via `SMAppService`); if -macOS needs you to approve the login item, the pane links straight to System -Settings. +The **General** pane has *Launch Ledger at login* (via `SMAppService`), with a +shortcut to System Settings if macOS needs you to approve the login item. ## What it shows -- **Menu-bar title** — the current billing cycle's total spend, updated +- **Menu-bar title** — the current billing cycle's usage-based spend, refreshed automatically (every 15 minutes) and whenever you open the popover. -- **Popover** — the cycle total, an included-usage / on-demand breakdown, the - usage-based request count, and when it last updated. On an error (missing - credentials, unknown email, a rejected key, a network failure) it explains - what to fix and offers a shortcut to Settings. +- **Popover** — this cycle's spend and date range, a **year-to-date** total, + included-usage used/limit, your plan tier, and when it last updated. On an + error (no session, an expired session, a network failure) it explains what to + fix and offers a shortcut to Settings. ## Design notes -- The status item and popover are **AppKit** (`NSStatusItem` + `NSPopover`), - not `MenuBarExtra`: the menu-bar title mirrors observable model state via an +- The status item and popover are **AppKit** (`NSStatusItem` + `NSPopover`), not + `MenuBarExtra`: the menu-bar title mirrors observable model state via an `Observations` loop, which the AppKit path drives reliably. (The old Foreman menu-bar app landed on the same pattern for the same reason.) -- **Only the current cycle** is shown — the Cursor Admin API exposes no - per-user historical spend, and Ledger keeps no local history. +- Data comes from Cursor's **undocumented dashboard API** (the same endpoints + the website calls), so it can change without notice. ## Limitations -- Requires a Cursor **Admin API key**, which is a team/enterprise capability. -- The Admin API returns spend for the **current billing cycle only**. +- Reuses your Cursor **web session**; when it expires you re-open Cursor (or + paste a fresh token). +- On a plan with usage-based pricing off, the `$` figures reflect the value of + included compute, not money owed. diff --git a/Ledger/Ledger/Sources/CurrencyFormat.swift b/Ledger/Ledger/Sources/CurrencyFormat.swift index c0f88479..01809aad 100644 --- a/Ledger/Ledger/Sources/CurrencyFormat.swift +++ b/Ledger/Ledger/Sources/CurrencyFormat.swift @@ -4,14 +4,7 @@ import Foundation /// popover render dollars identically. enum CurrencyFormat { /// A full currency string, e.g. `$1,234.56`. - static func full(_ dollars: Double) -> String { + static func dollars(_ dollars: Double) -> String { dollars.formatted(.currency(code: "USD")) } - - /// The menu-bar title form — the same currency string; kept as its own - /// entry point so the status-item presentation can diverge later without - /// touching call sites. - static func compact(_ dollars: Double) -> String { - full(dollars) - } } diff --git a/Ledger/Ledger/Sources/LedgerSession.swift b/Ledger/Ledger/Sources/LedgerSession.swift index db88b8df..c5d990bc 100644 --- a/Ledger/Ledger/Sources/LedgerSession.swift +++ b/Ledger/Ledger/Sources/LedgerSession.swift @@ -5,7 +5,7 @@ import Observation /// Thin app-side facade over the LedgerCore model tree. Views read the /// observable ``LedgerServices`` state (and bind its settings) directly; this /// class owns the root and forwards the few app-level intents (launch, refresh, -/// quit teardown, credential edits). +/// quit teardown, token edits). @MainActor @Observable final class LedgerSession { @@ -21,13 +21,17 @@ final class LedgerSession { services.lastUpdated } - /// Whether an Admin API key is stored (drives the Settings key field). - var hasAPIKey: Bool { - services.hasAPIKey + /// Whether a token was pasted (a manual override) vs. auto-detected. + var hasManualToken: Bool { + services.hasManualToken } - /// Settings; `SettingsView` binds these observable properties (persistence - /// happens in Core). + /// Whether a token can be auto-detected from the local Cursor app. + var autoTokenAvailable: Bool { + services.autoTokenAvailable + } + + /// Settings; `SettingsView` binds these observable properties. var settings: LedgerSettings { services.settings } @@ -38,23 +42,20 @@ final class LedgerSession { set { services.startsAtLogin = newValue } } - /// The login item is registered but awaiting approval in System Settings. var loginItemNeedsApproval: Bool { services.loginItemNeedsApproval } - /// The most recent login-item failure (shown in the General settings pane). var loginItemError: String? { services.loginItemError } /// The status-bar title: the current-cycle dollar amount when loaded, a - /// placeholder otherwise. Observed by the app delegate to keep the menu bar - /// current. + /// placeholder otherwise. Observed by the app delegate. var statusTitle: String { switch services.loadState { - case let .loaded(member): - CurrencyFormat.compact(member.totalDollars) + case let .loaded(snapshot): + CurrencyFormat.dollars(snapshot.currentCycleDollars) case .idle, .loading, .failed: "—" } @@ -68,38 +69,35 @@ final class LedgerSession { self.init(services: LedgerServices()) } - /// First fetch and periodic refresh. Called once at app launch. func start() { services.start() } - /// Stops the periodic refresh; the app's quit path. func stop() { services.stop() } - /// Fetches spend now (popover open, manual Refresh, after a credential edit). + /// Fetches spend now (popover open, manual Refresh, after a token edit). func refresh() { Task { await services.refresh() } } - /// Stores (or clears, for an empty string) the Admin API key, then refreshes. - func setAPIKey(_ key: String) throws { - try services.setAPIKey(key) + /// Stores (or clears, for an empty string) a pasted session token, then refreshes. + func setManualToken(_ token: String) throws { + try services.setManualToken(token) refresh() } - /// Removes the stored Admin API key. - func clearAPIKey() throws { - try services.clearAPIKey() + /// Removes any pasted token (falls back to auto-detection), then refreshes. + func clearManualToken() throws { + try services.clearManualToken() + refresh() } - /// Re-reads the login-item status from the OS. func refreshLoginItemStatus() { services.refreshLoginItemStatus() } - /// Opens System Settings › General › Login Items (to approve a pending item). func openSystemSettingsLoginItems() { services.openSystemSettingsLoginItems() } diff --git a/Ledger/Ledger/Sources/PreviewSupport.swift b/Ledger/Ledger/Sources/PreviewSupport.swift index 0608e262..adefeaa1 100644 --- a/Ledger/Ledger/Sources/PreviewSupport.swift +++ b/Ledger/Ledger/Sources/PreviewSupport.swift @@ -2,38 +2,49 @@ import Foundation @_spi(Testing) import LedgerCore - /// Preview fixtures. Sessions are backed by an in-memory Keychain and a - /// scripted spend provider — they never touch the real Keychain, the - /// network, or `~/Library/Application Support`. + /// Preview fixtures. Sessions are backed by a stub token source and a + /// scripted dashboard provider — they never read the real Cursor state, + /// touch the network, or write Application Support. @MainActor enum PreviewSupport { - /// A session already showing a member's spend. + /// A session already showing spend. static func loadedSession() -> LedgerSession { - let member = MemberSpend( - userId: "user_preview", - name: "Preview User", - email: "you@company.com", - spendCents: 4212, - includedSpendCents: 8000, - overallSpendCents: 12212, - fastPremiumRequests: 143, - ) + let provider = ScriptedDashboardProvider(.success( + summary: .fixture( + onDemandCents: 315_609, + membershipType: "ultra", + includedUsed: 40000, + includedLimit: 40000, + ), + invoiceCentsByMonth: [ + 1: 120_000, + 2: 98000, + 3: 210_000, + 4: 175_000, + 5: 260_000, + 6: 288_000, + ], + )) let session = session( - provider: ScriptedSpendProvider(member: member), - email: member.email, + provider: provider, + autoToken: SessionToken(cookieValue: "user_preview::jwt"), ) session.refresh() return session } - private static func session(provider: any SpendProvider, email: String?) -> LedgerSession { + private static func session( + provider: any DashboardProvider, + autoToken: SessionToken?, + ) -> LedgerSession { let base = FileManager.default.temporaryDirectory .appendingPathComponent("LedgerPreview-\(UUID().uuidString)") let store = LedgerConfigStore(directory: base) - try? store.save(LedgerConfiguration(teamMemberEmail: email, refreshInterval: 900)) + try? store.save(LedgerConfiguration(refreshInterval: 900)) let services = LedgerServices( configStore: store, - keychain: InMemoryKeychainStore(secret: "preview-key"), + keychain: InMemoryKeychainStore(), + tokenSource: StubTokenSource(token: autoToken), provider: provider, loginItem: LoginItemController(), ) diff --git a/Ledger/Ledger/Sources/SettingsView.swift b/Ledger/Ledger/Sources/SettingsView.swift index 50ffb455..d0d16696 100644 --- a/Ledger/Ledger/Sources/SettingsView.swift +++ b/Ledger/Ledger/Sources/SettingsView.swift @@ -118,18 +118,17 @@ private struct GeneralSettingsPane: SettingsPane { } } -/// Account settings: the team-member email and the Admin API key (stored in the -/// Keychain). Both commit explicitly on Save so a half-typed value can't leak -/// out by navigating away. +/// Account settings: how Ledger authenticates to the Cursor dashboard. It +/// auto-detects the session from your local Cursor app; a pasted token is an +/// optional override for when that isn't available (or has expired). private struct AccountSettingsPane: SettingsPane { static let title = "Account" static let icon = "person.crop.circle" let session: LedgerSession - @State private var emailDraft: String = "" - @State private var keyDraft: String = "" - @State private var keyError: String? + @State private var tokenDraft: String = "" + @State private var tokenError: String? init(session: LedgerSession) { self.session = session @@ -137,35 +136,42 @@ private struct AccountSettingsPane: SettingsPane { var body: some View { Form { - Section("Team member") { - TextField("Email", text: $emailDraft, prompt: Text("you@company.com")) - .textContentType(.username) - Button("Save Email") { saveEmail() } - .disabled(emailDraft.trimmingCharacters(in: .whitespaces) - == (session.settings.teamMemberEmail ?? "")) - Text("Ledger shows the spend for this member of your Cursor team.") - .font(.caption) - .foregroundStyle(.secondary) + Section("Cursor session") { + if session.hasManualToken { + Label("Using a pasted session token", systemImage: "key.fill") + .foregroundStyle(.secondary) + } else if session.autoTokenAvailable { + Label("Using your signed-in Cursor session", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + } else { + Label( + "No Cursor session found — sign in to Cursor, or paste a token below", + systemImage: "exclamationmark.triangle.fill", + ) + .foregroundStyle(.orange) + } } - Section("Admin API key") { - SecureField("Admin API key", text: $keyDraft, prompt: Text( - session.hasAPIKey ? "•••••••• (stored)" : "Paste your Admin API key", + Section("Session token (optional)") { + SecureField("Session token", text: $tokenDraft, prompt: Text( + session.hasManualToken ? "•••••••• (stored)" : "Paste WorkosCursorSessionToken", )) HStack { - Button(session.hasAPIKey ? "Update Key" : "Save Key") { saveKey() } - .disabled(keyDraft.trimmingCharacters(in: .whitespaces).isEmpty) - if session.hasAPIKey { - Button("Clear Key", role: .destructive) { clearKey() } + Button(session.hasManualToken ? "Update Token" : "Save Token") { saveToken() } + .disabled(tokenDraft.trimmingCharacters(in: .whitespaces).isEmpty) + if session.hasManualToken { + Button("Clear Token", role: .destructive) { clearToken() } } } - if let keyError { - Label(keyError, systemImage: "xmark.octagon.fill") + if let tokenError { + Label(tokenError, systemImage: "xmark.octagon.fill") .font(.callout) .foregroundStyle(.red) } Text( - "Stored securely in your Keychain. Create a key in the Cursor dashboard (Admin API).", + "Only needed if auto-detect fails. Stored securely in your Keychain. Copy the " + + + "WorkosCursorSessionToken cookie from cursor.com (DevTools › Application › Cookies).", ) .font(.caption) .foregroundStyle(.secondary) @@ -173,34 +179,25 @@ private struct AccountSettingsPane: SettingsPane { } .formStyle(.grouped) .navigationTitle(Self.title) - .onAppear { - emailDraft = session.settings.teamMemberEmail ?? "" - } - } - - private func saveEmail() { - let trimmed = emailDraft.trimmingCharacters(in: .whitespacesAndNewlines) - session.settings.teamMemberEmail = trimmed.isEmpty ? nil : trimmed - session.refresh() } - private func saveKey() { + private func saveToken() { do { - try session.setAPIKey(keyDraft) - keyDraft = "" - keyError = nil + try session.setManualToken(tokenDraft) + tokenDraft = "" + tokenError = nil } catch { - keyError = "Couldn't save the key: \(error.localizedDescription)" + tokenError = "Couldn't save the token: \(error.localizedDescription)" } } - private func clearKey() { + private func clearToken() { do { - try session.clearAPIKey() - keyDraft = "" - keyError = nil + try session.clearManualToken() + tokenDraft = "" + tokenError = nil } catch { - keyError = "Couldn't clear the key: \(error.localizedDescription)" + tokenError = "Couldn't clear the token: \(error.localizedDescription)" } } } diff --git a/Ledger/Ledger/Sources/SpendView.swift b/Ledger/Ledger/Sources/SpendView.swift index 4569f63a..07fbefb3 100644 --- a/Ledger/Ledger/Sources/SpendView.swift +++ b/Ledger/Ledger/Sources/SpendView.swift @@ -1,10 +1,10 @@ import LedgerCore import SwiftUI -/// The menu-bar popover: the current billing cycle's Cursor spend, plus a -/// footer with Refresh, Settings, and Quit. Renders the single -/// ``LedgerServices/LoadState`` — spinner, error, or value — so the three -/// states can never overlap. +/// The menu-bar popover: the current billing cycle's Cursor spend and the +/// year-to-date total, plus a footer with Refresh, Settings, and Quit. Renders +/// the single ``LedgerServices/LoadState`` — spinner, error, or value — so the +/// three states can never overlap. struct SpendView: View { @Bindable var session: LedgerSession @@ -37,8 +37,8 @@ struct SpendView: View { switch session.loadState { case .idle, .loading: placeholder - case let .loaded(member): - loaded(member) + case let .loaded(snapshot): + loaded(snapshot) case let .failed(error): failure(error) } @@ -54,25 +54,34 @@ struct SpendView: View { .frame(height: 60) } - private func loaded(_ member: MemberSpend) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text("This cycle") - .font(.caption) - .foregroundStyle(.secondary) - Text(CurrencyFormat.full(member.totalDollars)) - .font(.system(size: 34, weight: .semibold, design: .rounded)) - .monospacedDigit() + private func loaded(_ snapshot: SpendSnapshot) -> some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text("This cycle") + .font(.caption) + .foregroundStyle(.secondary) + Text(CurrencyFormat.dollars(snapshot.currentCycleDollars)) + .font(.system(size: 34, weight: .semibold, design: .rounded)) + .monospacedDigit() + if let range = cycleRange(snapshot) { + Text(range) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } VStack(alignment: .leading, spacing: 4) { - if let included = member.includedDollars { - breakdownRow("Included usage", CurrencyFormat.full(included)) - } - breakdownRow("On-demand", CurrencyFormat.full(member.onDemandDollars)) - if let requests = member.fastPremiumRequests { - breakdownRow("Usage-based requests", requests.formatted()) + breakdownRow("This year", CurrencyFormat.dollars(snapshot.yearToDateDollars)) + if let used = snapshot.includedUsedDollars, + let limit = snapshot.includedLimitDollars + { + breakdownRow( + "Included usage", + "\(CurrencyFormat.dollars(used)) / \(CurrencyFormat.dollars(limit))", + ) } + breakdownRow("Plan", snapshot.membershipType.capitalized) } - .padding(.top, 2) if let updated = session.lastUpdated { Text("Updated \(updated.formatted(date: .omitted, time: .shortened))") @@ -93,12 +102,18 @@ struct SpendView: View { .font(.callout) } + private func cycleRange(_ snapshot: SpendSnapshot) -> String? { + guard let start = snapshot.cycleStart, let end = snapshot.cycleEnd else { return nil } + let formatter = Date.FormatStyle.dateTime.month(.abbreviated).day() + return "\(start.formatted(formatter)) – \(end.formatted(formatter))" + } + private func failure(_ error: LedgerServices.LoadError) -> some View { VStack(alignment: .leading, spacing: 8) { Label(error.message, systemImage: "exclamationmark.triangle.fill") .font(.callout) .foregroundStyle(.secondary) - if error == .missingCredentials || error == .memberNotFound { + if error == .missingCredentials || error == .notAuthenticated { SettingsLink { Text("Open Settings") } diff --git a/Ledger/LedgerCore/AGENTS.md b/Ledger/LedgerCore/AGENTS.md index 56acfe7b..f3dfe1ff 100644 --- a/Ledger/LedgerCore/AGENTS.md +++ b/Ledger/LedgerCore/AGENTS.md @@ -2,16 +2,17 @@ LedgerCore is the model layer for the Ledger menu bar app: a tree of `@MainActor @Observable` objects rooted in `LedgerServices` that fetches the -current-cycle Cursor spend from the Admin API and reduces it to the signed-in -member. The SwiftUI/AppKit layer lives in the app target -([`Ledger/Ledger`](../Ledger)) and binds the tree directly; see -[`README.md`](README.md) for the narrative and per-type detail. +current-cycle Cursor spend (and a year-to-date total) from Cursor's +undocumented dashboard API and reduces it to one observable `LoadState`. The +SwiftUI/AppKit layer lives in the app target ([`Ledger/Ledger`](../Ledger)) and +binds the tree directly; see [`README.md`](README.md) for the narrative and +per-type detail. ``` -LedgerServices ── LedgerSettings (email, interval) - ├────────── KeychainStore (Admin API key) - ├────────── SpendProvider ── CursorSpendAPI (POST /teams/spend) - ├────────── LedgerConfigStore (LedgerConfiguration JSON) +LedgerServices ── LedgerSettings (refresh interval) + ├────────── SessionTokenSource ── CursorLocalTokenSource (state.vscdb, read-only) + ├────────── KeychainStore (a pasted token override) + ├────────── DashboardProvider ── CursorDashboardAPI (/api/usage-summary, get-monthly-invoice) └────────── LoginItemController (SMAppService) ``` @@ -20,9 +21,9 @@ build system, formatting, and global conventions. Read that first. ## Scope & dependencies -- **Foundation + Observation + Security + ServiceManagement + LogKit only.** No - SwiftUI, no AppKit UI — views and the thin session facade belong to the app - target. LedgerCore is the repo's only macOS-only package library +- **Foundation + Observation + Security + ServiceManagement + SQLite3 + LogKit + only.** No SwiftUI, no AppKit UI — views and the thin session facade belong to + the app target. LedgerCore is the repo's only macOS-only package library (`.macOS(.v26)` in [`Package.swift`](../../Package.swift)). - The hostless macOS test bundle `LedgerCoreTests` is declared directly in [`Project.swift`](../../Project.swift) and runs via the `Ledger-macOS-Tests` @@ -32,35 +33,40 @@ build system, formatting, and global conventions. Read that first. - **One `LoadState`, never a mix.** Success, failure, loading, and "not loaded yet" are the four cases of `LedgerServices.LoadState` — the UI reads exactly - one. Don't reintroduce parallel `isLoading` / `error` / `value` fields. -- **The API key never touches disk-in-plaintext.** It lives only in the - Keychain (`KeychainStore`); `LedgerConfiguration` persists the email and - refresh interval and nothing else. Never add the key to the JSON. -- **Filter to the member client-side, by email, case-insensitively.** The - request sends `{}` (the whole team) and `SpendResponse.member(matching:)` - selects the row — a `searchTerm` on the wire could hide a renamed account. - No match is `LoadError.memberNotFound`, not an empty success. -- **`totalCents` prefers `overallSpendCents`,** falling back to `spendCents + - includedSpendCents` only when the API omits it — so the headline figure is - never silently short. Cent fields are `Double` (sub-cent precision), not Int. + one. +- **Auth is a session cookie, not an API key.** The cookie value must be + `"::"`; `SessionToken` derives the `userId` from the JWT `sub` + when given a bare JWT (as the local Cursor app stores it). A raw JWT alone is + a guaranteed 401 — never send it un-prefixed. +- **Token precedence: pasted overrides auto.** A Keychain token wins; otherwise + the local Cursor session (`CursorLocalTokenSource`) is used. No token at all + is `LoadError.missingCredentials`. +- **Read Cursor's `state.vscdb` read-only.** Open with `SQLITE_OPEN_READONLY` + and never write/lock it — Cursor may hold it open. Any failure (missing file, + missing key, locked) degrades to "no auto-token" (`nil`), not a throw. +- **`onDemand.used` is "this cycle"; year-to-date = prior-month invoices + the + live cycle figure.** The current month's invoice lags until charges post, so + the live usage-summary number stands in for it — don't double-count by also + summing the current month's (sparse) invoice. - **Failures are observable, never swallowed.** Transport/HTTP/decode failures - become a typed `SpendProviderError`, mapped into `LoadState.failed(LoadError)` - and logged; a stale response (an earlier fetch finishing after a newer one) - is dropped via the request generation counter, not applied. -- **The login item is OS-owned, not persisted config.** `LoginItemController` - reads/writes `SMAppService.mainApp` (behind an `@_spi(Testing)` backend) as a - typed `LoginItemStatus`; `requiresApproval` counts as *on*. `startsAtLogin` - is a live read, and its setter surfaces failures on `loginItemError` while - keeping the observed value honest. -- **Absence vs failure.** A missing config file is `.initial`; a file that - exists but can't be decoded throws rather than silently resetting. + become a typed `DashboardError`, mapped into `LoadState.failed(LoadError)` and + logged; 401 maps to `.notAuthenticated` (expired session). A stale response + (an earlier fetch finishing after a newer one) is dropped via the request + generation counter. +- **The login item is OS-owned, not persisted config** (see + `LoginItemController`); `startsAtLogin` is a live read whose setter keeps the + observed value honest and surfaces failures on `loginItemError`. +- **No secrets in JSON.** `LedgerConfiguration` persists only the refresh + interval; a pasted token lives in the Keychain, the auto-token in Cursor's own + store. ## Testing Swift Testing in [`Tests/`](Tests), hostless on macOS (`tuist test LedgerCoreTests -- -destination 'platform=macOS'`). Shared fixtures live in -[`LedgerCoreTestSupport.swift`](Tests/LedgerCoreTestSupport.swift). The network -and Keychain seams use the module's `@_spi(Testing)` DEBUG doubles -(`ScriptedSpendProvider`, `InMemoryKeychainStore`); filesystem tests use unique -temp directories and never touch the user's real Application Support or -Keychain. +[`LedgerCoreTestSupport.swift`](Tests/LedgerCoreTestSupport.swift). The network, +token-source, and Keychain seams use the module's `@_spi(Testing)` DEBUG doubles +(`ScriptedDashboardProvider`, `StubTokenSource`, `InMemoryKeychainStore`); the +`CursorLocalTokenSource` suite builds a throwaway SQLite file, and other +filesystem tests use unique temp directories — never the user's real state, +Application Support, or Keychain. diff --git a/Ledger/LedgerCore/README.md b/Ledger/LedgerCore/README.md index 0c752861..d10beb53 100644 --- a/Ledger/LedgerCore/README.md +++ b/Ledger/LedgerCore/README.md @@ -1,61 +1,71 @@ # LedgerCore The model layer for the **Ledger** menu bar app: it fetches your current -Cursor billing-cycle spend from the Cursor **Admin API** and reduces it to the -single team member you're signed in as. The SwiftUI/AppKit shell lives in the -[`Ledger`](../Ledger) app target and binds this tree directly. +Cursor billing-cycle spend (and a year-to-date total) from the same +undocumented dashboard endpoints the `cursor.com/dashboard/usage` page uses, +authenticated with your Cursor **session token**. The SwiftUI/AppKit shell +lives in the [`Ledger`](../Ledger) app target and binds this tree directly. ## What it does -- Calls `POST https://api.cursor.com/teams/spend` (HTTP Basic auth, the Admin - API key as the username and an empty password — the same shape as `curl -u - YOUR_API_KEY:`). -- Filters the team response down to the member whose email you configured - (case-insensitively). -- Exposes one observable `LoadState` (`idle` / `loading` / `loaded(MemberSpend)` - / `failed(LoadError)`) so the UI can never show a half-loaded mix of value, - spinner, and error. +- Resolves a session token — **auto-detected** from your local Cursor app + (`state.vscdb` → `cursorAuth/accessToken`), or a value you **paste** into + Settings (stored in the Keychain, which overrides auto-detect). +- Calls `GET /api/usage-summary` for the current cycle's dates, plan type, and + live usage-based spend, and `POST /api/dashboard/get-monthly-invoice` for each + prior month of the year. +- Reduces it all to one observable `LoadState` (`idle` / `loading` / + `loaded(SpendSnapshot)` / `failed(LoadError)`). -Only the **current billing cycle** is available from the API — there is no -per-user historical endpoint, and LedgerCore keeps no local history. +Works for **individual** accounts (no team/Admin API needed). + +## Authentication + +The dashboard uses WorkOS session cookies. The cookie value must be +`"::"`; the Cursor app stores only the raw JWT, so +`SessionToken(rawToken:)` derives the `userId` from the JWT's `sub` claim +(`auth0|user_ABC` → `user_ABC`) and builds the cookie. A bare JWT is rejected +by the API (HTTP 401) — hence the prefix. + +`CursorLocalTokenSource` reads Cursor's `state.vscdb` (a SQLite key-value store) +**read-only**. Nothing is written or locked; a missing file/key is simply "no +auto-token", surfaced as `LoadError.missingCredentials`. ## Public API -- `LedgerServices` — the `@MainActor @Observable` root. Owns the settings, - Keychain, spend provider, and login item. Key surface: `loadState`, - `lastUpdated`, `hasAPIKey`, `settings`, `startsAtLogin`, `refresh()`, - `setAPIKey(_:)` / `clearAPIKey()`, `start()` / `stop()`. -- `LedgerSettings` — the observable settings node (`teamMemberEmail`, - `refreshInterval`); mutations funnel to the tree, which persists them. -- `LedgerConfiguration` / `LedgerConfigStore` — the persisted JSON - (`~/Library/Application Support/com.stuff.ledger/configuration.json`). The - email and refresh interval only — **never** the API key. -- `KeychainStore` (protocol) + `SystemKeychainStore` — the Admin API key lives - in the login Keychain, not the JSON. -- `SpendProvider` (protocol) + `CursorSpendAPI` — the network seam. -- `Spend` types — `SpendResponse` / `MemberSpend`, with a `totalCents` that - prefers the API's `overallSpendCents` and falls back to on-demand + included. +- `LedgerServices` — the `@MainActor @Observable` root: `loadState`, + `lastUpdated`, `hasManualToken`, `autoTokenAvailable`, `settings`, + `startsAtLogin`, `refresh()`, `setManualToken(_:)` / `clearManualToken()`, + `start()` / `stop()`. +- `SessionToken` / `SessionTokenSource` / `CursorLocalTokenSource` — the auth + seam. +- `DashboardProvider` + `CursorDashboardAPI` — the network seam. +- `UsageSummary`, `MonthlyInvoice`, `SpendSnapshot` — the wire + view models + (cents are integers). +- `KeychainStore` / `SystemKeychainStore` — a pasted token's storage. +- `LedgerSettings` / `LedgerConfiguration` / `LedgerConfigStore` — the persisted + refresh interval (no secrets). - `LoginItemController` — launch-at-login via `SMAppService`. - `LedgerLog` — the LogKit logging facade (subsystem `com.stuff.ledger`). -## How spend is computed - -`MemberSpend.totalCents` prefers `overallSpendCents` when the API returns it, -otherwise sums `spendCents` (on-demand overage) and `includedSpendCents` -(allowance usage). Cent fields are `Double`: the Admin API added sub-cent -precision on 2026-06-04 so results reconcile with invoices. +## How the figures are computed -## Credentials +- **This cycle** = `usage-summary` → `individualUsage.onDemand.used` (cents), + the live usage-based spend. +- **This year** = the sum of the prior months' `get-monthly-invoice` totals plus + the current cycle's live figure (the current month's invoice lags until + charges post, so the live number stands in for it). -Create an Admin API key in the Cursor dashboard. It's stored in the Keychain -(`SystemKeychainStore`); tests and previews swap in `InMemoryKeychainStore`. -Ledger isn't sandboxed, so it reaches the login Keychain without a -keychain-access-group entitlement. +All money is cents. Note: on a plan with usage-based pricing off, these `$` +figures reflect included-compute value, not money owed. ## Testing Swift Testing in [`Tests/`](Tests), hostless on macOS (`tuist test -LedgerCoreTests -- -destination 'platform=macOS'`). Network and Keychain are -behind protocol seams, so `ScriptedSpendProvider` and `InMemoryKeychainStore` -(both `@_spi(Testing)`, DEBUG-only) drive the suites without real HTTP or -Keychain access. +LedgerCoreTests -- -destination 'platform=macOS'`). Shared fixtures live in +[`LedgerCoreTestSupport.swift`](Tests/LedgerCoreTestSupport.swift). The network, +token source, and Keychain are behind protocol seams, so `ScriptedDashboardProvider`, +`StubTokenSource`, and `InMemoryKeychainStore` (all `@_spi(Testing)`, DEBUG-only) +drive the suites without real HTTP, `state.vscdb`, or Keychain access. The +`CursorLocalTokenSource` suite builds a throwaway SQLite file to exercise the +real reader. diff --git a/Ledger/LedgerCore/Sources/DashboardProvider.swift b/Ledger/LedgerCore/Sources/DashboardProvider.swift new file mode 100644 index 00000000..b052f462 --- /dev/null +++ b/Ledger/LedgerCore/Sources/DashboardProvider.swift @@ -0,0 +1,122 @@ +import Foundation + +/// A failure talking to the Cursor dashboard API. Kept transport-shaped; +/// ``LedgerServices`` maps it into its user-facing ``LedgerServices/LoadError``. +public enum DashboardError: Error, Equatable, Sendable { + /// HTTP 401 — the session token is missing, malformed, or expired. + case notAuthenticated + /// Another non-2xx HTTP response, carrying the status code. + case http(Int) + /// The transport failed (offline, DNS, TLS, timeout). + case network(String) + /// A 2xx response whose body didn't decode. + case decode(String) +} + +/// The seam between ``LedgerServices`` and Cursor's dashboard API. Production +/// uses ``CursorDashboardAPI``; tests conform ``ScriptedDashboardProvider``. +public protocol DashboardProvider: Sendable { + /// The current billing cycle's usage summary. + func usageSummary(token: SessionToken) async throws -> UsageSummary + /// The itemized invoice for a given month (`month` is 1-based) and year. + func monthlyInvoice(month: Int, year: Int, token: SessionToken) async throws -> MonthlyInvoice +} + +/// The production ``DashboardProvider``: the same undocumented endpoints the +/// `cursor.com/dashboard/usage` page calls, authenticated with the +/// `WorkosCursorSessionToken` cookie. +public struct CursorDashboardAPI: DashboardProvider { + private let baseURL: URL + private let session: URLSession + + /// The dashboard's origin. + public static let defaultBaseURL = URL(string: "https://cursor.com")! + + private static let logger = LedgerLog.channel(.spendAPI) + + public init(baseURL: URL = CursorDashboardAPI.defaultBaseURL, session: URLSession = .shared) { + self.baseURL = baseURL + self.session = session + } + + public func usageSummary(token: SessionToken) async throws -> UsageSummary { + let request = makeRequest( + path: "/api/usage-summary", + method: "GET", + token: token, + body: nil, + ) + return try await send(request, decoding: UsageSummary.self) + } + + public func monthlyInvoice( + month: Int, + year: Int, + token: SessionToken, + ) async throws -> MonthlyInvoice { + let body = try JSONSerialization.data(withJSONObject: [ + "month": month, + "year": year, + "includeUsageEvents": false, + ]) + let request = makeRequest( + path: "/api/dashboard/get-monthly-invoice", + method: "POST", + token: token, + body: body, + ) + return try await send(request, decoding: MonthlyInvoice.self) + } + + private func makeRequest( + path: String, + method: String, + token: SessionToken, + body: Data?, + ) -> URLRequest { + var request = URLRequest(url: baseURL.appendingPathComponent(path)) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue("https://cursor.com", forHTTPHeaderField: "Origin") + request.setValue("https://cursor.com/dashboard?tab=usage", forHTTPHeaderField: "Referer") + request.setValue( + "WorkosCursorSessionToken=\(token.cookieValue)", + forHTTPHeaderField: "Cookie", + ) + request.httpBody = body + return request + } + + private func send( + _ request: URLRequest, + decoding _: Response.Type, + ) async throws -> Response { + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + Self.logger.error("Dashboard request transport failed: \(error.localizedDescription)") + throw DashboardError.network(error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw DashboardError.network("Non-HTTP response") + } + if http.statusCode == 401 { + throw DashboardError.notAuthenticated + } + guard (200 ..< 300).contains(http.statusCode) else { + Self.logger.error("Dashboard request returned HTTP \(http.statusCode)") + throw DashboardError.http(http.statusCode) + } + + do { + return try JSONDecoder().decode(Response.self, from: data) + } catch { + Self.logger.error("Dashboard response failed to decode: \(error.localizedDescription)") + throw DashboardError.decode(error.localizedDescription) + } + } +} diff --git a/Ledger/LedgerCore/Sources/KeychainStore.swift b/Ledger/LedgerCore/Sources/KeychainStore.swift index cadfe89a..d2c7c2e9 100644 --- a/Ledger/LedgerCore/Sources/KeychainStore.swift +++ b/Ledger/LedgerCore/Sources/KeychainStore.swift @@ -15,9 +15,9 @@ public struct KeychainError: LocalizedError, Equatable, Sendable { } } -/// Stores a single secret string (Ledger's Admin API key) securely. The seam -/// is a protocol so tests use an in-memory fake — the real Keychain isn't -/// available in a hostless test process without a signed, entitled host. +/// Stores a single secret string (a pasted Cursor session token) securely. +/// The seam is a protocol so tests use an in-memory fake — the real Keychain +/// isn't available in a hostless test process without a signed, entitled host. public protocol KeychainStore: Sendable { /// The stored secret, or `nil` when nothing is stored. Throws /// ``KeychainError`` on an unexpected Keychain failure (a missing item is @@ -39,8 +39,8 @@ public struct SystemKeychainStore: KeychainStore { private let account: String /// Defaults to the app's bundle-style service and a fixed account name; - /// there is only ever one secret (the Admin API key). - public init(service: String = "com.stuff.ledger", account: String = "admin-api-key") { + /// there is only ever one secret (a pasted session token). + public init(service: String = "com.stuff.ledger", account: String = "session-token") { self.service = service self.account = account } diff --git a/Ledger/LedgerCore/Sources/LedgerConfigStore.swift b/Ledger/LedgerCore/Sources/LedgerConfigStore.swift index e0a1a6d9..4834616f 100644 --- a/Ledger/LedgerCore/Sources/LedgerConfigStore.swift +++ b/Ledger/LedgerCore/Sources/LedgerConfigStore.swift @@ -1,25 +1,19 @@ import Foundation -/// Everything Ledger persists as JSON: the team-member email and the refresh -/// interval. The Admin API key is deliberately absent — it lives in the -/// Keychain, never in this plaintext file. +/// Everything Ledger persists as JSON: just the refresh interval. Identity +/// comes from the Cursor session token (auto-detected or pasted), and any +/// pasted token lives in the Keychain — never in this plaintext file. public struct LedgerConfiguration: Codable, Equatable, Sendable { - /// The team member whose spend to display; `nil` until the user sets it. - public var teamMemberEmail: String? /// Seconds between automatic refreshes. public var refreshInterval: TimeInterval - public init(teamMemberEmail: String?, refreshInterval: TimeInterval) { - self.teamMemberEmail = teamMemberEmail + public init(refreshInterval: TimeInterval) { self.refreshInterval = refreshInterval } - /// The configuration a fresh install starts from: no email yet, refreshing - /// every 15 minutes. - public static let initial = LedgerConfiguration( - teamMemberEmail: nil, - refreshInterval: 15 * 60, - ) + /// The configuration a fresh install starts from: refreshing every 15 + /// minutes. + public static let initial = LedgerConfiguration(refreshInterval: 15 * 60) } /// Loads and saves the ``LedgerConfiguration`` JSON file. diff --git a/Ledger/LedgerCore/Sources/LedgerServices.swift b/Ledger/LedgerCore/Sources/LedgerServices.swift index 91477043..507ba193 100644 --- a/Ledger/LedgerCore/Sources/LedgerServices.swift +++ b/Ledger/LedgerCore/Sources/LedgerServices.swift @@ -1,35 +1,31 @@ import Foundation import Observation -/// The root of the Ledger model tree. Owns the settings node, the Keychain -/// wrapper (Admin API key), the spend provider (network), and the login-item -/// controller, and exposes a single observable ``LoadState`` the UI renders. -/// -/// `configuration` is the *retained backing store* that ``settings`` writes -/// through and persists — the tree never rebuilds it from scratch. +/// The root of the Ledger model tree. Resolves a Cursor session token +/// (auto-detected from the local Cursor app, or pasted and kept in the +/// Keychain), fetches the current cycle's usage summary and the year's monthly +/// invoices from the dashboard API, and exposes a single observable +/// ``LoadState`` the UI renders. @MainActor @Observable public final class LedgerServices { /// The spend-load state machine: exactly one of these holds at a time, so /// the UI can't see a half-loaded mix of value + error + spinner. public enum LoadState: Sendable, Equatable { - /// Nothing loaded yet (before the first fetch). case idle - /// A fetch is in flight. case loading - /// A fetch succeeded and produced the signed-in member's spend. - case loaded(MemberSpend) - /// A fetch failed; carries the reason for the UI to explain. + case loaded(SpendSnapshot) case failed(LoadError) } /// Why a spend fetch couldn't produce a value. public enum LoadError: Sendable, Equatable { - /// No Admin API key or no team-member email is configured yet. + /// No session token could be found (Cursor signed out / not installed, + /// and nothing pasted). case missingCredentials - /// The API responded, but no member matched the configured email. - case memberNotFound - /// A non-2xx HTTP response (401 usually means a bad key). + /// HTTP 401 — the token is expired or malformed. + case notAuthenticated + /// Another non-2xx HTTP response. case http(Int) /// The transport failed (offline, DNS, TLS, timeout). case network(String) @@ -40,17 +36,15 @@ public final class LedgerServices { public var message: String { switch self { case .missingCredentials: - "Add your Admin API key and email in Settings." - case .memberNotFound: - "No team member matches that email. Check it in Settings." - case .http(401): - "That Admin API key was rejected (HTTP 401). Check it in Settings." + "Sign in to the Cursor app, or paste a session token in Settings." + case .notAuthenticated: + "Your Cursor session expired. Reopen Cursor (or paste a fresh token in Settings)." case let .http(status): - "The spend request failed (HTTP \(status))." + "The dashboard request failed (HTTP \(status))." case let .network(reason): "Couldn't reach Cursor: \(reason)" case let .decode(reason): - "Couldn't read the spend response: \(reason)" + "Couldn't read the dashboard response: \(reason)" } } } @@ -61,25 +55,21 @@ public final class LedgerServices { /// When the last successful fetch completed, for the "updated …" caption. public private(set) var lastUpdated: Date? - /// Whether an Admin API key is stored. Mirrored from the Keychain so the - /// UI can reflect it without a (throwing) Keychain read on every access. - public private(set) var hasAPIKey: Bool = false + /// Whether a token was pasted into the Keychain (a manual override). + public private(set) var hasManualToken: Bool = false + + /// Whether a token can be auto-detected from the local Cursor app. + public private(set) var autoTokenAvailable: Bool = false /// Settings; created from the loaded configuration. - /// - /// `unowned` is safe: `settings` is reached only through a live - /// `LedgerServices`, and its callback fires from a direct property write. @ObservationIgnored public private(set) lazy var settings: LedgerSettings = .init( - teamMemberEmail: configuration.teamMemberEmail, refreshInterval: configuration.refreshInterval, onPersistentChange: { [unowned self] in settingsDidChange() }, ) /// Whether Ledger is registered to launch at login. Backed by - /// `SMAppService` (the OS owns the real state). The setter registers or - /// unregisters and, on failure, logs *and* surfaces ``loginItemError`` - /// while leaving the observed value honest. + /// `SMAppService` (the OS owns the real state). public var startsAtLogin: Bool { get { loginItem.isEnabled } set { @@ -100,8 +90,7 @@ public final class LedgerServices { loginItem.needsApproval } - /// The most recent login-item failure, surfaced in Settings. Cleared by - /// the next successful toggle. + /// The most recent login-item failure, surfaced in Settings. public private(set) var loginItemError: String? private static let logger = LedgerLog.channel(.services) @@ -109,51 +98,54 @@ public final class LedgerServices { @ObservationIgnored private var configuration: LedgerConfiguration @ObservationIgnored private let configStore: LedgerConfigStore @ObservationIgnored private let keychain: any KeychainStore - @ObservationIgnored private let provider: any SpendProvider + @ObservationIgnored private let tokenSource: any SessionTokenSource + @ObservationIgnored private let provider: any DashboardProvider @ObservationIgnored private let loginItem: LoginItemController - /// The auto-refresh loop; cancelled by ``stop()``. + @ObservationIgnored private let calendar: Calendar + @ObservationIgnored private let now: @Sendable () -> Date @ObservationIgnored private var refreshLoop: Task? - /// Increments per fetch so a slow earlier response can't clobber a newer - /// one (manual refresh racing the timer). + /// Increments per fetch so a slow earlier response can't clobber a newer one. @ObservationIgnored private var requestGeneration = 0 public convenience init() { self.init( configStore: .applicationSupport(), keychain: SystemKeychainStore(), - provider: CursorSpendAPI(), + tokenSource: CursorLocalTokenSource(), + provider: CursorDashboardAPI(), loginItem: LoginItemController(), ) } - /// Loads the configuration eagerly so `settings` never sees pre-load - /// defaults. A file that exists but can't be read keeps the in-memory - /// defaults without overwriting it — nothing saves until the user changes - /// something deliberately. @_spi(Testing) public init( configStore: LedgerConfigStore, keychain: any KeychainStore, - provider: any SpendProvider, + tokenSource: any SessionTokenSource, + provider: any DashboardProvider, loginItem: LoginItemController, + calendar: Calendar = .current, + now: @escaping @Sendable () -> Date = { Date() }, ) { self.configStore = configStore self.keychain = keychain + self.tokenSource = tokenSource self.provider = provider self.loginItem = loginItem + self.calendar = calendar + self.now = now do { configuration = try configStore.load() } catch { Self.logger.error("Couldn't load configuration: \(error)") configuration = .initial } - refreshHasAPIKey() + refreshTokenAvailability() } // MARK: - Lifecycle - /// Kicks off the first fetch and the periodic refresh loop. Call once at - /// app launch. + /// Kicks off the first fetch and the periodic refresh loop. public func start() { guard refreshLoop == nil else { return } refreshLoop = Task { [weak self] in @@ -173,24 +165,12 @@ public final class LedgerServices { // MARK: - Spend - /// Fetches the current-cycle spend and reduces it to the configured - /// member. Updates ``loadState`` (and ``lastUpdated`` on success). Safe to - /// call concurrently: a stale response is dropped. + /// Fetches the current cycle's usage summary and the year's invoices, then + /// builds a ``SpendSnapshot``. Safe to call concurrently: a stale response + /// is dropped. public func refresh() async { - guard let email = settings.teamMemberEmail? - .trimmingCharacters(in: .whitespacesAndNewlines), !email.isEmpty - else { - loadState = .failed(.missingCredentials) - return - } - let key: String? - do { - key = try keychain.read() - } catch { - Self.logger.error("Couldn't read the API key: \(error.localizedDescription)") - key = nil - } - guard let apiKey = key, !apiKey.isEmpty else { + refreshTokenAvailability() + guard let token = resolveToken() else { loadState = .failed(.missingCredentials) return } @@ -200,60 +180,108 @@ public final class LedgerServices { loadState = .loading do { - let response = try await provider.fetchSpend(apiKey: apiKey) + let summary = try await provider.usageSummary(token: token) + let components = calendar.dateComponents([.year, .month], from: now()) + let year = components.year ?? 1970 + let month = components.month ?? 1 + let priorMonthsCents = try await priorMonthsSpend( + before: month, + year: year, + token: token, + ) + guard generation == requestGeneration else { return } - if let member = response.member(matching: email) { - loadState = .loaded(member) - lastUpdated = Date() - } else { - loadState = .failed(.memberNotFound) - } - } catch let error as SpendProviderError { + let snapshot = SpendSnapshot( + currentCycleCents: summary.onDemandCents, + // "This year" = prior months' billed invoices + the current + // cycle's live spend (the current month's invoice lags until + // charges post). + yearToDateCents: priorMonthsCents + summary.onDemandCents, + cycleStart: summary.cycleStart, + cycleEnd: summary.cycleEnd, + membershipType: summary.membershipType, + includedUsedCents: summary.individualUsage.plan.used, + includedLimitCents: summary.individualUsage.plan.limit, + ) + loadState = .loaded(snapshot) + lastUpdated = now() + } catch let error as DashboardError { guard generation == requestGeneration else { return } loadState = .failed(error.asLoadError) } catch { guard generation == requestGeneration else { return } - Self.logger.error("Unexpected spend error: \(error.localizedDescription)") + Self.logger.error("Unexpected dashboard error: \(error.localizedDescription)") loadState = .failed(.network(error.localizedDescription)) } } - // MARK: - Credentials - - /// Stores (or, for an empty string, clears) the Admin API key in the - /// Keychain and refreshes spend. Rethrows a Keychain failure so the UI can - /// surface it rather than silently losing the key. - public func setAPIKey(_ key: String) throws { - try keychain.write(key) - refreshHasAPIKey() + /// Sums the billed invoice totals for months `1 ..< month` of `year`, + /// fetched concurrently. + private func priorMonthsSpend( + before month: Int, + year: Int, + token: SessionToken, + ) async throws -> Int { + guard month > 1 else { return 0 } + let provider = provider + return try await withThrowingTaskGroup(of: Int.self) { group in + for m in 1 ..< month { + group.addTask { + try await provider.monthlyInvoice(month: m, year: year, token: token).totalCents + } + } + var total = 0 + for try await cents in group { + total += cents + } + return total + } } - /// Removes the stored Admin API key. - public func clearAPIKey() throws { - try keychain.remove() - refreshHasAPIKey() + // MARK: - Token + + /// A pasted token (Keychain) wins as an explicit override; otherwise the + /// auto-detected local Cursor session is used. + private func resolveToken() -> SessionToken? { + if let manual = manualToken(), let token = SessionToken(rawToken: manual) { + return token + } + return tokenSource.currentToken() } - private func refreshHasAPIKey() { + private func manualToken() -> String? { do { - let key = try keychain.read() - hasAPIKey = !(key ?? "").isEmpty + let value = try keychain.read() + return (value?.isEmpty ?? true) ? nil : value } catch { - Self.logger.error("Couldn't read the API key: \(error.localizedDescription)") - hasAPIKey = false + Self.logger.error("Couldn't read the pasted token: \(error.localizedDescription)") + return nil } } + /// Stores (or clears, for an empty string) a pasted session token. + public func setManualToken(_ token: String) throws { + try keychain.write(token) + refreshTokenAvailability() + } + + /// Removes any pasted token (falling back to auto-detection). + public func clearManualToken() throws { + try keychain.remove() + refreshTokenAvailability() + } + + private func refreshTokenAvailability() { + hasManualToken = manualToken() != nil + autoTokenAvailable = tokenSource.currentToken() != nil + } + // MARK: - Login item - /// Re-reads the login-item status from the OS; it can change outside the - /// app (System Settings › General › Login Items). public func refreshLoginItemStatus() { loginItem.refresh() } - /// Opens System Settings › General › Login Items so the user can approve a - /// pending login item. public func openSystemSettingsLoginItems() { loginItem.openSystemSettingsLoginItems() } @@ -261,7 +289,6 @@ public final class LedgerServices { // MARK: - Persistence private func settingsDidChange() { - configuration.teamMemberEmail = settings.teamMemberEmail configuration.refreshInterval = settings.refreshInterval persist() } @@ -275,10 +302,10 @@ public final class LedgerServices { } } -extension SpendProviderError { - /// Maps the transport-level error into the user-facing ``LedgerServices/LoadError``. +extension DashboardError { fileprivate var asLoadError: LedgerServices.LoadError { switch self { + case .notAuthenticated: .notAuthenticated case let .http(status): .http(status) case let .network(reason): .network(reason) case let .decode(reason): .decode(reason) diff --git a/Ledger/LedgerCore/Sources/LedgerSettings.swift b/Ledger/LedgerCore/Sources/LedgerSettings.swift index ce414bfe..9294c8bd 100644 --- a/Ledger/LedgerCore/Sources/LedgerSettings.swift +++ b/Ledger/LedgerCore/Sources/LedgerSettings.swift @@ -1,26 +1,16 @@ import Foundation import Observation -/// The settings node of the Ledger model tree: which team member's spend to -/// show and how often to refresh it. The Admin API key is *not* here — it -/// lives in the Keychain (see ``KeychainStore``); this holds only the -/// non-secret preferences that persist as JSON. +/// The settings node of the Ledger model tree: how often to refresh. Identity +/// comes from the Cursor session token (auto-detected from the local Cursor +/// app, or pasted and kept in the Keychain), so there's no email or key here — +/// only the non-secret preference that persists as JSON. /// /// Mutations notify the injected funnel so the owning tree persists on any /// change; reassigning an equal value is a no-op. @MainActor @Observable public final class LedgerSettings { - /// The email of the team member whose spend Ledger displays. `nil` until - /// the user sets it in Settings; a spend fetch with no email surfaces - /// ``LedgerServices/LoadError/missingCredentials``. - public var teamMemberEmail: String? { - didSet { - guard oldValue != teamMemberEmail else { return } - onPersistentChange() - } - } - /// How often the spend is auto-refreshed while the app runs. public var refreshInterval: TimeInterval { didSet { @@ -34,11 +24,9 @@ public final class LedgerSettings { /// Initial values are the saved configuration; assigning them here does /// not invoke the funnel. public init( - teamMemberEmail: String?, refreshInterval: TimeInterval, onPersistentChange: @escaping @MainActor () -> Void, ) { - self.teamMemberEmail = teamMemberEmail self.refreshInterval = refreshInterval self.onPersistentChange = onPersistentChange } diff --git a/Ledger/LedgerCore/Sources/MonthlyInvoice.swift b/Ledger/LedgerCore/Sources/MonthlyInvoice.swift new file mode 100644 index 00000000..ebc46301 --- /dev/null +++ b/Ledger/LedgerCore/Sources/MonthlyInvoice.swift @@ -0,0 +1,30 @@ +import Foundation + +/// The decoded `POST /api/dashboard/get-monthly-invoice` response for one +/// month. Completed months carry itemized `cents` lines; the in-progress month +/// can come back with no `items` until charges post — modeled as an optional +/// so "not yet billed" reads as an empty total, not a decode failure. +public struct MonthlyInvoice: Codable, Equatable, Sendable { + public var items: [Item]? + + public struct Item: Codable, Equatable, Sendable { + /// Human-readable line (model, call count, dollar total). + public var description: String + /// The line's charge, in cents. + public var cents: Int + + public init(description: String, cents: Int) { + self.description = description + self.cents = cents + } + } + + public init(items: [Item]?) { + self.items = items + } + + /// The month's total charge in cents (0 when nothing is billed yet). + public var totalCents: Int { + (items ?? []).reduce(0) { $0 + $1.cents } + } +} diff --git a/Ledger/LedgerCore/Sources/SessionToken.swift b/Ledger/LedgerCore/Sources/SessionToken.swift new file mode 100644 index 00000000..32bb1c91 --- /dev/null +++ b/Ledger/LedgerCore/Sources/SessionToken.swift @@ -0,0 +1,64 @@ +import Foundation + +/// A Cursor dashboard session credential, normalized to the exact value the +/// `WorkosCursorSessionToken` cookie needs: `"::"`. +/// +/// The dashboard API rejects a bare JWT (HTTP 401) — the cookie must carry the +/// user id prefix. The Cursor app stores only the raw JWT (in `state.vscdb` / +/// Keychain), so ``init(rawToken:)`` derives the prefix from the JWT's `sub` +/// claim (`"auth0|user_ABC"` → `user_ABC`). A value the user pastes may already +/// be in `userId::jwt` form, in which case it's used as-is. +public struct SessionToken: Equatable, Sendable { + /// The ready-to-send cookie value (`userId::jwt`). + public let cookieValue: String + + /// Wraps an already-formed `userId::jwt` cookie value verbatim. + public init(cookieValue: String) { + self.cookieValue = cookieValue + } + + /// Builds the cookie value from whatever the user or the local Cursor app + /// provides: a `userId::jwt` string is kept as-is; a bare JWT gets its + /// `userId` derived from the `sub` claim and prepended. Returns `nil` for + /// an empty string. + public init?(rawToken: String) { + let trimmed = rawToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.contains("::") { + cookieValue = trimmed + return + } + guard let userID = Self.userID(fromJWT: trimmed) else { + // Not a JWT we can parse — keep it verbatim so a mis-paste surfaces + // as an honest 401 rather than being silently dropped. + cookieValue = trimmed + return + } + cookieValue = "\(userID)::\(trimmed)" + } + + /// Extracts the user id from a JWT's `sub` claim, stripping any + /// `"provider|"` prefix (e.g. `"auth0|user_ABC"` → `"user_ABC"`). + static func userID(fromJWT jwt: String) -> String? { + let segments = jwt.split(separator: ".") + guard segments.count >= 2 else { return nil } + guard let payload = base64URLDecoded(String(segments[1])) else { return nil } + guard + let object = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], + let sub = object["sub"] as? String + else { return nil } + return sub.contains("|") ? String(sub.split(separator: "|").last ?? "") : sub + } + + /// Decodes a base64url segment (no padding, `-`/`_` alphabet) to `Data`. + private static func base64URLDecoded(_ input: String) -> Data? { + var base64 = input.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let remainder = base64.count % 4 + if remainder > 0 { + base64 += String(repeating: "=", count: 4 - remainder) + } + return Data(base64Encoded: base64) + } +} diff --git a/Ledger/LedgerCore/Sources/SessionTokenSource.swift b/Ledger/LedgerCore/Sources/SessionTokenSource.swift new file mode 100644 index 00000000..3b67b176 --- /dev/null +++ b/Ledger/LedgerCore/Sources/SessionTokenSource.swift @@ -0,0 +1,84 @@ +import Foundation +import SQLite3 + +/// Resolves the Cursor session token automatically from the locally installed +/// Cursor app, so the menu-bar app can reuse the session you're already signed +/// into without anything to paste. The seam is a protocol so tests inject a +/// fixed token instead of reading a real database. +public protocol SessionTokenSource: Sendable { + /// The current auto-detected token, or `nil` when none is available + /// (Cursor not installed, signed out, or the store moved). + func currentToken() -> SessionToken? +} + +/// Reads the Cursor IDE's stored access token from its `state.vscdb` +/// key-value store (`ItemTable`, key `cursorAuth/accessToken`) and normalizes +/// it into a ``SessionToken``. +/// +/// The database is opened **read-only**; Cursor may hold it open, so we never +/// write or lock it. A missing file, missing key, or open failure is a plain +/// `nil` (auto-detect simply isn't available) — not a thrown error. +public struct CursorLocalTokenSource: SessionTokenSource { + private let databaseURL: URL + private let key: String + + /// The default location of Cursor's global state store on macOS. + public static var defaultDatabaseURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent( + "Library/Application Support/Cursor/User/globalStorage/state.vscdb", + isDirectory: false, + ) + } + + public init( + databaseURL: URL = CursorLocalTokenSource.defaultDatabaseURL, + key: String = "cursorAuth/accessToken", + ) { + self.databaseURL = databaseURL + self.key = key + } + + public func currentToken() -> SessionToken? { + guard let raw = readValue(forKey: key), let token = SessionToken(rawToken: raw) else { + return nil + } + return token + } + + /// Reads a single `ItemTable.value` for `key`, or `nil` on any failure. + private func readValue(forKey key: String) -> String? { + guard FileManager.default.fileExists(atPath: databaseURL.path) else { return nil } + + var db: OpaquePointer? + // Read-only, and don't create the file if it's missing. + guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(db) + return nil + } + defer { sqlite3_close(db) } + + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT value FROM ItemTable WHERE key = ? LIMIT 1", + -1, + &statement, + nil, + ) == SQLITE_OK else { + return nil + } + defer { sqlite3_finalize(statement) } + + // SQLITE_TRANSIENT tells SQLite to copy the bound string. + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, key, -1, transient) + + guard sqlite3_step(statement) == SQLITE_ROW, + let bytes = sqlite3_column_text(statement, 0) + else { + return nil + } + return String(cString: bytes) + } +} diff --git a/Ledger/LedgerCore/Sources/Spend.swift b/Ledger/LedgerCore/Sources/Spend.swift deleted file mode 100644 index acc8584d..00000000 --- a/Ledger/LedgerCore/Sources/Spend.swift +++ /dev/null @@ -1,96 +0,0 @@ -import Foundation - -/// The decoded response of `POST /teams/spend` (Cursor Admin API). -/// -/// Only the fields Ledger reads are modeled; the endpoint returns more -/// (pagination, totals) that synthesized `Codable` simply ignores. Unknown or -/// team-tier-specific fields being absent is expected, not an error. -public struct SpendResponse: Codable, Equatable, Sendable { - /// Per-member spend for the current billing cycle. - public var teamMemberSpend: [MemberSpend] - - /// Start of the current billing cycle, as milliseconds since the Unix - /// epoch, when the API includes it. Surfaced so the UI can label the cycle. - public var subscriptionCycleStart: Double? - - public init(teamMemberSpend: [MemberSpend], subscriptionCycleStart: Double? = nil) { - self.teamMemberSpend = teamMemberSpend - self.subscriptionCycleStart = subscriptionCycleStart - } - - /// The member whose email matches `email`, case-insensitively — the caller - /// (Ledger) filters the whole team down to the signed-in user. `nil` when - /// no member matches (surfaced as ``LedgerServices/LoadError/memberNotFound``). - public func member(matching email: String) -> MemberSpend? { - let target = email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !target.isEmpty else { return nil } - return teamMemberSpend.first { $0.email.lowercased() == target } - } -} - -/// One team member's current-cycle spend. -/// -/// Cent fields are `Double` on purpose: on 2026-06-04 the Admin API added -/// sub-cent precision to `spendCents`/`overallSpendCents` so results reconcile -/// with invoice amounts, so these are not whole integers. -public struct MemberSpend: Codable, Equatable, Sendable, Identifiable { - public var userId: String - public var name: String? - public var email: String - /// On-demand (overage) spend in cents for the current cycle — excludes - /// included usage. - public var spendCents: Double - /// Spend drawn from the member's included usage allowance, in cents, when - /// the API reports it (tiered self-serve teams). - public var includedSpendCents: Double? - /// Total cycle-to-date spend in cents (on-demand + included) when the API - /// reports it. May be absent depending on team type. - public var overallSpendCents: Double? - /// Number of usage-based premium requests made during the cycle. - public var fastPremiumRequests: Int? - - public var id: String { - userId - } - - public init( - userId: String, - name: String?, - email: String, - spendCents: Double, - includedSpendCents: Double?, - overallSpendCents: Double?, - fastPremiumRequests: Int?, - ) { - self.userId = userId - self.name = name - self.email = email - self.spendCents = spendCents - self.includedSpendCents = includedSpendCents - self.overallSpendCents = overallSpendCents - self.fastPremiumRequests = fastPremiumRequests - } - - /// Total cycle-to-date spend in cents. Prefers the API's own - /// `overallSpendCents`; when that's absent (some team types omit it) it - /// falls back to on-demand + included so the headline figure is never - /// silently short. - public var totalCents: Double { - overallSpendCents ?? (spendCents + (includedSpendCents ?? 0)) - } - - /// Total cycle-to-date spend in dollars. - public var totalDollars: Double { - totalCents / 100 - } - - /// On-demand (overage) spend in dollars. - public var onDemandDollars: Double { - spendCents / 100 - } - - /// Included-allowance spend in dollars, or `nil` when the API omits it. - public var includedDollars: Double? { - includedSpendCents.map { $0 / 100 } - } -} diff --git a/Ledger/LedgerCore/Sources/SpendProvider.swift b/Ledger/LedgerCore/Sources/SpendProvider.swift deleted file mode 100644 index db1db9fc..00000000 --- a/Ledger/LedgerCore/Sources/SpendProvider.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation - -/// A failure fetching spend from a ``SpendProvider``. Kept low-level and -/// transport-shaped; ``LedgerServices`` maps it into its user-facing -/// ``LedgerServices/LoadError``. -public enum SpendProviderError: Error, Equatable, Sendable { - /// A non-2xx HTTP response, carrying the status code (401 = bad key). - case http(Int) - /// The transport failed (offline, DNS, TLS, timeout). - case network(String) - /// A 2xx response whose body didn't decode as a ``SpendResponse``. - case decode(String) -} - -/// The seam between ``LedgerServices`` and the network. Production uses -/// ``CursorSpendAPI``; tests conform ``ScriptedSpendProvider`` so no real HTTP -/// happens (per the "test doubles conform to the production protocol" rule). -public protocol SpendProvider: Sendable { - /// Fetches the current-cycle team spend, authenticating with `apiKey`. - /// Throws ``SpendProviderError`` on transport, HTTP, or decode failure. - func fetchSpend(apiKey: String) async throws -> SpendResponse -} - -/// The production ``SpendProvider``: `POST https://api.cursor.com/teams/spend` -/// with HTTP Basic auth (the Admin API key as the username, empty password), -/// exactly as the Admin API documents. -public struct CursorSpendAPI: SpendProvider { - private let endpoint: URL - private let session: URLSession - - /// The documented Admin API spend endpoint. - public static let defaultEndpoint = URL(string: "https://api.cursor.com/teams/spend")! - - private static let logger = LedgerLog.channel(.spendAPI) - - public init(endpoint: URL = CursorSpendAPI.defaultEndpoint, session: URLSession = .shared) { - self.endpoint = endpoint - self.session = session - } - - public func fetchSpend(apiKey: String) async throws -> SpendResponse { - var request = URLRequest(url: endpoint) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(Self.basicAuthValue(apiKey: apiKey), forHTTPHeaderField: "Authorization") - // An empty JSON object: the whole team, default sort/pagination. Ledger - // filters to the signed-in user client-side rather than via searchTerm, - // so a rename of the account can't hide the row. - request.httpBody = Data("{}".utf8) - - let data: Data - let response: URLResponse - do { - (data, response) = try await session.data(for: request) - } catch { - Self.logger.error("Spend request transport failed: \(error.localizedDescription)") - throw SpendProviderError.network(error.localizedDescription) - } - - guard let http = response as? HTTPURLResponse else { - throw SpendProviderError.network("Non-HTTP response") - } - guard (200 ..< 300).contains(http.statusCode) else { - Self.logger.error("Spend request returned HTTP \(http.statusCode)") - throw SpendProviderError.http(http.statusCode) - } - - do { - return try JSONDecoder().decode(SpendResponse.self, from: data) - } catch { - Self.logger.error("Spend response failed to decode: \(error.localizedDescription)") - throw SpendProviderError.decode(error.localizedDescription) - } - } - - /// `Basic base64(apiKey + ":")` — the API key is the username, the password - /// is empty (mirrors `curl -u YOUR_API_KEY:`). - @_spi(Testing) - public static func basicAuthValue(apiKey: String) -> String { - let token = Data("\(apiKey):".utf8).base64EncodedString() - return "Basic \(token)" - } -} diff --git a/Ledger/LedgerCore/Sources/SpendSnapshot.swift b/Ledger/LedgerCore/Sources/SpendSnapshot.swift new file mode 100644 index 00000000..fd38f2c9 --- /dev/null +++ b/Ledger/LedgerCore/Sources/SpendSnapshot.swift @@ -0,0 +1,55 @@ +import Foundation + +/// The spend figures Ledger renders, distilled from the usage summary and the +/// year's monthly invoices into one value the UI binds to. All money is cents. +public struct SpendSnapshot: Equatable, Sendable { + /// Usage-based spend for the current billing cycle (live, from the usage + /// summary). + public var currentCycleCents: Int + /// Usage-based spend billed so far this calendar year (sum of the monthly + /// invoices). + public var yearToDateCents: Int + /// The current billing cycle's start/end, when known. + public var cycleStart: Date? + public var cycleEnd: Date? + /// Plan tier (`"pro"`, `"ultra"`, …). + public var membershipType: String + /// Included-allowance usage this cycle, in cents (when reported). + public var includedUsedCents: Int? + /// The included allowance in cents (when reported). + public var includedLimitCents: Int? + + public init( + currentCycleCents: Int, + yearToDateCents: Int, + cycleStart: Date?, + cycleEnd: Date?, + membershipType: String, + includedUsedCents: Int?, + includedLimitCents: Int?, + ) { + self.currentCycleCents = currentCycleCents + self.yearToDateCents = yearToDateCents + self.cycleStart = cycleStart + self.cycleEnd = cycleEnd + self.membershipType = membershipType + self.includedUsedCents = includedUsedCents + self.includedLimitCents = includedLimitCents + } + + public var currentCycleDollars: Double { + Double(currentCycleCents) / 100 + } + + public var yearToDateDollars: Double { + Double(yearToDateCents) / 100 + } + + public var includedUsedDollars: Double? { + includedUsedCents.map { Double($0) / 100 } + } + + public var includedLimitDollars: Double? { + includedLimitCents.map { Double($0) / 100 } + } +} diff --git a/Ledger/LedgerCore/Sources/TestDoubles.swift b/Ledger/LedgerCore/Sources/TestDoubles.swift index 037629cd..14605d93 100644 --- a/Ledger/LedgerCore/Sources/TestDoubles.swift +++ b/Ledger/LedgerCore/Sources/TestDoubles.swift @@ -1,15 +1,15 @@ #if DEBUG import Foundation - /// A ``SpendProvider`` that returns a scripted outcome instead of hitting + /// A ``DashboardProvider`` that returns scripted results instead of hitting /// the network — used by unit tests and SwiftUI previews. Lives in the /// module (behind `@_spi(Testing)` + `#if DEBUG`) so both callers share one /// double that conforms to the production protocol. @_spi(Testing) - public struct ScriptedSpendProvider: SpendProvider { + public struct ScriptedDashboardProvider: DashboardProvider { public enum Outcome: Sendable { - case success(SpendResponse) - case failure(SpendProviderError) + case success(summary: UsageSummary, invoiceCentsByMonth: [Int: Int]) + case failure(DashboardError) } private let outcome: Outcome @@ -18,17 +18,48 @@ self.outcome = outcome } - /// Convenience: succeed with a single member for `email`. - public init(member: MemberSpend) { - outcome = .success(SpendResponse(teamMemberSpend: [member])) + /// Convenience: a successful summary with no prior-month invoices. + public init(summary: UsageSummary) { + outcome = .success(summary: summary, invoiceCentsByMonth: [:]) } - public func fetchSpend(apiKey _: String) async throws -> SpendResponse { + public func usageSummary(token _: SessionToken) async throws -> UsageSummary { switch outcome { - case let .success(response): response + case let .success(summary, _): summary case let .failure(error): throw error } } + + public func monthlyInvoice( + month: Int, + year _: Int, + token _: SessionToken, + ) async throws -> MonthlyInvoice { + switch outcome { + case let .success(_, invoiceCentsByMonth): + let cents = invoiceCentsByMonth[month] ?? 0 + return MonthlyInvoice(items: cents == 0 ? nil : [.init( + description: "m\(month)", + cents: cents, + )]) + case let .failure(error): + throw error + } + } + } + + /// A ``SessionTokenSource`` that returns a fixed token (or none), so + /// auto-detection is testable without reading a real `state.vscdb`. + @_spi(Testing) + public struct StubTokenSource: SessionTokenSource { + private let token: SessionToken? + public init(token: SessionToken?) { + self.token = token + } + + public func currentToken() -> SessionToken? { + token + } } /// An in-memory ``KeychainStore`` — the real Keychain needs a signed, @@ -61,4 +92,32 @@ lock.withLock { secret = nil } } } + + extension UsageSummary { + /// A minimal summary for previews/tests. + public static func fixture( + onDemandCents: Int, + membershipType: String = "pro", + includedUsed: Int = 0, + includedLimit: Int? = nil, + cycleStart: String = "2026-07-04T18:16:08.000Z", + cycleEnd: String = "2026-08-04T18:16:08.000Z", + ) -> UsageSummary { + UsageSummary( + billingCycleStart: cycleStart, + billingCycleEnd: cycleEnd, + membershipType: membershipType, + individualUsage: .init( + onDemand: .init(enabled: true, used: onDemandCents, limit: nil, remaining: nil), + plan: .init( + enabled: true, + used: includedUsed, + limit: includedLimit, + remaining: nil, + breakdown: nil, + ), + ), + ) + } + } #endif diff --git a/Ledger/LedgerCore/Sources/UsageSummary.swift b/Ledger/LedgerCore/Sources/UsageSummary.swift new file mode 100644 index 00000000..a95b7015 --- /dev/null +++ b/Ledger/LedgerCore/Sources/UsageSummary.swift @@ -0,0 +1,86 @@ +import Foundation + +/// The decoded `GET /api/usage-summary` response — the current billing cycle's +/// dates, plan type, and live usage/spend for an individual account. +/// +/// Only the fields Ledger reads are modeled; synthesized `Codable` ignores the +/// rest. Cent amounts are integers here (the dashboard reports whole cents). +public struct UsageSummary: Codable, Equatable, Sendable { + /// ISO-8601 start of the current billing cycle (kept as the wire string; + /// see ``cycleStart``). + public var billingCycleStart: String + /// ISO-8601 end of the current billing cycle. + public var billingCycleEnd: String + /// `"pro"`, `"ultra"`, `"free"`, etc. + public var membershipType: String + public var individualUsage: IndividualUsage + + public struct IndividualUsage: Codable, Equatable, Sendable { + /// Usage-based (pay-per-use) spend — the money beyond the subscription. + public var onDemand: OnDemand + /// Usage against the plan's included allowance. + public var plan: Plan + } + + public struct OnDemand: Codable, Equatable, Sendable { + public var enabled: Bool + /// Usage-based spend this cycle, in cents. + public var used: Int + /// The spend cap in cents, or `nil` when uncapped. + public var limit: Int? + public var remaining: Int? + } + + public struct Plan: Codable, Equatable, Sendable { + public var enabled: Bool + /// Included-allowance usage this cycle, in cents. + public var used: Int + /// The included allowance in cents. + public var limit: Int? + public var remaining: Int? + public var breakdown: Breakdown? + } + + public struct Breakdown: Codable, Equatable, Sendable { + public var included: Int + public var bonus: Int + public var total: Int + } + + public init( + billingCycleStart: String, + billingCycleEnd: String, + membershipType: String, + individualUsage: IndividualUsage, + ) { + self.billingCycleStart = billingCycleStart + self.billingCycleEnd = billingCycleEnd + self.membershipType = membershipType + self.individualUsage = individualUsage + } + + /// The parsed cycle start, or `nil` if the wire string doesn't parse. + public var cycleStart: Date? { + Self.parseDate(billingCycleStart) + } + + /// The parsed cycle end. + public var cycleEnd: Date? { + Self.parseDate(billingCycleEnd) + } + + /// Usage-based spend this cycle, in cents. + public var onDemandCents: Int { + individualUsage.onDemand.used + } + + private static func parseDate(_ string: String) -> Date? { + // Dashboard timestamps carry fractional seconds ("…T18:16:08.000Z"). + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFraction.date(from: string) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: string) + } +} diff --git a/Ledger/LedgerCore/Tests/DashboardProviderTests.swift b/Ledger/LedgerCore/Tests/DashboardProviderTests.swift new file mode 100644 index 00000000..1b27aa5e --- /dev/null +++ b/Ledger/LedgerCore/Tests/DashboardProviderTests.swift @@ -0,0 +1,28 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct DashboardProviderTests { + private let token = SessionToken(cookieValue: "user_X::jwt") + + @Test func scriptedProviderReturnsItsScriptedSummaryAndInvoices() async throws { + let provider = ScriptedDashboardProvider(.success( + summary: .fixture(onDemandCents: 4200), + invoiceCentsByMonth: [3: 5000], + )) + + #expect(try await provider.usageSummary(token: token).onDemandCents == 4200) + #expect(try await provider.monthlyInvoice(month: 3, year: 2026, token: token) + .totalCents == 5000) + // A month with no scripted value is a sparse (zero) invoice. + #expect(try await provider.monthlyInvoice(month: 4, year: 2026, token: token) + .totalCents == 0) + } + + @Test func scriptedProviderThrowsItsFailureOutcome() async { + let provider = ScriptedDashboardProvider(.failure(.notAuthenticated)) + await #expect(throws: DashboardError.notAuthenticated) { + try await provider.usageSummary(token: token) + } + } +} diff --git a/Ledger/LedgerCore/Tests/LedgerConfigStoreTests.swift b/Ledger/LedgerCore/Tests/LedgerConfigStoreTests.swift index b9772b94..da2283a9 100644 --- a/Ledger/LedgerCore/Tests/LedgerConfigStoreTests.swift +++ b/Ledger/LedgerCore/Tests/LedgerConfigStoreTests.swift @@ -20,10 +20,7 @@ struct LedgerConfigStoreTests { let (store, directory) = makeStore() defer { try? FileManager.default.removeItem(at: directory) } - let configuration = LedgerConfiguration( - teamMemberEmail: "me@company.com", - refreshInterval: 300, - ) + let configuration = LedgerConfiguration(refreshInterval: 300) try store.save(configuration) #expect(try store.load() == configuration) } diff --git a/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift b/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift index 238cf606..6da034ea 100644 --- a/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift +++ b/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift @@ -40,57 +40,60 @@ final class LoginItemRecorder: LoginItemBackend { /// A stand-in error for login-item failure injection. struct LoginItemTestError: Error {} -enum SpendFixture { - /// Builds a member with the fields tests care about; the rest default. - static func member( - email: String, - spendCents: Double = 0, - includedSpendCents: Double? = nil, - overallSpendCents: Double? = nil, - fastPremiumRequests: Int? = nil, - ) -> MemberSpend { - MemberSpend( - userId: "user_\(email)", - name: email, - email: email, - spendCents: spendCents, - includedSpendCents: includedSpendCents, - overallSpendCents: overallSpendCents, - fastPremiumRequests: fastPremiumRequests, - ) +enum DashboardFixture { + /// Builds a signed-looking JWT (unsigned; only the payload matters) whose + /// `sub` is `sub`. base64url, no padding. + static func jwt(sub: String) -> String { + func segment(_ object: [String: Any]) -> String { + let data = try! JSONSerialization.data(withJSONObject: object) + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return "\(segment(["alg": "HS256"])).\(segment(["sub": sub])).signature" } - /// A realistic `/teams/spend` response body, including fields Ledger does - /// not model (they must be ignored, not fail decoding). - static let responseJSON = """ + /// A trimmed but realistic `/api/usage-summary` body. + static let usageSummaryJSON = """ { - "teamMemberSpend": [ - { - "userId": "user_ABC", - "name": "Alex Admin", - "email": "alex@company.com", - "role": "owner", - "spendCents": 4212.5, - "includedSpendCents": 8000.0, - "overallSpendCents": 12212.5, - "fastPremiumRequests": 143, - "profilePictureUrl": null, - "monthlyLimitDollars": null, - "hardLimitOverrideDollars": 0 + "billingCycleStart": "2026-07-04T18:16:08.000Z", + "billingCycleEnd": "2026-08-04T18:16:08.000Z", + "membershipType": "ultra", + "limitType": "user", + "isUnlimited": false, + "individualUsage": { + "plan": { + "enabled": true, + "used": 40000, + "limit": 40000, + "remaining": 0, + "breakdown": { "included": 40000, "bonus": 12158, "total": 52158 } }, - { - "userId": "user_DEF", - "name": "Blair Member", - "email": "blair@company.com", - "role": "member", - "spendCents": 0, - "includedSpendCents": 150.75, - "fastPremiumRequests": 2 - } + "onDemand": { "enabled": true, "used": 315609, "limit": null, "remaining": null } + }, + "teamUsage": {} + } + """ + + /// A `get-monthly-invoice` body with two itemized lines. + static let monthlyInvoiceJSON = """ + { + "items": [ + { "description": "88 calls to claude-fable-5-thinking-xhigh ($828.11)", "cents": 82811 }, + { "description": "90 calls to claude-opus-4-8-thinking-high ($334.92)", "cents": 33492 } ], - "totalMembers": 2, - "totalPages": 1, - "subscriptionCycleStart": 1708992000000 + "periodStartMs": "1785542400000", + "periodEndMs": "1788220800000" + } + """ + + /// A sparse invoice (current month, nothing billed yet). + static let emptyInvoiceJSON = """ + { + "pricingDescription": { "id": "abc" }, + "periodStartMs": "1785542400000", + "periodEndMs": "1788220800000" } """ } diff --git a/Ledger/LedgerCore/Tests/LedgerServicesTests.swift b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift index 821a8ce8..f42adc85 100644 --- a/Ledger/LedgerCore/Tests/LedgerServicesTests.swift +++ b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift @@ -4,98 +4,123 @@ import Testing @MainActor struct LedgerServicesTests { + /// A fixed "now" in July 2026 (month 7), UTC, so prior-month invoice fetches + /// (months 1..6) are deterministic. + private func julyCalendarAndNow() -> (Calendar, @Sendable () -> Date) { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let now = calendar.date(from: DateComponents(year: 2026, month: 7, day: 15))! + return (calendar, { now }) + } + private func makeServices( - provider: any SpendProvider = ScriptedSpendProvider(.failure(.network("unused"))), - keychainSecret: String? = nil, - email: String? = nil, + provider: any DashboardProvider = ScriptedDashboardProvider(.failure(.network("unused"))), + manualToken: String? = nil, + autoToken: SessionToken? = nil, ) -> LedgerServices { + let (calendar, now) = julyCalendarAndNow() let directory = FileManager.default.temporaryDirectory .appendingPathComponent("LedgerServicesTests-\(UUID().uuidString)") let store = LedgerConfigStore(directory: directory) - try? store.save(LedgerConfiguration(teamMemberEmail: email, refreshInterval: 900)) return LedgerServices( configStore: store, - keychain: InMemoryKeychainStore(secret: keychainSecret), + keychain: InMemoryKeychainStore(secret: manualToken), + tokenSource: StubTokenSource(token: autoToken), provider: provider, loginItem: LoginItemController(backend: LoginItemRecorder()), + calendar: calendar, + now: now, ) } @Test func startsIdle() { - let services = makeServices() - #expect(services.loadState == .idle) - } - - @Test func failsWithMissingCredentialsWhenNoEmail() async { - let services = makeServices(keychainSecret: "key", email: nil) - await services.refresh() - #expect(services.loadState == .failed(.missingCredentials)) + #expect(makeServices().loadState == .idle) } - @Test func failsWithMissingCredentialsWhenNoKey() async { - let services = makeServices(keychainSecret: nil, email: "me@company.com") + @Test func failsWithMissingCredentialsWhenNoTokenAnywhere() async { + let services = makeServices(autoToken: nil) await services.refresh() #expect(services.loadState == .failed(.missingCredentials)) } - @Test func loadsTheMatchingMember() async { - let member = SpendFixture.member(email: "me@company.com", overallSpendCents: 4200) + @Test func loadsUsingTheAutoDetectedToken() async { + let provider = ScriptedDashboardProvider(.success( + summary: .fixture( + onDemandCents: 5000, + membershipType: "ultra", + includedUsed: 40000, + includedLimit: 40000, + ), + invoiceCentsByMonth: [1: 100, 2: 200, 3: 300, 4: 400, 5: 500, 6: 600], + )) let services = makeServices( - provider: ScriptedSpendProvider(member: member), - keychainSecret: "key", - email: "ME@company.com", + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), ) await services.refresh() - #expect(services.loadState == .loaded(member)) + guard case let .loaded(snapshot) = services.loadState else { + Issue.record("expected loaded, got \(services.loadState)") + return + } + #expect(snapshot.currentCycleCents == 5000) + // Year-to-date = prior months (1..6 = 2100) + current cycle live (5000). + #expect(snapshot.yearToDateCents == 2100 + 5000) + #expect(snapshot.membershipType == "ultra") #expect(services.lastUpdated != nil) } - @Test func failsWithMemberNotFoundWhenNoEmailMatches() async { - let provider = ScriptedSpendProvider(member: SpendFixture - .member(email: "someone@company.com")) - let services = makeServices( - provider: provider, - keychainSecret: "key", - email: "me@company.com", - ) + @Test func loadsUsingAPastedTokenWhenNoAutoToken() async { + let jwt = DashboardFixture.jwt(sub: "auth0|user_PASTE") + let provider = ScriptedDashboardProvider(summary: .fixture(onDemandCents: 999)) + let services = makeServices(provider: provider, manualToken: jwt, autoToken: nil) + #expect(services.hasManualToken) + await services.refresh() - #expect(services.loadState == .failed(.memberNotFound)) + guard case let .loaded(snapshot) = services.loadState else { + Issue.record("expected loaded, got \(services.loadState)") + return + } + #expect(snapshot.currentCycleCents == 999) } - @Test func mapsHTTPErrors() async { + @Test func mapsNotAuthenticated() async { let services = makeServices( - provider: ScriptedSpendProvider(.failure(.http(401))), - keychainSecret: "bad-key", - email: "me@company.com", + provider: ScriptedDashboardProvider(.failure(.notAuthenticated)), + autoToken: SessionToken(cookieValue: "auto::jwt"), ) await services.refresh() - #expect(services.loadState == .failed(.http(401))) + #expect(services.loadState == .failed(.notAuthenticated)) } @Test func mapsNetworkErrors() async { let services = makeServices( - provider: ScriptedSpendProvider(.failure(.network("offline"))), - keychainSecret: "key", - email: "me@company.com", + provider: ScriptedDashboardProvider(.failure(.network("offline"))), + autoToken: SessionToken(cookieValue: "auto::jwt"), ) await services.refresh() #expect(services.loadState == .failed(.network("offline"))) } - @Test func setAndClearAPIKeyTracksHasAPIKey() throws { + @Test func tracksManualTokenPresence() throws { let services = makeServices() - #expect(!services.hasAPIKey) + #expect(!services.hasManualToken) + + try services.setManualToken(DashboardFixture.jwt(sub: "auth0|user_X")) + #expect(services.hasManualToken) - try services.setAPIKey("new-key") - #expect(services.hasAPIKey) + try services.clearManualToken() + #expect(!services.hasManualToken) + } - try services.clearAPIKey() - #expect(!services.hasAPIKey) + @Test func reportsAutoTokenAvailability() { + #expect(makeServices(autoToken: nil).autoTokenAvailable == false) + #expect(makeServices(autoToken: SessionToken(cookieValue: "a::b")) + .autoTokenAvailable == true) } - @Test func the401MessageMentionsTheKey() { - #expect(LedgerServices.LoadError.http(401).message.contains("401")) - #expect(LedgerServices.LoadError.missingCredentials.message.contains("Settings")) + @Test func errorMessagesAreActionable() { + #expect(LedgerServices.LoadError.missingCredentials.message.contains("Cursor")) + #expect(LedgerServices.LoadError.notAuthenticated.message.contains("expired")) } } diff --git a/Ledger/LedgerCore/Tests/MonthlyInvoiceTests.swift b/Ledger/LedgerCore/Tests/MonthlyInvoiceTests.swift new file mode 100644 index 00000000..3c7b293a --- /dev/null +++ b/Ledger/LedgerCore/Tests/MonthlyInvoiceTests.swift @@ -0,0 +1,23 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct MonthlyInvoiceTests { + @Test func decodesItemsAndSumsCents() throws { + let invoice = try JSONDecoder().decode( + MonthlyInvoice.self, + from: Data(DashboardFixture.monthlyInvoiceJSON.utf8), + ) + #expect(invoice.items?.count == 2) + #expect(invoice.totalCents == 82811 + 33492) + } + + @Test func aSparseInvoiceTotalsToZero() throws { + let invoice = try JSONDecoder().decode( + MonthlyInvoice.self, + from: Data(DashboardFixture.emptyInvoiceJSON.utf8), + ) + #expect(invoice.items == nil) + #expect(invoice.totalCents == 0) + } +} diff --git a/Ledger/LedgerCore/Tests/SessionTokenSourceTests.swift b/Ledger/LedgerCore/Tests/SessionTokenSourceTests.swift new file mode 100644 index 00000000..c8b0f850 --- /dev/null +++ b/Ledger/LedgerCore/Tests/SessionTokenSourceTests.swift @@ -0,0 +1,52 @@ +import Foundation +@_spi(Testing) import LedgerCore +import SQLite3 +import Testing + +struct SessionTokenSourceTests { + @Test func returnsNilWhenTheDatabaseIsMissing() { + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent("nope-\(UUID().uuidString).vscdb") + let source = CursorLocalTokenSource(databaseURL: missing) + #expect(source.currentToken() == nil) + } + + @Test func readsAndDerivesTheTokenFromAStateStore() throws { + let jwt = DashboardFixture.jwt(sub: "auth0|user_STATE") + let url = try makeStateDB(accessToken: jwt) + defer { try? FileManager.default.removeItem(at: url) } + + let source = CursorLocalTokenSource(databaseURL: url) + #expect(source.currentToken()?.cookieValue == "user_STATE::\(jwt)") + } + + @Test func returnsNilWhenTheKeyIsAbsent() throws { + let url = try makeStateDB(accessToken: nil) + defer { try? FileManager.default.removeItem(at: url) } + + let source = CursorLocalTokenSource(databaseURL: url) + #expect(source.currentToken() == nil) + } + + /// Creates a minimal `state.vscdb`-shaped SQLite file with an `ItemTable`, + /// optionally seeding `cursorAuth/accessToken`. + private func makeStateDB(accessToken: String?) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("state-\(UUID().uuidString).vscdb") + var db: OpaquePointer? + #expect(sqlite3_open(url.path, &db) == SQLITE_OK) + defer { sqlite3_close(db) } + #expect(sqlite3_exec( + db, + "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)", + nil, + nil, + nil, + ) == SQLITE_OK) + if let accessToken { + let sql = "INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/accessToken', '\(accessToken)')" + #expect(sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK) + } + return url + } +} diff --git a/Ledger/LedgerCore/Tests/SessionTokenTests.swift b/Ledger/LedgerCore/Tests/SessionTokenTests.swift new file mode 100644 index 00000000..0b67f7b9 --- /dev/null +++ b/Ledger/LedgerCore/Tests/SessionTokenTests.swift @@ -0,0 +1,31 @@ +@_spi(Testing) import LedgerCore +import Testing + +struct SessionTokenTests { + @Test func derivesUserIDFromJWTSubAndStripsProvider() { + let jwt = DashboardFixture.jwt(sub: "auth0|user_01ABC") + let token = SessionToken(rawToken: jwt) + #expect(token?.cookieValue == "user_01ABC::\(jwt)") + } + + @Test func keepsAnAlreadyFormedUserIDColonColonJWTVerbatim() { + let jwt = DashboardFixture.jwt(sub: "auth0|user_01ABC") + let combined = "user_01ABC::\(jwt)" + #expect(SessionToken(rawToken: combined)?.cookieValue == combined) + } + + @Test func subWithoutProviderPrefixIsUsedAsIs() { + let jwt = DashboardFixture.jwt(sub: "user_bare") + #expect(SessionToken(rawToken: jwt)?.cookieValue == "user_bare::\(jwt)") + } + + @Test func nonJWTIsKeptVerbatimSoItSurfacesAsAn401() { + // Not parseable as a JWT: kept as-is rather than dropped, so it fails + // honestly at the API instead of silently vanishing. + #expect(SessionToken(rawToken: "not-a-jwt")?.cookieValue == "not-a-jwt") + } + + @Test func emptyIsNil() { + #expect(SessionToken(rawToken: " ") == nil) + } +} diff --git a/Ledger/LedgerCore/Tests/SpendProviderTests.swift b/Ledger/LedgerCore/Tests/SpendProviderTests.swift deleted file mode 100644 index 54ece763..00000000 --- a/Ledger/LedgerCore/Tests/SpendProviderTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -@_spi(Testing) import LedgerCore -import Testing - -struct SpendProviderTests { - @Test func basicAuthPutsTheKeyInTheUsernameWithAnEmptyPassword() { - // Mirrors `curl -u KEY:` — base64 of "KEY:". - let expected = "Basic " + Data("secret-key:".utf8).base64EncodedString() - #expect(CursorSpendAPI.basicAuthValue(apiKey: "secret-key") == expected) - } - - @Test func scriptedProviderReturnsItsSuccessOutcome() async throws { - let member = SpendFixture.member(email: "a@b.com", overallSpendCents: 500) - let provider = ScriptedSpendProvider(member: member) - - let response = try await provider.fetchSpend(apiKey: "ignored") - #expect(response.teamMemberSpend == [member]) - } - - @Test func scriptedProviderThrowsItsFailureOutcome() async { - let provider = ScriptedSpendProvider(.failure(.http(401))) - - await #expect(throws: SpendProviderError.http(401)) { - try await provider.fetchSpend(apiKey: "ignored") - } - } -} diff --git a/Ledger/LedgerCore/Tests/SpendTests.swift b/Ledger/LedgerCore/Tests/SpendTests.swift deleted file mode 100644 index 0a199b1b..00000000 --- a/Ledger/LedgerCore/Tests/SpendTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation -@_spi(Testing) import LedgerCore -import Testing - -struct SpendTests { - @Test func decodesTheDocumentedResponseIgnoringUnknownFields() throws { - let data = Data(SpendFixture.responseJSON.utf8) - let response = try JSONDecoder().decode(SpendResponse.self, from: data) - - #expect(response.teamMemberSpend.count == 2) - #expect(response.subscriptionCycleStart == 1_708_992_000_000) - - let alex = try #require(response.teamMemberSpend.first) - #expect(alex.email == "alex@company.com") - // Sub-cent precision survives (the fields are Double, not Int). - #expect(alex.spendCents == 4212.5) - #expect(alex.includedSpendCents == 8000) - #expect(alex.overallSpendCents == 12212.5) - #expect(alex.fastPremiumRequests == 143) - } - - @Test func matchesMemberByEmailCaseInsensitively() throws { - let data = Data(SpendFixture.responseJSON.utf8) - let response = try JSONDecoder().decode(SpendResponse.self, from: data) - - #expect(response.member(matching: "ALEX@company.com")?.userId == "user_ABC") - #expect(response.member(matching: " blair@company.com ")?.userId == "user_DEF") - #expect(response.member(matching: "nobody@company.com") == nil) - #expect(response.member(matching: "") == nil) - } - - @Test func totalPrefersOverallSpendWhenPresent() { - let member = SpendFixture.member( - email: "a@b.com", - spendCents: 100, - includedSpendCents: 200, - overallSpendCents: 999, - ) - #expect(member.totalCents == 999) - #expect(member.totalDollars == 9.99) - } - - @Test func totalFallsBackToOnDemandPlusIncludedWhenOverallAbsent() { - let member = SpendFixture.member( - email: "a@b.com", - spendCents: 100, - includedSpendCents: 200, - overallSpendCents: nil, - ) - #expect(member.totalCents == 300) - #expect(member.onDemandDollars == 1) - #expect(member.includedDollars == 2) - } - - @Test func totalFallsBackToOnDemandOnlyWhenIncludedAlsoAbsent() { - let member = SpendFixture.member(email: "a@b.com", spendCents: 250) - #expect(member.totalCents == 250) - #expect(member.includedDollars == nil) - } -} diff --git a/Ledger/LedgerCore/Tests/UsageSummaryTests.swift b/Ledger/LedgerCore/Tests/UsageSummaryTests.swift new file mode 100644 index 00000000..5727917c --- /dev/null +++ b/Ledger/LedgerCore/Tests/UsageSummaryTests.swift @@ -0,0 +1,41 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct UsageSummaryTests { + @Test func decodesTheDashboardBodyIgnoringUnknownFields() throws { + let summary = try JSONDecoder().decode( + UsageSummary.self, + from: Data(DashboardFixture.usageSummaryJSON.utf8), + ) + + #expect(summary.membershipType == "ultra") + #expect(summary.onDemandCents == 315_609) + #expect(summary.individualUsage.plan.used == 40000) + #expect(summary.individualUsage.plan.limit == 40000) + #expect(summary.individualUsage.plan.breakdown?.total == 52158) + #expect(summary.individualUsage.onDemand.limit == nil) + } + + @Test func parsesFractionalSecondISO8601CycleDates() throws { + let summary = try JSONDecoder().decode( + UsageSummary.self, + from: Data(DashboardFixture.usageSummaryJSON.utf8), + ) + let start = try #require(summary.cycleStart) + let end = try #require(summary.cycleEnd) + #expect(end > start) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + let expected = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 7, + day: 4, + hour: 18, + minute: 16, + second: 8, + ))) + #expect(start == expected) + } +} From 8a27379d5e4b91aeb078b7a779df7cacd283b806 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 17 Jul 2026 16:57:33 -0700 Subject: [PATCH 05/34] Always show the amount in the menu-bar item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status item was icon-only until the first fetch, making it easy to lose among other menu-bar items. Show the current-cycle amount as the title always, with a "$—" placeholder while loading, so it's a wider, findable target. --- Ledger/Ledger/Sources/LedgerApp.swift | 7 +++---- Ledger/Ledger/Sources/LedgerSession.swift | 7 ++++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Ledger/Ledger/Sources/LedgerApp.swift b/Ledger/Ledger/Sources/LedgerApp.swift index 9db34c6c..d6ffeee9 100644 --- a/Ledger/Ledger/Sources/LedgerApp.swift +++ b/Ledger/Ledger/Sources/LedgerApp.swift @@ -84,10 +84,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return popover } - /// Shows the current-cycle dollar amount beside the icon while spend is - /// loaded, and nothing (icon only) otherwise. + /// Shows the current-cycle dollar amount beside the icon, always — a `$—` + /// placeholder until the first fetch lands — so the item is easy to spot. private func updateTitle() { - let title = session.statusTitle - statusItem?.button?.title = title == "—" ? "" : " \(title)" + statusItem?.button?.title = " \(session.statusTitle)" } } diff --git a/Ledger/Ledger/Sources/LedgerSession.swift b/Ledger/Ledger/Sources/LedgerSession.swift index c5d990bc..70b727d6 100644 --- a/Ledger/Ledger/Sources/LedgerSession.swift +++ b/Ledger/Ledger/Sources/LedgerSession.swift @@ -50,14 +50,15 @@ final class LedgerSession { services.loginItemError } - /// The status-bar title: the current-cycle dollar amount when loaded, a - /// placeholder otherwise. Observed by the app delegate. + /// The status-bar title: the current-cycle dollar amount once loaded, and a + /// `$—` placeholder until then (so the item always shows something findable + /// in the menu bar). Observed by the app delegate. var statusTitle: String { switch services.loadState { case let .loaded(snapshot): CurrencyFormat.dollars(snapshot.currentCycleDollars) case .idle, .loading, .failed: - "—" + "$—" } } From 33dacdfe23d7ae6cccc8ebf4f15027c24e4cdb58 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 17 Jul 2026 17:02:37 -0700 Subject: [PATCH 06/34] Don't refresh spend on popover open Opening the menu-bar popover fired a network fetch every time, which is too frequent. Rely on the periodic 15-minute refresh loop (which drives both the title and the popover) plus the explicit Refresh button; opening now just shows the latest fetched state. --- Ledger/Ledger/README.md | 10 ++++++---- Ledger/Ledger/Sources/LedgerApp.swift | 4 +++- Ledger/Ledger/Sources/LedgerSession.swift | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Ledger/Ledger/README.md b/Ledger/Ledger/README.md index 48f73bdc..e1cbe430 100644 --- a/Ledger/Ledger/README.md +++ b/Ledger/Ledger/README.md @@ -36,11 +36,13 @@ shortcut to System Settings if macOS needs you to approve the login item. ## What it shows - **Menu-bar title** — the current billing cycle's usage-based spend, refreshed - automatically (every 15 minutes) and whenever you open the popover. + automatically every 15 minutes. Opening the popover just shows the latest + fetched state; it doesn't trigger a network request. - **Popover** — this cycle's spend and date range, a **year-to-date** total, - included-usage used/limit, your plan tier, and when it last updated. On an - error (no session, an expired session, a network failure) it explains what to - fix and offers a shortcut to Settings. + included-usage used/limit, your plan tier, and when it last updated. A + **Refresh** button forces an immediate fetch. On an error (no session, an + expired session, a network failure) it explains what to fix and offers a + shortcut to Settings. ## Design notes diff --git a/Ledger/Ledger/Sources/LedgerApp.swift b/Ledger/Ledger/Sources/LedgerApp.swift index d6ffeee9..f7f52971 100644 --- a/Ledger/Ledger/Sources/LedgerApp.swift +++ b/Ledger/Ledger/Sources/LedgerApp.swift @@ -68,7 +68,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { popover.performClose(nil) return } - session.refresh() + // Don't fetch on open — the periodic refresh loop keeps both the title + // and the popover current; opening just shows the latest state. Use the + // popover's Refresh button to force an immediate fetch. popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) // The popover's own window must become key for text fields / buttons to // take input reliably. diff --git a/Ledger/Ledger/Sources/LedgerSession.swift b/Ledger/Ledger/Sources/LedgerSession.swift index 70b727d6..169c7c55 100644 --- a/Ledger/Ledger/Sources/LedgerSession.swift +++ b/Ledger/Ledger/Sources/LedgerSession.swift @@ -78,7 +78,7 @@ final class LedgerSession { services.stop() } - /// Fetches spend now (popover open, manual Refresh, after a token edit). + /// Fetches spend now (the manual Refresh button, or after a token edit). func refresh() { Task { await services.refresh() } } From e514a2fd94272928662de1751065f6f3c6cf2e3a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 17 Jul 2026 18:43:07 -0700 Subject: [PATCH 07/34] Animate spend with the numeric-text transition Host a SwiftUI MenuBarLabel in the status item (via a click-through NSHostingView, so the button still toggles the popover) instead of setting a plain NSButton title, and give both it and the popover's headline amount .contentTransition(.numericText()) so digit changes roll over. The hosted label observes the session directly, replacing the manual Observations title loop. --- Ledger/Ledger/Sources/LedgerApp.swift | 63 ++++++++++++++---------- Ledger/Ledger/Sources/MenuBarLabel.swift | 23 +++++++++ Ledger/Ledger/Sources/SpendView.swift | 2 + 3 files changed, 62 insertions(+), 26 deletions(-) create mode 100644 Ledger/Ledger/Sources/MenuBarLabel.swift diff --git a/Ledger/Ledger/Sources/LedgerApp.swift b/Ledger/Ledger/Sources/LedgerApp.swift index f7f52971..4a72b6a4 100644 --- a/Ledger/Ledger/Sources/LedgerApp.swift +++ b/Ledger/Ledger/Sources/LedgerApp.swift @@ -1,12 +1,11 @@ import AppKit import LedgerCore -import Observation import SwiftUI -/// The status item and popover are AppKit (not `MenuBarExtra`) on purpose: -/// the menu-bar title mirrors observable model state on every change, and the -/// AppKit `NSStatusItem` + `Observations` loop drives that reliably (the same -/// reason the old Foreman app landed here). See the module README. +/// The status item and popover are AppKit (not `MenuBarExtra`) on purpose (the +/// same reason the old Foreman app landed here). The status item hosts a small +/// SwiftUI `MenuBarLabel` so the amount gets the numeric-text roll-over and +/// updates itself from the observable session. See the module README. @main struct LedgerApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @@ -26,30 +25,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem? private var popover: NSPopover? - private var titleTask: Task? func applicationDidFinishLaunching(_: Notification) { session.start() let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - item.button?.target = self - item.button?.action = #selector(togglePopover) - item.button?.image = NSImage( - systemSymbolName: "dollarsign.circle", - accessibilityDescription: "Cursor spend", - ) - item.button?.imagePosition = .imageLeading statusItem = item + guard let button = item.button else { return } + button.target = self + button.action = #selector(togglePopover) + button.setAccessibilityTitle("Cursor spend") - updateTitle() - // AppKit owns the title, so keeping it current is a plain loop: the - // stdlib Observations sequence yields after every change to the - // derived status title (no SwiftUI invalidation involved). - titleTask = Task { [weak self, session] in - for await _ in Observations({ @MainActor in session.statusTitle }) { - self?.updateTitle() - } - } + // Host the SwiftUI label inside the status button. It's click-through + // (see ClickThroughHostingView) so the button still receives the click + // that toggles the popover, and the label sizes the (variable-length) + // item via its Auto Layout constraints. + let label = ClickThroughHostingView(rootView: MenuBarLabel(session: session)) + label.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: button.leadingAnchor), + label.trailingAnchor.constraint(equalTo: button.trailingAnchor), + label.topAnchor.constraint(equalTo: button.topAnchor), + label.bottomAnchor.constraint(equalTo: button.bottomAnchor), + ]) } /// Stop the refresh loop on quit. @@ -85,10 +84,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) return popover } +} + +/// An `NSHostingView` that never claims mouse hits, so the SwiftUI content it +/// draws is display-only and the enclosing status-item button keeps receiving +/// the click that toggles the popover. +private final class ClickThroughHostingView: NSHostingView { + override func hitTest(_: NSPoint) -> NSView? { + nil + } + + required init(rootView: Content) { + super.init(rootView: rootView) + } - /// Shows the current-cycle dollar amount beside the icon, always — a `$—` - /// placeholder until the first fetch lands — so the item is easy to spot. - private func updateTitle() { - statusItem?.button?.title = " \(session.statusTitle)" + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("not used") } } diff --git a/Ledger/Ledger/Sources/MenuBarLabel.swift b/Ledger/Ledger/Sources/MenuBarLabel.swift new file mode 100644 index 00000000..09530d67 --- /dev/null +++ b/Ledger/Ledger/Sources/MenuBarLabel.swift @@ -0,0 +1,23 @@ +import LedgerCore +import SwiftUI + +/// The SwiftUI content hosted in the status item: the `$` glyph and the +/// current-cycle amount, with the numeric-text content transition so digit +/// changes roll over like the popover's figure. Bound to the observable +/// session, it re-renders itself when the amount changes — no manual title +/// updates needed. +struct MenuBarLabel: View { + let session: LedgerSession + + var body: some View { + HStack(spacing: 2) { + Image(systemName: "dollarsign.circle") + Text(session.statusTitle) + .monospacedDigit() + .contentTransition(.numericText()) + } + .font(.system(size: 13)) + .padding(.horizontal, 4) + .animation(.default, value: session.statusTitle) + } +} diff --git a/Ledger/Ledger/Sources/SpendView.swift b/Ledger/Ledger/Sources/SpendView.swift index 07fbefb3..f1a85fa5 100644 --- a/Ledger/Ledger/Sources/SpendView.swift +++ b/Ledger/Ledger/Sources/SpendView.swift @@ -63,6 +63,8 @@ struct SpendView: View { Text(CurrencyFormat.dollars(snapshot.currentCycleDollars)) .font(.system(size: 34, weight: .semibold, design: .rounded)) .monospacedDigit() + .contentTransition(.numericText(value: snapshot.currentCycleDollars)) + .animation(.default, value: snapshot.currentCycleDollars) if let range = cycleRange(snapshot) { Text(range) .font(.caption2) From d398d8117742e80b9278767bc43dbee203c7647b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 17 Jul 2026 18:54:32 -0700 Subject: [PATCH 08/34] Size the status item to the hosted label so the amount shows A variable-length NSStatusItem doesn't auto-size to a hosted SwiftUI view, so only the leading icon was visible and the amount was clipped. MenuBarLabel now reports its rendered width (fixedSize + onGeometryChange) and the app delegate sets the item's length to match. --- Ledger/Ledger/Sources/LedgerApp.swift | 10 +++++++--- Ledger/Ledger/Sources/MenuBarLabel.swift | 7 +++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Ledger/Ledger/Sources/LedgerApp.swift b/Ledger/Ledger/Sources/LedgerApp.swift index 4a72b6a4..34ec9ec1 100644 --- a/Ledger/Ledger/Sources/LedgerApp.swift +++ b/Ledger/Ledger/Sources/LedgerApp.swift @@ -38,9 +38,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Host the SwiftUI label inside the status button. It's click-through // (see ClickThroughHostingView) so the button still receives the click - // that toggles the popover, and the label sizes the (variable-length) - // item via its Auto Layout constraints. - let label = ClickThroughHostingView(rootView: MenuBarLabel(session: session)) + // that toggles the popover. A variable-length item doesn't auto-size to + // a hosted view, so the label reports its width and we set the item's + // length to match (otherwise the amount is clipped to just the icon). + let label = + ClickThroughHostingView(rootView: MenuBarLabel(session: session) { [weak self] width in + self?.statusItem?.length = ceil(width) + }) label.translatesAutoresizingMaskIntoConstraints = false button.addSubview(label) NSLayoutConstraint.activate([ diff --git a/Ledger/Ledger/Sources/MenuBarLabel.swift b/Ledger/Ledger/Sources/MenuBarLabel.swift index 09530d67..371fb6ad 100644 --- a/Ledger/Ledger/Sources/MenuBarLabel.swift +++ b/Ledger/Ledger/Sources/MenuBarLabel.swift @@ -6,8 +6,13 @@ import SwiftUI /// changes roll over like the popover's figure. Bound to the observable /// session, it re-renders itself when the amount changes — no manual title /// updates needed. +/// +/// A hosted view doesn't auto-size a variable-length `NSStatusItem`, so the +/// label reports its rendered width via `onWidthChange`; the app delegate sets +/// the item's length to match (otherwise the amount is clipped to the icon). struct MenuBarLabel: View { let session: LedgerSession + let onWidthChange: (CGFloat) -> Void var body: some View { HStack(spacing: 2) { @@ -18,6 +23,8 @@ struct MenuBarLabel: View { } .font(.system(size: 13)) .padding(.horizontal, 4) + .fixedSize() .animation(.default, value: session.statusTitle) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { onWidthChange($0) } } } From 889e9eda6e236ba3316a2cf9f662341218cce708 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 17 Jul 2026 19:10:19 -0700 Subject: [PATCH 09/34] Enrich the popover: included-usage bar, status lines, top models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an included-usage progress bar (usage-summary totalPercentUsed) with Cursor's own status messages, and a top-models-this-cycle breakdown from get-aggregated-usage-events. The per-model figure measures compute differently from the billed on-demand headline (they don't reconcile), so models are shown as usage *shares*, not dollars. The aggregated fetch is best-effort — a failure logs and yields no models rather than failing the whole load. --- Ledger/Ledger/README.md | 8 +- Ledger/Ledger/Sources/PreviewSupport.swift | 43 ++++++---- Ledger/Ledger/Sources/SpendView.swift | 58 +++++++++++-- Ledger/LedgerCore/AGENTS.md | 8 +- Ledger/LedgerCore/README.md | 11 ++- .../LedgerCore/Sources/AggregatedUsage.swift | 81 +++++++++++++++++++ .../Sources/DashboardProvider.swift | 23 ++++++ .../LedgerCore/Sources/LedgerServices.swift | 25 +++++- Ledger/LedgerCore/Sources/SpendSnapshot.swift | 35 ++++---- Ledger/LedgerCore/Sources/TestDoubles.swift | 43 +++++++++- Ledger/LedgerCore/Sources/UsageSummary.swift | 21 +++++ .../Tests/AggregatedUsageTests.swift | 39 +++++++++ .../Tests/DashboardProviderTests.swift | 22 +++++ .../Tests/LedgerCoreTestSupport.swift | 28 +++++++ .../Tests/LedgerServicesTests.swift | 40 +++++++++ 15 files changed, 436 insertions(+), 49 deletions(-) create mode 100644 Ledger/LedgerCore/Sources/AggregatedUsage.swift create mode 100644 Ledger/LedgerCore/Tests/AggregatedUsageTests.swift diff --git a/Ledger/Ledger/README.md b/Ledger/Ledger/README.md index e1cbe430..31615896 100644 --- a/Ledger/Ledger/README.md +++ b/Ledger/Ledger/README.md @@ -39,11 +39,17 @@ shortcut to System Settings if macOS needs you to approve the login item. automatically every 15 minutes. Opening the popover just shows the latest fetched state; it doesn't trigger a network request. - **Popover** — this cycle's spend and date range, a **year-to-date** total, - included-usage used/limit, your plan tier, and when it last updated. A + your plan tier, an **included-usage** progress bar (with Cursor's own status + lines), **top models this cycle** as usage shares, and when it last updated. A **Refresh** button forces an immediate fetch. On an error (no session, an expired session, a network failure) it explains what to fix and offers a shortcut to Settings. +The per-model rows are shown as **relative shares**, not dollars: the dashboard's +per-model figure (`get-aggregated-usage-events`) measures compute differently +from the billed on-demand headline, so showing its dollars alongside the +headline would look like they don't add up. + ## Design notes - The status item and popover are **AppKit** (`NSStatusItem` + `NSPopover`), not diff --git a/Ledger/Ledger/Sources/PreviewSupport.swift b/Ledger/Ledger/Sources/PreviewSupport.swift index adefeaa1..51cd7be1 100644 --- a/Ledger/Ledger/Sources/PreviewSupport.swift +++ b/Ledger/Ledger/Sources/PreviewSupport.swift @@ -9,22 +9,35 @@ enum PreviewSupport { /// A session already showing spend. static func loadedSession() -> LedgerSession { - let provider = ScriptedDashboardProvider(.success( - summary: .fixture( - onDemandCents: 315_609, - membershipType: "ultra", - includedUsed: 40000, - includedLimit: 40000, + let provider = ScriptedDashboardProvider( + .success( + summary: .fixture( + onDemandCents: 315_609, + membershipType: "ultra", + includedUsed: 40000, + includedLimit: 40000, + totalPercentUsed: 20.9, + messages: [ + "You've used 21% of your included total usage", + "You've used 100% of your included API usage", + ], + ), + invoiceCentsByMonth: [ + 1: 120_000, + 2: 98000, + 3: 210_000, + 4: 175_000, + 5: 260_000, + 6: 288_000, + ], ), - invoiceCentsByMonth: [ - 1: 120_000, - 2: 98000, - 3: 210_000, - 4: 175_000, - 5: 260_000, - 6: 288_000, - ], - )) + aggregated: .fixture([ + "claude-opus-4-8-thinking-xhigh": 28929, + "claude-fable-5-thinking-xhigh": 21082, + "composer-2.5-fast": 8008, + "github_bugbot": 606, + ]), + ) let session = session( provider: provider, autoToken: SessionToken(cookieValue: "user_preview::jwt"), diff --git a/Ledger/Ledger/Sources/SpendView.swift b/Ledger/Ledger/Sources/SpendView.swift index f1a85fa5..0fc6c933 100644 --- a/Ledger/Ledger/Sources/SpendView.swift +++ b/Ledger/Ledger/Sources/SpendView.swift @@ -74,17 +74,17 @@ struct SpendView: View { VStack(alignment: .leading, spacing: 4) { breakdownRow("This year", CurrencyFormat.dollars(snapshot.yearToDateDollars)) - if let used = snapshot.includedUsedDollars, - let limit = snapshot.includedLimitDollars - { - breakdownRow( - "Included usage", - "\(CurrencyFormat.dollars(used)) / \(CurrencyFormat.dollars(limit))", - ) - } breakdownRow("Plan", snapshot.membershipType.capitalized) } + if let fraction = snapshot.includedFractionUsed { + includedUsage(fraction, messages: snapshot.usageMessages) + } + + if !snapshot.topModels.isEmpty { + topModels(snapshot.topModels) + } + if let updated = session.lastUpdated { Text("Updated \(updated.formatted(date: .omitted, time: .shortened))") .font(.caption2) @@ -93,6 +93,48 @@ struct SpendView: View { } } + private func includedUsage(_ fraction: Double, messages: [String]) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Included usage") + .foregroundStyle(.secondary) + Spacer() + Text(fraction.formatted(.percent.precision(.fractionLength(0)))) + .monospacedDigit() + } + .font(.callout) + ProgressView(value: min(max(fraction, 0), 1)) + ForEach(messages, id: \.self) { message in + Text(message) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + private func topModels(_ models: [ModelShare]) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("Top models this cycle") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(models) { model in + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(model.name) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(model.fraction.formatted(.percent.precision(.fractionLength(0)))) + .monospacedDigit() + .foregroundStyle(.secondary) + } + .font(.caption) + ProgressView(value: min(max(model.fraction, 0), 1)) + } + } + } + } + private func breakdownRow(_ label: String, _ value: String) -> some View { HStack { Text(label) diff --git a/Ledger/LedgerCore/AGENTS.md b/Ledger/LedgerCore/AGENTS.md index f3dfe1ff..858fe068 100644 --- a/Ledger/LedgerCore/AGENTS.md +++ b/Ledger/LedgerCore/AGENTS.md @@ -12,7 +12,7 @@ per-type detail. LedgerServices ── LedgerSettings (refresh interval) ├────────── SessionTokenSource ── CursorLocalTokenSource (state.vscdb, read-only) ├────────── KeychainStore (a pasted token override) - ├────────── DashboardProvider ── CursorDashboardAPI (/api/usage-summary, get-monthly-invoice) + ├────────── DashboardProvider ── CursorDashboardAPI (usage-summary, get-monthly-invoice, get-aggregated-usage-events) └────────── LoginItemController (SMAppService) ``` @@ -48,6 +48,12 @@ build system, formatting, and global conventions. Read that first. live cycle figure.** The current month's invoice lags until charges post, so the live usage-summary number stands in for it — don't double-count by also summing the current month's (sparse) invoice. +- **Per-model usage is a dollar-free share, and best-effort.** + `get-aggregated-usage-events`' `totalCostCents` measures compute differently + from the billed on-demand figure (they don't reconcile), so `ModelShare` + carries only a fraction — never present it as spend next to the headline. A + failure to fetch it logs a warning and yields no models; it must not fail the + whole load. - **Failures are observable, never swallowed.** Transport/HTTP/decode failures become a typed `DashboardError`, mapped into `LoadState.failed(LoadError)` and logged; 401 maps to `.notAuthenticated` (expired session). A stale response diff --git a/Ledger/LedgerCore/README.md b/Ledger/LedgerCore/README.md index d10beb53..61bbf062 100644 --- a/Ledger/LedgerCore/README.md +++ b/Ledger/LedgerCore/README.md @@ -12,8 +12,9 @@ lives in the [`Ledger`](../Ledger) app target and binds this tree directly. (`state.vscdb` → `cursorAuth/accessToken`), or a value you **paste** into Settings (stored in the Keychain, which overrides auto-detect). - Calls `GET /api/usage-summary` for the current cycle's dates, plan type, and - live usage-based spend, and `POST /api/dashboard/get-monthly-invoice` for each - prior month of the year. + live usage-based spend, `POST /api/dashboard/get-monthly-invoice` for each + prior month of the year, and `POST /api/dashboard/get-aggregated-usage-events` + for the per-model breakdown (best-effort). - Reduces it all to one observable `LoadState` (`idle` / `loading` / `loaded(SpendSnapshot)` / `failed(LoadError)`). @@ -56,6 +57,12 @@ auto-token", surfaced as `LoadError.missingCredentials`. the current cycle's live figure (the current month's invoice lags until charges post, so the live number stands in for it). +- **Top models** = `get-aggregated-usage-events` over the cycle window, shown as + each model's **share** of that endpoint's total. This is deliberately + dollar-free: its `totalCostCents` measures compute differently from the billed + on-demand figure, so it must not be presented as spend. Best-effort — a + failure logs and yields an empty list rather than failing the whole load. + All money is cents. Note: on a plan with usage-based pricing off, these `$` figures reflect included-compute value, not money owed. diff --git a/Ledger/LedgerCore/Sources/AggregatedUsage.swift b/Ledger/LedgerCore/Sources/AggregatedUsage.swift new file mode 100644 index 00000000..b664c68e --- /dev/null +++ b/Ledger/LedgerCore/Sources/AggregatedUsage.swift @@ -0,0 +1,81 @@ +import Foundation + +/// The decoded `POST /api/dashboard/get-aggregated-usage-events` response — +/// per-model usage for a date range. Token counts arrive as strings on the +/// wire, so they're modeled as `String` and exposed as `Int` via `totalTokens`. +/// +/// Note: `totalCostCents` here is the dashboard's per-model compute measure, +/// which does **not** equal the billed on-demand figure in the usage summary — +/// Ledger uses it only for relative per-model **share**, never as spend. +public struct AggregatedUsage: Codable, Equatable, Sendable { + public var aggregations: [ModelUsage] + public var totalCostCents: Double + + public struct ModelUsage: Codable, Equatable, Sendable { + public var modelIntent: String + public var totalCents: Double + public var tier: Int? + public var inputTokens: String? + public var outputTokens: String? + public var cacheWriteTokens: String? + public var cacheReadTokens: String? + + public init( + modelIntent: String, + totalCents: Double, + tier: Int? = nil, + inputTokens: String? = nil, + outputTokens: String? = nil, + cacheWriteTokens: String? = nil, + cacheReadTokens: String? = nil, + ) { + self.modelIntent = modelIntent + self.totalCents = totalCents + self.tier = tier + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheWriteTokens = cacheWriteTokens + self.cacheReadTokens = cacheReadTokens + } + + /// Input + output + cache tokens, parsed from the wire strings. + public var totalTokens: Int { + [inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens] + .compactMap(\.self) + .compactMap { Int($0) } + .reduce(0, +) + } + } + + public init(aggregations: [ModelUsage], totalCostCents: Double) { + self.aggregations = aggregations + self.totalCostCents = totalCostCents + } + + /// The top `limit` models by compute, each as a ``ModelShare`` (fraction of + /// `totalCostCents`), highest first. + public func topModels(limit: Int) -> [ModelShare] { + let total = aggregations.reduce(0) { $0 + $1.totalCents } + guard total > 0 else { return [] } + return aggregations + .sorted { $0.totalCents > $1.totalCents } + .prefix(limit) + .map { ModelShare(name: $0.modelIntent, fraction: $0.totalCents / total) } + } +} + +/// One model's relative share of the current cycle's usage (0...1). Deliberately +/// dollar-free — see the note on ``AggregatedUsage``. +public struct ModelShare: Equatable, Sendable, Identifiable { + public var name: String + public var fraction: Double + + public var id: String { + name + } + + public init(name: String, fraction: Double) { + self.name = name + self.fraction = fraction + } +} diff --git a/Ledger/LedgerCore/Sources/DashboardProvider.swift b/Ledger/LedgerCore/Sources/DashboardProvider.swift index b052f462..23259d52 100644 --- a/Ledger/LedgerCore/Sources/DashboardProvider.swift +++ b/Ledger/LedgerCore/Sources/DashboardProvider.swift @@ -20,6 +20,9 @@ public protocol DashboardProvider: Sendable { func usageSummary(token: SessionToken) async throws -> UsageSummary /// The itemized invoice for a given month (`month` is 1-based) and year. func monthlyInvoice(month: Int, year: Int, token: SessionToken) async throws -> MonthlyInvoice + /// Per-model usage aggregated over `[startDate, endDate]`. + func aggregatedUsage(startDate: Date, endDate: Date, token: SessionToken) async throws + -> AggregatedUsage } /// The production ``DashboardProvider``: the same undocumented endpoints the @@ -68,6 +71,26 @@ public struct CursorDashboardAPI: DashboardProvider { return try await send(request, decoding: MonthlyInvoice.self) } + public func aggregatedUsage( + startDate: Date, + endDate: Date, + token: SessionToken, + ) async throws -> AggregatedUsage { + // `teamId: -1` selects individual usage; dates are epoch milliseconds. + let body = try JSONSerialization.data(withJSONObject: [ + "teamId": -1, + "startDate": Int(startDate.timeIntervalSince1970 * 1000), + "endDate": Int(endDate.timeIntervalSince1970 * 1000), + ]) + let request = makeRequest( + path: "/api/dashboard/get-aggregated-usage-events", + method: "POST", + token: token, + body: body, + ) + return try await send(request, decoding: AggregatedUsage.self) + } + private func makeRequest( path: String, method: String, diff --git a/Ledger/LedgerCore/Sources/LedgerServices.swift b/Ledger/LedgerCore/Sources/LedgerServices.swift index 507ba193..7ed9c976 100644 --- a/Ledger/LedgerCore/Sources/LedgerServices.swift +++ b/Ledger/LedgerCore/Sources/LedgerServices.swift @@ -189,6 +189,7 @@ public final class LedgerServices { year: year, token: token, ) + let models = await topModels(cycleStart: summary.cycleStart, token: token) guard generation == requestGeneration else { return } let snapshot = SpendSnapshot( @@ -200,8 +201,9 @@ public final class LedgerServices { cycleStart: summary.cycleStart, cycleEnd: summary.cycleEnd, membershipType: summary.membershipType, - includedUsedCents: summary.individualUsage.plan.used, - includedLimitCents: summary.individualUsage.plan.limit, + includedFractionUsed: summary.includedFractionUsed, + usageMessages: summary.usageMessages, + topModels: models, ) loadState = .loaded(snapshot) lastUpdated = now() @@ -238,6 +240,25 @@ public final class LedgerServices { } } + /// The top models by usage for the current cycle, as relative shares. + /// Best-effort: the per-model breakdown is supplementary, so a failure (or + /// an unknown cycle start) logs and yields an empty list rather than + /// failing the whole load. + private func topModels(cycleStart: Date?, token: SessionToken) async -> [ModelShare] { + guard let cycleStart else { return [] } + do { + let usage = try await provider.aggregatedUsage( + startDate: cycleStart, + endDate: now(), + token: token, + ) + return usage.topModels(limit: 5) + } catch { + Self.logger.warning("Couldn't load per-model usage: \(error.localizedDescription)") + return [] + } + } + // MARK: - Token /// A pasted token (Keychain) wins as an explicit override; otherwise the diff --git a/Ledger/LedgerCore/Sources/SpendSnapshot.swift b/Ledger/LedgerCore/Sources/SpendSnapshot.swift index fd38f2c9..5ff2825d 100644 --- a/Ledger/LedgerCore/Sources/SpendSnapshot.swift +++ b/Ledger/LedgerCore/Sources/SpendSnapshot.swift @@ -1,7 +1,8 @@ import Foundation -/// The spend figures Ledger renders, distilled from the usage summary and the -/// year's monthly invoices into one value the UI binds to. All money is cents. +/// The spend figures Ledger renders, distilled from the usage summary, the +/// year's monthly invoices, and the per-model aggregation into one value the UI +/// binds to. All money is cents. public struct SpendSnapshot: Equatable, Sendable { /// Usage-based spend for the current billing cycle (live, from the usage /// summary). @@ -14,10 +15,14 @@ public struct SpendSnapshot: Equatable, Sendable { public var cycleEnd: Date? /// Plan tier (`"pro"`, `"ultra"`, …). public var membershipType: String - /// Included-allowance usage this cycle, in cents (when reported). - public var includedUsedCents: Int? - /// The included allowance in cents (when reported). - public var includedLimitCents: Int? + /// Fraction (0...1) of the included allowance used this cycle, when known. + public var includedFractionUsed: Double? + /// The dashboard's own status lines (e.g. "You've used 21% of your included + /// total usage"), for display verbatim. + public var usageMessages: [String] + /// Top models by usage this cycle, as relative shares (dollar-free — see + /// ``AggregatedUsage``). Empty when the per-model fetch is unavailable. + public var topModels: [ModelShare] public init( currentCycleCents: Int, @@ -25,16 +30,18 @@ public struct SpendSnapshot: Equatable, Sendable { cycleStart: Date?, cycleEnd: Date?, membershipType: String, - includedUsedCents: Int?, - includedLimitCents: Int?, + includedFractionUsed: Double?, + usageMessages: [String], + topModels: [ModelShare], ) { self.currentCycleCents = currentCycleCents self.yearToDateCents = yearToDateCents self.cycleStart = cycleStart self.cycleEnd = cycleEnd self.membershipType = membershipType - self.includedUsedCents = includedUsedCents - self.includedLimitCents = includedLimitCents + self.includedFractionUsed = includedFractionUsed + self.usageMessages = usageMessages + self.topModels = topModels } public var currentCycleDollars: Double { @@ -44,12 +51,4 @@ public struct SpendSnapshot: Equatable, Sendable { public var yearToDateDollars: Double { Double(yearToDateCents) / 100 } - - public var includedUsedDollars: Double? { - includedUsedCents.map { Double($0) / 100 } - } - - public var includedLimitDollars: Double? { - includedLimitCents.map { Double($0) / 100 } - } } diff --git a/Ledger/LedgerCore/Sources/TestDoubles.swift b/Ledger/LedgerCore/Sources/TestDoubles.swift index 14605d93..d2643757 100644 --- a/Ledger/LedgerCore/Sources/TestDoubles.swift +++ b/Ledger/LedgerCore/Sources/TestDoubles.swift @@ -13,14 +13,26 @@ } private let outcome: Outcome + private let aggregated: AggregatedUsage + /// When set, only `aggregatedUsage` throws it — exercises the + /// best-effort per-model path (summary/invoices still succeed). + private let aggregatedFailure: DashboardError? - public init(_ outcome: Outcome) { + public init( + _ outcome: Outcome, + aggregated: AggregatedUsage = AggregatedUsage(aggregations: [], totalCostCents: 0), + aggregatedFailure: DashboardError? = nil, + ) { self.outcome = outcome + self.aggregated = aggregated + self.aggregatedFailure = aggregatedFailure } /// Convenience: a successful summary with no prior-month invoices. public init(summary: UsageSummary) { outcome = .success(summary: summary, invoiceCentsByMonth: [:]) + aggregated = AggregatedUsage(aggregations: [], totalCostCents: 0) + aggregatedFailure = nil } public func usageSummary(token _: SessionToken) async throws -> UsageSummary { @@ -46,6 +58,16 @@ throw error } } + + public func aggregatedUsage( + startDate _: Date, + endDate _: Date, + token _: SessionToken, + ) async throws -> AggregatedUsage { + if let aggregatedFailure { throw aggregatedFailure } + if case let .failure(error) = outcome { throw error } + return aggregated + } } /// A ``SessionTokenSource`` that returns a fixed token (or none), so @@ -100,10 +122,12 @@ membershipType: String = "pro", includedUsed: Int = 0, includedLimit: Int? = nil, + totalPercentUsed: Double? = nil, + messages: [String] = [], cycleStart: String = "2026-07-04T18:16:08.000Z", cycleEnd: String = "2026-08-04T18:16:08.000Z", ) -> UsageSummary { - UsageSummary( + var summary = UsageSummary( billingCycleStart: cycleStart, billingCycleEnd: cycleEnd, membershipType: membershipType, @@ -115,9 +139,24 @@ limit: includedLimit, remaining: nil, breakdown: nil, + totalPercentUsed: totalPercentUsed, ), ), ) + summary.autoModelSelectedDisplayMessage = messages.first + summary.namedModelSelectedDisplayMessage = messages.count > 1 ? messages[1] : nil + return summary + } + } + + extension AggregatedUsage { + /// A per-model aggregation from `[model: costCents]` pairs. + public static func fixture(_ modelCents: KeyValuePairs) -> AggregatedUsage { + let models = modelCents.map { ModelUsage(modelIntent: $0.key, totalCents: $0.value) } + return AggregatedUsage( + aggregations: models, + totalCostCents: models.reduce(0) { $0 + $1.totalCents }, + ) } } #endif diff --git a/Ledger/LedgerCore/Sources/UsageSummary.swift b/Ledger/LedgerCore/Sources/UsageSummary.swift index a95b7015..0b9bba93 100644 --- a/Ledger/LedgerCore/Sources/UsageSummary.swift +++ b/Ledger/LedgerCore/Sources/UsageSummary.swift @@ -14,6 +14,10 @@ public struct UsageSummary: Codable, Equatable, Sendable { /// `"pro"`, `"ultra"`, `"free"`, etc. public var membershipType: String public var individualUsage: IndividualUsage + /// The dashboard's own human-readable status lines, when present (e.g. + /// "You've used 21% of your included total usage"). + public var autoModelSelectedDisplayMessage: String? = nil + public var namedModelSelectedDisplayMessage: String? = nil public struct IndividualUsage: Codable, Equatable, Sendable { /// Usage-based (pay-per-use) spend — the money beyond the subscription. @@ -39,6 +43,12 @@ public struct UsageSummary: Codable, Equatable, Sendable { public var limit: Int? public var remaining: Int? public var breakdown: Breakdown? + /// Percentage (0...100) of the total included allowance used this + /// cycle, when reported — the figure behind the "N% of included usage" + /// message. + public var totalPercentUsed: Double? = nil + public var autoPercentUsed: Double? = nil + public var apiPercentUsed: Double? = nil } public struct Breakdown: Codable, Equatable, Sendable { @@ -74,6 +84,17 @@ public struct UsageSummary: Codable, Equatable, Sendable { individualUsage.onDemand.used } + /// Fraction (0...1) of the included allowance used this cycle, when the API + /// reports a percentage. + public var includedFractionUsed: Double? { + individualUsage.plan.totalPercentUsed.map { $0 / 100 } + } + + /// The dashboard's status lines, in order, omitting any the API didn't send. + public var usageMessages: [String] { + [autoModelSelectedDisplayMessage, namedModelSelectedDisplayMessage].compactMap(\.self) + } + private static func parseDate(_ string: String) -> Date? { // Dashboard timestamps carry fractional seconds ("…T18:16:08.000Z"). let withFraction = ISO8601DateFormatter() diff --git a/Ledger/LedgerCore/Tests/AggregatedUsageTests.swift b/Ledger/LedgerCore/Tests/AggregatedUsageTests.swift new file mode 100644 index 00000000..346b99f3 --- /dev/null +++ b/Ledger/LedgerCore/Tests/AggregatedUsageTests.swift @@ -0,0 +1,39 @@ +import Foundation +@_spi(Testing) import LedgerCore +import Testing + +struct AggregatedUsageTests { + @Test func decodesStringTokenCounts() throws { + let usage = try JSONDecoder().decode( + AggregatedUsage.self, + from: Data(DashboardFixture.aggregatedJSON.utf8), + ) + #expect(usage.aggregations.count == 2) + let opus = try #require(usage.aggregations.first) + #expect(opus.modelIntent == "claude-opus-4-8-thinking-xhigh") + #expect(opus.totalCents == 28929.15) + #expect(opus.tier == 1) + // 1846267 + 2088128 + 12919376 + 316915979 + #expect(opus.totalTokens == 333_769_750) + } + + @Test func topModelsAreSharesSortedByCostHighestFirst() { + let usage = AggregatedUsage.fixture([ + "b": 25, + "a": 75, + ]) + let top = usage.topModels(limit: 5) + #expect(top.map(\.name) == ["a", "b"]) + #expect(top[0].fraction == 0.75) + #expect(top[1].fraction == 0.25) + } + + @Test func topModelsRespectsTheLimit() { + let usage = AggregatedUsage.fixture(["a": 4, "b": 3, "c": 2, "d": 1]) + #expect(usage.topModels(limit: 2).map(\.name) == ["a", "b"]) + } + + @Test func topModelsIsEmptyWhenNoUsage() { + #expect(AggregatedUsage(aggregations: [], totalCostCents: 0).topModels(limit: 5).isEmpty) + } +} diff --git a/Ledger/LedgerCore/Tests/DashboardProviderTests.swift b/Ledger/LedgerCore/Tests/DashboardProviderTests.swift index 1b27aa5e..06a1b821 100644 --- a/Ledger/LedgerCore/Tests/DashboardProviderTests.swift +++ b/Ledger/LedgerCore/Tests/DashboardProviderTests.swift @@ -25,4 +25,26 @@ struct DashboardProviderTests { try await provider.usageSummary(token: token) } } + + @Test func scriptedProviderReturnsScriptedAggregatedUsage() async throws { + let provider = ScriptedDashboardProvider( + summary: .fixture(onDemandCents: 0), + ) + // The convenience init supplies empty aggregation. + let usage = try await provider.aggregatedUsage(startDate: .now, endDate: .now, token: token) + #expect(usage.aggregations.isEmpty) + } + + @Test func scriptedProviderCanFailOnlyAggregated() async throws { + let provider = ScriptedDashboardProvider( + .success(summary: .fixture(onDemandCents: 10), invoiceCentsByMonth: [:]), + aggregatedFailure: .http(500), + ) + // Summary still succeeds… + _ = try await provider.usageSummary(token: token) + // …while the per-model call fails. + await #expect(throws: DashboardError.http(500)) { + try await provider.aggregatedUsage(startDate: .now, endDate: .now, token: token) + } + } } diff --git a/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift b/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift index 6da034ea..81db7fd5 100644 --- a/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift +++ b/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift @@ -96,4 +96,32 @@ enum DashboardFixture { "periodEndMs": "1788220800000" } """ + + /// A `get-aggregated-usage-events` body — token fields are strings on the wire. + static let aggregatedJSON = """ + { + "aggregations": [ + { + "modelIntent": "claude-opus-4-8-thinking-xhigh", + "inputTokens": "1846267", + "outputTokens": "2088128", + "cacheWriteTokens": "12919376", + "cacheReadTokens": "316915979", + "totalCents": 28929.15, + "tier": 1 + }, + { + "modelIntent": "composer-2.5-fast", + "inputTokens": "8294988", + "outputTokens": "809154", + "cacheReadTokens": "86124639", + "totalCents": 8008.45, + "tier": 2 + } + ], + "totalInputTokens": "10141255", + "totalOutputTokens": "2897282", + "totalCostCents": 36937.6 + } + """ } diff --git a/Ledger/LedgerCore/Tests/LedgerServicesTests.swift b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift index f42adc85..8e340144 100644 --- a/Ledger/LedgerCore/Tests/LedgerServicesTests.swift +++ b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift @@ -84,6 +84,46 @@ struct LedgerServicesTests { #expect(snapshot.currentCycleCents == 999) } + @Test func loadsTopModelsAsShares() async { + let provider = ScriptedDashboardProvider( + .success(summary: .fixture(onDemandCents: 5000), invoiceCentsByMonth: [:]), + aggregated: .fixture(["a": 75, "b": 25]), + ) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + await services.refresh() + + guard case let .loaded(snapshot) = services.loadState else { + Issue.record("expected loaded, got \(services.loadState)") + return + } + #expect(snapshot.topModels.map(\.name) == ["a", "b"]) + #expect(snapshot.topModels.first?.fraction == 0.75) + } + + @Test func stillLoadsWhenPerModelFetchFails() async { + // The per-model breakdown is best-effort: its failure must not blank + // the headline. + let provider = ScriptedDashboardProvider( + .success(summary: .fixture(onDemandCents: 5000), invoiceCentsByMonth: [:]), + aggregatedFailure: .http(500), + ) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + await services.refresh() + + guard case let .loaded(snapshot) = services.loadState else { + Issue.record("expected loaded, got \(services.loadState)") + return + } + #expect(snapshot.currentCycleCents == 5000) + #expect(snapshot.topModels.isEmpty) + } + @Test func mapsNotAuthenticated() async { let services = makeServices( provider: ScriptedDashboardProvider(.failure(.notAuthenticated)), From 56e11c65334b78b657719af0a9e824b1c086f712 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 18 Jul 2026 11:00:32 -0700 Subject: [PATCH 10/34] Add Ledger/install: build Release and install to /Applications A script to run Ledger standalone without Xcode: regenerates the project, builds the Release configuration via xcodebuild (ad-hoc signed, so no Apple Developer account needed), installs to /Applications (quitting any running copy first), and launches it. --no-open skips the launch. --- Ledger/Ledger/README.md | 14 ++++++++ Ledger/install | 73 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100755 Ledger/install diff --git a/Ledger/Ledger/README.md b/Ledger/Ledger/README.md index 31615896..8a90f118 100644 --- a/Ledger/Ledger/README.md +++ b/Ledger/Ledger/README.md @@ -19,6 +19,20 @@ or run the `Ledger` scheme from Xcode. The app lives in the menu bar (a dollar-sign icon with the amount beside it once loaded) and shows no Dock icon (`LSUIElement`). It keeps running until you quit it from the popover. +### Install to /Applications + +To run it standalone (no Xcode), use the install script — it builds a Release +build, installs it to `/Applications`, and launches it: + +```bash +Ledger/install # build, install to /Applications, and launch +Ledger/install --no-open # build and install without launching +``` + +The app is ad-hoc code-signed (no Apple Developer account needed) and built +locally (no Gatekeeper quarantine). Re-run it to update your installed copy; it +quits any running instance first. + ## Setup If you're signed in to the Cursor app, **there's nothing to configure** — Ledger diff --git a/Ledger/install b/Ledger/install new file mode 100755 index 00000000..727f7332 --- /dev/null +++ b/Ledger/install @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# +# Build Ledger in Release and install it to /Applications so it runs standalone +# (no Xcode needed). Usage: +# +# Ledger/install build, install to /Applications, and launch +# Ledger/install --no-open build and install, but don't launch +# Ledger/install --help show this help +# +# The app is ad-hoc code-signed, so it works without an Apple Developer account; +# it's built locally (no quarantine), so Gatekeeper won't block it. + +set -euo pipefail + +APP_NAME="Ledger" +DEST="/Applications/${APP_NAME}.app" +OPEN_AFTER=1 + +for arg in "$@"; do + case "$arg" in + --no-open) OPEN_AFTER=0 ;; + -h | --help) + sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown option: $arg (try --help)" >&2 + exit 2 + ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +DERIVED="$(mktemp -d)" +trap 'rm -rf "$DERIVED"' EXIT + +echo "==> Generating the Xcode project" +mise exec -- tuist generate --no-open >/dev/null + +echo "==> Building ${APP_NAME} (Release)" +# Build straight through xcodebuild (not `tuist build`) so we control the +# DerivedData path and can find the product. Ad-hoc signing keeps it +# account-free while still satisfying SMAppService (launch-at-login) + Keychain. +mise exec -- xcodebuild \ + -workspace "Stuff.xcworkspace" \ + -scheme "${APP_NAME}" \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + -derivedDataPath "$DERIVED" \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=YES \ + build + +BUILT="${DERIVED}/Build/Products/Release/${APP_NAME}.app" +if [ ! -d "$BUILT" ]; then + echo "error: build product not found at ${BUILT}" >&2 + exit 1 +fi + +echo "==> Installing to ${DEST}" +# Quit any running copy (e.g. an Xcode-launched one) so the replace succeeds. +osascript -e "tell application \"${APP_NAME}\" to quit" >/dev/null 2>&1 || true +rm -rf "$DEST" +cp -R "$BUILT" "$DEST" + +echo "==> Installed ${APP_NAME} to ${DEST}" +if [ "$OPEN_AFTER" -eq 1 ]; then + open "$DEST" + echo "==> Launched. Look for the \$ amount in your menu bar." +fi From 88d06e1d7c83522a560299b1b86fb64d8949e782 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 18 Jul 2026 11:02:35 -0700 Subject: [PATCH 11/34] install: wait for the old app to exit before replacing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quit is asynchronous, so poll until the installed binary has actually exited (force-killing as a fallback) before rm/cp — making repeated runs race-free. --- Ledger/install | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Ledger/install b/Ledger/install index 727f7332..09a7741f 100755 --- a/Ledger/install +++ b/Ledger/install @@ -61,8 +61,17 @@ if [ ! -d "$BUILT" ]; then fi echo "==> Installing to ${DEST}" -# Quit any running copy (e.g. an Xcode-launched one) so the replace succeeds. +# Quit any running copy (an Xcode-launched one, or a previously installed one) +# so the replace is clean. `quit` is asynchronous, so wait for the installed +# binary to actually exit before replacing it, then force-kill as a fallback. +INSTALLED_BIN="${DEST}/Contents/MacOS/${APP_NAME}" osascript -e "tell application \"${APP_NAME}\" to quit" >/dev/null 2>&1 || true +for _ in $(seq 1 50); do + pgrep -f "$INSTALLED_BIN" >/dev/null 2>&1 || break + sleep 0.1 +done +pkill -f "$INSTALLED_BIN" 2>/dev/null || true + rm -rf "$DEST" cp -R "$BUILT" "$DEST" From afa9062b3d22a18ff4375760208c371571c290bf Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sat, 18 Jul 2026 11:03:33 -0700 Subject: [PATCH 12/34] Set Ledger's LSApplicationCategoryType Silences the 'No App Category is set' build warning by tagging Ledger as a developer-tools app in its Info.plist. --- Project.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Project.swift b/Project.swift index 087551d2..c96b18a9 100644 --- a/Project.swift +++ b/Project.swift @@ -225,6 +225,7 @@ let project = Project( "CFBundlePackageType": .string("APPL"), "CFBundleShortVersionString": .string("1.0"), "CFBundleVersion": .string("1"), + "LSApplicationCategoryType": .string("public.app-category.developer-tools"), "LSMinimumSystemVersion": .string("$(MACOSX_DEPLOYMENT_TARGET)"), "LSUIElement": .boolean(true), "NSPrincipalClass": .string("NSApplication"), From 14925eca8d4f323faded8a8e5a65b324d0181c8d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 09:54:50 -0700 Subject: [PATCH 13/34] Keep spend visible during a refresh Refreshing flipped loadState to .loading, which cleared the popover to a spinner. Now a refresh keeps the last loaded snapshot on screen and drives only a small header spinner via a new isRefreshing flag; the full loading state is used only for the very first load. Adds a gated-provider regression test. --- Ledger/Ledger/Sources/LedgerSession.swift | 6 ++ Ledger/Ledger/Sources/SpendView.swift | 2 +- .../LedgerCore/Sources/LedgerServices.swift | 17 +++- .../Tests/LedgerServicesTests.swift | 94 +++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/Ledger/Ledger/Sources/LedgerSession.swift b/Ledger/Ledger/Sources/LedgerSession.swift index 169c7c55..98a4efb2 100644 --- a/Ledger/Ledger/Sources/LedgerSession.swift +++ b/Ledger/Ledger/Sources/LedgerSession.swift @@ -21,6 +21,12 @@ final class LedgerSession { services.lastUpdated } + /// Whether a fetch is in flight (drives the header spinner without clearing + /// the shown data). + var isRefreshing: Bool { + services.isRefreshing + } + /// Whether a token was pasted (a manual override) vs. auto-detected. var hasManualToken: Bool { services.hasManualToken diff --git a/Ledger/Ledger/Sources/SpendView.swift b/Ledger/Ledger/Sources/SpendView.swift index 0fc6c933..0fbc2b2c 100644 --- a/Ledger/Ledger/Sources/SpendView.swift +++ b/Ledger/Ledger/Sources/SpendView.swift @@ -25,7 +25,7 @@ struct SpendView: View { Text("Cursor Spend") .font(.headline) Spacer() - if case .loading = session.loadState { + if session.isRefreshing { ProgressView() .controlSize(.small) } diff --git a/Ledger/LedgerCore/Sources/LedgerServices.swift b/Ledger/LedgerCore/Sources/LedgerServices.swift index 7ed9c976..9b899093 100644 --- a/Ledger/LedgerCore/Sources/LedgerServices.swift +++ b/Ledger/LedgerCore/Sources/LedgerServices.swift @@ -55,6 +55,11 @@ public final class LedgerServices { /// When the last successful fetch completed, for the "updated …" caption. public private(set) var lastUpdated: Date? + /// Whether a fetch is currently in flight. Distinct from `loadState`: + /// during a refresh the last loaded data stays visible (so the UI doesn't + /// clear), and this drives only a subtle in-progress indicator. + public private(set) var isRefreshing: Bool = false + /// Whether a token was pasted into the Keychain (a manual override). public private(set) var hasManualToken: Bool = false @@ -177,7 +182,17 @@ public final class LedgerServices { requestGeneration += 1 let generation = requestGeneration - loadState = .loading + // Keep any already-loaded data on screen during a refresh — only show + // the full-screen loading state for the very first load. The header + // spinner (driven by `isRefreshing`) signals the in-flight fetch. + if case .loaded = loadState {} else { + loadState = .loading + } + isRefreshing = true + defer { + // Don't clear the flag for a newer refresh that superseded this one. + if generation == requestGeneration { isRefreshing = false } + } do { let summary = try await provider.usageSummary(token: token) diff --git a/Ledger/LedgerCore/Tests/LedgerServicesTests.swift b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift index 8e340144..788e0668 100644 --- a/Ledger/LedgerCore/Tests/LedgerServicesTests.swift +++ b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift @@ -163,4 +163,98 @@ struct LedgerServicesTests { #expect(LedgerServices.LoadError.missingCredentials.message.contains("Cursor")) #expect(LedgerServices.LoadError.notAuthenticated.message.contains("expired")) } + + @Test func keepsLoadedDataVisibleDuringARefresh() async { + let provider = GatedDashboardProvider(summary: .fixture(onDemandCents: 5000)) + let services = makeServices( + provider: provider, + autoToken: SessionToken(cookieValue: "auto::jwt"), + ) + + // First load completes (the first usage-summary call isn't gated). + await services.refresh() + #expect(isLoaded(services.loadState)) + #expect(!services.isRefreshing) + + // A second refresh suspends inside usage-summary. + let task = Task { await services.refresh() } + await waitUntil { services.isRefreshing } + + // The already-loaded data stays on screen — not cleared to `.loading`. + #expect(isLoaded(services.loadState)) + + provider.release() + await task.value + #expect(!services.isRefreshing) + #expect(isLoaded(services.loadState)) + } + + private func isLoaded(_ state: LedgerServices.LoadState) -> Bool { + if case .loaded = state { true } else { false } + } + + private func waitUntil(_ predicate: () -> Bool) async { + for _ in 0 ..< 1000 { + if predicate() { return } + await Task.yield() + } + } +} + +/// A `DashboardProvider` whose *second* `usageSummary` call blocks until +/// `release()`, so a test can observe the in-flight-refresh state. +private final class GatedDashboardProvider: DashboardProvider, @unchecked Sendable { + private let summary: UsageSummary + private let lock = NSLock() + private var callCount = 0 + private var stored: CheckedContinuation? + private var released = false + + init(summary: UsageSummary) { + self.summary = summary + } + + func usageSummary(token _: SessionToken) async throws -> UsageSummary { + let shouldGate = lock.withLock { + callCount += 1 + return callCount >= 2 + } + if shouldGate { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let releaseNow = lock.withLock { () -> Bool in + if released { return true } + stored = continuation + return false + } + if releaseNow { continuation.resume() } + } + } + return summary + } + + func monthlyInvoice( + month _: Int, + year _: Int, + token _: SessionToken, + ) async throws -> MonthlyInvoice { + MonthlyInvoice(items: nil) + } + + func aggregatedUsage( + startDate _: Date, + endDate _: Date, + token _: SessionToken, + ) async throws -> AggregatedUsage { + AggregatedUsage(aggregations: [], totalCostCents: 0) + } + + func release() { + let continuation = lock.withLock { () -> CheckedContinuation? in + released = true + let stored = stored + self.stored = nil + return stored + } + continuation?.resume() + } } From ca795e3e17c590e0639f9c24574278240e36d6a9 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 10:16:17 -0700 Subject: [PATCH 14/34] Remove the unreliable year-to-date total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monthly-invoice endpoint is a billing ledger with cross-month adjustments — negative 'mid-month usage paid for ' credit lines that partly cancel the live cycle figure — so summing months produced a meaningless (often negative) year-to-date, e.g. -$574.65. Rather than show a wrong number, drop the YTD: remove yearToDateCents and the now-unused get-monthly-invoice plumbing (MonthlyInvoice, provider method, prior-month summation, calendar injection). Current-cycle spend (billed on-demand) remains the reliable headline. --- Ledger/Ledger/AGENTS.md | 8 ++-- Ledger/Ledger/README.md | 19 ++++++---- Ledger/Ledger/Sources/PreviewSupport.swift | 8 ---- Ledger/Ledger/Sources/SpendView.swift | 1 - Ledger/LedgerCore/AGENTS.md | 13 ++++--- Ledger/LedgerCore/README.md | 16 ++++---- .../Sources/DashboardProvider.swift | 21 ---------- .../LedgerCore/Sources/LedgerServices.swift | 38 ------------------- .../LedgerCore/Sources/MonthlyInvoice.swift | 30 --------------- Ledger/LedgerCore/Sources/SpendSnapshot.swift | 14 +------ Ledger/LedgerCore/Sources/TestDoubles.swift | 25 ++---------- .../Tests/DashboardProviderTests.swift | 15 ++------ .../Tests/LedgerCoreTestSupport.swift | 21 ---------- .../Tests/LedgerServicesTests.swift | 27 +------------ .../Tests/MonthlyInvoiceTests.swift | 23 ----------- 15 files changed, 42 insertions(+), 237 deletions(-) delete mode 100644 Ledger/LedgerCore/Sources/MonthlyInvoice.swift delete mode 100644 Ledger/LedgerCore/Tests/MonthlyInvoiceTests.swift diff --git a/Ledger/Ledger/AGENTS.md b/Ledger/Ledger/AGENTS.md index 417f850c..5e6c7a43 100644 --- a/Ledger/Ledger/AGENTS.md +++ b/Ledger/Ledger/AGENTS.md @@ -24,10 +24,10 @@ build system, formatting, and global conventions. Read that first. loop (deliberately AppKit, not `MenuBarExtra`). The SwiftUI `Settings` scene hosts `SettingsView`. - `LedgerSession` — the thin `@Observable` facade over `LedgerServices`: views - read its mirrored state and call its intent methods (`refresh`, `setAPIKey`, - …); it owns the Core root. -- `SpendView` — the popover; renders the single `LoadState` (this cycle + - year-to-date). + read its mirrored state and call its intent methods (`refresh`, + `setManualToken`, …); it owns the Core root. +- `SpendView` — the popover; renders the single `LoadState` (current-cycle + spend, included-usage, and top models). - `SettingsView` — a System-Settings-style sidebar (General + Account panes); Account shows the auto-detect status and an optional pasted-token override. - `CurrencyFormat` — the one place spend is formatted as USD. diff --git a/Ledger/Ledger/README.md b/Ledger/Ledger/README.md index 8a90f118..341e02c5 100644 --- a/Ledger/Ledger/README.md +++ b/Ledger/Ledger/README.md @@ -2,8 +2,8 @@ A macOS **menu bar app** that shows your current-cycle Cursor spend at a glance. The status item displays the cycle-to-date dollar amount; clicking it -opens a popover with this cycle's spend, a year-to-date total, the included-usage -breakdown, and your plan. +opens a popover with this cycle's spend, the included-usage breakdown, your +plan, and the top models by usage. All behavior lives in [`LedgerCore`](../LedgerCore); this target is the thin SwiftUI/AppKit shell. @@ -52,12 +52,15 @@ shortcut to System Settings if macOS needs you to approve the login item. - **Menu-bar title** — the current billing cycle's usage-based spend, refreshed automatically every 15 minutes. Opening the popover just shows the latest fetched state; it doesn't trigger a network request. -- **Popover** — this cycle's spend and date range, a **year-to-date** total, - your plan tier, an **included-usage** progress bar (with Cursor's own status - lines), **top models this cycle** as usage shares, and when it last updated. A - **Refresh** button forces an immediate fetch. On an error (no session, an - expired session, a network failure) it explains what to fix and offers a - shortcut to Settings. +- **Popover** — this cycle's spend and date range, your plan tier, an + **included-usage** progress bar (with Cursor's own status lines), **top models + this cycle** as usage shares, and when it last updated. A **Refresh** button + forces an immediate fetch. On an error (no session, an expired session, a + network failure) it explains what to fix and offers a shortcut to Settings. + +There's intentionally no year-to-date total: the monthly-invoice endpoint is a +billing ledger with cross-month credit/adjustment lines, so summing it isn't a +meaningful "spend this year" (see `LedgerCore`'s README). The per-model rows are shown as **relative shares**, not dollars: the dashboard's per-model figure (`get-aggregated-usage-events`) measures compute differently diff --git a/Ledger/Ledger/Sources/PreviewSupport.swift b/Ledger/Ledger/Sources/PreviewSupport.swift index 51cd7be1..6db831e5 100644 --- a/Ledger/Ledger/Sources/PreviewSupport.swift +++ b/Ledger/Ledger/Sources/PreviewSupport.swift @@ -22,14 +22,6 @@ "You've used 100% of your included API usage", ], ), - invoiceCentsByMonth: [ - 1: 120_000, - 2: 98000, - 3: 210_000, - 4: 175_000, - 5: 260_000, - 6: 288_000, - ], ), aggregated: .fixture([ "claude-opus-4-8-thinking-xhigh": 28929, diff --git a/Ledger/Ledger/Sources/SpendView.swift b/Ledger/Ledger/Sources/SpendView.swift index 0fbc2b2c..b860a795 100644 --- a/Ledger/Ledger/Sources/SpendView.swift +++ b/Ledger/Ledger/Sources/SpendView.swift @@ -73,7 +73,6 @@ struct SpendView: View { } VStack(alignment: .leading, spacing: 4) { - breakdownRow("This year", CurrencyFormat.dollars(snapshot.yearToDateDollars)) breakdownRow("Plan", snapshot.membershipType.capitalized) } diff --git a/Ledger/LedgerCore/AGENTS.md b/Ledger/LedgerCore/AGENTS.md index 858fe068..00fe120d 100644 --- a/Ledger/LedgerCore/AGENTS.md +++ b/Ledger/LedgerCore/AGENTS.md @@ -2,7 +2,7 @@ LedgerCore is the model layer for the Ledger menu bar app: a tree of `@MainActor @Observable` objects rooted in `LedgerServices` that fetches the -current-cycle Cursor spend (and a year-to-date total) from Cursor's +current-cycle Cursor spend from Cursor's undocumented dashboard API and reduces it to one observable `LoadState`. The SwiftUI/AppKit layer lives in the app target ([`Ledger/Ledger`](../Ledger)) and binds the tree directly; see [`README.md`](README.md) for the narrative and @@ -12,7 +12,7 @@ per-type detail. LedgerServices ── LedgerSettings (refresh interval) ├────────── SessionTokenSource ── CursorLocalTokenSource (state.vscdb, read-only) ├────────── KeychainStore (a pasted token override) - ├────────── DashboardProvider ── CursorDashboardAPI (usage-summary, get-monthly-invoice, get-aggregated-usage-events) + ├────────── DashboardProvider ── CursorDashboardAPI (usage-summary, get-aggregated-usage-events) └────────── LoginItemController (SMAppService) ``` @@ -44,10 +44,11 @@ build system, formatting, and global conventions. Read that first. - **Read Cursor's `state.vscdb` read-only.** Open with `SQLITE_OPEN_READONLY` and never write/lock it — Cursor may hold it open. Any failure (missing file, missing key, locked) degrades to "no auto-token" (`nil`), not a throw. -- **`onDemand.used` is "this cycle"; year-to-date = prior-month invoices + the - live cycle figure.** The current month's invoice lags until charges post, so - the live usage-summary number stands in for it — don't double-count by also - summing the current month's (sparse) invoice. +- **`onDemand.used` is "this cycle"; there is no year-to-date total.** The + `get-monthly-invoice` endpoint is a billing ledger with cross-month + adjustments (negative "mid-month usage paid for " credits) whose + contents shift as billing settles, so summing months is not a meaningful + yearly spend (it can go negative). Don't reintroduce a summed YTD. - **Per-model usage is a dollar-free share, and best-effort.** `get-aggregated-usage-events`' `totalCostCents` measures compute differently from the billed on-demand figure (they don't reconcile), so `ModelShare` diff --git a/Ledger/LedgerCore/README.md b/Ledger/LedgerCore/README.md index 61bbf062..d1f8b057 100644 --- a/Ledger/LedgerCore/README.md +++ b/Ledger/LedgerCore/README.md @@ -1,7 +1,7 @@ # LedgerCore The model layer for the **Ledger** menu bar app: it fetches your current -Cursor billing-cycle spend (and a year-to-date total) from the same +Cursor billing-cycle spend from the same undocumented dashboard endpoints the `cursor.com/dashboard/usage` page uses, authenticated with your Cursor **session token**. The SwiftUI/AppKit shell lives in the [`Ledger`](../Ledger) app target and binds this tree directly. @@ -12,8 +12,7 @@ lives in the [`Ledger`](../Ledger) app target and binds this tree directly. (`state.vscdb` → `cursorAuth/accessToken`), or a value you **paste** into Settings (stored in the Keychain, which overrides auto-detect). - Calls `GET /api/usage-summary` for the current cycle's dates, plan type, and - live usage-based spend, `POST /api/dashboard/get-monthly-invoice` for each - prior month of the year, and `POST /api/dashboard/get-aggregated-usage-events` + live usage-based spend, and `POST /api/dashboard/get-aggregated-usage-events` for the per-model breakdown (best-effort). - Reduces it all to one observable `LoadState` (`idle` / `loading` / `loaded(SpendSnapshot)` / `failed(LoadError)`). @@ -41,7 +40,7 @@ auto-token", surfaced as `LoadError.missingCredentials`. - `SessionToken` / `SessionTokenSource` / `CursorLocalTokenSource` — the auth seam. - `DashboardProvider` + `CursorDashboardAPI` — the network seam. -- `UsageSummary`, `MonthlyInvoice`, `SpendSnapshot` — the wire + view models +- `UsageSummary`, `AggregatedUsage`, `SpendSnapshot` — the wire + view models (cents are integers). - `KeychainStore` / `SystemKeychainStore` — a pasted token's storage. - `LedgerSettings` / `LedgerConfiguration` / `LedgerConfigStore` — the persisted @@ -53,9 +52,12 @@ auto-token", surfaced as `LoadError.missingCredentials`. - **This cycle** = `usage-summary` → `individualUsage.onDemand.used` (cents), the live usage-based spend. -- **This year** = the sum of the prior months' `get-monthly-invoice` totals plus - the current cycle's live figure (the current month's invoice lags until - charges post, so the live number stands in for it). +- There is deliberately **no year-to-date total**: the `get-monthly-invoice` + endpoint is a billing ledger with cross-month adjustments (negative + "mid-month usage paid for " credit lines) whose contents shift as + billing settles, so summing months doesn't yield a meaningful "spend this + year" (it can even go negative). Rather than show a wrong number, Ledger omits + it. - **Top models** = `get-aggregated-usage-events` over the cycle window, shown as each model's **share** of that endpoint's total. This is deliberately diff --git a/Ledger/LedgerCore/Sources/DashboardProvider.swift b/Ledger/LedgerCore/Sources/DashboardProvider.swift index 23259d52..d1ac3d12 100644 --- a/Ledger/LedgerCore/Sources/DashboardProvider.swift +++ b/Ledger/LedgerCore/Sources/DashboardProvider.swift @@ -18,8 +18,6 @@ public enum DashboardError: Error, Equatable, Sendable { public protocol DashboardProvider: Sendable { /// The current billing cycle's usage summary. func usageSummary(token: SessionToken) async throws -> UsageSummary - /// The itemized invoice for a given month (`month` is 1-based) and year. - func monthlyInvoice(month: Int, year: Int, token: SessionToken) async throws -> MonthlyInvoice /// Per-model usage aggregated over `[startDate, endDate]`. func aggregatedUsage(startDate: Date, endDate: Date, token: SessionToken) async throws -> AggregatedUsage @@ -52,25 +50,6 @@ public struct CursorDashboardAPI: DashboardProvider { return try await send(request, decoding: UsageSummary.self) } - public func monthlyInvoice( - month: Int, - year: Int, - token: SessionToken, - ) async throws -> MonthlyInvoice { - let body = try JSONSerialization.data(withJSONObject: [ - "month": month, - "year": year, - "includeUsageEvents": false, - ]) - let request = makeRequest( - path: "/api/dashboard/get-monthly-invoice", - method: "POST", - token: token, - body: body, - ) - return try await send(request, decoding: MonthlyInvoice.self) - } - public func aggregatedUsage( startDate: Date, endDate: Date, diff --git a/Ledger/LedgerCore/Sources/LedgerServices.swift b/Ledger/LedgerCore/Sources/LedgerServices.swift index 9b899093..947cee99 100644 --- a/Ledger/LedgerCore/Sources/LedgerServices.swift +++ b/Ledger/LedgerCore/Sources/LedgerServices.swift @@ -106,7 +106,6 @@ public final class LedgerServices { @ObservationIgnored private let tokenSource: any SessionTokenSource @ObservationIgnored private let provider: any DashboardProvider @ObservationIgnored private let loginItem: LoginItemController - @ObservationIgnored private let calendar: Calendar @ObservationIgnored private let now: @Sendable () -> Date @ObservationIgnored private var refreshLoop: Task? /// Increments per fetch so a slow earlier response can't clobber a newer one. @@ -129,7 +128,6 @@ public final class LedgerServices { tokenSource: any SessionTokenSource, provider: any DashboardProvider, loginItem: LoginItemController, - calendar: Calendar = .current, now: @escaping @Sendable () -> Date = { Date() }, ) { self.configStore = configStore @@ -137,7 +135,6 @@ public final class LedgerServices { self.tokenSource = tokenSource self.provider = provider self.loginItem = loginItem - self.calendar = calendar self.now = now do { configuration = try configStore.load() @@ -196,23 +193,11 @@ public final class LedgerServices { do { let summary = try await provider.usageSummary(token: token) - let components = calendar.dateComponents([.year, .month], from: now()) - let year = components.year ?? 1970 - let month = components.month ?? 1 - let priorMonthsCents = try await priorMonthsSpend( - before: month, - year: year, - token: token, - ) let models = await topModels(cycleStart: summary.cycleStart, token: token) guard generation == requestGeneration else { return } let snapshot = SpendSnapshot( currentCycleCents: summary.onDemandCents, - // "This year" = prior months' billed invoices + the current - // cycle's live spend (the current month's invoice lags until - // charges post). - yearToDateCents: priorMonthsCents + summary.onDemandCents, cycleStart: summary.cycleStart, cycleEnd: summary.cycleEnd, membershipType: summary.membershipType, @@ -232,29 +217,6 @@ public final class LedgerServices { } } - /// Sums the billed invoice totals for months `1 ..< month` of `year`, - /// fetched concurrently. - private func priorMonthsSpend( - before month: Int, - year: Int, - token: SessionToken, - ) async throws -> Int { - guard month > 1 else { return 0 } - let provider = provider - return try await withThrowingTaskGroup(of: Int.self) { group in - for m in 1 ..< month { - group.addTask { - try await provider.monthlyInvoice(month: m, year: year, token: token).totalCents - } - } - var total = 0 - for try await cents in group { - total += cents - } - return total - } - } - /// The top models by usage for the current cycle, as relative shares. /// Best-effort: the per-model breakdown is supplementary, so a failure (or /// an unknown cycle start) logs and yields an empty list rather than diff --git a/Ledger/LedgerCore/Sources/MonthlyInvoice.swift b/Ledger/LedgerCore/Sources/MonthlyInvoice.swift deleted file mode 100644 index ebc46301..00000000 --- a/Ledger/LedgerCore/Sources/MonthlyInvoice.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -/// The decoded `POST /api/dashboard/get-monthly-invoice` response for one -/// month. Completed months carry itemized `cents` lines; the in-progress month -/// can come back with no `items` until charges post — modeled as an optional -/// so "not yet billed" reads as an empty total, not a decode failure. -public struct MonthlyInvoice: Codable, Equatable, Sendable { - public var items: [Item]? - - public struct Item: Codable, Equatable, Sendable { - /// Human-readable line (model, call count, dollar total). - public var description: String - /// The line's charge, in cents. - public var cents: Int - - public init(description: String, cents: Int) { - self.description = description - self.cents = cents - } - } - - public init(items: [Item]?) { - self.items = items - } - - /// The month's total charge in cents (0 when nothing is billed yet). - public var totalCents: Int { - (items ?? []).reduce(0) { $0 + $1.cents } - } -} diff --git a/Ledger/LedgerCore/Sources/SpendSnapshot.swift b/Ledger/LedgerCore/Sources/SpendSnapshot.swift index 5ff2825d..b385374d 100644 --- a/Ledger/LedgerCore/Sources/SpendSnapshot.swift +++ b/Ledger/LedgerCore/Sources/SpendSnapshot.swift @@ -1,15 +1,11 @@ import Foundation -/// The spend figures Ledger renders, distilled from the usage summary, the -/// year's monthly invoices, and the per-model aggregation into one value the UI -/// binds to. All money is cents. +/// The spend figures Ledger renders, distilled from the usage summary and the +/// per-model aggregation into one value the UI binds to. All money is cents. public struct SpendSnapshot: Equatable, Sendable { /// Usage-based spend for the current billing cycle (live, from the usage /// summary). public var currentCycleCents: Int - /// Usage-based spend billed so far this calendar year (sum of the monthly - /// invoices). - public var yearToDateCents: Int /// The current billing cycle's start/end, when known. public var cycleStart: Date? public var cycleEnd: Date? @@ -26,7 +22,6 @@ public struct SpendSnapshot: Equatable, Sendable { public init( currentCycleCents: Int, - yearToDateCents: Int, cycleStart: Date?, cycleEnd: Date?, membershipType: String, @@ -35,7 +30,6 @@ public struct SpendSnapshot: Equatable, Sendable { topModels: [ModelShare], ) { self.currentCycleCents = currentCycleCents - self.yearToDateCents = yearToDateCents self.cycleStart = cycleStart self.cycleEnd = cycleEnd self.membershipType = membershipType @@ -47,8 +41,4 @@ public struct SpendSnapshot: Equatable, Sendable { public var currentCycleDollars: Double { Double(currentCycleCents) / 100 } - - public var yearToDateDollars: Double { - Double(yearToDateCents) / 100 - } } diff --git a/Ledger/LedgerCore/Sources/TestDoubles.swift b/Ledger/LedgerCore/Sources/TestDoubles.swift index d2643757..d8c10381 100644 --- a/Ledger/LedgerCore/Sources/TestDoubles.swift +++ b/Ledger/LedgerCore/Sources/TestDoubles.swift @@ -8,7 +8,7 @@ @_spi(Testing) public struct ScriptedDashboardProvider: DashboardProvider { public enum Outcome: Sendable { - case success(summary: UsageSummary, invoiceCentsByMonth: [Int: Int]) + case success(summary: UsageSummary) case failure(DashboardError) } @@ -28,37 +28,20 @@ self.aggregatedFailure = aggregatedFailure } - /// Convenience: a successful summary with no prior-month invoices. + /// Convenience: a successful summary. public init(summary: UsageSummary) { - outcome = .success(summary: summary, invoiceCentsByMonth: [:]) + outcome = .success(summary: summary) aggregated = AggregatedUsage(aggregations: [], totalCostCents: 0) aggregatedFailure = nil } public func usageSummary(token _: SessionToken) async throws -> UsageSummary { switch outcome { - case let .success(summary, _): summary + case let .success(summary): summary case let .failure(error): throw error } } - public func monthlyInvoice( - month: Int, - year _: Int, - token _: SessionToken, - ) async throws -> MonthlyInvoice { - switch outcome { - case let .success(_, invoiceCentsByMonth): - let cents = invoiceCentsByMonth[month] ?? 0 - return MonthlyInvoice(items: cents == 0 ? nil : [.init( - description: "m\(month)", - cents: cents, - )]) - case let .failure(error): - throw error - } - } - public func aggregatedUsage( startDate _: Date, endDate _: Date, diff --git a/Ledger/LedgerCore/Tests/DashboardProviderTests.swift b/Ledger/LedgerCore/Tests/DashboardProviderTests.swift index 06a1b821..d683f53e 100644 --- a/Ledger/LedgerCore/Tests/DashboardProviderTests.swift +++ b/Ledger/LedgerCore/Tests/DashboardProviderTests.swift @@ -5,18 +5,9 @@ import Testing struct DashboardProviderTests { private let token = SessionToken(cookieValue: "user_X::jwt") - @Test func scriptedProviderReturnsItsScriptedSummaryAndInvoices() async throws { - let provider = ScriptedDashboardProvider(.success( - summary: .fixture(onDemandCents: 4200), - invoiceCentsByMonth: [3: 5000], - )) - + @Test func scriptedProviderReturnsItsScriptedSummary() async throws { + let provider = ScriptedDashboardProvider(summary: .fixture(onDemandCents: 4200)) #expect(try await provider.usageSummary(token: token).onDemandCents == 4200) - #expect(try await provider.monthlyInvoice(month: 3, year: 2026, token: token) - .totalCents == 5000) - // A month with no scripted value is a sparse (zero) invoice. - #expect(try await provider.monthlyInvoice(month: 4, year: 2026, token: token) - .totalCents == 0) } @Test func scriptedProviderThrowsItsFailureOutcome() async { @@ -37,7 +28,7 @@ struct DashboardProviderTests { @Test func scriptedProviderCanFailOnlyAggregated() async throws { let provider = ScriptedDashboardProvider( - .success(summary: .fixture(onDemandCents: 10), invoiceCentsByMonth: [:]), + .success(summary: .fixture(onDemandCents: 10)), aggregatedFailure: .http(500), ) // Summary still succeeds… diff --git a/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift b/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift index 81db7fd5..74254ba4 100644 --- a/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift +++ b/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift @@ -76,27 +76,6 @@ enum DashboardFixture { } """ - /// A `get-monthly-invoice` body with two itemized lines. - static let monthlyInvoiceJSON = """ - { - "items": [ - { "description": "88 calls to claude-fable-5-thinking-xhigh ($828.11)", "cents": 82811 }, - { "description": "90 calls to claude-opus-4-8-thinking-high ($334.92)", "cents": 33492 } - ], - "periodStartMs": "1785542400000", - "periodEndMs": "1788220800000" - } - """ - - /// A sparse invoice (current month, nothing billed yet). - static let emptyInvoiceJSON = """ - { - "pricingDescription": { "id": "abc" }, - "periodStartMs": "1785542400000", - "periodEndMs": "1788220800000" - } - """ - /// A `get-aggregated-usage-events` body — token fields are strings on the wire. static let aggregatedJSON = """ { diff --git a/Ledger/LedgerCore/Tests/LedgerServicesTests.swift b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift index 788e0668..777a364c 100644 --- a/Ledger/LedgerCore/Tests/LedgerServicesTests.swift +++ b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift @@ -4,21 +4,11 @@ import Testing @MainActor struct LedgerServicesTests { - /// A fixed "now" in July 2026 (month 7), UTC, so prior-month invoice fetches - /// (months 1..6) are deterministic. - private func julyCalendarAndNow() -> (Calendar, @Sendable () -> Date) { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(identifier: "UTC")! - let now = calendar.date(from: DateComponents(year: 2026, month: 7, day: 15))! - return (calendar, { now }) - } - private func makeServices( provider: any DashboardProvider = ScriptedDashboardProvider(.failure(.network("unused"))), manualToken: String? = nil, autoToken: SessionToken? = nil, ) -> LedgerServices { - let (calendar, now) = julyCalendarAndNow() let directory = FileManager.default.temporaryDirectory .appendingPathComponent("LedgerServicesTests-\(UUID().uuidString)") let store = LedgerConfigStore(directory: directory) @@ -28,8 +18,6 @@ struct LedgerServicesTests { tokenSource: StubTokenSource(token: autoToken), provider: provider, loginItem: LoginItemController(backend: LoginItemRecorder()), - calendar: calendar, - now: now, ) } @@ -51,7 +39,6 @@ struct LedgerServicesTests { includedUsed: 40000, includedLimit: 40000, ), - invoiceCentsByMonth: [1: 100, 2: 200, 3: 300, 4: 400, 5: 500, 6: 600], )) let services = makeServices( provider: provider, @@ -64,8 +51,6 @@ struct LedgerServicesTests { return } #expect(snapshot.currentCycleCents == 5000) - // Year-to-date = prior months (1..6 = 2100) + current cycle live (5000). - #expect(snapshot.yearToDateCents == 2100 + 5000) #expect(snapshot.membershipType == "ultra") #expect(services.lastUpdated != nil) } @@ -86,7 +71,7 @@ struct LedgerServicesTests { @Test func loadsTopModelsAsShares() async { let provider = ScriptedDashboardProvider( - .success(summary: .fixture(onDemandCents: 5000), invoiceCentsByMonth: [:]), + .success(summary: .fixture(onDemandCents: 5000)), aggregated: .fixture(["a": 75, "b": 25]), ) let services = makeServices( @@ -107,7 +92,7 @@ struct LedgerServicesTests { // The per-model breakdown is best-effort: its failure must not blank // the headline. let provider = ScriptedDashboardProvider( - .success(summary: .fixture(onDemandCents: 5000), invoiceCentsByMonth: [:]), + .success(summary: .fixture(onDemandCents: 5000)), aggregatedFailure: .http(500), ) let services = makeServices( @@ -232,14 +217,6 @@ private final class GatedDashboardProvider: DashboardProvider, @unchecked Sendab return summary } - func monthlyInvoice( - month _: Int, - year _: Int, - token _: SessionToken, - ) async throws -> MonthlyInvoice { - MonthlyInvoice(items: nil) - } - func aggregatedUsage( startDate _: Date, endDate _: Date, diff --git a/Ledger/LedgerCore/Tests/MonthlyInvoiceTests.swift b/Ledger/LedgerCore/Tests/MonthlyInvoiceTests.swift deleted file mode 100644 index 3c7b293a..00000000 --- a/Ledger/LedgerCore/Tests/MonthlyInvoiceTests.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -@_spi(Testing) import LedgerCore -import Testing - -struct MonthlyInvoiceTests { - @Test func decodesItemsAndSumsCents() throws { - let invoice = try JSONDecoder().decode( - MonthlyInvoice.self, - from: Data(DashboardFixture.monthlyInvoiceJSON.utf8), - ) - #expect(invoice.items?.count == 2) - #expect(invoice.totalCents == 82811 + 33492) - } - - @Test func aSparseInvoiceTotalsToZero() throws { - let invoice = try JSONDecoder().decode( - MonthlyInvoice.self, - from: Data(DashboardFixture.emptyInvoiceJSON.utf8), - ) - #expect(invoice.items == nil) - #expect(invoice.totalCents == 0) - } -} From 7d8f898e00a997fdb15c64fef834b291e730226b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 15:12:52 -0700 Subject: [PATCH 15/34] Refresh every 5 minutes by default + add a Settings picker Lower the default cadence from 15 to 5 minutes, and add a Refresh picker to Settings > General (1 min / 5 min / 15 min / 30 min / hour) bound to the persisted refreshInterval. Changing it restarts the refresh loop so the new cadence applies immediately rather than after the current sleep. --- Ledger/Ledger/README.md | 5 +++-- Ledger/Ledger/Sources/LedgerSession.swift | 8 ++++++++ Ledger/Ledger/Sources/SettingsView.swift | 20 +++++++++++++++++++ .../Sources/LedgerConfigStore.swift | 4 ++-- .../LedgerCore/Sources/LedgerServices.swift | 6 ++++++ 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/Ledger/Ledger/README.md b/Ledger/Ledger/README.md index 341e02c5..310860cc 100644 --- a/Ledger/Ledger/README.md +++ b/Ledger/Ledger/README.md @@ -50,8 +50,9 @@ shortcut to System Settings if macOS needs you to approve the login item. ## What it shows - **Menu-bar title** — the current billing cycle's usage-based spend, refreshed - automatically every 15 minutes. Opening the popover just shows the latest - fetched state; it doesn't trigger a network request. + automatically every 5 minutes (configurable in Settings › General). Opening + the popover just shows the latest fetched state; it doesn't trigger a network + request. - **Popover** — this cycle's spend and date range, your plan tier, an **included-usage** progress bar (with Cursor's own status lines), **top models this cycle** as usage shares, and when it last updated. A **Refresh** button diff --git a/Ledger/Ledger/Sources/LedgerSession.swift b/Ledger/Ledger/Sources/LedgerSession.swift index 98a4efb2..c8d072bf 100644 --- a/Ledger/Ledger/Sources/LedgerSession.swift +++ b/Ledger/Ledger/Sources/LedgerSession.swift @@ -42,6 +42,14 @@ final class LedgerSession { services.settings } + /// How often spend is auto-refreshed, in seconds. `SettingsView` binds this + /// two-way; the change is persisted in Core and picked up by the refresh + /// loop on its next cycle. + var refreshInterval: TimeInterval { + get { services.settings.refreshInterval } + set { services.settings.refreshInterval = newValue } + } + /// Whether Ledger launches at login. `SettingsView` binds this two-way. var startsAtLogin: Bool { get { services.startsAtLogin } diff --git a/Ledger/Ledger/Sources/SettingsView.swift b/Ledger/Ledger/Sources/SettingsView.swift index d0d16696..f266d169 100644 --- a/Ledger/Ledger/Sources/SettingsView.swift +++ b/Ledger/Ledger/Sources/SettingsView.swift @@ -79,6 +79,15 @@ private struct GeneralSettingsPane: SettingsPane { _session = Bindable(session) } + /// Auto-refresh cadence options, in seconds. + private let refreshOptions: [(label: String, seconds: TimeInterval)] = [ + ("Every minute", 60), + ("Every 5 minutes", 5 * 60), + ("Every 15 minutes", 15 * 60), + ("Every 30 minutes", 30 * 60), + ("Every hour", 60 * 60), + ] + var body: some View { Form { Toggle("Launch Ledger at login", isOn: $session.startsAtLogin) @@ -88,6 +97,17 @@ private struct GeneralSettingsPane: SettingsPane { .font(.caption) .foregroundStyle(.secondary) + Picker("Refresh", selection: $session.refreshInterval) { + ForEach(refreshOptions, id: \.seconds) { option in + Text(option.label).tag(option.seconds) + } + } + Text( + "How often Ledger fetches your latest spend. The Refresh button in the popover updates it immediately.", + ) + .font(.caption) + .foregroundStyle(.secondary) + if session.loginItemNeedsApproval { LabeledContent { Button("Open Login Items") { diff --git a/Ledger/LedgerCore/Sources/LedgerConfigStore.swift b/Ledger/LedgerCore/Sources/LedgerConfigStore.swift index 4834616f..da947d43 100644 --- a/Ledger/LedgerCore/Sources/LedgerConfigStore.swift +++ b/Ledger/LedgerCore/Sources/LedgerConfigStore.swift @@ -11,9 +11,9 @@ public struct LedgerConfiguration: Codable, Equatable, Sendable { self.refreshInterval = refreshInterval } - /// The configuration a fresh install starts from: refreshing every 15 + /// The configuration a fresh install starts from: refreshing every 5 /// minutes. - public static let initial = LedgerConfiguration(refreshInterval: 15 * 60) + public static let initial = LedgerConfiguration(refreshInterval: 5 * 60) } /// Loads and saves the ``LedgerConfiguration`` JSON file. diff --git a/Ledger/LedgerCore/Sources/LedgerServices.swift b/Ledger/LedgerCore/Sources/LedgerServices.swift index 947cee99..0d9be46d 100644 --- a/Ledger/LedgerCore/Sources/LedgerServices.swift +++ b/Ledger/LedgerCore/Sources/LedgerServices.swift @@ -289,6 +289,12 @@ public final class LedgerServices { private func settingsDidChange() { configuration.refreshInterval = settings.refreshInterval persist() + // Apply a new cadence now rather than after the current sleep: restart + // the loop (which refreshes immediately) if it's running. + if refreshLoop != nil { + stop() + start() + } } private func persist() { From 5ebc9bf24fa7871987f58d897104f5917fcb167d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 17:49:16 -0700 Subject: [PATCH 16/34] Roll sub-20% models into one multi-colored bar Simplify the models breakdown: each model with >=20% share keeps its own bar; everything below rolls into a single 'Other models' bar with one color segment per model plus a compact legend, so there are fewer bars on screen. Core now returns all model shares (AggregatedUsage.modelShares, renamed from topModels(limit:)); the 20% grouping and coloring are view concerns via a new segmented ShareBar. --- Ledger/Ledger/README.md | 4 +- Ledger/Ledger/Sources/SpendView.swift | 124 ++++++++++++++++-- Ledger/LedgerCore/README.md | 11 +- .../LedgerCore/Sources/AggregatedUsage.swift | 8 +- .../LedgerCore/Sources/LedgerServices.swift | 10 +- Ledger/LedgerCore/Sources/SpendSnapshot.swift | 10 +- .../Tests/AggregatedUsageTests.swift | 18 +-- .../Tests/LedgerServicesTests.swift | 8 +- 8 files changed, 151 insertions(+), 42 deletions(-) diff --git a/Ledger/Ledger/README.md b/Ledger/Ledger/README.md index 310860cc..ea138dc2 100644 --- a/Ledger/Ledger/README.md +++ b/Ledger/Ledger/README.md @@ -55,7 +55,9 @@ shortcut to System Settings if macOS needs you to approve the login item. request. - **Popover** — this cycle's spend and date range, your plan tier, an **included-usage** progress bar (with Cursor's own status lines), **top models - this cycle** as usage shares, and when it last updated. A **Refresh** button + this cycle** as usage shares (each model ≥20% gets its own bar; smaller ones + roll into a single multi-colored "Other models" bar with a legend), and when + it last updated. A **Refresh** button forces an immediate fetch. On an error (no session, an expired session, a network failure) it explains what to fix and offers a shortcut to Settings. diff --git a/Ledger/Ledger/Sources/SpendView.swift b/Ledger/Ledger/Sources/SpendView.swift index b860a795..744b0ae8 100644 --- a/Ledger/Ledger/Sources/SpendView.swift +++ b/Ledger/Ledger/Sources/SpendView.swift @@ -8,6 +8,23 @@ import SwiftUI struct SpendView: View { @Bindable var session: LedgerSession + /// Models at or above this share get their own bar; the rest are rolled up. + private static let rollupThreshold = 0.20 + + /// Distinct colors assigned to models in share order (cycled if exhausted). + private static let palette: [Color] = [ + .blue, + .green, + .orange, + .purple, + .pink, + .teal, + .indigo, + .yellow, + .red, + .mint, + ] + var body: some View { VStack(alignment: .leading, spacing: 12) { header @@ -80,8 +97,8 @@ struct SpendView: View { includedUsage(fraction, messages: snapshot.usageMessages) } - if !snapshot.topModels.isEmpty { - topModels(snapshot.topModels) + if !snapshot.modelShares.isEmpty { + models(snapshot.modelShares) } if let updated = session.lastUpdated { @@ -111,14 +128,46 @@ struct SpendView: View { } } - private func topModels(_ models: [ModelShare]) -> some View { - VStack(alignment: .leading, spacing: 6) { + /// Models this cycle: each model with ≥20% share gets its own bar; the rest + /// roll into a single multi-colored "Other models" bar (one segment per + /// model), with a compact legend. + private func models(_ shares: [ModelShare]) -> some View { + let colored = shares.enumerated().map { index, share in + ColoredShare( + name: share.name, + fraction: share.fraction, + color: Self.palette[index % Self.palette.count], + ) + } + let majors = colored.filter { $0.fraction >= Self.rollupThreshold } + let minors = colored.filter { $0.fraction < Self.rollupThreshold } + let minorsTotal = minors.reduce(0) { $0 + $1.fraction } + + return VStack(alignment: .leading, spacing: 6) { Text("Top models this cycle") .font(.caption) .foregroundStyle(.secondary) - ForEach(models) { model in - VStack(alignment: .leading, spacing: 2) { - HStack { + + ForEach(majors) { model in + shareRow( + label: model.name, + fraction: model.fraction, + segments: [ShareSegment(color: model.color, fraction: model.fraction)], + ) + } + + if !minors.isEmpty { + shareRow( + label: "Other models", + fraction: minorsTotal, + segments: minors.map { ShareSegment(color: $0.color, fraction: $0.fraction) }, + ) + // Legend so the rolled-up colors are identifiable. + ForEach(minors) { model in + HStack(spacing: 6) { + Circle() + .fill(model.color) + .frame(width: 7, height: 7) Text(model.name) .lineLimit(1) .truncationMode(.middle) @@ -127,13 +176,29 @@ struct SpendView: View { .monospacedDigit() .foregroundStyle(.secondary) } - .font(.caption) - ProgressView(value: min(max(model.fraction, 0), 1)) + .font(.caption2) + .foregroundStyle(.secondary) } } } } + private func shareRow(label: String, fraction: Double, segments: [ShareSegment]) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(label) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(fraction.formatted(.percent.precision(.fractionLength(0)))) + .monospacedDigit() + .foregroundStyle(.secondary) + } + .font(.caption) + ShareBar(segments: segments) + } + } + private func breakdownRow(_ label: String, _ value: String) -> some View { HStack { Text(label) @@ -188,6 +253,47 @@ struct SpendView: View { } } +/// A model paired with its display color for the models breakdown. +private struct ColoredShare: Identifiable { + let name: String + let fraction: Double + let color: Color + + var id: String { + name + } +} + +/// One colored slice of a ``ShareBar`` (its width is `fraction` of the track). +private struct ShareSegment { + let color: Color + let fraction: Double +} + +/// A rounded track filled with one or more colored segments, each sized as a +/// fraction (0...1) of the full width. A single segment reads as a simple bar; +/// several read as a stacked breakdown. +private struct ShareBar: View { + let segments: [ShareSegment] + + var body: some View { + GeometryReader { geometry in + HStack(spacing: 1) { + ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in + segment.color + .frame(width: max( + 0, + geometry.size.width * min(max(segment.fraction, 0), 1), + )) + } + } + } + .frame(height: 6) + .background(Color.secondary.opacity(0.15)) + .clipShape(.capsule) + } +} + #if DEBUG #Preview { SpendView(session: PreviewSupport.loadedSession()) diff --git a/Ledger/LedgerCore/README.md b/Ledger/LedgerCore/README.md index d1f8b057..7e63ed3a 100644 --- a/Ledger/LedgerCore/README.md +++ b/Ledger/LedgerCore/README.md @@ -59,11 +59,12 @@ auto-token", surfaced as `LoadError.missingCredentials`. year" (it can even go negative). Rather than show a wrong number, Ledger omits it. -- **Top models** = `get-aggregated-usage-events` over the cycle window, shown as - each model's **share** of that endpoint's total. This is deliberately - dollar-free: its `totalCostCents` measures compute differently from the billed - on-demand figure, so it must not be presented as spend. Best-effort — a - failure logs and yields an empty list rather than failing the whole load. +- **Model shares** = `get-aggregated-usage-events` over the cycle window, each + model's **share** of that endpoint's total (all models, highest first; the UI + rolls sub-20% shares into one bar). This is deliberately dollar-free: its + `totalCostCents` measures compute differently from the billed on-demand + figure, so it must not be presented as spend. Best-effort — a failure logs + and yields an empty list rather than failing the whole load. All money is cents. Note: on a plan with usage-based pricing off, these `$` figures reflect included-compute value, not money owed. diff --git a/Ledger/LedgerCore/Sources/AggregatedUsage.swift b/Ledger/LedgerCore/Sources/AggregatedUsage.swift index b664c68e..809800df 100644 --- a/Ledger/LedgerCore/Sources/AggregatedUsage.swift +++ b/Ledger/LedgerCore/Sources/AggregatedUsage.swift @@ -52,14 +52,14 @@ public struct AggregatedUsage: Codable, Equatable, Sendable { self.totalCostCents = totalCostCents } - /// The top `limit` models by compute, each as a ``ModelShare`` (fraction of - /// `totalCostCents`), highest first. - public func topModels(limit: Int) -> [ModelShare] { + /// Every model as a ``ModelShare`` (fraction of `totalCostCents`), highest + /// first. The UI decides how to group them (e.g. rolling small shares into + /// one bar). + public func modelShares() -> [ModelShare] { let total = aggregations.reduce(0) { $0 + $1.totalCents } guard total > 0 else { return [] } return aggregations .sorted { $0.totalCents > $1.totalCents } - .prefix(limit) .map { ModelShare(name: $0.modelIntent, fraction: $0.totalCents / total) } } } diff --git a/Ledger/LedgerCore/Sources/LedgerServices.swift b/Ledger/LedgerCore/Sources/LedgerServices.swift index 0d9be46d..70b623c2 100644 --- a/Ledger/LedgerCore/Sources/LedgerServices.swift +++ b/Ledger/LedgerCore/Sources/LedgerServices.swift @@ -193,7 +193,7 @@ public final class LedgerServices { do { let summary = try await provider.usageSummary(token: token) - let models = await topModels(cycleStart: summary.cycleStart, token: token) + let models = await modelShares(cycleStart: summary.cycleStart, token: token) guard generation == requestGeneration else { return } let snapshot = SpendSnapshot( @@ -203,7 +203,7 @@ public final class LedgerServices { membershipType: summary.membershipType, includedFractionUsed: summary.includedFractionUsed, usageMessages: summary.usageMessages, - topModels: models, + modelShares: models, ) loadState = .loaded(snapshot) lastUpdated = now() @@ -217,11 +217,11 @@ public final class LedgerServices { } } - /// The top models by usage for the current cycle, as relative shares. + /// The models by usage for the current cycle, as relative shares. /// Best-effort: the per-model breakdown is supplementary, so a failure (or /// an unknown cycle start) logs and yields an empty list rather than /// failing the whole load. - private func topModels(cycleStart: Date?, token: SessionToken) async -> [ModelShare] { + private func modelShares(cycleStart: Date?, token: SessionToken) async -> [ModelShare] { guard let cycleStart else { return [] } do { let usage = try await provider.aggregatedUsage( @@ -229,7 +229,7 @@ public final class LedgerServices { endDate: now(), token: token, ) - return usage.topModels(limit: 5) + return usage.modelShares() } catch { Self.logger.warning("Couldn't load per-model usage: \(error.localizedDescription)") return [] diff --git a/Ledger/LedgerCore/Sources/SpendSnapshot.swift b/Ledger/LedgerCore/Sources/SpendSnapshot.swift index b385374d..bc3a5ffb 100644 --- a/Ledger/LedgerCore/Sources/SpendSnapshot.swift +++ b/Ledger/LedgerCore/Sources/SpendSnapshot.swift @@ -16,9 +16,9 @@ public struct SpendSnapshot: Equatable, Sendable { /// The dashboard's own status lines (e.g. "You've used 21% of your included /// total usage"), for display verbatim. public var usageMessages: [String] - /// Top models by usage this cycle, as relative shares (dollar-free — see - /// ``AggregatedUsage``). Empty when the per-model fetch is unavailable. - public var topModels: [ModelShare] + /// Models by usage this cycle, as relative shares highest-first (dollar-free + /// — see ``AggregatedUsage``). Empty when the per-model fetch is unavailable. + public var modelShares: [ModelShare] public init( currentCycleCents: Int, @@ -27,7 +27,7 @@ public struct SpendSnapshot: Equatable, Sendable { membershipType: String, includedFractionUsed: Double?, usageMessages: [String], - topModels: [ModelShare], + modelShares: [ModelShare], ) { self.currentCycleCents = currentCycleCents self.cycleStart = cycleStart @@ -35,7 +35,7 @@ public struct SpendSnapshot: Equatable, Sendable { self.membershipType = membershipType self.includedFractionUsed = includedFractionUsed self.usageMessages = usageMessages - self.topModels = topModels + self.modelShares = modelShares } public var currentCycleDollars: Double { diff --git a/Ledger/LedgerCore/Tests/AggregatedUsageTests.swift b/Ledger/LedgerCore/Tests/AggregatedUsageTests.swift index 346b99f3..be556762 100644 --- a/Ledger/LedgerCore/Tests/AggregatedUsageTests.swift +++ b/Ledger/LedgerCore/Tests/AggregatedUsageTests.swift @@ -17,23 +17,23 @@ struct AggregatedUsageTests { #expect(opus.totalTokens == 333_769_750) } - @Test func topModelsAreSharesSortedByCostHighestFirst() { + @Test func modelSharesAreSortedByCostHighestFirst() { let usage = AggregatedUsage.fixture([ "b": 25, "a": 75, ]) - let top = usage.topModels(limit: 5) - #expect(top.map(\.name) == ["a", "b"]) - #expect(top[0].fraction == 0.75) - #expect(top[1].fraction == 0.25) + let shares = usage.modelShares() + #expect(shares.map(\.name) == ["a", "b"]) + #expect(shares[0].fraction == 0.75) + #expect(shares[1].fraction == 0.25) } - @Test func topModelsRespectsTheLimit() { + @Test func modelSharesReturnsEveryModel() { let usage = AggregatedUsage.fixture(["a": 4, "b": 3, "c": 2, "d": 1]) - #expect(usage.topModels(limit: 2).map(\.name) == ["a", "b"]) + #expect(usage.modelShares().map(\.name) == ["a", "b", "c", "d"]) } - @Test func topModelsIsEmptyWhenNoUsage() { - #expect(AggregatedUsage(aggregations: [], totalCostCents: 0).topModels(limit: 5).isEmpty) + @Test func modelSharesIsEmptyWhenNoUsage() { + #expect(AggregatedUsage(aggregations: [], totalCostCents: 0).modelShares().isEmpty) } } diff --git a/Ledger/LedgerCore/Tests/LedgerServicesTests.swift b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift index 777a364c..be34a809 100644 --- a/Ledger/LedgerCore/Tests/LedgerServicesTests.swift +++ b/Ledger/LedgerCore/Tests/LedgerServicesTests.swift @@ -69,7 +69,7 @@ struct LedgerServicesTests { #expect(snapshot.currentCycleCents == 999) } - @Test func loadsTopModelsAsShares() async { + @Test func loadsModelSharesSortedByShare() async { let provider = ScriptedDashboardProvider( .success(summary: .fixture(onDemandCents: 5000)), aggregated: .fixture(["a": 75, "b": 25]), @@ -84,8 +84,8 @@ struct LedgerServicesTests { Issue.record("expected loaded, got \(services.loadState)") return } - #expect(snapshot.topModels.map(\.name) == ["a", "b"]) - #expect(snapshot.topModels.first?.fraction == 0.75) + #expect(snapshot.modelShares.map(\.name) == ["a", "b"]) + #expect(snapshot.modelShares.first?.fraction == 0.75) } @Test func stillLoadsWhenPerModelFetchFails() async { @@ -106,7 +106,7 @@ struct LedgerServicesTests { return } #expect(snapshot.currentCycleCents == 5000) - #expect(snapshot.topModels.isEmpty) + #expect(snapshot.modelShares.isEmpty) } @Test func mapsNotAuthenticated() async { From 8a5f34b21afd17c5740a48787fc447d99a38c146 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 17:56:38 -0700 Subject: [PATCH 17/34] Drop the duplicated included-usage status lines The included-usage percentage appeared twice: in the header above the bar and again in Cursor's status message below it. Keep the concise header + bar and remove the status-message lines, along with their now-unused plumbing (usageMessages / the display-message fields). --- Ledger/Ledger/README.md | 2 +- Ledger/Ledger/Sources/PreviewSupport.swift | 4 ---- Ledger/Ledger/Sources/SpendView.swift | 9 ++------- Ledger/LedgerCore/Sources/LedgerServices.swift | 1 - Ledger/LedgerCore/Sources/SpendSnapshot.swift | 5 ----- Ledger/LedgerCore/Sources/TestDoubles.swift | 6 +----- Ledger/LedgerCore/Sources/UsageSummary.swift | 9 --------- 7 files changed, 4 insertions(+), 32 deletions(-) diff --git a/Ledger/Ledger/README.md b/Ledger/Ledger/README.md index ea138dc2..2cc304f6 100644 --- a/Ledger/Ledger/README.md +++ b/Ledger/Ledger/README.md @@ -54,7 +54,7 @@ shortcut to System Settings if macOS needs you to approve the login item. the popover just shows the latest fetched state; it doesn't trigger a network request. - **Popover** — this cycle's spend and date range, your plan tier, an - **included-usage** progress bar (with Cursor's own status lines), **top models + **included-usage** progress bar, **top models this cycle** as usage shares (each model ≥20% gets its own bar; smaller ones roll into a single multi-colored "Other models" bar with a legend), and when it last updated. A **Refresh** button diff --git a/Ledger/Ledger/Sources/PreviewSupport.swift b/Ledger/Ledger/Sources/PreviewSupport.swift index 6db831e5..dc55ef6a 100644 --- a/Ledger/Ledger/Sources/PreviewSupport.swift +++ b/Ledger/Ledger/Sources/PreviewSupport.swift @@ -17,10 +17,6 @@ includedUsed: 40000, includedLimit: 40000, totalPercentUsed: 20.9, - messages: [ - "You've used 21% of your included total usage", - "You've used 100% of your included API usage", - ], ), ), aggregated: .fixture([ diff --git a/Ledger/Ledger/Sources/SpendView.swift b/Ledger/Ledger/Sources/SpendView.swift index 744b0ae8..537d9072 100644 --- a/Ledger/Ledger/Sources/SpendView.swift +++ b/Ledger/Ledger/Sources/SpendView.swift @@ -94,7 +94,7 @@ struct SpendView: View { } if let fraction = snapshot.includedFractionUsed { - includedUsage(fraction, messages: snapshot.usageMessages) + includedUsage(fraction) } if !snapshot.modelShares.isEmpty { @@ -109,7 +109,7 @@ struct SpendView: View { } } - private func includedUsage(_ fraction: Double, messages: [String]) -> some View { + private func includedUsage(_ fraction: Double) -> some View { VStack(alignment: .leading, spacing: 4) { HStack { Text("Included usage") @@ -120,11 +120,6 @@ struct SpendView: View { } .font(.callout) ProgressView(value: min(max(fraction, 0), 1)) - ForEach(messages, id: \.self) { message in - Text(message) - .font(.caption2) - .foregroundStyle(.tertiary) - } } } diff --git a/Ledger/LedgerCore/Sources/LedgerServices.swift b/Ledger/LedgerCore/Sources/LedgerServices.swift index 70b623c2..0f164748 100644 --- a/Ledger/LedgerCore/Sources/LedgerServices.swift +++ b/Ledger/LedgerCore/Sources/LedgerServices.swift @@ -202,7 +202,6 @@ public final class LedgerServices { cycleEnd: summary.cycleEnd, membershipType: summary.membershipType, includedFractionUsed: summary.includedFractionUsed, - usageMessages: summary.usageMessages, modelShares: models, ) loadState = .loaded(snapshot) diff --git a/Ledger/LedgerCore/Sources/SpendSnapshot.swift b/Ledger/LedgerCore/Sources/SpendSnapshot.swift index bc3a5ffb..32f5cf5d 100644 --- a/Ledger/LedgerCore/Sources/SpendSnapshot.swift +++ b/Ledger/LedgerCore/Sources/SpendSnapshot.swift @@ -13,9 +13,6 @@ public struct SpendSnapshot: Equatable, Sendable { public var membershipType: String /// Fraction (0...1) of the included allowance used this cycle, when known. public var includedFractionUsed: Double? - /// The dashboard's own status lines (e.g. "You've used 21% of your included - /// total usage"), for display verbatim. - public var usageMessages: [String] /// Models by usage this cycle, as relative shares highest-first (dollar-free /// — see ``AggregatedUsage``). Empty when the per-model fetch is unavailable. public var modelShares: [ModelShare] @@ -26,7 +23,6 @@ public struct SpendSnapshot: Equatable, Sendable { cycleEnd: Date?, membershipType: String, includedFractionUsed: Double?, - usageMessages: [String], modelShares: [ModelShare], ) { self.currentCycleCents = currentCycleCents @@ -34,7 +30,6 @@ public struct SpendSnapshot: Equatable, Sendable { self.cycleEnd = cycleEnd self.membershipType = membershipType self.includedFractionUsed = includedFractionUsed - self.usageMessages = usageMessages self.modelShares = modelShares } diff --git a/Ledger/LedgerCore/Sources/TestDoubles.swift b/Ledger/LedgerCore/Sources/TestDoubles.swift index d8c10381..2b684d20 100644 --- a/Ledger/LedgerCore/Sources/TestDoubles.swift +++ b/Ledger/LedgerCore/Sources/TestDoubles.swift @@ -106,11 +106,10 @@ includedUsed: Int = 0, includedLimit: Int? = nil, totalPercentUsed: Double? = nil, - messages: [String] = [], cycleStart: String = "2026-07-04T18:16:08.000Z", cycleEnd: String = "2026-08-04T18:16:08.000Z", ) -> UsageSummary { - var summary = UsageSummary( + UsageSummary( billingCycleStart: cycleStart, billingCycleEnd: cycleEnd, membershipType: membershipType, @@ -126,9 +125,6 @@ ), ), ) - summary.autoModelSelectedDisplayMessage = messages.first - summary.namedModelSelectedDisplayMessage = messages.count > 1 ? messages[1] : nil - return summary } } diff --git a/Ledger/LedgerCore/Sources/UsageSummary.swift b/Ledger/LedgerCore/Sources/UsageSummary.swift index 0b9bba93..f927dc19 100644 --- a/Ledger/LedgerCore/Sources/UsageSummary.swift +++ b/Ledger/LedgerCore/Sources/UsageSummary.swift @@ -14,10 +14,6 @@ public struct UsageSummary: Codable, Equatable, Sendable { /// `"pro"`, `"ultra"`, `"free"`, etc. public var membershipType: String public var individualUsage: IndividualUsage - /// The dashboard's own human-readable status lines, when present (e.g. - /// "You've used 21% of your included total usage"). - public var autoModelSelectedDisplayMessage: String? = nil - public var namedModelSelectedDisplayMessage: String? = nil public struct IndividualUsage: Codable, Equatable, Sendable { /// Usage-based (pay-per-use) spend — the money beyond the subscription. @@ -90,11 +86,6 @@ public struct UsageSummary: Codable, Equatable, Sendable { individualUsage.plan.totalPercentUsed.map { $0 / 100 } } - /// The dashboard's status lines, in order, omitting any the API didn't send. - public var usageMessages: [String] { - [autoModelSelectedDisplayMessage, namedModelSelectedDisplayMessage].compactMap(\.self) - } - private static func parseDate(_ string: String) -> Date? { // Dashboard timestamps carry fractional seconds ("…T18:16:08.000Z"). let withFraction = ISO8601DateFormatter() From 906ce4d6905e1c83e3a1e005cb0469a46b04dd9a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 17:58:14 -0700 Subject: [PATCH 18/34] Move the plan tier to the header Show the plan (e.g. Ultra) as a small badge in the top-right of the popover header, on the same row as 'Cursor Spend', and drop the separate Plan row (and the now-unused breakdownRow helper). --- Ledger/Ledger/Sources/SpendView.swift | 35 +++++++++++++++------------ 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/Ledger/Ledger/Sources/SpendView.swift b/Ledger/Ledger/Sources/SpendView.swift index 537d9072..f493f595 100644 --- a/Ledger/Ledger/Sources/SpendView.swift +++ b/Ledger/Ledger/Sources/SpendView.swift @@ -38,7 +38,7 @@ struct SpendView: View { } private var header: some View { - HStack { + HStack(spacing: 8) { Text("Cursor Spend") .font(.headline) Spacer() @@ -46,6 +46,24 @@ struct SpendView: View { ProgressView() .controlSize(.small) } + if let plan = planLabel { + Text(plan) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(.secondary.opacity(0.15)) + .clipShape(.capsule) + } + } + } + + /// The plan tier for the header badge, shown once loaded. + private var planLabel: String? { + if case let .loaded(snapshot) = session.loadState { + snapshot.membershipType.capitalized + } else { + nil } } @@ -89,10 +107,6 @@ struct SpendView: View { } } - VStack(alignment: .leading, spacing: 4) { - breakdownRow("Plan", snapshot.membershipType.capitalized) - } - if let fraction = snapshot.includedFractionUsed { includedUsage(fraction) } @@ -194,17 +208,6 @@ struct SpendView: View { } } - private func breakdownRow(_ label: String, _ value: String) -> some View { - HStack { - Text(label) - .foregroundStyle(.secondary) - Spacer() - Text(value) - .monospacedDigit() - } - .font(.callout) - } - private func cycleRange(_ snapshot: SpendSnapshot) -> String? { guard let start = snapshot.cycleStart, let end = snapshot.cycleEnd else { return nil } let formatter = Date.FormatStyle.dateTime.month(.abbreviated).day() From 2c3431d2eeeac56265325bc1e005092f27444509 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 18:06:25 -0700 Subject: [PATCH 19/34] Split included usage into first-party and API bars A single blended 'included usage' figure (totalPercentUsed) was misleading: it averaged a barely-used first-party/Auto pool (~1%) with a maxed third-party/API pool (100%). Show two side-by-side bars from autoPercentUsed/apiPercentUsed instead (each hidden when the API omits it), which also explains the on-demand spend. Drops the now-unused totalPercentUsed field. --- Ledger/Ledger/README.md | 4 ++- Ledger/Ledger/Sources/PreviewSupport.swift | 3 +- Ledger/Ledger/Sources/SpendView.swift | 35 +++++++++++++++---- .../LedgerCore/Sources/LedgerServices.swift | 3 +- Ledger/LedgerCore/Sources/SpendSnapshot.swift | 14 +++++--- Ledger/LedgerCore/Sources/TestDoubles.swift | 6 ++-- Ledger/LedgerCore/Sources/UsageSummary.swift | 21 ++++++----- .../Tests/LedgerCoreTestSupport.swift | 5 ++- .../LedgerCore/Tests/UsageSummaryTests.swift | 9 +++++ 9 files changed, 75 insertions(+), 25 deletions(-) diff --git a/Ledger/Ledger/README.md b/Ledger/Ledger/README.md index 2cc304f6..10638bb0 100644 --- a/Ledger/Ledger/README.md +++ b/Ledger/Ledger/README.md @@ -54,7 +54,9 @@ shortcut to System Settings if macOS needs you to approve the login item. the popover just shows the latest fetched state; it doesn't trigger a network request. - **Popover** — this cycle's spend and date range, your plan tier, an - **included-usage** progress bar, **top models + **included usage** as two side-by-side bars (first-party/Auto and + third-party/API — a single blended figure would hide that one pool can be + maxed while the other is barely used), **top models this cycle** as usage shares (each model ≥20% gets its own bar; smaller ones roll into a single multi-colored "Other models" bar with a legend), and when it last updated. A **Refresh** button diff --git a/Ledger/Ledger/Sources/PreviewSupport.swift b/Ledger/Ledger/Sources/PreviewSupport.swift index dc55ef6a..18844423 100644 --- a/Ledger/Ledger/Sources/PreviewSupport.swift +++ b/Ledger/Ledger/Sources/PreviewSupport.swift @@ -16,7 +16,8 @@ membershipType: "ultra", includedUsed: 40000, includedLimit: 40000, - totalPercentUsed: 20.9, + autoPercentUsed: 0.69, + apiPercentUsed: 100, ), ), aggregated: .fixture([ diff --git a/Ledger/Ledger/Sources/SpendView.swift b/Ledger/Ledger/Sources/SpendView.swift index f493f595..6ac4ae4b 100644 --- a/Ledger/Ledger/Sources/SpendView.swift +++ b/Ledger/Ledger/Sources/SpendView.swift @@ -107,8 +107,8 @@ struct SpendView: View { } } - if let fraction = snapshot.includedFractionUsed { - includedUsage(fraction) + if snapshot.autoFractionUsed != nil || snapshot.apiFractionUsed != nil { + includedUsage(auto: snapshot.autoFractionUsed, api: snapshot.apiFractionUsed) } if !snapshot.modelShares.isEmpty { @@ -123,18 +123,39 @@ struct SpendView: View { } } - private func includedUsage(_ fraction: Double) -> some View { + /// Included allowance shown as two side-by-side gauges — first-party (Auto) + /// and third-party (API) — since a single blended figure hides that one + /// pool can be exhausted while the other is barely touched. + private func includedUsage(auto: Double?, api: Double?) -> some View { VStack(alignment: .leading, spacing: 4) { + Text("Included usage") + .font(.callout) + .foregroundStyle(.secondary) + HStack(alignment: .top, spacing: 12) { + if let auto { + usageGauge(label: "First-party", fraction: auto, color: .teal) + } + if let api { + usageGauge(label: "API", fraction: api, color: .blue) + } + } + } + } + + private func usageGauge(label: String, fraction: Double, color: Color) -> some View { + VStack(alignment: .leading, spacing: 2) { HStack { - Text("Included usage") - .foregroundStyle(.secondary) + Text(label) + .lineLimit(1) Spacer() Text(fraction.formatted(.percent.precision(.fractionLength(0)))) .monospacedDigit() } - .font(.callout) - ProgressView(value: min(max(fraction, 0), 1)) + .font(.caption2) + .foregroundStyle(.secondary) + ShareBar(segments: [ShareSegment(color: color, fraction: fraction)]) } + .frame(maxWidth: .infinity, alignment: .leading) } /// Models this cycle: each model with ≥20% share gets its own bar; the rest diff --git a/Ledger/LedgerCore/Sources/LedgerServices.swift b/Ledger/LedgerCore/Sources/LedgerServices.swift index 0f164748..87dbbe72 100644 --- a/Ledger/LedgerCore/Sources/LedgerServices.swift +++ b/Ledger/LedgerCore/Sources/LedgerServices.swift @@ -201,7 +201,8 @@ public final class LedgerServices { cycleStart: summary.cycleStart, cycleEnd: summary.cycleEnd, membershipType: summary.membershipType, - includedFractionUsed: summary.includedFractionUsed, + autoFractionUsed: summary.autoFractionUsed, + apiFractionUsed: summary.apiFractionUsed, modelShares: models, ) loadState = .loaded(snapshot) diff --git a/Ledger/LedgerCore/Sources/SpendSnapshot.swift b/Ledger/LedgerCore/Sources/SpendSnapshot.swift index 32f5cf5d..3f7ac35a 100644 --- a/Ledger/LedgerCore/Sources/SpendSnapshot.swift +++ b/Ledger/LedgerCore/Sources/SpendSnapshot.swift @@ -11,8 +11,12 @@ public struct SpendSnapshot: Equatable, Sendable { public var cycleEnd: Date? /// Plan tier (`"pro"`, `"ultra"`, …). public var membershipType: String - /// Fraction (0...1) of the included allowance used this cycle, when known. - public var includedFractionUsed: Double? + /// Fraction (0...1) of the included first-party/Auto allowance used this + /// cycle, when known. + public var autoFractionUsed: Double? + /// Fraction (0...1) of the included third-party/API allowance used this + /// cycle, when known. + public var apiFractionUsed: Double? /// Models by usage this cycle, as relative shares highest-first (dollar-free /// — see ``AggregatedUsage``). Empty when the per-model fetch is unavailable. public var modelShares: [ModelShare] @@ -22,14 +26,16 @@ public struct SpendSnapshot: Equatable, Sendable { cycleStart: Date?, cycleEnd: Date?, membershipType: String, - includedFractionUsed: Double?, + autoFractionUsed: Double?, + apiFractionUsed: Double?, modelShares: [ModelShare], ) { self.currentCycleCents = currentCycleCents self.cycleStart = cycleStart self.cycleEnd = cycleEnd self.membershipType = membershipType - self.includedFractionUsed = includedFractionUsed + self.autoFractionUsed = autoFractionUsed + self.apiFractionUsed = apiFractionUsed self.modelShares = modelShares } diff --git a/Ledger/LedgerCore/Sources/TestDoubles.swift b/Ledger/LedgerCore/Sources/TestDoubles.swift index 2b684d20..23512480 100644 --- a/Ledger/LedgerCore/Sources/TestDoubles.swift +++ b/Ledger/LedgerCore/Sources/TestDoubles.swift @@ -105,7 +105,8 @@ membershipType: String = "pro", includedUsed: Int = 0, includedLimit: Int? = nil, - totalPercentUsed: Double? = nil, + autoPercentUsed: Double? = nil, + apiPercentUsed: Double? = nil, cycleStart: String = "2026-07-04T18:16:08.000Z", cycleEnd: String = "2026-08-04T18:16:08.000Z", ) -> UsageSummary { @@ -121,7 +122,8 @@ limit: includedLimit, remaining: nil, breakdown: nil, - totalPercentUsed: totalPercentUsed, + autoPercentUsed: autoPercentUsed, + apiPercentUsed: apiPercentUsed, ), ), ) diff --git a/Ledger/LedgerCore/Sources/UsageSummary.swift b/Ledger/LedgerCore/Sources/UsageSummary.swift index f927dc19..24feb59b 100644 --- a/Ledger/LedgerCore/Sources/UsageSummary.swift +++ b/Ledger/LedgerCore/Sources/UsageSummary.swift @@ -39,11 +39,10 @@ public struct UsageSummary: Codable, Equatable, Sendable { public var limit: Int? public var remaining: Int? public var breakdown: Breakdown? - /// Percentage (0...100) of the total included allowance used this - /// cycle, when reported — the figure behind the "N% of included usage" - /// message. - public var totalPercentUsed: Double? = nil + /// Percentage (0...100) of the included first-party/Auto allowance used + /// this cycle, when reported (some account types omit these). public var autoPercentUsed: Double? = nil + /// Percentage (0...100) of the included third-party/API allowance used. public var apiPercentUsed: Double? = nil } @@ -80,10 +79,16 @@ public struct UsageSummary: Codable, Equatable, Sendable { individualUsage.onDemand.used } - /// Fraction (0...1) of the included allowance used this cycle, when the API - /// reports a percentage. - public var includedFractionUsed: Double? { - individualUsage.plan.totalPercentUsed.map { $0 / 100 } + /// Fraction (0...1) of the included **first-party / Auto** allowance used + /// this cycle, when the API reports it. + public var autoFractionUsed: Double? { + individualUsage.plan.autoPercentUsed.map { $0 / 100 } + } + + /// Fraction (0...1) of the included **third-party / API** allowance used + /// this cycle, when the API reports it. + public var apiFractionUsed: Double? { + individualUsage.plan.apiPercentUsed.map { $0 / 100 } } private static func parseDate(_ string: String) -> Date? { diff --git a/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift b/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift index 74254ba4..ad68089f 100644 --- a/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift +++ b/Ledger/LedgerCore/Tests/LedgerCoreTestSupport.swift @@ -68,7 +68,10 @@ enum DashboardFixture { "used": 40000, "limit": 40000, "remaining": 0, - "breakdown": { "included": 40000, "bonus": 12158, "total": 52158 } + "breakdown": { "included": 40000, "bonus": 12158, "total": 52158 }, + "autoPercentUsed": 0.69, + "apiPercentUsed": 100, + "totalPercentUsed": 20.87 }, "onDemand": { "enabled": true, "used": 315609, "limit": null, "remaining": null } }, diff --git a/Ledger/LedgerCore/Tests/UsageSummaryTests.swift b/Ledger/LedgerCore/Tests/UsageSummaryTests.swift index 5727917c..ebe6bcec 100644 --- a/Ledger/LedgerCore/Tests/UsageSummaryTests.swift +++ b/Ledger/LedgerCore/Tests/UsageSummaryTests.swift @@ -17,6 +17,15 @@ struct UsageSummaryTests { #expect(summary.individualUsage.onDemand.limit == nil) } + @Test func exposesFirstPartyAndAPIPoolFractions() throws { + let summary = try JSONDecoder().decode( + UsageSummary.self, + from: Data(DashboardFixture.usageSummaryJSON.utf8), + ) + #expect(summary.autoFractionUsed == 0.0069) + #expect(summary.apiFractionUsed == 1.0) + } + @Test func parsesFractionalSecondISO8601CycleDates() throws { let summary = try JSONDecoder().decode( UsageSummary.self, From 6b1a5de40a118c2a16eccad7bfd4df14827ef5d1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 18:13:53 -0700 Subject: [PATCH 20/34] Add a model-name parser for friendlier labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse raw model ids (claude-opus-4-8-thinking-xhigh, github_bugbot, non-max-composer-2.5-fast, …) into a friendly displayName plus badges (reasoning effort / speed / mode) via a new LedgerCore ModelName tokenizer: maps known vendor/family words, collapses hyphenated version numbers (4-8 -> 4.8), drops 'thinking'/hosting prefixes, and title-cases unknown models. The popover renders the name with small badge chips in the model rows and legend. --- Ledger/Ledger/Sources/SpendView.swift | 49 ++++++-- Ledger/LedgerCore/README.md | 2 + Ledger/LedgerCore/Sources/ModelName.swift | 123 +++++++++++++++++++ Ledger/LedgerCore/Tests/ModelNameTests.swift | 63 ++++++++++ 4 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 Ledger/LedgerCore/Sources/ModelName.swift create mode 100644 Ledger/LedgerCore/Tests/ModelNameTests.swift diff --git a/Ledger/Ledger/Sources/SpendView.swift b/Ledger/Ledger/Sources/SpendView.swift index 6ac4ae4b..261d6bc7 100644 --- a/Ledger/Ledger/Sources/SpendView.swift +++ b/Ledger/Ledger/Sources/SpendView.swift @@ -180,27 +180,29 @@ struct SpendView: View { ForEach(majors) { model in shareRow( - label: model.name, fraction: model.fraction, segments: [ShareSegment(color: model.color, fraction: model.fraction)], - ) + ) { + ModelLabel(name: ModelName.parse(model.name)) + } } if !minors.isEmpty { shareRow( - label: "Other models", fraction: minorsTotal, segments: minors.map { ShareSegment(color: $0.color, fraction: $0.fraction) }, - ) + ) { + Text("Other models") + .lineLimit(1) + .truncationMode(.middle) + } // Legend so the rolled-up colors are identifiable. ForEach(minors) { model in HStack(spacing: 6) { Circle() .fill(model.color) .frame(width: 7, height: 7) - Text(model.name) - .lineLimit(1) - .truncationMode(.middle) + ModelLabel(name: ModelName.parse(model.name)) Spacer() Text(model.fraction.formatted(.percent.precision(.fractionLength(0)))) .monospacedDigit() @@ -213,12 +215,14 @@ struct SpendView: View { } } - private func shareRow(label: String, fraction: Double, segments: [ShareSegment]) -> some View { + private func shareRow( + fraction: Double, + segments: [ShareSegment], + @ViewBuilder label: () -> some View, + ) -> some View { VStack(alignment: .leading, spacing: 2) { HStack { - Text(label) - .lineLimit(1) - .truncationMode(.middle) + label() Spacer() Text(fraction.formatted(.percent.precision(.fractionLength(0)))) .monospacedDigit() @@ -272,6 +276,29 @@ struct SpendView: View { } } +/// Renders a parsed ``ModelName`` — the friendly name plus small badge chips +/// (reasoning effort, speed, mode). Adopts the ambient font for the name; the +/// badges stay compact. +private struct ModelLabel: View { + let name: ModelName + + var body: some View { + HStack(spacing: 4) { + Text(name.displayName) + .lineLimit(1) + .truncationMode(.tail) + ForEach(name.badges, id: \.self) { badge in + Text(badge) + .font(.system(size: 9, weight: .semibold)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(.secondary.opacity(0.2), in: .capsule) + .foregroundStyle(.secondary) + } + } + } +} + /// A model paired with its display color for the models breakdown. private struct ColoredShare: Identifiable { let name: String diff --git a/Ledger/LedgerCore/README.md b/Ledger/LedgerCore/README.md index 7e63ed3a..0499817d 100644 --- a/Ledger/LedgerCore/README.md +++ b/Ledger/LedgerCore/README.md @@ -40,6 +40,8 @@ auto-token", surfaced as `LoadError.missingCredentials`. - `SessionToken` / `SessionTokenSource` / `CursorLocalTokenSource` — the auth seam. - `DashboardProvider` + `CursorDashboardAPI` — the network seam. +- `ModelName` — parses a raw model id (`claude-opus-4-8-thinking-xhigh`, + `github_bugbot`, …) into a friendly `displayName` + `badges` (effort/speed/mode). - `UsageSummary`, `AggregatedUsage`, `SpendSnapshot` — the wire + view models (cents are integers). - `KeychainStore` / `SystemKeychainStore` — a pasted token's storage. diff --git a/Ledger/LedgerCore/Sources/ModelName.swift b/Ledger/LedgerCore/Sources/ModelName.swift new file mode 100644 index 00000000..6b3c4578 --- /dev/null +++ b/Ledger/LedgerCore/Sources/ModelName.swift @@ -0,0 +1,123 @@ +import Foundation + +/// A raw model identifier (e.g. `claude-opus-4-8-thinking-xhigh`, +/// `github_bugbot`, `non-max-composer-2.5-fast`) parsed into a friendly display +/// name plus a set of short badges (reasoning effort, speed, mode). +/// +/// This is a best-effort heuristic tokenizer, not an exhaustive registry: it +/// recognizes the vendor/family words, version numbers, and effort/mode +/// suffixes seen in Cursor's model ids, and title-cases anything it doesn't +/// know — so a brand-new model still renders reasonably (`mistral-large-2` → +/// "Mistral Large 2") rather than as a raw slug. +public struct ModelName: Equatable, Sendable { + /// The cleaned-up name, e.g. "Claude Opus 4.8". + public var displayName: String + /// Short badges in display order (mode, then effort, then speed), e.g. + /// `["Non-max", "XHigh"]`. + public var badges: [String] + + public init(displayName: String, badges: [String]) { + self.displayName = displayName + self.badges = badges + } + + /// Reasoning-effort tokens → badge label. + private static let effortLabels: [String: String] = [ + "low": "Low", + "medium": "Medium", + "high": "High", + "xhigh": "XHigh", + "min": "Min", + ] + + /// Vendor/family words → their display casing. Anything absent is + /// title-cased. + private static let wordLabels: [String: String] = [ + "claude": "Claude", + "opus": "Opus", + "sonnet": "Sonnet", + "haiku": "Haiku", + "fable": "Fable", + "gpt": "GPT", + "codex": "Codex", + "sol": "Sol", + "grok": "Grok", + "composer": "Composer", + "gemini": "Gemini", + "github": "GitHub", + "bugbot": "Bugbot", + "auto": "Auto", + ] + + /// Hosting/qualifier words dropped from the display name. + private static let droppedWords: Set = ["cursor"] + + /// Parses `raw` into a display name and badges. An unrecognized or empty + /// input falls back to the raw string as the display name. + public static func parse(_ raw: String) -> ModelName { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return ModelName(displayName: raw, badges: []) } + + var tokens = trimmed.lowercased().split { $0 == "-" || $0 == "_" }.map(String.init) + + var modeBadges: [String] = [] + // A leading "non-max" (→ ["non", "max"]) is a mode, not part of the name. + if tokens.first == "non", tokens.count > 1, tokens[1] == "max" { + modeBadges.append("Non-max") + tokens.removeFirst(2) + } + + var effortBadges: [String] = [] + var speedBadges: [String] = [] + var nameTokens: [String] = [] + for token in tokens { + if let effort = effortLabels[token] { + effortBadges.append(effort) + } else if token == "max" { + modeBadges.append("Max") + } else if token == "fast" { + speedBadges.append("Fast") + } else if token == "thinking" { + continue // implied by the effort badge; omit from the name + } else if droppedWords.contains(token) { + continue + } else { + nameTokens.append(token) + } + } + + let displayName = formatName(nameTokens) + let badges = modeBadges + effortBadges + speedBadges + return ModelName( + displayName: displayName.isEmpty ? trimmed : displayName, + badges: badges, + ) + } + + /// Joins name tokens, mapping known words and collapsing runs of integer + /// tokens into a dotted version (`["4", "8"]` → `"4.8"`). + private static func formatName(_ tokens: [String]) -> String { + var parts: [String] = [] + var index = 0 + while index < tokens.count { + if tokens[index].isAllDigits { + var numbers: [String] = [] + while index < tokens.count, tokens[index].isAllDigits { + numbers.append(tokens[index]) + index += 1 + } + parts.append(numbers.joined(separator: ".")) + } else { + parts.append(wordLabels[tokens[index]] ?? tokens[index].capitalized) + index += 1 + } + } + return parts.joined(separator: " ") + } +} + +extension String { + fileprivate var isAllDigits: Bool { + !isEmpty && allSatisfy(\.isNumber) + } +} diff --git a/Ledger/LedgerCore/Tests/ModelNameTests.swift b/Ledger/LedgerCore/Tests/ModelNameTests.swift new file mode 100644 index 00000000..887240a6 --- /dev/null +++ b/Ledger/LedgerCore/Tests/ModelNameTests.swift @@ -0,0 +1,63 @@ +@_spi(Testing) import LedgerCore +import Testing + +struct ModelNameTests { + @Test func parsesClaudeWithHyphenatedVersionAndEffort() { + let name = ModelName.parse("claude-opus-4-8-thinking-xhigh") + #expect(name.displayName == "Claude Opus 4.8") + #expect(name.badges == ["XHigh"]) + } + + @Test func keepsDottedVersionsAndMapsEffort() { + let name = ModelName.parse("composer-2.5-fast") + #expect(name.displayName == "Composer 2.5") + #expect(name.badges == ["Fast"]) + } + + @Test func dropsThinkingFromTheName() { + let name = ModelName.parse("claude-fable-5-thinking-high") + #expect(name.displayName == "Claude Fable 5") + #expect(name.badges == ["High"]) + } + + @Test func handlesEffortBeforeThinking() { + let name = ModelName.parse("claude-4.6-sonnet-high-thinking") + #expect(name.displayName == "Claude 4.6 Sonnet") + #expect(name.badges == ["High"]) + } + + @Test func mapsUnderscoreIdentifiers() { + let name = ModelName.parse("github_bugbot") + #expect(name.displayName == "GitHub Bugbot") + #expect(name.badges.isEmpty) + } + + @Test func extractsNonMaxModeBeforeEffort() { + let name = ModelName.parse("non-max-claude-opus-4-8-thinking-xhigh") + #expect(name.displayName == "Claude Opus 4.8") + #expect(name.badges == ["Non-max", "XHigh"]) + } + + @Test func dropsHostingPrefix() { + let name = ModelName.parse("cursor-grok-4.5-high") + #expect(name.displayName == "Grok 4.5") + #expect(name.badges == ["High"]) + } + + @Test func keepsFamilyWordsLikeSol() { + let name = ModelName.parse("gpt-5.6-sol-high") + #expect(name.displayName == "GPT 5.6 Sol") + #expect(name.badges == ["High"]) + } + + @Test func titleCasesUnknownModels() { + let name = ModelName.parse("mistral-large-2") + #expect(name.displayName == "Mistral Large 2") + #expect(name.badges.isEmpty) + } + + @Test func emptyFallsBackToRaw() { + #expect(ModelName.parse("").displayName == "") + #expect(ModelName.parse(" ").displayName == " ") + } +} From 29e0d9967937e5da47e1c414b040f99c2d3f4293 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 19 Jul 2026 18:17:59 -0700 Subject: [PATCH 21/34] Popover polish: lowercase badges, drop 'This cycle', move 'Updated' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model badges (xhigh, fast, high, non-max, …) render lowercase. - Remove the redundant 'This cycle' caption above the headline amount (the cycle date range below it already says so). - Move the 'Updated