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

+16
View File
@@ -0,0 +1,16 @@
// Install-channel detection.
//
// Until this app has a winget package, every install is treated as
// "portable" — meaning self-update applies via the inline-cmd handoff.
// When a winget package exists, restore the path-probe code from git
// history (see commit before Phase 6).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Channel {
Portable,
Winget,
}
pub fn current() -> Channel {
Channel::Portable
}
+89
View File
@@ -0,0 +1,89 @@
// Download a release asset and hand off via inline `cmd /c`.
//
// We avoid the helper-exe pattern entirely: after writing the new .exe
// to a staging path, we spawn cmd.exe with a one-liner that waits 2 s,
// moves the new binary over the running one (Windows releases the file
// lock when our process exits), and relaunches it.
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use crate::net::Client;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const DETACHED_PROCESS: u32 = 0x0000_0008;
pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error> {
let current = std::env::current_exe()?;
ensure_writable(&current)?;
let staging = stage_path()?;
if let Some(parent) = staging.parent() {
std::fs::create_dir_all(parent)?;
}
download(http, &release.asset_url, &staging)?;
spawn_handoff(&staging, &current)?;
Ok(())
}
/// CLI entry-point compatibility for `--apply-update <target> <source> <pid>`.
/// The inline-cmd handoff already does the swap-and-restart; if this binary
/// is invoked with the legacy flag (e.g. from an older release's helper)
/// just exit cleanly so the upgrade still completes.
pub fn run_cli(args: &[String]) -> Option<i32> {
if args.len() >= 2 && args[1] == "--apply-update" {
Some(0)
} else {
None
}
}
fn download(http: &Client, url: &str, to: &std::path::Path) -> Result<(), super::Error> {
let resp = http
.get(url)
.header("User-Agent", super::release::user_agent())
.send()?;
if !(200..300).contains(&resp.status()) {
return Err(super::Error::Network(crate::net::Error::Status(resp.status())));
}
std::fs::write(to, resp.body())?;
Ok(())
}
fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(), super::Error> {
let src_str = source.to_string_lossy().replace('"', "");
let tgt_str = target.to_string_lossy().replace('"', "");
// 2-second wait gives the current process time to exit and release the
// file lock before `move` overwrites it.
let cmd = format!(
r#"timeout /t 2 /nobreak >nul & move /y "{src_str}" "{tgt_str}" & start "" "{tgt_str}""#
);
Command::new("cmd.exe")
.args(["/c", &cmd])
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
Ok(())
}
fn stage_path() -> Result<PathBuf, super::Error> {
let base = dirs::data_local_dir().ok_or_else(|| {
super::Error::NotWritable("no local data directory available".to_string())
})?;
Ok(base
.join("ClaudeCodeUsageBubble")
.join("updates")
.join("update.exe"))
}
fn ensure_writable(target: &std::path::Path) -> Result<(), super::Error> {
let parent = target.parent().ok_or_else(|| {
super::Error::NotWritable("could not resolve install directory".to_string())
})?;
let probe = parent.join(".__bubble_update_probe");
std::fs::write(&probe, b"").map_err(|e| super::Error::NotWritable(e.to_string()))?;
let _ = std::fs::remove_file(&probe);
Ok(())
}
+34
View File
@@ -0,0 +1,34 @@
// Self-update subsystem.
//
// Two stages: `release::fetch_latest` checks GitHub releases for a newer
// build; `install::begin` downloads the .exe and hands off to a detached
// `cmd /c` script that swaps the binary and restarts.
pub mod channel;
pub mod install;
pub mod release;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("network: {0}")]
Network(#[from] crate::net::Error),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("no matching release asset")]
NoAsset,
#[error("install location not writable: {0}")]
NotWritable(String),
#[error("malformed version: {0}")]
BadVersion(String),
}
pub use channel::{current as current_channel, Channel};
pub use install::{begin, run_cli};
pub use release::{fetch_latest, Release, Version};
/// Result of a release-check call.
#[derive(Debug)]
pub enum CheckOutcome {
UpToDate,
Available(Release),
}
+91
View File
@@ -0,0 +1,91 @@
// Query the GitHub Releases API and pick the relevant asset.
use serde::Deserialize;
use crate::net::Client;
const ASSET_NAME: &str = "claude-code-usage-bubble.exe";
const REPO_OWNER: &str = "tiennm99";
const REPO_NAME: &str = "claude-code-usage-bubble";
#[derive(Clone, Debug)]
pub struct Release {
pub version: Version,
pub asset_url: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl Version {
pub fn current() -> Self {
Self::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version {
major: 0,
minor: 0,
patch: 0,
})
}
pub fn parse(s: &str) -> Option<Self> {
let core = s.trim().trim_start_matches('v').split('-').next()?;
let mut parts = core.split('.').map(|p| p.parse::<u32>().ok());
Some(Version {
major: parts.next().flatten().unwrap_or(0),
minor: parts.next().flatten().unwrap_or(0),
patch: parts.next().flatten().unwrap_or(0),
})
}
}
pub fn fetch_latest(http: &Client) -> Result<super::CheckOutcome, super::Error> {
let url = format!("https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest");
let resp = http
.get(&url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", user_agent())
.send()?;
if !(200..300).contains(&resp.status()) {
return Err(super::Error::Network(crate::net::Error::Status(resp.status())));
}
let body: GhRelease = resp.json()?;
let candidate = Version::parse(&body.tag_name)
.ok_or_else(|| super::Error::BadVersion(body.tag_name.clone()))?;
if candidate <= Version::current() {
return Ok(super::CheckOutcome::UpToDate);
}
let asset = body
.assets
.iter()
.find(|a| a.name.eq_ignore_ascii_case(ASSET_NAME))
.or_else(|| {
body.assets
.iter()
.find(|a| a.name.to_ascii_lowercase().ends_with(".exe"))
})
.ok_or(super::Error::NoAsset)?;
Ok(super::CheckOutcome::Available(Release {
version: candidate,
asset_url: asset.browser_download_url.clone(),
}))
}
pub fn user_agent() -> &'static str {
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))
}
#[derive(Deserialize)]
struct GhRelease {
tag_name: String,
assets: Vec<GhAsset>,
}
#[derive(Deserialize)]
struct GhAsset {
name: String,
browser_download_url: String,
}