From 4022d3f2de2764118e256c83f001efddb4186567 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 2 Jun 2026 16:32:21 +0700 Subject: [PATCH] feat: drop WSL and multi-language support --- Cargo.lock | 72 +-------- Cargo.toml | 3 +- README.md | 22 +-- src/app.rs | 74 +-------- src/bubble.rs | 12 +- src/creds/local_fs.rs | 3 +- src/creds/mod.rs | 17 +-- src/creds/wsl_bridge.rs | 155 ------------------- src/i18n/detect.rs | 73 --------- src/i18n/locales/en.toml | 60 -------- src/i18n/locales/ja.toml | 60 -------- src/i18n/locales/ko.toml | 60 -------- src/i18n/locales/vi.toml | 60 -------- src/i18n/locales/zh-TW.toml | 60 -------- src/i18n/mod.rs | 297 +++++++++++------------------------- src/settings.rs | 3 - src/update/install.rs | 4 +- src/usage/refresh.rs | 37 ++--- 18 files changed, 123 insertions(+), 949 deletions(-) delete mode 100644 src/creds/wsl_bridge.rs delete mode 100644 src/i18n/detect.rs delete mode 100644 src/i18n/locales/en.toml delete mode 100644 src/i18n/locales/ja.toml delete mode 100644 src/i18n/locales/ko.toml delete mode 100644 src/i18n/locales/vi.toml delete mode 100644 src/i18n/locales/zh-TW.toml diff --git a/Cargo.lock b/Cargo.lock index 82499ca..575b536 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,7 +71,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "claude-code-usage-bubble" -version = "0.4.1" +version = "0.5.0" dependencies = [ "dirs", "embed-resource", @@ -83,7 +83,6 @@ dependencies = [ "simplelog", "thiserror", "tiny-skia", - "toml 0.8.23", "windows", ] @@ -164,7 +163,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.2+spec-1.1.0", + "toml", "vswhom", "winreg", ] @@ -523,15 +522,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -693,18 +683,6 @@ dependencies = [ "strict-num", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit", -] - [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -713,20 +691,11 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.3", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", + "winnow", ] [[package]] @@ -738,35 +707,15 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -1045,15 +994,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index fe8970c..e350389 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "claude-code-usage-bubble" -version = "0.4.1" +version = "0.5.0" edition = "2021" license = "Apache-2.0" description = "Floating bubble showing Claude Code and Codex usage on Windows" @@ -14,7 +14,6 @@ dirs = "6" log = "0.4" simplelog = "0.12" thiserror = "2" -toml = "0.8" tiny-skia = "0.11" sha2 = "0.10" self-replace = "1.5" diff --git a/README.md b/README.md index 6bccf34..ebfe8c1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ which solves the same "how close am I to the Claude Code limit?" problem with a horizontal taskbar widget. This project takes the UX in a different direction — a floating, draggable circular bubble that the user can place anywhere on screen — and is a clean-room implementation: the HTTP client, -provider polling, credential discovery, localisation, tray rendering, and +provider polling, credential discovery, tray rendering, and self-updater are all written from scratch against the same public APIs (Anthropic, ChatGPT, GitHub Releases). @@ -33,8 +33,8 @@ self-updater are all written from scratch against the same public APIs (140–360 logical pixels) - Left-click the bubble for an expanded panel with both **5h** and **7d** bars plus reset countdowns -- Right-click for refresh, displayed providers, update frequency, language, - startup, updates, exit +- Right-click for refresh, displayed providers, update frequency, startup, + updates, exit - Optional system tray icons (one per enabled provider) - Auto-hide when a fullscreen app is in the foreground (games, video, presentations) — reappears when you leave fullscreen @@ -42,12 +42,8 @@ self-updater are all written from scratch against the same public APIs ## Who this is for Windows 10/11 users who already have **Claude Code (CLI or App) installed -and signed in**. Codex support is optional — install and sign in to the -Codex CLI, then enable Codex from the right-click **Providers** menu. - -If you use Claude Code through WSL, that is supported too. The monitor -can read your Claude Code credentials from Windows or from your WSL -environment. +and signed in on Windows**. Codex support is optional — install and sign in +to the Codex CLI, then enable Codex from the right-click **Providers** menu. ## Requirements @@ -85,9 +81,9 @@ corner of your primary monitor on first launch. Drag it where you want it, release to snap to the nearest edge if you let go close to one. - **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns) -- **Right-click** for refresh, providers, refresh frequency, language, "Start - with Windows", controls, auto-update check (Disabled / Hourly / Daily / - Weekly), manual "Check for updates", exit +- **Right-click** for refresh, providers, refresh frequency, "Start with + Windows", controls, auto-update check (Disabled / Hourly / Daily / Weekly), + manual "Check for updates", exit - **Drag** anywhere — it floats on top of all other windows - **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click menu, to resize it @@ -130,7 +126,6 @@ Settings are saved to: What the app reads: - Your local Claude Code OAuth credentials from `~/.claude/.credentials.json` -- If needed, the same credentials file inside an installed WSL distro - If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json` or `~/.codex/auth.json` @@ -145,7 +140,6 @@ What the app stores locally: - Bubble position(s) per model - Bubble size - Polling frequency -- Language preference - Last update check time - Displayed provider preferences diff --git a/src/app.rs b/src/app.rs index d18c01e..acb4a52 100644 --- a/src/app.rs +++ b/src/app.rs @@ -74,17 +74,12 @@ const IDM_RESTART: u16 = 33; const IDM_SIZE_SMALLER: u16 = 34; const IDM_SIZE_LARGER: u16 = 35; const IDM_RESET_SIZE: u16 = 36; -const IDM_LANG_SYSTEM: u16 = 40; // 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range // clear of it (and any future tray ids in the 5x band). const IDM_UPDATE_AUTO_OFF: u16 = 60; const IDM_UPDATE_AUTO_HOURLY: u16 = 61; const IDM_UPDATE_AUTO_DAILY: u16 = 62; const IDM_UPDATE_AUTO_WEEKLY: u16 = 63; -// IMPORTANT: language ids are dynamic and start at IDM_LANG_BASE. -// Keep IDM_LANG_BASE the highest static id so the catch-all match arm -// stays unambiguous. -const IDM_LANG_BASE: u16 = 100; // ---------- State ---------- @@ -213,7 +208,7 @@ pub fn run(args: crate::AppArgs) { }; let settings = settings::load(); - let i18n = I18n::load(settings.language.as_deref()); + let i18n = I18n::load(); let is_dark = os::theme::is_dark(); let install_channel = update::current_channel(); let http = match net::Client::new(HTTP_USER_AGENT) { @@ -436,13 +431,8 @@ fn on_menu_command(id: u32, _owner_hwnd: HWND) { IDM_UPDATE_AUTO_WEEKLY => { set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS)) } - IDM_LANG_SYSTEM => set_language(None), - // Static ids in the 30-99 band must match BEFORE the dynamic - // language guard, otherwise `x >= IDM_LANG_BASE` would swallow any - // future id that creeps into the >=100 range. tray::IDM_TOGGLE_WIDGET => toggle_widget_visibility(), IDM_RESTART => restart_app(), - x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize), _ => {} } } @@ -710,7 +700,7 @@ fn build_panel_data_from(snap: &UiSnapshot, model: ProviderId, p: &ProviderUiSta } fn placeholder_panel(model: ProviderId) -> PanelData { - let strings = i18n::I18n::load(None).strings().clone(); + let strings = i18n::I18n::load().strings().clone(); PanelData { model, session_pct: 0.0, @@ -934,8 +924,6 @@ fn announce_update_applied(_msg_hwnd: HWND, version: &str) { struct ContextMenuSnapshot { strings: LocaleStrings, - available: Vec<(String, String)>, - language_override: Option, current_interval: u32, update_check_interval_secs: Option, show_claude: bool, @@ -951,12 +939,6 @@ fn show_context_menu(owner_hwnd: HWND) { let snap = match lock_state().as_ref() { Some(s) => ContextMenuSnapshot { strings: s.i18n.strings().clone(), - available: s - .i18n - .available() - .map(|(c, n)| (c.to_string(), n.to_string())) - .collect(), - language_override: s.settings.language.clone(), current_interval: s.settings.poll_interval_ms, update_check_interval_secs: s.settings.update_check_interval_secs, show_claude: s.settings.show_claude_code, @@ -1057,39 +1039,6 @@ fn show_context_menu(owner_hwnd: HWND) { MENU_ITEM_FLAGS(0), ); - let Ok(lang) = CreatePopupMenu() else { - log::error!("CreatePopupMenu(lang) failed"); - let _ = DestroyMenu(settings_menu); - let _ = DestroyMenu(menu); - return; - }; - append_item( - lang, - IDM_LANG_SYSTEM, - &snap.strings.system_default, - if snap.language_override.is_none() { - MF_CHECKED - } else { - MENU_ITEM_FLAGS(0) - }, - ); - for (i, (code, name)) in snap.available.iter().enumerate() { - let id = IDM_LANG_BASE + i as u16; - let flags = if snap - .language_override - .as_deref() - .map(|c| c == code) - .unwrap_or(false) - { - MF_CHECKED - } else { - MENU_ITEM_FLAGS(0) - }; - append_item(lang, id, name, flags); - } - append_submenu(settings_menu, lang, &snap.strings.language); - let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null()); - let version_label = version_action_label(&snap); let version_flags = if matches!( snap.update_status, @@ -1372,25 +1321,6 @@ fn set_bubble_size(size_logical: i32) { } } -fn set_language(_dummy: Option<()>) { - update_settings(|s| { - s.i18n.set_active(None); - s.settings.language = None; - }); - propagate_to_ui(); -} - -fn set_language_by_index(idx: usize) { - update_settings(|s| { - let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string()); - if let Some(c) = code.as_deref() { - s.i18n.set_active(Some(c)); - } - s.settings.language = code; - }); - propagate_to_ui(); -} - fn version_action() { enum Act { Apply(update::Release, InstallChannel), diff --git a/src/bubble.rs b/src/bubble.rs index a4178b3..e03f1d0 100644 --- a/src/bubble.rs +++ b/src/bubble.rs @@ -1246,12 +1246,8 @@ mod fullscreen_tests { // ---------- Painting ---------- -// Sized for the widest countdown across all shipped locales. Korean -// "999시간" (3 digits + 2 CJK chars for the hour suffix) is the current -// worst case; ASCII-only "999d" was too narrow and let CJK text spill -// out of the column. Update this when adding a locale with a longer -// suffix. -const COUNTDOWN_TEMPLATE: &str = "999시간"; +// Sized for the widest English countdown text the bubble renders. +const COUNTDOWN_TEMPLATE: &str = "999d"; /// Geometry for the bubble's "circle head + pill tail" shape, in DPI-scaled pixels. /// @@ -1885,8 +1881,8 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) { // Head: 5h countdown text if available, otherwise the static "5h" tag. // The ring already signals "this is the 5h window", so the countdown // is the more useful glanceable info when we have it. Fall back to - // "5h" when the localized countdown would overflow the rect (e.g., - // wide CJK strings like "999시간" at the 140-logical minimum width) — + // "5h" when the countdown would overflow the rect at the + // 140-logical minimum width — // DT_NOCLIP would otherwise leak the glyphs onto the ring stroke. SetTextColor(hdc, COLORREF(muted_color.into_colorref())); let head_label_rect_w = layout.head_label_rect.right - layout.head_label_rect.left; diff --git a/src/creds/local_fs.rs b/src/creds/local_fs.rs index aa02cc0..921ea6f 100644 --- a/src/creds/local_fs.rs +++ b/src/creds/local_fs.rs @@ -51,8 +51,7 @@ impl super::CredentialSource for LocalClaudeCreds { } } -/// Shared between local-fs and wsl-bridge sources — both parse the same -/// JSON shape, the only difference is how they get to the file content. +/// Parse the Claude credential JSON shape written by the official CLI. pub(crate) fn parse_claude_json(content: &str) -> Result { let value: serde_json::Value = serde_json::from_str(content)?; let oauth = value diff --git a/src/creds/mod.rs b/src/creds/mod.rs index 36b610c..2507390 100644 --- a/src/creds/mod.rs +++ b/src/creds/mod.rs @@ -1,13 +1,12 @@ // Pluggable credential discovery. // // Each `CredentialSource` knows how to read a single OAuth token from -// somewhere (a local JSON file, a WSL filesystem, …). The `Locator` +// somewhere, such as a local JSON file. The `Locator` // holds a priority-ordered list and serves the first source that // actually has a token. New sources drop in without touching the locator. pub mod codex_auth; pub mod local_fs; -pub mod wsl_bridge; #[derive(Clone, Debug)] pub struct Token { @@ -23,8 +22,6 @@ pub struct Token { pub enum RefreshHint { /// `claude.cmd` / `claude.exe` on PATH. LocalClaudeCli, - /// Run `claude -p .` inside a specific WSL distro. - WslClaudeCli { distro: String }, /// `codex` / `codex.cmd` / `codex.ps1` on PATH. LocalCodexCli, } @@ -37,10 +34,6 @@ pub enum Error { Json(#[from] serde_json::Error), #[error("required field missing from credential JSON: {0}")] MissingField(&'static str), - #[error("WSL command in {distro:?} failed: {detail}")] - WslCommand { distro: String, detail: String }, - #[error("timeout while talking to WSL")] - WslTimeout, #[error("credential source unavailable")] Unavailable, } @@ -50,7 +43,7 @@ pub trait CredentialSource: Send + Sync { /// signatures (e.g. `"local:C:\\Users\\me\\.claude\\.credentials.json"`). fn id(&self) -> &str; - /// Read the current token. May spawn subprocesses (for WSL). + /// Read the current token. fn read(&self) -> Result; /// Cheap change-detection fingerprint. `None` means "source is missing". @@ -70,16 +63,12 @@ impl Locator { Self { sources } } - /// Build a Claude locator with the standard search order: Windows - /// home directory first, then every installed WSL distro. + /// Build a Claude locator with the standard Windows credential path. pub fn for_claude() -> Self { let mut sources: Vec> = Vec::new(); if let Some(s) = local_fs::LocalClaudeCreds::detect() { sources.push(Box::new(s)); } - for distro in wsl_bridge::list_distros() { - sources.push(Box::new(wsl_bridge::WslClaudeCreds::new(distro))); - } Self { sources } } diff --git a/src/creds/wsl_bridge.rs b/src/creds/wsl_bridge.rs deleted file mode 100644 index 7939387..0000000 --- a/src/creds/wsl_bridge.rs +++ /dev/null @@ -1,155 +0,0 @@ -// Reach into installed WSL distros to read their Claude credentials. -// -// We never mount the WSL filesystem ourselves — instead we shell out to -// `wsl.exe -d -- sh -lc '...'` and read stdout. Every call has -// a hard timeout so a hung WSL doesn't freeze the poll thread. - -use std::os::windows::process::CommandExt; -use std::process::{Command, Output, Stdio}; -use std::time::{Duration, Instant}; - -use super::local_fs::parse_claude_json; - -const CREATE_NO_WINDOW: u32 = 0x0800_0000; -const COMMAND_TIMEOUT: Duration = Duration::from_secs(5); - -/// Enumerate installed WSL distributions. Returns an empty vec if WSL is -/// not installed or the probe fails. -pub fn list_distros() -> Vec { - let Some(output) = run_with_timeout( - Command::new("wsl.exe").args(["-l", "-q"]), - COMMAND_TIMEOUT, - ) else { - return Vec::new(); - }; - if !output.status.success() { - return Vec::new(); - } - decode_wsl_text(&output.stdout) - .lines() - .map(str::trim) - .filter(|l| !l.is_empty()) - .map(ToOwned::to_owned) - .collect() -} - -pub struct WslClaudeCreds { - distro: String, - id: String, -} - -impl WslClaudeCreds { - pub fn new(distro: String) -> Self { - let id = format!("wsl:{distro}"); - Self { distro, id } - } - - pub fn distro(&self) -> &str { - &self.distro - } -} - -impl super::CredentialSource for WslClaudeCreds { - fn id(&self) -> &str { - &self.id - } - - fn read(&self) -> Result { - let output = wsl_run(&self.distro, "cat ~/.claude/.credentials.json")?; - if !output.status.success() { - return Err(super::Error::WslCommand { - distro: self.distro.clone(), - detail: format!("cat exited {}", output.status), - }); - } - let content = String::from_utf8(output.stdout).map_err(|_| super::Error::WslCommand { - distro: self.distro.clone(), - detail: "non-UTF-8 stdout".into(), - })?; - parse_claude_json(&content) - } - - fn signature(&self) -> Option { - let output = wsl_run( - &self.distro, - "if [ -f ~/.claude/.credentials.json ]; then \ - stat -c '%s|%Y' ~/.claude/.credentials.json; \ - else echo MISSING; fi", - ) - .ok()?; - if !output.status.success() { - return None; - } - let body = decode_wsl_text(&output.stdout).trim().to_string(); - if body == "MISSING" { - return None; - } - Some(format!("{}|{}", self.id, body)) - } - - fn refresh_hint(&self) -> super::RefreshHint { - super::RefreshHint::WslClaudeCli { - distro: self.distro.clone(), - } - } -} - -fn wsl_run(distro: &str, script: &str) -> Result { - run_with_timeout( - Command::new("wsl.exe") - .arg("-d") - .arg(distro) - .arg("--") - .arg("sh") - .arg("-lc") - .arg(script), - COMMAND_TIMEOUT, - ) - .ok_or(super::Error::WslTimeout) -} - -fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Option { - let mut child = cmd - .creation_flags(CREATE_NO_WINDOW) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .ok()?; - let start = Instant::now(); - loop { - match child.try_wait() { - Ok(Some(_)) => return child.wait_with_output().ok(), - Ok(None) => { - if start.elapsed() > timeout { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - std::thread::sleep(Duration::from_millis(80)); - } - Err(_) => return None, - } - } -} - -/// `wsl.exe -l -q` historically emits UTF-16LE on stdout; other commands -/// emit UTF-8. Detect by sampling high bytes and decode appropriately. -pub(crate) fn decode_wsl_text(bytes: &[u8]) -> String { - if bytes.len() >= 2 && bytes.len() % 2 == 0 { - let sample_end = bytes.len().min(256); - let mut high_nul = 0usize; - for chunk in bytes[..sample_end].chunks_exact(2) { - if chunk[1] == 0 { - high_nul += 1; - } - } - if high_nul * 2 >= sample_end / 2 { - let units: Vec = bytes - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); - return String::from_utf16_lossy(&units); - } - } - String::from_utf8_lossy(bytes).into_owned() -} diff --git a/src/i18n/detect.rs b/src/i18n/detect.rs deleted file mode 100644 index 2dfb3c3..0000000 --- a/src/i18n/detect.rs +++ /dev/null @@ -1,73 +0,0 @@ -// Discover the user's preferred Windows UI language. -// -// We try three sources in priority order and return the first non-empty -// result. Callers normalise the returned BCP-47-ish code against the -// list of locales we actually ship. - -use windows::core::PWSTR; -use windows::Win32::Globalization::{ - GetUserDefaultLocaleName, GetUserDefaultUILanguage, GetUserPreferredUILanguages, - LCIDToLocaleName, LOCALE_ALLOW_NEUTRAL_NAMES, MAX_LOCALE_NAME, MUI_LANGUAGE_NAME, -}; - -/// First non-empty locale code from the user's preferences. May be -/// `Some("en-US")` style; callers do prefix normalisation. -pub fn detect_system_locale() -> Option { - preferred_ui() - .into_iter() - .next() - .or_else(default_ui_language) - .or_else(default_locale_name) -} - -fn preferred_ui() -> Vec { - unsafe { - let mut count: u32 = 0; - let mut buf_len: u32 = 0; - if GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, &mut count, PWSTR::null(), &mut buf_len) - .is_err() - || buf_len == 0 - { - return Vec::new(); - } - let mut buffer = vec![0u16; buf_len as usize]; - if GetUserPreferredUILanguages( - MUI_LANGUAGE_NAME, - &mut count, - PWSTR(buffer.as_mut_ptr()), - &mut buf_len, - ) - .is_err() - { - return Vec::new(); - } - buffer - .split(|u| *u == 0) - .filter(|s| !s.is_empty()) - .map(String::from_utf16_lossy) - .collect() - } -} - -fn default_ui_language() -> Option { - unsafe { - let lcid = GetUserDefaultUILanguage(); - let mut buf = [0u16; MAX_LOCALE_NAME as usize]; - let len = LCIDToLocaleName(lcid as u32, Some(&mut buf), LOCALE_ALLOW_NEUTRAL_NAMES); - if len <= 1 { - return None; - } - Some(String::from_utf16_lossy(&buf[..(len as usize - 1)])) - } -} - -fn default_locale_name() -> Option { - unsafe { - let mut buf = [0u16; MAX_LOCALE_NAME as usize]; - let len = GetUserDefaultLocaleName(&mut buf); - if len <= 1 { - return None; - } - Some(String::from_utf16_lossy(&buf[..(len as usize - 1)])) - } -} diff --git a/src/i18n/locales/en.toml b/src/i18n/locales/en.toml deleted file mode 100644 index c24eb37..0000000 --- a/src/i18n/locales/en.toml +++ /dev/null @@ -1,60 +0,0 @@ -code = "en" -native_name = "English" - -window_title = "Claude Code Usage Bubble" -refresh = "Refresh" -update_frequency = "Update frequency" -one_minute = "1 minute" -five_minutes = "5 minutes" -fifteen_minutes = "15 minutes" -one_hour = "1 hour" -providers = "Providers" -claude_label = "Claude Code" -chatgpt_label = "Codex" -opencode_go_label = "OpenCode Go" -settings = "Settings" -start_with_windows = "Start with Windows" -reset_position = "Reset position" -size_smaller = "Make smaller" -size_larger = "Make larger" -reset_size = "Reset size" -controls = "Controls" -control_left_click = "Left-click: details" -control_right_click = "Right-click: menu" -control_drag = "Drag: move/snap" -control_ctrl_wheel = "Ctrl+Wheel: resize" -control_tray_click = "Tray click: show/hide" -tray_left_click = "Left-click: show/hide" -language = "Language" -system_default = "System default" -check_for_updates = "Check for updates" -checking_for_updates = "Checking for updates…" -up_to_date = "Up to date" -update_failed = "Update failed" -applying_update = "Applying update…" -update_available = "Update available" -update_via_winget = "via WinGet" -auto_update_check = "Auto-update check" -auto_check_disabled = "Disabled" -auto_check_hourly = "Hourly" -auto_check_daily = "Daily" -auto_check_weekly = "Weekly" -exit = "Exit" -restart = "Restart" -show_widget = "Show widget" -session_window = "5h" -weekly_window = "7d" -now = "now" -day_suffix = "d" -hour_suffix = "h" -minute_suffix = "m" -second_suffix = "s" -token_expired_title = "Claude Code session expired" -token_expired_body = "Sign in again to keep tracking your usage." -chatgpt_token_expired_title = "Codex session expired" -chatgpt_token_expired_body = "Sign in again to keep tracking your usage." -threshold_80_body = "Approaching the 5-hour limit." -threshold_95_body = "Limit is close — consider easing up." -update_applied_title = "Update applied" -update_applied_body = "Updated to v" -update_rollback_failed_body = "Update failed. Your original binary is saved at: " diff --git a/src/i18n/locales/ja.toml b/src/i18n/locales/ja.toml deleted file mode 100644 index 139efda..0000000 --- a/src/i18n/locales/ja.toml +++ /dev/null @@ -1,60 +0,0 @@ -code = "ja" -native_name = "日本語" - -window_title = "Claude Code Usage Bubble" -refresh = "更新" -update_frequency = "更新間隔" -one_minute = "1分" -five_minutes = "5分" -fifteen_minutes = "15分" -one_hour = "1時間" -providers = "プロバイダー" -claude_label = "Claude Code" -chatgpt_label = "Codex" -opencode_go_label = "OpenCode Go" -settings = "設定" -start_with_windows = "Windows起動時に開始" -reset_position = "位置をリセット" -size_smaller = "小さくする" -size_larger = "大きくする" -reset_size = "サイズをリセット" -controls = "操作" -control_left_click = "左クリック: 詳細" -control_right_click = "右クリック: メニュー" -control_drag = "ドラッグ: 移動/吸着" -control_ctrl_wheel = "Ctrl+ホイール: サイズ変更" -control_tray_click = "トレイ左クリック: 表示/非表示" -tray_left_click = "左クリック: 表示/非表示" -language = "言語" -system_default = "システム既定" -check_for_updates = "更新を確認" -checking_for_updates = "確認中…" -up_to_date = "最新です" -update_failed = "更新に失敗しました" -applying_update = "更新を適用中…" -update_available = "更新あり" -update_via_winget = "WinGet経由" -auto_update_check = "更新の自動確認" -auto_check_disabled = "無効" -auto_check_hourly = "1時間ごと" -auto_check_daily = "毎日" -auto_check_weekly = "毎週" -exit = "終了" -restart = "再起動" -show_widget = "ウィジェットを表示" -session_window = "5時間" -weekly_window = "7日" -now = "今" -day_suffix = "日" -hour_suffix = "時" -minute_suffix = "分" -second_suffix = "秒" -token_expired_title = "Claude Codeのセッションが切れました" -token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。" -chatgpt_token_expired_title = "Codexのセッションが切れました" -chatgpt_token_expired_body = "使用状況の追跡を続けるには再度サインインしてください。" -threshold_80_body = "5時間の上限に近づいています。" -threshold_95_body = "上限に近づきました — ペースを落としましょう。" -update_applied_title = "更新を適用しました" -update_applied_body = "バージョン v" -update_rollback_failed_body = "更新に失敗しました。元のバイナリは次の場所に保存されています: " diff --git a/src/i18n/locales/ko.toml b/src/i18n/locales/ko.toml deleted file mode 100644 index 28f371c..0000000 --- a/src/i18n/locales/ko.toml +++ /dev/null @@ -1,60 +0,0 @@ -code = "ko" -native_name = "한국어" - -window_title = "Claude Code Usage Bubble" -refresh = "새로 고침" -update_frequency = "업데이트 주기" -one_minute = "1분" -five_minutes = "5분" -fifteen_minutes = "15분" -one_hour = "1시간" -providers = "제공자" -claude_label = "Claude Code" -chatgpt_label = "Codex" -opencode_go_label = "OpenCode Go" -settings = "설정" -start_with_windows = "Windows 시작 시 실행" -reset_position = "위치 초기화" -size_smaller = "작게" -size_larger = "크게" -reset_size = "크기 초기화" -controls = "조작" -control_left_click = "왼쪽 클릭: 상세" -control_right_click = "오른쪽 클릭: 메뉴" -control_drag = "드래그: 이동/스냅" -control_ctrl_wheel = "Ctrl+휠: 크기 조절" -control_tray_click = "트레이 클릭: 표시/숨김" -tray_left_click = "왼쪽 클릭: 표시/숨김" -language = "언어" -system_default = "시스템 기본값" -check_for_updates = "업데이트 확인" -checking_for_updates = "확인 중…" -up_to_date = "최신 상태" -update_failed = "업데이트 실패" -applying_update = "업데이트 적용 중…" -update_available = "업데이트 있음" -update_via_winget = "WinGet 사용" -auto_update_check = "업데이트 자동 확인" -auto_check_disabled = "사용 안 함" -auto_check_hourly = "매시간" -auto_check_daily = "매일" -auto_check_weekly = "매주" -exit = "종료" -restart = "다시 시작" -show_widget = "위젯 표시" -session_window = "5시간" -weekly_window = "7일" -now = "지금" -day_suffix = "일" -hour_suffix = "시간" -minute_suffix = "분" -second_suffix = "초" -token_expired_title = "Claude Code 세션 만료" -token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요." -chatgpt_token_expired_title = "Codex 세션 만료" -chatgpt_token_expired_body = "사용량을 계속 추적하려면 다시 로그인하세요." -threshold_80_body = "5시간 한도에 가까워지고 있어요." -threshold_95_body = "한도 임박 — 잠시 쉬어가세요." -update_applied_title = "업데이트가 적용되었습니다" -update_applied_body = "버전 v" -update_rollback_failed_body = "업데이트 실패. 원본 바이너리는 다음 위치에 저장되었습니다: " diff --git a/src/i18n/locales/vi.toml b/src/i18n/locales/vi.toml deleted file mode 100644 index 8d9075c..0000000 --- a/src/i18n/locales/vi.toml +++ /dev/null @@ -1,60 +0,0 @@ -code = "vi" -native_name = "Tiếng Việt" - -window_title = "Claude Code Usage Bubble" -refresh = "Làm mới" -update_frequency = "Tần suất cập nhật" -one_minute = "1 phút" -five_minutes = "5 phút" -fifteen_minutes = "15 phút" -one_hour = "1 giờ" -providers = "Nhà cung cấp" -claude_label = "Claude Code" -chatgpt_label = "Codex" -opencode_go_label = "OpenCode Go" -settings = "Cài đặt" -start_with_windows = "Khởi động cùng Windows" -reset_position = "Đặt lại vị trí" -size_smaller = "Thu nhỏ" -size_larger = "Phóng to" -reset_size = "Đặt lại kích thước" -controls = "Điều khiển" -control_left_click = "Nhấp trái: chi tiết" -control_right_click = "Nhấp phải: menu" -control_drag = "Kéo: di chuyển/bám" -control_ctrl_wheel = "Ctrl+cuộn: đổi cỡ" -control_tray_click = "Khay: hiện/ẩn" -tray_left_click = "Nhấp trái: hiện/ẩn" -language = "Ngôn ngữ" -system_default = "Mặc định hệ thống" -check_for_updates = "Kiểm tra cập nhật" -checking_for_updates = "Đang kiểm tra cập nhật…" -up_to_date = "Đã là phiên bản mới nhất" -update_failed = "Cập nhật thất bại" -applying_update = "Đang áp dụng cập nhật…" -update_available = "Có bản cập nhật mới" -update_via_winget = "qua WinGet" -auto_update_check = "Tự động kiểm tra cập nhật" -auto_check_disabled = "Tắt" -auto_check_hourly = "Mỗi giờ" -auto_check_daily = "Hằng ngày" -auto_check_weekly = "Hằng tuần" -exit = "Thoát" -restart = "Khởi động lại" -show_widget = "Hiện widget" -session_window = "5g" -weekly_window = "7n" -now = "ngay" -day_suffix = "n" -hour_suffix = "g" -minute_suffix = "p" -second_suffix = "s" -token_expired_title = "Phiên Claude Code đã hết hạn" -token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng." -chatgpt_token_expired_title = "Phiên Codex đã hết hạn" -chatgpt_token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng." -threshold_80_body = "Sắp chạm giới hạn 5 giờ." -threshold_95_body = "Sắp tới giới hạn — hãy cân nhắc giảm tốc." -update_applied_title = "Đã áp dụng cập nhật" -update_applied_body = "Đã cập nhật lên v" -update_rollback_failed_body = "Cập nhật thất bại. Tệp gốc của bạn được lưu tại: " diff --git a/src/i18n/locales/zh-TW.toml b/src/i18n/locales/zh-TW.toml deleted file mode 100644 index 8d9c711..0000000 --- a/src/i18n/locales/zh-TW.toml +++ /dev/null @@ -1,60 +0,0 @@ -code = "zh-TW" -native_name = "繁體中文" - -window_title = "Claude Code Usage Bubble" -refresh = "重新整理" -update_frequency = "更新頻率" -one_minute = "1 分鐘" -five_minutes = "5 分鐘" -fifteen_minutes = "15 分鐘" -one_hour = "1 小時" -providers = "提供者" -claude_label = "Claude Code" -chatgpt_label = "Codex" -opencode_go_label = "OpenCode Go" -settings = "設定" -start_with_windows = "隨 Windows 啟動" -reset_position = "重設位置" -size_smaller = "縮小" -size_larger = "放大" -reset_size = "重設大小" -controls = "操作" -control_left_click = "左鍵: 詳細" -control_right_click = "右鍵: 選單" -control_drag = "拖曳: 移動/吸附" -control_ctrl_wheel = "Ctrl+滾輪: 調整大小" -control_tray_click = "系統匣: 顯示/隱藏" -tray_left_click = "左鍵: 顯示/隱藏" -language = "語言" -system_default = "系統預設" -check_for_updates = "檢查更新" -checking_for_updates = "檢查中…" -up_to_date = "已是最新" -update_failed = "更新失敗" -applying_update = "正在套用更新…" -update_available = "有可用更新" -update_via_winget = "透過 WinGet" -auto_update_check = "自動檢查更新" -auto_check_disabled = "停用" -auto_check_hourly = "每小時" -auto_check_daily = "每天" -auto_check_weekly = "每週" -exit = "結束" -restart = "重新啟動" -show_widget = "顯示小工具" -session_window = "5 小時" -weekly_window = "7 日" -now = "現在" -day_suffix = "日" -hour_suffix = "時" -minute_suffix = "分" -second_suffix = "秒" -token_expired_title = "Claude Code 工作階段已過期" -token_expired_body = "請重新登入以繼續追蹤使用量。" -chatgpt_token_expired_title = "Codex 工作階段已過期" -chatgpt_token_expired_body = "請重新登入以繼續追蹤使用量。" -threshold_80_body = "接近 5 小時上限。" -threshold_95_body = "上限將至 — 建議稍作休息。" -update_applied_title = "已套用更新" -update_applied_body = "已更新至 v" -update_rollback_failed_body = "更新失敗。您的原始執行檔已保存於: " diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 1ea6252..3e725df 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -1,25 +1,9 @@ -// Embedded TOML-based localisation. -// -// Each supported language lives in `locales/.toml`. At startup we -// `include_str!` every file, parse them with `toml`, and stash them in a -// HashMap keyed by language code. The active language defaults to whatever -// Windows reports for the user's preferred UI language; the menu lets the -// user override that. -// -// Adding a translation: copy `en.toml` to `.toml`, translate the -// strings, then add one `include_str!` entry to `RAW_LOCALES` below. +// English UI strings and formatting helpers. -use std::collections::BTreeMap; use std::time::{Duration, SystemTime}; -use serde::Deserialize; - -pub mod detect; - -const FALLBACK_CODE: &str = "en"; - -/// The strings every UI module needs. Field names map 1:1 to TOML keys. -#[derive(Clone, Debug, Deserialize)] +/// The strings every UI module needs. +#[derive(Clone, Debug)] pub struct LocaleStrings { pub window_title: String, pub refresh: String, @@ -45,8 +29,6 @@ pub struct LocaleStrings { pub control_ctrl_wheel: String, pub control_tray_click: String, pub tray_left_click: String, - pub language: String, - pub system_default: String, pub check_for_updates: String, pub checking_for_updates: String, pub up_to_date: String, @@ -74,147 +56,93 @@ pub struct LocaleStrings { pub chatgpt_token_expired_title: String, pub chatgpt_token_expired_body: String, /// Body text for "your usage just crossed 80% of the 5h limit". The - /// title is composed from the provider label + percent so it does not - /// need to be translated separately. + /// title is composed from the provider label + percent. pub threshold_80_body: String, /// Body text for the 95% threshold balloon. pub threshold_95_body: String, /// Title for the tray balloon shown on first launch after an auto-update. pub update_applied_title: String, - /// Prefix for the tray balloon body. Call site appends the version (e.g. "0.1.10"). + /// Prefix for the tray balloon body. Call site appends the version. pub update_applied_body: String, /// Prefix for the rollback-failed MessageBox body. Call site appends /// the backup path and a separator with the expected target filename. pub update_rollback_failed_body: String, } -#[derive(Deserialize)] -struct LocaleFile { - code: String, - native_name: String, - #[serde(flatten)] +pub struct I18n { strings: LocaleStrings, } -const RAW_LOCALES: &[(&str, &str)] = &[ - ("en", include_str!("locales/en.toml")), - ("ja", include_str!("locales/ja.toml")), - ("ko", include_str!("locales/ko.toml")), - ("vi", include_str!("locales/vi.toml")), - ("zh-TW", include_str!("locales/zh-TW.toml")), -]; - -pub struct I18n { - /// Sorted by code so menus list languages deterministically. - available: BTreeMap, - active: String, -} - impl I18n { - /// Load all embedded TOML files and pick an active language. - /// - /// `requested` overrides system detection. `None` means "ask Windows". - pub fn load(requested: Option<&str>) -> Self { - let mut available = BTreeMap::new(); - for (code, body) in RAW_LOCALES { - match toml::from_str::(body) { - Ok(file) => { - available.insert(file.code.clone(), (file.native_name, file.strings)); - } - Err(e) => { - log::error!("failed to parse locale {code}: {e}"); - } - } + pub fn load() -> Self { + Self { + strings: english_strings(), } - if !available.contains_key(FALLBACK_CODE) { - // Embedded TOMLs are validated by tests; this should never - // happen in practice. Fall through with whatever we have. - log::error!("fallback locale '{FALLBACK_CODE}' missing"); - } - - let active = requested - .and_then(|c| normalise(c, &available)) - .or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &available))) - .unwrap_or_else(|| FALLBACK_CODE.to_string()); - - Self { available, active } } pub fn strings(&self) -> &LocaleStrings { - self.available - .get(&self.active) - .map(|(_, s)| s) - .unwrap_or_else(|| { - // Defensive: if `active` was set to something unavailable - // (shouldn't happen given `load` validates) — fall back. - &self - .available - .get(FALLBACK_CODE) - .expect("fallback locale must exist") - .1 - }) - } - - pub fn active_code(&self) -> &str { - &self.active - } - - /// Iterate `(code, native_name)` pairs in stable order. - pub fn available(&self) -> impl Iterator { - self.available - .iter() - .map(|(code, (name, _))| (code.as_str(), name.as_str())) - } - - pub fn set_active(&mut self, requested: Option<&str>) { - let new_active = requested - .and_then(|c| normalise(c, &self.available)) - .or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &self.available))) - .unwrap_or_else(|| FALLBACK_CODE.to_string()); - self.active = new_active; + &self.strings } } -/// Resolve a user-supplied or system-supplied locale code to one we have. -/// -/// Handles `en_US`, `en-US`, `EN`, `zh-Hant-TW`, etc. by progressive -/// fallback: exact → ASCII-lower exact → prefix match. -fn normalise(input: &str, available: &BTreeMap) -> Option { - let cleaned = input.trim().replace('_', "-"); - if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("system") { - return None; +fn english_strings() -> LocaleStrings { + LocaleStrings { + window_title: "Claude Code Usage Bubble".into(), + refresh: "Refresh".into(), + update_frequency: "Update frequency".into(), + one_minute: "1 minute".into(), + five_minutes: "5 minutes".into(), + fifteen_minutes: "15 minutes".into(), + one_hour: "1 hour".into(), + providers: "Providers".into(), + claude_label: "Claude Code".into(), + chatgpt_label: "Codex".into(), + opencode_go_label: "OpenCode Go".into(), + settings: "Settings".into(), + start_with_windows: "Start with Windows".into(), + reset_position: "Reset position".into(), + size_smaller: "Make smaller".into(), + size_larger: "Make larger".into(), + reset_size: "Reset size".into(), + controls: "Controls".into(), + control_left_click: "Left-click: details".into(), + control_right_click: "Right-click: menu".into(), + control_drag: "Drag: move/snap".into(), + control_ctrl_wheel: "Ctrl+Wheel: resize".into(), + control_tray_click: "Tray click: show/hide".into(), + tray_left_click: "Left-click: show/hide".into(), + check_for_updates: "Check for updates".into(), + checking_for_updates: "Checking for updates...".into(), + up_to_date: "Up to date".into(), + update_failed: "Update failed".into(), + applying_update: "Applying update...".into(), + update_available: "Update available".into(), + update_via_winget: "via WinGet".into(), + auto_update_check: "Auto-update check".into(), + auto_check_disabled: "Disabled".into(), + auto_check_hourly: "Hourly".into(), + auto_check_daily: "Daily".into(), + auto_check_weekly: "Weekly".into(), + exit: "Exit".into(), + restart: "Restart".into(), + show_widget: "Show widget".into(), + session_window: "5h".into(), + weekly_window: "7d".into(), + now: "now".into(), + day_suffix: "d".into(), + hour_suffix: "h".into(), + minute_suffix: "m".into(), + second_suffix: "s".into(), + token_expired_title: "Claude Code session expired".into(), + token_expired_body: "Sign in again to keep tracking your usage.".into(), + chatgpt_token_expired_title: "Codex session expired".into(), + chatgpt_token_expired_body: "Sign in again to keep tracking your usage.".into(), + threshold_80_body: "Approaching the 5-hour limit.".into(), + threshold_95_body: "Limit is close - consider easing up.".into(), + update_applied_title: "Update applied".into(), + update_applied_body: "Updated to v".into(), + update_rollback_failed_body: "Update failed. Your original binary is saved at: ".into(), } - // Exact (case-insensitive) - for key in available.keys() { - if key.eq_ignore_ascii_case(&cleaned) { - return Some(key.clone()); - } - } - // Special-case: Traditional Chinese variants → zh-TW - let lower = cleaned.to_ascii_lowercase(); - if lower.starts_with("zh") - && (lower.contains("tw") || lower.contains("hk") || lower.contains("hant")) - { - if available.contains_key("zh-TW") { - return Some("zh-TW".to_string()); - } - } - // Prefix fallback (e.g. "en-US" → "en") - let prefix = lower.split('-').next().unwrap_or(""); - if !prefix.is_empty() { - for key in available.keys() { - if key - .split('-') - .next() - .map(str::to_ascii_lowercase) - .as_deref() - == Some(prefix) - { - return Some(key.clone()); - } - } - } - None } // ---------- Free-function helpers ---------- @@ -282,79 +210,24 @@ mod tests { use super::*; #[test] - fn embedded_locales_parse_and_include_required_control_strings() { - let mut has_fallback = false; - for (expected_code, body) in RAW_LOCALES { - let file = toml::from_str::(body) - .unwrap_or_else(|e| panic!("locale {expected_code} failed to parse: {e}")); - assert_eq!(file.code, *expected_code); - has_fallback |= file.code == FALLBACK_CODE; - - let strings = file.strings; - for (name, value) in [ - ("size_smaller", strings.size_smaller.as_str()), - ("providers", strings.providers.as_str()), - ("size_larger", strings.size_larger.as_str()), - ("opencode_go_label", strings.opencode_go_label.as_str()), - ("reset_size", strings.reset_size.as_str()), - ("controls", strings.controls.as_str()), - ("control_left_click", strings.control_left_click.as_str()), - ("control_right_click", strings.control_right_click.as_str()), - ("control_drag", strings.control_drag.as_str()), - ("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()), - ("control_tray_click", strings.control_tray_click.as_str()), - ("tray_left_click", strings.tray_left_click.as_str()), - ] { - assert!( - !value.trim().is_empty(), - "locale {expected_code} has empty {name}" - ); - } + fn english_strings_include_required_menu_labels() { + let strings = english_strings(); + for (name, value) in [ + ("refresh", strings.refresh.as_str()), + ("providers", strings.providers.as_str()), + ("settings", strings.settings.as_str()), + ("size_smaller", strings.size_smaller.as_str()), + ("size_larger", strings.size_larger.as_str()), + ("reset_size", strings.reset_size.as_str()), + ("controls", strings.controls.as_str()), + ("control_left_click", strings.control_left_click.as_str()), + ("control_right_click", strings.control_right_click.as_str()), + ("control_drag", strings.control_drag.as_str()), + ("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()), + ("control_tray_click", strings.control_tray_click.as_str()), + ("tray_left_click", strings.tray_left_click.as_str()), + ] { + assert!(!value.trim().is_empty(), "empty string: {name}"); } - assert!(has_fallback, "fallback locale {FALLBACK_CODE} missing"); - } - - #[test] - fn locale_schema_rejects_missing_or_malformed_control_strings() { - let (_, fallback_body) = RAW_LOCALES - .iter() - .find(|(code, _)| *code == FALLBACK_CODE) - .expect("fallback locale fixture must exist"); - - let missing_control = - fallback_body.replace("tray_left_click = \"Left-click: show/hide\"\n", ""); - assert!( - toml::from_str::(&missing_control).is_err(), - "missing tray_left_click should fail locale deserialization" - ); - - let malformed_control = fallback_body.replace( - "control_tray_click = \"Tray click: show/hide\"", - "control_tray_click = [\"Tray click: show/hide\"]", - ); - assert!( - toml::from_str::(&malformed_control).is_err(), - "malformed control_tray_click should fail locale deserialization" - ); - } - - #[test] - fn locale_schema_rejects_missing_provider_strings() { - let (_, fallback_body) = RAW_LOCALES - .iter() - .find(|(code, _)| *code == FALLBACK_CODE) - .expect("fallback locale fixture must exist"); - - let missing_providers = fallback_body.replace("providers = \"Providers\"\n", ""); - assert!( - toml::from_str::(&missing_providers).is_err(), - "missing providers should fail locale deserialization" - ); - - let missing_opencode = fallback_body.replace("opencode_go_label = \"OpenCode Go\"\n", ""); - assert!( - toml::from_str::(&missing_opencode).is_err(), - "missing opencode_go_label should fail locale deserialization" - ); } } diff --git a/src/settings.rs b/src/settings.rs index 9853997..2db18d7 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -139,8 +139,6 @@ pub struct Settings { #[serde(default = "default_poll_interval_ms")] pub poll_interval_ms: u32, #[serde(default)] - pub language: Option, - #[serde(default)] pub last_update_check_unix: Option, #[serde(default = "default_update_check_interval_secs")] pub update_check_interval_secs: Option, @@ -157,7 +155,6 @@ impl Default for Settings { bubble_positions: BubblePositions::default(), bubble_size_logical: default_bubble_size(), poll_interval_ms: default_poll_interval_ms(), - language: None, last_update_check_unix: None, update_check_interval_secs: default_update_check_interval_secs(), widget_visible: default_widget_visible(), diff --git a/src/update/install.rs b/src/update/install.rs index bf35ca1..5b1889e 100644 --- a/src/update/install.rs +++ b/src/update/install.rs @@ -245,9 +245,9 @@ fn backup_path(target: &Path) -> PathBuf { } fn surface_rollback_failure(backup: &Path, target_name: &str) { - // Pull the localized body from i18n; the caller passes the + // Pull the UI body text from i18n; the caller passes the // user-meaningful filename so we can format it in-place. - let strings = crate::i18n::I18n::load(None).strings().clone(); + let strings = crate::i18n::I18n::load().strings().clone(); let body = format!( "{}{}\n\n{}", strings.update_rollback_failed_body, diff --git a/src/usage/refresh.rs b/src/usage/refresh.rs index d6a0d28..5a28cf1 100644 --- a/src/usage/refresh.rs +++ b/src/usage/refresh.rs @@ -56,11 +56,13 @@ impl Orchestrator { fn spawn_cli(hint: &RefreshHint) -> bool { match hint { - RefreshHint::LocalClaudeCli => spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."]), - RefreshHint::WslClaudeCli { distro } => spawn_wsl(distro), - RefreshHint::LocalCodexCli => { - spawn_local(&["codex.cmd", "codex.ps1", "codex.exe", "codex"], &["exec", "."]) + RefreshHint::LocalClaudeCli => { + spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."]) } + RefreshHint::LocalCodexCli => spawn_local( + &["codex.cmd", "codex.ps1", "codex.exe", "codex"], + &["exec", "."], + ), } } @@ -69,7 +71,11 @@ fn spawn_local(candidates: &[&str], args: &[&str]) -> bool { let lower = name.to_ascii_lowercase(); let mut cmd = if lower.ends_with(".ps1") { let mut c = Command::new("powershell.exe"); - c.arg("-NoProfile").arg("-ExecutionPolicy").arg("Bypass").arg("-File").arg(name); + c.arg("-NoProfile") + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-File") + .arg(name); for a in args { c.arg(a); } @@ -100,24 +106,3 @@ fn spawn_local(candidates: &[&str], args: &[&str]) -> bool { } false } - -fn spawn_wsl(distro: &str) -> bool { - let script = "if command -v claude >/dev/null 2>&1; then claude -p .; \ - elif [ -x \"$HOME/.local/bin/claude\" ]; then \"$HOME/.local/bin/claude\" -p .; \ - else exit 127; fi"; - Command::new("wsl.exe") - .arg("-d") - .arg(distro) - .arg("--") - .arg("bash") - .arg("-lic") - .arg(script) - .env_remove("CLAUDECODE") - .env_remove("CLAUDE_CODE_ENTRYPOINT") - .creation_flags(CREATE_NO_WINDOW) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .is_ok() -}