diff --git a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs index 01809a2ee9..91d26dd773 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs @@ -1,34 +1,47 @@ -use std::sync::Mutex; -use std::time::Duration; -#[cfg(test)] -use std::time::Instant; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; use codexbar::settings::Settings; -use tauri::Manager; - -use crate::state::AppState; const AUTO_REFRESH_POLL_INTERVAL: Duration = Duration::from_secs(15); pub fn install(app: tauri::AppHandle) { tauri::async_runtime::spawn(async move { + let mut schedule: Option<(Duration, Instant)> = None; loop { - if should_refresh(&app) - && crate::commands::do_refresh_providers_if_stale(&app) - .await - .is_ok() - { - refresh_powertoys_local_usage_cache().await; + let interval = refresh_interval(Settings::load().refresh_interval_secs); + match interval { + None => schedule = None, + Some(interval) => { + let now = Instant::now(); + let scheduled_at = schedule + .filter(|(scheduled_interval, _)| *scheduled_interval == interval) + .map(|(_, scheduled_at)| scheduled_at) + .unwrap_or(now); + if now >= scheduled_at { + let _ = crate::commands::do_refresh_providers_if_stale(&app).await; + schedule = Some(( + interval, + next_fixed_tick(scheduled_at, Instant::now(), interval), + )); + } + } } tokio::time::sleep(AUTO_REFRESH_POLL_INTERVAL).await; } }); } -async fn refresh_powertoys_local_usage_cache() { - let settings = Settings::load(); - let provider_ids = powertoys_local_usage_provider_ids(&settings); - crate::commands::refresh_provider_local_usage_cache(provider_ids).await; +fn next_fixed_tick( + previous_scheduled_at: Instant, + completed_at: Instant, + interval: Duration, +) -> Instant { + let mut scheduled_at = previous_scheduled_at + interval; + while scheduled_at <= completed_at { + scheduled_at += interval; + } + scheduled_at } fn powertoys_local_usage_provider_ids(settings: &Settings) -> Vec { @@ -44,85 +57,50 @@ fn powertoys_local_usage_provider_ids(settings: &Settings) -> Vec { .collect() } -fn should_refresh(app: &tauri::AppHandle) -> bool { - let settings = Settings::load(); - let Some(interval) = refresh_interval(settings.refresh_interval_secs) else { - return false; +pub(crate) fn schedule_refresh_enrichment(settings: &Settings) { + let provider_ids = powertoys_local_usage_provider_ids(settings); + if provider_ids.is_empty() { + return; + } + static ENRICHMENT: OnceLock>> = OnceLock::new(); + let Ok(guard) = Arc::clone(ENRICHMENT.get_or_init(|| Arc::new(tokio::sync::Mutex::new(())))) + .try_lock_owned() + else { + return; }; - - let state = app.state::>(); - state - .lock() - .map(|guard| should_refresh_from_state(&guard, interval)) - .unwrap_or(false) + tauri::async_runtime::spawn(async move { + let _guard = guard; + crate::commands::refresh_provider_local_usage_cache(provider_ids).await; + }); } fn refresh_interval(seconds: u64) -> Option { (seconds > 0).then(|| Duration::from_secs(seconds)) } -fn should_refresh_from_state(state: &AppState, interval: Duration) -> bool { - if state.is_refreshing { - return false; - } - match state.provider_cache_updated_at { - Some(updated_at) => updated_at.elapsed() >= interval, - None => true, - } -} - -#[cfg(test)] -pub(crate) fn should_refresh_from_values( - is_refreshing: bool, - updated_at: Option, - interval_secs: u64, -) -> bool { - let Some(interval) = refresh_interval(interval_secs) else { - return false; - }; - if is_refreshing { - return false; - } - updated_at - .map(|updated| updated.elapsed() >= interval) - .unwrap_or(true) -} - #[cfg(test)] mod tests { use super::*; #[test] fn manual_refresh_setting_disables_background_refresh() { - assert!(!should_refresh_from_values(false, None, 0)); - } - - #[test] - fn missing_cache_triggers_background_refresh() { - assert!(should_refresh_from_values(false, None, 300)); - } - - #[test] - fn fresh_cache_does_not_refresh_before_interval() { - assert!(!should_refresh_from_values( - false, - Some(Instant::now() - Duration::from_secs(299)), - 300, - )); + assert_eq!(refresh_interval(0), None); } #[test] - fn stale_cache_refreshes_after_configured_interval() { - assert!(should_refresh_from_values( - false, - Some(Instant::now() - Duration::from_secs(300)), - 300, - )); - } + fn fixed_cadence_advances_from_the_scheduled_tick() { + let start = Instant::now(); + let interval = Duration::from_secs(100); + let first_tick = start + interval; - #[test] - fn active_refresh_blocks_overlapping_background_refresh() { - assert!(!should_refresh_from_values(true, None, 300)); + assert_eq!( + next_fixed_tick(first_tick, first_tick + Duration::from_secs(60), interval), + start + Duration::from_secs(200) + ); + assert_eq!( + next_fixed_tick(first_tick, first_tick + Duration::from_secs(260), interval), + start + Duration::from_secs(400) + ); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index ffef079984..241e4fbaff 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -181,6 +181,7 @@ async fn do_refresh_providers_with_policy( update_tray_and_notifications(app, &state, &inputs.settings, &inputs.token_accounts)?; events::emit_refresh_complete(app, enabled_count, error_count); + crate::auto_refresh::schedule_refresh_enrichment(&inputs.settings); Ok(()) } diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 45bc905f3a..ee8f4949c8 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -56,6 +56,10 @@ pub struct SettingsUpdate { } impl SettingsUpdate { + fn refreshes_provider_data(&self) -> bool { + self.enabled_providers.is_some() + } + fn notifies_float_bar(&self) -> bool { self.enabled_providers.is_some() || self.refresh_interval_secs.is_some() @@ -324,6 +328,7 @@ pub async fn update_settings( ) -> Result { let mut settings = Settings::load(); let notify_float_bar = patch.notifies_float_bar(); + let refresh_provider_data = patch.refreshes_provider_data(); let clear_local_usage_cache = patch.codex_custom_sessions_dirs.is_some(); let rebuild_tray_menu = patch.rebuilds_tray_menu(); let refresh_tray_presentation = patch.refreshes_tray_presentation(); @@ -352,6 +357,12 @@ pub async fn update_settings( // settings live — e.g. the Display tab's window-scale slider takes effect // immediately instead of only after the PopOut is reopened. events::emit_settings_changed(&app); + if refresh_provider_data { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let _ = crate::commands::do_refresh_providers(&app).await; + }); + } Ok(SettingsSnapshot::from(settings)) } @@ -360,6 +371,25 @@ pub async fn update_settings( mod tests { use super::*; + #[test] + fn only_data_affecting_settings_refresh_providers() { + assert!( + SettingsUpdate { + enabled_providers: Some(vec!["codex".to_string()]), + ..Default::default() + } + .refreshes_provider_data() + ); + assert!( + !SettingsUpdate { + provider_metrics: Some(Default::default()), + tray_icon_mode: Some("single".to_string()), + ..Default::default() + } + .refreshes_provider_data() + ); + } + #[test] fn display_settings_that_affect_tray_trigger_presentation_refresh() { assert!(