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.
This commit is contained in:
Tam Nhu Tran
2026-06-07 15:52:03 -04:00
parent 89414972be
commit 23abf6a635
7 changed files with 525 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.build/
dist/
*.zip
DerivedData/
*.xcodeproj
+23
View File
@@ -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"]),
]
)
+201
View File
@@ -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)
}
@@ -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<BarDiscovery, LoadError> {
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)
}
}
@@ -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
}
}
@@ -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"
}
}
}
@@ -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
}
}