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.rs
new 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
