mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
- 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
110 lines
4.1 KiB
Swift
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
|
|
}
|
|
}
|