mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-06-07 12:12:49 +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:
@@ -0,0 +1,9 @@
|
||||
// `net` namespace: HTTP client implementations.
|
||||
//
|
||||
// `winhttp` is the only backend right now (Windows-only app). It produces
|
||||
// `Response` values that are cheap to inspect via `status`, `header`,
|
||||
// `text`, and `json`.
|
||||
|
||||
pub mod winhttp;
|
||||
|
||||
pub use winhttp::{Client, Error, Response};
|
||||
@@ -0,0 +1,431 @@
|
||||
// Minimal blocking HTTP client built on Win32 WinHTTP.
|
||||
//
|
||||
// One `Client` owns a session handle and is thread-safe (WinHTTP sessions
|
||||
// can be used from multiple threads per MSDN). Each `send()` call manages
|
||||
// its own connection + request handle lifetime via RAII guards so failures
|
||||
// at any point clean up correctly.
|
||||
//
|
||||
// We deliberately do NOT use `WinHttpCrackUrl` — the small `parse_url`
|
||||
// helper below is enough for the HTTPS URLs this app actually talks to and
|
||||
// keeps the call sites simpler.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::ptr::null_mut;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Networking::WinHttp::*;
|
||||
|
||||
use crate::os::string::to_utf16_nul;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("WinHTTP call failed: {context}")]
|
||||
Win { context: String },
|
||||
#[error("invalid URL: {0}")]
|
||||
Url(String),
|
||||
#[error("response was not valid UTF-8")]
|
||||
Utf8,
|
||||
#[error("JSON parse: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("response status {0}")]
|
||||
Status(u32),
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
session: SessionHandle,
|
||||
}
|
||||
|
||||
// WinHTTP session handles are safe to use concurrently per the Microsoft docs
|
||||
// (https://learn.microsoft.com/en-us/windows/win32/winhttp/winhttp-functions).
|
||||
unsafe impl Send for Client {}
|
||||
unsafe impl Sync for Client {}
|
||||
|
||||
impl Client {
|
||||
/// Create a new HTTP client. `user_agent` is sent on every request.
|
||||
pub fn new(user_agent: &str) -> Result<Self, Error> {
|
||||
let ua = to_utf16_nul(user_agent);
|
||||
let session = unsafe {
|
||||
WinHttpOpen(
|
||||
PCWSTR::from_raw(ua.as_ptr()),
|
||||
WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
|
||||
PCWSTR::null(),
|
||||
PCWSTR::null(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if session.is_null() {
|
||||
return Err(Error::Win {
|
||||
context: "WinHttpOpen".into(),
|
||||
});
|
||||
}
|
||||
// Ask WinHTTP to decompress gzip/deflate transparently so callers
|
||||
// get plain bytes back from `Response::body()`. Best-effort; if it
|
||||
// fails the request still works, callers just see raw compressed
|
||||
// bytes on responses that opt-in to compression.
|
||||
unsafe {
|
||||
let flags: u32 = WINHTTP_DECOMPRESSION_FLAG_GZIP | WINHTTP_DECOMPRESSION_FLAG_DEFLATE;
|
||||
let flag_bytes = flags.to_ne_bytes();
|
||||
if let Err(e) = WinHttpSetOption(
|
||||
Some(session as *const c_void),
|
||||
WINHTTP_OPTION_DECOMPRESSION,
|
||||
Some(&flag_bytes),
|
||||
) {
|
||||
log::warn!("WinHttpSetOption(DECOMPRESSION) failed: {e}");
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
session: SessionHandle(session),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get<'a>(&'a self, url: &str) -> RequestBuilder<'a> {
|
||||
RequestBuilder::new(self, Method::Get, url)
|
||||
}
|
||||
|
||||
pub fn post<'a>(&'a self, url: &str) -> RequestBuilder<'a> {
|
||||
RequestBuilder::new(self, Method::Post, url)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Method {
|
||||
Get,
|
||||
Post,
|
||||
}
|
||||
|
||||
impl Method {
|
||||
fn verb(self) -> &'static str {
|
||||
match self {
|
||||
Method::Get => "GET",
|
||||
Method::Post => "POST",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RequestBuilder<'a> {
|
||||
client: &'a Client,
|
||||
method: Method,
|
||||
url: String,
|
||||
headers: Vec<(String, String)>,
|
||||
body: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<'a> RequestBuilder<'a> {
|
||||
fn new(client: &'a Client, method: Method, url: &str) -> Self {
|
||||
Self {
|
||||
client,
|
||||
method,
|
||||
url: url.to_string(),
|
||||
headers: Vec::new(),
|
||||
body: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn header(mut self, name: &str, value: &str) -> Self {
|
||||
self.headers.push((name.to_string(), value.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn json_body<T: Serialize>(mut self, body: &T) -> Result<Self, Error> {
|
||||
self.body = Some(serde_json::to_vec(body)?);
|
||||
self.headers
|
||||
.push(("Content-Type".into(), "application/json".into()));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn send(self) -> Result<Response, Error> {
|
||||
let parsed = parse_url(&self.url)?;
|
||||
let host_w = to_utf16_nul(&parsed.host);
|
||||
let path_w = to_utf16_nul(&parsed.path);
|
||||
let verb_w = to_utf16_nul(self.method.verb());
|
||||
|
||||
let connect = unsafe {
|
||||
WinHttpConnect(
|
||||
self.client.session.0,
|
||||
PCWSTR::from_raw(host_w.as_ptr()),
|
||||
parsed.port,
|
||||
0,
|
||||
)
|
||||
};
|
||||
if connect.is_null() {
|
||||
return Err(Error::Win {
|
||||
context: "WinHttpConnect".into(),
|
||||
});
|
||||
}
|
||||
let _connect_guard = HandleGuard(connect);
|
||||
|
||||
let flags = if parsed.secure {
|
||||
WINHTTP_FLAG_SECURE
|
||||
} else {
|
||||
WINHTTP_OPEN_REQUEST_FLAGS(0)
|
||||
};
|
||||
let request = unsafe {
|
||||
WinHttpOpenRequest(
|
||||
connect,
|
||||
PCWSTR::from_raw(verb_w.as_ptr()),
|
||||
PCWSTR::from_raw(path_w.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
PCWSTR::null(),
|
||||
std::ptr::null::<PCWSTR>(),
|
||||
flags,
|
||||
)
|
||||
};
|
||||
if request.is_null() {
|
||||
return Err(Error::Win {
|
||||
context: "WinHttpOpenRequest".into(),
|
||||
});
|
||||
}
|
||||
let _request_guard = HandleGuard(request);
|
||||
|
||||
// Combine headers into a single CRLF-separated string. The binding
|
||||
// takes a UTF-16 slice; length is derived from the slice and no
|
||||
// trailing NUL is required.
|
||||
if !self.headers.is_empty() {
|
||||
let header_str = self
|
||||
.headers
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}: {v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n");
|
||||
let header_w: Vec<u16> = header_str.encode_utf16().collect();
|
||||
unsafe {
|
||||
WinHttpAddRequestHeaders(
|
||||
request,
|
||||
&header_w[..],
|
||||
WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE,
|
||||
)
|
||||
}
|
||||
.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpAddRequestHeaders: {e}"),
|
||||
})?;
|
||||
}
|
||||
|
||||
// Send body if present. dwTotalLength = body length; dwOptionalLength
|
||||
// mirrors it for synchronous sends with the buffer included up front.
|
||||
let body_bytes: &[u8] = self.body.as_deref().unwrap_or(&[]);
|
||||
let body_ptr = if body_bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(body_bytes.as_ptr() as *const c_void)
|
||||
};
|
||||
let body_len = body_bytes.len() as u32;
|
||||
unsafe {
|
||||
WinHttpSendRequest(request, None, body_ptr, body_len, body_len, 0)
|
||||
}
|
||||
.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpSendRequest: {e}"),
|
||||
})?;
|
||||
|
||||
unsafe { WinHttpReceiveResponse(request, null_mut()) }.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpReceiveResponse: {e}"),
|
||||
})?;
|
||||
|
||||
let status = query_status_code(request)?;
|
||||
let headers = query_raw_headers(request)?;
|
||||
let body = read_body(request)?;
|
||||
|
||||
Ok(Response {
|
||||
status,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Response {
|
||||
status: u32,
|
||||
headers: Vec<(String, String)>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn status(&self) -> u32 {
|
||||
self.status
|
||||
}
|
||||
|
||||
pub fn header(&self, name: &str) -> Option<&str> {
|
||||
self.headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
pub fn body(&self) -> &[u8] {
|
||||
&self.body
|
||||
}
|
||||
|
||||
pub fn text(&self) -> Result<&str, Error> {
|
||||
std::str::from_utf8(&self.body).map_err(|_| Error::Utf8)
|
||||
}
|
||||
|
||||
pub fn json<T: DeserializeOwned>(&self) -> Result<T, Error> {
|
||||
Ok(serde_json::from_slice(&self.body)?)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Low-level helpers ----------
|
||||
|
||||
struct SessionHandle(*mut c_void);
|
||||
|
||||
impl Drop for SessionHandle {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe {
|
||||
let _ = WinHttpCloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HandleGuard(*mut c_void);
|
||||
|
||||
impl Drop for HandleGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe {
|
||||
let _ = WinHttpCloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query_status_code(request: *mut c_void) -> Result<u32, Error> {
|
||||
let mut status: u32 = 0;
|
||||
let mut size: u32 = std::mem::size_of::<u32>() as u32;
|
||||
unsafe {
|
||||
WinHttpQueryHeaders(
|
||||
request,
|
||||
WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
||||
PCWSTR::null(),
|
||||
Some((&mut status as *mut u32) as *mut c_void),
|
||||
&mut size,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
}
|
||||
.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpQueryHeaders(STATUS_CODE): {e}"),
|
||||
})?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn query_raw_headers(request: *mut c_void) -> Result<Vec<(String, String)>, Error> {
|
||||
// First call sizes the buffer (returns Err with ERROR_INSUFFICIENT_BUFFER
|
||||
// and writes the required byte count to `needed`).
|
||||
let mut needed: u32 = 0;
|
||||
let _ = unsafe {
|
||||
WinHttpQueryHeaders(
|
||||
request,
|
||||
WINHTTP_QUERY_RAW_HEADERS_CRLF,
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
&mut needed,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if needed == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let chars = (needed as usize) / std::mem::size_of::<u16>();
|
||||
let mut buf: Vec<u16> = vec![0u16; chars];
|
||||
unsafe {
|
||||
WinHttpQueryHeaders(
|
||||
request,
|
||||
WINHTTP_QUERY_RAW_HEADERS_CRLF,
|
||||
PCWSTR::null(),
|
||||
Some(buf.as_mut_ptr() as *mut c_void),
|
||||
&mut needed,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
}
|
||||
.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpQueryHeaders(RAW_HEADERS_CRLF): {e}"),
|
||||
})?;
|
||||
let text = String::from_utf16_lossy(&buf[..chars.saturating_sub(1)]);
|
||||
Ok(parse_header_block(&text))
|
||||
}
|
||||
|
||||
fn parse_header_block(block: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
let mut lines = block.split("\r\n");
|
||||
let _ = lines.next(); // status line, e.g. "HTTP/1.1 200 OK"
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once(':') {
|
||||
out.push((k.trim().to_string(), v.trim().to_string()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn read_body(request: *mut c_void) -> Result<Vec<u8>, Error> {
|
||||
let mut body = Vec::new();
|
||||
loop {
|
||||
let mut available: u32 = 0;
|
||||
unsafe { WinHttpQueryDataAvailable(request, &mut available) }.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpQueryDataAvailable: {e}"),
|
||||
})?;
|
||||
if available == 0 {
|
||||
break;
|
||||
}
|
||||
let mut chunk = vec![0u8; available as usize];
|
||||
let mut read: u32 = 0;
|
||||
unsafe {
|
||||
WinHttpReadData(
|
||||
request,
|
||||
chunk.as_mut_ptr() as *mut c_void,
|
||||
available,
|
||||
&mut read,
|
||||
)
|
||||
}
|
||||
.map_err(|e| Error::Win {
|
||||
context: format!("WinHttpReadData: {e}"),
|
||||
})?;
|
||||
chunk.truncate(read as usize);
|
||||
body.append(&mut chunk);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
// ---------- URL parsing ----------
|
||||
|
||||
struct ParsedUrl {
|
||||
host: String,
|
||||
port: u16,
|
||||
path: String,
|
||||
secure: bool,
|
||||
}
|
||||
|
||||
fn parse_url(url: &str) -> Result<ParsedUrl, Error> {
|
||||
let (scheme, rest) = url
|
||||
.split_once("://")
|
||||
.ok_or_else(|| Error::Url(url.to_string()))?;
|
||||
let secure = match scheme.to_ascii_lowercase().as_str() {
|
||||
"https" => true,
|
||||
"http" => false,
|
||||
other => return Err(Error::Url(format!("unsupported scheme: {other}"))),
|
||||
};
|
||||
let (host_port, path) = match rest.find('/') {
|
||||
Some(i) => (&rest[..i], &rest[i..]),
|
||||
None => (rest, "/"),
|
||||
};
|
||||
let (host, port) = match host_port.rsplit_once(':') {
|
||||
Some((h, p)) => (
|
||||
h.to_string(),
|
||||
p.parse::<u16>().map_err(|_| Error::Url(url.to_string()))?,
|
||||
),
|
||||
None => (host_port.to_string(), if secure { 443 } else { 80 }),
|
||||
};
|
||||
if host.is_empty() {
|
||||
return Err(Error::Url(url.to_string()));
|
||||
}
|
||||
Ok(ParsedUrl {
|
||||
host,
|
||||
port,
|
||||
path: path.to_string(),
|
||||
secure,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user