mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
Codex quota in the menu bar came only from frozen local session logs, so it
showed stale data ("older session") that no refresh could update. Fetch it live
from the same source the dashboard uses, under the existing Claude-style safety
controls (10-min TTL, in-flight coalescing, 429 backoff, circuit breaker,
serve-stale), falling back to local logs when offline or rate-limited. A success
with no usable 5h/weekly window keeps the local reading rather than caching a
contentless row. The footer refresh and a new inline button on the Codex card
force a live pull past the open debounce, and a forced /summary re-pulls native
rows while serving the last-known cached rows if the live pull overruns its
budget, so the Claude/Codex cards never blank mid-refresh.
The spend strip gains a Today / 7d / 30d selector (default 7d), a taller chart,
and bottom time-axis labels (local hours for today, weekday for 7d, dates for
30d). The analytics endpoint now exposes a 24-bucket hourly series for today,
converted from the pipeline's UTC hour keys to the user's local clock so the
intra-day chart matches the dashboard.
339 lines
11 KiB
Swift
339 lines
11 KiB
Swift
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 or by
|
|
/// self-starting the server, and fires a debounced force-refresh when the
|
|
/// menu opens.
|
|
@MainActor
|
|
final class BarViewModel: ObservableObject {
|
|
@Published var rows: [BarSummaryRow] = []
|
|
@Published var analytics: BarAnalytics?
|
|
@Published var offline = false
|
|
@Published var lastError: String?
|
|
@Published var isRefreshing = false
|
|
/// True while the launcher is waiting for the server to become reachable.
|
|
@Published var isStarting = false
|
|
@Published var iconStyle: BarIconStyle {
|
|
didSet { MenuBarIcon.saveStyle(iconStyle) }
|
|
}
|
|
/// User-selected dropdown theme (System / Light / Dark). Global chrome, not an
|
|
/// alert pref, so it persists on its own key and bypasses the draft/writeThrough
|
|
/// path. Being @Published makes the MenuBarExtra re-render `ThemedRoot` the
|
|
/// instant it changes, giving a live theme switch.
|
|
@Published var appearance: BarAppearance {
|
|
didSet { BarAppearanceStore.save(appearance) }
|
|
}
|
|
/// Which figure leads the always-on title. Persisted; a change re-derives
|
|
/// `statusTitle` live because it is @Published.
|
|
@Published var glanceMode: BarGlanceMode
|
|
/// Render style for the spend sparkline (bars or line). Persisted via
|
|
/// SpendChartStyleStore; didSet mirrors the BarAppearance/iconStyle pattern.
|
|
@Published var spendChartStyle: SpendChartStyle {
|
|
didSet { SpendChartStyleStore.save(spendChartStyle) }
|
|
}
|
|
/// Active time window for the spend sparkline (today/7d/30d). Persisted via
|
|
/// SpendPeriodStore; didSet mirrors the spendChartStyle pattern.
|
|
@Published var spendPeriod: SpendPeriod {
|
|
didSet { SpendPeriodStore.save(spendPeriod) }
|
|
}
|
|
/// The alerts the most recent evaluation wanted delivered, surfaced in the
|
|
/// dropdown so users who deny notifications still see the conditions.
|
|
@Published var activeAlerts: [BarNotification] = []
|
|
|
|
private let home: String
|
|
private var client: CCSBarClient?
|
|
private var debouncer = RefreshDebouncer(interval: 15)
|
|
/// Periodic background refresh so the glance self-heals from a transient
|
|
/// server gap without the user having to reopen the menu. Each tick re-probes
|
|
/// (not just re-reads bar.json) so a server that restarted on a new port is
|
|
/// found automatically.
|
|
private var pollTask: Task<Void, Never>?
|
|
/// Guard against overlapping start-and-connect sequences.
|
|
private var connectTask: Task<Void, Never>?
|
|
private let prefs: BarPreferences
|
|
private let notifier: NotificationDelivering
|
|
private let probe: BarServerProbe
|
|
private let launcher: BarServerLauncher
|
|
|
|
// MARK: - Init
|
|
|
|
init(
|
|
home: String = NSHomeDirectory(),
|
|
prefs: BarPreferences = BarPreferences(),
|
|
notifier: NotificationDelivering? = nil,
|
|
probe: BarServerProbe = BarServerProbe(),
|
|
launcher: BarServerLauncher? = nil
|
|
) {
|
|
self.home = home
|
|
self.prefs = prefs
|
|
prefs.registerDefaults()
|
|
self.notifier = notifier ?? BarNotifier()
|
|
self.probe = probe
|
|
self.launcher = launcher ?? BarServerLauncher(home: home)
|
|
self.iconStyle = MenuBarIcon.loadStyle()
|
|
self.appearance = BarAppearanceStore.load()
|
|
self.glanceMode = prefs.load().glanceMode
|
|
self.spendChartStyle = SpendChartStyleStore.load()
|
|
self.spendPeriod = SpendPeriodStore.load()
|
|
reconnect()
|
|
startBackgroundPolling()
|
|
}
|
|
|
|
// MARK: - Background polling
|
|
|
|
/// Periodically re-probe for the app's lifetime so a transient empty/missing-row
|
|
/// state recovers on its own within one interval. Each tick re-probes the
|
|
/// network (not just re-reads bar.json) so a server that moved ports is found.
|
|
private func startBackgroundPolling() {
|
|
pollTask?.cancel()
|
|
pollTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(60))
|
|
guard let self else { return }
|
|
// Re-probe first; if live, reconnect; then load cached data.
|
|
await self.reconnectIfNeeded()
|
|
await self.load(force: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Prefs
|
|
|
|
/// Re-read prefs after the preferences sheet writes through.
|
|
func reloadPrefs() {
|
|
glanceMode = prefs.load().glanceMode
|
|
}
|
|
|
|
/// Toggle the menu-bar icon between the color mark and the mono template.
|
|
func toggleIconStyle() {
|
|
iconStyle = (iconStyle == .color) ? .mono : .color
|
|
}
|
|
|
|
// MARK: - Status title
|
|
|
|
/// Compact status-bar title, resolved through the user's chosen glance mode.
|
|
var statusTitle: String {
|
|
if isStarting { return "CCS starting…" }
|
|
return offline
|
|
? "CCS offline"
|
|
: BarFormatting.statusTitle(rows: rows, analytics: analytics, mode: glanceMode)
|
|
}
|
|
|
|
// MARK: - Connection / discovery
|
|
|
|
/// Fast synchronous reconnect: reads bar.json and builds a client if the URL
|
|
/// is valid. Does NOT probe the network. Used as a cheap first-pass before
|
|
/// the async probe path.
|
|
func reconnect() {
|
|
switch BarDiscovery.load(home: home) {
|
|
case .success(let discovery):
|
|
if let url = discovery.resolvedURL {
|
|
client = CCSBarClient(baseURL: url)
|
|
// Don't clear `offline` yet — the network probe in connectAndLoad()
|
|
// will confirm liveness. Leave existing offline state until we know.
|
|
} else {
|
|
client = nil
|
|
}
|
|
case .failure:
|
|
client = nil
|
|
}
|
|
}
|
|
|
|
/// Full async reconnect: probe the network for a live server, build a client
|
|
/// when found, launch the server if not found. Updates `isStarting`/`offline`.
|
|
///
|
|
/// This is the entry point called by `onOpen()` and the background poll.
|
|
func reconnectAndLoad(force: Bool = false) {
|
|
// Cancel any in-flight connect so we don't stack up.
|
|
connectTask?.cancel()
|
|
connectTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.connectSequence(force: force)
|
|
}
|
|
}
|
|
|
|
/// Core sequence: probe → if live build client + load; else launch + poll → load.
|
|
private func connectSequence(force: Bool) async {
|
|
let discovery = try? BarDiscovery.load(home: home).get()
|
|
|
|
// 1. Probe for an already-running server.
|
|
if let liveURL = await probe.findLiveServer(discovery: discovery) {
|
|
client = CCSBarClient(baseURL: liveURL, transport: URLSessionTransport())
|
|
offline = false
|
|
isStarting = false
|
|
await load(force: force)
|
|
return
|
|
}
|
|
|
|
// 2. No live server found — try to start one.
|
|
isStarting = true
|
|
offline = false // show spinner, not the error state
|
|
|
|
let launched = await Task.detached(priority: .utility) { [launcher = self.launcher] in
|
|
launcher.start()
|
|
}.value
|
|
|
|
if !launched {
|
|
// Could not even attempt a launch (no descriptor, no ccs on PATH).
|
|
isStarting = false
|
|
offline = true
|
|
return
|
|
}
|
|
|
|
// 3. Poll until reachable (timeout ~12s, 500ms interval).
|
|
let deadline = Date().addingTimeInterval(12)
|
|
while Date() < deadline {
|
|
guard !Task.isCancelled else { break }
|
|
try? await Task.sleep(for: .milliseconds(500))
|
|
if let liveURL = await probe.findLiveServer(discovery: BarDiscovery.load(home: home).ok) {
|
|
client = CCSBarClient(baseURL: liveURL, transport: URLSessionTransport())
|
|
isStarting = false
|
|
offline = false
|
|
await load(force: true)
|
|
return
|
|
}
|
|
}
|
|
|
|
// 4. Timed out.
|
|
isStarting = false
|
|
offline = true
|
|
}
|
|
|
|
/// Re-probe silently during the background poll. If a live server is found and
|
|
/// we currently have no client (or are offline), build the client and clear
|
|
/// offline. If no server found and we had one, mark offline.
|
|
private func reconnectIfNeeded() async {
|
|
let discovery = try? BarDiscovery.load(home: home).get()
|
|
if let liveURL = await probe.findLiveServer(discovery: discovery) {
|
|
client = CCSBarClient(baseURL: liveURL, transport: URLSessionTransport())
|
|
if offline { offline = false }
|
|
} else if client != nil {
|
|
// We had a client but server is gone.
|
|
client = nil
|
|
if rows.isEmpty { offline = true }
|
|
}
|
|
}
|
|
|
|
// MARK: - Menu open
|
|
|
|
/// Menu opened: cached rows are already on screen; fire a debounced reconnect
|
|
/// + force-refresh so the glance reflects live provider data.
|
|
func onOpen() {
|
|
let force = debouncer.shouldRefresh(now: Date())
|
|
reconnectAndLoad(force: force)
|
|
}
|
|
|
|
// MARK: - Force refresh (bypasses the 15s debounce)
|
|
|
|
/// Unconditional force-refresh: skips the debouncer and calls
|
|
/// `reconnectAndLoad(force: true)` directly. Used by the footer Refresh button
|
|
/// and by the Codex stale-footnote inline action so those never silently no-op
|
|
/// inside the debounce window.
|
|
func forceRefresh() {
|
|
reconnectAndLoad(force: true)
|
|
}
|
|
|
|
// MARK: - Start CCS (called from offline UI button)
|
|
|
|
/// Trigger a full connect sequence (probe → launch → poll). Used by the
|
|
/// "Start CCS" button in the offline state.
|
|
func startCCS() {
|
|
reconnectAndLoad(force: true)
|
|
}
|
|
|
|
// MARK: - Data loading
|
|
|
|
func load(force: Bool) async {
|
|
// If we still have no client after the connect sequence, bail.
|
|
guard let client else {
|
|
if !isStarting { 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 }
|
|
}
|
|
|
|
// Analytics is a best-effort side-load: a failure here must never blank the
|
|
// glance or flip us offline. Keep the last-known analytics on error.
|
|
if let fresh = try? await client.analytics() {
|
|
analytics = fresh
|
|
}
|
|
|
|
evaluateAlerts()
|
|
}
|
|
|
|
// MARK: - Alert engine
|
|
|
|
/// Run the pure rule engine once per poll against the freshly-loaded state.
|
|
private func evaluateAlerts() {
|
|
let current = prefs.load()
|
|
let eval = BarAlertEngine.evaluate(
|
|
rows: rows,
|
|
analytics: analytics,
|
|
prefs: current,
|
|
priorFiredKeys: prefs.firedKeys,
|
|
now: Date())
|
|
for notification in eval.toDeliver {
|
|
notifier.deliver(notification)
|
|
}
|
|
prefs.firedKeys = eval.firedKeys
|
|
activeAlerts = eval.toDeliver
|
|
}
|
|
|
|
// 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.id) }
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Result convenience
|
|
|
|
private extension Result {
|
|
/// Extract the success value without throwing, for use in async contexts where
|
|
/// we want an optional rather than a thrown error.
|
|
var ok: Success? {
|
|
guard case .success(let v) = self else { return nil }
|
|
return v
|
|
}
|
|
}
|