From 23abf6a6350015dbbc97159564f5ee2678beaf6f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 7 Jun 2026 15:52:03 -0400 Subject: [PATCH] feat(macos-bar): add CCS Bar core (client, models, discovery) + tests Pure-Foundation CCSBarCore: thin CCS web-server client (summary force-fresh, pause/resume/default/solo/tier-lock), bar.json discovery with an offline state, summary models, status-title formatting, and a force-refresh debouncer. Tested via a runnable assert harness (ccs-bar-check) so it builds without full Xcode. --- macos-bar/.gitignore | 5 + macos-bar/Package.swift | 23 ++ macos-bar/Sources/CCSBarCheck/main.swift | 201 ++++++++++++++++++ .../Sources/CCSBarCore/BarDiscovery.swift | 61 ++++++ .../Sources/CCSBarCore/BarFormatting.swift | 62 ++++++ macos-bar/Sources/CCSBarCore/BarSummary.swift | 77 +++++++ .../Sources/CCSBarCore/CCSBarClient.swift | 96 +++++++++ 7 files changed, 525 insertions(+) create mode 100644 macos-bar/.gitignore create mode 100644 macos-bar/Package.swift create mode 100644 macos-bar/Sources/CCSBarCheck/main.swift create mode 100644 macos-bar/Sources/CCSBarCore/BarDiscovery.swift create mode 100644 macos-bar/Sources/CCSBarCore/BarFormatting.swift create mode 100644 macos-bar/Sources/CCSBarCore/BarSummary.swift create mode 100644 macos-bar/Sources/CCSBarCore/CCSBarClient.swift diff --git a/macos-bar/.gitignore b/macos-bar/.gitignore new file mode 100644 index 00000000..837dd9ee --- /dev/null +++ b/macos-bar/.gitignore @@ -0,0 +1,5 @@ +.build/ +dist/ +*.zip +DerivedData/ +*.xcodeproj diff --git a/macos-bar/Package.swift b/macos-bar/Package.swift new file mode 100644 index 00000000..8e8a642f --- /dev/null +++ b/macos-bar/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version:5.9 +import PackageDescription + +// CCS Bar - native macOS menu bar client for CCS. +// +// Build/test note: full Xcode (and therefore XCTest) is not required. The +// testable logic lives in the pure-Foundation `CCSBarCore` target and is +// exercised by the `ccs-bar-check` executable (an assert harness) so it runs +// on a CommandLineTools-only toolchain. The SwiftUI app target is added once +// the core is verified. +let package = Package( + name: "CCSBar", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "CCSBar", targets: ["CCSBarApp"]), + .executable(name: "ccs-bar-check", targets: ["CCSBarCheck"]), + ], + targets: [ + .target(name: "CCSBarCore"), + .executableTarget(name: "CCSBarApp", dependencies: ["CCSBarCore"]), + .executableTarget(name: "CCSBarCheck", dependencies: ["CCSBarCore"]), + ] +) diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift new file mode 100644 index 00000000..212b99fa --- /dev/null +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -0,0 +1,201 @@ +import Foundation +import CCSBarCore + +// Lightweight assert harness used in place of XCTest (unavailable without a +// full Xcode install). Run with `swift run ccs-bar-check`; exits non-zero on +// any failure so it works as a CI/test gate on a CommandLineTools toolchain. + +var failures = 0 +func check(_ condition: Bool, _ message: String) { + if condition { + print("[OK] \(message)") + } else { + print("[X] \(message)") + failures += 1 + } +} + +// Recording transport so CCSBarClient can be exercised without a live server. +final class RequestRecorder: @unchecked Sendable { + var lastRequest: URLRequest? + var responseData = Data("[]".utf8) + var status = 200 +} +struct RecordingTransport: HTTPTransport { + let recorder: RequestRecorder + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + recorder.lastRequest = request + let http = HTTPURLResponse( + url: request.url!, statusCode: recorder.status, httpVersion: nil, headerFields: nil)! + return (recorder.responseData, http) + } +} + +// MARK: BarSummaryRow decoding (mixed snake_case / camelCase keys) + +let summaryJSON = """ +[ + { + "account_id": "alice@example.com", + "provider": "agy", + "displayName": "Alice (Ultra)", + "tier": "ultra", + "paused": false, + "quota_percentage": 82.4, + "next_reset": "2026-06-08T00:00:00Z", + "today_cost": 3.2, + "health": "ok", + "cached": true, + "fetchedAt": "2026-06-07T19:00:00Z", + "needsReauth": false + }, + { + "account_id": "bob@example.com", + "provider": "codex", + "displayName": null, + "tier": null, + "paused": true, + "quota_percentage": null, + "next_reset": null, + "today_cost": null, + "health": "warning", + "cached": false, + "fetchedAt": null, + "needsReauth": true + } +] +""" + +do { + let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(summaryJSON.utf8)) + check(rows.count == 2, "decodes two rows") + check(rows[0].accountId == "alice@example.com", "maps account_id") + check(rows[0].quotaPercentage == 82.4, "maps quota_percentage") + check(rows[0].todayCost == 3.2, "maps today_cost") + check(rows[0].id == "agy:alice@example.com", "stable id is provider:account") + check(rows[1].quotaPercentage == nil, "null quota decodes to nil") + check(rows[1].paused == true, "maps paused") + check(rows[1].needsReauth == true, "maps needsReauth") + check(rows[1].healthDot == "!", "warning -> ! dot") + check(rows[0].healthDot == "OK", "ok -> OK dot") +} catch { + check(false, "decoding threw: \(error)") +} + +// MARK: BarDiscovery loading + +let tmp = NSTemporaryDirectory() + "ccs-bar-check-\(ProcessInfo.processInfo.globallyUniqueString)" +let ccsDir = tmp + "/.ccs" +try? FileManager.default.createDirectory(atPath: ccsDir, withIntermediateDirectories: true) +let barJSON = """ +{ "baseUrl": "http://127.0.0.1:3210", "port": 3210, "authMode": "loopback" } +""" +try? barJSON.write(toFile: ccsDir + "/bar.json", atomically: true, encoding: .utf8) + +switch BarDiscovery.load(home: tmp) { +case .success(let d): + check(d.port == 3210, "discovery reads port") + check(d.authMode == "loopback", "discovery reads authMode") + check(d.resolvedURL?.absoluteString == "http://127.0.0.1:3210", "resolvedURL from baseUrl") +case .failure(let e): + check(false, "discovery load failed: \(e)") +} + +let missingHome = NSTemporaryDirectory() + "ccs-bar-check-missing-\(ProcessInfo.processInfo.globallyUniqueString)" +switch BarDiscovery.load(home: missingHome) { +case .failure(.missing): + check(true, "absent bar.json -> .missing (offline state)") +case .success: + check(false, "expected .missing for absent file") +case .failure(let e): + check(false, "expected .missing, got \(e)") +} + +// MARK: BarFormatting + +check(BarFormatting.quotaLabel(82.4) == "82%", "quota label rounds") +check(BarFormatting.quotaLabel(nil) == "--", "nil quota -> --") +check(BarFormatting.costLabel(3.2) == "$3.20", "cost label formats") +check(BarFormatting.costLabel(0) == "", "zero cost hidden") +check(BarFormatting.costLabel(nil) == "", "nil cost hidden") + +do { + let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(summaryJSON.utf8)) + let title = BarFormatting.statusTitle(rows: rows) + // Active rows only (bob is paused); alice leads. Total cost = 3.2. + check(title.contains("agy 82%"), "title shows leading active account + quota") + check(title.contains("$3.20"), "title shows total cost") +} + +// MARK: RefreshDebouncer (arms at decision time) + +var deb = RefreshDebouncer(interval: 15) +let t0 = Date(timeIntervalSince1970: 1000) +check(deb.shouldRefresh(now: t0), "first force-refresh proceeds") +check(!deb.shouldRefresh(now: t0.addingTimeInterval(5)), "within 15s blocked") +check(!deb.shouldRefresh(now: t0.addingTimeInterval(14.9)), "just before window end blocked") +check(deb.shouldRefresh(now: t0.addingTimeInterval(15)), "at/after 15s proceeds") + +// MARK: CCSBarClient (recording transport) + +let recorder = RequestRecorder() +recorder.responseData = Data(summaryJSON.utf8) +let client = CCSBarClient( + baseURL: URL(string: "http://127.0.0.1:3210")!, + transport: RecordingTransport(recorder: recorder) +) + +do { + let rows = try await client.summary(refresh: true) + check(rows.count == 2, "client.summary decodes rows") + check( + recorder.lastRequest?.url?.query?.contains("refresh=true") == true, + "summary(refresh: true) adds ?refresh=true") +} catch { + check(false, "client.summary threw: \(error)") +} + +recorder.responseData = Data("{}".utf8) +recorder.lastRequest = nil +do { + try await client.pause(provider: "agy", accountId: "alice@example.com") + check(recorder.lastRequest?.httpMethod == "POST", "pause is POST") + check( + recorder.lastRequest?.url?.path.hasSuffix("bulk-pause") == true, + "pause hits bulk-pause endpoint") +} catch { + check(false, "pause threw: \(error)") +} + +recorder.lastRequest = nil +do { + try await client.tierLock(provider: "agy", tier: nil) + let body = recorder.lastRequest?.httpBody ?? Data() + let obj = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] + check(obj?["tier"] is NSNull, "tierLock(nil) serializes tier: null") + check((obj?["provider"] as? String) == "agy", "tierLock sends provider") +} catch { + check(false, "tierLock threw: \(error)") +} + +recorder.status = 409 +do { + _ = try await client.summary(refresh: false) + check(false, "non-200 summary should throw") +} catch CCSBarClientError.httpStatus(let code) { + check(code == 409, "non-200 -> httpStatus(409)") +} catch { + check(false, "wrong error type: \(error)") +} +recorder.status = 200 + +// cleanup +try? FileManager.default.removeItem(atPath: tmp) + +if failures > 0 { + print("\nFAILED: \(failures) check(s)") + exit(1) +} else { + print("\nALL CHECKS PASSED") + exit(0) +} diff --git a/macos-bar/Sources/CCSBarCore/BarDiscovery.swift b/macos-bar/Sources/CCSBarCore/BarDiscovery.swift new file mode 100644 index 00000000..3bbf4a8f --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarDiscovery.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Connection handshake the app reads to find the running CCS web-server. +/// +/// Written by `ccs bar launch` to `~/.ccs/bar.json`. v1 only supports +/// `authMode == "loopback"` (dashboard auth disabled, localhost). +public struct BarDiscovery: Codable, Sendable, Equatable { + public let baseUrl: String + public let port: Int + public let authMode: String + + public init(baseUrl: String, port: Int, authMode: String) { + self.baseUrl = baseUrl + self.port = port + self.authMode = authMode + } + + enum CodingKeys: String, CodingKey { + case baseUrl + case port + case authMode + } + + /// Resolved base URL, falling back to a localhost URL built from `port` + /// when `baseUrl` is empty or unparseable. + public var resolvedURL: URL? { + if let url = URL(string: baseUrl), url.scheme != nil { return url } + return URL(string: "http://127.0.0.1:\(port)") + } + + public enum LoadError: Error, Equatable { + case missing(path: String) + case unreadable(path: String) + case malformed + } + + /// Default discovery file path under the given home directory. + public static func defaultPath(home: String = NSHomeDirectory()) -> String { + URL(fileURLWithPath: home) + .appendingPathComponent(".ccs") + .appendingPathComponent("bar.json") + .path + } + + /// Load discovery from `~/.ccs/bar.json`. Returns a typed error when the + /// file is absent (CCS not launched) or malformed so the UI can show a + /// clear "CCS offline" state instead of crashing. + public static func load(home: String = NSHomeDirectory()) -> Result { + let path = defaultPath(home: home) + guard FileManager.default.fileExists(atPath: path) else { + return .failure(.missing(path: path)) + } + guard let data = FileManager.default.contents(atPath: path) else { + return .failure(.unreadable(path: path)) + } + guard let discovery = try? JSONDecoder().decode(BarDiscovery.self, from: data) else { + return .failure(.malformed) + } + return .success(discovery) + } +} diff --git a/macos-bar/Sources/CCSBarCore/BarFormatting.swift b/macos-bar/Sources/CCSBarCore/BarFormatting.swift new file mode 100644 index 00000000..76384ef2 --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarFormatting.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Pure formatting helpers for the status-bar title and dropdown rows. +/// No SwiftUI dependency so they are unit-testable on any toolchain. +public enum BarFormatting { + /// Quota percentage label, e.g. "82%" or "--" when unknown. + public static func quotaLabel(_ pct: Double?) -> String { + guard let pct else { return "--" } + return "\(Int(pct.rounded()))%" + } + + /// Today cost label, e.g. "$3.20" or "" when unknown/zero-not-shown. + public static func costLabel(_ cost: Double?) -> String { + guard let cost, cost > 0 else { return "" } + return String(format: "$%.2f", cost) + } + + /// Compact status-bar title. Shows the most-used (lowest remaining quota) + /// active account, plus today's total cost when available. + /// Example: "agy 82% ยท $3.20". Falls back to "CCS" when there are no rows. + public static func statusTitle(rows: [BarSummaryRow]) -> String { + let active = rows.filter { !$0.paused } + guard let lead = leadRow(active.isEmpty ? rows : active) else { return "CCS" } + var parts: [String] = [] + let q = quotaLabel(lead.quotaPercentage) + parts.append("\(lead.provider) \(q)") + let total = rows.compactMap { $0.todayCost }.reduce(0, +) + let cost = costLabel(total) + if !cost.isEmpty { parts.append(cost) } + return parts.joined(separator: " \u{00B7} ") + } + + /// The row to surface in the compact title: the one closest to exhaustion + /// (highest quota used). Rows without a known percentage sort last. + static func leadRow(_ rows: [BarSummaryRow]) -> BarSummaryRow? { + rows.max { lhs, rhs in + (lhs.quotaPercentage ?? -1) < (rhs.quotaPercentage ?? -1) + } + } +} + +/// Force-refresh debounce. Arms the window at decision time so concurrent +/// open-triggered refreshes do not both bypass it (matches the server-side +/// 15s debounce on `/api/bar/summary?refresh=true`). +public struct RefreshDebouncer { + public let interval: TimeInterval + private var lastArmed: Date? + + public init(interval: TimeInterval = 15) { + self.interval = interval + } + + /// Returns true and arms the window if a force-refresh should proceed at + /// `now`; returns false when still inside the previous window. + public mutating func shouldRefresh(now: Date) -> Bool { + if let lastArmed, now.timeIntervalSince(lastArmed) < interval { + return false + } + lastArmed = now + return true + } +} diff --git a/macos-bar/Sources/CCSBarCore/BarSummary.swift b/macos-bar/Sources/CCSBarCore/BarSummary.swift new file mode 100644 index 00000000..58bc3d4e --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarSummary.swift @@ -0,0 +1,77 @@ +import Foundation + +/// One account row in the menu-bar glance. +/// +/// Mirrors the `GET /api/bar/summary` payload exactly (mixed snake_case and +/// camelCase keys, matching the CCS web-server response). +public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { + public let accountId: String + public let provider: String + public let displayName: String? + public let tier: String? + public let paused: Bool + public let quotaPercentage: Double? + public let nextReset: String? + public let todayCost: Double? + public let health: String + public let cached: Bool + public let fetchedAt: String? + public let needsReauth: Bool + + /// Stable identity for SwiftUI lists: provider-scoped account id. + public var id: String { "\(provider):\(accountId)" } + + enum CodingKeys: String, CodingKey { + case accountId = "account_id" + case provider + case displayName + case tier + case paused + case quotaPercentage = "quota_percentage" + case nextReset = "next_reset" + case todayCost = "today_cost" + case health + case cached + case fetchedAt + case needsReauth + } + + public init( + accountId: String, + provider: String, + displayName: String? = nil, + tier: String? = nil, + paused: Bool = false, + quotaPercentage: Double? = nil, + nextReset: String? = nil, + todayCost: Double? = nil, + health: String = "ok", + cached: Bool = false, + fetchedAt: String? = nil, + needsReauth: Bool = false + ) { + self.accountId = accountId + self.provider = provider + self.displayName = displayName + self.tier = tier + self.paused = paused + self.quotaPercentage = quotaPercentage + self.nextReset = nextReset + self.todayCost = todayCost + self.health = health + self.cached = cached + self.fetchedAt = fetchedAt + self.needsReauth = needsReauth + } +} + +extension BarSummaryRow { + /// Health rendered as an ASCII-safe dot for the dropdown. + public var healthDot: String { + switch health { + case "error": return "X" + case "warning": return "!" + default: return "OK" + } + } +} diff --git a/macos-bar/Sources/CCSBarCore/CCSBarClient.swift b/macos-bar/Sources/CCSBarCore/CCSBarClient.swift new file mode 100644 index 00000000..b0fd5773 --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/CCSBarClient.swift @@ -0,0 +1,96 @@ +import Foundation + +/// Injectable HTTP transport so the client is testable without a live server +/// (the assert harness supplies a recording/mock transport). +public protocol HTTPTransport: Sendable { + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) +} + +public struct URLSessionTransport: HTTPTransport { + let session: URLSession + public init(session: URLSession = .shared) { self.session = session } + public func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw CCSBarClientError.nonHTTPResponse + } + return (data, http) + } +} + +public enum CCSBarClientError: Error, Equatable { + case nonHTTPResponse + case httpStatus(Int) + case badURL + case decoding +} + +/// Thin client over the CCS local web-server. The app NEVER talks to a +/// provider directly; every call goes to localhost and CCS performs any +/// provider fetch server-side. +public struct CCSBarClient { + let baseURL: URL + let transport: HTTPTransport + + public init(baseURL: URL, transport: HTTPTransport = URLSessionTransport()) { + self.baseURL = baseURL + self.transport = transport + } + + /// GET /api/bar/summary[?refresh=true]. Cached by default; `refresh: true` + /// asks CCS to pull live from providers server-side. + public func summary(refresh: Bool = false) async throws -> [BarSummaryRow] { + guard + var comps = URLComponents( + url: baseURL.appendingPathComponent("api/bar/summary"), + resolvingAgainstBaseURL: false + ) + else { throw CCSBarClientError.badURL } + if refresh { comps.queryItems = [URLQueryItem(name: "refresh", value: "true")] } + guard let url = comps.url else { throw CCSBarClientError.badURL } + + let (data, http) = try await transport.send(URLRequest(url: url)) + guard http.statusCode == 200 else { throw CCSBarClientError.httpStatus(http.statusCode) } + do { + return try JSONDecoder().decode([BarSummaryRow].self, from: data) + } catch { + throw CCSBarClientError.decoding + } + } + + // MARK: Account control (reuses existing CCS endpoints) + + public func pause(provider: String, accountId: String) async throws { + try await post("api/accounts/bulk-pause", body: ["provider": provider, "accountIds": [accountId]]) + } + + public func resume(provider: String, accountId: String) async throws { + try await post("api/accounts/bulk-resume", body: ["provider": provider, "accountIds": [accountId]]) + } + + public func setDefault(name: String) async throws { + try await post("api/accounts/default", body: ["name": name]) + } + + public func solo(provider: String, accountId: String) async throws { + try await post("api/accounts/solo", body: ["provider": provider, "accountId": accountId]) + } + + /// Lock a provider's account selection to a tier, or pass `nil` to clear. + public func tierLock(provider: String, tier: String?) async throws { + try await post("api/accounts/tier-lock", body: ["provider": provider, "tier": tier ?? NSNull()]) + } + + @discardableResult + func post(_ path: String, body: [String: Any]) async throws -> Data { + var request = URLRequest(url: baseURL.appendingPathComponent(path)) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, http) = try await transport.send(request) + guard (200..<300).contains(http.statusCode) else { + throw CCSBarClientError.httpStatus(http.statusCode) + } + return data + } +}