mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user