From 2ca5052915b6256f163a117c102d1c1e1bc8a1a2 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 16 May 2026 12:57:50 +0700 Subject: [PATCH] feat: configurable auto-update check frequency Adds a "Settings > Auto-update check" submenu with Disabled / Hourly / Daily / Weekly. Hourly is the default; existing settings files pick it up automatically via serde default. Manual "Check for updates" is unchanged and still fires when auto is disabled. The 24-hour hardcoded interval is replaced by reading Settings.update_check_interval_secs in both the startup scheduler and the post-check rearm path. None means auto is disabled and no timer is armed. Adds five new i18n keys across all eight locales. --- README.md | 5 +- src/app.rs | 109 ++++++++++++++++++++++++++++++------ src/i18n/locales/de.toml | 5 ++ src/i18n/locales/en.toml | 5 ++ src/i18n/locales/es.toml | 5 ++ src/i18n/locales/fr.toml | 5 ++ src/i18n/locales/ja.toml | 5 ++ src/i18n/locales/ko.toml | 5 ++ src/i18n/locales/nl.toml | 5 ++ src/i18n/locales/zh-TW.toml | 5 ++ src/i18n/mod.rs | 5 ++ src/settings.rs | 11 ++++ 12 files changed, 152 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7e322f8..65b01ce 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,9 @@ corner of your primary monitor on first launch. Drag it where you want it, release to snap to the nearest edge if you let go close to one. - **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns) -- **Right-click** for refresh, models, update frequency, language, "Start - with Windows", updates, exit +- **Right-click** for refresh, models, refresh frequency, language, "Start + with Windows", auto-update check (Disabled / Hourly / Daily / Weekly), + manual "Check for updates", exit - **Drag** anywhere — it floats on top of all other windows - **Ctrl + MouseWheel** on the bubble to resize it - **Tray icon** (if enabled): left-click toggles the bubble visibility, diff --git a/src/app.rs b/src/app.rs index 3bf0b4b..9f2af84 100644 --- a/src/app.rs +++ b/src/app.rs @@ -45,7 +45,8 @@ const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\ const STARTUP_VALUE_NAME: &str = "ClaudeCodeUsageBubble"; const APP_CLASS_NAME: &str = "ClaudeCodeUsageBubbleApp"; const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); -const UPDATE_CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; +// The auto-check interval is user-configurable (see +// `Settings::update_check_interval_secs`). `None` means auto-check is disabled. const BALLOON_COOLDOWN: Duration = Duration::from_secs(30 * 60); const REFRESH_TIMEOUT: Duration = Duration::from_secs(8); @@ -63,7 +64,16 @@ const IDM_START_WITH_WINDOWS: u16 = 30; const IDM_RESET_POSITION: u16 = 31; const IDM_VERSION_ACTION: u16 = 32; const IDM_LANG_SYSTEM: u16 = 40; -const IDM_LANG_BASE: u16 = 41; +// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range +// clear of it (and any future tray ids in the 5x band). +const IDM_UPDATE_AUTO_OFF: u16 = 60; +const IDM_UPDATE_AUTO_HOURLY: u16 = 61; +const IDM_UPDATE_AUTO_DAILY: u16 = 62; +const IDM_UPDATE_AUTO_WEEKLY: u16 = 63; +// IMPORTANT: language ids are dynamic and start at IDM_LANG_BASE. +// Keep IDM_LANG_BASE the highest static id so the catch-all match arm +// stays unambiguous. +const IDM_LANG_BASE: u16 = 100; // ---------- State ---------- @@ -349,6 +359,16 @@ pub fn on_menu_command(id: u32, _owner_hwnd: HWND) { IDM_START_WITH_WINDOWS => toggle_startup(), IDM_RESET_POSITION => reset_positions(), IDM_VERSION_ACTION => version_action(), + IDM_UPDATE_AUTO_OFF => set_update_check_interval(None), + IDM_UPDATE_AUTO_HOURLY => { + set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS)) + } + IDM_UPDATE_AUTO_DAILY => { + set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS)) + } + IDM_UPDATE_AUTO_WEEKLY => { + set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS)) + } IDM_LANG_SYSTEM => set_language(None), x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize), tray::IDM_TOGGLE_WIDGET => toggle_widget_visibility(), @@ -730,6 +750,7 @@ struct ContextMenuSnapshot { available: Vec<(String, String)>, language_override: Option, current_interval: u32, + update_check_interval_secs: Option, show_claude: bool, show_chatgpt: bool, widget_visible: bool, @@ -748,6 +769,7 @@ fn show_context_menu(owner_hwnd: HWND) { .collect(), language_override: s.settings.language.clone(), current_interval: s.settings.poll_interval_ms, + update_check_interval_secs: s.settings.update_check_interval_secs, show_claude: s.settings.show_claude_code, show_chatgpt: s.settings.show_codex, widget_visible: s.settings.widget_visible, @@ -844,6 +866,34 @@ fn show_context_menu(owner_hwnd: HWND) { MENU_ITEM_FLAGS(0) }; append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags); + + let auto_update = CreatePopupMenu().unwrap(); + for (id, value, label) in [ + (IDM_UPDATE_AUTO_OFF, None, &snap.strings.auto_check_disabled), + ( + IDM_UPDATE_AUTO_HOURLY, + Some(settings::UPDATE_CHECK_HOURLY_SECS), + &snap.strings.auto_check_hourly, + ), + ( + IDM_UPDATE_AUTO_DAILY, + Some(settings::UPDATE_CHECK_DAILY_SECS), + &snap.strings.auto_check_daily, + ), + ( + IDM_UPDATE_AUTO_WEEKLY, + Some(settings::UPDATE_CHECK_WEEKLY_SECS), + &snap.strings.auto_check_weekly, + ), + ] { + let flags = if value == snap.update_check_interval_secs { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + append_item(auto_update, id, label, flags); + } + append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check); append_submenu(menu, settings_menu, &snap.strings.settings); append_item( @@ -1067,18 +1117,27 @@ fn version_action() { } fn schedule_update_check_timer(hwnd: HWND) { - let last = lock_state() - .as_ref() - .and_then(|s| s.settings.last_update_check_unix); + let (last, interval) = match lock_state().as_ref() { + Some(s) => ( + s.settings.last_update_check_unix, + s.settings.update_check_interval_secs, + ), + None => return, + }; + let Some(interval) = interval else { + // Auto-check disabled. Manual "Check for updates" still works via + // the menu; this just suppresses the timer. + return; + }; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - let due = last.map_or(true, |t| now.saturating_sub(t) >= UPDATE_CHECK_INTERVAL_SECS); + let due = last.map_or(true, |t| now.saturating_sub(t) >= interval); if due { begin_update_check(hwnd); } else { - let remaining = UPDATE_CHECK_INTERVAL_SECS.saturating_sub(now.saturating_sub(last.unwrap_or(0))); + let remaining = interval.saturating_sub(now.saturating_sub(last.unwrap_or(0))); let ms = (remaining.saturating_mul(1000).min(u32::MAX as u64)) as u32; unsafe { SetTimer(hwnd, TIMER_UPDATE_CHECK, ms, None); @@ -1117,23 +1176,41 @@ fn begin_update_check(hwnd: HWND) { s.update_status = UpdateStatus::Failed; } } - s.settings.clone() + (s.settings.clone(), s.settings.update_check_interval_secs) }) }; - if let Some(snap) = snap_opt { + let next_interval = snap_opt.as_ref().and_then(|(_, iv)| *iv); + if let Some((snap, _)) = snap_opt { settings::save(&snap); } - unsafe { - SetTimer( - send_hwnd.to_hwnd(), - TIMER_UPDATE_CHECK, - (UPDATE_CHECK_INTERVAL_SECS as u32) * 1000, - None, - ); + if let Some(interval) = next_interval { + let ms = (interval.saturating_mul(1000).min(u32::MAX as u64)) as u32; + unsafe { + SetTimer(send_hwnd.to_hwnd(), TIMER_UPDATE_CHECK, ms, None); + } } }); } +fn set_update_check_interval(value: Option) { + let (snap, msg_hwnd) = { + let mut s = lock_state(); + let Some(s) = s.as_mut() else { + return; + }; + s.settings.update_check_interval_secs = value; + (s.settings.clone(), s.msg_hwnd) + }; + settings::save(&snap); + let hwnd = msg_hwnd.to_hwnd(); + unsafe { + let _ = KillTimer(hwnd, TIMER_UPDATE_CHECK); + } + if value.is_some() { + schedule_update_check_timer(hwnd); + } +} + // ---------- Start-with-Windows ---------- fn is_startup_enabled() -> bool { diff --git a/src/i18n/locales/de.toml b/src/i18n/locales/de.toml index 487c786..c862d38 100644 --- a/src/i18n/locales/de.toml +++ b/src/i18n/locales/de.toml @@ -23,6 +23,11 @@ update_failed = "Update fehlgeschlagen" applying_update = "Update wird angewendet…" update_available = "Update verfügbar" update_via_winget = "über WinGet" +auto_update_check = "Automatische Updateprüfung" +auto_check_disabled = "Deaktiviert" +auto_check_hourly = "Stündlich" +auto_check_daily = "Täglich" +auto_check_weekly = "Wöchentlich" exit = "Beenden" show_widget = "Widget anzeigen" session_window = "5h" diff --git a/src/i18n/locales/en.toml b/src/i18n/locales/en.toml index 440769f..f672d49 100644 --- a/src/i18n/locales/en.toml +++ b/src/i18n/locales/en.toml @@ -23,6 +23,11 @@ update_failed = "Update failed" applying_update = "Applying update…" update_available = "Update available" update_via_winget = "via WinGet" +auto_update_check = "Auto-update check" +auto_check_disabled = "Disabled" +auto_check_hourly = "Hourly" +auto_check_daily = "Daily" +auto_check_weekly = "Weekly" exit = "Exit" show_widget = "Show widget" session_window = "5h" diff --git a/src/i18n/locales/es.toml b/src/i18n/locales/es.toml index 91780da..c9875b5 100644 --- a/src/i18n/locales/es.toml +++ b/src/i18n/locales/es.toml @@ -23,6 +23,11 @@ update_failed = "Actualización fallida" applying_update = "Aplicando actualización…" update_available = "Actualización disponible" update_via_winget = "vía WinGet" +auto_update_check = "Búsqueda automática de actualizaciones" +auto_check_disabled = "Desactivada" +auto_check_hourly = "Cada hora" +auto_check_daily = "Cada día" +auto_check_weekly = "Cada semana" exit = "Salir" show_widget = "Mostrar widget" session_window = "5h" diff --git a/src/i18n/locales/fr.toml b/src/i18n/locales/fr.toml index 2c748d7..4ff3e8d 100644 --- a/src/i18n/locales/fr.toml +++ b/src/i18n/locales/fr.toml @@ -23,6 +23,11 @@ update_failed = "Mise à jour échouée" applying_update = "Mise à jour en cours…" update_available = "Mise à jour disponible" update_via_winget = "via WinGet" +auto_update_check = "Vérification automatique des mises à jour" +auto_check_disabled = "Désactivée" +auto_check_hourly = "Toutes les heures" +auto_check_daily = "Quotidienne" +auto_check_weekly = "Hebdomadaire" exit = "Quitter" show_widget = "Afficher le widget" session_window = "5h" diff --git a/src/i18n/locales/ja.toml b/src/i18n/locales/ja.toml index e8a6750..5c96b23 100644 --- a/src/i18n/locales/ja.toml +++ b/src/i18n/locales/ja.toml @@ -23,6 +23,11 @@ update_failed = "更新に失敗しました" applying_update = "更新を適用中…" update_available = "更新あり" update_via_winget = "WinGet経由" +auto_update_check = "更新の自動確認" +auto_check_disabled = "無効" +auto_check_hourly = "1時間ごと" +auto_check_daily = "毎日" +auto_check_weekly = "毎週" exit = "終了" show_widget = "ウィジェットを表示" session_window = "5時間" diff --git a/src/i18n/locales/ko.toml b/src/i18n/locales/ko.toml index c793983..5e9d5ec 100644 --- a/src/i18n/locales/ko.toml +++ b/src/i18n/locales/ko.toml @@ -23,6 +23,11 @@ update_failed = "업데이트 실패" applying_update = "업데이트 적용 중…" update_available = "업데이트 있음" update_via_winget = "WinGet 사용" +auto_update_check = "업데이트 자동 확인" +auto_check_disabled = "사용 안 함" +auto_check_hourly = "매시간" +auto_check_daily = "매일" +auto_check_weekly = "매주" exit = "종료" show_widget = "위젯 표시" session_window = "5시간" diff --git a/src/i18n/locales/nl.toml b/src/i18n/locales/nl.toml index 1185c6d..d7cdf97 100644 --- a/src/i18n/locales/nl.toml +++ b/src/i18n/locales/nl.toml @@ -23,6 +23,11 @@ update_failed = "Update mislukt" applying_update = "Update toepassen…" update_available = "Update beschikbaar" update_via_winget = "via WinGet" +auto_update_check = "Automatische updatecontrole" +auto_check_disabled = "Uitgeschakeld" +auto_check_hourly = "Per uur" +auto_check_daily = "Dagelijks" +auto_check_weekly = "Wekelijks" exit = "Afsluiten" show_widget = "Widget tonen" session_window = "5u" diff --git a/src/i18n/locales/zh-TW.toml b/src/i18n/locales/zh-TW.toml index 2c3bdea..2c88522 100644 --- a/src/i18n/locales/zh-TW.toml +++ b/src/i18n/locales/zh-TW.toml @@ -23,6 +23,11 @@ update_failed = "更新失敗" applying_update = "正在套用更新…" update_available = "有可用更新" update_via_winget = "透過 WinGet" +auto_update_check = "自動檢查更新" +auto_check_disabled = "停用" +auto_check_hourly = "每小時" +auto_check_daily = "每天" +auto_check_weekly = "每週" exit = "結束" show_widget = "顯示小工具" session_window = "5 小時" diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 5b25574..35f445c 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -43,6 +43,11 @@ pub struct LocaleStrings { pub applying_update: String, pub update_available: String, pub update_via_winget: String, + pub auto_update_check: String, + pub auto_check_disabled: String, + pub auto_check_hourly: String, + pub auto_check_daily: String, + pub auto_check_weekly: String, pub exit: String, pub show_widget: String, pub session_window: String, diff --git a/src/settings.rs b/src/settings.rs index bbdc6f0..e76150b 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -14,6 +14,11 @@ pub const POLL_5_MIN: u32 = 5 * 60_000; pub const POLL_15_MIN: u32 = 15 * 60_000; pub const POLL_1_HOUR: u32 = 60 * 60_000; +// Update-check intervals (seconds). `None` means auto-check is disabled. +pub const UPDATE_CHECK_HOURLY_SECS: u64 = 60 * 60; +pub const UPDATE_CHECK_DAILY_SECS: u64 = 24 * 60 * 60; +pub const UPDATE_CHECK_WEEKLY_SECS: u64 = 7 * 24 * 60 * 60; + fn default_show_claude() -> bool { true } @@ -29,6 +34,9 @@ fn default_bubble_size() -> i32 { fn default_poll_interval_ms() -> u32 { POLL_5_MIN } +fn default_update_check_interval_secs() -> Option { + Some(UPDATE_CHECK_HOURLY_SECS) +} #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BubblePositions { @@ -77,6 +85,8 @@ pub struct Settings { pub language: Option, #[serde(default)] pub last_update_check_unix: Option, + #[serde(default = "default_update_check_interval_secs")] + pub update_check_interval_secs: Option, #[serde(default = "default_widget_visible")] pub widget_visible: bool, } @@ -91,6 +101,7 @@ impl Default for Settings { poll_interval_ms: default_poll_interval_ms(), language: None, last_update_check_unix: None, + update_check_interval_secs: default_update_check_interval_secs(), widget_visible: default_widget_visible(), } }