Let a newer launcher take over from an older one that's already running (#59)

Issue #29's window wasn't 0.1.2: its tagline and header are 0.1.0's, and
it offered Download where a portable 0.1.2 offers Update and restart. A
0.1.0 launcher sat in the tray; starting 0.1.2 asked it to show its
window and exited, and that window still offered 0.1.1, found while 0.1.1
was the latest release.

A second start now says hello with its version and the running launcher
answers with its own. A newer start makes the running one quit (unless
it's downloading or installing; then it shows its window) and takes over
the port. The same version or older shows the running window as before.
Launchers from before the handshake hang up without an answer and can't
be asked to quit, so the new start runs anyway. A bare "show" from an
old launcher is still understood.

Tests: the handshake over loopback, including a running launcher that
predates it.

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Fabio Lussi 2026-10-11 11:02:09 +02:00 committed by GitHub
parent 714522d577
commit 6f9722c91a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 248 additions and 45 deletions

View File

@ -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<tray::Tray>,
host: DesktopHost,
show_requests: Receiver<()>,
/// Requests from later starts of the launcher.
starts: Receiver<single_instance::Request>,
/// 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<dyn Backend>, show_requests: Receiver<()>, settings_path: Option<PathBuf>) -> Self {
fn new(backend: Box<dyn Backend>, starts: Receiver<single_instance::Request>, settings_path: Option<PathBuf>) -> 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<Request> {
self.ui.logic(ctx, &mut self.host);
let mut requests = Vec::new();
if self.show_requests.try_iter().count() > 0 {
let starts: Vec<single_instance::Request> = 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<Seen>) {
fn launcher() -> (Launcher, Sender<single_instance::Request>, Arc<Seen>) {
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();

View File

@ -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 <version>`: a second start introduces itself.
const HELLO: &str = "artcraft-launcher:hello ";
/// `running <version>`: 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<Request>),
/// 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<std::sync::OnceLock<Box<dyn Fn() + Send + Sync>>>;
/// This launcher's own version.
fn ours() -> Option<Version> {
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<std::sync::OnceLock<Box<dyn Fn() + Send + Sync>>>) -> 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<Version>, wake: Wake) -> Receiver<Request> {
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<String>)> {
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<Answer> {
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<Request>) {
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>) -> 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);
}
}

View File

@ -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();