Compare commits

...
4 Commits
Author SHA1 Message Date
tiennm99 0f3acd40d4 feat(update): SHA-256 verification + reject paths containing '%'
GitHub's Releases API exposes a `digest: "sha256:..."` field on every
asset since 2024. We now parse it, hash the downloaded bytes locally,
and abort with ChecksumMismatch if they disagree. Releases that predate
the field (none currently exist for this repo) skip verification rather
than fail, so v0.1.0 / v0.1.1 / v0.1.2 still update normally.

cmd.exe expands `%var%` even inside double-quoted arguments, which
would let a path like `C:\Users\%PATHEXT%\bubble.exe` substitute the
expansion. Real Windows paths with `%` are vanishingly rare, so we
fail fast with UnsafePath rather than ship a bespoke cmd-escape
implementation.

Bumps version to 0.1.3.
2026-05-16 13:37:37 +07:00
tiennm99 a132c02711 fix(update): bypass Rust arg escaping in cmd.exe handoff
Rust's std::process::Command escapes inner double quotes as \" when
wrapping the args(["/c", &cmd]) array. cmd.exe does not understand the
\" escape, so the swap-and-restart command got mangled: `start ""
"PATH"` arrived as `start \"\" \"PATH\"`, which the cmd parser
collapsed into `start \ PATH` — producing the "Windows cannot find
'\'" dialog and aborting the update.

Switching to raw_arg lets us hand cmd.exe the literal command line it
expects. The two quote characters cmd needs to keep are the outer pair
wrapping the whole /c argument; cmd's "more than two quotes, special
chars present" branch then preserves the inner path quotes intact.

Bumps version to 0.1.2 since this is the first updater fix that ships
through the updater itself for any future v0.1.2+ user.
2026-05-16 13:04:08 +07:00
tiennm99 ca9ab4ea97 chore: bump version to 0.1.1 2026-05-16 12:58:18 +07:00
tiennm99 2ca5052915 feat: configurable auto-update check frequency
Adds a "Settings > Auto-update check" submenu with Disabled / Hourly /
Daily / Weekly. Hourly is the default; existing settings files pick it
up automatically via serde default. Manual "Check for updates" is
unchanged and still fires when auto is disabled.

The 24-hour hardcoded interval is replaced by reading
Settings.update_check_interval_secs in both the startup scheduler and
the post-check rearm path. None means auto is disabled and no timer is
armed.

Adds five new i18n keys across all eight locales.
2026-05-16 12:57:50 +07:00
17 changed files with 315 additions and 24 deletions
Generated
+73 -1
View File
@@ -44,6 +44,15 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bytemuck"
version = "1.25.0"
@@ -68,7 +77,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-bubble"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"dirs",
"embed-resource",
@@ -76,6 +85,7 @@ dependencies = [
"native-tls",
"serde",
"serde_json",
"sha2",
"simplelog",
"thiserror",
"tiny-skia",
@@ -100,6 +110,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -109,6 +128,16 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -118,6 +147,16 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -241,6 +280,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -761,6 +810,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1015,6 +1075,12 @@ version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -1066,6 +1132,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vswhom"
version = "0.1.0"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-bubble"
version = "0.1.0"
version = "0.1.3"
edition = "2021"
license = "Apache-2.0"
description = "Floating bubble showing Claude Code and Codex usage on Windows"
@@ -21,6 +21,7 @@ simplelog = "0.12"
thiserror = "2"
toml = "0.8"
tiny-skia = "0.11"
sha2 = "0.10"
[dependencies.windows]
version = "0.58"
+3 -2
View File
@@ -83,8 +83,9 @@ corner of your primary monitor on first launch. Drag it where you want it,
release to snap to the nearest edge if you let go close to one.
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
- **Right-click** for refresh, models, update frequency, language, "Start
with Windows", updates, exit
- **Right-click** for refresh, models, refresh frequency, language, "Start
with Windows", auto-update check (Disabled / Hourly / Daily / Weekly),
manual "Check for updates", exit
- **Drag** anywhere — it floats on top of all other windows
- **Ctrl + MouseWheel** on the bubble to resize it
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
+93 -16
View File
@@ -45,7 +45,8 @@ const STARTUP_REGISTRY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\
const STARTUP_VALUE_NAME: &str = "ClaudeCodeUsageBubble";
const APP_CLASS_NAME: &str = "ClaudeCodeUsageBubbleApp";
const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
const UPDATE_CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
// The auto-check interval is user-configurable (see
// `Settings::update_check_interval_secs`). `None` means auto-check is disabled.
const BALLOON_COOLDOWN: Duration = Duration::from_secs(30 * 60);
const REFRESH_TIMEOUT: Duration = Duration::from_secs(8);
@@ -63,7 +64,16 @@ const IDM_START_WITH_WINDOWS: u16 = 30;
const IDM_RESET_POSITION: u16 = 31;
const IDM_VERSION_ACTION: u16 = 32;
const IDM_LANG_SYSTEM: u16 = 40;
const IDM_LANG_BASE: u16 = 41;
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
// clear of it (and any future tray ids in the 5x band).
const IDM_UPDATE_AUTO_OFF: u16 = 60;
const IDM_UPDATE_AUTO_HOURLY: u16 = 61;
const IDM_UPDATE_AUTO_DAILY: u16 = 62;
const IDM_UPDATE_AUTO_WEEKLY: u16 = 63;
// IMPORTANT: language ids are dynamic and start at IDM_LANG_BASE.
// Keep IDM_LANG_BASE the highest static id so the catch-all match arm
// stays unambiguous.
const IDM_LANG_BASE: u16 = 100;
// ---------- State ----------
@@ -349,6 +359,16 @@ pub fn on_menu_command(id: u32, _owner_hwnd: HWND) {
IDM_START_WITH_WINDOWS => toggle_startup(),
IDM_RESET_POSITION => reset_positions(),
IDM_VERSION_ACTION => version_action(),
IDM_UPDATE_AUTO_OFF => set_update_check_interval(None),
IDM_UPDATE_AUTO_HOURLY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS))
}
IDM_UPDATE_AUTO_DAILY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS))
}
IDM_UPDATE_AUTO_WEEKLY => {
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
}
IDM_LANG_SYSTEM => set_language(None),
x if x >= IDM_LANG_BASE => set_language_by_index((x - IDM_LANG_BASE) as usize),
tray::IDM_TOGGLE_WIDGET => toggle_widget_visibility(),
@@ -730,6 +750,7 @@ struct ContextMenuSnapshot {
available: Vec<(String, String)>,
language_override: Option<String>,
current_interval: u32,
update_check_interval_secs: Option<u64>,
show_claude: bool,
show_chatgpt: bool,
widget_visible: bool,
@@ -748,6 +769,7 @@ fn show_context_menu(owner_hwnd: HWND) {
.collect(),
language_override: s.settings.language.clone(),
current_interval: s.settings.poll_interval_ms,
update_check_interval_secs: s.settings.update_check_interval_secs,
show_claude: s.settings.show_claude_code,
show_chatgpt: s.settings.show_codex,
widget_visible: s.settings.widget_visible,
@@ -844,6 +866,34 @@ fn show_context_menu(owner_hwnd: HWND) {
MENU_ITEM_FLAGS(0)
};
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags);
let auto_update = CreatePopupMenu().unwrap();
for (id, value, label) in [
(IDM_UPDATE_AUTO_OFF, None, &snap.strings.auto_check_disabled),
(
IDM_UPDATE_AUTO_HOURLY,
Some(settings::UPDATE_CHECK_HOURLY_SECS),
&snap.strings.auto_check_hourly,
),
(
IDM_UPDATE_AUTO_DAILY,
Some(settings::UPDATE_CHECK_DAILY_SECS),
&snap.strings.auto_check_daily,
),
(
IDM_UPDATE_AUTO_WEEKLY,
Some(settings::UPDATE_CHECK_WEEKLY_SECS),
&snap.strings.auto_check_weekly,
),
] {
let flags = if value == snap.update_check_interval_secs {
MF_CHECKED
} else {
MENU_ITEM_FLAGS(0)
};
append_item(auto_update, id, label, flags);
}
append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check);
append_submenu(menu, settings_menu, &snap.strings.settings);
append_item(
@@ -1067,18 +1117,27 @@ fn version_action() {
}
fn schedule_update_check_timer(hwnd: HWND) {
let last = lock_state()
.as_ref()
.and_then(|s| s.settings.last_update_check_unix);
let (last, interval) = match lock_state().as_ref() {
Some(s) => (
s.settings.last_update_check_unix,
s.settings.update_check_interval_secs,
),
None => return,
};
let Some(interval) = interval else {
// Auto-check disabled. Manual "Check for updates" still works via
// the menu; this just suppresses the timer.
return;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let due = last.map_or(true, |t| now.saturating_sub(t) >= UPDATE_CHECK_INTERVAL_SECS);
let due = last.map_or(true, |t| now.saturating_sub(t) >= interval);
if due {
begin_update_check(hwnd);
} else {
let remaining = UPDATE_CHECK_INTERVAL_SECS.saturating_sub(now.saturating_sub(last.unwrap_or(0)));
let remaining = interval.saturating_sub(now.saturating_sub(last.unwrap_or(0)));
let ms = (remaining.saturating_mul(1000).min(u32::MAX as u64)) as u32;
unsafe {
SetTimer(hwnd, TIMER_UPDATE_CHECK, ms, None);
@@ -1117,23 +1176,41 @@ fn begin_update_check(hwnd: HWND) {
s.update_status = UpdateStatus::Failed;
}
}
s.settings.clone()
(s.settings.clone(), s.settings.update_check_interval_secs)
})
};
if let Some(snap) = snap_opt {
let next_interval = snap_opt.as_ref().and_then(|(_, iv)| *iv);
if let Some((snap, _)) = snap_opt {
settings::save(&snap);
}
unsafe {
SetTimer(
send_hwnd.to_hwnd(),
TIMER_UPDATE_CHECK,
(UPDATE_CHECK_INTERVAL_SECS as u32) * 1000,
None,
);
if let Some(interval) = next_interval {
let ms = (interval.saturating_mul(1000).min(u32::MAX as u64)) as u32;
unsafe {
SetTimer(send_hwnd.to_hwnd(), TIMER_UPDATE_CHECK, ms, None);
}
}
});
}
fn set_update_check_interval(value: Option<u64>) {
let (snap, msg_hwnd) = {
let mut s = lock_state();
let Some(s) = s.as_mut() else {
return;
};
s.settings.update_check_interval_secs = value;
(s.settings.clone(), s.msg_hwnd)
};
settings::save(&snap);
let hwnd = msg_hwnd.to_hwnd();
unsafe {
let _ = KillTimer(hwnd, TIMER_UPDATE_CHECK);
}
if value.is_some() {
schedule_update_check_timer(hwnd);
}
}
// ---------- Start-with-Windows ----------
fn is_startup_enabled() -> bool {
+5
View File
@@ -23,6 +23,11 @@ update_failed = "Update fehlgeschlagen"
applying_update = "Update wird angewendet…"
update_available = "Update verfügbar"
update_via_winget = "über WinGet"
auto_update_check = "Automatische Updateprüfung"
auto_check_disabled = "Deaktiviert"
auto_check_hourly = "Stündlich"
auto_check_daily = "Täglich"
auto_check_weekly = "Wöchentlich"
exit = "Beenden"
show_widget = "Widget anzeigen"
session_window = "5h"
+5
View File
@@ -23,6 +23,11 @@ update_failed = "Update failed"
applying_update = "Applying update…"
update_available = "Update available"
update_via_winget = "via WinGet"
auto_update_check = "Auto-update check"
auto_check_disabled = "Disabled"
auto_check_hourly = "Hourly"
auto_check_daily = "Daily"
auto_check_weekly = "Weekly"
exit = "Exit"
show_widget = "Show widget"
session_window = "5h"
+5
View File
@@ -23,6 +23,11 @@ update_failed = "Actualización fallida"
applying_update = "Aplicando actualización…"
update_available = "Actualización disponible"
update_via_winget = "vía WinGet"
auto_update_check = "Búsqueda automática de actualizaciones"
auto_check_disabled = "Desactivada"
auto_check_hourly = "Cada hora"
auto_check_daily = "Cada día"
auto_check_weekly = "Cada semana"
exit = "Salir"
show_widget = "Mostrar widget"
session_window = "5h"
+5
View File
@@ -23,6 +23,11 @@ update_failed = "Mise à jour échouée"
applying_update = "Mise à jour en cours…"
update_available = "Mise à jour disponible"
update_via_winget = "via WinGet"
auto_update_check = "Vérification automatique des mises à jour"
auto_check_disabled = "Désactivée"
auto_check_hourly = "Toutes les heures"
auto_check_daily = "Quotidienne"
auto_check_weekly = "Hebdomadaire"
exit = "Quitter"
show_widget = "Afficher le widget"
session_window = "5h"
+5
View File
@@ -23,6 +23,11 @@ update_failed = "更新に失敗しました"
applying_update = "更新を適用中…"
update_available = "更新あり"
update_via_winget = "WinGet経由"
auto_update_check = "更新の自動確認"
auto_check_disabled = "無効"
auto_check_hourly = "1時間ごと"
auto_check_daily = "毎日"
auto_check_weekly = "毎週"
exit = "終了"
show_widget = "ウィジェットを表示"
session_window = "5時間"
+5
View File
@@ -23,6 +23,11 @@ update_failed = "업데이트 실패"
applying_update = "업데이트 적용 중…"
update_available = "업데이트 있음"
update_via_winget = "WinGet 사용"
auto_update_check = "업데이트 자동 확인"
auto_check_disabled = "사용 안 함"
auto_check_hourly = "매시간"
auto_check_daily = "매일"
auto_check_weekly = "매주"
exit = "종료"
show_widget = "위젯 표시"
session_window = "5시간"
+5
View File
@@ -23,6 +23,11 @@ update_failed = "Update mislukt"
applying_update = "Update toepassen…"
update_available = "Update beschikbaar"
update_via_winget = "via WinGet"
auto_update_check = "Automatische updatecontrole"
auto_check_disabled = "Uitgeschakeld"
auto_check_hourly = "Per uur"
auto_check_daily = "Dagelijks"
auto_check_weekly = "Wekelijks"
exit = "Afsluiten"
show_widget = "Widget tonen"
session_window = "5u"
+5
View File
@@ -23,6 +23,11 @@ update_failed = "更新失敗"
applying_update = "正在套用更新…"
update_available = "有可用更新"
update_via_winget = "透過 WinGet"
auto_update_check = "自動檢查更新"
auto_check_disabled = "停用"
auto_check_hourly = "每小時"
auto_check_daily = "每天"
auto_check_weekly = "每週"
exit = "結束"
show_widget = "顯示小工具"
session_window = "5 小時"
+5
View File
@@ -43,6 +43,11 @@ pub struct LocaleStrings {
pub applying_update: String,
pub update_available: String,
pub update_via_winget: String,
pub auto_update_check: String,
pub auto_check_disabled: String,
pub auto_check_hourly: String,
pub auto_check_daily: String,
pub auto_check_weekly: String,
pub exit: String,
pub show_widget: String,
pub session_window: String,
+11
View File
@@ -14,6 +14,11 @@ pub const POLL_5_MIN: u32 = 5 * 60_000;
pub const POLL_15_MIN: u32 = 15 * 60_000;
pub const POLL_1_HOUR: u32 = 60 * 60_000;
// Update-check intervals (seconds). `None` means auto-check is disabled.
pub const UPDATE_CHECK_HOURLY_SECS: u64 = 60 * 60;
pub const UPDATE_CHECK_DAILY_SECS: u64 = 24 * 60 * 60;
pub const UPDATE_CHECK_WEEKLY_SECS: u64 = 7 * 24 * 60 * 60;
fn default_show_claude() -> bool {
true
}
@@ -29,6 +34,9 @@ fn default_bubble_size() -> i32 {
fn default_poll_interval_ms() -> u32 {
POLL_5_MIN
}
fn default_update_check_interval_secs() -> Option<u64> {
Some(UPDATE_CHECK_HOURLY_SECS)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BubblePositions {
@@ -77,6 +85,8 @@ pub struct Settings {
pub language: Option<String>,
#[serde(default)]
pub last_update_check_unix: Option<u64>,
#[serde(default = "default_update_check_interval_secs")]
pub update_check_interval_secs: Option<u64>,
#[serde(default = "default_widget_visible")]
pub widget_visible: bool,
}
@@ -91,6 +101,7 @@ impl Default for Settings {
poll_interval_ms: default_poll_interval_ms(),
language: None,
last_update_check_unix: None,
update_check_interval_secs: default_update_check_interval_secs(),
widget_visible: default_widget_visible(),
}
}
+56 -4
View File
@@ -9,6 +9,8 @@ use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use sha2::{Digest, Sha256};
use crate::net::Client;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
@@ -18,10 +20,17 @@ pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error
let current = std::env::current_exe()?;
ensure_writable(&current)?;
let staging = stage_path()?;
// Refuse to proceed if either path contains `%`. Inside double quotes
// cmd.exe still expands `%var%` references, so a path containing `%`
// would let cmd substitute environment variables into the swap step.
// Such paths are vanishingly rare on real Windows installs; failing
// fast is safer than rolling a bespoke cmd-escape layer.
reject_unsafe_path(&current)?;
reject_unsafe_path(&staging)?;
if let Some(parent) = staging.parent() {
std::fs::create_dir_all(parent)?;
}
download(http, &release.asset_url, &staging)?;
download(http, &release.asset_url, &staging, release.asset_sha256.as_ref())?;
spawn_handoff(&staging, &current)?;
Ok(())
}
@@ -38,7 +47,12 @@ pub fn run_cli(args: &[String]) -> Option<i32> {
}
}
fn download(http: &Client, url: &str, to: &std::path::Path) -> Result<(), super::Error> {
fn download(
http: &Client,
url: &str,
to: &std::path::Path,
expected_sha256: Option<&[u8; 32]>,
) -> Result<(), super::Error> {
let resp = http
.get(url)
.header("User-Agent", super::release::user_agent())
@@ -46,7 +60,39 @@ fn download(http: &Client, url: &str, to: &std::path::Path) -> Result<(), super:
if !(200..300).contains(&resp.status()) {
return Err(super::Error::Network(crate::net::Error::Status(resp.status())));
}
std::fs::write(to, resp.body())?;
let body = resp.body();
if let Some(expected) = expected_sha256 {
let mut hasher = Sha256::new();
hasher.update(body);
let actual = hasher.finalize();
if actual.as_slice() != expected {
return Err(super::Error::ChecksumMismatch {
expected: hex_encode(expected),
actual: hex_encode(&actual),
});
}
}
std::fs::write(to, body)?;
Ok(())
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
fn reject_unsafe_path(p: &std::path::Path) -> Result<(), super::Error> {
let s = p.to_string_lossy();
if s.contains('%') {
return Err(super::Error::UnsafePath(format!(
"path contains '%' which cmd.exe expands as a variable: {s}"
)));
}
Ok(())
}
@@ -58,8 +104,14 @@ fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(
let cmd = format!(
r#"timeout /t 2 /nobreak >nul & move /y "{src_str}" "{tgt_str}" & start "" "{tgt_str}""#
);
// raw_arg bypasses Rust's std auto-escaping which would turn the inner
// `"` characters into `\"`. cmd.exe does not recognise `\"`, so the
// escaped form makes `start` see the path as `\\` and emit a
// "Windows cannot find '\\'" dialog. Feeding the command line raw
// preserves the quotes cmd.exe actually expects.
Command::new("cmd.exe")
.args(["/c", &cmd])
.raw_arg("/c")
.raw_arg(format!("\"{cmd}\""))
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
+4
View File
@@ -20,6 +20,10 @@ pub enum Error {
NotWritable(String),
#[error("malformed version: {0}")]
BadVersion(String),
#[error("asset checksum mismatch: expected {expected}, got {actual}")]
ChecksumMismatch { expected: String, actual: String },
#[error("path rejected for safety: {0}")]
UnsafePath(String),
}
pub use channel::{current as current_channel, Channel};
+28
View File
@@ -12,6 +12,10 @@ const REPO_NAME: &str = "claude-code-usage-bubble";
pub struct Release {
pub version: Version,
pub asset_url: String,
/// SHA-256 of the asset bytes, parsed from the GitHub Releases
/// API `digest` field. `None` if GitHub omitted it (older
/// releases predate the digest field).
pub asset_sha256: Option<[u8; 32]>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
@@ -71,9 +75,28 @@ pub fn fetch_latest(http: &Client) -> Result<super::CheckOutcome, super::Error>
Ok(super::CheckOutcome::Available(Release {
version: candidate,
asset_url: asset.browser_download_url.clone(),
asset_sha256: asset.digest.as_deref().and_then(parse_sha256_digest),
}))
}
/// Parse a GitHub `digest` field of the form `"sha256:<64 hex chars>"`
/// into a 32-byte array. Returns `None` for any other algorithm or
/// malformed input — callers should treat a missing digest as "no
/// integrity check available" rather than as a parse failure.
fn parse_sha256_digest(raw: &str) -> Option<[u8; 32]> {
let hex = raw.strip_prefix("sha256:")?;
if hex.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, byte_chars) in hex.as_bytes().chunks(2).enumerate() {
let high = (byte_chars[0] as char).to_digit(16)?;
let low = (byte_chars[1] as char).to_digit(16)?;
out[i] = ((high << 4) | low) as u8;
}
Some(out)
}
pub fn user_agent() -> &'static str {
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))
}
@@ -88,4 +111,9 @@ struct GhRelease {
struct GhAsset {
name: String,
browser_download_url: String,
/// GitHub started returning `digest: "sha256:..."` on the asset
/// object in 2024. Older releases omit it; we treat that as
/// "verification unavailable" rather than a hard error.
#[serde(default)]
digest: Option<String>,
}