mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-22 22:23:37 +00:00
Merge pull request #1478 from kaitranntt/kai/feat/ccs-bar-macos
feat: CCS Bar macOS app — SwiftUI menu bar client + ad-hoc packaging
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.build/
|
||||
dist/
|
||||
*.zip
|
||||
DerivedData/
|
||||
*.xcodeproj
|
||||
@@ -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"]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
# CCS Bar (macOS)
|
||||
|
||||
Native SwiftUI menu bar app for CCS. A thin client of the CCS local web-server:
|
||||
it glances per-account quota, cost and tier, and performs account control
|
||||
(pause/resume, set default, solo, tier-lock) from the menu bar.
|
||||
|
||||
The app never talks to a provider directly. Every call goes to `localhost`, and
|
||||
CCS performs any provider fetch server-side. Opening the menu fires a debounced
|
||||
force-refresh so the glance reflects live data without blocking the UI.
|
||||
|
||||
## Layout
|
||||
|
||||
- `Sources/CCSBarCore` — pure Foundation logic (no SwiftUI): API client,
|
||||
discovery handshake, models, formatting, refresh debounce. Fully unit-tested.
|
||||
- `Sources/CCSBarApp` — SwiftUI `MenuBarExtra` app: view-model + views.
|
||||
- `Sources/CCSBarCheck` — runnable assert harness used in place of XCTest
|
||||
(XCTest ships with full Xcode; this builds on a CommandLineTools toolchain).
|
||||
|
||||
## Build and test
|
||||
|
||||
Requires a Swift 5.9+ toolchain (CommandLineTools is enough; full Xcode not
|
||||
required for build/test).
|
||||
|
||||
```bash
|
||||
swift build # build all targets, including the app
|
||||
swift run ccs-bar-check # run the logic tests (exits non-zero on failure)
|
||||
```
|
||||
|
||||
## Discovery
|
||||
|
||||
The app reads `~/.ccs/bar.json` (written by `ccs bar launch`):
|
||||
|
||||
```json
|
||||
{ "baseUrl": "http://127.0.0.1:3000", "port": 3000, "authMode": "loopback" }
|
||||
```
|
||||
|
||||
v1 supports `authMode: "loopback"` only (dashboard auth disabled, localhost).
|
||||
|
||||
## Packaging
|
||||
|
||||
`Scripts/package_app.sh` assembles `CCS Bar.app` from a release build and signs
|
||||
it. v1 uses ad-hoc signing (`CCS_BAR_SIGNING=adhoc`, the default); users open it
|
||||
the first time via right-click then Open, or clear quarantine with
|
||||
`xattr -dr com.apple.quarantine "/Applications/CCS Bar.app"`. Developer ID
|
||||
signing + notarization (`CCS_BAR_SIGNING=developer-id`) is the public-launch
|
||||
path and is not required for ad-hoc distribution.
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>CCSBar</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ca.kaitran.ccs.bar</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>CCS Bar</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>CCS Bar</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>CCS Bar</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assemble and sign "CCS Bar.app" from a release build.
|
||||
#
|
||||
# Signing mode (CCS_BAR_SIGNING):
|
||||
# adhoc (default) ad-hoc sign with `codesign -s -`. Free, no Apple
|
||||
# Developer account. Users open via right-click > Open or clear
|
||||
# quarantine with `xattr -dr com.apple.quarantine`.
|
||||
# developer-id Sign with a Developer ID Application identity (set
|
||||
# CCS_BAR_SIGN_IDENTITY) for the notarized public-launch path.
|
||||
#
|
||||
# Usage:
|
||||
# ./Scripts/package_app.sh [version]
|
||||
# CCS_BAR_SIGNING=developer-id CCS_BAR_SIGN_IDENTITY="Developer ID Application: ..." ./Scripts/package_app.sh 0.1.0
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.0.0}"
|
||||
SIGNING="${CCS_BAR_SIGNING:-adhoc}"
|
||||
APP_NAME="CCS Bar"
|
||||
EXEC_NAME="CCSBar"
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DIST="$ROOT/dist"
|
||||
APP="$DIST/$APP_NAME.app"
|
||||
|
||||
echo "[i] Building release binary..."
|
||||
( cd "$ROOT" && swift build -c release )
|
||||
BIN="$ROOT/.build/release/$EXEC_NAME"
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "[X] Release binary not found at $BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[i] Assembling $APP_NAME.app (version $VERSION)..."
|
||||
rm -rf "$APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
cp "$BIN" "$APP/Contents/MacOS/$EXEC_NAME"
|
||||
sed "s/__VERSION__/$VERSION/g" "$ROOT/Resources/Info.plist" > "$APP/Contents/Info.plist"
|
||||
|
||||
echo "[i] Signing ($SIGNING)..."
|
||||
case "$SIGNING" in
|
||||
adhoc)
|
||||
codesign --force --deep --sign - "$APP"
|
||||
;;
|
||||
developer-id)
|
||||
: "${CCS_BAR_SIGN_IDENTITY:?Set CCS_BAR_SIGN_IDENTITY for developer-id signing}"
|
||||
codesign --force --deep --options runtime --timestamp \
|
||||
--sign "$CCS_BAR_SIGN_IDENTITY" "$APP"
|
||||
echo "[i] Signed with Developer ID. Notarize before public distribution:"
|
||||
echo " xcrun notarytool submit <zip> --keychain-profile <profile> --wait"
|
||||
;;
|
||||
*)
|
||||
echo "[X] Unknown CCS_BAR_SIGNING: $SIGNING (expected adhoc|developer-id)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ZIP="$DIST/CCS-Bar.app.zip"
|
||||
echo "[i] Zipping -> $ZIP"
|
||||
rm -f "$ZIP"
|
||||
( cd "$DIST" && ditto -c -k --keepParent "$APP_NAME.app" "CCS-Bar.app.zip" )
|
||||
|
||||
echo "[OK] Packaged: $APP"
|
||||
echo "[OK] Asset: $ZIP"
|
||||
if [[ "$SIGNING" == "adhoc" ]]; then
|
||||
echo "[!] Ad-hoc build: first launch needs right-click > Open, or"
|
||||
echo " xattr -dr com.apple.quarantine \"/Applications/$APP_NAME.app\""
|
||||
fi
|
||||
echo "[i] To publish: gh release upload ccs-bar-latest \"$ZIP\" --clobber"
|
||||
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import CCSBarCore
|
||||
|
||||
/// Dropdown content for the menu bar: per-account rows + actions, an offline
|
||||
/// state when CCS isn't running, and footer controls.
|
||||
struct BarMenuView: View {
|
||||
@ObservedObject var viewModel: BarViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
header
|
||||
|
||||
if viewModel.offline {
|
||||
offlineState
|
||||
} else if viewModel.rows.isEmpty {
|
||||
Text("No accounts found")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.rows) { row in
|
||||
BarRowView(row: row, viewModel: viewModel)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
footer
|
||||
}
|
||||
.padding(12)
|
||||
.frame(width: 320)
|
||||
.onAppear { viewModel.onOpen() }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Text("CCS").font(.headline)
|
||||
Spacer()
|
||||
if viewModel.isRefreshing {
|
||||
Text("refreshing…").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var offlineState: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("CCS is not running").font(.body)
|
||||
Text("Start CCS, then reopen this menu.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry") { viewModel.reconnect(); viewModel.onOpen() }
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Button("Open dashboard") { openDashboard() }
|
||||
Button("Refresh") { viewModel.onOpen() }
|
||||
Button("Quit") { NSApplication.shared.terminate(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
private func openDashboard() {
|
||||
// The dashboard runs on the same host/port the bar reads from discovery.
|
||||
if case .success(let discovery) = BarDiscovery.load(), let url = discovery.resolvedURL {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One account row: health dot, name, provider/tier/quota/paused subline, and
|
||||
/// a control menu.
|
||||
struct BarRowView: View {
|
||||
let row: BarSummaryRow
|
||||
@ObservedObject var viewModel: BarViewModel
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(row.healthDot)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(width: 22, alignment: .leading)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(row.displayName ?? row.accountId)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
Text(subline)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Menu("Actions") {
|
||||
if row.paused {
|
||||
Button("Resume") { viewModel.resume(row) }
|
||||
} else {
|
||||
Button("Pause") { viewModel.pause(row) }
|
||||
}
|
||||
Button("Set as default") { viewModel.setDefault(row) }
|
||||
Button("Solo (pause others)") { viewModel.solo(row) }
|
||||
if let tier = row.tier {
|
||||
Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) }
|
||||
}
|
||||
Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) }
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.frame(width: 90)
|
||||
}
|
||||
}
|
||||
|
||||
private var subline: String {
|
||||
var parts: [String] = [row.provider]
|
||||
if let tier = row.tier { parts.append(tier) }
|
||||
parts.append(BarFormatting.quotaLabel(row.quotaPercentage))
|
||||
let cost = BarFormatting.costLabel(row.todayCost)
|
||||
if !cost.isEmpty { parts.append(cost) }
|
||||
if row.paused { parts.append("paused") }
|
||||
if row.needsReauth { parts.append("needs reauth") }
|
||||
return parts.joined(separator: " \u{00B7} ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import CCSBarCore
|
||||
|
||||
/// Observable state for the menu bar. Holds the last-known rows for instant
|
||||
/// paint, reconnects to the CCS web-server via the discovery file, and fires a
|
||||
/// debounced force-refresh when the menu opens.
|
||||
@MainActor
|
||||
final class BarViewModel: ObservableObject {
|
||||
@Published var rows: [BarSummaryRow] = []
|
||||
@Published var offline = false
|
||||
@Published var lastError: String?
|
||||
@Published var isRefreshing = false
|
||||
|
||||
private let home: String
|
||||
private var client: CCSBarClient?
|
||||
private var debouncer = RefreshDebouncer(interval: 15)
|
||||
|
||||
init(home: String = NSHomeDirectory()) {
|
||||
self.home = home
|
||||
reconnect()
|
||||
}
|
||||
|
||||
/// Compact status-bar title.
|
||||
var statusTitle: String {
|
||||
offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows)
|
||||
}
|
||||
|
||||
/// Resolve the discovery file and (re)build the client. Marks offline when
|
||||
/// CCS hasn't been launched.
|
||||
func reconnect() {
|
||||
switch BarDiscovery.load(home: home) {
|
||||
case .success(let discovery):
|
||||
if let url = discovery.resolvedURL {
|
||||
client = CCSBarClient(baseURL: url)
|
||||
offline = false
|
||||
} else {
|
||||
client = nil
|
||||
offline = true
|
||||
}
|
||||
case .failure:
|
||||
client = nil
|
||||
offline = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu opened: cached rows are already on screen; fire a debounced
|
||||
/// force-refresh so the glance reflects live provider data.
|
||||
func onOpen() {
|
||||
let force = debouncer.shouldRefresh(now: Date())
|
||||
Task { await load(force: force) }
|
||||
}
|
||||
|
||||
func load(force: Bool) async {
|
||||
if client == nil { reconnect() }
|
||||
guard let client else {
|
||||
offline = true
|
||||
return
|
||||
}
|
||||
if force { isRefreshing = true }
|
||||
defer { isRefreshing = false }
|
||||
do {
|
||||
rows = try await client.summary(refresh: force)
|
||||
offline = false
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = describe(error)
|
||||
// Keep the last rows visible (instant cached paint); only flip to the
|
||||
// offline state when there is nothing to show.
|
||||
if rows.isEmpty { offline = true }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Account actions
|
||||
|
||||
func pause(_ row: BarSummaryRow) {
|
||||
perform { try await $0.pause(provider: row.provider, accountId: row.accountId) }
|
||||
}
|
||||
func resume(_ row: BarSummaryRow) {
|
||||
perform { try await $0.resume(provider: row.provider, accountId: row.accountId) }
|
||||
}
|
||||
func solo(_ row: BarSummaryRow) {
|
||||
perform { try await $0.solo(provider: row.provider, accountId: row.accountId) }
|
||||
}
|
||||
func setDefault(_ row: BarSummaryRow) {
|
||||
// The server's /api/accounts/default parses CLIProxy accounts as the
|
||||
// composite "provider:accountId" key; row.id already has that shape.
|
||||
// Sending the bare accountId fails parseCliproxyKey and 500s / no-ops.
|
||||
perform { try await $0.setDefault(name: row.id) }
|
||||
}
|
||||
func tierLock(_ row: BarSummaryRow, tier: String?) {
|
||||
perform { try await $0.tierLock(provider: row.provider, tier: tier) }
|
||||
}
|
||||
|
||||
private func perform(_ op: @escaping (CCSBarClient) async throws -> Void) {
|
||||
guard let client else { return }
|
||||
Task {
|
||||
do {
|
||||
try await op(client)
|
||||
await load(force: true)
|
||||
} catch {
|
||||
lastError = describe(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func describe(_ error: Error) -> String {
|
||||
String(describing: error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
/// CCS Bar entry point. A menu-bar-only app (no dock icon) whose title shows
|
||||
/// the leading account's quota and today's total cost, with a dropdown for
|
||||
/// per-account detail and control.
|
||||
@main
|
||||
struct CCSBarApp: App {
|
||||
@StateObject private var viewModel = BarViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
BarMenuView(viewModel: viewModel)
|
||||
} label: {
|
||||
Text(viewModel.statusTitle)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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")
|
||||
}
|
||||
|
||||
// leadRow features the account CLOSEST TO EXHAUSTION (lowest remaining %),
|
||||
// not the healthiest. quota_percentage is REMAINING quota.
|
||||
let twoActive = [
|
||||
BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 90, health: "ok"),
|
||||
BarSummaryRow(accountId: "b", provider: "agy", quotaPercentage: 30, health: "ok"),
|
||||
]
|
||||
let twoTitle = BarFormatting.statusTitle(rows: twoActive)
|
||||
check(twoTitle.contains("30%"), "title features lowest-remaining (closest to exhaustion)")
|
||||
check(!twoTitle.contains("90%"), "title does not feature the healthiest account")
|
||||
|
||||
// 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,66 @@
|
||||
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.
|
||||
/// `quota_percentage` is REMAINING quota (higher = more left), so the lead is
|
||||
/// the LOWEST remaining percentage. Rows without a known percentage are not
|
||||
/// chosen unless no row has one.
|
||||
static func leadRow(_ rows: [BarSummaryRow]) -> BarSummaryRow? {
|
||||
let withPct = rows.filter { $0.quotaPercentage != nil }
|
||||
if let lead = withPct.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }) {
|
||||
return lead
|
||||
}
|
||||
return rows.first
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user