mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-25 16:16:20 +00:00
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:
1 parent
c0f3e3f860
commit
aa6217d2cf
72 files changed
+6265
-3788
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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(¤t)?;
|
||||
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, ¤t)?;
|
||||
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(())
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user