diff --git a/.github/pr-assets/1517/ccs-bar-panel.webp b/.github/pr-assets/1517/ccs-bar-panel.webp new file mode 100644 index 00000000..bf3b995f Binary files /dev/null and b/.github/pr-assets/1517/ccs-bar-panel.webp differ diff --git a/.github/pr-assets/1517/dashboard-header-ccs-bar-button.png b/.github/pr-assets/1517/dashboard-header-ccs-bar-button.png new file mode 100644 index 00000000..00336bb1 Binary files /dev/null and b/.github/pr-assets/1517/dashboard-header-ccs-bar-button.png differ diff --git a/.github/pr-assets/1517/index.html b/.github/pr-assets/1517/index.html new file mode 100644 index 00000000..0a921ecf --- /dev/null +++ b/.github/pr-assets/1517/index.html @@ -0,0 +1,224 @@ + + + + + + CCS Bar Promo Evidence + + + +
+
+
+

PR 1517 · CCS Bar README and dashboard promotion

+

+ Captures from local branch kai/feat/ccs-bar-promo. Dashboard capture uses + Vite at 127.0.0.1:5187, light theme, 1440x900 viewport, local auth-check + stubbed open, and no private account data. +

+
+ +
+ +
+
+

What Changed

+

README now introduces CCS Bar, and dashboard chrome shows a compact global CCS Bar link.

+
+
+

Why It Matters

+

Users can discover the native macOS quota and usage companion from every dashboard route.

+
+
+

Review Cue

+

Red callout marks the new top-bar button next to ClaudeKit and Sponsor controls.

+
+
+ +
+

1. Global Dashboard Entry Point

+

The compact CCS Bar button is visible in the persistent dashboard header.

+
+
+
+ IMPLEMENTED + Dashboard chrome +
+ CCS dashboard header with red callout around the CCS Bar button +
+
+
+ CURRENT SNAPSHOT + Sanitized CCS Bar panel +
+ Sanitized CCS Bar macOS panel showing quota, spend, and account controls +
+
+
+
+ + + diff --git a/.gitignore b/.gitignore index cfda3195..487f2738 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,7 @@ tests/mocks/fixtures/*.js tests/mocks/fixtures/*.d.ts tests/mocks/fixtures/*.js.map tests/mocks/fixtures/*.d.ts.map + +# Bar demo scaffolding (local dev only) +_serve*.ts +_serve*.log diff --git a/README.md b/README.md index ab892b40..1d1c24cb 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,17 @@ Need the full setup path instead of the short version? ## See CCS In Action +### CCS Bar For macOS + +![CCS Bar](assets/screenshots/ccs-bar-panel.webp) + +CCS Bar puts live subscription quota, daily spend, and account controls in the +macOS menu bar. Install it with `ccs bar install`, launch it with `ccs bar`, and +tell us what would make it more useful: issues and ideas are welcome in +[GitHub Issues](https://github.com/kaitranntt/ccs/issues). + +Deep dive: [CCS Bar](https://docs.ccs.kaitran.ca/features/dashboard/ccs-bar). + ### Usage Analytics ![Analytics Dashboard](assets/screenshots/analytics.webp) @@ -164,6 +175,7 @@ reference material. | Install CCS cleanly on a new machine | [Installation](https://docs.ccs.kaitran.ca/getting-started/installation) | | Go from install to a successful first run | [Your First CCS Session](https://docs.ccs.kaitran.ca/getting-started/first-session) | | See the dashboard and workflow surfaces before setup | [Product Tour](https://docs.ccs.kaitran.ca/getting-started/product-tour) | +| Install the native macOS menu-bar companion | [CCS Bar](https://docs.ccs.kaitran.ca/features/dashboard/ccs-bar) | | Compare OAuth providers, Claude accounts, and API profiles | [Provider Overview](https://docs.ccs.kaitran.ca/providers/concepts/overview) | | Learn the dashboard structure and feature pages | [Dashboard Overview](https://docs.ccs.kaitran.ca/features/dashboard/overview) | | Configure profiles, paths, and environment variables | [Configuration](https://docs.ccs.kaitran.ca/getting-started/configuration) | diff --git a/assets/screenshots/ccs-bar-panel.webp b/assets/screenshots/ccs-bar-panel.webp new file mode 100644 index 00000000..bf3b995f Binary files /dev/null and b/assets/screenshots/ccs-bar-panel.webp differ diff --git a/config/base-claude.settings.json b/config/base-claude.settings.json index e9986c70..284681cc 100644 --- a/config/base-claude.settings.json +++ b/config/base-claude.settings.json @@ -1,10 +1,6 @@ { "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/claude", - "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", - "ANTHROPIC_MODEL": "claude-sonnet-4-6", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-7", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001" + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed" } } diff --git a/docs/ccs-bar.md b/docs/ccs-bar.md new file mode 100644 index 00000000..0cc48fb6 --- /dev/null +++ b/docs/ccs-bar.md @@ -0,0 +1,98 @@ +# CCS Bar — Native macOS Menu Bar App + +CCS Bar is a native macOS menu-bar app that shows live subscription quota and usage at a glance for your Claude Code, Codex, and CLIProxy accounts, without opening the dashboard. + +## What It Is + +CCS Bar is a thin client of the CCS local web-server. It 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. + +It is macOS only. + +## What It Shows + +- Per-account quota percent and reset countdown +- Account tier +- Today, 7-day, and 30-day cost +- A 30-day usage sparkline +- Account state (active, paused, default) +- Native subscription rows for Claude Code and Codex + +## Requirements + +- macOS +- CCS CLI installed and configured (`ccs config` works) +- The CCS web-server reachable on loopback. `ccs bar` (or `ccs bar launch`) starts it for you. + +## Install + +```bash +ccs bar install +``` + +This downloads `CCS-Bar.app.zip` from the floating `ccs-bar-latest` GitHub release and installs `CCS Bar.app` into `~/Applications`. Downloads are restricted to `github.com` and `objects.githubusercontent.com`, and extraction is guarded against zip-slip. + +If `CCS Bar.app` is already installed, the command shows the current version and proceeds as a reinstall. + +After installation, CCS reads the app version directly from the bundle's `Info.plist` and pins it to `~/.ccs/bar/.version`. It then performs a reachability check against the bar API (`GET /api/bar/summary`). A 404 response means the running CCS server predates CCS Bar support — update CCS to a version that includes CCS Bar, then restart `ccs bar`. + +After a successful install, CCS asks whether to launch CCS Bar immediately (default: yes). Pass `--launch` to skip the prompt and launch right away, or `--no-launch` to suppress the prompt entirely: + +```bash +ccs bar install --launch # install and launch immediately +ccs bar install --no-launch # install, skip launch prompt +``` + +### Gatekeeper note + +The install command automatically clears the macOS Gatekeeper quarantine attribute (`xattr -dr com.apple.quarantine`) on the downloaded app. If clearing fails for any reason, the command falls back to printing the manual command: + +```bash +xattr -dr com.apple.quarantine "$HOME/Applications/CCS Bar.app" +``` + +Or right-click the app and choose Open. + +## Launch + +```bash +ccs bar # alias: ccs bar launch +``` + +This checks whether a CCS web-server is already running (probing port 3000, 3001, 3002, 8000, and 8080, with the port from the previous `bar.json` checked first). If a live server is found it is reused; otherwise a new one is started. Either way the discovery file `~/.ccs/bar.json` is written and the app is opened. The discovery file looks like this: + +```json +{ "baseUrl": "http://127.0.0.1:3000", "port": 3000, "authMode": "loopback" } +``` + +The Swift app reads `~/.ccs/bar.json` to find the server. + +## Loopback / Localhost Requirement + +CCS Bar talks only to `http://127.0.0.1:`. v1 supports `authMode: "loopback"` only, meaning dashboard auth disabled on localhost. + +If you bind the dashboard beyond localhost (for example `--host` set to a non-loopback address) with dashboard auth disabled, the bar's read endpoints (`GET /api/bar/summary`, `GET /api/bar/analytics`) are refused for non-loopback callers, and the app cannot reach the server. Keep the dashboard on loopback for CCS Bar to work. + +## Uninstall + +```bash +ccs bar uninstall +``` + +This removes `~/Applications/CCS Bar.app` and the installed version pin. It is a no-op if the app is not present. + +## Troubleshooting + +- Install fails with "server predates CCS Bar" or bar API returns 404: the CCS server running does not yet include CCS Bar. Update CCS (`npm i -g ccs@latest` or equivalent), then restart `ccs bar`. +- Server failed to start: `ccs bar` first checks whether a CCS server is already running on the candidate ports (3000, 3001, 3002, 8000, 8080) and reuses it if found. A true failure here means a non-CCS process is occupying all candidate ports. Free one of those ports and re-run `ccs bar`. +- App won't open (Gatekeeper): right-click and Open, or clear quarantine with the `xattr` command above. +- Blank app: the web-server is not running. Re-run `ccs bar` and confirm `~/.ccs/bar.json` exists. +- Quota not updating: re-open the menu to force a refresh, or confirm the server is still reachable on loopback. + +## Development + +The source lives in `macos-bar/`. Contributors can build and run the logic checks with a Swift 5.9+ toolchain (CommandLineTools is enough, full Xcode not required): + +```bash +swift build # build all targets, including the app +swift run ccs-bar-check # run the logic tests +``` diff --git a/macos-bar/.gitignore b/macos-bar/.gitignore new file mode 100644 index 00000000..837dd9ee --- /dev/null +++ b/macos-bar/.gitignore @@ -0,0 +1,5 @@ +.build/ +dist/ +*.zip +DerivedData/ +*.xcodeproj diff --git a/macos-bar/Package.swift b/macos-bar/Package.swift new file mode 100644 index 00000000..8e8a642f --- /dev/null +++ b/macos-bar/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version:5.9 +import PackageDescription + +// CCS Bar - native macOS menu bar client for CCS. +// +// Build/test note: full Xcode (and therefore XCTest) is not required. The +// testable logic lives in the pure-Foundation `CCSBarCore` target and is +// exercised by the `ccs-bar-check` executable (an assert harness) so it runs +// on a CommandLineTools-only toolchain. The SwiftUI app target is added once +// the core is verified. +let package = Package( + name: "CCSBar", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "CCSBar", targets: ["CCSBarApp"]), + .executable(name: "ccs-bar-check", targets: ["CCSBarCheck"]), + ], + targets: [ + .target(name: "CCSBarCore"), + .executableTarget(name: "CCSBarApp", dependencies: ["CCSBarCore"]), + .executableTarget(name: "CCSBarCheck", dependencies: ["CCSBarCore"]), + ] +) 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/Resources/Assets/HeaderLogo.png b/macos-bar/Resources/Assets/HeaderLogo.png new file mode 100644 index 00000000..ab9f8c09 Binary files /dev/null and b/macos-bar/Resources/Assets/HeaderLogo.png differ diff --git a/macos-bar/Resources/Assets/MenuBarColor.png b/macos-bar/Resources/Assets/MenuBarColor.png new file mode 100644 index 00000000..af76e79c Binary files /dev/null and b/macos-bar/Resources/Assets/MenuBarColor.png differ diff --git a/macos-bar/Resources/Assets/MenuBarTemplate.png b/macos-bar/Resources/Assets/MenuBarTemplate.png new file mode 100644 index 00000000..9fac98fd Binary files /dev/null and b/macos-bar/Resources/Assets/MenuBarTemplate.png differ diff --git a/macos-bar/Resources/Info.plist b/macos-bar/Resources/Info.plist new file mode 100644 index 00000000..73a77fb9 --- /dev/null +++ b/macos-bar/Resources/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleExecutable + CCSBar + CFBundleIdentifier + ca.kaitran.ccs.bar + CFBundleName + CCS Bar + CFBundleDisplayName + CCS Bar + CFBundlePackageType + APPL + CFBundleShortVersionString + __VERSION__ + CFBundleVersion + __VERSION__ + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHumanReadableCopyright + CCS Bar + + diff --git a/macos-bar/Scripts/package_app.sh b/macos-bar/Scripts/package_app.sh new file mode 100755 index 00000000..39f34466 --- /dev/null +++ b/macos-bar/Scripts/package_app.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Assemble and sign "CCS Bar.app" from a release build. +# +# Signing mode (CCS_BAR_SIGNING): +# adhoc (default) ad-hoc sign with `codesign -s -`. Free, no Apple +# Developer account. Users open via right-click > Open or clear +# quarantine with `xattr -dr com.apple.quarantine`. +# developer-id Sign with a Developer ID Application identity (set +# CCS_BAR_SIGN_IDENTITY) for the notarized public-launch path. +# +# Usage: +# ./Scripts/package_app.sh [version] +# CCS_BAR_SIGNING=developer-id CCS_BAR_SIGN_IDENTITY="Developer ID Application: ..." ./Scripts/package_app.sh 0.1.0 +set -euo pipefail + +VERSION="${1:-0.0.0}" +SIGNING="${CCS_BAR_SIGNING:-adhoc}" +APP_NAME="CCS Bar" +EXEC_NAME="CCSBar" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DIST="$ROOT/dist" +APP="$DIST/$APP_NAME.app" + +echo "[i] Building release binary..." +( cd "$ROOT" && swift build -c release ) +BIN="$ROOT/.build/release/$EXEC_NAME" +if [[ ! -x "$BIN" ]]; then + echo "[X] Release binary not found at $BIN" >&2 + exit 1 +fi + +echo "[i] Assembling $APP_NAME.app (version $VERSION)..." +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/$EXEC_NAME" +sed "s/__VERSION__/$VERSION/g" "$ROOT/Resources/Info.plist" > "$APP/Contents/Info.plist" + +# Bundle the CCS icon assets (menu-bar color/template + header logo) so +# Bundle.main can resolve them at runtime. +if [[ -d "$ROOT/Resources/Assets" ]]; then + cp "$ROOT/Resources/Assets/"*.png "$APP/Contents/Resources/" 2>/dev/null || true +fi + +echo "[i] Signing ($SIGNING)..." +case "$SIGNING" in + adhoc) + codesign --force --deep --sign - "$APP" + ;; + developer-id) + : "${CCS_BAR_SIGN_IDENTITY:?Set CCS_BAR_SIGN_IDENTITY for developer-id signing}" + codesign --force --deep --options runtime --timestamp \ + --sign "$CCS_BAR_SIGN_IDENTITY" "$APP" + echo "[i] Signed with Developer ID. Notarize before public distribution:" + echo " xcrun notarytool submit --keychain-profile --wait" + ;; + *) + echo "[X] Unknown CCS_BAR_SIGNING: $SIGNING (expected adhoc|developer-id)" >&2 + exit 1 + ;; +esac + +ZIP="$DIST/CCS-Bar.app.zip" +echo "[i] Zipping -> $ZIP" +rm -f "$ZIP" +( cd "$DIST" && ditto -c -k --keepParent "$APP_NAME.app" "CCS-Bar.app.zip" ) + +echo "[OK] Packaged: $APP" +echo "[OK] Asset: $ZIP" +if [[ "$SIGNING" == "adhoc" ]]; then + echo "[!] Ad-hoc build: first launch needs right-click > Open, or" + echo " xattr -dr com.apple.quarantine \"/Applications/$APP_NAME.app\"" +fi +echo "[i] To publish: gh release upload ccs-bar-latest \"$ZIP\" --clobber" diff --git a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift new file mode 100644 index 00000000..4162a4af --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift @@ -0,0 +1,209 @@ +import SwiftUI +import CCSBarCore + +/// Demoted spend strip + surface/model breakdown. +/// +/// Spend is informational pool context, NEVER the headline — so the loud 2×2 +/// StatCell grid + 28pt sparkline that used to dominate the dropdown collapses +/// into one muted caption line ("today $NN · 7d $N.Nk") with an optional thin +/// inline sparkline. The By-surface / Top-models breakdowns stay, tightened and +/// subordinate, below the pool accounts. +/// +/// When the trailing 30 days carry no spend the strip reads the honest idle line +/// ("No usage in N days") instead of three dead "$0.00" cells. +struct BarAnalyticsView: View { + @Environment(\.barTheme) private var theme + let analytics: BarAnalytics + /// Which slot of the dropdown this instance renders. `.spend` is the thin strip + /// placed below the subscriptions cockpit; `.breakdown` is the by-surface / + /// top-models detail placed below the pool accounts. + enum Section { case spend, breakdown } + var section: Section = .spend + /// Controls whether the spend sparkline renders as bars or a line graph. + /// Passed in by BarMenuView so it reflects the user's persisted choice live. + var spendChartStyle: SpendChartStyle = .bars + /// Inline flip for the spend chart style, surfaced as a small toggle in the + /// Spend header (using the otherwise-blank space) so the user switches + /// bars/line in place rather than digging into Settings. nil for `.breakdown`. + var onToggleSpendStyle: (() -> Void)? = nil + + private var lastActive: String? { + BarFormatting.lastActiveLabel( + iso: analytics.lastActivityAt, daysSince: analytics.daysSinceLastActivity) + } + + var body: some View { + switch section { + case .spend: + spendStrip + case .breakdown: + breakdown + } + } + + /// By-surface + top-models detail, tightened and subordinate. + @ViewBuilder private var breakdown: some View { + VStack(alignment: .leading, spacing: 8) { + // Surface breakdown: "how much Claude Code vs Codex" — only shown when + // the backend supplies at least one surface entry. Top 5 keeps it compact. + if !analytics.bySurface.isEmpty { + SectionLabel("By surface") + let peakSurface = analytics.bySurface.map(\.cost).max() ?? 1 + ForEach(analytics.bySurface.prefix(5)) { surface in + SurfaceBar(surface: surface, peak: peakSurface) + } + } + + // Top models. + if !analytics.topModels.isEmpty { + let scope = analytics.topModelsWindow == "30d" ? "30d" : "all-time" + SectionLabel("Top models · \(scope)") + let peak = analytics.topModels.map(\.cost).max() ?? 1 + ForEach(analytics.topModels.prefix(4)) { model in + ModelBar(model: model, peak: peak) + } + } + } + } + + /// True when there's any surface/model detail worth a divider + section. + var hasBreakdown: Bool { + !analytics.bySurface.isEmpty || !analytics.topModels.isEmpty + } + + /// The collapsed informational spend strip: a "SPEND" label, one muted caption + /// line, and a thin inline 30-day sparkline when there is real spend. + private var spendStrip: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + SectionLabel("Spend") + Spacer() + // Inline bars/line toggle in the header's blank space — no Settings trip. + if let toggle = onToggleSpendStyle, analytics.hasRecentData, !sparklineIsEmpty { + Button(action: toggle) { + Image( + systemName: spendChartStyle == .bars + ? "chart.line.uptrend.xyaxis" : "chart.bar.fill" + ) + .font(.system(size: 10)) + } + .buttonStyle(.borderless) + .foregroundStyle(.tertiary) + .help("Spend graph: switch to \(spendChartStyle == .bars ? "line" : "bars")") + } + } + if analytics.hasRecentData { + Text(spendCaption) + .font(.caption2) + .foregroundStyle(.secondary) + if !sparklineIsEmpty { + // height: 30 (up from 18) so daily spend gradations are clearly readable. + Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent, + style: spendChartStyle) + .frame(height: 30) + } + } else { + Text(idleCaption) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + + /// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk". + private var spendCaption: String { + "today \(BarFormatting.money(analytics.today.cost))" + + " · 7d \(BarFormatting.money(analytics.last7d.cost))" + + " · 30d \(BarFormatting.money(analytics.last30d.cost))" + } + + /// Honest idle caption when there's no recent spend, folding in last-active. + private var idleCaption: String { + let headline = + analytics.daysSinceLastActivity.map { "No usage in \($0) days" } ?? "No usage in 30 days" + if let lastActive { return "\(headline) · \(lastActive.lowercased())" } + return headline + } + + private var sparklineIsEmpty: Bool { + analytics.byDay.allSatisfy { $0.cost <= 0 } + } +} + +/// One usage-surface row: surface name + proportional accent bar + cost and +/// request count. Mirrors ModelBar visually so the two sections feel cohesive. +private struct SurfaceBar: View { + @Environment(\.barTheme) private var theme + let surface: BarAnalyticsSurface + let peak: Double + + var body: some View { + GeometryReader { geo in + let fraction = peak > 0 ? CGFloat(surface.cost / peak) : 0 + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 5) + .fill(theme.accent.opacity(0.16)) + .frame(width: max(8, geo.size.width * fraction)) + HStack { + Text(surface.surface) + .font(.caption) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + HStack(spacing: 4) { + Text(BarFormatting.count(surface.requests)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + Text(BarFormatting.money(surface.cost)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 10) + } + } + .frame(height: 26) + } +} + +/// One top-model row: name + spend with a proportional accent bar behind. +private struct ModelBar: View { + @Environment(\.barTheme) private var theme + let model: BarAnalytics.Model + let peak: Double + + var body: some View { + GeometryReader { geo in + let fraction = peak > 0 ? CGFloat(model.cost / peak) : 0 + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 5) + .fill(theme.accent.opacity(0.16)) + .frame(width: max(8, geo.size.width * fraction)) + HStack { + Text(model.model) + .font(.caption) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(BarFormatting.money(model.cost)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + } + } + .frame(height: 26) + } +} + +/// Uppercase section divider label. +struct SectionLabel: View { + let text: String + init(_ text: String) { self.text = text } + var body: some View { + Text(text.uppercased()) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.secondary) + .padding(.top, 1) + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift b/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift new file mode 100644 index 00000000..0121faa1 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift @@ -0,0 +1,54 @@ +import Foundation +import CCSBarCore + +/// App-side date phrasing for the subscription card. Kept out of Core so the +/// shared formatting contract there stays untouched. Parses ISO-8601 the same +/// way Core does (with and without fractional seconds) so timestamp handling +/// matches the rest of the bar. +enum BarCardFormatting { + /// Parse an ISO-8601 timestamp, tolerating an optional fractional-seconds + /// component. Mirrors Core's parser, which is module-internal there. + private static func isoDate(_ iso: String) -> Date? { + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = withFraction.date(from: iso) { return d } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: iso) + } + + /// Compact reset form for the per-window bar chips and split lines: + /// <24h → compact duration via BarQuotaGauge.compactDuration (e.g. "3h 15m", "22m") + /// <7d → weekday abbreviation (e.g. "Fri") + /// >=7d → calendar date (e.g. "Jun 14") + /// Returns nil for a missing/unparseable timestamp (caller omits the clause). + static func shortReset(iso: String?, now: Date) -> String? { + guard let iso, let date = isoDate(iso) else { return nil } + let secs = date.timeIntervalSince(now) + if secs <= 0 { return "due" } + if secs < 24 * 3600 { + // Delegate to Core's authoritative compactDuration so both layers are + // consistent and the days-tier is automatically handled if ever needed. + let totalMinutes = Int(secs / 60) + return BarQuotaGauge.compactDuration(minutes: totalMinutes) + } + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + if secs < 7 * 24 * 3600 { + fmt.dateFormat = "EEE" // weekday, e.g. "Fri" + } else { + fmt.dateFormat = "MMM d" // e.g. "Jun 14" + } + return fmt.string(from: date) + } + + /// Local wall-clock "HH:mm" for the Codex stale footnote, e.g. "13:42". + /// Returns nil for a missing/unparseable timestamp. + static func clockTime(iso: String?) -> String? { + guard let iso, let date = isoDate(iso) else { return nil } + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.dateFormat = "HH:mm" + return fmt.string(from: date) + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift new file mode 100644 index 00000000..14a09437 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -0,0 +1,588 @@ +import SwiftUI +import AppKit +import CCSBarCore + +/// Dropdown content for the menu bar: a CCS-branded header, usage analytics, +/// per-account rows + actions, an offline state when CCS isn't running, and +/// footer controls. +struct BarMenuView: View { + @ObservedObject var viewModel: BarViewModel + /// Resolved theme injected by ThemedRoot — used to tint the armed Quit control + /// with the themed red ramp so it matches the dropdown on both plates. + @Environment(\.barTheme) private var theme + /// Two-step inline quit confirm. First footer-Quit click arms it (icon swaps + /// hollow->filled, tints red); second click terminates. Reset on every popover + /// open via .onAppear so a stale armed state never carries across sessions — + /// no modal, no .confirmationDialog (those steal focus and dismiss the popover, + /// the exact fragility of BUG 1). + @State private var quitArmed = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + + if viewModel.offline { + offlineState.padding(14) + } else { + // The scroll indicator is suppressed (.never, not just .hidden) AND the + // enclosing NSScrollView's scroller is hard-disabled via ScrollerHider: + // inside a MenuBarExtra popover the SwiftUI preference alone is sometimes + // ignored and a scroller track steals width + misaligns content. With the + // reorder + collapsed spend strip the important rows fit without scrolling + // for the common 1-4 subscription setup; the scroll only engages for + // genuine pool/model overflow. + ScrollView { + VStack(alignment: .leading, spacing: 12) { + // (1) ALERTS first — urgent quota crossings surface above everything. + // Spend-cap alerts are opt-in OFF by default, so by default only + // quota/reauth/cooldown conditions appear here. + if !viewModel.activeAlerts.isEmpty { + VStack(alignment: .leading, spacing: 8) { + SectionLabel("Alerts") + ForEach(viewModel.activeAlerts) { alert in + AlertRow(alert: alert) + } + } + } + + // (2) SUBSCRIPTIONS — the dominant section, opens here. + accountsSection + + // (3) SPEND — demoted to a thin informational strip below the cockpit. + // spendChartStyle is threaded from the viewModel and toggled inline + // from the Spend header, so a change updates the chart immediately. + if let analytics = viewModel.analytics { + Divider() + BarAnalyticsView( + analytics: analytics, section: .spend, + spendChartStyle: viewModel.spendChartStyle, + onToggleSpendStyle: { + viewModel.spendChartStyle = + viewModel.spendChartStyle == .bars ? .line : .bars + }) + } + + // (4) POOL ACCOUNTS — compact generic rows, subordinate. + poolSection + + // (5) BY-SURFACE / TOP MODELS — tightened detail, below the pool. + if let analytics = viewModel.analytics, + BarAnalyticsView(analytics: analytics, section: .breakdown).hasBreakdown + { + BarAnalyticsView(analytics: analytics, section: .breakdown) + } + + // Zero-size AppKit bridge that disables the popover's NSScrollView + // scroller at runtime (belt-and-suspenders with .scrollIndicators). + ScrollerHider().frame(width: 0, height: 0) + } + .padding(14) + } + .scrollIndicators(.never) + // 700 gives more vertical breathing room before scroll engages — useful + // for 3-4 subscription cards each carrying multiple quota windows. + // Scroll still engages gracefully on genuine overflow. + .frame(maxHeight: 700) + } + + Divider() + footer + } + // 360 is narrower than the old 380, keeping the popover compact while still + // fitting the bar-list fixed column widths (label 32 + bar 110 + pct 32 + chip 48). + .frame(width: 360) + .onAppear { + viewModel.onOpen() + // Disarm quit on every popover open so a stale armed state never persists. + quitArmed = false + } + } + + /// The cockpit. Native subscriptions (Claude Code / Codex) render as detailed + /// `BarSubscriptionCard`s at the very top, ordered tightest-binding-first + /// (closest to empty on top) so the window the user is about to run out of + /// leads. CLIProxy pool accounts keep the compact generic `BarRowView` below, + /// subordinate. The two-section split is suppressed when only one kind is + /// present, preserving the single "Accounts" header for a CLIProxy-only setup. + @ViewBuilder private var accountsSection: some View { + let parts = BarFormatting.partitionSubscriptions(viewModel.rows) + VStack(alignment: .leading, spacing: 8) { + if let error = viewModel.lastError { + ErrorBanner(message: error) + } + if viewModel.rows.isEmpty { + SectionLabel("Accounts") + Text("No accounts configured") + .font(.caption) + .foregroundStyle(.secondary) + } else if parts.subscriptions.isEmpty { + // CLIProxy-only setup: keep the single established header + generic rows. + SectionLabel("Accounts") + ForEach(parts.pool) { row in + BarRowView(row: row, viewModel: viewModel) + } + } else { + subscriptionsHeader(parts.subscriptions) + ForEach(orderedSubscriptions(parts.subscriptions)) { row in + BarSubscriptionCard(row: row) + } + } + } + } + + /// CLIProxy pool accounts as compact generic rows — subordinate, rendered below + /// the spend strip. Suppressed entirely when there are no pool accounts, or + /// when there are no subscriptions (the CLIProxy-only path renders pool rows + /// under the single "Accounts" header in `accountsSection` instead). + @ViewBuilder private var poolSection: some View { + let parts = BarFormatting.partitionSubscriptions(viewModel.rows) + if !parts.subscriptions.isEmpty && !parts.pool.isEmpty { + VStack(alignment: .leading, spacing: 8) { + SectionLabel("Pool accounts") + ForEach(parts.pool) { row in + BarRowView(row: row, viewModel: viewModel) + } + } + } + } + + /// "SUBSCRIPTIONS" header, with a right-aligned cross-tool headroom hint + /// ("most room: NN%") when there are >=2 subscriptions with quota data. + /// Falls back to the bare label otherwise. + @ViewBuilder private func subscriptionsHeader(_ subs: [BarSummaryRow]) -> some View { + HStack(alignment: .firstTextBaseline) { + SectionLabel("Subscriptions") + Spacer() + if let leader = BarQuotaGauge.headroomLeader(subs) { + Text("most room: \(leader.label) \(Int(leader.remainingPercent.rounded()))%") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + + /// Order subscription cards by tightest binding window ascending (closest to + /// empty on top). Rows with no binding window (error/reauth) sink to the bottom + /// so the actionable quota always leads. + private func orderedSubscriptions(_ subs: [BarSummaryRow]) -> [BarSummaryRow] { + subs.sorted { a, b in + let ra = BarQuotaGauge.selectBindingWindow(a.quotaWindows ?? [])?.remainingPercent + let rb = BarQuotaGauge.selectBindingWindow(b.quotaWindows ?? [])?.remainingPercent + switch (ra, rb) { + case let (.some(x), .some(y)): + if x != y { return x < y } + return (a.displayName ?? a.provider) < (b.displayName ?? b.provider) + case (.some, .none): + return true // a has quota, b doesn't → a first + case (.none, .some): + return false + case (.none, .none): + return (a.displayName ?? a.provider) < (b.displayName ?? b.provider) + } + } + } + + private var header: some View { + HStack(spacing: 8) { + Image(nsImage: MenuBarIcon.headerImage()) + VStack(alignment: .leading, spacing: 0) { + Text("CCS").font(.headline) + Text("usage & accounts").font(.caption2).foregroundStyle(.secondary) + } + Spacer() + if viewModel.isRefreshing { + ProgressView().controlSize(.small) + } + if let v = BarVersionDisplay.string() { + Text(v) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + private var offlineState: some View { + VStack(alignment: .leading, spacing: 6) { + Label("CCS is not running", systemImage: "bolt.slash.fill") + .font(.body) + Text("Start CCS, then reopen this menu.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Retry") { viewModel.reconnect(); viewModel.onOpen() } + .controlSize(.small) + } + } + + private var footer: some View { + HStack(spacing: 12) { + Button { + openDashboard() + } label: { + Label("Dashboard", systemImage: "chart.bar.xaxis") + } + Button { + viewModel.toggleIconStyle() + } label: { + Label( + "Icon", + systemImage: viewModel.iconStyle == .color ? "paintpalette" : "circle.lefthalf.filled" + ) + } + .help("Toggle the menu-bar icon between color and monochrome (does not change the bar theme)") + Button { + // Open Settings as a standalone AppKit NSWindow (NOT a .sheet on this + // popover). A sheet hosted in a .window-style MenuBarExtra popover pulls + // focus off the popover and auto-dismisses the whole bar (BUG 1). The + // window opens beside the popover and leaves it untouched. + SettingsWindowController.shared.show(viewModel: viewModel) + } label: { + Label("Settings", systemImage: "gearshape") + } + .help("Settings — appearance/theme, menu-bar glance, and alerts") + Spacer() + Button { + viewModel.onOpen() + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Refresh") + // Quit confirms via a two-step INLINE arm/confirm — no modal, no sheet, no + // .confirmationDialog. Those all steal focus and auto-dismiss the popover + // (the exact fragility of BUG 1). A stray single click can no longer kill + // the app: the first click only arms; the popover stays open and responsive. + quitButton + } + .buttonStyle(.borderless) + .font(.caption) + .padding(.horizontal, 14) + .padding(.vertical, 11) + } + + /// Two visual states in one footer slot. Disarmed: hollow power icon that arms + /// on click. Armed: filled power icon tinted themed red that terminates on + /// click. Reopening the popover disarms it (.onAppear on the root VStack). + @ViewBuilder private var quitButton: some View { + if !quitArmed { + Button { + quitArmed = true + } label: { + Image(systemName: "power") + } + .help("Quit CCS Bar (click again to confirm)") + } else { + Button { + NSApplication.shared.terminate(nil) + } label: { + Image(systemName: "power.circle.fill") + } + .help("Click to confirm quit") + .foregroundStyle(theme.bandRed) + } + } + + private func openDashboard() { + // Open the dashboard if the server is up; otherwise start it via `ccs config`. + Task { await DashboardLauncher.openOrStart() } + } +} + +/// One account row — the strongest section of the glance. +/// +/// Top line: health dot, name, default/paused/reauth badges. Subline: provider + +/// tier chips, the honest tri-state quota label (NN% / "no quota" / "quota ?"), +/// and a per-account "Last active " caption. Trailing: today's cost (or a +/// muted "no data" when unknown vs a real "$0.00"), a visible pause/resume +/// toggle, and the overflow menu (set-default / solo / tier-lock). +struct BarRowView: View { + @Environment(\.barTheme) private var theme + let row: BarSummaryRow + @ObservedObject var viewModel: BarViewModel + + /// A native first-party subscription (Claude Code / Codex) — drives the + /// distinct "subscription" badge + indigo provider chip. + private var isNativeSubscription: Bool { + BarFormatting.isNativeSubscription(provider: row.provider) + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(healthColor) + .frame(width: 8, height: 8) + .padding(.top, 5) + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 7) { + Text(row.displayName ?? row.accountId) + .font(.system(.body, design: .default).weight(.medium)) + .lineLimit(1) + .truncationMode(.middle) + if row.isDefault { + Chip("default", tint: theme.accent) + } + if row.paused { + Chip("paused", tint: .secondary) + } + if row.needsReauth { + Chip("reauth", tint: theme.bandRed) + } + if isNativeSubscription { + Chip("subscription", tint: theme.subscription) + } + } + HStack(spacing: 6) { + Chip( + BarFormatting.providerLabel(row.provider), + tint: isNativeSubscription ? theme.subscription : theme.accent) + if let tier = row.tier { Chip(tier, tint: .secondary) } + QuotaGaugeView( + percentage: row.quotaPercentage, + status: row.quotaStatus, + nextReset: row.nextReset) + } + if let lastActive = BarFormatting.lastActiveLabel( + iso: row.lastActivityAt, daysSince: nil) + { + Text(lastActive) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 4) + + VStack(alignment: .trailing, spacing: 3) { + costView + HStack(spacing: 2) { + pauseToggle + overflowMenu + } + } + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8)) + } + + /// Today's cost: a real "$x.xx" when known (including a genuine $0.00), a muted + /// "no data" when the value is null (no usage record on a possibly-stale snapshot). + @ViewBuilder private var costView: some View { + if let cost = row.todayCost { + Text(BarFormatting.money(cost)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } else { + Text("no data") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + /// Visible primary action: one tap to pause or resume the account. + private var pauseToggle: some View { + Button { + if row.paused { viewModel.resume(row) } else { viewModel.pause(row) } + } label: { + Image(systemName: row.paused ? "play.circle" : "pause.circle") + } + .buttonStyle(.borderless) + .help(row.paused ? "Resume account" : "Pause account") + } + + private var overflowMenu: some View { + Menu { + Button("Set as default") { viewModel.setDefault(row) } + Button("Solo (pause others)") { viewModel.solo(row) } + Divider() + if let tier = row.tier { + Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) } + } + Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(width: 24) + } + + /// Health dot. With the corrected backend, "unsupported" providers (ghcp/kiro) + /// arrive as health "ok" (green) — no permanent orange dot. Orange is reserved + /// for genuine transient fetch failures, red for accounts needing reauth. + private var healthColor: Color { + // Use the themed band ramp (not raw system .red/.orange/.green) so the dot + // matches the rest of the dropdown and stays legible on both plates. + switch row.health { + case "error": return theme.bandRed + case "warning": return theme.bandAmber + default: return theme.bandGreen + } + } +} + +/// Per-account quota gauge. When the row has a live "ok" quota with a percentage, +/// renders a thin colored bar (filled by the remaining fraction, tinted by the +/// severity band) plus a "resets in …" caption. When there is no live quota it +/// falls back to the honest text label ("no quota" / "quota ?"). All branch, +/// color, and countdown logic lives in the pure Core `BarQuotaGauge`; this view +/// is a thin render. +struct QuotaGaugeView: View { + @Environment(\.barTheme) private var theme + let percentage: Double? + let status: String + let nextReset: String? + + var body: some View { + let band = BarQuotaGauge.band(percentage: percentage, status: status) + if band != .none, let fill = BarQuotaGauge.fillFraction(percentage: percentage, status: status) { + HStack(spacing: 5) { + gaugeBar(fill: fill, color: color(for: band)) + Text(BarFormatting.quotaLabel(percentage: percentage, status: status)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(color(for: band)) + if let countdown = BarQuotaGauge.resetCountdown(nextReset: nextReset, now: Date()) { + Text(countdown) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + } else { + // No live quota: keep the existing honest text ("no quota" / "quota ?"). + Text(BarFormatting.quotaLabel(percentage: percentage, status: status)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private func gaugeBar(fill: Double, color: Color) -> some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.12)) + Capsule() + .fill(color) + .frame(width: max(2, geo.size.width * fill)) + } + } + .frame(width: 54, height: 6) + } + + private func color(for band: BarQuotaGauge.Band) -> Color { + // Themed band ramp for whole-dropdown consistency. .orange maps to the coral + // band (the warning step in the green→amber→coral→red ramp) so it stays + // distinct from the brand accent orange on both plates. + switch band { + case .green: return theme.bandGreen + case .yellow: return theme.bandAmber + case .orange: return theme.bandCoral + case .red: return theme.bandRed + case .none: return .secondary + } + } +} + +/// Inline banner surfacing the last failed action so it is visible rather than +/// silently swallowed. Success is confirmed by the default/paused badge updating. +struct ErrorBanner: View { + @Environment(\.barTheme) private var theme + let message: String + var body: some View { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(theme.accent) + Text(message) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 5) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 7)) + } +} + +/// One in-dropdown alert row. Mirrors a delivered notification so the conditions +/// are visible even when system notifications are denied. The icon is keyed off +/// the alert kind so each rule reads at a glance. +struct AlertRow: View { + @Environment(\.barTheme) private var theme + let alert: BarNotification + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: icon) + .foregroundStyle(tint) + .font(.caption) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 1) { + Text(alert.title) + .font(.caption.weight(.medium)) + Text(alert.body) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 0) + } + .padding(.vertical, 5) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 7)) + } + + private var icon: String { + switch alert.kind { + case .quotaRemainingBelow: return "gauge.with.dots.needle.bottom.0percent" + case .dailySpendAbove, .monthSpendAbove: return "dollarsign.circle" + case .reauthNeeded: return "key.slash" + case .accountCooldownOrPaused: return "pause.circle" + } + } + + private var tint: Color { + // Themed: quota warnings take the brand accent, reauth the critical band, + // so alert chips match the rest of the dropdown on both plates. + switch alert.kind { + case .quotaRemainingBelow: return theme.accent + case .dailySpendAbove, .monthSpendAbove: return theme.accent + case .reauthNeeded: return theme.bandRed + case .accountCooldownOrPaused: return .secondary + } + } +} + +/// Small pill label used in account sublines. +struct Chip: View { + @Environment(\.colorScheme) private var colorScheme + let text: String + let tint: Color + init(_ text: String, tint: Color) { + self.text = text + self.tint = tint + } + /// Lift the small 9pt label toward the opposite of the surface so it stays + /// legible: toward white on the dark plate (the raw indigo subscription tint + /// was too dim to read), toward black on the light plate (lifting toward white + /// there would wash the text out). The forced scheme is already in effect on + /// this subtree, so `colorScheme` reflects exactly the plate being drawn. + private var textColor: Color { + if tint == .secondary { return .secondary } + let target: NSColor = (colorScheme == .light) ? .black : .white + let lifted = NSColor(tint).blended(withFraction: 0.5, of: target) ?? NSColor(tint) + return Color(nsColor: lifted) + } + var body: some View { + Text(text) + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 5) + .padding(.vertical, 1.5) + .background(tint.opacity(0.22), in: Capsule()) + .foregroundStyle(textColor) + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarNotifier.swift b/macos-bar/Sources/CCSBarApp/BarNotifier.swift new file mode 100644 index 00000000..5708ca9f --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarNotifier.swift @@ -0,0 +1,84 @@ +import Foundation +import UserNotifications +import CCSBarCore + +/// Real notification delivery backed by `UNUserNotificationCenter`. +/// +/// Authorization is requested LAZILY on the first non-empty deliver, not at +/// launch. Ad-hoc-signed menu-bar apps launched via `open` get a flaky / silently +/// dropped prompt when authorization is requested during startup, so we defer the +/// request until there is actually something to show — the prompt then lands with +/// user-visible context. +/// +/// When authorization is denied, `deliver` is a no-op, but the rule engine keeps +/// updating its fired-keys regardless (the App persists them independent of +/// delivery). That avoids a backlog of stale alerts replaying if the user later +/// grants permission — only conditions still true at that later poll re-fire. +@MainActor +final class BarNotifier: NotificationDelivering { + enum AuthState { + case unknown + case authorized + case denied + } + + private let center: UNUserNotificationCenter? + private var authState: AuthState = .unknown + private var didRequest = false + + /// `UNUserNotificationCenter.current()` traps when there is no main bundle + /// identifier (e.g. a bare `swift run` with no .app wrapper). Guard it so the + /// wiring still compiles and runs headlessly; delivery is simply a no-op there. + init() { + if Bundle.main.bundleIdentifier != nil { + center = UNUserNotificationCenter.current() + } else { + center = nil + } + } + + /// Deliver one notification. The first call with a notification triggers a + /// one-time authorization request; subsequent calls reuse the cached state. + nonisolated func deliver(_ notification: BarNotification) { + Task { @MainActor in + self.send(notification) + } + } + + private func send(_ notification: BarNotification) { + guard let center else { return } + + if !didRequest { + didRequest = true + // Request once, lazily. The completion updates cached state; this in-flight + // notification is enqueued after, so an accepted prompt still shows it. + center.requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in + // Hop back to the main actor and re-read `self.center` there rather than + // capturing the non-Sendable center across the closure boundary. + Task { @MainActor in + guard let self else { return } + self.authState = granted ? .authorized : .denied + if granted, let c = self.center { self.post(notification, on: c) } + } + } + return + } + + // Already requested: post when authorized or still-unknown; a denied state is a no-op. + if authState == .authorized || authState == .unknown { + post(notification, on: center) + } + } + + private func post(_ notification: BarNotification, on center: UNUserNotificationCenter) { + let content = UNMutableNotificationContent() + content.title = notification.title + content.body = notification.body + content.sound = .default + // Identifier == fired-key so the OS de-dupes at the delivery layer too: a + // re-posted same-key request replaces rather than stacks. + let request = UNNotificationRequest( + identifier: notification.id, content: content, trigger: nil) + center.add(request, withCompletionHandler: nil) + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarPreferences.swift b/macos-bar/Sources/CCSBarApp/BarPreferences.swift new file mode 100644 index 00000000..7e4e0467 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarPreferences.swift @@ -0,0 +1,71 @@ +import Foundation +import CCSBarCore + +/// Live UserDefaults adapter for the alert/glance preferences. The pure +/// key/default/parse contract lives in Core (`BarAlertPrefsStore`); this type is +/// the thin App-side bridge that reads/writes the real defaults suite. +/// +/// `register(defaults:)` is called once at launch so that an absent Bool key does +/// NOT read back as `false` (which would silently disable every alert on a fresh +/// install). All reads go through `load()`; all writes go through `save(_:)`. +struct BarPreferences { + let defaults: UserDefaults + + /// Default to the standard suite. A stable suite name is intentionally NOT used + /// here because the rest of the app (MenuBarIcon) already persists to + /// `.standard`; keeping one suite avoids split state across the two. + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// Seed the registration domain so missing keys resolve to their real defaults + /// rather than the type-zero value. Idempotent — safe to call on every launch. + /// + /// The two pay-per-use spend-cap alerts default to OFF (opt-in): subscriptions + /// are flat-rate, so a spend alert on them is meaningless, and pool spend is + /// informational context, never a default-on alert. Quota / reauth / cooldown + /// stay default-on (the quota-first alert set). The caps themselves keep Core's + /// sane values so a user who opts in starts with $500 / $10000. + func registerDefaults() { + var d = BarAlertPrefsStore.registrationDefaults + d[BarAlertPrefsStore.Key.dailyEnabled] = false + d[BarAlertPrefsStore.Key.monthEnabled] = false + defaults.register(defaults: d) + } + + /// Read the current preferences. Pulls each key into a plain dictionary and + /// defers to the pure Core decoder so parsing/clamping stays in one place. + func load() -> BarAlertPrefs { + var dict: [String: Any] = [:] + // Only forward keys that are actually present; the Core decoder fills the + // rest from canonical defaults. `object(forKey:)` returns the registered + // default when nothing was explicitly written, which is exactly what we want. + dict[BarAlertPrefsStore.Key.quotaEnabled] = defaults.object(forKey: BarAlertPrefsStore.Key.quotaEnabled) + dict[BarAlertPrefsStore.Key.quotaLevels] = defaults.object(forKey: BarAlertPrefsStore.Key.quotaLevels) + dict[BarAlertPrefsStore.Key.dailyEnabled] = defaults.object(forKey: BarAlertPrefsStore.Key.dailyEnabled) + dict[BarAlertPrefsStore.Key.dailyCapUSD] = defaults.object(forKey: BarAlertPrefsStore.Key.dailyCapUSD) + dict[BarAlertPrefsStore.Key.monthEnabled] = defaults.object(forKey: BarAlertPrefsStore.Key.monthEnabled) + dict[BarAlertPrefsStore.Key.monthCapUSD] = defaults.object(forKey: BarAlertPrefsStore.Key.monthCapUSD) + dict[BarAlertPrefsStore.Key.reauthEnabled] = defaults.object(forKey: BarAlertPrefsStore.Key.reauthEnabled) + dict[BarAlertPrefsStore.Key.cooldownPausedEnabled] = + defaults.object(forKey: BarAlertPrefsStore.Key.cooldownPausedEnabled) + dict[BarAlertPrefsStore.Key.glanceMode] = defaults.object(forKey: BarAlertPrefsStore.Key.glanceMode) + return BarAlertPrefsStore.decode(from: dict.compactMapValues { $0 }) + } + + /// Persist the preferences, encoding levels comma-joined and mode as raw value. + func save(_ prefs: BarAlertPrefs) { + for (key, value) in BarAlertPrefsStore.encode(prefs) { + defaults.set(value, forKey: key) + } + } + + // MARK: Fired-key engine state (NOT user-editable) + + /// The engine's persisted fired-key set. Overwritten verbatim each poll with + /// `BarAlertEvaluation.firedKeys` so the stored set stays bounded. + var firedKeys: Set { + get { Set(defaults.stringArray(forKey: BarAlertPrefsStore.Key.firedKeys) ?? []) } + nonmutating set { defaults.set(Array(newValue), forKey: BarAlertPrefsStore.Key.firedKeys) } + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift b/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift new file mode 100644 index 00000000..6e9a39c2 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift @@ -0,0 +1,201 @@ +import SwiftUI +import CCSBarCore + +/// Preferences sheet reachable from the dropdown footer. Lets the user pick the +/// menu-bar glance mode and toggle / tune each alert rule. Writes through to +/// UserDefaults on every change and tells the view model to re-read prefs so the +/// title updates live and the next poll re-evaluates with the new settings. +struct BarPreferencesView: View { + @ObservedObject var viewModel: BarViewModel + let prefs: BarPreferences + @Environment(\.barTheme) private var theme + + // Local editable mirror of the persisted prefs. Loaded on appear; each change + // is written through immediately so there is no separate "save" step. + @State private var draft = BarAlertPrefs() + // Quota levels are edited as free text and parsed on commit; keeping the raw + // string in @State avoids fighting the user's keystrokes mid-edit. + @State private var levelsText = "" + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + Form { + appearanceSection + glanceSection + quotaSection + spendSection + accountSection + deliveryHint + } + .formStyle(.grouped) + Divider() + footer + } + // Fill the hosting window responsively (it is a real resizable NSWindow now, + // not a fixed 360x460 sheet) so there are no dead margins and resizing works. + .frame(minWidth: 420, idealWidth: 460, maxWidth: .infinity, + minHeight: 520, idealHeight: 600, maxHeight: .infinity) + .onAppear(perform: hydrate) + } + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: "bell.badge") + .foregroundStyle(theme.accent) + Text("Alerts & Glance").font(.headline) + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + /// Theme + chart style. Appearance affects the whole dropdown; chart style is + /// scoped to the spend sparkline. Both are bound directly to viewModel properties + /// whose `didSet` persist — no writeThrough() needed here. + private var appearanceSection: some View { + Section("Appearance") { + Picker("Menu bar theme", selection: $viewModel.appearance) { + Text("System").tag(BarAppearance.system) + Text("Light").tag(BarAppearance.light) + Text("Dark").tag(BarAppearance.dark) + } + .pickerStyle(.segmented) + // Spend graph bars/line is toggled inline in the dropdown's Spend header, + // not here — kept out of Settings so the choice lives where the chart is. + } + } + + private var glanceSection: some View { + Section("Menu-bar glance") { + Picker("Show in menu bar", selection: $draft.glanceMode) { + ForEach(BarGlanceMode.allCases, id: \.self) { mode in + Text(glanceLabel(mode)).tag(mode) + } + } + .onChange(of: draft.glanceMode) { _ in writeThrough() } + } + } + + private var quotaSection: some View { + Section("Quota") { + Toggle("Alert on low quota", isOn: $draft.quotaEnabled) + .onChange(of: draft.quotaEnabled) { _ in writeThrough() } + HStack { + Text("Levels (%)") + Spacer() + TextField("20,10,0", text: $levelsText) + .multilineTextAlignment(.trailing) + .frame(width: 120) + .onSubmit { commitLevels() } + } + .disabled(!draft.quotaEnabled) + Text("Fires once per account at the most-severe level crossed, then again after the next quota reset.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + /// Pay-per-use spend caps. Opt-in (OFF by default) and labelled for pool + /// accounts, NOT subscriptions: flat-rate subscription plans have no spend to + /// cap, so these alerts only make sense for metered pool usage. + private var spendSection: some View { + Section("Opt-in · pay-per-use spend") { + Toggle("Daily spend cap (pool accounts)", isOn: $draft.dailySpendEnabled) + .onChange(of: draft.dailySpendEnabled) { _ in writeThrough() } + capRow(label: "Daily cap", value: $draft.dailyCapUSD, enabled: draft.dailySpendEnabled) + + Toggle("Monthly spend cap (pool accounts)", isOn: $draft.monthSpendEnabled) + .onChange(of: draft.monthSpendEnabled) { _ in writeThrough() } + capRow(label: "Month cap", value: $draft.monthCapUSD, enabled: draft.monthSpendEnabled) + Text("Subscriptions are flat-rate and unaffected. These caps only watch metered pay-per-use pool spend, and are off until you enable them.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private var accountSection: some View { + Section("Account state") { + Toggle("Alert when an account needs re-auth", isOn: $draft.reauthEnabled) + .onChange(of: draft.reauthEnabled) { _ in writeThrough() } + Toggle("Alert when an account is paused / cooling down", isOn: $draft.cooldownPausedEnabled) + .onChange(of: draft.cooldownPausedEnabled) { _ in writeThrough() } + } + } + + /// Always-present delivery note. We don't synchronously read the live UN + /// authorization state here (it's async and ad-hoc signing makes the prompt + /// land late), so rather than a flickering "denied" branch we tell the user + /// where alerts surface either way: as system notifications when allowed, and + /// always in the in-menu alert list. + private var deliveryHint: some View { + Section { + HStack(spacing: 6) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + Text("Alerts show as system notifications when allowed (System Settings › Notifications) and always appear in the menu's Alerts list.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + + private func capRow(label: String, value: Binding, enabled: Bool) -> some View { + HStack { + Text(label) + Spacer() + Text("$") + .foregroundStyle(.secondary) + TextField("0", value: value, format: .number) + .multilineTextAlignment(.trailing) + .frame(width: 90) + .onSubmit { writeThrough() } + .onChange(of: value.wrappedValue) { _ in writeThrough() } + } + .disabled(!enabled) + } + + private var footer: some View { + HStack { + Spacer() + Button("Done") { commitLevels(); SettingsWindowController.shared.close() } + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + // MARK: State plumbing + + private func hydrate() { + draft = prefs.load() + levelsText = BarAlertPrefsStore.encodeQuotaLevels(draft.quotaLevels) + } + + /// Parse the free-text levels field into normalized ints, then persist. + private func commitLevels() { + let parsed = BarAlertPrefsStore.parseQuotaLevels(levelsText) + if !parsed.isEmpty { draft.quotaLevels = parsed } + // Reflect the normalized form back into the field so the user sees what stuck. + levelsText = BarAlertPrefsStore.encodeQuotaLevels(draft.quotaLevels) + writeThrough() + } + + /// Save the current draft and ask the view model to re-read it so the live + /// title + next evaluation pick up the change. + private func writeThrough() { + prefs.save(draft) + viewModel.reloadPrefs() + } + + private func glanceLabel(_ mode: BarGlanceMode) -> String { + switch mode { + case .auto: return "Auto (smart)" + case .todaySpend: return "Today's spend" + case .monthSpend: return "This month's spend" + case .lowestQuota: return "Lowest quota" + case .accountCount: return "Active account count" + } + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift new file mode 100644 index 00000000..34bbbc4e --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift @@ -0,0 +1,267 @@ +import SwiftUI +import CCSBarCore + +/// Dedicated card for a first-party subscription (Claude Code / Codex). +/// +/// Design goal: bar-first, glanceable in under a second. Each quota window is +/// rendered as an aligned row: +///