Files
claude-code-usage-bubble/src/net/winhttp.rs
T
tiennm99 aa6217d2cf 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/.
2026-05-16 10:09:43 +07:00

432 lines
12 KiB
Rust

// 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,
})
}