diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e56ead4..05e0cd2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -49,7 +49,7 @@ security-framework = "3.7" [target.'cfg(target_os = "windows")'.dependencies] webview2-com = "0.38" windows = "0.61" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security_Credentials", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Security_Credentials", "Win32_UI_WindowsAndMessaging"] } [target.'cfg(target_os = "linux")'.dependencies] secret-service = { version = "5.1", default-features = false, features = ["rt-async-io-crypto-rust"] } diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index f6c0c1c..01cddf5 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -184,6 +184,8 @@ mod tests { short_name: "P".into(), fallback_enabled: true, local_usage_source_note: None, + local_usage_source_key: None, + pi_usage_source_key: None, links: vec![ProviderLink::new("Status", "https://status.example.com/")], metrics: vec![MetricDefinition::new( "provider.session", diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index bc4351a..0e8aa35 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -120,6 +120,9 @@ pub async fn save_app_settings( } } crate::app_debug!("config", "application settings persisted"); + if previous.language != updated.language { + crate::update_tray_menu(&app, &updated.language); + } tray_presentation::update( &app, &service.state(), @@ -222,11 +225,13 @@ pub fn request_notification_permission( settings: State<'_, Arc>, ) -> SettingsViewState { crate::app_info!("notifications", "notification permission requested"); + let language = settings.get().language; + let labels = crate::native_i18n::Labels::for_preference(&language); let error = app .notification() .request_permission() .err() - .map(|_| "Notification permission could not be requested.".to_owned()); + .map(|_| labels.notification_permission_failed.to_owned()); if error.is_some() { crate::app_error!("notifications", "notification permission request failed"); } @@ -234,7 +239,8 @@ pub fn request_notification_permission( notification_permission(&app), error, app.state::().tray_available(), - app.state::().platform_summary(), + app.state::() + .platform_summary(&language), ) } @@ -294,6 +300,7 @@ pub fn open_log_folder(app: AppHandle) -> Result<(), String> { pub(crate) fn settings_view_state(app: &AppHandle, service: &SettingsService) -> SettingsViewState { let mut settings = service.get(); + let labels = crate::native_i18n::Labels::for_preference(&settings.language); let mut integration_error = match autostart_is_enabled(app) { Ok(enabled) => { if settings.launch_at_login != enabled { @@ -302,19 +309,20 @@ pub(crate) fn settings_view_state(app: &AppHandle, service: &SettingsService) -> } None } - Err(_) => Some("Launch at login status could not be read.".to_owned()), + Err(_) => Some(labels.launch_status_failed.to_owned()), }; if let Some(shortcut) = service.get().global_shortcut { if !app.global_shortcut().is_registered(shortcut.as_str()) { - integration_error = - Some("The saved global shortcut is currently unavailable.".to_owned()); + integration_error = Some(labels.shortcut_unavailable.to_owned()); } } + let language = service.get().language; service.view_state( notification_permission(app), integration_error, app.state::().tray_available(), - app.state::().platform_summary(), + app.state::() + .platform_summary(&language), ) } diff --git a/src-tauri/src/desktop_integration.rs b/src-tauri/src/desktop_integration.rs index 00657bd..b3d5968 100644 --- a/src-tauri/src/desktop_integration.rs +++ b/src-tauri/src/desktop_integration.rs @@ -25,7 +25,16 @@ pub enum LinuxDesktop { pub struct DesktopIntegration { tray_available: bool, floating_window: Arc, - platform_summary: Option, + #[cfg(any(target_os = "linux", test))] + platform_summary: Option, +} + +#[cfg(any(target_os = "linux", test))] +#[derive(Debug, Clone, Copy)] +struct PlatformSummary { + desktop: LinuxDesktop, + session: LinuxSessionType, + tray_available: bool, } impl DesktopIntegration { @@ -42,6 +51,7 @@ impl DesktopIntegration { Self { tray_available: true, floating_window: Arc::new(AtomicBool::new(false)), + #[cfg(test)] platform_summary: None, } } @@ -69,8 +79,44 @@ impl DesktopIntegration { self.floating_window.store(floating, Ordering::SeqCst); } - pub fn platform_summary(&self) -> Option { - self.platform_summary.clone() + pub fn platform_summary(&self, language: &str) -> Option { + #[cfg(not(any(target_os = "linux", test)))] + { + let _ = language; + None + } + #[cfg(any(target_os = "linux", test))] + { + let summary = self.platform_summary?; + let locale = crate::native_i18n::Locale::for_preference(language); + let desktop = match summary.desktop { + LinuxDesktop::Gnome => "GNOME", + LinuxDesktop::Kde => "KDE Plasma", + LinuxDesktop::Other => match locale { + crate::native_i18n::Locale::En => "Linux desktop", + crate::native_i18n::Locale::ZhCn => "Linux 桌面", + crate::native_i18n::Locale::ZhTw => "Linux 桌面", + }, + }; + let session = match summary.session { + LinuxSessionType::X11 => "X11", + LinuxSessionType::Wayland => "Wayland", + LinuxSessionType::Unknown => match locale { + crate::native_i18n::Locale::En => "unknown session", + crate::native_i18n::Locale::ZhCn => "未知会话", + crate::native_i18n::Locale::ZhTw => "未知工作階段", + }, + }; + let mode = match (locale, summary.tray_available) { + (crate::native_i18n::Locale::En, true) => "StatusNotifier tray", + (crate::native_i18n::Locale::En, false) => "standalone window", + (crate::native_i18n::Locale::ZhCn, true) => "StatusNotifier 托盘", + (crate::native_i18n::Locale::ZhCn, false) => "独立窗口", + (crate::native_i18n::Locale::ZhTw, true) => "StatusNotifier 狀態列", + (crate::native_i18n::Locale::ZhTw, false) => "獨立視窗", + }; + Some(format!("{desktop} · {session} · {mode}")) + } } } @@ -80,25 +126,14 @@ fn linux_integration( desktop: LinuxDesktop, tray_available: bool, ) -> DesktopIntegration { - let desktop = match desktop { - LinuxDesktop::Gnome => "GNOME", - LinuxDesktop::Kde => "KDE Plasma", - LinuxDesktop::Other => "Linux desktop", - }; - let session = match session { - LinuxSessionType::X11 => "X11", - LinuxSessionType::Wayland => "Wayland", - LinuxSessionType::Unknown => "unknown session", - }; - let mode = if tray_available { - "StatusNotifier tray" - } else { - "standalone window" - }; DesktopIntegration { tray_available, floating_window: Arc::new(AtomicBool::new(!tray_available)), - platform_summary: Some(format!("{desktop} · {session} · {mode}")), + platform_summary: Some(PlatformSummary { + desktop, + session, + tray_available, + }), } } @@ -180,9 +215,13 @@ mod tests { let integration = super::linux_integration(LinuxSessionType::Wayland, LinuxDesktop::Gnome, false); assert_eq!( - integration.platform_summary().as_deref(), + integration.platform_summary("en").as_deref(), Some("GNOME · Wayland · standalone window") ); + assert_eq!( + integration.platform_summary("zh-CN").as_deref(), + Some("GNOME · Wayland · 独立窗口") + ); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f822a48..b00e784 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod logging; #[cfg(any(target_os = "macos", test))] mod menu_bar; mod models; +mod native_i18n; mod notifications; mod pacing; mod policy; @@ -60,6 +61,47 @@ use crate::{ }, }; +fn tray_menu(app: &AppHandle, language: &str) -> tauri::Result> { + let labels = native_i18n::Labels::for_preference(language); + #[cfg(target_os = "macos")] + { + let settings = + MenuItem::with_id(app, "settings", labels.settings, true, Some("CmdOrCtrl+,"))?; + let separator = PredefinedMenuItem::separator(app)?; + let quit = MenuItem::with_id(app, "quit", labels.quit, true, Some("CmdOrCtrl+Q"))?; + Menu::with_items(app, &[&settings, &separator, &quit]) + } + #[cfg(not(target_os = "macos"))] + { + let open = MenuItem::with_id(app, "open", labels.open, true, None::<&str>)?; + let customize = MenuItem::with_id(app, "customize", labels.customize, true, None::<&str>)?; + let settings = MenuItem::with_id( + app, + "settings", + labels.settings_with_ellipsis, + true, + None::<&str>, + )?; + let separator = PredefinedMenuItem::separator(app)?; + let quit = MenuItem::with_id(app, "quit", labels.quit, true, None::<&str>)?; + Menu::with_items(app, &[&open, &customize, &settings, &separator, &quit]) + } +} + +pub(crate) fn update_tray_menu(app: &AppHandle, language: &str) { + let Some(tray) = app.tray_by_id("openquota-tray") else { + return; + }; + match tray_menu(app, language) { + Ok(menu) => { + if tray.set_menu(Some(menu)).is_err() { + app_warn!("tray", "tray menu language update failed"); + } + } + Err(_) => app_warn!("tray", "localized tray menu could not be built"), + } +} + fn spawn_startup_credential_detection( app: AppHandle, registry: Arc, @@ -282,33 +324,7 @@ pub fn run() { } if desktop_integration.tray_available() { - #[cfg(target_os = "macos")] - let menu = { - let settings_item = - MenuItem::with_id(app, "settings", "Settings", true, Some("CmdOrCtrl+,"))?; - let separator = PredefinedMenuItem::separator(app)?; - let quit = MenuItem::with_id( - app, - "quit", - "Quit OpenQuota", - true, - Some("CmdOrCtrl+Q"), - )?; - Menu::with_items(app, &[&settings_item, &separator, &quit])? - }; - #[cfg(not(target_os = "macos"))] - let menu = { - let open = - MenuItem::with_id(app, "open", "Open OpenQuota", true, None::<&str>)?; - let customize = - MenuItem::with_id(app, "customize", "Customize…", true, None::<&str>)?; - let settings_item = - MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?; - let separator = PredefinedMenuItem::separator(app)?; - let quit = - MenuItem::with_id(app, "quit", "Quit OpenQuota", true, None::<&str>)?; - Menu::with_items(app, &[&open, &customize, &settings_item, &separator, &quit])? - }; + let menu = tray_menu(app.handle(), &settings.get().language)?; let tray = TrayIconBuilder::with_id("openquota-tray") .icon( diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 0ac1f39..d6bd109 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -80,7 +80,7 @@ pub enum StatusTone { Danger, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StatusMetric { pub id: String, @@ -90,6 +90,16 @@ pub struct StatusMetric { pub tone: StatusTone, #[serde(default, skip_serializing_if = "Option::is_none")] pub subtitle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unit: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum StatusMetricUnit { + Cap, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -106,6 +116,10 @@ pub struct ProviderNotice { pub title: String, pub message: String, pub tone: ProviderNoticeTone, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub showing_stale_limits: Option, } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -158,6 +172,9 @@ pub struct ModelUsageVariant { pub struct ModelUsageBreakdown { pub models: Vec, pub source_note: String, + /// Stable localization key for the source note. Older snapshots only have `sourceNote`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -327,6 +344,8 @@ pub struct TrayMetricDefinition { pub struct MetricDefinition { pub id: String, pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label_key: Option, pub source: MetricSource, pub pinnable: bool, pub default_enabled: bool, @@ -336,6 +355,11 @@ pub struct MetricDefinition { } impl MetricDefinition { + pub fn with_label_key(mut self, key: &str) -> Self { + self.label_key = Some(key.to_owned()); + self + } + #[allow(clippy::too_many_arguments)] pub fn new( id: impl Into, @@ -351,6 +375,7 @@ impl MetricDefinition { Self { id: id.into(), label: label.into(), + label_key: None, source, pinnable, default_enabled, @@ -537,7 +562,12 @@ pub struct ProviderDefinition { pub display_name: String, pub short_name: String, pub fallback_enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] pub local_usage_source_note: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_usage_source_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pi_usage_source_key: Option, #[serde(default)] pub links: Vec, pub metrics: Vec, @@ -682,6 +712,8 @@ pub struct NotificationPreferences { #[serde(rename_all = "camelCase", default)] pub struct AppSettings { pub schema_version: u32, + #[serde(default = "default_language")] + pub language: String, pub providers: Vec, pub known_provider_ids: Vec, pub provider_names: BTreeMap, @@ -706,10 +738,25 @@ pub struct AppSettings { pub detection_notice_dismissed: bool, } +fn default_language() -> String { + "system".to_owned() +} + +pub fn normalize_language_preference(value: &str) -> &'static str { + match value { + "system" => "system", + "en" => "en", + "zh-CN" => "zh-CN", + "zh-TW" => "zh-TW", + _ => "en", + } +} + impl Default for AppSettings { fn default() -> Self { Self { schema_version: 6, + language: default_language(), providers: Vec::new(), known_provider_ids: Vec::new(), provider_names: BTreeMap::new(), @@ -749,6 +796,7 @@ impl AppSettings { #[serde(rename_all = "camelCase")] pub struct SettingsViewState { pub settings: AppSettings, + pub resolved_language: String, pub account_revision: u64, pub renamable_provider_ids: Vec, pub notification_permission: String, @@ -760,8 +808,10 @@ pub struct SettingsViewState { #[cfg(test)] mod tests { use super::{ - ApiKeyStatus, AppSettings, LogLevel, ProviderApiKeyState, ProviderErrorKind, ProviderLink, - ProviderSnapshot, ProviderViewState, UsagePeriod, WindowMode, + ApiKeyStatus, AppSettings, LogLevel, MetricDefinition, ModelUsageBreakdown, + ProviderApiKeyState, ProviderErrorKind, ProviderLink, ProviderNotice, ProviderNoticeTone, + ProviderSnapshot, ProviderViewState, StatusMetric, StatusMetricUnit, StatusTone, + UsagePeriod, WindowMode, }; #[test] @@ -782,6 +832,22 @@ mod tests { assert_eq!(settings.window_mode, WindowMode::Popup); } + #[test] + fn older_settings_without_language_default_to_system() { + let mut value = serde_json::to_value(AppSettings::default()).unwrap(); + value.as_object_mut().unwrap().remove("language"); + + let settings: AppSettings = serde_json::from_value(value).unwrap(); + assert_eq!(settings.language, "system"); + } + + #[test] + fn unsupported_language_preferences_fall_back_to_english() { + assert_eq!(super::normalize_language_preference("invalid-locale"), "en"); + assert_eq!(super::normalize_language_preference("zh-CN"), "zh-CN"); + assert_eq!(super::normalize_language_preference("zh-TW"), "zh-TW"); + } + #[test] fn unknown_persisted_log_levels_fall_back_to_info() { let mut value = serde_json::to_value(AppSettings::default()).unwrap(); @@ -835,6 +901,82 @@ mod tests { assert!(period.cost_estimated); } + #[test] + fn older_metric_and_usage_payloads_default_new_semantic_fields() { + let metric: MetricDefinition = serde_json::from_value(serde_json::json!({ + "id": "custom.session", + "label": "Custom Session", + "source": { + "kind": "quota", + "sourceId": "session", + "sessionWindow": false + }, + "pinnable": true, + "defaultEnabled": true, + "defaultSection": "alwaysVisible", + "defaultPinned": false, + "tray": null + })) + .unwrap(); + assert_eq!(metric.label_key, None); + + let breakdown: ModelUsageBreakdown = serde_json::from_value(serde_json::json!({ + "models": [], + "sourceNote": "From a custom source" + })) + .unwrap(); + assert_eq!(breakdown.source_key, None); + } + + #[test] + fn older_status_payloads_default_typed_presentation_fields() { + let metric: StatusMetric = serde_json::from_value(serde_json::json!({ + "id": "payAsYouGo", + "label": "Extra Usage", + "text": "2500 cap", + "tone": "positive" + })) + .unwrap(); + assert_eq!(metric.value, None); + assert_eq!(metric.unit, None); + + let notice: ProviderNotice = serde_json::from_value(serde_json::json!({ + "id": "rateLimited", + "title": "Live usage paused", + "message": "Retrying in about 5 minutes", + "tone": "warning" + })) + .unwrap(); + assert_eq!(notice.retry_seconds, None); + assert_eq!(notice.showing_stale_limits, None); + + let status = StatusMetric { + id: "payAsYouGo".into(), + label: "Extra Usage".into(), + text: String::new(), + tone: StatusTone::Positive, + subtitle: None, + value: Some(2500.0), + unit: Some(StatusMetricUnit::Cap), + }; + assert_eq!( + serde_json::to_value(status).unwrap()["unit"], + serde_json::json!("cap") + ); + + let notice = ProviderNotice { + id: "rateLimited".into(), + title: "Live usage paused".into(), + message: String::new(), + tone: ProviderNoticeTone::Warning, + retry_seconds: Some(60), + showing_stale_limits: Some(true), + }; + let value = serde_json::to_value(notice).unwrap(); + assert_eq!(value["retrySeconds"], serde_json::json!(60)); + assert_eq!(value["showingStaleLimits"], serde_json::json!(true)); + } + #[test] fn cached_snapshots_default_new_dynamic_rows() { let snapshot: ProviderSnapshot = serde_json::from_str( diff --git a/src-tauri/src/native_i18n.rs b/src-tauri/src/native_i18n.rs new file mode 100644 index 0000000..ed607d8 --- /dev/null +++ b/src-tauri/src/native_i18n.rs @@ -0,0 +1,303 @@ +use crate::models::{StatusMetricUnit, StatusTone}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Locale { + En, + ZhCn, + ZhTw, +} + +impl Locale { + pub fn for_preference(preference: &str) -> Self { + match preference { + "zh-CN" => Self::ZhCn, + "zh-TW" => Self::ZhTw, + "system" => system_language() + .as_deref() + .map(Self::from_language_tag) + .unwrap_or(Self::En), + _ => Self::En, + } + } + + pub fn from_language_tag(language: &str) -> Self { + let normalized = normalize_locale_tag(language); + if normalized == "zh-tw" + || normalized == "zh-hk" + || normalized == "zh-mo" + || normalized.starts_with("zh-hant") + { + Self::ZhTw + } else if normalized.starts_with("zh") { + Self::ZhCn + } else { + Self::En + } + } + + pub fn language_tag(self) -> &'static str { + match self { + Self::En => "en", + Self::ZhCn => "zh-CN", + Self::ZhTw => "zh-TW", + } + } +} + +fn normalize_locale_tag(language: &str) -> String { + language + .trim() + .split(['.', '@']) + .next() + .unwrap_or_default() + .replace('_', "-") + .to_ascii_lowercase() +} + +pub struct Labels { + #[cfg(any(not(target_os = "macos"), test))] + pub open: &'static str, + #[cfg(any(not(target_os = "macos"), test))] + pub customize: &'static str, + #[cfg(any(target_os = "macos", test))] + pub settings: &'static str, + #[cfg(any(not(target_os = "macos"), test))] + pub settings_with_ellipsis: &'static str, + pub quit: &'static str, + #[cfg(any(target_os = "linux", target_os = "macos", test))] + pub notification_action: &'static str, + pub notification_failed: &'static str, + pub notification_permission_failed: &'static str, + pub launch_status_failed: &'static str, + pub shortcut_unavailable: &'static str, +} + +impl Labels { + pub fn for_preference(preference: &str) -> Self { + match Locale::for_preference(preference) { + Locale::En => Self { + #[cfg(any(not(target_os = "macos"), test))] + open: "Open OpenQuota", + #[cfg(any(not(target_os = "macos"), test))] + customize: "Customize…", + #[cfg(any(target_os = "macos", test))] + settings: "Settings", + #[cfg(any(not(target_os = "macos"), test))] + settings_with_ellipsis: "Settings…", + quit: "Quit OpenQuota", + #[cfg(any(target_os = "linux", target_os = "macos", test))] + notification_action: "Open OpenQuota", + notification_failed: "The notification could not be delivered.", + notification_permission_failed: "Notification permission could not be requested.", + launch_status_failed: "Launch at login status could not be read.", + shortcut_unavailable: "The saved global shortcut is currently unavailable.", + }, + Locale::ZhCn => Self { + #[cfg(any(not(target_os = "macos"), test))] + open: "打开 OpenQuota", + #[cfg(any(not(target_os = "macos"), test))] + customize: "自定义…", + #[cfg(any(target_os = "macos", test))] + settings: "设置", + #[cfg(any(not(target_os = "macos"), test))] + settings_with_ellipsis: "设置…", + quit: "退出 OpenQuota", + #[cfg(any(target_os = "linux", target_os = "macos", test))] + notification_action: "打开 OpenQuota", + notification_failed: "无法发送通知。", + notification_permission_failed: "无法请求通知权限。", + launch_status_failed: "无法读取登录时启动状态。", + shortcut_unavailable: "已保存的全局快捷键当前不可用。", + }, + Locale::ZhTw => Self { + #[cfg(any(not(target_os = "macos"), test))] + open: "開啟 OpenQuota", + #[cfg(any(not(target_os = "macos"), test))] + customize: "自訂…", + #[cfg(any(target_os = "macos", test))] + settings: "設定", + #[cfg(any(not(target_os = "macos"), test))] + settings_with_ellipsis: "設定…", + quit: "結束 OpenQuota", + #[cfg(any(target_os = "linux", target_os = "macos", test))] + notification_action: "開啟 OpenQuota", + notification_failed: "無法傳送通知。", + notification_permission_failed: "無法請求通知權限。", + launch_status_failed: "無法讀取登入時啟動狀態。", + shortcut_unavailable: "已儲存的全域快捷鍵目前無法使用。", + }, + } + } +} + +pub fn metric_label(locale: Locale, key: Option<&str>, fallback: &str) -> String { + let common = match key { + Some("session") => Some(("Session", "会话", "工作階段")), + Some("weekly") => Some(("Weekly", "每周", "每週")), + Some("today") => Some(("Today", "今天", "今天")), + Some("yesterday") => Some(("Yesterday", "昨天", "昨天")), + Some("last30Days") => Some(("30 Days", "30 天", "30 天")), + Some("daily") => Some(("Daily", "每日", "每日")), + Some("monthly") => Some(("Monthly", "每月", "每月")), + Some("usageTrend") => Some(("Usage Trend", "用量趋势", "用量趨勢")), + Some("extraUsage") => Some(("Extra Usage", "额外用量", "額外用量")), + Some("extraBalance") => Some(("Extra Balance", "额外余额", "額外餘額")), + Some("disabled") => Some(("Disabled", "已禁用", "已停用")), + Some("rateLimitResets") => Some(("Rate Limit Resets", "限额重置", "限額重設")), + Some("credits") => Some(("Credits", "额度", "額度")), + Some("totalUsage") => Some(("Total Usage", "总用量", "總用量")), + Some("autoUsage") => Some(("Auto Usage", "自动用量", "自動用量")), + Some("apiUsage") => Some(("API Usage", "API 用量", "API 用量")), + Some("requestsLabel") => Some(("Requests", "请求", "要求")), + Some("balance") => Some(("Balance", "余额", "餘額")), + Some("thisWeek") => Some(("This Week", "本周", "本週")), + Some("thisMonth") => Some(("This Month", "本月", "本月")), + Some("keyLimit") => Some(("Key Limit", "密钥限额", "金鑰限額")), + Some("webSearches") => Some(("Web Searches", "网页搜索", "網頁搜尋")), + Some("sparkWeekly") => Some(("Spark Weekly", "Spark 每周", "Spark 每週")), + Some("claudeWeekly") => Some(("Claude Weekly", "Claude 每周", "Claude 每週")), + Some("orgCredits") => Some(("Org Credits", "组织额度", "組織額度")), + Some("orgSpend") => Some(("Org Spend", "组织消费", "組織消費")), + Some("chat") => Some(("Chat", "聊天", "聊天")), + Some("completions") => Some(("Completions", "代码补全", "程式碼補全")), + _ => None, + }; + common + .map(|labels| match locale { + Locale::En => labels.0, + Locale::ZhCn => labels.1, + Locale::ZhTw => labels.2, + }) + .unwrap_or(fallback) + .to_owned() +} + +pub fn status_metric_text( + locale: Locale, + id: &str, + tone: StatusTone, + value: Option, + unit: Option, + fallback: &str, +) -> String { + if id == "payAsYouGo" && tone == StatusTone::Neutral { + return metric_label(locale, Some("disabled"), fallback); + } + if id == "payAsYouGo" + && tone == StatusTone::Positive + && unit == Some(StatusMetricUnit::Cap) + && value.is_some_and(f64::is_finite) + { + let number = value.unwrap(); + let number = if number.fract() == 0.0 { + format!("{number:.0}") + } else { + number.to_string() + }; + return match locale { + Locale::En => format!("{number} cap"), + Locale::ZhCn | Locale::ZhTw => format!("上限 {number}"), + }; + } + fallback.to_owned() +} + +pub fn usage_word(locale: Locale, used: bool) -> &'static str { + match (locale, used) { + (Locale::En, true) => "used", + (Locale::En, false) => "left", + (Locale::ZhCn, true) => "已用", + (Locale::ZhCn, false) => "剩余", + (Locale::ZhTw, true) => "已用", + (Locale::ZhTw, false) => "剩餘", + } +} + +pub fn count_unit(locale: Locale, unit: &str) -> String { + match (locale, unit) { + (Locale::ZhCn, "requests") => "次请求".to_owned(), + (Locale::ZhCn, "searches") => "次搜索".to_owned(), + (Locale::ZhTw, "requests") => "次要求".to_owned(), + (Locale::ZhTw, "searches") => "次搜尋".to_owned(), + _ => unit.to_owned(), + } +} + +#[cfg(target_os = "windows")] +fn system_language() -> Option { + use windows_sys::Win32::Globalization::GetUserDefaultLocaleName; + + let mut buffer = [0_u16; 85]; + let length = unsafe { GetUserDefaultLocaleName(buffer.as_mut_ptr(), buffer.len() as i32) }; + (length > 1).then(|| String::from_utf16_lossy(&buffer[..length as usize - 1])) +} + +#[cfg(not(target_os = "windows"))] +fn system_language() -> Option { + ["LC_ALL", "LC_MESSAGES", "LANG"] + .into_iter() + .find_map(|name| { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + }) +} + +#[cfg(test)] +mod tests { + use super::{Labels, Locale}; + + #[test] + fn language_tags_match_frontend_resolution() { + assert_eq!(super::normalize_locale_tag("zh_CN.UTF-8"), "zh-cn"); + assert_eq!(super::normalize_locale_tag("zh_TW@variant"), "zh-tw"); + assert_eq!(Locale::from_language_tag("zh-CN"), Locale::ZhCn); + assert_eq!(Locale::from_language_tag("zh-SG"), Locale::ZhCn); + assert_eq!(Locale::from_language_tag("zh-TW"), Locale::ZhTw); + assert_eq!(Locale::from_language_tag("zh-HK"), Locale::ZhTw); + assert_eq!(Locale::from_language_tag("zh-Hant"), Locale::ZhTw); + assert_eq!(Locale::from_language_tag("fr-FR"), Locale::En); + assert_eq!(Locale::from_language_tag("C"), Locale::En); + assert_eq!(Locale::from_language_tag("POSIX"), Locale::En); + assert_eq!(Locale::En.language_tag(), "en"); + assert_eq!(Locale::ZhCn.language_tag(), "zh-CN"); + assert_eq!(Locale::ZhTw.language_tag(), "zh-TW"); + } + + #[test] + fn explicit_preferences_have_localized_native_labels() { + assert_eq!(Labels::for_preference("en").open, "Open OpenQuota"); + assert_eq!(Labels::for_preference("en").customize, "Customize…"); + assert_eq!(Labels::for_preference("en").settings, "Settings"); + assert_eq!( + Labels::for_preference("en").settings_with_ellipsis, + "Settings…" + ); + assert_eq!( + Labels::for_preference("en").notification_action, + "Open OpenQuota" + ); + assert_eq!(Labels::for_preference("zh-CN").open, "打开 OpenQuota"); + assert_eq!(Labels::for_preference("zh-CN").customize, "自定义…"); + assert_eq!(Labels::for_preference("zh-CN").settings, "设置"); + assert_eq!(Labels::for_preference("zh-TW").open, "開啟 OpenQuota"); + assert_eq!(Labels::for_preference("zh-TW").customize, "自訂…"); + assert_eq!(Labels::for_preference("zh-TW").settings, "設定"); + assert_eq!(Labels::for_preference("invalid").settings, "Settings"); + } + + #[test] + fn native_metric_labels_and_units_follow_the_resolved_locale() { + assert_eq!( + super::metric_label(Locale::ZhCn, Some("weekly"), "Weekly"), + "每周" + ); + assert_eq!( + super::metric_label(Locale::ZhTw, Some("requestsLabel"), "Requests"), + "要求" + ); + assert_eq!(super::count_unit(Locale::ZhTw, "searches"), "次搜尋"); + assert_eq!(super::usage_word(Locale::ZhCn, false), "剩余"); + assert_eq!(super::metric_label(Locale::En, None, "Custom"), "Custom"); + } +} diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index bdc4cdc..469d32a 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -7,6 +7,7 @@ use crate::{ models::{ProviderSnapshot, ProviderViewState}, pacing::{NotificationEvaluator, PaceAlert}, popup::PopupDismissGuard, + providers::ProviderRegistry, service::UsageViewState, settings::SettingsService, tray_presentation, @@ -38,7 +39,7 @@ pub fn finish_refresh( settings.registry(), chrono::Utc::now(), ); - let failed = deliver(app, &alerts); + let failed = deliver(app, &alerts, &preferences.language, settings.registry()); if !failed.is_empty() { notifications.rollback(&failed); } @@ -51,7 +52,12 @@ fn notification_snapshot(state: &ProviderViewState) -> Option<&ProviderSnapshot> state.snapshot.as_ref() } -fn deliver(app: &AppHandle, alerts: &[PaceAlert]) -> Vec { +fn deliver( + app: &AppHandle, + alerts: &[PaceAlert], + language: &str, + registry: &ProviderRegistry, +) -> Vec { if permission(app) != "granted" { if !alerts.is_empty() { crate::app_debug!( @@ -62,18 +68,24 @@ fn deliver(app: &AppHandle, alerts: &[PaceAlert]) -> Vec { } return alerts.to_vec(); } + let locale = crate::native_i18n::Locale::for_preference(language); alerts .iter() .filter_map(|alert| { + let metric_key = registry + .metric(&alert.metric_id) + .and_then(|definition| definition.label_key.as_deref()); + let metric = crate::native_i18n::metric_label(locale, metric_key, &alert.metric); let result = show( app, - alert.milestone.title(), + alert.milestone.title(locale), &format!( "{} · {}\n{}", alert.provider, - alert.metric, - alert.milestone.body() + metric, + alert.milestone.body(locale) ), + language, ); if result.is_ok() { crate::app_info!("notifications", "pace alert delivered"); @@ -86,11 +98,12 @@ fn deliver(app: &AppHandle, alerts: &[PaceAlert]) -> Vec { .collect() } -fn show(app: &AppHandle, title: &str, body: &str) -> Result<(), String> { +fn show(app: &AppHandle, title: &str, body: &str, language: &str) -> Result<(), String> { + let labels = crate::native_i18n::Labels::for_preference(language); let mut notification = notify_rust::Notification::new(); notification.summary(title).body(body).appname("OpenQuota"); #[cfg(any(target_os = "linux", target_os = "macos"))] - notification.action("default", "Open OpenQuota"); + notification.action("default", labels.notification_action); #[cfg(target_os = "windows")] notification.app_id(&app.config().identifier); #[cfg(target_os = "macos")] @@ -102,7 +115,7 @@ fn show(app: &AppHandle, title: &str, body: &str) -> Result<(), String> { let handle = notification .show() - .map_err(|_| "The notification could not be delivered.".to_owned())?; + .map_err(|_| labels.notification_failed.to_owned())?; let app = app.clone(); thread::spawn(move || { let _ = handle.wait_for_response(move |response: ¬ify_rust::NotificationResponse| { diff --git a/src-tauri/src/pacing.rs b/src-tauri/src/pacing.rs index 55dcb15..eeeee9c 100644 --- a/src-tauri/src/pacing.rs +++ b/src-tauri/src/pacing.rs @@ -125,19 +125,37 @@ pub enum Milestone { } impl Milestone { - pub fn title(self) -> &'static str { - match self { - Self::AlmostOut => "Almost Out", - Self::CuttingItClose => "Cutting It Close", - Self::WillRunOut => "Will Run Out", + pub fn title(self, locale: crate::native_i18n::Locale) -> &'static str { + match (locale, self) { + (crate::native_i18n::Locale::En, Self::AlmostOut) => "Almost Out", + (crate::native_i18n::Locale::En, Self::CuttingItClose) => "Cutting It Close", + (crate::native_i18n::Locale::En, Self::WillRunOut) => "Will Run Out", + (crate::native_i18n::Locale::ZhCn, Self::AlmostOut) => "即将用尽", + (crate::native_i18n::Locale::ZhCn, Self::CuttingItClose) => "接近限额", + (crate::native_i18n::Locale::ZhCn, Self::WillRunOut) => "将会用尽", + (crate::native_i18n::Locale::ZhTw, Self::AlmostOut) => "即將用盡", + (crate::native_i18n::Locale::ZhTw, Self::CuttingItClose) => "接近限額", + (crate::native_i18n::Locale::ZhTw, Self::WillRunOut) => "將會用盡", } } - pub fn body(self) -> &'static str { - match self { - Self::AlmostOut => "Under 10% usage remaining for this window.", - Self::CuttingItClose => "Projected to finish close to your limit.", - Self::WillRunOut => "Projected to run out before the limit resets.", + pub fn body(self, locale: crate::native_i18n::Locale) -> &'static str { + match (locale, self) { + (crate::native_i18n::Locale::En, Self::AlmostOut) => { + "Under 10% usage remaining for this window." + } + (crate::native_i18n::Locale::En, Self::CuttingItClose) => { + "Projected to finish close to your limit." + } + (crate::native_i18n::Locale::En, Self::WillRunOut) => { + "Projected to run out before the limit resets." + } + (crate::native_i18n::Locale::ZhCn, Self::AlmostOut) => "此周期的剩余用量低于 10%。", + (crate::native_i18n::Locale::ZhCn, Self::CuttingItClose) => "预计将在接近限额时结束。", + (crate::native_i18n::Locale::ZhCn, Self::WillRunOut) => "预计将在限额重置前用尽。", + (crate::native_i18n::Locale::ZhTw, Self::AlmostOut) => "此週期的剩餘用量低於 10%。", + (crate::native_i18n::Locale::ZhTw, Self::CuttingItClose) => "預計將在接近限額時結束。", + (crate::native_i18n::Locale::ZhTw, Self::WillRunOut) => "預計將在限額重設前用盡。", } } } @@ -147,7 +165,7 @@ pub struct PaceAlert { pub milestone: Milestone, pub provider: String, pub metric: String, - metric_id: String, + pub(crate) metric_id: String, previous_severity: Option, previous_was_under_ten: bool, } @@ -661,6 +679,8 @@ mod tests { short_name: "C".into(), fallback_enabled: true, local_usage_source_note: None, + local_usage_source_key: None, + pi_usage_source_key: None, links: vec![], metrics: vec![MetricDefinition::new( "custom.rolling", @@ -750,6 +770,8 @@ mod tests { short_name: "Sw".into(), fallback_enabled: true, local_usage_source_note: None, + local_usage_source_key: None, + pi_usage_source_key: None, links: vec![], metrics: vec![ quota_metric("switching.session", "session", "S"), diff --git a/src-tauri/src/providers/antigravity/mod.rs b/src-tauri/src/providers/antigravity/mod.rs index 2dd020b..26b9e0b 100644 --- a/src-tauri/src/providers/antigravity/mod.rs +++ b/src-tauri/src/providers/antigravity/mod.rs @@ -35,6 +35,8 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "A".into(), fallback_enabled: false, local_usage_source_note: None, + local_usage_source_key: None, + pi_usage_source_key: None, links: vec![], metrics: vec![ MetricDefinition::quota( @@ -46,7 +48,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "S", - ), + ) + .with_label_key("session"), MetricDefinition::quota( "antigravity.geminiWeekly", "Weekly", @@ -56,7 +59,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "W", - ), + ) + .with_label_key("weekly"), MetricDefinition::quota( "antigravity.claude", "Claude", @@ -76,7 +80,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "CW", - ), + ) + .with_label_key("claudeWeekly"), ], } } diff --git a/src-tauri/src/providers/claude/local_usage.rs b/src-tauri/src/providers/claude/local_usage.rs index d3ec01b..2464174 100644 --- a/src-tauri/src/providers/claude/local_usage.rs +++ b/src-tauri/src/providers/claude/local_usage.rs @@ -104,12 +104,18 @@ pub fn scan_local_usage( } else { false }; - let source_note = if includes_pi { - "From your Claude usage history and pi (estimated)" + let (source_note, source_key) = if includes_pi { + ( + "From your Claude usage history and pi (estimated)", + "estimatedHistoryWithPiSource", + ) } else { - "From your Claude usage history (estimated)" + ( + "From your Claude usage history (estimated)", + "estimatedUsageHistorySource", + ) }; - Ok(accumulator.build(now, source_note)) + Ok(accumulator.build_with_source_key(now, source_note, Some(source_key))) } fn discover_files(configured_roots: &[PathBuf], include_standard_roots: bool) -> Vec { @@ -438,7 +444,11 @@ fn aggregate( ) -> UsageHistory { let mut accumulator = DailyUsageAccumulator::default(); aggregate_into(events, now, pricing, &mut accumulator); - accumulator.build(now, "From your Claude usage history (estimated)") + accumulator.build_with_source_key( + now, + "From your Claude usage history (estimated)", + Some("estimatedUsageHistorySource"), + ) } fn aggregate_into( diff --git a/src-tauri/src/providers/claude/mod.rs b/src-tauri/src/providers/claude/mod.rs index a61f099..1c032aa 100644 --- a/src-tauri/src/providers/claude/mod.rs +++ b/src-tauri/src/providers/claude/mod.rs @@ -33,6 +33,8 @@ fn definition_for(id: &str, display_name: &str, fallback_enabled: bool) -> Provi short_name: "Cl".into(), fallback_enabled, local_usage_source_note: Some("From your Claude usage history (estimated)".into()), + local_usage_source_key: Some("estimatedUsageHistorySource".into()), + pi_usage_source_key: Some("estimatedHistoryWithPiSource".into()), links: vec![ ProviderLink::new("Status", "https://status.anthropic.com/"), ProviderLink::new("Dashboard", "https://claude.ai/settings/usage"), @@ -47,7 +49,8 @@ fn definition_for(id: &str, display_name: &str, fallback_enabled: bool) -> Provi MetricSection::AlwaysVisible, true, "S", - ), + ) + .with_label_key("session"), MetricDefinition::quota( "claude.weekly", "Weekly", @@ -57,7 +60,8 @@ fn definition_for(id: &str, display_name: &str, fallback_enabled: bool) -> Provi MetricSection::AlwaysVisible, true, "W", - ), + ) + .with_label_key("weekly"), MetricDefinition::quota( "claude.sonnet", "Sonnet", @@ -86,29 +90,33 @@ fn definition_for(id: &str, display_name: &str, fallback_enabled: bool) -> Provi MetricSection::AlwaysVisible, false, "E", - ), - MetricDefinition::trend("claude.trend"), + ) + .with_label_key("extraUsage"), + MetricDefinition::trend("claude.trend").with_label_key("usageTrend"), MetricDefinition::usage( "claude.today", "Today", UsagePeriodSelection::Today, MetricSection::OnDemand, "T", - ), + ) + .with_label_key("today"), MetricDefinition::usage( "claude.yesterday", "Yesterday", UsagePeriodSelection::Yesterday, MetricSection::OnDemand, "Y", - ), + ) + .with_label_key("yesterday"), MetricDefinition::usage( "claude.last30", "Last 30 Days", UsagePeriodSelection::Last30Days, MetricSection::OnDemand, "M", - ), + ) + .with_label_key("last30Days"), ], }; if id != "claude" { @@ -458,17 +466,10 @@ impl ClaudeProvider { let retry = until.signed_duration_since(now).num_seconds().max(0) as u64; if let Some(mut snapshot) = self.last_good.lock().ok().and_then(|value| value.clone()) { snapshot.usage = usage; - snapshot.warnings.push( - "Claude live usage is rate limited; showing the last successful limits.".into(), - ); snapshot.notices = vec![rate_limit_notice(retry, true)]; snapshot.refreshed_at = now; return Ok(snapshot); } - warnings.push(format!( - "Claude live usage is rate limited; retrying in about {}.", - retry_minutes(retry) - )); return Ok(ProviderSnapshot { provider_id: self.provider_id().into(), plan: plan_name(credential), @@ -509,18 +510,10 @@ impl ClaudeProvider { } if let Some(mut snapshot) = self.last_good.lock().ok().and_then(|value| value.clone()) { snapshot.usage = usage; - snapshot.warnings.push(format!( - "Claude live usage is rate limited; retrying in about {}.", - retry_minutes(retry) - )); snapshot.notices = vec![rate_limit_notice(retry, true)]; snapshot.refreshed_at = now; return Ok(snapshot); } - warnings.push(format!( - "Claude live usage is rate limited; retrying in about {}.", - retry_minutes(retry) - )); return Ok(ProviderSnapshot { provider_id: self.provider_id().into(), plan: plan_name(credential), @@ -600,31 +593,16 @@ impl ClaudeProvider { } fn rate_limit_notice(retry_seconds: u64, showing_stale_limits: bool) -> ProviderNotice { - let retry = if retry_seconds == 0 { - "Ready to retry".to_owned() - } else { - format!("Retrying in about {}", retry_minutes(retry_seconds)) - }; ProviderNotice { id: "rateLimited".into(), title: "Live usage paused".into(), - message: if showing_stale_limits { - format!("Showing the last successful limits · {retry}") - } else { - retry - }, + message: String::new(), tone: ProviderNoticeTone::Warning, + retry_seconds: Some(retry_seconds), + showing_stale_limits: Some(showing_stale_limits), } } -fn retry_minutes(retry_seconds: u64) -> String { - let minutes = retry_seconds.div_ceil(60); - format!( - "{minutes} {}", - if minutes == 1 { "minute" } else { "minutes" } - ) -} - fn refresh_credential( client: &ClaudeClient, credential: &mut ClaudeCredential, @@ -751,7 +729,7 @@ mod tests { use super::{ accounts::{self, ClaudeAccount, ClaudeAccountDiscovery}, - auth::{ClaudeCredentialScope, ClaudeOAuthConfig}, + auth::{self, ClaudeCredentialGeneration, ClaudeCredentialScope, ClaudeOAuthConfig}, client::ClaudeClient, definition, definition_for, rate_limit_notice, runtime_configs, ClaudeError, ClaudeProvider, ClaudeRuntimeConfig, @@ -864,14 +842,79 @@ mod tests { fn rate_limit_notice_distinguishes_empty_and_stale_live_usage() { let empty = rate_limit_notice(301, false); assert_eq!(empty.title, "Live usage paused"); - assert_eq!(empty.message, "Retrying in about 6 minutes"); + assert_eq!(empty.message, ""); + assert_eq!(empty.retry_seconds, Some(301)); + assert_eq!(empty.showing_stale_limits, Some(false)); assert_eq!(empty.tone, ProviderNoticeTone::Warning); let stale = rate_limit_notice(60, true); - assert_eq!( - stale.message, - "Showing the last successful limits · Retrying in about 1 minute" + assert_eq!(stale.message, ""); + assert_eq!(stale.retry_seconds, Some(60)); + assert_eq!(stale.showing_stale_limits, Some(true)); + } + + #[test] + fn cooldown_rate_limit_keeps_only_typed_notice_dynamic_content() { + let directory = tempdir().unwrap(); + let account_root = directory.path().join("account"); + fs::create_dir_all(&account_root).unwrap(); + fs::write( + account_root.join(".credentials.json"), + credential_json("access", "refresh", "pro"), + ) + .unwrap(); + let scope = ClaudeCredentialScope::ConfigDir { + path: account_root, + keychain_literal: "test".into(), + }; + let mut candidate = auth::load_candidates(&scope).pop().unwrap(); + let provider = ClaudeProvider::new_scoped( + ClaudeRuntimeConfig { + definition: definition(), + credential_scope: scope, + account_identity: None, + log_roots: Vec::new(), + include_standard_logs: false, + include_pi: false, + }, + Arc::new(Storage::open(&directory.path().join("openquota.db")).unwrap()), + Arc::new(PricingStore::new(directory.path().join("pricing")).unwrap()), + ClaudeClient::new().unwrap(), ); + let now = Utc::now(); + provider.activate_live_usage_cache(candidate.fingerprint()); + *provider.last_good.lock().unwrap() = Some(ProviderSnapshot { + provider_id: "claude".into(), + plan: Some("pro".into()), + quotas: Vec::new(), + value_metrics: Vec::new(), + status_metrics: Vec::new(), + notices: Vec::new(), + usage: UsageHistory::default(), + warnings: vec!["keep this warning".into()], + refreshed_at: now, + }); + *provider.rate_limited_until.lock().unwrap() = Some(now + Duration::minutes(5)); + let mut generation = ClaudeCredentialGeneration::from_candidates(&[candidate.clone()]); + let config = ClaudeOAuthConfig { + usage_url: "https://example.test/usage".into(), + refresh_url: "https://example.test/refresh".into(), + client_id: "test".into(), + }; + + let snapshot = provider + .refresh_candidate( + &mut candidate, + &config, + now, + &crate::pricing::test_bundled_pricing(), + &mut generation, + ) + .unwrap(); + assert_eq!(snapshot.warnings, ["keep this warning"]); + assert_eq!(snapshot.notices.len(), 1); + assert_eq!(snapshot.notices[0].retry_seconds, Some(300)); + assert_eq!(snapshot.notices[0].showing_stale_limits, Some(true)); } #[test] diff --git a/src-tauri/src/providers/codex/local_usage.rs b/src-tauri/src/providers/codex/local_usage.rs index 4976540..2575c7f 100644 --- a/src-tauri/src/providers/codex/local_usage.rs +++ b/src-tauri/src/providers/codex/local_usage.rs @@ -64,12 +64,15 @@ pub fn scan_local_usage( false } }; - let source_note = if includes_pi { - "From your Codex logs and pi (estimated)" + let (source_note, source_key) = if includes_pi { + ( + "From your Codex logs and pi (estimated)", + "estimatedLogsWithPiSource", + ) } else { - "From your Codex logs (estimated)" + ("From your Codex logs (estimated)", "estimatedLogsSource") }; - Ok(accumulator.build(now, source_note)) + Ok(accumulator.build_with_source_key(now, source_note, Some(source_key))) } fn scan_codex_events( @@ -489,7 +492,11 @@ fn auto_review_fallback(timestamp: &str) -> &'static str { fn aggregate(events: Vec, now: DateTime, pricing: &ModelPricing) -> UsageHistory { let mut accumulator = DailyUsageAccumulator::default(); aggregate_into(events, now, pricing, &mut accumulator); - accumulator.build(now, "From your Codex logs (estimated)") + accumulator.build_with_source_key( + now, + "From your Codex logs (estimated)", + Some("estimatedLogsSource"), + ) } fn aggregate_into( diff --git a/src-tauri/src/providers/codex/mod.rs b/src-tauri/src/providers/codex/mod.rs index c8bf0a1..00ae8ab 100644 --- a/src-tauri/src/providers/codex/mod.rs +++ b/src-tauri/src/providers/codex/mod.rs @@ -32,6 +32,8 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "Cx".into(), fallback_enabled: true, local_usage_source_note: Some("From your Codex logs (estimated)".into()), + local_usage_source_key: Some("estimatedLogsSource".into()), + pi_usage_source_key: Some("estimatedLogsWithPiSource".into()), links: vec![ ProviderLink::new("Status", "https://status.openai.com/"), ProviderLink::new("Dashboard", "https://chatgpt.com/codex/settings/usage"), @@ -46,7 +48,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "S", - ), + ) + .with_label_key("session"), MetricDefinition::quota( "codex.weekly", "Weekly", @@ -56,7 +59,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "W", - ), + ) + .with_label_key("weekly"), MetricDefinition::quota( "codex.spark", "Spark", @@ -76,8 +80,9 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "SW", - ), - MetricDefinition::trend("codex.trend"), + ) + .with_label_key("sparkWeekly"), + MetricDefinition::trend("codex.trend").with_label_key("usageTrend"), MetricDefinition::value( "codex.credits", "Extra Usage", @@ -87,7 +92,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "E", None, - ), + ) + .with_label_key("extraUsage"), MetricDefinition::value( "codex.rateLimitResets", "Rate Limit Resets", @@ -97,28 +103,32 @@ pub(crate) fn definition() -> ProviderDefinition { false, "R", Some("resets"), - ), + ) + .with_label_key("rateLimitResets"), MetricDefinition::usage( "codex.today", "Today", UsagePeriodSelection::Today, MetricSection::OnDemand, "T", - ), + ) + .with_label_key("today"), MetricDefinition::usage( "codex.yesterday", "Yesterday", UsagePeriodSelection::Yesterday, MetricSection::OnDemand, "Y", - ), + ) + .with_label_key("yesterday"), MetricDefinition::usage( "codex.last30", "Last 30 Days", UsagePeriodSelection::Last30Days, MetricSection::OnDemand, "M", - ), + ) + .with_label_key("last30Days"), ], } } diff --git a/src-tauri/src/providers/copilot/mod.rs b/src-tauri/src/providers/copilot/mod.rs index ab1e448..ea31232 100644 --- a/src-tauri/src/providers/copilot/mod.rs +++ b/src-tauri/src/providers/copilot/mod.rs @@ -36,6 +36,8 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "Co".into(), fallback_enabled: false, local_usage_source_note: None, + local_usage_source_key: None, + pi_usage_source_key: None, links: vec![ ProviderLink::new("Status", "https://www.githubstatus.com/"), ProviderLink::new("Dashboard", "https://github.com/settings/billing"), @@ -50,7 +52,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "C", - ), + ) + .with_label_key("credits"), MetricDefinition::value( "copilot.extra", "Extra Usage", @@ -60,7 +63,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "E", None, - ), + ) + .with_label_key("extraUsage"), MetricDefinition::value( "copilot.orgCredits", "Org Credits", @@ -70,7 +74,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "OC", None, - ), + ) + .with_label_key("orgCredits"), MetricDefinition::value( "copilot.orgSpend", "Org Spend", @@ -80,7 +85,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "OS", None, - ), + ) + .with_label_key("orgSpend"), MetricDefinition::quota( "copilot.chat", "Chat", @@ -90,7 +96,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "Ch", - ), + ) + .with_label_key("chat"), MetricDefinition::quota( "copilot.completions", "Completions", @@ -100,7 +107,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "Cm", - ), + ) + .with_label_key("completions"), ], } } diff --git a/src-tauri/src/providers/cursor/mapper.rs b/src-tauri/src/providers/cursor/mapper.rs index 8946c0f..b2834e3 100644 --- a/src-tauri/src/providers/cursor/mapper.rs +++ b/src-tauri/src/providers/cursor/mapper.rs @@ -513,7 +513,11 @@ pub fn usage_history( None => {} } } - accumulator.build(now, "From your Cursor usage export") + accumulator.build_with_source_key( + now, + "From your Cursor usage export", + Some("cursorExportSource"), + ) } fn quota( diff --git a/src-tauri/src/providers/cursor/mod.rs b/src-tauri/src/providers/cursor/mod.rs index 24218f5..a812ece 100644 --- a/src-tauri/src/providers/cursor/mod.rs +++ b/src-tauri/src/providers/cursor/mod.rs @@ -35,6 +35,8 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "Cu".into(), fallback_enabled: true, local_usage_source_note: Some("From your Cursor usage export".into()), + local_usage_source_key: Some("cursorExportSource".into()), + pi_usage_source_key: None, links: vec![ ProviderLink::new("Status", "https://status.cursor.com/"), ProviderLink::new("Dashboard", "https://www.cursor.com/dashboard"), @@ -49,7 +51,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "U", - ), + ) + .with_label_key("totalUsage"), MetricDefinition::quota( "cursor.auto", "Auto Usage", @@ -59,7 +62,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "A", - ), + ) + .with_label_key("autoUsage"), MetricDefinition::quota( "cursor.api", "API Usage", @@ -69,7 +73,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "AP", - ), + ) + .with_label_key("apiUsage"), MetricDefinition::quota_or_value( "cursor.onDemand", "Extra Usage", @@ -78,7 +83,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "E", - ), + ) + .with_label_key("extraUsage"), MetricDefinition::quota( "cursor.requests", "Requests", @@ -88,7 +94,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "R", - ), + ) + .with_label_key("requestsLabel"), MetricDefinition::value( "cursor.credits", "Credits", @@ -98,29 +105,33 @@ pub(crate) fn definition() -> ProviderDefinition { false, "C", None, - ), - MetricDefinition::trend("cursor.trend"), + ) + .with_label_key("credits"), + MetricDefinition::trend("cursor.trend").with_label_key("usageTrend"), MetricDefinition::usage( "cursor.today", "Today", UsagePeriodSelection::Today, MetricSection::OnDemand, "T", - ), + ) + .with_label_key("today"), MetricDefinition::usage( "cursor.yesterday", "Yesterday", UsagePeriodSelection::Yesterday, MetricSection::OnDemand, "Y", - ), + ) + .with_label_key("yesterday"), MetricDefinition::usage( "cursor.last30", "Last 30 Days", UsagePeriodSelection::Last30Days, MetricSection::OnDemand, "M", - ), + ) + .with_label_key("last30Days"), ], } } diff --git a/src-tauri/src/providers/daily_usage.rs b/src-tauri/src/providers/daily_usage.rs index 059a621..80c9b14 100644 --- a/src-tauri/src/providers/daily_usage.rs +++ b/src-tauri/src/providers/daily_usage.rs @@ -154,7 +154,17 @@ impl DailyUsageAccumulator { /// Builds the three spend periods. Idle or unknown-only periods stay unbacked (`None`), while /// the trend receives only active days and fills calendar gaps in the UI layer. + #[cfg(test)] pub fn build(self, now: DateTime, source_note: &str) -> UsageHistory { + self.build_with_source_key(now, source_note, None) + } + + pub fn build_with_source_key( + self, + now: DateTime, + source_note: &str, + source_key: Option<&str>, + ) -> UsageHistory { let today = now.with_timezone(&Local).date_naive(); let yesterday = today.checked_sub_days(Days::new(1)); let daily = self @@ -173,9 +183,9 @@ impl DailyUsageAccumulator { }) .collect(); - let today_period = self.period_for_days(&[today], source_note); + let today_period = self.period_for_days(&[today], source_note, source_key); let yesterday_period = - yesterday.and_then(|date| self.period_for_days(&[date], source_note)); + yesterday.and_then(|date| self.period_for_days(&[date], source_note, source_key)); let active_days = self .days .iter() @@ -191,7 +201,7 @@ impl DailyUsageAccumulator { .collect::>(); unknown_models.sort(); let last_30_days = self - .period_for_days(&active_days, source_note) + .period_for_days(&active_days, source_note, source_key) .map(|mut period| { period.estimate_complete = unknown_models.is_empty(); period.unknown_models.clone_from(&unknown_models); @@ -207,7 +217,12 @@ impl DailyUsageAccumulator { } } - fn period_for_days(&self, dates: &[NaiveDate], source_note: &str) -> Option { + fn period_for_days( + &self, + dates: &[NaiveDate], + source_note: &str, + source_key: Option<&str>, + ) -> Option { let mut total = DayAccumulator::default(); let mut unknown_models = HashSet::new(); for date in dates { @@ -233,7 +248,7 @@ impl DailyUsageAccumulator { estimated_cost_usd: Some(total.cost), cost_estimated: total.cost_estimated, estimate_complete: unknown_models.is_empty(), - model_breakdown: model_breakdown(&total, source_note), + model_breakdown: model_breakdown(&total, source_note, source_key), unknown_models, }) } @@ -252,7 +267,11 @@ fn has_usage(day: &DayAccumulator) -> bool { day.tokens > 0 || day.cost > 0.0 } -fn model_breakdown(day: &DayAccumulator, source_note: &str) -> Option { +fn model_breakdown( + day: &DayAccumulator, + source_note: &str, + source_key: Option<&str>, +) -> Option { let mut entries = day .models .values() @@ -340,6 +359,7 @@ fn model_breakdown(day: &DayAccumulator, source_note: &str) -> Option ProviderDefinition { short_name: "D".into(), fallback_enabled: false, local_usage_source_note: None, + local_usage_source_key: None, + pi_usage_source_key: None, links: vec![ProviderLink::new( "Dashboard", "https://app.devin.ai/settings/plans", @@ -40,7 +42,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "D", - ), + ) + .with_label_key("daily"), MetricDefinition::quota( "devin.weekly", "Weekly", @@ -50,7 +53,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "W", - ), + ) + .with_label_key("weekly"), MetricDefinition::value( "devin.extra", "Extra Balance", @@ -60,7 +64,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "E", None, - ), + ) + .with_label_key("extraBalance"), ], } } diff --git a/src-tauri/src/providers/grok/local_usage.rs b/src-tauri/src/providers/grok/local_usage.rs index 92ff5ee..6142091 100644 --- a/src-tauri/src/providers/grok/local_usage.rs +++ b/src-tauri/src/providers/grok/local_usage.rs @@ -22,6 +22,7 @@ use super::GrokError; const LOG_CACHE_SCHEMA_VERSION: u8 = 1; const SOURCE_NOTE: &str = "From your Grok logs (estimated)"; +const SOURCE_KEY: &str = "estimatedLogsSource"; #[derive(Debug, Clone)] pub struct GrokLogUsageScanner { @@ -224,7 +225,7 @@ fn aggregate(events: Vec, now: DateTime, pricing: &ModelPricing accumulator.add_unknown_model(date, model); } } - accumulator.build(now, SOURCE_NOTE) + accumulator.build_with_source_key(now, SOURCE_NOTE, Some(SOURCE_KEY)) } fn integer_u64(value: &Value) -> Option { diff --git a/src-tauri/src/providers/grok/mapper.rs b/src-tauri/src/providers/grok/mapper.rs index 6d44df6..8010a49 100644 --- a/src-tauri/src/providers/grok/mapper.rs +++ b/src-tauri/src/providers/grok/mapper.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use reqwest::StatusCode; use serde_json::Value; -use crate::models::{QuotaFormat, QuotaWindow, StatusMetric, StatusTone}; +use crate::models::{QuotaFormat, QuotaWindow, StatusMetric, StatusMetricUnit, StatusTone}; use super::{client::GrokResponse, GrokError}; @@ -26,20 +26,19 @@ struct CreditsConfig { pub fn map_credits(response: &GrokResponse) -> Result { require_success(response.status)?; let config = decode_credits(&response.body)?; + let has_cap = config.on_demand_cap > 0.0; let status_metrics = vec![StatusMetric { id: "payAsYouGo".into(), label: "Pay as you go".into(), - text: if config.on_demand_cap > 0.0 { - format!("{} cap", format_units(config.on_demand_cap)) - } else { - "Disabled".into() - }, - tone: if config.on_demand_cap > 0.0 { + text: String::new(), + tone: if has_cap { StatusTone::Positive } else { StatusTone::Neutral }, subtitle: None, + value: has_cap.then_some(config.on_demand_cap), + unit: has_cap.then_some(StatusMetricUnit::Cap), }]; let quotas = (config.period_type == WEEKLY_PERIOD_TYPE) .then(|| QuotaWindow { @@ -148,14 +147,6 @@ fn finite_number(value: &Value) -> Option { .filter(|value: &f64| value.is_finite()) } -fn format_units(value: f64) -> String { - if value.fract() == 0.0 { - format!("{value:.0}") - } else { - value.to_string() - } -} - #[cfg(test)] mod tests { use reqwest::StatusCode; @@ -205,7 +196,9 @@ mod tests { weekly.resets_at.unwrap().to_rfc3339(), "2026-07-07T21:36:52.140114+00:00" ); - assert_eq!(mapped.status_metrics[0].text, "Disabled"); + assert_eq!(mapped.status_metrics[0].text, ""); + assert_eq!(mapped.status_metrics[0].value, None); + assert_eq!(mapped.status_metrics[0].unit, None); assert_eq!(mapped.status_metrics[0].tone, StatusTone::Neutral); } @@ -218,7 +211,12 @@ mod tests { ))) .unwrap(); - assert_eq!(mapped.status_metrics[0].text, "2500 cap"); + assert_eq!(mapped.status_metrics[0].text, ""); + assert_eq!(mapped.status_metrics[0].value, Some(2500.0)); + assert_eq!( + mapped.status_metrics[0].unit, + Some(crate::models::StatusMetricUnit::Cap) + ); assert_eq!(mapped.status_metrics[0].tone, StatusTone::Positive); } @@ -232,7 +230,7 @@ mod tests { .unwrap(); assert!(mapped.quotas.is_empty()); - assert_eq!(mapped.status_metrics[0].text, "Disabled"); + assert_eq!(mapped.status_metrics[0].text, ""); } #[test] diff --git a/src-tauri/src/providers/grok/mod.rs b/src-tauri/src/providers/grok/mod.rs index 082fa0e..4f5b837 100644 --- a/src-tauri/src/providers/grok/mod.rs +++ b/src-tauri/src/providers/grok/mod.rs @@ -35,6 +35,8 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "G".into(), fallback_enabled: false, local_usage_source_note: Some("From your Grok logs (estimated)".into()), + local_usage_source_key: Some("estimatedLogsSource".into()), + pi_usage_source_key: None, links: vec![ProviderLink::new("Usage", "https://grok.com/?_s=usage")], metrics: vec![ MetricDefinition::quota( @@ -46,7 +48,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "W", - ), + ) + .with_label_key("weekly"), MetricDefinition::status( "grok.payAsYouGo", "Extra Usage", @@ -55,29 +58,33 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "E", - ), - MetricDefinition::trend("grok.trend"), + ) + .with_label_key("extraUsage"), + MetricDefinition::trend("grok.trend").with_label_key("usageTrend"), MetricDefinition::usage( "grok.today", "Today", UsagePeriodSelection::Today, MetricSection::OnDemand, "T", - ), + ) + .with_label_key("today"), MetricDefinition::usage( "grok.yesterday", "Yesterday", UsagePeriodSelection::Yesterday, MetricSection::OnDemand, "Y", - ), + ) + .with_label_key("yesterday"), MetricDefinition::usage( "grok.last30", "Last 30 Days", UsagePeriodSelection::Last30Days, MetricSection::OnDemand, "M", - ), + ) + .with_label_key("last30Days"), ], } } diff --git a/src-tauri/src/providers/grok/tests.rs b/src-tauri/src/providers/grok/tests.rs index 7c37f32..d0ba5e8 100644 --- a/src-tauri/src/providers/grok/tests.rs +++ b/src-tauri/src/providers/grok/tests.rs @@ -127,7 +127,8 @@ fn weekly_status_plan_and_local_history_form_one_snapshot() { assert_eq!(snapshot.plan.as_deref(), Some("SuperGrok Heavy")); assert_eq!(snapshot.quotas[0].id, "weekly"); assert_eq!(snapshot.quotas[0].used_percent, 99.0); - assert_eq!(snapshot.status_metrics[0].text, "Disabled"); + assert_eq!(snapshot.status_metrics[0].text, ""); + assert_eq!(snapshot.status_metrics[0].value, None); assert_eq!(snapshot.status_metrics[0].tone, StatusTone::Neutral); assert_eq!(snapshot.usage.today.unwrap().tokens, 2_000_000); assert!(snapshot.warnings.is_empty()); @@ -267,7 +268,7 @@ fn monthly_accounts_keep_extra_usage_without_a_fake_weekly_meter() { let snapshot = provider.refresh_inner().unwrap(); assert!(snapshot.quotas.is_empty()); - assert_eq!(snapshot.status_metrics[0].text, "Disabled"); + assert_eq!(snapshot.status_metrics[0].text, ""); server.finish(); } diff --git a/src-tauri/src/providers/opencode/mod.rs b/src-tauri/src/providers/opencode/mod.rs index f745e49..100db95 100644 --- a/src-tauri/src/providers/opencode/mod.rs +++ b/src-tauri/src/providers/opencode/mod.rs @@ -19,7 +19,7 @@ use crate::{ use self::{ paths::OpenCodePaths, - scanner::{OpenCodeUsageScanner, USAGE_SOURCE_NOTE}, + scanner::{OpenCodeUsageScanner, USAGE_SOURCE_KEY, USAGE_SOURCE_NOTE}, windows::OpenCodeWindows, }; @@ -32,6 +32,8 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "OC".into(), fallback_enabled: false, local_usage_source_note: Some(USAGE_SOURCE_NOTE.into()), + local_usage_source_key: Some(USAGE_SOURCE_KEY.into()), + pi_usage_source_key: None, links: vec![ProviderLink::new("Dashboard", "https://opencode.ai/auth")], metrics: vec![ MetricDefinition::quota( @@ -43,7 +45,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "S", - ), + ) + .with_label_key("session"), MetricDefinition::quota( "opencode.weekly", "Weekly", @@ -53,7 +56,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "W", - ), + ) + .with_label_key("weekly"), MetricDefinition::quota( "opencode.monthly", "Monthly", @@ -63,29 +67,33 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "M", - ), - MetricDefinition::trend("opencode.trend"), + ) + .with_label_key("monthly"), + MetricDefinition::trend("opencode.trend").with_label_key("usageTrend"), MetricDefinition::usage( "opencode.today", "Today", UsagePeriodSelection::Today, MetricSection::OnDemand, "T", - ), + ) + .with_label_key("today"), MetricDefinition::usage( "opencode.yesterday", "Yesterday", UsagePeriodSelection::Yesterday, MetricSection::OnDemand, "Y", - ), + ) + .with_label_key("yesterday"), MetricDefinition::usage( "opencode.last30", "Last 30 Days", UsagePeriodSelection::Last30Days, MetricSection::OnDemand, "30", - ), + ) + .with_label_key("last30Days"), ], } } diff --git a/src-tauri/src/providers/opencode/scanner.rs b/src-tauri/src/providers/opencode/scanner.rs index 4acc6e7..6b27496 100644 --- a/src-tauri/src/providers/opencode/scanner.rs +++ b/src-tauri/src/providers/opencode/scanner.rs @@ -17,6 +17,7 @@ use super::{ const SCAN_DAYS: i64 = 33; pub(crate) const USAGE_SOURCE_NOTE: &str = "From your OpenCode local database; missing costs use catalog estimates"; +pub(crate) const USAGE_SOURCE_KEY: &str = "openCodeDatabaseSource"; #[derive(Debug)] pub(crate) struct OpenCodeUsageScan { @@ -233,7 +234,7 @@ fn aggregate_history(records: &[UsageRecord], now: DateTime) -> UsageHistor accumulator.add_unknown_model(date, &record.model); } } - accumulator.build(now, USAGE_SOURCE_NOTE) + accumulator.build_with_source_key(now, USAGE_SOURCE_NOTE, Some(USAGE_SOURCE_KEY)) } #[cfg(test)] diff --git a/src-tauri/src/providers/openrouter/mod.rs b/src-tauri/src/providers/openrouter/mod.rs index a48ef11..156508c 100644 --- a/src-tauri/src/providers/openrouter/mod.rs +++ b/src-tauri/src/providers/openrouter/mod.rs @@ -32,6 +32,8 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "OR".into(), fallback_enabled: false, local_usage_source_note: None, + local_usage_source_key: None, + pi_usage_source_key: None, links: vec![ ProviderLink::new("Activity", "https://openrouter.ai/activity"), ProviderLink::new("Credits", "https://openrouter.ai/settings/credits"), @@ -46,7 +48,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "C", - ), + ) + .with_label_key("credits"), MetricDefinition::value( "openrouter.balance", "Balance", @@ -56,7 +59,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "B", None, - ), + ) + .with_label_key("balance"), MetricDefinition::value( "openrouter.today", "Today", @@ -66,7 +70,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "T", None, - ), + ) + .with_label_key("today"), MetricDefinition::value( "openrouter.week", "This Week", @@ -76,7 +81,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "W", None, - ), + ) + .with_label_key("thisWeek"), MetricDefinition::value( "openrouter.month", "This Month", @@ -86,7 +92,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "M", None, - ), + ) + .with_label_key("thisMonth"), MetricDefinition::quota( "openrouter.keyLimit", "Key Limit", @@ -96,7 +103,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "K", - ), + ) + .with_label_key("keyLimit"), ], } } diff --git a/src-tauri/src/providers/registry.rs b/src-tauri/src/providers/registry.rs index c92f4c0..db08f82 100644 --- a/src-tauri/src/providers/registry.rs +++ b/src-tauri/src/providers/registry.rs @@ -306,6 +306,8 @@ mod tests { short_name: "P".into(), fallback_enabled: true, local_usage_source_note: None, + local_usage_source_key: None, + pi_usage_source_key: None, links: vec![], metrics: vec![MetricDefinition::new( format!("{id}.session"), @@ -350,6 +352,28 @@ mod tests { assert!(registry.metric("first.session").is_some()); } + #[test] + fn metric_labels_use_explicit_keys_without_id_suffix_inference() { + let mut custom = definition("custom"); + custom.metrics[0].label = "Custom Session".into(); + let custom_registry = ProviderRegistry::new(vec![runtime(custom)]).unwrap(); + assert_eq!( + custom_registry.metric("custom.session").unwrap().label_key, + None + ); + + let builtin = + ProviderRegistry::new(vec![runtime(crate::providers::codex::definition())]).unwrap(); + assert_eq!( + builtin + .metric("codex.session") + .unwrap() + .label_key + .as_deref(), + Some("session") + ); + } + #[test] fn registry_exposes_only_trimmed_http_provider_links() { let mut provider = definition("links"); diff --git a/src-tauri/src/providers/zai/mod.rs b/src-tauri/src/providers/zai/mod.rs index 909b61b..6b74c83 100644 --- a/src-tauri/src/providers/zai/mod.rs +++ b/src-tauri/src/providers/zai/mod.rs @@ -28,6 +28,8 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "Z".into(), fallback_enabled: false, local_usage_source_note: None, + local_usage_source_key: None, + pi_usage_source_key: None, links: vec![ ProviderLink::new( "Dashboard", @@ -45,7 +47,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "S", - ), + ) + .with_label_key("session"), MetricDefinition::quota( "zai.weekly", "Weekly", @@ -55,7 +58,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "W", - ), + ) + .with_label_key("weekly"), MetricDefinition::quota( "zai.webSearches", "Web Searches", @@ -65,7 +69,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "Search", - ), + ) + .with_label_key("webSearches"), ], } } diff --git a/src-tauri/src/service.rs b/src-tauri/src/service.rs index 9fec27e..5cd2bea 100644 --- a/src-tauri/src/service.rs +++ b/src-tauri/src/service.rs @@ -9,6 +9,7 @@ use chrono::Utc; use crate::{ models::{ MetricSource, ProviderErrorKind, ProviderSnapshot, ProviderViewState, SnapshotSource, + StatusMetric, StatusMetricUnit, StatusTone, }, policy::{FAILURE_RETRY_BACKOFF, REFRESH_INTERVAL, STALE_AFTER}, providers::{ProviderError, ProviderRefresh, ProviderRegistry}, @@ -611,13 +612,27 @@ fn validate_snapshot( || snapshot .status_metrics .iter() - .any(|metric| metric.text.trim().is_empty() || metric.label.trim().is_empty()) + .any(|metric| metric.label.trim().is_empty() || !status_metric_has_content(metric)) { return Err(snapshot_contract_error()); } Ok(snapshot) } +fn status_metric_has_content(metric: &StatusMetric) -> bool { + if !metric.text.trim().is_empty() { + return true; + } + + match (metric.id.as_str(), metric.tone, metric.value, metric.unit) { + ("payAsYouGo", StatusTone::Positive, Some(value), Some(StatusMetricUnit::Cap)) => { + value.is_finite() && value > 0.0 + } + ("payAsYouGo", StatusTone::Neutral, None, None) => true, + _ => false, + } +} + fn has_duplicate_ids<'a>(mut ids: impl Iterator) -> bool { let mut seen = std::collections::HashSet::new(); ids.any(|id| !seen.insert(id)) @@ -674,8 +689,8 @@ mod tests { use crate::{ models::{ MetricDefinition, MetricSection, MetricSource, ProviderDefinition, ProviderErrorKind, - ProviderSnapshot, ProviderViewState, QuotaFormat, QuotaWindow, SnapshotSource, - StatusMetric, StatusTone, UsageHistory, + ProviderNotice, ProviderNoticeTone, ProviderSnapshot, ProviderViewState, QuotaFormat, + QuotaWindow, SnapshotSource, StatusMetric, StatusMetricUnit, StatusTone, UsageHistory, }, policy::{FAILURE_RETRY_BACKOFF, STALE_AFTER}, providers::{ @@ -811,6 +826,8 @@ mod tests { short_name: "T".into(), fallback_enabled: true, local_usage_source_note: None, + local_usage_source_key: None, + pi_usage_source_key: None, links: vec![], metrics: vec![MetricDefinition::new( format!("{id}.session"), @@ -1012,6 +1029,8 @@ mod tests { short_name: "D".into(), fallback_enabled: true, local_usage_source_note: None, + local_usage_source_key: None, + pi_usage_source_key: None, links: Vec::new(), metrics: vec![ MetricDefinition::quota( @@ -1025,9 +1044,9 @@ mod tests { "S", ), MetricDefinition::status( - "dynamic.extra", + "dynamic.payAsYouGo", "Extra Usage", - "extra", + "payAsYouGo", true, MetricSection::OnDemand, false, @@ -1051,15 +1070,55 @@ mod tests { source_note: None, }); snapshot.status_metrics.push(StatusMetric { - id: "extra".into(), + id: "payAsYouGo".into(), label: "Extra Usage".into(), text: "2500 cap".into(), tone: StatusTone::Positive, subtitle: None, + value: None, + unit: None, }); assert!(validate_snapshot(®istry, "dynamic", snapshot.clone()).is_ok()); + let mut typed_cap = snapshot.clone(); + typed_cap.status_metrics[0].text.clear(); + typed_cap.status_metrics[0].value = Some(2500.0); + typed_cap.status_metrics[0].unit = Some(StatusMetricUnit::Cap); + assert!(validate_snapshot(®istry, "dynamic", typed_cap.clone()).is_ok()); + + let mut disabled = typed_cap.clone(); + disabled.status_metrics[0].tone = StatusTone::Neutral; + disabled.status_metrics[0].value = None; + disabled.status_metrics[0].unit = None; + assert!(validate_snapshot(®istry, "dynamic", disabled).is_ok()); + + let mut missing_value = typed_cap.clone(); + missing_value.status_metrics[0].value = None; + assert!(validate_snapshot(®istry, "dynamic", missing_value).is_err()); + + let mut nan_value = typed_cap.clone(); + nan_value.status_metrics[0].value = Some(f64::NAN); + assert!(validate_snapshot(®istry, "dynamic", nan_value).is_err()); + + let mut unknown_empty = typed_cap.clone(); + unknown_empty.status_metrics[0].id = "unknown".into(); + unknown_empty.status_metrics[0].text.clear(); + unknown_empty.status_metrics[0].value = None; + unknown_empty.status_metrics[0].unit = None; + assert!(validate_snapshot(®istry, "dynamic", unknown_empty).is_err()); + + let mut typed_notice = test_snapshot("dynamic"); + typed_notice.notices.push(ProviderNotice { + id: "rateLimited".into(), + title: "Live usage paused".into(), + message: String::new(), + tone: ProviderNoticeTone::Warning, + retry_seconds: Some(60), + showing_stale_limits: Some(false), + }); + assert!(validate_snapshot(®istry, "dynamic", typed_notice).is_ok()); + let mut missing_unit = snapshot.clone(); missing_unit.quotas[0].unit = Some(" ".into()); assert!(validate_snapshot(®istry, "dynamic", missing_unit).is_err()); @@ -1427,12 +1486,15 @@ mod tests { let registry = Arc::new(ProviderRegistry::new(vec![provider]).unwrap()); let service = Arc::new(ProviderService::new(registry, storage)); - tauri::async_runtime::block_on(service.refresh("failing", false)); - tauri::async_runtime::block_on(service.refresh("failing", false)); + refresh_with_test_timeout(&service, "failing", false); + refresh_with_test_timeout(&service, "failing", false); assert_eq!(calls.load(Ordering::SeqCst), 1); - tauri::async_runtime::block_on(service.refresh("failing", true)); + refresh_with_test_timeout(&service, "failing", true); assert_eq!(calls.load(Ordering::SeqCst), 2); + wait_until("forced refresh runner should become idle", || { + refresh_runner_is_idle(&service, "failing") + }); service.last_failed_refresh.lock().unwrap().insert( "failing".into(), @@ -1440,7 +1502,7 @@ mod tests { .checked_sub(FAILURE_RETRY_BACKOFF) .unwrap(), ); - tauri::async_runtime::block_on(service.refresh("failing", false)); + refresh_with_test_timeout(&service, "failing", false); assert_eq!(calls.load(Ordering::SeqCst), 3); } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ffca4fc..d7485b4 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -532,8 +532,12 @@ impl SettingsService { } } } + let resolved_language = crate::native_i18n::Locale::for_preference(&settings.language) + .language_tag() + .to_owned(); SettingsViewState { settings, + resolved_language, account_revision, renamable_provider_ids, notification_permission: notification_permission.into(), @@ -605,6 +609,7 @@ fn normalize_with_persisted_accounts( let catalog = registry.catalog(); let migrating_to_multi_provider = settings.schema_version < 3; settings.schema_version = 6; + settings.language = crate::models::normalize_language_preference(&settings.language).to_owned(); settings.dismissed_update_version = settings .dismissed_update_version .take() diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index a442466..2eb6e47 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -513,6 +513,7 @@ mod tests { variants: None, }], source_note: "From your Codex logs (estimated)".into(), + source_key: Some("estimatedLogsSource".into()), }), unknown_models: Vec::new(), }), diff --git a/src-tauri/src/tray_presentation.rs b/src-tauri/src/tray_presentation.rs index 52b8bde..f5c9804 100644 --- a/src-tauri/src/tray_presentation.rs +++ b/src-tauri/src/tray_presentation.rs @@ -208,6 +208,7 @@ fn resolved_groups( settings: &AppSettings, registry: &ProviderRegistry, ) -> Vec { + let locale = crate::native_i18n::Locale::for_preference(&settings.language); settings .providers .iter() @@ -225,7 +226,7 @@ fn resolved_groups( .filter_map(|metric| { let metric_definition = registry.metric(&metric.id)?; let mut resolved = - tray_metric(metric_definition, snapshot, settings.usage_display)?; + tray_metric(metric_definition, snapshot, settings.usage_display, locale)?; resolved.detail = format!( "{} {}", settings.provider_display_name(definition), @@ -247,8 +248,14 @@ fn tray_metric( definition: &MetricDefinition, snapshot: &ProviderSnapshot, display: UsageDisplay, + locale: crate::native_i18n::Locale, ) -> Option { let tray = definition.tray.as_ref()?; + let localized_label = crate::native_i18n::metric_label( + locale, + definition.label_key.as_deref(), + &definition.label, + ); let quota = |id: &str| { snapshot .quotas @@ -262,14 +269,15 @@ fn tray_metric( UsageDisplay::Used => used, UsageDisplay::Left => (limit - used).max(0.0), }; - let word = match display { - UsageDisplay::Used => "used", - UsageDisplay::Left => "left", - }; - let unit = quota.unit.as_deref().unwrap_or("requests"); + let word = + crate::native_i18n::usage_word(locale, display == UsageDisplay::Used); + let unit = crate::native_i18n::count_unit( + locale, + quota.unit.as_deref().unwrap_or("requests"), + ); return TrayMetric { value: format!("{value:.0}"), - detail: format!("{} {value:.0} {unit} {word}", quota.label), + detail: format!("{localized_label} {value:.0} {unit} {word}"), gauge: used_fraction.map(|used_fraction| TrayGauge { display_fraction: match display { UsageDisplay::Used => used_fraction, @@ -287,13 +295,10 @@ fn tray_metric( UsageDisplay::Left => 1.0 - used_fraction, }; let percent = display_fraction * 100.0; - let word = match display { - UsageDisplay::Used => "used", - UsageDisplay::Left => "left", - }; + let word = crate::native_i18n::usage_word(locale, display == UsageDisplay::Used); TrayMetric { value: format!("{percent:.0}%"), - detail: format!("{} {percent:.0}% {word}", quota.label), + detail: format!("{localized_label} {percent:.0}% {word}"), gauge: Some(TrayGauge { display_fraction, #[cfg(any(not(target_os = "macos"), test))] @@ -304,15 +309,25 @@ fn tray_metric( }; match &definition.source { MetricSource::Quota { source_id, .. } => quota(source_id), - MetricSource::QuotaOrValue { source_id, .. } => { - quota(source_id).or_else(|| value_metric(snapshot, source_id, tray.suffix.as_deref())) + MetricSource::QuotaOrValue { source_id, .. } => quota(source_id).or_else(|| { + value_metric( + snapshot, + source_id, + tray.suffix.as_deref(), + &localized_label, + ) + }), + MetricSource::Value { source_id } => value_metric( + snapshot, + source_id, + tray.suffix.as_deref(), + &localized_label, + ), + MetricSource::Status { source_id } => { + status_metric(snapshot, source_id, &localized_label, locale) } - MetricSource::Value { source_id } => { - value_metric(snapshot, source_id, tray.suffix.as_deref()) - } - MetricSource::Status { source_id } => status_metric(snapshot, source_id), MetricSource::Usage { period } => { - usage_metric(&definition.label, usage_period(snapshot, *period)) + usage_metric(&localized_label, usage_period(snapshot, *period)) } MetricSource::Trend => None, } @@ -326,14 +341,27 @@ fn usage_period(snapshot: &ProviderSnapshot, period: UsagePeriodSelection) -> Op } } -fn status_metric(snapshot: &ProviderSnapshot, source_id: &str) -> Option { +fn status_metric( + snapshot: &ProviderSnapshot, + source_id: &str, + localized_label: &str, + locale: crate::native_i18n::Locale, +) -> Option { let metric = snapshot .status_metrics .iter() .find(|metric| metric.id == source_id)?; + let value = crate::native_i18n::status_metric_text( + locale, + &metric.id, + metric.tone, + metric.value, + metric.unit, + &metric.text, + ); Some(TrayMetric { - value: metric.text.clone(), - detail: format!("{} {}", metric.label, metric.text), + value: value.clone(), + detail: format!("{localized_label} {value}"), gauge: None, }) } @@ -342,6 +370,7 @@ fn value_metric( snapshot: &ProviderSnapshot, source_id: &str, tray_suffix: Option<&str>, + localized_label: &str, ) -> Option { let metric = snapshot .value_metrics @@ -364,7 +393,7 @@ fn value_metric( .unwrap_or(value); Some(TrayMetric { value, - detail: format!("{} {detail}", metric.label), + detail: format!("{localized_label} {detail}"), gauge: None, }) } @@ -699,15 +728,33 @@ mod tests { let catalog = ProviderRegistry::from_definitions(vec![cursor::definition()]).unwrap(); let definition = catalog.metric("cursor.requests").unwrap(); - let left = - super::tray_metric(definition, &snapshot, crate::models::UsageDisplay::Left).unwrap(); - let used = - super::tray_metric(definition, &snapshot, crate::models::UsageDisplay::Used).unwrap(); + let left = super::tray_metric( + definition, + &snapshot, + crate::models::UsageDisplay::Left, + crate::native_i18n::Locale::En, + ) + .unwrap(); + let used = super::tray_metric( + definition, + &snapshot, + crate::models::UsageDisplay::Used, + crate::native_i18n::Locale::En, + ) + .unwrap(); + let simplified_chinese = super::tray_metric( + definition, + &snapshot, + crate::models::UsageDisplay::Left, + crate::native_i18n::Locale::ZhCn, + ) + .unwrap(); assert_eq!(left.value, "75"); assert_eq!(left.detail, "Requests 75 searches left"); assert_eq!(used.value, "25"); assert_eq!(used.detail, "Requests 25 searches used"); + assert_eq!(simplified_chinese.detail, "请求 75 次搜索 剩余"); assert_eq!( left.gauge, Some(TrayGauge { @@ -811,6 +858,7 @@ mod tests { catalog.metric("codex.credits").unwrap(), &snapshot, crate::models::UsageDisplay::Left, + crate::native_i18n::Locale::En, ) .unwrap(); assert_eq!(metric.value, "$33 · 821 credits"); @@ -828,9 +876,11 @@ mod tests { status_metrics: vec![StatusMetric { id: "payAsYouGo".into(), label: "Extra Usage".into(), - text: "2500 cap".into(), + text: String::new(), tone: StatusTone::Positive, subtitle: None, + value: Some(2500.0), + unit: Some(crate::models::StatusMetricUnit::Cap), }], notices: Vec::new(), usage: UsageHistory::default(), @@ -847,8 +897,13 @@ mod tests { "E", ); - let metric = - super::tray_metric(&definition, &snapshot, crate::models::UsageDisplay::Left).unwrap(); + let metric = super::tray_metric( + &definition, + &snapshot, + crate::models::UsageDisplay::Left, + crate::native_i18n::Locale::En, + ) + .unwrap(); assert_eq!(metric.value, "2500 cap"); assert_eq!(metric.detail, "Extra Usage 2500 cap"); diff --git a/src/App.customization.test.ts b/src/App.customization.test.ts index 1c228c0..b28a46e 100644 --- a/src/App.customization.test.ts +++ b/src/App.customization.test.ts @@ -97,7 +97,7 @@ describe('OpenQuota customization persistence and reorder', () => { await screen.findByText('Plus'); await fireEvent.click(screen.getByLabelText('Open options')); await fireEvent.click(screen.getByRole('button', { name: 'Customize' })); - const toggle = screen.getByRole('checkbox', { name: 'Enable codex' }); + const toggle = screen.getByRole('checkbox', { name: 'Enable Codex' }); await fireEvent.click(toggle); await waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith( @@ -142,7 +142,7 @@ describe('OpenQuota customization persistence and reorder', () => { await fireEvent.click(launchAtLogin); await waitFor(() => expect(launchAtLogin).not.toBeChecked()); - expect(screen.getByRole('alert')).toHaveTextContent('Launch at login is unavailable.'); + expect(screen.getByRole('alert')).toHaveTextContent('Settings could not be saved.'); expect(mocks.invoke).toHaveBeenCalledWith('get_app_settings'); }); diff --git a/src/App.svelte b/src/App.svelte index 412e8f7..d900295 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -34,6 +34,7 @@ import { restoreCustomization } from './lib/customizationHistory'; import Dashboard from './lib/Dashboard.svelte'; import Icon from './lib/Icon.svelte'; + import { getUiLanguage, setUiLanguage, t, uiLanguage } from './lib/i18n'; import { createListenerRegistry } from './lib/listenerRegistry'; import { emptyProviderCatalog, ProviderCatalogIndex } from './lib/metrics'; import { springMotion } from './lib/motion'; @@ -115,6 +116,8 @@ if (settingsState.settings.theme === 'system') delete root.dataset.theme; else root.dataset.theme = settingsState.settings.theme; root.dataset.density = settingsState.settings.density; + setUiLanguage(settingsState.settings.language, settingsState.resolvedLanguage); + root.lang = getUiLanguage(); }); $effect(() => { @@ -282,7 +285,7 @@ ]), ), }; - settingsError = 'OpenQuota could not start a provider refresh.'; + settingsError = t('providerRefreshStartFailed'); } } async function refreshProvider(providerId: string) { @@ -306,7 +309,7 @@ }, }; } - settingsError = `${providerDisplayName(providerId)} usage could not be refreshed.`; + settingsError = t('providerRefreshFailed', { provider: providerDisplayName(providerId) }); } } function openProviderLink(providerId: string, linkIndex: number) { @@ -323,7 +326,7 @@ try { settingsController.setState(await resetCustomizationCommand()); } catch { - settingsError = 'Customization could not be reset.'; + settingsError = t('customizationResetFailed'); } finally { resettingCustomization = false; resetConfirmationOpen = false; @@ -338,7 +341,9 @@ try { settingsController.setState(await resetProviderCustomizationCommand(providerId)); } catch { - settingsError = `${providerDisplayName(providerId)} customization could not be reset.`; + settingsError = t('providerCustomizationResetFailed', { + provider: providerDisplayName(providerId), + }); } } async function copyCanvas(canvas: HTMLCanvasElement, fallback: string) { @@ -353,7 +358,7 @@ } else { await navigator.clipboard.writeText(fallback); } - showConfirmation('Copied to clipboard'); + showConfirmation(t('copiedToClipboard')); } async function shareProvider(providerId: string) { const current = settingsState; @@ -374,7 +379,7 @@ }); await copyCanvas(canvas, snapshot); } catch { - settingsError = 'Provider screenshot could not be copied.'; + settingsError = t('providerScreenshotCopyFailed'); } } async function shareTotalSpend(projection: SpendProjection) { @@ -392,21 +397,21 @@ await copyCanvas(canvas, card.innerText.trim()); return true; } catch { - settingsError = 'Total Spend screenshot could not be copied.'; + settingsError = t('totalSpendScreenshotCopyFailed'); return false; } } async function copyLogPath() { const path = await getLogPath(); await navigator.clipboard.writeText(path); - showConfirmation('Log path copied'); + showConfirmation(t('logPathCopied')); } async function openLogFolder() { await openSystemLogFolder(); } function topBarTitle() { if (screen.startsWith('provider:')) return providerDisplayName(screen.slice(9)); - return screen === 'settings' ? 'Settings' : 'Customize'; + return screen === 'settings' ? t('settings') : t('customize'); } function closeAboutFromBackdrop(event: MouseEvent) { if (event.target === event.currentTarget) showAbout = false; @@ -499,7 +504,7 @@ // upstream support is still unavailable. await getCurrentWindow().startResizeDragging(edge === 'top' ? 'North' : 'South'); } catch { - settingsError = 'OpenQuota panel resize could not be started.'; + settingsError = t('panelResizeFailed'); } finally { await lockPanelResizeAxis().catch(() => undefined); updatePanelHeightMode(); @@ -515,7 +520,7 @@ event.preventDefault(); void getCurrentWindow() .startDragging() - .catch(() => (settingsError = 'OpenQuota window could not be moved.')); + .catch(() => (settingsError = t('windowMoveFailed'))); } async function changePanelHeightMode(mode: PanelHeightMode) { if (!('__TAURI_INTERNALS__' in window)) return; @@ -526,7 +531,7 @@ acceptPanelHeightMode(mode); if (mode === 'automatic') scheduleWindowFit(); } catch { - settingsError = 'OpenQuota could not change the panel height mode.'; + settingsError = t('panelHeightModeFailed'); updatePanelHeightMode(); } } @@ -538,14 +543,14 @@ const permissionState = await requestNotificationPermission(); settingsController.setState({ ...permissionState, settings: currentSettings }); } catch { - settingsError = 'Notification permission could not be requested.'; + settingsError = t('notificationPermissionFailed'); } } async function openNotificationSettings() { try { await openSystemNotificationSettings(); } catch { - settingsError = 'Notification settings could not be opened on this system.'; + settingsError = t('notificationSettingsFailed'); } } async function checkForUpdates(manual = false) { @@ -635,7 +640,7 @@ document.addEventListener('keydown', handleKeydown); const clock = window.setInterval(() => (now = Date.now()), 30_000); const listeners = createListenerRegistry(() => { - settingsError ??= 'OpenQuota event bridge is unavailable.'; + settingsError ??= t('eventBridgeUnavailable'); }); listeners.add(onUsageState((state) => (viewState = state))); listeners.add( @@ -664,7 +669,7 @@ settingsController.setState(state.settings); automaticUpdatesReady = true; }) - .catch(() => (settingsError = 'OpenQuota backend is unavailable.')); + .catch(() => (settingsError = t('backendUnavailable'))); return () => { document.removeEventListener('keydown', handleKeydown); window.clearInterval(clock); @@ -686,23 +691,24 @@ class="popover" class:popover--floating={floatingWindow} class:popover--macos={floatingWindow && platform === 'macos'} - aria-label="OpenQuota usage dashboard" + aria-label={t('dashboard')} + data-language={$uiLanguage} oncontextmenu={(event) => event.preventDefault()} >

- Drag to reorder. With a keyboard, use Alt plus Up Arrow or Alt plus Down Arrow. + {t('reorderInstructions')}

{#if renderedResizeEdge === 'top'} {/if} {#if floatingWindow} -
+
OpenQuota @@ -710,7 +716,7 @@

{topBarTitle()}

@@ -729,8 +735,8 @@ class="text-button" type="button" onclick={requestCustomizationReset} - aria-label="Reset all customization" - data-tooltip="Reset All Customization" + aria-label={t('resetCustomization')} + data-tooltip={t('resetCustomization')} > {:else if screen.startsWith('provider:')} @@ -738,8 +744,8 @@ class="text-button" type="button" onclick={() => resetProviderCustomization(screen.slice(9))} - aria-label={`Reset ${topBarTitle()}`} - data-tooltip={`Reset ${topBarTitle()}`} + aria-label={t('reset', { label: topBarTitle() })} + data-tooltip={t('reset', { label: topBarTitle() })} > {:else} @@ -844,10 +850,10 @@ type="button" onclick={refresh} disabled={anyRefreshing} - aria-label="Refresh all provider usage" + aria-label={t('refreshAllProviderUsage')} > OpenQuota {appVersion}{anyRefreshing ? 'Updating…' : nextUpdateLabel(lastFullRefresh, now)}{anyRefreshing ? t('updating') : nextUpdateLabel(lastFullRefresh, now)} {#if screen === 'dashboard'} @@ -857,17 +863,17 @@ class="window-mode-toggle" class:window-mode-toggle--active={floatingWindow} type="button" - aria-label={floatingWindow ? 'Return to Tray Popup' : 'Keep Window Open'} + aria-label={floatingWindow ? t('returnToTrayPopup') : t('keepWindowOpen')} aria-pressed={floatingWindow} - data-tooltip={floatingWindow ? 'Return to Tray Popup' : 'Keep Window Open'} + data-tooltip={floatingWindow ? t('returnToTrayPopup') : t('keepWindowOpen')} onclick={toggleFloatingWindow} > {/if}
- Options{t('options')} { @@ -888,16 +894,17 @@ {t('customize')} {t('settings')}{shortcuts.settings}
Share Screenshot
{t('shareScreenshot')}
{#if shareMenuOpen} @@ -921,19 +928,18 @@
{t('checkUpdates')}
{t('about')} {t('quit')}{shortcuts.quit}
@@ -950,9 +956,9 @@ {#if resetConfirmationOpen} void confirmCustomizationReset()} onCancel={() => (resetConfirmationOpen = false)} @@ -974,19 +980,19 @@ role="dialog" tabindex="-1" aria-modal="true" - aria-label="About OpenQuota" + aria-label={t('aboutOpenQuota')} >

OpenQuota

-

Version {appVersion}

- Private, local usage monitoring for your AI coding tools. +

{t('version', { version: appVersion })}

+ {t('openQuotaDescription')} {/if} @@ -995,7 +1001,7 @@ {#if settingsError} {:else} -

Loading OpenQuota…

+

{t('loading')}

{/if} {/if} @@ -1003,7 +1009,7 @@ diff --git a/src/App.test.ts b/src/App.test.ts index 1d53eb6..b183e33 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -1,6 +1,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from './App.svelte'; +import { setUiLanguage } from './lib/i18n'; import type { AppSettings, ProviderCatalog, @@ -110,7 +111,10 @@ describe('OpenQuota dashboard', () => { return Promise.reject(new Error(`unexpected command ${command}`)); }); }); - afterEach(cleanup); + afterEach(() => { + cleanup(); + setUiLanguage('en'); + }); it('renders quota, total spend, and the 30-day trend from backend data', async () => { const { container } = render(App); @@ -253,6 +257,118 @@ describe('OpenQuota dashboard', () => { } }); + it('switches language immediately, persists it, and preserves temporary Settings state', async () => { + render(App); + await screen.findByText('Plus'); + const appRoot = document.querySelector('main'); + await fireEvent.click(screen.getByLabelText('Open options')); + await fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + + const shortcut = screen.getByRole('button', { name: 'Record Shortcut' }); + await fireEvent.click(shortcut); + expect(shortcut).toHaveTextContent('Type Shortcut…'); + + await fireEvent.click(screen.getByRole('combobox', { name: 'Language' })); + await fireEvent.click(screen.getByRole('option', { name: '简体中文' })); + + expect(await screen.findByRole('region', { name: '设置' })).toBeInTheDocument(); + expect(document.querySelector('main')).toBe(appRoot); + expect(screen.getByRole('button', { name: '输入快捷键…' })).toBe(shortcut); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith( + 'save_app_settings', + expect.objectContaining({ settings: expect.objectContaining({ language: 'zh-CN' }) }), + ), + ); + }); + + it('translates the Total Spend accessible name without changing its meaning', async () => { + let language: AppSettings['language'] = 'zh-CN'; + mockInvoke((command: string) => { + if (command === 'get_usage_state') return Promise.resolve(liveState); + if (command === 'get_app_settings') + return Promise.resolve({ + ...settingsState, + settings: { ...settingsState.settings, language }, + }); + if (command === 'get_panel_resize_edge') return Promise.resolve('bottom'); + if (command === 'get_panel_height_mode') return Promise.resolve('automatic'); + if (command === 'fit_panel_to_content') return Promise.resolve(true); + if (command === 'check_for_updates') + return Promise.resolve({ + available: false, + currentVersion: '0.3.3', + version: null, + body: null, + installable: true, + releaseUrl: '', + }); + return Promise.resolve(); + }); + + const simplified = render(App); + expect(await screen.findByRole('combobox', { name: '总消费指标' })).toBeInTheDocument(); + simplified.unmount(); + + language = 'zh-TW'; + render(App); + expect(await screen.findByRole('combobox', { name: '總消費指標' })).toBeInTheDocument(); + }); + + it('loads a persisted language and safely renders invalid saved values in English', async () => { + mockInvoke((command: string) => { + if (command === 'get_usage_state') return Promise.resolve(liveState); + if (command === 'get_app_settings') + return Promise.resolve({ + ...settingsState, + settings: { ...settingsState.settings, language: 'zh-TW' }, + }); + if (command === 'get_panel_resize_edge') return Promise.resolve('bottom'); + if (command === 'get_panel_height_mode') return Promise.resolve('automatic'); + if (command === 'fit_panel_to_content') return Promise.resolve(true); + if (command === 'check_for_updates') + return Promise.resolve({ + available: false, + currentVersion: '0.3.3', + version: null, + body: null, + installable: true, + releaseUrl: '', + }); + return Promise.resolve(); + }); + const first = render(App); + expect(await screen.findByRole('combobox', { name: '總消費指標' })).toBeInTheDocument(); + first.unmount(); + + mockInvoke((command: string) => { + if (command === 'get_usage_state') return Promise.resolve(liveState); + if (command === 'get_app_settings') + return Promise.resolve({ + ...settingsState, + settings: { + ...settingsState.settings, + language: 'invalid-locale' as AppSettings['language'], + }, + }); + if (command === 'get_panel_resize_edge') return Promise.resolve('bottom'); + if (command === 'get_panel_height_mode') return Promise.resolve('automatic'); + if (command === 'fit_panel_to_content') return Promise.resolve(true); + if (command === 'check_for_updates') + return Promise.resolve({ + available: false, + currentVersion: '0.3.3', + version: null, + body: null, + installable: true, + releaseUrl: '', + }); + return Promise.resolve(); + }); + render(App); + expect(await screen.findByRole('combobox', { name: 'Total Spend Metric' })).toBeInTheDocument(); + }); + it('renders Claude and Antigravity independently with provider-specific quota formats', async () => { const multiProviderSettings = { ...settingsState, @@ -328,7 +444,7 @@ describe('OpenQuota dashboard', () => { expect(screen.getAllByRole('progressbar')).toHaveLength(6); expect( within(screen.getByRole('region', { name: 'Total Spend' })).getByRole('img', { - name: 'Only includes Claude and Codex', + name: 'Only includes Claude and Codex.', }), ).toBeInTheDocument(); }); @@ -676,7 +792,7 @@ describe('OpenQuota dashboard', () => { await fireEvent.click(screen.getByLabelText('Open options')); await fireEvent.click(screen.getByRole('button', { name: 'Customize' })); expect(screen.getByRole('heading', { name: 'Customize' })).toBeInTheDocument(); - await fireEvent.click(screen.getByRole('button', { name: 'Customize codex' })); + await fireEvent.click(screen.getByRole('button', { name: 'Customize Codex' })); expect(screen.getByRole('group', { name: 'Always Visible metrics' })).toBeInTheDocument(); expect(screen.getByRole('group', { name: 'On Demand metrics' })).toBeInTheDocument(); }); @@ -686,7 +802,7 @@ describe('OpenQuota dashboard', () => { await screen.findByText('Plus'); await fireEvent.click(screen.getByLabelText('Open options')); await fireEvent.click(screen.getByRole('button', { name: 'Customize' })); - await fireEvent.click(screen.getByRole('button', { name: 'Customize codex' })); + await fireEvent.click(screen.getByRole('button', { name: 'Customize Codex' })); await fireEvent.click(screen.getByRole('button', { name: 'Reset Codex' })); expect(mocks.invoke).toHaveBeenCalledWith('reset_provider_customization', { @@ -699,7 +815,7 @@ describe('OpenQuota dashboard', () => { await screen.findByText('Plus'); await fireEvent.click(screen.getByLabelText('Open options')); await fireEvent.click(screen.getByRole('button', { name: 'Customize' })); - await fireEvent.click(screen.getByRole('button', { name: 'Customize codex' })); + await fireEvent.click(screen.getByRole('button', { name: 'Customize Codex' })); await fireEvent.click(screen.getByRole('button', { name: 'Pin Today' })); expect(screen.getByText('Up to 2 stars per provider')).toBeInTheDocument(); }); diff --git a/src/App.update.test.ts b/src/App.update.test.ts index 303ee54..0126744 100644 --- a/src/App.update.test.ts +++ b/src/App.update.test.ts @@ -256,7 +256,7 @@ describe('OpenQuota update lifecycle', () => { 'GitHub refused the update download.', ); expect(screen.getByRole('alert')).toHaveTextContent( - 'Try again or download it from the release page.', + 'Try again or download the installer from the release page.', ); expect(screen.getByRole('button', { name: 'Try Again' })).toBeInTheDocument(); await fireEvent.click(screen.getByRole('button', { name: 'View Release' })); diff --git a/src/lib/ConfirmationSheet.svelte b/src/lib/ConfirmationSheet.svelte index bc8976a..99ea1a3 100644 --- a/src/lib/ConfirmationSheet.svelte +++ b/src/lib/ConfirmationSheet.svelte @@ -1,5 +1,6 @@ -
+
{#each settings.providers.filter( (provider) => catalog.provider(provider.id) ) as provider (provider.id)}
onOpen(provider.id)} >{providerDisplayName(provider.id)}{provider.metrics.length} metrics{t('metricCount', { count: provider.metrics.length })}
{/each}
-
diff --git a/src/lib/Dashboard.svelte b/src/lib/Dashboard.svelte index c7b8b7d..e9b1129 100644 --- a/src/lib/Dashboard.svelte +++ b/src/lib/Dashboard.svelte @@ -1,4 +1,5 @@ @@ -314,20 +317,20 @@ /> {#if updateStatus?.available && updateStatus.version !== settings.dismissedUpdateVersion} -
+
- Update Available - OpenQuota {updateStatus.version} is ready to download. + {t('updateAvailable')} + {t('versionReadyToDownload', { version: updateStatus.version ?? '' })} {#if updateStatus.body}
- What’s new + {t('whatsNew')}

{updateStatus.body}

{/if} {#if installingUpdate && updateProgress}
{updateProgress.phase === 'installing' - ? 'Installing update…' + ? t('installingUpdate') : updateProgress.phase === 'retrying' - ? 'Download interrupted. Retrying…' + ? t('downloadInterrupted') : updateProgress.percent === null - ? 'Downloading update…' - : `Downloading update… ${updateProgress.percent}%`} + ? t('downloadingUpdate') + : t('downloadingUpdatePercent', { percent: updateProgress.percent })} {/if} {#if updateError} - +
@@ -421,7 +422,7 @@ data-reorder-group="dashboard-providers" data-reorder-id={provider.id} role="group" - aria-label={`${providerDisplayName(provider.id)} provider`} + aria-label={t('providerGroup', { provider: providerDisplayName(provider.id) })} use:pointerReorder={{ id: provider.id, group: 'dashboard-providers', @@ -438,7 +439,7 @@ class="provider-header" data-reorder-handle role="group" - aria-label={`Drag ${providerDisplayName(provider.id)} to reorder`} + aria-label={t('dragProviderToReorder', { provider: providerDisplayName(provider.id) })} > @@ -454,14 +455,14 @@ {#if snapshot.plan}{snapshot.plan}{/if} {#if state?.snapshot && state.stale}Outdated{t('outdated')}{/if} 0)} > {#if state?.refreshing} - {:else if state?.error} @@ -490,7 +491,7 @@
{#each snapshot.notices as notice (notice.id)} @@ -503,7 +504,9 @@ data-reorder-group={`dashboard-metrics:${provider.id}`} data-reorder-id={metric.id} role="group" - aria-label={`${metricDefinition(metric.id)?.label ?? metric.id} options`} + aria-label={t('metricOptions', { + metric: metricDefinition(metric.id)?.label ?? metric.id, + })} use:pointerReorder={{ id: metric.id, group: `dashboard-metrics:${provider.id}`, @@ -521,7 +524,7 @@ data-reorder-handle data-reorder-touch-handle type="button" - aria-label={`Move ${metricDefinition(metric.id)?.label ?? metric.id}`} + aria-label={t('move', { label: metricDefinition(metric.id)?.label ?? metric.id })} aria-describedby="reorder-instructions" aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown" > toggleDemandMetrics(provider)} > hideProvider(menuProvider.id)} - >Hide {providerDisplayName(menuProvider.id)}{t('hideProvider', { + provider: providerDisplayName(menuProvider.id), + })}
{t('refreshProvider', { + provider: providerDisplayName(menuProvider.id), + })} {#if canRenameProvider(menuProvider.id, renamableProviderIds)} {t('customizeMenu')}
{t('shareScreenshot')} {/if} @@ -664,7 +675,7 @@ type="button" role="menuitem" onclick={() => patchMetric(metricProvider.id, menuMetric.id, { enabled: false })} - >Hide{t('hide')} {#if metricDefinition(menuMetric.id)?.pinnable} {/if}
{t('refreshProvider', { + provider: providerDisplayName(metricProvider.id), + })} {t('customizeMenu')} {/if} @@ -697,7 +710,7 @@ {#if enabledProviders.length === 0}
- Turn on Customize to choose what to show. + {t('customizeHint')}
{/if} diff --git a/src/lib/MetricRenderer.svelte b/src/lib/MetricRenderer.svelte index 8c3f493..fc69ce0 100644 --- a/src/lib/MetricRenderer.svelte +++ b/src/lib/MetricRenderer.svelte @@ -1,4 +1,5 @@ {#if supported} -
-

API Key

+
+

{t('apiKey')}

{providerName} {open ? t('done') : status === 'notSet' ? t('add') : t('edit')}
{#if availabilityError} @@ -131,14 +132,14 @@ bind:value={apiKey} autocomplete="off" spellcheck="false" - placeholder="Paste API key" - aria-label={`${providerName} API key`} + placeholder={t('pasteApiKey')} + aria-label={t('apiKeyFor', { provider: providerName })} disabled={saving} /> {saving ? t('saving') : t('save')} {#if overrideExternal} - + {/if}
{:else} @@ -162,8 +165,8 @@ class="field-icon clear-icon" type="button" disabled={saving} - aria-label={saving ? 'Removing saved API key…' : 'Remove saved API key'} - title="Remove saved API key" + aria-label={saving ? t('removingSavedApiKey') : t('removeSavedApiKey')} + title={t('removeSavedApiKey')} onclick={remove} > @@ -173,14 +176,14 @@ class="api-key-source-field" type="text" use:displayValue={sourceLabel} - aria-label={`${providerName} API key source`} + aria-label={t('apiKeySourceFor', { provider: providerName })} disabled /> {#if status === 'fromEnvironment' || status === 'fromConfig'} {/if} {/if} diff --git a/src/lib/ProviderLinks.svelte b/src/lib/ProviderLinks.svelte index 30642e6..ffbb181 100644 --- a/src/lib/ProviderLinks.svelte +++ b/src/lib/ProviderLinks.svelte @@ -1,6 +1,7 @@
-

Name

+

{t('name')}

(focused = true)} onblur={() => { diff --git a/src/lib/ProviderNoticeRow.svelte b/src/lib/ProviderNoticeRow.svelte index 7c3f3f0..e42a78d 100644 --- a/src/lib/ProviderNoticeRow.svelte +++ b/src/lib/ProviderNoticeRow.svelte @@ -1,5 +1,6 @@ -
+

- {quota.label} + {localizedLabel} {#if quota.estimated} {/if} @@ -164,7 +210,7 @@ {paceLabel ?? ''} {#if freshSession} - Not started + {t('notStarted')} {:else} {recording + ? t('typeShortcut') + : (settings.globalShortcut ?? t('recordShortcut'))}{#if settings.globalShortcut}{/if} @@ -167,75 +171,88 @@

-

Appearance

+

{t('appearance')}

+
+ {t('language')} patch({ language: value as AppSettings['language'] })} + /> +
{#if platform === 'macos'}
- Icon Style{t('iconStyle')} patch({ menuBarStyle: value as AppSettings['menuBarStyle'] })} />
{/if}
- Theme{t('theme')} patch({ theme: value as AppSettings['theme'] })} />
- Density{t('density')} patch({ density: value as AppSettings['density'] })} />
{#if settingsView.trayAvailable}
- Window Mode{t('windowMode')} patch({ windowMode: value as AppSettings['windowMode'] })} />
{/if}
- Panel Height{t('panelHeight')} onPanelHeightModeChange(value as PanelHeightMode)} />
- Time Format{t('timeFormat')} patch({ timeFormat: value as AppSettings['timeFormat'] })} /> @@ -243,36 +260,35 @@
-

Usage Display

+

{t('usageDisplay')}

- Show Usage As{t('showUsageAs')} patch({ usageDisplay: value as AppSettings['usageDisplay'] })} />
- Reset Times{t('resetTimes')} patch({ resetDisplay: value as AppSettings['resetDisplay'] })} />
@@ -356,23 +372,23 @@
-

Advanced

+

{t('advanced')}

- Log Level{t('logLevel')} patch({ logLevel: value as AppSettings['logLevel'] })} />
{t('copyLogPath')}
@@ -386,9 +402,9 @@
-

Updates

+

{t('updates')}

{#if updateError}{/if}
-
@@ -483,7 +499,7 @@ color: var(--text); } - .shortcut-field button[aria-label='Clear global shortcut'] { + .shortcut-field .clear-shortcut-button { display: grid; width: 24px; height: 24px; @@ -493,8 +509,8 @@ place-items: center; } - .shortcut-field button[aria-label='Clear global shortcut']:hover, - .shortcut-field button[aria-label='Clear global shortcut']:focus-visible { + .shortcut-field .clear-shortcut-button:hover, + .shortcut-field .clear-shortcut-button:focus-visible { outline: none; color: var(--text); background: var(--button-hover); diff --git a/src/lib/StatusMetric.svelte b/src/lib/StatusMetric.svelte index f1ed71d..cd1b700 100644 --- a/src/lib/StatusMetric.svelte +++ b/src/lib/StatusMetric.svelte @@ -1,4 +1,6 @@ -
- Usage Trend +
+ {t('usageTrend')} {#if total > 0}
@@ -78,22 +80,27 @@ class="trend-bars" class:trend-bars--active={detailVisible} role="img" - aria-label={`30-day token chart. Peak ${compact(peak.tokens)} tokens on ${peak.date}.`} + aria-label={t('tokenChartPeak', { tokens: compact(peak.tokens), date: peak.date })} > {#each points as point (point.date)} 0 ? 18 : 2, (point.tokens / max) * 100)}%`} - title={`${point.date}: ${compact(point.tokens)} tokens`} + title={t('tokenChartPoint', { date: point.date, tokens: compact(point.tokens) })} > {/each}
{#if detailVisible}
diff --git a/src/lib/ValueMetric.svelte b/src/lib/ValueMetric.svelte index 3cd85aa..2b5526c 100644 --- a/src/lib/ValueMetric.svelte +++ b/src/lib/ValueMetric.svelte @@ -1,7 +1,8 @@