feat: clean-room rewrite — replace ported modules with original implementations

Every Rust module under src/ that previously contained upstream-derivative
code has been replaced by a from-scratch implementation:

  diag/    log + simplelog file appender (was: diagnose.rs)
  os/      color, dpi, registry, string, theme (was: theme.rs, native_interop.rs)
  net/     WinHTTP-based HTTP client (was: ureq + native-tls)
  i18n/    TOML-embedded locale tables (was: localization/*.rs)
  usage/   trait UsageProvider + ClaudeProvider + ChatGptProvider + refresh
           orchestrator + registry (was: poller.rs, models.rs)
  creds/   trait CredentialSource + local/WSL/Codex impls (was: poller.rs)
  tray/    stateless tray manager + tiny-skia anti-aliased badge renderer
           (was: tray_icon.rs)
  update/  release fetch + inline cmd /c handoff installer
           (was: updater.rs's helper-exe pattern)

Application files (app.rs, bubble.rs, panel.rs, settings.rs) migrated to
the new modules. main.rs declares only the new modules.

NOTICE deleted; LICENSE is plain Apache-2.0; README updated to credit
inspiration rather than claim derivation. Cargo.toml drops ureq + native-tls
+ winres in favour of log + simplelog + thiserror + toml + tiny-skia +
embed-resource. Build script swapped to embed-resource via res/icon.rc.

External contracts preserved unchanged: Anthropic + ChatGPT endpoints and
headers, ~/.claude/.credentials.json + Codex auth.json paths, WSL bridging
via wsl.exe, CLI-driven token refresh, GitHub Releases JSON shape, Windows
registry path for startup, single-instance mutex name.

Phase docs: plans/260516-0707-cleanroom-rewrite/.
This commit is contained in:
tiennm99 committed 2026-05-16 10:09:43 +07:00
1 parent c0f3e3f860
commit aa6217d2cf
72 files changed
+6265 -3788

No files matched your search

+243
View File
@@ -0,0 +1,243 @@
// Embedded TOML-based localisation.
//
// Each supported language lives in `locales/<code>.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 `<code>.toml`, translate the
// strings, then add one `include_str!` entry to `RAW_LOCALES` below.
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)]
pub struct LocaleStrings {
pub window_title: String,
pub refresh: String,
pub update_frequency: String,
pub one_minute: String,
pub five_minutes: String,
pub fifteen_minutes: String,
pub one_hour: String,
pub models: String,
pub claude_label: String,
pub chatgpt_label: String,
pub settings: String,
pub start_with_windows: String,
pub reset_position: String,
pub language: String,
pub system_default: String,
pub check_for_updates: String,
pub checking_for_updates: String,
pub up_to_date: String,
pub update_failed: String,
pub applying_update: String,
pub update_available: String,
pub update_via_winget: String,
pub exit: String,
pub show_widget: String,
pub session_window: String,
pub weekly_window: String,
pub now: String,
pub day_suffix: String,
pub hour_suffix: String,
pub minute_suffix: String,
pub second_suffix: String,
pub token_expired_title: String,
pub token_expired_body: String,
pub chatgpt_token_expired_title: String,
pub chatgpt_token_expired_body: String,
}
#[derive(Deserialize)]
struct LocaleFile {
code: String,
native_name: String,
#[serde(flatten)]
strings: LocaleStrings,
}
const RAW_LOCALES: &[(&str, &str)] = &[
("en", include_str!("locales/en.toml")),
("nl", include_str!("locales/nl.toml")),
("es", include_str!("locales/es.toml")),
("fr", include_str!("locales/fr.toml")),
("de", include_str!("locales/de.toml")),
("ja", include_str!("locales/ja.toml")),
("ko", include_str!("locales/ko.toml")),
("zh-TW", include_str!("locales/zh-TW.toml")),
];
pub struct I18n {
/// Sorted by code so menus list languages deterministically.
available: BTreeMap<String, (String, LocaleStrings)>,
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::<LocaleFile>(body) {
Ok(file) => {
available.insert(file.code.clone(), (file.native_name, file.strings));
}
Err(e) => {
log::error!("failed to parse locale {code}: {e}");
}
}
}
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<Item = (&str, &str)> {
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;
}
}
/// 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<String, (String, LocaleStrings)>) -> Option<String> {
let cleaned = input.trim().replace('_', "-");
if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("system") {
return None;
}
// 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 ----------
/// Format a `usage::Window` percentage + countdown as `"73% · 2h"`-style text.
/// Returns just the percentage when no reset time is available.
pub fn format_window(window: &crate::usage::Window, strings: &LocaleStrings) -> String {
let pct = format!("{:.0}%", window.utilization);
let cd = format_countdown(window.resets_at, strings);
if cd.is_empty() {
pct
} else {
format!("{pct} \u{00b7} {cd}")
}
}
fn format_countdown(resets_at: Option<SystemTime>, strings: &LocaleStrings) -> String {
let Some(reset) = resets_at else {
return String::new();
};
let remaining = match reset.duration_since(SystemTime::now()) {
Ok(d) => d,
Err(_) => return strings.now.clone(),
};
format_countdown_secs(remaining.as_secs(), strings)
}
fn format_countdown_secs(total_secs: u64, strings: &LocaleStrings) -> String {
let days = total_secs / 86_400;
let hours = total_secs / 3_600;
let mins = total_secs / 60;
if days >= 1 {
format!("{days}{}", strings.day_suffix)
} else if hours >= 1 {
format!("{hours}{}", strings.hour_suffix)
} else if mins >= 1 {
format!("{mins}{}", strings.minute_suffix)
} else {
format!("{total_secs}{}", strings.second_suffix)
}
}
/// How long before `format_window`'s string would change.
/// Used by the countdown timer to refresh exactly when needed.
pub fn time_until_display_change(resets_at: Option<SystemTime>) -> Option<Duration> {
let reset = resets_at?;
let remaining = reset.duration_since(SystemTime::now()).ok()?;
let secs = remaining.as_secs();
let bucket_start = if secs / 86_400 >= 1 {
(secs / 86_400) * 86_400
} else if secs / 3_600 >= 1 {
(secs / 3_600) * 3_600
} else if secs / 60 >= 1 {
(secs / 60) * 60
} else {
secs
};
Some(Duration::from_secs(secs.saturating_sub(bucket_start) + 1))
}