From 672196bb8f7f79ae9bbcea200a4dc5aeec28cd8a Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 1 Sep 2026 13:50:56 -0700 Subject: [PATCH] feat(windows): Capacity Dock, an edge-docked quota rail A slim always-on-top rail on the right screen edge showing one capacity ring per signed-in provider, the Windows twin of the macOS Capacity Dock. The window is created on demand and closed when the dock is switched off, so a disabled dock leaves no webview resident. Hovering widens the same window to reveal a detail panel with each quota window, its used percent, plan and reset countdown; the panel unmounts on collapse rather than lingering hidden. Data comes from `codeburn quota --format json` through the existing cli.rs spawn plumbing. A CLI without that subcommand exits with `unknown command`, which the rail reports as a quiet update-needed state instead of an error. Metrics are the mac dock's whole-point values at its default 0.6 scale. --- windows/src-tauri/capabilities/default.json | 2 +- windows/src-tauri/src/cli.rs | 42 +++++ windows/src-tauri/src/dock.rs | 111 ++++++++++++ windows/src-tauri/src/lib.rs | 63 ++++++- windows/src/Dock.tsx | 181 ++++++++++++++++++++ windows/src/dock.css | 170 ++++++++++++++++++ windows/src/main.tsx | 9 +- 7 files changed, 573 insertions(+), 5 deletions(-) create mode 100644 windows/src-tauri/src/dock.rs create mode 100644 windows/src/Dock.tsx create mode 100644 windows/src/dock.css diff --git a/windows/src-tauri/capabilities/default.json b/windows/src-tauri/capabilities/default.json index e227dc18..606e5d6e 100644 --- a/windows/src-tauri/capabilities/default.json +++ b/windows/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default permissions for the CodeBurn tray app window", - "windows": ["popover"], + "windows": ["popover", "dock"], "permissions": [ "core:default", "core:window:allow-close", diff --git a/windows/src-tauri/src/cli.rs b/windows/src-tauri/src/cli.rs index 53435547..88e9ec2c 100644 --- a/windows/src-tauri/src/cli.rs +++ b/windows/src-tauri/src/cli.rs @@ -17,6 +17,7 @@ const MAX_PAYLOAD_BYTES: usize = 20 * 1024 * 1024; const MAX_STDERR_BYTES: usize = 256 * 1024; const FETCH_TIMEOUT_SECS: u64 = 60; const VERSION_TIMEOUT_SECS: u64 = 20; +const QUOTA_TIMEOUT_SECS: u64 = 45; /// Oldest CLI this app can talk to. 0.9.9 is the first release whose /// `status --format menubar-json` accepts `--no-optimize`, which every quiet background @@ -62,6 +63,15 @@ pub struct CliStatus { pub error: Option, } +/// What the Capacity Dock renders: the provider array, or the reason there isn't one. +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum DockQuota { + Ready { providers: Value }, + CliOutdated, + Unavailable { message: String }, +} + impl CodeburnCli { /// Honours `CODEBURN_BIN` only when every whitespace-delimited token passes the /// allowlist. Otherwise resolves `codeburn` from PATH and the usual npm locations. @@ -163,6 +173,38 @@ impl CodeburnCli { Ok(payload) } + /// Spawns `codeburn quota --format json` for the Capacity Dock. A CLI without the + /// subcommand exits 1 with `unknown command`, which the dock reports as a quiet + /// "CLI update needed" state rather than an error. + pub async fn fetch_quota(&self) -> DockQuota { + let stdout = match self + .run_capture(&["quota", "--format", "json"], QUOTA_TIMEOUT_SECS) + .await + { + Ok(stdout) => stdout, + Err(err) => { + let message = err.to_string(); + return if message.contains("unknown command") { + DockQuota::CliOutdated + } else { + DockQuota::Unavailable { message } + }; + } + }; + + match serde_json::from_str::(&stdout) { + Ok(payload) => DockQuota::Ready { + providers: payload + .get("providers") + .cloned() + .unwrap_or_else(|| Value::Array(vec![])), + }, + Err(err) => DockQuota::Unavailable { + message: format!("CLI returned invalid JSON: {err}"), + }, + } + } + async fn run_capture(&self, args: &[&str], timeout_secs: u64) -> Result { let mut full_args = self.extra_args.clone(); full_args.extend(args.iter().map(|s| s.to_string())); diff --git a/windows/src-tauri/src/dock.rs b/windows/src-tauri/src/dock.rs new file mode 100644 index 00000000..947d8630 --- /dev/null +++ b/windows/src-tauri/src/dock.rs @@ -0,0 +1,111 @@ +//! The Capacity Dock: a slim quota rail docked to the right screen edge, the Windows twin of +//! the macOS Capacity Dock. The window is created on demand and closed again when the dock is +//! switched off, so a disabled dock leaves no webview resident. + +use std::fs; +use std::path::PathBuf; + +use anyhow::Result; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +pub const DOCK_LABEL: &str = "dock"; + +/// Whole-point metrics at the macOS dock's default 0.6 scale: its 88pt rail lands on 53, which +/// `layout.activityBarWidth` in tokens.json already rounds to 56 for this frontend. Fractional +/// sizes cost the mac app 5-7% idle CPU by making the hosting view re-lay itself out forever, +/// so every value here stays an integer. +const RAIL_WIDTH: f64 = 56.0; +const ROW_HEIGHT: f64 = 50.0; +const ROW_SPACING: f64 = 7.0; +const RAIL_PAD: f64 = 12.0; +const DETAIL_WIDTH: f64 = 300.0; + +fn state_path() -> PathBuf { + dirs::home_dir() + .map(|h| h.join(".config/codeburn")) + .unwrap_or_else(|| PathBuf::from(".codeburn")) + .join("windows-dock.json") +} + +pub fn is_enabled() -> bool { + fs::read(state_path()) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|value| value.get("enabled").and_then(serde_json::Value::as_bool)) + .unwrap_or(false) +} + +pub fn set_enabled(enabled: bool) -> Result<()> { + let path = state_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let body = serde_json::to_vec_pretty(&serde_json::json!({ "enabled": enabled }))?; + fs::write(&path, body)?; + Ok(()) +} + +fn logical_height(rows: u32) -> f64 { + let rows = rows.max(1) as f64; + RAIL_PAD * 2.0 + rows * ROW_HEIGHT + (rows - 1.0) * ROW_SPACING +} + +/// Right edge, vertically centred, sized to the provider count. Expanding keeps the right edge +/// flush and grows leftwards, so the rail never moves under the pointer. +pub fn apply_layout(window: &tauri::WebviewWindow, rows: u32, expanded: bool) { + let Ok(Some(monitor)) = window.primary_monitor() else { + return; + }; + let scale = monitor.scale_factor(); + let width = if expanded { + RAIL_WIDTH + DETAIL_WIDTH + } else { + RAIL_WIDTH + }; + let w = (width * scale).round() as i32; + let h = (logical_height(rows) * scale).round() as i32; + + let area = monitor.work_area(); + let x = area.position.x + area.size.width as i32 - w; + let y = area.position.y + ((area.size.height as i32 - h) / 2).max(0); + + let _ = window.set_size(tauri::PhysicalSize::new(w.max(1) as u32, h.max(1) as u32)); + let _ = window.set_position(tauri::PhysicalPosition::new(x, y)); +} + +pub fn show(app: &AppHandle) -> tauri::Result<()> { + if let Some(existing) = app.get_webview_window(DOCK_LABEL) { + existing.show()?; + return Ok(()); + } + + let builder = WebviewWindowBuilder::new(app, DOCK_LABEL, WebviewUrl::default()) + .title("CodeBurn Capacity Dock") + .inner_size(RAIL_WIDTH, logical_height(1)) + .decorations(false) + .resizable(false) + .always_on_top(true) + .skip_taskbar(true) + .focused(false) + .shadow(false) + .visible(false); + + // The page draws its own rounded card, so the window behind it has to be see-through. + // `transparent` is only compiled in off macOS, where it needs the private-API feature. + #[cfg(not(target_os = "macos"))] + let builder = builder.transparent(true); + + let window = builder.build()?; + + apply_layout(&window, 1, false); + window.show()?; + Ok(()) +} + +/// Closing rather than hiding is deliberate: a hidden webview keeps rendering, which is what +/// cost the macOS popover 6% idle CPU. +pub fn hide(app: &AppHandle) { + if let Some(window) = app.get_webview_window(DOCK_LABEL) { + let _ = window.close(); + } +} diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index f8759e04..844bca7a 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod autostart; mod cli; mod config; +mod dock; mod fx; mod plan; /// The spend-in-the-tray badge is a second tray icon, which only the Tauri tray backend @@ -22,10 +23,13 @@ use tauri::Listener; #[cfg(not(target_os = "linux"))] use tauri::{ - menu::{Menu, MenuItem, PredefinedMenuItem}, + menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, }; +#[cfg(not(target_os = "linux"))] +static DOCK_MENU_ITEM: std::sync::OnceLock> = std::sync::OnceLock::new(); + use crate::cli::CodeburnCli; use crate::config::CurrencyConfig; use crate::fx::FxCache; @@ -78,9 +82,18 @@ pub fn run() { round_window_corners(&window); } + if dock::is_enabled() { + let _ = dock::show(app.handle()); + } + Ok(()) }) .on_window_event(|window, event| { + // The dock is a persistent rail: it owns its own lifecycle and must survive both + // the hide-on-blur and the hide-on-close that keep the popover alive. + if window.label() == dock::DOCK_LABEL { + return; + } match event { WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); @@ -108,6 +121,8 @@ pub fn run() { commands::plan_usage, commands::launch_at_login, commands::set_launch_at_login, + commands::dock_quota, + commands::dock_set_layout, ]) .build(tauri::generate_context!()) .expect("error while running tauri application") @@ -132,6 +147,14 @@ fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> { let open = MenuItem::with_id(app, "open", "Open CodeBurn", true, None::<&str>)?; let refresh = MenuItem::with_id(app, "refresh", "Refresh", true, None::<&str>)?; let theme = MenuItem::with_id(app, "toggle_theme", "Toggle Dark/Light", true, None::<&str>)?; + let capacity_dock = CheckMenuItem::with_id( + app, + "toggle_dock", + "Show Capacity Dock", + true, + dock::is_enabled(), + None::<&str>, + )?; let report = MenuItem::with_id(app, "report", "Open Full Report", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Quit CodeBurn", true, None::<&str>)?; let menu = Menu::with_items( @@ -140,11 +163,15 @@ fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> { &open, &refresh, &theme, + &capacity_dock, &report, &PredefinedMenuItem::separator(app)?, &quit, ], )?; + // Both tray icons share one menu, so the handler needs the item itself to keep the + // checkmark in step with the persisted state. + let _ = DOCK_MENU_ITEM.set(capacity_dock); tray.set_menu(Some(menu.clone()))?; tray.set_show_menu_on_left_click(false)?; @@ -190,6 +217,7 @@ fn on_tray_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) { let _ = window.emit("codeburn://toggle-theme", ()); } } + "toggle_dock" => set_dock_enabled(app, !dock::is_enabled()), "report" => { let _ = cli::spawn_in_terminal(app, &["report"]); } @@ -290,6 +318,24 @@ fn mark_hidden(app: &AppHandle) { let _ = app.emit("codeburn://hidden", ()); } +/// Persists the dock preference, then brings the window in line with it. The checkmark is only +/// moved once the state is stored, so a failed write cannot leave the menu lying. +fn set_dock_enabled(app: &AppHandle, enabled: bool) { + if let Err(err) = dock::set_enabled(enabled) { + eprintln!("codeburn: failed to persist the Capacity Dock setting: {err}"); + return; + } + if enabled { + let _ = dock::show(app); + } else { + dock::hide(app); + } + #[cfg(not(target_os = "linux"))] + if let Some(item) = DOCK_MENU_ITEM.get() { + let _ = item.set_checked(enabled); + } +} + fn toggle_popover(app: &AppHandle, anchor: Option<(i32, i32)>) { let Some(window) = app.get_webview_window(POPOVER_LABEL) else { return; @@ -526,4 +572,19 @@ mod commands { pub async fn plan_usage(state: State<'_, AppState>) -> Result { state.plan.fetch().await.map_err(|e| e.to_string()) } + + #[tauri::command] + pub async fn dock_quota(state: State<'_, AppState>) -> Result { + let cli = state.cli.lock().map_err(|e| e.to_string())?.clone(); + Ok(cli.fetch_quota().await) + } + + /// The rail only knows its own provider count and hover state; the geometry that turns + /// those into a window lives in `dock`. + #[tauri::command] + pub fn dock_set_layout(app: AppHandle, rows: u32, expanded: bool) { + if let Some(window) = app.get_webview_window(crate::dock::DOCK_LABEL) { + crate::dock::apply_layout(&window, rows, expanded); + } + } } diff --git a/windows/src/Dock.tsx b/windows/src/Dock.tsx new file mode 100644 index 00000000..ed7e16d5 --- /dev/null +++ b/windows/src/Dock.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import './dock.css' + +const REFRESH_MS = 5 * 60 * 1000 +const HOVER_CLOSE_MS = 180 + +type QuotaWindow = { + label: string + usedPct: number + resetsAt?: string +} + +type Provider = { + id: string + name: string + available: boolean + plan?: string + windows: QuotaWindow[] + error?: string +} + +type DockQuota = + | { state: 'ready'; providers: Provider[] } + | { state: 'cliOutdated' } + | { state: 'unavailable'; message: string } + +const RING_SIZE = 32 +const RING_STROKE = 3 +const RADIUS = (RING_SIZE - RING_STROKE) / 2 +const CIRCUMFERENCE = 2 * Math.PI * RADIUS + +function headline(provider: Provider): QuotaWindow | null { + if (provider.windows.length === 0) return null + return provider.windows.reduce((worst, row) => (row.usedPct > worst.usedPct ? row : worst)) +} + +// Whole percentage points, matching the mac dock's rounded metrics. +function pct(value: number): number { + return Math.min(100, Math.max(0, Math.round(value))) +} + +function resetsIn(iso?: string): string { + if (!iso) return '' + const seconds = (new Date(iso).getTime() - Date.now()) / 1000 + if (Number.isNaN(seconds)) return '' + if (seconds < 60) return 'now' + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + if (days > 0) return `${days}d ${hours % 24}h` + if (hours > 0) return `${hours}h ${minutes % 60}m` + return `${minutes}m` +} + +function Ring({ provider }: { provider: Provider }) { + const top = headline(provider) + const used = top ? pct(top.usedPct) : 0 + return ( +
+ + + + + = 90 ? ' is-spent' : ''}`}>{top ? `${used}%` : '--'} +
+ ) +} + +function notice(quota: DockQuota | null, providers: Provider[]): string | null { + if (quota?.state === 'cliOutdated') return 'CLI update needed for live quota. Run npm install -g codeburn.' + if (quota?.state === 'unavailable') return 'Quota is unavailable right now.' + if (quota?.state === 'ready' && providers.length === 0) return 'No signed in providers to show.' + return null +} + +function Detail({ providers, message }: { providers: Provider[]; message: string | null }) { + return ( +
+ {message ?

{message}

: null} + {providers.map((provider) => ( +
+
+ {provider.name} + {provider.plan ? {provider.plan} : null} +
+ {provider.windows.map((row) => { + const used = pct(row.usedPct) + const resets = resetsIn(row.resetsAt) + return ( +
+
+ {row.label} + {used}% +
+
+
+
+ {resets ? Resets in {resets} : null} +
+ ) + })} +
+ ))} +
+ ) +} + +export function Dock() { + const [quota, setQuota] = useState(null) + const [expanded, setExpanded] = useState(false) + const closeTimer = useRef(undefined) + + const load = useCallback(async () => { + try { + setQuota(await invoke('dock_quota')) + } catch (err) { + setQuota({ state: 'unavailable', message: String(err) }) + } + }, []) + + useEffect(() => { + void load() + const timer = window.setInterval(() => void load(), REFRESH_MS) + return () => window.clearInterval(timer) + }, [load]) + + const providers = + quota?.state === 'ready' ? quota.providers.filter((provider) => provider.available) : [] + const rows = Math.max(providers.length, 1) + + useEffect(() => { + void invoke('dock_set_layout', { rows, expanded }) + }, [rows, expanded]) + + useEffect(() => () => window.clearTimeout(closeTimer.current), []) + + const message = notice(quota, providers) + + const open = () => { + window.clearTimeout(closeTimer.current) + if (providers.length > 0 || message) setExpanded(true) + } + + // A short grace period keeps the panel from flickering shut as the pointer crosses the + // seam between the rail and the panel. + const close = () => { + window.clearTimeout(closeTimer.current) + closeTimer.current = window.setTimeout(() => setExpanded(false), HOVER_CLOSE_MS) + } + + return ( +
+ {expanded ? : null} +
void load()}> + {providers.map((provider) => ( + + ))} + {providers.length === 0 ? ( +
+ {quota?.state === 'cliOutdated' ? 'CLI' : '--'} +
+ ) : null} +
+
+ ) +} diff --git a/windows/src/dock.css b/windows/src/dock.css new file mode 100644 index 00000000..664af7eb --- /dev/null +++ b/windows/src/dock.css @@ -0,0 +1,170 @@ +/* Capacity Dock rail. Metrics are the macOS dock's whole-point values at its default 0.6 + scale (mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift); the window that holds + them is sized in src-tauri/src/dock.rs and the two must stay in step. */ + +:root { + --dock-rail-w: 56px; + --dock-row-h: 50px; + --dock-row-gap: 7px; + --dock-pad: 12px; + --dock-detail-w: 300px; + --dock-radius: 10px; + + /* claude, codex and copilot are tokens.json color.categorical; the remaining three keep + the ring colours the macOS Capacity Dock already gives them. */ + --dock-claude: #C9521D; + --dock-codex: #5C7CA3; + --dock-copilot: #B8944C; + --dock-gemini: #478CFA; + --dock-kimi: #E6F014; + --dock-antigravity: #FF7A45; +} + +.dock { + display: flex; + justify-content: flex-end; + height: 100%; + overflow: hidden; + font-family: var(--font-sans); +} + +.dock-rail { + flex: 0 0 var(--dock-rail-w); + display: flex; + flex-direction: column; + gap: var(--dock-row-gap); + padding: var(--dock-pad) 0; + background: var(--surface); + border-radius: var(--dock-radius) 0 0 var(--dock-radius); + border: 1px solid var(--separator); + border-right: none; + box-shadow: var(--menu-shadow); +} + +.dock-row { + height: var(--dock-row-h); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; +} + +.dock-ring { + transform: rotate(-90deg); +} + +.dock-ring-track { + fill: none; + stroke: var(--fill-12); +} + +.dock-ring-arc { + fill: none; + stroke: var(--dock-accent, var(--label-2)); + stroke-linecap: round; + transition: stroke-dashoffset 240ms var(--ease-ui); +} + +.dock-pct { + font-size: 10px; + font-variant-numeric: tabular-nums; + color: var(--label-2); +} + +.dock-pct.is-spent { + color: var(--danger); +} + +.dock-quiet { + font-size: 10px; + color: var(--label-3); +} + +.dock-detail { + flex: 0 0 var(--dock-detail-w); + overflow-y: auto; + padding: var(--dock-pad); + display: flex; + flex-direction: column; + gap: 10px; + background: var(--surface); + border-radius: var(--dock-radius) 0 0 var(--dock-radius); + border: 1px solid var(--separator); + border-right: none; + box-shadow: var(--menu-shadow); +} + +.dock-notice { + margin: 0; + font-size: 11px; + color: var(--label-2); +} + +.dock-detail-card { + display: flex; + flex-direction: column; + gap: 6px; +} + +.dock-detail-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.dock-detail-name { + font-size: 12px; + font-weight: 600; + color: var(--label); +} + +.dock-detail-plan { + font-size: 10px; + color: var(--label-2); +} + +.dock-window { + display: flex; + flex-direction: column; + gap: 3px; +} + +.dock-window-top { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 11px; + color: var(--label-2); +} + +.dock-window-pct { + font-variant-numeric: tabular-nums; + color: var(--label); +} + +.dock-track { + height: 4px; + border-radius: var(--radius-bar); + background: var(--fill-12); + overflow: hidden; +} + +.dock-fill { + height: 100%; + border-radius: var(--radius-bar); + background: var(--dock-accent, var(--label-2)); +} + +.dock-window-resets { + font-size: 10px; + color: var(--label-3); +} + +[data-provider='claude'] { --dock-accent: var(--dock-claude); } +[data-provider='codex'] { --dock-accent: var(--dock-codex); } +[data-provider='copilot'] { --dock-accent: var(--dock-copilot); } +[data-provider='gemini'] { --dock-accent: var(--dock-gemini); } +[data-provider='kimi'] { --dock-accent: var(--dock-kimi); } +[data-provider='antigravity'] { --dock-accent: var(--dock-antigravity); } diff --git a/windows/src/main.tsx b/windows/src/main.tsx index 5dd018a7..93fb4782 100644 --- a/windows/src/main.tsx +++ b/windows/src/main.tsx @@ -1,10 +1,13 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import { getCurrentWindow } from '@tauri-apps/api/window' import { App } from './App' +import { Dock } from './Dock' import './styles.css' +// Both windows load the one bundle, so the label decides which surface mounts. +const isDock = getCurrentWindow().label === 'dock' + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - + {isDock ? : } )