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/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 new file mode 100644 index 00000000000..bf8637f615f --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/credential_alerts.rs @@ -0,0 +1,126 @@ +//! 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: Option, + 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) => { + 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); + } + } + } +} + +#[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, + Some(policy), + ProviderId::Codex, + None, + FetchAttempt::of(&failure), + &failure, + Some(&good), + ); + observe_attempt( + &mut manager, + Some(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..c3ae3410c7c 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, @@ -386,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, @@ -568,6 +574,8 @@ async fn refresh_provider( 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 +609,23 @@ 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, + id, + token_account_id, + fetch_attempt, + &snapshot, + cached.as_ref(), + ); Some(snapshot) } } else { @@ -1053,24 +1078,40 @@ 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 { 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/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/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 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/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 38dc0b05aea..5140e523f1a 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -669,6 +669,10 @@ PredictivePaceWarnings = Avisos predictivos de ritmo PredictivePaceWarningsHelper = Avisa cuando el uso de Codex o Claude probablemente se agote antes del reinicio PredictivePaceWarningTitle = Aviso de ritmo de { "{}" } ({ "{}" }) PredictivePaceWarningBody = La cuota puede agotarse en { "{}" } +CredentialExpiryNotifications = Alertas de expiración de credenciales +CredentialExpiryNotificationsHelper = Avisar una vez cuando una cuenta de proveedor necesite iniciar sesión de nuevo +CredentialExpiryTitle = { "{}" } necesita iniciar sesión +CredentialExpiryBody = Abre CodexBar para revisar el error de la cuenta e iniciar sesión de nuevo. ShowResetWhenExhausted = Mostrar reinicio al agotar la cuota ShowResetWhenExhaustedHelper = Reemplaza el porcentaje agotado con la cuenta regresiva del reinicio PromoteTrayIconLabel = Anclar a la barra de tareas (Windows 11) diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index b6c2dc0fc8d..303d8314eda 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -651,6 +651,10 @@ PredictivePaceWarnings = 予測ペース警告 PredictivePaceWarningsHelper = リセット前に Codex または Claude の使用量がなくなりそうなときに警告 PredictivePaceWarningTitle = { "{}" } { "{}" } ペース警告 PredictivePaceWarningBody = クレジットが { "{}" } 以内になくなる可能性 +CredentialExpiryNotifications = 認証情報の有効期限アラート +CredentialExpiryNotificationsHelper = プロバイダーアカウントで再ログインが必要になったときに一度だけ通知 +CredentialExpiryTitle = { "{}" } の再ログインが必要です +CredentialExpiryBody = CodexBarを開いてアカウントのエラーを確認し、再ログインしてください。 ShowResetWhenExhausted = 使い切ったときリセット時刻を表示 ShowResetWhenExhaustedHelper = 使い切った割合をライブのリセットカウントダウンに置き換え diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 299dcf1a54d..5482324517d 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -656,6 +656,10 @@ PredictivePaceWarnings = ?? ?? ?? ?? PredictivePaceWarningsHelper = Codex ?? Claude ???? ??? ?? ??? ???? ??? ???? PredictivePaceWarningTitle = { "{}" } { "{}" } ?? ?? ?? PredictivePaceWarningBody = { "{}" } ? ???? ??? ? ???? +CredentialExpiryNotifications = 인증 정보 만료 알림 +CredentialExpiryNotificationsHelper = 공급자 계정에 다시 로그인이 필요할 때 한 번 알림 +CredentialExpiryTitle = { "{}" } 계정에 다시 로그인이 필요합니다 +CredentialExpiryBody = CodexBar를 열어 계정 오류를 확인하고 다시 로그인하세요. ShowResetWhenExhausted = ?? ? ??? ?? ?? ShowResetWhenExhaustedHelper = ??? ??? ??? ??? ??????? ???? PromoteTrayIconLabel = 작업 표시줄에 고정 (Windows 11) diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index f978d540e76..34aed85d1cb 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -29,6 +29,10 @@ PredictivePaceWarnings = Прогнозируемые предупреждени PredictivePaceWarningsHelper = Предупреждать, если лимит Codex или Claude может закончиться до сброса PredictivePaceWarningTitle = { "{}" } { "{}" } предупреждение о темпе PredictivePaceWarningBody = Квота может закончиться в { "{}" } +CredentialExpiryNotifications = Уведомления об истечении срока действия учетных данных +CredentialExpiryNotificationsHelper = Уведомлять один раз, когда для учетной записи поставщика потребуется войти снова +CredentialExpiryTitle = Для «{ "{}" }» требуется повторный вход +CredentialExpiryBody = Откройте CodexBar, чтобы проверить ошибку учетной записи и войти снова. UsageDisplay = Отображение использования ShowUsageAsUsed = Показать использование как использованное ShowUsageAsUsedHelper = Отображать процент использования, а не остаток diff --git a/rust/src/locale/tests.rs b/rust/src/locale/tests.rs index 9daf93d8098..fc7424fa222 100644 --- a/rust/src/locale/tests.rs +++ b/rust/src/locale/tests.rs @@ -397,6 +397,12 @@ fn test_english_is_complete_and_other_languages_can_fallback() { .map(|(locale, resource)| (locale, resource_key_names(resource))) .collect(); let locale_key_names: HashSet<&str> = 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.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..33e8a51fa3a --- /dev/null +++ b/rust/src/notifications/credential.rs @@ -0,0 +1,444 @@ +//! 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 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 { + 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]