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.
This commit is contained in:
Tam Nhu Tran
2026-06-07 15:52:03 -04:00
parent 23abf6a635
commit e7001fc252
4 changed files with 292 additions and 0 deletions
+46
View File
@@ -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,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,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)
}
}
@@ -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)
}
}