feat(tray): show cached provider usage in the system tray menu (#2184)

* feat: add Rust-side write-through usage cache

Introduce an in-memory UsageCache on AppState that the existing usage
query commands populate on success. The cache is read-only to the rest
of the app today; the next commit consumes it from the tray menu.

- New services::usage_cache module with split maps: subscription keyed
  by AppType, script keyed by (AppType, provider_id).
- AppType gains Eq + Hash so it can be used as a HashMap key.
- commands::subscription::get_subscription_quota now takes State<AppState>
  and writes through on success (signature change is invisible to the
  frontend — Tauri injects State automatically).
- commands::provider::queryProviderUsage body extracted into an inner
  async fn; the public command wraps it with write-through, covering
  Copilot, coding-plan, balance, and generic script paths uniformly.

Cache is in-memory only; auto-query interval and the upcoming tray
refresh action rebuild it after restarts.

* feat(tray): surface cached usage in the system tray menu

Read UsageCache populated by the previous commit and render it in three
places, scoped to whatever TRAY_SECTIONS covers (Claude/Codex/Gemini):

1. Inline suffix on each provider submenu item
   "AnyProvider  · 🟢 5h 18% / 7d 23%"
2. Disabled summary row per visible app under "Show Main"
   "Claude · Anthropic Official · 🟢 5h 18% / 7d 23%"
3. "Refresh all usage" menu item that triggers get_subscription_quota +
   queryProviderUsage for every applicable provider, then rebuilds the
   tray menu via the existing refresh_tray_menu path.

Color encoding uses emoji (🟢 <70% / 🟠 70-89% / 🔴 ≥90%) since Tauri 2
tray labels are plain text. Missing cache entry leaves the label
unchanged — tray never issues network requests when opened. Three new
i18n-ready strings live in TrayTexts (en/zh/ja), following the existing
pattern for tray text.

Closes #2178.

* feat(usage): bridge tray UsageCache writes to frontend React Query

Why: tray hover triggers backend-only refresh that wrote to UsageCache but
never notified the frontend, leaving main UI stale while tray showed fresh
numbers. Emit a payload-carrying event after each cache write so React Query
can setQueryData directly, keeping both views in sync without duplicate fetches.

* fix(tray): skip hidden apps on hover refresh and drop stale disabled-script cache

Address P2 findings from automated review on #2184:

1. refresh_all_usage_in_tray now filters TRAY_SECTIONS by settings.visible_apps
   before scheduling subscription/script queries, matching create_tray_menu and
   preventing wasted external API calls (and rate-limit/auth-error log noise)
   for apps the user has hidden.

2. format_usage_suffix only trusts the script cache when provider.meta.usage_script
   is still enabled; when a script is disabled/removed the cached suffix is now
   invalidated so the tray label no longer shows stale data indefinitely.

* refactor: consolidate codex provider helpers and fix test semantics

- Add Provider::is_codex_oauth() and Provider::codex_fast_mode_enabled()
  to eliminate duplicated meta extraction in claude.rs and stream_check.rs
- Fix non-codex-oauth tests to pass codex_fast_mode=false (was true, harmless
  but semantically misleading)
- Remove redundant is_dir() guard after resolve_skill_source_dir already
  guarantees the returned path is a directory

* style: apply cargo fmt

* fix(tray): reflect failed refreshes in cache and support Gemini flash-lite

Follow-up to the tray usage-display feature addressing review feedback:

- Write snapshots for both Ok(success:false) and Err paths in
  queryProviderUsage / get_subscription_quota so stale success data
  no longer persists across failed refreshes; the original Err is
  still returned to the frontend onError handler.
- Include gemini_flash_lite tier in the tray summary with label "l".
  Matches the frontend SubscriptionQuotaFooter and keeps the worst
  emoji correct when lite is the highest utilization.
- Add TIER_GEMINI_PRO / _FLASH / _FLASH_LITE constants in
  services/subscription.rs and reuse them in classify_gemini_model
  and sort_order.
- Extract Provider::has_usage_script_enabled() to remove the
  duplicated meta.usage_script chain at two call sites.
- Use db.get_provider_by_id in refresh_all_usage_in_tray instead of
  materialising the full provider map, and parallelise subscription
  and script futures via futures::future::join.
- Narrow refresh_all_usage_in_tray to each section's effective
  current provider (script if enabled, else subscription when the
  provider is official). Hover refreshes now issue at most
  TRAY_SECTIONS.len() outbound requests.
- Add 10 unit tests in tray::tests covering Claude/Codex h/w dispatch,
  Gemini p/f/l dispatch (including lite-only and lite-worst cases),
  and success/failure guards.

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
涂瑜
2026-04-23 16:17:15 +08:00
committed by GitHub
parent 010b163430
commit dc04165f18
21 changed files with 855 additions and 115 deletions
+462 -18
View File
@@ -2,13 +2,20 @@
//!
//! 负责系统托盘图标和菜单的创建、更新和事件处理。
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem, SubmenuBuilder};
use once_cell::sync::Lazy;
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem, Submenu, SubmenuBuilder};
use tauri::{Emitter, Manager};
use crate::app_config::AppType;
use crate::error::AppError;
use crate::store::AppState;
/// 每个 app 分区的子菜单句柄,用于 usage 更新时就地改 label 而非整菜单重建。
/// `create_tray_menu` 每次重建都会整表覆盖写入,保证句柄始终指向当前活跃菜单。
static TRAY_SECTION_SUBMENUS: Lazy<
std::sync::Mutex<std::collections::HashMap<AppType, Submenu<tauri::Wry>>>,
> = Lazy::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
/// 托盘菜单文本(国际化)
#[derive(Clone, Copy)]
pub struct TrayTexts {
@@ -84,6 +91,140 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
},
];
/// 配色阈值(与前端 `utilizationColor` 语义一致)。
const UTIL_WARN_PCT: f64 = 70.0;
const UTIL_DANGER_PCT: f64 = 90.0;
fn emoji_for_utilization(pct: f64) -> &'static str {
if pct >= UTIL_DANGER_PCT {
"\u{1F534}" // 🔴
} else if pct >= UTIL_WARN_PCT {
"\u{1F7E0}" // 🟠
} else {
"\u{1F7E2}" // 🟢
}
}
fn format_subscription_summary(
quota: &crate::services::subscription::SubscriptionQuota,
) -> Option<String> {
use crate::services::subscription::{
TIER_FIVE_HOUR, TIER_GEMINI_FLASH, TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_SEVEN_DAY,
};
if !quota.success {
return None;
}
// 按 tool 选取主卡槽 tier 并映射到短 label
// Claude / Codex 沿用时间窗口(h=5 小时,w=7 天);
// Gemini 用模型维度(p=prof=flashl=flash-lite)——Gemini 后端 tier
// 命名是 gemini_pro / gemini_flash / gemini_flash_lite,与时间窗口不同命名空间。
// flash_lite 必须纳入:否则 lite 利用率最高时色标偏低,与前端 footer 行为不一致。
let parts: Vec<(&'static str, f64)> = match quota.tool.as_str() {
"gemini" => {
let mut v = Vec::new();
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_GEMINI_PRO) {
v.push(("p", t.utilization));
}
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_GEMINI_FLASH) {
v.push(("f", t.utilization));
}
if let Some(t) = quota
.tiers
.iter()
.find(|t| t.name == TIER_GEMINI_FLASH_LITE)
{
v.push(("l", t.utilization));
}
v
}
_ => {
let mut v = Vec::new();
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_FIVE_HOUR) {
v.push(("h", t.utilization));
}
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_SEVEN_DAY) {
v.push(("w", t.utilization));
}
v
}
};
if parts.is_empty() {
return None;
}
// 色标取所有已选 tier 里最高的利用率——用户更关心"离上限多近"。
let worst = parts
.iter()
.map(|(_, u)| *u)
.fold(f64::NEG_INFINITY, f64::max);
if !worst.is_finite() {
return None;
}
let emoji = emoji_for_utilization(worst);
let body = parts
.iter()
.map(|(label, u)| format!("{label}{}%", u.round() as i64))
.collect::<Vec<_>>()
.join(" ");
Some(format!("{emoji} {body}"))
}
fn format_script_summary(result: &crate::provider::UsageResult) -> Option<String> {
if !result.success {
return None;
}
let data = result.data.as_ref()?.first()?;
let pct = match (data.used, data.total) {
(Some(used), Some(total)) if total > 0.0 => used / total * 100.0,
_ => return None,
};
let emoji = emoji_for_utilization(pct);
let plan = data.plan_name.as_deref().unwrap_or("");
let rounded = pct.round() as i64;
if plan.is_empty() {
Some(format!("{} {}%", emoji, rounded))
} else {
Some(format!("{} {} {}%", emoji, plan, rounded))
}
}
fn format_usage_suffix(
app_state: &AppState,
app_type: &AppType,
provider: &crate::provider::Provider,
provider_id: &str,
) -> Option<String> {
// 当前脚本是否启用:禁用/删除时不再沿用旧 UsageCache 结果,
// 并顺手 invalidate,防止后续重建继续命中过期数据。
if provider.has_usage_script_enabled() {
// 脚本缓存优先(覆盖 Copilot/coding_plan/balance/自定义脚本),借用访问避免克隆整条 UsageResult。
if let Some(Some(s)) =
app_state
.usage_cache
.with_script(app_type, provider_id, format_script_summary)
{
return Some(format!(" · {s}"));
}
} else {
app_state
.usage_cache
.invalidate_script(app_type, provider_id);
}
if provider.category.as_deref() == Some("official") {
if let Some(Some(s)) = app_state
.usage_cache
.with_subscription(app_type, format_subscription_summary)
{
return Some(format!(" · {s}"));
}
}
None
}
/// 对供应商列表排序:sort_index → created_at → name
fn sort_providers(
providers: &indexmap::IndexMap<String, crate::provider::Provider>,
@@ -291,6 +432,8 @@ pub fn create_tray_menu(
let visible_apps = app_settings.visible_apps.unwrap_or_default();
let mut menu_builder = MenuBuilder::new(app);
let mut section_handles: std::collections::HashMap<AppType, Submenu<tauri::Wry>> =
std::collections::HashMap::new();
// 顶部:打开主界面
let show_main_item =
@@ -323,10 +466,13 @@ pub fn create_tray_menu(
})?;
menu_builder = menu_builder.item(&empty_item);
} else {
// 有供应商:构建子菜单
let current_name = providers.get(&current_id).map(|p| p.name.as_str());
let submenu_label = match current_name {
Some(name) => format!("{} · {}", section.header_label, name),
let current_provider = providers.get(&current_id);
let submenu_label = match current_provider {
Some(p) => {
let suffix = format_usage_suffix(app_state, &section.app_type, p, &current_id)
.unwrap_or_default();
format!("{} · {}{}", section.header_label, p.name, suffix)
}
None => section.header_label.to_string(),
};
let submenu_id = format!("submenu_{}", app_type_str);
@@ -369,6 +515,7 @@ pub fn create_tray_menu(
let submenu = submenu_builder.build().map_err(|e| {
AppError::Message(format!("构建{}子菜单失败: {e}", section.log_name))
})?;
section_handles.insert(section.app_type.clone(), submenu.clone());
menu_builder = menu_builder.item(&submenu);
}
@@ -393,9 +540,51 @@ pub fn create_tray_menu(
menu_builder = menu_builder.item(&quit_item);
menu_builder
let menu = menu_builder
.build()
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))?;
*TRAY_SECTION_SUBMENUS
.lock()
.unwrap_or_else(|p| p.into_inner()) = section_handles;
Ok(menu)
}
/// 就地更新各 app 分区子菜单的标题(usage 后缀变化时走这条),
/// 避免 `set_menu` 导致用户打开中的菜单被关闭。
/// 句柄由上一次 `create_tray_menu` 填充;为空(从未构建过菜单)时无事发生。
fn update_tray_usage_labels(app: &tauri::AppHandle) {
let Some(app_state) = app.try_state::<AppState>() else {
return;
};
let handles = match TRAY_SECTION_SUBMENUS.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
for section in TRAY_SECTIONS.iter() {
let Some(submenu) = handles.get(&section.app_type) else {
continue;
};
let Ok(providers) = app_state.db.get_all_providers(section.app_type.as_str()) else {
continue;
};
let Ok(Some(current_id)) =
crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)
else {
continue;
};
let Some(provider) = providers.get(&current_id) else {
continue;
};
let suffix = format_usage_suffix(&app_state, &section.app_type, provider, &current_id)
.unwrap_or_default();
let new_label = format!("{} · {}{}", section.header_label, provider.name, suffix);
if let Err(e) = submenu.set_text(&new_label) {
log::debug!("[Tray] 更新{}子菜单标题失败: {e}", section.log_name);
}
}
}
pub fn refresh_tray_menu(app: &tauri::AppHandle) {
@@ -412,17 +601,6 @@ pub fn refresh_tray_menu(app: &tauri::AppHandle) {
}
}
#[cfg(test)]
mod tests {
use super::TRAY_ID;
#[test]
fn tray_id_is_unique_to_app() {
assert_eq!(TRAY_ID, "cc-switch");
assert_ne!(TRAY_ID, "main");
}
}
#[cfg(target_os = "macos")]
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
use tauri::ActivationPolicy;
@@ -491,3 +669,269 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
}
}
}
static LAST_TRAY_USAGE_REFRESH: std::sync::Mutex<Option<std::time::Instant>> =
std::sync::Mutex::new(None);
const MIN_TRAY_USAGE_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
/// 合并多次快速触发的"usage 标题软更新":批量刷新期间多个 usage 命令
/// 同时成功时,只会产生一次就地 `set_text` 批量调用。走软更新而不是
/// `refresh_tray_menu` 整建,避免用户打开中的菜单被 macOS 系统关闭。
static TRAY_REBUILD_SCHEDULED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub fn schedule_tray_refresh(app: &tauri::AppHandle) {
use std::sync::atomic::Ordering;
if TRAY_REBUILD_SCHEDULED.swap(true, Ordering::AcqRel) {
return;
}
let app = app.clone();
tauri::async_runtime::spawn_blocking(move || {
// 50ms 合窗:让同一轮 React Query / 托盘批量刷新触发的多个写入
// 共享一次标题更新。
std::thread::sleep(std::time::Duration::from_millis(50));
TRAY_REBUILD_SCHEDULED.store(false, Ordering::Release);
update_tray_usage_labels(&app);
});
}
/// 并行刷新每个可见 app "当前 provider" 的用量;成功 / 失败结果都通过各
/// command 的 write-through 逻辑写入 `UsageCache`,单次重建菜单由
/// `schedule_tray_refresh` 做合并。内部 10 秒节流防止鼠标悬停反复进出时
/// 雪崩请求;互斥锁被毒化时以上次状态为准继续推进,不会永久阻塞。
///
/// 刷新面与 `format_usage_suffix` 的展示面严格对齐 —— 每次悬停最多发
/// `TRAY_SECTIONS.len()` 次外部请求,script 优先(覆盖 coding_plan / balance /
/// Copilot / 自定义脚本),否则当前 provider 必须是 `official` 才查订阅。
pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
use crate::commands::CopilotAuthState;
use futures::future::join_all;
{
let mut guard = LAST_TRAY_USAGE_REFRESH
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let now = std::time::Instant::now();
if let Some(last) = *guard {
if now.duration_since(last) < MIN_TRAY_USAGE_REFRESH_INTERVAL {
return;
}
}
*guard = Some(now);
}
let Some(app_state) = app.try_state::<AppState>() else {
return;
};
// 与 `create_tray_menu` 保持一致:用户隐藏的 app 不参与外部 API 查询,
// 避免在未使用的 app 上浪费请求、撞 rate limit 或反复触发鉴权失败日志。
let visible_apps = crate::settings::get_settings()
.visible_apps
.unwrap_or_default();
let mut subscription_futures = Vec::new();
let mut script_futures = Vec::new();
for section in TRAY_SECTIONS.iter() {
if !visible_apps.is_visible(&section.app_type) {
continue;
}
let app_type_str = section.app_type.as_str();
let log_name = section.log_name;
// 解析 effective current provider;未设置 / 出错都静默跳过,
// 与 create_tray_menu 的行为保持一致。
let current_id =
match crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)
{
Ok(Some(id)) => id,
Ok(None) => continue,
Err(e) => {
log::warn!("[Tray] 读取{log_name}当前供应商失败: {e}");
continue;
}
};
// 只需当前 provider —— by-id 查询避免把整个 app 的 provider 列表加载
// 进内存(每次悬停 × 3 sections 的热路径)。
let current = match app_state.db.get_provider_by_id(&current_id, app_type_str) {
Ok(Some(p)) => p,
Ok(None) => continue,
Err(e) => {
log::warn!("[Tray] 读取{log_name}当前供应商失败: {e}");
continue;
}
};
// 与 format_usage_suffix 同一优先级:脚本启用 → 查脚本;
// 否则当前 provider 是 official → 查订阅;其它情况不发请求。
if current.has_usage_script_enabled() {
let app_clone = app.clone();
let state = app.state::<AppState>();
let copilot_state = app.state::<CopilotAuthState>();
let provider_id = current_id.clone();
let app_str = app_type_str.to_string();
script_futures.push(async move {
if let Err(e) = crate::commands::queryProviderUsage(
app_clone,
state,
copilot_state,
provider_id.clone(),
app_str,
)
.await
{
log::debug!("[Tray] 刷新{log_name}供应商 {provider_id} 用量失败: {e}");
}
});
} else if current.category.as_deref() == Some("official") {
let app_clone = app.clone();
let state = app.state::<AppState>();
let tool = app_type_str.to_string();
subscription_futures.push(async move {
if let Err(e) =
crate::commands::get_subscription_quota(app_clone, state, tool).await
{
log::debug!("[Tray] 刷新{log_name}订阅用量失败(可能未登录): {e}");
}
});
}
}
// 两组并行启动,整体等待 —— 订阅/脚本互不依赖,没必要串行。
futures::future::join(join_all(subscription_futures), join_all(script_futures)).await;
}
#[cfg(test)]
mod tests {
use super::{format_subscription_summary, TRAY_ID};
use crate::services::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota};
#[test]
fn tray_id_is_unique_to_app() {
assert_eq!(TRAY_ID, "cc-switch");
assert_ne!(TRAY_ID, "main");
}
fn make_quota(tool: &str, success: bool, tiers: Vec<QuotaTier>) -> SubscriptionQuota {
SubscriptionQuota {
tool: tool.to_string(),
credential_status: CredentialStatus::Valid,
credential_message: None,
success,
tiers,
extra_usage: None,
error: None,
queried_at: Some(0),
}
}
fn tier(name: &str, utilization: f64) -> QuotaTier {
QuotaTier {
name: name.to_string(),
utilization,
resets_at: None,
}
}
#[test]
fn claude_summary_uses_h_and_w_labels() {
let quota = make_quota(
"claude",
true,
vec![tier("five_hour", 9.0), tier("seven_day", 27.0)],
);
let s = format_subscription_summary(&quota).expect("should format");
assert!(s.contains("h9%"), "expected h9% in {s}");
assert!(s.contains("w27%"), "expected w27% in {s}");
}
#[test]
fn gemini_summary_uses_p_and_f_labels() {
let quota = make_quota(
"gemini",
true,
vec![tier("gemini_pro", 15.0), tier("gemini_flash", 42.0)],
);
let s = format_subscription_summary(&quota).expect("should format");
assert!(s.contains("p15%"), "expected p15% in {s}");
assert!(s.contains("f42%"), "expected f42% in {s}");
}
#[test]
fn gemini_summary_includes_all_three_tiers() {
let quota = make_quota(
"gemini",
true,
vec![
tier("gemini_pro", 5.0),
tier("gemini_flash", 42.0),
tier("gemini_flash_lite", 80.0),
],
);
let s = format_subscription_summary(&quota).expect("should format");
assert!(s.contains("p5%"), "expected p5% in {s}");
assert!(s.contains("f42%"), "expected f42% in {s}");
assert!(s.contains("l80%"), "expected l80% in {s}");
}
#[test]
fn gemini_summary_lite_only_still_renders() {
// flash_lite 如果是 API 返回的唯一 tier,仍应显示(避免前端 footer 能看到、
// 托盘空白的不对称)。
let quota = make_quota("gemini", true, vec![tier("gemini_flash_lite", 80.0)]);
let s = format_subscription_summary(&quota).expect("should format");
assert!(s.contains("l80%"), "expected l80% in {s}");
}
#[test]
fn gemini_summary_emoji_reflects_highest_tier_including_lite() {
// lite 是利用率最高的那条 → emoji 必须是红色,不能被 pro/flash 掩盖。
let quota = make_quota(
"gemini",
true,
vec![
tier("gemini_pro", 10.0),
tier("gemini_flash", 20.0),
tier("gemini_flash_lite", 95.0),
],
);
let s = format_subscription_summary(&quota).unwrap();
assert!(
s.starts_with("\u{1F534}"),
"expected red emoji (lite worst) in {s}"
);
}
#[test]
fn worst_emoji_reflects_highest_utilization() {
// 🔴 = \u{1F534}; 任一 tier ≥ 90% 时预期显示红色。
let quota = make_quota(
"claude",
true,
vec![tier("five_hour", 10.0), tier("seven_day", 95.0)],
);
let s = format_subscription_summary(&quota).unwrap();
assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}");
}
#[test]
fn failure_quota_returns_none() {
let quota = make_quota("claude", false, vec![tier("five_hour", 50.0)]);
assert!(format_subscription_summary(&quota).is_none());
}
#[test]
fn unknown_tiers_return_none() {
let quota = make_quota("claude", true, vec![tier("one_hour", 80.0)]);
assert!(format_subscription_summary(&quota).is_none());
}
#[test]
fn gemini_without_any_known_tiers_returns_none() {
// 完全没有 pro/flash/flash_lite 三种 tier 的退化响应 → None。
let quota = make_quota("gemini", true, vec![tier("some_future_tier", 80.0)]);
assert!(format_subscription_summary(&quota).is_none());
}
}