From 7cddcacdfce5e4fa084639a00b9722488fa58b98 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:41:31 +0700 Subject: [PATCH 1/2] Port upstream 0.67.0: credential expiry alerts --- .../src-tauri/src/commands/bridge.rs | 3 + .../src/commands/credential_alerts.rs | 124 +++++ .../src-tauri/src/commands/mod.rs | 1 + .../src-tauri/src/commands/providers.rs | 23 +- .../src-tauri/src/commands/settings.rs | 4 + apps/desktop-tauri/src/App.test.tsx | 1 + .../src/floatbar/FloatBar.test.tsx | 1 + apps/desktop-tauri/src/i18n/keys.ts | 4 + .../src/surfaces/PopOutPanel.test.tsx | 1 + .../src/surfaces/TrayPanel.test.tsx | 1 + .../surfaces/settings/tabs/AboutTab.test.tsx | 1 + .../settings/tabs/AdvancedTab.test.tsx | 1 + .../settings/tabs/GeneralTab.test.tsx | 23 + .../src/surfaces/settings/tabs/GeneralTab.tsx | 12 + apps/desktop-tauri/src/types/bridge.test.ts | 1 + apps/desktop-tauri/src/types/bridge.ts | 2 + rust/src/core/provider_state.rs | 19 + rust/src/locale.rs | 4 + rust/src/locale/en-US.ftl | 4 + rust/src/notifications.rs | 27 +- rust/src/notifications/credential.rs | 432 ++++++++++++++++++ rust/src/settings.rs | 5 + rust/src/settings/raw.rs | 3 + rust/src/settings/tests.rs | 2 + 24 files changed, 693 insertions(+), 6 deletions(-) create mode 100644 apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs create mode 100644 rust/src/notifications/credential.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index ab5efbef8a6..9c56a92ebac 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -659,6 +659,7 @@ pub struct SettingsSnapshot { provider_usage_thresholds: std::collections::HashMap, predictive_pace_warning_enabled: bool, + credential_expiry_notifications_enabled: bool, show_pace: bool, tray_icon_mode: &'static str, switcher_shows_icons: bool, @@ -781,6 +782,8 @@ impl From for SettingsSnapshot { critical_usage_threshold: settings.critical_usage_threshold, provider_usage_thresholds: settings.provider_usage_thresholds, predictive_pace_warning_enabled: settings.predictive_pace_warning_enabled, + credential_expiry_notifications_enabled: settings + .credential_expiry_notifications_enabled, show_pace: settings.show_pace, tray_icon_mode: tray_icon_mode_label(settings.tray_icon_mode), switcher_shows_icons: settings.switcher_shows_icons, diff --git a/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs b/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs new file mode 100644 index 00000000000..5675b96ef75 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs @@ -0,0 +1,124 @@ +//! Bridges provider refresh outcomes to the core credential-expiry episode +//! tracker (`codexbar::notifications`). Classification stays typed: the +//! failure kind is captured from the fresh fetch before any last-good +//! fallback replaces the published snapshot, so cached data can neither end +//! nor restart an episode. + +use codexbar::core::{ProviderId, ProviderStateKind}; +use codexbar::notifications::{CredentialAlertPolicy, NotificationManager}; + +use super::bridge::ProviderUsageSnapshot; +use super::providers::quota_notification_account_identity; + +/// Result of the fresh fetch, before last-good preservation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum FetchAttempt { + Succeeded, + Failed(ProviderStateKind), +} + +impl FetchAttempt { + pub(super) fn of(fresh: &ProviderUsageSnapshot) -> Self { + if fresh.error.is_none() { + Self::Succeeded + } else { + Self::Failed(fresh.error_state) + } + } +} + +/// Report one accepted refresh outcome. `published` is the snapshot that +/// replaced the cache; `last_good` is the previous error-free snapshot, the +/// only source of account identity when the fetch itself failed. +pub(super) fn observe_attempt( + manager: &mut NotificationManager, + policy: CredentialAlertPolicy, + id: ProviderId, + token_account_id: Option, + attempt: FetchAttempt, + published: &ProviderUsageSnapshot, + last_good: Option<&ProviderUsageSnapshot>, +) { + match attempt { + FetchAttempt::Succeeded => { + let account = quota_notification_account_identity(published, token_account_id); + manager.observe_credential_recovery(id, &account); + } + FetchAttempt::Failed(kind) => { + let account = quota_notification_account_identity( + last_good.unwrap_or(published), + token_account_id, + ); + manager.observe_credential_failure(policy, id, &account, kind); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codexbar::core::{ProviderError, instantiate_provider}; + use codexbar::settings::Settings; + + fn failed(kind: ProviderStateKind) -> ProviderUsageSnapshot { + ProviderUsageSnapshot::from_error( + ProviderId::Codex, + instantiate_provider(ProviderId::Codex).metadata(), + "Authentication required".to_string(), + kind, + ) + } + + fn succeeded() -> ProviderUsageSnapshot { + let mut snapshot = failed(ProviderStateKind::Ready); + snapshot.error = None; + snapshot.account_email = Some("person@example.test".to_string()); + snapshot + } + + #[test] + fn attempt_reads_the_fresh_error_state() { + assert_eq!( + FetchAttempt::of(&failed(ProviderStateKind::ExpiredSession)), + FetchAttempt::Failed(ProviderStateKind::ExpiredSession) + ); + assert_eq!(FetchAttempt::of(&succeeded()), FetchAttempt::Succeeded); + } + + #[test] + fn typed_state_matches_provider_error_classification() { + let kind = ProviderError::AuthRequired.state_kind(); + assert_eq!( + FetchAttempt::of(&failed(kind)), + FetchAttempt::Failed(ProviderStateKind::NeedsAuthentication) + ); + } + + #[test] + fn observing_outcomes_with_alerts_off_never_delivers() { + // Default settings leave credential alerts off, so no toast can fire. + let policy = CredentialAlertPolicy::from_settings(&Settings::default()); + let mut manager = NotificationManager::new(); + let good = succeeded(); + let failure = failed(ProviderStateKind::NeedsAuthentication); + + observe_attempt( + &mut manager, + policy, + ProviderId::Codex, + None, + FetchAttempt::of(&failure), + &failure, + Some(&good), + ); + observe_attempt( + &mut manager, + policy, + ProviderId::Codex, + None, + FetchAttempt::Succeeded, + &good, + None, + ); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index fdfaeda9674..dec5b8ec247 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -36,6 +36,7 @@ mod claude_accounts; pub(crate) mod claude_reconciliation; mod codex_accounts; mod codex_workspaces; +mod credential_alerts; mod credential_detection; mod credentials; mod diagnostics; diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 103d47b8696..e377081cd12 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1,3 +1,4 @@ +use super::credential_alerts::{self, FetchAttempt}; use super::provider_refresh::{ ProviderRefreshCompletion, ProviderRefreshReservation, complete_provider_refresh, reserve_provider_refresh, @@ -6,6 +7,7 @@ use super::warning_identity::WarningIdentity; use super::*; use chrono::{Local, Utc}; use codexbar::core::HookUsageWindow; +use codexbar::notifications::CredentialAlertPolicy; use serde::Serialize; use std::sync::Arc; @@ -519,6 +521,7 @@ fn spawn_provider_refreshes( .and_then(ProviderAccountData::active_account) .map(|account| account.id); let hooks_enabled = inputs.settings.hooks_enabled; + let credential_alerts = CredentialAlertPolicy::from_settings(&inputs.settings); handles.push(tokio::spawn(async move { let Ok(_permit) = fetch_permits.acquire_owned().await else { @@ -531,6 +534,7 @@ fn spawn_provider_refreshes( generation, token_account_id, hooks_enabled, + credential_alerts, ) .await; })); @@ -564,10 +568,13 @@ async fn refresh_provider( generation: u64, token_account_id: Option, hooks_enabled: bool, + credential_alerts: CredentialAlertPolicy, ) { let (snapshot, account_identity, failure_policy) = fetch_provider_snapshot(id, ctx, token_account_id).await; let fresh_snapshot = snapshot.error.is_none(); + // Captured before last-good preservation can swap in a cached snapshot. + let fetch_attempt = FetchAttempt::of(&snapshot); let state = app.state::>(); let published = if let Ok(mut guard) = state.lock() { @@ -601,6 +608,15 @@ async fn refresh_provider( .provider_cache_updated_at_by_provider .insert(id, std::time::Instant::now()); } + credential_alerts::observe_attempt( + &mut guard.notification_manager, + credential_alerts, + id, + token_account_id, + fetch_attempt, + &snapshot, + cached.as_ref(), + ); Some(snapshot) } } else { @@ -950,6 +966,11 @@ fn update_tray_and_notifications( crate::tray_bridge::update_tray_status_items(app, &cached); crate::tray_bridge::update_tray_icon_and_tooltip(app, &cached); notify_usage_thresholds(state, settings, token_accounts, &cached); + if let Ok(mut guard) = state.lock() { + guard + .notification_manager + .retire_credential_episodes_except(&settings.get_enabled_provider_ids()); + } Ok(()) } @@ -1053,7 +1074,7 @@ fn dispatch_quota_hooks( /// Stable account discriminator for threshold/session toast dedupe. /// Prefer token-account id, then email, org, plan; empty for single-account lanes. -fn quota_notification_account_identity( +pub(super) fn quota_notification_account_identity( snapshot: &ProviderUsageSnapshot, token_account_id: Option, ) -> String { diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index df0e6621a9e..ca18cfcfa6d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -24,6 +24,7 @@ pub struct SettingsUpdate { pub provider_usage_thresholds: Option>, pub predictive_pace_warning_enabled: Option, + pub credential_expiry_notifications_enabled: Option, pub show_pace: Option, pub tray_icon_mode: Option, pub switcher_shows_icons: Option, @@ -298,6 +299,9 @@ impl SettingsUpdate { if let Some(v) = self.predictive_pace_warning_enabled { settings.predictive_pace_warning_enabled = v; } + if let Some(v) = self.credential_expiry_notifications_enabled { + settings.credential_expiry_notifications_enabled = v; + } if let Some(v) = self.show_pace { settings.show_pace = v; } diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index 42d55a7c276..4059e9a056b 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -84,6 +84,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: false, diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 88860e087d6..d5a26087106 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -149,6 +149,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: false, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 195620e3bff..360ae9da0ab 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -58,6 +58,10 @@ export const ALL_LOCALE_KEYS = [ "PredictivePaceWarningsHelper", "PredictivePaceWarningTitle", "PredictivePaceWarningBody", + "CredentialExpiryNotifications", + "CredentialExpiryNotificationsHelper", + "CredentialExpiryTitle", + "CredentialExpiryBody", "UsageDisplay", "ShowUsageAsUsed", "ShowUsageAsUsedHelper", diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index 0e7f3053a9c..b24605805e6 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -154,6 +154,7 @@ function settings(): SettingsSnapshot { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: false, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 8e156a34bf1..a5985a3820e 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -141,6 +141,7 @@ function settings(overrides: Partial = {}): SettingsSnapshot { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: false, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx index 1a818ed97f2..29e1bf7f5b5 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AboutTab.test.tsx @@ -60,6 +60,7 @@ const settings: SettingsSnapshot = { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: true, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx index 8d1d7f21489..7ba050d5649 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.test.tsx @@ -29,6 +29,7 @@ const settings: SettingsSnapshot = { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: true, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index 33a78a00e95..0b849644dfb 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -51,6 +51,7 @@ const settings: SettingsSnapshot = { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: true, @@ -157,6 +158,28 @@ describe("GeneralTab language picker", () => { expect(set).toHaveBeenCalledWith({ predictivePaceWarningEnabled: true }); }); + it("updates the credential expiry alert preference", () => { + const set = vi.fn(); + render( + , + ); + + const toggle = screen.getByRole("checkbox", { + name: "CredentialExpiryNotifications", + }); + expect(toggle).not.toBeChecked(); + fireEvent.click(toggle); + + expect(set).toHaveBeenCalledWith({ + credentialExpiryNotificationsEnabled: true, + }); + }); + it("updates the low power mode preference", () => { const set = vi.fn(); render(); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index f2e700a5e3b..99d40e5ab8d 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -338,6 +338,18 @@ export default function GeneralTab({ onChange={(v) => set({ predictivePaceWarningEnabled: v })} /> + + set({ credentialExpiryNotificationsEnabled: v })} + /> + { highUsageThreshold: 70, criticalUsageThreshold: 90, predictivePaceWarningEnabled: false, + credentialExpiryNotificationsEnabled: false, trayIconMode: "single", switcherShowsIcons: true, menuBarShowsHighestUsage: true, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 88754baa7de..d75458c4db1 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -188,6 +188,7 @@ export interface SettingsSnapshot { criticalUsageThreshold: number; providerUsageThresholds?: Record; predictivePaceWarningEnabled: boolean; + credentialExpiryNotificationsEnabled: boolean; showPace?: boolean; trayIconMode: TrayIconMode; switcherShowsIcons: boolean; @@ -297,6 +298,7 @@ export interface SettingsUpdate { criticalUsageThreshold?: number; providerUsageThresholds?: Record; predictivePaceWarningEnabled?: boolean; + credentialExpiryNotificationsEnabled?: boolean; showPace?: boolean; trayIconMode?: TrayIconMode; switcherShowsIcons?: boolean; diff --git a/rust/src/core/provider_state.rs b/rust/src/core/provider_state.rs index a4087e1ca59..ec2eff1ab23 100644 --- a/rust/src/core/provider_state.rs +++ b/rust/src/core/provider_state.rs @@ -44,6 +44,16 @@ impl ProviderStateKind { pub const fn is_problem(self) -> bool { !matches!(self, ProviderStateKind::Ready) } + + /// Whether this state means the account must sign in again. The only + /// states that qualify for credential-expiry alerts; quota, permission, + /// rate-limit and transport failures map elsewhere and never do. + pub const fn needs_sign_in(self) -> bool { + matches!( + self, + ProviderStateKind::NeedsAuthentication | ProviderStateKind::ExpiredSession + ) + } } impl ProviderError { @@ -137,6 +147,15 @@ mod tests { } } + #[test] + fn only_authentication_states_need_sign_in() { + assert!(ProviderStateKind::NeedsAuthentication.needs_sign_in()); + assert!(ProviderStateKind::ExpiredSession.needs_sign_in()); + assert!(!ProviderStateKind::Ready.needs_sign_in()); + assert!(!ProviderStateKind::LocalRuntimeOffline.needs_sign_in()); + assert!(!ProviderStateKind::Unknown.needs_sign_in()); + } + #[test] fn serializes_as_camel_case_for_the_bridge() { assert_eq!( diff --git a/rust/src/locale.rs b/rust/src/locale.rs index e6ec918ac42..d7cd2868b81 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -244,6 +244,10 @@ locale_keys! { PredictivePaceWarningsHelper, PredictivePaceWarningTitle, PredictivePaceWarningBody, + CredentialExpiryNotifications, + CredentialExpiryNotificationsHelper, + CredentialExpiryTitle, + CredentialExpiryBody, // Display settings (Preferences) UsageDisplay, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index fb96ecd4b8b..034974a2e31 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -51,6 +51,10 @@ PredictivePaceWarnings = Predictive Pace Warnings PredictivePaceWarningsHelper = Alert when Codex or Claude usage is likely to run out before reset PredictivePaceWarningTitle = { "{}" } { "{}" } pace warning PredictivePaceWarningBody = Quota may run out in { "{}" } +CredentialExpiryNotifications = Credential Expiry Alerts +CredentialExpiryNotificationsHelper = Alert once when a provider account needs to sign in again +CredentialExpiryTitle = { "{}" } needs sign-in +CredentialExpiryBody = Open CodexBar to review the account error and sign in again. UsageDisplay = Usage Display ShowUsageAsUsed = Show Usage as Used ShowUsageAsUsedHelper = Display as used percentage instead of remaining diff --git a/rust/src/notifications.rs b/rust/src/notifications.rs index 94a109bf402..bfde706afb0 100755 --- a/rust/src/notifications.rs +++ b/rust/src/notifications.rs @@ -14,6 +14,11 @@ use crate::settings::Settings; use crate::sound::{NotificationSoundEvent, play_alert}; use chrono::{DateTime, Utc}; +mod credential; + +pub use credential::CredentialAlertPolicy; +use credential::CredentialEpisodes; + /// Notification types #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum NotificationType { @@ -132,6 +137,8 @@ pub struct NotificationManager { previous_session_percent: std::collections::HashMap, predictive_warning_keys: std::collections::HashSet, deepseek_pricing_period: Option, + /// Open credential-expiry episodes (in memory; reset on restart). + credential_episodes: CredentialEpisodes, } impl NotificationManager { @@ -141,6 +148,7 @@ impl NotificationManager { previous_session_percent: std::collections::HashMap::new(), predictive_warning_keys: std::collections::HashSet::new(), deepseek_pricing_period: None, + credential_episodes: CredentialEpisodes::default(), } } @@ -514,8 +522,10 @@ impl NotificationManager { Self::play_notification_sound(NotificationSoundEvent::StatusIssue, settings); } + /// Returns whether the toast was handed to the OS (a spawn failure is the + /// only failure the fire-and-forget PowerShell dispatch can observe). #[cfg(target_os = "windows")] - fn show_toast(&self, title: &str, body: &str) { + fn show_toast(&self, title: &str, body: &str) -> bool { use std::os::windows::process::CommandExt; use std::process::Command; use std::sync::Once; @@ -573,13 +583,19 @@ impl NotificationManager { .creation_flags(0x08000000) // CREATE_NO_WINDOW .spawn() { - Ok(_) => tracing::debug!("Toast notification dispatched: {}", title), - Err(e) => tracing::warn!("Failed to dispatch toast notification '{}': {}", title, e), + Ok(_) => { + tracing::debug!("Toast notification dispatched: {}", title); + true + } + Err(e) => { + tracing::warn!("Failed to dispatch toast notification '{}': {}", title, e); + false + } } } #[cfg(not(target_os = "windows"))] - fn show_toast(&self, title: &str, body: &str) { + fn show_toast(&self, title: &str, body: &str) -> bool { use std::process::Command; // Try notify-send first (works on most Linux distros including WSL with WSLg) @@ -594,10 +610,11 @@ impl NotificationManager { && output.status.success() { tracing::debug!("Sent notification via notify-send: {}", title); - return; + return true; } tracing::info!("Notification: {} - {}", title, body); + false } } diff --git a/rust/src/notifications/credential.rs b/rust/src/notifications/credential.rs new file mode 100644 index 00000000000..d0566f80692 --- /dev/null +++ b/rust/src/notifications/credential.rs @@ -0,0 +1,432 @@ +//! Opt-in credential-expiry alerts (upstream 0.67.0 `handleCredentialOutcome`). +//! +//! One toast per (provider, account scope) failure episode. Only a typed +//! sign-in state ([`ProviderStateKind::needs_sign_in`]) starts an episode; +//! only a fresh successful fetch ends it. Cached or last-good fallbacks, +//! repeated failures and unrelated failures (network, timeout, parse, quota) +//! leave it untouched, so a flapping provider never re-alerts. Episodes live +//! in memory and start fresh when the app restarts. +//! +//! The toast names the provider only; it never carries the raw error, email +//! or account id. + +use std::collections::HashSet; + +use super::NotificationManager; +use crate::core::{ProviderId, ProviderStateKind}; +use crate::locale::{self, LocaleKey}; +use crate::settings::{Language, Settings}; + +/// Delivery settings captured at refresh time, so a refresh task does not +/// need the whole [`Settings`] value. +#[derive(Debug, Clone, Copy)] +pub struct CredentialAlertPolicy { + enabled: bool, + language: Language, +} + +impl CredentialAlertPolicy { + /// The master notification switch also silences credential alerts, like + /// every other toast. + pub fn from_settings(settings: &Settings) -> Self { + Self { + enabled: settings.show_notifications + && settings.credential_expiry_notifications_enabled, + language: settings.ui_language, + } + } +} + +/// Open episodes keyed by provider and account scope. An empty scope means +/// no account evidence was available, so it acts as a wildcard for that +/// provider: an identity gap must neither hide a real recovery nor re-alert +/// on every refresh. +#[derive(Debug, Default)] +pub(super) struct CredentialEpisodes { + open: HashSet<(ProviderId, String)>, +} + +impl CredentialEpisodes { + fn covers(&self, provider: ProviderId, account: &str) -> bool { + if account.is_empty() { + return self.open.iter().any(|(open, _)| *open == provider); + } + self.open.contains(&(provider, account.to_string())) + || self.open.contains(&(provider, String::new())) + } + + /// Reserve an episode; `false` when one already covers this account. + fn begin(&mut self, provider: ProviderId, account: &str) -> bool { + if self.covers(provider, account) { + return false; + } + self.open.insert((provider, account.to_string())); + true + } + + fn release(&mut self, provider: ProviderId, account: &str) { + self.open.remove(&(provider, account.to_string())); + } + + fn end(&mut self, provider: ProviderId, account: &str) { + if account.is_empty() { + self.open.retain(|(open, _)| *open != provider); + } else { + self.open.remove(&(provider, account.to_string())); + self.open.remove(&(provider, String::new())); + } + } +} + +impl NotificationManager { + /// Record a failed refresh for `account`. Posts one toast when `kind` is a + /// sign-in state, alerts are enabled and no episode is open yet. Returns + /// whether a toast was delivered. + pub fn observe_credential_failure( + &mut self, + policy: CredentialAlertPolicy, + provider: ProviderId, + account: &str, + kind: ProviderStateKind, + ) -> bool { + self.observe_credential_failure_with(policy, provider, account, kind, Self::show_toast) + } + + fn observe_credential_failure_with( + &mut self, + policy: CredentialAlertPolicy, + provider: ProviderId, + account: &str, + kind: ProviderStateKind, + deliver: impl FnOnce(&Self, &str, &str) -> bool, + ) -> bool { + if !policy.enabled || !kind.needs_sign_in() { + return false; + } + if !self.credential_episodes.begin(provider, account) { + return false; + } + let title = locale::format_locale( + policy.language, + LocaleKey::CredentialExpiryTitle, + &[provider.display_name()], + ); + let body = locale::get_text(policy.language, LocaleKey::CredentialExpiryBody); + let delivered = deliver(self, &title, &body); + if !delivered { + // A failed toast must not swallow the alert: the next refresh retries. + self.credential_episodes.release(provider, account); + } + delivered + } + + /// Record a fresh successful fetch (no error, not a last-good replay). + /// Ends the episode even while alerts are disabled, so re-enabling them + /// never replays a stale failure. + pub fn observe_credential_recovery(&mut self, provider: ProviderId, account: &str) { + self.credential_episodes.end(provider, account); + } + + /// Forget episodes of providers that are no longer enabled. + pub fn retire_credential_episodes_except(&mut self, enabled: &[ProviderId]) { + self.credential_episodes + .open + .retain(|(provider, _)| enabled.contains(provider)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::ProviderError; + use std::cell::RefCell; + + const ACCOUNT: &str = "person@example.test"; + + fn policy(enabled: bool) -> CredentialAlertPolicy { + CredentialAlertPolicy { + enabled, + language: Language::English, + } + } + + /// Records delivered toasts; `succeed` scripts the delivery result. + struct Toasts { + sent: RefCell>, + } + + impl Toasts { + fn new() -> Self { + Self { + sent: RefCell::new(Vec::new()), + } + } + + fn fail( + &self, + manager: &mut NotificationManager, + provider: ProviderId, + account: &str, + kind: ProviderStateKind, + succeed: bool, + ) -> bool { + manager.observe_credential_failure_with( + policy(true), + provider, + account, + kind, + |_, title, body| { + self.sent + .borrow_mut() + .push((title.to_string(), body.to_string())); + succeed + }, + ) + } + + fn count(&self) -> usize { + self.sent.borrow().len() + } + } + + fn network_error() -> ProviderError { + ProviderError::Network( + reqwest::Client::new() + .get("file:///private.example.test/unreachable") + .build() + .unwrap_err(), + ) + } + + #[test] + fn each_provider_error_variant_alerts_only_for_sign_in_states() { + use ProviderError as E; + let cases = [ + (E::NotInstalled("key missing".into()), true), + (E::AuthRequired, true), + (E::NoCookies, true), + (E::OAuth("credentials not found".into()), true), + (E::OAuthRevoked("revoked".into()), true), + (E::OAuthExpired("expired".into()), true), + (E::OAuthTransient("try again".into()), false), + (E::Parse("bad body".into()), false), + (network_error(), false), + (E::Timeout, false), + (E::UnsupportedSource(crate::core::SourceMode::Auto), false), + (E::Other("API error 500".into()), false), + (E::Other("quota exhausted".into()), false), + (E::Other("403 permission denied".into()), false), + (E::Other("429 rate limited".into()), false), + ]; + for (error, expect_alert) in cases { + let mut manager = NotificationManager::new(); + let toasts = Toasts::new(); + let alerted = toasts.fail( + &mut manager, + ProviderId::Claude, + ACCOUNT, + error.state_kind(), + true, + ); + assert_eq!(alerted, expect_alert, "{error}"); + assert_eq!(toasts.count(), usize::from(expect_alert), "{error}"); + } + } + + #[test] + fn provider_specific_offline_state_does_not_alert() { + let mut manager = NotificationManager::new(); + let toasts = Toasts::new(); + assert!(!toasts.fail( + &mut manager, + ProviderId::Antigravity, + "", + ProviderStateKind::LocalRuntimeOffline, + true, + )); + assert_eq!(toasts.count(), 0); + } + + #[test] + fn toast_names_the_provider_and_nothing_else() { + let mut manager = NotificationManager::new(); + let toasts = Toasts::new(); + assert!(toasts.fail( + &mut manager, + ProviderId::Claude, + ACCOUNT, + ProviderStateKind::ExpiredSession, + true, + )); + let sent = toasts.sent.borrow(); + assert_eq!(sent[0].0, "Claude needs sign-in"); + assert_eq!( + sent[0].1, + "Open CodexBar to review the account error and sign in again." + ); + assert!(!sent[0].0.contains(ACCOUNT) && !sent[0].1.contains(ACCOUNT)); + } + + #[test] + fn episode_ends_only_on_fresh_success() { + // fail, fail, timeout, cached success (no recovery call), fresh + // success, fail => two toasts in total. + let mut manager = NotificationManager::new(); + let toasts = Toasts::new(); + let auth = ProviderStateKind::NeedsAuthentication; + let p = ProviderId::Codex; + + assert!(toasts.fail(&mut manager, p, ACCOUNT, auth, true)); + assert!(!toasts.fail(&mut manager, p, ACCOUNT, auth, true)); + assert!(!toasts.fail(&mut manager, p, ACCOUNT, ProviderStateKind::Unknown, true)); + // A cached / last-good replay is never reported as a recovery, so the + // next sign-in failure is still the same episode. + assert!(!toasts.fail(&mut manager, p, ACCOUNT, auth, true)); + + manager.observe_credential_recovery(p, ACCOUNT); + assert!(toasts.fail(&mut manager, p, ACCOUNT, auth, true)); + assert_eq!(toasts.count(), 2); + } + + #[test] + fn failed_toast_releases_the_reservation_for_retry() { + let mut manager = NotificationManager::new(); + let toasts = Toasts::new(); + let auth = ProviderStateKind::NeedsAuthentication; + + assert!(!toasts.fail(&mut manager, ProviderId::Codex, ACCOUNT, auth, false)); + assert!(toasts.fail(&mut manager, ProviderId::Codex, ACCOUNT, auth, true)); + assert!(!toasts.fail(&mut manager, ProviderId::Codex, ACCOUNT, auth, true)); + assert_eq!(toasts.count(), 2); + } + + #[test] + fn accounts_and_providers_have_independent_episodes() { + let mut manager = NotificationManager::new(); + let toasts = Toasts::new(); + let auth = ProviderStateKind::NeedsAuthentication; + + assert!(toasts.fail( + &mut manager, + ProviderId::Codex, + "a@example.test", + auth, + true + )); + assert!(toasts.fail( + &mut manager, + ProviderId::Codex, + "b@example.test", + auth, + true + )); + assert!(toasts.fail( + &mut manager, + ProviderId::Claude, + "a@example.test", + auth, + true + )); + + manager.observe_credential_recovery(ProviderId::Codex, "a@example.test"); + assert!(toasts.fail( + &mut manager, + ProviderId::Codex, + "a@example.test", + auth, + true + )); + assert!(!toasts.fail( + &mut manager, + ProviderId::Codex, + "b@example.test", + auth, + true + )); + } + + #[test] + fn identity_gap_neither_realerts_nor_hides_recovery() { + let mut manager = NotificationManager::new(); + let toasts = Toasts::new(); + let auth = ProviderStateKind::NeedsAuthentication; + let p = ProviderId::Codex; + + // No identity yet, then the same account surfaces without evidence. + assert!(toasts.fail(&mut manager, p, "", auth, true)); + assert!(!toasts.fail(&mut manager, p, ACCOUNT, auth, true)); + // Failing with a known identity is covered by that episode too. + manager.observe_credential_recovery(p, ACCOUNT); + assert!(toasts.fail(&mut manager, p, "", auth, true)); + // A success with no identity ends every episode of the provider. + manager.observe_credential_recovery(p, ""); + assert!(toasts.fail(&mut manager, p, ACCOUNT, auth, true)); + assert_eq!(toasts.count(), 3); + } + + #[test] + fn disabled_alerts_deliver_nothing_but_keep_open_episodes() { + let mut manager = NotificationManager::new(); + let toasts = Toasts::new(); + let auth = ProviderStateKind::NeedsAuthentication; + let p = ProviderId::Codex; + + assert!(toasts.fail(&mut manager, p, ACCOUNT, auth, true)); + + let disabled = manager.observe_credential_failure_with( + policy(false), + p, + "other@example.test", + auth, + |_, _, _| panic!("no delivery while disabled"), + ); + assert!(!disabled); + + // Off then on: the unresolved episode is remembered, so no replay... + assert!(!toasts.fail(&mut manager, p, ACCOUNT, auth, true)); + // ...and the account that failed while disabled alerts once enabled. + assert!(toasts.fail(&mut manager, p, "other@example.test", auth, true)); + assert_eq!(toasts.count(), 2); + } + + #[test] + fn recovery_while_disabled_still_ends_the_episode() { + let mut manager = NotificationManager::new(); + let toasts = Toasts::new(); + let auth = ProviderStateKind::ExpiredSession; + + assert!(toasts.fail(&mut manager, ProviderId::Codex, ACCOUNT, auth, true)); + manager.observe_credential_recovery(ProviderId::Codex, ACCOUNT); + assert!(toasts.fail(&mut manager, ProviderId::Codex, ACCOUNT, auth, true)); + } + + #[test] + fn disabled_providers_retire_their_episodes() { + let mut manager = NotificationManager::new(); + let toasts = Toasts::new(); + let auth = ProviderStateKind::NeedsAuthentication; + + assert!(toasts.fail(&mut manager, ProviderId::Codex, ACCOUNT, auth, true)); + assert!(toasts.fail(&mut manager, ProviderId::Claude, ACCOUNT, auth, true)); + + manager.retire_credential_episodes_except(&[ProviderId::Claude]); + + assert!(toasts.fail(&mut manager, ProviderId::Codex, ACCOUNT, auth, true)); + assert!(!toasts.fail(&mut manager, ProviderId::Claude, ACCOUNT, auth, true)); + } + + #[test] + fn policy_requires_master_switch_and_credential_toggle() { + let mut settings = Settings { + show_notifications: true, + credential_expiry_notifications_enabled: true, + ..Settings::default() + }; + assert!(CredentialAlertPolicy::from_settings(&settings).enabled); + settings.show_notifications = false; + assert!(!CredentialAlertPolicy::from_settings(&settings).enabled); + settings.show_notifications = true; + settings.credential_expiry_notifications_enabled = false; + assert!(!CredentialAlertPolicy::from_settings(&settings).enabled); + } +} diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 9863f194ceb..da636db21fa 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -165,6 +165,10 @@ pub struct Settings { #[serde(default)] pub predictive_pace_warning_enabled: bool, + /// Notify once per failure episode when a provider account needs sign-in again. + #[serde(default)] + pub credential_expiry_notifications_enabled: bool, + /// Show pace visualizations and forecast text in provider menu cards. #[serde(default = "default_true")] pub show_pace: bool, @@ -549,6 +553,7 @@ impl Default for Settings { reset_time_relative: true, // Show relative times by default show_reset_when_exhausted: false, predictive_pace_warning_enabled: false, + credential_expiry_notifications_enabled: false, show_pace: true, menu_bar_display_mode: "detailed".to_string(), // Detailed mode by default overview_layout: default_overview_layout(), diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 850c90e9602..8826eb336bd 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -44,6 +44,7 @@ pub(super) struct RawSettings { reset_time_relative: bool, show_reset_when_exhausted: bool, predictive_pace_warning_enabled: bool, + credential_expiry_notifications_enabled: bool, #[serde(default = "default_true")] show_pace: bool, menu_bar_display_mode: String, @@ -214,6 +215,7 @@ impl Default for RawSettings { reset_time_relative: s.reset_time_relative, show_reset_when_exhausted: s.show_reset_when_exhausted, predictive_pace_warning_enabled: s.predictive_pace_warning_enabled, + credential_expiry_notifications_enabled: s.credential_expiry_notifications_enabled, show_pace: s.show_pace, menu_bar_display_mode: s.menu_bar_display_mode, overview_layout: s.overview_layout, @@ -541,6 +543,7 @@ impl From for Settings { reset_time_relative: raw.reset_time_relative, show_reset_when_exhausted: raw.show_reset_when_exhausted, predictive_pace_warning_enabled: raw.predictive_pace_warning_enabled, + credential_expiry_notifications_enabled: raw.credential_expiry_notifications_enabled, show_pace: raw.show_pace, menu_bar_display_mode: raw.menu_bar_display_mode, overview_layout: normalize_overview_layout(&raw.overview_layout), diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index a6db4aa05b2..d56ea02783f 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -19,6 +19,7 @@ fn test_settings_default() { assert_eq!(settings.critical_usage_threshold, 90.0); assert!(!settings.show_reset_when_exhausted); assert!(!settings.predictive_pace_warning_enabled); + assert!(!settings.credential_expiry_notifications_enabled); assert!(!settings.float_bar_show_cost); assert!(!settings.tray_panel_always_on_top); assert_eq!(settings.overview_layout, "compact"); @@ -183,6 +184,7 @@ fn new_warning_and_reset_settings_are_backward_compatible() { assert!(!loaded.show_reset_when_exhausted); assert!(!loaded.predictive_pace_warning_enabled); + assert!(!loaded.credential_expiry_notifications_enabled); } #[test] From 5d03b7fbbc26d85ba9566a87f531dac4be63bb85 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:05:25 +0700 Subject: [PATCH 2/2] Address thermo review --- .../src-tauri/src/commands/codex_accounts.rs | 135 ++++++++++++++---- .../src/commands/credential_alerts.rs | 18 +-- .../src-tauri/src/commands/providers.rs | 42 ++++-- rust/src/codex_accounts/api.rs | 56 +++++++- rust/src/codex_accounts/credentials.rs | 8 +- rust/src/locale/es-MX.ftl | 4 + rust/src/locale/ja-JP.ftl | 4 + rust/src/locale/ko-KR.ftl | 4 + rust/src/locale/ru-RU.ftl | 4 + rust/src/locale/tests.rs | 12 ++ rust/src/locale/tr-TR.ftl | 4 + rust/src/locale/zh-CN.ftl | 4 + rust/src/locale/zh-TW.ftl | 4 + rust/src/notifications/credential.rs | 12 ++ 14 files changed, 256 insertions(+), 55 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs index eede8b8a3ba..848901c28d5 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs @@ -5,12 +5,16 @@ use uuid::Uuid; use codexbar::codex_accounts::{ AccountStore, CodexAccount, CodexAccountApi, CodexAccountManager, CodexAccountManagerError, - CodexAccountRuntime, CodexApiError, CodexSwitchResult, SnapshotStore, display_names_by_id, - ordinals_by_id, restart_codex_desktop, + CodexAccountRuntime, CodexAccountSource, CodexApiError, CodexSwitchResult, SnapshotStore, + display_names_by_id, ordinals_by_id, restart_codex_desktop, }; +use codexbar::notifications::CredentialAlertPolicy; +use codexbar::settings::Settings; use crate::state::AppState; +use super::credential_alerts::FetchAttempt; +use super::providers::quota_notification_account_identity_for; use super::*; // ── Codex multi-account (ADR 0003, milestone 2) ────────────────────── @@ -115,10 +119,10 @@ pub(crate) async fn refresh_codex_account_lanes( return; } - let mut handles = Vec::with_capacity(accounts.len()); + let mut handles = tokio::task::JoinSet::new(); for account in accounts { let permits = Arc::clone(&fetch_permits); - handles.push(tokio::spawn(async move { + handles.spawn(async move { let Ok(_permit) = permits.acquire_owned().await else { return None; }; @@ -126,7 +130,7 @@ pub(crate) async fn refresh_codex_account_lanes( let home_path = account.codex_home_path.clone(); let email_hint = account.email_hint.clone(); let workspace_account_id = account.effective_workspace_account_id(); - match tokio::time::timeout( + let result = tokio::time::timeout( std::time::Duration::from_secs(DEFAULT_FETCH_TIMEOUT_SECONDS), api.fetch_snapshot_for_workspace( &home_path, @@ -135,36 +139,73 @@ pub(crate) async fn refresh_codex_account_lanes( true, ), ) - .await - { - Ok(Ok(snapshot)) => Some((account, snapshot)), - Ok(Err(e)) => { - tracing::debug!( - "codex account lane {} failed: {}", - account.id, - into_api_message(e) - ); - None + .await; + match result { + Ok(result) => { + if let Err(error) = &result { + tracing::debug!("codex account lane {} failed: {}", account.id, error); + } + Some((account, result)) } Err(_) => { tracing::debug!("codex account lane {} timed out", account.id); None } } - })); + }); } - let mut updates = Vec::new(); - for handle in handles { - if let Ok(Some((fetched_account, snapshot))) = handle.await { - updates.push((fetched_account, snapshot)); + let mut outcomes = Vec::new(); + while let Some(result) = handles.join_next().await { + if let Ok(Some(outcome)) = result { + outcomes.push(outcome); } } + + let current_accounts = match load_codex_accounts() { + Ok(accounts) => accounts, + Err(e) => { + tracing::warn!("codex account lanes: failed to reload accounts: {e}"); + return; + } + }; // Hold the generation owner through the read/merge/write so an invalidated // batch cannot overwrite a replacement batch's account snapshots. let state = app.state::>(); - let Ok(state) = state.lock() else { return }; - match save_codex_lane_results(&state, generation, updates) { + let Ok(mut state) = state.lock() else { return }; + if !is_current_provider_refresh_generation(&state, generation) { + return; + } + let current_by_id: HashMap = current_accounts + .iter() + .map(|account| (account.id, account)) + .collect(); + let mut updates = Vec::new(); + for (fetched_account, result) in outcomes { + let Some(current) = current_by_id.get(&fetched_account.id).copied() else { + continue; + }; + if !account_lane_is_current(&fetched_account, current) { + continue; + } + match result { + Ok(snapshot) if account_snapshot_belongs_to(current, &snapshot) => { + observe_codex_account_lane_outcome( + &mut state.notification_manager, + &fetched_account, + FetchAttempt::Succeeded, + ); + updates.push((fetched_account, snapshot)); + } + Ok(_) => {} + Err(error) => observe_codex_account_lane_outcome( + &mut state.notification_manager, + &fetched_account, + FetchAttempt::Failed(error.state_kind()), + ), + } + } + match save_codex_lane_results(&state, generation, updates, ¤t_accounts) { Ok(false) => return, Err(e) => tracing::warn!("codex account lanes: failed to persist snapshots: {e}"), Ok(true) => {} @@ -172,20 +213,48 @@ pub(crate) async fn refresh_codex_account_lanes( events::emit_codex_accounts_updated(&app); } +fn observe_codex_account_lane_outcome( + manager: &mut codexbar::notifications::NotificationManager, + account: &CodexAccount, + attempt: FetchAttempt, +) { + if account.source != CodexAccountSource::ManagedByApp { + return; + } + + let account_scope = quota_notification_account_identity_for( + ProviderId::Codex, + "", + account.email_hint.as_deref(), + None, + Some(account.id), + ); + match attempt { + FetchAttempt::Succeeded => { + manager.observe_credential_recovery(ProviderId::Codex, &account_scope); + } + FetchAttempt::Failed(kind) if kind.needs_sign_in() => { + let policy = CredentialAlertPolicy::from_settings(&Settings::load()); + manager.observe_credential_failure(policy, ProviderId::Codex, &account_scope, kind); + } + FetchAttempt::Failed(_) => {} + } +} + fn save_codex_lane_results( state: &AppState, generation: u64, updates: Vec<(CodexAccount, codexbar::codex_accounts::AccountUsageSnapshot)>, + current_accounts: &[CodexAccount], ) -> Result { if !is_current_provider_refresh_generation(state, generation) { return Ok(false); } - let current_accounts = load_codex_accounts().map_err(std::io::Error::other)?; let current_by_id: HashMap = current_accounts .iter() .map(|account| (account.id, account)) .collect(); - let mut snapshots = snapshots_for_accounts(¤t_accounts, SnapshotStore::new().load()?); + let mut snapshots = snapshots_for_accounts(current_accounts, SnapshotStore::new().load()?); for (fetched_account, snapshot) in updates { if current_by_id .get(&fetched_account.id) @@ -511,7 +580,10 @@ fn into_user_message(error: CodexAccountManagerError) -> String { fn into_api_message(error: CodexApiError) -> String { match error { - CodexApiError::Message(msg) => msg, + CodexApiError::AuthenticationRequired(msg) + | CodexApiError::SessionExpired(msg) + | CodexApiError::PermissionDenied(msg) + | CodexApiError::Message(msg) => msg, CodexApiError::Network(e) => format!("network error: {e}"), CodexApiError::Parse(e) => format!("failed to parse Codex payload: {e}"), } @@ -650,7 +722,8 @@ mod tests { save_codex_lane_results( &state, state.provider_refresh_generation, - vec![(account.clone(), snapshot.clone())] + vec![(account.clone(), snapshot.clone())], + &[account.clone()] ) .unwrap() ); @@ -659,7 +732,15 @@ mod tests { email: Some("old@example.com".into()), ..snapshot }; - assert!(!save_codex_lane_results(&state, old_generation, vec![(account, stale)]).unwrap()); + assert!( + !save_codex_lane_results( + &state, + old_generation, + vec![(account.clone(), stale)], + &[account] + ) + .unwrap() + ); assert_eq!( std::fs::read(file_locations::snapshots_file()).unwrap(), before diff --git a/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs b/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs index 5675b96ef75..bf8637f615f 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs @@ -32,7 +32,7 @@ impl FetchAttempt { /// only source of account identity when the fetch itself failed. pub(super) fn observe_attempt( manager: &mut NotificationManager, - policy: CredentialAlertPolicy, + policy: Option, id: ProviderId, token_account_id: Option, attempt: FetchAttempt, @@ -45,11 +45,13 @@ pub(super) fn observe_attempt( manager.observe_credential_recovery(id, &account); } FetchAttempt::Failed(kind) => { - let account = quota_notification_account_identity( - last_good.unwrap_or(published), - token_account_id, - ); - manager.observe_credential_failure(policy, id, &account, kind); + if let Some(policy) = policy { + let account = quota_notification_account_identity( + last_good.unwrap_or(published), + token_account_id, + ); + manager.observe_credential_failure(policy, id, &account, kind); + } } } } @@ -104,7 +106,7 @@ mod tests { observe_attempt( &mut manager, - policy, + Some(policy), ProviderId::Codex, None, FetchAttempt::of(&failure), @@ -113,7 +115,7 @@ mod tests { ); observe_attempt( &mut manager, - policy, + Some(policy), ProviderId::Codex, None, FetchAttempt::Succeeded, diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index e377081cd12..c3ae3410c7c 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -7,7 +7,6 @@ use super::warning_identity::WarningIdentity; use super::*; use chrono::{Local, Utc}; use codexbar::core::HookUsageWindow; -use codexbar::notifications::CredentialAlertPolicy; use serde::Serialize; use std::sync::Arc; @@ -388,6 +387,11 @@ async fn do_refresh_providers_with_policy( let settings = Settings::load(); let enabled_ids = settings.get_enabled_provider_ids(); let refresh_ids = scope.provider_ids(&settings, &enabled_ids); + if let Ok(mut guard) = state.lock() { + guard + .notification_manager + .retire_credential_episodes_except(&enabled_ids); + } if refresh_ids.is_empty() { return Ok(ProviderRefreshOutcome::Skipped { reason: ProviderRefreshSkipReason::NoEnabledProviders, @@ -521,7 +525,6 @@ fn spawn_provider_refreshes( .and_then(ProviderAccountData::active_account) .map(|account| account.id); let hooks_enabled = inputs.settings.hooks_enabled; - let credential_alerts = CredentialAlertPolicy::from_settings(&inputs.settings); handles.push(tokio::spawn(async move { let Ok(_permit) = fetch_permits.acquire_owned().await else { @@ -534,7 +537,6 @@ fn spawn_provider_refreshes( generation, token_account_id, hooks_enabled, - credential_alerts, ) .await; })); @@ -568,7 +570,6 @@ async fn refresh_provider( generation: u64, token_account_id: Option, hooks_enabled: bool, - credential_alerts: CredentialAlertPolicy, ) { let (snapshot, account_identity, failure_policy) = fetch_provider_snapshot(id, ctx, token_account_id).await; @@ -608,6 +609,14 @@ async fn refresh_provider( .provider_cache_updated_at_by_provider .insert(id, std::time::Instant::now()); } + // Read consent after the fetch completes, so a toggle change made + // while the request was in flight takes effect for this outcome. + let credential_alerts = match fetch_attempt { + FetchAttempt::Failed(kind) if kind.needs_sign_in() => Some( + codexbar::notifications::CredentialAlertPolicy::from_settings(&Settings::load()), + ), + _ => None, + }; credential_alerts::observe_attempt( &mut guard.notification_manager, credential_alerts, @@ -966,11 +975,6 @@ fn update_tray_and_notifications( crate::tray_bridge::update_tray_status_items(app, &cached); crate::tray_bridge::update_tray_icon_and_tooltip(app, &cached); notify_usage_thresholds(state, settings, token_accounts, &cached); - if let Ok(mut guard) = state.lock() { - guard - .notification_manager - .retire_credential_episodes_except(&settings.get_enabled_provider_ids()); - } Ok(()) } @@ -1080,18 +1084,34 @@ pub(super) fn quota_notification_account_identity( ) -> String { ProviderId::from_cli_name(&snapshot.provider_id) .map(|provider| { - WarningIdentity::new( + quota_notification_account_identity_for( provider, &snapshot.source_label, snapshot.account_email.as_deref(), snapshot.account_organization.as_deref(), token_account_id, ) - .threshold_key() }) .unwrap_or_default() } +pub(super) fn quota_notification_account_identity_for( + provider: ProviderId, + source_label: &str, + account_email: Option<&str>, + account_organization: Option<&str>, + token_account_id: Option, +) -> String { + WarningIdentity::new( + provider, + source_label, + account_email, + account_organization, + token_account_id, + ) + .threshold_key() +} + fn notify_predictive_pace( manager: &mut codexbar::notifications::NotificationManager, provider: ProviderId, diff --git a/rust/src/codex_accounts/api.rs b/rust/src/codex_accounts/api.rs index ca5ae772709..f19835cffa7 100644 --- a/rust/src/codex_accounts/api.rs +++ b/rust/src/codex_accounts/api.rs @@ -20,7 +20,7 @@ use super::models::{ AccountUsageSnapshot, CodexExtraUsageCost, CreditsBalanceSnapshot, UsageWindowSnapshot, WindowRole, }; -use crate::core::credentialed_http_client_builder; +use crate::core::{ProviderStateKind, credentialed_http_client_builder}; use crate::providers::openai::OpenAISubscriptionFetchResult; #[path = "subscription.rs"] @@ -35,6 +35,12 @@ const UNAUTHORIZED_MESSAGE: &str = "The Codex usage API request returned unautho /// Friendly error surfaced to callers. #[derive(Debug, Error)] pub enum CodexApiError { + #[error("{0}")] + AuthenticationRequired(String), + #[error("{0}")] + SessionExpired(String), + #[error("{0}")] + PermissionDenied(String), #[error("{0}")] Message(String), #[error("network error: {0}")] @@ -43,6 +49,19 @@ pub enum CodexApiError { Parse(String), } +impl CodexApiError { + /// Classify account API failures before their display text is flattened. + pub fn state_kind(&self) -> ProviderStateKind { + match self { + Self::AuthenticationRequired(_) => ProviderStateKind::NeedsAuthentication, + Self::SessionExpired(_) => ProviderStateKind::ExpiredSession, + Self::PermissionDenied(_) | Self::Message(_) | Self::Network(_) | Self::Parse(_) => { + ProviderStateKind::Unknown + } + } + } +} + // ── Quota fetching ────────────────────────────────────────────────────────── /// Client for live quota reads. Stateless per call; refresh decisions happen in @@ -121,7 +140,7 @@ impl CodexAccountApi { verify_live_data, ) .await; - if !matches!(&result, Err(CodexApiError::Message(msg)) if msg == UNAUTHORIZED_MESSAGE) + if !matches!(&result, Err(CodexApiError::SessionExpired(_))) || credentials.refresh_token.is_empty() { return result; @@ -333,10 +352,15 @@ impl CodexAccountApi { .map_err(|e| CodexApiError::Network(e.to_string()))?; if !response.status().is_success() { let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED - || status == reqwest::StatusCode::FORBIDDEN - { - return Err(CodexApiError::Message(UNAUTHORIZED_MESSAGE.to_string())); + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(CodexApiError::SessionExpired( + UNAUTHORIZED_MESSAGE.to_string(), + )); + } + if status == reqwest::StatusCode::FORBIDDEN { + return Err(CodexApiError::PermissionDenied( + "The Codex usage API request was forbidden.".to_string(), + )); } let body = response.text().await.unwrap_or_default().trim().to_string(); let msg = if body.is_empty() { @@ -671,6 +695,26 @@ mod tests { use super::*; use base64::Engine; + #[test] + fn credential_error_states_exclude_permission_and_transport_failures() { + assert_eq!( + CodexApiError::AuthenticationRequired("missing credentials".to_string()).state_kind(), + ProviderStateKind::NeedsAuthentication + ); + assert_eq!( + CodexApiError::SessionExpired("unauthorized".to_string()).state_kind(), + ProviderStateKind::ExpiredSession + ); + assert_eq!( + CodexApiError::PermissionDenied("forbidden".to_string()).state_kind(), + ProviderStateKind::Unknown + ); + assert_eq!( + CodexApiError::Network("offline".to_string()).state_kind(), + ProviderStateKind::Unknown + ); + } + #[tokio::test] async fn active_fetches_use_and_sync_ambient_credentials_even_when_usage_fails() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; diff --git a/rust/src/codex_accounts/credentials.rs b/rust/src/codex_accounts/credentials.rs index 2ef98286c74..f8496fabfc5 100644 --- a/rust/src/codex_accounts/credentials.rs +++ b/rust/src/codex_accounts/credentials.rs @@ -45,7 +45,9 @@ pub fn load_credentials(codex_home_path: &Path) -> Result Result Result = LocaleKey::ALL.iter().map(|(_, name)| *name).collect(); + let credential_expiry_keys = [ + "CredentialExpiryNotifications", + "CredentialExpiryNotificationsHelper", + "CredentialExpiryTitle", + "CredentialExpiryBody", + ]; for (locale, keys) in &resource_keys { for name in keys { @@ -405,6 +411,12 @@ fn test_english_is_complete_and_other_languages_can_fallback() { "unknown Fluent key {name} in {locale}" ); } + for name in credential_expiry_keys { + assert!( + keys.contains(name), + "missing credential-expiry localization {name} in {locale}" + ); + } } for (key, name) in LocaleKey::ALL { diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 08de23e2e33..92c2b780932 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -51,6 +51,10 @@ PredictivePaceWarnings = Tahmini Kullanım Hızı Uyarıları PredictivePaceWarningsHelper = Codex veya Claude kullanımının sıfırlanmadan önce tükenmesi bekleniyorsa uyar PredictivePaceWarningTitle = { "{}" } { "{}" } kullanım hızı uyarısı PredictivePaceWarningBody = Kota { "{}" } içinde tükenebilir +CredentialExpiryNotifications = Kimlik bilgisi süresi dolma uyarıları +CredentialExpiryNotificationsHelper = Bir sağlayıcı hesabında yeniden oturum açmak gerektiğinde bir kez uyar +CredentialExpiryTitle = { "{}" } için yeniden oturum açmak gerekiyor +CredentialExpiryBody = Hesap hatasını incelemek ve yeniden oturum açmak için CodexBar'ı açın. UsageDisplay = Kullanım Gösterimi ShowUsageAsUsed = Kullanımı Harcanan Olarak Göster ShowUsageAsUsedHelper = Kalan yerine kullanılan yüzdeyi göster diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index daf8e3c9afa..7e5a2bb386e 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -650,6 +650,10 @@ PredictivePaceWarnings = 预测配额告警 PredictivePaceWarningsHelper = 当 Codex 或 Claude 的用量可能在重置前耗尽时提醒 PredictivePaceWarningTitle = { "{}" } { "{}" } 配额告警 PredictivePaceWarningBody = 配额可能在 { "{}" } 内耗尽 +CredentialExpiryNotifications = 凭据过期提醒 +CredentialExpiryNotificationsHelper = 提供商账户需要重新登录时提醒一次 +CredentialExpiryTitle = { "{}" }需要重新登录 +CredentialExpiryBody = 打开 CodexBar 查看账户错误并重新登录。 ShowResetWhenExhausted = 耗尽时显示重置时间 ShowResetWhenExhaustedHelper = 用量百分比耗尽时,用实时重置倒计时替换显示 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 31bb3bcf024..6928ae35289 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -650,6 +650,10 @@ PredictivePaceWarnings = 預測配額告警 PredictivePaceWarningsHelper = 當 Codex 或 Claude 的用量可能在重置前耗盡時提醒 PredictivePaceWarningTitle = { "{}" } { "{}" } 配額告警 PredictivePaceWarningBody = 配額可能在 { "{}" } 內耗盡 +CredentialExpiryNotifications = 憑證過期提醒 +CredentialExpiryNotificationsHelper = 提供者帳戶需要重新登入時提醒一次 +CredentialExpiryTitle = { "{}" } 需要重新登入 +CredentialExpiryBody = 開啟 CodexBar 查看帳戶錯誤並重新登入。 ShowResetWhenExhausted = 耗盡時顯示重置時間 ShowResetWhenExhaustedHelper = 用量百分比耗盡時,用即時重置倒數替換顯示 diff --git a/rust/src/notifications/credential.rs b/rust/src/notifications/credential.rs index d0566f80692..33e8a51fa3a 100644 --- a/rust/src/notifications/credential.rs +++ b/rust/src/notifications/credential.rs @@ -415,6 +415,18 @@ mod tests { assert!(!toasts.fail(&mut manager, ProviderId::Claude, ACCOUNT, auth, true)); } + #[test] + fn no_enabled_providers_retires_all_episodes() { + let mut manager = NotificationManager::new(); + let toasts = Toasts::new(); + let auth = ProviderStateKind::NeedsAuthentication; + + assert!(toasts.fail(&mut manager, ProviderId::Codex, ACCOUNT, auth, true)); + manager.retire_credential_episodes_except(&[]); + assert!(toasts.fail(&mut manager, ProviderId::Codex, ACCOUNT, auth, true)); + assert_eq!(toasts.count(), 2); + } + #[test] fn policy_requires_master_switch_and_credential_toggle() { let mut settings = Settings {