authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 11:24:26-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 12:53:12-07:00
logf4abeb10328178d4685cc9788c296516e6b186a6
tree41b6c6684263f8989d4cdcd9b3e87f669aa6d423
parent282b96db3198a95fdb61aa5d1977a7b2c9e05828
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: a later launch brings the running window forward on Wayland

A second launch handed its files to the running app, which could not raise its window under GNOME's focus-stealing prevention. The launcher's activation token (XDG_ACTIVATION_TOKEN, or DESKTOP_STARTUP_ID) now travels with the files, and the running app activates its surface with xdg_activation_v1, or on X11 sends _NET_ACTIVE_WINDOW at the launch's time and ends the startup notification. A cold launch opens with the token too, and the desktop entry asks for startup notification, without which GLib launchers pass no token. Verified on Fedora Silverblue 44 (GNOME 50) by opening a second section from Files over the running window, on Wayland and on Xwayland. Fixes #58. Assisted-by: claude-opus-5.5

7 files changed, 125 insertions(+), 19 deletions(-)

Cargo.lock+1
......@@ -3711,6 +3711,7 @@ dependencies = [
37113711 "wgpu",
37123712 "windows-sys 0.61.2",
37133713 "winit",
3714 "x11rb",
37143715 "zbus",
37153716 "zeroize",
37163717]
crates/snowbound/Cargo.toml+2
......@@ -118,6 +118,8 @@ sctk-adwaita = { version = "0.10.1", default-features = false }
118118wayland-backend = { version = "0.3.17", features = ["client_system"] }
119119wayland-client = "0.31.15"
120120wayland-protocols = { version = "0.32.13", features = ["client", "staging"] }
121# Brings the window forward on X11, on a connection of its own.
122x11rb = "0.13.2"
121123
122124[dev-dependencies]
123125# Draws the pictures of the video recording the tests store.
crates/snowbound/linux/snowbound.desktop+1
......@@ -8,4 +8,5 @@ Icon=net.paperclover.snowbound
88Terminal=false
99Categories=Office;
1010MimeType=application/onenote;
11StartupNotify=true
1112StartupWMClass=net.paperclover.snowbound
crates/snowbound/src/desktop_linux.rs+77-2
......@@ -17,9 +17,10 @@ use wayland_client::{
1717 Connection, Dispatch, EventQueue, Proxy,
1818 backend::{Backend, ObjectId},
1919 globals::{GlobalList, GlobalListContents, registry_queue_init},
20 protocol::{wl_buffer, wl_registry, wl_shm, wl_shm_pool},
20 protocol::{wl_buffer, wl_registry, wl_shm, wl_shm_pool, wl_surface::WlSurface},
2121};
2222use wayland_protocols::xdg::{
23 activation::v1::client::xdg_activation_v1::XdgActivationV1,
2324 shell::client::xdg_toplevel::XdgToplevel,
2425 toplevel_icon::v1::client::{
2526 xdg_toplevel_icon_manager_v1::XdgToplevelIconManagerV1,
......@@ -29,7 +30,7 @@ use wayland_protocols::xdg::{
2930use winit::{
3031 event_loop::EventLoop,
3132 platform::wayland::{EventLoopExtWayland, WindowExtWayland},
32 raw_window_handle::{HasDisplayHandle, RawDisplayHandle},
33 raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle},
3334 window::{Icon, Window},
3435};
3536
......@@ -248,6 +249,7 @@ wayland_client::delegate_noop!(Wayland: wl_shm_pool::WlShmPool);
248249wayland_client::delegate_noop!(Wayland: ignore wl_buffer::WlBuffer);
249250wayland_client::delegate_noop!(Wayland: ignore XdgToplevelIconManagerV1);
250251wayland_client::delegate_noop!(Wayland: XdgToplevelIconV1);
252wayland_client::delegate_noop!(Wayland: XdgActivationV1);
251253
252254fn display(handle: &impl HasDisplayHandle) -> Option<NonNull<c_void>> {
253255 match handle.display_handle().ok()?.as_raw() {
......@@ -349,6 +351,79 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> {
349351 Some(())
350352}
351353
354/// The token the launcher gave this launch for its window to take the focus with: an
355/// xdg-activation token on Wayland, a startup notification ID on X11.
356pub fn activation_token() -> Option<String> {
357 std::env::var("XDG_ACTIVATION_TOKEN")
358 .or_else(|_| std::env::var("DESKTOP_STARTUP_ID"))
359 .ok()
360 .filter(|token| !token.is_empty())
361}
362
363/// Brings the window forward with the token a later launch handed over.
364pub fn activate(window: &Window, token: &str) {
365 match window.window_handle().map(|handle| handle.as_raw()) {
366 Ok(RawWindowHandle::Wayland(handle)) => {
367 activate_wayland(window, handle.surface, token);
368 }
369 Ok(RawWindowHandle::Xlib(handle)) => {
370 if let Err(error) = activate_x11(handle.window as u32, token) {
371 eprintln!("Cannot activate the window: {error}");
372 }
373 }
374 _ => {}
375 }
376}
377
378fn activate_wayland(window: &Window, surface: NonNull<c_void>, token: &str) -> Option<()> {
379 let (connection, globals, mut queue) = connect(display(window)?)?;
380 let activation: XdgActivationV1 = globals.bind(&queue.handle(), 1..=1, ()).ok()?;
381 // Safety: winit's surface lives as long as the window.
382 let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), surface.as_ptr().cast()) };
383 let surface = WlSurface::from_id(&connection, id.ok()?).ok()?;
384 activation.activate(token.into(), &surface);
385 activation.destroy();
386 queue.roundtrip(&mut Wayland).ok()?;
387 Some(())
388}
389
390/// Asks for the focus at the launch's time, which the window manager weighs against the
391/// user's latest input, then ends the launch's startup notification.
392fn activate_x11(window: u32, token: &str) -> Result<(), Box<dyn std::error::Error>> {
393 use x11rb::{
394 connection::Connection as _,
395 protocol::xproto::{ClientMessageEvent, ConnectionExt as _, EventMask},
396 };
397 let (connection, screen) = x11rb::connect(None)?;
398 let root = connection.setup().roots[screen].root;
399 let atom = |name: &str| -> Result<u32, Box<dyn std::error::Error>> {
400 Ok(connection
401 .intern_atom(false, name.as_bytes())?
402 .reply()?
403 .atom)
404 };
405 let time = token
406 .rsplit_once("_TIME")
407 .and_then(|(_, time)| time.parse().ok())
408 .unwrap_or(x11rb::CURRENT_TIME);
409 let active =
410 ClientMessageEvent::new(32, window, atom("_NET_ACTIVE_WINDOW")?, [1, time, 0, 0, 0]);
411 let mask = EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY;
412 connection.send_event(false, root, mask, active)?;
413 let quoted = token.replace('\\', "\\\\").replace('"', "\\\"");
414 let mut kind = atom("_NET_STARTUP_INFO_BEGIN")?;
415 let more = atom("_NET_STARTUP_INFO")?;
416 for chunk in format!("remove: ID=\"{quoted}\"\0").as_bytes().chunks(20) {
417 let mut data = [0; 20];
418 data[..chunk.len()].copy_from_slice(chunk);
419 let message = ClientMessageEvent::new(8, window, kind, data);
420 connection.send_event(false, root, EventMask::PROPERTY_CHANGE, message)?;
421 kind = more;
422 }
423 connection.flush()?;
424 Ok(())
425}
426
352427/// Copies this executable to `~/.local/bin` unless it runs from there, adds Snowbound to the
353428/// app menu and to the apps opening OneNote's files, and says where Uninstall is.
354429pub fn install() {
crates/snowbound/src/instance.rs+28-13
......@@ -6,9 +6,11 @@
66use std::path::{Path, PathBuf};
77use winit::event_loop::EventLoopProxy;
88
9/// The files a launch asks to open, separated by NULs, which no path holds.
10fn encode(paths: &[PathBuf]) -> Vec<u8> {
11 let mut bytes = Vec::new();
9/// The launcher's activation token, which may be empty, then the files a launch asks to open,
10/// each ended by a NUL, which neither holds.
11fn encode(token: Option<&str>, paths: &[PathBuf]) -> Vec<u8> {
12 let mut bytes = token.unwrap_or_default().as_bytes().to_vec();
13 bytes.push(0);
1214 for path in paths {
1315 bytes.extend(path_bytes(path));
1416 bytes.push(0);
......@@ -16,12 +18,17 @@ fn encode(paths: &[PathBuf]) -> Vec<u8> {
1618 bytes
1719}
1820
19fn decode(bytes: &[u8]) -> Vec<PathBuf> {
20 bytes
21 .split(|&byte| byte == 0)
21fn decode(bytes: &[u8]) -> (Option<String>, Vec<PathBuf>) {
22 let mut fields = bytes.split(|&byte| byte == 0);
23 let token = fields
24 .next()
25 .filter(|token| !token.is_empty())
26 .map(|token| String::from_utf8_lossy(token).into_owned());
27 let paths = fields
2228 .filter(|path| !path.is_empty())
2329 .map(bytes_path)
24 .collect()
30 .collect();
31 (token, paths)
2532}
2633
2734/// Opens what each later launch hands over, on a thread of its own.
......@@ -33,7 +40,13 @@ fn serve<C: std::io::Read + Send + 'static>(
3340 while let Some(mut connection) = accept() {
3441 let mut bytes = Vec::new();
3542 if connection.read_to_end(&mut bytes).is_ok() {
36 let _ = proxy.send_event(crate::UserEvent::Open(decode(&bytes)));
43 #[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
44 let (token, paths) = decode(&bytes);
45 let _ = proxy.send_event(crate::UserEvent::Open(paths));
46 #[cfg(target_os = "linux")]
47 if let Some(token) = token {
48 let _ = proxy.send_event(crate::UserEvent::Activate(token));
49 }
3750 }
3851 }
3952 });
......@@ -81,7 +94,8 @@ mod linux {
8194 for _ in 0..3 {
8295 match UnixStream::connect(&socket) {
8396 Ok(mut stream) => {
84 if stream.write_all(&encode(paths)).is_ok() {
97 let token = crate::desktop::activation_token();
98 if stream.write_all(&encode(token.as_deref(), paths)).is_ok() {
8599 return None;
86100 }
87101 }
......@@ -192,7 +206,7 @@ mod windows {
192206 // The running app may then bring its window to the front.
193207 unsafe { AllowSetForegroundWindow(ASFW_ANY) };
194208 let mut pipe = unsafe { File::from_raw_handle(handle) };
195 if pipe.write_all(&encode(paths)).is_ok() {
209 if pipe.write_all(&encode(None, paths)).is_ok() {
196210 return None;
197211 }
198212 } else if unsafe { GetLastError() } == ERROR_PIPE_BUSY {
......@@ -236,12 +250,13 @@ mod tests {
236250 use super::*;
237251
238252 #[test]
239 fn handed_paths_read_back() {
253 fn handed_token_and_paths_read_back() {
240254 let paths = [
241255 PathBuf::from("/notes/Personal/Garden.one"),
242256 PathBuf::from("/notes/a b/Open Notebook.onetoc2"),
243257 ];
244 assert_eq!(decode(&encode(&paths)), paths);
245 assert!(decode(&encode(&[])).is_empty());
258 assert_eq!(decode(&encode(None, &paths)), (None, paths.to_vec()));
259 let token = Some("gnome-shell/Snowbound/1234-0-host_TIME5678".to_owned());
260 assert_eq!(decode(&encode(token.as_deref(), &[])), (token, vec![]));
246261 }
247262}
crates/snowbound/src/linux.rs+10-4
......@@ -23,10 +23,11 @@ use ui::{Flags, Spec, Ui, px};
2323use winit::{
2424 event_loop::{EventLoop, EventLoopProxy},
2525 platform::{
26 startup_notify::WindowAttributesExtStartupNotify,
2627 wayland::{EventLoopExtWayland, WindowAttributesExtWayland},
2728 x11::EventLoopBuilderExtX11,
2829 },
29 window::{ResizeDirection, Theme, Window, WindowAttributes},
30 window::{ActivationToken, ResizeDirection, Theme, Window, WindowAttributes},
3031};
3132use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
3233
......@@ -191,13 +192,18 @@ fn breeze_radius(connection: &zbus::blocking::Connection) -> Option<f32> {
191192
192193/// Decorated by the window manager, or on Wayland by the compositor where it offers
193194/// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies
194/// the icon on Wayland, while X11 takes it from the window.
195/// the icon on Wayland, while X11 takes it from the window. It opens in front with the
196/// launcher's activation token, which also ends the launch's startup notification.
195197pub fn window_attributes() -> WindowAttributes {
196198 use crate::desktop::APP_ID;
197 Window::default_attributes()
199 let attributes = Window::default_attributes()
198200 .with_name(APP_ID, APP_ID)
199201 .with_window_icon(crate::desktop::window_icon())
200 .with_transparent(cuts_corners())
202 .with_transparent(cuts_corners());
203 match crate::desktop::activation_token() {
204 Some(token) => attributes.with_activation_token(ActivationToken::from_raw(token)),
205 None => attributes,
206 }
201207}
202208
203209/// The window manager or the frame draws the title bar; the icon is the window's own where
crates/snowbound/src/main.rs+6
......@@ -291,6 +291,10 @@ enum UserEvent {
291291 /// The system or a later launch asked to open notebook folders, tables of contents or
292292 /// sections, as the Finder does with a double-clicked file; none brings the window forward.
293293 Open(Vec<PathBuf>),
294 /// The activation token a later launch's launcher gave it, which lets the window come
295 /// forward despite the desktop's focus-stealing prevention.
296 #[cfg(target_os = "linux")]
297 Activate(String),
294298}
295299
296300/// Asks the event loop for a frame from any thread.
......@@ -5662,6 +5666,8 @@ impl State {
56625666 self.window.focus_window();
56635667 self.window.request_redraw();
56645668 }
5669 #[cfg(target_os = "linux")]
5670 UserEvent::Activate(token) => desktop::activate(&self.window, &token),
56655671 UserEvent::Update => {
56665672 self.updated();
56675673 self.window.request_redraw();