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.
This commit is contained in:
2026-05-16 12:57:50 +07:00
parent 5fc9d18c67
commit 2ca5052915
12 changed files with 152 additions and 18 deletions
+3 -2
View File
@@ -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,
+93 -16
View File
@@ -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<String>,
current_interval: u32,
update_check_interval_secs: Option<u64>,
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<u64>) {
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 {
+5
View File
@@ -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"
+5
View File
@@ -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"
+5
View File
@@ -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"
+5
View File
@@ -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"
+5
View File
@@ -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時間"
+5
View File
@@ -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시간"
+5
View File
@@ -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"
+5
View File
@@ -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 小時"
+5
View File
@@ -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,
+11
View File
@@ -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<u64> {
Some(UPDATE_CHECK_HOURLY_SECS)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BubblePositions {
@@ -77,6 +85,8 @@ pub struct Settings {
pub language: Option<String>,
#[serde(default)]
pub last_update_check_unix: Option<u64>,
#[serde(default = "default_update_check_interval_secs")]
pub update_check_interval_secs: Option<u64>,
#[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(),
}
}