diff --git a/Cargo.lock b/Cargo.lock index 9f608c8..e47e8fc 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" @@ -3357,7 +3385,7 @@ dependencies = [ [[package]] name = "throttle" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "crossbeam-channel", @@ -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..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" @@ -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..321c4e4 --- /dev/null +++ b/src/autostart.rs @@ -0,0 +1,210 @@ +//! 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, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU32, Ordering}; + +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()) +} + +/// 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(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(); + 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('\'', "'") +} + +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::*; + + #[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 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"); + 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..3ab3184 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -4,17 +4,21 @@ //! 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; 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; @@ -25,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); @@ -191,12 +198,37 @@ 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, + /// Whether the window was minimised last frame, to catch the moment it is. + was_minimized: 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, + 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 +238,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 +263,16 @@ 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, + was_minimized: false, + start_hidden: start_hidden.then(|| Instant::now() + START_HIDDEN_WAIT), + exiting: false, update_rx: update_check::spawn(APP_VERSION), update: None, update_dismissed: false, @@ -236,6 +287,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 +456,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 +493,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 +553,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 +1055,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 +1074,59 @@ impl eframe::App for ThrottleApp { self.last_snapshot = snap; } - // Window close -> tell the backend to shut down. + // 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.start_hidden = None; + self.show_window(ctx); + } + TrayAction::Exit => self.exit(ctx), + } + } + + // 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.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()) { - 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 +1149,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 +1158,100 @@ 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 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); + 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 + } + } +} + +/// 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); + std::thread::spawn(move || { + let enabled = autostart::is_enabled(); + if enabled + && autostart::task_command().is_some_and(|cmd| !cmd.exists()) + && 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..ad25907 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,14 @@ fn main() -> eframe::Result { return Ok(()); } + let settings = settings::load(); + // The startup task passes --tray; "Start minimised" covers manual launches. + // 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(), width: icon::SIZE, @@ -150,7 +160,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; @@ -161,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/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..e50f5d9 100644 --- a/src/single_instance.rs +++ b/src/single_instance.rs @@ -6,16 +6,35 @@ //! 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 std::sync::OnceLock; -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}; +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. @@ -27,27 +46,73 @@ 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_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 }) } } -/// Best-effort: restore and raise the existing instance's main window. -fn focus_existing_window() { +/// Run `on_request` every time a second launch asks this instance to show +/// 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) { + let Some(&event) = SHOW_EVENT_HANDLE.get() else { + 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 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, };