From e7001fc252c4eea1075d89c2455f8fb29164fdf4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 7 Jun 2026 15:52:03 -0400 Subject: [PATCH] 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) + } +}