diff --git a/src/proton.rs b/src/proton.rs index c14e462..6ebab65 100644 --- a/src/proton.rs +++ b/src/proton.rs @@ -79,9 +79,12 @@ fn install_version(config: &Config, tag: &str) -> Result<()> { .context("Download failed")? .error_for_status() .context("Download returned an error status")?; - let mut f = std::fs::File::create(&tmp_path) + let total = resp.content_length(); + let f = std::fs::File::create(&tmp_path) .with_context(|| format!("Failed to create temp file {tmp_path:?}"))?; - std::io::copy(&mut resp, &mut f).context("Failed to stream archive to disk")?; + let mut progress = ProgressWriter::new(f, total); + std::io::copy(&mut resp, &mut progress).context("Failed to stream archive to disk")?; + progress.finish(); } println!("Extracting to {:?}...", config.proton_compat_dir); @@ -156,6 +159,55 @@ fn print_list(config: &Config) -> Result<()> { Ok(()) } +/// Wraps a writer to print download progress to stderr, throttled to one +/// update per megabyte so we don't spam the terminal. +struct ProgressWriter { + inner: W, + total: Option, + written: u64, + last_print: u64, +} + +impl ProgressWriter { + fn new(inner: W, total: Option) -> Self { + Self { inner, total, written: 0, last_print: 0 } + } + + fn finish(&mut self) { + let _ = self.inner.flush(); + // Clear the progress line — the caller's next println! starts fresh. + eprintln!(); + } +} + +impl Write for ProgressWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let n = self.inner.write(buf)?; + self.written += n as u64; + if self.written - self.last_print >= 1 << 20 { + self.last_print = self.written; + match self.total { + Some(t) => { + let pct = (self.written as f64 / t as f64) * 100.0; + eprint!( + "\r {:.1}% ({} / {} MiB)", + pct, + self.written >> 20, + t >> 20, + ); + } + None => eprint!("\r {} MiB", self.written >> 20), + } + let _ = std::io::stderr().flush(); + } + Ok(n) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + fn pick_interactively(config: &Config) -> Result { let releases = fetch_releases(10)?; if releases.is_empty() { diff --git a/src/tray.rs b/src/tray.rs index 6896d2f..eda6d57 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -7,6 +7,9 @@ pub struct BattlenetTray { pub config: Config, /// Whether Battle.net is currently running; updated by background poller. pub running: bool, + /// Set after the service spawns so Quit can shut down the SNI item + /// cleanly instead of yanking it off the bus via exit(). + pub handle: Option>, } impl ksni::Tray for BattlenetTray { @@ -113,8 +116,18 @@ impl ksni::Tray for BattlenetTray { StandardItem { label: "Quit".into(), icon_name: "application-exit".into(), - activate: Box::new(|_this: &mut Self| { - std::process::exit(0); + activate: Box::new(|this: &mut Self| { + if let Some(h) = this.handle.clone() { + // Run shutdown off-thread: we're currently holding + // the tray lock inside update(), and shutdown wants + // the service loop to turn. + thread::spawn(move || { + h.shutdown(); + std::process::exit(0); + }); + } else { + std::process::exit(0); + } }), ..Default::default() } @@ -130,6 +143,7 @@ pub fn run(config: &Config) -> Result<()> { let tray = BattlenetTray { config: config.clone(), running: launcher::is_running(), + handle: None, }; let service = ksni::TrayService::new(tray); @@ -137,10 +151,17 @@ pub fn run(config: &Config) -> Result<()> { let handle = service.handle(); service.spawn(); + // Hand the tray a clone of its own handle so Quit can shut down cleanly. + let handle_for_self = handle.clone(); + handle.update(move |t: &mut BattlenetTray| { + t.handle = Some(handle_for_self); + }); + // Background thread: poll Battle.net process state every 2 s and update the tray. + let poll_handle = handle; thread::spawn(move || loop { let running = launcher::is_running(); - handle.update(|tray: &mut BattlenetTray| { + poll_handle.update(|tray: &mut BattlenetTray| { tray.running = running; }); thread::sleep(Duration::from_secs(2));