Files
ccs/macos-bar/Sources/CCSBarCore/CCSBarClient.swift
T
Tam Nhu Tran 9b64514a57 feat(bar-app): add CCS icon variants and usage analytics UI
- Menu-bar icon from the CCS logo: color mark + monochrome template that
  macOS auto-tints; footer toggle, persisted in UserDefaults
- Dropdown analytics section: today/7d/30d/all-time spend, 7-day sparkline,
  top-model bars (BarAnalytics model + client + view)
- Polished rows: branded header, colored health dots, provider/tier chips
- package_app.sh bundles the icon assets into the .app
- assert harness covers analytics decode + compact money/count formatting
2026-06-08 09:31:43 -04:00

110 lines
4.1 KiB
Swift

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
}
}
/// GET /api/bar/analytics. Server-side rollup of the usage snapshot
/// (today / 7d / 30d / all-time spend, sparkline, top models).
public func analytics() async throws -> BarAnalytics {
let url = baseURL.appendingPathComponent("api/bar/analytics")
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(BarAnalytics.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
}
}