diff --git a/AGENTS.md b/AGENTS.md index cdae508890..6ef98b581e 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ - **Settings**: `%config%/CodexBar/settings.json` via `Settings::load` / `save` and `secure_file` (DPAPI-capable on Windows). Frontend `updateSettings` patch → save → `codexbar:settings-updated` / float-bar config events. - **Tray**: `tray_bridge` + `tray_menu`. Icon pixels from shared `codexbar::tray::{render_bar_icon_rgba, render_percent_icon_rgba}`. - **Float bar**: `floatbar/` owns the auxiliary always-on-top window. The builder must pin `.theme(Some(tauri::Theme::Dark))` — WebView2 resolves `prefers-color-scheme` on a shared process profile; an unpinned window flips other webviews under theme `auto`. -- **Proof harness**: env `CODEXBAR_PROOF_MODE` (e.g. `settings:menu`) opens a target surface and suppresses blur-dismiss for automation / CUA capture. +- **Proof harness**: env `CODEXBAR_PROOF_MODE` (e.g. `settings:menu`) opens a target surface and suppresses blur-dismiss for automation / CUA capture. `CODEXBAR_SEED_USAGE_JSON=` seeds one synthetic bridge-shaped Codex `ProviderUsageSnapshot` into the provider cache at launch (pinned against refresh eviction; malformed files are warned about and skipped). ## Key Directories diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 664c6d9f22..270e792e2a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -2,22 +2,39 @@ use super::*; // ── Bridge snapshot types ──────────────────────────────────────────── -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RateWindowSnapshot { pub used_percent: f64, + /// Defaults to `100.0` when absent in JSON (e.g. proof-seed files). + #[serde(default = "default_full_remaining")] pub remaining_percent: f64, + #[serde(default)] pub window_minutes: Option, + #[serde(default)] pub resets_at: Option, + #[serde(default)] pub reset_description: Option, + #[serde(default)] pub is_exhausted: bool, + #[serde(default)] pub is_informational: bool, + #[serde(default)] pub reserve_percent: Option, + #[serde(default)] pub reserve_description: Option, + #[serde(default)] pub reserve_will_last_to_reset: bool, + #[serde(default)] pub reserve_eta_seconds: Option, } +/// Serde default for [`RateWindowSnapshot::remaining_percent`] — the common +/// case for a fresh window (0 %% used → 100 %% remaining). +fn default_full_remaining() -> f64 { + 100.0 +} + impl RateWindowSnapshot { pub(super) fn from_rate_window(rw: &RateWindow) -> Self { Self { @@ -50,22 +67,41 @@ impl RateWindowSnapshot { } } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CostSnapshotBridge { pub used: f64, + #[serde(default)] pub limit: Option, + #[serde(default)] pub remaining: Option, + #[serde(default = "default_currency")] pub currency_code: String, + #[serde(default = "default_cost_period")] pub period: String, + #[serde(default)] pub resets_at: Option, + /// Defaults to `format!("${:.2}", used)` when absent (filled by + /// [`parse_seed_usage_snapshot`](crate::proof_harness::parse_seed_usage_snapshot)). + #[serde(default)] pub formatted_used: String, + #[serde(default)] pub formatted_limit: Option, + #[serde(default)] pub balance: Option, + #[serde(default)] pub formatted_balance: Option, } -#[derive(Debug, Clone, Serialize)] +fn default_currency() -> String { + "USD".to_string() +} + +fn default_cost_period() -> String { + "month".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NamedRateWindowSnapshot { pub id: String, @@ -74,19 +110,23 @@ pub struct NamedRateWindowSnapshot { } /// Pace prediction snapshot for tray/bridge display. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PaceSnapshot { - pub stage: &'static str, + pub stage: String, pub delta_percent: f64, + #[serde(default)] pub will_last_to_reset: bool, + #[serde(default)] pub eta_seconds: Option, + #[serde(default)] pub expected_used_percent: f64, + #[serde(default)] pub actual_used_percent: f64, } /// Session-equivalent weekly forecast for Claude/Codex menu secondary line. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionEquivalentForecastSnapshot { pub estimated_windows_to_exhaust_weekly: f64, @@ -98,34 +138,62 @@ pub struct SessionEquivalentForecastSnapshot { } /// A frontend-friendly snapshot of one provider's usage data. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProviderUsageSnapshot { + #[serde(default)] pub tertiary_label: Option, pub provider_id: String, + #[serde(default = "default_display_name")] pub display_name: String, pub primary: RateWindowSnapshot, + #[serde(default)] pub primary_label: Option, + #[serde(default)] pub secondary: Option, + #[serde(default)] pub secondary_label: Option, + #[serde(default)] pub model_specific: Option, + #[serde(default)] pub tertiary: Option, + #[serde(default)] pub extra_rate_windows: Vec, + #[serde(default)] pub cost: Option, + #[serde(default)] pub plan_name: Option, + #[serde(default)] pub account_email: Option, + #[serde(default = "default_source_label")] pub source_label: String, + /// Defaults to launch time when absent so the card renders as fresh. + #[serde(default)] pub updated_at: String, + #[serde(default)] pub error: Option, + #[serde(default)] pub pace: Option, + #[serde(default)] pub account_organization: Option, + #[serde(default)] pub tray_status_label: Option, + #[serde(default)] pub fetch_duration_ms: Option, + #[serde(default)] pub wayfinder_usage: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "Option::is_none", default)] pub session_equivalent_forecast: Option, } +fn default_display_name() -> String { + "Codex".to_string() +} + +fn default_source_label() -> String { + "seed".to_string() +} + /// Provider payload after applying settings-driven cross-surface presentation. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -190,7 +258,7 @@ impl ProviderUsageSnapshot { .and_then(|window| codexbar::core::UsagePace::weekly(window, None, 10080)); let pace = primary_pace.as_ref().map(|p| PaceSnapshot { - stage: pace_stage_str(p.stage), + stage: pace_stage_str(p.stage).to_string(), delta_percent: p.delta_percent, will_last_to_reset: p.will_last_to_reset, eta_seconds: p.eta_seconds, diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index fce7da696c..7cbf1acd1a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -292,6 +292,12 @@ fn begin_provider_refresh( } fn provider_cache_can_skip_refresh(guard: &AppState, force: bool) -> bool { + // Proof-harness seed: pin the synthetic snapshot for the whole run so a + // periodic auto-refresh cannot overwrite seeded capture conditions. + if !force && crate::proof_harness::seed_usage_json_active() && !guard.provider_cache.is_empty() + { + return true; + } !force && !guard.provider_cache.is_empty() && is_provider_cache_fresh(guard.provider_cache_updated_at, PROVIDER_CACHE_STALE_AFTER) diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 222c65b833..fa6a2924c0 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -124,6 +124,18 @@ fn main() { let mut initial_state = AppState::new(); initial_state.proof_config = proof_config; + // Proof-harness seed: CODEXBAR_SEED_USAGE_JSON plants one synthetic Codex + // ProviderUsageSnapshot before the event loop and any WebView read. The + // cache timestamp makes the seeded cache count as fresh so the first + // frontend refresh-if-stale call does not evict the synthetic data. + if let Some(snapshot) = proof_harness::seed_usage_snapshot_from_env() { + tracing::info!( + "proof-harness: seeded provider snapshot for '{}'", + snapshot.provider_id + ); + initial_state.provider_cache.push(snapshot); + initial_state.provider_cache_updated_at = Some(std::time::Instant::now()); + } tauri::Builder::default() .manage(Mutex::new(initial_state)) diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index 39d8e1168f..00b81a09f2 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -15,12 +15,19 @@ //! In proof mode the shell immediately transitions to the requested surface //! and suppresses blur-dismiss so the window stays visible for automated //! screenshot capture. +//! +//! `CODEXBAR_SEED_USAGE_JSON=` additionally seeds one synthetic, +//! bridge-shaped Codex [`ProviderUsageSnapshot`] into the provider cache at +//! launch (before the first event/WebView read) and pins it against refresh +//! eviction for the run. Malformed files log a warning and the shell +//! continues without seeding — proof runs must never crash on the seed. use std::sync::Mutex; use serde::Serialize; use tauri::{AppHandle, Manager}; +use crate::commands::{CostSnapshotBridge, ProviderUsageSnapshot, RateWindowSnapshot}; use crate::shell; use crate::state::AppState; use crate::surface::SurfaceMode; @@ -242,6 +249,98 @@ pub fn is_proof_mode(app: &AppHandle) -> bool { .unwrap_or(false) } +// ── Provider-usage seed (CODEXBAR_SEED_USAGE_JSON) ─────────────────── + +/// Environment variable pointing at a JSON file with one synthetic, +/// bridge-shaped `ProviderUsageSnapshot` for the codex provider. +pub const SEED_USAGE_ENV_VAR: &str = "CODEXBAR_SEED_USAGE_JSON"; + +/// Whether a seed path was configured at launch. While set, the provider +/// cache is pinned fresh so the synthetic snapshot is never evicted by an +/// automatic refresh during a proof/capture run. +pub fn seed_usage_json_active() -> bool { + std::env::var_os(SEED_USAGE_ENV_VAR).is_some() +} + +/// Read and validate the seed file referenced by `CODEXBAR_SEED_USAGE_JSON`. +/// +/// Returns `None` (with a warn, never a crash) when the variable is unset, +/// the file is unreadable, the JSON is malformed, or the snapshot is not +/// for the `codex` provider. +pub fn seed_usage_snapshot_from_env() -> Option { + let path = std::env::var_os(SEED_USAGE_ENV_VAR)?; + let path = std::path::PathBuf::from(path); + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(err) => { + tracing::warn!( + "{SEED_USAGE_ENV_VAR}: cannot read {}: {err}", + path.display() + ); + return None; + } + }; + match parse_seed_usage_snapshot(&raw) { + Ok(snapshot) => Some(snapshot), + Err(msg) => { + tracing::warn!("{SEED_USAGE_ENV_VAR}: {msg} in {}", path.display()); + None + } + } +} + +/// Parse a seed-usage JSON string directly into a canonical +/// [`ProviderUsageSnapshot`]. +/// +/// The canonical bridge types carry `#[serde(default)]` on optional/derived +/// fields so the seed JSON can omit them. This function fills in the +/// computed defaults that serde cannot express from sibling fields +/// (`remainingPercent` from `usedPercent`, `formattedUsed` from `used`, +/// `updatedAt` from the current time) and validates that the snapshot is +/// for the `codex` provider. +/// +/// Pure: no env vars, no files, no global state. +pub fn parse_seed_usage_snapshot(json: &str) -> Result { + let mut snapshot: ProviderUsageSnapshot = + serde_json::from_str(json).map_err(|e| format!("malformed JSON: {e}"))?; + + if snapshot.provider_id != "codex" { + return Err(format!( + "snapshot providerId '{}' is not 'codex', ignoring", + snapshot.provider_id + )); + } + + normalize_rate_window(&mut snapshot.primary); + snapshot.secondary.as_mut().map(normalize_rate_window); + snapshot.model_specific.as_mut().map(normalize_rate_window); + snapshot.tertiary.as_mut().map(normalize_rate_window); + for extra in &mut snapshot.extra_rate_windows { + normalize_rate_window(&mut extra.window); + } + snapshot.cost.as_mut().map(normalize_cost); + + if snapshot.updated_at.is_empty() { + snapshot.updated_at = chrono::Utc::now().to_rfc3339(); + } + + Ok(snapshot) +} + +/// Recompute `remaining_percent` from `used_percent` (matching the canonical +/// [`RateWindowSnapshot::from_rate_window`] behaviour) so the seed JSON can +/// omit it. +fn normalize_rate_window(w: &mut RateWindowSnapshot) { + w.remaining_percent = 100.0 - w.used_percent.clamp(0.0, 100.0); +} + +/// Fill `formatted_used` from `used` when the seed JSON omits it. +fn normalize_cost(c: &mut CostSnapshotBridge) { + if c.formatted_used.is_empty() { + c.formatted_used = format!("${:.2}", c.used); + } +} + fn proof_payload_is_supported(surface_mode: SurfaceMode, payload: Option<&str>) -> bool { match (surface_mode, payload) { (SurfaceMode::Hidden | SurfaceMode::TrayPanel, None) => true, @@ -402,4 +501,86 @@ mod tests { assert_eq!(cfg.surface_target(), SurfaceTarget::Dashboard); }); } + + #[test] + fn parse_seed_snapshot_decodes_codex_usage() { + let json = serde_json::json!({ + "providerId": "codex", + "displayName": "Codex", + "primary": { + "usedPercent": 61.0, + "windowMinutes": 300, + "resetsAt": "2099-01-02T03:04:05Z", + "resetDescription": "seeded 5h", + }, + "primaryLabel": "Session", + "secondary": { "usedPercent": 74.0, "windowMinutes": 10080 }, + "secondaryLabel": "Weekly", + "extraRateWindows": [ + { + "id": "reset-credits", + "title": "Reset Credits", + "window": { + "usedPercent": 0.0, + "resetsAt": "2099-01-09T00:00:00Z", + "resetDescription": "2 reset credits available", + "isInformational": true, + }, + }, + ], + }) + .to_string(); + + let snapshot = parse_seed_usage_snapshot(&json).expect("seed parses into a snapshot"); + assert_eq!(snapshot.provider_id, "codex"); + assert_eq!(snapshot.primary.used_percent, 61.0); + assert_eq!( + snapshot.primary.resets_at.as_deref(), + Some("2099-01-02T03:04:05Z") + ); + assert_eq!(snapshot.primary.remaining_percent, 39.0); + assert_eq!(snapshot.primary_label.as_deref(), Some("Session")); + assert_eq!( + snapshot.secondary.as_ref().map(|s| s.used_percent), + Some(74.0) + ); + assert_eq!( + snapshot.secondary.as_ref().map(|s| s.remaining_percent), + Some(26.0) + ); + assert_eq!(snapshot.extra_rate_windows.len(), 1); + assert_eq!(snapshot.extra_rate_windows[0].id, "reset-credits"); + assert!(snapshot.extra_rate_windows[0].window.is_informational); + // Defaults applied: displayName, sourceLabel, updatedAt + assert_eq!(snapshot.display_name, "Codex"); + assert_eq!(snapshot.source_label, "seed"); + assert!(!snapshot.updated_at.is_empty()); + } + + #[test] + fn parse_seed_snapshot_rejects_non_codex_provider() { + let json = r#"{"providerId": "claude", "primary": {"usedPercent": 10.0}}"#; + assert!(parse_seed_usage_snapshot(json).is_err()); + } + + #[test] + fn parse_seed_snapshot_rejects_malformed_json() { + assert!(parse_seed_usage_snapshot("{not json}").is_err()); + } + + #[test] + fn parse_seed_snapshot_fills_cost_defaults() { + let json = r#"{ + "providerId": "codex", + "primary": {"usedPercent": 50.0}, + "cost": {"used": 12.5, "limit": 100.0} + }"#; + let snapshot = parse_seed_usage_snapshot(json).unwrap(); + let cost = snapshot.cost.expect("cost present"); + assert_eq!(cost.used, 12.5); + assert_eq!(cost.limit, Some(100.0)); + assert_eq!(cost.currency_code, "USD"); + assert_eq!(cost.period, "month"); + assert_eq!(cost.formatted_used, "$12.50"); + } } diff --git a/docs/WINDOWS_PROOF.md b/docs/WINDOWS_PROOF.md index 8d680144f9..ec7f8d055d 100644 --- a/docs/WINDOWS_PROOF.md +++ b/docs/WINDOWS_PROOF.md @@ -30,6 +30,13 @@ cargo test cargo test -p codexbar-desktop-tauri cargo clippy -p codexbar-desktop-tauri --all-targets -- -D warnings +# Proof/CUA captures: set `CODEXBAR_PROOF_MODE` (e.g. `trayPanel`) to open a +# surface with blur-dismiss suppressed; optionally set +# `CODEXBAR_SEED_USAGE_JSON=` to plant one synthetic bridge-shaped +# Codex ProviderUsageSnapshot into the provider cache at launch (malformed +# files are warned about and ignored) — the seeded cache is pinned against +# refresh eviction for the duration of the run. + # Frontend unit tests + bundle cd apps\desktop-tauri pnpm install --frozen-lockfile