mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +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