collective

Browse / github.com/storytold/craft-launcher / patch

fix(linux): links bring the browser in front of the launcher

Links open the browser behind the launcher on Linux (no focus) ## What happens On Linux (Wayland, notably KDE Plasma) clicking a link in ArtCraft Launcher (Website, GitHub, release notes, an app's page, the details drawer…) opens the page in the browser, but the browser stays behind the launcher. KWin only marks it as wanting attention (taskbar flash), so it looks like nothing happened. ## Why A program started from another only comes in front when it is handed an **activation token** from the program the user was using: an xdg-activation token under Wayland, a startup-notification id under X11. eframe opens links itself (`webbrowser`) without one, and

Status
open
Score
0.00
Adopters
0 other operators
Author
benandez (imported from pull request #699, open)
Base
ba3b0345f0b14f5cb892dcb8b917ca212c124cf9
Size
+389 −6 in 6 files
Review
pending
Shared
2026-10-28
Id
39414b2f14f4106beb34bf73

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock Cargo.toml apps/artcraft-launcher/Cargo.toml apps/artcraft-launcher/src/browser.rs apps/artcraft-launcher/src/main.rs flake.nix

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -294,6 +294,8 @@ dependencies = [  "ron",  "serde",  "tray-icon",+ "webbrowser",+ "winit",  "winreg",  "winresource",  "zbus",@@ -1155,6 +1157,11 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]]+name = "dpi"+version = "0.1.1"+source = "git+https://github.com/UCS-Care-Group/winit?rev=bb472d90071a8b48288aeaa70ad81b767808ae9a#bb472d90071a8b48288aeaa70ad81b767808ae9a"+ [[package]] name = "dpi" version = "0.1.2"@@ -2783,7 +2790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" dependencies = [  "crossbeam-channel",- "dpi",+ "dpi 0.1.2",  "gtk",  "keyboard-types",  "libxdo",@@ -5695,8 +5702,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"+source = "git+https://github.com/UCS-Care-Group/winit?rev=bb472d90071a8b48288aeaa70ad81b767808ae9a#bb472d90071a8b48288aeaa70ad81b767808ae9a" dependencies = [  "ahash",  "android-activity",@@ -5710,7 +5716,7 @@ dependencies = [  "core-foundation 0.9.4",  "core-graphics",  "cursor-icon",- "dpi",+ "dpi 0.1.1",  "js-sys",  "libc",  "memmap2",diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -44,3 +44,8 @@ inherits = "release" opt-level = "s" lto = "fat" strip = "debuginfo"++[patch.crates-io]+# winit 0.30.13 with an activation token that names the latest click, which KWin needs before it+# brings the browser forward (#698; MailCraft #144). Dropped when winit itself does it.+winit = { git = "https://github.com/UCS-Care-Group/winit", rev = "bb472d90071a8b48288aeaa70ad81b767808ae9a" }diff --git a/apps/artcraft-launcher/Cargo.toml b/apps/artcraft-launcher/Cargo.toml--- a/apps/artcraft-launcher/Cargo.toml+++ b/apps/artcraft-launcher/Cargo.toml@@ -57,6 +57,13 @@ winreg = "0.55" [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } +# Links bring the browser in front (browser.rs): the window asks winit for an activation token,+# which winit hands back as a window event that eframe drops, and the token goes to `xdg-open`.+# Both already in the tree: eframe runs on winit, and opens links with webbrowser.+[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]+winit = { version = "0.30", default-features = false, features = ["wayland", "x11"] }+webbrowser = "1.2"+ [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))'.dependencies] tray-icon = { version = "0.25", default-features = false, features = ["ksni"] } diff --git a/apps/artcraft-launcher/src/browser.rs b/apps/artcraft-launcher/src/browser.rsnew file mode 100644--- /dev/null+++ b/apps/artcraft-launcher/src/browser.rs@@ -0,0 +1,259 @@+//! Opening the window's links with the browser brought in front of it (Linux and FreeBSD).+//!+//! A program started from another only comes in front when it is handed an activation token+//! from the program the user was using: an xdg-activation token under Wayland, a+//! startup-notification id under X11. eframe opens links itself, without one, so the browser+//! opens behind the launcher. Instead each link the window follows ([`take_links`]) waits for a+//! token: the window asks winit for one ([`Browser::pump`]), winit hands it back as a window event+//! (`WithTokens` in `main.rs`), and `xdg-open` is started with it in its environment. A+//! compositor that gives none, or doesn't answer within [`TOKEN_WAIT`], has the link opened+//! without one, as before. Ported from MailCraft's `browser.rs` (#698).+//!+//! Windows and macOS open pages in front already; nothing here runs there.++use std::sync::{Mutex, PoisonError};+use std::time::{Duration, Instant};++/// How long a link waits for its activation token before it is opened without one.+pub const TOKEN_WAIT: Duration = Duration::from_millis(1000);+/// At most this many links wait at once; more are opened at once, without a token.+const MAX_WAITING: usize = 32;+/// How long `xdg-open` is given to report a failure before it is taken to have opened the page.+const OPENER_WAIT: Duration = Duration::from_secs(2);++/// A link followed and not yet opened, with the token asked for it and when (`None` until asked).+struct Asked<S> {+    url: String,+    token: Option<(S, Instant)>,+}++/// The links waiting for their activation token. `S` is winit's `AsyncRequestSerial` (any+/// comparable value in the tests).+pub struct Browser<S> {+    asked: Mutex<Vec<Asked<S>>>,+    launch: fn(&str, Option<&str>),+}++impl<S: PartialEq> Browser<S> {+    pub fn new(launch: fn(&str, Option<&str>)) -> Self {+        Browser { asked: Mutex::new(Vec::new()), launch }+    }++    fn asked(&self) -> std::sync::MutexGuard<'_, Vec<Asked<S>>> {+        self.asked.lock().unwrap_or_else(PoisonError::into_inner)+    }++    /// Links the window followed in this frame.+    pub fn follow(&self, urls: Vec<String>) {+        self.asked().extend(urls.into_iter().map(|url| Asked { url, token: None }));+    }++    /// Every frame, on the window's thread: ask a token for each new link (`request_token` is+    /// `None` when the window system gives none, and the link opens at once), and open those+    /// whose token is overdue without one. Returns how long until the next is overdue.+    pub fn pump(&self, request_token: &mut dyn FnMut() -> Option<S>, now: Instant) -> Option<Duration> {+        let mut now_open = Vec::new();+        let next = {+            let mut asked = self.asked();+            let mut waiting = asked.iter().filter(|a| a.token.is_some()).count();+            let mut kept = Vec::with_capacity(asked.len());+            for mut a in std::mem::take(&mut *asked) {+                match &a.token {+                    None => match (waiting < MAX_WAITING).then(&mut *request_token).flatten() {+                        Some(serial) => {+                            a.token = Some((serial, now));+                            waiting += 1;+                            kept.push(a);+                        }+                        None => now_open.push(a.url),+                    },+                    Some((_, since)) if now.saturating_duration_since(*since) >= TOKEN_WAIT => now_open.push(a.url),+                    Some(_) => kept.push(a),+                }+            }+            *asked = kept;+            asked.iter().filter_map(|a| a.token.as_ref()).map(|(_, since)| TOKEN_WAIT.saturating_sub(now.saturating_duration_since(*since))).min()+        };+        for url in now_open {+            (self.launch)(&url, None);+        }+        next+    }++    /// The window system's answer to a token request: the link that waited for it opens with it.+    pub fn token_arrived(&self, serial: &S, token: &str) {+        let found = {+            let mut asked = self.asked();+            let at = asked.iter().position(|a| a.token.as_ref().is_some_and(|(s, _)| s == serial));+            at.map(|at| asked.remove(at).url)+        };+        if let Some(url) = found {+            (self.launch)(&url, Some(token));+        }+    }+}++/// Take the links the window followed in this frame out of its output, before eframe opens them+/// itself (which would leave the browser behind the window).+pub fn take_links(ctx: &egui::Context) -> Vec<String> {+    ctx.output_mut(|o| {+        let mut urls = Vec::new();+        o.commands.retain(|c| match c {+            egui::OutputCommand::OpenUrl(open) => {+                urls.push(open.url.clone());+                false+            }+            _ => true,+        });+        urls+    })+}++/// Open `url` on a thread of its own (the opener can take a moment to report): `xdg-open` with+/// the token when there is one, the way eframe opened links otherwise or when that fails.+/// (`$BROWSER`, when set, is the user's choice, which the fallback honours.) The address isn't logged.+pub fn launch(url: &str, token: Option<&str>) {+    log::info!("browser: opening a link {} an activation token", if token.is_some() { "with" } else { "without" });+    let (url, token) = (url.to_string(), token.map(str::to_string));+    let work = move || {+        if let Some(token) = token+            && std::env::var_os("BROWSER").is_none()+            && let Some(mut command) = opener_command(&url, &token)+            && xdg_open(&mut command)+        {+            return;+        }+        if let Err(e) = webbrowser::open(&url) {+            log::warn!("couldn't start a browser ({:?})", e.kind());+        }+    };+    if let Err(e) = std::thread::Builder::new().name("browser".into()).spawn(work) {+        log::warn!("couldn't start a browser: no thread for it ({e})");+    }+}++/// Start `xdg-open` and see whether it opened the page: it exits at once on the desktops; where it+/// starts the browser itself it is still running, and is reaped in the background.+fn xdg_open(command: &mut std::process::Command) -> bool {+    let Ok(mut child) = command.spawn() else { return false };+    let started = Instant::now();+    loop {+        match child.try_wait() {+            Ok(Some(status)) => return status.success(),+            Ok(None) if started.elapsed() < OPENER_WAIT => std::thread::sleep(Duration::from_millis(50)),+            Ok(None) => {+                let _ = std::thread::Builder::new().name("browser".into()).spawn(move || child.wait());+                return true;+            }+            Err(_) => return true,+        }+    }+}++/// `xdg-open <url>` with the token in its environment under both the Wayland and the X11 name (on+/// the host through `flatpak-spawn` inside the Flatpak sandbox); `None` for anything but a plain+/// web address, so nothing in an address can be taken for an option.+pub fn opener_command(url: &str, token: &str) -> Option<std::process::Command> {+    use artcraft_launcher_native::process::{command, in_sandbox};+    use std::process::Stdio;+    let lower = url.get(..8).unwrap_or(url).to_ascii_lowercase();+    let web = lower.starts_with("https://") || lower.starts_with("http://");+    let clean = |s: &str| !s.is_empty() && !s.chars().any(|c| c.is_control() || c.is_whitespace());+    if !web || !clean(url) || !clean(token) || token.len() > 4096 {+        return None;+    }+    let vars = [("XDG_ACTIVATION_TOKEN", token), ("DESKTOP_STARTUP_ID", token)];+    let mut cmd = if in_sandbox() {+        let mut c = command("flatpak-spawn");+        c.arg("--host");+        c.args(vars.iter().map(|(k, v)| format!("--env={k}={v}")));+        c.arg("xdg-open");+        c+    } else {+        let mut c = command("xdg-open");+        c.envs(vars);+        c+    };+    cmd.arg(url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());+    Some(cmd)+}++#[cfg(test)]+mod tests {+    use super::*;++    static OPENED: Mutex<Vec<(String, Option<String>)>> = Mutex::new(Vec::new());++    fn record(url: &str, token: Option<&str>) {+        OPENED.lock().unwrap().push((url.to_string(), token.map(str::to_string)));+    }++    fn opened() -> Vec<(String, Option<String>)> {+        std::mem::take(&mut *OPENED.lock().unwrap())+    }++    // One test, as the recorder is shared.+    #[test]+    fn links_wait_for_their_token_or_open_without_one() {+        let browser = Browser::new(record);+        let now = Instant::now();++        // With a token: waits, then opens with it; a late or unknown answer opens nothing.+        browser.follow(vec!["https://a.example/".into()]);+        assert_eq!(browser.pump(&mut || Some(7), now), Some(TOKEN_WAIT));+        assert!(opened().is_empty());+        browser.token_arrived(&8, "other");+        browser.token_arrived(&7, "tok-7");+        browser.token_arrived(&7, "tok-7");+        assert_eq!(opened(), [("https://a.example/".to_string(), Some("tok-7".to_string()))]);++        // No token given: at once. No answer: once the wait is over.+        browser.follow(vec!["https://b.example/".into()]);+        browser.pump(&mut || None, now);+        browser.follow(vec!["https://c.example/".into()]);+        browser.pump(&mut || Some(1), now);+        assert_eq!(browser.pump(&mut || Some(2), now + TOKEN_WAIT / 2), Some(TOKEN_WAIT / 2));+        assert_eq!(browser.pump(&mut || Some(3), now + TOKEN_WAIT), None);+        assert_eq!(opened(), [("https://b.example/".to_string(), None), ("https://c.example/".to_string(), None)]);++        // Past the cap, links don't wait.+        browser.follow((0..40).map(|i| format!("https://d.example/{i}")).collect());+        let mut serial = 10;+        browser.pump(+            &mut || {+                serial += 1;+                Some(serial)+            },+            now,+        );+        assert_eq!(opened().len(), 40 - MAX_WAITING);+        browser.token_arrived(&11, "t11");+        assert_eq!(opened(), [("https://d.example/0".to_string(), Some("t11".to_string()))]);+    }++    #[test]+    fn the_windows_links_are_taken_out_of_its_output() {+        let ctx = egui::Context::default();+        let mut taken = Vec::new();+        let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {+            ui.ctx().open_url(egui::OpenUrl::new_tab("https://a.example/"));+            ui.ctx().copy_text("kept".into());+            taken = take_links(ui.ctx());+        });+        assert_eq!(taken, ["https://a.example/"]);+        output.textures_delta.clear();+        assert!(matches!(output.platform_output.commands.as_slice(), [egui::OutputCommand::CopyText(t)] if t == "kept"));+    }++    #[test]+    fn the_opener_gets_the_token_and_nothing_an_address_could_misuse() {+        let command = opener_command("https://a.example/x?y=1", "abc_123").unwrap();+        let args: Vec<_> = command.get_args().collect();+        assert_eq!(args.last().unwrap(), &"https://a.example/x?y=1");+        for url in ["-x https://a.example/", "mailto:a@example.org", "file:///etc/passwd", "https://a.example/\nb", "", "ht"] {+            assert!(opener_command(url, "abc").is_none(), "{url:?}");+        }+        assert!(opener_command("https://a.example/", "a b").is_none());+        assert!(opener_command("https://a.example/", "").is_none());+    }+}diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs--- a/apps/artcraft-launcher/src/main.rs+++ b/apps/artcraft-launcher/src/main.rs@@ -5,6 +5,8 @@ #![cfg_attr(windows, windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(any(target_os = "linux", target_os = "freebsd"))]+mod browser; mod logging; mod motion; mod settings_file;@@ -105,7 +107,7 @@ fn main() -> ExitCode {         if window {             let result = {                 let (waker, launcher) = (waker.clone(), &mut launcher);-                eframe::run_native(+                run_window(                     "ArtCraft Launcher",                     native_options(hidden),                     Box::new(move |cc| {@@ -131,6 +133,107 @@ fn main() -> ExitCode {     } } +/// The links waiting for the window's activation token (`browser`), set before the first window opens.+#[cfg(any(target_os = "linux", target_os = "freebsd"))]+static BROWSER: OnceLock<browser::Browser<winit::event_loop::AsyncRequestSerial>> = OnceLock::new();++/// The links the window followed in this frame: each asks winit for an activation token and opens+/// when it comes (`WithTokens`) or is overdue, so the browser comes in front of the window.+#[cfg(any(target_os = "linux", target_os = "freebsd"))]+fn open_links(ctx: &egui::Context, frame: &eframe::Frame) {+    use winit::platform::startup_notify::WindowExtStartupNotify as _;+    let Some(browser) = BROWSER.get() else { return };+    browser.follow(browser::take_links(ctx));+    let window = frame.winit_window();+    let mut request = || window.and_then(|w| w.request_activation_token().ok());+    if let Some(next) = browser.pump(&mut request, std::time::Instant::now()) {+        ctx.request_repaint_after(next);+    }+}++#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]+fn open_links(_ctx: &egui::Context, _frame: &eframe::Frame) {}++/// eframe's own handling of winit's events, plus the activation tokens the window asked for+/// (`open_links`), which eframe would drop.+#[cfg(any(target_os = "linux", target_os = "freebsd"))]+struct WithTokens<'a>(eframe::EframeWinitApplication<'a>);++#[cfg(any(target_os = "linux", target_os = "freebsd"))]+impl winit::application::ApplicationHandler<eframe::UserEvent> for WithTokens<'_> {+    fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {+        self.0.resumed(event_loop);+    }+    fn window_event(+        &mut self,+        event_loop: &winit::event_loop::ActiveEventLoop,+        window_id: winit::window::WindowId,+        event: winit::event::WindowEvent,+    ) {+        match event {+            winit::event::WindowEvent::ActivationTokenDone { serial, token } => {+                if let Some(browser) = BROWSER.get() {+                    browser.token_arrived(&serial, &token.into_raw());+                }+            }+            event => self.0.window_event(event_loop, window_id, event),+        }+    }+    fn new_events(&mut self, event_loop: &winit::event_loop::ActiveEventLoop, cause: winit::event::StartCause) {+        self.0.new_events(event_loop, cause);+    }+    fn user_event(&mut self, event_loop: &winit::event_loop::ActiveEventLoop, event: eframe::UserEvent) {+        self.0.user_event(event_loop, event);+    }+    fn device_event(&mut self, event_loop: &winit::event_loop::ActiveEventLoop, device_id: winit::event::DeviceId, event: winit::event::DeviceEvent) {+        self.0.device_event(event_loop, device_id, event);+    }+    fn about_to_wait(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {+        self.0.about_to_wait(event_loop);+    }+    fn suspended(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {+        self.0.suspended(event_loop);+    }+    fn exiting(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {+        self.0.exiting(event_loop);+    }+    fn memory_warning(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {+        self.0.memory_warning(event_loop);+    }+}++/// Run one pass of the window: on Linux and FreeBSD on an event loop of our own (kept for the next+/// pass, as winit allows one per process), so the activation tokens reach `WithTokens`; elsewhere+/// as eframe runs it.+#[cfg(any(target_os = "linux", target_os = "freebsd"))]+fn run_window(app_name: &str, options: eframe::NativeOptions, creator: eframe::AppCreator<'_>) -> eframe::Result {+    use winit::platform::run_on_demand::EventLoopExtRunOnDemand as _;+    thread_local!(static EVENT_LOOP: std::cell::RefCell<Option<winit::event_loop::EventLoop<eframe::UserEvent>>> = const { std::cell::RefCell::new(None) });+    let _ = BROWSER.set(browser::Browser::new(browser::launch));+    EVENT_LOOP.with_borrow_mut(|event_loop| {+        let event_loop = match event_loop {+            Some(event_loop) => event_loop,+            None => event_loop.insert(winit::event_loop::EventLoop::<eframe::UserEvent>::with_user_event().build()?),+        };+        // eframe keeps a start-up failure to itself on this path: the app is only created once the+        // window is, so a pass that ends without having created it failed to start.+        let started = Arc::new(AtomicBool::new(false));+        let seen = started.clone();+        let creator: eframe::AppCreator<'_> = Box::new(move |cc| {+            seen.store(true, Ordering::Relaxed);+            creator(cc)+        });+        let mut app = WithTokens(eframe::create_native(app_name, options, creator, event_loop));+        event_loop.run_app_on_demand(&mut app)?;+        if started.load(Ordering::Relaxed) { Ok(()) } else { Err(eframe::Error::AppCreation("the window couldn't be started (see the log)".into())) }+    })+}++#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]+fn run_window(app_name: &str, options: eframe::NativeOptions, creator: eframe::AppCreator<'_>) -> eframe::Result {+    eframe::run_native(app_name, options, creator)+}+ fn native_options(hidden: bool) -> eframe::NativeOptions {     let icon =         eframe::icon_data::from_png_bytes(include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.artcraft-launcher.png")).ok();@@ -725,7 +828,7 @@ impl eframe::App for Shell<'_> {         }     } -    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {+    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {         self.launcher.ui.ui(ui, &mut self.launcher.host);         ui.ctx().output_mut(|o| {             o.commands.retain(|cmd| {@@ -740,6 +843,7 @@ impl eframe::App for Shell<'_> {                 true             });         });+        open_links(ui.ctx(), frame);     }      fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {diff --git a/flake.nix b/flake.nix--- a/flake.nix+++ b/flake.nix@@ -62,6 +62,8 @@           version = version;           src = ./.;           cargoLock.lockFile = ./Cargo.lock;+          # The winit patch in Cargo.toml is a git dependency.+          cargoLock.allowBuiltinFetchGit = true;            nativeBuildInputs = with pkgs; [             pkg-config