mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-08 18:17:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d9c59b43b | ||
|
|
7039bf7e98 | ||
|
|
cc73c3f9b4 | ||
|
|
a68d80415b | ||
|
|
cce22cc3b7 | ||
|
|
5e00009e5a | ||
|
|
c91dc996f8 | ||
|
|
b2e48cc119 | ||
|
|
bee9f3cfa0 | ||
|
|
860eb62d1b | ||
|
|
c424b17bd7 |
Generated
+1
-1
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.3.5"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"embed-resource",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.3.5"
|
||||
version = "0.3.11"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
||||
|
||||
+293
-40
@@ -19,6 +19,9 @@ use std::time::{Duration, SystemTime};
|
||||
use tiny_skia::{FillRule, LineCap, Paint, PathBuilder, Pixmap, Rect, Stroke, Transform};
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::Graphics::Dwm::{
|
||||
DwmGetWindowAttribute, DWMWA_CLOAKED, DWMWA_EXTENDED_FRAME_BOUNDS,
|
||||
};
|
||||
use windows::Win32::Graphics::Gdi::*;
|
||||
use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW};
|
||||
use windows::Win32::UI::HiDpi::*;
|
||||
@@ -55,6 +58,7 @@ const TASKBAR_GAP_LOGICAL: i32 = 4;
|
||||
const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8;
|
||||
const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
|
||||
const FULLSCREEN_POLL_MS: u32 = 1500;
|
||||
const FULLSCREEN_EDGE_TOLERANCE_PX: i32 = 2;
|
||||
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
|
||||
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
|
||||
|
||||
@@ -965,30 +969,7 @@ fn align_with_peer(this_hwnd: HWND, ny: &mut i32, tolerance: i32) {
|
||||
|
||||
fn check_fullscreen(bubble_hwnd: HWND) {
|
||||
let fg = unsafe { GetForegroundWindow() };
|
||||
if fg == HWND::default() || fg == bubble_hwnd {
|
||||
return;
|
||||
}
|
||||
let mut fr = RECT::default();
|
||||
unsafe {
|
||||
if GetWindowRect(fg, &mut fr).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) };
|
||||
if monitor.is_invalid() {
|
||||
return;
|
||||
}
|
||||
let mut info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let ok = unsafe { GetMonitorInfoW(monitor, &mut info).as_bool() };
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
let mr = info.rcMonitor;
|
||||
let is_fullscreen =
|
||||
fr.left <= mr.left && fr.top <= mr.top && fr.right >= mr.right && fr.bottom >= mr.bottom;
|
||||
let evaluation = evaluate_foreground_fullscreen(fg, bubble_hwnd);
|
||||
|
||||
let (was_hidden_by_fs, user_hidden) = {
|
||||
let bubbles = lock_bubbles();
|
||||
@@ -998,26 +979,265 @@ fn check_fullscreen(bubble_hwnd: HWND) {
|
||||
(b.hidden_by_fullscreen, b.user_hidden)
|
||||
};
|
||||
|
||||
if is_fullscreen && !was_hidden_by_fs {
|
||||
if evaluation.is_fullscreen && !was_hidden_by_fs {
|
||||
unsafe {
|
||||
let _ = ShowWindow(bubble_hwnd, SW_HIDE);
|
||||
}
|
||||
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
|
||||
b.hidden_by_fullscreen = true;
|
||||
}
|
||||
} else if !is_fullscreen && was_hidden_by_fs {
|
||||
if !user_hidden {
|
||||
unsafe {
|
||||
let _ = ShowWindow(bubble_hwnd, SW_SHOWNOACTIVATE);
|
||||
}
|
||||
// Re-paint so the layered surface has the cached data again
|
||||
// (see comment in `set_user_visible`).
|
||||
render(bubble_hwnd);
|
||||
log_fullscreen_decision("hide", &evaluation, false);
|
||||
} else if !evaluation.is_fullscreen && was_hidden_by_fs {
|
||||
show_after_fullscreen(bubble_hwnd, user_hidden);
|
||||
log_fullscreen_decision("show", &evaluation, user_hidden);
|
||||
}
|
||||
}
|
||||
|
||||
struct FullscreenEvaluation {
|
||||
is_fullscreen: bool,
|
||||
hwnd: HWND,
|
||||
class_name: String,
|
||||
bounds: Option<RECT>,
|
||||
bounds_source: &'static str,
|
||||
monitor: Option<RECT>,
|
||||
reason: &'static str,
|
||||
}
|
||||
|
||||
fn evaluate_foreground_fullscreen(fg: HWND, bubble_hwnd: HWND) -> FullscreenEvaluation {
|
||||
let mut evaluation = FullscreenEvaluation {
|
||||
is_fullscreen: false,
|
||||
hwnd: fg,
|
||||
class_name: String::new(),
|
||||
bounds: None,
|
||||
bounds_source: "none",
|
||||
monitor: None,
|
||||
reason: "not evaluated",
|
||||
};
|
||||
|
||||
if fg == HWND::default() {
|
||||
evaluation.reason = "no foreground window";
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
evaluation.class_name = window_class_name(fg);
|
||||
if fg == bubble_hwnd || is_ignored_fullscreen_class(&evaluation.class_name) {
|
||||
evaluation.reason = "ignored foreground class";
|
||||
return evaluation;
|
||||
}
|
||||
if unsafe { !IsWindowVisible(fg).as_bool() || IsIconic(fg).as_bool() } {
|
||||
evaluation.reason = "foreground invisible or minimized";
|
||||
return evaluation;
|
||||
}
|
||||
if is_dwm_cloaked(fg) {
|
||||
evaluation.reason = "foreground cloaked";
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
let Some((bounds, source)) = visible_window_bounds(fg) else {
|
||||
evaluation.reason = "foreground bounds unavailable";
|
||||
return evaluation;
|
||||
};
|
||||
evaluation.bounds = Some(bounds);
|
||||
evaluation.bounds_source = source;
|
||||
|
||||
let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) };
|
||||
if monitor.is_invalid() {
|
||||
evaluation.reason = "foreground monitor unavailable";
|
||||
return evaluation;
|
||||
}
|
||||
let mut info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
if unsafe { !GetMonitorInfoW(monitor, &mut info).as_bool() } {
|
||||
evaluation.reason = "foreground monitor info unavailable";
|
||||
return evaluation;
|
||||
}
|
||||
evaluation.monitor = Some(info.rcMonitor);
|
||||
|
||||
if !rect_covers_monitor(&bounds, &info.rcMonitor) {
|
||||
evaluation.reason = "visible bounds do not cover monitor";
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
if !window_style_allows_fullscreen(fg) {
|
||||
evaluation.reason = "standard framed window";
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
evaluation.is_fullscreen = true;
|
||||
evaluation.reason = "visible bounds cover monitor";
|
||||
evaluation
|
||||
}
|
||||
|
||||
fn show_after_fullscreen(bubble_hwnd: HWND, user_hidden: bool) {
|
||||
if !user_hidden {
|
||||
unsafe {
|
||||
let _ = ShowWindow(bubble_hwnd, SW_SHOWNOACTIVATE);
|
||||
}
|
||||
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
|
||||
b.hidden_by_fullscreen = false;
|
||||
// Re-paint so the layered surface has the cached data again
|
||||
// (see comment in `set_user_visible`).
|
||||
render(bubble_hwnd);
|
||||
}
|
||||
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
|
||||
b.hidden_by_fullscreen = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_window_bounds(hwnd: HWND) -> Option<(RECT, &'static str)> {
|
||||
let mut rect = RECT::default();
|
||||
let dwm_ok = unsafe {
|
||||
DwmGetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_EXTENDED_FRAME_BOUNDS,
|
||||
&mut rect as *mut _ as *mut c_void,
|
||||
std::mem::size_of::<RECT>() as u32,
|
||||
)
|
||||
.is_ok()
|
||||
};
|
||||
if dwm_ok && !rect_is_empty(&rect) {
|
||||
return Some((rect, "dwm-extended-frame"));
|
||||
}
|
||||
|
||||
unsafe {
|
||||
if GetWindowRect(hwnd, &mut rect).is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if rect_is_empty(&rect) {
|
||||
None
|
||||
} else {
|
||||
Some((rect, "window-rect"))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_dwm_cloaked(hwnd: HWND) -> bool {
|
||||
let mut cloaked = 0u32;
|
||||
unsafe {
|
||||
DwmGetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_CLOAKED,
|
||||
&mut cloaked as *mut _ as *mut c_void,
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
)
|
||||
.is_ok()
|
||||
&& cloaked != 0
|
||||
}
|
||||
}
|
||||
|
||||
fn window_class_name(hwnd: HWND) -> String {
|
||||
let mut buf = [0u16; 256];
|
||||
let len = unsafe { GetClassNameW(hwnd, &mut buf) };
|
||||
if len <= 0 {
|
||||
String::new()
|
||||
} else {
|
||||
String::from_utf16_lossy(&buf[..len as usize])
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ignored_fullscreen_class(class_name: &str) -> bool {
|
||||
["Progman", "WorkerW", "Shell_TrayWnd", CLASS_NAME]
|
||||
.iter()
|
||||
.any(|ignored| class_name.eq_ignore_ascii_case(ignored))
|
||||
}
|
||||
|
||||
fn window_style_allows_fullscreen(hwnd: HWND) -> bool {
|
||||
unsafe {
|
||||
SetLastError(WIN32_ERROR(0));
|
||||
}
|
||||
let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) };
|
||||
if style == 0 && unsafe { GetLastError() } != WIN32_ERROR(0) {
|
||||
return false;
|
||||
}
|
||||
window_style_bits_allow_fullscreen(style as u32)
|
||||
}
|
||||
|
||||
fn window_style_bits_allow_fullscreen(style: u32) -> bool {
|
||||
let has_child = style & WS_CHILD.0 != 0;
|
||||
let has_popup = style & WS_POPUP.0 != 0;
|
||||
let has_caption = style & WS_CAPTION.0 != 0;
|
||||
let has_resize_frame = style & WS_THICKFRAME.0 != 0;
|
||||
|
||||
!has_child && (has_popup || (!has_caption && !has_resize_frame))
|
||||
}
|
||||
|
||||
fn rect_covers_monitor(rect: &RECT, bounds: &RECT) -> bool {
|
||||
rect.left <= bounds.left + FULLSCREEN_EDGE_TOLERANCE_PX
|
||||
&& rect.top <= bounds.top + FULLSCREEN_EDGE_TOLERANCE_PX
|
||||
&& rect.right >= bounds.right - FULLSCREEN_EDGE_TOLERANCE_PX
|
||||
&& rect.bottom >= bounds.bottom - FULLSCREEN_EDGE_TOLERANCE_PX
|
||||
}
|
||||
|
||||
fn rect_is_empty(rect: &RECT) -> bool {
|
||||
rect.right <= rect.left || rect.bottom <= rect.top
|
||||
}
|
||||
|
||||
fn log_fullscreen_decision(action: &str, evaluation: &FullscreenEvaluation, user_hidden: bool) {
|
||||
log::info!(
|
||||
"bubble fullscreen {action} fg=0x{:X} class={} reason={} bounds_source={} bounds={} monitor={} user_hidden={}",
|
||||
evaluation.hwnd.0 as usize,
|
||||
if evaluation.class_name.is_empty() {
|
||||
"<unknown>"
|
||||
} else {
|
||||
evaluation.class_name.as_str()
|
||||
},
|
||||
evaluation.reason,
|
||||
evaluation.bounds_source,
|
||||
format_rect(evaluation.bounds.as_ref()),
|
||||
format_rect(evaluation.monitor.as_ref()),
|
||||
user_hidden
|
||||
);
|
||||
}
|
||||
|
||||
fn format_rect(rect: Option<&RECT>) -> String {
|
||||
match rect {
|
||||
Some(r) => format!(
|
||||
"({},{} {}x{})",
|
||||
r.left,
|
||||
r.top,
|
||||
r.right - r.left,
|
||||
r.bottom - r.top
|
||||
),
|
||||
None => String::from("<none>"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fullscreen_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn style_bits_allow_popup_or_borderless_windows_only() {
|
||||
assert!(window_style_bits_allow_fullscreen(WS_POPUP.0));
|
||||
assert!(window_style_bits_allow_fullscreen(0));
|
||||
assert!(!window_style_bits_allow_fullscreen(WS_CAPTION.0 | WS_THICKFRAME.0));
|
||||
assert!(!window_style_bits_allow_fullscreen(WS_CHILD.0 | WS_POPUP.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_cover_check_allows_small_edge_differences() {
|
||||
let monitor = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 1920,
|
||||
bottom: 1080,
|
||||
};
|
||||
let almost_exact = RECT {
|
||||
left: 1,
|
||||
top: 2,
|
||||
right: 1919,
|
||||
bottom: 1078,
|
||||
};
|
||||
let inset = RECT {
|
||||
left: 8,
|
||||
top: 0,
|
||||
right: 1920,
|
||||
bottom: 1080,
|
||||
};
|
||||
|
||||
assert!(rect_covers_monitor(&almost_exact, &monitor));
|
||||
assert!(!rect_covers_monitor(&inset, &monitor));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Painting ----------
|
||||
@@ -1288,9 +1508,12 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
|
||||
let mut stroke = Stroke::default();
|
||||
stroke.width = layout.time_ring_stroke_w;
|
||||
stroke.line_cap = LineCap::Round;
|
||||
if let Some(path) =
|
||||
build_arc(layout.ring_cx, layout.ring_cy, layout.time_ring_radius, frac)
|
||||
{
|
||||
if let Some(path) = build_remaining_arc(
|
||||
layout.ring_cx,
|
||||
layout.ring_cy,
|
||||
layout.time_ring_radius,
|
||||
frac,
|
||||
) {
|
||||
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
@@ -1338,9 +1561,11 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
|
||||
inputs.weekly_resets_at,
|
||||
window_duration_secs(inputs.model, UsageWindowKind::Secondary),
|
||||
) {
|
||||
let fill_w = bar_w * frac;
|
||||
let fill_w = (bar_w * frac).min(bar_w);
|
||||
if fill_w > 0.0 {
|
||||
paint_pill(&mut pixmap, bar_x, bar_y, fill_w.min(bar_w), bar_h, cap, time_fill);
|
||||
// Anchor on the right edge so the bar shrinks toward the right as time passes.
|
||||
let fill_x = bar_x + bar_w - fill_w;
|
||||
paint_pill(&mut pixmap, fill_x, bar_y, fill_w, bar_h, cap, time_fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1391,6 +1616,34 @@ fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option<tiny_
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// Clockwise arc that ENDS at 12 o'clock; the consumed wedge grows clockwise
|
||||
/// from 12 as `remaining_fraction` shrinks, mirroring a clock-hand countdown.
|
||||
fn build_remaining_arc(
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
radius: f32,
|
||||
remaining_fraction: f32,
|
||||
) -> Option<tiny_skia::Path> {
|
||||
let frac = remaining_fraction.clamp(0.0, 1.0);
|
||||
let segments = ((frac * 64.0).ceil() as usize).max(1);
|
||||
let mut pb = PathBuilder::new();
|
||||
let twelve: f32 = -std::f32::consts::FRAC_PI_2;
|
||||
let total = frac * std::f32::consts::TAU;
|
||||
let start_angle = twelve + (std::f32::consts::TAU - total);
|
||||
for i in 0..=segments {
|
||||
let t = i as f32 / segments as f32;
|
||||
let a = start_angle + t * total;
|
||||
let x = cx + a.cos() * radius;
|
||||
let y = cy + a.sin() * radius;
|
||||
if i == 0 {
|
||||
pb.move_to(x, y);
|
||||
} else {
|
||||
pb.line_to(x, y);
|
||||
}
|
||||
}
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// Copy a premultiplied-RGBA `Pixmap` into the 32bpp BI_RGB DIB the bubble
|
||||
/// uses for `UpdateLayeredWindow`. The DIB stores BGRA bytes (little-endian
|
||||
/// `0xAARRGGBB` when read as u32); tiny-skia's premultiplied alpha is exactly
|
||||
|
||||
@@ -22,6 +22,7 @@ use windows::Win32::System::Threading::{
|
||||
/// so no zombie wait is required.
|
||||
pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
|
||||
let mut cmdline = build_command_line(exe, args);
|
||||
let exe_w: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
|
||||
let si = STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
|
||||
@@ -32,7 +33,7 @@ pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
|
||||
let flags = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
|
||||
let ok = unsafe {
|
||||
CreateProcessW(
|
||||
PCWSTR::null(),
|
||||
PCWSTR::from_raw(exe_w.as_ptr()),
|
||||
windows::core::PWSTR(cmdline.as_mut_ptr()),
|
||||
None,
|
||||
None,
|
||||
|
||||
+54
-41
@@ -1,10 +1,9 @@
|
||||
// Download a release asset and swap it in via native Win32 calls.
|
||||
// Download a release asset and swap it in via a detached helper process.
|
||||
//
|
||||
// After writing the new .exe to a staging path and verifying its
|
||||
// SHA-256, we `MoveFileExW` the running exe sideways (so Windows
|
||||
// releases the file lock on our own image), then `MoveFileExW` the
|
||||
// staged exe into place, then spawn the new binary detached via
|
||||
// `handoff::spawn_detached`. No shell, no console allocation.
|
||||
// The running process cannot reliably replace its own mapped image on
|
||||
// Windows. Instead, it writes the new .exe to a staging path, starts that
|
||||
// staged binary with `--apply-update`, and then exits. The helper waits for
|
||||
// the parent process to exit before replacing the install target.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -13,7 +12,7 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS,
|
||||
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS,
|
||||
};
|
||||
use windows::Win32::System::Threading::GetCurrentProcessId;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
@@ -36,19 +35,37 @@ pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
download(http, &release.asset_url, &staging, release.asset_sha256.as_ref())?;
|
||||
swap_and_spawn(&staging, ¤t, &release.version)?;
|
||||
spawn_update_helper(&staging, ¤t, &release.version)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CLI entry-point compatibility for `--apply-update <target> <source> <pid>`.
|
||||
/// The native 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.
|
||||
/// CLI entry point for `--apply-update <target> <source> <parent-pid> <version>`.
|
||||
/// Runs from the staged new binary, waits for the old UI process to exit,
|
||||
/// replaces the installed exe, and starts the installed copy.
|
||||
pub fn run_cli(args: &[String]) -> Option<i32> {
|
||||
if args.len() >= 2 && args[1] == "--apply-update" {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
if args.get(1).map(String::as_str) != Some("--apply-update") {
|
||||
return None;
|
||||
}
|
||||
let Some(target) = args.get(2).map(PathBuf::from) else {
|
||||
return Some(2);
|
||||
};
|
||||
let Some(source) = args.get(3).map(PathBuf::from) else {
|
||||
return Some(2);
|
||||
};
|
||||
let Some(parent_pid) = args.get(4).and_then(|s| s.parse::<u32>().ok()) else {
|
||||
return Some(2);
|
||||
};
|
||||
let Some(version) = args.get(5).cloned() else {
|
||||
return Some(2);
|
||||
};
|
||||
|
||||
super::handoff::wait_for_parent_exit(parent_pid, 15_000);
|
||||
match replace_from_helper(&source, &target, &version) {
|
||||
Ok(()) => Some(0),
|
||||
Err(e) => {
|
||||
log::error!("apply-update failed: {e}");
|
||||
Some(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,25 +118,29 @@ fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn swap_and_spawn(
|
||||
source: &Path,
|
||||
fn spawn_update_helper(
|
||||
staging: &Path,
|
||||
target: &Path,
|
||||
version: &super::release::Version,
|
||||
) -> Result<(), super::Error> {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
|
||||
let args = vec![
|
||||
OsString::from("--apply-update"),
|
||||
target.as_os_str().to_os_string(),
|
||||
staging.as_os_str().to_os_string(),
|
||||
OsString::from(pid.to_string()),
|
||||
OsString::from(version_str),
|
||||
];
|
||||
super::handoff::spawn_detached(staging, &args).map_err(super::Error::Io)
|
||||
}
|
||||
|
||||
fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<(), super::Error> {
|
||||
let backup = backup_path(target);
|
||||
// Step 1: rename running exe sideways. Windows allows renaming a
|
||||
// file even while its image is mapped into memory; this releases
|
||||
// the lock on the original `target` path. Same directory by
|
||||
// construction, so plain MoveFileExW with no flags is sufficient.
|
||||
// Parent has exited, so the install target is no longer mapped.
|
||||
move_file(target, &backup, MOVE_FILE_FLAGS(0))?;
|
||||
|
||||
// Step 2: move staged exe into place. Staging lives under
|
||||
// %LOCALAPPDATA%, target lives wherever the user installed —
|
||||
// COPY_ALLOWED lets MoveFileExW fall back to copy+delete when
|
||||
// the two paths cross volumes (portable installs on D:/E:/etc.).
|
||||
let step2_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED;
|
||||
if let Err(swap_err) = move_file(source, target, step2_flags) {
|
||||
// Best-effort revert. Same volume, no COPY_ALLOWED needed.
|
||||
if let Err(copy_err) = std::fs::copy(source, target) {
|
||||
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
|
||||
log::error!("rollback also failed: {revert_err}; surfacing modal");
|
||||
let target_name = target
|
||||
@@ -128,27 +149,19 @@ fn swap_and_spawn(
|
||||
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
|
||||
surface_rollback_failure(&backup, &target_name);
|
||||
}
|
||||
return Err(swap_err);
|
||||
return Err(super::Error::Io(copy_err));
|
||||
}
|
||||
|
||||
// Step 3: spawn the new exe detached with --wait-pid + --updated-to.
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
|
||||
let args = vec![
|
||||
OsString::from("--wait-pid"),
|
||||
OsString::from(pid.to_string()),
|
||||
OsString::from("--updated-to"),
|
||||
OsString::from(version_str),
|
||||
];
|
||||
let args = vec![OsString::from("--updated-to"), OsString::from(version)];
|
||||
if let Err(spawn_err) = super::handoff::spawn_detached(target, &args) {
|
||||
// New binary is on disk but won't auto-launch. Roll back so
|
||||
// the user's next "Restart" stays on the known-good version.
|
||||
log::error!("spawn_detached failed after swap: {spawn_err}; attempting revert");
|
||||
let _ = std::fs::remove_file(target);
|
||||
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
|
||||
log::error!("post-spawn revert failed: {revert_err}");
|
||||
}
|
||||
return Err(super::Error::Io(spawn_err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+15
-8
@@ -5,20 +5,27 @@
|
||||
use crate::os::Rgb as Color;
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex =
|
||||
/// OpenAI brand teal `#10A37F`, used consistently across dark and light
|
||||
/// themes so the badge/bubble/panel never disagree on Codex identity.
|
||||
pub fn accent_color_for(model: ProviderId, _is_dark: bool) -> Color {
|
||||
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex tracks
|
||||
/// the OpenAI Codex monochrome palette — near-white `#E5E5E5` on dark themes,
|
||||
/// near-black `#1A1A1A` on light — so the accent stays readable against both
|
||||
/// the dark bubble surface and the `#F3F3F3` light background.
|
||||
pub fn accent_color_for(model: ProviderId, is_dark: bool) -> Color {
|
||||
match model {
|
||||
ProviderId::Claude => Color::from_hex("#D97757"),
|
||||
ProviderId::ChatGpt => Color::from_hex("#10A37F"),
|
||||
ProviderId::ChatGpt => {
|
||||
if is_dark {
|
||||
Color::from_hex("#E5E5E5")
|
||||
} else {
|
||||
Color::from_hex("#1A1A1A")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discrete 4-band fill color. The "safe" band uses the provider's identity
|
||||
/// color so Codex bars stay white-on-dark while Claude bars stay orange; the
|
||||
/// warning bands are theme-aware so light-mode amber stays readable against
|
||||
/// the `#F3F3F3` background.
|
||||
/// color so Codex bars render monochrome (light-on-dark / dark-on-light) while
|
||||
/// Claude bars stay orange; the warning bands are theme-aware so light-mode
|
||||
/// amber stays readable against the `#F3F3F3` background.
|
||||
///
|
||||
/// - <60% → provider accent
|
||||
/// - 60–80% → amber (dark `#E0A040`, light `#B47A20` for WCAG AA contrast)
|
||||
|
||||
Reference in New Issue
Block a user