From 944b8199bc4b1e6ce12564ed50bd9687d7aa074e Mon Sep 17 00:00:00 2001 From: Francesco Gruosso <64712227+FrancescoCoding@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:39:53 +0000 Subject: [PATCH 1/5] Add system tray: minimise/close to tray, start minimised, launch at startup Closes #10. - Tray icon (tray-icon crate): left click shows the window, right click opens Show / Exit. - Settings menu with Minimise to tray, Close to tray, Start minimised (saved to %APPDATA%\Throttle\settings.json, all off by default) and Launch at startup. - Launch at startup registers a Task Scheduler logon task with highest privileges, no time limit, normal priority and no battery conditions, started with --tray. The Run key cannot start an elevated app. - Snapshot draining and tray handling move to App::logic, which keeps running while the window is hidden, so the unbounded snapshot channel does not grow in the background. - A second launch signals the running instance through a named event to show itself, instead of calling ShowWindow from outside, which would desync winit's cached visibility. - File > Exit, tray Exit and the update relaunch always quit, even with Close to tray on. --- Cargo.lock | 48 ++++++++ Cargo.toml | 1 + README.md | 4 + src/autostart.rs | 169 ++++++++++++++++++++++++++ src/gui.rs | 267 +++++++++++++++++++++++++++++++++++++++-- src/main.rs | 17 ++- src/settings.rs | 85 +++++++++++++ src/single_instance.rs | 68 +++++++++-- 8 files changed, 640 insertions(+), 19 deletions(-) create mode 100644 src/autostart.rs create mode 100644 src/settings.rs diff --git a/Cargo.lock b/Cargo.lock index 9f608c8..4a07aa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1780,6 +1780,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -1966,6 +1976,24 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "png", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + [[package]] name = "naga" version = "30.0.1" @@ -3370,6 +3398,7 @@ dependencies = [ "sha2", "tracing", "tracing-subscriber", + "tray-icon", "ureq", "windivert", "windivert-sys", @@ -3536,6 +3565,25 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tray-icon" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5cc5858ecf9574ddd8f0032a2ff7ffd6f7724d2b81e48b9374984b3fd6ddc9" +dependencies = [ + "crossbeam-channel", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + [[package]] name = "ttf-parser" version = "0.25.1" diff --git a/Cargo.toml b/Cargo.toml index 83ba142..b2db3d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ serde_json = "1.0.150" sha2 = "0.10" tracing = "0.1.44" tracing-subscriber = "0.3.23" +tray-icon = { version = "0.26.1", default-features = false } ureq = "3" windivert = "0.6.0" windivert-sys = "0.10.0" diff --git a/README.md b/README.md index 67908f0..e55f6a3 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ https://github.com/user-attachments/assets/632312e7-4096-4957-b2f7-f2277aa96bb3 - Right-click any process to set a download limit, set an upload limit, block, unblock, or remove its rule. - Rate editor uses a number field plus a unit dropdown (B/s, KB/s, MB/s, GB/s). +- System tray icon, with optional minimise to tray, close to tray, start + minimised, and launch at Windows startup (Settings menu). Startup uses a + Task Scheduler logon task, since Windows skips Run-key entries that need + elevation. - Runs as a single elevated process; engine and GUI communicate over channels. ## Screenshots diff --git a/src/autostart.rs b/src/autostart.rs new file mode 100644 index 0000000..63083e7 --- /dev/null +++ b/src/autostart.rs @@ -0,0 +1,169 @@ +//! Launch at Windows startup via a Task Scheduler logon task. +//! +//! The usual `HKCU\...\Run` key does not work for Throttle: the release build's +//! manifest requires elevation, and Windows silently skips Run entries that +//! need it. A scheduled task with "run with highest privileges" starts elevated +//! at logon without a UAC prompt. Throttle is already elevated when it creates +//! the task, so no extra prompt is needed for that either. +//! +//! The task's existence is the source of truth for the setting; nothing is +//! stored in `settings.json`. + +use std::os::windows::process::CommandExt; +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, bail}; + +/// Task Scheduler name of the logon task. +const TASK_NAME: &str = "Throttle"; + +/// Command-line flag the task passes so a logon start goes straight to the tray. +pub const TRAY_FLAG: &str = "--tray"; + +/// Keep `schtasks` from flashing a console window (the release build has none). +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +fn schtasks(args: &[&str]) -> std::io::Result { + Command::new("schtasks") + .args(args) + .creation_flags(CREATE_NO_WINDOW) + .output() +} + +/// Whether the logon task exists. +pub fn is_enabled() -> bool { + schtasks(&["/Query", "/TN", TASK_NAME]).is_ok_and(|out| out.status.success()) +} + +/// Create (or replace) the logon task so it starts `exe` in the tray. +pub fn enable(exe: &Path) -> anyhow::Result<()> { + let user = current_user(); + let xml = task_xml(exe, &user); + + // schtasks reads task XML as UTF-16 with a BOM. + let mut bytes = vec![0xFF, 0xFE]; + bytes.extend(xml.encode_utf16().flat_map(u16::to_le_bytes)); + let xml_path = std::env::temp_dir().join("throttle-autostart.xml"); + std::fs::write(&xml_path, bytes).context("write task XML")?; + + let xml_arg = xml_path.to_string_lossy(); + let out = schtasks(&["/Create", "/TN", TASK_NAME, "/XML", &xml_arg, "/F"]); + let _ = std::fs::remove_file(&xml_path); + let out = out.context("run schtasks")?; + if !out.status.success() { + bail!("{}", String::from_utf8_lossy(&out.stderr).trim()); + } + Ok(()) +} + +/// Delete the logon task. +pub fn disable() -> anyhow::Result<()> { + let out = schtasks(&["/Delete", "/TN", TASK_NAME, "/F"]).context("run schtasks")?; + if !out.status.success() { + bail!("{}", String::from_utf8_lossy(&out.stderr).trim()); + } + Ok(()) +} + +/// `DOMAIN\user` for the logon trigger, so the task fires for this user only. +fn current_user() -> String { + let user = std::env::var("USERNAME").unwrap_or_default(); + match std::env::var("USERDOMAIN") { + Ok(domain) if !domain.is_empty() => format!("{domain}\\{user}"), + _ => user, + } +} + +/// Task definition. The non-default settings matter: +/// - `ExecutionTimeLimit PT0S`: the default (72 hours) would kill Throttle; +/// - both battery flags off: the defaults skip or stop the task on battery; +/// - `Priority 4`: the default (7) runs the shaper below normal priority. +fn task_xml(exe: &Path, user: &str) -> String { + let command = xml_escape(&exe.to_string_lossy()); + let workdir = xml_escape(&exe.parent().unwrap_or(Path::new("")).to_string_lossy()); + let user = xml_escape(user); + format!( + r#" + + + Start Throttle in the tray when you sign in. + + + + true + {user} + + + + + {user} + InteractiveToken + HighestAvailable + + + + IgnoreNew + false + false + true + false + false + + false + false + + true + true + false + false + false + PT0S + 4 + + + + {command} + {TRAY_FLAG} + {workdir} + + + +"# + ) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn task_xml_escapes_paths_and_user() { + let xml = task_xml( + Path::new(r"C:\Tools & Apps\Throttle\throttle.exe"), + r"PC\O'Brien", + ); + assert!(xml.contains(r"C:\Tools & Apps\Throttle\throttle.exe")); + assert!(xml.contains(r"C:\Tools & Apps\Throttle")); + assert!(xml.contains(r"PC\O'Brien")); + } + + #[test] + fn task_xml_never_stops_or_deprioritises_the_shaper() { + let xml = task_xml(Path::new(r"C:\throttle.exe"), "me"); + assert!(xml.contains("PT0S")); + assert!(xml.contains("false")); + assert!(xml.contains("false")); + assert!(xml.contains("4")); + assert!(xml.contains("HighestAvailable")); + assert!(xml.contains(&format!("{TRAY_FLAG}"))); + } +} diff --git a/src/gui.rs b/src/gui.rs index f8523c5..ec9d83c 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -10,11 +10,15 @@ use crossbeam_channel::{Receiver, Sender}; use eframe::egui; use eframe::egui::Widget; use egui_plot::{Legend, Line, Plot, PlotPoints}; +use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; +use tray_icon::{Icon, MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; use crate::backend::rules; use crate::backend::synthetic::{is_shapable, is_system}; +use crate::settings::{self, Settings}; use crate::types::{Command, ProcessStats, Rule, Snapshot}; use crate::update_check::{self, UpdateInfo}; +use crate::{autostart, icon, single_instance}; /// How many total-rate samples we keep for the header graph. const TOTAL_HISTORY_LEN: usize = 120; @@ -191,12 +195,34 @@ pub struct ThrottleApp { about_open: bool, shutdown_sent: bool, + settings: Settings, + /// `None` until the background check of the startup task reports back. + autostart: Option, + autostart_rx: Receiver, + autostart_error: Option, + + /// `None` if the tray icon could not be created; the window then never hides. + tray: Option, + tray_rx: Receiver, + /// The window is hidden to the tray (no taskbar button). + hidden: bool, + /// Hide to the tray on the first frame (`--tray` or "Start minimised"). + start_hidden: bool, + /// A real exit is under way, so a close request must not go to the tray. + exiting: bool, + update_rx: Receiver, update: Option, update_dismissed: bool, update_install: UpdateInstall, } +/// What the tray icon (or a second launch) asks the app to do. +enum TrayAction { + Show, + Exit, +} + /// Progress of an in-app update started from the banner. enum UpdateInstall { Idle, @@ -206,10 +232,19 @@ enum UpdateInstall { impl ThrottleApp { pub fn new( - _cc: &eframe::CreationContext<'_>, + cc: &eframe::CreationContext<'_>, snapshot_rx: Receiver, cmd_tx: Sender, + settings: Settings, + start_hidden: bool, ) -> Self { + let (tray_tx, tray_rx) = crossbeam_channel::unbounded(); + let tray = create_tray(&cc.egui_ctx, tray_tx.clone()); + let ctx = cc.egui_ctx.clone(); + single_instance::on_show_request(move || { + let _ = tray_tx.send(TrayAction::Show); + ctx.request_repaint(); + }); Self { snapshot_rx, cmd_tx, @@ -222,6 +257,15 @@ impl ThrottleApp { limit_modal: None, about_open: false, shutdown_sent: false, + settings, + autostart: None, + autostart_rx: spawn_autostart_check(), + autostart_error: None, + tray, + tray_rx, + hidden: false, + start_hidden, + exiting: false, update_rx: update_check::spawn(APP_VERSION), update: None, update_dismissed: false, @@ -236,6 +280,53 @@ impl ThrottleApp { } } + /// Quit for real, even if "Close to tray" is on. + fn exit(&mut self, ctx: &egui::Context) { + self.exiting = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + + fn hide_to_tray(&mut self, ctx: &egui::Context) { + // Without a tray icon there would be no way back to a hidden window. + if self.tray.is_some() { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + self.hidden = true; + } + } + + fn show_window(&mut self, ctx: &egui::Context) { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + self.hidden = false; + } + + fn save_settings(&self) { + if let Err(e) = settings::save(&self.settings) { + tracing::warn!("failed to persist settings: {e:#}"); + } + } + + fn set_autostart(&mut self, enabled: bool) { + let result = if enabled { + std::env::current_exe() + .map_err(anyhow::Error::from) + .and_then(|exe| autostart::enable(&exe)) + } else { + autostart::disable() + }; + match result { + Ok(()) => { + self.autostart = Some(enabled); + self.autostart_error = None; + } + Err(e) => { + tracing::warn!("failed to update the startup task: {e:#}"); + self.autostart_error = Some(format!("Could not update the startup task: {e:#}")); + } + } + } + /// The current rule for `key`, or a fresh default rule bound to that path. fn base_rule(&self, key: &str) -> Rule { self.last_snapshot @@ -358,10 +449,11 @@ impl ThrottleApp { } ui.separator(); if ui.button("Exit").clicked() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + self.exit(ui.ctx()); ui.close(); } }); + ui.menu_button("Settings", |ui| self.draw_settings_menu(ui)); ui.menu_button("Help", |ui| { if ui.button("Documentation").clicked() { open_url(ui.ctx(), &format!("{REPO_URL}#readme")); @@ -394,6 +486,45 @@ impl ThrottleApp { }); } + fn draw_settings_menu(&mut self, ui: &mut egui::Ui) { + let mut changed = false; + ui.add_enabled_ui(self.tray.is_some(), |ui| { + changed |= ui + .checkbox(&mut self.settings.minimize_to_tray, "Minimise to tray") + .on_hover_text("Minimising hides Throttle to the tray instead of the taskbar.") + .changed(); + changed |= ui + .checkbox(&mut self.settings.close_to_tray, "Close to tray") + .on_hover_text("The X button hides Throttle to the tray. File > Exit still quits.") + .changed(); + changed |= ui + .checkbox(&mut self.settings.start_minimized, "Start minimised") + .on_hover_text("Open in the tray instead of a window.") + .changed(); + }) + .response + .on_disabled_hover_text("The system tray is unavailable."); + if changed { + self.save_settings(); + } + + ui.separator(); + let mut autostart = self.autostart.unwrap_or(false); + let toggled = ui + .add_enabled( + self.autostart.is_some(), + egui::Checkbox::new(&mut autostart, "Launch at startup"), + ) + .on_hover_text("Start Throttle in the tray when you sign in to Windows.") + .changed(); + if toggled { + self.set_autostart(autostart); + } + if let Some(e) = &self.autostart_error { + ui.label(egui::RichText::new(e).small().color(COLOR_BLOCK)); + } + } + fn draw_update_banner(&mut self, ui: &mut egui::Ui) { if self.update_dismissed { return; @@ -415,7 +546,7 @@ impl ThrottleApp { self.update_install = match relaunched { Ok(_) => { self.send_shutdown(); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + self.exit(ui.ctx()); UpdateInstall::Idle } Err(e) => { @@ -917,10 +1048,10 @@ impl ThrottleApp { } impl eframe::App for ThrottleApp { - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - let ctx = ui.ctx().clone(); - - // Drain any pending snapshots each frame. + /// Runs every frame, and also while the window is hidden to the tray, so + /// everything that must keep ticking in the background lives here. + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Drain pending snapshots even while hidden: the channel is unbounded. let incoming: Vec = self.snapshot_rx.try_iter().collect(); for snap in incoming { self.total_down_hist.push(snap.total_down_rate as f64); @@ -936,9 +1067,42 @@ impl eframe::App for ThrottleApp { self.last_snapshot = snap; } - // Window close -> tell the backend to shut down. + if std::mem::take(&mut self.start_hidden) { + self.hide_to_tray(ctx); + } + + let actions: Vec = self.tray_rx.try_iter().collect(); + for action in actions { + match action { + TrayAction::Show => self.show_window(ctx), + TrayAction::Exit => self.exit(ctx), + } + } + + let minimized = ctx.input(|i| i.viewport().minimized == Some(true)); + if minimized && self.settings.minimize_to_tray && !self.hidden { + self.hide_to_tray(ctx); + } + + // Window close: hide to the tray if asked to, else shut the backend down. if ctx.input(|i| i.viewport().close_requested()) { - self.send_shutdown(); + if self.settings.close_to_tray && self.tray.is_some() && !self.exiting { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.hide_to_tray(ctx); + } else { + self.send_shutdown(); + } + } + + // Keep ticking (and draining snapshots) while hidden to the tray. + ctx.request_repaint_after(Duration::from_millis(500)); + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); + + if let Ok(enabled) = self.autostart_rx.try_recv() { + self.autostart = Some(enabled); } let snap = self.last_snapshot.clone(); @@ -961,8 +1125,6 @@ impl eframe::App for ThrottleApp { self.draw_table(ui, &snap); self.draw_limit_modal(&ctx); self.draw_about(&ctx); - - ctx.request_repaint_after(Duration::from_millis(500)); } } @@ -972,6 +1134,89 @@ impl Drop for ThrottleApp { } } +// --------------------------------------------------------------------------- +// Tray and startup helpers +// --------------------------------------------------------------------------- + +/// Create the tray icon: left click shows the window, right click opens a +/// Show / Exit menu. Returns `None` (logged) if the shell refuses the icon, +/// e.g. Explorer is not running yet. +fn create_tray(ctx: &egui::Context, tx: Sender) -> Option { + let show = MenuItem::new("Show Throttle", true, None); + let exit = MenuItem::new("Exit", true, None); + let menu = Menu::new(); + if let Err(e) = menu.append_items(&[&show, &PredefinedMenuItem::separator(), &exit]) { + tracing::warn!("tray menu unavailable: {e}"); + return None; + } + + // Hovering fires Move/Enter/Leave constantly: only clicks wake the app. + let (click_tx, click_ctx) = (tx.clone(), ctx.clone()); + TrayIconEvent::set_event_handler(Some(move |event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } + | TrayIconEvent::DoubleClick { + button: MouseButton::Left, + .. + } = event + { + let _ = click_tx.send(TrayAction::Show); + click_ctx.request_repaint(); + } + })); + let (show_id, exit_id, ctx) = (show.id().clone(), exit.id().clone(), ctx.clone()); + MenuEvent::set_event_handler(Some(move |event: MenuEvent| { + let action = if event.id == show_id { + TrayAction::Show + } else if event.id == exit_id { + TrayAction::Exit + } else { + return; + }; + let _ = tx.send(action); + ctx.request_repaint(); + })); + + let built = Icon::from_rgba(icon::rgba(), icon::SIZE, icon::SIZE) + .map_err(|e| e.to_string()) + .and_then(|icon| { + TrayIconBuilder::new() + .with_icon(icon) + .with_tooltip("Throttle") + .with_menu(Box::new(menu)) + .with_menu_on_left_click(false) + .build() + .map_err(|e| e.to_string()) + }); + match built { + Ok(tray) => Some(tray), + Err(e) => { + tracing::warn!("tray icon unavailable: {e}"); + None + } + } +} + +/// Check the startup task off the UI thread (`schtasks` takes a moment). An +/// existing task is re-pointed at this exe, in case Throttle was moved. +fn spawn_autostart_check() -> Receiver { + let (tx, rx) = crossbeam_channel::bounded(1); + std::thread::spawn(move || { + let enabled = autostart::is_enabled(); + if enabled + && let Ok(exe) = std::env::current_exe() + && let Err(e) = autostart::enable(&exe) + { + tracing::warn!("failed to refresh the startup task: {e:#}"); + } + let _ = tx.send(enabled); + }); + rx +} + // --------------------------------------------------------------------------- // Shell helpers // --------------------------------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index 792a615..6deede5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,9 +10,11 @@ windows_subsystem = "windows" )] +mod autostart; mod backend; mod gui; mod icon; +mod settings; mod single_instance; mod types; mod update_check; @@ -125,6 +127,11 @@ fn main() -> eframe::Result { return Ok(()); } + let settings = settings::load(); + // The startup task passes --tray; "Start minimised" covers manual launches. + let start_hidden = + std::env::args().any(|a| a == autostart::TRAY_FLAG) || settings.start_minimized; + let app_icon = eframe::egui::IconData { rgba: icon::rgba(), width: icon::SIZE, @@ -150,7 +157,15 @@ fn main() -> eframe::Result { let result = eframe::run_native( "Throttle", native_options, - Box::new(move |cc| Ok(Box::new(gui::ThrottleApp::new(cc, snapshot_rx, cmd_tx)))), + Box::new(move |cc| { + Ok(Box::new(gui::ThrottleApp::new( + cc, + snapshot_rx, + cmd_tx, + settings, + start_hidden, + ))) + }), ); // The app sent `Command::Shutdown` on close (and dropping it dropped // the command sender). Wait for the aggregator to persist rules; diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..2d64b83 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,85 @@ +//! App preferences: load/save `Settings` as JSON at +//! `%APPDATA%\Throttle\settings.json`. +//! +//! A missing file means defaults; parse errors are logged and also fall back to +//! defaults, so a corrupt file never stops the app from starting. Launch at +//! startup is not stored here: the scheduled task's existence is the source of +//! truth (see `autostart`). + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::backend::rules::config_dir; + +/// User preferences for window and tray behaviour. Every field defaults to +/// `false`, so an upgrade keeps the old "taskbar window, X quits" behaviour. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Settings { + /// Minimising the window hides it to the tray instead of the taskbar. + pub minimize_to_tray: bool, + /// The window's X button hides it to the tray; File > Exit still quits. + pub close_to_tray: bool, + /// Start hidden in the tray when launched by hand. + pub start_minimized: bool, +} + +/// Full path to the settings file. +pub fn settings_path() -> PathBuf { + config_dir().join("settings.json") +} + +/// Load the persisted settings. Missing file or any error => defaults. +pub fn load() -> Settings { + let path = settings_path(); + let data = match std::fs::read_to_string(&path) { + Ok(d) => d, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Settings::default(), + Err(e) => { + tracing::warn!("failed to read {}: {e}", path.display()); + return Settings::default(); + } + }; + serde_json::from_str(&data).unwrap_or_else(|e| { + tracing::warn!("failed to parse {}: {e}; using defaults", path.display()); + Settings::default() + }) +} + +/// Persist the settings as pretty JSON, creating the directory if needed. +pub fn save(settings: &Settings) -> anyhow::Result<()> { + std::fs::create_dir_all(config_dir())?; + let json = serde_json::to_string_pretty(settings)?; + std::fs::write(settings_path(), json)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_round_trip_through_json() { + let settings = Settings { + minimize_to_tray: true, + close_to_tray: false, + start_minimized: true, + }; + let json = serde_json::to_string(&settings).unwrap(); + let back: Settings = serde_json::from_str(&json).unwrap(); + assert_eq!(settings, back); + } + + #[test] + fn missing_fields_default_to_off() { + let back: Settings = serde_json::from_str(r#"{"close_to_tray": true}"#).unwrap(); + assert_eq!( + back, + Settings { + close_to_tray: true, + ..Settings::default() + } + ); + } +} diff --git a/src/single_instance.rs b/src/single_instance.rs index e160859..ac2724d 100644 --- a/src/single_instance.rs +++ b/src/single_instance.rs @@ -6,17 +6,29 @@ //! bypassed by the other. A machine-wide named mutex guarantees one shaper. //! Because Throttle always runs elevated, the mutex lives in the `Global\` //! namespace so instances in different sessions still see each other. +//! +//! A second launch asks the running instance to show itself through a named +//! event rather than by calling `ShowWindow` on its window: winit caches the +//! window's visibility, so showing it from outside would leave winit thinking +//! it is still hidden, and the next hide to the tray would do nothing. -use windows::Win32::Foundation::{ERROR_ALREADY_EXISTS, GetLastError, HANDLE, HWND}; -use windows::Win32::System::Threading::CreateMutexW; +use windows::Win32::Foundation::{ERROR_ALREADY_EXISTS, GetLastError, HANDLE, HWND, WAIT_OBJECT_0}; +use windows::Win32::System::Threading::{ + CreateEventW, CreateMutexW, EVENT_MODIFY_STATE, INFINITE, OpenEventW, SetEvent, + WaitForSingleObject, +}; use windows::Win32::UI::WindowsAndMessaging::{ - FindWindowW, IsIconic, SW_RESTORE, SetForegroundWindow, ShowWindow, + ASFW_ANY, AllowSetForegroundWindow, FindWindowW, IsIconic, SW_RESTORE, SetForegroundWindow, + ShowWindow, }; use windows::core::{PCWSTR, w}; /// Title of the main window, used to locate an already-running instance. const WINDOW_TITLE: PCWSTR = w!("Throttle"); +/// Auto-reset event a second launch signals to bring the running window back. +const SHOW_EVENT: PCWSTR = w!("Global\\ThrottleShowRequest"); + /// Held for the life of the process. Dropping it would release the mutex, so /// the handle is intentionally never closed. pub struct InstanceLock { @@ -27,21 +39,63 @@ pub struct InstanceLock { /// Try to become the single running instance. /// /// Returns `Some(lock)` if this process now owns the mutex. Returns `None` if -/// another instance already holds it; in that case the existing main window, -/// if it can be found, has been restored and brought to the foreground so the -/// user's double-click still does something visible. +/// another instance already holds it; in that case the running instance has +/// been asked to show its window (even from the tray) so the user's +/// double-click still does something visible. pub fn acquire() -> Option { // SAFETY: plain Win32 calls with valid, null-terminated static strings. unsafe { let handle = CreateMutexW(None, false, w!("Global\\ThrottleSingleInstance")).ok()?; if GetLastError() == ERROR_ALREADY_EXISTS { - focus_existing_window(); + if !request_show() { + // A build without the show event: fall back to raising its window. + focus_existing_window(); + } return None; } Some(InstanceLock { handle }) } } +/// Run `on_request` every time a second launch asks this instance to show +/// itself. The callback runs on a dedicated background thread. +pub fn on_show_request(on_request: impl Fn() + Send + 'static) { + // SAFETY: plain Win32 calls; the handle is valid for the process lifetime + // and intentionally never closed. + let event = match unsafe { CreateEventW(None, false, false, SHOW_EVENT) } { + Ok(h) => h.0 as usize, + Err(e) => { + tracing::warn!("show-request event unavailable: {e}"); + return; + } + }; + std::thread::Builder::new() + .name("show-request".into()) + .spawn(move || { + let event = HANDLE(event as *mut _); + // SAFETY: waiting on a valid event handle owned by this process. + while unsafe { WaitForSingleObject(event, INFINITE) } == WAIT_OBJECT_0 { + on_request(); + } + }) + .expect("spawn show-request thread"); +} + +/// Ask the running instance to show its window. Returns false if it has no +/// show event to signal. +fn request_show() -> bool { + // SAFETY: plain Win32 calls with a valid, null-terminated static string. + unsafe { + let Ok(event) = OpenEventW(EVENT_MODIFY_STATE, false, SHOW_EVENT) else { + return false; + }; + // We are the foreground process (the user just launched us), so we may + // let the running instance take focus when it shows its window. + let _ = AllowSetForegroundWindow(ASFW_ANY); + SetEvent(event).is_ok() + } +} + /// Best-effort: restore and raise the existing instance's main window. fn focus_existing_window() { // SAFETY: FindWindowW returns a null HWND when nothing matches; all calls From ee67b17ec227893daae3889fddd895b489777a9c Mon Sep 17 00:00:00 2001 From: Francesco Gruosso <64712227+FrancescoCoding@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:43:09 +0000 Subject: [PATCH 2/5] Skip refreshing the startup task when it launched us --- src/gui.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/gui.rs b/src/gui.rs index ec9d83c..5c570a7 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -1201,12 +1201,15 @@ fn create_tray(ctx: &egui::Context, tx: Sender) -> Option } /// Check the startup task off the UI thread (`schtasks` takes a moment). An -/// existing task is re-pointed at this exe, in case Throttle was moved. +/// existing task is re-pointed at this exe, in case Throttle was moved (not +/// needed when the task itself launched us). fn spawn_autostart_check() -> Receiver { let (tx, rx) = crossbeam_channel::bounded(1); + let launched_by_task = std::env::args().any(|a| a == autostart::TRAY_FLAG); std::thread::spawn(move || { let enabled = autostart::is_enabled(); if enabled + && !launched_by_task && let Ok(exe) = std::env::current_exe() && let Err(e) = autostart::enable(&exe) { From b37889b8eac64606c67d12ffe0505b67da7470a2 Mon Sep 17 00:00:00 2001 From: Francesco Gruosso <64712227+FrancescoCoding@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:51:39 +0000 Subject: [PATCH 3/5] Restore from the tray in one click Right after a restore the window still reads as minimised for a frame, so the minimise check hid it again. Hide only when the window becomes minimised. --- src/gui.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/gui.rs b/src/gui.rs index 5c570a7..70b5d64 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -206,6 +206,8 @@ pub struct ThrottleApp { tray_rx: Receiver, /// The window is hidden to the tray (no taskbar button). hidden: bool, + /// Whether the window was minimised last frame, to catch the moment it is. + was_minimized: bool, /// Hide to the tray on the first frame (`--tray` or "Start minimised"). start_hidden: bool, /// A real exit is under way, so a close request must not go to the tray. @@ -264,6 +266,7 @@ impl ThrottleApp { tray, tray_rx, hidden: false, + was_minimized: false, start_hidden, exiting: false, update_rx: update_check::spawn(APP_VERSION), @@ -1079,10 +1082,14 @@ impl eframe::App for ThrottleApp { } } + // Hide when the window becomes minimised, not while it stays so: just + // after a restore from the tray it still reads as minimised for a frame, + // and hiding then would take a second click to bring it back. let minimized = ctx.input(|i| i.viewport().minimized == Some(true)); - if minimized && self.settings.minimize_to_tray && !self.hidden { + if minimized && !self.was_minimized && self.settings.minimize_to_tray && !self.hidden { self.hide_to_tray(ctx); } + self.was_minimized = minimized; // Window close: hide to the tray if asked to, else shut the backend down. if ctx.input(|i| i.viewport().close_requested()) { From d5b35b358d5b89aa5438ad171c4211ba74aa2b49 Mon Sep 17 00:00:00 2001 From: Francesco Gruosso <64712227+FrancescoCoding@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:51:55 +0000 Subject: [PATCH 4/5] Bump version to 0.1.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a07aa5..e47e8fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3385,7 +3385,7 @@ dependencies = [ [[package]] name = "throttle" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "crossbeam-channel", diff --git a/Cargo.toml b/Cargo.toml index b2db3d5..aa4d0e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "throttle" -version = "0.1.2" +version = "0.1.3" edition = "2024" description = "A free, open-source per-process bandwidth monitor and limiter for Windows" repository = "https://github.com/FrancescoCoding/Throttle" From a2a4541ac785733aa7b7c15e2025ac538b29bb47 Mon Sep 17 00:00:00 2001 From: Francesco Gruosso <64712227+FrancescoCoding@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:24:34 +0000 Subject: [PATCH 5/5] Address review: tray icon at logon, startup task takeover, early second launch - Starting in the tray waits for the taskbar before hiding: tray-icon reports success even when the taskbar does not exist yet, so hiding on the first frame at logon could leave no icon to click. Gives up and stays visible after a minute. - The startup check only re-points the task when the exe it starts no longer exists, so running another copy once no longer takes it over. Each task XML write uses its own temp file. - The show event is created with the single-instance lock, so a second launch during startup is queued instead of lost. The error window is raised by a second launch too. - A relaunch after an in-app update always shows the window, even with Start minimised on. --- src/autostart.rs | 45 +++++++++++++++++++++++++++++++++-- src/gui.rs | 53 +++++++++++++++++++++++++++++++----------- src/main.rs | 10 ++++++-- src/single_instance.rs | 41 ++++++++++++++++++++------------ 4 files changed, 116 insertions(+), 33 deletions(-) diff --git a/src/autostart.rs b/src/autostart.rs index 63083e7..321c4e4 100644 --- a/src/autostart.rs +++ b/src/autostart.rs @@ -10,8 +10,9 @@ //! stored in `settings.json`. use std::os::windows::process::CommandExt; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::atomic::{AtomicU32, Ordering}; use anyhow::{Context, bail}; @@ -36,15 +37,32 @@ pub fn is_enabled() -> bool { schtasks(&["/Query", "/TN", TASK_NAME]).is_ok_and(|out| out.status.success()) } +/// The program the logon task starts, or `None` if there is no task or its +/// definition cannot be read. +pub fn task_command() -> Option { + let out = schtasks(&["/Query", "/TN", TASK_NAME, "/XML"]).ok()?; + if !out.status.success() { + return None; + } + parse_command(&String::from_utf8_lossy(&out.stdout)).map(PathBuf::from) +} + /// Create (or replace) the logon task so it starts `exe` in the tray. pub fn enable(exe: &Path) -> anyhow::Result<()> { + // The UI and the startup check can both call this; give each its own file. + static NEXT_FILE: AtomicU32 = AtomicU32::new(0); + let user = current_user(); let xml = task_xml(exe, &user); // schtasks reads task XML as UTF-16 with a BOM. let mut bytes = vec![0xFF, 0xFE]; bytes.extend(xml.encode_utf16().flat_map(u16::to_le_bytes)); - let xml_path = std::env::temp_dir().join("throttle-autostart.xml"); + let xml_path = std::env::temp_dir().join(format!( + "throttle-autostart-{}-{}.xml", + std::process::id(), + NEXT_FILE.fetch_add(1, Ordering::Relaxed) + )); std::fs::write(&xml_path, bytes).context("write task XML")?; let xml_arg = xml_path.to_string_lossy(); @@ -141,6 +159,21 @@ fn xml_escape(s: &str) -> String { .replace('\'', "'") } +fn xml_unescape(s: &str) -> String { + s.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +/// The unescaped `` of a task definition. +fn parse_command(xml: &str) -> Option { + let start = xml.find("")? + "".len(); + let len = xml[start..].find("")?; + Some(xml_unescape(xml[start..start + len].trim())) +} + #[cfg(test)] mod tests { use super::*; @@ -156,6 +189,14 @@ mod tests { assert!(xml.contains(r"PC\O'Brien")); } + #[test] + fn command_round_trips_through_task_xml() { + let exe = r"C:\Tools & \Throttle\throttle.exe"; + let xml = task_xml(Path::new(exe), "me"); + assert_eq!(parse_command(&xml).as_deref(), Some(exe)); + assert_eq!(parse_command(""), None); + } + #[test] fn task_xml_never_stops_or_deprioritises_the_shaper() { let xml = task_xml(Path::new(r"C:\throttle.exe"), "me"); diff --git a/src/gui.rs b/src/gui.rs index 70b5d64..3ab3184 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -4,7 +4,7 @@ //! This file is built against the shared contract in `crate::types` and must //! not depend on any backend internals beyond the `snapshot_rx` receiver. -use std::time::Duration; +use std::time::{Duration, Instant}; use crossbeam_channel::{Receiver, Sender}; use eframe::egui; @@ -29,6 +29,9 @@ const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Version of the bundled WinDivert driver (see `vendor/`). const WINDIVERT_VERSION: &str = "2.2.2"; +/// How long a start in the tray waits for the taskbar before staying visible. +const START_HIDDEN_WAIT: Duration = Duration::from_secs(60); + const COLOR_DOWN: egui::Color32 = egui::Color32::from_rgb(0x4c, 0xaf, 0x50); const COLOR_UP: egui::Color32 = egui::Color32::from_rgb(0x21, 0x96, 0xf3); const COLOR_BLOCK: egui::Color32 = egui::Color32::from_rgb(0xe5, 0x39, 0x35); @@ -208,8 +211,9 @@ pub struct ThrottleApp { hidden: bool, /// Whether the window was minimised last frame, to catch the moment it is. was_minimized: bool, - /// Hide to the tray on the first frame (`--tray` or "Start minimised"). - start_hidden: bool, + /// Hide to the tray once the taskbar exists (`--tray` or "Start minimised"), + /// unless it has not appeared by this deadline. + start_hidden: Option, /// A real exit is under way, so a close request must not go to the tray. exiting: bool, @@ -267,7 +271,7 @@ impl ThrottleApp { tray_rx, hidden: false, was_minimized: false, - start_hidden, + start_hidden: start_hidden.then(|| Instant::now() + START_HIDDEN_WAIT), exiting: false, update_rx: update_check::spawn(APP_VERSION), update: None, @@ -1070,14 +1074,27 @@ impl eframe::App for ThrottleApp { self.last_snapshot = snap; } - if std::mem::take(&mut self.start_hidden) { - self.hide_to_tray(ctx); + // At logon the startup task can run before Explorer has created the + // taskbar. The tray icon is then only added once it appears, so wait for + // it rather than hide with no icon to click; give up and stay visible if + // it takes too long. + if let Some(deadline) = self.start_hidden { + if taskbar_ready() { + self.start_hidden = None; + self.hide_to_tray(ctx); + } else if Instant::now() >= deadline { + tracing::warn!("taskbar not found; starting visible instead of in the tray"); + self.start_hidden = None; + } } let actions: Vec = self.tray_rx.try_iter().collect(); for action in actions { match action { - TrayAction::Show => self.show_window(ctx), + TrayAction::Show => { + self.start_hidden = None; + self.show_window(ctx); + } TrayAction::Exit => self.exit(ctx), } } @@ -1146,8 +1163,9 @@ impl Drop for ThrottleApp { // --------------------------------------------------------------------------- /// Create the tray icon: left click shows the window, right click opens a -/// Show / Exit menu. Returns `None` (logged) if the shell refuses the icon, -/// e.g. Explorer is not running yet. +/// Show / Exit menu. Returns `None` (logged) if it cannot be created. If the +/// taskbar does not exist yet, tray-icon still succeeds and adds the icon when +/// Explorer announces the taskbar (see `taskbar_ready`). fn create_tray(ctx: &egui::Context, tx: Sender) -> Option { let show = MenuItem::new("Show Throttle", true, None); let exit = MenuItem::new("Exit", true, None); @@ -1207,16 +1225,23 @@ fn create_tray(ctx: &egui::Context, tx: Sender) -> Option } } -/// Check the startup task off the UI thread (`schtasks` takes a moment). An -/// existing task is re-pointed at this exe, in case Throttle was moved (not -/// needed when the task itself launched us). +/// Whether Explorer's taskbar (and so the notification area) exists. +fn taskbar_ready() -> bool { + use windows::Win32::UI::WindowsAndMessaging::FindWindowW; + use windows::core::w; + // SAFETY: FindWindowW with a valid static class name; a miss is an error. + unsafe { FindWindowW(w!("Shell_TrayWnd"), None) }.is_ok_and(|h| !h.is_invalid()) +} + +/// Check the startup task off the UI thread (`schtasks` takes a moment). The +/// task is re-pointed at this exe only if the exe it starts no longer exists +/// (Throttle was moved): running another copy once must not take it over. fn spawn_autostart_check() -> Receiver { let (tx, rx) = crossbeam_channel::bounded(1); - let launched_by_task = std::env::args().any(|a| a == autostart::TRAY_FLAG); std::thread::spawn(move || { let enabled = autostart::is_enabled(); if enabled - && !launched_by_task + && autostart::task_command().is_some_and(|cmd| !cmd.exists()) && let Ok(exe) = std::env::current_exe() && let Err(e) = autostart::enable(&exe) { diff --git a/src/main.rs b/src/main.rs index 6deede5..ad25907 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,8 +129,11 @@ fn main() -> eframe::Result { let settings = settings::load(); // The startup task passes --tray; "Start minimised" covers manual launches. - let start_hidden = - std::env::args().any(|a| a == autostart::TRAY_FLAG) || settings.start_minimized; + // A relaunch after an in-app update (--wait-pid) always shows the window, + // so the user sees the update finish. + let relaunched_by_update = std::env::args().any(|a| a == "--wait-pid"); + let start_hidden = !relaunched_by_update + && (std::env::args().any(|a| a == autostart::TRAY_FLAG) || settings.start_minimized); let app_icon = eframe::egui::IconData { rgba: icon::rgba(), @@ -176,6 +179,9 @@ fn main() -> eframe::Result { Err(err) => { tracing::error!("backend failed to start: {err:#}"); let message = format!("{err:#}"); + // A second launch should raise the error window. It never hides to + // the tray, so raising it directly is safe. + single_instance::on_show_request(|| single_instance::focus_window("Throttle")); // Show the error in a minimal window instead of exiting silently. eframe::run_native( "Throttle error", diff --git a/src/single_instance.rs b/src/single_instance.rs index ac2724d..e50f5d9 100644 --- a/src/single_instance.rs +++ b/src/single_instance.rs @@ -12,6 +12,8 @@ //! window's visibility, so showing it from outside would leave winit thinking //! it is still hidden, and the next hide to the tray would do nothing. +use std::sync::OnceLock; + use windows::Win32::Foundation::{ERROR_ALREADY_EXISTS, GetLastError, HANDLE, HWND, WAIT_OBJECT_0}; use windows::Win32::System::Threading::{ CreateEventW, CreateMutexW, EVENT_MODIFY_STATE, INFINITE, OpenEventW, SetEvent, @@ -21,14 +23,19 @@ use windows::Win32::UI::WindowsAndMessaging::{ ASFW_ANY, AllowSetForegroundWindow, FindWindowW, IsIconic, SW_RESTORE, SetForegroundWindow, ShowWindow, }; -use windows::core::{PCWSTR, w}; +use windows::core::{HSTRING, PCWSTR, w}; /// Title of the main window, used to locate an already-running instance. -const WINDOW_TITLE: PCWSTR = w!("Throttle"); +const WINDOW_TITLE: &str = "Throttle"; /// Auto-reset event a second launch signals to bring the running window back. const SHOW_EVENT: PCWSTR = w!("Global\\ThrottleShowRequest"); +/// The show event, created with the lock so a second launch during startup +/// (driver load, window creation) is not lost: the auto-reset event stays +/// signalled until `on_show_request` starts waiting on it. +static SHOW_EVENT_HANDLE: OnceLock = OnceLock::new(); + /// Held for the life of the process. Dropping it would release the mutex, so /// the handle is intentionally never closed. pub struct InstanceLock { @@ -49,25 +56,27 @@ pub fn acquire() -> Option { if GetLastError() == ERROR_ALREADY_EXISTS { if !request_show() { // A build without the show event: fall back to raising its window. - focus_existing_window(); + focus_window(WINDOW_TITLE); } return None; } + // The handle is valid for the process lifetime and intentionally never closed. + match CreateEventW(None, false, false, SHOW_EVENT) { + Ok(event) => { + let _ = SHOW_EVENT_HANDLE.set(event.0 as usize); + } + Err(e) => tracing::warn!("show-request event unavailable: {e}"), + } Some(InstanceLock { handle }) } } /// Run `on_request` every time a second launch asks this instance to show -/// itself. The callback runs on a dedicated background thread. +/// itself, including a request made before this was called. The callback runs +/// on a dedicated background thread. pub fn on_show_request(on_request: impl Fn() + Send + 'static) { - // SAFETY: plain Win32 calls; the handle is valid for the process lifetime - // and intentionally never closed. - let event = match unsafe { CreateEventW(None, false, false, SHOW_EVENT) } { - Ok(h) => h.0 as usize, - Err(e) => { - tracing::warn!("show-request event unavailable: {e}"); - return; - } + let Some(&event) = SHOW_EVENT_HANDLE.get() else { + return; }; std::thread::Builder::new() .name("show-request".into()) @@ -96,12 +105,14 @@ fn request_show() -> bool { } } -/// Best-effort: restore and raise the existing instance's main window. -fn focus_existing_window() { +/// Best-effort: restore and raise the window titled `title`. Not for a window +/// that may be hidden to the tray, since showing it from outside winit would +/// desync winit's cached visibility. +pub fn focus_window(title: &str) { // SAFETY: FindWindowW returns a null HWND when nothing matches; all calls // tolerate that and return failure rather than faulting. unsafe { - let hwnd: HWND = match FindWindowW(None, WINDOW_TITLE) { + let hwnd: HWND = match FindWindowW(None, &HSTRING::from(title)) { Ok(h) if !h.is_invalid() => h, _ => return, };