diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs index 6014fc7..cb7e87d 100644 --- a/apps/artcraft-launcher/src/main.rs +++ b/apps/artcraft-launcher/src/main.rs @@ -41,7 +41,7 @@ fn main() -> ExitCode { let mut hidden = args.iter().any(|a| a == login::HIDDEN_FLAG); let wake: Wake = Arc::new(OnceLock::new()); - let show_requests = match single_instance::claim(wake.clone()) { + let starts = match single_instance::claim(wake.clone()) { Instance::Secondary => return ExitCode::SUCCESS, Instance::Primary(rx) => rx, }; @@ -54,7 +54,7 @@ fn main() -> ExitCode { let backend_waker = waker.clone(); let settings_path = settings_file::path(APP_ID); - let mut launcher = Launcher::new(Box::new(NativeBackend::new(move || backend_waker.wake())), show_requests, settings_path.clone()); + let mut launcher = Launcher::new(Box::new(NativeBackend::new(move || backend_waker.wake())), starts, settings_path.clone()); // Started hidden at login where a window can't hide: start in the tray without mapping one, // so no window flashes up. Without a tray to come back from, the window opens after all. let mut window = true; @@ -335,7 +335,8 @@ struct Launcher { ui: LauncherUi, tray: Option, host: DesktopHost, - show_requests: Receiver<()>, + /// Requests from later starts of the launcher. + starts: Receiver, /// Stands in for a window's context while only the tray exists. A window can't reuse it: a /// new window's renderer has none of the textures a used context has already handed out. windowless: egui::Context, @@ -351,7 +352,7 @@ struct Launcher { } impl Launcher { - fn new(backend: Box, show_requests: Receiver<()>, settings_path: Option) -> Self { + fn new(backend: Box, starts: Receiver, settings_path: Option) -> Self { let windowless = egui::Context::default(); let (asked, run_again) = channel(); windowless.set_request_repaint_callback(move |info| { @@ -361,7 +362,7 @@ impl Launcher { ui: LauncherUi::new(&windowless, backend, Settings::default()), tray: None, host: DesktopHost { tray: false, quit: false }, - show_requests, + starts, windowless, run_again, started: false, @@ -409,7 +410,11 @@ impl Launcher { fn tick(&mut self, ctx: &egui::Context) -> Vec { self.ui.logic(ctx, &mut self.host); let mut requests = Vec::new(); - if self.show_requests.try_iter().count() > 0 { + let starts: Vec = self.starts.try_iter().collect(); + if starts.contains(&single_instance::Request::Replace) && !self.ui.busy() { + // A newer launcher is starting and takes over once this one has quit. + requests.push(Request::Quit); + } else if !starts.is_empty() { requests.push(Request::Show); } let actions = self.tray.as_ref().map(tray::Tray::actions).unwrap_or_default(); @@ -643,7 +648,7 @@ mod tests { } /// A launcher with no tray and no window, the sender of its show requests, and its backend's record. - fn launcher() -> (Launcher, Sender<()>, Arc) { + fn launcher() -> (Launcher, Sender, Arc) { let seen = Arc::new(Seen { clock: AtomicI64::new(NOW), ..Seen::default() }); let (show, show_requests) = channel(); (Launcher::new(Box::new(Quiet(seen.clone())), show_requests, None), show, seen) @@ -715,7 +720,7 @@ mod tests { fn with_no_window_a_show_request_opens_one() { let (mut launcher, show, _) = launcher(); let (_wake, woken) = sync_channel(1); - assert!(show.send(()).is_ok()); + assert!(show.send(single_instance::Request::Show).is_ok()); assert!(launcher.wait_in_tray(&woken)); } @@ -723,7 +728,7 @@ mod tests { fn with_no_window_quit_wins_over_a_show_request() { let (mut launcher, show, _) = launcher(); let (_wake, woken) = sync_channel(1); - assert!(show.send(()).is_ok()); + assert!(show.send(single_instance::Request::Show).is_ok()); launcher.host.quit(); assert!(!launcher.wait_in_tray(&woken)); } @@ -737,7 +742,7 @@ mod tests { let pause = Duration::from_millis(200); let waking = std::thread::spawn(move || { std::thread::sleep(pause); - let _ = show.send(()); + let _ = show.send(single_instance::Request::Show); let _ = wake.try_send(()); }); let started = Instant::now(); diff --git a/crates/native/src/single_instance.rs b/crates/native/src/single_instance.rs index 5aac28e..180a6b2 100644 --- a/crates/native/src/single_instance.rs +++ b/crates/native/src/single_instance.rs @@ -1,54 +1,246 @@ //! One launcher at a time: a second start asks the first to show its window, then exits. //! -//! The first instance listens on a fixed loopback port. The only message is `show`, and the -//! worst another local program can do with it is bring the window forward. +//! The first instance listens on a fixed loopback port. A second start says hello with its +//! version, and the first answers with its own: +//! +//! - the newcomer is the same version or older: the running one shows its window and the newcomer +//! exits, as before; +//! - the newcomer is newer: the running one quits (unless it's in the middle of installing +//! something; then it shows itself) and the newcomer takes over. Otherwise starting a new +//! version while an old one sat in the tray brought back the old window, which still offered +//! whatever update it had found back then (issue #29: 0.1.2 showed "0.1.1 is available"); +//! - the running one is from before this handshake (0.1.3 or older) and closes without an +//! answer: it can't be asked to quit, so the newcomer starts anyway. +//! +//! Launchers from before the handshake send a bare `show`, which is still understood. The worst +//! another local program can do with any of this is bring the window forward or make the launcher +//! quit. -use std::io::{Read, Write}; +use std::io::{ErrorKind, Read, Write}; use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; use std::sync::mpsc::{Receiver, channel}; -use std::time::Duration; +use std::time::{Duration, Instant}; + +use artcraft_launcher_core::version::{self, Version}; const PORT: u16 = 47_413; -const MESSAGE: &[u8] = b"artcraft-launcher:show"; +/// What launchers before the version handshake send (and still the answer to an unknown peer). +const SHOW: &[u8] = b"artcraft-launcher:show"; +/// `hello `: a second start introduces itself. +const HELLO: &str = "artcraft-launcher:hello "; +/// `running `: the first instance's answer. +const RUNNING: &str = "artcraft-launcher:running "; +/// How long a newer start waits for the old one to quit and free the port. +const TAKE_OVER_WITHIN: Duration = Duration::from_secs(10); + +/// What another start asks of the running launcher. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Request { + /// Bring the window forward. + Show, + /// A newer launcher is starting: quit so it can take over (or show the window when quitting + /// now would cut short an install). + Replace, +} pub enum Instance { - /// We're the only one. A message arrives on the receiver whenever another start asks to show - /// the window; `wake` is called to repaint the (perhaps hidden) window. - Primary(Receiver<()>), + /// We're the only one. A request arrives on the receiver whenever another start asks + /// something; `wake` is called to repaint the (perhaps hidden) window. + Primary(Receiver), /// Another launcher is running and has been asked to show itself. Secondary, } +/// How the running launcher answered our hello. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Answer { + /// It's as new as we are, or newer, and shows its window: we exit. + Defer, + /// It's older and quits: we take over the port. + TakeOver, + /// It closed without an answer: a launcher from before the handshake. + Legacy, +} + +type Wake = std::sync::Arc>>; + +/// This launcher's own version. +fn ours() -> Option { + version::parse(env!("CARGO_PKG_VERSION")) +} + /// Claims the instance; `wake` is set later (the UI context doesn't exist yet). -pub fn claim(wake: std::sync::Arc>>) -> Instance { +pub fn claim(wake: Wake) -> Instance { let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, PORT)); - match TcpListener::bind(addr) { - Ok(listener) => { - let (tx, rx) = channel(); - std::thread::spawn(move || { - for stream in listener.incoming().flatten() { - let mut buf = [0u8; 64]; - let _ = stream.set_read_timeout(Some(Duration::from_secs(1))); - let n = (&stream).read(&mut buf).unwrap_or(0); - if buf.get(..n) == Some(MESSAGE) { - if tx.send(()).is_err() { - break; - } - if let Some(wake) = wake.get() { - wake(); - } - } + let ours = ours(); + if let Ok(listener) = TcpListener::bind(addr) { + return Instance::Primary(listen(listener, ours, wake)); + } + match ask(addr, ours.as_ref()) { + Ok(Answer::Defer) => Instance::Secondary, + Ok(Answer::TakeOver) => { + let deadline = Instant::now() + TAKE_OVER_WITHIN; + while Instant::now() < deadline { + if let Ok(listener) = TcpListener::bind(addr) { + return Instance::Primary(listen(listener, ours, wake)); } - }); - Instance::Primary(rx) - } - Err(_) => match TcpStream::connect_timeout(&addr, Duration::from_secs(1)) { - Ok(mut stream) => { - let _ = stream.write_all(MESSAGE); - Instance::Secondary + std::thread::sleep(Duration::from_millis(100)); } - // The port is taken by something else: run anyway rather than refuse to start. - Err(_) => Instance::Primary(channel().1), - }, + // It stayed (busy installing) and showed its window instead. + Instance::Secondary + } + Ok(Answer::Legacy) => { + log::warn!("an older ArtCraft Launcher is running and can't be asked to quit; starting anyway"); + Instance::Primary(channel().1) + } + // The port is taken by something else: run anyway rather than refuse to start. + Err(_) => Instance::Primary(channel().1), + } +} + +/// Answers other starts on `listener` until the receiver is dropped. +fn listen(listener: TcpListener, ours: Option, wake: Wake) -> Receiver { + let (tx, rx) = channel(); + std::thread::spawn(move || { + for mut stream in listener.incoming().flatten() { + let mut buf = [0u8; 64]; + let _ = stream.set_read_timeout(Some(Duration::from_secs(1))); + let n = stream.read(&mut buf).unwrap_or(0); + let Some((request, reply)) = buf.get(..n).and_then(|m| answer(m, ours.as_ref())) else { continue }; + if let Some(reply) = reply { + let _ = stream.write_all(reply.as_bytes()); + } + drop(stream); + if tx.send(request).is_err() { + break; + } + if let Some(wake) = wake.get() { + wake(); + } + } + }); + rx +} + +/// The running launcher's side: what `message` asks for, and what to answer. +fn answer(message: &[u8], ours: Option<&Version>) -> Option<(Request, Option)> { + if message == SHOW { + return Some((Request::Show, None)); + } + let theirs = std::str::from_utf8(message).ok()?.strip_prefix(HELLO)?; + let newer = match (version::parse(theirs), ours) { + (Some(theirs), Some(ours)) => version::is_newer(&theirs, ours), + _ => false, + }; + let reply = format!("{RUNNING}{}", ours.map_or_else(|| "unknown".to_string(), ToString::to_string)); + Some((if newer { Request::Replace } else { Request::Show }, Some(reply))) +} + +/// The new start's side: says hello to whatever holds the port and reads the answer. +fn ask(addr: SocketAddr, ours: Option<&Version>) -> std::io::Result { + let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(1))?; + let Some(ours) = ours else { + // Can't compare without our own version: ask it to show itself, as before. + stream.write_all(SHOW)?; + return Ok(Answer::Defer); + }; + stream.write_all(format!("{HELLO}{ours}").as_bytes())?; + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + let mut reply = Vec::new(); + match (&mut stream).take(64).read_to_end(&mut reply) { + Ok(_) => {} + // An old launcher may reset the connection rather than close it. + Err(e) if reply.is_empty() && matches!(e.kind(), ErrorKind::ConnectionReset | ErrorKind::ConnectionAborted) => {} + // No answer in time: leave the running one be. + Err(_) if reply.is_empty() => return Ok(Answer::Defer), + Err(_) => {} + } + Ok(verdict(&reply, ours)) +} + +/// Reads the running launcher's answer. +fn verdict(reply: &[u8], ours: &Version) -> Answer { + if reply.is_empty() { + return Answer::Legacy; + } + let running = std::str::from_utf8(reply).ok().and_then(|r| r.strip_prefix(RUNNING)).and_then(version::parse); + match running { + Some(running) if version::is_newer(ours, &running) => Answer::TakeOver, + _ => Answer::Defer, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(s: &str) -> Version { + version::parse(s).unwrap() + } + + /// A primary on a free loopback port, as version `running`. + fn primary(running: &str) -> (SocketAddr, Receiver) { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + (addr, listen(listener, Some(v(running)), Wake::default())) + } + + fn next(rx: &Receiver) -> Request { + rx.recv_timeout(Duration::from_secs(5)).unwrap() + } + + // Issue #29: 0.1.2 started while 0.1.0 sat in the tray, and the 0.1.0 window came up + // offering 0.1.1. A newer start must replace the older one, not show it. + #[test] + fn a_newer_start_replaces_an_older_running_launcher() { + let (addr, rx) = primary("0.1.0"); + assert_eq!(ask(addr, Some(&v("0.1.2"))).ok(), Some(Answer::TakeOver)); + assert_eq!(next(&rx), Request::Replace); + } + + #[test] + fn the_same_or_an_older_start_shows_the_running_launcher() { + let (addr, rx) = primary("0.1.2"); + assert_eq!(ask(addr, Some(&v("0.1.2"))).ok(), Some(Answer::Defer)); + assert_eq!(next(&rx), Request::Show); + assert_eq!(ask(addr, Some(&v("0.1.0"))).ok(), Some(Answer::Defer)); + assert_eq!(next(&rx), Request::Show); + } + + #[test] + fn a_start_from_before_the_handshake_still_shows_the_window() { + let (addr, rx) = primary("0.1.4"); + let mut s = TcpStream::connect(addr).unwrap(); + s.write_all(SHOW).unwrap(); + assert_eq!(next(&rx), Request::Show); + } + + #[test] + fn a_running_launcher_from_before_the_handshake_is_recognised() { + // What 0.1.0 to 0.1.3 do with a message they don't know: read it and hang up. + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let old = std::thread::spawn(move || { + if let Ok((mut s, _)) = listener.accept() { + let mut buf = [0u8; 64]; + let _ = s.read(&mut buf); + } + }); + assert_eq!(ask(addr, Some(&v("0.1.4"))).ok(), Some(Answer::Legacy)); + let _ = old.join(); + } + + #[test] + fn answers_are_read_defensively() { + assert_eq!(answer(b"artcraft-launcher:hello 9.9.9", Some(&v("0.1.0"))).map(|a| a.0), Some(Request::Replace)); + assert_eq!(answer(b"artcraft-launcher:hello nonsense", Some(&v("0.1.0"))).map(|a| a.0), Some(Request::Show)); + assert_eq!(answer(b"artcraft-launcher:hello 9.9.9", None).map(|a| a.0), Some(Request::Show)); + assert_eq!(answer(b"hello", Some(&v("0.1.0"))), None); + assert_eq!(answer(&[0xff, 0xfe], Some(&v("0.1.0"))), None); + assert_eq!(verdict(b"artcraft-launcher:running 0.1.0", &v("0.1.2")), Answer::TakeOver); + assert_eq!(verdict(b"artcraft-launcher:running 0.1.2", &v("0.1.2")), Answer::Defer); + assert_eq!(verdict(b"artcraft-launcher:running unknown", &v("0.1.2")), Answer::Defer); + assert_eq!(verdict(b"garbage", &v("0.1.2")), Answer::Defer); + assert_eq!(verdict(b"", &v("0.1.2")), Answer::Legacy); } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index e4faa64..16d0591 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -173,6 +173,12 @@ impl LauncherUi { self.entries.iter().filter(|e| e.status(self.platform, self.caps.manage_apps) == AppStatus::UpdateAvailable).count() } + /// Something is downloading or installing, the launcher's own update included: quitting now + /// would cut it short. + pub fn busy(&self) -> bool { + self.launcher_job.is_some() || self.entries.iter().any(|e| e.job.is_some()) + } + /// Looks for updates now (the tray's "Check for Updates"). pub fn check_now(&mut self) { self.auto_update_attempted.clear();