From 23abf6a6350015dbbc97159564f5ee2678beaf6f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 7 Jun 2026 15:52:03 -0400 Subject: [PATCH 1/4] 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 + } +} From e7001fc252c4eea1075d89c2455f8fb29164fdf4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 7 Jun 2026 15:52:03 -0400 Subject: [PATCH 2/4] feat(macos-bar): add SwiftUI MenuBarExtra app + view-model Menu-bar app that paints cached rows instantly and fires a debounced force-refresh on open. Dropdown shows per-account health, quota, tier, cost and paused state with pause/resume, set default, solo and tier-lock actions, plus an offline state when CCS is not running. --- macos-bar/README.md | 46 +++++++ macos-bar/Sources/CCSBarApp/BarMenuView.swift | 121 ++++++++++++++++++ .../Sources/CCSBarApp/BarViewModel.swift | 107 ++++++++++++++++ macos-bar/Sources/CCSBarApp/CCSBarApp.swift | 18 +++ 4 files changed, 292 insertions(+) create mode 100644 macos-bar/README.md create mode 100644 macos-bar/Sources/CCSBarApp/BarMenuView.swift create mode 100644 macos-bar/Sources/CCSBarApp/BarViewModel.swift create mode 100644 macos-bar/Sources/CCSBarApp/CCSBarApp.swift diff --git a/macos-bar/README.md b/macos-bar/README.md new file mode 100644 index 00000000..796b072c --- /dev/null +++ b/macos-bar/README.md @@ -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. diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift new file mode 100644 index 00000000..156b4991 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -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} ") + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarViewModel.swift b/macos-bar/Sources/CCSBarApp/BarViewModel.swift new file mode 100644 index 00000000..26d69e0a --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarViewModel.swift @@ -0,0 +1,107 @@ +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) { + perform { try await $0.setDefault(name: row.accountId) } + } + 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) + } +} diff --git a/macos-bar/Sources/CCSBarApp/CCSBarApp.swift b/macos-bar/Sources/CCSBarApp/CCSBarApp.swift new file mode 100644 index 00000000..91ef8824 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/CCSBarApp.swift @@ -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) + } +} From df4554fe1f9a0d3d4e43e2b21f454fb862a131c5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 7 Jun 2026 15:52:03 -0400 Subject: [PATCH 3/4] feat(macos-bar): add ad-hoc packaging script + Info.plist package_app.sh assembles and signs CCS Bar.app (menu-bar-only via LSUIElement). Default ad-hoc signing for v1 with documented Gatekeeper guidance; developer-id mode wired for the notarized public-launch path. --- macos-bar/Resources/Info.plist | 26 ++++++++++++ macos-bar/Scripts/package_app.sh | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 macos-bar/Resources/Info.plist create mode 100755 macos-bar/Scripts/package_app.sh diff --git a/macos-bar/Resources/Info.plist b/macos-bar/Resources/Info.plist new file mode 100644 index 00000000..73a77fb9 --- /dev/null +++ b/macos-bar/Resources/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleExecutable + CCSBar + CFBundleIdentifier + ca.kaitran.ccs.bar + CFBundleName + CCS Bar + CFBundleDisplayName + CCS Bar + CFBundlePackageType + APPL + CFBundleShortVersionString + __VERSION__ + CFBundleVersion + __VERSION__ + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHumanReadableCopyright + CCS Bar + + diff --git a/macos-bar/Scripts/package_app.sh b/macos-bar/Scripts/package_app.sh new file mode 100755 index 00000000..d03e701b --- /dev/null +++ b/macos-bar/Scripts/package_app.sh @@ -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 --keychain-profile --wait" + ;; + *) + echo "[X] Unknown CCS_BAR_SIGNING: $SIGNING (expected adhoc|developer-id)" >&2 + exit 1 + ;; +esac + +ZIP="$DIST/CCS-Bar.zip" +echo "[i] Zipping -> $ZIP" +rm -f "$ZIP" +( cd "$DIST" && ditto -c -k --keepParent "$APP_NAME.app" "CCS-Bar.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" From 8350c247333cec3494409543f4030802b8c768d5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 7 Jun 2026 16:38:38 -0400 Subject: [PATCH 4/4] fix(macos-bar): correct set-default key, lead account, asset name Send the composite provider:accountId (row.id) for Set as default so the server can resolve the CLIProxy account; feature the account closest to exhaustion (lowest remaining quota) in the title instead of the healthiest; rename the packaged asset to CCS-Bar.app.zip to match what 'ccs bar install' downloads. --- macos-bar/Scripts/package_app.sh | 4 ++-- macos-bar/Sources/CCSBarApp/BarViewModel.swift | 5 ++++- macos-bar/Sources/CCSBarCheck/main.swift | 10 ++++++++++ macos-bar/Sources/CCSBarCore/BarFormatting.swift | 12 ++++++++---- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/macos-bar/Scripts/package_app.sh b/macos-bar/Scripts/package_app.sh index d03e701b..e23927d1 100755 --- a/macos-bar/Scripts/package_app.sh +++ b/macos-bar/Scripts/package_app.sh @@ -54,10 +54,10 @@ case "$SIGNING" in ;; esac -ZIP="$DIST/CCS-Bar.zip" +ZIP="$DIST/CCS-Bar.app.zip" echo "[i] Zipping -> $ZIP" rm -f "$ZIP" -( cd "$DIST" && ditto -c -k --keepParent "$APP_NAME.app" "CCS-Bar.zip" ) +( cd "$DIST" && ditto -c -k --keepParent "$APP_NAME.app" "CCS-Bar.app.zip" ) echo "[OK] Packaged: $APP" echo "[OK] Asset: $ZIP" diff --git a/macos-bar/Sources/CCSBarApp/BarViewModel.swift b/macos-bar/Sources/CCSBarApp/BarViewModel.swift index 26d69e0a..785fa2b4 100644 --- a/macos-bar/Sources/CCSBarApp/BarViewModel.swift +++ b/macos-bar/Sources/CCSBarApp/BarViewModel.swift @@ -83,7 +83,10 @@ final class BarViewModel: ObservableObject { perform { try await $0.solo(provider: row.provider, accountId: row.accountId) } } func setDefault(_ row: BarSummaryRow) { - perform { try await $0.setDefault(name: row.accountId) } + // 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) } diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index 212b99fa..77e49a70 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -127,6 +127,16 @@ do { 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) diff --git a/macos-bar/Sources/CCSBarCore/BarFormatting.swift b/macos-bar/Sources/CCSBarCore/BarFormatting.swift index 76384ef2..75cda29c 100644 --- a/macos-bar/Sources/CCSBarCore/BarFormatting.swift +++ b/macos-bar/Sources/CCSBarCore/BarFormatting.swift @@ -30,12 +30,16 @@ public enum BarFormatting { 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. + /// 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? { - rows.max { lhs, rhs in - (lhs.quotaPercentage ?? -1) < (rhs.quotaPercentage ?? -1) + let withPct = rows.filter { $0.quotaPercentage != nil } + if let lead = withPct.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }) { + return lead } + return rows.first } }